mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-10-10 03:28:14 +00:00
Merge remote-tracking branch 'origin/main' into codex-pr-973
# Conflicts: # packages/memory-graph/src/components/memory-graph.tsx
This commit is contained in:
commit
73f9c16df1
72 changed files with 9434 additions and 3066 deletions
|
|
@ -4,7 +4,7 @@ description: "Connect Amazon S3 or S3-compatible storage to sync files into your
|
|||
icon: "aws"
|
||||
---
|
||||
|
||||
Connect Amazon S3 buckets or S3-compatible storage services (MinIO, DigitalOcean Spaces, Cloudflare R2) to sync files into your Supermemory knowledge base.
|
||||
Connect Amazon S3 buckets or S3-compatible storage services (MinIO, DigitalOcean Spaces, Cloudflare R2, Tigris) to sync files into your Supermemory knowledge base.
|
||||
|
||||
<Note>
|
||||
The S3 connector requires a **Scale Plan** or higher. You can also create S3 connections directly from the [Supermemory Console](https://console.supermemory.ai).
|
||||
|
|
@ -78,7 +78,7 @@ For S3, provider-specific connection fields are passed inside the top-level `met
|
|||
| `accessKeyId` | `metadata.accessKeyId` | Yes | AWS access key ID or S3-compatible service key |
|
||||
| `secretAccessKey` | `metadata.secretAccessKey` | Yes | AWS secret access key |
|
||||
| `bucket` | `metadata.bucket` | Yes | S3 bucket name |
|
||||
| `region` | `metadata.region` | Yes | AWS region (e.g., `us-east-1`). Use `auto` for Cloudflare R2. |
|
||||
| `region` | `metadata.region` | Yes | AWS region (e.g., `us-east-1`). Use `auto` for S3-compatible providers that don't expose AWS-style regions (MinIO, R2, Tigris). |
|
||||
| `endpoint` | `metadata.endpoint` | No | Custom endpoint for S3-compatible services |
|
||||
| `prefix` | `metadata.prefix` | No | Key prefix filter (e.g., `documents/`) |
|
||||
| `containerTagRegex` | `metadata.containerTagRegex` | No | Regex to extract container tags from file paths |
|
||||
|
|
@ -91,7 +91,7 @@ In the Python SDK, use `container_tags` for the top-level option, but keep S3 me
|
|||
|
||||
## S3-Compatible Services
|
||||
|
||||
Use `metadata.endpoint` to connect to S3-compatible storage:
|
||||
Use `metadata.endpoint` to connect to S3-compatible storage. These services don't use AWS-style regions, so set `metadata.region` to `auto` — the value is still required for request signing but the service ignores it.
|
||||
|
||||
```typescript
|
||||
// MinIO
|
||||
|
|
@ -100,7 +100,7 @@ const connection = await client.connections.create('s3', {
|
|||
accessKeyId: 'minio-key',
|
||||
secretAccessKey: 'minio-secret',
|
||||
bucket: 'my-bucket',
|
||||
region: 'us-east-1',
|
||||
region: 'auto',
|
||||
endpoint: 'https://minio.example.com'
|
||||
},
|
||||
containerTags: ['minio-sync']
|
||||
|
|
@ -113,6 +113,7 @@ Common S3-compatible endpoint values:
|
|||
|---------|----------------------|-------------------|
|
||||
| DigitalOcean Spaces | `https://nyc3.digitaloceanspaces.com` | `nyc3` |
|
||||
| Cloudflare R2 | `https://<account-id>.r2.cloudflarestorage.com` | `auto` |
|
||||
| Tigris | `https://t3.storage.dev` | `auto` |
|
||||
|
||||
Cloudflare R2 example:
|
||||
|
||||
|
|
|
|||
|
|
@ -46,7 +46,10 @@ export SUPERMEMORY_API_KEY="sm_..."
|
|||
// PATCH https://api.supermemory.ai/v3/settings
|
||||
fetch('https://api.supermemory.ai/v3/settings', {
|
||||
method: 'PATCH',
|
||||
headers: { 'x-supermemory-api-key': process.env.SUPERMEMORY_API_KEY },
|
||||
headers: {
|
||||
'Authorization': `Bearer ${process.env.SUPERMEMORY_API_KEY}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
shouldLLMFilter: true,
|
||||
filterPrompt: `This is a [your app description]. containerTag is [userId/orgId]. We store [what data].`
|
||||
|
|
@ -210,17 +213,20 @@ client.add(content=f"user: {user_message}\\nassistant: {response}", container_ta
|
|||
```bash
|
||||
# Add memory
|
||||
curl -X POST https://api.supermemory.ai/v3/documents \
|
||||
-H "x-supermemory-api-key: $SUPERMEMORY_API_KEY" \
|
||||
-H "Authorization: Bearer $SUPERMEMORY_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"content": "conversation", "containerTag": "userId"}'
|
||||
|
||||
# Get profile
|
||||
curl -X POST https://api.supermemory.ai/v4/profile \
|
||||
-H "x-supermemory-api-key: $SUPERMEMORY_API_KEY" \
|
||||
-H "Authorization: Bearer $SUPERMEMORY_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"containerTag": "userId", "q": "search query"}'
|
||||
|
||||
# Search
|
||||
curl -X POST https://api.supermemory.ai/v4/search \
|
||||
-H "x-supermemory-api-key: $SUPERMEMORY_API_KEY" \
|
||||
-H "Authorization: Bearer $SUPERMEMORY_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"q": "query", "containerTag": "userId", "searchMode": "hybrid"}'
|
||||
```
|
||||
|
||||
|
|
@ -234,7 +240,10 @@ formData.append('containerTag', userId)
|
|||
|
||||
await fetch('https://api.supermemory.ai/v3/documents/file', {
|
||||
method: 'POST',
|
||||
headers: { 'x-supermemory-api-key': process.env.SUPERMEMORY_API_KEY },
|
||||
headers: {
|
||||
'Authorization': `Bearer ${process.env.SUPERMEMORY_API_KEY}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: formData
|
||||
})
|
||||
|
||||
|
|
@ -282,17 +291,20 @@ await client.search({
|
|||
```bash
|
||||
# 1. Configure settings
|
||||
curl -X PATCH https://api.supermemory.ai/v3/settings \
|
||||
-H "x-supermemory-api-key: $SUPERMEMORY_API_KEY" \
|
||||
-H "Authorization: Bearer $SUPERMEMORY_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"shouldLLMFilter": true, "filterPrompt": "..."}'
|
||||
|
||||
# 2. Add test memory
|
||||
curl -X POST https://api.supermemory.ai/v3/documents \
|
||||
-H "x-supermemory-api-key: $SUPERMEMORY_API_KEY" \
|
||||
-H "Authorization: Bearer $SUPERMEMORY_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"content": "Test", "containerTag": "test_user"}'
|
||||
|
||||
# 3. Get profile
|
||||
curl -X POST https://api.supermemory.ai/v4/profile \
|
||||
-H "x-supermemory-api-key: $SUPERMEMORY_API_KEY" \
|
||||
-H "Authorization: Bearer $SUPERMEMORY_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"containerTag": "test_user"}'
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -52,6 +52,39 @@ You are integrating Supermemory into my application. Supermemory provides user m
|
|||
|
||||
Note: You can always reference the documentation by using the **SearchSupermemoryDocs MCP** or running a web search tool for content on **supermemory.ai/docs**.
|
||||
|
||||
CANONICAL API SURFACE (use these, nothing else):
|
||||
|
||||
- Auth header: `Authorization: Bearer $SUPERMEMORY_API_KEY` — the only supported auth header
|
||||
- Write content: POST https://api.supermemory.ai/v3/documents
|
||||
- Search: POST https://api.supermemory.ai/v4/search
|
||||
- Profile + search: POST https://api.supermemory.ai/v4/profile
|
||||
- Settings: PATCH https://api.supermemory.ai/v3/settings
|
||||
- Scoping: `containerTag` (singular string) in the JSON body — never in a header
|
||||
- SDK: `client.documents.add()`, `client.search.memories()`, `client.profile()`
|
||||
|
||||
DO NOT USE — these are deprecated, undocumented, or fabricated by previous AI codegen:
|
||||
|
||||
- Endpoints: /v1/anything, /v3/memories, /v3/search (use /v3/documents and /v4/search)
|
||||
- Headers: x-supermemory-api-key, x-api-key, x-sm-user-id, x-sm-project,
|
||||
x-project-id, X-Workspace-Id (always use Authorization: Bearer)
|
||||
- Body keys: containerTags (plural array), userId, spaces, schema, container,
|
||||
tags (top-level), filter (singular) (use containerTag + filters)
|
||||
- SDK calls: client.search.execute, client.documents.add (use client.add),
|
||||
client.documents.deleteBulk, client.documents.batch_add,
|
||||
client.memories.updateMemory (the real method is client.memories.update)
|
||||
- Kwargs: chunk_threshold (use `threshold`), sort, order, include_content,
|
||||
include_full_docs, timeout (as an SDK kwarg)
|
||||
|
||||
NOTE on memory mutation: `client.memories.update`, `client.memories.delete`, and
|
||||
`client.memories.forget` ARE real and supported — but most apps don't need them.
|
||||
Memories are auto-extracted from documents. Only reach for these if you're exposing
|
||||
a "manage my memories" UI to end users or agents.
|
||||
- Mixing: `rerank` and `rewriteQuery` are valid on /v4/search ONLY — never on /v3/search
|
||||
|
||||
SCOPING IS LOAD-BEARING. Every write and every search MUST include `containerTag`.
|
||||
If you omit it, every user's data collapses into the API key's default bucket — this
|
||||
is the single most common bug in AI-generated Supermemory integrations.
|
||||
|
||||
STEP 1: ASK ME THESE QUESTIONS
|
||||
|
||||
1. What are you building?
|
||||
|
|
@ -91,29 +124,34 @@ export SUPERMEMORY_API_KEY="sm_..."
|
|||
|
||||
STEP 3: CONFIGURE SETTINGS (DO THIS FIRST)
|
||||
|
||||
typescript
|
||||
```typescript
|
||||
// PATCH https://api.supermemory.ai/v3/settings
|
||||
fetch('https://api.supermemory.ai/v3/settings', {
|
||||
method: 'PATCH',
|
||||
headers: { 'x-supermemory-api-key': process.env.SUPERMEMORY_API_KEY },
|
||||
headers: {
|
||||
'Authorization': `Bearer ${process.env.SUPERMEMORY_API_KEY}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
shouldLLMFilter: true,
|
||||
filterPrompt: `This is a [your app description]. containerTag is [userId/orgId]. We store [what data].`
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
STEP 4: CONTAINER TAG STRATEGY
|
||||
|
||||
Based on their data model answer:
|
||||
|
||||
USER-ONLY APP:
|
||||
typescript
|
||||
|
||||
```typescript
|
||||
containerTag: userId // Each user's memories are isolated
|
||||
```
|
||||
|
||||
ORG-ONLY APP:
|
||||
typescript
|
||||
```typescript
|
||||
containerTag: orgId // Org members share memories
|
||||
|
||||
```
|
||||
|
||||
BOTH (ask which):
|
||||
- Option A: `containerTag: \`\${userId}-\${orgId}\``
|
||||
|
|
@ -126,7 +164,7 @@ Based on their integration choice:
|
|||
|
||||
--- VERCEL AI SDK ---
|
||||
|
||||
typescript
|
||||
```typescript
|
||||
import { streamText } from 'ai'
|
||||
import { anthropic } from '@ai-sdk/anthropic'
|
||||
import { supermemoryTools } from '@supermemory/tools/ai-sdk'
|
||||
|
|
@ -136,7 +174,7 @@ const result = await streamText({
|
|||
model: anthropic('claude-3-5-sonnet-20241022'),
|
||||
prompt: userMessage,
|
||||
tools: supermemoryTools(process.env.SUPERMEMORY_API_KEY, {
|
||||
containerTags: [userId]
|
||||
containerTag: userId // singular string — never an array
|
||||
})
|
||||
})
|
||||
// Agent gets searchMemories, addMemory, fetchMemory tools
|
||||
|
|
@ -153,11 +191,12 @@ const result = await generateText({
|
|||
messages: [{ role: 'user', content: userMessage }]
|
||||
})
|
||||
// Profile is automatically injected into context
|
||||
```
|
||||
|
||||
|
||||
--- DIRECT SDK (WITH PROFILES) ---
|
||||
|
||||
typescript
|
||||
```typescript
|
||||
import Supermemory from 'supermemory'
|
||||
|
||||
const client = new Supermemory()
|
||||
|
|
@ -187,7 +226,7 @@ await client.add({
|
|||
content: `user: ${userMessage}\nassistant: ${response}`,
|
||||
containerTag: userId
|
||||
})
|
||||
|
||||
```
|
||||
|
||||
--- DIRECT SDK (NO PROFILES) ---
|
||||
|
||||
|
|
@ -218,9 +257,11 @@ await client.add({
|
|||
content: `user: ${userMessage}\nassistant: ${response}`,
|
||||
containerTag: userId
|
||||
})
|
||||
```
|
||||
|
||||
--- PYTHON VERSION ---
|
||||
python
|
||||
|
||||
```python
|
||||
from supermemory import Supermemory
|
||||
|
||||
client = Supermemory()
|
||||
|
|
@ -238,28 +279,33 @@ Dynamic: {chr(10).join(profile_data.profile.dynamic)}
|
|||
|
||||
# Store conversation
|
||||
client.add(content=f"user: {user_message}\\nassistant: {response}", container_tag=user_id)
|
||||
```
|
||||
|
||||
--- DIRECT API ---
|
||||
|
||||
bash
|
||||
```bash
|
||||
# Add memory
|
||||
curl -X POST https://api.supermemory.ai/v3/documents \
|
||||
-H "x-supermemory-api-key: $SUPERMEMORY_API_KEY" \
|
||||
-H "Authorization: Bearer $SUPERMEMORY_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"content": "conversation", "containerTag": "userId"}'
|
||||
|
||||
# Get profile
|
||||
curl -X POST https://api.supermemory.ai/v4/profile \
|
||||
-H "x-supermemory-api-key: $SUPERMEMORY_API_KEY" \
|
||||
-H "Authorization: Bearer $SUPERMEMORY_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"containerTag": "userId", "q": "search query"}'
|
||||
|
||||
# Search
|
||||
curl -X POST https://api.supermemory.ai/v4/search \
|
||||
-H "x-supermemory-api-key: $SUPERMEMORY_API_KEY" \
|
||||
-H "Authorization: Bearer $SUPERMEMORY_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"q": "query", "containerTag": "userId", "searchMode": "hybrid"}'
|
||||
```
|
||||
|
||||
STEP 6: FILE UPLOADS (if they need it)
|
||||
|
||||
typescript
|
||||
```typescript
|
||||
// Files are automatically extracted (PDFs, images with OCR, videos with transcription)
|
||||
const formData = new FormData()
|
||||
formData.append('file', fileBlob)
|
||||
|
|
@ -267,28 +313,29 @@ formData.append('containerTag', userId)
|
|||
|
||||
await fetch('https://api.supermemory.ai/v3/documents/file', {
|
||||
method: 'POST',
|
||||
headers: { 'x-supermemory-api-key': process.env.SUPERMEMORY_API_KEY },
|
||||
headers: { 'Authorization': `Bearer ${process.env.SUPERMEMORY_API_KEY}` },
|
||||
body: formData
|
||||
})
|
||||
|
||||
// Processing is async - check status before assuming searchable
|
||||
// GET /v3/documents/{documentId}
|
||||
|
||||
```
|
||||
|
||||
STEP 7: SEARCH MODES
|
||||
|
||||
typescript
|
||||
```typescript
|
||||
// HYBRID (recommended) - searches memories + document chunks
|
||||
searchMode: 'hybrid'
|
||||
|
||||
// MEMORIES ONLY - just extracted memories, no original text
|
||||
searchMode: 'memories'
|
||||
|
||||
```
|
||||
|
||||
STEP 8: METADATA FILTERS (if they need secondary filtering)
|
||||
|
||||
typescript
|
||||
await client.search({
|
||||
```typescript
|
||||
// Always against /v4/search — rerank/rewriteQuery/filters are v4-only
|
||||
await client.search.memories({
|
||||
q: query,
|
||||
containerTag: userId,
|
||||
filters: {
|
||||
|
|
@ -298,6 +345,7 @@ await client.search({
|
|||
]
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
KEY POINTS:
|
||||
|
||||
|
|
@ -311,21 +359,25 @@ KEY POINTS:
|
|||
|
||||
TESTING:
|
||||
|
||||
bash
|
||||
```bash
|
||||
# 1. Configure settings
|
||||
curl -X PATCH https://api.supermemory.ai/v3/settings \
|
||||
-H "x-supermemory-api-key: $SUPERMEMORY_API_KEY" \
|
||||
-H "Authorization: Bearer $SUPERMEMORY_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"shouldLLMFilter": true, "filterPrompt": "..."}'
|
||||
|
||||
# 2. Add test memory
|
||||
curl -X POST https://api.supermemory.ai/v3/documents \
|
||||
-H "x-supermemory-api-key: $SUPERMEMORY_API_KEY" \
|
||||
-H "Authorization: Bearer $SUPERMEMORY_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"content": "Test", "containerTag": "test_user"}'
|
||||
|
||||
# 3. Get profile
|
||||
curl -X POST https://api.supermemory.ai/v4/profile \
|
||||
-H "x-supermemory-api-key: $SUPERMEMORY_API_KEY" \
|
||||
-H "Authorization: Bearer $SUPERMEMORY_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"containerTag": "test_user"}'
|
||||
```
|
||||
|
||||
|
||||
NOW:
|
||||
|
|
|
|||
|
|
@ -1,14 +1,12 @@
|
|||
"use client"
|
||||
|
||||
import { EnsureWorkspace } from "@/components/ensure-workspace"
|
||||
import { NextAppResearchCta } from "@/components/next-app-research-cta"
|
||||
import { PWAInstallPrompt } from "@/components/pwa-install-prompt"
|
||||
|
||||
export default function AppLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<EnsureWorkspace>{children}</EnsureWorkspace>
|
||||
<NextAppResearchCta />
|
||||
<PWAInstallPrompt />
|
||||
</>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -555,6 +555,7 @@ export default function OnboardingPage() {
|
|||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const skippingRef = useRef(false)
|
||||
const [isSkipping, setIsSkipping] = useState(false)
|
||||
const [spotlightCategory, setSpotlightCategory] =
|
||||
useState<SpotlightCategoryId>("productivity")
|
||||
|
||||
|
|
@ -617,6 +618,7 @@ export default function OnboardingPage() {
|
|||
const handleSkip = useCallback(async () => {
|
||||
if (skippingRef.current) return
|
||||
skippingRef.current = true
|
||||
setIsSkipping(true)
|
||||
try {
|
||||
await ensureOrg()
|
||||
const pendingPath = consumePendingConnectUrl()
|
||||
|
|
@ -624,6 +626,7 @@ export default function OnboardingPage() {
|
|||
} catch (err) {
|
||||
console.error(err)
|
||||
skippingRef.current = false
|
||||
setIsSkipping(false)
|
||||
}
|
||||
}, [ensureOrg, router])
|
||||
|
||||
|
|
@ -798,9 +801,22 @@ export default function OnboardingPage() {
|
|||
<button
|
||||
type="button"
|
||||
onClick={handleSkip}
|
||||
className="text-[#525966] text-sm hover:text-white transition-colors cursor-pointer"
|
||||
disabled={isSkipping}
|
||||
className={cn(
|
||||
"text-sm transition-colors cursor-pointer flex items-center gap-1.5",
|
||||
isSkipping
|
||||
? "text-[#525966] cursor-not-allowed"
|
||||
: "text-[#525966] hover:text-white",
|
||||
)}
|
||||
>
|
||||
Skip for now →
|
||||
{isSkipping ? (
|
||||
<>
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
Skipping…
|
||||
</>
|
||||
) : (
|
||||
"Skip for now →"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
|
@ -870,34 +886,36 @@ export default function OnboardingPage() {
|
|||
/>
|
||||
|
||||
<AnimatePresence>
|
||||
{isCheckingAccount && (
|
||||
{hasDetectedAccount && detected !== "resume" && (
|
||||
<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"
|
||||
className="absolute right-1 flex size-8 items-center justify-center"
|
||||
>
|
||||
<Loader2 className="size-4 animate-spin text-[#6BB0FF]" />
|
||||
{isCheckingAccount ? (
|
||||
<Loader2 className="size-4 animate-spin text-[#6BB0FF]" />
|
||||
) : canSubmit ? (
|
||||
<motion.button
|
||||
type="button"
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
onClick={() =>
|
||||
handleSubmit(detected as "x" | "linkedin")
|
||||
}
|
||||
className="rounded-xl size-8 flex items-center justify-center border-[0.5px] border-[#161F2C] hover:scale-[0.95] active:scale-[0.95] transition-transform cursor-pointer"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(180deg, #0D121A -26.14%, #000 100%)",
|
||||
}}
|
||||
>
|
||||
<SubmitArrow />
|
||||
</motion.button>
|
||||
) : null}
|
||||
</motion.span>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{canSubmit && (
|
||||
<motion.button
|
||||
type="button"
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
onClick={() => handleSubmit(detected as "x" | "linkedin")}
|
||||
className="absolute right-1 rounded-xl size-8 flex items-center justify-center border-[0.5px] border-[#161F2C] hover:scale-[0.95] active:scale-[0.95] transition-transform cursor-pointer"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(180deg, #0D121A -26.14%, #000 100%)",
|
||||
}}
|
||||
>
|
||||
<SubmitArrow />
|
||||
</motion.button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
|
|
@ -929,9 +947,6 @@ export default function OnboardingPage() {
|
|||
{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"]}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
import { AnimatePresence, motion } from "motion/react"
|
||||
import { useQueryState } from "nuqs"
|
||||
import { Header, PublicHeader } from "@/components/header"
|
||||
import { MobileBottomNav } from "@/components/bottom-nav"
|
||||
import { ChatSidebar, HomeChatComposer } from "@/components/chat"
|
||||
import { DashboardView } from "@/components/dashboard-view"
|
||||
import { MemoriesGrid } from "@/components/memories-grid"
|
||||
|
|
@ -60,6 +61,7 @@ import {
|
|||
type IntegrationParamValue,
|
||||
} from "@/lib/search-params"
|
||||
import { getChatSpaceDisplayLabel } from "@/lib/chat-space-label"
|
||||
import { getToolDocumentSpace } from "@/lib/plugin-space"
|
||||
|
||||
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
|
||||
type DocumentWithMemories = DocumentsResponse["documents"][0]
|
||||
|
|
@ -106,7 +108,7 @@ export default function NewPage() {
|
|||
const isMobile = useIsMobile()
|
||||
const { user, session } = useAuth()
|
||||
|
||||
const { selectedProject, selectedProjects } = useProject()
|
||||
const { selectedProject, selectedProjects, setSelectedProject } = useProject()
|
||||
const selectedProjectTag = selectedProjects[0]
|
||||
const { allProjects } = useContainerTags()
|
||||
const dashboardSpaceLabel = useMemo(
|
||||
|
|
@ -408,6 +410,18 @@ export default function NewPage() {
|
|||
[setDocId],
|
||||
)
|
||||
|
||||
const handleOpenToolDocument = useCallback(
|
||||
(document: DocumentWithMemories, pluginClientId: string) => {
|
||||
const documentSpace = getToolDocumentSpace(document, pluginClientId)
|
||||
if (documentSpace) {
|
||||
setSelectedProject(documentSpace)
|
||||
}
|
||||
handleOpenDocument(document)
|
||||
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
|
||||
|
|
@ -514,9 +528,13 @@ export default function NewPage() {
|
|||
|
||||
const handleOpenIntegrations = useCallback(
|
||||
(integration?: IntegrationParamValue) => {
|
||||
if (integration === "notion" || integration === "google-drive") {
|
||||
void setAddDoc("connect")
|
||||
return
|
||||
}
|
||||
void setViewMode(integration ?? "integrations")
|
||||
},
|
||||
[setViewMode],
|
||||
[setViewMode, setAddDoc],
|
||||
)
|
||||
|
||||
const handleOpenPlugins = useCallback(() => {
|
||||
|
|
@ -544,6 +562,7 @@ export default function NewPage() {
|
|||
const isDashboardShell =
|
||||
viewMode === "dashboard" || (viewMode === "graph" && isMobile)
|
||||
const isGraphMode = viewMode === "graph"
|
||||
const showBottomNav = isMobile && !isChatView && !!session
|
||||
|
||||
return (
|
||||
<HotkeysProvider>
|
||||
|
|
@ -551,23 +570,23 @@ export default function NewPage() {
|
|||
className={cn(
|
||||
"relative flex min-h-dvh flex-col bg-[#05080D]",
|
||||
isGraphMode && "h-dvh overflow-hidden",
|
||||
showBottomNav &&
|
||||
!isGraphMode &&
|
||||
"pb-[calc(5.5rem+env(safe-area-inset-bottom))]",
|
||||
)}
|
||||
>
|
||||
{showNovaBackdrop && (
|
||||
<>
|
||||
<div className="pointer-events-none fixed inset-0 z-0">
|
||||
<AnimatedGradientBackground
|
||||
animateFromBottom={false}
|
||||
topPosition={gradientTopPosition}
|
||||
/>
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 z-0 bg-[#05080D]/50"
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="absolute inset-0 bg-[#05080D]/50" aria-hidden />
|
||||
<div
|
||||
id="graph-dotted-grid"
|
||||
className="pointer-events-none absolute inset-0 z-[1] bg-[radial-gradient(circle_at_center,rgba(105,167,240,0.25)_1px,transparent_1px)] bg-size-[32px_32px] mask-[radial-gradient(ellipse_at_center,black_60%,transparent_100%)]"
|
||||
className="absolute inset-0 bg-[radial-gradient(circle_at_center,rgba(105,167,240,0.25)_1px,transparent_1px)] bg-size-[32px_32px] mask-[radial-gradient(ellipse_at_center,black_60%,transparent_100%)]"
|
||||
/>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
{!session && viewMode === "mcp" ? (
|
||||
<PublicHeader />
|
||||
|
|
@ -711,6 +730,7 @@ export default function NewPage() {
|
|||
onNavigateToMemories={() => void setViewMode("list")}
|
||||
onNavigateToGraph={() => void setViewMode("graph")}
|
||||
onOpenDocument={handleOpenDocument}
|
||||
onOpenToolDocument={handleOpenToolDocument}
|
||||
onHighlightsChat={handleHighlightsChat}
|
||||
onHighlightsShowRelated={handleHighlightsShowRelated}
|
||||
onResetHighlights={handleResetHighlights}
|
||||
|
|
@ -722,14 +742,37 @@ export default function NewPage() {
|
|||
</motion.main>
|
||||
</AnimatePresence>
|
||||
|
||||
{isDashboardShell && showBottomNav && (
|
||||
<div className="pointer-events-none fixed inset-x-0 bottom-0 z-20 h-64 bg-gradient-to-t from-[#05080D] via-[#05080D]/95 to-transparent" />
|
||||
)}
|
||||
{isDashboardShell && (
|
||||
<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={cn(
|
||||
"pointer-events-none fixed inset-x-0 z-30",
|
||||
showBottomNav
|
||||
? "bottom-[4.25rem]"
|
||||
: "bottom-0 bg-gradient-to-t from-black via-black/40 to-transparent pt-12",
|
||||
)}
|
||||
>
|
||||
<div className="pointer-events-auto">
|
||||
<HomeChatComposer onStartChat={handleHomeChatStart} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showBottomNav && (
|
||||
<MobileBottomNav
|
||||
onAddMemory={() => {
|
||||
analytics.addDocumentModalOpened()
|
||||
setAddDoc("note")
|
||||
}}
|
||||
onOpenSearch={() => {
|
||||
analytics.searchOpened({ source: "header" })
|
||||
setIsSearchOpen(true)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AddDocumentModal
|
||||
isOpen={addDoc !== null}
|
||||
onClose={() => setAddDoc(null)}
|
||||
|
|
|
|||
|
|
@ -8,8 +8,9 @@ export default function SettingsIntegrationsPage() {
|
|||
const searchParams = useSearchParams()
|
||||
|
||||
useEffect(() => {
|
||||
const qs = searchParams.toString()
|
||||
router.replace(`/settings${qs ? `?${qs}` : ""}#integrations`)
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
params.set("view", "integrations")
|
||||
router.replace(`/?${params.toString()}`)
|
||||
}, [router, searchParams])
|
||||
|
||||
return null
|
||||
|
|
|
|||
|
|
@ -2,12 +2,12 @@
|
|||
import { Logo } from "@ui/assets/Logo"
|
||||
import { UserProfileMenu } from "@/components/user-profile-menu"
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
import { motion } from "motion/react"
|
||||
import NovaOrb from "@/components/nova/nova-orb"
|
||||
import { useState, useEffect, useRef } from "react"
|
||||
import { useState, useEffect, useRef, useMemo } from "react"
|
||||
import { cn } from "@lib/utils"
|
||||
import { dmSansClassName, dmSans125ClassName } from "@/lib/fonts"
|
||||
import Account from "@/components/settings/account"
|
||||
import Billing from "@/components/settings/billing"
|
||||
import Integrations from "@/components/settings/integrations"
|
||||
import ConnectionsMCP from "@/components/settings/connections-mcp"
|
||||
import Support from "@/components/settings/support"
|
||||
|
|
@ -16,13 +16,42 @@ import { useRouter } from "next/navigation"
|
|||
import { useIsMobile } from "@hooks/use-mobile"
|
||||
import { useLocalStorageUsername } from "@hooks/use-local-storage-username"
|
||||
import { analytics } from "@/lib/analytics"
|
||||
import { LogOut, RotateCcw, Trash2, Sun, LoaderIcon } from "lucide-react"
|
||||
import {
|
||||
LogOut,
|
||||
RotateCcw,
|
||||
Trash2,
|
||||
Sun,
|
||||
LoaderIcon,
|
||||
User as UserIcon,
|
||||
Zap,
|
||||
HelpCircle,
|
||||
CreditCard,
|
||||
ShieldAlert,
|
||||
ChevronRight,
|
||||
ChevronsUpDown,
|
||||
Check,
|
||||
Building2,
|
||||
} from "lucide-react"
|
||||
import { authClient } from "@lib/auth"
|
||||
import { Dialog, DialogContent, DialogClose } from "@ui/components/dialog"
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@ui/components/popover"
|
||||
import { useResetOrganization } from "@/hooks/use-reset-organization"
|
||||
import { useDeleteUserAccount } from "@/hooks/use-account-settings"
|
||||
import { useCustomer } from "autumn-js/react"
|
||||
import { useOrgSummaries } from "@/hooks/use-org-summaries"
|
||||
import {
|
||||
PLAN_DISPLAY_NAMES,
|
||||
useTokenUsage,
|
||||
type PlanType,
|
||||
} from "@/hooks/use-token-usage"
|
||||
|
||||
const TABS = ["account", "integrations", "connections", "support"] as const
|
||||
const TABS = [
|
||||
"account",
|
||||
"billing",
|
||||
"integrations",
|
||||
"connections",
|
||||
"support",
|
||||
] as const
|
||||
type SettingsTab = (typeof TABS)[number]
|
||||
|
||||
type NavItem = {
|
||||
|
|
@ -32,134 +61,39 @@ type NavItem = {
|
|||
icon: React.ReactNode
|
||||
}
|
||||
|
||||
type DangerItem = {
|
||||
id: "logout" | "reset" | "delete"
|
||||
label: string
|
||||
description: string
|
||||
icon: React.ReactNode
|
||||
color: "neutral" | "amber" | "red"
|
||||
}
|
||||
|
||||
const NAV_ITEMS: NavItem[] = [
|
||||
{
|
||||
id: "account",
|
||||
label: "Account & Billing",
|
||||
description: "Manage your profile, plan, usage and payments",
|
||||
icon: (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="12" cy="7" r="4" />
|
||||
</svg>
|
||||
),
|
||||
label: "Account",
|
||||
description: "Your profile and organization",
|
||||
icon: <UserIcon className="size-[18px]" />,
|
||||
},
|
||||
{
|
||||
id: "billing",
|
||||
label: "Billing",
|
||||
description: "Plan, usage and payments",
|
||||
icon: <CreditCard className="size-[18px]" />,
|
||||
},
|
||||
{
|
||||
id: "integrations",
|
||||
label: "Integrations",
|
||||
description: "Save, sync and search memories across tools",
|
||||
icon: <Sun className="size-5" />,
|
||||
description: "Save, sync and search across tools",
|
||||
icon: <Sun className="size-[18px]" />,
|
||||
},
|
||||
{
|
||||
id: "connections",
|
||||
label: "Connections & MCP",
|
||||
description: "Sync with Google Drive, Notion, OneDrive and MCP client",
|
||||
icon: (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M13 2 3 14h9l-1 8 10-12h-9l1-8z" />
|
||||
</svg>
|
||||
),
|
||||
description: "Drive, Notion, OneDrive, MCP",
|
||||
icon: <Zap className="size-[18px]" />,
|
||||
},
|
||||
{
|
||||
id: "support",
|
||||
label: "Support & Help",
|
||||
description: "Find answers or share feedback. We're here to help.",
|
||||
icon: (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" />
|
||||
<path d="M12 17h.01" />
|
||||
</svg>
|
||||
),
|
||||
description: "Get help or share feedback",
|
||||
icon: <HelpCircle className="size-[18px]" />,
|
||||
},
|
||||
]
|
||||
|
||||
const DANGER_ITEMS: DangerItem[] = [
|
||||
{
|
||||
id: "logout",
|
||||
label: "Log out",
|
||||
description: "Sign out of your account on this device",
|
||||
icon: <LogOut className="size-5" />,
|
||||
color: "neutral",
|
||||
},
|
||||
{
|
||||
id: "reset",
|
||||
label: "Reset data",
|
||||
description: "Erase all memories, connections and spaces",
|
||||
icon: <RotateCcw className="size-5" />,
|
||||
color: "amber",
|
||||
},
|
||||
{
|
||||
id: "delete",
|
||||
label: "Delete account",
|
||||
description: "Permanently delete your account and all data",
|
||||
icon: <Trash2 className="size-5" />,
|
||||
color: "red",
|
||||
},
|
||||
]
|
||||
|
||||
const DANGER_COLORS: Record<
|
||||
DangerItem["color"],
|
||||
{ idle: string; hover: string; icon: string }
|
||||
> = {
|
||||
neutral: {
|
||||
idle: "text-white/50",
|
||||
hover: "hover:text-white",
|
||||
icon: "text-white/40",
|
||||
},
|
||||
amber: {
|
||||
idle: "text-[#7A6030]",
|
||||
hover: "hover:text-[#C7991B]",
|
||||
icon: "text-[#7A6030]",
|
||||
},
|
||||
red: {
|
||||
idle: "text-[#6B2A2A]",
|
||||
hover: "hover:text-[#C73B1B]",
|
||||
icon: "text-[#6B2A2A]",
|
||||
},
|
||||
}
|
||||
|
||||
function parseHashToTab(hash: string): SettingsTab {
|
||||
const cleaned = hash.replace("#", "").toLowerCase()
|
||||
return TABS.includes(cleaned as SettingsTab)
|
||||
|
|
@ -167,30 +101,75 @@ function parseHashToTab(hash: string): SettingsTab {
|
|||
: "account"
|
||||
}
|
||||
|
||||
export function UserSupermemory({ name }: { name: string }) {
|
||||
const ORG_PLAN_BADGE_STYLES: Record<PlanType, string> = {
|
||||
free: "bg-[#2E353D] font-mono font-medium tracking-[0.12em] text-[#A3A3A3]",
|
||||
pro: "bg-[#4BA0FA] font-bold tracking-[0.36px] text-[#00171A]",
|
||||
scale: "bg-[#0054AD] font-bold tracking-[0.36px] text-[#FAFAFA]",
|
||||
enterprise: "bg-[#FAFAFA] font-bold tracking-[0.36px] text-[#0D121A]",
|
||||
}
|
||||
|
||||
function OrgPlanBadge({ plan }: { plan: PlanType }) {
|
||||
return (
|
||||
<motion.div
|
||||
className="absolute inset-0 top-[-40px] flex items-center justify-center z-10"
|
||||
initial={{ opacity: 0, y: 0 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 0 }}
|
||||
transition={{ duration: 1, ease: "easeOut" }}
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"inline-flex h-[18px] min-w-[42px] shrink-0 items-center justify-center rounded-[3px] px-1.5 text-[10px] uppercase",
|
||||
ORG_PLAN_BADGE_STYLES[plan],
|
||||
)}
|
||||
>
|
||||
<Logo className="h-7 text-white" />
|
||||
<div className="flex flex-col items-start justify-center ml-4 space-y-1">
|
||||
<p className="text-white text-[15px] font-medium leading-none">
|
||||
{name.split(" ")[0]}'s
|
||||
</p>
|
||||
<p className="text-white font-bold text-xl leading-none -mt-2">
|
||||
supermemory
|
||||
</p>
|
||||
{PLAN_DISPLAY_NAMES[plan]}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function resolveOrgPlan(
|
||||
orgId: string,
|
||||
isCurrent: boolean,
|
||||
currentPlan: PlanType,
|
||||
planByOrgId: Map<string, PlanType>,
|
||||
): PlanType {
|
||||
const fromSummary = planByOrgId.get(orgId)
|
||||
if (fromSummary) return fromSummary
|
||||
if (isCurrent) return currentPlan
|
||||
return "free"
|
||||
}
|
||||
|
||||
function SectionLabel({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="px-3 pt-3 pb-1.5 text-[10.5px] font-semibold tracking-[0.14em] text-white/30 uppercase">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function IdentityCard({ displayName }: { displayName: string }) {
|
||||
const firstName = displayName?.split(" ")[0] || ""
|
||||
|
||||
return (
|
||||
<div className="relative flex items-center justify-center h-[140px]">
|
||||
<NovaOrb size={150} className="blur-[3px]!" />
|
||||
<div className="absolute inset-0 flex items-center justify-center z-10">
|
||||
<Logo className="h-7 shrink-0 text-white" />
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col items-start justify-center ml-3.5",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
<p className="text-white text-[14px] font-medium leading-none">
|
||||
{firstName ? `${firstName}'s` : "Your"}
|
||||
</p>
|
||||
<p className="text-white font-bold text-[20px] leading-none mt-1">
|
||||
supermemory
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { user, org } = useAuth()
|
||||
const { user, org, organizations, setActiveOrg } = useAuth()
|
||||
const [activeTab, setActiveTab] = useState<SettingsTab>("account")
|
||||
const hasInitialized = useRef(false)
|
||||
const router = useRouter()
|
||||
|
|
@ -205,6 +184,40 @@ export default function SettingsPage() {
|
|||
const [deleteEmailConfirm, setDeleteEmailConfirm] = useState("")
|
||||
const deleteUserAccount = useDeleteUserAccount()
|
||||
|
||||
const [dangerMenuOpen, setDangerMenuOpen] = useState(false)
|
||||
const [orgSwitcherOpen, setOrgSwitcherOpen] = useState(false)
|
||||
const [switchingOrgId, setSwitchingOrgId] = useState<string | null>(null)
|
||||
const canSwitchOrg = (organizations?.length ?? 0) > 1
|
||||
|
||||
const autumn = useCustomer()
|
||||
const { currentPlan } = useTokenUsage(autumn)
|
||||
const { data: orgSummaries } = useOrgSummaries()
|
||||
const planByOrgId = useMemo(() => {
|
||||
const map = new Map<string, PlanType>()
|
||||
for (const summary of orgSummaries ?? []) {
|
||||
map.set(summary.orgId, summary.plan)
|
||||
}
|
||||
return map
|
||||
}, [orgSummaries])
|
||||
const activeOrgPlan = org?.id
|
||||
? resolveOrgPlan(org.id, true, currentPlan, planByOrgId)
|
||||
: currentPlan
|
||||
|
||||
const handleOrgSwitch = async (orgSlug: string, orgId: string) => {
|
||||
if (orgId === org?.id) {
|
||||
setOrgSwitcherOpen(false)
|
||||
return
|
||||
}
|
||||
setSwitchingOrgId(orgId)
|
||||
try {
|
||||
await setActiveOrg(orgSlug)
|
||||
window.location.reload()
|
||||
} catch (error) {
|
||||
console.error("Failed to switch organization:", error)
|
||||
setSwitchingOrgId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleLogout = async () => {
|
||||
await authClient.signOut()
|
||||
router.push("/login")
|
||||
|
|
@ -233,7 +246,6 @@ export default function SettingsPage() {
|
|||
setActiveTab(tab)
|
||||
analytics.settingsTabChanged({ tab })
|
||||
|
||||
// If no hash or invalid hash, push #account
|
||||
if (!hash || !TABS.includes(hash.replace("#", "") as SettingsTab)) {
|
||||
window.history.pushState(null, "", "#account")
|
||||
}
|
||||
|
|
@ -256,13 +268,10 @@ export default function SettingsPage() {
|
|||
user?.name ||
|
||||
user?.email?.split("@")[0] ||
|
||||
""
|
||||
const headerPossessive = headerDisplayName
|
||||
? `${headerDisplayName.split(" ")[0]}'s`
|
||||
: "Your"
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col overflow-hidden">
|
||||
<header className="relative z-20 flex justify-between items-center gap-3 px-4 md:px-6 py-3 shrink-0">
|
||||
<div className="h-screen flex flex-col overflow-hidden bg-[#08090C]">
|
||||
<header className="relative z-20 flex justify-between items-center gap-3 px-4 md:px-8 py-3 shrink-0">
|
||||
<nav
|
||||
className={cn(
|
||||
"flex items-center gap-2 sm:gap-3 min-w-0 text-sm",
|
||||
|
|
@ -279,144 +288,271 @@ export default function SettingsPage() {
|
|||
)}
|
||||
>
|
||||
<Logo className="h-7 shrink-0" />
|
||||
{headerDisplayName ? (
|
||||
<div className="flex flex-col items-start justify-center ml-2 min-w-0">
|
||||
<p className="text-[#8B8B8B] text-[11px] leading-tight">
|
||||
{headerPossessive}
|
||||
</p>
|
||||
<p className="text-white font-bold text-xl leading-none -mt-1">
|
||||
supermemory
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<span className="ml-2 font-medium text-white/90">
|
||||
supermemory
|
||||
</span>
|
||||
)}
|
||||
<span className="ml-2 font-semibold text-white/90 tracking-tight">
|
||||
supermemory
|
||||
</span>
|
||||
</button>
|
||||
<span className="text-white/35 shrink-0" aria-hidden>
|
||||
<span className="text-white/30 shrink-0" aria-hidden>
|
||||
/
|
||||
</span>
|
||||
<span className="text-white/50 font-medium shrink-0">Settings</span>
|
||||
<span className="text-white/55 font-medium shrink-0">Settings</span>
|
||||
</nav>
|
||||
<UserProfileMenu />
|
||||
</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: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: 28,
|
||||
paddingTop: 0,
|
||||
}}
|
||||
transition={{
|
||||
duration: 0.8,
|
||||
ease: "easeOut",
|
||||
delay: 0.2,
|
||||
}}
|
||||
className="relative flex items-center justify-center"
|
||||
<div className="flex items-center gap-2 sm:gap-3 shrink-0">
|
||||
{!isMobile &&
|
||||
(canSwitchOrg ? (
|
||||
<Popover open={orgSwitcherOpen} onOpenChange={setOrgSwitcherOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"group flex items-center gap-2 rounded-full pl-1.5 pr-2.5 py-1.5 transition-colors",
|
||||
"bg-white/[0.03] border border-white/[0.06]",
|
||||
"cursor-pointer hover:bg-white/[0.06]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
<span className="flex size-6 shrink-0 items-center justify-center rounded-full bg-white/[0.05] text-white/55">
|
||||
<Building2 className="size-[13px]" />
|
||||
</span>
|
||||
<span className="max-w-[160px] text-left text-[13px] font-medium text-white truncate leading-none">
|
||||
{org?.name ?? "Personal"}
|
||||
</span>
|
||||
<OrgPlanBadge plan={activeOrgPlan} />
|
||||
<ChevronsUpDown className="size-3.5 shrink-0 text-white/40" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="end"
|
||||
side="bottom"
|
||||
sideOffset={8}
|
||||
className={cn(
|
||||
"w-[260px] max-h-80 overflow-y-auto p-1.5",
|
||||
"bg-[#14161A] border-white/10 rounded-[14px]",
|
||||
"shadow-[0px_8px_28px_rgba(0,0,0,0.5)]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
{[...(organizations ?? [])]
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map((organization) => {
|
||||
const isCurrent = organization.id === org?.id
|
||||
const isSwitching = switchingOrgId === organization.id
|
||||
const plan = resolveOrgPlan(
|
||||
organization.id,
|
||||
isCurrent,
|
||||
currentPlan,
|
||||
planByOrgId,
|
||||
)
|
||||
return (
|
||||
<button
|
||||
key={organization.id}
|
||||
type="button"
|
||||
disabled={isCurrent || isSwitching}
|
||||
onClick={() =>
|
||||
handleOrgSwitch(organization.slug, organization.id)
|
||||
}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2.5 rounded-[10px] px-3 py-2 text-left transition-colors",
|
||||
isCurrent
|
||||
? "bg-white/5"
|
||||
: "hover:bg-white/5 cursor-pointer",
|
||||
"disabled:cursor-default",
|
||||
)}
|
||||
>
|
||||
<Building2 className="size-4 shrink-0 text-white/40" />
|
||||
<span className="min-w-0 flex-1 truncate text-[13.5px] text-white">
|
||||
{organization.name}
|
||||
</span>
|
||||
{isSwitching ? (
|
||||
<LoaderIcon className="size-4 shrink-0 animate-spin text-[#4BA0FA]" />
|
||||
) : isCurrent ? (
|
||||
<Check className="size-4 shrink-0 text-[#4BA0FA]" />
|
||||
) : null}
|
||||
<OrgPlanBadge plan={plan} />
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-full pl-1.5 pr-2.5 py-1.5",
|
||||
"bg-white/[0.03] border border-white/[0.06]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
<NovaOrb size={140} className="blur-[3px]!" />
|
||||
<UserSupermemory name={headerDisplayName} />
|
||||
</motion.div>
|
||||
<span className="flex size-6 shrink-0 items-center justify-center rounded-full bg-white/[0.05] text-white/55">
|
||||
<Building2 className="size-[13px]" />
|
||||
</span>
|
||||
<span className="max-w-[160px] text-left text-[13px] font-medium text-white truncate leading-none">
|
||||
{org?.name ?? "Personal"}
|
||||
</span>
|
||||
<OrgPlanBadge plan={activeOrgPlan} />
|
||||
</div>
|
||||
))}
|
||||
<UserProfileMenu />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 min-h-0 overflow-y-auto md:overflow-hidden">
|
||||
<div className="flex flex-col md:flex-row gap-4 md:gap-6 lg:gap-10 px-4 md:px-8 pt-5 md:pt-7 pb-6 md:h-full md:w-full md:max-w-[1240px] md:mx-auto">
|
||||
{/* Left rail */}
|
||||
<aside className="md:flex md:h-full md:min-h-0 md:w-[280px] md:flex-col shrink-0">
|
||||
{!isMobile && (
|
||||
<div className="mb-4">
|
||||
<IdentityCard displayName={headerDisplayName} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<nav
|
||||
className={cn(
|
||||
"flex",
|
||||
isMobile
|
||||
? "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",
|
||||
: "min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto pr-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
window.location.hash = item.id
|
||||
setActiveTab(item.id)
|
||||
analytics.settingsTabChanged({ tab: item.id })
|
||||
}}
|
||||
className={cn(
|
||||
"rounded-xl transition-colors flex items-start gap-3 shrink-0",
|
||||
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)]",
|
||||
)}
|
||||
>
|
||||
<span className={cn("shrink-0", !isMobile && "mt-0.5")}>
|
||||
{item.icon}
|
||||
</span>
|
||||
{isMobile ? (
|
||||
<span className="font-medium whitespace-nowrap">
|
||||
{item.label}
|
||||
</span>
|
||||
) : (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="font-medium">{item.label}</span>
|
||||
<span className="text-xs leading-snug text-white/50">
|
||||
{item.description}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
{!isMobile && <SectionLabel>Organisation</SectionLabel>}
|
||||
|
||||
{/* Divider */}
|
||||
{!isMobile && <div className="my-0.5 h-px bg-[#0F1621]" />}
|
||||
|
||||
{DANGER_ITEMS.map((item) => {
|
||||
const colors = DANGER_COLORS[item.color]
|
||||
const handleClick = () => {
|
||||
if (item.id === "logout") handleLogout()
|
||||
else if (item.id === "reset") setIsResetDialogOpen(true)
|
||||
else if (item.id === "delete") setIsDeleteDialogOpen(true)
|
||||
}
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const isActive = activeTab === item.id
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
onClick={() => {
|
||||
window.location.hash = item.id
|
||||
setActiveTab(item.id)
|
||||
analytics.settingsTabChanged({ tab: item.id })
|
||||
}}
|
||||
className={cn(
|
||||
"rounded-xl transition-colors flex items-start gap-3 shrink-0 group",
|
||||
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,
|
||||
"relative rounded-xl transition-colors flex items-center gap-3 shrink-0 group",
|
||||
isMobile
|
||||
? "px-3 py-2 text-sm border border-white/[0.06]"
|
||||
: "text-left px-3 py-2",
|
||||
isActive
|
||||
? "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-white/[0.025]",
|
||||
)}
|
||||
>
|
||||
{!isMobile && (
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"absolute left-0 top-1/2 -translate-y-1/2 w-[3px] rounded-r-full transition-all",
|
||||
isActive
|
||||
? "h-5 bg-[#4BA0FA]"
|
||||
: "h-0 bg-transparent group-hover:h-3 group-hover:bg-white/20",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0",
|
||||
!isMobile && "mt-0.5",
|
||||
colors.icon,
|
||||
`group-hover:${colors.hover.replace("hover:", "")}`,
|
||||
"shrink-0 transition-colors",
|
||||
isActive ? "text-white" : "text-white/45",
|
||||
)}
|
||||
>
|
||||
{item.icon}
|
||||
</span>
|
||||
{isMobile ? (
|
||||
<span className="font-medium whitespace-nowrap">
|
||||
{item.label}
|
||||
</span>
|
||||
) : (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="font-medium">{item.label}</span>
|
||||
<span className="text-xs leading-snug opacity-60">
|
||||
{item.description}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<span className="font-medium text-[14px] whitespace-nowrap">
|
||||
{item.label}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
||||
{!isMobile && <div className="flex-1" />}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLogout}
|
||||
className={cn(
|
||||
"group rounded-xl transition-colors flex items-center gap-3 shrink-0 cursor-pointer text-white/60 hover:text-white hover:bg-white/[0.025]",
|
||||
isMobile
|
||||
? "px-3 py-2 text-sm border border-white/[0.06]"
|
||||
: "text-left px-3 py-2",
|
||||
)}
|
||||
>
|
||||
<LogOut className="size-[18px] shrink-0 text-white/45" />
|
||||
<span className="font-medium text-[14px] whitespace-nowrap">
|
||||
Log out
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<Popover open={dangerMenuOpen} onOpenChange={setDangerMenuOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"group rounded-xl transition-colors flex items-center gap-3 shrink-0 cursor-pointer",
|
||||
isMobile
|
||||
? "px-3 py-2 text-sm border border-[#3A211C]"
|
||||
: "text-left px-3 py-2.5 mt-1",
|
||||
dangerMenuOpen
|
||||
? "bg-[#1A0F0C] text-[#C73B1B]"
|
||||
: "text-[#8A5247] hover:text-[#C73B1B] hover:bg-[#1A0F0C]/60",
|
||||
)}
|
||||
>
|
||||
<ShieldAlert className="size-[18px] shrink-0" />
|
||||
<span className="font-medium text-[14px] whitespace-nowrap">
|
||||
Danger zone
|
||||
</span>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"size-4 ml-auto shrink-0 transition-transform opacity-60",
|
||||
dangerMenuOpen ? "-rotate-90" : "rotate-0",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
side="top"
|
||||
sideOffset={8}
|
||||
className={cn(
|
||||
"w-[var(--radix-popover-trigger-width)] min-w-[248px] p-1.5 bg-[#14161A] border-white/10 rounded-[14px]",
|
||||
"shadow-[0px_8px_28px_rgba(0,0,0,0.5)]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setDangerMenuOpen(false)
|
||||
setIsResetDialogOpen(true)
|
||||
}}
|
||||
className="w-full flex items-center gap-3 rounded-[10px] px-3 py-2 text-left text-[#A37A2E] hover:text-[#C7991B] hover:bg-[#1A1200]/60 transition-colors cursor-pointer"
|
||||
>
|
||||
<RotateCcw className="size-[16px] shrink-0" />
|
||||
<span className="font-medium text-[13.5px]">
|
||||
Reset data
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div className="my-1 h-px bg-white/[0.06]" />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setDangerMenuOpen(false)
|
||||
setIsDeleteDialogOpen(true)
|
||||
}}
|
||||
className="w-full flex items-center gap-3 rounded-[10px] px-3 py-2 text-left text-[#C73B1B] hover:bg-[#290F0A]/60 transition-colors cursor-pointer"
|
||||
>
|
||||
<Trash2 className="size-[16px] shrink-0" />
|
||||
<span className="font-semibold text-[13.5px]">
|
||||
Delete account
|
||||
</span>
|
||||
</button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</nav>
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col gap-4 md:overflow-y-auto md:max-w-2xl [scrollbar-gutter:stable] md:pr-[17px]">
|
||||
</aside>
|
||||
|
||||
{/* Content */}
|
||||
<section className="flex-1 min-w-0 flex flex-col md:overflow-y-auto md:max-w-4xl [scrollbar-gutter:stable] md:pr-2">
|
||||
<ErrorBoundary
|
||||
key={activeTab}
|
||||
fallback={
|
||||
|
|
@ -433,11 +569,12 @@ export default function SettingsPage() {
|
|||
}
|
||||
>
|
||||
{activeTab === "account" && <Account />}
|
||||
{activeTab === "billing" && <Billing />}
|
||||
{activeTab === "integrations" && <Integrations />}
|
||||
{activeTab === "connections" && <ConnectionsMCP />}
|
||||
{activeTab === "support" && <Support />}
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
|
|
@ -509,6 +646,11 @@ export default function SettingsPage() {
|
|||
resetConfirmation !== confirmText ||
|
||||
resetOrganization.isPending
|
||||
}
|
||||
title={
|
||||
!confirmText || resetConfirmation !== confirmText
|
||||
? `Type "${confirmText || "your name"}" exactly to enable`
|
||||
: undefined
|
||||
}
|
||||
onClick={() =>
|
||||
resetOrganization.mutate(
|
||||
{ confirmation: confirmText },
|
||||
|
|
@ -589,6 +731,11 @@ export default function SettingsPage() {
|
|||
deleteEmailConfirm !== user?.email ||
|
||||
deleteUserAccount.isPending
|
||||
}
|
||||
title={
|
||||
deleteEmailConfirm !== user?.email
|
||||
? `Type ${user?.email ?? "your email"} exactly to confirm`
|
||||
: undefined
|
||||
}
|
||||
onClick={handleDeleteAccount}
|
||||
className="relative flex items-center gap-1.5 px-4 py-2 rounded-full text-sm font-medium cursor-pointer transition-opacity bg-[#290F0A] text-[#C73B1B] disabled:opacity-40 disabled:cursor-not-allowed hover:opacity-90"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,524 +1,26 @@
|
|||
"use client"
|
||||
import { redirect } from "next/navigation"
|
||||
|
||||
import { signIn, useSession } from "@lib/auth"
|
||||
import { usePostHog } from "@lib/posthog"
|
||||
import { TextSeparator } from "@ui/components/text-separator"
|
||||
import { ExternalAuthButton } from "@ui/button/external-auth"
|
||||
import { Button } from "@ui/components/button"
|
||||
import { Badge } from "@ui/components/badge"
|
||||
import { LabeledInput } from "@ui/input/labeled-input"
|
||||
import { HeadingH3Medium } from "@ui/text/heading/heading-h3-medium"
|
||||
import { Label1Regular } from "@ui/text/label/label-1-regular"
|
||||
import { Title1Bold } from "@ui/text/title/title-1-bold"
|
||||
import { InitialHeader } from "@/components/initial-header"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { useState, useEffect } from "react"
|
||||
import { motion } from "motion/react"
|
||||
import { dmSansClassName } from "@/lib/fonts"
|
||||
import { cn } from "@lib/utils"
|
||||
import { Logo } from "@ui/assets/Logo"
|
||||
|
||||
function isMcpOAuthAuthorizeContext(sp: Pick<URLSearchParams, "get">): boolean {
|
||||
return sp.get("response_type") === "code" && Boolean(sp.get("client_id"))
|
||||
}
|
||||
|
||||
function buildMcpAuthorizeResumeUrl(
|
||||
sp: Pick<URLSearchParams, "toString">,
|
||||
function serializeSearchParams(
|
||||
sp: Record<string, string | string[] | undefined>,
|
||||
): string {
|
||||
const backend =
|
||||
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
|
||||
const p = new URLSearchParams(sp.toString())
|
||||
p.delete("redirect")
|
||||
p.delete("error")
|
||||
return `${backend}/api/auth/mcp/authorize?${p.toString()}`
|
||||
}
|
||||
|
||||
function AnimatedGradientBackground() {
|
||||
return (
|
||||
<div className="fixed inset-0 z-0 overflow-hidden">
|
||||
<motion.div
|
||||
className="absolute top-[20%] left-0 right-0 bottom-0 bg-[url('/onboarding/bg-gradient-0.png')] bg-size-[150%_auto] bg-top bg-no-repeat"
|
||||
initial={{ y: "100%" }}
|
||||
animate={{
|
||||
y: 0,
|
||||
opacity: [1, 0, 1],
|
||||
}}
|
||||
transition={{
|
||||
y: { duration: 0.75, ease: "easeOut" },
|
||||
opacity: {
|
||||
duration: 8,
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
ease: "easeInOut",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<motion.div
|
||||
className="absolute top-[20%] left-0 right-0 bottom-0 bg-[url('/onboarding/bg-gradient-1.png')] bg-size-[150%_auto] bg-top bg-no-repeat"
|
||||
initial={{ y: "100%" }}
|
||||
animate={{
|
||||
y: 0,
|
||||
opacity: [0, 1, 0],
|
||||
}}
|
||||
transition={{
|
||||
y: { duration: 0.75, ease: "easeOut" },
|
||||
opacity: {
|
||||
duration: 8,
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
ease: "easeInOut",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<motion.div
|
||||
className="absolute top-0 left-0 right-0 bottom-0 bg-[url('/bg-rectangle.png')] bg-cover bg-center bg-no-repeat"
|
||||
transition={{ duration: 0.75, ease: "easeOut", bounce: 0 }}
|
||||
style={{
|
||||
mixBlendMode: "soft-light",
|
||||
opacity: 0.4,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LoginCard({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<motion.div
|
||||
className="flex py-8 px-11 flex-col items-start gap-2 rounded-[22px] bg-linear-to-b from-[#06101F] to-[#030912] shadow-[1.5px_1.5px_20px_0_rgba(0,0,0,0.65),1px_1.5px_2px_0_rgba(128,189,255,0.07)_inset,-0.5px_-1.5px_4px_0_rgba(0,35,73,0.40)_inset]"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.75, ease: "easeOut" }}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
const [email, setEmail] = useState("")
|
||||
const [submittedEmail, setSubmittedEmail] = useState<string | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isLoadingEmail, setIsLoadingEmail] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [lastUsedMethod, setLastUsedMethod] = useState<string | null>(null)
|
||||
const router = useRouter()
|
||||
|
||||
const posthog = usePostHog()
|
||||
|
||||
const params = useSearchParams()
|
||||
const { data: sessionData, isPending: sessionPending } = useSession()
|
||||
|
||||
const oauthQueryForResume = params.toString()
|
||||
|
||||
useEffect(() => {
|
||||
if (sessionPending) return
|
||||
if (!sessionData?.session) return
|
||||
const sp = new URLSearchParams(oauthQueryForResume)
|
||||
if (!isMcpOAuthAuthorizeContext(sp)) return
|
||||
window.location.assign(buildMcpAuthorizeResumeUrl(sp))
|
||||
}, [sessionPending, sessionData?.session, oauthQueryForResume])
|
||||
|
||||
// Get redirect URL from query params
|
||||
const redirectUrl = params.get("redirect")
|
||||
|
||||
// Create callback URL that includes redirect parameter if provided
|
||||
const getCallbackURL = () => {
|
||||
const origin = window.location.origin
|
||||
|
||||
if (isMcpOAuthAuthorizeContext(params)) {
|
||||
return buildMcpAuthorizeResumeUrl(params)
|
||||
}
|
||||
|
||||
let finalUrl: URL
|
||||
|
||||
if (redirectUrl) {
|
||||
try {
|
||||
finalUrl = new URL(redirectUrl, origin)
|
||||
} catch {
|
||||
finalUrl = new URL(origin)
|
||||
}
|
||||
const q = new URLSearchParams()
|
||||
for (const [key, value] of Object.entries(sp)) {
|
||||
if (value === undefined) continue
|
||||
if (Array.isArray(value)) {
|
||||
for (const v of value) q.append(key, v)
|
||||
} else {
|
||||
finalUrl = new URL(origin)
|
||||
q.set(key, value)
|
||||
}
|
||||
|
||||
finalUrl.searchParams.set("extension-auth-success", "true")
|
||||
return finalUrl.toString()
|
||||
}
|
||||
|
||||
// Load last used method from localStorage on mount
|
||||
useEffect(() => {
|
||||
const savedMethod = localStorage.getItem("supermemory-last-login-method")
|
||||
setLastUsedMethod(savedMethod)
|
||||
}, [])
|
||||
|
||||
// Record the pending login method (will be committed after successful auth)
|
||||
function setPendingLoginMethod(method: string) {
|
||||
try {
|
||||
localStorage.setItem("supermemory-pending-login-method", method)
|
||||
localStorage.setItem(
|
||||
"supermemory-pending-login-timestamp",
|
||||
String(Date.now()),
|
||||
)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function isNetworkError(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) return false
|
||||
const message = error.message.toLowerCase()
|
||||
return (
|
||||
message.includes("load failed") ||
|
||||
message.includes("networkerror") ||
|
||||
message.includes("failed to fetch") ||
|
||||
message.includes("network request failed")
|
||||
)
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown): string {
|
||||
if (isNetworkError(error)) {
|
||||
return "Network error. Please check your connection and try again."
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return error.message
|
||||
}
|
||||
return "An unexpected error occurred. Please try again."
|
||||
}
|
||||
|
||||
// If we land back on this page with an error, clear any pending marker
|
||||
useEffect(() => {
|
||||
if (params.get("error")) {
|
||||
try {
|
||||
localStorage.removeItem("supermemory-pending-login-method")
|
||||
localStorage.removeItem("supermemory-pending-login-timestamp")
|
||||
} catch {}
|
||||
}
|
||||
}, [params])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
setIsLoading(true)
|
||||
setIsLoadingEmail(true)
|
||||
setError(null)
|
||||
|
||||
// Track login attempt
|
||||
posthog.capture("login_attempt", {
|
||||
method: "magic_link",
|
||||
email_domain: email.split("@")[1] || "unknown",
|
||||
})
|
||||
|
||||
const { error } = await signIn.magicLink({
|
||||
callbackURL: getCallbackURL(),
|
||||
email,
|
||||
})
|
||||
|
||||
if (error) {
|
||||
console.error(error)
|
||||
|
||||
// Track login failure
|
||||
posthog.capture("login_failed", {
|
||||
method: "magic_link",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
email_domain: email.split("@")[1] || "unknown",
|
||||
is_network_error: isNetworkError(error),
|
||||
})
|
||||
|
||||
setError(getErrorMessage(error))
|
||||
setIsLoading(false)
|
||||
setIsLoadingEmail(false)
|
||||
return
|
||||
}
|
||||
|
||||
setSubmittedEmail(email)
|
||||
setPendingLoginMethod("magic_link")
|
||||
posthog.capture("login_magic_link_sent", {
|
||||
email_domain: email.split("@")[1] || "unknown",
|
||||
})
|
||||
|
||||
setIsLoading(false)
|
||||
setIsLoadingEmail(false)
|
||||
}
|
||||
|
||||
const handleSubmitToken = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
setIsLoading(true)
|
||||
|
||||
const formData = new FormData(event.currentTarget)
|
||||
const token = formData.get("token") as string
|
||||
const callbackURL = getCallbackURL()
|
||||
router.push(
|
||||
`${process.env.NEXT_PUBLIC_BACKEND_URL}/api/auth/magic-link/verify?token=${token}&callbackURL=${encodeURIComponent(callbackURL)}`,
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="relative h-screen overflow-hidden">
|
||||
<AnimatedGradientBackground />
|
||||
<div className="relative z-10">
|
||||
<InitialHeader />
|
||||
<section className="flex flex-col items-center justify-center p-4 space-y-12 sm:p-6 md:p-8 lg:px-20 lg:py-12.5 min-h-[calc(100vh-80px)]">
|
||||
<div className="text-center">
|
||||
<div className="text-5xl font-medium">
|
||||
Never forget anything, anywhere
|
||||
</div>
|
||||
<div className="text-5xl font-medium">with supermemory</div>
|
||||
</div>
|
||||
{submittedEmail ? (
|
||||
<LoginCard>
|
||||
<div className="w-[360px] flex flex-col gap-4 lg:gap-6 min-h-2/3">
|
||||
<div className="flex flex-col gap-2 text-center lg:text-left">
|
||||
<Title1Bold className="text-foreground">
|
||||
Almost there!
|
||||
</Title1Bold>
|
||||
<HeadingH3Medium className="text-muted-foreground">
|
||||
Click the magic link we've sent to{" "}
|
||||
<span className="text-foreground">{submittedEmail}</span>.
|
||||
</HeadingH3Medium>
|
||||
</div>
|
||||
|
||||
<TextSeparator text="OR" className={cn(dmSansClassName())} />
|
||||
|
||||
<form
|
||||
className="flex flex-col gap-4 lg:gap-6"
|
||||
onSubmit={handleSubmitToken}
|
||||
>
|
||||
<LabeledInput
|
||||
inputPlaceholder="your temporary login code"
|
||||
inputProps={{
|
||||
name: "token",
|
||||
required: true,
|
||||
disabled: isLoading,
|
||||
"aria-invalid": error ? "true" : "false",
|
||||
}}
|
||||
inputType="text"
|
||||
label="Enter code"
|
||||
/>
|
||||
|
||||
<Button disabled={isLoading} id="verify-token" type="submit">
|
||||
Verify Token
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</LoginCard>
|
||||
) : (
|
||||
<LoginCard>
|
||||
<div className="w-[360px] flex flex-col" style={{ gap: "12px" }}>
|
||||
{params.get("error") && (
|
||||
<div className="text-red-500">
|
||||
Error: {params.get("error")}. Please try again!
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
{process.env.NEXT_PUBLIC_HOST_ID === "supermemory" ||
|
||||
!process.env.NEXT_PUBLIC_GOOGLE_AUTH_ENABLED ? (
|
||||
<div className="relative grow">
|
||||
<ExternalAuthButton
|
||||
authIcon={
|
||||
<svg
|
||||
className="size-4 sm:size-5"
|
||||
fill="none"
|
||||
height="25"
|
||||
viewBox="0 0 24 25"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<title>Google</title>
|
||||
<path
|
||||
d="M21.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.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 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.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>
|
||||
}
|
||||
authProvider="Google"
|
||||
className="w-full"
|
||||
disabled={isLoading}
|
||||
onClick={() => {
|
||||
if (isLoading) return
|
||||
setIsLoading(true)
|
||||
posthog.capture("login_attempt", {
|
||||
method: "social",
|
||||
provider: "google",
|
||||
})
|
||||
setPendingLoginMethod("google")
|
||||
signIn
|
||||
.social({
|
||||
callbackURL: getCallbackURL(),
|
||||
provider: "google",
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
setError(getErrorMessage(err))
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false)
|
||||
})
|
||||
}}
|
||||
/>
|
||||
{lastUsedMethod === "google" && (
|
||||
<div className="absolute -top-2 -right-2">
|
||||
<Badge variant="default" className="text-xs">
|
||||
Last used
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{process.env.NEXT_PUBLIC_HOST_ID === "supermemory" ||
|
||||
!process.env.NEXT_PUBLIC_GITHUB_AUTH_ENABLED ? (
|
||||
<div className="relative grow">
|
||||
<ExternalAuthButton
|
||||
authIcon={
|
||||
<svg
|
||||
className="size-4 sm:size-5 text-foreground"
|
||||
fill="none"
|
||||
height="25"
|
||||
viewBox="0 0 26 25"
|
||||
width="26"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<title>Github</title>
|
||||
<g clipPath="url(#clip0_2579_3356)">
|
||||
<path
|
||||
clipRule="evenodd"
|
||||
d="M12.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"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_2579_3356">
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="24"
|
||||
transform="translate(0.75 0.21)"
|
||||
width="24.5"
|
||||
/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
}
|
||||
authProvider="Github"
|
||||
className="w-full"
|
||||
disabled={isLoading}
|
||||
onClick={() => {
|
||||
if (isLoading) return
|
||||
setIsLoading(true)
|
||||
posthog.capture("login_attempt", {
|
||||
method: "social",
|
||||
provider: "github",
|
||||
})
|
||||
setPendingLoginMethod("github")
|
||||
signIn
|
||||
.social({
|
||||
callbackURL: getCallbackURL(),
|
||||
provider: "github",
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
setError(getErrorMessage(err))
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false)
|
||||
})
|
||||
}}
|
||||
/>
|
||||
{lastUsedMethod === "github" && (
|
||||
<div className="absolute -top-2 -right-2">
|
||||
<Badge variant="default" className="text-xs">
|
||||
Last used
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<TextSeparator text="OR" className={cn(dmSansClassName())} />
|
||||
|
||||
<div className="flex flex-col gap-6">
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-6">
|
||||
<LabeledInput
|
||||
error={error}
|
||||
inputPlaceholder="your@email.com"
|
||||
inputProps={{
|
||||
"aria-invalid": error ? "true" : "false",
|
||||
disabled: isLoading,
|
||||
id: "email",
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setEmail(e.target.value)
|
||||
error && setError(null)
|
||||
},
|
||||
required: true,
|
||||
value: email,
|
||||
}}
|
||||
inputType="email"
|
||||
/>
|
||||
|
||||
<div className="relative">
|
||||
<Button
|
||||
className="flex justify-center items-center w-full h-[44px] relative gap-3 p-2 rounded-xl"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(182.37deg, #0ff0d2 -91.53%, #5bd3fb -67.8%, #1e0ff0 95.17%)",
|
||||
boxShadow:
|
||||
"1px 1px 2px 0px #1A88FF inset, 0 2px 10px 0 rgba(5, 1, 0, 0.20)",
|
||||
}}
|
||||
disabled={isLoading}
|
||||
type="submit"
|
||||
>
|
||||
<Logo className="size-4" />
|
||||
{isLoadingEmail
|
||||
? "Sending login link..."
|
||||
: "Log in with Supermemory"}
|
||||
</Button>
|
||||
{lastUsedMethod === "magic_link" && (
|
||||
<div className="absolute -top-2 -right-2">
|
||||
<Badge variant="default" className="text-xs">
|
||||
Last used
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<Label1Regular
|
||||
className={cn(
|
||||
"text-center text-xs! text-[#737373B2]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
By continuing, you agree to our{" "}
|
||||
<span className="inline-block">
|
||||
<a
|
||||
className="underline"
|
||||
href="https://supermemory.ai/terms/"
|
||||
>
|
||||
Terms
|
||||
</a>{" "}
|
||||
and{" "}
|
||||
<a
|
||||
className="underline"
|
||||
href="https://supermemory.ai/privacy/"
|
||||
>
|
||||
Privacy Policy
|
||||
</a>
|
||||
.
|
||||
</span>
|
||||
</Label1Regular>
|
||||
</div>
|
||||
</div>
|
||||
</LoginCard>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
return q.toString()
|
||||
}
|
||||
|
||||
export default async function Page({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>
|
||||
}) {
|
||||
const sp = await searchParams
|
||||
const query = serializeSearchParams(sp)
|
||||
redirect(query ? `/login?${query}` : "/login")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,26 +1,548 @@
|
|||
import { redirect } from "next/navigation"
|
||||
"use client"
|
||||
|
||||
function serializeSearchParams(
|
||||
sp: Record<string, string | string[] | undefined>,
|
||||
import { signIn, useSession } from "@lib/auth"
|
||||
import { usePostHog } from "@lib/posthog"
|
||||
import { TextSeparator } from "@ui/components/text-separator"
|
||||
import { ExternalAuthButton } from "@ui/button/external-auth"
|
||||
import { Button } from "@ui/components/button"
|
||||
import { Badge } from "@ui/components/badge"
|
||||
import { LabeledInput } from "@ui/input/labeled-input"
|
||||
import { HeadingH3Medium } from "@ui/text/heading/heading-h3-medium"
|
||||
import { Label1Regular } from "@ui/text/label/label-1-regular"
|
||||
import { Title1Bold } from "@ui/text/title/title-1-bold"
|
||||
import { InitialHeader } from "@/components/initial-header"
|
||||
import { LoginToolsPanel } from "@/components/login-tools-panel"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { useState, useEffect } from "react"
|
||||
import { motion } from "motion/react"
|
||||
import { dmSansClassName } from "@/lib/fonts"
|
||||
import { cn } from "@lib/utils"
|
||||
import { Logo } from "@ui/assets/Logo"
|
||||
import { resolveAuthRedirectUrl } from "@/lib/url-helpers"
|
||||
import { Loader2 } from "lucide-react"
|
||||
|
||||
function isMcpOAuthAuthorizeContext(sp: Pick<URLSearchParams, "get">): boolean {
|
||||
return sp.get("response_type") === "code" && Boolean(sp.get("client_id"))
|
||||
}
|
||||
|
||||
function buildMcpAuthorizeResumeUrl(
|
||||
sp: Pick<URLSearchParams, "toString">,
|
||||
): string {
|
||||
const q = new URLSearchParams()
|
||||
for (const [key, value] of Object.entries(sp)) {
|
||||
if (value === undefined) continue
|
||||
if (Array.isArray(value)) {
|
||||
for (const v of value) q.append(key, v)
|
||||
} else {
|
||||
q.set(key, value)
|
||||
}
|
||||
}
|
||||
return q.toString()
|
||||
const backend =
|
||||
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
|
||||
const p = new URLSearchParams(sp.toString())
|
||||
p.delete("redirect")
|
||||
p.delete("error")
|
||||
return `${backend}/api/auth/mcp/authorize?${p.toString()}`
|
||||
}
|
||||
|
||||
export default async function Page({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>
|
||||
}) {
|
||||
const sp = await searchParams
|
||||
const query = serializeSearchParams(sp)
|
||||
redirect(query ? `/login/new?${query}` : "/login/new")
|
||||
function LoginHeadline({ className }: { className?: string }) {
|
||||
return (
|
||||
<div className={cn("max-w-sm text-center", className)}>
|
||||
<h1 className="text-balance text-lg font-medium leading-tight tracking-tight sm:text-xl lg:text-[1.375rem] xl:text-2xl">
|
||||
Never forget anything, anywhere
|
||||
<span className="block text-brand-accent">with supermemory</span>
|
||||
</h1>
|
||||
<p
|
||||
className={cn(
|
||||
"mt-1.5 text-xs leading-snug text-muted-foreground/50 lg:mt-2",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
Save from Chrome, Notion, X — search it all in one place.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LastUsedBadge({ show }: { show: boolean }) {
|
||||
if (!show) return null
|
||||
return (
|
||||
<div className="mb-1 flex justify-end">
|
||||
<Badge variant="default" className="h-5 px-1.5 text-[10px]">
|
||||
Last used
|
||||
</Badge>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
function LoginCard({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<motion.div
|
||||
className="flex w-full max-w-[400px] shrink-0 flex-col items-center gap-2 rounded-[22px] py-5 px-5 sm:w-auto sm:max-w-none sm:items-start sm:py-8 sm:px-11 bg-linear-to-b from-[#06101F] to-[#030912] shadow-[1.5px_1.5px_20px_0_rgba(0,0,0,0.65),1px_1.5px_2px_0_rgba(128,189,255,0.07)_inset,-0.5px_-1.5px_4px_0_rgba(0,35,73,0.40)_inset]"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.75, ease: "easeOut" }}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
function LoginCardBody({
|
||||
children,
|
||||
loadingMessage,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
loadingMessage?: string | null
|
||||
}) {
|
||||
return (
|
||||
<div className="relative w-full max-w-[360px] sm:w-[360px]">
|
||||
<div className={loadingMessage ? "invisible" : undefined}>{children}</div>
|
||||
{loadingMessage ? (
|
||||
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-3 rounded-[14px] bg-[#030912]/90 backdrop-blur-sm">
|
||||
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">{loadingMessage}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
const [email, setEmail] = useState("")
|
||||
const [submittedEmail, setSubmittedEmail] = useState<string | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isLoadingEmail, setIsLoadingEmail] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [lastUsedMethod, setLastUsedMethod] = useState<string | null>(null)
|
||||
const router = useRouter()
|
||||
|
||||
const posthog = usePostHog()
|
||||
|
||||
const params = useSearchParams()
|
||||
const { data: sessionData, isPending: sessionPending } = useSession()
|
||||
|
||||
const oauthQueryForResume = params.toString()
|
||||
const isRedirecting = !sessionPending && Boolean(sessionData?.session)
|
||||
const isAuthResolving = sessionPending || isRedirecting
|
||||
const loadingMessage = isAuthResolving
|
||||
? sessionPending
|
||||
? "Checking session…"
|
||||
: "Redirecting…"
|
||||
: isLoading
|
||||
? isLoadingEmail
|
||||
? "Sending login link…"
|
||||
: "Redirecting…"
|
||||
: null
|
||||
|
||||
useEffect(() => {
|
||||
if (sessionPending) return
|
||||
if (!sessionData?.session) return
|
||||
const sp = new URLSearchParams(oauthQueryForResume)
|
||||
if (isMcpOAuthAuthorizeContext(sp)) {
|
||||
window.location.assign(buildMcpAuthorizeResumeUrl(sp))
|
||||
return
|
||||
}
|
||||
const redirectUrl = params.get("redirect")
|
||||
if (redirectUrl) {
|
||||
window.location.assign(
|
||||
resolveAuthRedirectUrl(redirectUrl, window.location.origin).toString(),
|
||||
)
|
||||
return
|
||||
}
|
||||
router.replace("/")
|
||||
}, [
|
||||
sessionPending,
|
||||
sessionData?.session,
|
||||
oauthQueryForResume,
|
||||
params,
|
||||
router,
|
||||
])
|
||||
|
||||
// Get redirect URL from query params
|
||||
const redirectUrl = params.get("redirect")
|
||||
|
||||
// Create callback URL that includes redirect parameter if provided
|
||||
const getCallbackURL = () => {
|
||||
const origin = window.location.origin
|
||||
|
||||
if (isMcpOAuthAuthorizeContext(params)) {
|
||||
return buildMcpAuthorizeResumeUrl(params)
|
||||
}
|
||||
|
||||
const finalUrl = resolveAuthRedirectUrl(redirectUrl, origin)
|
||||
|
||||
finalUrl.searchParams.set("extension-auth-success", "true")
|
||||
return finalUrl.toString()
|
||||
}
|
||||
|
||||
// Load last used method from localStorage on mount
|
||||
useEffect(() => {
|
||||
const savedMethod = localStorage.getItem("supermemory-last-login-method")
|
||||
setLastUsedMethod(savedMethod)
|
||||
}, [])
|
||||
|
||||
// Record the pending login method (will be committed after successful auth)
|
||||
function setPendingLoginMethod(method: string) {
|
||||
try {
|
||||
localStorage.setItem("supermemory-pending-login-method", method)
|
||||
localStorage.setItem(
|
||||
"supermemory-pending-login-timestamp",
|
||||
String(Date.now()),
|
||||
)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function isNetworkError(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) return false
|
||||
const message = error.message.toLowerCase()
|
||||
return (
|
||||
message.includes("load failed") ||
|
||||
message.includes("networkerror") ||
|
||||
message.includes("failed to fetch") ||
|
||||
message.includes("network request failed")
|
||||
)
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown): string {
|
||||
if (isNetworkError(error)) {
|
||||
return "Network error. Please check your connection and try again."
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return error.message
|
||||
}
|
||||
return "An unexpected error occurred. Please try again."
|
||||
}
|
||||
|
||||
// If we land back on this page with an error, clear any pending marker
|
||||
useEffect(() => {
|
||||
if (params.get("error")) {
|
||||
try {
|
||||
localStorage.removeItem("supermemory-pending-login-method")
|
||||
localStorage.removeItem("supermemory-pending-login-timestamp")
|
||||
} catch {}
|
||||
}
|
||||
}, [params])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
setIsLoading(true)
|
||||
setIsLoadingEmail(true)
|
||||
setError(null)
|
||||
|
||||
// Track login attempt
|
||||
posthog.capture("login_attempt", {
|
||||
method: "magic_link",
|
||||
email_domain: email.split("@")[1] || "unknown",
|
||||
})
|
||||
|
||||
const { error } = await signIn.magicLink({
|
||||
callbackURL: getCallbackURL(),
|
||||
email,
|
||||
})
|
||||
|
||||
if (error) {
|
||||
console.error(error)
|
||||
|
||||
// Track login failure
|
||||
posthog.capture("login_failed", {
|
||||
method: "magic_link",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
email_domain: email.split("@")[1] || "unknown",
|
||||
is_network_error: isNetworkError(error),
|
||||
})
|
||||
|
||||
setError(getErrorMessage(error))
|
||||
setIsLoading(false)
|
||||
setIsLoadingEmail(false)
|
||||
return
|
||||
}
|
||||
|
||||
setSubmittedEmail(email)
|
||||
setPendingLoginMethod("magic_link")
|
||||
posthog.capture("login_magic_link_sent", {
|
||||
email_domain: email.split("@")[1] || "unknown",
|
||||
})
|
||||
|
||||
setIsLoading(false)
|
||||
setIsLoadingEmail(false)
|
||||
}
|
||||
|
||||
const handleSubmitToken = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
setIsLoading(true)
|
||||
|
||||
const formData = new FormData(event.currentTarget)
|
||||
const token = formData.get("token") as string
|
||||
const callbackURL = getCallbackURL()
|
||||
router.push(
|
||||
`${process.env.NEXT_PUBLIC_BACKEND_URL}/api/auth/magic-link/verify?token=${token}&callbackURL=${encodeURIComponent(callbackURL)}`,
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="login-page-grid relative bg-[#030912]">
|
||||
<div className="login-page-mobile-bg" aria-hidden>
|
||||
<div className="login-panel-orb h-full" />
|
||||
<div className="login-panel-orb-image h-full" />
|
||||
</div>
|
||||
<div className="relative z-20 lg:hidden">
|
||||
<InitialHeader />
|
||||
</div>
|
||||
<LoginToolsPanel />
|
||||
<div className="relative z-10 flex min-h-0 min-w-0 flex-col lg:col-start-2 lg:row-start-1 lg:h-full">
|
||||
<div className="hidden lg:block">
|
||||
<InitialHeader />
|
||||
</div>
|
||||
<section className="flex min-h-0 flex-1 flex-col items-center px-4 pb-[max(1rem,env(safe-area-inset-bottom))] pt-1 lg:justify-center lg:px-10 lg:py-10">
|
||||
<div className="mt-auto flex w-full flex-col items-center lg:mt-0">
|
||||
<LoginHeadline className="mb-3 sm:mb-4 lg:mb-8" />
|
||||
<LoginCard>
|
||||
<LoginCardBody loadingMessage={loadingMessage}>
|
||||
{submittedEmail ? (
|
||||
<div className="flex flex-col gap-4 lg:gap-6">
|
||||
<div className="flex flex-col gap-2 text-center lg:text-left">
|
||||
<Title1Bold className="text-foreground">
|
||||
Almost there!
|
||||
</Title1Bold>
|
||||
<HeadingH3Medium className="text-muted-foreground">
|
||||
Click the magic link we've sent to{" "}
|
||||
<span className="text-foreground">
|
||||
{submittedEmail}
|
||||
</span>
|
||||
.
|
||||
</HeadingH3Medium>
|
||||
</div>
|
||||
|
||||
<TextSeparator
|
||||
text="OR"
|
||||
className={cn(dmSansClassName())}
|
||||
/>
|
||||
|
||||
<form
|
||||
className="flex flex-col gap-4 lg:gap-6"
|
||||
onSubmit={handleSubmitToken}
|
||||
>
|
||||
<LabeledInput
|
||||
inputPlaceholder="your temporary login code"
|
||||
inputProps={{
|
||||
name: "token",
|
||||
required: true,
|
||||
disabled: isLoading,
|
||||
"aria-invalid": error ? "true" : "false",
|
||||
}}
|
||||
inputType="text"
|
||||
label="Enter code"
|
||||
/>
|
||||
|
||||
<Button
|
||||
disabled={isLoading}
|
||||
id="verify-token"
|
||||
type="submit"
|
||||
>
|
||||
Verify Token
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col" style={{ gap: "12px" }}>
|
||||
{params.get("error") && (
|
||||
<div className="text-red-500">
|
||||
Error: {params.get("error")}. Please try again!
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
{process.env.NEXT_PUBLIC_HOST_ID === "supermemory" ||
|
||||
!process.env.NEXT_PUBLIC_GOOGLE_AUTH_ENABLED ? (
|
||||
<div className="w-full">
|
||||
<LastUsedBadge show={lastUsedMethod === "google"} />
|
||||
<ExternalAuthButton
|
||||
authIcon={
|
||||
<svg
|
||||
className="size-4 sm:size-5"
|
||||
fill="none"
|
||||
height="25"
|
||||
viewBox="0 0 24 25"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<title>Google</title>
|
||||
<path
|
||||
d="M21.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.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 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.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>
|
||||
}
|
||||
authProvider="Google"
|
||||
className="w-full"
|
||||
disabled={Boolean(loadingMessage)}
|
||||
onClick={() => {
|
||||
if (loadingMessage) return
|
||||
setIsLoading(true)
|
||||
posthog.capture("login_attempt", {
|
||||
method: "social",
|
||||
provider: "google",
|
||||
})
|
||||
setPendingLoginMethod("google")
|
||||
signIn
|
||||
.social({
|
||||
callbackURL: getCallbackURL(),
|
||||
provider: "google",
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
setError(getErrorMessage(err))
|
||||
setIsLoading(false)
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{process.env.NEXT_PUBLIC_HOST_ID === "supermemory" ||
|
||||
!process.env.NEXT_PUBLIC_GITHUB_AUTH_ENABLED ? (
|
||||
<div className="w-full">
|
||||
<LastUsedBadge show={lastUsedMethod === "github"} />
|
||||
<ExternalAuthButton
|
||||
authIcon={
|
||||
<svg
|
||||
className="size-4 sm:size-5 text-foreground"
|
||||
fill="none"
|
||||
height="25"
|
||||
viewBox="0 0 26 25"
|
||||
width="26"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<title>Github</title>
|
||||
<g clipPath="url(#clip0_2579_3356)">
|
||||
<path
|
||||
clipRule="evenodd"
|
||||
d="M12.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"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_2579_3356">
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="24"
|
||||
transform="translate(0.75 0.21)"
|
||||
width="24.5"
|
||||
/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
}
|
||||
authProvider="Github"
|
||||
className="w-full"
|
||||
disabled={Boolean(loadingMessage)}
|
||||
onClick={() => {
|
||||
if (loadingMessage) return
|
||||
setIsLoading(true)
|
||||
posthog.capture("login_attempt", {
|
||||
method: "social",
|
||||
provider: "github",
|
||||
})
|
||||
setPendingLoginMethod("github")
|
||||
signIn
|
||||
.social({
|
||||
callbackURL: getCallbackURL(),
|
||||
provider: "github",
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
setError(getErrorMessage(err))
|
||||
setIsLoading(false)
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<TextSeparator
|
||||
text="OR"
|
||||
className={cn(dmSansClassName())}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-6">
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="flex flex-col gap-6"
|
||||
>
|
||||
<LabeledInput
|
||||
error={error}
|
||||
inputPlaceholder="your@email.com"
|
||||
inputProps={{
|
||||
"aria-invalid": error ? "true" : "false",
|
||||
disabled: Boolean(loadingMessage),
|
||||
id: "email",
|
||||
onChange: (
|
||||
e: React.ChangeEvent<HTMLInputElement>,
|
||||
) => {
|
||||
setEmail(e.target.value)
|
||||
error && setError(null)
|
||||
},
|
||||
required: true,
|
||||
value: email,
|
||||
}}
|
||||
inputType="email"
|
||||
/>
|
||||
|
||||
<div className="w-full">
|
||||
<LastUsedBadge
|
||||
show={lastUsedMethod === "magic_link"}
|
||||
/>
|
||||
<Button
|
||||
className="flex justify-center items-center w-full h-[44px] relative gap-3 p-2 rounded-xl"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(182.37deg, #0ff0d2 -91.53%, #5bd3fb -67.8%, #1e0ff0 95.17%)",
|
||||
boxShadow:
|
||||
"1px 1px 2px 0px #1A88FF inset, 0 2px 10px 0 rgba(5, 1, 0, 0.20)",
|
||||
}}
|
||||
disabled={Boolean(loadingMessage)}
|
||||
type="submit"
|
||||
>
|
||||
<Logo className="size-4" />
|
||||
Log in with Supermemory
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<Label1Regular
|
||||
className={cn(
|
||||
"text-center text-xs! text-[#737373B2]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
By continuing, you agree to our{" "}
|
||||
<span className="inline-block">
|
||||
<a
|
||||
className="underline"
|
||||
href="https://supermemory.ai/terms/"
|
||||
>
|
||||
Terms
|
||||
</a>{" "}
|
||||
and{" "}
|
||||
<a
|
||||
className="underline"
|
||||
href="https://supermemory.ai/privacy/"
|
||||
>
|
||||
Privacy Policy
|
||||
</a>
|
||||
.
|
||||
</span>
|
||||
</Label1Regular>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</LoginCardBody>
|
||||
</LoginCard>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,8 +9,22 @@ interface ExaApiResponse {
|
|||
results: ExaContentResult[]
|
||||
}
|
||||
|
||||
const exaApiKey = process.env.EXA_API_KEY
|
||||
if (!exaApiKey) {
|
||||
console.error(
|
||||
"EXA_API_KEY is not configured; /api/onboarding/extract-content will return 503",
|
||||
)
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
if (!exaApiKey) {
|
||||
return Response.json(
|
||||
{ error: "Content extraction is unavailable" },
|
||||
{ status: 503 },
|
||||
)
|
||||
}
|
||||
|
||||
const { urls } = await request.json()
|
||||
|
||||
if (!Array.isArray(urls) || urls.length === 0) {
|
||||
|
|
@ -30,7 +44,7 @@ export async function POST(request: Request) {
|
|||
const response = await fetch("https://api.exa.ai/contents", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-api-key": process.env.EXA_API_KEY ?? "",
|
||||
"x-api-key": exaApiKey,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
|
|
|
|||
|
|
@ -90,6 +90,17 @@ const PLUGIN_INFO: Record<string, PluginInfo> = {
|
|||
],
|
||||
icon: "/images/plugins/cursor.svg",
|
||||
},
|
||||
codex: {
|
||||
name: "OpenAI Codex",
|
||||
description:
|
||||
"Persistent memory for OpenAI Codex CLI. Remembers your coding context, patterns, and decisions across sessions.",
|
||||
features: [
|
||||
"Auto-recalls relevant context before each prompt",
|
||||
"Captures coding decisions and patterns automatically",
|
||||
"Builds persistent user profile across projects",
|
||||
],
|
||||
icon: "/images/plugins/codex.svg",
|
||||
},
|
||||
}
|
||||
|
||||
function getPluginName(client: string): string {
|
||||
|
|
|
|||
|
|
@ -194,9 +194,12 @@ export function FileContent({
|
|||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-[16px] font-medium pl-2">
|
||||
Upload files (images, PDF, documents, sheets, markdown)
|
||||
</p>
|
||||
<div className="flex flex-col gap-0.5 pl-2">
|
||||
<p className="text-[16px] font-medium">Upload files</p>
|
||||
<p className="text-[#737373] text-xs">
|
||||
Images, PDF, documents, sheets, markdown
|
||||
</p>
|
||||
</div>
|
||||
<label
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
|
|
@ -242,12 +245,12 @@ export function FileContent({
|
|||
{hasItems ? (
|
||||
<ul
|
||||
id="file-upload-queue"
|
||||
className="flex flex-col gap-2 max-h-[220px] overflow-y-auto scrollbar-thin pr-1"
|
||||
className="flex flex-col gap-2 max-h-[360px] overflow-y-auto scrollbar-thin pr-1"
|
||||
>
|
||||
{data.items.map((item) => (
|
||||
<li
|
||||
key={item.id}
|
||||
className="relative overflow-hidden rounded-[12px] bg-[#14161A] shadow-inside-out text-sm"
|
||||
className="relative shrink-0 overflow-hidden rounded-[12px] bg-[#14161A] shadow-inside-out text-sm"
|
||||
>
|
||||
{item.status === "uploading" ? (
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -105,7 +105,7 @@ const tabs = [
|
|||
id: "connect" as const,
|
||||
icon: ZapIcon,
|
||||
title: "Connect knowledge bases",
|
||||
compactLabel: "Connections",
|
||||
compactLabel: "Connect",
|
||||
description: "Sync with Google Drive, Notion and OneDrive and import data",
|
||||
isPro: true,
|
||||
},
|
||||
|
|
@ -284,6 +284,18 @@ export function AddDocument({
|
|||
const fileTabSubmitDisabled =
|
||||
activeTab === "file" && (!fileTabHasPending || isSubmitting)
|
||||
|
||||
const spaceSelector = (
|
||||
<SpaceSelector
|
||||
selectedProjects={[localSelectedProject]}
|
||||
onValueChange={(projects) =>
|
||||
setLocalSelectedProject(projects[0] ?? localSelectedProject)
|
||||
}
|
||||
variant="insideOut"
|
||||
compact={isMobile}
|
||||
triggerClassName={cn(isMobile && "h-12 shrink-0")}
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col overflow-hidden text-white md:flex-row md:space-x-5">
|
||||
{isMobile && !hasPaidPlan && (
|
||||
|
|
@ -445,6 +457,23 @@ export function AddDocument({
|
|||
isMobile ? "w-full px-4 pt-1" : "w-2/3 px-1",
|
||||
)}
|
||||
>
|
||||
{isMobile && (
|
||||
<div className="mb-3 flex h-10 w-full shrink-0 items-center overflow-hidden rounded-full border border-[#1F2937] bg-[#0D121A] p-1">
|
||||
{tabs.map((tab) => (
|
||||
<TabButton
|
||||
key={tab.id}
|
||||
active={activeTab === tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
icon={tab.icon}
|
||||
title={tab.title}
|
||||
compactLabel={tab.compactLabel}
|
||||
description={tab.description}
|
||||
isPro={tab.isPro}
|
||||
compact
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-h-0 flex-1 overflow-auto scrollbar-thin">
|
||||
{activeTab === "note" && (
|
||||
<NoteContent
|
||||
|
|
@ -487,38 +516,14 @@ export function AddDocument({
|
|||
: "justify-between gap-2 pt-3",
|
||||
)}
|
||||
>
|
||||
{isMobile && (
|
||||
<div className="flex h-10 w-full shrink-0 items-center overflow-hidden rounded-full border border-[#1F2937] bg-[#0D121A] p-1">
|
||||
{tabs.map((tab) => (
|
||||
<TabButton
|
||||
key={tab.id}
|
||||
active={activeTab === tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
icon={tab.icon}
|
||||
title={tab.title}
|
||||
compactLabel={tab.compactLabel}
|
||||
description={tab.description}
|
||||
isPro={tab.isPro}
|
||||
compact
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!isMobile && (
|
||||
<SpaceSelector
|
||||
selectedProjects={[localSelectedProject]}
|
||||
onValueChange={(projects) =>
|
||||
setLocalSelectedProject(projects[0] ?? localSelectedProject)
|
||||
}
|
||||
variant="insideOut"
|
||||
/>
|
||||
)}
|
||||
{!isMobile && spaceSelector}
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2",
|
||||
isMobile ? "w-full" : "justify-end",
|
||||
)}
|
||||
>
|
||||
{isMobile && spaceSelector}
|
||||
{!isMobile && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
|
@ -536,7 +541,7 @@ export function AddDocument({
|
|||
disabled={
|
||||
activeTab === "file" ? fileTabSubmitDisabled : isSubmitting
|
||||
}
|
||||
className={cn(isMobile && "h-12 w-full px-5 text-[15px]")}
|
||||
className={cn(isMobile && "h-12 flex-1 px-5 text-[15px]")}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
|
|
|
|||
204
apps/web/components/bottom-nav.tsx
Normal file
204
apps/web/components/bottom-nav.tsx
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
"use client"
|
||||
|
||||
import {
|
||||
Home,
|
||||
LayoutGrid,
|
||||
Plus,
|
||||
MoreHorizontal,
|
||||
SearchIcon,
|
||||
Sun,
|
||||
LifeBuoy,
|
||||
Settings,
|
||||
} from "lucide-react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useQueryState } from "nuqs"
|
||||
import { cn } from "@lib/utils"
|
||||
import { dmSansClassName } from "@/lib/fonts"
|
||||
import { GraphIcon } from "@/components/integration-icons"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@ui/components/dropdown-menu"
|
||||
import { useViewMode, type ViewMode } from "@/lib/view-mode-context"
|
||||
import { feedbackParam } from "@/lib/search-params"
|
||||
import NovaOrb from "@/components/nova/nova-orb"
|
||||
|
||||
const INTEGRATION_VIEWS: ViewMode[] = [
|
||||
"integrations",
|
||||
"mcp",
|
||||
"plugins",
|
||||
"chrome",
|
||||
"connections",
|
||||
"shortcuts",
|
||||
"raycast",
|
||||
"import",
|
||||
]
|
||||
|
||||
interface BottomNavProps {
|
||||
onAddMemory?: () => void
|
||||
onOpenSearch?: () => void
|
||||
}
|
||||
|
||||
export function MobileBottomNav({ onAddMemory, onOpenSearch }: BottomNavProps) {
|
||||
const router = useRouter()
|
||||
const { viewMode, setViewMode } = useViewMode()
|
||||
const [, setFeedbackOpen] = useQueryState("feedback", feedbackParam)
|
||||
|
||||
const isHome = viewMode === "dashboard"
|
||||
const isMemories = viewMode === "list" || viewMode === "graph"
|
||||
const isMore = INTEGRATION_VIEWS.includes(viewMode)
|
||||
|
||||
return (
|
||||
<nav
|
||||
aria-label="Primary"
|
||||
className={cn(
|
||||
"fixed inset-x-0 bottom-[calc(0.75rem+env(safe-area-inset-bottom))] z-40 flex justify-center px-3 md:hidden",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
<div className="flex w-full items-center justify-around rounded-full border border-[#161F2C] bg-muted/95 px-2.5 py-2 shadow-[0_10px_30px_rgba(0,0,0,0.55)] backdrop-blur-xl">
|
||||
<NavTab
|
||||
label="Home"
|
||||
icon={Home}
|
||||
active={isHome}
|
||||
onClick={() => void setViewMode("dashboard")}
|
||||
/>
|
||||
<NavTab
|
||||
label="Memories"
|
||||
icon={LayoutGrid}
|
||||
active={isMemories}
|
||||
onClick={() => void setViewMode("list")}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Open chat"
|
||||
onClick={() => void setViewMode("chat")}
|
||||
className="group relative flex size-11 shrink-0 items-center justify-center self-center rounded-full outline-none transition-transform hover:scale-[1.03] focus-visible:ring-2 focus-visible:ring-[#4BA0FA]/50"
|
||||
>
|
||||
<NovaOrb
|
||||
size={42}
|
||||
className="pointer-events-none blur-[1px]! transition-transform group-hover:scale-105"
|
||||
/>
|
||||
</button>
|
||||
<NavTab
|
||||
label="Add"
|
||||
icon={Plus}
|
||||
active={false}
|
||||
onClick={() => onAddMemory?.()}
|
||||
/>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<NavTabButton label="More" active={isMore}>
|
||||
<MoreHorizontal className="size-6" />
|
||||
</NavTabButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
side="top"
|
||||
align="end"
|
||||
sideOffset={12}
|
||||
className={cn(
|
||||
"min-w-[200px] rounded-2xl border border-[#263348]/60 p-1.5 shadow-[0px_1.5px_20px_0px_rgba(0,0,0,0.65)]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
style={{
|
||||
background: "linear-gradient(180deg, #101822 0%, #0A0E14 100%)",
|
||||
}}
|
||||
>
|
||||
<MoreItem icon={SearchIcon} label="Search" onClick={onOpenSearch} />
|
||||
<MoreItem
|
||||
icon={GraphIcon}
|
||||
label="Graph"
|
||||
onClick={() => void setViewMode("graph")}
|
||||
/>
|
||||
<MoreItem
|
||||
icon={Sun}
|
||||
label="Integrations"
|
||||
onClick={() => void setViewMode("integrations")}
|
||||
/>
|
||||
<DropdownMenuSeparator className="bg-[#263348]/50" />
|
||||
<MoreItem
|
||||
icon={LifeBuoy}
|
||||
label="Feedback"
|
||||
onClick={() => setFeedbackOpen(true)}
|
||||
/>
|
||||
<MoreItem
|
||||
icon={Settings}
|
||||
label="Settings"
|
||||
onClick={() => router.push("/settings")}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
function NavTab({
|
||||
label,
|
||||
icon: Icon,
|
||||
active,
|
||||
onClick,
|
||||
}: {
|
||||
label: string
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
active: boolean
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<NavTabButton label={label} active={active} onClick={onClick}>
|
||||
<Icon className="size-6" />
|
||||
</NavTabButton>
|
||||
)
|
||||
}
|
||||
|
||||
function NavTabButton({
|
||||
label,
|
||||
active,
|
||||
onClick,
|
||||
children,
|
||||
...props
|
||||
}: {
|
||||
label: string
|
||||
active: boolean
|
||||
onClick?: () => void
|
||||
children: React.ReactNode
|
||||
} & React.ComponentProps<"button">) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-current={active ? "page" : undefined}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"flex shrink-0 flex-col items-center gap-1 rounded-full px-3 py-1.5 outline-none transition-colors",
|
||||
active ? "text-white" : "text-[#737373] hover:text-white",
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<span className="text-[10px] font-medium leading-none">{label}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function MoreItem({
|
||||
icon: Icon,
|
||||
label,
|
||||
onClick,
|
||||
}: {
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
label: string
|
||||
onClick?: () => void
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
onClick={onClick}
|
||||
className="gap-2 rounded-md px-3 py-2.5 text-sm font-medium text-white hover:bg-[#293952]/40"
|
||||
>
|
||||
<Icon className="size-4 text-[#737373]" />
|
||||
{label}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ import { useProject } from "@/stores"
|
|||
import { cn } from "@lib/utils"
|
||||
import type { ModelId } from "@/lib/models"
|
||||
import { SpaceSelector } from "@/components/space-selector"
|
||||
import { AUTO_CHAT_SPACE_ID } from "@/lib/chat-auto-space"
|
||||
|
||||
export function HomeChatComposer({
|
||||
onStartChat,
|
||||
|
|
@ -19,7 +20,7 @@ export function HomeChatComposer({
|
|||
const [selectedModel, setSelectedModel] = useState<ModelId>("gemini-2.5-pro")
|
||||
const { selectedProject } = useProject()
|
||||
const [chatSpaceProjects, setChatSpaceProjects] = useState<string[]>([
|
||||
selectedProject,
|
||||
AUTO_CHAT_SPACE_ID,
|
||||
])
|
||||
|
||||
const send = useCallback(() => {
|
||||
|
|
@ -59,6 +60,7 @@ export function HomeChatComposer({
|
|||
onValueChange={setChatSpaceProjects}
|
||||
variant="insideOut"
|
||||
includeAuto
|
||||
hideCount
|
||||
triggerClassName="h-auto min-h-0 max-w-[min(160px,35vw)] rounded-full border border-[#161F2C] bg-[#000000] px-3 py-1.5 shadow-none hover:bg-[#05080D]"
|
||||
/>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -153,7 +153,7 @@ export function ChatSidebar({
|
|||
const targetHighlightChatIdRef = useRef<string | null>(null)
|
||||
const { selectedProject } = useProject()
|
||||
const [chatSpaceProjects, setChatSpaceProjects] = useState<string[]>([
|
||||
initialChatProject ?? selectedProject,
|
||||
initialChatProject ?? AUTO_CHAT_SPACE_ID,
|
||||
])
|
||||
const chatProject = chatSpaceProjects[0] ?? selectedProject
|
||||
const { allProjects } = useContainerTags()
|
||||
|
|
@ -248,6 +248,11 @@ export function ChatSidebar({
|
|||
id: string
|
||||
messages: UIMessage[]
|
||||
} | null>(null)
|
||||
const [loadedThreadScrollTarget, setLoadedThreadScrollTarget] = useState<{
|
||||
id: string
|
||||
messageCount: number
|
||||
lastMessageId: string | null
|
||||
} | null>(null)
|
||||
|
||||
// Adjust chat height based on scroll position (desktop only, grid mode only)
|
||||
useEffect(() => {
|
||||
|
|
@ -298,6 +303,13 @@ export function ChatSidebar({
|
|||
useEffect(() => {
|
||||
if (pendingThreadLoad && currentChatId === pendingThreadLoad.id) {
|
||||
setMessages(pendingThreadLoad.messages)
|
||||
setLoadedThreadScrollTarget({
|
||||
id: pendingThreadLoad.id,
|
||||
messageCount: pendingThreadLoad.messages.length,
|
||||
lastMessageId:
|
||||
pendingThreadLoad.messages[pendingThreadLoad.messages.length - 1]
|
||||
?.id ?? null,
|
||||
})
|
||||
setPendingThreadLoad(null)
|
||||
}
|
||||
}, [currentChatId, pendingThreadLoad, setMessages])
|
||||
|
|
@ -653,15 +665,39 @@ export function ChatSidebar({
|
|||
}
|
||||
}, [queuedMessage])
|
||||
|
||||
// Scroll to bottom when a new user message is added
|
||||
// Scroll to bottom when a new user message is added or a thread is loaded
|
||||
useEffect(() => {
|
||||
const lastMessageId = messages[messages.length - 1]?.id ?? null
|
||||
const loadedThreadIsRendered =
|
||||
loadedThreadScrollTarget &&
|
||||
currentChatId === loadedThreadScrollTarget.id &&
|
||||
messages.length === loadedThreadScrollTarget.messageCount &&
|
||||
lastMessageId === loadedThreadScrollTarget.lastMessageId
|
||||
|
||||
if (loadedThreadIsRendered) {
|
||||
// Trigger the same scroll behavior as the button after loaded messages render.
|
||||
scrollToBottom()
|
||||
setTimeout(scrollToBottom, 50)
|
||||
setTimeout(scrollToBottom, 150)
|
||||
setTimeout(() => {
|
||||
scrollToBottom()
|
||||
setLoadedThreadScrollTarget(null)
|
||||
}, 300)
|
||||
return
|
||||
}
|
||||
const lastMessage = messages[messages.length - 1]
|
||||
if (lastMessage?.role === "user" && messagesContainerRef.current) {
|
||||
scrollToBottom()
|
||||
} else {
|
||||
checkIfScrolledToBottom()
|
||||
}
|
||||
}, [messages, checkIfScrolledToBottom, scrollToBottom])
|
||||
}, [
|
||||
currentChatId,
|
||||
loadedThreadScrollTarget,
|
||||
messages,
|
||||
checkIfScrolledToBottom,
|
||||
scrollToBottom,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
const isStreaming = status === "streaming"
|
||||
|
|
@ -944,6 +980,7 @@ export function ChatSidebar({
|
|||
onValueChange={setChatSpaceProjects}
|
||||
variant="insideOut"
|
||||
includeAuto
|
||||
hideCount
|
||||
triggerClassName="h-10 min-h-10 max-w-[min(192px,42vw)] border border-[#73737333] bg-[#0D121A] shadow-[1.5px_1.5px_4.5px_0_rgba(0,0,0,0.70)_inset]"
|
||||
/>
|
||||
</>
|
||||
|
|
@ -952,100 +989,102 @@ export function ChatSidebar({
|
|||
{chatToolbarActions}
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
ref={messagesContainerRef}
|
||||
className={cn(
|
||||
"relative flex-1 overflow-y-auto scrollbar-thin",
|
||||
isPageDesktop && "min-h-0",
|
||||
"px-4",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
{isInputExpanded && (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-0 z-10! pointer-events-none",
|
||||
isPageDesktop ? "rounded-none" : "rounded-2xl",
|
||||
)}
|
||||
style={{ backgroundColor: "#000000E5" }}
|
||||
/>
|
||||
)}
|
||||
{messages.length === 0 && (
|
||||
<ChatEmptyStatePlaceholder
|
||||
onSuggestionClick={handleSuggestedQuestion}
|
||||
suggestions={emptyStateSuggestions}
|
||||
subtitle={emptyStateSubtitle}
|
||||
/>
|
||||
)}
|
||||
<div className="relative flex-1 min-h-0">
|
||||
<div
|
||||
className={
|
||||
messages.length > 0
|
||||
? cn(
|
||||
"flex flex-col space-y-3 min-h-full justify-end",
|
||||
isPageDesktop ? "pt-2" : "pt-14",
|
||||
)
|
||||
: ""
|
||||
}
|
||||
>
|
||||
{messages.map((message, index) => (
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: Hover detection for message actions
|
||||
<div
|
||||
key={message.id}
|
||||
className={cn(
|
||||
"flex gap-2 w-full",
|
||||
message.role === "user" ? "justify-end" : "justify-start",
|
||||
)}
|
||||
onMouseEnter={() =>
|
||||
message.role === "assistant" && setHoveredMessageId(message.id)
|
||||
}
|
||||
onMouseLeave={() =>
|
||||
message.role === "assistant" && setHoveredMessageId(null)
|
||||
}
|
||||
>
|
||||
{message.role === "user" ? (
|
||||
<UserMessage
|
||||
message={message}
|
||||
copiedMessageId={copiedMessageId}
|
||||
onCopy={handleCopyMessage}
|
||||
/>
|
||||
) : (
|
||||
<AgentMessage
|
||||
message={message}
|
||||
index={index}
|
||||
messagesLength={messages.length}
|
||||
hoveredMessageId={hoveredMessageId}
|
||||
copiedMessageId={copiedMessageId}
|
||||
messageFeedback={messageFeedback}
|
||||
expandedMemories={expandedMemories}
|
||||
onCopy={handleCopyMessage}
|
||||
onLike={handleLikeMessage}
|
||||
onDislike={handleDislikeMessage}
|
||||
onToggleMemories={handleToggleMemories}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{(status === "submitted" || status === "streaming") && (
|
||||
<div className="flex gap-2">
|
||||
<SuperLoader label="Thinking…" />
|
||||
</div>
|
||||
ref={messagesContainerRef}
|
||||
className={cn(
|
||||
"relative h-full overflow-y-auto scrollbar-thin",
|
||||
"px-4",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isScrolledToBottom && messages.length > 0 && (
|
||||
<div className="absolute bottom-24 left-0 right-0 flex justify-center z-50 pointer-events-none">
|
||||
<button
|
||||
type="button"
|
||||
className="cursor-pointer pointer-events-auto"
|
||||
onClick={scrollToBottom}
|
||||
>
|
||||
{isInputExpanded && (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-0 z-10! pointer-events-none",
|
||||
isPageDesktop ? "rounded-none" : "rounded-2xl",
|
||||
)}
|
||||
style={{ backgroundColor: "#000000E5" }}
|
||||
/>
|
||||
)}
|
||||
{messages.length === 0 && (
|
||||
<ChatEmptyStatePlaceholder
|
||||
onSuggestionClick={handleSuggestedQuestion}
|
||||
suggestions={emptyStateSuggestions}
|
||||
subtitle={emptyStateSubtitle}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={
|
||||
messages.length > 0
|
||||
? cn(
|
||||
"flex flex-col space-y-3 min-h-full justify-end",
|
||||
isPageDesktop ? "pt-2" : "pt-14",
|
||||
)
|
||||
: ""
|
||||
}
|
||||
>
|
||||
<div className="rounded-full p-2 bg-[#0D121A] shadow-[1.5px_1.5px_4.5px_0_rgba(0,0,0,0.70)_inset] hover:bg-[#0F1620] transition-colors">
|
||||
<ChevronDownIcon className="size-4 text-white" />
|
||||
</div>
|
||||
</button>
|
||||
{messages.map((message, index) => (
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: Hover detection for message actions
|
||||
<div
|
||||
key={message.id}
|
||||
className={cn(
|
||||
"flex gap-2 w-full",
|
||||
message.role === "user" ? "justify-end" : "justify-start",
|
||||
)}
|
||||
onMouseEnter={() =>
|
||||
message.role === "assistant" &&
|
||||
setHoveredMessageId(message.id)
|
||||
}
|
||||
onMouseLeave={() =>
|
||||
message.role === "assistant" && setHoveredMessageId(null)
|
||||
}
|
||||
>
|
||||
{message.role === "user" ? (
|
||||
<UserMessage
|
||||
message={message}
|
||||
copiedMessageId={copiedMessageId}
|
||||
onCopy={handleCopyMessage}
|
||||
/>
|
||||
) : (
|
||||
<AgentMessage
|
||||
message={message}
|
||||
index={index}
|
||||
messagesLength={messages.length}
|
||||
hoveredMessageId={hoveredMessageId}
|
||||
copiedMessageId={copiedMessageId}
|
||||
messageFeedback={messageFeedback}
|
||||
expandedMemories={expandedMemories}
|
||||
onCopy={handleCopyMessage}
|
||||
onLike={handleLikeMessage}
|
||||
onDislike={handleDislikeMessage}
|
||||
onToggleMemories={handleToggleMemories}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{(status === "submitted" || status === "streaming") && (
|
||||
<div className="flex gap-2">
|
||||
<SuperLoader label="Thinking…" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isScrolledToBottom && messages.length > 0 && (
|
||||
<div className="absolute bottom-3 left-0 right-0 flex justify-center z-50 pointer-events-none">
|
||||
<button
|
||||
type="button"
|
||||
className="cursor-pointer pointer-events-auto"
|
||||
onClick={scrollToBottom}
|
||||
>
|
||||
<div className="rounded-full p-2 bg-[#0D121A] shadow-[1.5px_1.5px_4.5px_0_rgba(0,0,0,0.70)_inset] hover:bg-[#0F1620] transition-colors">
|
||||
<ChevronDownIcon className="size-4 text-white" />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{chatStreamError && (
|
||||
<div
|
||||
|
|
@ -1103,7 +1142,7 @@ export function ChatSidebar({
|
|||
className={cn(
|
||||
"shrink-0",
|
||||
isStackedInput &&
|
||||
"px-4 pb-[max(1.25rem,calc(env(safe-area-inset-bottom)+1rem))] md:pb-6",
|
||||
"pb-[max(1.25rem,calc(env(safe-area-inset-bottom)+1rem))] md:pb-6",
|
||||
)}
|
||||
>
|
||||
<ChatInput
|
||||
|
|
@ -1138,6 +1177,7 @@ export function ChatSidebar({
|
|||
onValueChange={setChatSpaceProjects}
|
||||
variant="insideOut"
|
||||
includeAuto
|
||||
hideCount
|
||||
triggerClassName="h-auto min-h-0 max-w-[min(160px,35vw)] rounded-full border border-[#161F2C] bg-[#000000] px-3 py-1.5 shadow-none hover:bg-[#05080D]"
|
||||
/>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { cn } from "@lib/utils"
|
||||
import { SquareIcon } from "lucide-react"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@ui/components/tooltip"
|
||||
|
||||
export function SendButton({
|
||||
onClick,
|
||||
|
|
@ -8,7 +9,7 @@ export function SendButton({
|
|||
onClick: () => void
|
||||
disabled: boolean
|
||||
}) {
|
||||
return (
|
||||
const button = (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
|
|
@ -35,6 +36,19 @@ export function SendButton({
|
|||
</svg>
|
||||
</button>
|
||||
)
|
||||
|
||||
if (disabled) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex">{button}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Type a message to send</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
return button
|
||||
}
|
||||
|
||||
export function StopButton({ onClick }: { onClick: () => void }) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { cn } from "@lib/utils"
|
||||
import { Button } from "@ui/components/button"
|
||||
import { dmSansClassName } from "@/lib/fonts"
|
||||
|
|
@ -23,6 +23,21 @@ export default function ChatModelSelector({
|
|||
const [internalModel, setInternalModel] =
|
||||
useState<ModelId>("claude-sonnet-4.6")
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (
|
||||
containerRef.current &&
|
||||
!containerRef.current.contains(e.target as Node)
|
||||
) {
|
||||
setIsOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClickOutside)
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside)
|
||||
}, [isOpen])
|
||||
|
||||
const selectedModel = selectedModelProp ?? internalModel
|
||||
const currentModelData = modelNames[selectedModel]
|
||||
|
|
@ -73,53 +88,44 @@ export default function ChatModelSelector({
|
|||
)
|
||||
|
||||
return (
|
||||
<div className="relative flex min-w-0 shrink items-center gap-2">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative z-10 flex min-w-0 shrink items-center gap-2"
|
||||
>
|
||||
{trigger}
|
||||
|
||||
{isOpen && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="fixed inset-0 z-40"
|
||||
onClick={() => setIsOpen(false)}
|
||||
onKeyDown={(e) => e.key === "Escape" && setIsOpen(false)}
|
||||
aria-label="Close model selector"
|
||||
/>
|
||||
|
||||
<div className="absolute bottom-full left-0 mb-2 w-64 bg-surface-card backdrop-blur-xl border border-surface-border rounded-lg shadow-xl z-50 overflow-hidden">
|
||||
<div className="p-2 space-y-1">
|
||||
{models.map((model) => {
|
||||
const modelData = modelNames[model.id]
|
||||
return (
|
||||
<button
|
||||
key={model.id}
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex flex-col items-start p-2 px-3 rounded-md transition-colors cursor-pointer w-full text-left",
|
||||
selectedModel === model.id
|
||||
? "bg-[#293952]/60"
|
||||
: "hover:bg-[#293952]/40",
|
||||
)}
|
||||
onClick={() => handleModelSelect(model.id)}
|
||||
onKeyDown={(e) =>
|
||||
e.key === "Enter" && handleModelSelect(model.id)
|
||||
}
|
||||
>
|
||||
<div className="text-sm font-medium text-white">
|
||||
{modelData.name}{" "}
|
||||
<span className="text-fg-subtle">
|
||||
{modelData.version}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-fg-muted truncate w-full">
|
||||
{model.description}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="absolute bottom-full left-0 mb-2 w-64 bg-surface-card backdrop-blur-xl border border-surface-border rounded-lg shadow-xl z-50 overflow-hidden">
|
||||
<div className="p-2 space-y-1">
|
||||
{models.map((model) => {
|
||||
const modelData = modelNames[model.id]
|
||||
return (
|
||||
<button
|
||||
key={model.id}
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex flex-col items-start p-2 px-3 rounded-md transition-colors cursor-pointer w-full text-left",
|
||||
selectedModel === model.id
|
||||
? "bg-[#293952]/60"
|
||||
: "hover:bg-[#293952]/40",
|
||||
)}
|
||||
onClick={() => handleModelSelect(model.id)}
|
||||
onKeyDown={(e) =>
|
||||
e.key === "Enter" && handleModelSelect(model.id)
|
||||
}
|
||||
>
|
||||
<div className="text-sm font-medium text-white">
|
||||
{modelData.name}{" "}
|
||||
<span className="text-fg-subtle">{modelData.version}</span>
|
||||
</div>
|
||||
<div className="text-xs text-fg-muted truncate w-full">
|
||||
{model.description}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -338,8 +338,8 @@ export function ConnectAIModal({
|
|||
return (
|
||||
<Dialog onOpenChange={setIsOpen} open={isOpen}>
|
||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-4xl">
|
||||
<DialogHeader>
|
||||
<DialogContent className="max-h-[calc(100dvh-1rem)] w-[calc(100vw-1rem)] max-w-[calc(100vw-1rem)] overflow-y-auto overflow-x-hidden p-4 sm:max-w-4xl sm:p-6">
|
||||
<DialogHeader className="pr-7 sm:pr-0">
|
||||
<DialogTitle>Connect supermemory to Your AI</DialogTitle>
|
||||
<DialogDescription>
|
||||
Enable your AI assistant to create, search, and access your memories
|
||||
|
|
@ -347,9 +347,9 @@ export function ConnectAIModal({
|
|||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="min-w-0 space-y-6">
|
||||
{/* Step 1: Client Selection */}
|
||||
<div className="space-y-4">
|
||||
<div className="min-w-0 space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="size-8 rounded-full flex items-center justify-center text-sm font-semibold bg-accent text-accent-foreground">
|
||||
1
|
||||
|
|
@ -357,14 +357,15 @@ export function ConnectAIModal({
|
|||
<h3 className="text-sm font-medium">Select Your AI Client</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-x-2 space-y-2">
|
||||
<div className="flex min-w-0 flex-wrap gap-2">
|
||||
{Object.entries(clients).map(([key, clientName]) => (
|
||||
<button
|
||||
className={`pr-3 pl-1 rounded-full border cursor-pointer transition-all ${
|
||||
className={cn(
|
||||
"max-w-full rounded-full border py-0.5 pr-3 pl-1 cursor-pointer transition-all",
|
||||
selectedClient === key
|
||||
? "border-primary bg-primary/10"
|
||||
: "border-border hover:border-border/60 hover:bg-muted/50"
|
||||
}`}
|
||||
: "border-border hover:border-border/60 hover:bg-muted/50",
|
||||
)}
|
||||
key={key}
|
||||
onClick={() => {
|
||||
setSelectedClient(key as keyof typeof clients)
|
||||
|
|
@ -372,8 +373,8 @@ export function ConnectAIModal({
|
|||
}}
|
||||
type="button"
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="size-8 flex items-center justify-center">
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
<div className="size-7 shrink-0 flex items-center justify-center sm:size-8">
|
||||
<Image
|
||||
alt={clientName}
|
||||
className="rounded object-contain"
|
||||
|
|
@ -403,7 +404,7 @@ export function ConnectAIModal({
|
|||
width={20}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-foreground/80">
|
||||
<span className="truncate text-sm font-medium text-foreground/80">
|
||||
{clientName}
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -413,7 +414,7 @@ export function ConnectAIModal({
|
|||
</div>
|
||||
|
||||
{selectedClient && (
|
||||
<div className="space-y-4">
|
||||
<div className="min-w-0 space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-8 items-center justify-center rounded-full bg-accent text-sm font-semibold text-accent-foreground">
|
||||
2
|
||||
|
|
@ -424,13 +425,13 @@ export function ConnectAIModal({
|
|||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
{clientSetup && mcpClientSetupShowsTabs(clientSetup) ? (
|
||||
<div
|
||||
className="flex w-full max-w-md flex-row gap-1 rounded-full border border-border bg-muted/50 p-1 sm:w-auto sm:min-w-[280px]"
|
||||
className="flex w-full min-w-0 max-w-full flex-row gap-1 rounded-full border border-border bg-muted/50 p-1 sm:w-auto sm:min-w-[280px]"
|
||||
role="tablist"
|
||||
aria-label="Setup method"
|
||||
>
|
||||
<button
|
||||
className={cn(
|
||||
"min-h-9 flex-1 rounded-full px-3 py-2 text-center text-xs font-medium transition-all",
|
||||
"min-h-9 flex-1 rounded-full px-2 py-2 text-center text-xs font-medium transition-all sm:px-3",
|
||||
effectiveSetupTab === "manual"
|
||||
? "border border-border bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
|
|
@ -444,7 +445,7 @@ export function ConnectAIModal({
|
|||
</button>
|
||||
<button
|
||||
className={cn(
|
||||
"min-h-9 flex-1 rounded-full px-3 py-2 text-center text-xs font-medium transition-all",
|
||||
"min-h-9 flex-1 rounded-full px-2 py-2 text-center text-xs font-medium transition-all sm:px-3",
|
||||
effectiveSetupTab === "oneClick"
|
||||
? "border border-border bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
|
|
@ -461,7 +462,7 @@ export function ConnectAIModal({
|
|||
{selectedClient !== "mcp-url" && (
|
||||
<div
|
||||
className={cn(
|
||||
"flex justify-end",
|
||||
"flex justify-start sm:justify-end",
|
||||
!(
|
||||
clientSetup && mcpClientSetupShowsTabs(clientSetup)
|
||||
) && "w-full sm:justify-start",
|
||||
|
|
@ -523,9 +524,9 @@ export function ConnectAIModal({
|
|||
Paste this URL into clients that support remote MCP
|
||||
over HTTPS (OAuth).
|
||||
</p>
|
||||
<div className="relative max-w-xl">
|
||||
<div className="relative max-w-full sm:max-w-xl">
|
||||
<Input
|
||||
className="w-full pr-10 font-mono text-xs"
|
||||
className="w-full min-w-0 pr-10 font-mono text-xs"
|
||||
readOnly
|
||||
value={CHATGPT_REMOTE_MCP_URL}
|
||||
/>
|
||||
|
|
@ -552,7 +553,7 @@ export function ConnectAIModal({
|
|||
Optional: scope installs to a project. Then copy and
|
||||
run the command in your terminal.
|
||||
</p>
|
||||
<div className="max-w-md">
|
||||
<div className="max-w-full sm:max-w-md">
|
||||
<Select
|
||||
disabled={isLoadingProjects}
|
||||
onValueChange={setSelectedProject}
|
||||
|
|
@ -586,7 +587,7 @@ export function ConnectAIModal({
|
|||
</div>
|
||||
<div className="relative max-w-full">
|
||||
<Input
|
||||
className="w-full pr-10 font-mono text-xs"
|
||||
className="w-full min-w-0 pr-10 font-mono text-xs"
|
||||
readOnly
|
||||
value={generateInstallCommand()}
|
||||
/>
|
||||
|
|
@ -622,9 +623,9 @@ export function ConnectAIModal({
|
|||
</li>
|
||||
<li>Complete OAuth in ChatGPT.</li>
|
||||
</ol>
|
||||
<div className="relative max-w-xl">
|
||||
<div className="relative max-w-full sm:max-w-xl">
|
||||
<Input
|
||||
className="w-full pr-10 font-mono text-xs"
|
||||
className="w-full min-w-0 pr-10 font-mono text-xs"
|
||||
readOnly
|
||||
value={CHATGPT_REMOTE_MCP_URL}
|
||||
/>
|
||||
|
|
@ -677,8 +678,8 @@ export function ConnectAIModal({
|
|||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="relative max-w-full">
|
||||
<pre className="max-h-80 overflow-x-auto overflow-y-auto rounded-lg border border-border bg-muted p-4 pr-12 text-xs">
|
||||
<div className="relative min-w-0 max-w-full">
|
||||
<pre className="max-h-80 max-w-full overflow-x-auto overflow-y-auto rounded-lg border border-border bg-muted p-3 pr-12 text-xs sm:p-4">
|
||||
<code className="block font-mono whitespace-pre-wrap break-all">
|
||||
{remoteSnippet}
|
||||
</code>
|
||||
|
|
@ -715,8 +716,8 @@ export function ConnectAIModal({
|
|||
Merge the snippet with your existing config. Restart
|
||||
the client and sign in with OAuth when prompted.
|
||||
</p>
|
||||
<div className="relative max-w-full">
|
||||
<pre className="max-h-80 overflow-x-auto overflow-y-auto rounded-lg border border-border bg-muted p-4 pr-12 text-xs">
|
||||
<div className="relative min-w-0 max-w-full">
|
||||
<pre className="max-h-80 max-w-full overflow-x-auto overflow-y-auto rounded-lg border border-border bg-muted p-3 pr-12 text-xs sm:p-4">
|
||||
<code className="block font-mono whitespace-pre-wrap break-all">
|
||||
{manual.snippet}
|
||||
</code>
|
||||
|
|
@ -782,8 +783,8 @@ export function ConnectAIModal({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-muted/50 rounded-lg p-4 border border-border">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="rounded-lg border border-border bg-muted/50 p-4">
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<h3 className="text-sm font-medium">Connection Status</h3>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
{isCheckingConnection ? (
|
||||
|
|
@ -817,8 +818,8 @@ export function ConnectAIModal({
|
|||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center pt-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex flex-col gap-3 pt-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex flex-wrap items-center gap-2 sm:gap-4">
|
||||
<Button
|
||||
onClick={() =>
|
||||
window.open(
|
||||
|
|
@ -839,7 +840,12 @@ export function ConnectAIModal({
|
|||
Migrate from v1
|
||||
</Button>
|
||||
</div>
|
||||
<Button onClick={() => setIsOpen(false)}>Done</Button>
|
||||
<Button
|
||||
className="w-full sm:w-auto"
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
|
|
|||
|
|
@ -7,12 +7,14 @@ import { $fetch } from "@lib/api"
|
|||
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { useRouter } from "next/navigation"
|
||||
import Image from "next/image"
|
||||
import {
|
||||
ArrowRight,
|
||||
ExternalLink,
|
||||
FileText,
|
||||
Lightbulb,
|
||||
Link2,
|
||||
Plug,
|
||||
RotateCcw,
|
||||
SearchIcon,
|
||||
Terminal,
|
||||
|
|
@ -37,6 +39,7 @@ import {
|
|||
usePersonalization,
|
||||
type Profession,
|
||||
} from "@/hooks/use-personalization"
|
||||
import { normalizePluginClientId } from "@/lib/plugin-catalog"
|
||||
|
||||
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
|
||||
type DocumentWithMemories = DocumentsResponse["documents"][0]
|
||||
|
|
@ -268,6 +271,22 @@ const PROFESSION_LABELS: {
|
|||
{ value: "medical", label: "Medical" },
|
||||
]
|
||||
|
||||
function getChangeProfessionPrompt(profession: Profession): string {
|
||||
const prompts: Record<Exclude<Profession, "default">, string> = {
|
||||
developer: "Not a developer?",
|
||||
research: "Not a researcher?",
|
||||
finance: "Not in finance?",
|
||||
design: "Not a designer?",
|
||||
legal: "Not in the legal field?",
|
||||
marketing: "Not in marketing?",
|
||||
medical: "Not in healthcare?",
|
||||
}
|
||||
if (profession === "default") {
|
||||
return "Not your role?"
|
||||
}
|
||||
return prompts[profession] ?? "Not your role?"
|
||||
}
|
||||
|
||||
// Static plugin metadata — shared between PluginPromoCard and RecommendedPluginsCard
|
||||
const PLUGIN_STATIC = [
|
||||
{
|
||||
|
|
@ -313,6 +332,468 @@ const PLUGIN_STATIC = [
|
|||
},
|
||||
] as const
|
||||
|
||||
// Plugin catalog for tool usage display - maps plugin IDs to display names and icons
|
||||
const PLUGIN_DISPLAY_CATALOG: Record<
|
||||
string,
|
||||
{ name: string; icon: string | null; type: "Plugin" }
|
||||
> = {
|
||||
claude_code: {
|
||||
name: "Claude Code",
|
||||
icon: "/images/plugins/claude-code.svg",
|
||||
type: "Plugin",
|
||||
},
|
||||
opencode: {
|
||||
name: "OpenCode",
|
||||
icon: "/images/plugins/opencode.svg",
|
||||
type: "Plugin",
|
||||
},
|
||||
openclaw: {
|
||||
name: "OpenClaw",
|
||||
icon: "/images/plugins/openclaw.svg",
|
||||
type: "Plugin",
|
||||
},
|
||||
hermes: {
|
||||
name: "Hermes",
|
||||
icon: "/images/plugins/hermes.svg",
|
||||
type: "Plugin",
|
||||
},
|
||||
codex: {
|
||||
name: "OpenAI Codex",
|
||||
icon: "/mcp-supported-tools/codex.png",
|
||||
type: "Plugin",
|
||||
},
|
||||
}
|
||||
|
||||
// Types for tool usage
|
||||
interface ToolUsageItem {
|
||||
id: string
|
||||
name: string
|
||||
type: "Plugin" | "MCP"
|
||||
icon: string | null
|
||||
lastUsedAt: Date | null
|
||||
hasBeenUsed: boolean
|
||||
connectedAt: Date | null
|
||||
lastDocumentTitle: string | null
|
||||
lastDocumentId: string | null
|
||||
lastDocumentPreview: string | null
|
||||
lastDocument: DocumentWithMemories | null
|
||||
}
|
||||
|
||||
type ToolUsageApiKey = {
|
||||
id: string
|
||||
name: string
|
||||
createdAt: string
|
||||
lastRequest: string | null
|
||||
metadata: string
|
||||
}
|
||||
|
||||
function toValidDate(value: string | Date | null | undefined): Date | null {
|
||||
if (!value) return null
|
||||
const date = new Date(value)
|
||||
return Number.isNaN(date.getTime()) ? null : date
|
||||
}
|
||||
|
||||
function compactText(value: string): string {
|
||||
return value.replace(/\s+/g, " ").trim()
|
||||
}
|
||||
|
||||
function getDocumentText(document: DocumentWithMemories): string {
|
||||
return typeof document.content === "string" ? document.content : ""
|
||||
}
|
||||
|
||||
function toMetadataRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (!value) return null
|
||||
if (typeof value === "object" && !Array.isArray(value)) {
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
if (typeof value !== "string") return null
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(value)
|
||||
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
||||
? (parsed as Record<string, unknown>)
|
||||
: null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function getDocumentMetadataRecords(
|
||||
document: DocumentWithMemories,
|
||||
): Record<string, unknown>[] {
|
||||
const records = [toMetadataRecord(document.metadata)]
|
||||
|
||||
for (const entry of document.memoryEntries ?? []) {
|
||||
records.push(
|
||||
toMetadataRecord(entry.metadata),
|
||||
toMetadataRecord(entry.sourceMetadata),
|
||||
)
|
||||
}
|
||||
|
||||
return records.filter((record): record is Record<string, unknown> => !!record)
|
||||
}
|
||||
|
||||
function hasClaudeCodeContainer(document: DocumentWithMemories): boolean {
|
||||
const containerTags =
|
||||
(document as { containerTags?: string[] }).containerTags ?? []
|
||||
if (containerTags.some((tag) => tag.startsWith("claudecode_"))) return true
|
||||
|
||||
return (document.memoryEntries ?? []).some((entry) =>
|
||||
entry.spaceContainerTag?.startsWith("claudecode_"),
|
||||
)
|
||||
}
|
||||
|
||||
function getPluginClientFromDocument(
|
||||
document: DocumentWithMemories,
|
||||
): string | null {
|
||||
for (const metadata of getDocumentMetadataRecords(document)) {
|
||||
const metadataClient =
|
||||
typeof metadata.sm_client === "string"
|
||||
? metadata.sm_client
|
||||
: typeof metadata.sm_internal_plugin_client === "string"
|
||||
? metadata.sm_internal_plugin_client
|
||||
: typeof metadata.sm_internal_mcp_client_name === "string"
|
||||
? metadata.sm_internal_mcp_client_name
|
||||
: null
|
||||
if (metadataClient) return normalizePluginClientId(metadataClient)
|
||||
|
||||
if (metadata.sm_source === "claude-code-plugin") return "claude_code"
|
||||
}
|
||||
|
||||
if (hasClaudeCodeContainer(document)) return "claude_code"
|
||||
|
||||
const content = getDocumentText(document)
|
||||
const title = document.title ?? ""
|
||||
if (
|
||||
/\[Session\s+[^\]]+\]/i.test(content) ||
|
||||
/\[SAVE:[^\]]+\]/i.test(content)
|
||||
) {
|
||||
return "codex"
|
||||
}
|
||||
if (/\bCodex\b/i.test(title)) return "codex"
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function getDocumentPreview(document: DocumentWithMemories): string | null {
|
||||
const summary =
|
||||
typeof document.summary === "string" ? compactText(document.summary) : ""
|
||||
if (summary) return summary
|
||||
|
||||
const content = getDocumentText(document)
|
||||
if (!content) return document.title?.trim() || null
|
||||
|
||||
const transcriptTurns = Array.from(
|
||||
content.matchAll(
|
||||
/\d+\.\s+\[(user|assistant)\]\s*([\s\S]*?)(?=\d+\.\s+\[(?:user|assistant|tool|system)\]|---|\[\/?[A-Za-z]|$)/gi,
|
||||
),
|
||||
)
|
||||
.slice(0, 2)
|
||||
.map((match) => {
|
||||
const role = match[1] === "assistant" ? "Assistant" : "You"
|
||||
const text = compactText(match[2] ?? "")
|
||||
return text ? `${role}: ${text}` : null
|
||||
})
|
||||
.filter(Boolean)
|
||||
|
||||
if (transcriptTurns.length > 0) return transcriptTurns.join(" · ")
|
||||
|
||||
const cleaned = compactText(
|
||||
content
|
||||
.replace(/\[Session\s+[^\]]+\]/gi, "")
|
||||
.replace(/\[SAVE:[^\]]+\]/gi, "")
|
||||
.replace(/\[\/SAVE\]/gi, ""),
|
||||
)
|
||||
return cleaned || document.title?.trim() || null
|
||||
}
|
||||
|
||||
function getToolDocumentTitle(document: DocumentWithMemories): string {
|
||||
return document.title?.trim() || "Recent conversation"
|
||||
}
|
||||
|
||||
// Parse API keys to extract tool usage data
|
||||
function parseToolUsage(
|
||||
apiKeys: ToolUsageApiKey[],
|
||||
recentMcpDocuments: DocumentWithMemories[] = [],
|
||||
): ToolUsageItem[] {
|
||||
const toolMap = new Map<string, ToolUsageItem>()
|
||||
let latestMcpClientName: string | null = null
|
||||
let latestMcpDocumentAt: Date | null = null
|
||||
|
||||
const latestDocPerPlugin = new Map<
|
||||
string,
|
||||
{
|
||||
title: string
|
||||
id: string
|
||||
at: Date
|
||||
preview: string | null
|
||||
document: DocumentWithMemories
|
||||
}
|
||||
>()
|
||||
|
||||
for (const key of apiKeys) {
|
||||
let meta: Record<string, unknown> = {}
|
||||
try {
|
||||
meta = key.metadata ? JSON.parse(key.metadata) : {}
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
const smType = meta.sm_type as string | undefined
|
||||
const smClient = meta.sm_client as string | undefined
|
||||
const smSource = meta.sm_source as string | undefined
|
||||
const smKind = meta.sm_kind as string | undefined
|
||||
|
||||
// Plugin keys
|
||||
if (smType === "plugin_auth" && smClient) {
|
||||
const normalizedClient = normalizePluginClientId(smClient)
|
||||
const catalog = PLUGIN_DISPLAY_CATALOG[normalizedClient]
|
||||
const existingItem = toolMap.get(`plugin_${normalizedClient}`)
|
||||
const lastUsed =
|
||||
toValidDate(key.lastRequest) ?? toValidDate(key.createdAt)
|
||||
const existingLastUsed = existingItem?.lastUsedAt
|
||||
|
||||
// Keep the most recent usage
|
||||
if (
|
||||
!existingItem ||
|
||||
(lastUsed &&
|
||||
(!existingLastUsed ||
|
||||
lastUsed.getTime() > existingLastUsed.getTime()))
|
||||
) {
|
||||
toolMap.set(`plugin_${normalizedClient}`, {
|
||||
id: `plugin_${normalizedClient}`,
|
||||
name: catalog?.name ?? smClient,
|
||||
type: "Plugin",
|
||||
icon: catalog?.icon ?? null,
|
||||
lastUsedAt: lastUsed,
|
||||
hasBeenUsed: !!key.lastRequest,
|
||||
connectedAt: toValidDate(key.createdAt),
|
||||
lastDocumentTitle: null,
|
||||
lastDocumentId: null,
|
||||
lastDocumentPreview: null,
|
||||
lastDocument: null,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// MCP keys
|
||||
if (smSource === "mcp" || smKind === "mcp_oauth_exchange") {
|
||||
const existingItem = toolMap.get("mcp")
|
||||
const lastUsed =
|
||||
toValidDate(key.lastRequest) ?? toValidDate(key.createdAt)
|
||||
const existingLastUsed = existingItem?.lastUsedAt
|
||||
|
||||
// Keep the most recent usage
|
||||
if (
|
||||
!existingItem ||
|
||||
(lastUsed &&
|
||||
(!existingLastUsed ||
|
||||
lastUsed.getTime() > existingLastUsed.getTime()))
|
||||
) {
|
||||
toolMap.set("mcp", {
|
||||
id: "mcp",
|
||||
name: "Supermemory MCP",
|
||||
type: "MCP",
|
||||
icon: null,
|
||||
lastUsedAt: lastUsed,
|
||||
hasBeenUsed: !!key.lastRequest,
|
||||
connectedAt: toValidDate(key.createdAt),
|
||||
lastDocumentTitle: null,
|
||||
lastDocumentId: null,
|
||||
lastDocumentPreview: null,
|
||||
lastDocument: null,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const doc of recentMcpDocuments) {
|
||||
const metadataRecords = getDocumentMetadataRecords(doc)
|
||||
const clientName =
|
||||
metadataRecords
|
||||
.map((record) => record.sm_internal_mcp_client_name)
|
||||
.find((value): value is string => typeof value === "string") ?? null
|
||||
const pluginClient = getPluginClientFromDocument(doc)
|
||||
const isMcpDocument =
|
||||
doc.source === "mcp" ||
|
||||
metadataRecords.some(
|
||||
(record) => record.sm_internal_event_from === "mcp",
|
||||
) ||
|
||||
!!clientName
|
||||
const isPluginDocument = !!pluginClient && pluginClient !== "mcp"
|
||||
|
||||
if (!isMcpDocument && !isPluginDocument) continue
|
||||
|
||||
const createdAt = toValidDate(doc.createdAt)
|
||||
if (
|
||||
isMcpDocument &&
|
||||
clientName &&
|
||||
clientName !== "unknown" &&
|
||||
(!latestMcpDocumentAt ||
|
||||
(createdAt && createdAt.getTime() > latestMcpDocumentAt.getTime()))
|
||||
) {
|
||||
latestMcpClientName = clientName
|
||||
latestMcpDocumentAt = createdAt
|
||||
}
|
||||
|
||||
if (isMcpDocument) {
|
||||
const existingItem = toolMap.get("mcp")
|
||||
const existingLastUsed = existingItem?.lastUsedAt
|
||||
if (
|
||||
!existingItem ||
|
||||
(createdAt &&
|
||||
(!existingLastUsed ||
|
||||
createdAt.getTime() > existingLastUsed.getTime()))
|
||||
) {
|
||||
toolMap.set("mcp", {
|
||||
id: "mcp",
|
||||
name:
|
||||
clientName && clientName !== "unknown"
|
||||
? clientName
|
||||
: "Supermemory MCP",
|
||||
type: "MCP",
|
||||
icon: null,
|
||||
lastUsedAt: createdAt,
|
||||
hasBeenUsed: true,
|
||||
connectedAt: toolMap.get("mcp")?.connectedAt ?? null,
|
||||
lastDocumentTitle: doc.title?.trim() || null,
|
||||
lastDocumentId: doc.id ?? null,
|
||||
lastDocumentPreview: getDocumentPreview(doc),
|
||||
lastDocument: doc,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (pluginClient && createdAt && doc.id) {
|
||||
const existing = latestDocPerPlugin.get(pluginClient)
|
||||
if (!existing || createdAt.getTime() > existing.at.getTime()) {
|
||||
latestDocPerPlugin.set(pluginClient, {
|
||||
title: getToolDocumentTitle(doc),
|
||||
id: doc.id,
|
||||
at: createdAt,
|
||||
preview: getDocumentPreview(doc),
|
||||
document: doc,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (latestMcpClientName) {
|
||||
const existingItem = toolMap.get("mcp")
|
||||
if (existingItem) {
|
||||
toolMap.set("mcp", {
|
||||
...existingItem,
|
||||
name: latestMcpClientName,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Attach latest document info to plugin items where available from MCP documents
|
||||
for (const [, item] of toolMap) {
|
||||
if (item.type === "Plugin" && !item.lastDocumentTitle) {
|
||||
const pluginId = item.id.replace(/^plugin_/, "")
|
||||
const docInfo = latestDocPerPlugin.get(pluginId)
|
||||
if (docInfo) {
|
||||
item.lastDocumentTitle = docInfo.title
|
||||
item.lastDocumentId = docInfo.id
|
||||
item.lastDocumentPreview = docInfo.preview
|
||||
item.lastDocument = docInfo.document
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by lastUsedAt (most recent first), then by hasBeenUsed
|
||||
return Array.from(toolMap.values()).sort((a, b) => {
|
||||
// Items that have been used come first
|
||||
if (a.hasBeenUsed !== b.hasBeenUsed) {
|
||||
return a.hasBeenUsed ? -1 : 1
|
||||
}
|
||||
// Then sort by recency
|
||||
if (!a.lastUsedAt && !b.lastUsedAt) return 0
|
||||
if (!a.lastUsedAt) return 1
|
||||
if (!b.lastUsedAt) return -1
|
||||
return b.lastUsedAt.getTime() - a.lastUsedAt.getTime()
|
||||
})
|
||||
}
|
||||
|
||||
// Format relative time for tool usage
|
||||
function formatToolUsageTime(date: Date | null, hasBeenUsed: boolean): string {
|
||||
if (!hasBeenUsed) return "Never used"
|
||||
if (!date) return "Connected"
|
||||
const diffMs = Date.now() - date.getTime()
|
||||
const diffMins = Math.floor(diffMs / (1000 * 60))
|
||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
|
||||
const diffDays = Math.floor(diffHours / 24)
|
||||
if (diffMins < 1) return "Just now"
|
||||
if (diffMins < 60) return `${diffMins}m ago`
|
||||
if (diffHours < 24) return `${diffHours}h ago`
|
||||
if (diffDays === 1) return "Yesterday"
|
||||
if (diffDays < 7) return `${diffDays}d ago`
|
||||
return date.toLocaleDateString()
|
||||
}
|
||||
|
||||
function ToolUsageRecentRow({
|
||||
item,
|
||||
onOpenPlugins,
|
||||
onOpenToolDocument,
|
||||
}: {
|
||||
item: ToolUsageItem
|
||||
onOpenPlugins: () => void
|
||||
onOpenToolDocument: (
|
||||
document: DocumentWithMemories,
|
||||
pluginClientId: string,
|
||||
) => void
|
||||
}) {
|
||||
const pluginClientId = item.id.replace(/^plugin_/, "")
|
||||
|
||||
return (
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (item.lastDocument) {
|
||||
onOpenToolDocument(item.lastDocument, pluginClientId)
|
||||
return
|
||||
}
|
||||
onOpenPlugins()
|
||||
}}
|
||||
className="group flex w-full items-start gap-3 rounded-lg px-2.5 py-2 text-left transition-all hover:bg-surface-hover hover:py-2.5"
|
||||
>
|
||||
<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">
|
||||
{item.icon ? (
|
||||
<Image
|
||||
src={item.icon}
|
||||
alt={item.name}
|
||||
width={14}
|
||||
height={14}
|
||||
className="size-3.5"
|
||||
/>
|
||||
) : item.type === "MCP" ? (
|
||||
<MCPIcon className="size-3.5" />
|
||||
) : (
|
||||
<Plug className="size-3.5 text-fg-subtle" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="min-w-0 flex-1 truncate text-sm text-fg-muted group-hover:text-white transition-colors">
|
||||
{item.lastDocumentTitle ?? "No saved memory yet"}
|
||||
</span>
|
||||
<span className="shrink-0 text-[10px] text-fg-faint">
|
||||
{formatToolUsageTime(item.lastUsedAt, item.hasBeenUsed)}
|
||||
</span>
|
||||
</div>
|
||||
{item.lastDocumentPreview ? (
|
||||
<p className="mt-0 max-h-0 overflow-hidden text-[11px] leading-snug text-fg-subtle opacity-0 transition-all duration-200 line-clamp-2 group-hover:mt-1 group-hover:max-h-9 group-hover:opacity-100">
|
||||
{item.lastDocumentPreview}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
function RecommendedPluginsCard({
|
||||
profession,
|
||||
setProfession,
|
||||
|
|
@ -353,7 +834,7 @@ function RecommendedPluginsCard({
|
|||
return PLUGIN_STATIC.map((p) => ({
|
||||
...p,
|
||||
connected: connected[p.id] ?? false,
|
||||
onClick: onClicks[p.id]!,
|
||||
onClick: onClicks[p.id] ?? (() => {}),
|
||||
}))
|
||||
}, [hasMcp, connectedProviders, onOpenPlugins, onOpenIntegrations])
|
||||
|
||||
|
|
@ -446,11 +927,7 @@ function RecommendedPluginsCard({
|
|||
onClick={() => setIsEditing(true)}
|
||||
className="text-left px-2 pb-1 text-[10px] text-fg-faint hover:text-fg-muted transition-colors cursor-pointer"
|
||||
>
|
||||
Not a{" "}
|
||||
{PROFESSION_LABELS.find(
|
||||
(p) => p.value === profession,
|
||||
)?.label.toLowerCase()}
|
||||
? Change →
|
||||
{getChangeProfessionPrompt(profession)} Change role →
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
|
@ -525,7 +1002,7 @@ function PluginPromoCard({
|
|||
return PLUGIN_STATIC.map((p) => ({
|
||||
...p,
|
||||
connected: connected[p.id] ?? false,
|
||||
onClick: onClicks[p.id]!,
|
||||
onClick: onClicks[p.id] ?? (() => {}),
|
||||
})).filter((p) => !p.connected)
|
||||
}, [hasMcp, connectedProviders, onOpenPlugins, onOpenIntegrations])
|
||||
|
||||
|
|
@ -628,9 +1105,10 @@ export function DashboardView({
|
|||
onOpenSearch,
|
||||
onOpenIntegrations,
|
||||
onOpenPlugins,
|
||||
onNavigateToMemories,
|
||||
onNavigateToMemories: _onNavigateToMemories,
|
||||
onNavigateToGraph,
|
||||
onOpenDocument,
|
||||
onOpenToolDocument,
|
||||
onHighlightsChat,
|
||||
onHighlightsShowRelated,
|
||||
onResetHighlights,
|
||||
|
|
@ -647,12 +1125,16 @@ export function DashboardView({
|
|||
onNavigateToMemories: () => void
|
||||
onNavigateToGraph: () => void
|
||||
onOpenDocument: (document: DocumentWithMemories) => void
|
||||
onOpenToolDocument: (
|
||||
document: DocumentWithMemories,
|
||||
pluginClientId: string,
|
||||
) => void
|
||||
onHighlightsChat: (highlightContent: string, userReply: string) => void
|
||||
onHighlightsShowRelated: (query: string) => void
|
||||
onResetHighlights: () => void
|
||||
memoryOfDay: MemoryOfDay | null
|
||||
}) {
|
||||
const { user } = useAuth()
|
||||
const { user, org } = useAuth()
|
||||
const { effectiveContainerTags } = useProject()
|
||||
const _router = useRouter()
|
||||
const { data: recentsData, isPending: isRecentsLoading } = useQuery({
|
||||
|
|
@ -672,7 +1154,7 @@ export function DashboardView({
|
|||
return response.data as DocumentsResponse
|
||||
},
|
||||
staleTime: 60 * 1000,
|
||||
enabled: !!user,
|
||||
enabled: !!user && !!org?.id,
|
||||
})
|
||||
|
||||
const { data: connections = [] } = useQuery({
|
||||
|
|
@ -698,6 +1180,58 @@ export function DashboardView({
|
|||
enabled: !!user,
|
||||
})
|
||||
|
||||
// Fetch API keys for tool usage tracking
|
||||
const { data: apiKeysData } = useQuery({
|
||||
queryKey: ["api-keys-tool-usage", org?.id],
|
||||
queryFn: async () => {
|
||||
const API_URL =
|
||||
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
|
||||
const res = await fetch(`${API_URL}/v3/auth/keys`, {
|
||||
credentials: "include",
|
||||
})
|
||||
if (!res.ok) return { keys: [] }
|
||||
return (await res.json()) as {
|
||||
keys: Array<{
|
||||
id: string
|
||||
name: string
|
||||
createdAt: string
|
||||
lastRequest: string | null
|
||||
metadata: string
|
||||
}>
|
||||
}
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
enabled: !!user,
|
||||
})
|
||||
|
||||
const { data: recentMcpDocumentsData } = useQuery({
|
||||
queryKey: ["dashboard-tool-documents", org?.id],
|
||||
queryFn: async (): Promise<DocumentsResponse> => {
|
||||
const response = await $fetch("@post/documents/documents", {
|
||||
body: {
|
||||
page: 1,
|
||||
limit: 50,
|
||||
sort: "createdAt",
|
||||
order: "desc",
|
||||
},
|
||||
disableValidation: true,
|
||||
})
|
||||
if (response.error) throw new Error(response.error?.message)
|
||||
return response.data as DocumentsResponse
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
enabled: !!user,
|
||||
})
|
||||
|
||||
const toolUsageItems = useMemo(
|
||||
() =>
|
||||
parseToolUsage(
|
||||
apiKeysData?.keys ?? [],
|
||||
recentMcpDocumentsData?.documents ?? [],
|
||||
),
|
||||
[apiKeysData, recentMcpDocumentsData],
|
||||
)
|
||||
|
||||
const {
|
||||
copy: personalizedCopy,
|
||||
profession,
|
||||
|
|
@ -705,6 +1239,14 @@ export function DashboardView({
|
|||
} = usePersonalization()
|
||||
|
||||
const recents = recentsData?.documents ?? []
|
||||
const recentToolUsageItems = toolUsageItems
|
||||
.filter((item) => item.type === "Plugin" && item.lastDocument)
|
||||
.sort((a, b) => {
|
||||
const aTime = toValidDate(a.lastDocument?.createdAt)?.getTime() ?? 0
|
||||
const bTime = toValidDate(b.lastDocument?.createdAt)?.getTime() ?? 0
|
||||
return bTime - aTime
|
||||
})
|
||||
.slice(0, 3)
|
||||
const totalMemories = recentsData?.pagination?.totalItems ?? 0
|
||||
const hasMcp = mcpData?.previousLogin ?? false
|
||||
const connectedProviders = new Set(connections.map((c) => c.provider))
|
||||
|
|
@ -755,7 +1297,7 @@ export function DashboardView({
|
|||
Home
|
||||
</p>
|
||||
<h1
|
||||
className="max-w-2xl text-xl font-medium tracking-tight text-white md:text-2xl"
|
||||
className="max-w-2xl text-lg font-medium leading-snug tracking-tight text-white md:text-2xl md:leading-tight"
|
||||
title={spaceLabel}
|
||||
>
|
||||
{homeHeadline}
|
||||
|
|
@ -767,7 +1309,7 @@ export function DashboardView({
|
|||
<button
|
||||
type="button"
|
||||
onClick={onNavigateToGraph}
|
||||
className="group relative shrink-0 w-[140px] h-[56px] rounded-xl overflow-hidden border border-surface-border hover:border-[#3A4A63] transition-all bg-surface-card hover:scale-[1.02]"
|
||||
className="group relative hidden h-[56px] w-[140px] shrink-0 overflow-hidden rounded-xl border border-surface-border bg-surface-card transition-all hover:scale-[1.02] hover:border-[#3A4A63] md:block"
|
||||
aria-label="Open graph view"
|
||||
>
|
||||
<StaticGraphPreview
|
||||
|
|
@ -819,6 +1361,7 @@ export function DashboardView({
|
|||
onChat={onHighlightsChat}
|
||||
onShowRelated={onHighlightsShowRelated}
|
||||
isLoading={isLoadingHighlights}
|
||||
onAddMemory={() => onAddMemory("note")}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-[2] hidden sm:block min-w-0">
|
||||
|
|
@ -895,9 +1438,9 @@ export function DashboardView({
|
|||
className="space-y-2"
|
||||
>
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-[3] min-w-0">
|
||||
<div className="flex-[4] min-w-0">
|
||||
<p className="text-[10px] font-medium uppercase tracking-[0.12em] text-fg-faint">
|
||||
Recently saved
|
||||
Recents
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-[2] min-w-0 hidden sm:block">
|
||||
|
|
@ -908,7 +1451,7 @@ export function DashboardView({
|
|||
</div>
|
||||
|
||||
<div className="flex gap-4 items-start">
|
||||
<div className="flex-[3] min-w-0">
|
||||
<div className="flex-[4] min-w-0">
|
||||
{isRecentsLoading ? (
|
||||
<ul
|
||||
className="space-y-0.5"
|
||||
|
|
@ -929,8 +1472,16 @@ export function DashboardView({
|
|||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : recents.length > 0 ? (
|
||||
) : recents.length > 0 || recentToolUsageItems.length > 0 ? (
|
||||
<ul className="space-y-0.5">
|
||||
{recentToolUsageItems.map((item) => (
|
||||
<ToolUsageRecentRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
onOpenPlugins={onOpenPlugins}
|
||||
onOpenToolDocument={onOpenToolDocument}
|
||||
/>
|
||||
))}
|
||||
{recents.map((doc) => {
|
||||
const isLink = !!doc.url
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -103,7 +103,7 @@ export function DocumentContent({
|
|||
return <TextEditorContent {...textEditorProps} />
|
||||
|
||||
case "pdf":
|
||||
return <PdfViewer url={document.url} />
|
||||
return <PdfViewer url={document.url} documentId={document.id} />
|
||||
|
||||
case "notion":
|
||||
return <NotionDoc content={document.content ?? ""} />
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client"
|
||||
|
||||
import { Document, Page, pdfjs } from "react-pdf"
|
||||
import { useCallback, useState } from "react"
|
||||
import { useCallback, useMemo, useState } from "react"
|
||||
import "react-pdf/dist/Page/AnnotationLayer.css"
|
||||
import "react-pdf/dist/Page/TextLayer.css"
|
||||
|
||||
|
|
@ -13,9 +13,25 @@ pdfjs.GlobalWorkerOptions.workerSrc = new URL(
|
|||
|
||||
interface PdfViewerProps {
|
||||
url: string | null | undefined
|
||||
documentId?: string | null
|
||||
}
|
||||
|
||||
export function PdfViewer({ url }: PdfViewerProps) {
|
||||
export function PdfViewer({ url, documentId }: PdfViewerProps) {
|
||||
const fileSource = useMemo(() => {
|
||||
if (!url) return null
|
||||
try {
|
||||
if (new URL(url).hostname === "www.googleapis.com" && documentId) {
|
||||
const base =
|
||||
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
|
||||
return {
|
||||
url: `${base}/v3/drive-proxy/${documentId}`,
|
||||
withCredentials: true,
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
return url
|
||||
}, [url, documentId])
|
||||
|
||||
const [numPages, setNumPages] = useState<number | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
|
@ -70,7 +86,7 @@ export function PdfViewer({ url }: PdfViewerProps) {
|
|||
<Document
|
||||
key={retryKey}
|
||||
file={
|
||||
url ||
|
||||
fileSource ||
|
||||
"https://corsproxy.io/?" +
|
||||
encodeURIComponent("http://www.pdf995.com/samples/pdf.pdf")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,18 @@
|
|||
import { useEffect } from "react"
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation"
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
import { Loader2 } from "lucide-react"
|
||||
|
||||
function WorkspaceLoadingShell() {
|
||||
return (
|
||||
<div className="flex min-h-dvh items-center justify-center bg-[#05080D]">
|
||||
<Loader2
|
||||
className="size-6 animate-spin text-white/40"
|
||||
aria-label="Loading workspace"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function EnsureWorkspace({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname()
|
||||
|
|
@ -11,6 +23,7 @@ export function EnsureWorkspace({ children }: { children: React.ReactNode }) {
|
|||
const { session, organizations, isRestoring } = useAuth()
|
||||
|
||||
const isMcpPublicPage = searchParams.get("view") === "mcp"
|
||||
const isOnboarding = pathname.startsWith("/onboarding")
|
||||
|
||||
useEffect(() => {
|
||||
if (isMcpPublicPage) return
|
||||
|
|
@ -23,9 +36,30 @@ export function EnsureWorkspace({ children }: { children: React.ReactNode }) {
|
|||
}
|
||||
if (organizations === null) return
|
||||
if (organizations.length > 0) return
|
||||
if (pathname.startsWith("/onboarding")) return
|
||||
if (isOnboarding) return
|
||||
router.replace("/onboarding")
|
||||
}, [session, organizations, isRestoring, pathname, router, isMcpPublicPage])
|
||||
}, [
|
||||
session,
|
||||
organizations,
|
||||
isRestoring,
|
||||
isOnboarding,
|
||||
router,
|
||||
isMcpPublicPage,
|
||||
])
|
||||
|
||||
const showLoading =
|
||||
!isMcpPublicPage &&
|
||||
(isRestoring ||
|
||||
(!session && !isRestoring) ||
|
||||
(session && organizations === null) ||
|
||||
(session &&
|
||||
organizations !== null &&
|
||||
organizations.length === 0 &&
|
||||
!isOnboarding))
|
||||
|
||||
if (showLoading) {
|
||||
return <WorkspaceLoadingShell />
|
||||
}
|
||||
|
||||
return children
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,7 +46,6 @@ export const GraphLayoutView = memo(function GraphLayoutView({
|
|||
maxNodes={undefined}
|
||||
canvasRef={canvasRef}
|
||||
onOpenDocument={onOpenDocument}
|
||||
autoFetchAll
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -39,14 +39,39 @@ import { FeedbackModal } from "./feedback-modal"
|
|||
import { useViewMode } from "@/lib/view-mode-context"
|
||||
import { useQueryState } from "nuqs"
|
||||
import { feedbackParam } from "@/lib/search-params"
|
||||
import { useCustomer } from "autumn-js/react"
|
||||
import { useTokenUsage } from "@/hooks/use-token-usage"
|
||||
import { useOrgSummaries } from "@/hooks/use-org-summaries"
|
||||
import { OrgPlanBadge, resolveOrgPlan } from "@/components/org-plan-badge"
|
||||
|
||||
interface HeaderProps {
|
||||
onAddMemory?: () => void
|
||||
onOpenSearch?: () => void
|
||||
}
|
||||
|
||||
const brainItemClass = (active: boolean) =>
|
||||
cn(
|
||||
"gap-2.5 rounded-lg px-2 py-1.5 text-sm font-medium cursor-pointer transition-colors",
|
||||
"hover:bg-white/[0.06] focus:bg-white/[0.06] focus:text-white",
|
||||
active ? "bg-white/[0.06] text-white" : "text-white/85",
|
||||
)
|
||||
|
||||
const brainTileClass = (active: boolean) =>
|
||||
cn(
|
||||
"flex size-7 shrink-0 items-center justify-center rounded-lg border text-[11px] font-semibold",
|
||||
active
|
||||
? "border-[#2261CA66] bg-[#0B2A57] text-[#7EB0FF]"
|
||||
: "border-white/[0.08] bg-white/[0.04] text-white/70",
|
||||
)
|
||||
|
||||
export function Header({ onAddMemory, onOpenSearch }: HeaderProps) {
|
||||
const { user, isRestoring } = useAuth()
|
||||
const { user, isRestoring, org, organizations, setActiveOrg } = useAuth()
|
||||
const autumn = useCustomer()
|
||||
const { currentPlan } = useTokenUsage(autumn)
|
||||
const { data: orgSummaries } = useOrgSummaries()
|
||||
const planByOrgId = new Map(
|
||||
(orgSummaries ?? []).map((s) => [s.orgId, s.plan] as const),
|
||||
)
|
||||
const { selectedProjects, setSelectedProjects } = useProject()
|
||||
const router = useRouter()
|
||||
const isMobile = useIsMobile()
|
||||
|
|
@ -66,6 +91,14 @@ export function Header({ onAddMemory, onOpenSearch }: HeaderProps) {
|
|||
user?.email?.split("@")[0] ||
|
||||
""
|
||||
const userName = displayName ? `${displayName.split(" ")[0]}'s` : "My"
|
||||
const orgLabel = org?.name.replace(/\s*organizations?\s*$/i, "").trim()
|
||||
const topLabel = orgLabel || userName
|
||||
const hasOrgs = (organizations?.length ?? 0) > 0
|
||||
|
||||
const selectOrg = (slug: string, isActive: boolean) => {
|
||||
if (isActive) return
|
||||
void setActiveOrg(slug).then(() => window.location.reload())
|
||||
}
|
||||
return (
|
||||
<div className="relative z-10 flex shrink-0 items-center justify-between gap-1.5 p-2.5 md:gap-2 md:p-3">
|
||||
<div className="z-10! flex min-w-0 shrink items-center justify-center gap-1.5 md:gap-3">
|
||||
|
|
@ -73,34 +106,65 @@ export function Header({ onAddMemory, onOpenSearch }: HeaderProps) {
|
|||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
className="relative flex shrink-0 cursor-pointer items-center rounded-lg px-1.5 py-1 transition-colors hover:bg-white/5 outline-none focus-visible:outline-none md:-ml-2 before:absolute before:-inset-x-2 before:-inset-y-2.5 before:content-['']"
|
||||
>
|
||||
<Logo className="h-6 md:h-7" />
|
||||
{!isMobile && userName && (
|
||||
<div className="ml-1.5 flex flex-col items-start justify-center sm:ml-2">
|
||||
<p className="text-[10px] leading-tight text-[#6B6B6B] sm:text-[11px]">
|
||||
{userName}
|
||||
</p>
|
||||
<p className="-mt-0.5 text-base leading-none font-medium text-white/90 sm:text-lg">
|
||||
supermemory
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<Logo className="h-6" />
|
||||
<div className="ml-1.5 flex flex-col items-start justify-center sm:ml-2">
|
||||
<p className="max-w-[16ch] truncate text-[10px] leading-tight text-[#6B6B6B] sm:text-[11px]">
|
||||
{topLabel}
|
||||
</p>
|
||||
<p className="-mt-0.5 text-sm leading-none font-medium text-white/90 sm:text-lg">
|
||||
supermemory
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
alignOffset={12}
|
||||
className={cn(
|
||||
"min-w-[200px] p-1.5 rounded-xl border border-[#2E3033] shadow-[0px_1.5px_20px_0px_rgba(0,0,0,0.65)]",
|
||||
"min-w-[244px] p-1.5 rounded-xl border border-white/[0.08] shadow-[0px_1.5px_20px_0px_rgba(0,0,0,0.65)]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
style={{
|
||||
background: "linear-gradient(180deg, #0A0E14 0%, #05070A 100%)",
|
||||
}}
|
||||
>
|
||||
{hasOrgs && (
|
||||
<>
|
||||
<p className="px-2 pt-1 pb-1.5 text-[10px] font-semibold uppercase tracking-[0.08em] text-[#5B6675]">
|
||||
Switch brain
|
||||
</p>
|
||||
<div className="flex max-h-[40vh] flex-col gap-0.5 overflow-y-auto overscroll-contain">
|
||||
{organizations?.map((o) => {
|
||||
const active = org?.id === o.id
|
||||
const plan = resolveOrgPlan(
|
||||
o.id,
|
||||
active,
|
||||
currentPlan,
|
||||
planByOrgId,
|
||||
)
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={o.id}
|
||||
onClick={() => selectOrg(o.slug, active)}
|
||||
className={cn(brainItemClass(active))}
|
||||
>
|
||||
<span className={cn(brainTileClass(active))}>
|
||||
{o.name?.trim().charAt(0).toUpperCase() || "?"}
|
||||
</span>
|
||||
<span className="flex-1 truncate">{o.name}</span>
|
||||
<OrgPlanBadge plan={plan} />
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<DropdownMenuSeparator className="mx-1 my-1.5 bg-white/[0.06]" />
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
asChild
|
||||
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
|
||||
className="gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium text-white/85 hover:bg-white/[0.06] focus:bg-white/[0.06] focus:text-white cursor-pointer"
|
||||
>
|
||||
<Link href="/">
|
||||
<Home className="size-4 text-[#737373]" />
|
||||
|
|
@ -109,7 +173,7 @@ export function Header({ onAddMemory, onOpenSearch }: HeaderProps) {
|
|||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
asChild
|
||||
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
|
||||
className="gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium text-white/85 hover:bg-white/[0.06] focus:bg-white/[0.06] focus:text-white cursor-pointer"
|
||||
>
|
||||
<a
|
||||
href="https://console.supermemory.ai"
|
||||
|
|
@ -122,7 +186,7 @@ export function Header({ onAddMemory, onOpenSearch }: HeaderProps) {
|
|||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
asChild
|
||||
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
|
||||
className="gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium text-white/85 hover:bg-white/[0.06] focus:bg-white/[0.06] focus:text-white cursor-pointer"
|
||||
>
|
||||
<a href="https://supermemory.ai" target="_blank" rel="noreferrer">
|
||||
<ExternalLink className="size-4 text-[#737373]" />
|
||||
|
|
@ -290,49 +354,49 @@ export function Header({ onAddMemory, onOpenSearch }: HeaderProps) {
|
|||
>
|
||||
<DropdownMenuItem
|
||||
onClick={onAddMemory}
|
||||
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
|
||||
className="gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium text-white/85 hover:bg-white/[0.06] focus:bg-white/[0.06] focus:text-white cursor-pointer"
|
||||
>
|
||||
<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"
|
||||
className="gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium text-white/85 hover:bg-white/[0.06] focus:bg-white/[0.06] focus:text-white cursor-pointer"
|
||||
>
|
||||
<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"
|
||||
className="gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium text-white/85 hover:bg-white/[0.06] focus:bg-white/[0.06] focus:text-white cursor-pointer"
|
||||
>
|
||||
<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"
|
||||
className="gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium text-white/85 hover:bg-white/[0.06] focus:bg-white/[0.06] focus:text-white cursor-pointer"
|
||||
>
|
||||
<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"
|
||||
className="gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium text-white/85 hover:bg-white/[0.06] focus:bg-white/[0.06] focus:text-white cursor-pointer"
|
||||
>
|
||||
<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"
|
||||
className="gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium text-white/85 hover:bg-white/[0.06] focus:bg-white/[0.06] focus:text-white cursor-pointer"
|
||||
>
|
||||
<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"
|
||||
className="gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium text-white/85 hover:bg-white/[0.06] focus:bg-white/[0.06] focus:text-white cursor-pointer"
|
||||
>
|
||||
<MessageCircleIcon className="size-4 text-[#737373]" />
|
||||
Chat with Nova
|
||||
|
|
@ -340,14 +404,14 @@ export function Header({ onAddMemory, onOpenSearch }: HeaderProps) {
|
|||
<DropdownMenuSeparator className="bg-[#2E3033]" />
|
||||
<DropdownMenuItem
|
||||
onClick={handleFeedback}
|
||||
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
|
||||
className="gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium text-white/85 hover:bg-white/[0.06] focus:bg-white/[0.06] focus:text-white cursor-pointer"
|
||||
>
|
||||
<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"
|
||||
className="gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium text-white/85 hover:bg-white/[0.06] focus:bg-white/[0.06] focus:text-white cursor-pointer"
|
||||
>
|
||||
<Settings className="size-4 text-[#737373]" />
|
||||
Settings
|
||||
|
|
@ -444,7 +508,7 @@ export function PublicHeader() {
|
|||
Sign in
|
||||
</button>
|
||||
</Link>
|
||||
<Link href="/login/new">
|
||||
<Link href="/login">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
X,
|
||||
} from "lucide-react"
|
||||
import { Logo } from "@ui/assets/Logo"
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@ui/components/popover"
|
||||
import { analytics } from "@/lib/analytics"
|
||||
|
||||
export type HighlightFormat = "paragraph" | "bullets" | "quote" | "one_liner"
|
||||
|
|
@ -31,6 +32,7 @@ interface HighlightsCardProps {
|
|||
onChat: (highlightContent: string, userReply: string) => void
|
||||
onShowRelated: (query: string) => void
|
||||
isLoading?: boolean
|
||||
onAddMemory?: () => void
|
||||
}
|
||||
|
||||
function renderContent(content: string, format: HighlightFormat) {
|
||||
|
|
@ -68,6 +70,7 @@ export function HighlightsCard({
|
|||
onChat,
|
||||
onShowRelated,
|
||||
isLoading = false,
|
||||
onAddMemory,
|
||||
}: HighlightsCardProps) {
|
||||
const [activeIndex, setActiveIndex] = useState(0)
|
||||
const [isReplyOpen, setIsReplyOpen] = useState(false)
|
||||
|
|
@ -185,10 +188,19 @@ export function HighlightsCard({
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-3">
|
||||
<p className="text-[11px] text-fg-muted text-center">
|
||||
Add some documents to see highlights here
|
||||
</p>
|
||||
{onAddMemory ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAddMemory}
|
||||
className="text-[11px] font-medium text-brand-accent hover:text-brand-accent/80 transition-colors cursor-pointer"
|
||||
>
|
||||
Add memory →
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
@ -213,7 +225,37 @@ export function HighlightsCard({
|
|||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Info className="size-[14px] text-fg-subtle" />
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="About the Daily Brief"
|
||||
className="shrink-0 rounded-full p-0.5 text-fg-subtle transition-colors hover:text-fg-primary focus-visible:outline-none"
|
||||
>
|
||||
<Info className="size-[14px]" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="end"
|
||||
side="bottom"
|
||||
className={cn(
|
||||
"w-64 rounded-xl border border-[#263348]/60 p-3 shadow-[0px_1.5px_20px_0px_rgba(0,0,0,0.65)]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
style={{
|
||||
background: "linear-gradient(180deg, #101822 0%, #0A0E14 100%)",
|
||||
}}
|
||||
>
|
||||
<p className="mb-1 text-[12px] font-semibold text-fg-primary">
|
||||
Daily Brief
|
||||
</p>
|
||||
<p className="text-[12px] leading-relaxed text-fg-subtle">
|
||||
AI-generated highlights and questions drawn from your memories. It
|
||||
refreshes automatically every few hours — tap the refresh icon to
|
||||
update it now.
|
||||
</p>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div id="highlights-body" className="flex flex-col gap-1.5">
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { useRouter } from "next/navigation"
|
|||
import { useOrgOnboarding } from "@hooks/use-org-onboarding"
|
||||
import { analytics } from "@/lib/analytics"
|
||||
import { consumePendingConnectUrl } from "@/lib/constants"
|
||||
import { cn } from "@lib/utils"
|
||||
|
||||
export function InitialHeader({
|
||||
showUserSupermemory,
|
||||
|
|
@ -28,10 +29,10 @@ export function InitialHeader({
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="flex p-6 justify-between items-center">
|
||||
<div className="flex items-center z-10!">
|
||||
<Logo className="h-7" />
|
||||
{showUserSupermemory && (
|
||||
<div className="flex items-center justify-between p-4 sm:p-6">
|
||||
<div className="flex items-center z-10! min-w-0">
|
||||
<Logo className="h-6 sm:h-7 shrink-0" />
|
||||
{showUserSupermemory ? (
|
||||
<div className="flex flex-col items-start justify-center ml-2">
|
||||
<p className="text-[#8B8B8B] text-[11px] leading-tight">
|
||||
{userName}
|
||||
|
|
@ -40,9 +41,13 @@ export function InitialHeader({
|
|||
supermemory
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="ml-2 truncate text-xl leading-none font-semibold text-white/90">
|
||||
supermemory
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 z-10!">
|
||||
<div className="flex items-center gap-2 sm:gap-3 z-10! shrink-0">
|
||||
{showSkipOnboarding && !isLoading && (
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -54,10 +59,21 @@ export function InitialHeader({
|
|||
)}
|
||||
<Button
|
||||
variant="newDefault"
|
||||
className="rounded-2xl text-base gap-1 h-11!"
|
||||
className={cn(
|
||||
"rounded-xl sm:rounded-2xl gap-1 h-9 sm:h-11 px-3 sm:px-4 text-sm sm:text-base",
|
||||
)}
|
||||
size="lg"
|
||||
onClick={() =>
|
||||
window.open(
|
||||
"https://console.supermemory.ai",
|
||||
"_blank",
|
||||
"noopener,noreferrer",
|
||||
)
|
||||
}
|
||||
>
|
||||
Memory API <span className="text-xs mt-[4px]">↗</span>
|
||||
<span className="sm:hidden">API</span>
|
||||
<span className="hidden sm:inline">Memory API</span>{" "}
|
||||
<span className="text-xs mt-[4px]">↗</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
399
apps/web/components/login-tools-panel.tsx
Normal file
399
apps/web/components/login-tools-panel.tsx
Normal file
|
|
@ -0,0 +1,399 @@
|
|||
"use client"
|
||||
|
||||
import Image from "next/image"
|
||||
import { useEffect, useState } from "react"
|
||||
import { motion } from "motion/react"
|
||||
import { cn } from "@lib/utils"
|
||||
import { dmSansClassName } from "@/lib/fonts"
|
||||
import { ChromeIcon, RaycastIcon } from "@/components/integration-icons"
|
||||
import {
|
||||
ClaudeDesktopIcon,
|
||||
GoogleDrive,
|
||||
MCPIcon,
|
||||
Notion,
|
||||
} from "@ui/assets/icons"
|
||||
import { Logo } from "@ui/assets/Logo"
|
||||
import NovaOrb from "@/components/nova/nova-orb"
|
||||
|
||||
type ToolNode = {
|
||||
id: string
|
||||
name: string
|
||||
x: number
|
||||
y: number
|
||||
icon?: React.ComponentType<{ className?: string }>
|
||||
iconSrc?: string
|
||||
}
|
||||
|
||||
type ContextConnection = {
|
||||
from: ToolNode
|
||||
to: ToolNode
|
||||
}
|
||||
|
||||
type ContextPhase = "idle" | "capture" | "hold" | "recall"
|
||||
|
||||
const CENTER = { x: 50, y: 50 }
|
||||
const IN_MS = 1100
|
||||
const HOLD_MS = 700
|
||||
const OUT_MS = 1100
|
||||
const TOTAL_MS = IN_MS + HOLD_MS + OUT_MS
|
||||
|
||||
const TOOL_NODES: ToolNode[] = [
|
||||
{ id: "chrome", name: "Chrome", x: 14, y: 20, icon: ChromeIcon },
|
||||
{ id: "notion", name: "Notion", x: 84, y: 16, icon: Notion },
|
||||
{ id: "drive", name: "Google Drive", x: 10, y: 52, icon: GoogleDrive },
|
||||
{ id: "claude", name: "Claude", x: 90, y: 44, icon: ClaudeDesktopIcon },
|
||||
{ id: "raycast", name: "Raycast", x: 76, y: 76, icon: RaycastIcon },
|
||||
{ id: "mcp", name: "MCP", x: 22, y: 84, icon: MCPIcon },
|
||||
{
|
||||
id: "claude-code",
|
||||
name: "Claude Code",
|
||||
x: 20,
|
||||
y: 36,
|
||||
iconSrc: "/images/plugins/claude-code.svg",
|
||||
},
|
||||
{
|
||||
id: "codex",
|
||||
name: "Codex",
|
||||
x: 92,
|
||||
y: 28,
|
||||
iconSrc: "/images/plugins/codex.png",
|
||||
},
|
||||
{
|
||||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
x: 58,
|
||||
y: 10,
|
||||
iconSrc: "/images/plugins/opencode.svg",
|
||||
},
|
||||
{
|
||||
id: "hermes",
|
||||
name: "Hermes",
|
||||
x: 36,
|
||||
y: 90,
|
||||
iconSrc: "/images/plugins/hermes.svg",
|
||||
},
|
||||
{
|
||||
id: "openclaw",
|
||||
name: "OpenClaw",
|
||||
x: 68,
|
||||
y: 68,
|
||||
iconSrc: "/images/plugins/openclaw.svg",
|
||||
},
|
||||
]
|
||||
|
||||
const CONTEXT_FLOWS: [string, string][] = [
|
||||
["chrome", "claude"],
|
||||
["notion", "raycast"],
|
||||
["drive", "claude-code"],
|
||||
["opencode", "codex"],
|
||||
["claude-code", "mcp"],
|
||||
["hermes", "openclaw"],
|
||||
["claude", "mcp"],
|
||||
["notion", "claude"],
|
||||
]
|
||||
|
||||
function nodeById(id: string) {
|
||||
return TOOL_NODES.find((node) => node.id === id)
|
||||
}
|
||||
|
||||
function pickContextFlow(): ContextConnection | null {
|
||||
const flow = CONTEXT_FLOWS[Math.floor(Math.random() * CONTEXT_FLOWS.length)]
|
||||
if (!flow) return null
|
||||
const [fromId, toId] = flow
|
||||
const from = nodeById(fromId)
|
||||
const to = nodeById(toId)
|
||||
if (!from || !to) return null
|
||||
return { from, to }
|
||||
}
|
||||
|
||||
function ToolNodeIcon({
|
||||
node,
|
||||
role,
|
||||
}: {
|
||||
node: ToolNode
|
||||
role?: "source" | "destination"
|
||||
}) {
|
||||
const Icon = node.icon
|
||||
|
||||
return (
|
||||
<div
|
||||
className="login-tool-node-wrap"
|
||||
style={{ left: `${node.x}%`, top: `${node.y}%` }}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"login-tool-node",
|
||||
role === "source" && "login-tool-node-source",
|
||||
role === "destination" && "login-tool-node-destination",
|
||||
)}
|
||||
title={node.name}
|
||||
>
|
||||
{Icon ? (
|
||||
<Icon className="login-tool-node-icon shrink-0" />
|
||||
) : node.iconSrc ? (
|
||||
<Image
|
||||
src={node.iconSrc}
|
||||
alt=""
|
||||
width={22}
|
||||
height={22}
|
||||
className="login-tool-node-icon shrink-0"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"login-tool-node-label",
|
||||
dmSansClassName(),
|
||||
role && "login-tool-node-label-visible",
|
||||
)}
|
||||
aria-hidden={!role}
|
||||
>
|
||||
{node.name}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MemoryChip() {
|
||||
return (
|
||||
<div className="login-context-chip">
|
||||
<div className="login-context-chip-lines" aria-hidden>
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AnimatedContextFlow({
|
||||
connection,
|
||||
phase,
|
||||
}: {
|
||||
connection: ContextConnection
|
||||
phase: ContextPhase
|
||||
}) {
|
||||
const { from, to } = connection
|
||||
const dIn = `M ${from.x} ${from.y} L ${CENTER.x} ${CENTER.y}`
|
||||
const dOut = `M ${CENTER.x} ${CENTER.y} L ${to.x} ${to.y}`
|
||||
|
||||
const chipLeft =
|
||||
phase === "recall"
|
||||
? [`${CENTER.x}%`, `${to.x}%`]
|
||||
: phase === "hold"
|
||||
? `${CENTER.x}%`
|
||||
: [`${from.x}%`, `${CENTER.x}%`]
|
||||
|
||||
const chipTop =
|
||||
phase === "recall"
|
||||
? [`${CENTER.y}%`, `${to.y}%`]
|
||||
: phase === "hold"
|
||||
? `${CENTER.y}%`
|
||||
: [`${from.y}%`, `${CENTER.y}%`]
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-0 z-[1]">
|
||||
<svg
|
||||
className="absolute inset-0 h-full w-full"
|
||||
viewBox="0 0 100 100"
|
||||
preserveAspectRatio="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d={dIn}
|
||||
fill="none"
|
||||
stroke="rgb(75 160 250 / 0.12)"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
<path
|
||||
d={dOut}
|
||||
fill="none"
|
||||
stroke="rgb(75 160 250 / 0.12)"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
{(phase === "capture" || phase === "hold") && (
|
||||
<motion.path
|
||||
key={`in-${from.id}`}
|
||||
d={dIn}
|
||||
fill="none"
|
||||
stroke="rgb(140 205 255 / 0.75)"
|
||||
strokeWidth="1.75"
|
||||
strokeLinecap="round"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
initial={{ pathLength: 0, opacity: 0 }}
|
||||
animate={{ pathLength: 1, opacity: 1 }}
|
||||
transition={{ duration: IN_MS / 1000, ease: [0.33, 0, 0.2, 1] }}
|
||||
/>
|
||||
)}
|
||||
{phase === "recall" && (
|
||||
<motion.path
|
||||
key={`out-${to.id}`}
|
||||
d={dOut}
|
||||
fill="none"
|
||||
stroke="rgb(160 220 255 / 0.9)"
|
||||
strokeWidth="1.75"
|
||||
strokeLinecap="round"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
initial={{ pathLength: 0, opacity: 0 }}
|
||||
animate={{ pathLength: 1, opacity: 1 }}
|
||||
transition={{ duration: OUT_MS / 1000, ease: [0.33, 0, 0.2, 1] }}
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
|
||||
{phase !== "idle" && (
|
||||
<motion.div
|
||||
className="absolute z-[2] -translate-x-1/2 -translate-y-1/2"
|
||||
initial={false}
|
||||
animate={{
|
||||
left: chipLeft,
|
||||
top: chipTop,
|
||||
opacity: phase === "hold" ? 1 : [1, 1],
|
||||
scale: phase === "hold" ? 1.05 : 1,
|
||||
}}
|
||||
transition={{
|
||||
left: {
|
||||
duration: phase === "recall" ? OUT_MS / 1000 : IN_MS / 1000,
|
||||
ease: [0.35, 0, 0.15, 1],
|
||||
},
|
||||
top: {
|
||||
duration: phase === "recall" ? OUT_MS / 1000 : IN_MS / 1000,
|
||||
ease: [0.35, 0, 0.15, 1],
|
||||
},
|
||||
scale: { duration: 0.3 },
|
||||
}}
|
||||
>
|
||||
<MemoryChip />
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ToolsContextNetwork() {
|
||||
const [connection, setConnection] = useState<ContextConnection | null>(null)
|
||||
const [pulseId, setPulseId] = useState(0)
|
||||
const [phase, setPhase] = useState<ContextPhase>("idle")
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
let pulseTimeout: ReturnType<typeof setTimeout>
|
||||
|
||||
const runPulse = () => {
|
||||
if (cancelled) return
|
||||
const next = pickContextFlow()
|
||||
if (!next) return
|
||||
setConnection(next)
|
||||
setPulseId((id) => id + 1)
|
||||
pulseTimeout = setTimeout(runPulse, TOTAL_MS + 900 + Math.random() * 500)
|
||||
}
|
||||
|
||||
runPulse()
|
||||
return () => {
|
||||
cancelled = true
|
||||
clearTimeout(pulseTimeout)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!connection) return
|
||||
|
||||
setPhase("capture")
|
||||
const holdTimer = setTimeout(() => setPhase("hold"), IN_MS)
|
||||
const recallTimer = setTimeout(() => setPhase("recall"), IN_MS + HOLD_MS)
|
||||
const idleTimer = setTimeout(() => setPhase("idle"), TOTAL_MS)
|
||||
|
||||
return () => {
|
||||
clearTimeout(holdTimer)
|
||||
clearTimeout(recallTimer)
|
||||
clearTimeout(idleTimer)
|
||||
}
|
||||
}, [connection])
|
||||
|
||||
const sourceRole = (nodeId: string) =>
|
||||
connection &&
|
||||
connection.from.id === nodeId &&
|
||||
(phase === "capture" || phase === "hold")
|
||||
? ("source" as const)
|
||||
: undefined
|
||||
|
||||
const destRole = (nodeId: string) =>
|
||||
connection && connection.to.id === nodeId && phase === "recall"
|
||||
? ("destination" as const)
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<div
|
||||
className="login-tools-network relative h-full min-h-[240px] w-full lg:min-h-0"
|
||||
aria-hidden
|
||||
>
|
||||
{connection && phase !== "idle" ? (
|
||||
<AnimatedContextFlow
|
||||
key={pulseId}
|
||||
connection={connection}
|
||||
phase={phase}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{TOOL_NODES.map((node) => (
|
||||
<ToolNodeIcon
|
||||
key={node.id}
|
||||
node={node}
|
||||
role={sourceRole(node.id) ?? destRole(node.id)}
|
||||
/>
|
||||
))}
|
||||
|
||||
<div className="absolute left-1/2 top-1/2 z-[3] -translate-x-1/2 -translate-y-1/2">
|
||||
<motion.div
|
||||
className="relative flex size-24 items-center justify-center sm:size-28 lg:size-36"
|
||||
animate={{ scale: phase === "hold" ? [1, 1.08, 1] : 1 }}
|
||||
transition={{ duration: 0.55, ease: "easeOut" }}
|
||||
>
|
||||
<NovaOrb size={112} className="blur-[2px]!" />
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<Logo className="size-6 opacity-80 sm:size-7 lg:size-8" />
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LoginPanelBackground() {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 bg-[#030912]"
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="login-panel-orb pointer-events-none" aria-hidden />
|
||||
<div className="login-panel-orb-image" aria-hidden />
|
||||
<div className="login-panel-orb-image-alt" aria-hidden />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function LoginToolsPanel() {
|
||||
return (
|
||||
<aside className="relative hidden min-h-0 flex-col overflow-hidden border-white/[0.06] lg:col-start-1 lg:row-start-1 lg:flex lg:h-full lg:border-r">
|
||||
<LoginPanelBackground />
|
||||
|
||||
<div className="login-tools-panel-inner relative z-10 flex flex-col justify-center px-4 py-8 sm:px-8 lg:px-10">
|
||||
<ToolsContextNetwork />
|
||||
</div>
|
||||
|
||||
<p
|
||||
className={cn(
|
||||
"relative z-10 shrink-0 px-4 pb-4 text-center text-[11px] leading-snug text-white/40 sm:px-8 sm:text-xs lg:absolute lg:bottom-6 lg:left-1/2 lg:max-w-none lg:-translate-x-1/2 lg:px-0 lg:pb-0 lg:text-sm lg:whitespace-nowrap lg:text-white/45",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
One memory layer — context from any tool, everywhere you need it.
|
||||
</p>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
|
@ -69,52 +69,76 @@ export function ClaudeDesktopManualTimeline({
|
|||
}: ClaudeDesktopManualTimelineProps) {
|
||||
const isDetail = variant === "detail"
|
||||
|
||||
const textMuted = isDetail ? "text-[#B0B0B0]" : "text-muted-foreground"
|
||||
const border = isDetail ? "border-[#3D434D]" : "border-border"
|
||||
const spine = isDetail ? "bg-[#7C8C9E]" : "bg-muted-foreground/40"
|
||||
const textMuted = isDetail ? "text-[#A1A1AA]" : "text-muted-foreground"
|
||||
const border = isDetail ? "border-white/[0.07]" : "border-border"
|
||||
const spine = isDetail ? "bg-white/[0.14]" : "bg-muted-foreground/40"
|
||||
const circleBorder = isDetail
|
||||
? "border-[#9CA8B8]"
|
||||
? "border-white/[0.12]"
|
||||
: "border-muted-foreground/50"
|
||||
const circleBg = isDetail ? "bg-[#080B0F]" : "bg-background"
|
||||
const codeBg = isDetail ? "bg-[#050608]" : "bg-muted"
|
||||
const imgBorder = isDetail ? "border-[#3D434D]" : "border-border"
|
||||
const screenshotBed = isDetail ? "bg-[#2A2D35]" : "bg-muted"
|
||||
const circleBg = isDetail ? "bg-[#0D121A]" : "bg-background"
|
||||
const codeBg = isDetail ? "bg-[#0B0E13]" : "bg-muted"
|
||||
const imgBorder = isDetail ? "border-white/[0.07]" : "border-border"
|
||||
const screenshotBed = isDetail ? "bg-[#0B0E13]" : "bg-muted"
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"space-y-0",
|
||||
isDetail
|
||||
? "rounded-2xl border border-[#3D434D] bg-[#080B0F] p-5 sm:p-6"
|
||||
? ""
|
||||
: "rounded-xl border border-border bg-muted/50 p-4 sm:p-5",
|
||||
)}
|
||||
>
|
||||
<div className="relative">
|
||||
<div
|
||||
className={cn(
|
||||
"absolute top-4.5 bottom-4.5 left-4.5 w-0.5 rounded-full",
|
||||
isDetail
|
||||
? "absolute top-3 bottom-3 left-[10.5px] w-px"
|
||||
: "absolute top-4.5 bottom-4.5 left-4.5 w-0.5 rounded-full",
|
||||
spine,
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
|
||||
<ol className="relative m-0 list-none space-y-10">
|
||||
<ol className="relative m-0 list-none space-y-7">
|
||||
{STEPS.map((item) => (
|
||||
<li className="relative flex gap-4 sm:gap-5" key={item.step}>
|
||||
<div className="relative z-1 flex w-9 shrink-0 justify-center pt-0.5">
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-9 items-center justify-center rounded-full border-2 text-sm font-semibold tabular-nums shadow-sm",
|
||||
circleBorder,
|
||||
circleBg,
|
||||
isDetail ? "text-[#FAFAFA]" : "text-foreground",
|
||||
)}
|
||||
>
|
||||
{item.step}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"relative z-1 flex shrink-0 justify-center pt-0.5",
|
||||
isDetail ? "w-[22px]" : "w-9",
|
||||
)}
|
||||
>
|
||||
{isDetail ? (
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-[22px] items-center justify-center rounded-full bg-[#0D121A] text-[11px] font-semibold tabular-nums text-[#4BA0FA]",
|
||||
"shadow-[inset_0_2px_4px_rgba(0,0,0,0.3),inset_0_1px_2px_rgba(0,0,0,0.1)]",
|
||||
)}
|
||||
>
|
||||
{item.step}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-9 items-center justify-center rounded-full border-2 text-sm font-semibold tabular-nums shadow-sm",
|
||||
circleBorder,
|
||||
circleBg,
|
||||
"text-foreground",
|
||||
)}
|
||||
>
|
||||
{item.step}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 space-y-3">
|
||||
<p className={cn("text-sm leading-relaxed", textMuted)}>
|
||||
<p
|
||||
className={cn(
|
||||
isDetail
|
||||
? "text-[13px] leading-relaxed text-[#A1A1AA]"
|
||||
: cn("text-sm leading-relaxed", textMuted),
|
||||
)}
|
||||
>
|
||||
{item.body}
|
||||
</p>
|
||||
|
||||
|
|
@ -123,14 +147,14 @@ export function ClaudeDesktopManualTimeline({
|
|||
<div className="relative">
|
||||
<pre
|
||||
className={cn(
|
||||
"max-w-full overflow-x-auto rounded-lg border p-4 pr-12 text-xs",
|
||||
"max-w-full overflow-x-auto rounded-[10px] border p-4 pr-12 text-xs",
|
||||
border,
|
||||
codeBg,
|
||||
)}
|
||||
>
|
||||
<code
|
||||
className={cn(
|
||||
"block font-mono whitespace-pre-wrap break-all text-emerald-400",
|
||||
"block font-mono whitespace-pre-wrap break-all text-[#E4E4E7]",
|
||||
dmMonoClassName(),
|
||||
)}
|
||||
>
|
||||
|
|
@ -139,13 +163,13 @@ export function ClaudeDesktopManualTimeline({
|
|||
</pre>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute top-2 right-2 flex size-8 cursor-pointer items-center justify-center rounded-md border border-[#3D434D] bg-[#121820] hover:bg-[#1a2230]"
|
||||
className="absolute top-2 right-2 flex size-7 cursor-pointer items-center justify-center rounded-full bg-[#0D121A] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.6)] transition-opacity hover:opacity-80"
|
||||
onClick={onCopySnippet}
|
||||
>
|
||||
{snippetCopied ? (
|
||||
<span className="text-xs text-emerald-500">✓</span>
|
||||
<CheckIcon className="size-3.5 text-[#4BA0FA]" />
|
||||
) : (
|
||||
<CopyIcon className="size-3.5 text-[#8B8B8B] hover:text-white" />
|
||||
<CopyIcon className="size-3.5 text-[#737373]" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -30,6 +30,67 @@ import {
|
|||
getManualInstallEntry,
|
||||
} from "@/lib/mcp-manual-instructions"
|
||||
import { ClaudeDesktopManualTimeline } from "@/components/mcp-modal/claude-desktop-manual-timeline"
|
||||
import { INSET } from "@/components/integrations/install-steps"
|
||||
|
||||
function McpCodeBlock({
|
||||
code,
|
||||
multiline,
|
||||
onCopy,
|
||||
}: {
|
||||
code: string
|
||||
multiline?: boolean
|
||||
onCopy?: () => void
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
const copy = () => {
|
||||
navigator.clipboard.writeText(code)
|
||||
setCopied(true)
|
||||
toast.success("Copied to clipboard!")
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
onCopy?.()
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group flex min-w-0 gap-2 rounded-[10px] border border-white/[0.07] bg-[#0B0E13] px-3 py-2.5",
|
||||
multiline ? "items-start" : "items-center",
|
||||
)}
|
||||
>
|
||||
<div className="relative min-w-0 flex-1">
|
||||
<pre
|
||||
className={cn(
|
||||
"scrollbar-none min-w-0 overflow-x-auto font-mono text-[12px] leading-[1.6] text-[#E4E4E7]",
|
||||
multiline ? "whitespace-pre-wrap break-all" : "whitespace-pre pr-4",
|
||||
dmMonoClassName(),
|
||||
)}
|
||||
>
|
||||
{code}
|
||||
</pre>
|
||||
{!multiline && (
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-y-0 right-0 w-8 bg-gradient-to-l from-[#0B0E13] to-transparent"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Copy"
|
||||
onClick={copy}
|
||||
className={cn(
|
||||
"flex size-7 shrink-0 items-center justify-center rounded-full bg-[#0D121A] transition-opacity hover:opacity-80",
|
||||
INSET,
|
||||
)}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="size-3.5 text-[#4BA0FA]" />
|
||||
) : (
|
||||
<CopyIcon className="size-3.5 text-[#737373]" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const clients = {
|
||||
chatgpt: "ChatGPT",
|
||||
|
|
@ -101,7 +162,8 @@ function ClientToolIcon({
|
|||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"size-10 shrink-0 rounded-[10px] bg-[#0D121A] border border-[#242A33] flex items-center justify-center overflow-hidden",
|
||||
"size-10 shrink-0 rounded-[10px] bg-[#080B0F] flex items-center justify-center overflow-hidden",
|
||||
"shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.6)]",
|
||||
)}
|
||||
>
|
||||
{failed ? (
|
||||
|
|
@ -137,7 +199,6 @@ export function MCPSteps({ variant = "full" }: MCPStepsProps) {
|
|||
"mcpTab",
|
||||
parseAsStringLiteral(["oneClick", "manual"] as const).withDefault("manual"),
|
||||
)
|
||||
const [isCommandCopied, setIsCommandCopied] = useState(false)
|
||||
const [isManualCopied, setIsManualCopied] = useState(false)
|
||||
const [activeStep, setActiveStep] = useQueryState(
|
||||
"mcpStep",
|
||||
|
|
@ -170,15 +231,6 @@ export function MCPSteps({ variant = "full" }: MCPStepsProps) {
|
|||
return command
|
||||
}
|
||||
|
||||
const copyToClipboard = () => {
|
||||
const command = generateInstallCommand()
|
||||
navigator.clipboard.writeText(command)
|
||||
analytics.mcpInstallCmdCopied()
|
||||
setIsCommandCopied(true)
|
||||
setActiveStep(3)
|
||||
setTimeout(() => setIsCommandCopied(false), 2000)
|
||||
}
|
||||
|
||||
const copyManualSnippet = (text: string) => {
|
||||
navigator.clipboard.writeText(text)
|
||||
analytics.mcpInstallCmdCopied()
|
||||
|
|
@ -203,17 +255,6 @@ export function MCPSteps({ variant = "full" }: MCPStepsProps) {
|
|||
? "manual"
|
||||
: setupTab
|
||||
|
||||
const gradientStep3 =
|
||||
activeStep === 3
|
||||
? {
|
||||
background:
|
||||
"linear-gradient(94deg, #369BFD 4.8%, #36FDFD 77.04%, #36FDB5 143.99%)",
|
||||
backgroundClip: "text",
|
||||
WebkitBackgroundClip: "text",
|
||||
WebkitTextFillColor: "transparent",
|
||||
}
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
|
|
@ -228,11 +269,11 @@ export function MCPSteps({ variant = "full" }: MCPStepsProps) {
|
|||
key={category.label}
|
||||
className={cn(
|
||||
"flex flex-col gap-4 py-5 sm:flex-row sm:items-start sm:gap-8",
|
||||
rowIndex > 0 && "border-t border-[#242A33]",
|
||||
rowIndex > 0 && "border-t border-white/[0.06]",
|
||||
)}
|
||||
>
|
||||
<div className="w-full shrink-0 sm:w-30">
|
||||
<p className="text-[13px] font-semibold tracking-[-0.01em] text-[#8B8B8B]">
|
||||
<p className="text-[13px] font-semibold tracking-[-0.01em] text-[#A1A1AA]">
|
||||
{category.label}
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -249,9 +290,9 @@ export function MCPSteps({ variant = "full" }: MCPStepsProps) {
|
|||
setActiveStep(2)
|
||||
}}
|
||||
className={cn(
|
||||
"group flex w-full min-w-0 items-center gap-3 rounded-xl border border-[#242A33] bg-[#080B0F] p-3.5 text-left transition-colors",
|
||||
"hover:border-[#3273FC4D] hover:bg-[#08142D]",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#3273FC]/40",
|
||||
"group flex w-full min-w-0 items-center gap-3 rounded-[12px] bg-[#0D121A] p-3.5 text-left transition-colors",
|
||||
"shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.5)] hover:bg-[#10151D]",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4BA0FA]/40",
|
||||
)}
|
||||
>
|
||||
<ClientToolIcon clientKey={item.key} title={item.title} />
|
||||
|
|
@ -259,10 +300,12 @@ export function MCPSteps({ variant = "full" }: MCPStepsProps) {
|
|||
<p className="text-sm font-medium text-[#FAFAFA]">
|
||||
{item.title}
|
||||
</p>
|
||||
<p className="text-xs text-[#737373]">{item.subtitle}</p>
|
||||
<p className="text-[12px] text-[#737373]">
|
||||
{item.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronRight
|
||||
className="size-4 shrink-0 text-[#525866] transition-transform group-hover:translate-x-0.5"
|
||||
className="size-4 shrink-0 text-[#525D6E] transition-transform group-hover:translate-x-0.5"
|
||||
aria-hidden
|
||||
/>
|
||||
</button>
|
||||
|
|
@ -270,51 +313,63 @@ export function MCPSteps({ variant = "full" }: MCPStepsProps) {
|
|||
</div>
|
||||
</div>
|
||||
))}
|
||||
<p className="pt-1 text-sm text-[#8B8B8B]">
|
||||
<p className="pt-1 text-[13px] text-[#A1A1AA]">
|
||||
You can connect several clients; setup is different for each one.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className={cn("space-y-4", dmSansClassName())}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedClient(null)
|
||||
setActiveStep(1)
|
||||
}}
|
||||
className="text-sm font-medium text-[#8B8B8B] transition-colors hover:text-[#FAFAFA]"
|
||||
{!isEmbedded && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedClient(null)
|
||||
setActiveStep(1)
|
||||
}}
|
||||
className="text-sm font-medium text-[#A1A1AA] transition-colors hover:text-[#FAFAFA]"
|
||||
>
|
||||
← All clients
|
||||
</button>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<ClientToolIcon
|
||||
clientKey={selectedKey}
|
||||
title={clients[selectedKey]}
|
||||
/>
|
||||
<div>
|
||||
<p className="text-lg font-medium text-[#FAFAFA]">
|
||||
{clients[selectedKey]}
|
||||
</p>
|
||||
<p className="text-sm text-[#737373]">
|
||||
{detailSetup ? setupInstructionsSubtitle(detailSetup) : ""}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"space-y-4",
|
||||
!isEmbedded && "border-t border-white/[0.06] pt-8",
|
||||
)}
|
||||
>
|
||||
← All clients
|
||||
</button>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<ClientToolIcon
|
||||
clientKey={selectedKey}
|
||||
title={clients[selectedKey]}
|
||||
/>
|
||||
<div>
|
||||
<p className="text-lg font-medium text-[#FAFAFA]">
|
||||
{clients[selectedKey]}
|
||||
</p>
|
||||
<p className="text-sm text-[#737373]">
|
||||
{detailSetup ? setupInstructionsSubtitle(detailSetup) : ""}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 border-t border-[#242A33] pt-8">
|
||||
{detailSetup && mcpClientSetupShowsTabs(detailSetup) ? (
|
||||
<div
|
||||
className="flex w-full max-w-md flex-row gap-1 rounded-full border border-[#3D434D] bg-[#0D121A] p-1"
|
||||
className={cn(
|
||||
"flex w-full flex-row gap-0.5 rounded-full bg-[#0D121A] p-0.5",
|
||||
"shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.5)]",
|
||||
)}
|
||||
role="tablist"
|
||||
aria-label="Setup method"
|
||||
>
|
||||
<button
|
||||
className={cn(
|
||||
"min-h-9 flex-1 rounded-full px-3 py-2 text-center text-xs font-medium transition-all",
|
||||
"min-h-8 flex-1 rounded-full px-3 text-center text-[12px] font-medium transition-colors",
|
||||
effectiveSetupTab === "manual"
|
||||
? "border border-[#3D434D] bg-[#080B0F] text-white"
|
||||
: "text-[#8B8B8B] hover:text-white",
|
||||
? "bg-white/[0.10] text-[#FAFAFA]"
|
||||
: "text-[#A1A1AA] hover:text-[#FAFAFA]",
|
||||
)}
|
||||
onClick={() => setSetupTab("manual")}
|
||||
role="tab"
|
||||
|
|
@ -325,10 +380,10 @@ export function MCPSteps({ variant = "full" }: MCPStepsProps) {
|
|||
</button>
|
||||
<button
|
||||
className={cn(
|
||||
"min-h-9 flex-1 rounded-full px-3 py-2 text-center text-xs font-medium transition-all",
|
||||
"min-h-8 flex-1 rounded-full px-3 text-center text-[12px] font-medium transition-colors",
|
||||
effectiveSetupTab === "oneClick"
|
||||
? "border border-[#3D434D] bg-[#080B0F] text-white"
|
||||
: "text-[#8B8B8B] hover:text-white",
|
||||
? "bg-white/[0.10] text-[#FAFAFA]"
|
||||
: "text-[#A1A1AA] hover:text-[#FAFAFA]",
|
||||
)}
|
||||
onClick={() => setSetupTab("oneClick")}
|
||||
role="tab"
|
||||
|
|
@ -345,47 +400,31 @@ export function MCPSteps({ variant = "full" }: MCPStepsProps) {
|
|||
<>
|
||||
{selectedClient === "mcp-url" && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-[#8B8B8B]">
|
||||
<p className="text-[13px] leading-relaxed text-[#A1A1AA]">
|
||||
Paste this URL into any MCP client that supports remote
|
||||
servers over HTTPS.
|
||||
</p>
|
||||
<div className="relative">
|
||||
<input
|
||||
className={cn(
|
||||
"w-full rounded-lg border border-[#3D434D] bg-black p-2 pr-10 font-mono text-xs text-emerald-400",
|
||||
dmMonoClassName(),
|
||||
)}
|
||||
readOnly
|
||||
value="https://mcp.supermemory.ai/mcp"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute top-1 right-1 cursor-pointer p-1"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(
|
||||
"https://mcp.supermemory.ai/mcp",
|
||||
)
|
||||
analytics.mcpInstallCmdCopied()
|
||||
toast.success("Copied to clipboard!")
|
||||
setActiveStep(3)
|
||||
}}
|
||||
>
|
||||
<CopyIcon className="size-4 text-[#8B8B8B] hover:text-white" />
|
||||
</button>
|
||||
</div>
|
||||
<McpCodeBlock
|
||||
code="https://mcp.supermemory.ai/mcp"
|
||||
onCopy={() => {
|
||||
analytics.mcpInstallCmdCopied()
|
||||
toast.success("Copied to clipboard!")
|
||||
setActiveStep(3)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{selectedClient === "cursor" && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-[#8B8B8B]">
|
||||
<p className="text-[13px] leading-relaxed text-[#A1A1AA]">
|
||||
Open the link below to add supermemory in Cursor, or use
|
||||
Manual instructions to edit{" "}
|
||||
<code className="text-xs text-emerald-400/90">
|
||||
<code className="font-mono text-[12px] text-[#8BC6FF]">
|
||||
~/.cursor/mcp.json
|
||||
</code>
|
||||
.
|
||||
</p>
|
||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-[#1e3a2f] bg-[#0a1510] p-6">
|
||||
<div className="flex flex-col items-center gap-4 rounded-[10px] border border-white/[0.07] bg-[#0B0E13] p-6">
|
||||
<a
|
||||
href={getCursorDeeplink()}
|
||||
onClick={() => {
|
||||
|
|
@ -411,50 +450,18 @@ export function MCPSteps({ variant = "full" }: MCPStepsProps) {
|
|||
{selectedClient !== "mcp-url" &&
|
||||
selectedClient !== "cursor" && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-[#8B8B8B]">
|
||||
<p className="text-[13px] leading-relaxed text-[#A1A1AA]">
|
||||
Run this command in your terminal. It installs the MCP
|
||||
for {clients[selectedKey]} and starts OAuth when
|
||||
needed.
|
||||
</p>
|
||||
<div className="relative">
|
||||
<input
|
||||
className={cn(
|
||||
"w-full overflow-hidden text-ellipsis whitespace-nowrap rounded-xl py-4 pr-28 pl-3 text-xs text-white",
|
||||
dmMonoClassName(),
|
||||
)}
|
||||
style={{
|
||||
border: "1px solid rgba(61, 67, 77, 0.10)",
|
||||
background: "#0D121A",
|
||||
}}
|
||||
readOnly
|
||||
value={generateInstallCommand()}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"absolute top-[5px] right-1 flex cursor-pointer items-center gap-2 rounded-[10px] px-3 py-2",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(180deg, #267BF1 40.23%, #15468B 100%), linear-gradient(180deg, #0D121A -26.14%, #000 100%)",
|
||||
border: "1px solid #000",
|
||||
}}
|
||||
onClick={copyToClipboard}
|
||||
>
|
||||
{isCommandCopied ? (
|
||||
<>
|
||||
<Check className="size-4 text-white" />
|
||||
<span className="text-white">Copied</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CopyIcon className="size-5 text-white stroke-[2px]" />
|
||||
<span className="text-white">Copy</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<McpCodeBlock
|
||||
code={generateInstallCommand()}
|
||||
onCopy={() => {
|
||||
analytics.mcpInstallCmdCopied()
|
||||
setActiveStep(3)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
|
@ -464,7 +471,7 @@ export function MCPSteps({ variant = "full" }: MCPStepsProps) {
|
|||
if (manual.kind === "chatgpt") {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<ol className="list-decimal space-y-2 pl-5 text-sm text-[#8B8B8B]">
|
||||
<ol className="list-decimal space-y-2 pl-5 text-[13px] leading-relaxed text-[#A1A1AA]">
|
||||
<li>Open ChatGPT in your browser.</li>
|
||||
<li>
|
||||
Go to Settings → Apps → Advanced settings → enable
|
||||
|
|
@ -478,27 +485,15 @@ export function MCPSteps({ variant = "full" }: MCPStepsProps) {
|
|||
Paste the URL below and complete OAuth in ChatGPT.
|
||||
</li>
|
||||
</ol>
|
||||
<div className="relative">
|
||||
<input
|
||||
className={cn(
|
||||
"w-full rounded-lg border border-[#3D434D] bg-black p-2 pr-10 font-mono text-xs text-emerald-400",
|
||||
dmMonoClassName(),
|
||||
)}
|
||||
readOnly
|
||||
value={CHATGPT_REMOTE_MCP_URL}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute top-1 right-1 cursor-pointer p-1"
|
||||
onClick={() =>
|
||||
copyManualSnippet(CHATGPT_REMOTE_MCP_URL)
|
||||
}
|
||||
>
|
||||
<CopyIcon className="size-4 text-[#8B8B8B] hover:text-white" />
|
||||
</button>
|
||||
</div>
|
||||
<McpCodeBlock
|
||||
code={CHATGPT_REMOTE_MCP_URL}
|
||||
onCopy={() => {
|
||||
analytics.mcpInstallCmdCopied()
|
||||
setActiveStep(3)
|
||||
}}
|
||||
/>
|
||||
<a
|
||||
className="inline-block text-sm text-[#4BA0FA] underline hover:text-[#6BB8FF]"
|
||||
className="inline-block text-[13px] text-[#4BA0FA] underline hover:text-[#8BC6FF]"
|
||||
href="https://developers.openai.com/api/docs/guides/developer-mode/"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
|
|
@ -523,36 +518,21 @@ export function MCPSteps({ variant = "full" }: MCPStepsProps) {
|
|||
const snippet = buildMcpUrlRemoteJson("your-api-key-here")
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-[#8B8B8B]">
|
||||
<p className="text-[13px] leading-relaxed text-[#A1A1AA]">
|
||||
Add this to your client's MCP config. Replace the
|
||||
placeholder with an API key from supermemory settings
|
||||
(Integrations).
|
||||
</p>
|
||||
<div className="relative">
|
||||
<pre className="max-w-full overflow-x-auto rounded-lg border border-[#3D434D] bg-black p-4 pr-12 text-xs">
|
||||
<code
|
||||
className={cn(
|
||||
"block font-mono whitespace-pre-wrap break-all text-emerald-400",
|
||||
dmMonoClassName(),
|
||||
)}
|
||||
>
|
||||
{snippet}
|
||||
</code>
|
||||
</pre>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute top-2 right-2 flex size-8 cursor-pointer items-center justify-center rounded bg-[#0D121A] hover:bg-[#1a1a1a]"
|
||||
onClick={() => copyManualSnippet(snippet)}
|
||||
>
|
||||
{isManualCopied ? (
|
||||
<span className="text-emerald-500">✓</span>
|
||||
) : (
|
||||
<CopyIcon className="size-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<McpCodeBlock
|
||||
code={snippet}
|
||||
multiline
|
||||
onCopy={() => {
|
||||
analytics.mcpInstallCmdCopied()
|
||||
setActiveStep(3)
|
||||
}}
|
||||
/>
|
||||
{detailSetup?.oneClick ? (
|
||||
<p className="text-xs text-[#737373]">
|
||||
<p className="text-[12px] text-[#737373]">
|
||||
Use Bearer auth in headers, or switch to One click
|
||||
setup and paste the HTTPS URL if your client
|
||||
supports OAuth only.
|
||||
|
|
@ -563,35 +543,22 @@ export function MCPSteps({ variant = "full" }: MCPStepsProps) {
|
|||
}
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-[#8B8B8B]">{manual.paths}</p>
|
||||
<p className="text-sm text-[#8B8B8B]">
|
||||
<p className="text-[13px] leading-relaxed text-[#A1A1AA]">
|
||||
{manual.paths}
|
||||
</p>
|
||||
<p className="text-[13px] leading-relaxed text-[#A1A1AA]">
|
||||
Merge the snippet with your existing file (create it if
|
||||
needed). Restart the client and complete OAuth when
|
||||
prompted.
|
||||
</p>
|
||||
<div className="relative">
|
||||
<pre className="max-w-full overflow-x-auto rounded-lg border border-[#3D434D] bg-black p-4 pr-12 text-xs">
|
||||
<code
|
||||
className={cn(
|
||||
"block font-mono whitespace-pre-wrap break-all text-emerald-400",
|
||||
dmMonoClassName(),
|
||||
)}
|
||||
>
|
||||
{manual.snippet}
|
||||
</code>
|
||||
</pre>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute top-2 right-2 flex size-8 cursor-pointer items-center justify-center rounded bg-[#0D121A] hover:bg-[#1a1a1a]"
|
||||
onClick={() => copyManualSnippet(manual.snippet)}
|
||||
>
|
||||
{isManualCopied ? (
|
||||
<span className="text-emerald-500">✓</span>
|
||||
) : (
|
||||
<CopyIcon className="size-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<McpCodeBlock
|
||||
code={manual.snippet}
|
||||
multiline
|
||||
onCopy={() => {
|
||||
analytics.mcpInstallCmdCopied()
|
||||
setActiveStep(3)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})()
|
||||
|
|
@ -599,47 +566,47 @@ export function MCPSteps({ variant = "full" }: MCPStepsProps) {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-[#242A33] pt-8">
|
||||
<h3
|
||||
className="mb-4 text-lg font-medium text-white"
|
||||
style={gradientStep3}
|
||||
>
|
||||
{detailSetup != null &&
|
||||
mcpClientShowsManual(detailSetup, effectiveSetupTab)
|
||||
? selectedClient === "chatgpt"
|
||||
? "Finish in ChatGPT"
|
||||
: selectedClient === "claude"
|
||||
? "Finish in Claude Desktop"
|
||||
: "Save and restart"
|
||||
: selectedClient === "cursor"
|
||||
? "Finish in Cursor"
|
||||
: "Run in terminal"}
|
||||
</h3>
|
||||
{activeStep === 3 && (
|
||||
<p
|
||||
{!isEmbedded && (
|
||||
<div className="border-t border-white/[0.06] pt-6">
|
||||
<h3
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-xl p-4 pl-3 font-mono text-xs text-white",
|
||||
dmMonoClassName(),
|
||||
dmSansClassName(),
|
||||
"mb-3 text-[15px] font-semibold tracking-[-0.01em] text-[#FAFAFA]",
|
||||
)}
|
||||
style={{
|
||||
border: "1px solid rgba(61, 67, 77, 0.10)",
|
||||
background: "#0D121A",
|
||||
}}
|
||||
>
|
||||
<SyncLogoIcon className="size-4" />
|
||||
{detailSetup != null &&
|
||||
mcpClientShowsManual(detailSetup, effectiveSetupTab)
|
||||
? selectedClient === "chatgpt"
|
||||
? "Complete app setup in ChatGPT, then enable it for your chat."
|
||||
? "Finish in ChatGPT"
|
||||
: selectedClient === "claude"
|
||||
? "After Connectors, complete any prompts so supermemory is enabled for chat."
|
||||
: "Save your config, restart the client, and sign in if prompted."
|
||||
? "Finish in Claude Desktop"
|
||||
: "Save and restart"
|
||||
: selectedClient === "cursor"
|
||||
? "Complete the install in Cursor and sign in with OAuth if asked."
|
||||
: "Waiting for installation"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
? "Finish in Cursor"
|
||||
: "Run in terminal"}
|
||||
</h3>
|
||||
{activeStep === 3 && (
|
||||
<p
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"flex items-center gap-2 rounded-[10px] border border-white/[0.07] bg-[#0B0E13] p-3 text-[12px] text-[#A1A1AA]",
|
||||
)}
|
||||
>
|
||||
<SyncLogoIcon className="size-4 shrink-0 text-[#4BA0FA]" />
|
||||
{detailSetup != null &&
|
||||
mcpClientShowsManual(detailSetup, effectiveSetupTab)
|
||||
? selectedClient === "chatgpt"
|
||||
? "Complete app setup in ChatGPT, then enable it for your chat."
|
||||
: selectedClient === "claude"
|
||||
? "After Connectors, complete any prompts so supermemory is enabled for chat."
|
||||
: "Save your config, restart the client, and sign in if prompted."
|
||||
: selectedClient === "cursor"
|
||||
? "Complete the install in Cursor and sign in with OAuth if asked."
|
||||
: "Waiting for installation"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
|
|||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query"
|
||||
import { useCallback, memo, useMemo, useState, useRef, useEffect } from "react"
|
||||
import { useQueryState } from "nuqs"
|
||||
import { AnimatePresence } from "motion/react"
|
||||
import type { z } from "zod"
|
||||
import { Masonry, useInfiniteLoader } from "masonic"
|
||||
import { dmSansClassName } from "@/lib/fonts"
|
||||
|
|
@ -54,11 +55,21 @@ import {
|
|||
CheckIcon,
|
||||
LayoutGrid,
|
||||
Loader,
|
||||
MoreHorizontal,
|
||||
Trash2Icon,
|
||||
UserRound,
|
||||
XIcon,
|
||||
} from "lucide-react"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@ui/components/dropdown-menu"
|
||||
import { useProcessingDocuments } from "@/hooks/use-processing-documents"
|
||||
import { TimelineView } from "./timeline-view"
|
||||
import { SpaceProfilePanel } from "@/components/space-profile-panel"
|
||||
import { SpaceProfileModal } from "@/components/space-profile-modal"
|
||||
|
||||
// Document category type
|
||||
type DocumentCategory =
|
||||
|
|
@ -249,6 +260,7 @@ export function MemoriesGrid({
|
|||
emptyStateProps,
|
||||
}: MemoriesGridProps) {
|
||||
const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false)
|
||||
const [profileOpen, setProfileOpen] = useState(false)
|
||||
const [localViewMode, setLocalViewMode] = useState<"grid" | "timeline">(
|
||||
() => {
|
||||
if (typeof window === "undefined") return "grid"
|
||||
|
|
@ -259,7 +271,7 @@ export function MemoriesGrid({
|
|||
},
|
||||
)
|
||||
const { user, isSessionPending } = useAuth()
|
||||
const { effectiveContainerTags } = useProject()
|
||||
const { effectiveContainerTags, selectedProject } = useProject()
|
||||
const processingStatusMap = useProcessingDocuments()
|
||||
const isMobile = useIsMobile()
|
||||
const [selectedCategories, setSelectedCategories] = useQueryState(
|
||||
|
|
@ -347,6 +359,14 @@ export function MemoriesGrid({
|
|||
localStorage.setItem("memories-view-mode", mode)
|
||||
}, [])
|
||||
|
||||
const handleToggleProfile = useCallback(() => {
|
||||
if (isMobile) {
|
||||
setProfileOpen(true)
|
||||
return
|
||||
}
|
||||
setProfileOpen((open) => !open)
|
||||
}, [isMobile])
|
||||
|
||||
const handleCategoryToggle = useCallback(
|
||||
(category: DocumentCategory) => {
|
||||
setSelectedCategories((prev) => {
|
||||
|
|
@ -394,11 +414,14 @@ export function MemoriesGrid({
|
|||
return items
|
||||
}, [documents, isMobile, hasQuickNote, isSelectionMode, selectedDocumentIds])
|
||||
|
||||
// Stable key for Masonry based on document IDs, not item values
|
||||
// Reset Masonry when the actual rendered item set changes. Masonic caches
|
||||
// positions by index, so mobile removing the quick note must remount it.
|
||||
const masonryKey = useMemo(() => {
|
||||
const docIds = documents.map((d) => d.id).join(",")
|
||||
return `masonry-${documents.length}-${docIds}-${isChatOpen}-${hasQuickNote}`
|
||||
}, [documents, isChatOpen, hasQuickNote])
|
||||
const itemIds = masonryItems.map((item) => item.id).join(",")
|
||||
return `masonry-${isMobile ? "mobile" : "desktop"}-${masonryItems.length}-${itemIds}-${isChatOpen}`
|
||||
}, [masonryItems, isChatOpen, isMobile])
|
||||
|
||||
const getMasonryItemKey = useCallback((item: MasonryItem) => item.id, [])
|
||||
|
||||
const isLoadingMore = isFetchingNextPage
|
||||
|
||||
|
|
@ -538,17 +561,17 @@ export function MemoriesGrid({
|
|||
documents.every((d) => d.id && selectedDocumentIds.has(d.id))
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="relative flex h-full min-h-0 flex-col">
|
||||
{!isEmpty && !isSelectionMode && (
|
||||
<div
|
||||
id="filter-pills"
|
||||
className="flex items-center justify-between gap-4 mb-3 pr-2"
|
||||
className="mb-3 flex flex-col gap-2 pr-2 sm:flex-row sm:items-start sm:justify-between sm:gap-4"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<div className="order-2 flex w-full min-w-0 items-center gap-1.5 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden sm:order-1 sm:flex-wrap sm:overflow-visible">
|
||||
<Button
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"rounded-full border border-[#161F2C] bg-[#0D121A] px-2.5 py-1 text-xs h-auto hover:bg-[#00173C] hover:border-[#2261CA33]",
|
||||
"shrink-0 whitespace-nowrap rounded-full border border-[#161F2C] bg-[#0D121A] px-2.5 py-1 text-xs h-auto hover:bg-[#00173C] hover:border-[#2261CA33]",
|
||||
selectedCategories.length === 0 &&
|
||||
"bg-[#00173C] border-[#2261CA33]",
|
||||
)}
|
||||
|
|
@ -566,7 +589,7 @@ export function MemoriesGrid({
|
|||
key={facet.category}
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"rounded-full border border-[#161F2C] bg-[#0D121A] px-2.5 py-1 text-xs h-auto hover:bg-[#00173C] hover:border-[#2261CA33]",
|
||||
"shrink-0 whitespace-nowrap rounded-full border border-[#161F2C] bg-[#0D121A] px-2.5 py-1 text-xs h-auto hover:bg-[#00173C] hover:border-[#2261CA33]",
|
||||
selectedCategoriesSet.has(facet.category) &&
|
||||
"bg-[#00173C] border-[#2261CA33]",
|
||||
)}
|
||||
|
|
@ -577,7 +600,7 @@ export function MemoriesGrid({
|
|||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<div className="order-1 flex w-full items-center justify-between gap-2 sm:order-2 sm:w-auto sm:justify-start sm:self-start">
|
||||
{/* View mode toggle — segmented control */}
|
||||
<div
|
||||
role="tablist"
|
||||
|
|
@ -618,17 +641,52 @@ export function MemoriesGrid({
|
|||
Timeline
|
||||
</button>
|
||||
</div>
|
||||
{onEnterSelectionMode && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Select documents"
|
||||
title="Select documents"
|
||||
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}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="More memory actions"
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"inline-flex h-8 items-center justify-center gap-1.5 rounded-full border border-[#161F2C] bg-[#0D121A] px-2.5 text-xs font-medium transition-colors cursor-pointer",
|
||||
profileOpen
|
||||
? "border-[#2261CA33] bg-[#00173C] text-white"
|
||||
: "text-[#737373] hover:bg-white/5",
|
||||
)}
|
||||
>
|
||||
<MoreHorizontal className="size-3.5" />
|
||||
More
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className={cn(
|
||||
"min-w-[180px] rounded-xl border border-[#2E3033] p-1.5 shadow-[0px_1.5px_20px_0px_rgba(0,0,0,0.65)]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(180deg, #0A0E14 0%, #05070A 100%)",
|
||||
}}
|
||||
>
|
||||
<BoxSelect className="size-4 text-[#737373]" />
|
||||
</button>
|
||||
)}
|
||||
{onEnterSelectionMode && (
|
||||
<DropdownMenuItem
|
||||
onSelect={onEnterSelectionMode}
|
||||
className="gap-2 rounded-md px-3 py-2.5 text-sm font-medium text-white cursor-pointer hover:bg-[#293952]/40"
|
||||
>
|
||||
<BoxSelect className="size-4 text-[#737373]" />
|
||||
Select memories
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onSelect={handleToggleProfile}
|
||||
className="gap-2 rounded-md px-3 py-2.5 text-sm font-medium text-white cursor-pointer hover:bg-[#293952]/40"
|
||||
>
|
||||
<UserRound className="size-4 text-[#737373]" />
|
||||
Space profile
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -638,27 +696,29 @@ export function MemoriesGrid({
|
|||
id="selection-toolbar"
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"flex items-center justify-between gap-3 mb-3 mr-2 px-3 py-2 rounded-full border border-[#2261CA33] bg-[#00173C]/40",
|
||||
"flex items-center justify-between gap-1.5 mb-3 mr-2 px-2.5 py-2 rounded-full border border-[#2261CA33] bg-[#00173C]/40 sm:gap-3 sm:px-3",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className="flex min-w-0 shrink items-center gap-1.5 sm:gap-2">
|
||||
<span className="flex items-center gap-1.5 text-xs text-[#FAFAFA] font-medium shrink-0">
|
||||
<span className="inline-flex items-center justify-center min-w-[20px] h-5 px-1.5 rounded-full bg-[#369BFD] text-[#0B0F14] text-[11px] font-semibold">
|
||||
{selectedDocumentIds.size}
|
||||
</span>
|
||||
{selectedDocumentIds.size === 1 ? "selected" : "selected"}
|
||||
<span className="hidden sm:inline">
|
||||
{selectedDocumentIds.size === 1 ? "selected" : "selected"}
|
||||
</span>
|
||||
</span>
|
||||
{selectedDocumentIds.size === 0 && (
|
||||
<span className="text-xs text-[#737373] truncate">
|
||||
<span className="hidden truncate text-xs text-[#737373] sm:inline">
|
||||
Tap documents to select
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<div className="flex min-w-0 shrink-0 items-center gap-0.5 sm:gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"text-xs px-2.5 h-7 rounded-full transition-colors cursor-pointer",
|
||||
"h-7 rounded-full px-2 text-xs transition-colors cursor-pointer sm:px-2.5",
|
||||
allVisibleSelected
|
||||
? "text-[#737373] hover:text-white"
|
||||
: "text-[#FAFAFA] hover:bg-white/5",
|
||||
|
|
@ -672,7 +732,7 @@ export function MemoriesGrid({
|
|||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex items-center gap-1 text-xs px-3 h-7 rounded-full transition-colors cursor-pointer",
|
||||
"flex h-7 items-center gap-1 rounded-full px-2 text-xs transition-colors cursor-pointer sm:px-3",
|
||||
selectedDocumentIds.size === 0 || isBulkDeleting
|
||||
? "text-[#737373]/60 cursor-not-allowed"
|
||||
: "text-red-400 hover:text-red-300 hover:bg-red-500/10",
|
||||
|
|
@ -692,7 +752,7 @@ export function MemoriesGrid({
|
|||
<button
|
||||
type="button"
|
||||
aria-label="Exit selection mode"
|
||||
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"
|
||||
className="flex h-7 items-center gap-1 rounded-full px-2 text-xs text-[#737373] transition-colors hover:text-white hover:bg-white/5 cursor-pointer sm:px-3"
|
||||
onClick={onClearSelection}
|
||||
>
|
||||
<XIcon className="size-3" />
|
||||
|
|
@ -744,60 +804,82 @@ export function MemoriesGrid({
|
|||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{error ? (
|
||||
<div className="h-full flex items-center justify-center p-4">
|
||||
<div className="text-center text-muted-foreground">
|
||||
Error loading documents: {error.message}
|
||||
</div>
|
||||
</div>
|
||||
) : isPending ? (
|
||||
<MemoriesGridLoading />
|
||||
) : showNovaEmptyState ? (
|
||||
<NovaEmptyState
|
||||
onAddMemory={emptyStateProps.onAddMemory}
|
||||
onOpenIntegrations={emptyStateProps.onOpenIntegrations}
|
||||
isAllSpaces={emptyStateProps.isAllSpaces}
|
||||
spaceName={emptyStateProps.spaceName}
|
||||
onSwitchToAllSpaces={emptyStateProps.onSwitchToAllSpaces}
|
||||
/>
|
||||
) : isEmpty ? (
|
||||
<div className="h-full flex items-center justify-center p-4">
|
||||
<div className="text-center text-muted-foreground">
|
||||
No memories found
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full overflow-auto scrollbar-thin">
|
||||
{localViewMode === "timeline" ? (
|
||||
<TimelineView
|
||||
documents={documents}
|
||||
onOpenDocument={onOpenDocument}
|
||||
hasNextPage={hasNextPage}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
onLoadMore={loadMoreDocuments}
|
||||
/>
|
||||
) : (
|
||||
<Masonry
|
||||
key={masonryKey}
|
||||
items={masonryItems}
|
||||
render={renderMasonryItem}
|
||||
columnGutter={0}
|
||||
rowGutter={0}
|
||||
columnWidth={260}
|
||||
maxColumnCount={isMobile ? 1 : undefined}
|
||||
itemHeightEstimate={200}
|
||||
overscanBy={3}
|
||||
onRender={maybeLoadMore}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isLoadingMore && localViewMode === "grid" && (
|
||||
<div className="py-10 flex items-center justify-center">
|
||||
<Loader className="size-10 animate-spin text-sky-400" />
|
||||
<div className="min-h-0 flex-1">
|
||||
{error ? (
|
||||
<div className="h-full flex items-center justify-center p-4">
|
||||
<div className="text-center text-muted-foreground">
|
||||
Error loading documents: {error.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : isPending ? (
|
||||
<MemoriesGridLoading />
|
||||
) : showNovaEmptyState ? (
|
||||
<NovaEmptyState
|
||||
onAddMemory={emptyStateProps.onAddMemory}
|
||||
onOpenIntegrations={emptyStateProps.onOpenIntegrations}
|
||||
isAllSpaces={emptyStateProps.isAllSpaces}
|
||||
spaceName={emptyStateProps.spaceName}
|
||||
onSwitchToAllSpaces={emptyStateProps.onSwitchToAllSpaces}
|
||||
/>
|
||||
) : isEmpty ? (
|
||||
<div className="h-full flex items-center justify-center p-4">
|
||||
<div className="text-center text-muted-foreground">
|
||||
No memories found
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full min-h-0 gap-4">
|
||||
<div className="min-w-0 flex-1 overflow-auto scrollbar-thin">
|
||||
{localViewMode === "timeline" ? (
|
||||
<TimelineView
|
||||
documents={documents}
|
||||
onOpenDocument={onOpenDocument}
|
||||
hasNextPage={hasNextPage}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
onLoadMore={loadMoreDocuments}
|
||||
isSelectionMode={isSelectionMode}
|
||||
selectedDocumentIds={selectedDocumentIds}
|
||||
onToggleSelection={onToggleSelection}
|
||||
/>
|
||||
) : (
|
||||
<Masonry
|
||||
key={masonryKey}
|
||||
items={masonryItems}
|
||||
render={renderMasonryItem}
|
||||
itemKey={getMasonryItemKey}
|
||||
columnGutter={0}
|
||||
rowGutter={0}
|
||||
columnWidth={260}
|
||||
maxColumnCount={isMobile ? 1 : undefined}
|
||||
itemHeightEstimate={200}
|
||||
overscanBy={3}
|
||||
onRender={maybeLoadMore}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isLoadingMore && localViewMode === "grid" && (
|
||||
<div className="py-10 flex items-center justify-center">
|
||||
<Loader className="size-10 animate-spin text-sky-400" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<AnimatePresence initial={false}>
|
||||
{profileOpen && !isMobile && (
|
||||
<SpaceProfilePanel
|
||||
containerTag={selectedProject}
|
||||
isOpen
|
||||
onClose={() => setProfileOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<SpaceProfileModal
|
||||
containerTag={selectedProject}
|
||||
open={profileOpen && isMobile}
|
||||
onOpenChange={setProfileOpen}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client"
|
||||
|
||||
import { useInfiniteQuery } from "@tanstack/react-query"
|
||||
import { useEffect, useMemo } from "react"
|
||||
import { useMemo } from "react"
|
||||
import { $fetch } from "@lib/api"
|
||||
import type {
|
||||
GraphApiDocument,
|
||||
|
|
@ -15,7 +15,6 @@ interface UseGraphApiOptions {
|
|||
containerTags?: string[]
|
||||
documentIds?: string[]
|
||||
enabled?: boolean
|
||||
autoFetchAll?: boolean
|
||||
}
|
||||
|
||||
interface ApiMemoryEntry {
|
||||
|
|
@ -109,12 +108,7 @@ function toGraphDocument(
|
|||
}
|
||||
|
||||
export function useGraphApi(options: UseGraphApiOptions = {}) {
|
||||
const {
|
||||
containerTags,
|
||||
documentIds,
|
||||
enabled = true,
|
||||
autoFetchAll = false,
|
||||
} = options
|
||||
const { containerTags, documentIds, enabled = true } = options
|
||||
const filteredDocumentIds = documentIds?.filter(Boolean)
|
||||
const hasDocumentIds =
|
||||
filteredDocumentIds != null && filteredDocumentIds.length > 0
|
||||
|
|
@ -169,19 +163,6 @@ export function useGraphApi(options: UseGraphApiOptions = {}) {
|
|||
enabled,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoFetchAll || !enabled) return
|
||||
if (!hasNextPage || isPending || isFetchingNextPage) return
|
||||
fetchNextPage()
|
||||
}, [
|
||||
autoFetchAll,
|
||||
enabled,
|
||||
hasNextPage,
|
||||
isPending,
|
||||
isFetchingNextPage,
|
||||
fetchNextPage,
|
||||
])
|
||||
|
||||
const documents = useMemo(() => {
|
||||
if (!data?.pages) return []
|
||||
return data.pages.flatMap((page) =>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,9 @@
|
|||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react"
|
||||
import { Loader2 } from "lucide-react"
|
||||
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"
|
||||
import { cn } from "@lib/utils"
|
||||
|
||||
export interface MemoryGraphWrapperProps {
|
||||
children?: React.ReactNode
|
||||
|
|
@ -23,7 +21,6 @@ export interface MemoryGraphWrapperProps {
|
|||
onSlideshowStop?: () => void
|
||||
canvasRef?: React.RefObject<HTMLCanvasElement | null>
|
||||
onOpenDocument?: (documentId: string) => void
|
||||
autoFetchAll?: boolean
|
||||
}
|
||||
|
||||
export function MemoryGraph({
|
||||
|
|
@ -35,7 +32,6 @@ export function MemoryGraph({
|
|||
documentIds,
|
||||
maxNodes,
|
||||
canvasRef,
|
||||
autoFetchAll = false,
|
||||
...rest
|
||||
}: MemoryGraphWrapperProps) {
|
||||
const [containerSize, setContainerSize] = useState({ width: 0, height: 0 })
|
||||
|
|
@ -64,22 +60,10 @@ export function MemoryGraph({
|
|||
containerTags,
|
||||
documentIds,
|
||||
enabled: containerSize.width > 0 && containerSize.height > 0,
|
||||
autoFetchAll,
|
||||
})
|
||||
|
||||
const loadedDocCount = documents.length
|
||||
const loadedMemoryCount = useMemo(
|
||||
() => documents.reduce((sum, doc) => sum + doc.memories.length, 0),
|
||||
[documents],
|
||||
)
|
||||
const showProgress =
|
||||
autoFetchAll &&
|
||||
!apiIsLoading &&
|
||||
totalCount > 0 &&
|
||||
(hasMore || isLoadingMore || loadedDocCount < totalCount)
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative size-full [&>div]:!bg-none">
|
||||
<div ref={containerRef} className="size-full [&>div]:!bg-none">
|
||||
<MemoryGraphBase
|
||||
documents={documents}
|
||||
isLoading={externalIsLoading || apiIsLoading}
|
||||
|
|
@ -101,24 +85,6 @@ export function MemoryGraph({
|
|||
>
|
||||
{children}
|
||||
</MemoryGraphBase>
|
||||
{showProgress && (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute right-3 top-3 z-15 md:right-4 md:top-4",
|
||||
"flex items-center gap-2 rounded-full",
|
||||
"bg-background/85 px-3 py-1.5 text-xs backdrop-blur-md",
|
||||
"border border-border/60 shadow-sm",
|
||||
)}
|
||||
aria-live="polite"
|
||||
>
|
||||
<Loader2 className="size-3 shrink-0 animate-spin text-muted-foreground" />
|
||||
<span className="tabular-nums">
|
||||
{loadedMemoryCount.toLocaleString()} memories ·{" "}
|
||||
{loadedDocCount.toLocaleString()}/{totalCount.toLocaleString()}{" "}
|
||||
documents
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
35
apps/web/components/org-plan-badge.tsx
Normal file
35
apps/web/components/org-plan-badge.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { cn } from "@lib/utils"
|
||||
import { dmSans125ClassName } from "@/lib/fonts"
|
||||
import { PLAN_DISPLAY_NAMES, type PlanType } from "@/hooks/use-token-usage"
|
||||
|
||||
const orgPlanBadgeBase = cn(
|
||||
dmSans125ClassName(),
|
||||
"inline-flex h-[18px] min-w-[42px] shrink-0 items-center justify-center rounded-[3px] px-1.5 text-[10px] uppercase",
|
||||
)
|
||||
|
||||
const ORG_PLAN_BADGE_STYLES: Record<PlanType, string> = {
|
||||
free: "bg-[#2E353D] font-mono font-medium tracking-[0.12em] text-[#A3A3A3]",
|
||||
pro: "bg-[#4BA0FA] font-bold tracking-[0.36px] text-[#00171A]",
|
||||
scale: "bg-[#0054AD] font-bold tracking-[0.36px] text-[#FAFAFA]",
|
||||
enterprise: "bg-[#FAFAFA] font-bold tracking-[0.36px] text-[#0D121A]",
|
||||
}
|
||||
|
||||
export function OrgPlanBadge({ plan }: { plan: PlanType }) {
|
||||
return (
|
||||
<span className={cn(orgPlanBadgeBase, ORG_PLAN_BADGE_STYLES[plan])}>
|
||||
{PLAN_DISPLAY_NAMES[plan]}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function resolveOrgPlan(
|
||||
orgId: string,
|
||||
isCurrent: boolean,
|
||||
currentPlan: PlanType,
|
||||
planByOrgId: Map<string, PlanType>,
|
||||
): PlanType {
|
||||
const fromSummary = planByOrgId.get(orgId)
|
||||
if (fromSummary) return fromSummary
|
||||
if (isCurrent) return currentPlan
|
||||
return "free"
|
||||
}
|
||||
|
|
@ -1,11 +1,14 @@
|
|||
"use client"
|
||||
|
||||
import { useRef, useCallback } from "react"
|
||||
import { useRef, useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { createPortal } from "react-dom"
|
||||
import { AnimatePresence, motion } from "motion/react"
|
||||
import { cn } from "@lib/utils"
|
||||
import { dmSansClassName } from "@/lib/fonts"
|
||||
import { Maximize2, Plus, Loader2 } from "lucide-react"
|
||||
import { Maximize2, Plus, Loader2, X } from "lucide-react"
|
||||
import { useProject } from "@/stores"
|
||||
import { useQuickNoteDraft } from "@/stores/quick-note-draft"
|
||||
import { TextEditor } from "./text-editor"
|
||||
|
||||
interface QuickNoteCardProps {
|
||||
onSave: (content: string) => void
|
||||
|
|
@ -13,33 +16,74 @@ interface QuickNoteCardProps {
|
|||
isSaving?: boolean
|
||||
}
|
||||
|
||||
type NoteRect = {
|
||||
left: number
|
||||
top: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
function getExpandedRect(source: NoteRect): NoteRect {
|
||||
const viewportWidth = window.innerWidth
|
||||
const viewportHeight = window.innerHeight
|
||||
const margin = viewportWidth < 768 ? 16 : 32
|
||||
const availableWidth = viewportWidth - source.left - margin
|
||||
const availableHeight = viewportHeight - source.top - margin
|
||||
const width = Math.min(1008, Math.max(source.width, availableWidth))
|
||||
const height = Math.min(720, Math.max(source.height, availableHeight))
|
||||
|
||||
return {
|
||||
left: source.left,
|
||||
top: source.top,
|
||||
width,
|
||||
height,
|
||||
}
|
||||
}
|
||||
|
||||
export function QuickNoteCard({
|
||||
onSave,
|
||||
onMaximize,
|
||||
isSaving = false,
|
||||
}: QuickNoteCardProps) {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const cardRef = useRef<HTMLDivElement>(null)
|
||||
const wasSavingRef = useRef(isSaving)
|
||||
const { selectedProject } = useProject()
|
||||
const { draft, setDraft } = useQuickNoteDraft(selectedProject)
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
const [sourceRect, setSourceRect] = useState<NoteRect | null>(null)
|
||||
const [targetRect, setTargetRect] = useState<NoteRect | null>(null)
|
||||
const [expandedInitialContent, setExpandedInitialContent] = useState<
|
||||
string | undefined
|
||||
>(undefined)
|
||||
const [isMounted, setIsMounted] = useState(false)
|
||||
|
||||
const handleChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setDraft(e.target.value)
|
||||
(content: string) => {
|
||||
setDraft(content)
|
||||
},
|
||||
[setDraft],
|
||||
)
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
if (draft.trim() && !isSaving) {
|
||||
onSave(draft)
|
||||
}
|
||||
}
|
||||
},
|
||||
[draft, isSaving, onSave],
|
||||
)
|
||||
const handleExpand = useCallback(() => {
|
||||
const rect = cardRef.current?.getBoundingClientRect()
|
||||
if (!rect) return
|
||||
|
||||
const nextSourceRect = {
|
||||
left: rect.left,
|
||||
top: rect.top,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
}
|
||||
|
||||
setSourceRect(nextSourceRect)
|
||||
setTargetRect(getExpandedRect(nextSourceRect))
|
||||
setExpandedInitialContent(draft || undefined)
|
||||
setIsExpanded(true)
|
||||
}, [draft])
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setIsExpanded(false)
|
||||
}, [])
|
||||
|
||||
const handleSaveClick = useCallback(() => {
|
||||
if (draft.trim() && !isSaving) {
|
||||
|
|
@ -48,112 +92,278 @@ export function QuickNoteCard({
|
|||
}, [draft, isSaving, onSave])
|
||||
|
||||
const handleMaximizeClick = useCallback(() => {
|
||||
setIsExpanded(false)
|
||||
onMaximize(draft)
|
||||
}, [draft, onMaximize])
|
||||
|
||||
const canSave = draft.trim().length > 0 && !isSaving
|
||||
const previewText = useMemo(() => {
|
||||
const trimmed = draft.trim()
|
||||
if (!trimmed) return null
|
||||
return trimmed.replace(/\s+/g, " ")
|
||||
}, [draft])
|
||||
|
||||
useEffect(() => {
|
||||
setIsMounted(true)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isExpanded || !sourceRect) return
|
||||
|
||||
const handleResize = () => setTargetRect(getExpandedRect(sourceRect))
|
||||
window.addEventListener("resize", handleResize)
|
||||
return () => window.removeEventListener("resize", handleResize)
|
||||
}, [isExpanded, sourceRect])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isExpanded) return
|
||||
|
||||
const previousOverflow = document.body.style.overflow
|
||||
document.body.style.overflow = "hidden"
|
||||
|
||||
const handleGlobalKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault()
|
||||
handleClose()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", handleGlobalKeyDown)
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = previousOverflow
|
||||
document.removeEventListener("keydown", handleGlobalKeyDown)
|
||||
}
|
||||
}, [isExpanded, handleClose])
|
||||
|
||||
useEffect(() => {
|
||||
if (wasSavingRef.current && !isSaving && draft.trim().length === 0) {
|
||||
setIsExpanded(false)
|
||||
}
|
||||
wasSavingRef.current = isSaving
|
||||
}, [draft, isSaving])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="bg-[#1B1F24] rounded-[22px] p-1"
|
||||
style={{
|
||||
boxShadow:
|
||||
"0 2.842px 14.211px 0 rgba(0, 0, 0, 0.25), 0.711px 0.711px 0.711px 0 rgba(255, 255, 255, 0.10) inset",
|
||||
}}
|
||||
>
|
||||
<>
|
||||
<div
|
||||
id="quick-note-inner"
|
||||
className="bg-[#0B1017] rounded-[18px] p-3 relative"
|
||||
ref={cardRef}
|
||||
className="bg-[#1B1F24] rounded-[22px] p-1"
|
||||
style={{
|
||||
boxShadow: "inset 1.421px 1.421px 4.263px 0 rgba(11, 15, 21, 0.4)",
|
||||
boxShadow:
|
||||
"0 2.842px 14.211px 0 rgba(0, 0, 0, 0.25), 0.711px 0.711px 0.711px 0 rgba(255, 255, 255, 0.10) inset",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleMaximizeClick}
|
||||
className="absolute top-3 right-3 text-[#737373] hover:text-white transition-colors cursor-pointer"
|
||||
aria-label="Expand to full screen"
|
||||
>
|
||||
<Maximize2 className="size-[14px]" />
|
||||
</button>
|
||||
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={draft}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Start writing..."
|
||||
disabled={isSaving}
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"w-full h-[120px] bg-transparent resize-none outline-none text-[12px] leading-normal text-white placeholder:text-[#737373] pr-5 disabled:opacity-50",
|
||||
)}
|
||||
/>
|
||||
|
||||
<div
|
||||
id="quick-note-action-bar"
|
||||
className="bg-[#1B1F24] rounded-[8px] px-2 py-1.5 flex items-center justify-center gap-8 w-full"
|
||||
id="quick-note-inner"
|
||||
className="bg-[#0B1017] rounded-[18px] p-3 relative"
|
||||
style={{
|
||||
boxShadow:
|
||||
"0 4px 20px 0 rgba(0, 0, 0, 0.25), inset 1px 1px 1px 0 rgba(255, 255, 255, 0.1)",
|
||||
boxShadow: "inset 1.421px 1.421px 4.263px 0 rgba(11, 15, 21, 0.4)",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSaveClick}
|
||||
disabled={!canSave}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 cursor-pointer disabled:cursor-not-allowed disabled:opacity-50",
|
||||
)}
|
||||
onClick={handleMaximizeClick}
|
||||
className="absolute top-3 right-3 text-[#737373] hover:text-white transition-colors cursor-pointer"
|
||||
aria-label="Open full screen note"
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
{isSaving ? (
|
||||
<Loader2 className="size-2 animate-spin text-[#fafafa]" />
|
||||
) : (
|
||||
<Plus className="size-2 text-[#fafafa]" />
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"text-[10px] font-medium text-[#fafafa]",
|
||||
)}
|
||||
>
|
||||
{isSaving ? "Saving..." : "Save note"}
|
||||
</span>
|
||||
</span>
|
||||
<Maximize2 className="size-[14px]" />
|
||||
</button>
|
||||
|
||||
<span
|
||||
className={cn(
|
||||
"bg-[rgba(33,33,33,0.5)] border border-[rgba(115,115,115,0.2)] rounded px-1 py-0.5 flex items-center gap-1 h-4",
|
||||
)}
|
||||
>
|
||||
<svg
|
||||
className="size-[10px]"
|
||||
viewBox="0 0 9 9"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<title>Command Key</title>
|
||||
<path
|
||||
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"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleExpand}
|
||||
disabled={isSaving}
|
||||
className="w-full h-[120px] cursor-text text-left disabled:cursor-not-allowed disabled:opacity-50"
|
||||
aria-label="Expand quick note"
|
||||
>
|
||||
<span className="flex h-full flex-col pr-5">
|
||||
<span
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"text-[10px] font-medium text-[#737373]",
|
||||
"line-clamp-4 text-[12px] leading-normal text-[#D7DEE8]",
|
||||
!previewText && "text-[#737373]",
|
||||
)}
|
||||
>
|
||||
Enter
|
||||
{previewText ?? "Start writing..."}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div
|
||||
id="quick-note-action-bar"
|
||||
className="bg-[#1B1F24] rounded-[8px] px-2 py-1.5 flex items-center justify-center gap-8 w-full"
|
||||
style={{
|
||||
boxShadow:
|
||||
"0 4px 20px 0 rgba(0, 0, 0, 0.25), inset 1px 1px 1px 0 rgba(255, 255, 255, 0.1)",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSaveClick}
|
||||
disabled={!canSave}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 cursor-pointer disabled:cursor-not-allowed disabled:opacity-50",
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
{isSaving ? (
|
||||
<Loader2 className="size-2 animate-spin text-[#fafafa]" />
|
||||
) : (
|
||||
<Plus className="size-2 text-[#fafafa]" />
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"text-[10px] font-medium text-[#fafafa]",
|
||||
)}
|
||||
>
|
||||
{isSaving ? "Saving..." : "Save note"}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span
|
||||
className={cn(
|
||||
"bg-[rgba(33,33,33,0.5)] border border-[rgba(115,115,115,0.2)] rounded px-1 py-0.5 flex items-center gap-1 h-4",
|
||||
)}
|
||||
>
|
||||
<svg
|
||||
className="size-[10px]"
|
||||
viewBox="0 0 9 9"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<title>Command Key</title>
|
||||
<path
|
||||
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"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
<span
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"text-[10px] font-medium text-[#737373]",
|
||||
)}
|
||||
>
|
||||
Enter
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isMounted &&
|
||||
createPortal(
|
||||
<AnimatePresence>
|
||||
{isExpanded && sourceRect && targetRect && (
|
||||
<div className="fixed inset-0 z-[100]">
|
||||
<motion.button
|
||||
type="button"
|
||||
aria-label="Close quick note"
|
||||
className="absolute inset-0 cursor-default bg-[#05080D]/60 backdrop-blur-md"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2, ease: [0.4, 0, 0.2, 1] }}
|
||||
onClick={handleClose}
|
||||
/>
|
||||
|
||||
<motion.div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="New quick note"
|
||||
className="absolute overflow-hidden rounded-[22px] bg-[#1B1F24] p-1"
|
||||
initial={sourceRect}
|
||||
animate={targetRect}
|
||||
exit={sourceRect}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 420,
|
||||
damping: 42,
|
||||
mass: 0.9,
|
||||
}}
|
||||
style={{
|
||||
boxShadow:
|
||||
"0 28px 80px rgba(0, 0, 0, 0.55), 0 2.842px 14.211px 0 rgba(0, 0, 0, 0.25), 0.711px 0.711px 0.711px 0 rgba(255, 255, 255, 0.10) inset",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex size-full flex-col rounded-[18px] bg-[#0B1017]"
|
||||
style={{
|
||||
boxShadow:
|
||||
"inset 1.421px 1.421px 4.263px 0 rgba(11, 15, 21, 0.4)",
|
||||
}}
|
||||
>
|
||||
<header className="flex shrink-0 justify-end border-b border-[#202A36]/70 px-5 py-4 md:px-7">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleMaximizeClick}
|
||||
className="flex size-8 items-center justify-center rounded-full text-[#737373] transition-colors hover:bg-white/5 hover:text-white cursor-pointer"
|
||||
aria-label="Open full screen note"
|
||||
>
|
||||
<Maximize2 className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="flex size-8 items-center justify-center rounded-full text-[#737373] transition-colors hover:bg-white/5 hover:text-white cursor-pointer"
|
||||
aria-label="Close quick note"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-auto px-5 py-5 md:px-7 md:py-6">
|
||||
<TextEditor
|
||||
content={expandedInitialContent}
|
||||
onContentChange={handleChange}
|
||||
onSubmit={handleSaveClick}
|
||||
debounceMs={0}
|
||||
autoFocus
|
||||
placeholder="Start writing..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<footer className="flex shrink-0 justify-center border-t border-[#202A36]/70 px-4 py-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSaveClick}
|
||||
disabled={!canSave}
|
||||
className={cn(
|
||||
"bg-[#1B1F24] rounded-[8px] px-4 py-2.5 flex items-center justify-center gap-1.5 cursor-pointer disabled:cursor-not-allowed disabled:opacity-50",
|
||||
)}
|
||||
style={{
|
||||
boxShadow:
|
||||
"0 4px 20px 0 rgba(0, 0, 0, 0.25), inset 1px 1px 1px 0 rgba(255, 255, 255, 0.1)",
|
||||
}}
|
||||
>
|
||||
{isSaving ? (
|
||||
<Loader2 className="size-2 animate-spin text-[#fafafa]" />
|
||||
) : (
|
||||
<Plus className="size-2 text-[#fafafa]" />
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"text-[14px] font-medium text-[#fafafa]",
|
||||
)}
|
||||
>
|
||||
{isSaving ? "Saving..." : "Save note"}
|
||||
</span>
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,9 @@ import { useState, useMemo, useEffect, useCallback, useRef } from "react"
|
|||
import Image from "next/image"
|
||||
import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
|
||||
import { Dialog, DialogContent } from "@repo/ui/components/dialog"
|
||||
import { Drawer, DrawerContent, DrawerTitle } from "@repo/ui/components/drawer"
|
||||
import { cn } from "@lib/utils"
|
||||
import { useIsMobile } from "@hooks/use-mobile"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import {
|
||||
XIcon,
|
||||
|
|
@ -47,6 +49,7 @@ import { useProjectMutations } from "@/hooks/use-project-mutations"
|
|||
import { AUTO_CHAT_SPACE_ID } from "@/lib/chat-auto-space"
|
||||
import NovaOrb from "@/components/nova/nova-orb"
|
||||
import { AutoSpaceIcon } from "@/components/nova/auto-space-icon"
|
||||
import { SpaceGlyph } from "./space-glyph"
|
||||
|
||||
interface SelectSpacesModalProps {
|
||||
isOpen: boolean
|
||||
|
|
@ -116,6 +119,7 @@ export function SelectSpacesModal({
|
|||
const editInputRef = useRef<HTMLInputElement | null>(null)
|
||||
const editingContainerTag = editingProject?.containerTag
|
||||
const currentSelection = selectedProjects[0] ?? ""
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
const pluginTags = useMemo(
|
||||
() =>
|
||||
|
|
@ -645,7 +649,11 @@ export function SelectSpacesModal({
|
|||
</button>
|
||||
{isEditing ? (
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<span className="shrink-0 text-lg">{project.emoji || "📁"}</span>
|
||||
<SpaceGlyph
|
||||
emoji={project.emoji}
|
||||
size={18}
|
||||
className="shrink-0"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={editingProject.name}
|
||||
|
|
@ -709,9 +717,11 @@ export function SelectSpacesModal({
|
|||
) : isOwnSpace ? (
|
||||
<NovaOrb size={20} className="shrink-0 blur-[0.55px]!" />
|
||||
) : (
|
||||
<span className="shrink-0 text-lg">
|
||||
{project.emoji || "📁"}
|
||||
</span>
|
||||
<SpaceGlyph
|
||||
emoji={project.emoji}
|
||||
size={20}
|
||||
className="shrink-0"
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
className="min-w-0 flex-1 truncate text-[#fafafa] text-sm font-medium"
|
||||
|
|
@ -825,6 +835,283 @@ export function SelectSpacesModal({
|
|||
)
|
||||
}, [currentSelection, handleSelectAuto])
|
||||
|
||||
const renderCategoryChip = (
|
||||
category: {
|
||||
id: string
|
||||
label: string
|
||||
count?: number
|
||||
iconSrc?: string
|
||||
emoji?: string
|
||||
},
|
||||
isDiscover: boolean,
|
||||
) => {
|
||||
const isActive = activeCategory === category.id
|
||||
return (
|
||||
<button
|
||||
key={category.id}
|
||||
type="button"
|
||||
onClick={() => setActiveCategory(category.id)}
|
||||
className={cn(
|
||||
"flex shrink-0 items-center gap-2 whitespace-nowrap rounded-full border px-3 py-2 transition-colors",
|
||||
isActive
|
||||
? "border-[#2261CA33] bg-[#00173C] text-[#fafafa]"
|
||||
: "border-[#161F2C] bg-[#0D121A] text-[#A1A1AA]",
|
||||
isDiscover && !isActive && "opacity-60",
|
||||
)}
|
||||
>
|
||||
<span className="flex size-[18px] shrink-0 items-center justify-center">
|
||||
{category.id === "all" ? (
|
||||
<LayoutGrid
|
||||
className={cn(
|
||||
"size-4",
|
||||
isActive ? "text-[#fafafa]" : "text-[#737373]",
|
||||
)}
|
||||
/>
|
||||
) : category.iconSrc ? (
|
||||
<Image
|
||||
src={category.iconSrc}
|
||||
alt=""
|
||||
width={18}
|
||||
height={18}
|
||||
className="rounded-[3px]"
|
||||
aria-hidden
|
||||
/>
|
||||
) : category.emoji ? (
|
||||
<SpaceGlyph emoji={category.emoji} size={16} />
|
||||
) : category.id.startsWith("plugin:") ? (
|
||||
<span
|
||||
className="flex h-[18px] w-[18px] items-center justify-center rounded-[3px] bg-[#1E232B] text-[10px] font-semibold uppercase text-[#FAFAFA]"
|
||||
aria-hidden
|
||||
>
|
||||
{pluginInitial(category.label)}
|
||||
</span>
|
||||
) : (
|
||||
<FolderIcon
|
||||
className={cn(
|
||||
"size-4",
|
||||
isActive ? "text-[#fafafa]" : "text-[#737373]",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-[13px] font-medium">{category.label}</span>
|
||||
{isDiscover ? (
|
||||
<ArrowRight className="size-3.5 text-[#737373]" />
|
||||
) : (
|
||||
<span className="text-[11px] tabular-nums text-[#737373]">
|
||||
{category.count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
const rightPanelContent = activeCategory.startsWith("discover:") ? (
|
||||
<DiscoverPanel
|
||||
catalogId={activeDiscoverId ?? ""}
|
||||
isConnecting={connectingPluginId === activeDiscoverId}
|
||||
newKey={newKey?.pluginId === activeDiscoverId ? newKey.key : null}
|
||||
onConnect={() => {
|
||||
if (activeDiscoverId) connectMutation.mutate(activeDiscoverId)
|
||||
}}
|
||||
onDismissKey={() => setNewKey(null)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="relative shrink-0">
|
||||
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-[#737373]" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search spaces..."
|
||||
className={cn(
|
||||
"w-full rounded-[12px] bg-[#14161A] py-2.5 pl-10 pr-4 text-[14px] text-[#fafafa] shadow-inside-out placeholder:text-[#737373] focus:outline-none",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto scrollbar-thin pr-1">
|
||||
{filteredProjects.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-[#737373]">
|
||||
No spaces found
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
{showAutoRow && (
|
||||
<>
|
||||
<div className="px-3 pt-1 pb-0.5 text-[10px] uppercase tracking-[0.08em] text-[#737373]">
|
||||
Mode
|
||||
</div>
|
||||
{renderAutoRow()}
|
||||
<div className="my-1.5 h-px bg-[rgba(82,89,102,0.18)]" />
|
||||
</>
|
||||
)}
|
||||
{recentProjects.length > 0 && (
|
||||
<>
|
||||
<div className="flex items-center gap-1.5 px-3 pt-1 pb-0.5 text-[10px] uppercase tracking-[0.08em] text-[#737373]">
|
||||
<Clock className="size-3" />
|
||||
Recently used
|
||||
</div>
|
||||
{recentProjects.map(renderRow)}
|
||||
<div className="my-1.5 h-px bg-[rgba(82,89,102,0.18)]" />
|
||||
<div className="px-3 pt-0.5 pb-0.5 text-[10px] uppercase tracking-[0.08em] text-[#737373]">
|
||||
All spaces
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{mainList.map(renderRow)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
const footerContent = !activeCategory.startsWith("discover:") &&
|
||||
(isBulkDeleteMode || (showNewSpace && onNewSpace)) && (
|
||||
<div className="flex shrink-0 items-center justify-between gap-3 border-t border-[rgba(82,89,102,0.18)] px-4 py-3">
|
||||
{isBulkDeleteMode ? (
|
||||
<>
|
||||
<p className="min-w-0 text-[13px] font-medium text-[#737373]">
|
||||
{bulkDeleteCount === 0
|
||||
? "No spaces selected"
|
||||
: `${bulkDeleteCount} ${
|
||||
bulkDeleteCount === 1 ? "space" : "spaces"
|
||||
} selected`}
|
||||
</p>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleBulkModeToggle}
|
||||
className={cn(
|
||||
"px-3 py-2 text-[13px] font-medium text-[#737373] transition-colors hover:text-[#fafafa]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={bulkDeleteCount === 0}
|
||||
onClick={() => {
|
||||
if (bulkDeleteCount === 0) return
|
||||
onBulkDeleteRequest?.(bulkDeleteProjects)
|
||||
setIsBulkDeleteMode(false)
|
||||
setBulkDeleteTags(new Set())
|
||||
setLastBulkDeleteTag(null)
|
||||
}}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-full bg-red-600 px-4 py-2 text-[13px] font-medium text-white transition-colors hover:bg-red-700 disabled:cursor-not-allowed disabled:opacity-40",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
Delete selected
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span />
|
||||
{showNewSpace && onNewSpace && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNewSpace}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-full bg-[#14161A] px-4 py-2 text-[13px] font-medium text-[#fafafa] shadow-inside-out transition-colors hover:bg-[#121820] focus:outline-none focus:ring-0",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
New space
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Drawer open={isOpen} onOpenChange={handleOpenChange}>
|
||||
<DrawerContent
|
||||
className={cn(
|
||||
"flex h-[85dvh] flex-col gap-0 overflow-hidden border-none bg-[#1B1F24] p-0",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
<DrawerTitle className="sr-only">Select Space</DrawerTitle>
|
||||
<div className="flex shrink-0 items-start justify-between gap-3 px-4 pt-1">
|
||||
<div className="space-y-1">
|
||||
<p
|
||||
className={cn(
|
||||
"font-semibold text-[#fafafa]",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
Select Space
|
||||
</p>
|
||||
<p className="text-[13px] font-medium leading-[1.35] text-[#737373]">
|
||||
{isBulkDeleteMode
|
||||
? "Choose spaces to permanently delete"
|
||||
: "Filter your memories by space"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{enableDelete && onBulkDeleteRequest && !activeDiscoverId && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleBulkModeToggle}
|
||||
className={cn(
|
||||
"flex h-8 items-center gap-1.5 rounded-full bg-[#0D121A] px-2.5 text-[12px] font-medium transition-colors",
|
||||
isBulkDeleteMode ? "text-[#fafafa]" : "text-[#737373]",
|
||||
)}
|
||||
style={{
|
||||
boxShadow:
|
||||
"inset 1.313px 1.313px 3.938px 0px rgba(0,0,0,0.7)",
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
{isBulkDeleteMode ? "Cancel" : "Bulk delete"}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleOpenChange(false)}
|
||||
aria-label="Close"
|
||||
className="flex size-8 shrink-0 items-center justify-center rounded-full border border-[rgba(115,115,115,0.2)] bg-[#0D121A]"
|
||||
style={{
|
||||
boxShadow:
|
||||
"inset 1.313px 1.313px 3.938px 0px rgba(0,0,0,0.7)",
|
||||
}}
|
||||
>
|
||||
<XIcon stroke="#737373" className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex shrink-0 gap-1.5 overflow-x-auto px-4 pb-2 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
{categories.map((category) => renderCategoryChip(category, false))}
|
||||
{discoverCategories.length > 0 && (
|
||||
<>
|
||||
<div className="mx-0.5 my-1 w-px shrink-0 bg-[rgba(82,89,102,0.25)]" />
|
||||
{discoverCategories.map((category) =>
|
||||
renderCategoryChip(category, true),
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex min-h-0 flex-1 flex-col gap-3 overflow-hidden px-4 pb-2">
|
||||
{rightPanelContent}
|
||||
</div>
|
||||
|
||||
{footerContent}
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
|
||||
<DialogContent
|
||||
|
|
@ -921,7 +1208,7 @@ export function SelectSpacesModal({
|
|||
aria-hidden
|
||||
/>
|
||||
) : category.emoji ? (
|
||||
<span className="text-base">{category.emoji}</span>
|
||||
<SpaceGlyph emoji={category.emoji} size={16} />
|
||||
) : category.id.startsWith("plugin:") ? (
|
||||
<span
|
||||
className="w-[18px] h-[18px] flex items-center justify-center rounded-[3px] bg-[#1E232B] text-[#FAFAFA] text-[10px] font-semibold uppercase"
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
1129
apps/web/components/settings/billing.tsx
Normal file
1129
apps/web/components/settings/billing.tsx
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -12,7 +12,7 @@ import {
|
|||
ChevronDown,
|
||||
History,
|
||||
Loader2,
|
||||
Play,
|
||||
RefreshCw,
|
||||
Plus,
|
||||
Trash2,
|
||||
Zap,
|
||||
|
|
@ -270,7 +270,7 @@ function ConnectionRow({
|
|||
{isSyncing ? (
|
||||
<Loader2 className="size-[18px] animate-spin" />
|
||||
) : (
|
||||
<Play className="size-[18px]" />
|
||||
<RefreshCw className="size-[18px]" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
|
@ -559,7 +559,7 @@ export default function ConnectionsMCP() {
|
|||
const isLoading = autumn.isLoading
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8 pt-4 w-full">
|
||||
<div className="flex flex-col gap-8 w-full">
|
||||
{/* Supermemory Connections Section */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<SectionTitle badge={<ProBadge />}>
|
||||
|
|
@ -581,11 +581,11 @@ export default function ConnectionsMCP() {
|
|||
!hasProProduct && !isLoading && "opacity-30 pointer-events-none",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-semibold text-[16px] tracking-[-0.16px] text-[#FAFAFA]",
|
||||
"min-w-0 flex-1 truncate font-semibold text-[15px] tracking-[-0.16px] text-[#FAFAFA] sm:text-[16px]",
|
||||
)}
|
||||
>
|
||||
Connected to Supermemory
|
||||
|
|
@ -593,7 +593,7 @@ export default function ConnectionsMCP() {
|
|||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-semibold text-[16px] tracking-[-0.16px] text-[#737373]",
|
||||
"shrink-0 whitespace-nowrap text-right font-semibold text-[13px] tracking-[-0.16px] text-[#737373] sm:text-[16px]",
|
||||
)}
|
||||
>
|
||||
{connections.length}/{connectionsLimit} connections used
|
||||
|
|
|
|||
|
|
@ -259,7 +259,7 @@ export default function Integrations() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 pt-4 w-full">
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<SectionTitle>Integrations</SectionTitle>
|
||||
|
||||
<IntegrationCard id="chrome-extension-card">
|
||||
|
|
|
|||
385
apps/web/components/settings/org-context.tsx
Normal file
385
apps/web/components/settings/org-context.tsx
Normal file
|
|
@ -0,0 +1,385 @@
|
|||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { LoaderIcon, Settings, X } from "lucide-react"
|
||||
import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
|
||||
import { useOrgSettings, useUpdateOrgSettings } from "@/hooks/use-org-settings"
|
||||
import { cn } from "@lib/utils"
|
||||
import { Dialog, DialogContent, DialogTitle } from "@ui/components/dialog"
|
||||
|
||||
type ContextTemplate = {
|
||||
id: string
|
||||
label: string
|
||||
description: string
|
||||
prompt: string
|
||||
}
|
||||
|
||||
const SURFACE_SHADOW =
|
||||
"0 2.842px 14.211px 0 rgba(0,0,0,0.25), 0.711px 0.711px 0.711px 0 rgba(255,255,255,0.10) inset"
|
||||
|
||||
const CONTEXT_TEMPLATES: ContextTemplate[] = [
|
||||
{
|
||||
id: "personal-general",
|
||||
label: "General Personal Assistant",
|
||||
description:
|
||||
"Remember preferences, routines, relationships, plans, and life context.",
|
||||
prompt: `Supermemory personal assistant. The user saves conversations, notes, and daily context.
|
||||
|
||||
EXTRACT:
|
||||
- Preferences: "prefers morning meetings", "allergic to peanuts"
|
||||
- Routines: "works out every Tuesday and Thursday"
|
||||
- Relationships: "Sarah is their manager", "lives with roommate Jake"
|
||||
- Plans: "planning a trip to Japan in March"
|
||||
- Life events: "moved to Austin last month", "started a new job"
|
||||
|
||||
SKIP:
|
||||
- Generic assistant suggestions the user did not confirm
|
||||
- Pleasantries and small talk without factual content
|
||||
- Repeated scheduling details already captured`,
|
||||
},
|
||||
{
|
||||
id: "personal-productivity",
|
||||
label: "Productivity Assistant",
|
||||
description:
|
||||
"Focus on tasks, decisions, deadlines, workflows, and blockers.",
|
||||
prompt: `Supermemory productivity tool. The user saves meeting notes, task updates, and project context.
|
||||
|
||||
EXTRACT:
|
||||
- Action items: "needs to send proposal to client by Friday"
|
||||
- Decisions: "team decided to use Figma for design handoff"
|
||||
- Deadlines: "Q3 review due September 15th"
|
||||
- Workflows: "deploys happen every Wednesday via CI pipeline"
|
||||
- Blockers: "waiting on legal approval before launch"
|
||||
|
||||
SKIP:
|
||||
- Meeting filler ("let's circle back", "good point")
|
||||
- Status updates that repeat previously captured information
|
||||
- Agenda items with no outcome or decision`,
|
||||
},
|
||||
]
|
||||
|
||||
function SectionTitle({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-semibold text-[20px] tracking-[-0.2px] text-[#FAFAFA] px-2",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
function SettingsCard({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative bg-[#14161A] rounded-[14px] p-6 w-full overflow-hidden",
|
||||
"shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PillButton({
|
||||
children,
|
||||
onClick,
|
||||
disabled,
|
||||
variant = "default",
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
onClick: () => void
|
||||
disabled?: boolean
|
||||
variant?: "default" | "danger" | "primary"
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"inline-flex h-9 items-center justify-center gap-2 rounded-full border px-4 text-[13px] font-semibold transition-opacity cursor-pointer disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"bg-[#0D121A] text-[#FAFAFA] hover:opacity-80",
|
||||
"shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)]",
|
||||
variant === "primary"
|
||||
? "border-transparent"
|
||||
: variant === "danger"
|
||||
? "border-transparent"
|
||||
: "border-transparent",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export function OrgContext() {
|
||||
const { data: settings, isLoading, isError } = useOrgSettings()
|
||||
const updateSettings = useUpdateOrgSettings()
|
||||
const [confirmDialog, setConfirmDialog] = useState<
|
||||
"enable" | "disable" | null
|
||||
>(null)
|
||||
const [isManaging, setIsManaging] = useState(false)
|
||||
const [prompt, setPrompt] = useState("")
|
||||
|
||||
const enabled = settings?.shouldLLMFilter ?? false
|
||||
const savedPrompt = settings?.filterPrompt ?? ""
|
||||
const dirty = prompt.trim() !== savedPrompt.trim()
|
||||
const settingsReady = !isLoading && !isError
|
||||
|
||||
useEffect(() => {
|
||||
setPrompt(settings?.filterPrompt ?? "")
|
||||
}, [settings?.filterPrompt])
|
||||
|
||||
const selectedTemplateId = useMemo(() => {
|
||||
const normalized = prompt.trim()
|
||||
return CONTEXT_TEMPLATES.find(
|
||||
(template) => template.prompt.trim() === normalized,
|
||||
)?.id
|
||||
}, [prompt])
|
||||
|
||||
const handleConfirmToggle = () => {
|
||||
const newEnabled = confirmDialog === "enable"
|
||||
updateSettings.mutate(
|
||||
newEnabled
|
||||
? { shouldLLMFilter: true }
|
||||
: { shouldLLMFilter: false, filterPrompt: null },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setConfirmDialog(null)
|
||||
if (!newEnabled) {
|
||||
setIsManaging(false)
|
||||
setPrompt("")
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
updateSettings.mutate(
|
||||
{
|
||||
shouldLLMFilter: true,
|
||||
filterPrompt: prompt.trim() ? prompt.trim() : null,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setIsManaging(false)
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
setPrompt(savedPrompt)
|
||||
setIsManaging(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<section id="organization-context" className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1 px-2">
|
||||
<SectionTitle>Organization Context</SectionTitle>
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[13px] tracking-[-0.13px] text-[#737373] px-2",
|
||||
)}
|
||||
>
|
||||
Guide how Nova processes and remembers your content.
|
||||
</p>
|
||||
</div>
|
||||
<SettingsCard>
|
||||
<div className={cn(dmSansClassName(), "flex flex-col gap-5")}>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center">
|
||||
<div className="min-w-0">
|
||||
<p className="text-[15px] font-medium text-[#FAFAFA]">
|
||||
{enabled ? "Context is enabled" : "Context is disabled"}
|
||||
</p>
|
||||
<p className="text-[13px] leading-snug text-[#737373]">
|
||||
{enabled
|
||||
? "New content will use your guidance when Nova creates memories."
|
||||
: "Turn this on to tell Nova what matters most when it learns."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<PillButton
|
||||
onClick={() => setConfirmDialog(enabled ? "disable" : "enable")}
|
||||
disabled={!settingsReady || updateSettings.isPending}
|
||||
variant={enabled ? "danger" : "primary"}
|
||||
>
|
||||
{enabled ? "DISABLE" : "ENABLE"}
|
||||
</PillButton>
|
||||
{enabled && (
|
||||
<PillButton
|
||||
onClick={() => setIsManaging(true)}
|
||||
disabled={updateSettings.isPending}
|
||||
>
|
||||
<Settings className="size-3.5" />
|
||||
MANAGE
|
||||
</PillButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{enabled && !isManaging && savedPrompt && (
|
||||
<div className="rounded-[12px] border border-white/[0.08] bg-[#0D121A] p-4">
|
||||
<p className="line-clamp-6 whitespace-pre-wrap text-[13px] leading-relaxed text-[#A3A3A3]">
|
||||
{savedPrompt}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{enabled && !isManaging && !savedPrompt && (
|
||||
<p className="text-[13px] italic text-[#737373]">
|
||||
No organization context configured.{" "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsManaging(true)}
|
||||
className="text-[#4BA0FA] transition-colors hover:text-[#7BB8FF] cursor-pointer"
|
||||
>
|
||||
Set up now
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{enabled && isManaging && (
|
||||
<div className="overflow-hidden rounded-[18px] border border-white/[0.08] bg-[#1B1F24]">
|
||||
<div className="flex flex-col gap-4 p-4">
|
||||
<label className="flex flex-col gap-2">
|
||||
<span className="text-[13px] font-medium text-[#FAFAFA]">
|
||||
What should Nova focus on?
|
||||
</span>
|
||||
<textarea
|
||||
value={prompt}
|
||||
onChange={(event) => setPrompt(event.target.value)}
|
||||
placeholder="Describe what Nova should extract, skip, and prioritize when turning your content into memories..."
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"min-h-[180px] w-full resize-y rounded-[14px] border border-white/[0.08] bg-[#0D121A] p-4 text-[13px] leading-relaxed text-[#FAFAFA] placeholder:text-[#525966] focus:border-white/[0.14] focus:outline-none",
|
||||
)}
|
||||
maxLength={750}
|
||||
/>
|
||||
<span className="self-end text-[11px] text-[#737373]">
|
||||
{prompt.length}/750
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{CONTEXT_TEMPLATES.map((template) => {
|
||||
const isSelected = selectedTemplateId === template.id
|
||||
return (
|
||||
<button
|
||||
key={template.id}
|
||||
type="button"
|
||||
onClick={() => setPrompt(template.prompt)}
|
||||
className={cn(
|
||||
"rounded-[14px] border p-4 text-left transition-colors cursor-pointer",
|
||||
"bg-[#14161A] hover:bg-[#121820]",
|
||||
isSelected
|
||||
? "border-white/[0.16] bg-[#171B22]"
|
||||
: "border-white/[0.08]",
|
||||
)}
|
||||
>
|
||||
<p className="text-[14px] font-medium text-[#FAFAFA]">
|
||||
{template.label}
|
||||
</p>
|
||||
<p className="mt-1 text-[12px] leading-snug text-[#737373]">
|
||||
{template.description}
|
||||
</p>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 border-t border-white/[0.08] bg-[#171B22] px-4 py-3">
|
||||
<PillButton onClick={handleCancel}>CANCEL</PillButton>
|
||||
<PillButton
|
||||
onClick={handleSave}
|
||||
disabled={!dirty || updateSettings.isPending}
|
||||
variant="primary"
|
||||
>
|
||||
{updateSettings.isPending && (
|
||||
<LoaderIcon className="size-3.5 animate-spin" />
|
||||
)}
|
||||
SAVE
|
||||
</PillButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
|
||||
<Dialog
|
||||
open={!!confirmDialog}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setConfirmDialog(null)
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
style={{
|
||||
boxShadow: SURFACE_SHADOW,
|
||||
}}
|
||||
className={cn(
|
||||
"sm:max-w-[440px] border border-white/[0.12] bg-[#1B1F24] p-0 gap-0 rounded-[22px] overflow-hidden",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-4 p-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1.5 flex-1">
|
||||
<DialogTitle
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[18px] font-semibold tracking-[-0.18px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
{confirmDialog === "enable"
|
||||
? "Enable Organization Context?"
|
||||
: "Disable Organization Context?"}
|
||||
</DialogTitle>
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[13px] tracking-[-0.13px] leading-relaxed text-[#737373]",
|
||||
)}
|
||||
>
|
||||
{confirmDialog === "enable"
|
||||
? "Nova will use your guidance when processing new content across this organization."
|
||||
: "Nova will stop using this guidance for new content, and the saved context will be cleared."}
|
||||
</p>
|
||||
</div>
|
||||
<DialogPrimitive.Close className="flex size-7 shrink-0 items-center justify-center rounded-full bg-[#0D121A] text-[#737373] shadow-inside-out transition-opacity hover:opacity-80 focus:outline-hidden cursor-pointer">
|
||||
<X className="size-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<PillButton onClick={() => setConfirmDialog(null)}>
|
||||
CANCEL
|
||||
</PillButton>
|
||||
<PillButton
|
||||
onClick={handleConfirmToggle}
|
||||
disabled={updateSettings.isPending}
|
||||
variant={confirmDialog === "disable" ? "danger" : "primary"}
|
||||
>
|
||||
{updateSettings.isPending && (
|
||||
<LoaderIcon className="size-3.5 animate-spin" />
|
||||
)}
|
||||
{confirmDialog === "enable" ? "ENABLE" : "DISABLE"}
|
||||
</PillButton>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
@ -103,7 +103,7 @@ export default function Support() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8 pt-4 w-full">
|
||||
<div className="flex flex-col gap-8 w-full">
|
||||
{/* Support & Help Section */}
|
||||
<section className="flex flex-col gap-4">
|
||||
<SectionTitle>Support & Help</SectionTitle>
|
||||
|
|
|
|||
34
apps/web/components/space-folder-icon.tsx
Normal file
34
apps/web/components/space-folder-icon.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
export function SpaceFolderIcon({
|
||||
size = 16,
|
||||
className,
|
||||
}: {
|
||||
size?: number
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
className={className}
|
||||
aria-hidden
|
||||
>
|
||||
<title>Space</title>
|
||||
{/* paper tab peeking above the folder */}
|
||||
<rect x="7" y="3.2" width="10" height="5" rx="1.6" fill="#F5C518" />
|
||||
{/* folder back with tab */}
|
||||
<path
|
||||
d="M2.6 8.8a2 2 0 0 1 2-2h4.2a2 2 0 0 1 1.5.68l1 1.13a2 2 0 0 0 1.5.69h6.6a2 2 0 0 1 2 2V17a2 2 0 0 1-2 2H4.6a2 2 0 0 1-2-2Z"
|
||||
fill="#161D29"
|
||||
stroke="#3C4658"
|
||||
strokeWidth="1.3"
|
||||
/>
|
||||
{/* lighter front flap for depth */}
|
||||
<path
|
||||
d="M2.6 11.6h18.8V17a2 2 0 0 1-2 2H4.6a2 2 0 0 1-2-2Z"
|
||||
fill="#27313F"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
24
apps/web/components/space-glyph.tsx
Normal file
24
apps/web/components/space-glyph.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { cn } from "@lib/utils"
|
||||
import { SpaceFolderIcon } from "./space-folder-icon"
|
||||
|
||||
export function SpaceGlyph({
|
||||
emoji,
|
||||
size = 16,
|
||||
className,
|
||||
}: {
|
||||
emoji?: string | null
|
||||
size?: number
|
||||
className?: string
|
||||
}) {
|
||||
if (!emoji || emoji === "📁") {
|
||||
return <SpaceFolderIcon size={size} className={cn("shrink-0", className)} />
|
||||
}
|
||||
return (
|
||||
<span
|
||||
className={cn("shrink-0 leading-none", className)}
|
||||
style={{ fontSize: size }}
|
||||
>
|
||||
{emoji}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
39
apps/web/components/space-profile-modal.tsx
Normal file
39
apps/web/components/space-profile-modal.tsx
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
"use client"
|
||||
|
||||
import { dmSansClassName } from "@/lib/fonts"
|
||||
import { SpaceProfileContent } from "@/components/space-profile-panel"
|
||||
import { cn } from "@lib/utils"
|
||||
import { Dialog, DialogContent, DialogTitle } from "@ui/components/dialog"
|
||||
|
||||
type SpaceProfileModalProps = {
|
||||
containerTag: string
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function SpaceProfileModal({
|
||||
containerTag,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: SpaceProfileModalProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className={cn(
|
||||
"w-[94%]! max-w-[440px]! max-h-[82dvh] border border-white/[0.08] bg-[#1B1F24] p-4 rounded-[22px]",
|
||||
"shadow-[0_2.842px_14.211px_rgba(0,0,0,0.25),inset_0.711px_0.711px_0.711px_rgba(255,255,255,0.10)]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
<DialogTitle className="sr-only">Space Profile</DialogTitle>
|
||||
<div className="flex min-h-[420px] flex-col">
|
||||
<SpaceProfileContent
|
||||
containerTag={containerTag}
|
||||
onClose={() => onOpenChange(false)}
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
168
apps/web/components/space-profile-panel.tsx
Normal file
168
apps/web/components/space-profile-panel.tsx
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
"use client"
|
||||
|
||||
import { Brain, X } from "lucide-react"
|
||||
import { motion } from "motion/react"
|
||||
import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
|
||||
import { useSpaceProfile } from "@/hooks/use-space-profile"
|
||||
import { cn } from "@lib/utils"
|
||||
|
||||
type SpaceProfilePanelProps = {
|
||||
containerTag: string
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
type SpaceProfileContentProps = {
|
||||
containerTag: string
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
function CountBadge({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<span className="inline-flex h-[18px] min-w-[22px] items-center justify-center rounded-[4px] bg-white/[0.05] px-1.5 text-[10px] font-semibold text-[#A3A3A3]">
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function LoadingState() {
|
||||
return (
|
||||
<div className="flex flex-col gap-3 pt-1">
|
||||
{Array.from({ length: 5 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="h-10 w-full animate-pulse rounded-[10px] bg-white/[0.04]"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyState() {
|
||||
return (
|
||||
<div className="flex min-h-[180px] flex-col items-center justify-center rounded-[14px] border border-white/[0.08] bg-[#14161A] px-5 py-8 text-center">
|
||||
<div className="mb-3 flex size-10 items-center justify-center rounded-full bg-[#0D121A] shadow-inside-out">
|
||||
<Brain className="size-4 text-[#A3A3A3]" />
|
||||
</div>
|
||||
<p className="text-[14px] font-medium text-[#FAFAFA]">No profile yet</p>
|
||||
<p className="mt-1 text-[12px] leading-relaxed text-[#737373]">
|
||||
Add more memories and Nova will learn about this space.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ProfileSection({ label, items }: { label: string; items: string[] }) {
|
||||
if (items.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[11px] font-semibold uppercase tracking-[0.12em] text-[#737373]",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<CountBadge>{items.length}</CountBadge>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{items.map((item, index) => (
|
||||
<div
|
||||
key={`${label}-${index}-${item}`}
|
||||
className="rounded-[10px] px-2 py-1.5 transition-colors hover:bg-white/[0.03]"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<Brain className="mt-1 size-3 shrink-0 text-[#A3A3A3]" />
|
||||
<p className="line-clamp-3 text-[12px] leading-relaxed text-[#D4D4D4]">
|
||||
{item}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SpaceProfileContent({
|
||||
containerTag,
|
||||
onClose,
|
||||
}: SpaceProfileContentProps) {
|
||||
const { data, isLoading, error } = useSpaceProfile(containerTag)
|
||||
const keyFacts = data?.static ?? []
|
||||
const recentContext = data?.dynamic ?? []
|
||||
const totalCount = keyFacts.length + recentContext.length
|
||||
|
||||
return (
|
||||
<div className={cn(dmSansClassName(), "flex min-h-0 flex-1 flex-col")}>
|
||||
<div className="flex items-start justify-between gap-3 border-b border-white/[0.08] pb-3">
|
||||
<div>
|
||||
<h3
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[15px] font-semibold tracking-[-0.15px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
Space Profile
|
||||
</h3>
|
||||
<p className="mt-0.5 text-[12px] text-[#737373]">
|
||||
What Nova knows in this space
|
||||
</p>
|
||||
</div>
|
||||
{onClose ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close space profile"
|
||||
onClick={onClose}
|
||||
className="flex size-7 shrink-0 items-center justify-center rounded-full bg-[#0D121A] text-[#737373] shadow-inside-out transition-opacity hover:opacity-80 cursor-pointer"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto pt-4 scrollbar-thin">
|
||||
{isLoading ? (
|
||||
<LoadingState />
|
||||
) : error ? (
|
||||
<p className="rounded-[12px] border border-white/[0.08] bg-[#14161A] p-3 text-[13px] text-[#A3A3A3]">
|
||||
Failed to load space profile.
|
||||
</p>
|
||||
) : totalCount === 0 ? (
|
||||
<EmptyState />
|
||||
) : (
|
||||
<div className="flex flex-col gap-5">
|
||||
<ProfileSection label="Key Facts" items={keyFacts} />
|
||||
<ProfileSection label="Recent Context" items={recentContext} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SpaceProfilePanel({
|
||||
containerTag,
|
||||
isOpen,
|
||||
onClose,
|
||||
}: SpaceProfilePanelProps) {
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<motion.aside
|
||||
initial={{ opacity: 0, width: 0, x: 12 }}
|
||||
animate={{ opacity: 1, width: 300, x: 0 }}
|
||||
exit={{ opacity: 0, width: 0, x: 12 }}
|
||||
transition={{ duration: 0.2, ease: [0.4, 0, 0.2, 1] }}
|
||||
className={cn(
|
||||
"hidden h-full w-[300px] shrink-0 flex-col rounded-[18px] border border-white/[0.08] bg-[#1B1F24] p-4 md:flex",
|
||||
"shadow-[0_2.842px_14.211px_rgba(0,0,0,0.25),inset_0.711px_0.711px_0.711px_rgba(255,255,255,0.10)]",
|
||||
)}
|
||||
>
|
||||
<SpaceProfileContent containerTag={containerTag} onClose={onClose} />
|
||||
</motion.aside>
|
||||
)
|
||||
}
|
||||
|
|
@ -12,6 +12,7 @@ import type { ContainerTagListType } from "@lib/types"
|
|||
import { AUTO_CHAT_SPACE_ID } from "@/lib/chat-auto-space"
|
||||
import { AddSpaceModal } from "./add-space-modal"
|
||||
import { SelectSpacesModal } from "./select-spaces-modal"
|
||||
import { SpaceGlyph } from "./space-glyph"
|
||||
import { useProjectMutations } from "@/hooks/use-project-mutations"
|
||||
import { useContainerTags } from "@/hooks/use-container-tags"
|
||||
import { motion } from "motion/react"
|
||||
|
|
@ -52,6 +53,7 @@ export interface SpaceSelectorProps {
|
|||
enableDelete?: boolean
|
||||
compact?: boolean
|
||||
includeAuto?: boolean
|
||||
hideCount?: boolean
|
||||
}
|
||||
|
||||
const triggerVariants = {
|
||||
|
|
@ -111,6 +113,7 @@ export function SpaceSelector({
|
|||
enableDelete = false,
|
||||
compact = false,
|
||||
includeAuto = false,
|
||||
hideCount = false,
|
||||
}: SpaceSelectorProps) {
|
||||
const [showCreateDialog, setShowCreateDialog] = useState(false)
|
||||
const [showSelectSpacesModal, setShowSelectSpacesModal] = useState(false)
|
||||
|
|
@ -418,40 +421,29 @@ export function SpaceSelector({
|
|||
</span>
|
||||
)
|
||||
) : (
|
||||
<span
|
||||
className="shrink-0 text-sm font-bold tracking-[-0.98px]"
|
||||
aria-hidden
|
||||
>
|
||||
{displayInfo.emoji}
|
||||
</span>
|
||||
)}
|
||||
{!compact && (
|
||||
<span
|
||||
className={cn(
|
||||
"min-w-0 truncate text-sm font-medium text-white",
|
||||
"max-w-[10rem] md:max-w-[15rem]",
|
||||
)}
|
||||
title={isLoading ? undefined : displayInfo.name}
|
||||
>
|
||||
{isLoading ? "…" : displayInfo.name}
|
||||
</span>
|
||||
)}
|
||||
{!compact && spaceCountData !== undefined && spaceCountData > 0 && (
|
||||
<span className="shrink-0 text-[11px] text-[#737373] tabular-nums">
|
||||
· {formatCount(spaceCountData)}
|
||||
</span>
|
||||
)}
|
||||
{!compact && (
|
||||
<ChevronDownIcon
|
||||
className="size-3.5 shrink-0 text-[#737373]"
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
{compact && (
|
||||
<span className="sr-only">
|
||||
{isLoading ? "Loading" : displayInfo.name}
|
||||
</span>
|
||||
<SpaceGlyph emoji={displayInfo.emoji} size={compact ? 16 : 18} />
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
"min-w-0 truncate text-sm font-medium text-white",
|
||||
compact ? "max-w-[7rem]" : "max-w-[10rem] md:max-w-[15rem]",
|
||||
)}
|
||||
title={isLoading ? undefined : displayInfo.name}
|
||||
>
|
||||
{isLoading ? "…" : displayInfo.name}
|
||||
</span>
|
||||
{!compact &&
|
||||
!hideCount &&
|
||||
spaceCountData !== undefined &&
|
||||
spaceCountData > 0 && (
|
||||
<span className="shrink-0 text-[11px] text-[#737373] tabular-nums">
|
||||
· {formatCount(spaceCountData)}
|
||||
</span>
|
||||
)}
|
||||
<ChevronDownIcon
|
||||
className="size-3.5 shrink-0 text-[#737373]"
|
||||
aria-hidden
|
||||
/>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className={dmSansClassName()}>
|
||||
|
|
@ -633,7 +625,7 @@ export function SpaceSelector({
|
|||
className="shrink-0 blur-[0.45px]!"
|
||||
/>
|
||||
) : (
|
||||
<span>{p.emoji || "📁"}</span>
|
||||
<SpaceGlyph emoji={p.emoji} size={16} />
|
||||
)}
|
||||
<span className="truncate">
|
||||
{p.containerTag === DEFAULT_PROJECT_ID ? (
|
||||
|
|
|
|||
|
|
@ -6,9 +6,13 @@ import TaskList from "@tiptap/extension-task-list"
|
|||
import TaskItem from "@tiptap/extension-task-item"
|
||||
import { cx } from "class-variance-authority"
|
||||
|
||||
const placeholder = Placeholder.configure({
|
||||
placeholder: 'Write, paste anything or type "/" for commands...',
|
||||
})
|
||||
const DEFAULT_PLACEHOLDER = 'Write, paste anything or type "/" for commands...'
|
||||
|
||||
function createPlaceholder(placeholderText = DEFAULT_PLACEHOLDER) {
|
||||
return Placeholder.configure({
|
||||
placeholder: placeholderText,
|
||||
})
|
||||
}
|
||||
|
||||
const taskList = TaskList.configure({
|
||||
HTMLAttributes: {
|
||||
|
|
@ -82,11 +86,15 @@ const starterKit = StarterKit.configure({
|
|||
gapcursor: false,
|
||||
})
|
||||
|
||||
export const defaultExtensions = [
|
||||
starterKit,
|
||||
placeholder,
|
||||
link,
|
||||
image,
|
||||
taskList,
|
||||
taskItem,
|
||||
]
|
||||
export function createDefaultExtensions(placeholderText?: string) {
|
||||
return [
|
||||
starterKit,
|
||||
createPlaceholder(placeholderText),
|
||||
link,
|
||||
image,
|
||||
taskList,
|
||||
taskItem,
|
||||
]
|
||||
}
|
||||
|
||||
export const defaultExtensions = createDefaultExtensions()
|
||||
|
|
|
|||
|
|
@ -4,30 +4,36 @@ import { useEditor, EditorContent } from "@tiptap/react"
|
|||
import { BubbleMenu } from "@tiptap/react/menus"
|
||||
import type { Editor } from "@tiptap/core"
|
||||
import { Markdown } from "@tiptap/markdown"
|
||||
import { useRef, useEffect, useCallback } from "react"
|
||||
import { defaultExtensions } from "./extensions"
|
||||
import { useRef, useEffect, useCallback, useMemo } from "react"
|
||||
import { createDefaultExtensions } from "./extensions"
|
||||
import { slashCommand } from "./suggestions"
|
||||
import { Bold, Italic, Code } from "lucide-react"
|
||||
import { useDebouncedCallback } from "use-debounce"
|
||||
import { cn } from "@lib/utils"
|
||||
|
||||
const extensions = [...defaultExtensions, slashCommand, Markdown]
|
||||
|
||||
export function TextEditor({
|
||||
content: initialContent,
|
||||
onContentChange,
|
||||
onSubmit,
|
||||
debounceMs = 500,
|
||||
autoFocus = false,
|
||||
placeholder,
|
||||
}: {
|
||||
content: string | undefined
|
||||
onContentChange: (content: string) => void
|
||||
onSubmit: () => void
|
||||
debounceMs?: number
|
||||
autoFocus?: boolean
|
||||
placeholder?: string
|
||||
}) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const editorRef = useRef<Editor | null>(null)
|
||||
const onSubmitRef = useRef(onSubmit)
|
||||
const hasUserEditedRef = useRef(false)
|
||||
const extensions = useMemo(
|
||||
() => [...createDefaultExtensions(placeholder), slashCommand, Markdown],
|
||||
[placeholder],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
onSubmitRef.current = onSubmit
|
||||
|
|
@ -92,6 +98,16 @@ export function TextEditor({
|
|||
}
|
||||
}, [editor, initialContent])
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor || !autoFocus) return
|
||||
|
||||
const id = window.setTimeout(() => {
|
||||
editor.commands.focus("end")
|
||||
}, 0)
|
||||
|
||||
return () => window.clearTimeout(id)
|
||||
}, [editor, autoFocus])
|
||||
|
||||
const handleClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const target = e.target as HTMLElement
|
||||
if (target.closest(".ProseMirror")) {
|
||||
|
|
|
|||
|
|
@ -111,7 +111,7 @@ function CommandMenu({
|
|||
if (!mounted) return null
|
||||
|
||||
return createPortal(
|
||||
<div ref={refs.setFloating} style={floatingStyles} className="z-50">
|
||||
<div ref={refs.setFloating} style={floatingStyles} className="z-[120]">
|
||||
<CommandList
|
||||
items={items}
|
||||
command={command}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,14 @@
|
|||
"use client"
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from "react"
|
||||
import { AnimatePresence, motion } from "motion/react"
|
||||
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
|
||||
import type { z } from "zod"
|
||||
import { cn } from "@lib/utils"
|
||||
import { dmSansClassName } from "@/lib/fonts"
|
||||
import { SyncLogoIcon } from "@ui/assets/icons"
|
||||
import { DocumentIcon } from "@/components/document-icon"
|
||||
import { ChevronDownIcon } from "lucide-react"
|
||||
import { CheckIcon, ChevronDownIcon } from "lucide-react"
|
||||
|
||||
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
|
||||
type DocumentWithMemories = DocumentsResponse["documents"][0]
|
||||
|
|
@ -100,6 +101,39 @@ function getPreviewText(doc: DocumentWithMemories): string {
|
|||
return doc.summary || doc.content || doc.title || ""
|
||||
}
|
||||
|
||||
function isTemporaryId(id: string | null | undefined): boolean {
|
||||
if (!id) return false
|
||||
return id.startsWith("temp-") || id.startsWith("temp-file-")
|
||||
}
|
||||
|
||||
function SelectionBox({
|
||||
isSelected,
|
||||
isPartial = false,
|
||||
}: {
|
||||
isSelected: boolean
|
||||
isPartial?: boolean
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"flex size-4 shrink-0 items-center justify-center rounded-[4px] border transition-colors",
|
||||
isSelected
|
||||
? "border-[#369BFD] bg-[#369BFD]"
|
||||
: isPartial
|
||||
? "border-[#369BFD] bg-[#369BFD]/20"
|
||||
: "border-[#737373] bg-transparent",
|
||||
)}
|
||||
aria-hidden
|
||||
>
|
||||
{isSelected ? (
|
||||
<CheckIcon className="size-3 text-white" strokeWidth={3} />
|
||||
) : isPartial ? (
|
||||
<span className="h-0.5 w-2 rounded-full bg-[#369BFD]" />
|
||||
) : null}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Grouped data structures ─────────────────────────────────────────────────
|
||||
|
||||
type TypeGroup = { categoryInfo: CategoryInfo; docs: DocumentWithMemories[] }
|
||||
|
|
@ -126,7 +160,7 @@ function groupDocuments(
|
|||
}
|
||||
|
||||
return periodOrder.map((label) => {
|
||||
const docs = periodMap.get(label)!
|
||||
const docs = periodMap.get(label) ?? []
|
||||
const categoryMap = new Map<
|
||||
string,
|
||||
{ info: CategoryInfo; docs: DocumentWithMemories[] }
|
||||
|
|
@ -144,9 +178,9 @@ function groupDocuments(
|
|||
|
||||
return {
|
||||
label,
|
||||
typeGroups: categoryOrder.map((key) => {
|
||||
const entry = categoryMap.get(key)!
|
||||
return { categoryInfo: entry.info, docs: entry.docs }
|
||||
typeGroups: categoryOrder.flatMap((key) => {
|
||||
const entry = categoryMap.get(key)
|
||||
return entry ? [{ categoryInfo: entry.info, docs: entry.docs }] : []
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
|
@ -157,30 +191,58 @@ function groupDocuments(
|
|||
function TimelineCard({
|
||||
doc,
|
||||
onOpenDocument,
|
||||
isSelectionMode = false,
|
||||
isSelected = false,
|
||||
onToggleSelection,
|
||||
indent = false,
|
||||
isLast = false,
|
||||
}: {
|
||||
doc: DocumentWithMemories
|
||||
onOpenDocument: (doc: DocumentWithMemories) => void
|
||||
isSelectionMode?: boolean
|
||||
isSelected?: boolean
|
||||
onToggleSelection?: (doc: DocumentWithMemories) => void
|
||||
indent?: boolean
|
||||
isLast?: boolean
|
||||
}) {
|
||||
const preview = getPreviewText(doc)
|
||||
const typeLabel = doc.type
|
||||
? doc.type.charAt(0).toUpperCase() + doc.type.slice(1).replace(/_/g, " ")
|
||||
: "Document"
|
||||
const totalMemories = doc.memoryEntries.length
|
||||
const canSelect = !isTemporaryId(doc.id) && !isTemporaryId(doc.customId)
|
||||
|
||||
const handleClick = () => {
|
||||
if (isSelectionMode && canSelect) {
|
||||
onToggleSelection?.(doc)
|
||||
return
|
||||
}
|
||||
onOpenDocument(doc)
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"w-full text-left px-4 py-3 cursor-pointer transition-colors",
|
||||
"relative w-full text-left px-3 py-3 cursor-pointer transition-colors sm:px-4",
|
||||
indent
|
||||
? "bg-transparent hover:bg-white/[0.04]"
|
||||
: "rounded-2xl border border-[#252B35] bg-[#1B1F24] hover:bg-[#21262D]",
|
||||
? "bg-[#121820] hover:bg-[#17202A]"
|
||||
: "rounded-xl border border-[#252B35] bg-[#1B1F24] hover:bg-[#21262D] sm:rounded-2xl",
|
||||
indent && !isLast && "border-b border-[#252B35]/70",
|
||||
indent && isLast && "rounded-b-xl sm:rounded-b-2xl",
|
||||
isSelectionMode && canSelect && "pl-14 sm:pl-16",
|
||||
isSelectionMode && isSelected && "border-[#369BFD]/70 bg-[#00173C]/45",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
onClick={() => onOpenDocument(doc)}
|
||||
onClick={handleClick}
|
||||
aria-pressed={isSelectionMode ? isSelected : undefined}
|
||||
>
|
||||
{isSelectionMode && canSelect && (
|
||||
<span className="absolute left-5 top-4 z-10">
|
||||
<SelectionBox isSelected={isSelected} />
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Type label */}
|
||||
<div className="flex items-center gap-1.5 mb-2">
|
||||
<DocumentIcon
|
||||
|
|
@ -243,15 +305,23 @@ function GroupCard({
|
|||
isExpanded,
|
||||
onToggle,
|
||||
onOpenDocument,
|
||||
isSelectionMode,
|
||||
selectedDocumentIds,
|
||||
onToggleSelection,
|
||||
expandKey,
|
||||
}: {
|
||||
group: TypeGroup
|
||||
isExpanded: boolean
|
||||
onToggle: () => void
|
||||
onOpenDocument: (doc: DocumentWithMemories) => void
|
||||
isSelectionMode: boolean
|
||||
selectedDocumentIds: Set<string>
|
||||
onToggleSelection?: (documentId: string) => void
|
||||
expandKey: string
|
||||
}) {
|
||||
const firstDoc = group.docs[0]!
|
||||
const firstDoc = group.docs[0]
|
||||
if (!firstDoc) return null
|
||||
|
||||
const preview = getPreviewText(firstDoc)
|
||||
const count = group.docs.length
|
||||
const { label, singularLabel } = group.categoryInfo
|
||||
|
|
@ -260,75 +330,153 @@ function GroupCard({
|
|||
(sum, d) => sum + d.memoryEntries.length,
|
||||
0,
|
||||
)
|
||||
const selectableDocs = group.docs.filter(
|
||||
(doc) => !isTemporaryId(doc.id) && !isTemporaryId(doc.customId) && doc.id,
|
||||
)
|
||||
const selectedCount = selectableDocs.filter(
|
||||
(doc) => doc.id && selectedDocumentIds.has(doc.id),
|
||||
).length
|
||||
const isGroupSelected =
|
||||
selectableDocs.length > 0 && selectedCount === selectableDocs.length
|
||||
const isGroupPartial = selectedCount > 0 && !isGroupSelected
|
||||
|
||||
const handleGroupSelect = () => {
|
||||
for (const doc of selectableDocs) {
|
||||
if (!doc.id) continue
|
||||
const shouldToggle = isGroupSelected
|
||||
? selectedDocumentIds.has(doc.id)
|
||||
: !selectedDocumentIds.has(doc.id)
|
||||
if (shouldToggle) onToggleSelection?.(doc.id)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"w-full text-left rounded-2xl px-4 py-3 cursor-pointer transition-colors",
|
||||
"border border-[#252B35] bg-[#1B1F24] hover:bg-[#21262D]",
|
||||
"flex items-center justify-between gap-3",
|
||||
isExpanded && "rounded-b-none border-b-transparent",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
onClick={onToggle}
|
||||
aria-expanded={isExpanded}
|
||||
>
|
||||
<div className="flex items-center gap-2.5 min-w-0 flex-1">
|
||||
<DocumentIcon
|
||||
type={firstDoc.type}
|
||||
source={firstDoc.source ?? undefined}
|
||||
url={firstDoc.url ?? undefined}
|
||||
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}
|
||||
</span>
|
||||
)}
|
||||
{totalMemories > 0 && (
|
||||
<span
|
||||
className="text-[11px] font-medium shrink-0 ml-auto"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(94deg, #369BFD 4.8%, #36FDFD 77.04%, #36FDB5 143.99%)",
|
||||
backgroundClip: "text",
|
||||
WebkitBackgroundClip: "text",
|
||||
WebkitTextFillColor: "transparent",
|
||||
}}
|
||||
>
|
||||
{totalMemories}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
"size-3.5 text-white/20 shrink-0 transition-transform duration-200",
|
||||
isExpanded && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div
|
||||
id={`group-${expandKey}`}
|
||||
className="border border-t-0 border-[#252B35] rounded-b-2xl overflow-hidden divide-y divide-[#252B35]"
|
||||
>
|
||||
{group.docs.map((doc) => (
|
||||
<TimelineCard
|
||||
key={doc.id}
|
||||
doc={doc}
|
||||
onOpenDocument={onOpenDocument}
|
||||
indent
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-hidden rounded-xl border border-[#252B35] bg-[#1B1F24] sm:rounded-2xl",
|
||||
isSelectionMode &&
|
||||
(isGroupSelected || isGroupPartial) &&
|
||||
"border-[#369BFD]/70 bg-[#00173C]/45",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full items-stretch transition-colors hover:bg-[#21262D]",
|
||||
isExpanded && "border-b border-[#252B35]",
|
||||
isSelectionMode &&
|
||||
(isGroupSelected || isGroupPartial) &&
|
||||
"bg-[#00173C]/45",
|
||||
)}
|
||||
>
|
||||
{isSelectionMode && selectableDocs.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-14 shrink-0 cursor-pointer items-start justify-center py-4 sm:w-16"
|
||||
onClick={handleGroupSelect}
|
||||
aria-label={isGroupSelected ? "Deselect group" : "Select group"}
|
||||
aria-pressed={isGroupSelected}
|
||||
>
|
||||
<SelectionBox
|
||||
isSelected={isGroupSelected}
|
||||
isPartial={isGroupPartial}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex min-w-0 flex-1 cursor-pointer items-start justify-between gap-2 py-3 pr-3 text-left sm:items-center sm:gap-3 sm:pr-4",
|
||||
isSelectionMode && selectableDocs.length > 0
|
||||
? "pl-1"
|
||||
: "pl-3 sm:pl-4",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
onClick={onToggle}
|
||||
aria-expanded={isExpanded}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-x-2.5 gap-y-1">
|
||||
<DocumentIcon
|
||||
type={firstDoc.type}
|
||||
source={firstDoc.source ?? undefined}
|
||||
url={firstDoc.url ?? undefined}
|
||||
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="basis-full text-[12px] leading-relaxed text-white/35 line-clamp-2 sm:basis-auto sm:truncate sm:leading-normal">
|
||||
· {preview}
|
||||
</span>
|
||||
)}
|
||||
{totalMemories > 0 && (
|
||||
<span
|
||||
className="shrink-0 text-[11px] font-medium sm:ml-auto"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(94deg, #369BFD 4.8%, #36FDFD 77.04%, #36FDB5 143.99%)",
|
||||
backgroundClip: "text",
|
||||
WebkitBackgroundClip: "text",
|
||||
WebkitTextFillColor: "transparent",
|
||||
}}
|
||||
>
|
||||
{totalMemories}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
"mt-0.5 size-3.5 text-white/20 shrink-0 transition-transform duration-200 sm:mt-0",
|
||||
isExpanded && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AnimatePresence initial={false}>
|
||||
{isExpanded && (
|
||||
<motion.div
|
||||
id={`group-${expandKey}`}
|
||||
className="overflow-hidden bg-[#121820] shadow-[inset_0_1px_0_rgba(255,255,255,0.03)]"
|
||||
initial={{ height: 0, opacity: 0, y: -4 }}
|
||||
animate={{ height: "auto", opacity: 1, y: 0 }}
|
||||
exit={{ height: 0, opacity: 0, y: -3 }}
|
||||
transition={{
|
||||
height: { duration: 0.28, ease: [0.4, 0, 0.2, 1] },
|
||||
opacity: { duration: 0.18 },
|
||||
y: { duration: 0.22, ease: [0.4, 0, 0.2, 1] },
|
||||
}}
|
||||
>
|
||||
{group.docs.map((doc, index) => (
|
||||
<motion.div
|
||||
key={doc.id}
|
||||
initial={{ opacity: 0, y: -4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -3 }}
|
||||
transition={{
|
||||
duration: 0.18,
|
||||
delay: Math.min(index * 0.025, 0.12),
|
||||
ease: [0.4, 0, 0.2, 1],
|
||||
}}
|
||||
>
|
||||
<TimelineCard
|
||||
doc={doc}
|
||||
onOpenDocument={onOpenDocument}
|
||||
isSelectionMode={isSelectionMode}
|
||||
isSelected={doc.id ? selectedDocumentIds.has(doc.id) : false}
|
||||
onToggleSelection={(doc) => {
|
||||
if (doc.id) onToggleSelection?.(doc.id)
|
||||
}}
|
||||
indent
|
||||
isLast={index === group.docs.length - 1}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -341,6 +489,9 @@ interface TimelineViewProps {
|
|||
hasNextPage?: boolean
|
||||
isFetchingNextPage?: boolean
|
||||
onLoadMore?: () => void
|
||||
isSelectionMode?: boolean
|
||||
selectedDocumentIds?: Set<string>
|
||||
onToggleSelection?: (documentId: string) => void
|
||||
}
|
||||
|
||||
export function TimelineView({
|
||||
|
|
@ -349,6 +500,9 @@ export function TimelineView({
|
|||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
onLoadMore,
|
||||
isSelectionMode = false,
|
||||
selectedDocumentIds = new Set(),
|
||||
onToggleSelection,
|
||||
}: TimelineViewProps) {
|
||||
const [now] = useState(() => new Date())
|
||||
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set())
|
||||
|
|
@ -378,50 +532,131 @@ export function TimelineView({
|
|||
}, [])
|
||||
|
||||
const periodGroups = groupDocuments(documents, now)
|
||||
const handleTimelineCardSelection = useCallback(
|
||||
(doc: DocumentWithMemories) => {
|
||||
if (doc.id) onToggleSelection?.(doc.id)
|
||||
},
|
||||
[onToggleSelection],
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"w-full max-w-[780px] mx-auto py-4 pb-12 space-y-6",
|
||||
"w-full max-w-[820px] mx-auto py-3 pb-12 space-y-5 sm:py-4 sm:space-y-7",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
{periodGroups.map((period) => (
|
||||
<div key={period.label} className="grid grid-cols-[88px_1fr] gap-x-4">
|
||||
<div className="pt-3 text-right shrink-0">
|
||||
<span className="text-[10px] text-white/30 font-medium uppercase tracking-[0.15em] leading-none">
|
||||
{period.label}
|
||||
</span>
|
||||
</div>
|
||||
{periodGroups.map((period, periodIndex) => {
|
||||
const periodHasExpandedGroup = period.typeGroups.some((group) =>
|
||||
expandedGroups.has(`${period.label}::${group.categoryInfo.key}`),
|
||||
)
|
||||
|
||||
<div className="space-y-1.5 min-w-0">
|
||||
{period.typeGroups.map((group) => {
|
||||
const expandKey = `${period.label}::${group.categoryInfo.key}`
|
||||
return (
|
||||
<div
|
||||
key={period.label}
|
||||
className="grid grid-cols-[22px_minmax(0,1fr)] gap-x-2 gap-y-2 sm:grid-cols-[96px_24px_minmax(0,1fr)] sm:gap-x-3 sm:gap-y-0"
|
||||
>
|
||||
<div className="col-start-2 row-start-1 shrink-0 pt-0 sm:col-start-1 sm:row-start-1 sm:pt-3 sm:text-right">
|
||||
<span className="inline-flex rounded-full border border-[#252B35] bg-[#0D121A] px-2.5 py-1 text-[10px] font-medium uppercase leading-none tracking-[0.15em] text-white/45 sm:border-transparent sm:bg-transparent sm:px-0 sm:py-0 sm:text-white/30">
|
||||
{period.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="relative col-start-1 row-start-1 row-span-2 flex justify-center sm:col-start-2 sm:row-start-1 sm:row-span-1">
|
||||
<div
|
||||
className={cn(
|
||||
"absolute top-4 bottom-[-20px] w-px bg-[#1F2835] sm:top-5 sm:bottom-[-28px]",
|
||||
periodIndex === periodGroups.length - 1 && "bottom-2",
|
||||
)}
|
||||
/>
|
||||
<motion.div
|
||||
className={cn(
|
||||
"absolute top-4 bottom-[-20px] w-px origin-top bg-linear-to-b from-[#369BFD] via-[#369BFD]/70 to-transparent sm:top-5 sm:bottom-[-28px]",
|
||||
periodIndex === periodGroups.length - 1 && "bottom-2",
|
||||
)}
|
||||
initial={false}
|
||||
animate={{
|
||||
scaleY: periodHasExpandedGroup ? 1 : 0,
|
||||
opacity: periodHasExpandedGroup ? 1 : 0,
|
||||
}}
|
||||
transition={{ duration: 0.34, ease: [0.4, 0, 0.2, 1] }}
|
||||
/>
|
||||
<motion.div
|
||||
className="relative mt-1.5 flex size-3 items-center justify-center rounded-full border border-[#369BFD]/35 bg-[#0D121A] shadow-[0_0_0_4px_rgba(54,155,253,0.06)] sm:mt-3"
|
||||
initial={false}
|
||||
animate={{
|
||||
borderColor: periodHasExpandedGroup
|
||||
? "rgba(54,155,253,0.8)"
|
||||
: "rgba(54,155,253,0.35)",
|
||||
boxShadow: periodHasExpandedGroup
|
||||
? "0 0 0 7px rgba(54,155,253,0.09)"
|
||||
: "0 0 0 4px rgba(54,155,253,0.06)",
|
||||
}}
|
||||
transition={{ duration: 0.28, ease: [0.4, 0, 0.2, 1] }}
|
||||
>
|
||||
<AnimatePresence>
|
||||
{periodHasExpandedGroup && (
|
||||
<motion.div
|
||||
className="absolute inset-[-6px] rounded-full border border-[#369BFD]/35"
|
||||
initial={{ scale: 0.6, opacity: 0.8 }}
|
||||
animate={{ scale: 1.45, opacity: 0 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{
|
||||
duration: 0.52,
|
||||
ease: [0.2, 0.8, 0.2, 1],
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<motion.div
|
||||
className="size-1.5 rounded-full bg-[#369BFD]"
|
||||
initial={false}
|
||||
animate={{ scale: periodHasExpandedGroup ? 1.15 : 1 }}
|
||||
transition={{ duration: 0.24, ease: [0.4, 0, 0.2, 1] }}
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<div className="col-start-2 row-start-2 min-w-0 space-y-1.5 sm:col-start-3 sm:row-start-1">
|
||||
{period.typeGroups.map((group) => {
|
||||
const expandKey = `${period.label}::${group.categoryInfo.key}`
|
||||
|
||||
if (group.docs.length === 1) {
|
||||
const doc = group.docs[0]
|
||||
if (!doc) return null
|
||||
|
||||
return (
|
||||
<TimelineCard
|
||||
key={expandKey}
|
||||
doc={doc}
|
||||
onOpenDocument={onOpenDocument}
|
||||
isSelectionMode={isSelectionMode}
|
||||
isSelected={
|
||||
doc.id ? selectedDocumentIds.has(doc.id) : false
|
||||
}
|
||||
onToggleSelection={handleTimelineCardSelection}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (group.docs.length === 1) {
|
||||
return (
|
||||
<TimelineCard
|
||||
<GroupCard
|
||||
key={expandKey}
|
||||
doc={group.docs[0]!}
|
||||
group={group}
|
||||
expandKey={expandKey}
|
||||
isExpanded={expandedGroups.has(expandKey)}
|
||||
onToggle={() => toggleGroup(expandKey)}
|
||||
onOpenDocument={onOpenDocument}
|
||||
isSelectionMode={isSelectionMode}
|
||||
selectedDocumentIds={selectedDocumentIds}
|
||||
onToggleSelection={onToggleSelection}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<GroupCard
|
||||
key={expandKey}
|
||||
group={group}
|
||||
expandKey={expandKey}
|
||||
isExpanded={expandedGroups.has(expandKey)}
|
||||
onToggle={() => toggleGroup(expandKey)}
|
||||
onOpenDocument={onOpenDocument}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
|
||||
<div ref={sentinelRef} className="h-1" />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ export function UserProfileMenu({
|
|||
} catch {
|
||||
// still navigate if the request fails (offline, etc.)
|
||||
}
|
||||
router.push("/login/new")
|
||||
router.push("/login")
|
||||
})()
|
||||
}
|
||||
|
||||
|
|
@ -125,28 +125,28 @@ export function UserProfileMenu({
|
|||
<DropdownMenuContent
|
||||
align="end"
|
||||
className={cn(
|
||||
"min-w-[220px] p-1.5 rounded-xl border border-[#2E3033] shadow-[0px_1.5px_20px_0px_rgba(0,0,0,0.65)]",
|
||||
"min-w-[240px] p-1.5 rounded-xl border border-white/[0.08] shadow-[0px_1.5px_20px_0px_rgba(0,0,0,0.65)]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
style={{
|
||||
background: "linear-gradient(180deg, #0A0E14 0%, #05070A 100%)",
|
||||
}}
|
||||
>
|
||||
<div id="user-info" className="px-3 py-2.5">
|
||||
<div id="user-info" className="px-2.5 py-2">
|
||||
<p className="text-white text-sm font-medium truncate">{user.name}</p>
|
||||
<p className="text-[#737373] text-xs truncate">{user.email}</p>
|
||||
</div>
|
||||
<DropdownMenuSeparator className="bg-[#2E3033]" />
|
||||
<DropdownMenuSeparator className="mx-1 my-1.5 bg-white/[0.06]" />
|
||||
<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"
|
||||
className="gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium text-white/85 hover:bg-white/[0.06] focus:bg-white/[0.06] focus:text-white cursor-pointer"
|
||||
>
|
||||
<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"
|
||||
className="gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium text-white/85 hover:bg-white/[0.06] focus:bg-white/[0.06] focus:text-white cursor-pointer"
|
||||
>
|
||||
<RotateCcw className="size-4 text-[#737373]" />
|
||||
Try onboarding
|
||||
|
|
@ -154,16 +154,16 @@ export function UserProfileMenu({
|
|||
{onOpenFeedback ? (
|
||||
<DropdownMenuItem
|
||||
onClick={onOpenFeedback}
|
||||
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
|
||||
className="gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium text-white/85 hover:bg-white/[0.06] focus:bg-white/[0.06] focus:text-white cursor-pointer"
|
||||
>
|
||||
<LifeBuoy className="size-4 text-[#737373]" />
|
||||
Feedback
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuSeparator className="bg-[#2E3033]" />
|
||||
<DropdownMenuSeparator className="mx-1 my-1.5 bg-white/[0.06]" />
|
||||
<DropdownMenuItem
|
||||
asChild
|
||||
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
|
||||
className="gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium text-white/85 hover:bg-white/[0.06] focus:bg-white/[0.06] focus:text-white cursor-pointer"
|
||||
>
|
||||
<a href="mailto:support@supermemory.com">
|
||||
<HelpCircle className="size-4 text-[#737373]" />
|
||||
|
|
@ -172,7 +172,7 @@ export function UserProfileMenu({
|
|||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
asChild
|
||||
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
|
||||
className="gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium text-white/85 hover:bg-white/[0.06] focus:bg-white/[0.06] focus:text-white cursor-pointer"
|
||||
>
|
||||
<a
|
||||
href="https://supermemory.link/discord"
|
||||
|
|
@ -190,10 +190,10 @@ export function UserProfileMenu({
|
|||
Discord
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator className="bg-[#2E3033]" />
|
||||
<DropdownMenuSeparator className="mx-1 my-1.5 bg-white/[0.06]" />
|
||||
<DropdownMenuItem
|
||||
onClick={handleSignOut}
|
||||
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
|
||||
className="gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium text-white/85 hover:bg-white/[0.06] focus:bg-white/[0.06] focus:text-white cursor-pointer"
|
||||
>
|
||||
<LogOut className="size-4 text-[#737373]" />
|
||||
Logout
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@
|
|||
|
||||
--animate-file-upload-grow: file-upload-grow 6s cubic-bezier(0.22, 1, 0.36, 1)
|
||||
forwards;
|
||||
--animate-login-marquee: login-marquee 40s linear infinite;
|
||||
}
|
||||
|
||||
:root {
|
||||
|
|
@ -236,3 +237,319 @@
|
|||
width: 92%;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes login-marquee {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
}
|
||||
to {
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
.login-page-grid {
|
||||
display: grid;
|
||||
min-height: 100dvh;
|
||||
height: 100dvh;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.login-page-grid {
|
||||
height: 100dvh;
|
||||
overflow: hidden;
|
||||
grid-template-columns: 65% 35%;
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.login-page-mobile-bg {
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.login-page-mobile-bg {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.login-tools-panel-inner {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.login-panel-orb {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
radial-gradient(
|
||||
ellipse 130% 100% at 50% 45%,
|
||||
rgba(70, 155, 255, 0.24) 0%,
|
||||
rgba(35, 100, 210, 0.14) 38%,
|
||||
rgba(8, 22, 45, 0.55) 68%,
|
||||
#030912 100%
|
||||
),
|
||||
radial-gradient(
|
||||
ellipse 90% 70% at 50% 100%,
|
||||
rgba(50, 130, 240, 0.16) 0%,
|
||||
transparent 65%
|
||||
);
|
||||
}
|
||||
|
||||
.login-panel-orb-image {
|
||||
position: absolute;
|
||||
inset: -10% -15% -5% -15%;
|
||||
background-image: url("/onboarding/bg-gradient-0.png");
|
||||
background-size: cover;
|
||||
background-position: center 35%;
|
||||
background-repeat: no-repeat;
|
||||
opacity: 0.22;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.login-panel-orb-image-alt {
|
||||
position: absolute;
|
||||
inset: -10% -15% -5% -15%;
|
||||
background-image: url("/onboarding/bg-gradient-1.png");
|
||||
background-size: cover;
|
||||
background-position: center 35%;
|
||||
background-repeat: no-repeat;
|
||||
opacity: 0.11;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.login-tools-section {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
padding: 1.5rem 0 2rem;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.login-tools-section {
|
||||
padding: 1.75rem 0 2.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.login-tools-section::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset-inline: 10%;
|
||||
top: 0;
|
||||
height: 1px;
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
transparent,
|
||||
rgb(255 255 255 / 0.12),
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
.login-tools-marquee {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
mask-image: linear-gradient(
|
||||
to right,
|
||||
transparent,
|
||||
black 10%,
|
||||
black 90%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
.login-tools-marquee-track {
|
||||
display: flex;
|
||||
width: max-content;
|
||||
gap: 0.625rem;
|
||||
animation: login-marquee var(--marquee-duration, 40s) linear infinite;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.login-tools-marquee-track-reverse {
|
||||
animation-direction: reverse;
|
||||
}
|
||||
|
||||
.login-tools-marquee:hover .login-tools-marquee-track {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
|
||||
.login-tools-chip {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
border-radius: 9999px;
|
||||
background: rgb(255 255 255 / 0.05);
|
||||
padding: 0.375rem 0.875rem 0.375rem 0.375rem;
|
||||
box-shadow: 0 8px 24px rgb(0 0 0 / 0.18);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.login-tools-chip-icon {
|
||||
display: flex;
|
||||
width: 1.625rem;
|
||||
height: 1.625rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 9999px;
|
||||
background: rgb(255 255 255 / 0.07);
|
||||
}
|
||||
|
||||
.login-tools-chip-icon svg,
|
||||
.login-tools-chip-icon img {
|
||||
width: 0.875rem;
|
||||
height: 0.875rem;
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
.login-tools-chip-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: rgb(255 255 255 / 0.82);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.login-tool-node-wrap {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.login-tool-node-label {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.4rem);
|
||||
left: 50%;
|
||||
max-width: 5.5rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
transform: translateX(-50%);
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
color: rgb(200 225 255 / 0.95);
|
||||
text-shadow: 0 1px 8px rgb(0 0 0 / 0.65);
|
||||
opacity: 0;
|
||||
transition: opacity 0.25s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.login-tool-node-label-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.login-tool-node {
|
||||
display: flex;
|
||||
width: 2.625rem;
|
||||
height: 2.625rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid rgb(255 255 255 / 0.12);
|
||||
background: rgb(12 22 40 / 0.82);
|
||||
backdrop-filter: blur(12px);
|
||||
box-shadow:
|
||||
0 1px 0 0 rgb(255 255 255 / 0.1) inset,
|
||||
0 6px 20px rgb(0 0 0 / 0.22);
|
||||
transition:
|
||||
border-color 0.35s ease,
|
||||
background 0.35s ease,
|
||||
box-shadow 0.35s ease,
|
||||
transform 0.35s cubic-bezier(0.34, 1.2, 0.64, 1);
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.login-tool-node {
|
||||
width: 2.875rem;
|
||||
height: 2.875rem;
|
||||
border-radius: 0.8125rem;
|
||||
}
|
||||
}
|
||||
|
||||
.login-tool-node-icon {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
opacity: 0.88;
|
||||
transition: opacity 0.35s ease;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.login-tool-node-icon {
|
||||
width: 1.375rem;
|
||||
height: 1.375rem;
|
||||
}
|
||||
}
|
||||
|
||||
.login-tool-node-source {
|
||||
border-color: rgb(100 180 255 / 0.55);
|
||||
background: rgb(14 28 50 / 0.92);
|
||||
box-shadow:
|
||||
0 0 0 1px rgb(100 180 255 / 0.25),
|
||||
0 0 24px rgb(75 160 250 / 0.28),
|
||||
0 1px 0 0 rgb(255 255 255 / 0.1) inset;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.login-tool-node-source .login-tool-node-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.login-tool-node-destination {
|
||||
border-color: rgb(130 210 255 / 0.65);
|
||||
background: rgb(16 32 58 / 0.95);
|
||||
box-shadow:
|
||||
0 0 0 1px rgb(130 210 255 / 0.3),
|
||||
0 0 32px rgb(100 180 255 / 0.35),
|
||||
0 1px 0 0 rgb(255 255 255 / 0.12) inset;
|
||||
transform: scale(1.08);
|
||||
}
|
||||
|
||||
.login-tool-node-destination .login-tool-node-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.login-context-chip {
|
||||
display: flex;
|
||||
width: 2.75rem;
|
||||
height: 1.625rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 0.375rem;
|
||||
border: 1px solid rgb(180 220 255 / 0.35);
|
||||
background: rgb(12 24 44 / 0.95);
|
||||
box-shadow:
|
||||
0 0 16px rgb(100 180 255 / 0.4),
|
||||
0 1px 0 0 rgb(255 255 255 / 0.12) inset;
|
||||
}
|
||||
|
||||
.login-context-chip-lines {
|
||||
display: flex;
|
||||
width: 1.5rem;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.login-context-chip-lines span {
|
||||
display: block;
|
||||
height: 0.125rem;
|
||||
border-radius: 9999px;
|
||||
background: rgb(180 220 255 / 0.55);
|
||||
}
|
||||
|
||||
.login-context-chip-lines span:nth-child(1) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.login-context-chip-lines span:nth-child(2) {
|
||||
width: 78%;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.login-context-chip-lines span:nth-child(3) {
|
||||
width: 52%;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
|
|
|||
80
apps/web/hooks/use-org-settings.ts
Normal file
80
apps/web/hooks/use-org-settings.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { toast } from "sonner"
|
||||
import { $fetch } from "@lib/api"
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
|
||||
const API_BASE = `${process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"}/v3`
|
||||
|
||||
export type OrgSettings = {
|
||||
shouldLLMFilter: boolean
|
||||
filterPrompt: string | null
|
||||
includeItems?: string[] | null
|
||||
excludeItems?: string[] | null
|
||||
}
|
||||
|
||||
type OrgSettingsResponse = {
|
||||
settings?: Partial<OrgSettings>
|
||||
} & Partial<OrgSettings>
|
||||
|
||||
export function useOrgSettings() {
|
||||
const { org } = useAuth()
|
||||
const orgId = org?.id ?? ""
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["settings", "org", orgId],
|
||||
queryFn: async (): Promise<OrgSettings> => {
|
||||
const response = await $fetch("@get/settings", {
|
||||
disableValidation: true,
|
||||
})
|
||||
if (response.error) {
|
||||
throw new Error(response.error.message || "Failed to load settings")
|
||||
}
|
||||
const data = response.data as OrgSettingsResponse | null
|
||||
const settings = data?.settings ?? data ?? {}
|
||||
return {
|
||||
shouldLLMFilter: settings.shouldLLMFilter ?? false,
|
||||
filterPrompt: settings.filterPrompt ?? null,
|
||||
includeItems: settings.includeItems ?? null,
|
||||
excludeItems: settings.excludeItems ?? null,
|
||||
}
|
||||
},
|
||||
enabled: !!orgId,
|
||||
staleTime: 60 * 1000,
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateOrgSettings() {
|
||||
const { org } = useAuth()
|
||||
const queryClient = useQueryClient()
|
||||
const orgId = org?.id ?? ""
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (settings: Partial<OrgSettings>) => {
|
||||
const res = await fetch(`${API_BASE}/settings`, {
|
||||
method: "PATCH",
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-App-Source": "nova",
|
||||
},
|
||||
body: JSON.stringify(settings),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const body = (await res.json().catch(() => ({}))) as {
|
||||
message?: string
|
||||
}
|
||||
throw new Error(body?.message || "Failed to save settings")
|
||||
}
|
||||
return res.json()
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["settings", "org", orgId] })
|
||||
toast.success("Settings saved")
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Failed to save settings",
|
||||
)
|
||||
},
|
||||
})
|
||||
}
|
||||
37
apps/web/hooks/use-space-profile.ts
Normal file
37
apps/web/hooks/use-space-profile.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { useQuery } from "@tanstack/react-query"
|
||||
import { $fetch } from "@lib/api"
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
|
||||
export type SpaceProfile = {
|
||||
static: string[]
|
||||
dynamic: string[]
|
||||
}
|
||||
|
||||
export function useSpaceProfile(containerTag: string) {
|
||||
const { org } = useAuth()
|
||||
const orgId = org?.id ?? ""
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["space-profile", orgId, containerTag],
|
||||
queryFn: async (): Promise<SpaceProfile> => {
|
||||
const response = await $fetch(
|
||||
"@get/container-tags/:containerTag/profile",
|
||||
{
|
||||
params: { containerTag },
|
||||
},
|
||||
)
|
||||
if (response.error) {
|
||||
throw new Error(
|
||||
response.error.message || "Failed to load space profile",
|
||||
)
|
||||
}
|
||||
const profile = response.data.profile
|
||||
return {
|
||||
static: profile.static ?? [],
|
||||
dynamic: profile.dynamic ?? [],
|
||||
}
|
||||
},
|
||||
enabled: !!orgId && !!containerTag,
|
||||
staleTime: 60 * 1000,
|
||||
})
|
||||
}
|
||||
|
|
@ -151,7 +151,7 @@ export const analytics = {
|
|||
|
||||
// settings / spaces / docs analytics
|
||||
settingsTabChanged: (props: {
|
||||
tab: "account" | "integrations" | "connections" | "support"
|
||||
tab: "account" | "billing" | "integrations" | "connections" | "support"
|
||||
}) => safeCapture("settings_tab_changed", props),
|
||||
|
||||
spaceCreated: () => safeCapture("space_created"),
|
||||
|
|
|
|||
|
|
@ -147,3 +147,9 @@ const SPACE_TO_CATALOG_ID: Record<string, string> = {
|
|||
export function spacePluginIdToCatalogId(spacePluginId: string): string | null {
|
||||
return SPACE_TO_CATALOG_ID[spacePluginId] ?? null
|
||||
}
|
||||
|
||||
/** Normalize plugin client ids from API keys, metadata, and space ids. */
|
||||
export function normalizePluginClientId(client: string): string {
|
||||
const trimmed = client.trim().toLowerCase()
|
||||
return spacePluginIdToCatalogId(trimmed) ?? trimmed.replace(/-/g, "_")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { normalizePluginClientId } from "@/lib/plugin-catalog"
|
||||
|
||||
export type PluginSpaceInfo = {
|
||||
pluginId: "claude-code" | "openclaw" | "opencode" | "codex" | "amp"
|
||||
label: string
|
||||
|
|
@ -131,6 +133,48 @@ export function detectPluginSource(
|
|||
}
|
||||
}
|
||||
|
||||
type DocumentSpaceCandidate = {
|
||||
containerTags?: string[]
|
||||
memoryEntries?: Array<{ spaceContainerTag?: string | null }> | null
|
||||
}
|
||||
|
||||
function catalogIdForPluginSpace(
|
||||
pluginId: PluginSpaceInfo["pluginId"],
|
||||
): string {
|
||||
return pluginId.replace(/-/g, "_")
|
||||
}
|
||||
|
||||
/** Resolve the best container tag for a tool/plugin document. */
|
||||
export function getToolDocumentSpace(
|
||||
document: DocumentSpaceCandidate,
|
||||
pluginClientId?: string | null,
|
||||
): string | null {
|
||||
const containerTags = document.containerTags ?? []
|
||||
const memorySpaceTags = (document.memoryEntries ?? [])
|
||||
.map((entry) => entry.spaceContainerTag)
|
||||
.filter((tag): tag is string => !!tag)
|
||||
const allTags = [...containerTags, ...memorySpaceTags]
|
||||
|
||||
if (pluginClientId) {
|
||||
const normalizedClient = normalizePluginClientId(pluginClientId)
|
||||
for (const tag of allTags) {
|
||||
const pluginSpace = detectPluginSpace(tag)
|
||||
if (
|
||||
pluginSpace &&
|
||||
catalogIdForPluginSpace(pluginSpace.pluginId) === normalizedClient
|
||||
) {
|
||||
return tag
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const tag of allTags) {
|
||||
if (detectPluginSpace(tag)) return tag
|
||||
}
|
||||
|
||||
return allTags[0] ?? null
|
||||
}
|
||||
|
||||
export function detectPluginSpace(
|
||||
containerTag: string,
|
||||
): PluginSpaceInfo | null {
|
||||
|
|
|
|||
|
|
@ -42,7 +42,8 @@ export type ViewParamValue = (typeof viewLiterals)[number]
|
|||
export const viewParam =
|
||||
parseAsStringLiteral(viewLiterals).withDefault("dashboard")
|
||||
|
||||
// Kept for backwards compat with components that pass integration hints
|
||||
// Kept for backwards compat with components that pass integration hints.
|
||||
// "notion"/"google-drive" are connection providers, not view modes — they open the connect modal.
|
||||
export type IntegrationParamValue =
|
||||
| "mcp"
|
||||
| "plugins"
|
||||
|
|
@ -51,6 +52,8 @@ export type IntegrationParamValue =
|
|||
| "shortcuts"
|
||||
| "raycast"
|
||||
| "import"
|
||||
| "notion"
|
||||
| "google-drive"
|
||||
export const categoriesParam = parseAsArrayOf(parseAsString, ",").withDefault(
|
||||
[],
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,3 +1,47 @@
|
|||
const PROXY_LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "::1"])
|
||||
|
||||
/** Reconstruct the browser-facing URL when running behind portless (or similar). */
|
||||
export function getPublicRequestUrl(request: Request): URL {
|
||||
const internal = new URL(request.url)
|
||||
const forwardedHost = request.headers
|
||||
.get("x-forwarded-host")
|
||||
?.split(",")[0]
|
||||
?.trim()
|
||||
if (forwardedHost) {
|
||||
const proto = request.headers.get("x-forwarded-proto") || "https"
|
||||
return new URL(
|
||||
`${proto}://${forwardedHost}${internal.pathname}${internal.search}`,
|
||||
)
|
||||
}
|
||||
const portlessUrl = process.env.PORTLESS_URL
|
||||
if (portlessUrl) {
|
||||
try {
|
||||
const base = new URL(portlessUrl)
|
||||
return new URL(`${base.origin}${internal.pathname}${internal.search}`)
|
||||
} catch {}
|
||||
}
|
||||
return internal
|
||||
}
|
||||
|
||||
/** Map portless proxy localhost redirects back to the current public origin. */
|
||||
export function resolveAuthRedirectUrl(
|
||||
redirectUrl: string | null,
|
||||
origin: string,
|
||||
): URL {
|
||||
const fallback = new URL(origin)
|
||||
if (!redirectUrl) return fallback
|
||||
try {
|
||||
const target = new URL(redirectUrl)
|
||||
if (PROXY_LOCAL_HOSTS.has(target.hostname)) {
|
||||
return new URL(`${target.pathname}${target.search}`, origin)
|
||||
}
|
||||
if (target.origin === origin) return target
|
||||
return fallback
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates if a string is a valid URL.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -1,14 +1,22 @@
|
|||
import { getSessionCookie } from "better-auth/cookies"
|
||||
import { NextResponse } from "next/server"
|
||||
import { getPublicRequestUrl } from "@/lib/url-helpers"
|
||||
|
||||
function getAuthSessionCookie(request: Request): string | null {
|
||||
return (
|
||||
getSessionCookie(request) ??
|
||||
getSessionCookie(request, { cookiePrefix: "better-auth-dev" })
|
||||
)
|
||||
}
|
||||
|
||||
export default async function proxy(request: Request) {
|
||||
console.debug("[PROXY] === PROXY START ===")
|
||||
const url = new URL(request.url)
|
||||
const url = getPublicRequestUrl(request)
|
||||
|
||||
console.debug("[PROXY] Path:", url.pathname)
|
||||
console.debug("[PROXY] Method:", request.method)
|
||||
|
||||
const sessionCookie = getSessionCookie(request)
|
||||
const sessionCookie = getAuthSessionCookie(request)
|
||||
console.debug("[PROXY] Session cookie exists:", !!sessionCookie)
|
||||
|
||||
// Always allow access to login and waitlist pages
|
||||
|
|
@ -40,9 +48,9 @@ export default async function proxy(request: Request) {
|
|||
console.debug(
|
||||
"[PROXY] No session cookie and not on public path, redirecting to /login",
|
||||
)
|
||||
const url = new URL("/login", request.url)
|
||||
url.searchParams.set("redirect", request.url)
|
||||
return NextResponse.redirect(url)
|
||||
const loginUrl = new URL("/login", url.origin)
|
||||
loginUrl.searchParams.set("redirect", url.toString())
|
||||
return NextResponse.redirect(loginUrl)
|
||||
}
|
||||
|
||||
// TEMPORARILY DISABLED: Waitlist check
|
||||
|
|
|
|||
2
bun.lock
2
bun.lock
|
|
@ -295,7 +295,7 @@
|
|||
},
|
||||
"packages/memory-graph": {
|
||||
"name": "@supermemory/memory-graph",
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.2",
|
||||
"dependencies": {
|
||||
"d3-force": "^3.0.0",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -164,7 +164,7 @@ export const apiSchema = createSchema({
|
|||
|
||||
// Settings operations
|
||||
"@get/settings": {
|
||||
output: z.object({ settings: z.object({}).passthrough() }),
|
||||
output: z.object({}).passthrough(),
|
||||
},
|
||||
"@patch/settings": {
|
||||
input: SettingsRequestSchema,
|
||||
|
|
@ -254,6 +254,17 @@ export const apiSchema = createSchema({
|
|||
"@get/container-tags/list": {
|
||||
output: ListContainerTagsResponseSchema,
|
||||
},
|
||||
"@get/container-tags/:containerTag/profile": {
|
||||
output: z.object({
|
||||
profile: z.object({
|
||||
static: z.array(z.string()).optional(),
|
||||
dynamic: z.array(z.string()).optional(),
|
||||
}),
|
||||
}),
|
||||
params: z.object({
|
||||
containerTag: z.string(),
|
||||
}),
|
||||
},
|
||||
"@patch/container-tags/:containerTag": {
|
||||
input: UpdateContainerTagSettingsRequestSchema,
|
||||
output: ContainerTagSettingsUpdateSchema,
|
||||
|
|
|
|||
|
|
@ -26,8 +26,9 @@ interface AuthContextType {
|
|||
isRestoring: boolean
|
||||
isSessionPending: boolean
|
||||
setActiveOrg: (orgSlug: string) => Promise<void>
|
||||
clearActiveOrg: () => void
|
||||
clearActiveOrg: () => Promise<void>
|
||||
updateOrgMetadata: (partial: Record<string, unknown>) => void
|
||||
refetchActiveOrg: () => Promise<Organization | null>
|
||||
refetchOrganizations: () => Promise<unknown>
|
||||
}
|
||||
|
||||
|
|
@ -61,7 +62,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||
localStorage.setItem(STORAGE_KEY, slug)
|
||||
}, [])
|
||||
|
||||
const clearActiveOrg = useCallback(() => {
|
||||
const clearActiveOrg = useCallback(async () => {
|
||||
try {
|
||||
await authClient.organization.setActive({ organizationId: null })
|
||||
} catch {}
|
||||
setOrg(null)
|
||||
try {
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
|
|
@ -81,6 +85,13 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||
})
|
||||
}, [])
|
||||
|
||||
const refetchActiveOrg = useCallback(async () => {
|
||||
const full = await authClient.organization.getFullOrganization()
|
||||
const nextOrg = full?.data ?? null
|
||||
setOrg(nextOrg)
|
||||
return nextOrg
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (isSessionPending) return
|
||||
|
||||
|
|
@ -198,6 +209,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||
setActiveOrg,
|
||||
clearActiveOrg,
|
||||
updateOrgMetadata,
|
||||
refetchActiveOrg,
|
||||
refetchOrganizations,
|
||||
}}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@supermemory/memory-graph",
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.2",
|
||||
"description": "Interactive graph visualization component for Supermemory - visualize and explore your memory connections",
|
||||
"type": "module",
|
||||
"main": "./src/index.tsx",
|
||||
|
|
|
|||
|
|
@ -310,11 +310,7 @@ export const Legend = memo(function Legend({
|
|||
const updateNodeCount = nodes.filter(isUpdateMemoryNode).length
|
||||
|
||||
const outerStyle: React.CSSProperties = {
|
||||
position: "absolute",
|
||||
zIndex: 20,
|
||||
overflow: "hidden",
|
||||
bottom: 16,
|
||||
left: 16,
|
||||
width: 214,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -657,11 +657,15 @@ export function MemoryGraph({
|
|||
justifyContent: "center",
|
||||
}
|
||||
|
||||
const navControlsStyle: React.CSSProperties = {
|
||||
const bottomLeftStackStyle: React.CSSProperties = {
|
||||
position: "absolute",
|
||||
bottom: isCompactViewport ? 148 : 72,
|
||||
bottom: 16,
|
||||
left: 16,
|
||||
zIndex: 15,
|
||||
zIndex: 20,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "flex-start",
|
||||
gap: 8,
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -718,29 +722,27 @@ export function MemoryGraph({
|
|||
/>
|
||||
)}
|
||||
|
||||
<div>
|
||||
{containerSize.width > 0 && (
|
||||
<div style={navControlsStyle}>
|
||||
<NavigationControls
|
||||
nodes={nodes}
|
||||
compact={isCompactViewport}
|
||||
onAutoFit={handleAutoFit}
|
||||
onCenter={handleCenter}
|
||||
onZoomIn={handleZoomIn}
|
||||
onZoomOut={handleZoomOut}
|
||||
zoomLevel={zoomDisplay}
|
||||
colors={colors}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<Legend
|
||||
colors={colors}
|
||||
edges={edges}
|
||||
hoveredNode={hoveredNode}
|
||||
isLoading={isLoading}
|
||||
nodes={nodes}
|
||||
/>
|
||||
</div>
|
||||
{containerSize.width > 0 && (
|
||||
<div style={bottomLeftStackStyle}>
|
||||
<NavigationControls
|
||||
nodes={nodes}
|
||||
compact={isCompactViewport}
|
||||
onAutoFit={handleAutoFit}
|
||||
onCenter={handleCenter}
|
||||
onZoomIn={handleZoomIn}
|
||||
onZoomOut={handleZoomOut}
|
||||
zoomLevel={zoomDisplay}
|
||||
colors={colors}
|
||||
/>
|
||||
<Legend
|
||||
colors={colors}
|
||||
edges={edges}
|
||||
hoveredNode={hoveredNode}
|
||||
isLoading={isLoading}
|
||||
nodes={nodes}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -193,6 +193,9 @@ async def add_system_prompt(
|
|||
},
|
||||
)
|
||||
|
||||
if not memories:
|
||||
return messages
|
||||
|
||||
if system_prompt_exists:
|
||||
logger.debug("Added memories to existing system prompt")
|
||||
return [
|
||||
|
|
|
|||
|
|
@ -318,6 +318,65 @@ class TestMemoryInjection:
|
|||
assert "User prefers Python" in system_message["content"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_memories_do_not_modify_messages(
|
||||
self, mock_async_openai_client, mock_openai_response
|
||||
):
|
||||
"""No system prompt should be injected when the memory lookup returns nothing.
|
||||
|
||||
Previously, ``add_system_prompt`` would still modify messages even when
|
||||
``memories`` resolved to an empty string: it appended `` \\n `` (whitespace) to
|
||||
any existing system prompt, or prepended a new ``{"role": "system", "content": ""}``
|
||||
message when none existed. Both outcomes pollute the conversation context with
|
||||
meaningless tokens and can confuse the downstream model.
|
||||
"""
|
||||
original_create = AsyncMock(return_value=mock_openai_response)
|
||||
mock_async_openai_client.chat.completions.create = original_create
|
||||
|
||||
empty_response = {
|
||||
"profile": {"static": [], "dynamic": []},
|
||||
"searchResults": {"results": []},
|
||||
}
|
||||
|
||||
with patch.dict(os.environ, {"SUPERMEMORY_API_KEY": "test-key"}):
|
||||
with patch("supermemory_openai.middleware.supermemory_profile_search") as mock_search:
|
||||
mock_search.return_value = Mock()
|
||||
mock_search.return_value.profile = empty_response["profile"]
|
||||
mock_search.return_value.search_results = empty_response["searchResults"]
|
||||
|
||||
wrapped_client = with_supermemory(
|
||||
mock_async_openai_client,
|
||||
OpenAIMiddlewareOptions(
|
||||
container_tag="user-123", custom_id="test-conv", mode="full"
|
||||
),
|
||||
)
|
||||
|
||||
# Case 1: no pre-existing system prompt — no system message should be prepended
|
||||
user_only_messages = [{"role": "user", "content": "Hello"}]
|
||||
await wrapped_client.chat.completions.create(
|
||||
model="gpt-4", messages=user_only_messages
|
||||
)
|
||||
sent_messages = original_create.call_args[1]["messages"]
|
||||
assert sent_messages == user_only_messages, (
|
||||
"An empty-memory response must not prepend a blank system message"
|
||||
)
|
||||
|
||||
original_create.reset_mock()
|
||||
|
||||
# Case 2: existing system prompt — it must not be modified
|
||||
with_system = [
|
||||
{"role": "system", "content": "You are helpful."},
|
||||
{"role": "user", "content": "Hello"},
|
||||
]
|
||||
await wrapped_client.chat.completions.create(
|
||||
model="gpt-4", messages=with_system
|
||||
)
|
||||
sent_messages = original_create.call_args[1]["messages"]
|
||||
assert sent_messages[0]["content"] == "You are helpful.", (
|
||||
"An empty-memory response must not append whitespace to the existing system prompt"
|
||||
)
|
||||
|
||||
|
||||
class TestMemoryStorage:
|
||||
"""Test memory storage functionality."""
|
||||
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ function DialogOverlay({
|
|||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50 backdrop-blur-[4px]",
|
||||
className,
|
||||
)}
|
||||
data-slot="dialog-overlay"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue