mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-09-07 08:26:15 +00:00
Merge branch 'main' into feat/personalisation
This commit is contained in:
commit
44ac45fb53
101 changed files with 4011 additions and 1394 deletions
|
|
@ -159,7 +159,7 @@ async function getRelatedMemoriesForChatGPT(actionSource: string) {
|
|||
if (response?.success && response?.data) {
|
||||
const promptElement = document.getElementById("prompt-textarea")
|
||||
if (promptElement) {
|
||||
promptElement.dataset.supermemories = `<div>Supermemories of user (only for the reference): ${response.data}</div>`
|
||||
promptElement.dataset.supermemories = `\n\nSupermemories of user (only for the reference): ${response.data}`
|
||||
console.log(
|
||||
"Prompt element dataset:",
|
||||
promptElement.dataset.supermemories,
|
||||
|
|
@ -471,7 +471,7 @@ function updateChatGPTIconFeedback(
|
|||
|
||||
const promptElement = document.getElementById("prompt-textarea")
|
||||
if (promptElement) {
|
||||
promptElement.dataset.supermemories = `<div>Supermemories of user (only for the reference): ${updatedMemories}</div>`
|
||||
promptElement.dataset.supermemories = `\n\nSupermemories of user (only for the reference): ${updatedMemories}`
|
||||
}
|
||||
|
||||
content
|
||||
|
|
@ -647,7 +647,7 @@ function setupChatGPTPromptCapture() {
|
|||
promptTextarea &&
|
||||
!promptContent.includes("Supermemories of user")
|
||||
) {
|
||||
promptTextarea.innerHTML = `${promptTextarea.innerHTML} ${storedMemories}`
|
||||
promptTextarea.appendChild(document.createTextNode(storedMemories))
|
||||
promptContent = promptTextarea.textContent || ""
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -230,7 +230,7 @@ async function getRelatedMemoriesForClaude(actionSource: string) {
|
|||
) as HTMLElement
|
||||
|
||||
if (textareaElement) {
|
||||
textareaElement.dataset.supermemories = `<div>Supermemories of user (only for the reference): ${response.data}</div>`
|
||||
textareaElement.dataset.supermemories = `\n\nSupermemories of user (only for the reference): ${response.data}`
|
||||
console.log(
|
||||
"Text element dataset:",
|
||||
textareaElement.dataset.supermemories,
|
||||
|
|
@ -442,7 +442,7 @@ function updateClaudeIconFeedback(
|
|||
'div[contenteditable="true"]',
|
||||
) as HTMLElement
|
||||
if (textareaElement) {
|
||||
textareaElement.dataset.supermemories = `<div>Supermemories of user (only for the reference): ${updatedMemories}</div>`
|
||||
textareaElement.dataset.supermemories = `\n\nSupermemories of user (only for the reference): ${updatedMemories}`
|
||||
}
|
||||
|
||||
content
|
||||
|
|
@ -520,7 +520,7 @@ function setupClaudePromptCapture() {
|
|||
contentEditableDiv &&
|
||||
!promptContent.includes("Supermemories of user")
|
||||
) {
|
||||
contentEditableDiv.innerHTML = `${contentEditableDiv.innerHTML} ${storedMemories}`
|
||||
contentEditableDiv.appendChild(document.createTextNode(storedMemories))
|
||||
promptContent =
|
||||
contentEditableDiv.textContent || contentEditableDiv.innerText || ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -238,13 +238,7 @@ async function getRelatedMemoriesForT3(actionSource: string) {
|
|||
}
|
||||
|
||||
if (textareaElement) {
|
||||
if (textareaElement.tagName === "TEXTAREA") {
|
||||
;(textareaElement as HTMLTextAreaElement).dataset.supermemories =
|
||||
`<br>Supermemories of user (only for the reference): ${response.data}</br>`
|
||||
} else {
|
||||
;(textareaElement as HTMLElement).dataset.supermemories =
|
||||
`<br>Supermemories of user (only for the reference): ${response.data}</br>`
|
||||
}
|
||||
textareaElement.dataset.supermemories = `\n\nSupermemories of user (only for the reference): ${response.data}`
|
||||
|
||||
iconElement.dataset.memoriesData = response.data
|
||||
|
||||
|
|
@ -450,7 +444,7 @@ function updateT3IconFeedback(
|
|||
(document.querySelector("textarea") as HTMLTextAreaElement) ||
|
||||
(document.querySelector('div[contenteditable="true"]') as HTMLElement)
|
||||
if (textareaElement) {
|
||||
textareaElement.dataset.supermemories = `<div>Supermemories of user (only for the reference): ${updatedMemories}</div>`
|
||||
textareaElement.dataset.supermemories = `\n\nSupermemories of user (only for the reference): ${updatedMemories}`
|
||||
}
|
||||
|
||||
content
|
||||
|
|
@ -537,7 +531,7 @@ function setupT3PromptCapture() {
|
|||
`${promptContent} ${storedMemories}`
|
||||
promptContent = (textareaElement as HTMLTextAreaElement).value
|
||||
} else {
|
||||
textareaElement.innerHTML = `${textareaElement.innerHTML} ${storedMemories}`
|
||||
textareaElement.appendChild(document.createTextNode(storedMemories))
|
||||
promptContent =
|
||||
textareaElement.textContent || textareaElement.innerText || ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -184,6 +184,7 @@
|
|||
"integrations/agent-framework",
|
||||
"integrations/mastra",
|
||||
"integrations/voltagent",
|
||||
"integrations/convex",
|
||||
"integrations/langchain",
|
||||
"integrations/crewai",
|
||||
"integrations/agno",
|
||||
|
|
|
|||
208
apps/docs/integrations/convex.mdx
Normal file
208
apps/docs/integrations/convex.mdx
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
---
|
||||
title: "Convex"
|
||||
sidebarTitle: "Convex"
|
||||
description: "Add persistent memory to Convex apps with Supermemory"
|
||||
icon: "database"
|
||||
---
|
||||
|
||||
Convex apps don't have built-in memory for AI. Supermemory fixes that. You get a memory layer that stores conversations, builds user profiles, and gives your AI context about who it's talking to.
|
||||
|
||||
## What you can do
|
||||
|
||||
- Store user interactions and retrieve them in future sessions
|
||||
- Build automatic user profiles from conversations
|
||||
- Search memories to give your AI relevant context
|
||||
- Keep everything in your Convex database for full visibility
|
||||
|
||||
## Setup
|
||||
|
||||
Install the packages:
|
||||
|
||||
```bash
|
||||
npm install supermemory convex
|
||||
```
|
||||
|
||||
For the AI chat example, also install the AI SDK packages:
|
||||
|
||||
```bash
|
||||
npm install @supermemory/tools @ai-sdk/openai ai
|
||||
```
|
||||
|
||||
Set up your environment variable in Convex:
|
||||
|
||||
```bash
|
||||
npx convex env set SUPERMEMORY_API_KEY your-supermemory-api-key
|
||||
```
|
||||
|
||||
<Note>Get your Supermemory API key from [console.supermemory.ai](https://console.supermemory.ai).</Note>
|
||||
|
||||
## Basic integration
|
||||
|
||||
Create simple helper functions for each Supermemory operation:
|
||||
|
||||
```typescript
|
||||
// convex/memory.ts
|
||||
import { action } from "./_generated/server";
|
||||
import { v } from "convex/values";
|
||||
import Supermemory from "supermemory";
|
||||
|
||||
const memory = new Supermemory({ apiKey: process.env.SUPERMEMORY_API_KEY });
|
||||
|
||||
// Get user profile and relevant memories
|
||||
export const getProfile = action({
|
||||
args: { userId: v.string(), query: v.optional(v.string()) },
|
||||
handler: async (ctx, { userId, query }) => {
|
||||
return await memory.profile({
|
||||
containerTag: userId,
|
||||
q: query,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Add a memory
|
||||
export const addMemory = action({
|
||||
args: { userId: v.string(), content: v.string() },
|
||||
handler: async (ctx, { userId, content }) => {
|
||||
return await memory.add({
|
||||
content,
|
||||
containerTag: userId,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Search memories
|
||||
export const searchMemories = action({
|
||||
args: { userId: v.string(), query: v.string(), limit: v.optional(v.number()) },
|
||||
handler: async (ctx, { userId, query, limit }) => {
|
||||
return await memory.search.memories({
|
||||
q: query,
|
||||
containerTag: userId,
|
||||
searchMode: "hybrid",
|
||||
limit: limit ?? 10,
|
||||
});
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Example: AI chat with memory
|
||||
|
||||
A chat endpoint using the Supermemory AI SDK middleware. It automatically injects context and saves memories.
|
||||
|
||||
```typescript
|
||||
// convex/chat.ts
|
||||
import { action } from "./_generated/server";
|
||||
import { v } from "convex/values";
|
||||
import { generateText } from "ai";
|
||||
import { openai } from "@ai-sdk/openai";
|
||||
import { withSupermemory } from "@supermemory/tools/ai-sdk";
|
||||
|
||||
export const chat = action({
|
||||
args: { userId: v.string(), message: v.string() },
|
||||
handler: async (ctx, { userId, message }) => {
|
||||
// Wrap the model - automatically injects context and saves memories
|
||||
const model = withSupermemory(openai("gpt-4o-mini"), {
|
||||
containerTag: userId,
|
||||
customId: `convex-chat-${userId}`,
|
||||
mode: "full",
|
||||
addMemory: "always",
|
||||
});
|
||||
|
||||
const { text } = await generateText({
|
||||
model,
|
||||
system: "You are a helpful assistant.",
|
||||
prompt: message,
|
||||
});
|
||||
|
||||
return text;
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Storing memories in Convex tables
|
||||
|
||||
Keep a local copy of memories in your Convex database for full visibility:
|
||||
|
||||
```typescript
|
||||
// convex/schema.ts
|
||||
import { defineSchema, defineTable } from "convex/server";
|
||||
import { v } from "convex/values";
|
||||
|
||||
export default defineSchema({
|
||||
memories: defineTable({
|
||||
userId: v.string(),
|
||||
content: v.string(),
|
||||
createdAt: v.number(),
|
||||
}).index("by_user", ["userId"]),
|
||||
});
|
||||
```
|
||||
|
||||
```typescript
|
||||
// convex/memory.ts
|
||||
import { action, mutation, query } from "./_generated/server";
|
||||
import { api } from "./_generated/api";
|
||||
import { v } from "convex/values";
|
||||
import Supermemory from "supermemory";
|
||||
|
||||
const memory = new Supermemory({ apiKey: process.env.SUPERMEMORY_API_KEY });
|
||||
|
||||
// Store in Convex
|
||||
export const storeMemory = mutation({
|
||||
args: { userId: v.string(), content: v.string() },
|
||||
handler: async (ctx, { userId, content }) => {
|
||||
return await ctx.db.insert("memories", {
|
||||
userId,
|
||||
content,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Add memory to both Supermemory and Convex
|
||||
export const addMemory = action({
|
||||
args: { userId: v.string(), content: v.string() },
|
||||
handler: async (ctx, { userId, content }) => {
|
||||
// Add to Supermemory
|
||||
await memory.add({ content, containerTag: userId });
|
||||
|
||||
// Store in Convex
|
||||
// Note: in production, handle partial failures — if the Convex mutation
|
||||
// fails after the Supermemory write succeeds, the two stores will be out of sync.
|
||||
await ctx.runMutation(api.memory.storeMemory, { userId, content });
|
||||
},
|
||||
});
|
||||
|
||||
// List memories from Convex
|
||||
export const listMemories = query({
|
||||
args: { userId: v.string() },
|
||||
handler: async (ctx, { userId }) => {
|
||||
return await ctx.db
|
||||
.query("memories")
|
||||
.withIndex("by_user", q => q.eq("userId", userId))
|
||||
.order("desc")
|
||||
.take(50);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Related docs
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="User profiles" icon="user" href="/user-profiles">
|
||||
How automatic profiling works
|
||||
</Card>
|
||||
<Card title="Search" icon="search" href="/search">
|
||||
Filtering and search modes
|
||||
</Card>
|
||||
<Card title="Vercel AI SDK" icon="triangle" href="/integrations/ai-sdk">
|
||||
Memory middleware for Next.js
|
||||
</Card>
|
||||
<Card title="LangChain" icon="link" href="/integrations/langchain">
|
||||
Memory for LangChain apps
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
|
@ -6,7 +6,10 @@ import {
|
|||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
type Dispatch,
|
||||
type RefObject,
|
||||
type ReactNode,
|
||||
type SetStateAction,
|
||||
} from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
|
|
@ -30,11 +33,25 @@ import {
|
|||
RaycastIcon,
|
||||
} from "@/components/integration-icons"
|
||||
import { GoogleDrive, Notion, OneDrive } from "@ui/assets/icons"
|
||||
import { Sparkles, ChevronLeft, ChevronRight } from "lucide-react"
|
||||
import {
|
||||
Sparkles,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
Loader2,
|
||||
} from "lucide-react"
|
||||
import { analytics } from "@/lib/analytics"
|
||||
import { consumePendingConnectUrl } from "@/lib/constants"
|
||||
|
||||
type DetectedSource = "x" | "linkedin" | "resume" | null
|
||||
type Status = "idle" | "processing" | "done" | "error"
|
||||
type AccountLookupStatus = "checking" | "found" | "not_found" | "error"
|
||||
type AccountLookup = {
|
||||
source: "x" | "linkedin"
|
||||
status: AccountLookupStatus
|
||||
message: string
|
||||
}
|
||||
type DocStatus =
|
||||
| "unknown"
|
||||
| "queued"
|
||||
|
|
@ -53,7 +70,7 @@ function XIcon({ className }: { className?: string }) {
|
|||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-4.714-6.231-5.401 6.231H2.744l7.73-8.835L1.254 2.25H8.08l4.253 5.622 5.911-5.622Zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
|
||||
<path d="M18.24 2.25h3.31l-7.23 8.26 8.5 11.24H16.17l-4.71-6.23-5.4 6.23H2.74l7.73-8.84L1.25 2.25H8.08l4.25 5.62 5.91-5.62Zm-1.16 17.52h1.83L7.08 4.13H5.12z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
|
@ -66,7 +83,7 @@ function LinkedInIcon({ className }: { className?: string }) {
|
|||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 0 1-2.063-2.065 2.064 2.064 0 1 1 2.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z" />
|
||||
<path d="M20.45 20.45h-3.55v-5.57c0-1.33-.027-3.04-1.85-3.04-1.85 0-2.14 1.45-2.14 2.94v5.67H9.35V9h3.41v1.56h.046c.477-.9 1.64-1.85 3.37-1.85 3.6 0 4.27 2.37 4.27 5.46v6.29zM5.34 7.43a2.06 2.06 0 0 1-2.06-2.06 2.06 2.06 0 1 1 2.06 2.06zm1.78 13.02H3.56V9h3.56v11.45zM22.23 0H1.77C.792 0 0 .774 0 1.73v20.54C0 23.23.792 24 1.77 24h20.45C23.2 24 24 23.23 24 22.27V1.73C24 .774 23.2 0 22.22 0h.003z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
|
@ -76,7 +93,7 @@ function SubmitArrow() {
|
|||
<svg width="12" height="9" viewBox="0 0 12 9" fill="none">
|
||||
<title>Submit</title>
|
||||
<path
|
||||
d="M8.05099 9.60156L6.93234 8.49987L9.00014 6.44902L9.62726 6.04224L9.54251 5.788L8.79675 5.90665H0.0170898V4.31343H8.79675L9.54251 4.43207L9.62726 4.17783L9.00014 3.77105L6.93234 1.72021L8.05099 0.601562L11.9832 4.53377V5.68631L8.05099 9.60156Z"
|
||||
d="M8.05 9.6L6.93 8.5L9 6.45L9.63 6.04L9.54 5.79L8.8 5.91H0.02V4.31H8.8L9.54 4.43L9.63 4.18L9 3.77L6.93 1.72L8.05 0.6L11.98 4.53V5.69L8.05 9.6Z"
|
||||
fill="#FAFAFA"
|
||||
/>
|
||||
</svg>
|
||||
|
|
@ -121,8 +138,13 @@ const SOURCE_ICON: Record<
|
|||
}
|
||||
|
||||
const SOURCE_LABEL: Record<"x" | "linkedin", string> = {
|
||||
x: "X profile detected — press Enter to continue",
|
||||
linkedin: "LinkedIn profile detected — press Enter to continue",
|
||||
x: "X profile detected - checking account",
|
||||
linkedin: "LinkedIn profile detected - checking account",
|
||||
}
|
||||
|
||||
const SOURCE_NAME: Record<"x" | "linkedin", string> = {
|
||||
x: "X",
|
||||
linkedin: "LinkedIn",
|
||||
}
|
||||
|
||||
type SpotlightItem = {
|
||||
|
|
@ -352,6 +374,167 @@ function buildSpotlightCatalog(
|
|||
}
|
||||
}
|
||||
|
||||
function isAccountSource(source: DetectedSource): source is "x" | "linkedin" {
|
||||
return source === "x" || source === "linkedin"
|
||||
}
|
||||
|
||||
function useSpotlightAutoRotation(
|
||||
status: Status,
|
||||
pauseSpotlight: boolean,
|
||||
setSpotlightCategory: Dispatch<SetStateAction<SpotlightCategoryId>>,
|
||||
) {
|
||||
useEffect(() => {
|
||||
if (status !== "processing") return
|
||||
if (pauseSpotlight) return
|
||||
const n = SPOTLIGHT_CATEGORY_ORDER.length
|
||||
if (n <= 1) return
|
||||
const t = setInterval(() => {
|
||||
setSpotlightCategory((cur) => {
|
||||
const i = SPOTLIGHT_CATEGORY_ORDER.indexOf(cur)
|
||||
const from = i >= 0 ? i : 0
|
||||
const next = (from + 1) % n
|
||||
return SPOTLIGHT_CATEGORY_ORDER[next] ?? cur
|
||||
})
|
||||
}, 8000)
|
||||
return () => clearInterval(t)
|
||||
}, [status, pauseSpotlight, setSpotlightCategory])
|
||||
}
|
||||
|
||||
function useInitialInputFocus(inputRef: RefObject<HTMLInputElement | null>) {
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => inputRef.current?.focus(), 500)
|
||||
return () => clearTimeout(t)
|
||||
}, [inputRef])
|
||||
}
|
||||
|
||||
function useAccountLookup({
|
||||
detected,
|
||||
status,
|
||||
value,
|
||||
}: {
|
||||
detected: DetectedSource
|
||||
status: Status
|
||||
value: string
|
||||
}) {
|
||||
const [accountLookup, setAccountLookup] = useState<AccountLookup | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (status !== "idle") return
|
||||
|
||||
const source = isAccountSource(detected) ? detected : null
|
||||
const trimmedValue = value.trim()
|
||||
|
||||
if (!source || !trimmedValue) {
|
||||
setAccountLookup(null)
|
||||
return
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
setAccountLookup({
|
||||
source,
|
||||
status: "checking",
|
||||
message: SOURCE_LABEL[source],
|
||||
})
|
||||
|
||||
const timeout = setTimeout(async () => {
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
source,
|
||||
value: trimmedValue,
|
||||
})
|
||||
const response = await fetch(
|
||||
`/api/onboarding/account-status?${params.toString()}`,
|
||||
{ signal: controller.signal },
|
||||
)
|
||||
const data: {
|
||||
found?: boolean
|
||||
handle?: string
|
||||
reason?: string
|
||||
verified?: boolean
|
||||
} = await response.json().catch(() => ({}))
|
||||
|
||||
if (controller.signal.aborted) return
|
||||
|
||||
if (response.ok && data.found === true) {
|
||||
const account =
|
||||
source === "x" && data.handle ? ` @${data.handle}` : ""
|
||||
setAccountLookup({
|
||||
source,
|
||||
status: "found",
|
||||
message: `${SOURCE_NAME[source]} account${account} found - press Enter to continue`,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
(response.ok && data.found === false) ||
|
||||
data.reason === "invalid"
|
||||
) {
|
||||
setAccountLookup({
|
||||
source,
|
||||
status: "not_found",
|
||||
message: `${SOURCE_NAME[source]} account not found. Check the link and try again.`,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setAccountLookup({
|
||||
source,
|
||||
status: "error",
|
||||
message: `Could not verify ${SOURCE_NAME[source]} account. You can still continue.`,
|
||||
})
|
||||
} catch (err) {
|
||||
if (controller.signal.aborted) return
|
||||
console.error(err)
|
||||
setAccountLookup({
|
||||
source,
|
||||
status: "error",
|
||||
message: `Could not verify ${SOURCE_NAME[source]} account. You can still continue.`,
|
||||
})
|
||||
}
|
||||
}, 450)
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeout)
|
||||
controller.abort()
|
||||
}
|
||||
}, [detected, status, value])
|
||||
|
||||
return accountLookup
|
||||
}
|
||||
|
||||
function usePollingCleanup(
|
||||
pollingRef: RefObject<ReturnType<typeof setInterval> | null>,
|
||||
) {
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (pollingRef.current) clearInterval(pollingRef.current)
|
||||
}
|
||||
}, [pollingRef])
|
||||
}
|
||||
|
||||
function useDoneAnimation(
|
||||
status: Status,
|
||||
setStampLanded: Dispatch<SetStateAction<boolean>>,
|
||||
setVisibleSnippets: Dispatch<SetStateAction<number>>,
|
||||
) {
|
||||
useEffect(() => {
|
||||
if (status !== "done") return
|
||||
setStampLanded(false)
|
||||
setVisibleSnippets(0)
|
||||
const t1 = setTimeout(() => setStampLanded(true), 400)
|
||||
const t2 = setTimeout(() => setVisibleSnippets(1), 900)
|
||||
const t3 = setTimeout(() => setVisibleSnippets(2), 1200)
|
||||
const t4 = setTimeout(() => setVisibleSnippets(3), 1500)
|
||||
return () => {
|
||||
clearTimeout(t1)
|
||||
clearTimeout(t2)
|
||||
clearTimeout(t3)
|
||||
clearTimeout(t4)
|
||||
}
|
||||
}, [status, setStampLanded, setVisibleSnippets])
|
||||
}
|
||||
|
||||
export default function OnboardingPage() {
|
||||
const router = useRouter()
|
||||
const { user, organizations, refetchOrganizations, setActiveOrg } = useAuth()
|
||||
|
|
@ -374,6 +557,12 @@ export default function OnboardingPage() {
|
|||
const skippingRef = useRef(false)
|
||||
const [spotlightCategory, setSpotlightCategory] =
|
||||
useState<SpotlightCategoryId>("productivity")
|
||||
|
||||
/** Navigate home, or back to the plugin connect page if one is pending. */
|
||||
const goHomeOrPendingConnect = useCallback(() => {
|
||||
const pendingPath = consumePendingConnectUrl()
|
||||
router.push(pendingPath ?? "/")
|
||||
}, [router])
|
||||
const [pauseSpotlight, setPauseSpotlight] = useState(false)
|
||||
|
||||
const spotlightCatalog = useMemo(
|
||||
|
|
@ -395,48 +584,11 @@ export default function OnboardingPage() {
|
|||
[spotlightCategory],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (status !== "processing") return
|
||||
if (pauseSpotlight) return
|
||||
const n = SPOTLIGHT_CATEGORY_ORDER.length
|
||||
if (n <= 1) return
|
||||
const t = setInterval(() => {
|
||||
setSpotlightCategory((cur) => {
|
||||
const i = SPOTLIGHT_CATEGORY_ORDER.indexOf(cur)
|
||||
const from = i >= 0 ? i : 0
|
||||
const next = (from + 1) % n
|
||||
return SPOTLIGHT_CATEGORY_ORDER[next] ?? cur
|
||||
})
|
||||
}, 8000)
|
||||
return () => clearInterval(t)
|
||||
}, [status, pauseSpotlight])
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => inputRef.current?.focus(), 500)
|
||||
return () => clearTimeout(t)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (pollingRef.current) clearInterval(pollingRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (status !== "done") return
|
||||
setStampLanded(false)
|
||||
setVisibleSnippets(0)
|
||||
const t1 = setTimeout(() => setStampLanded(true), 400)
|
||||
const t2 = setTimeout(() => setVisibleSnippets(1), 900)
|
||||
const t3 = setTimeout(() => setVisibleSnippets(2), 1200)
|
||||
const t4 = setTimeout(() => setVisibleSnippets(3), 1500)
|
||||
return () => {
|
||||
clearTimeout(t1)
|
||||
clearTimeout(t2)
|
||||
clearTimeout(t3)
|
||||
clearTimeout(t4)
|
||||
}
|
||||
}, [status])
|
||||
useSpotlightAutoRotation(status, pauseSpotlight, setSpotlightCategory)
|
||||
useInitialInputFocus(inputRef)
|
||||
const accountLookup = useAccountLookup({ detected, status, value })
|
||||
usePollingCleanup(pollingRef)
|
||||
useDoneAnimation(status, setStampLanded, setVisibleSnippets)
|
||||
|
||||
const handleChange = (v: string) => {
|
||||
setValue(v)
|
||||
|
|
@ -467,7 +619,8 @@ export default function OnboardingPage() {
|
|||
skippingRef.current = true
|
||||
try {
|
||||
await ensureOrg()
|
||||
router.push("/")
|
||||
const pendingPath = consumePendingConnectUrl()
|
||||
router.push(pendingPath ?? "/")
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
skippingRef.current = false
|
||||
|
|
@ -597,7 +750,18 @@ export default function OnboardingPage() {
|
|||
}
|
||||
}
|
||||
|
||||
const canSubmit = detected && detected !== "resume"
|
||||
const hasDetectedAccount = detected === "x" || detected === "linkedin"
|
||||
const currentAccountLookup =
|
||||
accountLookup?.source === detected ? accountLookup : null
|
||||
const isCheckingAccount =
|
||||
hasDetectedAccount &&
|
||||
(!currentAccountLookup || currentAccountLookup.status === "checking")
|
||||
const canSubmit = Boolean(
|
||||
hasDetectedAccount &&
|
||||
currentAccountLookup &&
|
||||
currentAccountLookup.status !== "checking" &&
|
||||
currentAccountLookup.status !== "not_found",
|
||||
)
|
||||
|
||||
return (
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: full-surface drag-and-drop for resume PDF
|
||||
|
|
@ -705,6 +869,20 @@ export default function OnboardingPage() {
|
|||
)}
|
||||
/>
|
||||
|
||||
<AnimatePresence>
|
||||
{isCheckingAccount && (
|
||||
<motion.span
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.8 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="absolute right-3 flex items-center pointer-events-none"
|
||||
>
|
||||
<Loader2 className="size-4 animate-spin text-[#6BB0FF]" />
|
||||
</motion.span>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{canSubmit && (
|
||||
<motion.button
|
||||
type="button"
|
||||
|
|
@ -729,9 +907,35 @@ export default function OnboardingPage() {
|
|||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -4 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="text-xs text-[#6BB0FF] pl-1"
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 text-xs pl-1",
|
||||
currentAccountLookup?.status === "found" &&
|
||||
"text-[#65D08C]",
|
||||
currentAccountLookup?.status === "not_found" &&
|
||||
"text-[#FF8A8A]",
|
||||
currentAccountLookup?.status === "error" &&
|
||||
"text-[#F0B86A]",
|
||||
(!currentAccountLookup ||
|
||||
currentAccountLookup.status === "checking") &&
|
||||
"text-[#6BB0FF]",
|
||||
)}
|
||||
>
|
||||
{SOURCE_LABEL[detected as "x" | "linkedin"]}
|
||||
{currentAccountLookup?.status === "found" && (
|
||||
<CheckCircle2 className="size-3.5 shrink-0" />
|
||||
)}
|
||||
{currentAccountLookup?.status === "not_found" && (
|
||||
<AlertCircle className="size-3.5 shrink-0" />
|
||||
)}
|
||||
{currentAccountLookup?.status === "error" && (
|
||||
<AlertCircle className="size-3.5 shrink-0" />
|
||||
)}
|
||||
{isCheckingAccount && (
|
||||
<Loader2 className="size-3.5 shrink-0 animate-spin" />
|
||||
)}
|
||||
<span>
|
||||
{currentAccountLookup?.message ??
|
||||
SOURCE_LABEL[detected as "x" | "linkedin"]}
|
||||
</span>
|
||||
</motion.p>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
|
@ -809,7 +1013,7 @@ export default function OnboardingPage() {
|
|||
Finishing your first save
|
||||
</p>
|
||||
<p className="text-sm text-[#6b7c91] leading-relaxed">
|
||||
Most finish in under a minute. Below is optional — ways to add
|
||||
Most finish in under a minute. Below is optional: ways to add
|
||||
more later.
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -948,7 +1152,7 @@ export default function OnboardingPage() {
|
|||
</p>
|
||||
<p className="text-sm text-[#8B9DB5] leading-relaxed">
|
||||
Your first save is ready. When you want more, use Integrations
|
||||
for browser, phone, editor, and AI tools — all in one place.
|
||||
for browser, phone, editor, and AI tools, all in one place.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -1077,7 +1281,7 @@ export default function OnboardingPage() {
|
|||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push("/")}
|
||||
onClick={goHomeOrPendingConnect}
|
||||
className="text-sm text-[#3A4A5E] hover:text-[#6B7A8D] transition-colors cursor-pointer"
|
||||
>
|
||||
Go to home
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ const GRADIENT_TOP_WIDTH_MAX = 1440
|
|||
function gradientTopPositionForWidth(width: number) {
|
||||
const minW = 320
|
||||
const pctWide = 15
|
||||
const pctNarrow = 70
|
||||
const pctNarrow = 55
|
||||
const w = Math.min(GRADIENT_TOP_WIDTH_MAX, Math.max(minW, width))
|
||||
const t = (w - minW) / (GRADIENT_TOP_WIDTH_MAX - minW)
|
||||
const eased = t * t
|
||||
|
|
@ -415,6 +415,17 @@ export default function NewPage() {
|
|||
void setViewMode("list")
|
||||
},
|
||||
[handleOpenDocument, setSelectedProject, setViewMode],
|
||||
|
||||
// Separate from handleOpenDocument because the graph view only has a document ID,
|
||||
// not the full document object. The modal will fetch the document via the docId
|
||||
// query param, so there may be a brief loading state (unlike handleOpenDocument
|
||||
// which pre-populates via setSelectedDocument).
|
||||
const handleOpenDocumentById = useCallback(
|
||||
(documentId: string) => {
|
||||
analytics.documentModalOpened({ document_id: documentId })
|
||||
setDocId(documentId)
|
||||
},
|
||||
[setDocId],
|
||||
)
|
||||
|
||||
const handleQuickNoteSave = useCallback(
|
||||
|
|
@ -530,12 +541,11 @@ export default function NewPage() {
|
|||
const gradientTopPosition = gradientTopPositionForWidth(viewportWidth)
|
||||
|
||||
const isChatView = viewMode === "chat"
|
||||
const isGraphMode = viewMode === "graph" && !isMobile
|
||||
const isMemoriesDesktop = viewMode === "list" && !isMobile
|
||||
const isHomeDesktop = viewMode === "dashboard" && !isMobile
|
||||
const showNovaBackdrop = isGraphMode || isMemoriesDesktop || isHomeDesktop
|
||||
const showNovaBackdrop =
|
||||
viewMode === "graph" || viewMode === "list" || viewMode === "dashboard"
|
||||
const isDashboardShell =
|
||||
viewMode === "dashboard" || (viewMode === "graph" && isMobile)
|
||||
const isGraphMode = viewMode === "graph"
|
||||
|
||||
return (
|
||||
<HotkeysProvider>
|
||||
|
|
@ -644,9 +654,9 @@ export default function NewPage() {
|
|||
<XBookmarksDetailView
|
||||
onBack={() => void setViewMode("integrations")}
|
||||
/>
|
||||
) : viewMode === "graph" && !isMobile ? (
|
||||
) : viewMode === "graph" ? (
|
||||
<div className="min-h-0 min-w-0 flex-1">
|
||||
<GraphLayoutView />
|
||||
<GraphLayoutView onOpenDocument={handleOpenDocumentById} />
|
||||
</div>
|
||||
) : viewMode === "list" ? (
|
||||
<div
|
||||
|
|
@ -689,20 +699,7 @@ export default function NewPage() {
|
|||
) : (
|
||||
<DashboardView
|
||||
spaceLabel={dashboardSpaceLabel}
|
||||
headerNotice={
|
||||
viewMode === "graph" && isMobile ? (
|
||||
<div
|
||||
id="graph-mobile-notice"
|
||||
className="rounded-lg border border-[#2261CA33] bg-[#041127] px-3 py-2.5 text-sm text-[#8B8B8B]"
|
||||
>
|
||||
<span className="font-medium text-white">
|
||||
Graph view is available on desktop.
|
||||
</span>{" "}
|
||||
Use a larger screen for the full graph, or keep
|
||||
working from this home view.
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
headerNotice={undefined}
|
||||
highlights={highlightsData?.highlights ?? []}
|
||||
isLoadingHighlights={isLoadingHighlights}
|
||||
onAddMemory={handleAddMemory}
|
||||
|
|
@ -728,12 +725,7 @@ export default function NewPage() {
|
|||
</AnimatePresence>
|
||||
|
||||
{isDashboardShell && (
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none fixed inset-x-0 z-30 bg-gradient-to-t from-black via-black/40 to-transparent pt-12",
|
||||
isMobile ? "bottom-[4.5rem]" : "bottom-0",
|
||||
)}
|
||||
>
|
||||
<div className="pointer-events-none fixed inset-x-0 bottom-0 z-30 bg-gradient-to-t from-black via-black/40 to-transparent pt-12">
|
||||
<div className="pointer-events-auto">
|
||||
<HomeChatComposer onStartChat={handleHomeChatStart} />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -251,10 +251,14 @@ export default function SettingsPage() {
|
|||
}, [])
|
||||
|
||||
const headerDisplayName =
|
||||
user?.displayUsername || localStorageUsername || user?.name || ""
|
||||
user?.displayUsername ||
|
||||
localStorageUsername ||
|
||||
user?.name ||
|
||||
user?.email?.split("@")[0] ||
|
||||
""
|
||||
const headerPossessive = headerDisplayName
|
||||
? `${headerDisplayName.split(" ")[0]}'s`
|
||||
: ""
|
||||
: "Your"
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col overflow-hidden">
|
||||
|
|
@ -299,12 +303,12 @@ export default function SettingsPage() {
|
|||
</header>
|
||||
<main className="flex-1 min-h-0 overflow-y-auto md:overflow-hidden">
|
||||
<div className="flex flex-col md:flex-row md:justify-center gap-4 md:gap-8 lg:gap-12 px-4 md:px-6 pt-4 pb-6 md:h-full">
|
||||
<div className="md:w-auto md:max-w-[380px] shrink-0">
|
||||
<div className="md:flex md:h-full md:min-h-0 md:w-auto md:max-w-[380px] md:flex-col md:overflow-hidden shrink-0">
|
||||
{!isMobile && (
|
||||
<motion.div
|
||||
animate={{
|
||||
scale: 1,
|
||||
padding: 48,
|
||||
padding: 28,
|
||||
paddingTop: 0,
|
||||
}}
|
||||
transition={{
|
||||
|
|
@ -314,16 +318,16 @@ export default function SettingsPage() {
|
|||
}}
|
||||
className="relative flex items-center justify-center"
|
||||
>
|
||||
<NovaOrb size={175} className="blur-[3px]!" />
|
||||
<UserSupermemory name={user?.name ?? ""} />
|
||||
<NovaOrb size={140} className="blur-[3px]!" />
|
||||
<UserSupermemory name={headerDisplayName} />
|
||||
</motion.div>
|
||||
)}
|
||||
<nav
|
||||
className={cn(
|
||||
"flex gap-2",
|
||||
"flex",
|
||||
isMobile
|
||||
? "flex-row overflow-x-auto pb-2 scrollbar-thin"
|
||||
: "flex-col",
|
||||
? "flex-row gap-2 overflow-x-auto pb-2 scrollbar-thin"
|
||||
: "min-h-0 flex-1 flex-col gap-1.5 overflow-y-auto pr-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
|
|
@ -338,7 +342,7 @@ export default function SettingsPage() {
|
|||
}}
|
||||
className={cn(
|
||||
"rounded-xl transition-colors flex items-start gap-3 shrink-0",
|
||||
isMobile ? "px-3 py-2 text-sm" : "text-left p-4",
|
||||
isMobile ? "px-3 py-2 text-sm" : "text-left px-3 py-2.5",
|
||||
activeTab === item.id
|
||||
? "bg-[#14161A] text-white shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]"
|
||||
: "text-white/60 hover:text-white hover:bg-[#14161A] hover:shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]",
|
||||
|
|
@ -354,7 +358,7 @@ export default function SettingsPage() {
|
|||
) : (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="font-medium">{item.label}</span>
|
||||
<span className="text-sm text-white/50">
|
||||
<span className="text-xs leading-snug text-white/50">
|
||||
{item.description}
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -363,7 +367,7 @@ export default function SettingsPage() {
|
|||
))}
|
||||
|
||||
{/* Divider */}
|
||||
{!isMobile && <div className="my-1 h-px bg-[#0F1621]" />}
|
||||
{!isMobile && <div className="my-0.5 h-px bg-[#0F1621]" />}
|
||||
|
||||
{DANGER_ITEMS.map((item) => {
|
||||
const colors = DANGER_COLORS[item.color]
|
||||
|
|
@ -379,7 +383,7 @@ export default function SettingsPage() {
|
|||
onClick={handleClick}
|
||||
className={cn(
|
||||
"rounded-xl transition-colors flex items-start gap-3 shrink-0 group",
|
||||
isMobile ? "px-3 py-2 text-sm" : "text-left p-4",
|
||||
isMobile ? "px-3 py-2 text-sm" : "text-left px-3 py-2.5",
|
||||
"hover:bg-[#14161A] hover:shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]",
|
||||
colors.idle,
|
||||
colors.hover,
|
||||
|
|
@ -402,7 +406,7 @@ export default function SettingsPage() {
|
|||
) : (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="font-medium">{item.label}</span>
|
||||
<span className="text-sm opacity-60">
|
||||
<span className="text-xs leading-snug opacity-60">
|
||||
{item.description}
|
||||
</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -313,7 +313,7 @@ export default function LoginPage() {
|
|||
<ExternalAuthButton
|
||||
authIcon={
|
||||
<svg
|
||||
className="w-4 h-4 sm:w-5 sm:h-5"
|
||||
className="size-4 sm:size-5"
|
||||
fill="none"
|
||||
height="25"
|
||||
viewBox="0 0 24 25"
|
||||
|
|
@ -322,19 +322,19 @@ export default function LoginPage() {
|
|||
>
|
||||
<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"
|
||||
d="M21.81 10.26H21V10.21H12V14.21H17.65C16.83 16.54 14.61 18.21 12 18.21C8.69 18.21 6 15.53 6 12.21C6 8.9 8.69 6.21 12 6.21C13.53 6.21 14.92 6.79 15.98 7.73L18.81 4.91C17.02 3.24 14.63 2.21 12 2.21C6.48 2.21 2 6.69 2 12.21C2 17.74 6.48 22.21 12 22.21C17.52 22.21 22 17.74 22 12.21C22 11.54 21.93 10.89 21.81 10.26Z"
|
||||
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"
|
||||
d="M3.15 7.56L6.44 9.97C7.33 7.77 9.48 6.21 12 6.21C13.53 6.21 14.92 6.79 15.98 7.73L18.81 4.91C17.02 3.24 14.63 2.21 12 2.21C8.16 2.21 4.83 4.38 3.15 7.56Z"
|
||||
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"
|
||||
d="M12 22.22C14.58 22.22 16.93 21.23 18.7 19.62L15.61 17C14.57 17.79 13.3 18.22 12 18.22C9.4 18.22 7.19 16.56 6.36 14.24L3.1 16.75C4.75 19.99 8.11 22.22 12 22.22Z"
|
||||
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"
|
||||
d="M21.81 10.26H21V10.21H12V14.21H17.65C17.26 15.32 16.55 16.29 15.61 17L15.61 17L18.7 19.62C18.49 19.82 22 17.21 22 12.21C22 11.54 21.93 10.89 21.81 10.26Z"
|
||||
fill="#1976D2"
|
||||
/>
|
||||
</svg>
|
||||
|
|
@ -378,7 +378,7 @@ export default function LoginPage() {
|
|||
<ExternalAuthButton
|
||||
authIcon={
|
||||
<svg
|
||||
className="w-4 h-4 sm:w-5 sm:h-5 text-foreground"
|
||||
className="size-4 sm:size-5 text-foreground"
|
||||
fill="none"
|
||||
height="25"
|
||||
viewBox="0 0 26 25"
|
||||
|
|
@ -389,7 +389,7 @@ export default function LoginPage() {
|
|||
<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"
|
||||
d="M12.96 0.21C6.21 0.21 0.75 5.71 0.75 12.52C0.75 17.96 4.25 22.56 9.1 24.19C9.71 24.31 9.93 23.93 9.93 23.6C9.93 23.32 9.91 22.34 9.91 21.32C6.51 22.05 5.81 19.85 5.81 19.85C5.26 18.43 4.45 18.06 4.45 18.06C3.34 17.31 4.53 17.31 4.53 17.31C5.76 17.39 6.41 18.57 6.41 18.57C7.5 20.44 9.26 19.91 9.97 19.59C10.07 18.79 10.4 18.24 10.74 17.94C8.03 17.65 5.18 16.59 5.18 11.87C5.18 10.52 5.66 9.42 6.43 8.57C6.31 8.26 5.89 7 6.55 5.31C6.55 5.31 7.58 4.98 9.91 6.57C10.91 6.3 11.93 6.16 12.96 6.16C13.99 6.16 15.05 6.31 16.02 6.57C18.34 4.98 19.37 5.31 19.37 5.31C20.04 7 19.62 8.26 19.49 8.57C20.28 9.42 20.75 10.52 20.75 11.87C20.75 16.59 17.9 17.63 15.17 17.94C15.61 18.32 16 19.06 16 20.22C16 21.87 15.98 23.19 15.98 23.6C15.98 23.93 16.2 24.31 16.81 24.19C21.66 22.56 25.16 17.96 25.16 12.52C25.18 5.71 19.7 0.21 12.96 0.21Z"
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
|
|
@ -399,7 +399,7 @@ export default function LoginPage() {
|
|||
<rect
|
||||
fill="currentColor"
|
||||
height="24"
|
||||
transform="translate(0.75 0.214844)"
|
||||
transform="translate(0.75 0.21)"
|
||||
width="24.5"
|
||||
/>
|
||||
</clipPath>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { ImageResponse } from "next/og"
|
|||
|
||||
export async function GET() {
|
||||
return new ImageResponse(
|
||||
<div tw="w-full h-full flex flex-col justify-center items-center">
|
||||
<div tw="size-full flex flex-col justify-center items-center">
|
||||
<img
|
||||
src="https://pub-1be2b1df2c7e456f8e21149e972f4caf.r2.dev/bust.png"
|
||||
alt="Google Logo"
|
||||
|
|
|
|||
236
apps/web/app/api/onboarding/account-status/route.ts
Normal file
236
apps/web/app/api/onboarding/account-status/route.ts
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
type AccountSource = "x" | "linkedin"
|
||||
|
||||
type ParsedAccount = {
|
||||
handle: string
|
||||
url: string
|
||||
}
|
||||
|
||||
function parseXAccount(value: string): ParsedAccount | null {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return null
|
||||
|
||||
let handle = trimmed.replace(/^@/, "")
|
||||
const lowerValue = handle.toLowerCase()
|
||||
|
||||
if (lowerValue.includes("x.com") || lowerValue.includes("twitter.com")) {
|
||||
try {
|
||||
const url = new URL(
|
||||
handle.startsWith("http://") || handle.startsWith("https://")
|
||||
? handle
|
||||
: `https://${handle}`,
|
||||
)
|
||||
handle = url.pathname.split("/").filter(Boolean)[0] ?? ""
|
||||
} catch {
|
||||
handle = handle.match(/(?:x\.com|twitter\.com)\/([^/\s?#]+)/i)?.[1] ?? ""
|
||||
}
|
||||
}
|
||||
|
||||
handle = handle.replace(/^@/, "").split(/[/?#]/)[0] ?? ""
|
||||
if (!/^[A-Za-z0-9_]{1,15}$/.test(handle)) return null
|
||||
|
||||
return { handle, url: `https://x.com/${handle}` }
|
||||
}
|
||||
|
||||
function parseLinkedInAccount(value: string): ParsedAccount | null {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return null
|
||||
|
||||
try {
|
||||
const url = new URL(
|
||||
trimmed.startsWith("http://") || trimmed.startsWith("https://")
|
||||
? trimmed
|
||||
: `https://${trimmed}`,
|
||||
)
|
||||
const match = url.pathname.match(/\/(in|pub)\/([^/\s?#]+)/i)
|
||||
const handle = match?.[2]
|
||||
if (!handle) return null
|
||||
|
||||
return {
|
||||
handle,
|
||||
url: `https://www.linkedin.com/${match[1]?.toLowerCase()}/${handle}`,
|
||||
}
|
||||
} catch {
|
||||
const match = trimmed.match(/linkedin\.com\/(in|pub)\/([^/\s?#]+)/i)
|
||||
const handle = match?.[2]
|
||||
if (!handle) return null
|
||||
|
||||
return {
|
||||
handle,
|
||||
url: `https://www.linkedin.com/${match[1]?.toLowerCase()}/${handle}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function parseAccount(
|
||||
source: AccountSource,
|
||||
value: string,
|
||||
): ParsedAccount | null {
|
||||
return source === "x" ? parseXAccount(value) : parseLinkedInAccount(value)
|
||||
}
|
||||
|
||||
function looksUnavailable(source: AccountSource, html: string) {
|
||||
const lowerHtml = html.toLowerCase()
|
||||
if (source === "x") {
|
||||
return (
|
||||
lowerHtml.includes("this account doesn") ||
|
||||
lowerHtml.includes("account suspended") ||
|
||||
lowerHtml.includes("profile not found")
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
lowerHtml.includes("profile not found") ||
|
||||
lowerHtml.includes("page not found") ||
|
||||
lowerHtml.includes("this linkedin profile is unavailable")
|
||||
)
|
||||
}
|
||||
|
||||
function linkedinFallback(account: ParsedAccount, status?: number) {
|
||||
return Response.json({
|
||||
found: null,
|
||||
verified: false,
|
||||
reason: "unable_to_verify_linkedin",
|
||||
handle: account.handle,
|
||||
status,
|
||||
url: account.url,
|
||||
})
|
||||
}
|
||||
|
||||
async function verifyXAccount(account: ParsedAccount, signal: AbortSignal) {
|
||||
const oembedUrl = new URL("https://publish.twitter.com/oembed")
|
||||
oembedUrl.searchParams.set("url", account.url)
|
||||
|
||||
const response = await fetch(oembedUrl, {
|
||||
signal,
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"User-Agent":
|
||||
"Mozilla/5.0 (compatible; SuperMemory/1.0; +https://supermemory.ai)",
|
||||
},
|
||||
})
|
||||
|
||||
if (response.status === 404 || response.status === 410) {
|
||||
return Response.json({
|
||||
found: false,
|
||||
handle: account.handle,
|
||||
status: response.status,
|
||||
url: account.url,
|
||||
})
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
return Response.json(
|
||||
{
|
||||
error: "Unable to verify account",
|
||||
handle: account.handle,
|
||||
status: response.status,
|
||||
url: account.url,
|
||||
},
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
|
||||
return Response.json({
|
||||
found: true,
|
||||
handle: account.handle,
|
||||
status: response.status,
|
||||
url: account.url,
|
||||
})
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const source = searchParams.get("source")
|
||||
const value = searchParams.get("value")
|
||||
|
||||
if (source !== "x" && source !== "linkedin") {
|
||||
return Response.json({ error: "Invalid account source" }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!value?.trim()) {
|
||||
return Response.json({ error: "Missing account value" }, { status: 400 })
|
||||
}
|
||||
|
||||
const account = parseAccount(source, value)
|
||||
if (!account) {
|
||||
return Response.json({ found: false, reason: "invalid" }, { status: 400 })
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), 7000)
|
||||
|
||||
try {
|
||||
if (source === "x") {
|
||||
return await verifyXAccount(account, controller.signal)
|
||||
}
|
||||
|
||||
const response = await fetch(account.url, {
|
||||
signal: controller.signal,
|
||||
redirect: "follow",
|
||||
headers: {
|
||||
Accept:
|
||||
"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||
"User-Agent":
|
||||
"Mozilla/5.0 (compatible; SuperMemory/1.0; +https://supermemory.ai)",
|
||||
},
|
||||
})
|
||||
|
||||
if (response.status === 404 || response.status === 410) {
|
||||
return Response.json({
|
||||
found: false,
|
||||
handle: account.handle,
|
||||
status: response.status,
|
||||
url: account.url,
|
||||
})
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
if (source === "linkedin") {
|
||||
return linkedinFallback(account, response.status)
|
||||
}
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
error: "Unable to verify account",
|
||||
handle: account.handle,
|
||||
status: response.status,
|
||||
url: account.url,
|
||||
},
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
|
||||
const html = await response.text()
|
||||
const found = !looksUnavailable(source, html)
|
||||
|
||||
return Response.json({
|
||||
found,
|
||||
handle: account.handle,
|
||||
status: response.status,
|
||||
url: account.url,
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
if (source === "linkedin") {
|
||||
return linkedinFallback(account)
|
||||
}
|
||||
|
||||
return Response.json(
|
||||
{ error: "Account lookup timed out", handle: account.handle },
|
||||
{ status: 504 },
|
||||
)
|
||||
}
|
||||
|
||||
console.error("Account status lookup failed:", error)
|
||||
if (source === "linkedin") {
|
||||
return linkedinFallback(account)
|
||||
}
|
||||
|
||||
return Response.json(
|
||||
{ error: "Unable to verify account", handle: account.handle },
|
||||
{ status: 502 },
|
||||
)
|
||||
} finally {
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
}
|
||||
|
|
@ -7,8 +7,10 @@ import { dmSans125ClassName } from "@/lib/fonts"
|
|||
import { useCustomer } from "autumn-js/react"
|
||||
import { ArrowRight, Loader, XCircle } from "lucide-react"
|
||||
import Image from "next/image"
|
||||
import { useSearchParams } from "next/navigation"
|
||||
import { Suspense, useState } from "react"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { Suspense, useEffect, useState } from "react"
|
||||
|
||||
import { PENDING_CONNECT_URL_KEY } from "@/lib/constants"
|
||||
|
||||
const API_URL =
|
||||
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
|
||||
|
|
@ -116,8 +118,9 @@ const cardClass = cn(
|
|||
|
||||
function AuthConnectContent() {
|
||||
const params = useSearchParams()
|
||||
const router = useRouter()
|
||||
const { data: session, isPending } = useSession()
|
||||
const { org } = useAuth()
|
||||
const { org, organizations, isRestoring } = useAuth()
|
||||
const autumn = useCustomer()
|
||||
const [status, setStatus] = useState<Status>("loading")
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
|
@ -129,6 +132,29 @@ function AuthConnectContent() {
|
|||
const displayName = validClient ? getPluginName(validClient) : "External Tool"
|
||||
const pluginInfo = validClient ? PLUGIN_INFO[validClient] : null
|
||||
|
||||
// Redirect new users (logged in but no organization) to onboarding.
|
||||
// Store the current connect URL so onboarding can redirect back here.
|
||||
const shouldRedirectToOnboarding =
|
||||
!isPending &&
|
||||
!isRestoring &&
|
||||
!!session &&
|
||||
Array.isArray(organizations) &&
|
||||
organizations.length === 0
|
||||
|
||||
useEffect(() => {
|
||||
if (isPending || isRestoring) return
|
||||
if (!session) return
|
||||
if (organizations === null) return // orgs query still pending
|
||||
if (organizations.length > 0) return // has orgs, nothing to do
|
||||
|
||||
try {
|
||||
sessionStorage.setItem(PENDING_CONNECT_URL_KEY, window.location.href)
|
||||
} catch (e) {
|
||||
console.warn("Failed to access sessionStorage for pending connect URL", e)
|
||||
}
|
||||
router.replace("/onboarding")
|
||||
}, [isPending, isRestoring, session, organizations, router])
|
||||
|
||||
async function handleConnect() {
|
||||
if (!callback) {
|
||||
setStatus("error")
|
||||
|
|
@ -140,7 +166,13 @@ function AuthConnectContent() {
|
|||
setError("Invalid callback URL.")
|
||||
return
|
||||
}
|
||||
if (!session || !org) return
|
||||
if (!session || !org) {
|
||||
setStatus("error")
|
||||
setError(
|
||||
"Your account is not fully set up yet. Please complete onboarding first.",
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setStatus("creating")
|
||||
|
|
@ -180,7 +212,7 @@ function AuthConnectContent() {
|
|||
setIsUpgrading(true)
|
||||
const safeSuccessUrl = `${window.location.origin}${window.location.pathname}?callback=${encodeURIComponent(callback ?? "")}&client=${encodeURIComponent(validClient ?? "")}`
|
||||
await autumn.attach({
|
||||
productId: "api_pro",
|
||||
planId: "api_pro",
|
||||
successUrl: safeSuccessUrl,
|
||||
})
|
||||
} catch (err) {
|
||||
|
|
@ -189,7 +221,10 @@ function AuthConnectContent() {
|
|||
}
|
||||
}
|
||||
|
||||
if (isPending) {
|
||||
// Show a spinner while session/org data is loading or while we're about
|
||||
// to redirect to onboarding (prevents a brief flash of the connect card).
|
||||
const isAuthLoading = isPending || isRestoring || organizations === null
|
||||
if (isAuthLoading || shouldRedirectToOnboarding) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen bg-background">
|
||||
<div className="size-6 border-2 border-[#4BA0FA] border-t-transparent rounded-full animate-spin" />
|
||||
|
|
@ -202,7 +237,7 @@ function AuthConnectContent() {
|
|||
<div className={pageWrapperClass}>
|
||||
<div className={cardClass}>
|
||||
<div className="flex flex-col items-center gap-5">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg border border-[#1E293B] bg-[#080B0F]">
|
||||
<div className="flex size-10 items-center justify-center rounded-lg border border-[#1E293B] bg-[#080B0F]">
|
||||
{pluginInfo ? (
|
||||
<Image
|
||||
alt={pluginInfo.name}
|
||||
|
|
@ -281,7 +316,7 @@ function AuthConnectContent() {
|
|||
<div className={pageWrapperClass}>
|
||||
<div className={cardClass}>
|
||||
<div className="flex flex-col items-center gap-5">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg border border-[#1E293B] bg-[#080B0F]">
|
||||
<div className="flex size-10 items-center justify-center rounded-lg border border-[#1E293B] bg-[#080B0F]">
|
||||
{pluginInfo ? (
|
||||
<Image
|
||||
alt={pluginInfo.name}
|
||||
|
|
@ -351,7 +386,7 @@ function AuthConnectContent() {
|
|||
{isUpgrading || autumn.isLoading ? (
|
||||
<>
|
||||
<Loader className="size-4 animate-spin mr-2" />
|
||||
Upgrading...
|
||||
Upgrading…
|
||||
</>
|
||||
) : (
|
||||
"Upgrade to Pro \u2014 $19/month"
|
||||
|
|
@ -429,9 +464,9 @@ function AuthConnectContent() {
|
|||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="size-6 border-2 border-[#4BA0FA] border-t-transparent rounded-full animate-spin" />
|
||||
<p className={dmSans125ClassName("text-sm text-[#737373]")}>
|
||||
{status === "creating" && `Connecting ${displayName}...`}
|
||||
{status === "creating" && `Connecting ${displayName}…`}
|
||||
{status === "success" &&
|
||||
`Success! Redirecting back to ${displayName}...`}
|
||||
`Success! Redirecting back to ${displayName}…`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ export default function RootLayout({
|
|||
"https://api.supermemory.ai"
|
||||
}
|
||||
includeCredentials={true}
|
||||
headers={{ "X-App-Source": "nova" }}
|
||||
>
|
||||
<QueryProvider>
|
||||
<AuthProvider>
|
||||
|
|
|
|||
|
|
@ -82,8 +82,8 @@ export default function ReferralPage() {
|
|||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4 bg-[#0f1419]">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<LoaderIcon className="w-8 h-8 text-orange-500 animate-spin" />
|
||||
<p className="text-white/60">Checking invitation...</p>
|
||||
<LoaderIcon className="size-8 text-orange-500 animate-spin" />
|
||||
<p className="text-white/60">Checking invitation…</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
@ -119,8 +119,8 @@ export default function ReferralPage() {
|
|||
{/* Welcome Card */}
|
||||
<Card className="bg-[#1a1f2a] border-white/10">
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto mb-4 w-16 h-16 rounded-full bg-orange-500/10 flex items-center justify-center">
|
||||
<ShareIcon className="w-8 h-8 text-orange-500" />
|
||||
<div className="mx-auto mb-4 size-16 rounded-full bg-orange-500/10 flex items-center justify-center">
|
||||
<ShareIcon className="size-8 text-orange-500" />
|
||||
</div>
|
||||
<CardTitle className="text-2xl font-bold text-white">
|
||||
You're invited to supermemory!
|
||||
|
|
@ -181,9 +181,9 @@ export default function ReferralPage() {
|
|||
className="shrink-0 border-white/10 hover:bg-white/5"
|
||||
>
|
||||
{copiedLink ? (
|
||||
<CheckIcon className="w-4 h-4" />
|
||||
<CheckIcon className="size-4" />
|
||||
) : (
|
||||
<CopyIcon className="w-4 h-4" />
|
||||
<CopyIcon className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -193,7 +193,7 @@ export default function ReferralPage() {
|
|||
variant="outline"
|
||||
className="w-full border-white/10 text-white hover:bg-white/5"
|
||||
>
|
||||
<ShareIcon className="w-4 h-4" />
|
||||
<ShareIcon className="size-4" />
|
||||
Share this link
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -16,8 +16,8 @@ export default function ReferralHomePage() {
|
|||
<div className="min-h-screen flex items-center justify-center p-4 bg-[#0f1419]">
|
||||
<Card className="max-w-md w-full bg-[#1a1f2a] border-white/10">
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto mb-4 w-16 h-16 rounded-full bg-orange-500/10 flex items-center justify-center">
|
||||
<ShareIcon className="w-8 h-8 text-orange-500" />
|
||||
<div className="mx-auto mb-4 size-16 rounded-full bg-orange-500/10 flex items-center justify-center">
|
||||
<ShareIcon className="size-8 text-orange-500" />
|
||||
</div>
|
||||
<CardTitle className="text-2xl font-bold text-white">
|
||||
Missing Referral Code
|
||||
|
|
|
|||
|
|
@ -152,7 +152,7 @@ export default function MigrateMCPPage() {
|
|||
<div className="relative">
|
||||
<div className="absolute inset-0 bg-blue-500/20 rounded-full blur-xl" />
|
||||
<div className="relative bg-blue-500/10 p-3 rounded-full border border-blue-500/20">
|
||||
<Zap className="w-6 h-6 text-blue-400" />
|
||||
<Zap className="size-6 text-blue-400" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -177,7 +177,7 @@ export default function MigrateMCPPage() {
|
|||
className="text-sm font-medium text-slate-200 flex items-center gap-2"
|
||||
htmlFor="mcpUrl"
|
||||
>
|
||||
<Upload className="w-4 h-4" />
|
||||
<Upload className="size-4" />
|
||||
MCP URL
|
||||
</label>
|
||||
<div className="relative">
|
||||
|
|
@ -231,13 +231,13 @@ export default function MigrateMCPPage() {
|
|||
>
|
||||
{migrateMutation.isPending ? (
|
||||
<>
|
||||
<Spinner className="mr-2 w-4 h-4" />
|
||||
Migrating documents...
|
||||
<Spinner className="mr-2 size-4" />
|
||||
Migrating documents…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Start Upgrade
|
||||
<ArrowRight className="ml-2 w-4 h-4" />
|
||||
<ArrowRight className="ml-2 size-4" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
|
@ -260,7 +260,7 @@ export default function MigrateMCPPage() {
|
|||
<div className="relative p-4 border border-green-500/20 rounded-xl">
|
||||
<div className="text-green-400">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<CheckCircle className="w-5 h-5" />
|
||||
<CheckCircle className="size-5" />
|
||||
<p className="font-medium">
|
||||
Migration completed successfully!
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -9,9 +9,11 @@ import { useCustomer } from "autumn-js/react"
|
|||
import {
|
||||
Check,
|
||||
ChevronDown,
|
||||
Clock,
|
||||
FolderOpen,
|
||||
History,
|
||||
Loader,
|
||||
Loader2,
|
||||
Play,
|
||||
Trash2,
|
||||
Zap,
|
||||
} from "lucide-react"
|
||||
|
|
@ -30,6 +32,11 @@ import {
|
|||
DropdownMenuTrigger,
|
||||
} from "@ui/components/dropdown-menu"
|
||||
import { RemoveConnectionDialog } from "@/components/remove-connection-dialog"
|
||||
import { SyncStatusBadge } from "@/components/settings/sync-status-badge"
|
||||
import { SyncHistoryPanel } from "@/components/settings/sync-history-panel"
|
||||
import { useTriggerSync } from "@/hooks/use-trigger-sync"
|
||||
import { formatRelativeTime } from "@/components/settings/sync-utils"
|
||||
import type { ImportProvider } from "@/components/settings/sync-utils"
|
||||
|
||||
type GDriveSyncScope = "scoped" | "full"
|
||||
|
||||
|
|
@ -71,17 +78,20 @@ const CONNECTORS: Record<
|
|||
},
|
||||
} as const
|
||||
|
||||
function formatRelativeTime(date: string | null | undefined): string {
|
||||
if (!date) return "Never"
|
||||
const d = new Date(date)
|
||||
const diffMs = Date.now() - d.getTime()
|
||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
|
||||
const diffDays = Math.floor(diffHours / 24)
|
||||
if (diffHours < 1) return "Just now"
|
||||
if (diffHours < 24) return `${diffHours}h ago`
|
||||
if (diffDays === 1) return "Yesterday"
|
||||
if (diffDays < 7) return `${diffDays} days ago`
|
||||
return d.toLocaleDateString()
|
||||
/** Extract typed metadata from a connection, with runtime validation. */
|
||||
function getConnectionMeta(connection: Connection) {
|
||||
const m = connection.metadata as Record<string, unknown> | undefined
|
||||
return {
|
||||
syncInProgress: m?.syncInProgress === true,
|
||||
lastSyncedAt:
|
||||
typeof m?.lastSyncedAt === "number" ? m.lastSyncedAt : undefined,
|
||||
documentCount: typeof m?.documentCount === "number" ? m.documentCount : 0,
|
||||
}
|
||||
}
|
||||
|
||||
/** Check if a connection's auth token has expired. */
|
||||
function isConnectionExpired(connection: Connection): boolean {
|
||||
return !!connection.expiresAt && new Date(connection.expiresAt) <= new Date()
|
||||
}
|
||||
|
||||
function ConnectionRow({
|
||||
|
|
@ -89,18 +99,23 @@ function ConnectionRow({
|
|||
onDelete,
|
||||
isDeleting,
|
||||
projects,
|
||||
onTriggerSync,
|
||||
isSyncing,
|
||||
}: {
|
||||
connection: Connection
|
||||
onDelete: () => void
|
||||
isDeleting: boolean
|
||||
projects: Project[]
|
||||
onTriggerSync: () => void
|
||||
isSyncing: boolean
|
||||
}) {
|
||||
const [historyOpen, setHistoryOpen] = useState(false)
|
||||
const config = CONNECTORS[connection.provider as ConnectorProvider]
|
||||
if (!config) return null
|
||||
|
||||
const Icon = config.icon
|
||||
const isConnected =
|
||||
!connection.expiresAt || new Date(connection.expiresAt) > new Date()
|
||||
const meta = getConnectionMeta(connection)
|
||||
const expired = isConnectionExpired(connection)
|
||||
|
||||
const getProjectName = (tag: string): string => {
|
||||
if (tag === DEFAULT_PROJECT_ID) return "Default"
|
||||
|
|
@ -110,12 +125,8 @@ function ConnectionRow({
|
|||
)
|
||||
}
|
||||
|
||||
const documentCount = (connection.metadata?.documentCount as number) ?? 0
|
||||
const containerTags = (
|
||||
connection as Connection & { containerTags?: string[] }
|
||||
).containerTags
|
||||
const projectName = containerTags?.[0]
|
||||
? getProjectName(containerTags[0])
|
||||
const projectName = connection.containerTags?.[0]
|
||||
? getProjectName(connection.containerTags[0])
|
||||
: null
|
||||
|
||||
return (
|
||||
|
|
@ -138,23 +149,11 @@ function ConnectionRow({
|
|||
>
|
||||
{config.title}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
"size-[7px] rounded-full",
|
||||
isConnected ? "bg-[#00AC3F]" : "bg-[#737373]",
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[14px]",
|
||||
isConnected ? "text-[#00AC3F]" : "text-[#737373]",
|
||||
)}
|
||||
>
|
||||
{isConnected ? "Connected" : "Disconnected"}
|
||||
</span>
|
||||
</div>
|
||||
<SyncStatusBadge
|
||||
syncInProgress={meta.syncInProgress}
|
||||
lastSyncedAt={meta.lastSyncedAt}
|
||||
isExpired={expired}
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
className={cn(dmSans125ClassName(), "text-[14px] text-[#737373]")}
|
||||
|
|
@ -162,14 +161,71 @@ function ConnectionRow({
|
|||
{connection.email || "Unknown"}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDelete}
|
||||
disabled={isDeleting}
|
||||
className="text-[#737373] hover:text-red-400 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Trash2 className="size-[22px]" />
|
||||
</button>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onTriggerSync()
|
||||
}}
|
||||
disabled={isSyncing || expired}
|
||||
className="text-[#737373] hover:text-[#4BA0FA] transition-colors disabled:opacity-50 disabled:cursor-not-allowed p-1.5 rounded-lg hover:bg-white/5"
|
||||
aria-label={
|
||||
expired
|
||||
? "Connection expired"
|
||||
: isSyncing
|
||||
? "Sync in progress"
|
||||
: "Sync now"
|
||||
}
|
||||
title={
|
||||
expired
|
||||
? "Reconnect to sync"
|
||||
: isSyncing
|
||||
? "Sync in progress"
|
||||
: "Sync now"
|
||||
}
|
||||
>
|
||||
{isSyncing ? (
|
||||
<Loader2 className="size-[18px] animate-spin" />
|
||||
) : (
|
||||
<Play className="size-[18px]" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setHistoryOpen((v) => !v)
|
||||
}}
|
||||
aria-label="Sync history"
|
||||
aria-expanded={historyOpen}
|
||||
title={historyOpen ? "Hide sync history" : "Sync history"}
|
||||
className={cn(
|
||||
"transition-colors disabled:opacity-50 disabled:cursor-not-allowed p-1.5 rounded-lg hover:bg-white/5 flex items-center gap-0.5",
|
||||
historyOpen
|
||||
? "text-[#FAFAFA] bg-white/5"
|
||||
: "text-[#737373] hover:text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
<History className="size-[18px]" />
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"size-3 transition-transform",
|
||||
historyOpen && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDelete}
|
||||
disabled={isDeleting}
|
||||
className="text-[#737373] hover:text-red-400 transition-colors disabled:opacity-50 disabled:cursor-not-allowed p-1.5 rounded-lg hover:bg-white/5"
|
||||
aria-label="Delete connection"
|
||||
title="Remove connection"
|
||||
>
|
||||
<Trash2 className="size-[18px]" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 pt-2.5 border-t border-[rgba(82,89,102,0.12)]">
|
||||
<div className="flex items-center gap-2 flex-1 flex-wrap">
|
||||
|
|
@ -186,17 +242,11 @@ function ConnectionRow({
|
|||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-1">
|
||||
<Clock className="size-3 text-[#4B5563]" />
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[12px] text-[#737373]",
|
||||
)}
|
||||
>
|
||||
{formatRelativeTime(connection.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className={cn(dmSans125ClassName(), "text-[12px] text-[#737373]")}
|
||||
>
|
||||
Last synced: {formatRelativeTime(meta.lastSyncedAt)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-1 shrink-0">
|
||||
<span
|
||||
|
|
@ -205,7 +255,7 @@ function ConnectionRow({
|
|||
"text-[14px] font-semibold text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
{documentCount}
|
||||
{meta.documentCount}
|
||||
</span>
|
||||
<span
|
||||
className={cn(dmSans125ClassName(), "text-[12px] text-[#737373]")}
|
||||
|
|
@ -214,6 +264,15 @@ function ConnectionRow({
|
|||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{historyOpen && (
|
||||
<div className="border-t border-[rgba(82,89,102,0.12)] pt-3">
|
||||
<SyncHistoryPanel
|
||||
connectionId={connection.id}
|
||||
isOpen={historyOpen}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
@ -226,7 +285,7 @@ interface ConnectContentProps {
|
|||
export function ConnectContent({ selectedProject }: ConnectContentProps) {
|
||||
const queryClient = useQueryClient()
|
||||
const autumn = useCustomer()
|
||||
const isProUser = hasActivePlan(autumn.customer?.products, "api_pro")
|
||||
const isProUser = hasActivePlan(autumn.data?.subscriptions, "api_pro")
|
||||
const [connectingProvider, setConnectingProvider] =
|
||||
useState<ConnectorProvider | null>(null)
|
||||
const [gdriveSyncScope, setGdriveSyncScope] =
|
||||
|
|
@ -236,6 +295,7 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
|
|||
open: boolean
|
||||
connection: Connection | null
|
||||
}>({ open: false, connection: null })
|
||||
const triggerSync = useTriggerSync()
|
||||
|
||||
const projects = (queryClient.getQueryData<Project[]>(["projects"]) ||
|
||||
[]) as Project[]
|
||||
|
|
@ -243,20 +303,26 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
|
|||
const handleUpgrade = async () => {
|
||||
setIsUpgrading(true)
|
||||
try {
|
||||
await autumn.attach({
|
||||
productId: "api_pro",
|
||||
const result = await autumn.attach({
|
||||
planId: "api_pro",
|
||||
successUrl: window.location.href,
|
||||
})
|
||||
if (result?.paymentUrl) {
|
||||
window.open(result.paymentUrl, "_self")
|
||||
return
|
||||
}
|
||||
autumn.refetch?.()
|
||||
} catch (error) {
|
||||
console.error("Upgrade error:", error)
|
||||
toast.error("Failed to start upgrade process")
|
||||
} finally {
|
||||
setIsUpgrading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const connectionsFeature = autumn.customer?.features?.connections
|
||||
const connectionsUsed = connectionsFeature?.usage ?? 0
|
||||
const connectionsLimit = connectionsFeature?.included_usage ?? 10
|
||||
const connectionsBalance = autumn.data?.balances?.connections
|
||||
const connectionsUsed = connectionsBalance?.usage ?? 0
|
||||
const connectionsLimit = connectionsBalance?.granted ?? 10
|
||||
const canAddConnection = connectionsUsed < connectionsLimit
|
||||
|
||||
// Fetch connections
|
||||
|
|
@ -276,7 +342,13 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
|
|||
return response.data as Connection[]
|
||||
},
|
||||
staleTime: 30 * 1000,
|
||||
refetchInterval: 60 * 1000,
|
||||
refetchInterval: (query) => {
|
||||
const conns = query.state.data as Connection[] | undefined
|
||||
if (conns?.some((c) => getConnectionMeta(c).syncInProgress)) {
|
||||
return 5000
|
||||
}
|
||||
return 60 * 1000
|
||||
},
|
||||
refetchIntervalInBackground: true,
|
||||
})
|
||||
|
||||
|
|
@ -409,7 +481,7 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
|
|||
className="bg-[#14161A] rounded-[12px] px-4 py-3 flex items-center justify-between gap-3"
|
||||
>
|
||||
<div className="flex items-center gap-3 flex-1">
|
||||
<Icon className="w-6 h-6 text-[#737373]" />
|
||||
<Icon className="size-6 text-[#737373]" />
|
||||
<div className="space-y-[6px] flex-1">
|
||||
<p className="text-[16px] font-medium">{config.title}</p>
|
||||
<p className="text-[16px] text-[#737373]">
|
||||
|
|
@ -431,7 +503,7 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
|
|||
className="bg-[#4BA0FA] text-black hover:bg-[#4BA0FA]/90 text-[14px] font-medium px-3 h-8 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{isConnecting ? (
|
||||
<Loader className="h-4 w-4 animate-spin" />
|
||||
<Loader className="size-4 animate-spin" />
|
||||
) : (
|
||||
"Connect"
|
||||
)}
|
||||
|
|
@ -443,7 +515,7 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
|
|||
type="button"
|
||||
className="bg-[#4BA0FA] text-black hover:bg-[#4BA0FA]/90 px-1.5 h-8 flex items-center transition-colors"
|
||||
>
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
<ChevronDown className="size-3" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-40">
|
||||
|
|
@ -463,7 +535,7 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
|
|||
>
|
||||
{label}
|
||||
{gdriveSyncScope === scope && (
|
||||
<Check className="w-3 h-3 text-[#4BA0FA]" />
|
||||
<Check className="size-3 text-[#4BA0FA]" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
|
|
@ -483,7 +555,7 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
|
|||
className="bg-[#4BA0FA] text-black hover:bg-[#4BA0FA]/90 text-[14px] font-medium px-3 py-1.5 h-8"
|
||||
>
|
||||
{isConnecting ? (
|
||||
<Loader className="h-4 w-4 animate-spin" />
|
||||
<Loader className="size-4 animate-spin" />
|
||||
) : (
|
||||
"Connect"
|
||||
)}
|
||||
|
|
@ -524,7 +596,7 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
|
|||
className="flex items-center gap-1.5 bg-[#4BA0FA] text-black hover:bg-[#4BA0FA]/90 disabled:opacity-50 disabled:cursor-not-allowed text-[13px] font-medium rounded-full h-8 px-3 transition-colors shrink-0"
|
||||
>
|
||||
{isAnyConnecting ? (
|
||||
<Loader className="h-3.5 w-3.5 animate-spin" />
|
||||
<Loader className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<span>+ Add a connection</span>
|
||||
|
|
@ -638,6 +710,18 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
|
|||
projects={projects}
|
||||
onDelete={() => setRemoveDialog({ open: true, connection })}
|
||||
isDeleting={deleteConnectionMutation.isPending}
|
||||
onTriggerSync={() =>
|
||||
triggerSync.mutate({
|
||||
connectionId: connection.id,
|
||||
provider: connection.provider as ImportProvider,
|
||||
containerTags: connection.containerTags,
|
||||
})
|
||||
}
|
||||
isSyncing={
|
||||
(triggerSync.isPending &&
|
||||
triggerSync.variables?.connectionId === connection.id) ||
|
||||
getConnectionMeta(connection).syncInProgress
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -650,14 +734,14 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
|
|||
id="no-active-connections"
|
||||
className="bg-[#14161A] shadow-inside-out rounded-[12px] px-4 py-6 h-full mb-4 flex flex-col justify-center items-center"
|
||||
>
|
||||
<Zap className="w-6 h-6 text-[#737373] mb-3" />
|
||||
<Zap className="size-6 text-[#737373] mb-3" />
|
||||
{!isProUser ? (
|
||||
<>
|
||||
<p className="text-[14px] text-[#737373] mb-4 text-center">
|
||||
{isUpgrading || autumn.isLoading ? (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Loader className="h-4 w-4 animate-spin" />
|
||||
Upgrading...
|
||||
<Loader className="size-4 animate-spin" />
|
||||
Upgrading…
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
|
|
@ -676,19 +760,19 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
|
|||
</p>
|
||||
<div className="space-y-2 text-[14px]">
|
||||
<div className="flex items-center gap-2">
|
||||
<Check className="w-4 h-4 text-[#4BA0FA]" />
|
||||
<Check className="size-4 text-[#4BA0FA]" />
|
||||
<span>Unlimited memories</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Check className="w-4 h-4 text-[#4BA0FA]" />
|
||||
<Check className="size-4 text-[#4BA0FA]" />
|
||||
<span>10 connections</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Check className="w-4 h-4 text-[#4BA0FA]" />
|
||||
<Check className="size-4 text-[#4BA0FA]" />
|
||||
<span>Advanced search</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Check className="w-4 h-4 text-[#4BA0FA]" />
|
||||
<Check className="size-4 text-[#4BA0FA]" />
|
||||
<span>Priority support</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -210,10 +210,10 @@ export function FileContent({
|
|||
multiple
|
||||
onChange={handleFileSelect}
|
||||
disabled={isSubmitting}
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer disabled:cursor-not-allowed"
|
||||
className="absolute inset-0 size-full opacity-0 cursor-pointer disabled:cursor-not-allowed"
|
||||
accept={FILE_ACCEPT}
|
||||
/>
|
||||
<div className="flex items-center justify-center w-12 h-12 rounded-full bg-[#0F1217]">
|
||||
<div className="flex items-center justify-center size-12 rounded-full bg-[#0F1217]">
|
||||
<FileIcon className="size-6 text-[#737373]" />
|
||||
</div>
|
||||
{hasItems ? (
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { useQueryState } from "nuqs"
|
|||
import { Dialog, DialogContent, DialogTitle } from "@repo/ui/components/dialog"
|
||||
import { cn } from "@lib/utils"
|
||||
import { dmSansClassName } from "@/lib/fonts"
|
||||
import { FileTextIcon, GlobeIcon, ZapIcon, Loader2 } from "lucide-react"
|
||||
import { FileTextIcon, GlobeIcon, ZapIcon, Loader2, XIcon } from "lucide-react"
|
||||
import { Button } from "@ui/components/button"
|
||||
import { ConnectContent } from "./connections"
|
||||
import { NoteContent } from "./note"
|
||||
|
|
@ -16,7 +16,7 @@ import { toast } from "sonner"
|
|||
import { useDocumentMutations } from "../../hooks/use-document-mutations"
|
||||
import { useCustomer } from "autumn-js/react"
|
||||
import { useTokenUsage } from "@/hooks/use-token-usage"
|
||||
import { tokensToCredits, formatUsageNumber } from "@/lib/billing-utils"
|
||||
import { formatUsageNumber } from "@/lib/billing-utils"
|
||||
import { SpaceSelector } from "../space-selector"
|
||||
import { useIsMobile } from "@hooks/use-mobile"
|
||||
import { addDocumentParam } from "@/lib/search-params"
|
||||
|
|
@ -35,10 +35,10 @@ export function AddDocumentModal({ isOpen, onClose }: AddDocumentModalProps) {
|
|||
<Dialog open={isOpen} onOpenChange={(open: boolean) => !open && onClose()}>
|
||||
<DialogContent
|
||||
className={cn(
|
||||
"border-none bg-[#1B1F24] flex flex-col p-3 md:p-4 gap-3",
|
||||
"border-none bg-[#1B1F24] flex flex-col",
|
||||
isMobile
|
||||
? "w-[calc(100vw-1rem)]! h-[calc(100dvh-1rem)]! max-w-none! max-h-none! rounded-xl"
|
||||
: "w-[80%]! max-w-[1000px]! h-[80%]! max-h-[800px]! rounded-[22px]",
|
||||
? "top-2! left-2! translate-x-0! translate-y-0! w-[calc(100vw-1rem)]! h-[calc(100dvh-1rem)]! max-w-none! max-h-none! rounded-[18px] p-0 gap-0 overflow-hidden"
|
||||
: "w-[80%]! max-w-[1000px]! h-[80%]! max-h-[800px]! rounded-[22px] p-4 gap-3",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
style={{
|
||||
|
|
@ -48,7 +48,7 @@ export function AddDocumentModal({ isOpen, onClose }: AddDocumentModalProps) {
|
|||
showCloseButton={false}
|
||||
>
|
||||
<DialogTitle className="sr-only">Add Document</DialogTitle>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
<AddDocument onClose={onClose} isOpen={isOpen} />
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
|
@ -127,11 +127,8 @@ export function AddDocument({
|
|||
const autumn = useCustomer()
|
||||
const {
|
||||
tokensUsed,
|
||||
tokensLimit,
|
||||
tokensPercent,
|
||||
searchesUsed,
|
||||
searchesLimit,
|
||||
searchesPercent,
|
||||
planUsagePct,
|
||||
hasPaidPlan,
|
||||
isLoading: isLoadingUsage,
|
||||
} = useTokenUsage(autumn)
|
||||
|
|
@ -259,19 +256,44 @@ export function AddDocument({
|
|||
activeTab === "file" && (!fileTabHasPending || isSubmitting)
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col md:flex-row text-white md:space-x-5 space-y-3 md:space-y-0">
|
||||
<div className="flex h-full min-h-0 flex-col overflow-hidden text-white md:flex-row md:space-x-5">
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col justify-between",
|
||||
isMobile ? "w-full" : "w-1/3",
|
||||
isMobile
|
||||
? "w-full shrink-0 border-b border-[#0F1621] bg-[#1B1F24] px-3 pt-3 pb-3"
|
||||
: "w-1/3",
|
||||
)}
|
||||
>
|
||||
{isMobile && (
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div>
|
||||
<p
|
||||
className={cn(
|
||||
"text-sm font-medium text-white",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
Add memory
|
||||
</p>
|
||||
<p className="text-xs text-[#737373]">
|
||||
Save something to recall later
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={isSubmitting}
|
||||
className="flex size-9 items-center justify-center rounded-full border border-[#1F2937] bg-[#0D121A] text-[#8B8B8B] transition-colors hover:text-white disabled:opacity-50"
|
||||
aria-label="Close add memory"
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"flex gap-1",
|
||||
isMobile
|
||||
? "flex-row overflow-x-auto pb-2 scrollbar-thin"
|
||||
: "flex-col",
|
||||
isMobile ? "grid grid-cols-4 gap-1" : "flex flex-col gap-1",
|
||||
)}
|
||||
>
|
||||
{tabs.map((tab) => (
|
||||
|
|
@ -288,6 +310,58 @@ export function AddDocument({
|
|||
))}
|
||||
</div>
|
||||
|
||||
{isMobile && (
|
||||
<div className="mt-3 flex flex-col gap-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span
|
||||
className={cn(
|
||||
"text-[#FAFAFA] text-sm font-medium",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
Plan usage
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm font-medium tabular-nums",
|
||||
hasPaidPlan ? "text-[#4BA0FA]" : "text-[#737373]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
{isLoadingUsage
|
||||
? "…"
|
||||
: `${planUsagePct < 1 && planUsagePct > 0 ? "< 1" : Math.round(planUsagePct)}% used`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 w-full rounded-[40px] bg-[#2E353D] p-px overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-[40px]"
|
||||
style={{
|
||||
width: `${planUsagePct}%`,
|
||||
background:
|
||||
planUsagePct > 80
|
||||
? "#ef4444"
|
||||
: hasPaidPlan
|
||||
? "linear-gradient(to right, #4BA0FA 80%, #002757 100%)"
|
||||
: "#0054AD",
|
||||
}}
|
||||
title={`${formatUsageNumber(tokensUsed)} tokens · ${formatUsageNumber(searchesUsed)} queries`}
|
||||
/>
|
||||
</div>
|
||||
{!isLoadingUsage && (
|
||||
<p
|
||||
className={cn(
|
||||
"text-xs text-[#737373] tabular-nums",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
{formatUsageNumber(tokensUsed)} tokens ·{" "}
|
||||
{formatUsageNumber(searchesUsed)} queries
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isMobile && (
|
||||
<div data-testid="usage-counter" className="flex flex-col gap-3 mr-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
|
|
@ -298,72 +372,46 @@ export function AddDocument({
|
|||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
Credits
|
||||
Plan usage
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm font-medium",
|
||||
"text-sm font-medium tabular-nums",
|
||||
hasPaidPlan ? "text-[#4BA0FA]" : "text-[#737373]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
{isLoadingUsage
|
||||
? "…"
|
||||
: `${tokensToCredits(tokensUsed)} / ${tokensToCredits(tokensLimit)}`}
|
||||
: `${planUsagePct < 1 && planUsagePct > 0 ? "< 1" : Math.round(planUsagePct)}% used`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 w-full rounded-[40px] bg-[#2E353D] p-px overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-[40px]"
|
||||
style={{
|
||||
width: `${tokensPercent}%`,
|
||||
width: `${planUsagePct}%`,
|
||||
background:
|
||||
tokensPercent > 80
|
||||
planUsagePct > 80
|
||||
? "#ef4444"
|
||||
: hasPaidPlan
|
||||
? "linear-gradient(to right, #4BA0FA 80%, #002757 100%)"
|
||||
: "#0054AD",
|
||||
}}
|
||||
title={`${formatUsageNumber(tokensUsed)} tokens · ${formatUsageNumber(searchesUsed)} queries`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span
|
||||
{!isLoadingUsage && (
|
||||
<p
|
||||
className={cn(
|
||||
"text-[#FAFAFA] text-sm font-medium",
|
||||
"text-xs text-[#737373] tabular-nums",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
Search Queries
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm font-medium",
|
||||
hasPaidPlan ? "text-[#4BA0FA]" : "text-[#737373]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
{isLoadingUsage
|
||||
? "…"
|
||||
: `${formatUsageNumber(searchesUsed)} / ${formatUsageNumber(searchesLimit)}`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 w-full rounded-[40px] bg-[#2E353D] p-px overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-[40px]"
|
||||
style={{
|
||||
width: `${searchesPercent}%`,
|
||||
background:
|
||||
searchesPercent > 80
|
||||
? "#ef4444"
|
||||
: hasPaidPlan
|
||||
? "linear-gradient(to right, #4BA0FA 80%, #002757 100%)"
|
||||
: "#0054AD",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{formatUsageNumber(tokensUsed)} tokens ·{" "}
|
||||
{formatUsageNumber(searchesUsed)} queries
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!hasPaidPlan && (
|
||||
|
|
@ -372,13 +420,19 @@ export function AddDocument({
|
|||
onClick={async () => {
|
||||
setIsUpgrading(true)
|
||||
try {
|
||||
await autumn.attach({
|
||||
productId: "api_pro",
|
||||
successUrl: "https://app.supermemory.ai/settings#account",
|
||||
const result = await autumn.attach({
|
||||
planId: "api_pro",
|
||||
successUrl: `${window.location.origin}/settings#account`,
|
||||
})
|
||||
window.location.reload()
|
||||
if (result?.paymentUrl) {
|
||||
window.open(result.paymentUrl, "_self")
|
||||
return
|
||||
}
|
||||
autumn.refetch?.()
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
toast.error("Failed to start checkout. Please try again.")
|
||||
} finally {
|
||||
setIsUpgrading(false)
|
||||
}
|
||||
}}
|
||||
|
|
@ -400,7 +454,7 @@ export function AddDocument({
|
|||
{isUpgrading ? (
|
||||
<>
|
||||
<Loader2 className="size-3 animate-spin mr-1.5" />
|
||||
Upgrading...
|
||||
Upgrading…
|
||||
</>
|
||||
) : (
|
||||
"Upgrade to Pro"
|
||||
|
|
@ -414,11 +468,11 @@ export function AddDocument({
|
|||
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col flex-1 min-h-0 px-1",
|
||||
isMobile ? "w-full" : "w-2/3",
|
||||
"flex min-h-0 flex-1 flex-col",
|
||||
isMobile ? "w-full px-3 pt-3" : "w-2/3 px-1",
|
||||
)}
|
||||
>
|
||||
<div className="overflow-auto flex-1 min-h-0 scrollbar-thin">
|
||||
<div className="min-h-0 flex-1 overflow-auto scrollbar-thin">
|
||||
{activeTab === "note" && (
|
||||
<NoteContent
|
||||
onSubmit={handleNoteSubmit}
|
||||
|
|
@ -452,8 +506,10 @@ export function AddDocument({
|
|||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex gap-2 pt-3 shrink-0",
|
||||
isMobile ? "flex-col" : "justify-between",
|
||||
"flex shrink-0 gap-2",
|
||||
isMobile
|
||||
? "mx-[-0.75rem] mt-3 border-t border-[#0F1621] bg-[#1B1F24] px-3 py-3 pb-[max(0.75rem,env(safe-area-inset-bottom))]"
|
||||
: "justify-between pt-3",
|
||||
)}
|
||||
>
|
||||
{!isMobile && (
|
||||
|
|
@ -467,13 +523,19 @@ export function AddDocument({
|
|||
/>
|
||||
)}
|
||||
<div
|
||||
className={cn("flex items-center gap-2", isMobile && "justify-end")}
|
||||
className={cn(
|
||||
"flex items-center gap-2",
|
||||
isMobile && "w-full justify-end",
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={onClose}
|
||||
disabled={isSubmitting}
|
||||
className="text-[#737373] cursor-pointer rounded-full"
|
||||
className={cn(
|
||||
"cursor-pointer rounded-full text-[#737373]",
|
||||
isMobile && "h-11 px-4",
|
||||
)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
|
|
@ -484,11 +546,12 @@ export function AddDocument({
|
|||
disabled={
|
||||
activeTab === "file" ? fileTabSubmitDisabled : isSubmitting
|
||||
}
|
||||
className={cn(isMobile && "h-11 min-w-[8rem] px-5")}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin mr-2" />
|
||||
Adding...
|
||||
Adding…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
|
|
@ -537,19 +600,24 @@ function TabButton({
|
|||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-3 py-2 rounded-full text-left transition-colors whitespace-nowrap focus:outline-none focus:ring-0 shrink-0",
|
||||
active ? "bg-[#14161A] shadow-inside-out" : "hover:bg-[#14161A]/50",
|
||||
"relative flex h-14 min-w-0 flex-col items-center justify-center gap-1 rounded-xl px-1 text-center transition-colors focus:outline-none focus:ring-0",
|
||||
active
|
||||
? "bg-[#0F141B] text-white shadow-inside-out ring-1 ring-[#2261CA33]"
|
||||
: "text-[#8B8B8B] hover:bg-[#14161A]/50",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
<Icon className={cn("size-4 shrink-0 text-white")} />
|
||||
<Icon className="size-3.5 shrink-0" />
|
||||
<span
|
||||
className={cn("font-medium text-white text-sm", dmSansClassName())}
|
||||
className={cn(
|
||||
"min-w-0 truncate text-xs font-medium leading-none",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
{title.split(" ")[0]}
|
||||
</span>
|
||||
{isPro && (
|
||||
<span className="bg-[#4BA0FA] text-black text-[8px] font-semibold px-1 py-0.5 rounded">
|
||||
<span className="absolute top-1 right-1 rounded bg-[#4BA0FA] px-1 py-0.5 text-[7px] font-semibold leading-none text-black">
|
||||
PRO
|
||||
</span>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -173,7 +173,7 @@ export function LinkContent({
|
|||
{isPreviewLoading ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin mr-2" />
|
||||
Loading...
|
||||
Loading…
|
||||
</>
|
||||
) : (
|
||||
"Preview Link"
|
||||
|
|
@ -216,7 +216,7 @@ export function LinkContent({
|
|||
<img
|
||||
src={image}
|
||||
alt={title || "Link preview"}
|
||||
className="w-full h-full object-cover"
|
||||
className="size-full object-cover"
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = "none"
|
||||
e.currentTarget.parentElement?.classList.add("opacity-50")
|
||||
|
|
@ -228,7 +228,7 @@ export function LinkContent({
|
|||
</div>
|
||||
) : (
|
||||
<div className="w-full max-w-md aspect-4/2 bg-[#0F1217] opacity-50 rounded-xl flex items-center justify-center">
|
||||
<ImageIcon className="w-8 h-8 text-[#737373]" />
|
||||
<ImageIcon className="size-8 text-[#737373]" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -40,11 +40,12 @@ export function NoteContent({
|
|||
}, [isOpen, onContentChange])
|
||||
|
||||
return (
|
||||
<div className="p-4 overflow-y-auto flex-1 w-full h-full mb-4! bg-[#14161A] shadow-inside-out rounded-[14px]">
|
||||
<div className="flex h-full min-h-[45dvh] w-full flex-1 overflow-y-auto rounded-[14px] bg-[#10151C] p-3 shadow-inside-out ring-1 ring-[#202A36] md:mb-4! md:bg-[#14161A] md:p-4 md:ring-0">
|
||||
<TextEditor
|
||||
content={undefined}
|
||||
onContentChange={handleContentChange}
|
||||
onSubmit={handleSubmit}
|
||||
debounceMs={0}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -150,7 +150,7 @@ export function AddSpaceModal({
|
|||
</p>
|
||||
</div>
|
||||
<DialogPrimitive.Close
|
||||
className="bg-[#0D121A] w-7 h-7 flex items-center justify-center focus:ring-ring rounded-full 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 border border-[rgba(115,115,115,0.2)] shrink-0"
|
||||
className="bg-[#0D121A] size-7 flex items-center justify-center focus:ring-ring rounded-full 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 border border-[rgba(115,115,115,0.2)] shrink-0"
|
||||
style={{
|
||||
boxShadow: "inset 1.313px 1.313px 3.938px 0px rgba(0,0,0,0.7)",
|
||||
}}
|
||||
|
|
@ -243,7 +243,7 @@ export function AddSpaceModal({
|
|||
{createProjectMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin mr-2" />
|
||||
Creating...
|
||||
Creating…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -69,12 +69,12 @@ function ChatEmptyStatePlaceholder({
|
|||
id="chat-empty-state"
|
||||
className="flex flex-col items-center justify-center h-full"
|
||||
>
|
||||
<div className="relative w-32 h-32">
|
||||
<GradientLogo className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-16 h-16" />
|
||||
<LogoBgGradient className="w-full h-full" />
|
||||
<div className="relative size-32">
|
||||
<GradientLogo className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 size-16" />
|
||||
<LogoBgGradient className="size-full" />
|
||||
</div>
|
||||
<div className="gap-3 flex flex-col items-center justify-center">
|
||||
<p>Ask me anything about your memories...</p>
|
||||
<p>Ask me anything about your memories…</p>
|
||||
<div
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
|
|
@ -187,6 +187,7 @@ export function ChatSidebar({
|
|||
null,
|
||||
)
|
||||
const messagesContainerRef = useRef<HTMLDivElement>(null)
|
||||
const isScrolledToBottomRef = useRef(true)
|
||||
const sentQueuedMessageRef = useRef<string | null>(null)
|
||||
const { selectedProject } = useProject()
|
||||
const { allProjects } = useContainerTags()
|
||||
|
|
@ -296,6 +297,7 @@ export function ChatSidebar({
|
|||
const { scrollTop, scrollHeight, clientHeight } = container
|
||||
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
|
||||
const isAtBottom = distanceFromBottom <= 20
|
||||
isScrolledToBottomRef.current = isAtBottom
|
||||
setIsScrolledToBottom(isAtBottom)
|
||||
}, [])
|
||||
|
||||
|
|
@ -303,6 +305,7 @@ export function ChatSidebar({
|
|||
if (messagesContainerRef.current) {
|
||||
messagesContainerRef.current.scrollTop =
|
||||
messagesContainerRef.current.scrollHeight
|
||||
isScrolledToBottomRef.current = true
|
||||
setIsScrolledToBottom(true)
|
||||
}
|
||||
}, [])
|
||||
|
|
@ -399,6 +402,12 @@ export function ChatSidebar({
|
|||
}
|
||||
}, [selectedProject])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isHistoryOpen) return
|
||||
fetchThreads()
|
||||
analytics.chatHistoryViewed?.()
|
||||
}, [isHistoryOpen, fetchThreads])
|
||||
|
||||
const loadThread = useCallback(
|
||||
async (id: string) => {
|
||||
try {
|
||||
|
|
@ -441,12 +450,14 @@ export function ChatSidebar({
|
|||
|
||||
// Auto-restore thread from URL on mount (e.g. reload or direct link)
|
||||
const didAutoLoadRef = useRef(false)
|
||||
const initialThreadIdRef = useRef(threadId)
|
||||
useEffect(() => {
|
||||
if (didAutoLoadRef.current) return
|
||||
if (!threadId) return
|
||||
const initialThreadId = initialThreadIdRef.current
|
||||
if (!initialThreadId) return
|
||||
didAutoLoadRef.current = true
|
||||
loadThread(threadId)
|
||||
}, [threadId, loadThread])
|
||||
loadThread(initialThreadId)
|
||||
}, [loadThread])
|
||||
|
||||
const deleteThread = useCallback(
|
||||
async (threadId: string) => {
|
||||
|
|
@ -553,6 +564,46 @@ export function ChatSidebar({
|
|||
checkIfScrolledToBottom()
|
||||
}, [messages, checkIfScrolledToBottom])
|
||||
|
||||
useEffect(() => {
|
||||
const isStreaming = status === "streaming"
|
||||
const lastMessage = messages[messages.length - 1]
|
||||
const isLastMessageFromAssistant = lastMessage?.role === "assistant"
|
||||
|
||||
if (
|
||||
isStreaming &&
|
||||
isLastMessageFromAssistant &&
|
||||
isScrolledToBottomRef.current
|
||||
) {
|
||||
scrollToBottom()
|
||||
}
|
||||
}, [status, messages, scrollToBottom])
|
||||
|
||||
useEffect(() => {
|
||||
const container = messagesContainerRef.current
|
||||
if (!container) return
|
||||
|
||||
const isStreaming = status === "streaming"
|
||||
if (!isStreaming) return
|
||||
|
||||
const mutationObserver = new MutationObserver(() => {
|
||||
if (isScrolledToBottomRef.current) {
|
||||
requestAnimationFrame(() => {
|
||||
scrollToBottom()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
mutationObserver.observe(container, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
characterData: true,
|
||||
})
|
||||
|
||||
return () => {
|
||||
mutationObserver.disconnect()
|
||||
}
|
||||
}, [status, scrollToBottom])
|
||||
|
||||
// Add scroll event listener to track scroll position
|
||||
useEffect(() => {
|
||||
const container = messagesContainerRef.current
|
||||
|
|
@ -596,10 +647,7 @@ export function ChatSidebar({
|
|||
open={isHistoryOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsHistoryOpen(open)
|
||||
if (open) {
|
||||
fetchThreads()
|
||||
analytics.chatHistoryViewed?.()
|
||||
} else {
|
||||
if (!open) {
|
||||
setConfirmingDeleteId(null)
|
||||
}
|
||||
}}
|
||||
|
|
@ -622,7 +670,7 @@ export function ChatSidebar({
|
|||
<div className="py-4">
|
||||
{isLoadingThreads ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<SuperLoader label="Loading..." />
|
||||
<SuperLoader label="Loading…" />
|
||||
</div>
|
||||
) : threads.length === 0 ? (
|
||||
<div className="py-8 text-center text-sm text-[#737373]">
|
||||
|
|
@ -659,7 +707,7 @@ export function ChatSidebar({
|
|||
e.stopPropagation()
|
||||
deleteThread(thread.id)
|
||||
}}
|
||||
className="h-7 w-7 bg-red-500 text-white hover:bg-red-600"
|
||||
className="size-7 bg-red-500 text-white hover:bg-red-600"
|
||||
>
|
||||
<Check className="size-3" />
|
||||
</Button>
|
||||
|
|
@ -671,7 +719,7 @@ export function ChatSidebar({
|
|||
e.stopPropagation()
|
||||
setConfirmingDeleteId(null)
|
||||
}}
|
||||
className="h-7 w-7"
|
||||
className="size-7"
|
||||
>
|
||||
<XIcon className="size-3 text-[#737373]" />
|
||||
</Button>
|
||||
|
|
@ -685,7 +733,7 @@ export function ChatSidebar({
|
|||
e.stopPropagation()
|
||||
setConfirmingDeleteId(thread.id)
|
||||
}}
|
||||
className="ml-2 h-7 w-7"
|
||||
className="ml-2 size-7"
|
||||
>
|
||||
<Trash2 className="size-3 text-[#737373]" />
|
||||
</Button>
|
||||
|
|
@ -883,7 +931,7 @@ export function ChatSidebar({
|
|||
))}
|
||||
{(status === "submitted" || status === "streaming") && (
|
||||
<div className="flex gap-2">
|
||||
<SuperLoader label="Thinking..." />
|
||||
<SuperLoader label="Thinking…" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -965,10 +1013,10 @@ export function ChatSidebar({
|
|||
isResponding={status === "submitted" || status === "streaming"}
|
||||
activeStatus={
|
||||
status === "submitted"
|
||||
? "Thinking..."
|
||||
? "Thinking…"
|
||||
: status === "streaming"
|
||||
? "Structuring response..."
|
||||
: "Waiting for input..."
|
||||
? "Structuring response…"
|
||||
: "Waiting for input…"
|
||||
}
|
||||
onExpandedChange={setIsInputExpanded}
|
||||
chainOfThoughtComponent={
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ export function SendButton({
|
|||
>
|
||||
<title>Send Icon</title>
|
||||
<path
|
||||
d="M12 6L10.55 7.4L7 3.85L7 16L5 16L5 3.85L1.45 7.4L-4.37114e-07 6L6 -2.62268e-07L12 6Z"
|
||||
d="M12 6L10.55 7.4L7 3.85L7 16L5 16L5 3.85L1.45 7.4L-4.37e-07 6L6 -2.62e-07L12 6Z"
|
||||
fill="#FAFAFA"
|
||||
/>
|
||||
</svg>
|
||||
|
|
|
|||
|
|
@ -165,11 +165,9 @@ function BashToolDisplay({ part }: { part: ToolCallDisplayPart }) {
|
|||
: "text-white/70",
|
||||
)}
|
||||
>
|
||||
{cmd ?? "..."}
|
||||
{cmd ?? "…"}
|
||||
</span>
|
||||
{isLoading && (
|
||||
<span className="text-white/30 shrink-0">running...</span>
|
||||
)}
|
||||
{isLoading && <span className="text-white/30 shrink-0">running…</span>}
|
||||
{isDone && !hasOutput && (
|
||||
<span className="text-white/30 shrink-0">done</span>
|
||||
)}
|
||||
|
|
@ -261,7 +259,7 @@ function ToolCallDisplay({ part }: { part: ToolCallDisplayPart }) {
|
|||
>
|
||||
{label}
|
||||
</span>
|
||||
{isLoading && <span className="text-white/40 ml-auto">running...</span>}
|
||||
{isLoading && <span className="text-white/40 ml-auto">running…</span>}
|
||||
{isDone && <span className="text-white/40 ml-auto">done</span>}
|
||||
{isError && <span className="text-red-400/60 ml-auto">error</span>}
|
||||
{expanded ? (
|
||||
|
|
|
|||
|
|
@ -351,7 +351,7 @@ export function ConnectAIModal({
|
|||
{/* Step 1: Client Selection */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full flex items-center justify-center text-sm font-semibold bg-accent text-accent-foreground">
|
||||
<div className="size-8 rounded-full flex items-center justify-center text-sm font-semibold bg-accent text-accent-foreground">
|
||||
1
|
||||
</div>
|
||||
<h3 className="text-sm font-medium">Select Your AI Client</h3>
|
||||
|
|
@ -373,7 +373,7 @@ export function ConnectAIModal({
|
|||
type="button"
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-8 h-8 flex items-center justify-center">
|
||||
<div className="size-8 flex items-center justify-center">
|
||||
<Image
|
||||
alt={clientName}
|
||||
className="rounded object-contain"
|
||||
|
|
@ -499,7 +499,7 @@ export function ConnectAIModal({
|
|||
href={getCursorDeeplink()}
|
||||
onClick={() => {
|
||||
analytics.mcpInstallCmdCopied()
|
||||
toast.success("Opening Cursor installer...")
|
||||
toast.success("Opening Cursor installer…")
|
||||
}}
|
||||
>
|
||||
<img
|
||||
|
|
@ -673,7 +673,7 @@ export function ConnectAIModal({
|
|||
</p>
|
||||
{createMcpApiKeyMutation.isPending ? (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-primary" />
|
||||
<Loader2 className="size-6 animate-spin text-primary" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
|
|
@ -684,7 +684,7 @@ export function ConnectAIModal({
|
|||
</code>
|
||||
</pre>
|
||||
<Button
|
||||
className="absolute top-2 right-2 h-8 w-8 cursor-pointer p-0 bg-muted/80 hover:bg-muted"
|
||||
className="absolute top-2 right-2 size-8 cursor-pointer p-0 bg-muted/80 hover:bg-muted"
|
||||
onClick={() =>
|
||||
copyManualSnippet(remoteSnippet)
|
||||
}
|
||||
|
|
@ -722,7 +722,7 @@ export function ConnectAIModal({
|
|||
</code>
|
||||
</pre>
|
||||
<Button
|
||||
className="absolute top-2 right-2 h-8 w-8 cursor-pointer p-0 bg-muted/80 hover:bg-muted"
|
||||
className="absolute top-2 right-2 size-8 cursor-pointer p-0 bg-muted/80 hover:bg-muted"
|
||||
onClick={() => copyManualSnippet(manual.snippet)}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
|
|
@ -788,21 +788,21 @@ export function ConnectAIModal({
|
|||
<div className="flex items-center gap-2 text-xs">
|
||||
{isCheckingConnection ? (
|
||||
<>
|
||||
<Loader2 className="w-3 h-3 animate-spin text-muted-foreground" />
|
||||
<span className="text-muted-foreground">Checking...</span>
|
||||
<Loader2 className="size-3 animate-spin text-muted-foreground" />
|
||||
<span className="text-muted-foreground">Checking…</span>
|
||||
</>
|
||||
) : connectionStatus?.previousLogin ? (
|
||||
<>
|
||||
<div className="w-2 h-2 rounded-full bg-green-500" />
|
||||
<div className="size-2 rounded-full bg-green-500" />
|
||||
<span className="text-green-600 font-medium">
|
||||
Connected
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="w-2 h-2 rounded-full bg-yellow-500" />
|
||||
<div className="size-2 rounded-full bg-yellow-500" />
|
||||
<span className="text-yellow-600 font-medium">
|
||||
Waiting for connection...
|
||||
Waiting for connection…
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
|
|
@ -828,7 +828,7 @@ export function ConnectAIModal({
|
|||
}
|
||||
variant="outline"
|
||||
>
|
||||
<ExternalLink className="w-2 h-2 mr-2" />
|
||||
<ExternalLink className="size-2 mr-2" />
|
||||
Learn More
|
||||
</Button>
|
||||
|
||||
|
|
@ -919,8 +919,8 @@ export function ConnectAIModal({
|
|||
>
|
||||
{migrateMCPMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
Migrating...
|
||||
<Loader2 className="size-4 animate-spin mr-2" />
|
||||
Migrating…
|
||||
</>
|
||||
) : (
|
||||
"Migrate"
|
||||
|
|
|
|||
|
|
@ -1055,7 +1055,7 @@ function MemoryOfDayCard({ data }: { data: MemoryOfDay }) {
|
|||
type="button"
|
||||
onClick={() => router.push(href)}
|
||||
className={cn(
|
||||
"group w-full h-full text-left bg-surface-card/60 backdrop-blur-md rounded-[18px] p-3 flex flex-col justify-between transition-colors cursor-pointer shadow-[0_12px_40px_rgba(0,0,0,0.22)]",
|
||||
"group size-full text-left bg-surface-card/60 backdrop-blur-md rounded-[18px] p-3 flex flex-col justify-between transition-colors cursor-pointer shadow-[0_12px_40px_rgba(0,0,0,0.22)]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
|
|
@ -1355,7 +1355,7 @@ export function DashboardView({
|
|||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"min-h-0 flex-1 overflow-y-auto p-4 pt-2! pb-32 md:p-6 md:pb-36 md:pr-0",
|
||||
"min-h-0 flex-1 overflow-y-auto p-4 pt-2! pb-20 md:p-6 md:pb-36 md:pr-0",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
|
|
@ -1531,7 +1531,7 @@ export function DashboardView({
|
|||
onClick={() => onOpenDocument(doc)}
|
||||
className="group flex w-full items-center gap-3 rounded-lg px-2.5 py-2 text-left transition-colors hover:bg-surface-hover"
|
||||
>
|
||||
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-surface-card ring-1 ring-surface-border group-hover:bg-[#182333] transition-colors">
|
||||
<div className="flex size-6 shrink-0 items-center justify-center rounded-md bg-surface-card ring-1 ring-surface-border group-hover:bg-[#182333] transition-colors">
|
||||
{isLink ? (
|
||||
<ExternalLink className="size-3 text-fg-subtle" />
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client"
|
||||
|
||||
import { memo, useState } from "react"
|
||||
import { memo, useCallback, useState } from "react"
|
||||
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
|
||||
import type { z } from "zod"
|
||||
import { dmSansClassName } from "@/lib/fonts"
|
||||
|
|
@ -49,6 +49,7 @@ export const FilePreview = memo(function FilePreview({
|
|||
document: DocumentWithMemories
|
||||
}) {
|
||||
const [imageError, setImageError] = useState(false)
|
||||
const [retryKey, setRetryKey] = useState(0)
|
||||
const { extension, color } = getFileTypeInfo(document)
|
||||
|
||||
const type = document.type?.toLowerCase()
|
||||
|
|
@ -58,6 +59,17 @@ export const FilePreview = memo(function FilePreview({
|
|||
document.url &&
|
||||
!imageError
|
||||
|
||||
// On first failure, wait briefly then force a re-render with a new key to
|
||||
// retry the fetch (covers transient R2 timing issues).
|
||||
// On second failure, give up and show the fallback file icon view.
|
||||
const handleImageError = useCallback(() => {
|
||||
if (retryKey === 0) {
|
||||
setTimeout(() => setRetryKey(1), 500)
|
||||
return
|
||||
}
|
||||
setImageError(true)
|
||||
}, [retryKey])
|
||||
|
||||
return (
|
||||
<div className="bg-[#0B1017] rounded-[18px] gap-3 relative overflow-hidden">
|
||||
{color && (
|
||||
|
|
@ -80,10 +92,11 @@ export const FilePreview = memo(function FilePreview({
|
|||
/>
|
||||
<div className="absolute inset-0 bg-black/20" />
|
||||
<img
|
||||
key={retryKey}
|
||||
src={document.url}
|
||||
alt={document.title || "Image preview"}
|
||||
className="relative max-w-full max-h-full w-auto h-auto object-contain z-10"
|
||||
onError={() => setImageError(true)}
|
||||
className="relative max-w-full max-h-full size-auto object-contain z-10"
|
||||
onError={handleImageError}
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -93,7 +106,7 @@ export const FilePreview = memo(function FilePreview({
|
|||
<DocumentIcon
|
||||
type={document.type}
|
||||
url={document.url}
|
||||
className="w-4 h-4"
|
||||
className="size-4"
|
||||
/>
|
||||
<p
|
||||
className={cn(dmSansClassName(), "text-[11px] font-semibold")}
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ export function GoogleDocsPreview({
|
|||
<DocumentIcon
|
||||
type={document.type}
|
||||
url={document.url}
|
||||
className="w-4 h-4"
|
||||
className="size-4"
|
||||
/>
|
||||
<p className={cn(dmSansClassName(), "text-[13px] font-semibold")}>
|
||||
{label}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ export function NotePreview({ document }: { document: DocumentWithMemories }) {
|
|||
return (
|
||||
<div className="bg-[#0B1017] p-3 rounded-[18px] space-y-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<DocumentIcon type="note" className="w-4 h-4" />
|
||||
<DocumentIcon type="note" className="size-4" />
|
||||
<p className={cn(dmSansClassName(), "text-[13px] font-semibold")}>
|
||||
Note
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -73,9 +73,9 @@ export function NotionPreview({
|
|||
</div>
|
||||
{/* Decorative dots mimicking Notion's block handles */}
|
||||
<div className="flex gap-[3px]">
|
||||
<div className="w-[3px] h-[3px] rounded-full bg-[#333]" />
|
||||
<div className="w-[3px] h-[3px] rounded-full bg-[#333]" />
|
||||
<div className="w-[3px] h-[3px] rounded-full bg-[#333]" />
|
||||
<div className="size-[3px] rounded-full bg-[#333]" />
|
||||
<div className="size-[3px] rounded-full bg-[#333]" />
|
||||
<div className="size-[3px] rounded-full bg-[#333]" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -118,7 +118,7 @@ export function NotionPreview({
|
|||
<div key={i} className="flex items-start gap-1.5">
|
||||
<div
|
||||
className={cn(
|
||||
"mt-[2px] w-[10px] h-[10px] rounded-[2px] border shrink-0",
|
||||
"mt-[2px] size-[10px] rounded-[2px] border shrink-0",
|
||||
block.checked
|
||||
? "bg-[#2F81F7] border-[#2F81F7]"
|
||||
: "border-[#3B3B3B] bg-transparent",
|
||||
|
|
@ -127,7 +127,7 @@ export function NotionPreview({
|
|||
{block.checked && (
|
||||
<svg
|
||||
viewBox="0 0 10 10"
|
||||
className="w-full h-full text-white"
|
||||
className="size-full text-white"
|
||||
aria-label="Checked"
|
||||
role="img"
|
||||
>
|
||||
|
|
@ -159,7 +159,7 @@ export function NotionPreview({
|
|||
if (block.type === "bullet") {
|
||||
return (
|
||||
<div key={i} className="flex items-start gap-1.5">
|
||||
<div className="mt-[5px] w-[4px] h-[4px] rounded-full bg-[#555] shrink-0" />
|
||||
<div className="mt-[5px] size-[4px] rounded-full bg-[#555] shrink-0" />
|
||||
<p className="text-[11px] text-[#737373] line-clamp-1 leading-[140%]">
|
||||
{block.text}
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -105,7 +105,7 @@ function CustomTweetMedia({
|
|||
<img
|
||||
src={previewUrl}
|
||||
alt="Tweet media"
|
||||
className="w-full h-full object-cover"
|
||||
className="size-full object-cover"
|
||||
/>
|
||||
{isVideo && (
|
||||
<div className="absolute inset-0 bg-[rgba(4,5,5,0.8)] flex items-center justify-center">
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ export const WebsitePreview = memo(function WebsitePreview({
|
|||
<img
|
||||
src={displayOgImage}
|
||||
alt={document.title || "Website preview"}
|
||||
className="w-full h-full object-cover"
|
||||
className="size-full object-cover"
|
||||
onError={() => setImageError(true)}
|
||||
loading="lazy"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ export const YoutubePreview = memo(function YoutubePreview({
|
|||
<iframe
|
||||
src={embedUrl}
|
||||
title={document.title || "YouTube video"}
|
||||
className="w-full h-full"
|
||||
className="size-full"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowFullScreen
|
||||
loading="lazy"
|
||||
|
|
|
|||
|
|
@ -28,8 +28,8 @@ function MCPIcon({ className }: { className?: string }) {
|
|||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<title>ModelContextProtocol</title>
|
||||
<path d="M15.688 2.343a2.588 2.588 0 00-3.61 0l-9.626 9.44a.863.863 0 01-1.203 0 .823.823 0 010-1.18l9.626-9.44a4.313 4.313 0 016.016 0 4.116 4.116 0 011.204 3.54 4.3 4.3 0 013.609 1.18l.05.05a4.115 4.115 0 010 5.9l-8.706 8.537a.274.274 0 000 .393l1.788 1.754a.823.823 0 010 1.18.863.863 0 01-1.203 0l-1.788-1.753a1.92 1.92 0 010-2.754l8.706-8.538a2.47 2.47 0 000-3.54l-.05-.049a2.588 2.588 0 00-3.607-.003l-7.172 7.034-.002.002-.098.097a.863.863 0 01-1.204 0 .823.823 0 010-1.18l7.273-7.133a2.47 2.47 0 00-.003-3.537z" />
|
||||
<path d="M14.485 4.703a.823.823 0 000-1.18.863.863 0 00-1.204 0l-7.119 6.982a4.115 4.115 0 000 5.9 4.314 4.314 0 006.016 0l7.12-6.982a.823.823 0 000-1.18.863.863 0 00-1.204 0l-7.119 6.982a2.588 2.588 0 01-3.61 0 2.47 2.47 0 010-3.54l7.12-6.982z" />
|
||||
<path d="M15.69 2.34a2.59 2.59 0 00-3.61 0l-9.63 9.44a.863.86 0 01-1.2 0 .823.82 0 010-1.18l9.63-9.44a4.31 4.31 0 16.02 0 4.12 4.12 0 11.2 3.54 4.3 4.3 0 13.61 1.18l.05.05a4.12 4.12 0 010 5.9l-8.71 8.54a.274.27 0 000 .393l1.79 1.75a.823.82 0 010 1.18.86.863 0 01-1.2 0l-1.79-1.75a1.92 1.92 0 010-2.75l8.71-8.54a2.47 2.47 0 000-3.54l-.05-.049a2.59 2.59 0 00-3.61-.003l-7.17 7.03-.2-.98.1a.863.86 0 01-1.2 0 .823.82 0 010-1.18l7.27-7.13a2.47 2.47 0 00-.003-3.54z" />
|
||||
<path d="M14.48 4.7a.823.82 0 000-1.18.86.863 0 00-1.2 0l-7.12 6.98a4.12 4.12 0 000 5.9 4.31 4.31 0 6.02 0l7.12-6.98a.823.82 0 000-1.18.86.863 0 00-1.2 0l-7.12 6.98a2.59 2.59 0 01-3.61 0 2.47 2.47 0 010-3.54l7.12-6.98z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
|
@ -101,7 +101,7 @@ function YouTubeIcon({ className }: { className?: string }) {
|
|||
>
|
||||
<title>YouTube</title>
|
||||
<path
|
||||
d="M8 10L13.19 7L8 4V10ZM19.56 2.17C19.69 2.64 19.78 3.27 19.84 4.07C19.91 4.87 19.94 5.56 19.94 6.16L20 7C20 9.19 19.84 10.8 19.56 11.83C19.31 12.73 18.73 13.31 17.83 13.56C17.36 13.69 16.5 13.78 15.18 13.84C13.88 13.91 12.69 13.94 11.59 13.94L10 14C5.81 14 3.2 13.84 2.17 13.56C1.27 13.31 0.69 12.73 0.44 11.83C0.31 11.36 0.22 10.73 0.16 9.93C0.0900001 9.13 0.0599999 8.44 0.0599999 7.84L0 7C0 4.81 0.16 3.2 0.44 2.17C0.69 1.27 1.27 0.69 2.17 0.44C2.64 0.31 3.5 0.22 4.82 0.16C6.12 0.0899998 7.31 0.0599999 8.41 0.0599999L10 0C14.19 0 16.8 0.16 17.83 0.44C18.73 0.69 19.31 1.27 19.56 2.17Z"
|
||||
d="M8 10L13.19 7L8 4V10ZM19.56 2.17C19.69 2.64 19.78 3.27 19.84 4.07C19.91 4.87 19.94 5.56 19.94 6.16L20 7C20 9.19 19.84 10.8 19.56 11.83C19.31 12.73 18.73 13.31 17.83 13.56C17.36 13.69 16.5 13.78 15.18 13.84C13.88 13.91 12.69 13.94 11.59 13.94L10 14C5.81 14 3.2 13.84 2.17 13.56C1.27 13.31 0.69 12.73 0.44 11.83C0.31 11.36 0.22 10.73 0.16 9.93C0.09 9.13 0.06 8.44 0.06 7.84L0 7C0 4.81 0.16 3.2 0.44 2.17C0.69 1.27 1.27 0.69 2.17 0.44C2.64 0.31 3.5 0.22 4.82 0.16C6.12 0.09 7.31 0.06 8.41 0.06L10 0C14.19 0 16.8 0.16 17.83 0.44C18.73 0.69 19.31 1.27 19.56 2.17Z"
|
||||
fill="#FF0000"
|
||||
/>
|
||||
<path d="M8 10L13.19 7L8 4V10Z" fill="white" />
|
||||
|
|
@ -119,7 +119,7 @@ function TextDocumentIcon({ className }: { className?: string }) {
|
|||
>
|
||||
<title>Text Document</title>
|
||||
<path
|
||||
d="M3.33333 8.33333H9.16667L10.8333 6.66667H3.33333V8.33333ZM3.33333 5H8.33333V3.33333H3.33333V5ZM1.66667 1.66667V10H7.5L5.83333 11.6667H0V0H16.6667V2.5H15V1.66667H1.66667ZM17.4167 6.08333C17.4861 6.15278 17.5208 6.22917 17.5208 6.3125C17.5208 6.39583 17.4861 6.47222 17.4167 6.54167L16.6667 7.29167L15.2083 5.83333L15.9583 5.08333C16.0278 5.01389 16.1042 4.97917 16.1875 4.97917C16.2708 4.97917 16.3472 5.01389 16.4167 5.08333L17.4167 6.08333ZM9.16667 13.3333V11.875L14.7083 6.33333L16.1667 7.79167L10.625 13.3333H9.16667Z"
|
||||
d="M3.33 8.33H9.17L10.83 6.67H3.33V8.33ZM3.33 5H8.33V3.33H3.33V5ZM1.67 1.67V10H7.5L5.83 11.67H0V0H16.67V2.5H15V1.67H1.67ZM17.42 6.08C17.49 6.15 17.52 6.23 17.52 6.31C17.52 6.4 17.49 6.47 17.42 6.54L16.67 7.29L15.21 5.83L15.96 5.08C16.03 5.01 16.1 4.98 16.19 4.98C16.27 4.98 16.35 5.01 16.42 5.08L17.42 6.08ZM9.17 13.33V11.88L14.71 6.33L16.17 7.79L10.63 13.33H9.17Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
|
|
@ -135,7 +135,7 @@ function XIcon({ className }: { className?: string }) {
|
|||
className={cn("text-white", className)}
|
||||
>
|
||||
<title>X (Twitter)</title>
|
||||
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-4.714-6.231-5.401 6.231H2.746l7.73-8.835L1.254 2.25H8.08l4.253 5.622 5.911-5.622zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
|
||||
<path d="M18.24 2.25h3.31l-7.23 8.26 8.5 11.24H16.17l-4.71-6.23-5.4 6.23H2.75l7.73-8.84L1.25 2.25H8.08l4.25 5.62 5.91-5.62zm-1.16 17.52h1.83L7.08 4.13H5.12z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
|
@ -153,7 +153,7 @@ export function DocumentIcon({
|
|||
url,
|
||||
className,
|
||||
}: DocumentIconProps) {
|
||||
const iconClassName = cn("w-4 h-4", className)
|
||||
const iconClassName = cn("size-4", className)
|
||||
|
||||
if (source === "mcp") {
|
||||
return <MCPIcon className={iconClassName} />
|
||||
|
|
|
|||
|
|
@ -33,11 +33,11 @@ export function GoogleDocViewer({ url, customId, type }: GoogleDocViewerProps) {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full w-full overflow-hidden">
|
||||
<div className="flex flex-col size-full overflow-hidden">
|
||||
{loading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-[#14161A] z-10">
|
||||
<div className="flex items-center gap-2 text-gray-400">
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
<Loader2 className="size-5 animate-spin" />
|
||||
<span>Loading {typeLabels[type]}...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -49,7 +49,7 @@ export function GoogleDocViewer({ url, customId, type }: GoogleDocViewerProps) {
|
|||
)}
|
||||
<iframe
|
||||
src={embedUrl}
|
||||
className="flex-1 w-full h-full border-0 rounded-[14px]"
|
||||
className="flex-1 size-full border-0 rounded-[14px]"
|
||||
onLoad={() => setLoading(false)}
|
||||
onError={() => {
|
||||
setLoading(false)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useCallback, useState } from "react"
|
||||
import { cn } from "@lib/utils"
|
||||
|
||||
interface ImagePreviewProps {
|
||||
|
|
@ -11,6 +11,19 @@ interface ImagePreviewProps {
|
|||
export function ImagePreview({ url, title }: ImagePreviewProps) {
|
||||
const [imageError, setImageError] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [retryKey, setRetryKey] = useState(0)
|
||||
|
||||
// On first failure, wait briefly then force a re-render with a new key to
|
||||
// retry the fetch (covers transient R2 timing issues).
|
||||
// On second failure, give up and show the error state.
|
||||
const handleImageError = useCallback(() => {
|
||||
if (retryKey === 0) {
|
||||
setTimeout(() => setRetryKey(1), 500)
|
||||
return
|
||||
}
|
||||
setImageError(true)
|
||||
setIsLoading(false)
|
||||
}, [retryKey])
|
||||
|
||||
if (imageError || !url) {
|
||||
return (
|
||||
|
|
@ -21,10 +34,10 @@ export function ImagePreview({ url, title }: ImagePreviewProps) {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="relative w-full h-full overflow-hidden flex items-center justify-center bg-[#0B1017]">
|
||||
<div className="relative size-full overflow-hidden flex items-center justify-center bg-[#0B1017]">
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 bg-cover bg-center animate-pulse">
|
||||
<div className="w-full h-full bg-[#1B1F24]" />
|
||||
<div className="size-full bg-[#1B1F24]" />
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
|
|
@ -38,16 +51,14 @@ export function ImagePreview({ url, title }: ImagePreviewProps) {
|
|||
/>
|
||||
<div className="absolute inset-0 bg-black/30" />
|
||||
<img
|
||||
key={retryKey}
|
||||
src={url}
|
||||
alt={title || "Image preview"}
|
||||
className={cn(
|
||||
"relative max-w-full max-h-full w-auto h-auto object-contain z-10",
|
||||
"relative max-w-full max-h-full size-auto object-contain z-10",
|
||||
isLoading && "opacity-0",
|
||||
)}
|
||||
onError={() => {
|
||||
setImageError(true)
|
||||
setIsLoading(false)
|
||||
}}
|
||||
onError={handleImageError}
|
||||
onLoad={() => setIsLoading(false)}
|
||||
loading="lazy"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ const PdfViewer = dynamic(
|
|||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="flex items-center justify-center h-full text-gray-400">
|
||||
Loading PDF viewer...
|
||||
Loading PDF viewer…
|
||||
</div>
|
||||
),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client"
|
||||
|
||||
import { Document, Page, pdfjs } from "react-pdf"
|
||||
import { useState } from "react"
|
||||
import { useCallback, useState } from "react"
|
||||
import "react-pdf/dist/Page/AnnotationLayer.css"
|
||||
import "react-pdf/dist/Page/TextLayer.css"
|
||||
|
||||
|
|
@ -19,6 +19,7 @@ export function PdfViewer({ url }: PdfViewerProps) {
|
|||
const [numPages, setNumPages] = useState<number | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [retryKey, setRetryKey] = useState(0)
|
||||
|
||||
if (!url) {
|
||||
return (
|
||||
|
|
@ -34,16 +35,30 @@ export function PdfViewer({ url }: PdfViewerProps) {
|
|||
setError(null)
|
||||
}
|
||||
|
||||
function onDocumentLoadError(error: Error) {
|
||||
setError(error.message || "Failed to load PDF")
|
||||
setLoading(false)
|
||||
}
|
||||
// On first failure, wait briefly then force a re-mount of the Document
|
||||
// component to retry (covers transient R2 timing issues).
|
||||
// On second failure, give up and show the error state.
|
||||
const onDocumentLoadError = useCallback(
|
||||
(err: Error) => {
|
||||
if (retryKey === 0) {
|
||||
setTimeout(() => {
|
||||
setRetryKey(1)
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
}, 500)
|
||||
return
|
||||
}
|
||||
setError(err.message || "Failed to load PDF")
|
||||
setLoading(false)
|
||||
},
|
||||
[retryKey],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full w-full overflow-hidden scrollbar-thin">
|
||||
<div className="flex flex-col size-full overflow-hidden scrollbar-thin">
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center h-full text-gray-400">
|
||||
Loading PDF...
|
||||
Loading PDF…
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
|
|
@ -53,6 +68,7 @@ export function PdfViewer({ url }: PdfViewerProps) {
|
|||
)}
|
||||
<div className="flex-1 overflow-auto w-full">
|
||||
<Document
|
||||
key={retryKey}
|
||||
file={
|
||||
url ||
|
||||
"https://corsproxy.io/?" +
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ export function TextEditorContent({
|
|||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin mr-1" />
|
||||
Saving...
|
||||
Saving…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ export function TweetContent({
|
|||
className="flex items-center gap-1.5 text-sm text-blue-400 hover:underline mt-4"
|
||||
>
|
||||
View on X
|
||||
<ExternalLinkIcon className="w-3.5 h-3.5" />
|
||||
<ExternalLinkIcon className="size-3.5" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -55,7 +55,7 @@ export function TweetContent({
|
|||
className="flex items-center gap-1.5 text-sm text-blue-400 hover:underline"
|
||||
>
|
||||
View on X
|
||||
<ExternalLinkIcon className="w-3.5 h-3.5" />
|
||||
<ExternalLinkIcon className="size-3.5" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ export function YoutubeVideo({ url }: YoutubeVideoProps) {
|
|||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full text-gray-400">
|
||||
Loading video...
|
||||
Loading video…
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -81,7 +81,7 @@ export function YoutubeVideo({ url }: YoutubeVideoProps) {
|
|||
title="YouTube video player"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
allowFullScreen
|
||||
className="w-full h-full rounded-lg shadow-lg"
|
||||
className="size-full rounded-lg shadow-lg"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -38,21 +38,21 @@ function VersionStatus({
|
|||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M5.00069 0L9.33082 2.5V7.5L5.00069 10L0.670563 7.5V2.5L5.00069 0ZM7.5008 2.5H5.0008H2.50069L2.5008 7.5H7.5008V5V2.5Z"
|
||||
d="M5 0L9.33 2.5V7.5L5 10L0.67 7.5V2.5L5 0ZM7.5 2.5H5H2.5L2.5 7.5H7.5V5V2.5Z"
|
||||
fill="#00FFA9"
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M5.0008 2.5H2.50069L2.5008 7.5H7.5008V5C6.12009 5 5.0008 3.88071 5.0008 2.5Z"
|
||||
d="M5 2.5H2.5L2.5 7.5H7.5V5C6.12 5 5 3.88 5 2.5Z"
|
||||
fill="#005236"
|
||||
/>
|
||||
<path
|
||||
d="M7.5008 2.5H5.0008C5.0008 3.88071 6.12009 5 7.5008 5V2.5Z"
|
||||
d="M7.5 2.5H5C5 3.88 6.12 5 7.5 5V2.5Z"
|
||||
fill="#00FFA9"
|
||||
/>
|
||||
<path
|
||||
d="M9.08072 2.64453V7.35449L5.00064 9.71094L0.920563 7.35449V2.64453L5.00064 0.288086L9.08072 2.64453ZM2.25064 2.25V7.75H7.75064V2.25H2.25064ZM4.76334 2.75C4.88226 4.0691 5.93157 5.11728 7.25064 5.23633V7.25H2.75064V2.75H4.76334ZM7.25064 2.75V4.73438C6.20794 4.61894 5.3806 3.79274 5.26529 2.75H7.25064Z"
|
||||
d="M9.08 2.64V7.35L5 9.71L0.92 7.35V2.64L5 0.29L9.08 2.64ZM2.25 2.25V7.75H7.75V2.25H2.25ZM4.76 2.75C4.88 4.07 5.93 5.12 7.25 5.24V7.25H2.75V2.75H4.76ZM7.25 2.75V4.73C6.21 4.62 5.38 3.79 5.27 2.75H7.25Z"
|
||||
stroke="#00FFA9"
|
||||
strokeWidth="0.5"
|
||||
/>
|
||||
|
|
@ -71,19 +71,11 @@ function VersionStatus({
|
|||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<title>Static</title>
|
||||
<circle cx="5.00061" cy="5.00073" r="1.25" fill="#369BFD" />
|
||||
<circle cx="5" cy="5" r="1.25" fill="#369BFD" />
|
||||
<circle
|
||||
cx="5.00061"
|
||||
cy="5.00073"
|
||||
r="0.833333"
|
||||
stroke="#369BFD"
|
||||
strokeOpacity="0.5"
|
||||
strokeWidth="0.833333"
|
||||
/>
|
||||
<circle
|
||||
cx="5.00057"
|
||||
cy="5.00081"
|
||||
r="2.91667"
|
||||
cx="5"
|
||||
cy="5"
|
||||
r="0.83"
|
||||
stroke="#369BFD"
|
||||
strokeOpacity="0.5"
|
||||
strokeWidth="0.833333"
|
||||
|
|
@ -91,7 +83,15 @@ function VersionStatus({
|
|||
<circle
|
||||
cx="5"
|
||||
cy="5"
|
||||
r="4.58333"
|
||||
r="2.92"
|
||||
stroke="#369BFD"
|
||||
strokeOpacity="0.5"
|
||||
strokeWidth="0.833333"
|
||||
/>
|
||||
<circle
|
||||
cx="5"
|
||||
cy="5"
|
||||
r="4.58"
|
||||
stroke="#369BFD"
|
||||
strokeOpacity="0.2"
|
||||
strokeWidth="0.833333"
|
||||
|
|
@ -114,17 +114,17 @@ function VersionStatus({
|
|||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M2.50066 2.5H7.50077V7.5H2.50077L2.50066 2.5Z"
|
||||
d="M2.5 2.5H7.5V7.5H2.5L2.5 2.5Z"
|
||||
fill="#4D2E00"
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M5.00066 0L9.33078 2.5V7.5L5.00066 10L0.670532 7.5V2.5L5.00066 0ZM7.50077 2.5H2.50066L2.50077 7.5H7.50077V2.5Z"
|
||||
d="M5 0L9.33 2.5V7.5L5 10L0.67 7.5V2.5L5 0ZM7.5 2.5H2.5L2.5 7.5H7.5V2.5Z"
|
||||
fill="#FE9900"
|
||||
/>
|
||||
<path
|
||||
d="M9.08069 2.64453V7.35449L5.00061 9.71094L0.920532 7.35449V2.64453L5.00061 0.288086L9.08069 2.64453ZM2.25061 2.25V7.75H7.75061V2.25H2.25061ZM7.25061 2.75V7.25H2.75061V2.75H7.25061Z"
|
||||
d="M9.08 2.64V7.35L5 9.71L0.92 7.35V2.64L5 0.29L9.08 2.64ZM2.25 2.25V7.75H7.75V2.25H2.25ZM7.25 2.75V7.25H2.75V2.75H7.25Z"
|
||||
stroke="#FE9900"
|
||||
strokeWidth="0.5"
|
||||
/>
|
||||
|
|
@ -145,13 +145,13 @@ function VersionStatus({
|
|||
<title>Forgotten</title>
|
||||
<path
|
||||
opacity="0.6"
|
||||
d="M9.08008 2.64453V7.35449L5 9.71094L0.919922 7.35449V2.64453L5 0.288086L9.08008 2.64453Z"
|
||||
d="M9.08 2.64V7.35L5 9.71L0.92 7.35V2.64L5 0.29L9.08 2.64Z"
|
||||
fill="#60272C"
|
||||
stroke="#FF6467"
|
||||
strokeWidth="0.5"
|
||||
/>
|
||||
<path
|
||||
d="M2.08333 2.08341L7.91677 7.91685M7.91667 2.08341L2.08333 7.91675"
|
||||
d="M2.08 2.08L7.92 7.92M7.92 2.08L2.08 7.92"
|
||||
stroke="#9C4044"
|
||||
strokeWidth="0.5"
|
||||
/>
|
||||
|
|
@ -226,7 +226,7 @@ export function GraphListMemories({
|
|||
</mask>
|
||||
<g mask="url(#mask0_891_45456)">
|
||||
<path
|
||||
d="M7.00057 12.8334C6.51446 12.8334 6.10126 12.6632 5.76099 12.3229C5.42071 11.9827 5.25057 11.5695 5.25057 11.0834C5.25057 11.0347 5.253 10.9813 5.25786 10.9229C5.26272 10.8646 5.27001 10.8111 5.27974 10.7625L4.06932 10.0771C3.91376 10.2132 3.73876 10.3177 3.54432 10.3906C3.34988 10.4636 3.14085 10.5 2.91724 10.5C2.43113 10.5 2.01793 10.3299 1.67765 9.9896C1.33738 9.64933 1.16724 9.23613 1.16724 8.75002C1.16724 8.26391 1.33738 7.85071 1.67765 7.51044C2.01793 7.17016 2.43113 7.00002 2.91724 7.00002C3.15057 7.00002 3.36932 7.04377 3.57349 7.13127C3.77765 7.21877 3.96238 7.3403 4.12765 7.49585L5.86307 6.62085C5.8339 6.39724 5.84606 6.17849 5.89953 5.9646C5.953 5.75071 6.04779 5.55141 6.1839 5.36669L5.68807 4.60835C5.62001 4.6278 5.54953 4.64238 5.47661 4.6521C5.40369 4.66183 5.32835 4.66669 5.25057 4.66669C4.76446 4.66669 4.35126 4.49655 4.01099 4.15627C3.67071 3.81599 3.50057 3.4028 3.50057 2.91669C3.50057 2.43058 3.67071 2.01738 4.01099 1.6771C4.35126 1.33683 4.76446 1.16669 5.25057 1.16669C5.73668 1.16669 6.14988 1.33683 6.49015 1.6771C6.83043 2.01738 7.00057 2.43058 7.00057 2.91669C7.00057 3.11113 6.96897 3.29828 6.90578 3.47815C6.84258 3.65801 6.75751 3.82085 6.65057 3.96669L7.16099 4.72502C7.23876 4.70558 7.31168 4.69099 7.37974 4.68127C7.44779 4.67155 7.52071 4.66669 7.59849 4.66669C7.76376 4.66669 7.91932 4.68613 8.06515 4.72502C8.21099 4.76391 8.35196 4.82224 8.48807 4.90002L9.45057 4.11252C9.41168 4.0153 9.38251 3.91565 9.36307 3.81356C9.34363 3.71148 9.3339 3.60696 9.3339 3.50002C9.3339 3.01391 9.50404 2.60071 9.84432 2.26044C10.1846 1.92016 10.5978 1.75002 11.0839 1.75002C11.57 1.75002 11.9832 1.92016 12.3235 2.26044C12.6638 2.60071 12.8339 3.01391 12.8339 3.50002C12.8339 3.98613 12.6638 4.39933 12.3235 4.7396C11.9832 5.07988 11.57 5.25002 11.0839 5.25002C10.9186 5.25002 10.7631 5.22815 10.6172 5.1844C10.4714 5.14065 10.3304 5.07988 10.1943 5.0021L9.23182 5.80419C9.27071 5.90141 9.29988 6.00106 9.31932 6.10315C9.33876 6.20523 9.34849 6.30974 9.34849 6.41669C9.34849 6.9028 9.17835 7.31599 8.83807 7.65627C8.49779 7.99655 8.0846 8.16669 7.59849 8.16669C7.36515 8.16669 7.14397 8.12294 6.93494 8.03544C6.72592 7.94794 6.53876 7.82641 6.37349 7.67085L4.65265 8.53127C4.6721 8.61877 4.67939 8.70627 4.67453 8.79377C4.66967 8.88127 4.65751 8.96877 4.63807 9.05627L5.86307 9.75627C6.01863 9.62016 6.19119 9.51565 6.38078 9.44273C6.57036 9.36981 6.77696 9.33335 7.00057 9.33335C7.48668 9.33335 7.89988 9.50349 8.24015 9.84377C8.58043 10.184 8.75057 10.5972 8.75057 11.0834C8.75057 11.5695 8.58043 11.9827 8.24015 12.3229C7.89988 12.6632 7.48668 12.8334 7.00057 12.8334ZM2.91724 9.33335C3.08251 9.33335 3.22106 9.27745 3.33286 9.16565C3.44467 9.05384 3.50057 8.9153 3.50057 8.75002C3.50057 8.58474 3.44467 8.4462 3.33286 8.3344C3.22106 8.22259 3.08251 8.16669 2.91724 8.16669C2.75196 8.16669 2.61342 8.22259 2.50161 8.3344C2.38981 8.4462 2.3339 8.58474 2.3339 8.75002C2.3339 8.9153 2.38981 9.05384 2.50161 9.16565C2.61342 9.27745 2.75196 9.33335 2.91724 9.33335ZM5.25057 3.50002C5.41585 3.50002 5.55439 3.44412 5.66619 3.33231C5.778 3.22051 5.8339 3.08196 5.8339 2.91669C5.8339 2.75141 5.778 2.61287 5.66619 2.50106C5.55439 2.38926 5.41585 2.33335 5.25057 2.33335C5.08529 2.33335 4.94675 2.38926 4.83494 2.50106C4.72314 2.61287 4.66724 2.75141 4.66724 2.91669C4.66724 3.08196 4.72314 3.22051 4.83494 3.33231C4.94675 3.44412 5.08529 3.50002 5.25057 3.50002ZM7.00057 11.6667C7.16585 11.6667 7.30439 11.6108 7.41619 11.499C7.528 11.3872 7.5839 11.2486 7.5839 11.0834C7.5839 10.9181 7.528 10.7795 7.41619 10.6677C7.30439 10.5559 7.16585 10.5 7.00057 10.5C6.83529 10.5 6.69675 10.5559 6.58494 10.6677C6.47314 10.7795 6.41724 10.9181 6.41724 11.0834C6.41724 11.2486 6.47314 11.3872 6.58494 11.499C6.69675 11.6108 6.83529 11.6667 7.00057 11.6667ZM7.5839 7.00002C7.74918 7.00002 7.88772 6.94412 7.99953 6.83231C8.11133 6.72051 8.16724 6.58196 8.16724 6.41669C8.16724 6.25141 8.11133 6.11287 7.99953 6.00106C7.88772 5.88926 7.74918 5.83335 7.5839 5.83335C7.41862 5.83335 7.28008 5.88926 7.16828 6.00106C7.05647 6.11287 7.00057 6.25141 7.00057 6.41669C7.00057 6.58196 7.05647 6.72051 7.16828 6.83231C7.28008 6.94412 7.41862 7.00002 7.5839 7.00002ZM11.0839 4.08335C11.2492 4.08335 11.3877 4.02745 11.4995 3.91565C11.6113 3.80384 11.6672 3.6653 11.6672 3.50002C11.6672 3.33474 11.6113 3.1962 11.4995 3.0844C11.3877 2.97259 11.2492 2.91669 11.0839 2.91669C10.9186 2.91669 10.7801 2.97259 10.6683 3.0844C10.5565 3.1962 10.5006 3.33474 10.5006 3.50002C10.5006 3.6653 10.5565 3.80384 10.6683 3.91565C10.7801 4.02745 10.9186 4.08335 11.0839 4.08335Z"
|
||||
d="M7 12.83C6.51 12.83 6.1 12.66 5.76 12.32C5.42 11.98 5.25 11.57 5.25 11.08C5.25 11.03 5.25 10.98 5.26 10.92C5.26 10.86 5.27 10.81 5.28 10.76L4.07 10.08C3.91 10.21 3.74 10.32 3.54 10.39C3.35 10.46 3.14 10.5 2.92 10.5C2.43 10.5 2.02 10.33 1.68 9.99C1.34 9.65 1.17 9.24 1.17 8.75C1.17 8.26 1.34 7.85 1.68 7.51C2.02 7.17 2.43 7 2.92 7C3.15 7 3.37 7.04 3.57 7.13C3.78 7.22 3.96 7.34 4.13 7.5L5.86 6.62C5.83 6.4 5.85 6.18 5.9 5.96C5.95 5.75 6.05 5.55 6.18 5.37L5.69 4.61C5.62 4.63 5.55 4.64 5.48 4.65C5.4 4.66 5.33 4.67 5.25 4.67C4.76 4.67 4.35 4.5 4.01 4.16C3.67 3.82 3.5 3.4 3.5 2.92C3.5 2.43 3.67 2.02 4.01 1.68C4.35 1.34 4.76 1.17 5.25 1.17C5.74 1.17 6.15 1.34 6.49 1.68C6.83 2.02 7 2.43 7 2.92C7 3.11 6.97 3.3 6.91 3.48C6.84 3.66 6.76 3.82 6.65 3.97L7.16 4.73C7.24 4.71 7.31 4.69 7.38 4.68C7.45 4.67 7.52 4.67 7.6 4.67C7.76 4.67 7.92 4.69 8.07 4.73C8.21 4.76 8.35 4.82 8.49 4.9L9.45 4.11C9.41 4.02 9.38 3.92 9.36 3.81C9.34 3.71 9.33 3.61 9.33 3.5C9.33 3.01 9.5 2.6 9.84 2.26C10.18 1.92 10.6 1.75 11.08 1.75C11.57 1.75 11.98 1.92 12.32 2.26C12.66 2.6 12.83 3.01 12.83 3.5C12.83 3.99 12.66 4.4 12.32 4.74C11.98 5.08 11.57 5.25 11.08 5.25C10.92 5.25 10.76 5.23 10.62 5.18C10.47 5.14 10.33 5.08 10.19 5L9.23 5.8C9.27 5.9 9.3 6 9.32 6.1C9.34 6.21 9.35 6.31 9.35 6.42C9.35 6.9 9.18 7.32 8.84 7.66C8.5 8 8.08 8.17 7.6 8.17C7.37 8.17 7.14 8.12 6.93 8.04C6.73 7.95 6.54 7.83 6.37 7.67L4.65 8.53C4.67 8.62 4.68 8.71 4.67 8.79C4.67 8.88 4.66 8.97 4.64 9.06L5.86 9.76C6.02 9.62 6.19 9.52 6.38 9.44C6.57 9.37 6.78 9.33 7 9.33C7.49 9.33 7.9 9.5 8.24 9.84C8.58 10.18 8.75 10.6 8.75 11.08C8.75 11.57 8.58 11.98 8.24 12.32C7.9 12.66 7.49 12.83 7 12.83ZM2.92 9.33C3.08 9.33 3.22 9.28 3.33 9.17C3.44 9.05 3.5 8.92 3.5 8.75C3.5 8.58 3.44 8.45 3.33 8.33C3.22 8.22 3.08 8.17 2.92 8.17C2.75 8.17 2.61 8.22 2.5 8.33C2.39 8.45 2.33 8.58 2.33 8.75C2.33 8.92 2.39 9.05 2.5 9.17C2.61 9.28 2.75 9.33 2.92 9.33ZM5.25 3.5C5.42 3.5 5.55 3.44 5.67 3.33C5.78 3.22 5.83 3.08 5.83 2.92C5.83 2.75 5.78 2.61 5.67 2.5C5.55 2.39 5.42 2.33 5.25 2.33C5.09 2.33 4.95 2.39 4.83 2.5C4.72 2.61 4.67 2.75 4.67 2.92C4.67 3.08 4.72 3.22 4.83 3.33C4.95 3.44 5.09 3.5 5.25 3.5ZM7 11.67C7.17 11.67 7.3 11.61 7.42 11.5C7.53 11.39 7.58 11.25 7.58 11.08C7.58 10.92 7.53 10.78 7.42 10.67C7.3 10.56 7.17 10.5 7 10.5C6.84 10.5 6.7 10.56 6.58 10.67C6.47 10.78 6.42 10.92 6.42 11.08C6.42 11.25 6.47 11.39 6.58 11.5C6.7 11.61 6.84 11.67 7 11.67ZM7.58 7C7.75 7 7.89 6.94 8 6.83C8.11 6.72 8.17 6.58 8.17 6.42C8.17 6.25 8.11 6.11 8 6C7.89 5.89 7.75 5.83 7.58 5.83C7.42 5.83 7.28 5.89 7.17 6C7.06 6.11 7 6.25 7 6.42C7 6.58 7.06 6.72 7.17 6.83C7.28 6.94 7.42 7 7.58 7ZM11.08 4.08C11.25 4.08 11.39 4.03 11.5 3.92C11.61 3.8 11.67 3.67 11.67 3.5C11.67 3.33 11.61 3.2 11.5 3.08C11.39 2.97 11.25 2.92 11.08 2.92C10.92 2.92 10.78 2.97 10.67 3.08C10.56 3.2 10.5 3.33 10.5 3.5C10.5 3.67 10.56 3.8 10.67 3.92C10.78 4.03 10.92 4.08 11.08 4.08Z"
|
||||
className="fill-[#737373] group-hover:fill-white group-data-[state=active]:fill-white"
|
||||
/>
|
||||
</g>
|
||||
|
|
@ -251,7 +251,7 @@ export function GraphListMemories({
|
|||
>
|
||||
<title>List</title>
|
||||
<path
|
||||
d="M0.00153996 1.78136H0.0032201C0.00332596 1.86499 0.0363955 1.94522 0.095258 2.00463C0.15412 2.06404 0.23403 2.09786 0.31766 2.09874L8.76792 2.09902C8.85189 2.09861 8.93229 2.06485 8.99154 2.00536C9.0508 1.94586 9.08411 1.86533 9.08418 1.78136H9.08558V0.3171C9.08539 0.23296 9.05185 0.152327 8.9923 0.0928833C8.93275 0.0334395 8.85206 3.68622e-05 8.76792 0H0.31766C0.233423 3.71008e-05 0.152646 0.0335166 0.0930814 0.0930814C0.0335167 0.152646 3.71008e-05 0.233423 0 0.31766C0 0.32312 0.00139988 0.3283 0.00167988 0.33376L0.00153996 1.78136ZM8.76778 4.49314H0.31766C0.233423 4.49318 0.152646 4.52666 0.0930814 4.58622C0.0335167 4.64579 3.71008e-05 4.72656 0 4.8108C0 4.81626 0.00139988 4.82144 0.00167988 4.8269V6.2745H0.00336002C0.00346588 6.35813 0.0365354 6.43836 0.0953979 6.49777C0.15426 6.55718 0.234171 6.591 0.3178 6.59188L8.76806 6.59216C8.85203 6.59175 8.93243 6.55799 8.99168 6.4985C9.05094 6.439 9.08425 6.35847 9.08432 6.2745H9.08572V4.81024C9.08553 4.72605 9.05195 4.64538 8.99234 4.58592C8.93273 4.52647 8.85197 4.4931 8.76778 4.49314ZM8.76778 8.98614H0.31766C0.233423 8.98618 0.152646 9.01966 0.0930814 9.07922C0.0335167 9.13879 3.71008e-05 9.21956 0 9.3038C0 9.30926 0.00139988 9.31444 0.00167988 9.3199V10.7675H0.00336002C0.00346588 10.8511 0.0365354 10.9314 0.0953979 10.9908C0.15426 11.0502 0.234171 11.084 0.3178 11.0849L8.76806 11.0852C8.85205 11.0848 8.93247 11.051 8.99173 10.9914C9.051 10.9319 9.08428 10.8514 9.08432 10.7674H9.08572V9.3031C9.0855 9.21894 9.0519 9.1383 8.99229 9.07887C8.93269 9.01945 8.85194 8.9861 8.76778 8.98614Z"
|
||||
d="M0 1.78H0C0 1.86 0.04 1.95 0.1 2C0.15 2.06 0.23 2.1 0.32 2.1L8.77 2.1C8.85 2.1 8.93 2.06 8.99 2.01C9.05 1.95 9.08 1.87 9.08 1.78H9.09V0.32C9.09 0.23 9.05 0.15 8.99 0.09C8.93 0.03 8.85 3.69e-05 8.77 0H0.32C0.23 3.71e-05 0.15 0.03 0.09 0.09C0.03 0.15 3.71e-05 0.23 0 0.32C0 0.32 0 0.33 0 0.33L0 1.78ZM8.77 4.49H0.32C0.23 4.49 0.15 4.53 0.09 4.59C0.03 4.65 3.71e-05 4.73 0 4.81C0 4.82 0 4.82 0 4.83V6.27H0C0 6.36 0.04 6.44 0.1 6.5C0.15 6.56 0.23 6.59 0.32 6.59L8.77 6.59C8.85 6.59 8.93 6.56 8.99 6.5C9.05 6.44 9.08 6.36 9.08 6.27H9.09V4.81C9.09 4.73 9.05 4.65 8.99 4.59C8.93 4.53 8.85 4.49 8.77 4.49ZM8.77 8.99H0.32C0.23 8.99 0.15 9.02 0.09 9.08C0.03 9.14 3.71e-05 9.22 0 9.3C0 9.31 0 9.31 0 9.32V10.77H0C0 10.85 0.04 10.93 0.1 10.99C0.15 11.05 0.23 11.08 0.32 11.08L8.77 11.09C8.85 11.08 8.93 11.05 8.99 10.99C9.05 10.93 9.08 10.85 9.08 10.77H9.09V9.3C9.09 9.22 9.05 9.14 8.99 9.08C8.93 9.02 8.85 8.99 8.77 8.99Z"
|
||||
className="fill-[#737373] group-hover:fill-white group-data-[state=active]:fill-white"
|
||||
/>
|
||||
</svg>
|
||||
|
|
@ -261,7 +261,7 @@ export function GraphListMemories({
|
|||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="graph" className="flex-1 min-h-0 mt-3">
|
||||
<div className="w-full h-full rounded-lg overflow-hidden">
|
||||
<div className="size-full rounded-lg overflow-hidden">
|
||||
<MemoryGraph
|
||||
documentIds={documentId ? [documentId] : undefined}
|
||||
variant="consumer"
|
||||
|
|
|
|||
|
|
@ -110,10 +110,10 @@ function DeleteButton({
|
|||
transition={{ duration: 0.15 }}
|
||||
onClick={() => setDeleteConfirmOpen(true)}
|
||||
tabIndex={-1}
|
||||
className="bg-[#0D121A] w-7 h-7 flex items-center justify-center rounded-full transition-opacity hover:opacity-100 focus-visible:ring-2 focus-visible:ring-offset-2 focus:outline-none cursor-pointer shadow-[inset_0_2px_4px_rgba(0,0,0,0.3),inset_0_1px_2px_rgba(0,0,0,0.1)]"
|
||||
className="bg-[#0D121A] size-7 flex items-center justify-center rounded-full transition-opacity hover:opacity-100 focus-visible:ring-2 focus-visible:ring-offset-2 focus:outline-none cursor-pointer shadow-[inset_0_2px_4px_rgba(0,0,0,0.3),inset_0_1px_2px_rgba(0,0,0,0.1)]"
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
<Trash2Icon className="w-4 h-4 text-red-500" />
|
||||
<Trash2Icon className="size-4 text-red-500" />
|
||||
<span className="sr-only">Delete document</span>
|
||||
</motion.button>
|
||||
) : (
|
||||
|
|
@ -129,21 +129,21 @@ function DeleteButton({
|
|||
type="button"
|
||||
onClick={() => setDeleteConfirmOpen(false)}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="w-6 h-6 flex items-center justify-center rounded-full transition-opacity hover:opacity-100 focus-visible:ring-2 focus-visible:ring-offset-2 focus:outline-none cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="size-6 flex items-center justify-center rounded-full transition-opacity hover:opacity-100 focus-visible:ring-2 focus-visible:ring-offset-2 focus:outline-none cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<XIcon className="w-4 h-4 text-[#737373]" />
|
||||
<XIcon className="size-4 text-[#737373]" />
|
||||
<span className="sr-only">Cancel delete</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDelete}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="w-6 h-6 flex items-center justify-center rounded-full transition-opacity hover:opacity-100 focus-visible:ring-2 focus-visible:ring-offset-2 focus:outline-none cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="size-6 flex items-center justify-center rounded-full transition-opacity hover:opacity-100 focus-visible:ring-2 focus-visible:ring-offset-2 focus:outline-none cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{deleteMutation.isPending ? (
|
||||
<Loader2 className="w-4 h-4 text-green-500 animate-spin" />
|
||||
<Loader2 className="size-4 text-green-500 animate-spin" />
|
||||
) : (
|
||||
<CheckIcon className="w-4 h-4 text-green-500" />
|
||||
<CheckIcon className="size-4 text-green-500" />
|
||||
)}
|
||||
<span className="sr-only">Confirm delete</span>
|
||||
</button>
|
||||
|
|
@ -268,17 +268,17 @@ export function DocumentModal({
|
|||
rel="noopener noreferrer"
|
||||
className={cn(
|
||||
"flex items-center gap-1 bg-[#0D121A] rounded-full shadow-[inset_0_2px_4px_rgba(0,0,0,0.3),inset_0_1px_2px_rgba(0,0,0,0.1)]",
|
||||
isMobile ? "w-7 h-7 justify-center" : "px-3 py-2",
|
||||
isMobile ? "size-7 justify-center" : "px-3 py-2",
|
||||
)}
|
||||
>
|
||||
{!isMobile && (
|
||||
<span className="line-clamp-1">Visit source</span>
|
||||
)}
|
||||
<ArrowUpRightIcon className="w-4 h-4 text-[#737373]" />
|
||||
<ArrowUpRightIcon className="size-4 text-[#737373]" />
|
||||
</a>
|
||||
)}
|
||||
<DialogPrimitive.Close
|
||||
className="bg-[#0D121A] w-7 h-7 flex items-center justify-center rounded-full transition-opacity hover:opacity-100 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus:outline-none disabled:pointer-events-none cursor-pointer [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 shadow-[inset_0_2px_4px_rgba(0,0,0,0.3),inset_0_1px_2px_rgba(0,0,0,0.1)]"
|
||||
className="bg-[#0D121A] size-7 flex items-center justify-center rounded-full transition-opacity hover:opacity-100 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus:outline-none disabled:pointer-events-none cursor-pointer [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 shadow-[inset_0_2px_4px_rgba(0,0,0,0.3),inset_0_1px_2px_rgba(0,0,0,0.1)]"
|
||||
data-slot="dialog-close"
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ function CalendarIcon() {
|
|||
>
|
||||
<title>Calendar Icon</title>
|
||||
<path
|
||||
d="M1.33333 13.3333C0.966667 13.3333 0.652778 13.2028 0.391667 12.9417C0.130556 12.6806 0 12.3667 0 12V2.66667C0 2.3 0.130556 1.98611 0.391667 1.725C0.652778 1.46389 0.966667 1.33333 1.33333 1.33333H2V0H3.33333V1.33333H8.66667V0H10V1.33333H10.6667C11.0333 1.33333 11.3472 1.46389 11.6083 1.725C11.8694 1.98611 12 2.3 12 2.66667V6H10.6667V5.33333H1.33333V12H6V13.3333H1.33333ZM1.33333 4H10.6667V2.66667H1.33333V4ZM7.33333 13.3333V11.2833L11.0167 7.61667C11.1167 7.51667 11.2278 7.44444 11.35 7.4C11.4722 7.35556 11.5944 7.33333 11.7167 7.33333C11.85 7.33333 11.9778 7.35833 12.1 7.40833C12.2222 7.45833 12.3333 7.53333 12.4333 7.63333L13.05 8.25C13.1389 8.35 13.2083 8.46111 13.2583 8.58333C13.3083 8.70555 13.3333 8.82778 13.3333 8.95C13.3333 9.07222 13.3111 9.19722 13.2667 9.325C13.2222 9.45278 13.15 9.56667 13.05 9.66667L9.38333 13.3333H7.33333ZM8.33333 12.3333H8.96667L10.9833 10.3L10.6833 9.98333L10.3667 9.68333L8.33333 11.7V12.3333ZM10.6833 9.98333L10.3667 9.68333L10.9833 10.3L10.6833 9.98333Z"
|
||||
d="M1.33 13.33C0.97 13.33 0.65 13.2 0.39 12.94C0.13 12.68 0 12.37 0 12V2.67C0 2.3 0.13 1.99 0.39 1.73C0.65 1.46 0.97 1.33 1.33 1.33H2V0H3.33V1.33H8.67V0H10V1.33H10.67C11.03 1.33 11.35 1.46 11.61 1.73C11.87 1.99 12 2.3 12 2.67V6H10.67V5.33H1.33V12H6V13.33H1.33ZM1.33 4H10.67V2.67H1.33V4ZM7.33 13.33V11.28L11.02 7.62C11.12 7.52 11.23 7.44 11.35 7.4C11.47 7.36 11.59 7.33 11.72 7.33C11.85 7.33 11.98 7.36 12.1 7.41C12.22 7.46 12.33 7.53 12.43 7.63L13.05 8.25C13.14 8.35 13.21 8.46 13.26 8.58C13.31 8.71 13.33 8.83 13.33 8.95C13.33 9.07 13.31 9.2 13.27 9.32C13.22 9.45 13.15 9.57 13.05 9.67L9.38 13.33H7.33ZM8.33 12.33H8.97L10.98 10.3L10.68 9.98L10.37 9.68L8.33 11.7V12.33ZM10.68 9.98L10.37 9.68L10.98 10.3L10.68 9.98Z"
|
||||
fill="#737373"
|
||||
/>
|
||||
</svg>
|
||||
|
|
@ -38,7 +38,7 @@ export function Summary({
|
|||
Summary
|
||||
</p>
|
||||
<div className="flex items-center gap-1 text-[#737373] opacity-50 text-[10px] leading-[150%]">
|
||||
<SyncLogoIcon className="w-[10px] h-[10px]" />
|
||||
<SyncLogoIcon className="size-[10px]" />
|
||||
powered by supermemory
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ export function Title({
|
|||
)}
|
||||
>
|
||||
<div className="pl-1 flex items-center gap-1 shrink-0">
|
||||
<DocumentIcon type={documentType} url={url} className="w-5 h-5" />
|
||||
<DocumentIcon type={documentType} url={url} className="size-5" />
|
||||
{extension && (
|
||||
<p
|
||||
className={cn(dmSansClassName(), "text-[12px] font-semibold")}
|
||||
|
|
|
|||
|
|
@ -129,10 +129,11 @@ export function DocumentsCommandPalette({
|
|||
if (queryData?.pages) {
|
||||
setCachedDocs(queryData.pages.flatMap((page) => page.documents ?? []))
|
||||
}
|
||||
setTimeout(() => inputRef.current?.focus(), 0)
|
||||
const focusTimer = setTimeout(() => inputRef.current?.focus(), 0)
|
||||
setSearch(initialSearch)
|
||||
setSelectedIndex(0)
|
||||
setSearchResults([])
|
||||
return () => clearTimeout(focusTimer)
|
||||
}
|
||||
}, [open, queryClient, projectId, initialSearch])
|
||||
|
||||
|
|
|
|||
|
|
@ -117,7 +117,7 @@ export function FeedbackModal({ isOpen, onClose }: FeedbackModalProps) {
|
|||
<DialogPrimitive.Close
|
||||
onClick={handleClose}
|
||||
disabled={isSubmitting}
|
||||
className="bg-[#0D121A] w-7 h-7 flex items-center justify-center focus:ring-ring rounded-full 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 border border-[rgba(115,115,115,0.2)] shrink-0"
|
||||
className="bg-[#0D121A] size-7 flex items-center justify-center focus:ring-ring rounded-full 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 border border-[rgba(115,115,115,0.2)] shrink-0"
|
||||
style={{
|
||||
boxShadow:
|
||||
"0 0.711px 2.842px 0 rgba(0, 0, 0, 0.25), 0.178px 0.178px 0.178px 0 rgba(255, 255, 255, 0.10) inset",
|
||||
|
|
@ -133,7 +133,7 @@ export function FeedbackModal({ isOpen, onClose }: FeedbackModalProps) {
|
|||
value={feedback}
|
||||
onChange={(e) => setFeedback(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Share your thoughts..."
|
||||
placeholder="Share your thoughts…"
|
||||
className={cn(
|
||||
"min-h-[120px] bg-[#0D121A] border border-[rgba(115,115,115,0.2)] text-[#fafafa] placeholder:text-[#737373] focus-visible:border-[#2261CA] focus-visible:ring-[#2261CA33] resize-none",
|
||||
dmSansClassName(),
|
||||
|
|
@ -164,7 +164,7 @@ export function FeedbackModal({ isOpen, onClose }: FeedbackModalProps) {
|
|||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin mr-2" />
|
||||
Submitting...
|
||||
Submitting…
|
||||
</>
|
||||
) : (
|
||||
"Submit"
|
||||
|
|
|
|||
|
|
@ -45,7 +45,11 @@ export function FullscreenNoteModal({
|
|||
}, [isOpen, initialContent])
|
||||
|
||||
const displayName =
|
||||
user?.displayUsername || localStorageUsername || user?.name || ""
|
||||
user?.displayUsername ||
|
||||
localStorageUsername ||
|
||||
user?.name ||
|
||||
user?.email?.split("@")[0] ||
|
||||
""
|
||||
const userName = displayName ? `${displayName.split(" ")[0]}'s` : "My"
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
|
|
@ -198,7 +202,7 @@ export function FullscreenNoteModal({
|
|||
>
|
||||
<title>Command Key</title>
|
||||
<path
|
||||
d="M6.66663 0.416626C6.33511 0.416626 6.01716 0.548322 5.78274 0.782743C5.54832 1.01716 5.41663 1.33511 5.41663 1.66663V6.66663C5.41663 6.99815 5.54832 7.31609 5.78274 7.55051C6.01716 7.78493 6.33511 7.91663 6.66663 7.91663C6.99815 7.91663 7.31609 7.78493 7.55051 7.55051C7.78493 7.31609 7.91663 6.99815 7.91663 6.66663C7.91663 6.33511 7.78493 6.01716 7.55051 5.78274C7.31609 5.54832 6.99815 5.41663 6.66663 5.41663H1.66663C1.33511 5.41663 1.01716 5.54832 0.782743 5.78274C0.548322 6.01716 0.416626 6.33511 0.416626 6.66663C0.416626 6.99815 0.548322 7.31609 0.782743 7.55051C1.01716 7.78493 1.33511 7.91663 1.66663 7.91663C1.99815 7.91663 2.31609 7.78493 2.55051 7.55051C2.78493 7.31609 2.91663 6.99815 2.91663 6.66663V1.66663C2.91663 1.33511 2.78493 1.01716 2.55051 0.782743C2.31609 0.548322 1.99815 0.416626 1.66663 0.416626C1.33511 0.416626 1.01716 0.548322 0.782743 0.782743C0.548322 1.01716 0.416626 1.33511 0.416626 1.66663C0.416626 1.99815 0.548322 2.31609 0.782743 2.55051C1.01716 2.78493 1.33511 2.91663 1.66663 2.91663H6.66663C6.99815 2.91663 7.31609 2.78493 7.55051 2.55051C7.78493 2.31609 7.91663 1.99815 7.91663 1.66663C7.91663 1.33511 7.78493 1.01716 7.55051 0.782743C7.31609 0.548322 6.99815 0.416626 6.66663 0.416626Z"
|
||||
d="M6.67 0.42C6.34 0.42 6.02 0.55 5.78 0.78C5.55 1.02 5.42 1.34 5.42 1.67V6.67C5.42 7 5.55 7.32 5.78 7.55C6.02 7.78 6.34 7.92 6.67 7.92C7 7.92 7.32 7.78 7.55 7.55C7.78 7.32 7.92 7 7.92 6.67C7.92 6.34 7.78 6.02 7.55 5.78C7.32 5.55 7 5.42 6.67 5.42H1.67C1.34 5.42 1.02 5.55 0.78 5.78C0.55 6.02 0.42 6.34 0.42 6.67C0.42 7 0.55 7.32 0.78 7.55C1.02 7.78 1.34 7.92 1.67 7.92C2 7.92 2.32 7.78 2.55 7.55C2.78 7.32 2.92 7 2.92 6.67V1.67C2.92 1.34 2.78 1.02 2.55 0.78C2.32 0.55 2 0.42 1.67 0.42C1.34 0.42 1.02 0.55 0.78 0.78C0.55 1.02 0.42 1.34 0.42 1.67C0.42 2 0.55 2.32 0.78 2.55C1.02 2.78 1.34 2.92 1.67 2.92H6.67C7 2.92 7.32 2.78 7.55 2.55C7.78 2.32 7.92 2 7.92 1.67C7.92 1.34 7.78 1.02 7.55 0.78C7.32 0.55 7 0.42 6.67 0.42Z"
|
||||
stroke="#737373"
|
||||
strokeWidth="0.833333"
|
||||
strokeLinecap="round"
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import { memo, useCallback, useRef } from "react"
|
||||
import { useQueryState } from "nuqs"
|
||||
import Image from "next/image"
|
||||
import { Share2 } from "lucide-react"
|
||||
import { MemoryGraph } from "./memory-graph"
|
||||
import { useProject } from "@/stores"
|
||||
import { useGraphHighlights } from "@/stores/highlights"
|
||||
|
|
@ -12,7 +13,11 @@ import { dmSansClassName } from "@/lib/fonts"
|
|||
import { ShareModal } from "./share-modal"
|
||||
import { shareParam } from "@/lib/search-params"
|
||||
|
||||
export const GraphLayoutView = memo(function GraphLayoutView() {
|
||||
export const GraphLayoutView = memo(function GraphLayoutView({
|
||||
onOpenDocument,
|
||||
}: {
|
||||
onOpenDocument?: (documentId: string) => void
|
||||
}) {
|
||||
const { effectiveContainerTags } = useProject()
|
||||
const { documentIds: allHighlightDocumentIds } = useGraphHighlights()
|
||||
const [isShareModalOpen, setIsShareModalOpen] = useQueryState(
|
||||
|
|
@ -30,7 +35,7 @@ export const GraphLayoutView = memo(function GraphLayoutView() {
|
|||
}, [setIsShareModalOpen])
|
||||
|
||||
return (
|
||||
<div className="relative h-full min-h-0 w-full">
|
||||
<div className="relative h-full min-h-[calc(100dvh-8.5rem)] w-full md:min-h-0">
|
||||
{/* Full-width graph */}
|
||||
<div className="absolute inset-0">
|
||||
<MemoryGraph
|
||||
|
|
@ -40,26 +45,31 @@ export const GraphLayoutView = memo(function GraphLayoutView() {
|
|||
highlightsVisible
|
||||
maxNodes={undefined}
|
||||
canvasRef={canvasRef}
|
||||
onOpenDocument={onOpenDocument}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Share graph button - top left */}
|
||||
<div className="absolute top-4 left-4 z-15">
|
||||
<div className="absolute left-3 top-3 z-15 md:left-4 md:top-4">
|
||||
<Button
|
||||
variant="headers"
|
||||
className={cn(
|
||||
"rounded-full text-base gap-2 h-10!",
|
||||
"size-10 rounded-full p-0 md:size-auto md:h-10! md:px-4",
|
||||
"md:gap-2 md:text-base",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
onClick={handleShare}
|
||||
aria-label="Share graph"
|
||||
>
|
||||
<Image
|
||||
src="/icons/share-graph.svg"
|
||||
alt="Share"
|
||||
width={16}
|
||||
height={16}
|
||||
className="hidden md:block"
|
||||
/>
|
||||
Share graph
|
||||
<Share2 className="size-4 md:hidden" />
|
||||
<span className="hidden md:inline">Share graph</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ export function Header({ onAddMemory, onOpenSearch }: HeaderProps) {
|
|||
user?.displayUsername ||
|
||||
(isRestoring ? localStorageUsername : "") ||
|
||||
user?.name ||
|
||||
user?.email?.split("@")[0] ||
|
||||
""
|
||||
const userName = displayName ? `${displayName.split(" ")[0]}'s` : "My"
|
||||
return (
|
||||
|
|
@ -71,7 +72,7 @@ export function Header({ onAddMemory, onOpenSearch }: HeaderProps) {
|
|||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="-ml-2 flex shrink-0 cursor-pointer items-center rounded-lg px-1.5 py-1 transition-colors hover:bg-white/5 focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:outline-none"
|
||||
className="flex shrink-0 cursor-pointer items-center rounded-lg px-1.5 py-1 transition-colors hover:bg-white/5 focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:outline-none md:-ml-2"
|
||||
>
|
||||
<Logo className="h-6 md:h-7" />
|
||||
{!isMobile && userName && (
|
||||
|
|
@ -101,7 +102,7 @@ export function Header({ onAddMemory, onOpenSearch }: HeaderProps) {
|
|||
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
|
||||
>
|
||||
<Link href="/">
|
||||
<Home className="h-4 w-4 text-[#737373]" />
|
||||
<Home className="size-4 text-[#737373]" />
|
||||
Home
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
|
@ -114,7 +115,7 @@ export function Header({ onAddMemory, onOpenSearch }: HeaderProps) {
|
|||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<Code2 className="h-4 w-4 text-[#737373]" />
|
||||
<Code2 className="size-4 text-[#737373]" />
|
||||
Developer console
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
|
|
@ -123,7 +124,7 @@ export function Header({ onAddMemory, onOpenSearch }: HeaderProps) {
|
|||
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
|
||||
>
|
||||
<a href="https://supermemory.ai" target="_blank" rel="noreferrer">
|
||||
<ExternalLink className="h-4 w-4 text-[#737373]" />
|
||||
<ExternalLink className="size-4 text-[#737373]" />
|
||||
supermemory.ai
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
|
|
@ -148,7 +149,7 @@ export function Header({ onAddMemory, onOpenSearch }: HeaderProps) {
|
|||
aria-current={viewMode === "dashboard" ? "page" : undefined}
|
||||
onClick={() => void setViewMode("dashboard")}
|
||||
className={cn(
|
||||
"flex h-10 w-10 shrink-0 cursor-pointer items-center justify-center rounded-full border transition-colors",
|
||||
"flex size-10 shrink-0 cursor-pointer items-center justify-center rounded-full border transition-colors",
|
||||
viewMode === "dashboard"
|
||||
? "border-[#2261CA33] bg-[#00173C] text-white"
|
||||
: "border-[#161F2C] bg-muted text-muted-foreground hover:bg-white/5",
|
||||
|
|
@ -236,7 +237,7 @@ export function Header({ onAddMemory, onOpenSearch }: HeaderProps) {
|
|||
aria-current={viewMode === "chat" ? "page" : undefined}
|
||||
onClick={() => void setViewMode("chat")}
|
||||
className={cn(
|
||||
"flex h-10 w-10 shrink-0 cursor-pointer items-center justify-center rounded-full border transition-colors",
|
||||
"flex size-10 shrink-0 cursor-pointer items-center justify-center rounded-full border transition-colors",
|
||||
viewMode === "chat"
|
||||
? "border-[#2261CA33] bg-[#00173C] text-white"
|
||||
: "border-[#161F2C] bg-muted text-muted-foreground hover:bg-white/5",
|
||||
|
|
@ -286,49 +287,49 @@ export function Header({ onAddMemory, onOpenSearch }: HeaderProps) {
|
|||
onClick={onAddMemory}
|
||||
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
|
||||
>
|
||||
<Plus className="h-4 w-4 text-[#737373]" />
|
||||
<Plus className="size-4 text-[#737373]" />
|
||||
Add memory
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => void setViewMode("dashboard")}
|
||||
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
|
||||
>
|
||||
<Home className="h-4 w-4 text-[#737373]" />
|
||||
<Home className="size-4 text-[#737373]" />
|
||||
Home
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setViewMode("integrations")}
|
||||
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
|
||||
>
|
||||
<Sun className="h-4 w-4 text-[#737373]" />
|
||||
<Sun className="size-4 text-[#737373]" />
|
||||
Integrations
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => void setViewMode("graph")}
|
||||
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
|
||||
>
|
||||
<GraphIcon className="h-4 w-4 text-[#737373]" />
|
||||
<GraphIcon className="size-4 text-[#737373]" />
|
||||
Graph
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => onOpenSearch?.()}
|
||||
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
|
||||
>
|
||||
<SearchIcon className="h-4 w-4 text-[#737373]" />
|
||||
<SearchIcon className="size-4 text-[#737373]" />
|
||||
Search
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => void setViewMode("list")}
|
||||
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
|
||||
>
|
||||
<LayoutGrid className="h-4 w-4 text-[#737373]" />
|
||||
<LayoutGrid className="size-4 text-[#737373]" />
|
||||
Memories
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => void setViewMode("chat")}
|
||||
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
|
||||
>
|
||||
<MessageCircleIcon className="h-4 w-4 text-[#737373]" />
|
||||
<MessageCircleIcon className="size-4 text-[#737373]" />
|
||||
Chat with Nova
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator className="bg-[#2E3033]" />
|
||||
|
|
@ -336,14 +337,14 @@ export function Header({ onAddMemory, onOpenSearch }: HeaderProps) {
|
|||
onClick={handleFeedback}
|
||||
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
|
||||
>
|
||||
<LifeBuoy className="h-4 w-4 text-[#737373]" />
|
||||
<LifeBuoy className="size-4 text-[#737373]" />
|
||||
Feedback
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => router.push("/settings")}
|
||||
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
|
||||
>
|
||||
<Settings className="h-4 w-4 text-[#737373]" />
|
||||
<Settings className="size-4 text-[#737373]" />
|
||||
Settings
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { Button } from "@ui/components/button"
|
|||
import { useRouter } from "next/navigation"
|
||||
import { useOrgOnboarding } from "@hooks/use-org-onboarding"
|
||||
import { analytics } from "@/lib/analytics"
|
||||
import { consumePendingConnectUrl } from "@/lib/constants"
|
||||
|
||||
export function InitialHeader({
|
||||
showUserSupermemory,
|
||||
|
|
@ -22,7 +23,8 @@ export function InitialHeader({
|
|||
const handleSkip = () => {
|
||||
markOrgOnboarded()
|
||||
analytics.onboardingCompleted()
|
||||
router.push("/")
|
||||
const pendingPath = consumePendingConnectUrl()
|
||||
router.push(pendingPath ?? "/")
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -13,28 +13,28 @@ export function IntegrationsIcon({ className }: { className?: string }) {
|
|||
>
|
||||
<title>Integrations</title>
|
||||
<path
|
||||
d="M15.6654 18.0026C16.954 18.0026 17.9987 16.9579 17.9987 15.6693C17.9987 14.3806 16.954 13.3359 15.6654 13.3359C14.3767 13.3359 13.332 14.3806 13.332 15.6693C13.332 16.9579 14.3767 18.0026 15.6654 18.0026Z"
|
||||
d="M15.67 18C16.95 18 18 16.96 18 15.67C18 14.38 16.95 13.34 15.67 13.34C14.38 13.34 13.33 14.38 13.33 15.67C13.33 16.96 14.38 18 15.67 18Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.55556"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M6.33333 8.66667C7.622 8.66667 8.66667 7.622 8.66667 6.33333C8.66667 5.04467 7.622 4 6.33333 4C5.04467 4 4 5.04467 4 6.33333C4 7.622 5.04467 8.66667 6.33333 8.66667Z"
|
||||
d="M6.33 8.67C7.62 8.67 8.67 7.62 8.67 6.33C8.67 5.04 7.62 4 6.33 4C5.04 4 4 5.04 4 6.33C4 7.62 5.04 8.67 6.33 8.67Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.55556"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M11.7773 6.33594H14.1107C14.5232 6.33594 14.9189 6.49983 15.2106 6.79155C15.5023 7.08327 15.6662 7.47893 15.6662 7.89149V13.3359"
|
||||
d="M11.78 6.34H14.11C14.52 6.34 14.92 6.5 15.21 6.79C15.5 7.08 15.67 7.48 15.67 7.89V13.34"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.55556"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M6.33203 8.66406V17.9974"
|
||||
d="M6.33 8.66V18"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.55556"
|
||||
strokeLinecap="round"
|
||||
|
|
@ -55,23 +55,23 @@ export function ChromeIcon({ className }: { className?: string }) {
|
|||
<title>Google Chrome Icon</title>
|
||||
<path
|
||||
fill="#fff"
|
||||
d="M95.252 142.873c26.304 0 47.627-21.324 47.627-47.628s-21.323-47.628-47.627-47.628-47.627 21.324-47.627 47.628 21.323 47.628 47.627 47.628z"
|
||||
d="M95.25 142.87c26.3 0 47.63-21.32 47.63-47.63s-21.32-47.63-47.63-47.63-47.63 21.32-47.63 47.63 21.32 47.63 47.63 47.63z"
|
||||
/>
|
||||
<path
|
||||
fill="#229342"
|
||||
d="m54.005 119.07-41.24-71.43a95.227 95.227 0 0 0-.003 95.25 95.234 95.234 0 0 0 82.496 47.61l41.24-71.43v-.011a47.613 47.613 0 0 1-17.428 17.443 47.62 47.62 0 0 1-47.632.007 47.62 47.62 0 0 1-17.433-17.437z"
|
||||
d="m54.01 119.07-41.24-71.43a95.23 95.23 0 0 0-.003 95.25 95.23 95.23 0 0 0 82.5 47.61l41.24-71.43v-.011a47.61 47.61 0 0 1-17.43 17.44 47.62 47.62 0 0 1-47.63.007 47.62 47.62 0 0 1-17.43-17.44z"
|
||||
/>
|
||||
<path
|
||||
fill="#fbc116"
|
||||
d="m136.495 119.067-41.239 71.43a95.229 95.229 0 0 0 82.489-47.622A95.24 95.24 0 0 0 190.5 95.248a95.237 95.237 0 0 0-12.772-47.623H95.249l-.01.007a47.62 47.62 0 0 1 23.819 6.372 47.618 47.618 0 0 1 17.439 17.431 47.62 47.62 0 0 1-.001 47.633z"
|
||||
d="m136.5 119.07-41.24 71.43a95.23 95.23 0 0 0 82.49-47.62A95.24 95.24 0 0 0 190.5 95.25a95.24 95.24 0 0 0-12.77-47.62H95.25l-.1.01a47.62 47.62 0 0 1 23.82 6.37 47.62 47.62 0 0 1 17.44 17.43 47.62 47.62 0 0 1-.001 47.63z"
|
||||
/>
|
||||
<path
|
||||
fill="#1a73e8"
|
||||
d="M95.252 132.961c20.824 0 37.705-16.881 37.705-37.706S116.076 57.55 95.252 57.55 57.547 74.431 57.547 95.255s16.881 37.706 37.705 37.706z"
|
||||
d="M95.25 132.96c20.82 0 37.7-16.88 37.7-37.71S116.08 57.55 95.25 57.55 57.55 74.43 57.55 95.25s16.88 37.71 37.7 37.71z"
|
||||
/>
|
||||
<path
|
||||
fill="#e33b2e"
|
||||
d="M95.252 47.628h82.479A95.237 95.237 0 0 0 142.87 12.76 95.23 95.23 0 0 0 95.245 0a95.222 95.222 0 0 0-47.623 12.767 95.23 95.23 0 0 0-34.856 34.872l41.24 71.43.011.006a47.62 47.62 0 0 1-.015-47.633 47.61 47.61 0 0 1 41.252-23.815z"
|
||||
d="M95.25 47.63h82.48A95.24 95.24 0 0 0 142.87 12.76 95.23 95.23 0 0 0 95.25 0a95.22 95.22 0 0 0-47.62 12.77 95.23 95.23 0 0 0-34.86 34.87l41.24 71.43.01.006a47.62 47.62 0 0 1-.015-47.63 47.61 47.61 0 0 1 41.25-23.82z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
|
@ -105,7 +105,7 @@ export function RaycastIcon({ className }: { className?: string }) {
|
|||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M7 18.079V21L0 14L1.46 12.54L7 18.081V18.079ZM9.921 21H7L14 28L15.46 26.54L9.921 21ZM26.535 15.462L27.996 14L13.996 0L12.538 1.466L18.077 7.004H14.73L10.864 3.146L9.404 4.606L11.809 7.01H10.129V17.876H20.994V16.196L23.399 18.6L24.859 17.14L20.994 13.274V9.927L26.535 15.462ZM7.73 6.276L6.265 7.738L7.833 9.304L9.294 7.844L7.73 6.276ZM20.162 18.708L18.702 20.17L20.268 21.738L21.73 20.276L20.162 18.708ZM4.596 9.41L3.134 10.872L7 14.738V11.815L4.596 9.41ZM16.192 21.006H13.268L17.134 24.872L18.596 23.41L16.192 21.006Z"
|
||||
d="M7 18.08V21L0 14L1.46 12.54L7 18.08V18.08ZM9.92 21H7L14 28L15.46 26.54L9.92 21ZM26.54 15.46L28 14L14 0L12.54 1.47L18.08 7H14.73L10.86 3.15L9.4 4.61L11.81 7.01H10.13V17.88H20.99V16.2L23.4 18.6L24.86 17.14L20.99 13.27V9.93L26.54 15.46ZM7.73 6.28L6.26 7.74L7.83 9.3L9.29 7.84L7.73 6.28ZM20.16 18.71L18.7 20.17L20.27 21.74L21.73 20.28L20.16 18.71ZM4.6 9.41L3.13 10.87L7 14.74V11.81L4.6 9.41ZM16.19 21.01H13.27L17.13 24.87L18.6 23.41L16.19 21.01Z"
|
||||
fill="#FF6363"
|
||||
/>
|
||||
</svg>
|
||||
|
|
|
|||
|
|
@ -170,7 +170,7 @@ export function IntegrationsView() {
|
|||
const [, setAddDoc] = useQueryState("add", addDocumentParam)
|
||||
const { org } = useAuth()
|
||||
const autumn = useCustomer()
|
||||
const hasProProduct = hasActivePlan(autumn.customer?.products, "api_pro")
|
||||
const hasProProduct = hasActivePlan(autumn.data?.subscriptions, "api_pro")
|
||||
|
||||
const { data: connections = [] } = useQuery({
|
||||
queryKey: ["connections"],
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ export function ChromeDetail() {
|
|||
>
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<ChromeIcon className="shrink-0 w-10 h-10" />
|
||||
<ChromeIcon className="shrink-0 size-10" />
|
||||
<div className="flex flex-col gap-1">
|
||||
<p
|
||||
className={cn(
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -99,7 +99,7 @@ export function RaycastDetail() {
|
|||
>
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<RaycastIcon className="shrink-0 w-10 h-10" />
|
||||
<RaycastIcon className="shrink-0 size-10" />
|
||||
<div className="flex flex-col gap-1">
|
||||
<p
|
||||
className={cn(
|
||||
|
|
@ -196,9 +196,9 @@ export function RaycastDetail() {
|
|||
className="p-2 rounded-lg bg-[#0D121A] border border-white/10 text-[#737373] hover:text-[#FAFAFA] transition-colors"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-4 w-4 text-[#4BA0FA]" />
|
||||
<Check className="size-4 text-[#4BA0FA]" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
<Copy className="size-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -219,7 +219,7 @@ export function RaycastDetail() {
|
|||
'Use "Add Memory" or "Search Memories" commands!',
|
||||
].map((text, i) => (
|
||||
<div key={text} className="flex items-start gap-3">
|
||||
<div className="shrink-0 w-6 h-6 bg-[#FF6363]/20 text-[#FF6363] rounded-full flex items-center justify-center text-xs font-medium">
|
||||
<div className="shrink-0 size-6 bg-[#FF6363]/20 text-[#FF6363] rounded-full flex items-center justify-center text-xs font-medium">
|
||||
{i + 1}
|
||||
</div>
|
||||
<p
|
||||
|
|
|
|||
|
|
@ -229,9 +229,9 @@ export function ShortcutsDetail() {
|
|||
className="p-2 rounded-lg bg-[#0D121A] border border-white/10 text-[#737373] hover:text-[#FAFAFA] transition-colors"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-4 w-4 text-[#4BA0FA]" />
|
||||
<Check className="size-4 text-[#4BA0FA]" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
<Copy className="size-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -252,7 +252,7 @@ export function ShortcutsDetail() {
|
|||
"Start using your shortcut!",
|
||||
].map((text, i) => (
|
||||
<div key={text} className="flex items-start gap-3">
|
||||
<div className="shrink-0 w-6 h-6 bg-[#4BA0FA]/20 text-[#4BA0FA] rounded-full flex items-center justify-center text-xs font-medium">
|
||||
<div className="shrink-0 size-6 bg-[#4BA0FA]/20 text-[#4BA0FA] rounded-full flex items-center justify-center text-xs font-medium">
|
||||
{i + 1}
|
||||
</div>
|
||||
<p
|
||||
|
|
|
|||
|
|
@ -168,7 +168,7 @@ export function ClaudeDesktopManualTimeline({
|
|||
</code>
|
||||
</pre>
|
||||
<Button
|
||||
className="absolute top-2 right-2 h-8 w-8 cursor-pointer p-0 bg-muted/80 hover:bg-muted"
|
||||
className="absolute top-2 right-2 size-8 cursor-pointer p-0 bg-muted/80 hover:bg-muted"
|
||||
onClick={onCopySnippet}
|
||||
size="icon"
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ import { getAbsoluteUrl, isYouTubeUrl, useYouTubeChannelName } from "./utils"
|
|||
import { SyncLogoIcon } from "@ui/assets/icons"
|
||||
import { McpPreview } from "./document-cards/mcp-preview"
|
||||
import { NotionPreview } from "./document-cards/notion-preview"
|
||||
import { getFaviconUrl } from "@/lib/url-helpers"
|
||||
import { getFaviconUrl, isSupermemoryFileUrl } from "@/lib/url-helpers"
|
||||
import { QuickNoteCard } from "./quick-note-card"
|
||||
import type { HighlightItem } from "./highlights-card"
|
||||
import { Button } from "@ui/components/button"
|
||||
|
|
@ -594,7 +594,7 @@ export function MemoriesGrid({
|
|||
)}
|
||||
onClick={() => handleSetViewMode("grid")}
|
||||
>
|
||||
<LayoutGrid className="w-3.5 h-3.5" />
|
||||
<LayoutGrid className="size-3.5" />
|
||||
Grid
|
||||
</button>
|
||||
<button
|
||||
|
|
@ -609,7 +609,7 @@ export function MemoriesGrid({
|
|||
)}
|
||||
onClick={() => handleSetViewMode("timeline")}
|
||||
>
|
||||
<AlignLeft className="w-3.5 h-3.5" />
|
||||
<AlignLeft className="size-3.5" />
|
||||
Timeline
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -618,10 +618,10 @@ export function MemoriesGrid({
|
|||
type="button"
|
||||
aria-label="Select documents"
|
||||
title="Select documents"
|
||||
className="w-8 h-8 flex items-center justify-center rounded-full border border-[#161F2C] bg-[#0D121A] hover:bg-[#00173C] hover:border-[#2261CA33] transition-colors cursor-pointer"
|
||||
className="size-8 flex items-center justify-center rounded-full border border-[#161F2C] bg-[#0D121A] hover:bg-[#00173C] hover:border-[#2261CA33] transition-colors cursor-pointer"
|
||||
onClick={onEnterSelectionMode}
|
||||
>
|
||||
<BoxSelect className="w-4 h-4 text-[#737373]" />
|
||||
<BoxSelect className="size-4 text-[#737373]" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -675,7 +675,7 @@ export function MemoriesGrid({
|
|||
onClick={handleBulkDeleteClick}
|
||||
disabled={selectedDocumentIds.size === 0 || isBulkDeleting}
|
||||
>
|
||||
<Trash2Icon className="w-3 h-3" />
|
||||
<Trash2Icon className="size-3" />
|
||||
<span>Delete</span>
|
||||
{selectedDocumentIds.size > 0 && (
|
||||
<span className="text-red-400/70">
|
||||
|
|
@ -690,7 +690,7 @@ export function MemoriesGrid({
|
|||
className="flex items-center gap-1 text-xs px-3 h-7 rounded-full text-[#737373] hover:text-white hover:bg-white/5 transition-colors cursor-pointer"
|
||||
onClick={onClearSelection}
|
||||
>
|
||||
<XIcon className="w-3 h-3" />
|
||||
<XIcon className="size-3" />
|
||||
<span>Done</span>
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -866,9 +866,9 @@ function ProcessingBadge() {
|
|||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="relative flex h-1.5 w-1.5 shrink-0">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-sky-400 opacity-75" />
|
||||
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-sky-400" />
|
||||
<span className="relative flex size-1.5 shrink-0">
|
||||
<span className="animate-ping absolute inline-flex size-full rounded-full bg-sky-400 opacity-75" />
|
||||
<span className="relative inline-flex rounded-full size-1.5 bg-sky-400" />
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
|
|
@ -886,7 +886,7 @@ function DoneBadge() {
|
|||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<CheckIcon
|
||||
className="w-2.5 h-2.5 text-emerald-400 shrink-0"
|
||||
className="size-2.5 text-emerald-400 shrink-0"
|
||||
strokeWidth={3}
|
||||
/>
|
||||
<span
|
||||
|
|
@ -947,7 +947,7 @@ const DocumentCard = memo(
|
|||
document.type !== "notion_doc" &&
|
||||
!document.url.includes("x.com") &&
|
||||
!document.url.includes("twitter.com") &&
|
||||
!document.url.includes("files.supermemory.ai") &&
|
||||
!isSupermemoryFileUrl(document.url) &&
|
||||
!document.url.includes("docs.googleapis.com") &&
|
||||
!document.url.includes("notion.so") &&
|
||||
(!document.title || !ogImage)
|
||||
|
|
@ -1019,11 +1019,11 @@ const DocumentCard = memo(
|
|||
}}
|
||||
>
|
||||
{isSelected ? (
|
||||
<div className="w-3 h-3 rounded-[2.25px] border border-[#369BFD] bg-[#369BFD] flex items-center justify-center">
|
||||
<CheckIcon className="w-2 h-2 text-white" strokeWidth={3} />
|
||||
<div className="size-3 rounded-[2.25px] border border-[#369BFD] bg-[#369BFD] flex items-center justify-center">
|
||||
<CheckIcon className="size-2 text-white" strokeWidth={3} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-3 h-3 rounded-[2.25px] border border-[#737373]" />
|
||||
<div className="size-3 rounded-[2.25px] border border-[#737373]" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
|
@ -1062,7 +1062,7 @@ const DocumentCard = memo(
|
|||
) && (
|
||||
<div className="pb-[10px] space-y-1">
|
||||
{document.url &&
|
||||
!document.url.includes("files.supermemory.ai") &&
|
||||
!isSupermemoryFileUrl(document.url) &&
|
||||
(document.title ||
|
||||
(!document.url.includes("x.com") &&
|
||||
!document.url.includes("twitter.com"))) && (
|
||||
|
|
@ -1080,7 +1080,7 @@ const DocumentCard = memo(
|
|||
<img
|
||||
src={getFaviconUrl(document.url) || ""}
|
||||
alt=""
|
||||
className="w-4 h-4 shrink-0 rounded-lg"
|
||||
className="size-4 shrink-0 rounded-lg"
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = "none"
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -157,7 +157,7 @@ export const GraphCard = memo<GraphCardProps>(
|
|||
<div className="flex-1 w-full relative overflow-hidden rounded-lg">
|
||||
{isLoading ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="w-6 h-6 border-2 border-blue-400/30 border-t-blue-400 rounded-full animate-spin" />
|
||||
<div className="size-6 border-2 border-blue-400/30 border-t-blue-400 rounded-full animate-spin" />
|
||||
</div>
|
||||
) : documentCount > 0 || memoryCount > 0 ? (
|
||||
<StaticGraphPreview
|
||||
|
|
@ -175,7 +175,7 @@ export const GraphCard = memo<GraphCardProps>(
|
|||
)}
|
||||
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/0 group-hover:bg-black/30 transition-colors">
|
||||
<Expand className="w-5 h-5 text-white opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
<Expand className="size-5 text-white opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { MemoryGraph as MemoryGraphBase } from "@supermemory/memory-graph"
|
||||
import type { GraphThemeColors } from "@supermemory/memory-graph"
|
||||
import { useGraphApi } from "./hooks/use-graph-api"
|
||||
|
||||
export interface MemoryGraphWrapperProps {
|
||||
|
|
@ -19,6 +20,7 @@ export interface MemoryGraphWrapperProps {
|
|||
onSlideshowNodeChange?: (nodeId: string | null) => void
|
||||
onSlideshowStop?: () => void
|
||||
canvasRef?: React.RefObject<HTMLCanvasElement | null>
|
||||
onOpenDocument?: (documentId: string) => void
|
||||
}
|
||||
|
||||
export function MemoryGraph({
|
||||
|
|
@ -59,7 +61,7 @@ export function MemoryGraph({
|
|||
})
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="w-full h-full [&>div]:!bg-none">
|
||||
<div ref={containerRef} className="size-full [&>div]:!bg-none">
|
||||
<MemoryGraphBase
|
||||
documents={documents}
|
||||
isLoading={externalIsLoading || apiIsLoading}
|
||||
|
|
@ -75,7 +77,7 @@ export function MemoryGraph({
|
|||
{
|
||||
bg: "transparent",
|
||||
edgeDerives: "#9ca3af",
|
||||
} as any
|
||||
} satisfies Partial<GraphThemeColors>
|
||||
}
|
||||
{...rest}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -200,7 +200,7 @@ export function NextAppResearchCta() {
|
|||
"mt-1 text-[12px] text-[#737373] tracking-[-0.12px]",
|
||||
)}
|
||||
>
|
||||
Share what you want next—we’d love a quick call.
|
||||
Share what you want next. We’d love a quick call.
|
||||
</p>
|
||||
<div className="mt-2.5 flex justify-end">
|
||||
<a
|
||||
|
|
|
|||
|
|
@ -135,7 +135,7 @@ export function QuickNoteCard({
|
|||
>
|
||||
<title>Command Key</title>
|
||||
<path
|
||||
d="M6.66663 0.416626C6.33511 0.416626 6.01716 0.548322 5.78274 0.782743C5.54832 1.01716 5.41663 1.33511 5.41663 1.66663V6.66663C5.41663 6.99815 5.54832 7.31609 5.78274 7.55051C6.01716 7.78493 6.33511 7.91663 6.66663 7.91663C6.99815 7.91663 7.31609 7.78493 7.55051 7.55051C7.78493 7.31609 7.91663 6.99815 7.91663 6.66663C7.91663 6.33511 7.78493 6.01716 7.55051 5.78274C7.31609 5.54832 6.99815 5.41663 6.66663 5.41663H1.66663C1.33511 5.41663 1.01716 5.54832 0.782743 5.78274C0.548322 6.01716 0.416626 6.33511 0.416626 6.66663C0.416626 6.99815 0.548322 7.31609 0.782743 7.55051C1.01716 7.78493 1.33511 7.91663 1.66663 7.91663C1.99815 7.91663 2.31609 7.78493 2.55051 7.55051C2.78493 7.31609 2.91663 6.99815 2.91663 6.66663V1.66663C2.91663 1.33511 2.78493 1.01716 2.55051 0.782743C2.31609 0.548322 1.99815 0.416626 1.66663 0.416626C1.33511 0.416626 1.01716 0.548322 0.782743 0.782743C0.548322 1.01716 0.416626 1.33511 0.416626 1.66663C0.416626 1.99815 0.548322 2.31609 0.782743 2.55051C1.01716 2.78493 1.33511 2.91663 1.66663 2.91663H6.66663C6.99815 2.91663 7.31609 2.78493 7.55051 2.55051C7.78493 2.31609 7.91663 1.99815 7.91663 1.66663C7.91663 1.33511 7.78493 1.01716 7.55051 0.782743C7.31609 0.548322 6.99815 0.416626 6.66663 0.416626Z"
|
||||
d="M6.67 0.42C6.34 0.42 6.02 0.55 5.78 0.78C5.55 1.02 5.42 1.34 5.42 1.67V6.67C5.42 7 5.55 7.32 5.78 7.55C6.02 7.78 6.34 7.92 6.67 7.92C7 7.92 7.32 7.78 7.55 7.55C7.78 7.32 7.92 7 7.92 6.67C7.92 6.34 7.78 6.02 7.55 5.78C7.32 5.55 7 5.42 6.67 5.42H1.67C1.34 5.42 1.02 5.55 0.78 5.78C0.55 6.02 0.42 6.34 0.42 6.67C0.42 7 0.55 7.32 0.78 7.55C1.02 7.78 1.34 7.92 1.67 7.92C2 7.92 2.32 7.78 2.55 7.55C2.78 7.32 2.92 7 2.92 6.67V1.67C2.92 1.34 2.78 1.02 2.55 0.78C2.32 0.55 2 0.42 1.67 0.42C1.34 0.42 1.02 0.55 0.78 0.78C0.55 1.02 0.42 1.34 0.42 1.67C0.42 2 0.55 2.32 0.78 2.55C1.02 2.78 1.34 2.92 1.67 2.92H6.67C7 2.92 7.32 2.78 7.55 2.55C7.78 2.32 7.92 2 7.92 1.67C7.92 1.34 7.78 1.02 7.55 0.78C7.32 0.55 7 0.42 6.67 0.42Z"
|
||||
stroke="#737373"
|
||||
strokeWidth="0.833333"
|
||||
strokeLinecap="round"
|
||||
|
|
|
|||
|
|
@ -87,7 +87,7 @@ export function RemoveConnectionDialog({
|
|||
</DialogTitle>
|
||||
<DialogPrimitive.Close
|
||||
disabled={isDeleting}
|
||||
className="bg-[#0D121A] w-7 h-7 flex items-center justify-center focus:ring-ring rounded-full 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 border border-[rgba(115,115,115,0.2)] shrink-0"
|
||||
className="bg-[#0D121A] size-7 flex items-center justify-center focus:ring-ring rounded-full 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 border border-[rgba(115,115,115,0.2)] shrink-0"
|
||||
style={{
|
||||
boxShadow: "inset 1.313px 1.313px 3.938px 0px rgba(0,0,0,0.7)",
|
||||
}}
|
||||
|
|
@ -152,7 +152,7 @@ export function RemoveConnectionDialog({
|
|||
{isDeleting ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Disconnecting...
|
||||
Disconnecting…
|
||||
</>
|
||||
) : alsoDelete ? (
|
||||
`Disconnect and delete ${memoryNoun}`
|
||||
|
|
|
|||
|
|
@ -132,7 +132,7 @@ export function SelectSpacesModal({
|
|||
</p>
|
||||
</div>
|
||||
<DialogPrimitive.Close
|
||||
className="bg-[#0D121A] w-7 h-7 flex items-center justify-center focus:ring-ring rounded-full 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 border border-[rgba(115,115,115,0.2)] shrink-0"
|
||||
className="bg-[#0D121A] size-7 flex items-center justify-center focus:ring-ring rounded-full 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 border border-[rgba(115,115,115,0.2)] shrink-0"
|
||||
style={{
|
||||
boxShadow: "inset 1.313px 1.313px 3.938px 0px rgba(0,0,0,0.7)",
|
||||
}}
|
||||
|
|
@ -184,18 +184,18 @@ export function SelectSpacesModal({
|
|||
{singleSelect ? (
|
||||
<div
|
||||
className={cn(
|
||||
"w-5 h-5 rounded-full border-2 flex items-center justify-center shrink-0 transition-colors",
|
||||
"size-5 rounded-full border-2 flex items-center justify-center shrink-0 transition-colors",
|
||||
isSelected ? "border-blue-500" : "border-[#737373]",
|
||||
)}
|
||||
>
|
||||
{isSelected && (
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-blue-500" />
|
||||
<div className="size-2.5 rounded-full bg-blue-500" />
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
"w-5 h-5 rounded-md border-2 flex items-center justify-center shrink-0 transition-colors",
|
||||
"size-5 rounded-md border-2 flex items-center justify-center shrink-0 transition-colors",
|
||||
isSelected
|
||||
? "bg-blue-500 border-blue-500"
|
||||
: "border-[#737373]",
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ import {
|
|||
} from "@/hooks/use-account-settings"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@ui/components/avatar"
|
||||
import { useTokenUsage } from "@/hooks/use-token-usage"
|
||||
import { formatUsageNumber, tokensToCredits } from "@/lib/billing-utils"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
|
|
@ -57,36 +56,123 @@ function SettingsCard({ children }: { children: React.ReactNode }) {
|
|||
)
|
||||
}
|
||||
|
||||
function PlanFeatureRow({
|
||||
icon,
|
||||
text,
|
||||
variant = "muted",
|
||||
function PlanComparisonCard({
|
||||
name,
|
||||
price,
|
||||
period,
|
||||
description,
|
||||
credits,
|
||||
features,
|
||||
highlight,
|
||||
}: {
|
||||
icon: "check" | "x"
|
||||
text: string
|
||||
variant?: "muted" | "highlight"
|
||||
name: string
|
||||
price: string
|
||||
period: string
|
||||
description: string
|
||||
credits: string
|
||||
features: string[]
|
||||
highlight: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{icon === "check" ? (
|
||||
<Check
|
||||
className={cn(
|
||||
"size-4 shrink-0",
|
||||
variant === "highlight" ? "text-[#4BA0FA]" : "text-[#737373]",
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<X className="size-4 shrink-0 text-[#737373]" />
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex flex-col gap-3 p-4 rounded-[10px] overflow-hidden",
|
||||
highlight
|
||||
? "bg-[#1B1F24] border border-[#4BA0FA]/30 shadow-[0px_2.842px_14.211px_rgba(0,0,0,0.25)]"
|
||||
: "border border-white/10",
|
||||
)}
|
||||
<span
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-mono uppercase tracking-[0.12em] text-[10px]",
|
||||
highlight ? "text-[#4BA0FA]" : "text-[#737373]",
|
||||
)}
|
||||
>
|
||||
{name}
|
||||
</p>
|
||||
{highlight && (
|
||||
<span className="bg-[#4BA0FA] text-[#00171A] text-[10px] font-bold tracking-[0.36px] px-1.5 py-0.5 rounded-[3px]">
|
||||
RECOMMENDED
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-bold text-[28px] leading-none text-[#FAFAFA] tabular-nums",
|
||||
)}
|
||||
>
|
||||
{price}
|
||||
</span>
|
||||
{period && (
|
||||
<span
|
||||
className={cn(dmSans125ClassName(), "text-[12px] text-[#737373]")}
|
||||
>
|
||||
{period}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[14px] tracking-[-0.14px]",
|
||||
variant === "highlight" ? "text-white" : "text-[#737373]",
|
||||
"text-[12px] tracking-[-0.12px] text-[#A3A3A3] leading-snug",
|
||||
)}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
{description}
|
||||
</p>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-lg px-3 py-2",
|
||||
highlight ? "bg-[#4BA0FA]/10" : "bg-white/5",
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-semibold text-[12px] tabular-nums leading-none",
|
||||
highlight ? "text-[#4BA0FA]" : "text-[#A3A3A3]",
|
||||
)}
|
||||
>
|
||||
{credits}
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"mt-1 text-[10px] leading-none",
|
||||
highlight ? "text-[#4BA0FA]/70" : "text-[#737373]",
|
||||
)}
|
||||
>
|
||||
of usage included
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul className="flex flex-col gap-2">
|
||||
{features.map((text) => (
|
||||
<li
|
||||
key={text}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"flex items-start gap-2 text-[12px] tracking-[-0.12px] leading-snug text-[#A3A3A3]",
|
||||
)}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mt-0.5 size-3 shrink-0",
|
||||
highlight ? "text-[#4BA0FA]" : "text-[#737373]",
|
||||
)}
|
||||
/>
|
||||
<span>{text}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -102,15 +188,23 @@ function formatOrgRole(role: string): string {
|
|||
}
|
||||
|
||||
export default function Account() {
|
||||
const { user, org, setActiveOrg, clearActiveOrg } = useAuth()
|
||||
const {
|
||||
user,
|
||||
org,
|
||||
organizations: allOrgs,
|
||||
setActiveOrg,
|
||||
clearActiveOrg,
|
||||
} = useAuth()
|
||||
const autumn = useCustomer()
|
||||
const [isUpgrading, setIsUpgrading] = useState(false)
|
||||
const [isCancelling, setIsCancelling] = useState(false)
|
||||
const [isCancelDialogOpen, setIsCancelDialogOpen] = useState(false)
|
||||
const [emailConfirm, setEmailConfirm] = useState("")
|
||||
const [notifyWhenDeleted, setNotifyWhenDeleted] = useState(false)
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
|
||||
const [isClosingAccount, setIsClosingAccount] = useState(false)
|
||||
const [switchingOrgId, setSwitchingOrgId] = useState<string | null>(null)
|
||||
const { data: allOrgs } = authClient.useListOrganizations()
|
||||
const [orgMenuOpen, setOrgMenuOpen] = useState(false)
|
||||
const canSwitchOrg = (allOrgs?.length ?? 0) > 1
|
||||
const { data: memberships, isPending: membershipsPending } =
|
||||
useAccountMemberships()
|
||||
|
||||
|
|
@ -152,18 +246,21 @@ export default function Account() {
|
|||
}
|
||||
|
||||
const {
|
||||
tokensUsed,
|
||||
tokensLimit,
|
||||
tokensPercent,
|
||||
searchesUsed,
|
||||
searchesLimit,
|
||||
searchesPercent,
|
||||
usdIncluded,
|
||||
usdSpent,
|
||||
planUsagePct,
|
||||
currentPlan,
|
||||
hasPaidPlan,
|
||||
isLoading: isCheckingStatus,
|
||||
daysRemaining,
|
||||
} = useTokenUsage(autumn)
|
||||
|
||||
const formatUsd = (n: number) =>
|
||||
n.toLocaleString(undefined, {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})
|
||||
|
||||
const planDisplayNames: Record<string, string> = {
|
||||
free: "Free",
|
||||
pro: "Pro",
|
||||
|
|
@ -175,24 +272,56 @@ export default function Account() {
|
|||
const handleUpgrade = async () => {
|
||||
setIsUpgrading(true)
|
||||
try {
|
||||
await autumn.attach({
|
||||
productId: "api_pro",
|
||||
successUrl: "https://app.supermemory.ai/settings#account",
|
||||
const result = await autumn.attach({
|
||||
planId: "api_pro",
|
||||
successUrl: `${window.location.origin}/settings#account`,
|
||||
})
|
||||
window.location.reload()
|
||||
if (result?.paymentUrl) {
|
||||
window.open(result.paymentUrl, "_self")
|
||||
return
|
||||
}
|
||||
autumn.refetch?.()
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
toast.error("Failed to start checkout. Please try again.")
|
||||
} finally {
|
||||
setIsUpgrading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Enterprise is contract-based — direct those users to the portal/sales.
|
||||
const cancellablePlanId =
|
||||
currentPlan === "pro" || currentPlan === "scale"
|
||||
? (`api_${currentPlan}` as const)
|
||||
: null
|
||||
|
||||
const handleCancelSubscription = async () => {
|
||||
if (!cancellablePlanId) return
|
||||
setIsCancelling(true)
|
||||
try {
|
||||
await autumn.updateSubscription({
|
||||
planId: cancellablePlanId,
|
||||
cancelAction: "cancel_end_of_cycle",
|
||||
})
|
||||
autumn.refetch?.()
|
||||
setIsCancelDialogOpen(false)
|
||||
toast.success(
|
||||
`Subscription cancelled. ${planDisplayNames[currentPlan]} features remain active until the end of your billing period.`,
|
||||
)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
toast.error("Failed to cancel subscription. Please try again.")
|
||||
} finally {
|
||||
setIsCancelling(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteAccount = async () => {
|
||||
if (!user?.email || !emailMatches || membershipsPending) return
|
||||
setIsClosingAccount(true)
|
||||
try {
|
||||
await deleteUserAccount.mutateAsync({
|
||||
confirmation: user.email,
|
||||
notifyOnComplete: notifyWhenDeleted,
|
||||
})
|
||||
clearActiveOrg()
|
||||
try {
|
||||
|
|
@ -203,7 +332,6 @@ export default function Account() {
|
|||
}
|
||||
setIsDeleteDialogOpen(false)
|
||||
setEmailConfirm("")
|
||||
setNotifyWhenDeleted(false)
|
||||
window.location.assign("/login/new")
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : "Something went wrong"
|
||||
|
|
@ -271,10 +399,19 @@ export default function Account() {
|
|||
>
|
||||
Organization
|
||||
</p>
|
||||
<Popover>
|
||||
<Popover
|
||||
open={orgMenuOpen && canSwitchOrg}
|
||||
onOpenChange={(open) => {
|
||||
if (canSwitchOrg) setOrgMenuOpen(open)
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger
|
||||
disabled={!canSwitchOrg}
|
||||
className={cn(
|
||||
"flex items-center gap-2 cursor-pointer transition-opacity hover:opacity-90",
|
||||
"flex items-center gap-2 transition-opacity",
|
||||
canSwitchOrg
|
||||
? "cursor-pointer hover:opacity-90"
|
||||
: "cursor-default",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
|
|
@ -286,14 +423,16 @@ export default function Account() {
|
|||
>
|
||||
{org?.name ?? "Personal"}
|
||||
</span>
|
||||
<ChevronDown className="size-4 text-[#737373]" />
|
||||
{canSwitchOrg && (
|
||||
<ChevronDown className="size-4 text-[#737373]" />
|
||||
)}
|
||||
</PopoverTrigger>
|
||||
{allOrgs && allOrgs.length > 1 && (
|
||||
{canSwitchOrg && (
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className="w-72 bg-[#1B1F24] rounded-[12px] border-white/10 p-1.5 shadow-[0px_4px_16px_rgba(0,0,0,0.4)]"
|
||||
>
|
||||
{allOrgs.map((organization) => {
|
||||
{allOrgs?.map((organization) => {
|
||||
const isCurrent = organization.id === org?.id
|
||||
const isSwitching = switchingOrgId === organization.id
|
||||
return (
|
||||
|
|
@ -388,7 +527,7 @@ export default function Account() {
|
|||
</p>
|
||||
</div>
|
||||
|
||||
{/* Credits Usage Progress */}
|
||||
{/* Plan usage (unified) */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<p
|
||||
|
|
@ -397,166 +536,179 @@ export default function Account() {
|
|||
"font-medium text-[16px] tracking-[-0.16px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
Credits Used
|
||||
Plan usage
|
||||
</p>
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-medium text-[16px] tracking-[-0.16px] text-[#4BA0FA]",
|
||||
"font-medium text-[16px] tracking-[-0.16px] text-[#4BA0FA] tabular-nums",
|
||||
)}
|
||||
>
|
||||
{tokensToCredits(tokensUsed)} /{" "}
|
||||
{tokensToCredits(tokensLimit)}
|
||||
{planUsagePct < 1 && planUsagePct > 0
|
||||
? "< 1"
|
||||
: Math.round(planUsagePct)}
|
||||
% used
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-3 w-full rounded-[40px] bg-[#2E353D] p-px overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-[40px]"
|
||||
style={{
|
||||
width: `${tokensPercent}%`,
|
||||
width: `${planUsagePct}%`,
|
||||
background:
|
||||
tokensPercent > 80
|
||||
planUsagePct > 80
|
||||
? "#ef4444"
|
||||
: "linear-gradient(to right, #4BA0FA 80%, #002757 100%)",
|
||||
}}
|
||||
title={`$${formatUsd(usdSpent)} of $${formatUsd(usdIncluded)} used`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search Queries Progress */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-medium text-[16px] tracking-[-0.16px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
Search Queries
|
||||
</p>
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-medium text-[16px] tracking-[-0.16px] text-[#4BA0FA]",
|
||||
)}
|
||||
>
|
||||
{formatUsageNumber(searchesUsed)} /{" "}
|
||||
{formatUsageNumber(searchesLimit)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-3 w-full rounded-[40px] bg-[#2E353D] p-px overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-[40px]"
|
||||
style={{
|
||||
width: `${searchesPercent}%`,
|
||||
background:
|
||||
searchesPercent > 80
|
||||
? "#ef4444"
|
||||
: "linear-gradient(to right, #4BA0FA 80%, #002757 100%)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Days remaining indicator */}
|
||||
{daysRemaining !== null && (
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-sm text-[#737373]",
|
||||
"text-sm tracking-[-0.14px] text-[#737373] tabular-nums",
|
||||
)}
|
||||
>
|
||||
Resets in {daysRemaining} day
|
||||
{daysRemaining !== 1 ? "s" : ""}
|
||||
{daysRemaining !== null
|
||||
? `Resets in ${daysRemaining} day${daysRemaining !== 1 ? "s" : ""}`
|
||||
: ""}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
autumn.openBillingPortal?.()
|
||||
}}
|
||||
className={cn(
|
||||
"relative w-full h-11 rounded-full flex items-center justify-center gap-2",
|
||||
"bg-[#0D121A] border border-[rgba(115,115,115,0.2)]",
|
||||
"text-[#FAFAFA] font-medium text-[14px] tracking-[-0.14px]",
|
||||
"cursor-pointer transition-opacity hover:opacity-90",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
<Settings className="size-4" />
|
||||
Manage billing
|
||||
<div className="absolute inset-0 pointer-events-none rounded-[inherit] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)]" />
|
||||
</button>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{/* Free plan card */}
|
||||
<div className="flex flex-col gap-4 p-4 rounded-[10px] border border-white/10 overflow-hidden">
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-medium text-[16px] tracking-[-0.16px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
Free plan
|
||||
</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
<PlanFeatureRow
|
||||
icon="check"
|
||||
text="10 credits / 1M tokens"
|
||||
/>
|
||||
<PlanFeatureRow icon="check" text="10K search queries" />
|
||||
<PlanFeatureRow icon="x" text="No connections" />
|
||||
<PlanFeatureRow icon="check" text="Basic support" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Current plan card - highlighted */}
|
||||
<div
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
autumn.openCustomerPortal?.({
|
||||
returnUrl:
|
||||
"https://app.supermemory.ai/settings#account",
|
||||
})
|
||||
}}
|
||||
className={cn(
|
||||
"flex flex-col gap-4 p-4 rounded-[10px]",
|
||||
"bg-[#1B1F24]",
|
||||
"shadow-[0px_2.842px_14.211px_rgba(0,0,0,0.25)]",
|
||||
"relative overflow-hidden",
|
||||
"relative flex-1 h-11 rounded-full flex items-center justify-center gap-2",
|
||||
"bg-[#0D121A] border border-[rgba(115,115,115,0.2)]",
|
||||
"text-[#FAFAFA] font-medium text-[14px] tracking-[-0.14px]",
|
||||
"cursor-pointer transition-opacity hover:opacity-90",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<p
|
||||
<Settings className="size-4" />
|
||||
Manage billing
|
||||
<div className="absolute inset-0 pointer-events-none rounded-[inherit] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)]" />
|
||||
</button>
|
||||
{cancellablePlanId && (
|
||||
<Dialog
|
||||
open={isCancelDialogOpen}
|
||||
onOpenChange={setIsCancelDialogOpen}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"relative flex-1 h-11 rounded-full flex items-center justify-center gap-2",
|
||||
"bg-[#290F0A] text-[#C73B1B]",
|
||||
"font-medium text-[14px] tracking-[-0.14px]",
|
||||
"cursor-pointer transition-opacity hover:opacity-90",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
Cancel subscription
|
||||
<div className="absolute inset-0 pointer-events-none rounded-[inherit] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.4)]" />
|
||||
</button>
|
||||
</DialogTrigger>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-medium text-[16px] tracking-[-0.16px] text-[#FAFAFA]",
|
||||
"bg-[#1B1F24] rounded-[22px] p-4",
|
||||
"shadow-[0px_2.842px_14.211px_rgba(0,0,0,0.25)]",
|
||||
"min-w-xl",
|
||||
)}
|
||||
>
|
||||
{planDisplayNames[currentPlan]} plan
|
||||
</p>
|
||||
<span className="bg-[#4BA0FA] text-[#00171A] text-[12px] font-bold tracking-[0.36px] px-1 py-[3px] rounded-[3px] h-[18px] flex items-center justify-center">
|
||||
ACTIVE
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<PlanFeatureRow
|
||||
icon="check"
|
||||
text="30 credits / 3M tokens"
|
||||
variant="highlight"
|
||||
/>
|
||||
<PlanFeatureRow
|
||||
icon="check"
|
||||
text="100K search queries"
|
||||
variant="highlight"
|
||||
/>
|
||||
<PlanFeatureRow
|
||||
icon="check"
|
||||
text="10 connections"
|
||||
variant="highlight"
|
||||
/>
|
||||
<PlanFeatureRow
|
||||
icon="check"
|
||||
text="Priority support"
|
||||
variant="highlight"
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute inset-0 pointer-events-none rounded-[inherit] shadow-[inset_0.711px_0.711px_0.711px_rgba(255,255,255,0.1)]" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex flex-1 flex-col gap-3 pl-1">
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-semibold text-[16px] tracking-[-0.16px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
Cancel {planDisplayNames[currentPlan]}{" "}
|
||||
subscription?
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[13px] tracking-[-0.13px] text-[#A3A3A3] leading-snug",
|
||||
)}
|
||||
>
|
||||
You'll keep Pro features until the end of
|
||||
your current billing period
|
||||
{daysRemaining !== null
|
||||
? ` (${daysRemaining} day${daysRemaining !== 1 ? "s" : ""} remaining)`
|
||||
: ""}
|
||||
. After that, your account will switch to the
|
||||
Free plan.
|
||||
</p>
|
||||
</div>
|
||||
<DialogClose asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"relative size-7 rounded-full bg-[#0D121A] border border-[#73737333]",
|
||||
"flex items-center justify-center shrink-0",
|
||||
"cursor-pointer transition-opacity hover:opacity-80",
|
||||
)}
|
||||
>
|
||||
<X className="size-4 text-[#737373]" />
|
||||
<div className="absolute inset-0 pointer-events-none rounded-[inherit] shadow-[inset_1.313px_1.313px_3.938px_rgba(0,0,0,0.7)]" />
|
||||
</button>
|
||||
</DialogClose>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-5">
|
||||
<DialogClose asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-medium text-[14px] tracking-[-0.14px] text-[#737373]",
|
||||
"cursor-pointer transition-opacity hover:opacity-80",
|
||||
)}
|
||||
>
|
||||
Keep plan
|
||||
</button>
|
||||
</DialogClose>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleCancelSubscription()}
|
||||
disabled={isCancelling}
|
||||
className={cn(
|
||||
"relative flex items-center gap-1.5 px-4 py-2 rounded-full",
|
||||
"bg-[#290F0A] text-[#C73B1B]",
|
||||
"font-normal text-[14px] tracking-[-0.14px]",
|
||||
"cursor-pointer transition-opacity",
|
||||
"disabled:opacity-40 disabled:cursor-not-allowed",
|
||||
!isCancelling && "hover:opacity-90",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
{isCancelling && (
|
||||
<LoaderIcon className="size-[18px] animate-spin" />
|
||||
)}
|
||||
<span>
|
||||
{isCancelling
|
||||
? "Cancelling…"
|
||||
: "Cancel subscription"}
|
||||
</span>
|
||||
<div className="absolute inset-0 pointer-events-none rounded-[inherit] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.4)]" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute inset-0 pointer-events-none rounded-[inherit] shadow-[inset_0.711px_0.711px_0.711px_rgba(255,255,255,0.1)]" />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
|
|
@ -580,7 +732,7 @@ export default function Account() {
|
|||
</p>
|
||||
</div>
|
||||
|
||||
{/* Credits Usage Progress */}
|
||||
{/* Plan usage (unified) */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<p
|
||||
|
|
@ -589,74 +741,41 @@ export default function Account() {
|
|||
"font-medium text-[16px] tracking-[-0.16px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
Credits Used
|
||||
Plan usage
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-medium text-[16px] tracking-[-0.16px] text-[#737373]",
|
||||
"font-medium text-[16px] tracking-[-0.16px] text-[#737373] tabular-nums",
|
||||
)}
|
||||
>
|
||||
{tokensToCredits(tokensUsed)} /{" "}
|
||||
{tokensToCredits(tokensLimit)}
|
||||
{planUsagePct < 1 && planUsagePct > 0
|
||||
? "< 1"
|
||||
: Math.round(planUsagePct)}
|
||||
% used
|
||||
</p>
|
||||
</div>
|
||||
<div className="h-3 w-full rounded-[40px] bg-[#2E353D] p-px overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-[40px] transition-all"
|
||||
style={{
|
||||
width: `${tokensPercent}%`,
|
||||
background: tokensPercent > 80 ? "#ef4444" : "#0054AD",
|
||||
width: `${planUsagePct}%`,
|
||||
background: planUsagePct > 80 ? "#ef4444" : "#0054AD",
|
||||
}}
|
||||
title={`$${formatUsd(usdSpent)} of $${formatUsd(usdIncluded)} used`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search Queries Progress */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-medium text-[16px] tracking-[-0.16px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
Search Queries
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-medium text-[16px] tracking-[-0.16px] text-[#737373]",
|
||||
)}
|
||||
>
|
||||
{formatUsageNumber(searchesUsed)} /{" "}
|
||||
{formatUsageNumber(searchesLimit)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="h-3 w-full rounded-[40px] bg-[#2E353D] p-px overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-[40px] transition-all"
|
||||
style={{
|
||||
width: `${searchesPercent}%`,
|
||||
background:
|
||||
searchesPercent > 80 ? "#ef4444" : "#0054AD",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Days remaining indicator */}
|
||||
{daysRemaining !== null && (
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-sm text-[#737373]",
|
||||
"text-sm tracking-[-0.14px] text-[#737373] tabular-nums",
|
||||
)}
|
||||
>
|
||||
Resets in {daysRemaining} day
|
||||
{daysRemaining !== 1 ? "s" : ""}
|
||||
{daysRemaining !== null
|
||||
? `Resets in ${daysRemaining} day${daysRemaining !== 1 ? "s" : ""}`
|
||||
: ""}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -680,7 +799,7 @@ export default function Account() {
|
|||
{isUpgrading || isCheckingStatus || autumn.isLoading ? (
|
||||
<>
|
||||
<LoaderIcon className="size-4 animate-spin mr-2" />
|
||||
Upgrading...
|
||||
Upgrading…
|
||||
</>
|
||||
) : (
|
||||
"Upgrade to Pro - $19/month"
|
||||
|
|
@ -690,75 +809,32 @@ export default function Account() {
|
|||
</button>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{/* Free plan card */}
|
||||
<div className="flex flex-col gap-4 p-4 rounded-[10px] border border-white/10">
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-medium text-[16px] tracking-[-0.16px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
Free plan
|
||||
</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
<PlanFeatureRow
|
||||
icon="check"
|
||||
text="10 credits / 1M tokens"
|
||||
/>
|
||||
<PlanFeatureRow icon="check" text="10K search queries" />
|
||||
<PlanFeatureRow icon="x" text="No connections" />
|
||||
<PlanFeatureRow icon="check" text="Basic support" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pro plan card */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-4 p-4 rounded-[10px]",
|
||||
"bg-[#1B1F24] border border-white/10",
|
||||
"shadow-[0px_2.842px_14.211px_rgba(0,0,0,0.25)]",
|
||||
"relative overflow-hidden",
|
||||
)}
|
||||
>
|
||||
{/* Header with badge */}
|
||||
<div className="flex items-center justify-between">
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-medium text-[16px] tracking-[-0.16px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
Pro plan
|
||||
</p>
|
||||
<span className="bg-[#4BA0FA] text-[#00171A] text-[12px] font-bold tracking-[0.36px] px-1 py-0.5 rounded-[3px]">
|
||||
RECOMMENDED
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<PlanFeatureRow
|
||||
icon="check"
|
||||
text="30 credits / 3M tokens"
|
||||
variant="highlight"
|
||||
/>
|
||||
<PlanFeatureRow
|
||||
icon="check"
|
||||
text="100K search queries"
|
||||
variant="highlight"
|
||||
/>
|
||||
<PlanFeatureRow
|
||||
icon="check"
|
||||
text="10 connections"
|
||||
variant="highlight"
|
||||
/>
|
||||
<PlanFeatureRow
|
||||
icon="check"
|
||||
text="Priority support"
|
||||
variant="highlight"
|
||||
/>
|
||||
</div>
|
||||
{/* Inset highlight */}
|
||||
<div className="absolute inset-0 pointer-events-none rounded-[inherit] shadow-[inset_0.711px_0.711px_0.711px_rgba(255,255,255,0.1)]" />
|
||||
</div>
|
||||
<PlanComparisonCard
|
||||
name="Free"
|
||||
price="$0"
|
||||
period=""
|
||||
description="Try the API with no commitment"
|
||||
credits="$5"
|
||||
features={[
|
||||
"Pay-as-you-go after $5 runs out",
|
||||
"Full search & memory API access",
|
||||
"Email support",
|
||||
]}
|
||||
highlight={false}
|
||||
/>
|
||||
<PlanComparisonCard
|
||||
name="Pro"
|
||||
price="$19"
|
||||
period="/mo"
|
||||
description="For developers building with AI memory"
|
||||
credits="$20"
|
||||
features={[
|
||||
"Auto top-up when balance runs low",
|
||||
"All plugins (Claude Code, Cursor, Hermes…)",
|
||||
"Priority support",
|
||||
]}
|
||||
highlight={true}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
|
@ -785,7 +861,6 @@ export default function Account() {
|
|||
setIsDeleteDialogOpen(open)
|
||||
if (!open) {
|
||||
setEmailConfirm("")
|
||||
setNotifyWhenDeleted(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
|
@ -993,26 +1068,6 @@ export default function Account() {
|
|||
/>
|
||||
<div className="absolute inset-0 pointer-events-none rounded-[inherit] shadow-[inset_0px_0px_0px_1px_rgba(43,49,67,0.08),inset_0px_1px_1px_rgba(0,0,0,0.08),inset_0px_2px_4px_rgba(0,0,0,0.02)]" />
|
||||
</div>
|
||||
<label
|
||||
className={cn(
|
||||
"flex cursor-pointer items-start gap-3 pl-2 pt-2",
|
||||
dmSans125ClassName(),
|
||||
"text-[13px] tracking-[-0.13px] text-[#A3A3A3]",
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={notifyWhenDeleted}
|
||||
onChange={(e) =>
|
||||
setNotifyWhenDeleted(e.target.checked)
|
||||
}
|
||||
className="mt-0.5 size-4 shrink-0 rounded border-onboarding bg-[#14161A]"
|
||||
/>
|
||||
<span>
|
||||
Email me when my account and data have been fully
|
||||
removed.
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,16 @@ import { hasActivePlan } from "@lib/queries"
|
|||
import { GoogleDrive, Notion, OneDrive } from "@ui/assets/icons"
|
||||
import { useCustomer } from "autumn-js/react"
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { Check, Plus, Trash2, Zap } from "lucide-react"
|
||||
import {
|
||||
Check,
|
||||
ChevronDown,
|
||||
History,
|
||||
Loader2,
|
||||
Play,
|
||||
Plus,
|
||||
Trash2,
|
||||
Zap,
|
||||
} from "lucide-react"
|
||||
import { useEffect, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { useQueryState } from "nuqs"
|
||||
|
|
@ -20,9 +29,30 @@ import { RemoveConnectionDialog } from "@/components/remove-connection-dialog"
|
|||
import { addDocumentParam } from "@/lib/search-params"
|
||||
import { DEFAULT_PROJECT_ID } from "@lib/constants"
|
||||
import type { Project } from "@lib/types"
|
||||
import { SyncStatusBadge } from "@/components/settings/sync-status-badge"
|
||||
import { SyncHistoryPanel } from "@/components/settings/sync-history-panel"
|
||||
import { useTriggerSync } from "@/hooks/use-trigger-sync"
|
||||
import { formatRelativeTime } from "@/components/settings/sync-utils"
|
||||
import type { ImportProvider } from "@/components/settings/sync-utils"
|
||||
|
||||
type Connection = z.infer<typeof ConnectionResponseSchema>
|
||||
|
||||
/** Extract typed metadata from a connection, with runtime validation. */
|
||||
function getConnectionMeta(connection: Connection) {
|
||||
const m = connection.metadata as Record<string, unknown> | undefined
|
||||
return {
|
||||
syncInProgress: m?.syncInProgress === true,
|
||||
lastSyncedAt:
|
||||
typeof m?.lastSyncedAt === "number" ? m.lastSyncedAt : undefined,
|
||||
documentCount: typeof m?.documentCount === "number" ? m.documentCount : 0,
|
||||
}
|
||||
}
|
||||
|
||||
/** Check if a connection's auth token has expired. */
|
||||
function isConnectionExpired(connection: Connection): boolean {
|
||||
return !!connection.expiresAt && new Date(connection.expiresAt) <= new Date()
|
||||
}
|
||||
|
||||
const CONNECTORS = {
|
||||
"google-drive": {
|
||||
title: "Google Drive",
|
||||
|
|
@ -128,64 +158,30 @@ function PillButton({
|
|||
)
|
||||
}
|
||||
|
||||
function ConnectionStatusBadge({ connected }: { connected: boolean }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
"size-[7px] rounded-full",
|
||||
connected ? "bg-[#00AC3F]" : "bg-[#737373]",
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-medium text-[16px] tracking-[-0.16px]",
|
||||
connected ? "text-[#00AC3F]" : "text-[#737373]",
|
||||
)}
|
||||
>
|
||||
{connected ? "Connected" : "Disconnected"}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ConnectionRow({
|
||||
connection,
|
||||
onDelete,
|
||||
isDeleting,
|
||||
disabled,
|
||||
projects,
|
||||
onTriggerSync,
|
||||
isSyncing,
|
||||
}: {
|
||||
connection: Connection
|
||||
onDelete: () => void
|
||||
isDeleting: boolean
|
||||
disabled?: boolean
|
||||
projects: Project[]
|
||||
onTriggerSync: () => void
|
||||
isSyncing: boolean
|
||||
}) {
|
||||
const [historyOpen, setHistoryOpen] = useState(false)
|
||||
const config = CONNECTORS[connection.provider as ConnectorProvider]
|
||||
if (!config) return null
|
||||
|
||||
const Icon = config.icon
|
||||
// Check if connection is active: if expiresAt exists and is in the future, or if no expiresAt
|
||||
const isConnected =
|
||||
!connection.expiresAt || new Date(connection.expiresAt) > new Date()
|
||||
|
||||
// Format relative time
|
||||
const formatRelativeTime = (date: string | null | undefined) => {
|
||||
if (!date) return "Never"
|
||||
const d = new Date(date)
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - d.getTime()
|
||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
|
||||
const diffDays = Math.floor(diffHours / 24)
|
||||
|
||||
if (diffHours < 1) return "Just now"
|
||||
if (diffHours < 24) return `${diffHours}h ago`
|
||||
if (diffDays === 1) return "Yesterday"
|
||||
if (diffDays < 7) return `${diffDays} days ago`
|
||||
return d.toLocaleDateString()
|
||||
}
|
||||
const meta = getConnectionMeta(connection)
|
||||
const expired = isConnectionExpired(connection)
|
||||
|
||||
const getProjectDisplayName = (containerTag: string): string => {
|
||||
if (containerTag === DEFAULT_PROJECT_ID) return "Default Project"
|
||||
|
|
@ -194,13 +190,11 @@ function ConnectionRow({
|
|||
return containerTag.replace(/^sm_project_/, "") // if cached project is not found, remove the prefix
|
||||
}
|
||||
|
||||
const documentCount = (connection.metadata?.documentCount as number) ?? 0
|
||||
const containerTags = (
|
||||
connection as Connection & { containerTags?: string[] }
|
||||
).containerTags
|
||||
const projectName =
|
||||
containerTags && containerTags.length > 0 && containerTags[0]
|
||||
? getProjectDisplayName(containerTags[0])
|
||||
connection.containerTags &&
|
||||
connection.containerTags.length > 0 &&
|
||||
connection.containerTags[0]
|
||||
? getProjectDisplayName(connection.containerTags[0])
|
||||
: null
|
||||
|
||||
return (
|
||||
|
|
@ -224,7 +218,11 @@ function ConnectionRow({
|
|||
>
|
||||
{config.title}
|
||||
</span>
|
||||
<ConnectionStatusBadge connected={isConnected} />
|
||||
<SyncStatusBadge
|
||||
syncInProgress={meta.syncInProgress}
|
||||
lastSyncedAt={meta.lastSyncedAt}
|
||||
isExpired={expired}
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
|
|
@ -235,15 +233,72 @@ function ConnectionRow({
|
|||
{connection.email || "Unknown"}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDelete}
|
||||
disabled={isDeleting || disabled}
|
||||
className="text-[#737373] hover:text-red-400 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
aria-label="Delete connection"
|
||||
>
|
||||
<Trash2 className="size-[22px]" />
|
||||
</button>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onTriggerSync()
|
||||
}}
|
||||
disabled={isSyncing || disabled || expired}
|
||||
className="text-[#737373] hover:text-[#4BA0FA] transition-colors disabled:opacity-50 disabled:cursor-not-allowed p-1.5 rounded-lg hover:bg-white/5"
|
||||
aria-label={
|
||||
expired
|
||||
? "Connection expired"
|
||||
: isSyncing
|
||||
? "Sync in progress"
|
||||
: "Sync now"
|
||||
}
|
||||
title={
|
||||
expired
|
||||
? "Reconnect to sync"
|
||||
: isSyncing
|
||||
? "Sync in progress"
|
||||
: "Sync now"
|
||||
}
|
||||
>
|
||||
{isSyncing ? (
|
||||
<Loader2 className="size-[18px] animate-spin" />
|
||||
) : (
|
||||
<Play className="size-[18px]" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setHistoryOpen((v) => !v)
|
||||
}}
|
||||
disabled={disabled}
|
||||
aria-label="Sync history"
|
||||
aria-expanded={historyOpen}
|
||||
title={historyOpen ? "Hide sync history" : "Sync history"}
|
||||
className={cn(
|
||||
"transition-colors disabled:opacity-50 disabled:cursor-not-allowed p-1.5 rounded-lg hover:bg-white/5 flex items-center gap-0.5",
|
||||
historyOpen
|
||||
? "text-[#FAFAFA] bg-white/5"
|
||||
: "text-[#737373] hover:text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
<History className="size-[18px]" />
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"size-3 transition-transform",
|
||||
historyOpen && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDelete}
|
||||
disabled={isDeleting || disabled}
|
||||
className="text-[#737373] hover:text-red-400 transition-colors disabled:opacity-50 disabled:cursor-not-allowed p-1.5 rounded-lg hover:bg-white/5"
|
||||
aria-label="Delete connection"
|
||||
title="Remove connection"
|
||||
>
|
||||
<Trash2 className="size-[18px]" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Meta row */}
|
||||
|
|
@ -267,7 +322,7 @@ function ConnectionRow({
|
|||
"font-medium text-[14px] tracking-[-0.14px] text-[#737373]",
|
||||
)}
|
||||
>
|
||||
Added: {formatRelativeTime(connection.createdAt)}
|
||||
Last synced: {formatRelativeTime(meta.lastSyncedAt)}
|
||||
</span>
|
||||
<div className="size-[3px] rounded-full bg-[#737373]" />
|
||||
<span
|
||||
|
|
@ -276,9 +331,18 @@ function ConnectionRow({
|
|||
"font-medium text-[14px] tracking-[-0.14px] text-[#737373]",
|
||||
)}
|
||||
>
|
||||
{documentCount} {config.documentLabel} connected
|
||||
{meta.documentCount} {config.documentLabel} connected
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{historyOpen && (
|
||||
<div className="border-t border-[rgba(82,89,102,0.15)] pt-4">
|
||||
<SyncHistoryPanel
|
||||
connectionId={connection.id}
|
||||
isOpen={historyOpen}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
@ -342,16 +406,16 @@ export default function ConnectionsMCP() {
|
|||
open: boolean
|
||||
connection: Connection | null
|
||||
}>({ open: false, connection: null })
|
||||
const triggerSync = useTriggerSync()
|
||||
|
||||
const projects = (queryClient.getQueryData<Project[]>(["projects"]) ||
|
||||
[]) as Project[]
|
||||
|
||||
const hasProProduct = hasActivePlan(autumn.customer?.products, "api_pro")
|
||||
const hasProProduct = hasActivePlan(autumn.data?.subscriptions, "api_pro")
|
||||
|
||||
// Get connections data directly from autumn customer
|
||||
const connectionsFeature = autumn.customer?.features?.connections
|
||||
const connectionsUsed = connectionsFeature?.usage ?? 0
|
||||
const connectionsLimit = connectionsFeature?.included_usage ?? 10
|
||||
const connectionsBalance = autumn.data?.balances?.connections
|
||||
const connectionsUsed = connectionsBalance?.usage ?? 0
|
||||
const connectionsLimit = connectionsBalance?.granted ?? 10
|
||||
|
||||
const canAddConnection = connectionsUsed < connectionsLimit
|
||||
|
||||
|
|
@ -376,7 +440,13 @@ export default function ConnectionsMCP() {
|
|||
return response.data as Connection[]
|
||||
},
|
||||
staleTime: 30 * 1000,
|
||||
refetchInterval: 60 * 1000,
|
||||
refetchInterval: (query) => {
|
||||
const conns = query.state.data as Connection[] | undefined
|
||||
if (conns?.some((c) => getConnectionMeta(c).syncInProgress)) {
|
||||
return 5000
|
||||
}
|
||||
return 60 * 1000
|
||||
},
|
||||
enabled: hasProProduct,
|
||||
})
|
||||
|
||||
|
|
@ -424,13 +494,18 @@ export default function ConnectionsMCP() {
|
|||
// Upgrade handler
|
||||
const handleUpgrade = async () => {
|
||||
try {
|
||||
await autumn.attach({
|
||||
productId: "api_pro",
|
||||
successUrl: "https://app.supermemory.ai/settings#connections",
|
||||
const result = await autumn.attach({
|
||||
planId: "api_pro",
|
||||
successUrl: `${window.location.origin}/settings#connections`,
|
||||
})
|
||||
window.location.reload()
|
||||
if (result?.paymentUrl) {
|
||||
window.open(result.paymentUrl, "_self")
|
||||
return
|
||||
}
|
||||
autumn.refetch?.()
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
toast.error("Failed to start checkout. Please try again.")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -492,6 +567,19 @@ export default function ConnectionsMCP() {
|
|||
isDeleting={deleteConnectionMutation.isPending}
|
||||
disabled={!hasProProduct}
|
||||
projects={projects}
|
||||
onTriggerSync={() =>
|
||||
triggerSync.mutate({
|
||||
connectionId: connection.id,
|
||||
provider: connection.provider as ImportProvider,
|
||||
containerTags: connection.containerTags,
|
||||
})
|
||||
}
|
||||
isSyncing={
|
||||
(triggerSync.isPending &&
|
||||
triggerSync.variables?.connectionId ===
|
||||
connection.id) ||
|
||||
getConnectionMeta(connection).syncInProgress
|
||||
}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
|
|
@ -578,7 +666,9 @@ export default function ConnectionsMCP() {
|
|||
}}
|
||||
provider={removeDialog.connection?.provider}
|
||||
documentCount={
|
||||
(removeDialog.connection?.metadata?.documentCount as number) ?? 0
|
||||
removeDialog.connection
|
||||
? getConnectionMeta(removeDialog.connection).documentCount
|
||||
: 0
|
||||
}
|
||||
onConfirm={(deleteDocuments) => {
|
||||
if (removeDialog.connection) {
|
||||
|
|
|
|||
|
|
@ -273,7 +273,7 @@ export default function Integrations() {
|
|||
<IntegrationCard id="chrome-extension-card">
|
||||
<div className="flex flex-col gap-6">
|
||||
<div id="chrome-extension-header" className="flex items-center gap-4">
|
||||
<ChromeIcon className="shrink-0 w-10 h-10" />
|
||||
<ChromeIcon className="shrink-0 size-10" />
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<p
|
||||
className={cn(
|
||||
|
|
@ -384,7 +384,7 @@ export default function Integrations() {
|
|||
id="raycast-extension-header"
|
||||
className="flex items-center gap-4"
|
||||
>
|
||||
<RaycastIcon className="shrink-0 w-10 h-10" />
|
||||
<RaycastIcon className="shrink-0 size-10" />
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<p
|
||||
className={cn(
|
||||
|
|
@ -476,9 +476,9 @@ export default function Integrations() {
|
|||
className="p-2 rounded-lg bg-[#0D121A] border border-white/10 text-[#737373] hover:text-[#FAFAFA] transition-colors"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-4 w-4 text-[#4BA0FA]" />
|
||||
<Check className="size-4 text-[#4BA0FA]" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
<Copy className="size-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -495,7 +495,7 @@ export default function Integrations() {
|
|||
</h4>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="shrink-0 w-6 h-6 bg-[#4BA0FA]/20 text-[#4BA0FA] rounded-full flex items-center justify-center text-xs font-medium">
|
||||
<div className="shrink-0 size-6 bg-[#4BA0FA]/20 text-[#4BA0FA] rounded-full flex items-center justify-center text-xs font-medium">
|
||||
1
|
||||
</div>
|
||||
<p
|
||||
|
|
@ -508,7 +508,7 @@ export default function Integrations() {
|
|||
</p>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="shrink-0 w-6 h-6 bg-[#4BA0FA]/20 text-[#4BA0FA] rounded-full flex items-center justify-center text-xs font-medium">
|
||||
<div className="shrink-0 size-6 bg-[#4BA0FA]/20 text-[#4BA0FA] rounded-full flex items-center justify-center text-xs font-medium">
|
||||
2
|
||||
</div>
|
||||
<p
|
||||
|
|
@ -521,7 +521,7 @@ export default function Integrations() {
|
|||
</p>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="shrink-0 w-6 h-6 bg-[#4BA0FA]/20 text-[#4BA0FA] rounded-full flex items-center justify-center text-xs font-medium">
|
||||
<div className="shrink-0 size-6 bg-[#4BA0FA]/20 text-[#4BA0FA] rounded-full flex items-center justify-center text-xs font-medium">
|
||||
3
|
||||
</div>
|
||||
<p
|
||||
|
|
@ -612,9 +612,9 @@ export default function Integrations() {
|
|||
className="p-2 rounded-lg bg-[#0D121A] border border-white/10 text-[#737373] hover:text-[#FAFAFA] transition-colors"
|
||||
>
|
||||
{raycastCopied ? (
|
||||
<Check className="h-4 w-4 text-[#4BA0FA]" />
|
||||
<Check className="size-4 text-[#4BA0FA]" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
<Copy className="size-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -631,7 +631,7 @@ export default function Integrations() {
|
|||
</h4>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="shrink-0 w-6 h-6 bg-[#FF6363]/20 text-[#FF6363] rounded-full flex items-center justify-center text-xs font-medium">
|
||||
<div className="shrink-0 size-6 bg-[#FF6363]/20 text-[#FF6363] rounded-full flex items-center justify-center text-xs font-medium">
|
||||
1
|
||||
</div>
|
||||
<p
|
||||
|
|
@ -644,7 +644,7 @@ export default function Integrations() {
|
|||
</p>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="shrink-0 w-6 h-6 bg-[#FF6363]/20 text-[#FF6363] rounded-full flex items-center justify-center text-xs font-medium">
|
||||
<div className="shrink-0 size-6 bg-[#FF6363]/20 text-[#FF6363] rounded-full flex items-center justify-center text-xs font-medium">
|
||||
2
|
||||
</div>
|
||||
<p
|
||||
|
|
@ -657,7 +657,7 @@ export default function Integrations() {
|
|||
</p>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="shrink-0 w-6 h-6 bg-[#FF6363]/20 text-[#FF6363] rounded-full flex items-center justify-center text-xs font-medium">
|
||||
<div className="shrink-0 size-6 bg-[#FF6363]/20 text-[#FF6363] rounded-full flex items-center justify-center text-xs font-medium">
|
||||
3
|
||||
</div>
|
||||
<p
|
||||
|
|
|
|||
267
apps/web/components/settings/sync-history-panel.tsx
Normal file
267
apps/web/components/settings/sync-history-panel.tsx
Normal file
|
|
@ -0,0 +1,267 @@
|
|||
"use client"
|
||||
|
||||
import { cn } from "@lib/utils"
|
||||
import { dmSans125ClassName } from "@/lib/fonts"
|
||||
import { useSyncRuns } from "@/hooks/use-sync-runs"
|
||||
import type { SyncRun } from "@/hooks/use-sync-runs"
|
||||
import {
|
||||
formatRelativeTime,
|
||||
TRIGGER_TYPE_LABELS,
|
||||
} from "@/components/settings/sync-utils"
|
||||
|
||||
const STATUS_COLORS: Record<string, { dot: string; text: string }> = {
|
||||
completed: { dot: "bg-[#00AC3F]", text: "text-[#00AC3F]" },
|
||||
failed: { dot: "bg-[#EF4444]", text: "text-[#EF4444]" },
|
||||
running: { dot: "bg-[#4BA0FA] animate-pulse", text: "text-[#4BA0FA]" },
|
||||
}
|
||||
|
||||
function pluralize(count: number, noun: string) {
|
||||
return `${count} ${noun}${count === 1 ? "" : "s"}`
|
||||
}
|
||||
|
||||
/** Calendar-day bucket label for grouping runs in the timeline. */
|
||||
function dayLabel(date: string) {
|
||||
const d = new Date(date)
|
||||
if (Number.isNaN(d.getTime())) return "Unknown"
|
||||
const today = new Date()
|
||||
const startOfDay = (x: Date) =>
|
||||
new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime()
|
||||
const diffDays = Math.round((startOfDay(today) - startOfDay(d)) / 86_400_000)
|
||||
if (diffDays <= 0) return "Today"
|
||||
if (diffDays === 1) return "Yesterday"
|
||||
if (diffDays < 7) return `${diffDays} days ago`
|
||||
return d.toLocaleDateString(undefined, { month: "short", day: "numeric" })
|
||||
}
|
||||
|
||||
function StatTile({ value, label }: { value: string; label: string }) {
|
||||
return (
|
||||
<div className="flex-1 bg-[#0D0F14] rounded-[10px] border border-[rgba(82,89,102,0.2)] px-3 py-2 flex flex-col gap-0.5">
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[15px] font-medium text-[#FAFAFA] tabular-nums",
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
<span className={cn(dmSans125ClassName(), "text-[11px] text-[#737373]")}>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SummaryStats({ runs }: { runs: SyncRun[] }) {
|
||||
const totalItems = runs.reduce((sum, r) => sum + r.itemsProcessed, 0)
|
||||
const finished = runs.filter((r) => r.status !== "running")
|
||||
const succeeded = finished.filter((r) => r.status === "completed").length
|
||||
const successRate =
|
||||
finished.length > 0 ? Math.round((succeeded / finished.length) * 100) : null
|
||||
|
||||
return (
|
||||
<div className="flex items-stretch gap-2">
|
||||
<StatTile
|
||||
value={String(runs.length)}
|
||||
label={runs.length === 1 ? "sync" : "syncs"}
|
||||
/>
|
||||
<StatTile
|
||||
value={String(totalItems)}
|
||||
label={totalItems === 1 ? "item" : "items"}
|
||||
/>
|
||||
<StatTile
|
||||
value={successRate === null ? "—" : `${successRate}%`}
|
||||
label="success"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TimelineRow({ run, isLast }: { run: SyncRun; isLast: boolean }) {
|
||||
const colors = STATUS_COLORS[run.status] ?? {
|
||||
dot: "bg-[#4BA0FA] animate-pulse",
|
||||
text: "text-[#4BA0FA]",
|
||||
}
|
||||
const triggerLabel = TRIGGER_TYPE_LABELS[run.triggerType] ?? run.triggerType
|
||||
const statusLabel = run.status.charAt(0).toUpperCase() + run.status.slice(1)
|
||||
|
||||
return (
|
||||
<div className="flex gap-2.5">
|
||||
{/* Timeline rail: dot + connecting line */}
|
||||
<div className="flex flex-col items-center pt-1.5">
|
||||
<div className={cn("size-[7px] rounded-full shrink-0", colors.dot)} />
|
||||
{!isLast && <div className="w-px flex-1 bg-[#1A1D24] mt-1" />}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className={cn("flex-1 min-w-0", isLast ? "pb-0" : "pb-3")}>
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<div className="flex items-baseline gap-1.5 min-w-0">
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[13px] font-medium shrink-0",
|
||||
colors.text,
|
||||
)}
|
||||
>
|
||||
{statusLabel}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[12px] text-[#737373] truncate",
|
||||
)}
|
||||
>
|
||||
· {triggerLabel}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[12px] text-[#737373] shrink-0",
|
||||
)}
|
||||
>
|
||||
{formatRelativeTime(run.startedAt)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{(run.itemsProcessed > 0 || run.itemsFailed > 0) && (
|
||||
<div
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[12px] text-[#737373] mt-0.5",
|
||||
)}
|
||||
>
|
||||
{pluralize(run.itemsProcessed, "item")} processed
|
||||
{run.itemsFailed > 0 && (
|
||||
<span className="text-[#EF4444]">
|
||||
{" "}
|
||||
· {run.itemsFailed} failed
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{run.error && (
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[12px] text-[#EF4444]/80 break-words line-clamp-3 mt-0.5",
|
||||
)}
|
||||
>
|
||||
{run.error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Timeline({ runs }: { runs: SyncRun[] }) {
|
||||
// Group consecutive runs by calendar-day label, preserving server order.
|
||||
const groups: { label: string; runs: SyncRun[] }[] = []
|
||||
for (const run of runs) {
|
||||
const label = dayLabel(run.startedAt)
|
||||
const last = groups.at(-1)
|
||||
if (last && last.label === label) {
|
||||
last.runs.push(run)
|
||||
} else {
|
||||
groups.push({ label, runs: [run] })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{groups.map((group, gi) => (
|
||||
<div key={`${group.label}-${gi}`} className="flex flex-col gap-1.5">
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[10px] uppercase tracking-wide text-[#525966]",
|
||||
)}
|
||||
>
|
||||
{group.label}
|
||||
</span>
|
||||
<div className="flex flex-col">
|
||||
{group.runs.map((run, i) => (
|
||||
<TimelineRow
|
||||
key={run.id}
|
||||
run={run}
|
||||
isLast={i === group.runs.length - 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface SyncHistoryPanelProps {
|
||||
connectionId: string
|
||||
/** Only fetch / render when expanded. */
|
||||
isOpen: boolean
|
||||
}
|
||||
|
||||
/** Inline sync-history view (stats strip + timeline) rendered inside an expanded connection row. */
|
||||
export function SyncHistoryPanel({
|
||||
connectionId,
|
||||
isOpen,
|
||||
}: SyncHistoryPanelProps) {
|
||||
const {
|
||||
data: syncRuns,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
} = useSyncRuns(isOpen ? connectionId : "")
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
const hasRuns = !isLoading && !error && syncRuns && syncRuns.length > 0
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center py-6">
|
||||
<div className="size-5 border-2 border-[#737373] border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && !isLoading && (
|
||||
<div className="flex items-center justify-center gap-2 py-6">
|
||||
<span
|
||||
className={cn(dmSans125ClassName(), "text-[13px] text-[#737373]")}
|
||||
>
|
||||
Failed to load sync history
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => refetch()}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[13px] text-[#4BA0FA] hover:text-[#4BA0FA]/80 underline cursor-pointer",
|
||||
)}
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !error && syncRuns && syncRuns.length === 0 && (
|
||||
<div className="flex items-center justify-center py-6">
|
||||
<span
|
||||
className={cn(dmSans125ClassName(), "text-[13px] text-[#737373]")}
|
||||
>
|
||||
No syncs yet — runs will appear here.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasRuns && (
|
||||
<>
|
||||
<SummaryStats runs={syncRuns} />
|
||||
<Timeline runs={syncRuns} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
95
apps/web/components/settings/sync-status-badge.tsx
Normal file
95
apps/web/components/settings/sync-status-badge.tsx
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import { cn } from "@lib/utils"
|
||||
import { dmSans125ClassName } from "@/lib/fonts"
|
||||
import { formatRelativeTime } from "@/components/settings/sync-utils"
|
||||
|
||||
function deriveStatus(
|
||||
syncInProgress?: boolean,
|
||||
lastSyncedAt?: number,
|
||||
isExpired?: boolean,
|
||||
): "syncing" | "synced" | "expired" | "idle" {
|
||||
if (isExpired) return "expired"
|
||||
if (syncInProgress) return "syncing"
|
||||
if (lastSyncedAt) return "synced"
|
||||
return "idle"
|
||||
}
|
||||
|
||||
interface SyncStatusBadgeProps {
|
||||
syncInProgress?: boolean
|
||||
lastSyncedAt?: number
|
||||
isExpired?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function SyncStatusBadge({
|
||||
syncInProgress,
|
||||
lastSyncedAt,
|
||||
isExpired,
|
||||
className,
|
||||
}: SyncStatusBadgeProps) {
|
||||
const status = deriveStatus(syncInProgress, lastSyncedAt, isExpired)
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-center gap-1.5", className)}>
|
||||
<div
|
||||
className={cn(
|
||||
"size-[6px] rounded-full",
|
||||
status === "syncing" && "bg-[#4BA0FA] animate-pulse",
|
||||
status === "synced" && "bg-[#00AC3F]",
|
||||
status === "expired" && "bg-[#EF4444]",
|
||||
status === "idle" && "bg-[#737373]",
|
||||
)}
|
||||
/>
|
||||
{status === "syncing" && (
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-medium text-[13px] tracking-[-0.13px] text-[#4BA0FA]",
|
||||
)}
|
||||
>
|
||||
Syncing...
|
||||
</span>
|
||||
)}
|
||||
{status === "synced" && (
|
||||
<>
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-medium text-[13px] tracking-[-0.13px] text-[#00AC3F]",
|
||||
)}
|
||||
>
|
||||
Synced
|
||||
</span>
|
||||
<div className="size-[3px] rounded-full bg-[#737373]" />
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-medium text-[12px] tracking-[-0.12px] text-[#737373]",
|
||||
)}
|
||||
>
|
||||
{formatRelativeTime(lastSyncedAt)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{status === "expired" && (
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-medium text-[13px] tracking-[-0.13px] text-[#EF4444]",
|
||||
)}
|
||||
>
|
||||
Disconnected
|
||||
</span>
|
||||
)}
|
||||
{status === "idle" && (
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-medium text-[13px] tracking-[-0.13px] text-[#737373]",
|
||||
)}
|
||||
>
|
||||
Waiting for first sync
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
55
apps/web/components/settings/sync-utils.ts
Normal file
55
apps/web/components/settings/sync-utils.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
/**
|
||||
* Format a date/timestamp into a human-readable relative time string.
|
||||
* Accepts ISO string, epoch milliseconds (number), Date object, or null/undefined.
|
||||
*/
|
||||
export function formatRelativeTime(
|
||||
date: string | number | Date | null | undefined,
|
||||
): string {
|
||||
if (!date) return "Never"
|
||||
const d = typeof date === "number" ? new Date(date) : new Date(date)
|
||||
if (Number.isNaN(d.getTime())) return "Never"
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - d.getTime()
|
||||
|
||||
// Handle future dates (e.g. slight server clock skew)
|
||||
if (diffMs < 0) return "Just now"
|
||||
|
||||
const diffMinutes = Math.floor(diffMs / (1000 * 60))
|
||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
|
||||
const diffDays = Math.floor(diffHours / 24)
|
||||
|
||||
if (diffMinutes < 1) return "Just now"
|
||||
if (diffMinutes < 60) return `${diffMinutes}m ago`
|
||||
if (diffHours < 24) return `${diffHours}h ago`
|
||||
if (diffDays === 1) return "Yesterday"
|
||||
if (diffDays < 7) return `${diffDays} days ago`
|
||||
return d.toLocaleDateString()
|
||||
}
|
||||
|
||||
/** Map backend trigger type enum to user-facing display label */
|
||||
export const TRIGGER_TYPE_LABELS: Record<string, string> = {
|
||||
event: "Webhook",
|
||||
cron: "Scheduled",
|
||||
manual: "Manual",
|
||||
}
|
||||
|
||||
/** Canonical provider → display name map. Import this everywhere instead of duplicating. */
|
||||
export const PROVIDER_DISPLAY_NAMES: Record<string, string> = {
|
||||
"google-drive": "Google Drive",
|
||||
notion: "Notion",
|
||||
onedrive: "OneDrive",
|
||||
gmail: "Gmail",
|
||||
github: "GitHub",
|
||||
"web-crawler": "Web Crawler",
|
||||
s3: "S3",
|
||||
}
|
||||
|
||||
/** Provider type union matching the backend import endpoint */
|
||||
export type ImportProvider =
|
||||
| "google-drive"
|
||||
| "notion"
|
||||
| "onedrive"
|
||||
| "gmail"
|
||||
| "github"
|
||||
| "web-crawler"
|
||||
| "s3"
|
||||
|
|
@ -36,7 +36,7 @@ const XIcon2 = ({ className }: { className?: string }) => (
|
|||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M12.6 0.75H15.054L9.694 6.89L16 15.25H11.063L7.196 10.176L2.771 15.25H0.316L6.05 8.682L0 0.75H5.063L8.558 5.391L12.6 0.75ZM11.74 13.77H13.1L4.324 2.145H2.865L11.74 13.77Z"
|
||||
d="M12.6 0.75H15.05L9.69 6.89L16 15.25H11.06L7.2 10.18L2.77 15.25H0.32L6.05 8.68L0 0.75H5.06L8.56 5.39L12.6 0.75ZM11.74 13.77H13.1L4.32 2.15H2.87L11.74 13.77Z"
|
||||
fill="#737373"
|
||||
/>
|
||||
</svg>
|
||||
|
|
@ -52,7 +52,7 @@ const LinkedInIcon = ({ className }: { className?: string }) => (
|
|||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M3.58065 4.89474H0V16H3.58065V4.89474ZM1.79032 0C0.801613 0 0 0.801613 0 1.79032C0 2.77903 0.801613 3.58065 1.79032 3.58065C2.77903 3.58065 3.58065 2.77903 3.58065 1.79032C3.58065 0.801613 2.77903 0 1.79032 0ZM8.71613 4.89474H5.33871V16H8.71613V10.0645C8.71613 8.46774 9.11774 6.93548 11.1613 6.93548C13.1774 6.93548 13.2097 8.75806 13.2097 10.1613V16H16V9.48387C16 6.74194 15.3871 4.64516 12.1935 4.64516C10.6452 4.64516 9.59677 5.48387 9.16129 6.27419H9.12903V4.89474H8.71613Z"
|
||||
d="M3.58 4.89H0V16H3.58V4.89ZM1.79 0C0.8 0 0 0.8 0 1.79C0 2.78 0.8 3.58 1.79 3.58C2.78 3.58 3.58 2.78 3.58 1.79C3.58 0.8 2.78 0 1.79 0ZM8.72 4.89H5.34V16H8.72V10.06C8.72 8.47 9.12 6.94 11.16 6.94C13.18 6.94 13.21 8.76 13.21 10.16V16H16V9.48C16 6.74 15.39 4.65 12.19 4.65C10.65 4.65 9.6 5.48 9.16 6.27H9.13V4.89H8.72Z"
|
||||
fill="#737373"
|
||||
/>
|
||||
</svg>
|
||||
|
|
@ -68,22 +68,16 @@ const InstagramIcon = ({ className }: { className?: string }) => (
|
|||
aria-hidden="true"
|
||||
>
|
||||
<rect
|
||||
x="1.08333"
|
||||
y="1.08333"
|
||||
x="1.08"
|
||||
y="1.08"
|
||||
width="10.8333"
|
||||
height="10.8333"
|
||||
rx="2.58333"
|
||||
stroke="#737373"
|
||||
strokeWidth="1.08333"
|
||||
/>
|
||||
<circle
|
||||
cx="6.5"
|
||||
cy="6.5"
|
||||
r="2.16667"
|
||||
stroke="#737373"
|
||||
strokeWidth="1.08333"
|
||||
/>
|
||||
<circle cx="9.75" cy="3.25" r="0.8125" fill="#737373" />
|
||||
<circle cx="6.5" cy="6.5" r="2.17" stroke="#737373" strokeWidth="1.08333" />
|
||||
<circle cx="9.75" cy="3.25" r="0.81" fill="#737373" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
|
|
@ -195,11 +189,11 @@ const ThemeButton = ({
|
|||
}) => {
|
||||
const getPreviewContent = () => {
|
||||
if (theme === "black") {
|
||||
return <div className="w-full h-full bg-black rounded" />
|
||||
return <div className="size-full bg-black rounded" />
|
||||
}
|
||||
if (theme === "dark-gradient") {
|
||||
return (
|
||||
<div className="w-full h-full rounded relative overflow-hidden">
|
||||
<div className="size-full rounded relative overflow-hidden">
|
||||
<div className="absolute inset-0 bg-[#030710]" />
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
|
|
@ -212,7 +206,7 @@ const ThemeButton = ({
|
|||
)
|
||||
}
|
||||
return (
|
||||
<div className="w-full h-full rounded relative overflow-hidden">
|
||||
<div className="size-full rounded relative overflow-hidden">
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
|
|
@ -264,7 +258,7 @@ const SocialButton = ({
|
|||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="w-8 h-8 flex items-center justify-center rounded-full hover:bg-white/10 transition-colors"
|
||||
className="size-8 flex items-center justify-center rounded-full hover:bg-white/10 transition-colors"
|
||||
style={{
|
||||
background: "#0D121A",
|
||||
border: "0.875px solid rgba(115, 115, 115, 0.2)",
|
||||
|
|
@ -290,7 +284,11 @@ export function ShareModal({
|
|||
|
||||
const localStorageUsername = useLocalStorageUsername()
|
||||
const displayName =
|
||||
user?.displayUsername || localStorageUsername || user?.name || ""
|
||||
user?.displayUsername ||
|
||||
localStorageUsername ||
|
||||
user?.name ||
|
||||
user?.email?.split("@")[0] ||
|
||||
""
|
||||
const userName = displayName ? `${displayName.split(" ")[0]}'s` : "Your"
|
||||
|
||||
const capturePreview = useCallback(async (): Promise<Blob | null> => {
|
||||
|
|
@ -413,7 +411,7 @@ export function ShareModal({
|
|||
</DialogHeader>
|
||||
<DialogPrimitive.Close
|
||||
onClick={handleClose}
|
||||
className="bg-[#0D121A] w-7 h-7 flex items-center justify-center focus:ring-ring rounded-full 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 border border-[rgba(115,115,115,0.2)] shrink-0"
|
||||
className="bg-[#0D121A] size-7 flex items-center justify-center focus:ring-ring rounded-full 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 border border-[rgba(115,115,115,0.2)] shrink-0"
|
||||
style={{
|
||||
boxShadow:
|
||||
"0 0.711px 2.842px 0 rgba(0, 0, 0, 0.25), 0.178px 0.178px 0.178px 0 rgba(255, 255, 255, 0.10) inset",
|
||||
|
|
|
|||
|
|
@ -297,9 +297,15 @@ export function SpaceSelector({
|
|||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
align={compact ? "end" : "start"}
|
||||
alignOffset={0}
|
||||
sideOffset={compact ? 8 : 4}
|
||||
collisionPadding={compact ? 16 : 8}
|
||||
className={cn(
|
||||
"min-w-[200px] max-w-[min(calc(100vw-1.5rem),20rem)] overflow-hidden p-1.5 rounded-xl border border-[#2E3033] shadow-[0px_1.5px_20px_0px_rgba(0,0,0,0.65)]",
|
||||
"min-w-[200px] overflow-hidden p-1.5 rounded-xl border border-[#2E3033] shadow-[0px_1.5px_20px_0px_rgba(0,0,0,0.65)]",
|
||||
compact
|
||||
? "w-[min(calc(100vw-3rem),18rem)]"
|
||||
: "max-w-[min(calc(100vw-2rem),20rem)]",
|
||||
dmSansClassName(),
|
||||
contentClassName,
|
||||
)}
|
||||
|
|
@ -472,7 +478,7 @@ export function SpaceSelector({
|
|||
</DialogDescription>
|
||||
</div>
|
||||
<DialogPrimitive.Close
|
||||
className="bg-[#0D121A] w-7 h-7 flex items-center justify-center focus:ring-ring rounded-full 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 border border-[rgba(115,115,115,0.2)] shrink-0"
|
||||
className="bg-[#0D121A] size-7 flex items-center justify-center focus:ring-ring rounded-full 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 border border-[rgba(115,115,115,0.2)] shrink-0"
|
||||
style={{
|
||||
boxShadow:
|
||||
"inset 1.313px 1.313px 3.938px 0px rgba(0,0,0,0.7)",
|
||||
|
|
@ -505,14 +511,14 @@ export function SpaceSelector({
|
|||
>
|
||||
<div
|
||||
className={cn(
|
||||
"w-4 h-4 rounded-full border-2 flex items-center justify-center shrink-0",
|
||||
"size-4 rounded-full border-2 flex items-center justify-center shrink-0",
|
||||
deleteDialog.action === "move"
|
||||
? "border-blue-500"
|
||||
: "border-[#737373]",
|
||||
)}
|
||||
>
|
||||
{deleteDialog.action === "move" && (
|
||||
<div className="w-2 h-2 rounded-full bg-blue-500" />
|
||||
<div className="size-2 rounded-full bg-blue-500" />
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[#fafafa] text-sm font-medium">
|
||||
|
|
@ -602,14 +608,14 @@ export function SpaceSelector({
|
|||
>
|
||||
<div
|
||||
className={cn(
|
||||
"w-4 h-4 rounded-full border-2 flex items-center justify-center shrink-0",
|
||||
"size-4 rounded-full border-2 flex items-center justify-center shrink-0",
|
||||
deleteDialog.action === "delete"
|
||||
? "border-red-500"
|
||||
: "border-[#737373]",
|
||||
)}
|
||||
>
|
||||
{deleteDialog.action === "delete" && (
|
||||
<div className="w-2 h-2 rounded-full bg-red-500" />
|
||||
<div className="size-2 rounded-full bg-red-500" />
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[#fafafa] text-sm font-medium">
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ const strokeVariants: Variants = {
|
|||
export function SuperLoader({
|
||||
size = 42,
|
||||
colorClassName = "text-sky-400",
|
||||
label = "Loading...",
|
||||
label = "Loading…",
|
||||
className = "",
|
||||
}: NovaPathLoaderProps) {
|
||||
const prefersReducedMotion = useReducedMotion()
|
||||
|
|
@ -56,7 +56,7 @@ export function SuperLoader({
|
|||
height={size * 0.5}
|
||||
className={`shrink-0 ${colorClassName}`}
|
||||
>
|
||||
<title>Loading...</title>
|
||||
<title>Loading…</title>
|
||||
{/* Right path */}
|
||||
<motion.path
|
||||
d={PATH_RIGHT}
|
||||
|
|
|
|||
|
|
@ -17,10 +17,12 @@ export function TextEditor({
|
|||
content: initialContent,
|
||||
onContentChange,
|
||||
onSubmit,
|
||||
debounceMs = 500,
|
||||
}: {
|
||||
content: string | undefined
|
||||
onContentChange: (content: string) => void
|
||||
onSubmit: () => void
|
||||
debounceMs?: number
|
||||
}) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const editorRef = useRef<Editor | null>(null)
|
||||
|
|
@ -36,7 +38,7 @@ export function TextEditor({
|
|||
const json = editor.getJSON()
|
||||
const markdown = editor.storage.markdown?.manager?.serialize(json) ?? ""
|
||||
onContentChange?.(markdown)
|
||||
}, 500)
|
||||
}, debounceMs)
|
||||
|
||||
const editor = useEditor({
|
||||
extensions,
|
||||
|
|
@ -48,6 +50,13 @@ export function TextEditor({
|
|||
},
|
||||
onUpdate: ({ editor }) => {
|
||||
editorRef.current = editor
|
||||
if (!hasUserEditedRef.current) return
|
||||
if (debounceMs === 0) {
|
||||
const json = editor.getJSON()
|
||||
const markdown = editor.storage.markdown?.manager?.serialize(json) ?? ""
|
||||
onContentChange?.(markdown)
|
||||
return
|
||||
}
|
||||
debouncedUpdates(editor)
|
||||
},
|
||||
editorProps: {
|
||||
|
|
@ -120,7 +129,7 @@ export function TextEditor({
|
|||
tabIndex={0}
|
||||
ref={containerRef}
|
||||
onClick={handleClick}
|
||||
className="w-full h-full outline-none prose prose-invert max-w-none [&_.ProseMirror]:outline-none [&_.ProseMirror]:focus:outline-none [&_.ProseMirror-focused]:outline-none text-editor-prose cursor-text"
|
||||
className="size-full cursor-text outline-none prose prose-invert max-w-none text-editor-prose [&_.ProseMirror]:min-h-full [&_.ProseMirror]:outline-none [&_.ProseMirror]:text-[15px] [&_.ProseMirror]:leading-6 [&_.ProseMirror]:text-[#D7DEE8] [&_.ProseMirror-focused]:outline-none [&_.ProseMirror]:focus:outline-none"
|
||||
>
|
||||
<EditorContent editor={editor} />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -187,7 +187,7 @@ function TimelineCard({
|
|||
type={doc.type}
|
||||
source={doc.source ?? undefined}
|
||||
url={doc.url ?? undefined}
|
||||
className="w-3.5 h-3.5 shrink-0 opacity-60"
|
||||
className="size-3.5 shrink-0 opacity-60"
|
||||
/>
|
||||
<span className="text-[10px] text-white/40 uppercase tracking-widest">
|
||||
{typeLabel}
|
||||
|
|
@ -280,14 +280,14 @@ function GroupCard({
|
|||
type={firstDoc.type}
|
||||
source={firstDoc.source ?? undefined}
|
||||
url={firstDoc.url ?? undefined}
|
||||
className="w-3.5 h-3.5 shrink-0 opacity-60"
|
||||
className="size-3.5 shrink-0 opacity-60"
|
||||
/>
|
||||
<span className="text-[13px] text-white/75 font-medium whitespace-nowrap shrink-0">
|
||||
{countLabel}
|
||||
</span>
|
||||
{preview && (
|
||||
<span className="text-[12px] text-white/35 truncate">
|
||||
— {preview}
|
||||
· {preview}
|
||||
</span>
|
||||
)}
|
||||
{totalMemories > 0 && (
|
||||
|
|
@ -308,7 +308,7 @@ function GroupCard({
|
|||
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
"w-3.5 h-3.5 text-white/20 shrink-0 transition-transform duration-200",
|
||||
"size-3.5 text-white/20 shrink-0 transition-transform duration-200",
|
||||
isExpanded && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -141,14 +141,14 @@ export function UserProfileMenu({
|
|||
onClick={() => router.push("/settings")}
|
||||
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
|
||||
>
|
||||
<Settings className="h-4 w-4 text-[#737373]" />
|
||||
<Settings className="size-4 text-[#737373]" />
|
||||
Settings
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={handleTryOnboarding}
|
||||
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4 text-[#737373]" />
|
||||
<RotateCcw className="size-4 text-[#737373]" />
|
||||
Try onboarding
|
||||
</DropdownMenuItem>
|
||||
{onOpenFeedback ? (
|
||||
|
|
@ -156,7 +156,7 @@ export function UserProfileMenu({
|
|||
onClick={onOpenFeedback}
|
||||
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
|
||||
>
|
||||
<LifeBuoy className="h-4 w-4 text-[#737373]" />
|
||||
<LifeBuoy className="size-4 text-[#737373]" />
|
||||
Feedback
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
|
|
@ -166,7 +166,7 @@ export function UserProfileMenu({
|
|||
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
|
||||
>
|
||||
<a href="mailto:support@supermemory.com">
|
||||
<HelpCircle className="h-4 w-4 text-[#737373]" />
|
||||
<HelpCircle className="size-4 text-[#737373]" />
|
||||
Help & Support
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
|
|
@ -180,7 +180,7 @@ export function UserProfileMenu({
|
|||
rel="noreferrer"
|
||||
>
|
||||
<svg
|
||||
className="h-4 w-4 text-[#737373]"
|
||||
className="size-4 text-[#737373]"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
|
|
@ -195,7 +195,7 @@ export function UserProfileMenu({
|
|||
onClick={handleSignOut}
|
||||
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
|
||||
>
|
||||
<LogOut className="h-4 w-4 text-[#737373]" />
|
||||
<LogOut className="size-4 text-[#737373]" />
|
||||
Logout
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
|
|
|
|||
|
|
@ -65,6 +65,14 @@
|
|||
scrollbar-color: var(--sm-scrollbar-thumb) transparent;
|
||||
}
|
||||
|
||||
.scrollbar-none {
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.scrollbar-none::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sm-tweet-theme .react-tweet-theme {
|
||||
--tweet-container-margin: 0px;
|
||||
font-size: inherit !important;
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ export function useAccountMemberships() {
|
|||
queryFn: async () => {
|
||||
const res = await fetch(`${API_BASE}/v3/auth/account/memberships`, {
|
||||
credentials: "include",
|
||||
headers: { "X-App-Source": "nova" },
|
||||
})
|
||||
if (!res.ok) {
|
||||
throw new Error("Failed to load organizations")
|
||||
|
|
@ -41,6 +42,7 @@ export function useLeaveNonOwnerMemberships() {
|
|||
{
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "X-App-Source": "nova" },
|
||||
},
|
||||
)
|
||||
if (!res.ok) {
|
||||
|
|
@ -73,7 +75,7 @@ export function useDeleteUserAccount() {
|
|||
const res = await fetch(`${API_BASE}/v3/auth/account/delete`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
headers: { "Content-Type": "application/json", "X-App-Source": "nova" },
|
||||
body: JSON.stringify({
|
||||
confirmation,
|
||||
...(notifyOnComplete === true ? { notifyOnComplete: true } : {}),
|
||||
|
|
|
|||
46
apps/web/hooks/use-sync-runs.ts
Normal file
46
apps/web/hooks/use-sync-runs.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
"use client"
|
||||
|
||||
import { $fetch } from "@lib/api"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
|
||||
/**
|
||||
* Mirrors the Zod schema at `apiSchema["@get/connections/:connectionId/sync-runs"].output`.
|
||||
* Keep in sync with `packages/lib/api.ts` if fields are added/removed.
|
||||
*/
|
||||
export type SyncRun = {
|
||||
id: string
|
||||
connectionId: string
|
||||
status: "running" | "completed" | "failed"
|
||||
triggerType: "event" | "cron" | "manual"
|
||||
startedAt: string
|
||||
completedAt: string | null
|
||||
itemsProcessed: number
|
||||
itemsFailed: number
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export function useSyncRuns(connectionId: string) {
|
||||
return useQuery<SyncRun[]>({
|
||||
queryKey: ["sync-runs", connectionId],
|
||||
queryFn: async () => {
|
||||
const response = await $fetch(
|
||||
"@get/connections/:connectionId/sync-runs",
|
||||
{ params: { connectionId } },
|
||||
)
|
||||
if (response.error) {
|
||||
throw new Error("Failed to fetch sync runs")
|
||||
}
|
||||
return response.data as SyncRun[]
|
||||
},
|
||||
enabled: !!connectionId,
|
||||
staleTime: 30 * 1000,
|
||||
refetchOnMount: "always",
|
||||
refetchInterval: (query) => {
|
||||
const runs = query.state.data as SyncRun[] | undefined
|
||||
if (runs?.some((r) => r.status === "running")) {
|
||||
return 5000
|
||||
}
|
||||
return false
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -4,8 +4,15 @@ import { calculateUsagePercent, getDaysRemaining } from "@/lib/billing-utils"
|
|||
|
||||
export type PlanType = "free" | "pro" | "scale" | "enterprise"
|
||||
|
||||
const TOKEN_METER_IDS = [
|
||||
"sm_tokens_text",
|
||||
"sm_tokens_rich",
|
||||
"sm_superrag_text",
|
||||
"sm_superrag_rich",
|
||||
] as const
|
||||
|
||||
export function useTokenUsage(autumn: ReturnType<typeof useCustomer>) {
|
||||
const status = getSubscriptionStatus(autumn.customer?.products)
|
||||
const status = getSubscriptionStatus(autumn.data?.subscriptions)
|
||||
|
||||
let currentPlan: PlanType = "free"
|
||||
if (isAllowedFrom(status, "api_enterprise")) {
|
||||
|
|
@ -18,30 +25,38 @@ export function useTokenUsage(autumn: ReturnType<typeof useCustomer>) {
|
|||
|
||||
const hasPaidPlan = currentPlan !== "free"
|
||||
|
||||
// Get token usage from autumn customer features
|
||||
const tokensFeature = autumn.customer?.features?.api_tokens
|
||||
const tokensUsed = tokensFeature?.usage ?? 0
|
||||
const tokensLimit = tokensFeature?.included_usage ?? 0
|
||||
const tokensResetAt = tokensFeature?.next_reset_at
|
||||
const balances = autumn.data?.balances ?? {}
|
||||
|
||||
// Get search queries usage from autumn customer features
|
||||
const searchesFeature = autumn.customer?.features?.api_search_queries
|
||||
const searchesUsed = searchesFeature?.usage ?? 0
|
||||
const searchesLimit = searchesFeature?.included_usage ?? 0
|
||||
const tokensUsed = TOKEN_METER_IDS.reduce((sum, id) => {
|
||||
const balance = balances[id]
|
||||
return sum + (balance?.usage ?? 0)
|
||||
}, 0)
|
||||
|
||||
const searchesBalance = balances.sm_search_queries
|
||||
const searchesUsed = searchesBalance?.usage ?? 0
|
||||
|
||||
const usdBalance = balances.usd_credits
|
||||
const usdIncluded = usdBalance?.granted ?? 0
|
||||
const usdSpent = usdBalance?.usage ?? 0
|
||||
|
||||
const planUsagePct =
|
||||
usdIncluded > 0 ? calculateUsagePercent(usdSpent, usdIncluded) : 0
|
||||
|
||||
const resetAt =
|
||||
usdBalance?.nextResetAt ??
|
||||
balances.sm_tokens_text?.nextResetAt ??
|
||||
searchesBalance?.nextResetAt ??
|
||||
undefined
|
||||
const daysRemaining = getDaysRemaining(resetAt)
|
||||
|
||||
const isLoading = autumn.isLoading
|
||||
|
||||
const tokensPercent = calculateUsagePercent(tokensUsed, tokensLimit)
|
||||
const searchesPercent = calculateUsagePercent(searchesUsed, searchesLimit)
|
||||
const daysRemaining = getDaysRemaining(tokensResetAt)
|
||||
|
||||
return {
|
||||
tokensUsed,
|
||||
tokensLimit,
|
||||
tokensPercent,
|
||||
searchesUsed,
|
||||
searchesLimit,
|
||||
searchesPercent,
|
||||
usdIncluded,
|
||||
usdSpent,
|
||||
planUsagePct,
|
||||
currentPlan,
|
||||
hasPaidPlan,
|
||||
isLoading,
|
||||
|
|
|
|||
97
apps/web/hooks/use-trigger-sync.ts
Normal file
97
apps/web/hooks/use-trigger-sync.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
"use client"
|
||||
|
||||
import { $fetch } from "@lib/api"
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { toast } from "sonner"
|
||||
import type { ConnectionResponseSchema } from "@repo/validation/api"
|
||||
import type { z } from "zod"
|
||||
import type { ImportProvider } from "@/components/settings/sync-utils"
|
||||
import type { SyncRun } from "@/hooks/use-sync-runs"
|
||||
|
||||
type Connection = z.infer<typeof ConnectionResponseSchema>
|
||||
|
||||
export function useTriggerSync() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
provider,
|
||||
containerTags,
|
||||
}: {
|
||||
// connectionId isn't sent to the backend (import is keyed by provider) — kept for cache updates
|
||||
connectionId: string
|
||||
provider: ImportProvider
|
||||
containerTags?: string[]
|
||||
}) => {
|
||||
const response = await $fetch("@post/connections/:provider/import", {
|
||||
params: { provider },
|
||||
body: { containerTags },
|
||||
})
|
||||
if (response.error) {
|
||||
throw new Error(
|
||||
(response.error as { message?: string })?.message ||
|
||||
"Failed to trigger sync",
|
||||
)
|
||||
}
|
||||
return response.data
|
||||
},
|
||||
// Optimistically flip to "syncing" so the badge/button update instantly; the 5s poll then converges on real state
|
||||
onMutate: async (variables) => {
|
||||
await queryClient.cancelQueries({ queryKey: ["connections"] })
|
||||
const previousConnections = queryClient.getQueryData<Connection[]>([
|
||||
"connections",
|
||||
])
|
||||
queryClient.setQueryData<Connection[]>(["connections"], (old) =>
|
||||
old?.map((c) =>
|
||||
c.provider === variables.provider
|
||||
? {
|
||||
...c,
|
||||
metadata: {
|
||||
...((c.metadata as Record<string, unknown> | null) ?? {}),
|
||||
syncInProgress: true,
|
||||
},
|
||||
}
|
||||
: c,
|
||||
),
|
||||
)
|
||||
|
||||
const syncRunsKey = ["sync-runs", variables.connectionId]
|
||||
const previousSyncRuns = queryClient.getQueryData<SyncRun[]>(syncRunsKey)
|
||||
if (previousSyncRuns) {
|
||||
const optimisticRun: SyncRun = {
|
||||
id: `optimistic-${Date.now()}`,
|
||||
connectionId: variables.connectionId,
|
||||
status: "running",
|
||||
triggerType: "manual",
|
||||
startedAt: new Date().toISOString(),
|
||||
completedAt: null,
|
||||
itemsProcessed: 0,
|
||||
itemsFailed: 0,
|
||||
error: null,
|
||||
}
|
||||
queryClient.setQueryData<SyncRun[]>(syncRunsKey, [
|
||||
optimisticRun,
|
||||
...previousSyncRuns,
|
||||
])
|
||||
}
|
||||
|
||||
return { previousConnections, previousSyncRuns, syncRunsKey }
|
||||
},
|
||||
// Don't invalidate connections/sync-runs here — an immediate refetch races the backend and clobbers the optimistic state; the 5s polls handle it
|
||||
onSuccess: () => {
|
||||
toast.success("Sync started")
|
||||
queryClient.invalidateQueries({ queryKey: ["processing-documents"] })
|
||||
},
|
||||
onError: (error, _variables, context) => {
|
||||
if (context?.previousConnections !== undefined) {
|
||||
queryClient.setQueryData(["connections"], context.previousConnections)
|
||||
}
|
||||
if (context?.previousSyncRuns !== undefined) {
|
||||
queryClient.setQueryData(context.syncRunsKey, context.previousSyncRuns)
|
||||
}
|
||||
toast.error("Failed to start sync", {
|
||||
description: error instanceof Error ? error.message : "Unknown error",
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -1,12 +1,3 @@
|
|||
/**
|
||||
* Convert tokens to credits
|
||||
* 100,000 tokens = 1 credit
|
||||
* Uses floor rounding (2.9 credits shows as 2, only shows 3 when it hits 3.0)
|
||||
*/
|
||||
export function tokensToCredits(tokens: number): number {
|
||||
return Math.floor(tokens / 100_000)
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a number with K/M suffix for display
|
||||
* @example formatUsageNumber(1500000) => "1.5M"
|
||||
|
|
|
|||
28
apps/web/lib/constants.ts
Normal file
28
apps/web/lib/constants.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
/**
|
||||
* sessionStorage key used to stash the full plugin-connect URL so that
|
||||
* the onboarding flow can redirect back to it after the user creates
|
||||
* their first organization.
|
||||
*/
|
||||
export const PENDING_CONNECT_URL_KEY = "supermemory-pending-connect-url"
|
||||
|
||||
/**
|
||||
* Consume the pending plugin-connect URL from sessionStorage (if any)
|
||||
* and return the relative path to redirect to. Returns `null` when
|
||||
* there is nothing stored or the stored value is invalid.
|
||||
*
|
||||
* This is extracted into a shared helper so that every onboarding
|
||||
* completion / skip path can reuse it (SetupHeader, IntegrationsStep,
|
||||
* InitialHeader, etc.).
|
||||
*/
|
||||
export function consumePendingConnectUrl(): string | null {
|
||||
try {
|
||||
const pendingUrl = sessionStorage.getItem(PENDING_CONNECT_URL_KEY)
|
||||
if (!pendingUrl) return null
|
||||
sessionStorage.removeItem(PENDING_CONNECT_URL_KEY)
|
||||
const parsed = new URL(pendingUrl)
|
||||
return parsed.pathname + parsed.search + parsed.hash
|
||||
} catch (e) {
|
||||
console.warn("Failed to access sessionStorage for pending connect URL", e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
|
@ -189,6 +189,29 @@ export function toLinkedInProfileUrl(handle: string): string {
|
|||
return `https://linkedin.com/in/${handle.trim()}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a URL points to a supermemory-hosted file.
|
||||
* Matches the public bucket domain (files.supermemory.ai) and
|
||||
* presigned R2 URLs whose hostname ends with `.r2.cloudflarestorage.com`.
|
||||
*
|
||||
* Note: The R2 check is intentionally broad — it matches any Cloudflare R2
|
||||
* presigned URL, not only supermemory's account. This is acceptable because
|
||||
* the function is only called on `document.url` values returned by our own
|
||||
* backend, where all R2 URLs originate from the supermemory bucket.
|
||||
* If user-supplied external R2 URLs ever appear in this field, tighten the
|
||||
* check by also validating the account-id subdomain or the bucket path prefix.
|
||||
*/
|
||||
export const isSupermemoryFileUrl = (url: string): boolean => {
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
if (parsed.hostname === "files.supermemory.ai") return true
|
||||
if (parsed.hostname.endsWith(".r2.cloudflarestorage.com")) return true
|
||||
return false
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the favicon URL for a given URL.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@
|
|||
"@types/dompurify": "^3.2.0",
|
||||
"agents": "^0.4.0",
|
||||
"ai": "^6.0.168",
|
||||
"autumn-js": "0.0.116",
|
||||
"autumn-js": "1.2.12",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"d3-force": "^3.0.0",
|
||||
|
|
|
|||
BIN
apps/web/public/images/plugins/codex.png
Normal file
BIN
apps/web/public/images/plugins/codex.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.4 KiB |
BIN
apps/web/public/images/plugins/cursor.png
Normal file
BIN
apps/web/public/images/plugins/cursor.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 35 KiB |
81
bun.lock
81
bun.lock
|
|
@ -184,7 +184,7 @@
|
|||
"@types/dompurify": "^3.2.0",
|
||||
"agents": "^0.4.0",
|
||||
"ai": "^6.0.168",
|
||||
"autumn-js": "0.0.116",
|
||||
"autumn-js": "1.2.12",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"d3-force": "^3.0.0",
|
||||
|
|
@ -295,15 +295,18 @@
|
|||
},
|
||||
"packages/memory-graph": {
|
||||
"name": "@supermemory/memory-graph",
|
||||
"version": "0.2.0",
|
||||
"version": "0.2.1",
|
||||
"dependencies": {
|
||||
"d3-force": "^3.0.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/d3-force": "^3.0.10",
|
||||
"@types/react": "^19.2.2",
|
||||
"@types/react-dom": "^19.2.2",
|
||||
"@vitejs/plugin-react": "^5.1.0",
|
||||
"happy-dom": "^20.9.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.2.1",
|
||||
"vitest": "^3.2.4",
|
||||
|
|
@ -315,7 +318,7 @@
|
|||
},
|
||||
"packages/tools": {
|
||||
"name": "@supermemory/tools",
|
||||
"version": "1.4.7",
|
||||
"version": "2.0.0",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "^2.0.25",
|
||||
"@ai-sdk/openai": "^2.0.23",
|
||||
|
|
@ -397,6 +400,8 @@
|
|||
|
||||
"@a2a-js/sdk": ["@a2a-js/sdk@0.2.5", "", { "dependencies": { "@types/cors": "^2.8.17", "@types/express": "^4.17.23", "body-parser": "^2.2.0", "cors": "^2.8.5", "express": "^4.21.2", "uuid": "^11.1.0" } }, "sha512-VTDuRS5V0ATbJ/LkaQlisMnTAeYKXAK6scMguVBstf+KIBQ7HIuKhiXLv+G/hvejkV+THoXzoNifInAkU81P1g=="],
|
||||
|
||||
"@adobe/css-tools": ["@adobe/css-tools@4.4.4", "", {}, "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg=="],
|
||||
|
||||
"@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@3.0.88", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.70", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.22", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-boKql8OkCiBCEK6ETc6BCwHxAzWhleiAkSvZbxU/UjmusZXyfTktk+KAQOu+y6ct5R6vu3E1Iqfgpk6GiBy1ZQ=="],
|
||||
|
||||
"@ai-sdk/anthropic": ["@ai-sdk/anthropic@1.2.12", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "@ai-sdk/provider-utils": "2.2.8" }, "peerDependencies": { "zod": "^3.0.0" } }, "sha512-YSzjlko7JvuiyQFmI9RN1tNZdEiZxc+6xld/0tq/VkJaHpEzGAb1yiNxxvmYVcjvfu/PcvCxAAYXmTYQQ63IHQ=="],
|
||||
|
|
@ -1871,6 +1876,12 @@
|
|||
|
||||
"@tanstack/virtual-core": ["@tanstack/virtual-core@3.13.20", "", {}, "sha512-Djnq7ujPWcRAKyDpwqL4JDe6ZTN9AWAqE2wLstBlsEu4OnO7Im0p8KsHzLU7TPIvLQgNKpkn9EmgBH6xs8yjfA=="],
|
||||
|
||||
"@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="],
|
||||
|
||||
"@testing-library/jest-dom": ["@testing-library/jest-dom@6.9.1", "", { "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", "picocolors": "^1.1.1", "redent": "^3.0.0" } }, "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA=="],
|
||||
|
||||
"@testing-library/react": ["@testing-library/react@16.3.2", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "@testing-library/dom": "^10.0.0", "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g=="],
|
||||
|
||||
"@tiptap/core": ["@tiptap/core@3.20.0", "", { "peerDependencies": { "@tiptap/pm": "^3.20.0" } }, "sha512-aC9aROgia/SpJqhsXFiX9TsligL8d+oeoI8W3u00WI45s0VfsqjgeKQLDLF7Tu7hC+7F02teC84SAHuup003VQ=="],
|
||||
|
||||
"@tiptap/extension-blockquote": ["@tiptap/extension-blockquote@3.20.0", "", { "peerDependencies": { "@tiptap/core": "^3.20.0" } }, "sha512-LQzn6aGtL4WXz2+rYshl/7/VnP2qJTpD7fWL96GXAzhqviPEY1bJES7poqJb3MU/gzl8VJUVzVzU1VoVfUKlbA=="],
|
||||
|
|
@ -1955,6 +1966,8 @@
|
|||
|
||||
"@types/acorn": ["@types/acorn@4.0.6", "", { "dependencies": { "@types/estree": "*" } }, "sha512-veQTnWP+1D/xbxVrPC3zHnCZRjSrKfhbMUlEA43iMZLu7EsnTtkJklIuwrCPbOi8YkvDQAiW05VQQFvvz9oieQ=="],
|
||||
|
||||
"@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="],
|
||||
|
||||
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
|
||||
|
||||
"@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
|
||||
|
|
@ -2145,6 +2158,10 @@
|
|||
|
||||
"@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="],
|
||||
|
||||
"@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="],
|
||||
|
||||
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
|
||||
|
||||
"@types/yauzl": ["@types/yauzl@2.10.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q=="],
|
||||
|
||||
"@typescript/vfs": ["@typescript/vfs@1.6.4", "", { "dependencies": { "debug": "^4.4.3" }, "peerDependencies": { "typescript": "*" } }, "sha512-PJFXFS4ZJKiJ9Qiuix6Dz/OwEIqHD7Dme1UwZhTK11vR+5dqW2ACbdndWQexBzCx+CPuMe5WBYQWCsFyGlQLlQ=="],
|
||||
|
|
@ -2293,6 +2310,8 @@
|
|||
|
||||
"aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
|
||||
|
||||
"aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="],
|
||||
|
||||
"arkregex": ["arkregex@0.0.3", "", { "dependencies": { "@ark/util": "0.55.0" } }, "sha512-bU21QJOJEFJK+BPNgv+5bVXkvRxyAvgnon75D92newgHxkBJTgiFwQxusyViYyJkETsddPlHyspshDQcCzmkNg=="],
|
||||
|
||||
"arktype": ["arktype@2.1.27", "", { "dependencies": { "@ark/schema": "0.55.0", "@ark/util": "0.55.0", "arkregex": "0.0.3" } }, "sha512-enctOHxI4SULBv/TDtCVi5M8oLd4J5SVlPUblXDzSsOYQNMzmVbUosGBnJuZDKmFlN5Ie0/QVEuTE+Z5X1UhsQ=="],
|
||||
|
|
@ -2345,7 +2364,7 @@
|
|||
|
||||
"auto-bind": ["auto-bind@5.0.1", "", {}, "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg=="],
|
||||
|
||||
"autumn-js": ["autumn-js@0.0.116", "", { "dependencies": { "rou3": "^0.6.1", "swr": "^2.3.3", "zod": "^3.24.1" }, "peerDependencies": { "better-auth": "^1.2.12", "better-call": "^1.0.12" }, "optionalPeers": ["better-auth", "better-call"] }, "sha512-h/kZKDTuUk7MOAnRSjyWuf++2BVH5HF/yIeTOFEL3YwLXtWHBjqlYSsHlQK7BH7R1TC1fPsXGKMRGRjgcJD/tA=="],
|
||||
"autumn-js": ["autumn-js@1.2.12", "", { "dependencies": { "query-string": "^9.2.2", "rou3": "^0.6.1", "zod": "^4.0.0" }, "peerDependencies": { "better-auth": "^1.6.0", "better-call": "^1.0.12", "express": "^5.2.1", "hono": "^4.0.0", "next": "^14.0.0 || ^15.0.0 || ^16.0.0", "react": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["better-auth", "better-call", "express", "hono", "next", "react"] }, "sha512-UALRS5oD2pkPQqMfgM5op7EoAZazbLJl8+sAyFeNzGi+sGUdEUen/SopnQtqiXxtWpTDlXf8UbJqL2LkeG+ltg=="],
|
||||
|
||||
"available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="],
|
||||
|
||||
|
|
@ -2603,6 +2622,8 @@
|
|||
|
||||
"css-what": ["css-what@6.2.2", "", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="],
|
||||
|
||||
"css.escape": ["css.escape@1.5.1", "", {}, "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg=="],
|
||||
|
||||
"cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
|
||||
|
||||
"cssfilter": ["cssfilter@0.0.10", "", {}, "sha512-FAaLDaplstoRsDR8XGYH51znUN0UY7nMc6Z9/fvE8EXGwvJE9hu7W2vHwx1+bd6gCYnln9nLbzxFTrcO9YQDZw=="],
|
||||
|
|
@ -2713,6 +2734,8 @@
|
|||
|
||||
"decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="],
|
||||
|
||||
"decode-uri-component": ["decode-uri-component@0.4.1", "", {}, "sha512-+8VxcR21HhTy8nOt6jf20w0c9CADrw1O8d+VZ/YzzCt4bJ3uBjw+D1q2osAB8RnpwwaeYBxy0HyKQxD5JBMuuQ=="],
|
||||
|
||||
"decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="],
|
||||
|
||||
"dedent": ["dedent@1.7.2", "", { "peerDependencies": { "babel-plugin-macros": "^3.1.0" }, "optionalPeers": ["babel-plugin-macros"] }, "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA=="],
|
||||
|
|
@ -2783,6 +2806,8 @@
|
|||
|
||||
"docs-test": ["docs-test@workspace:packages/docs-test"],
|
||||
|
||||
"dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="],
|
||||
|
||||
"dom-helpers": ["dom-helpers@5.2.1", "", { "dependencies": { "@babel/runtime": "^7.8.7", "csstype": "^3.0.2" } }, "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA=="],
|
||||
|
||||
"dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="],
|
||||
|
|
@ -2855,7 +2880,7 @@
|
|||
|
||||
"enquirer": ["enquirer@2.4.1", "", { "dependencies": { "ansi-colors": "^4.1.1", "strip-ansi": "^6.0.1" } }, "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ=="],
|
||||
|
||||
"entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
|
||||
"entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
|
||||
|
||||
"env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="],
|
||||
|
||||
|
|
@ -3025,6 +3050,8 @@
|
|||
|
||||
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
|
||||
|
||||
"filter-obj": ["filter-obj@5.1.0", "", {}, "sha512-qWeTREPoT7I0bifpPUXtxkZJ1XJzxWtfoWWkdVGqa+eCr3SHW/Ocp89o8vLvbUuQnadybJpjOKu4V+RwO6sGng=="],
|
||||
|
||||
"finalhandler": ["finalhandler@1.3.2", "", { "dependencies": { "debug": "2.6.9", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "statuses": "~2.0.2", "unpipe": "~1.0.0" } }, "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg=="],
|
||||
|
||||
"find-process": ["find-process@2.1.0", "", { "dependencies": { "chalk": "~4.1.2", "commander": "^12.1.0", "loglevel": "^1.9.2" }, "bin": { "find-process": "dist/cjs/bin/find-process.js" } }, "sha512-uf1F6mFDAltKaVHf4/zlXstW/k01qPwv+OB6bMRnuIycO6dTmQo+sUW1Uj/xJzGmmVarYW4ZM7TBBbBy6f/y0g=="],
|
||||
|
|
@ -3161,6 +3188,8 @@
|
|||
|
||||
"hachure-fill": ["hachure-fill@0.5.2", "", {}, "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg=="],
|
||||
|
||||
"happy-dom": ["happy-dom@20.9.0", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.18.3" } }, "sha512-GZZ9mKe8r646NUAf/zemnGbjYh4Bt8/MqASJY+pSm5ZDtc3YQox+4gsLI7yi1hba6o+eCsGxpHn5+iEVn31/FQ=="],
|
||||
|
||||
"has-bigints": ["has-bigints@1.1.0", "", {}, "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg=="],
|
||||
|
||||
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
|
||||
|
|
@ -3277,7 +3306,7 @@
|
|||
|
||||
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
|
||||
|
||||
"indent-string": ["indent-string@5.0.0", "", {}, "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg=="],
|
||||
"indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="],
|
||||
|
||||
"index-array-by": ["index-array-by@1.4.2", "", {}, "sha512-SP23P27OUKzXWEC/TOyWlwLviofQkCSCKONnc62eItjp69yCZZPqDQtr3Pw5gJDnPeUMqExmKydNZaJO0FU9pw=="],
|
||||
|
||||
|
|
@ -3623,6 +3652,8 @@
|
|||
|
||||
"lucide-react": ["lucide-react@0.525.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Tm1txJ2OkymCGkvwoHt33Y2JpN5xucVq1slHcgE6Lk0WjDfjgKWor5CdVER8U6DvcfMwh4M8XxmpTiyzfmfDYQ=="],
|
||||
|
||||
"lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="],
|
||||
|
||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
|
||||
"magicast": ["magicast@0.5.2", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "source-map-js": "^1.2.1" } }, "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ=="],
|
||||
|
|
@ -3801,6 +3832,8 @@
|
|||
|
||||
"mimic-response": ["mimic-response@4.0.0", "", {}, "sha512-e5ISH9xMYU0DzrT+jl8q2ze9D6eWBto+I8CNpe+VI+K2J/F/k3PdkdTdz4wvGVH4NTpo+NRYTVIuMQEMMcsLqg=="],
|
||||
|
||||
"min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="],
|
||||
|
||||
"miniflare": ["miniflare@4.20260301.1", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "^0.34.5", "undici": "7.18.2", "workerd": "1.20260301.1", "ws": "8.18.0", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-fqkHx0QMKswRH9uqQQQOU/RoaS3Wjckxy3CUX3YGJr0ZIMu7ObvI+NovdYi6RIsSPthNtq+3TPmRNxjeRiasog=="],
|
||||
|
||||
"minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="],
|
||||
|
|
@ -4115,6 +4148,8 @@
|
|||
|
||||
"prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="],
|
||||
|
||||
"pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="],
|
||||
|
||||
"pretty-ms": ["pretty-ms@9.3.0", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ=="],
|
||||
|
||||
"process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="],
|
||||
|
|
@ -4205,6 +4240,8 @@
|
|||
|
||||
"query-selector-shadow-dom": ["query-selector-shadow-dom@1.0.1", "", {}, "sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw=="],
|
||||
|
||||
"query-string": ["query-string@9.3.1", "", { "dependencies": { "decode-uri-component": "^0.4.1", "filter-obj": "^5.1.0", "split-on-first": "^3.0.0" } }, "sha512-5fBfMOcDi5SA9qj5jZhWAcTtDfKF5WFdd2uD9nVNlbxVv1baq65aALy6qofpNEGELHvisjjasxQp7BlM9gvMzw=="],
|
||||
|
||||
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
|
||||
|
||||
"quick-format-unescaped": ["quick-format-unescaped@4.0.4", "", {}, "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="],
|
||||
|
|
@ -4279,6 +4316,8 @@
|
|||
|
||||
"recma-stringify": ["recma-stringify@1.0.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-util-to-js": "^2.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-cjwII1MdIIVloKvC9ErQ+OgAtwHBmcZ0Bg4ciz78FtbT8In39aAYbaA7zvxQ61xVMSPE8WxhLwLbhif4Js2C+g=="],
|
||||
|
||||
"redent": ["redent@3.0.0", "", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="],
|
||||
|
||||
"reflect-metadata": ["reflect-metadata@0.2.2", "", {}, "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q=="],
|
||||
|
||||
"reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="],
|
||||
|
|
@ -4519,6 +4558,8 @@
|
|||
|
||||
"split": ["split@1.0.1", "", { "dependencies": { "through": "2" } }, "sha512-mTyOoPbrivtXnwnIxZRFYRrPNtEFKlpB2fvjSnCQUiAA6qAZzqwna5envK4uk6OIeP17CsdF3rSBGYVBsU0Tkg=="],
|
||||
|
||||
"split-on-first": ["split-on-first@3.0.0", "", {}, "sha512-qxQJTx2ryR0Dw0ITYyekNQWpz6f8dGd7vffGNflQQ3Iqj9NJ6qiZ7ELpZsJ/QBhIVAiDfXdag3+Gp8RvWa62AA=="],
|
||||
|
||||
"split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="],
|
||||
|
||||
"sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="],
|
||||
|
|
@ -4567,6 +4608,8 @@
|
|||
|
||||
"strip-final-newline": ["strip-final-newline@4.0.0", "", {}, "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw=="],
|
||||
|
||||
"strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="],
|
||||
|
||||
"strip-json-comments": ["strip-json-comments@5.0.2", "", {}, "sha512-4X2FR3UwhNUE9G49aIsJW5hRRR3GXGTBTZRMfv568O60ojM8HcWjV/VxAxCDW3SUND33O6ZY66ZuRcdkj73q2g=="],
|
||||
|
||||
"strip-literal": ["strip-literal@3.1.0", "", { "dependencies": { "js-tokens": "^9.0.1" } }, "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg=="],
|
||||
|
|
@ -4901,6 +4944,8 @@
|
|||
|
||||
"webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="],
|
||||
|
||||
"whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="],
|
||||
|
||||
"whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
|
||||
|
||||
"when": ["when@3.7.7", "", {}, "sha512-9lFZp/KHoqH6bPKjbWqa+3Dg/K/r2v0X/3/G2x4DBGchVS2QX2VXL3cZV994WQVnTM1/PD71Az25nAzryEUugw=="],
|
||||
|
|
@ -5485,6 +5530,10 @@
|
|||
|
||||
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"@testing-library/dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="],
|
||||
|
||||
"@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="],
|
||||
|
||||
"@types/serve-static/@types/send": ["@types/send@0.17.6", "", { "dependencies": { "@types/mime": "^1", "@types/node": "*" } }, "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og=="],
|
||||
|
||||
"@vanilla-extract/css/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
|
||||
|
|
@ -5519,6 +5568,8 @@
|
|||
|
||||
"agents/@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.26.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg=="],
|
||||
|
||||
"aggregate-error/indent-string": ["indent-string@5.0.0", "", {}, "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg=="],
|
||||
|
||||
"ai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.22", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-fFT1KfUUKktfAFm5mClJhS1oux9tP2qgzmEZVl5UdwltQ1LO/s8hd7znVrgKzivwv1s1FIPza0s9OpJaNB/vHw=="],
|
||||
|
||||
"ai-gateway-provider/@ai-sdk/provider": ["@ai-sdk/provider@1.1.3", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg=="],
|
||||
|
|
@ -5535,6 +5586,8 @@
|
|||
|
||||
"atmn/dotenv": ["dotenv@17.3.1", "", {}, "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA=="],
|
||||
|
||||
"autumn-js/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
|
||||
|
||||
"basic-auth/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
|
||||
|
||||
"better-auth/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
|
||||
|
|
@ -5619,6 +5672,8 @@
|
|||
|
||||
"docs-test/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
|
||||
|
||||
"dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
|
||||
|
||||
"eciesjs/@noble/ciphers": ["@noble/ciphers@1.3.0", "", {}, "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw=="],
|
||||
|
||||
"eciesjs/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="],
|
||||
|
|
@ -5699,12 +5754,12 @@
|
|||
|
||||
"html-to-text/htmlparser2": ["htmlparser2@8.0.2", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.0.1", "entities": "^4.4.0" } }, "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA=="],
|
||||
|
||||
"htmlparser2/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
|
||||
|
||||
"http-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
|
||||
|
||||
"ink/cli-cursor": ["cli-cursor@4.0.0", "", { "dependencies": { "restore-cursor": "^4.0.0" } }, "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg=="],
|
||||
|
||||
"ink/indent-string": ["indent-string@5.0.0", "", {}, "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg=="],
|
||||
|
||||
"ink/react-reconciler": ["react-reconciler@0.32.0", "", { "dependencies": { "scheduler": "^0.26.0" }, "peerDependencies": { "react": "^19.1.0" } }, "sha512-2NPMOzgTlG0ZWdIf3qG+dcbLSoAc/uLfOwckc3ofy5sSK0pLJqnQLpUFxvGcN2rlXSjnVtGeeFLNimCQEj5gOQ=="],
|
||||
|
||||
"ink/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
|
||||
|
|
@ -5727,6 +5782,8 @@
|
|||
|
||||
"local-pkg/quansync": ["quansync@0.2.11", "", {}, "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA=="],
|
||||
|
||||
"markdown-it/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
|
||||
|
||||
"mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
|
||||
|
||||
"mdast-util-frontmatter/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
|
||||
|
|
@ -5801,6 +5858,12 @@
|
|||
|
||||
"postcss-nested/postcss-selector-parser": ["postcss-selector-parser@6.1.2", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg=="],
|
||||
|
||||
"pretty-format/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
|
||||
|
||||
"pretty-format/react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="],
|
||||
|
||||
"prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
|
||||
|
||||
"proper-lockfile/retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="],
|
||||
|
|
@ -6599,6 +6662,8 @@
|
|||
|
||||
"gray-matter/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
|
||||
|
||||
"html-to-text/htmlparser2/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
|
||||
|
||||
"ink/cli-cursor/restore-cursor": ["restore-cursor@4.0.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg=="],
|
||||
|
||||
"ink/react-reconciler/scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="],
|
||||
|
|
|
|||
|
|
@ -125,6 +125,41 @@ export const apiSchema = createSchema({
|
|||
}),
|
||||
},
|
||||
|
||||
"@get/connections/:connectionId/sync-runs": {
|
||||
output: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
connectionId: z.string(),
|
||||
status: z.enum(["running", "completed", "failed"]),
|
||||
triggerType: z.enum(["event", "cron", "manual"]),
|
||||
startedAt: z.string(),
|
||||
completedAt: z.string().nullable(),
|
||||
itemsProcessed: z.number(),
|
||||
itemsFailed: z.number(),
|
||||
error: z.string().nullable(),
|
||||
}),
|
||||
),
|
||||
params: z.object({ connectionId: z.string() }),
|
||||
},
|
||||
|
||||
"@post/connections/:provider/import": {
|
||||
input: z.object({
|
||||
containerTags: z.array(z.string()).optional(),
|
||||
}),
|
||||
output: z.unknown(),
|
||||
params: z.object({
|
||||
provider: z.enum([
|
||||
"google-drive",
|
||||
"notion",
|
||||
"onedrive",
|
||||
"gmail",
|
||||
"github",
|
||||
"web-crawler",
|
||||
"s3",
|
||||
]),
|
||||
}),
|
||||
},
|
||||
|
||||
// Settings operations
|
||||
"@get/settings": {
|
||||
output: z.object({ settings: z.object({}).passthrough() }),
|
||||
|
|
@ -253,6 +288,12 @@ export const apiSchema = createSchema({
|
|||
export const $fetch = createFetch({
|
||||
baseURL: `${process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"}/v3`,
|
||||
credentials: "include",
|
||||
headers: { "X-App-Source": "nova" },
|
||||
onRequest: (context: { headers: Headers }) => {
|
||||
if (!context.headers.has("X-App-Source")) {
|
||||
context.headers.set("X-App-Source", "nova")
|
||||
}
|
||||
},
|
||||
retry: {
|
||||
attempts: 3,
|
||||
delay: 100,
|
||||
|
|
|
|||
|
|
@ -54,10 +54,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||
const setActiveOrg = useCallback(async (slug: string) => {
|
||||
if (!slug) return
|
||||
|
||||
const activeOrg = await authClient.organization.setActive({
|
||||
const res = await authClient.organization.setActive({
|
||||
organizationSlug: slug,
|
||||
})
|
||||
setOrg(activeOrg)
|
||||
setOrg(res?.data ?? null)
|
||||
localStorage.setItem(STORAGE_KEY, slug)
|
||||
}, [])
|
||||
|
||||
|
|
@ -112,7 +112,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||
if (!one) return
|
||||
if (activeOrgId === one.id) {
|
||||
const full = await authClient.organization.getFullOrganization()
|
||||
if (!cancelled) setOrg(full)
|
||||
if (!cancelled) setOrg(full?.data ?? null)
|
||||
} else {
|
||||
await setActiveOrg(one.slug)
|
||||
}
|
||||
|
|
@ -125,7 +125,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||
if (match) {
|
||||
if (activeOrgId === match.id) {
|
||||
const full = await authClient.organization.getFullOrganization()
|
||||
if (!cancelled) setOrg(full)
|
||||
if (!cancelled) setOrg(full?.data ?? null)
|
||||
} else {
|
||||
await setActiveOrg(savedSlug)
|
||||
}
|
||||
|
|
@ -138,13 +138,13 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||
const fromList = orgs.find((o) => o.id === activeOrgId)
|
||||
if (fromList) {
|
||||
const full = await authClient.organization.getFullOrganization()
|
||||
if (!cancelled) setOrg(full)
|
||||
if (!cancelled) setOrg(full?.data ?? null)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const full = await authClient.organization.getFullOrganization()
|
||||
if (!cancelled) setOrg(full)
|
||||
if (!cancelled) setOrg(full?.data ?? null)
|
||||
} catch (error) {
|
||||
console.error("Failed to restore organization:", error)
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ export const authClient = createAuthClient({
|
|||
baseURL: process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai",
|
||||
fetchOptions: {
|
||||
credentials: "include",
|
||||
headers: { "X-App-Source": "nova" },
|
||||
},
|
||||
plugins: [
|
||||
usernameClient(),
|
||||
|
|
|
|||
|
|
@ -33,28 +33,28 @@ export function isAllowedFrom(
|
|||
}
|
||||
|
||||
export function getSubscriptionStatus(
|
||||
products: Array<{ id: string; status: string }> | undefined,
|
||||
subscriptions: Array<{ planId: string; status: string }> | undefined,
|
||||
): SubscriptionStatusMap {
|
||||
const statusMap: SubscriptionStatusMap = { ...DEFAULT_SUBSCRIPTION_STATUS }
|
||||
if (!products) return statusMap
|
||||
if (!subscriptions) return statusMap
|
||||
|
||||
const productMap = new Map(products.map((p) => [p.id, p]))
|
||||
const subMap = new Map(subscriptions.map((s) => [s.planId, s]))
|
||||
|
||||
for (const tier of PLAN_TIERS) {
|
||||
const product = productMap.get(tier)
|
||||
const sub = subMap.get(tier)
|
||||
statusMap[tier] = {
|
||||
allowed: product?.status === "active",
|
||||
status: product?.status ?? null,
|
||||
allowed: sub?.status === "active",
|
||||
status: sub?.status ?? null,
|
||||
}
|
||||
}
|
||||
return statusMap
|
||||
}
|
||||
|
||||
export function hasActivePlan(
|
||||
products: Array<{ id: string; status: string }> | undefined,
|
||||
subscriptions: Array<{ planId: string; status: string }> | undefined,
|
||||
minimumTier: PlanTier,
|
||||
): boolean {
|
||||
return isAllowedFrom(getSubscriptionStatus(products), minimumTier)
|
||||
return isAllowedFrom(getSubscriptionStatus(subscriptions), minimumTier)
|
||||
}
|
||||
|
||||
export const useDeleteDocument = (selectedProject: string) => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@supermemory/memory-graph",
|
||||
"version": "0.2.0",
|
||||
"version": "0.2.1",
|
||||
"description": "Interactive graph visualization component for Supermemory - visualize and explore your memory connections",
|
||||
"type": "module",
|
||||
"main": "./src/index.tsx",
|
||||
|
|
@ -52,10 +52,13 @@
|
|||
"d3-force": "^3.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/d3-force": "^3.0.10",
|
||||
"@types/react": "^19.2.2",
|
||||
"@types/react-dom": "^19.2.2",
|
||||
"@vitejs/plugin-react": "^5.1.0",
|
||||
"happy-dom": "^20.9.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.2.1",
|
||||
"vitest": "^3.2.4"
|
||||
|
|
|
|||
419
packages/memory-graph/src/__tests__/node-hover-popover.test.tsx
Normal file
419
packages/memory-graph/src/__tests__/node-hover-popover.test.tsx
Normal file
|
|
@ -0,0 +1,419 @@
|
|||
/**
|
||||
* Tests for UI changes introduced on the
|
||||
* vorflux/graph-popover-scrollable-view-full branch.
|
||||
*
|
||||
* The NodeHoverPopover component uses hooks (useMemo, useState, useCallback,
|
||||
* useEffect) internally, and the bun workspace's dual-React-instance layout
|
||||
* prevents rendering hook-bearing components in vitest's happy-dom environment.
|
||||
* These tests therefore verify behaviour through:
|
||||
*
|
||||
* (a) Direct source-code assertions — reading the component source and
|
||||
* asserting on specific string/AST patterns that encode the design
|
||||
* choices (style properties, constant values, truncation limits, etc.).
|
||||
*
|
||||
* (b) Pure-logic unit tests — re-implementing and testing the pure functions
|
||||
* (truncate, documentId derivation, render-guard booleans) without
|
||||
* mounting the component.
|
||||
*
|
||||
* This mirrors the pattern already used by all 107 existing tests in this
|
||||
* package (edge-logic, graph-data-utils, viewport, etc.) — none of them
|
||||
* mount React components either.
|
||||
*/
|
||||
|
||||
import { readFileSync } from "node:fs"
|
||||
import { resolve } from "node:path"
|
||||
import { describe, it, expect } from "vitest"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Source text — loaded once; all assertions operate on this string.
|
||||
// ---------------------------------------------------------------------------
|
||||
const SRC_PATH = resolve(__dirname, "../components/node-hover-popover.tsx")
|
||||
const src = readFileSync(SRC_PATH, "utf-8")
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure re-implementation of the `truncate` helper (matches the source exactly)
|
||||
// ---------------------------------------------------------------------------
|
||||
function truncate(s: string, max: number): string {
|
||||
return s.length > max ? `${s.substring(0, max)}...` : s
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Layout constants
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("Layout constants", () => {
|
||||
it("SHORTCUTS_W is 160 (widened from 100)", () => {
|
||||
expect(src).toContain("const SHORTCUTS_W = 160")
|
||||
})
|
||||
|
||||
it("CARD_W is 280 (unchanged)", () => {
|
||||
expect(src).toContain("const CARD_W = 280")
|
||||
})
|
||||
|
||||
it("TOTAL_W formula references both constants", () => {
|
||||
expect(src).toContain("const TOTAL_W = CARD_W + 12 + SHORTCUTS_W")
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Content area — scrollable, full text (no truncation)
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("Content area — scrollable and not truncated", () => {
|
||||
it("contentPadStyle has maxHeight: 100", () => {
|
||||
expect(src).toContain("maxHeight: 100,")
|
||||
})
|
||||
|
||||
it("contentPadStyle has overflowY: 'auto'", () => {
|
||||
// The property must appear inside the contentPadStyle block
|
||||
const contentPadIdx = src.indexOf("const contentPadStyle")
|
||||
const nextConst = src.indexOf("\n\t\tconst ", contentPadIdx + 1)
|
||||
const block = src.slice(contentPadIdx, nextConst)
|
||||
expect(block).toContain('overflowY: "auto"')
|
||||
})
|
||||
|
||||
it("contentPadStyle has flex: '1 1 auto' (grows to fill card height)", () => {
|
||||
const contentPadIdx = src.indexOf("const contentPadStyle")
|
||||
const nextConst = src.indexOf("\n\t\tconst ", contentPadIdx + 1)
|
||||
const block = src.slice(contentPadIdx, nextConst)
|
||||
expect(block).toContain('flex: "1 1 auto"')
|
||||
})
|
||||
|
||||
it("content paragraph renders {content} directly — no truncate() call on content", () => {
|
||||
// Old code: truncate(content, 100)
|
||||
// New code: {content || "No content"}
|
||||
expect(src).not.toMatch(/truncate\(content,\s*100\)/)
|
||||
expect(src).toContain('{content || "No content"}')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. KeyBadge — has dark background and border (icon badge style)
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("KeyBadge styles — has background and border", () => {
|
||||
it("KeyBadge style object contains backgroundColor", () => {
|
||||
// Locate the KeyBadge function body
|
||||
const start = src.indexOf("function KeyBadge(")
|
||||
const end = src.indexOf("\nfunction ", start + 1)
|
||||
const fnBody = src.slice(start, end)
|
||||
expect(fnBody).toContain("backgroundColor: colors.controlBg")
|
||||
})
|
||||
|
||||
it("KeyBadge style object contains a border property", () => {
|
||||
const start = src.indexOf("function KeyBadge(")
|
||||
const end = src.indexOf("\nfunction ", start + 1)
|
||||
const fnBody = src.slice(start, end)
|
||||
expect(fnBody).toMatch(/\bborder\s*:/)
|
||||
expect(fnBody).toContain("colors.controlBorder")
|
||||
})
|
||||
|
||||
it("KeyBadge still has the expected style properties", () => {
|
||||
const start = src.indexOf("function KeyBadge(")
|
||||
const end = src.indexOf("\nfunction ", start + 1)
|
||||
const fnBody = src.slice(start, end)
|
||||
expect(fnBody).toContain('display: "inline-flex"')
|
||||
expect(fnBody).toContain("width: 16")
|
||||
expect(fnBody).toContain("height: 16")
|
||||
expect(fnBody).toContain("borderRadius: 4")
|
||||
expect(fnBody).toContain("color: colors.popoverTextMuted")
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. Shortcuts panel — no background or border
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("Shortcuts panel styles — background and border removed", () => {
|
||||
it("shortcutsPanelStyle does not contain backgroundColor", () => {
|
||||
const start = src.indexOf("const shortcutsPanelStyle")
|
||||
const end = src.indexOf("\n\t\treturn (", start)
|
||||
const block = src.slice(start, end)
|
||||
expect(block).not.toContain("backgroundColor")
|
||||
})
|
||||
|
||||
it("shortcutsPanelStyle does not contain a border property", () => {
|
||||
const start = src.indexOf("const shortcutsPanelStyle")
|
||||
const end = src.indexOf("\n\t\treturn (", start)
|
||||
const block = src.slice(start, end)
|
||||
expect(block).not.toMatch(/\bborder\s*:/)
|
||||
})
|
||||
|
||||
it("shortcutsPanelStyle does not contain borderRadius", () => {
|
||||
const start = src.indexOf("const shortcutsPanelStyle")
|
||||
const end = src.indexOf("\n\t\treturn (", start)
|
||||
const block = src.slice(start, end)
|
||||
expect(block).not.toContain("borderRadius")
|
||||
})
|
||||
|
||||
it("shortcutsPanelStyle still has layout properties", () => {
|
||||
const start = src.indexOf("const shortcutsPanelStyle")
|
||||
const end = src.indexOf("\n\t\treturn (", start)
|
||||
const block = src.slice(start, end)
|
||||
expect(block).toContain('display: "flex"')
|
||||
expect(block).toContain('flexDirection: "column"')
|
||||
expect(block).toContain("gap: 6")
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. EyeIcon SVG component
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("EyeIcon SVG component", () => {
|
||||
it("EyeIcon function is defined in the source", () => {
|
||||
expect(src).toContain("function EyeIcon(")
|
||||
})
|
||||
|
||||
it("EyeIcon SVG has aria-hidden='true'", () => {
|
||||
const start = src.indexOf("function EyeIcon(")
|
||||
const end = src.indexOf("\nfunction ", start + 1)
|
||||
const fnBody = src.slice(start, end)
|
||||
expect(fnBody).toContain('aria-hidden="true"')
|
||||
})
|
||||
|
||||
it("EyeIcon SVG viewBox is '0 0 24 24'", () => {
|
||||
const start = src.indexOf("function EyeIcon(")
|
||||
const end = src.indexOf("\nfunction ", start + 1)
|
||||
const fnBody = src.slice(start, end)
|
||||
expect(fnBody).toContain('viewBox="0 0 24 24"')
|
||||
})
|
||||
|
||||
it("EyeIcon SVG contains a <path> element (outer eye shape)", () => {
|
||||
const start = src.indexOf("function EyeIcon(")
|
||||
const end = src.indexOf("\nfunction ", start + 1)
|
||||
const fnBody = src.slice(start, end)
|
||||
expect(fnBody).toContain("<path")
|
||||
})
|
||||
|
||||
it("EyeIcon SVG contains a <circle> element (pupil)", () => {
|
||||
const start = src.indexOf("function EyeIcon(")
|
||||
const end = src.indexOf("\nfunction ", start + 1)
|
||||
const fnBody = src.slice(start, end)
|
||||
expect(fnBody).toContain("<circle")
|
||||
})
|
||||
|
||||
it("EyeIcon receives stroke color from props", () => {
|
||||
const start = src.indexOf("function EyeIcon(")
|
||||
const end = src.indexOf("\nfunction ", start + 1)
|
||||
const fnBody = src.slice(start, end)
|
||||
expect(fnBody).toContain("stroke={color}")
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 6. "View document" button — presence guards and callback wiring
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("'View document' button — render guard in JSX", () => {
|
||||
it("button is gated on both onOpenDocument AND documentId", () => {
|
||||
// The render guard must be: {onOpenDocument && documentId && (…)}
|
||||
expect(src).toContain("{onOpenDocument && documentId && (")
|
||||
})
|
||||
|
||||
it("button onClick calls onOpenDocument(documentId)", () => {
|
||||
expect(src).toContain("onClick={() => onOpenDocument(documentId)}")
|
||||
})
|
||||
|
||||
it("button label text is 'View document'", () => {
|
||||
expect(src).toContain('label="View document"')
|
||||
})
|
||||
|
||||
it("button icon is the EyeIcon component (not a string shortcut key)", () => {
|
||||
expect(src).toContain("icon={<EyeIcon color={colors.popoverTextMuted} />}")
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 7. documentId derivation logic
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("documentId derivation — memory vs document node", () => {
|
||||
it("source derives documentId from data.documentId for memory nodes", () => {
|
||||
expect(src).toContain(
|
||||
"const documentId = isMemory ? (data as MemoryNodeData).documentId : node.id",
|
||||
)
|
||||
})
|
||||
|
||||
it("pure logic: memory node uses data.documentId as documentId", () => {
|
||||
// Re-implement the expression from source: isMemory ? data.documentId : node.id
|
||||
const isMemory = true
|
||||
const nodeId = "mem-1"
|
||||
const dataDocumentId = "parent-doc-7"
|
||||
const documentId = isMemory ? dataDocumentId : nodeId
|
||||
expect(documentId).toBe("parent-doc-7")
|
||||
})
|
||||
|
||||
it("pure logic: document node uses node.id as documentId", () => {
|
||||
const isMemory = false
|
||||
const nodeId = "doc-42"
|
||||
const dataDocumentId = undefined
|
||||
const documentId = isMemory ? dataDocumentId : nodeId
|
||||
expect(documentId).toBe("doc-42")
|
||||
})
|
||||
|
||||
it("pure logic: documentId is falsy (undefined) when memory node has no documentId", () => {
|
||||
// The render guard is `onOpenDocument && documentId`. When documentId is
|
||||
// undefined the whole expression is falsy regardless of onOpenDocument.
|
||||
const documentId: string | undefined = undefined
|
||||
expect(Boolean(documentId)).toBe(false)
|
||||
})
|
||||
|
||||
it("pure logic: onOpenDocument being undefined makes the guard falsy", () => {
|
||||
// Boolean(undefined && anything) === false
|
||||
const onOpenDocument: ((id: string) => void) | undefined = undefined
|
||||
expect(Boolean(onOpenDocument)).toBe(false)
|
||||
})
|
||||
|
||||
it("pure logic: both truthy values make the guard truthy", () => {
|
||||
// Both sides non-empty → guard passes
|
||||
const documentId: string | undefined = "doc-1"
|
||||
// Use a runtime-resolved value so TS can't narrow to a never-undefined type
|
||||
const handler = [(_id: string) => {}][0] as
|
||||
| ((id: string) => void)
|
||||
| undefined
|
||||
expect(Boolean(handler && documentId)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 8. VersionTimeline truncation limit — 120 chars (was 60)
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("VersionTimeline truncation limit", () => {
|
||||
it("source calls truncate(entry.memory, 120) — not 60", () => {
|
||||
expect(src).toContain("truncate(entry.memory, 120)")
|
||||
expect(src).not.toContain("truncate(entry.memory, 60)")
|
||||
})
|
||||
|
||||
it("truncate(s, 120): string of 150 chars is cut to 120 + '...'", () => {
|
||||
const s = "A".repeat(150)
|
||||
expect(truncate(s, 120)).toBe(`${"A".repeat(120)}...`)
|
||||
})
|
||||
|
||||
it("truncate(s, 120): string of exactly 120 chars is returned unchanged", () => {
|
||||
const s = "B".repeat(120)
|
||||
expect(truncate(s, 120)).toBe(s)
|
||||
})
|
||||
|
||||
it("truncate(s, 120): string of 61 chars is NOT truncated (old 60-char limit gone)", () => {
|
||||
const s = "C".repeat(61)
|
||||
const result = truncate(s, 120)
|
||||
expect(result).toBe(s)
|
||||
// Also confirm the old limit would have truncated it
|
||||
expect(truncate(s, 60)).toBe(`${"C".repeat(60)}...`)
|
||||
})
|
||||
|
||||
it("truncate(s, 120): empty string returns empty string", () => {
|
||||
expect(truncate("", 120)).toBe("")
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 9. VersionTimeline maxHeight increase — 160 (was 120)
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("VersionTimeline container maxHeight", () => {
|
||||
it("VersionTimeline containerStyle has maxHeight: 160 (was 120)", () => {
|
||||
// Locate VersionTimeline function body
|
||||
const start = src.indexOf("function VersionTimeline(")
|
||||
const end = src.indexOf("\nexport const NodeHoverPopover", start)
|
||||
const fnBody = src.slice(start, end)
|
||||
expect(fnBody).toContain("maxHeight: 160")
|
||||
expect(fnBody).not.toContain("maxHeight: 120")
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 10. onOpenDocument prop — declared in interface and destructured
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("onOpenDocument prop wiring", () => {
|
||||
it("is declared in NodeHoverPopoverProps interface", () => {
|
||||
const start = src.indexOf("export interface NodeHoverPopoverProps")
|
||||
const end = src.indexOf("}", start)
|
||||
const block = src.slice(start, end)
|
||||
expect(block).toContain("onOpenDocument?: (documentId: string) => void")
|
||||
})
|
||||
|
||||
it("is destructured in the component function parameters", () => {
|
||||
// The destructuring must appear inside the memo() call
|
||||
const memoStart = src.indexOf("export const NodeHoverPopover = memo")
|
||||
const fnBodyStart = src.indexOf("{", memoStart)
|
||||
const firstReturn = src.indexOf("return (", fnBodyStart)
|
||||
const paramBlock = src.slice(fnBodyStart, firstReturn)
|
||||
expect(paramBlock).toContain("onOpenDocument,")
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 11. NavButton icon type — accepts ReactNode (not just string)
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("NavButton icon prop type", () => {
|
||||
it("NavButton icon prop type is React.ReactNode (not string)", () => {
|
||||
// The NavButton params are: function NavButton({ icon, label, ... }: { icon: React.ReactNode ... })
|
||||
// The type annotation block follows the }: pattern.
|
||||
const start = src.indexOf("function NavButton(")
|
||||
// Find the }: { which starts the type annotation
|
||||
const typeAnnotationStart = src.indexOf("}: {", start)
|
||||
const typeAnnotationEnd = src.indexOf("}) {", typeAnnotationStart)
|
||||
const typeBlock = src.slice(typeAnnotationStart, typeAnnotationEnd)
|
||||
expect(typeBlock).toContain("icon: React.ReactNode")
|
||||
expect(typeBlock).not.toContain("icon: string")
|
||||
})
|
||||
|
||||
it("NavButton always wraps icon in KeyBadge", () => {
|
||||
// All icons (string or ReactNode) are wrapped in KeyBadge for consistent badge styling
|
||||
expect(src).toContain("<KeyBadge colors={colors}>{icon}</KeyBadge>")
|
||||
// No conditional dispatch — all icons go through KeyBadge
|
||||
expect(src).not.toContain('typeof icon === "string"')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 12. handleOpenDocument wrapper in memory-graph.tsx
|
||||
// Verifies the dismiss-then-open pattern: setSelectedNode(null) and
|
||||
// setHoveredNode(null) are called before onOpenDocument?.(documentId)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MG_SRC_PATH = resolve(__dirname, "../components/memory-graph.tsx")
|
||||
const mgSrc = readFileSync(MG_SRC_PATH, "utf-8")
|
||||
|
||||
describe("handleOpenDocument wrapper in MemoryGraph", () => {
|
||||
// Locate the handleOpenDocument block once for all sub-tests
|
||||
const wrapperStart = mgSrc.indexOf("const handleOpenDocument = useCallback(")
|
||||
const wrapperEnd = mgSrc.indexOf("\t)", wrapperStart) + 2
|
||||
const wrapperBlock = mgSrc.slice(wrapperStart, wrapperEnd)
|
||||
|
||||
it("handleOpenDocument wrapper is defined in memory-graph.tsx", () => {
|
||||
expect(wrapperBlock.length).toBeGreaterThan(0)
|
||||
expect(wrapperBlock).toContain("handleOpenDocument")
|
||||
})
|
||||
|
||||
it("calls setSelectedNode(null) to dismiss the selected node", () => {
|
||||
expect(wrapperBlock).toContain("setSelectedNode(null)")
|
||||
})
|
||||
|
||||
it("calls setHoveredNode(null) to dismiss the hovered node", () => {
|
||||
expect(wrapperBlock).toContain("setHoveredNode(null)")
|
||||
})
|
||||
|
||||
it("calls onOpenDocument?.(documentId) — optional chaining preserves no-op when undefined", () => {
|
||||
expect(wrapperBlock).toContain("onOpenDocument?.(documentId)")
|
||||
})
|
||||
|
||||
it("setSelectedNode(null) and setHoveredNode(null) appear before onOpenDocument?.(documentId)", () => {
|
||||
const selectedIdx = wrapperBlock.indexOf("setSelectedNode(null)")
|
||||
const hoveredIdx = wrapperBlock.indexOf("setHoveredNode(null)")
|
||||
const openDocIdx = wrapperBlock.indexOf("onOpenDocument?.(documentId)")
|
||||
expect(selectedIdx).toBeGreaterThanOrEqual(0)
|
||||
expect(hoveredIdx).toBeGreaterThanOrEqual(0)
|
||||
expect(openDocIdx).toBeGreaterThanOrEqual(0)
|
||||
// Both dismiss calls must appear before the open-document call
|
||||
expect(selectedIdx).toBeLessThan(openDocIdx)
|
||||
expect(hoveredIdx).toBeLessThan(openDocIdx)
|
||||
})
|
||||
|
||||
it("handleOpenDocument is only forwarded to NodeHoverPopover when onOpenDocument is provided", () => {
|
||||
// The prop is passed as: onOpenDocument={onOpenDocument ? handleOpenDocument : undefined}
|
||||
// This prevents forwarding a handler when the parent didn't pass a callback.
|
||||
expect(mgSrc).toContain(
|
||||
"onOpenDocument={onOpenDocument ? handleOpenDocument : undefined}",
|
||||
)
|
||||
})
|
||||
|
||||
it("handleOpenDocument is memoised with useCallback (dependency: onOpenDocument)", () => {
|
||||
expect(wrapperBlock).toContain("useCallback(")
|
||||
expect(wrapperBlock).toContain("[onOpenDocument]")
|
||||
})
|
||||
})
|
||||
32
packages/memory-graph/src/__tests__/setup.ts
Normal file
32
packages/memory-graph/src/__tests__/setup.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
/**
|
||||
* Vitest setup: patch React 19 shared internals to ensure a single hooks
|
||||
* dispatcher across react and react-dom instances in bun workspace monorepo.
|
||||
*
|
||||
* In bun workspaces with vitest, the CJS require("react") inside react-dom
|
||||
* CJS can resolve to a different ESM module instance than the ESM import React
|
||||
* in test/component source files. React 19 stores the hooks dispatcher on
|
||||
* __CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE.H
|
||||
* If react-dom uses a different object than the component's react import,
|
||||
* hooks see null as the dispatcher and throw "Invalid hook call".
|
||||
*/
|
||||
import React from "react"
|
||||
import ReactDOM from "react-dom"
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: React internals are untyped by design
|
||||
const R = React as any
|
||||
// biome-ignore lint/suspicious/noExplicitAny: ReactDOM internals are untyped by design
|
||||
const RD = ReactDOM as any
|
||||
|
||||
const INTERNALS_KEY =
|
||||
"__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE"
|
||||
|
||||
const testInternals = R[INTERNALS_KEY]
|
||||
const domInternals = RD[INTERNALS_KEY]
|
||||
|
||||
if (testInternals && domInternals && testInternals !== domInternals) {
|
||||
// Copy all keys from the test file's React internals object onto the one
|
||||
// react-dom loaded (or vice versa — we just need them to be the same object).
|
||||
Object.assign(domInternals, testInternals)
|
||||
// Also replace the reference so future updates to testInternals propagate
|
||||
RD[INTERNALS_KEY] = testInternals
|
||||
}
|
||||
|
|
@ -29,6 +29,7 @@ export function MemoryGraph({
|
|||
canvasRef: externalCanvasRef,
|
||||
colors: colorOverrides,
|
||||
totalCount,
|
||||
onOpenDocument,
|
||||
}: MemoryGraphProps) {
|
||||
const resolvedColors = useGraphTheme(colorOverrides)
|
||||
const colors = useMemo<GraphThemeColors>(
|
||||
|
|
@ -282,6 +283,18 @@ export function MemoryGraph({
|
|||
vp.zoomTo(vp.zoom / 1.3, containerSize.width / 2, containerSize.height / 2)
|
||||
}, [containerSize.width, containerSize.height])
|
||||
|
||||
// Wrap onOpenDocument to dismiss the popover before opening the modal.
|
||||
// Without this, the popover (z-index: 100) stays mounted on top of the
|
||||
// document modal (z-50), obscuring it and intercepting clicks.
|
||||
const handleOpenDocument = useCallback(
|
||||
(documentId: string) => {
|
||||
setSelectedNode(null)
|
||||
setHoveredNode(null)
|
||||
onOpenDocument?.(documentId)
|
||||
},
|
||||
[onOpenDocument],
|
||||
)
|
||||
|
||||
// Keyboard shortcuts — using useEffect with keydown listener
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
|
|
@ -473,7 +486,6 @@ export function MemoryGraph({
|
|||
const onSlideshowNodeChangeRef = useRef(onSlideshowNodeChange)
|
||||
onSlideshowNodeChangeRef.current = onSlideshowNodeChange
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: reads from refs to avoid resetting interval on resize/node changes
|
||||
useEffect(() => {
|
||||
if (!isSlideshowActive || nodes.length === 0) {
|
||||
if (!isSlideshowActive) {
|
||||
|
|
@ -498,7 +510,8 @@ export function MemoryGraph({
|
|||
idx = 0
|
||||
}
|
||||
lastIdx = idx
|
||||
const n = currentNodes[idx]!
|
||||
const n = currentNodes[idx]
|
||||
if (!n) return
|
||||
setSelectedNode(n.id)
|
||||
const sz = containerSizeRef.current
|
||||
viewportRef.current?.centerOn(n.x, n.y, sz.width, sz.height)
|
||||
|
|
@ -658,6 +671,7 @@ export function MemoryGraph({
|
|||
onNavigatePrev={navigatePrev}
|
||||
onNavigateUp={navigateUp}
|
||||
onSelectNode={handleNodeClick}
|
||||
onOpenDocument={onOpenDocument ? handleOpenDocument : undefined}
|
||||
screenX={activePopoverPosition.screenX}
|
||||
screenY={activePopoverPosition.screenY}
|
||||
versionChain={activeVersionChain}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ export interface NodeHoverPopoverProps {
|
|||
onNavigateUp?: () => void
|
||||
onNavigateDown?: () => void
|
||||
onSelectNode?: (nodeId: string) => void
|
||||
onOpenDocument?: (documentId: string) => void
|
||||
}
|
||||
|
||||
function useCopyToClipboard(timeout = 2000) {
|
||||
|
|
@ -65,22 +66,43 @@ function KeyBadge({
|
|||
borderRadius: 4,
|
||||
fontSize: 10,
|
||||
fontWeight: 500,
|
||||
backgroundColor: colors.controlBg,
|
||||
border: `1px solid ${colors.controlBorder}`,
|
||||
color: colors.popoverTextMuted,
|
||||
lineHeight: 1,
|
||||
backgroundColor: colors.controlBg,
|
||||
padding: 2,
|
||||
border: `1px solid ${colors.controlBorder}`,
|
||||
boxShadow: "0 1px 2px rgba(0,0,0,0.12)",
|
||||
}
|
||||
|
||||
return <span style={style}>{children}</span>
|
||||
}
|
||||
|
||||
function EyeIcon({ color }: { color: string }) {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function NavButton({
|
||||
icon,
|
||||
label,
|
||||
onClick,
|
||||
colors,
|
||||
}: {
|
||||
icon: string
|
||||
icon: React.ReactNode
|
||||
label: string
|
||||
onClick?: () => void
|
||||
colors: GraphThemeColors
|
||||
|
|
@ -216,7 +238,7 @@ function VersionTimeline({
|
|||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 0,
|
||||
maxHeight: 120,
|
||||
maxHeight: 160,
|
||||
overflowY: "auto",
|
||||
}
|
||||
|
||||
|
|
@ -271,7 +293,7 @@ function VersionTimeline({
|
|||
type="button"
|
||||
>
|
||||
<span style={versionStyle}>v{entry.version}</span>
|
||||
<span style={textStyle}>{truncate(entry.memory, 60)}</span>
|
||||
<span style={textStyle}>{truncate(entry.memory, 120)}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
|
@ -293,9 +315,10 @@ export const NodeHoverPopover = memo<NodeHoverPopoverProps>(
|
|||
onNavigateUp,
|
||||
onNavigateDown,
|
||||
onSelectNode,
|
||||
onOpenDocument,
|
||||
}) {
|
||||
const CARD_W = 280
|
||||
const SHORTCUTS_W = 100
|
||||
const SHORTCUTS_W = 160
|
||||
const GAP = 24
|
||||
const TOTAL_W = CARD_W + 12 + SHORTCUTS_W
|
||||
|
||||
|
|
@ -318,7 +341,7 @@ export const NodeHoverPopover = memo<NodeHoverPopoverProps>(
|
|||
const hasForgetInfo =
|
||||
memoryMeta && (memoryMeta.isForgotten || memoryMeta.forgetAfter)
|
||||
|
||||
const CARD_H = hasChain ? 200 : hasForgetInfo ? 165 : 135
|
||||
const CARD_H = hasChain ? 230 : hasForgetInfo ? 190 : 170
|
||||
const TOTAL_H = CARD_H
|
||||
|
||||
const { popoverX, popoverY, connectorPath } = useMemo(() => {
|
||||
|
|
@ -383,6 +406,9 @@ export const NodeHoverPopover = memo<NodeHoverPopoverProps>(
|
|||
|
||||
const docData = !isMemory ? (data as DocumentNodeData) : null
|
||||
|
||||
// For document nodes, node.id IS the document ID
|
||||
const documentId = isMemory ? (data as MemoryNodeData).documentId : node.id
|
||||
|
||||
const overlayStyle: React.CSSProperties = {
|
||||
pointerEvents: "none",
|
||||
position: "absolute",
|
||||
|
|
@ -426,6 +452,9 @@ export const NodeHoverPopover = memo<NodeHoverPopoverProps>(
|
|||
|
||||
const contentPadStyle: React.CSSProperties = {
|
||||
padding: 12,
|
||||
maxHeight: 100,
|
||||
overflowY: "auto",
|
||||
flex: "1 1 auto",
|
||||
}
|
||||
|
||||
const contentTextStyle: React.CSSProperties = {
|
||||
|
|
@ -469,9 +498,6 @@ export const NodeHoverPopover = memo<NodeHoverPopoverProps>(
|
|||
paddingRight: 12,
|
||||
paddingTop: 8,
|
||||
paddingBottom: 8,
|
||||
borderRadius: 12,
|
||||
border: `1px solid ${colors.popoverBorder}`,
|
||||
backgroundColor: colors.popoverBg,
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -497,9 +523,7 @@ export const NodeHoverPopover = memo<NodeHoverPopoverProps>(
|
|||
/>
|
||||
) : (
|
||||
<div style={contentPadStyle}>
|
||||
<p style={contentTextStyle}>
|
||||
{truncate(content, 100) || "No content"}
|
||||
</p>
|
||||
<p style={contentTextStyle}>{content || "No content"}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -602,6 +626,14 @@ export const NodeHoverPopover = memo<NodeHoverPopoverProps>(
|
|||
</div>
|
||||
|
||||
<div style={shortcutsPanelStyle}>
|
||||
{onOpenDocument && documentId && (
|
||||
<NavButton
|
||||
colors={colors}
|
||||
icon={<EyeIcon color={colors.popoverTextMuted} />}
|
||||
label="View document"
|
||||
onClick={() => onOpenDocument(documentId)}
|
||||
/>
|
||||
)}
|
||||
{isMemory && (
|
||||
<NavButton
|
||||
colors={colors}
|
||||
|
|
|
|||
|
|
@ -187,10 +187,12 @@ export interface MemoryGraphProps {
|
|||
onSlideshowStop?: () => void
|
||||
/** Canvas ref for external access (e.g. screenshot export) */
|
||||
canvasRef?: React.RefObject<HTMLCanvasElement | null>
|
||||
/** Custom theme colors - if not provided, reads from CSS variables */
|
||||
colors?: GraphThemeColors
|
||||
/** Custom theme colors (partial) - merged with CSS variable / default values */
|
||||
colors?: Partial<GraphThemeColors>
|
||||
/** Total count for loading indicator */
|
||||
totalCount?: number
|
||||
/** Callback when user wants to view full document content */
|
||||
onOpenDocument?: (documentId: string) => void
|
||||
}
|
||||
|
||||
export interface ChainEntry {
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue