Merge remote-tracking branch 'origin/main' into feat/personalisation

# Conflicts:
#	apps/web/components/dashboard-view.tsx
#	apps/web/middleware.ts
This commit is contained in:
Ishaan Gupta 2026-05-21 16:07:51 +05:30
commit 5673d4c7ef
135 changed files with 5399 additions and 3237 deletions

View file

@ -213,6 +213,7 @@
"integrations/openclaw",
"integrations/claude-code",
"integrations/opencode",
"integrations/codex",
"integrations/hermes"
]
}

View file

@ -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"}'
```

View file

@ -0,0 +1,169 @@
---
title: "OpenAI Codex"
sidebarTitle: "OpenAI Codex"
description: "codex-supermemory — persistent memory for OpenAI Codex CLI"
icon: "terminal"
---
[codex-supermemory](https://github.com/supermemoryai/codex-supermemory) wires Supermemory into the [OpenAI Codex CLI](https://github.com/openai/codex) via hooks and skills. Your agent gets **two layers of memory**:
- **Implicit** (hooks) — automatically recalls context before each prompt and captures conversations after each session.
- **Explicit** (skills) — lets you or the agent save, search, and manage memories on demand.
## Get Your API Key
Create a Supermemory API key from the [API Keys](https://console.supermemory.ai/keys) page, then export it in your shell profile:
<Tabs>
<Tab title="macOS / Linux (zsh)">
```bash
echo 'export SUPERMEMORY_CODEX_API_KEY="sm_..."' >> ~/.zshrc
source ~/.zshrc
```
</Tab>
<Tab title="macOS / Linux (bash)">
```bash
echo 'export SUPERMEMORY_CODEX_API_KEY="sm_..."' >> ~/.bashrc
source ~/.bashrc
```
</Tab>
<Tab title="Windows (PowerShell)">
```powershell
[System.Environment]::SetEnvironmentVariable("SUPERMEMORY_CODEX_API_KEY", "sm_...", "User")
```
Restart your terminal after running this.
</Tab>
</Tabs>
## Install the Plugin
```bash
npx codex-supermemory@latest install
```
This command:
- Copies hook and skill scripts to `~/.codex/supermemory/`
- Enables `codex_hooks = true` in `~/.codex/config.toml`
- Registers `UserPromptSubmit` (recall) and `Stop` (capture) hooks in `~/.codex/hooks.json`
- Installs `supermemory-search`, `supermemory-save`, and `supermemory-forget` skills to `~/.codex/skills/`
Restart Codex CLI after installing.
## How It Works
Once installed, the plugin runs automatically on every Codex session:
- **Recall** — Before each prompt, relevant memories and your user profile are fetched from Supermemory and injected as additional context.
- **Capture** — After each session ends, the conversation transcript is ingested into Supermemory, scoped to the current project and user.
- **Privacy** — Content wrapped in `<private>...</private>` tags is redacted before storage.
### Memory Scopes
Memories are tagged with two container tags per session, auto-derived from your environment:
| Tag | Derived from | Description |
|-----|-------------|-------------|
| User | `git config user.email` (hashed) | Memories shared across all your projects |
| Project | Current working directory (hashed) | Memories scoped to the current repo |
Tags are generated automatically — no configuration needed. You can override them in `~/.codex/supermemory.json` if needed:
```json
{
"userContainerTag": "my-custom-user-tag",
"projectContainerTag": "my-custom-project-tag"
}
```
## Explicit Memory Skills
The installer includes three skills that Codex auto-discovers from `~/.codex/skills/`. They use the same `SUPERMEMORY_CODEX_API_KEY` as the hooks — no separate login needed.
| Skill | Description |
|-------|-------------|
| `supermemory-search` | Search your memories by natural-language query |
| `supermemory-save` | Save important project knowledge to memory |
| `supermemory-forget` | Remove outdated or incorrect memories |
These skills let you interact with memory explicitly — for example:
```
> Remember that this project uses Vitest for unit tests and Playwright for E2E.
> What do you remember about our database schema?
> Forget the memory about the old API endpoint.
```
## Verify Installation
```bash
npx codex-supermemory status
```
Expected output when everything is configured:
```
codex-supermemory status:
API key: ✓ set (SUPERMEMORY_CODEX_API_KEY)
Hook scripts: ✓ installed at ~/.codex/supermemory
hooks.json: ✓ registered (implicit memory)
Skills: ✓ installed (supermemory-search, supermemory-save, supermemory-forget)
config.toml: ✓ exists
All good! Memory is active.
```
## Uninstall
```bash
npx codex-supermemory uninstall
```
This removes the hook registrations and skill scripts from `~/.codex/supermemory/`, removes skill directories from `~/.codex/skills/`, and disables `codex_hooks` in `~/.codex/config.toml`. Your existing memories in Supermemory are preserved.
## Configuration
Create `~/.codex/supermemory.json` to override defaults:
```json
{
"apiKey": "sm_...",
"similarityThreshold": 0.6,
"maxMemories": 5,
"maxProfileItems": 5,
"injectProfile": true,
"containerTagPrefix": "codex",
"debug": false
}
```
| Option | Default | Description |
|--------|---------|-------------|
| `apiKey` | — | API key (overrides env var) |
| `similarityThreshold` | `0.6` | Minimum match score for recall (01) |
| `maxMemories` | `5` | Max memories injected per prompt |
| `maxProfileItems` | `5` | Max profile facts injected per prompt |
| `injectProfile` | `true` | Include user profile in context |
| `containerTagPrefix` | `"codex"` | Prefix for container tags |
| `debug` | `false` | Write debug logs to `~/.codex-supermemory.log` |
## Logging
Enable debug logging to trace hook activity:
```bash
export SUPERMEMORY_DEBUG=true
tail -f ~/.codex-supermemory.log
```
## Next Steps
<CardGroup cols={2}>
<Card title="GitHub Repository" icon="github" href="https://github.com/supermemoryai/codex-supermemory">
Source code, issues, and detailed README.
</Card>
<Card title="Claude Code Plugin" icon="code" href="/integrations/claude-code">
Memory plugin for Claude Code.
</Card>
</CardGroup>

View file

@ -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:

View file

@ -329,7 +329,7 @@ export class SupermemoryClient {
async getDocuments(
containerTags?: string[],
page = 1,
limit = 200,
limit = 10,
): Promise<DocumentsApiResponse> {
try {
const response = await fetch(`${this.apiUrl}/v3/documents/documents`, {

View file

@ -318,7 +318,7 @@ export class SupermemoryMCP extends McpAgent<Env, unknown, Props> {
? [effectiveContainerTag]
: undefined
const result = await client.getDocuments(containerTags, 1, 200)
const result = await client.getDocuments(containerTags, 1, 10)
const memoryCount = result.documents.reduce(
(sum, d) => sum + d.memoryEntries.length,
@ -366,7 +366,7 @@ export class SupermemoryMCP extends McpAgent<Env, unknown, Props> {
inputSchema: z.object({
containerTag: z.string().optional(),
page: z.number().optional().default(1),
limit: z.number().optional().default(200),
limit: z.number().optional().default(10),
}),
_meta: {
ui: {

View file

@ -1,15 +1,15 @@
"use client"
import { EnsureWorkspace } from "@/components/ensure-workspace"
import { MobileBanner } from "@/components/mobile-banner"
import { NextAppResearchCta } from "@/components/next-app-research-cta"
import { PWAInstallPrompt } from "@/components/pwa-install-prompt"
export default function AppLayout({ children }: { children: React.ReactNode }) {
return (
<>
<MobileBanner />
<EnsureWorkspace>{children}</EnsureWorkspace>
<NextAppResearchCta />
<PWAInstallPrompt />
</>
)
}

View file

@ -159,6 +159,9 @@ export default function NewPage() {
const [fullscreenInitialContent, setFullscreenInitialContent] = useState("")
const [queuedChatSeed, setQueuedChatSeed] = useState<string | null>(null)
const [queuedChatModel, setQueuedChatModel] = useState<ModelId | null>(null)
const [queuedChatProject, setQueuedChatProject] = useState<string | null>(
null,
)
const [queuedHighlightContent, setQueuedHighlightContent] = useState<
string | null
>(null)
@ -360,7 +363,7 @@ export default function NewPage() {
new Date().toISOString().slice(0, 10),
],
queryFn: async (): Promise<MemoryOfDay | null> => {
const cacheKey = `memory-of-day:${user?.id}:${new Date().toISOString().slice(0, 10)}`
const cacheKey = `memory-of-day:v2:${user?.id}:${new Date().toISOString().slice(0, 10)}`
try {
const stored = localStorage.getItem(cacheKey)
if (stored) return JSON.parse(stored) as MemoryOfDay
@ -488,6 +491,7 @@ export default function NewPage() {
setQueuedHighlightContent(highlightContent)
setQueuedChatSeed(userReply)
setQueuedChatModel(null)
setQueuedChatProject(null)
setQueuedMessageSource("highlight")
void setViewMode("chat")
},
@ -495,10 +499,11 @@ export default function NewPage() {
)
const handleHomeChatStart = useCallback(
(message: string, model: ModelId) => {
(message: string, model: ModelId, projectId: string) => {
setQueuedHighlightContent(null)
setQueuedChatSeed(message)
setQueuedChatModel(model)
setQueuedChatProject(projectId)
setQueuedMessageSource("home")
void setViewMode("chat")
},
@ -508,6 +513,7 @@ export default function NewPage() {
const consumeQueuedChat = useCallback(() => {
setQueuedChatSeed(null)
setQueuedChatModel(null)
setQueuedChatProject(null)
setQueuedHighlightContent(null)
setQueuedMessageSource("highlight")
}, [])
@ -523,9 +529,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(() => {
@ -563,20 +573,17 @@ export default function NewPage() {
)}
>
{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 />
@ -623,7 +630,7 @@ export default function NewPage() {
onConsumeQueuedMessage={consumeQueuedChat}
queuedMessageSource={queuedMessageSource}
initialSelectedModel={queuedChatModel}
emptyStateSuggestions={highlightsData?.questions}
initialChatProject={queuedChatProject}
/>
</div>
) : viewMode === "integrations" ? (

View file

@ -0,0 +1,16 @@
"use client"
import { useEffect } from "react"
import { useRouter, useSearchParams } from "next/navigation"
export default function SettingsIntegrationsPage() {
const router = useRouter()
const searchParams = useSearchParams()
useEffect(() => {
const qs = searchParams.toString()
router.replace(`/settings${qs ? `?${qs}` : ""}#integrations`)
}, [router, searchParams])
return null
}

View file

@ -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,264 @@ 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 && (
<Popover
open={orgSwitcherOpen && canSwitchOrg}
onOpenChange={(open) => {
if (canSwitchOrg) setOrgSwitcherOpen(open)
}}
>
<PopoverTrigger asChild>
<button
type="button"
disabled={!canSwitchOrg}
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]",
canSwitchOrg
? "cursor-pointer hover:bg-white/[0.06]"
: "cursor-default",
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} />
{canSwitchOrg && (
<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(),
)}
>
<NovaOrb size={140} className="blur-[3px]!" />
<UserSupermemory name={headerDisplayName} />
</motion.div>
{[...(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>
)}
<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 +562,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>

View file

@ -17,6 +17,7 @@ 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"
function isMcpOAuthAuthorizeContext(sp: Pick<URLSearchParams, "get">): boolean {
return sp.get("response_type") === "code" && Boolean(sp.get("client_id"))
@ -113,9 +114,25 @@ export default function LoginPage() {
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])
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")
@ -128,17 +145,7 @@ export default function LoginPage() {
return buildMcpAuthorizeResumeUrl(params)
}
let finalUrl: URL
if (redirectUrl) {
try {
finalUrl = new URL(redirectUrl, origin)
} catch {
finalUrl = new URL(origin)
}
} else {
finalUrl = new URL(origin)
}
const finalUrl = resolveAuthRedirectUrl(redirectUrl, origin)
finalUrl.searchParams.set("extension-auth-success", "true")
return finalUrl.toString()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 30 KiB

View file

@ -1,4 +1,4 @@
import type { Metadata } from "next"
import type { Metadata, Viewport } from "next"
import { Space_Grotesk } from "next/font/google"
import "../globals.css"
import "@ui/globals.css"
@ -20,9 +20,26 @@ const font = Space_Grotesk({
export const metadata: Metadata = {
metadataBase: new URL("https://app.supermemory.ai"),
description: "Your memories, wherever you are",
icons: {
icon: [
{ url: "/favicon.ico", sizes: "any" },
{ url: "/favicon-16x16.png", sizes: "16x16", type: "image/png" },
{ url: "/favicon-32x32.png", sizes: "32x32", type: "image/png" },
],
apple: [
{ url: "/apple-touch-icon.png", sizes: "180x180", type: "image/png" },
],
},
manifest: "/manifest.webmanifest",
title: "supermemory app",
}
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
viewportFit: "cover",
}
export default function RootLayout({
children,
}: Readonly<{

View file

@ -11,10 +11,15 @@ export default function manifest(): MetadataRoute.Manifest {
theme_color: "#000000",
icons: [
{
src: "/images/logo.png",
src: "/android-chrome-192x192.png",
sizes: "192x192",
type: "image/png",
},
{
src: "/android-chrome-512x512.png",
sizes: "512x512",
type: "image/png",
},
],
}
}

View file

@ -34,6 +34,7 @@ import {
import { RemoveConnectionDialog } from "@/components/remove-connection-dialog"
import { SyncStatusBadge } from "@/components/settings/sync-status-badge"
import { SyncHistoryPanel } from "@/components/settings/sync-history-panel"
import { useConnectionHealth } from "@/hooks/use-connection-health"
import { useTriggerSync } from "@/hooks/use-trigger-sync"
import { formatRelativeTime } from "@/components/settings/sync-utils"
import type { ImportProvider } from "@/components/settings/sync-utils"
@ -89,11 +90,6 @@ function getConnectionMeta(connection: Connection) {
}
}
/** Check if a connection's auth token has expired. */
function isConnectionExpired(connection: Connection): boolean {
return !!connection.expiresAt && new Date(connection.expiresAt) <= new Date()
}
function ConnectionRow({
connection,
onDelete,
@ -101,6 +97,8 @@ function ConnectionRow({
projects,
onTriggerSync,
isSyncing,
onReconnect,
isReconnecting,
}: {
connection: Connection
onDelete: () => void
@ -108,14 +106,17 @@ function ConnectionRow({
projects: Project[]
onTriggerSync: () => void
isSyncing: boolean
onReconnect: () => void
isReconnecting: boolean
}) {
const [historyOpen, setHistoryOpen] = useState(false)
const config = CONNECTORS[connection.provider as ConnectorProvider]
const { needsReauth } = useConnectionHealth(connection.id)
if (!config) return null
const Icon = config.icon
const meta = getConnectionMeta(connection)
const expired = isConnectionExpired(connection)
const expired = needsReauth
const getProjectName = (tag: string): string => {
if (tag === DEFAULT_PROJECT_ID) return "Default"
@ -137,14 +138,14 @@ function ConnectionRow({
)}
>
<div className="flex flex-col gap-3">
<div className="flex items-center gap-4">
<div className="flex items-center gap-3">
<Icon className="size-6 shrink-0" />
<div className="flex-1 flex flex-col gap-1">
<div className="flex items-center gap-3">
<div className="min-w-0 flex-1 flex flex-col gap-1">
<div className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1">
<span
className={cn(
dmSans125ClassName(),
"font-medium text-[16px] text-[#FAFAFA]",
"truncate font-medium text-[16px] text-[#FAFAFA]",
)}
>
{config.title}
@ -156,41 +157,54 @@ function ConnectionRow({
/>
</div>
<span
className={cn(dmSans125ClassName(), "text-[14px] text-[#737373]")}
className={cn(
dmSans125ClassName(),
"truncate text-[14px] text-[#737373]",
)}
>
{connection.email || "Unknown"}
</span>
</div>
<div className="flex items-center gap-0.5">
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onTriggerSync()
}}
disabled={isSyncing || expired}
className="text-[#737373] hover:text-[#4BA0FA] transition-colors disabled:opacity-50 disabled:cursor-not-allowed p-1.5 rounded-lg hover:bg-white/5"
aria-label={
expired
? "Connection expired"
: isSyncing
? "Sync in progress"
: "Sync now"
}
title={
expired
? "Reconnect to sync"
: isSyncing
? "Sync in progress"
: "Sync now"
}
>
{isSyncing ? (
<Loader2 className="size-[18px] animate-spin" />
) : (
<Play className="size-[18px]" />
)}
</button>
<div className="flex shrink-0 items-center gap-0.5">
{expired ? (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onReconnect()
}}
disabled={isReconnecting}
className={cn(
dmSans125ClassName(),
"flex items-center gap-1.5 rounded-full bg-[#EF4444]/15 px-3 py-1.5 text-[12px] font-medium text-[#EF4444] transition-colors hover:bg-[#EF4444]/25 disabled:opacity-60 disabled:cursor-not-allowed",
)}
aria-label="Reconnect"
>
{isReconnecting ? (
<Loader2 className="size-[14px] animate-spin" />
) : (
"Reconnect"
)}
</button>
) : (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onTriggerSync()
}}
disabled={isSyncing}
className="text-[#737373] hover:text-[#4BA0FA] transition-colors disabled:opacity-50 disabled:cursor-not-allowed p-1.5 rounded-lg hover:bg-white/5"
aria-label={isSyncing ? "Sync in progress" : "Sync now"}
title={isSyncing ? "Sync in progress" : "Sync now"}
>
{isSyncing ? (
<Loader2 className="size-[18px] animate-spin" />
) : (
<Play className="size-[18px]" />
)}
</button>
)}
<button
type="button"
onClick={(e) => {
@ -411,6 +425,42 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
},
})
const reconnectMutation = useMutation({
mutationFn: async ({
connectionId: _connectionId,
provider,
containerTags,
}: {
connectionId: string
provider: ConnectorProvider
containerTags: string[] | undefined
}) => {
const response = await $fetch("@post/connections/:provider", {
params: { provider },
body: {
redirectUrl: window.location.href,
containerTags: containerTags ?? [selectedProject],
},
})
if ("data" in response && response.data && !("error" in response.data)) {
return response.data
}
throw new Error(response.error?.message || "Failed to reconnect")
},
onSuccess: (data) => {
if (data?.authLink) {
window.location.href = data.authLink
return
}
toast.error("Reconnect link missing — try again.")
},
onError: (error) => {
toast.error("Failed to reconnect", {
description: error instanceof Error ? error.message : "Unknown error",
})
},
})
const deleteConnectionMutation = useMutation({
mutationFn: async ({
connectionId,
@ -454,7 +504,7 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
connectingProvider !== null || addConnectionMutation.isPending
return (
<div className="h-full flex flex-col pt-4 space-y-4">
<div className="h-full flex flex-col pt-0 space-y-4 md:pt-4">
{/* Top header — only when empty; once connected, the Add CTA moves into the list header below */}
{!hasConnections && (
<div className="flex items-center justify-between px-2">
@ -571,13 +621,16 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
{/* Connected list - rich rows with status / project / last sync / doc count */}
{hasConnections && (
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between gap-3 px-1">
<div className="flex flex-col gap-0.5">
<div className="flex items-center justify-between gap-2 px-1">
<div className="flex min-w-0 flex-col gap-0.5">
<div className="flex items-center gap-2">
<p className="text-[16px] font-semibold">
Connected to Supermemory
<p className="truncate text-[16px] font-semibold">
<span className="hidden sm:inline">
Connected to Supermemory
</span>
<span className="sm:hidden">Connections</span>
</p>
<span className="bg-[#4BA0FA] text-black text-[10px] font-bold px-1 py-[2px] rounded-[3px]">
<span className="shrink-0 bg-[#4BA0FA] text-black text-[10px] font-bold px-1 py-[2px] rounded-[3px]">
PRO
</span>
</div>
@ -593,13 +646,16 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
<button
type="button"
disabled={!isProUser || isAnyConnecting}
className="flex items-center gap-1.5 bg-[#4BA0FA] text-black hover:bg-[#4BA0FA]/90 disabled:opacity-50 disabled:cursor-not-allowed text-[13px] font-medium rounded-full h-8 px-3 transition-colors shrink-0"
className="flex shrink-0 items-center gap-1.5 bg-[#4BA0FA] text-black hover:bg-[#4BA0FA]/90 disabled:opacity-50 disabled:cursor-not-allowed text-[13px] font-medium rounded-full h-8 px-3 transition-colors"
>
{isAnyConnecting ? (
<Loader className="size-3.5 animate-spin" />
) : (
<>
<span>+ Add a connection</span>
<span className="hidden sm:inline">
+ Add a connection
</span>
<span className="sm:hidden">+ Add</span>
<ChevronDown className="size-3" />
</>
)}
@ -722,6 +778,17 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
triggerSync.variables?.connectionId === connection.id) ||
getConnectionMeta(connection).syncInProgress
}
onReconnect={() => {
reconnectMutation.mutate({
connectionId: connection.id,
provider: connection.provider as ConnectorProvider,
containerTags: connection.containerTags,
})
}}
isReconnecting={
reconnectMutation.isPending &&
reconnectMutation.variables?.connectionId === connection.id
}
/>
))}
</div>

View file

@ -187,7 +187,12 @@ export function FileContent({
const hasItems = data.items.length > 0
return (
<div className={cn("h-full flex flex-col gap-6 pt-4", dmSansClassName())}>
<div
className={cn(
"h-full flex flex-col gap-6 pt-0 md:pt-4",
dmSansClassName(),
)}
>
<div className="flex flex-col gap-2">
<p className="text-[16px] font-medium pl-2">
Upload files (images, PDF, documents, sheets, markdown)

View file

@ -3,9 +3,10 @@
import { useState, useEffect, useCallback, useRef } from "react"
import { useQueryState } from "nuqs"
import { Dialog, DialogContent, DialogTitle } from "@repo/ui/components/dialog"
import { Drawer, DrawerContent, DrawerTitle } from "@repo/ui/components/drawer"
import { cn } from "@lib/utils"
import { dmSansClassName } from "@/lib/fonts"
import { FileTextIcon, GlobeIcon, ZapIcon, Loader2, XIcon } from "lucide-react"
import { FileTextIcon, GlobeIcon, ZapIcon, Loader2 } from "lucide-react"
import { Button } from "@ui/components/button"
import { ConnectContent } from "./connections"
import { NoteContent } from "./note"
@ -31,14 +32,36 @@ interface AddDocumentModalProps {
export function AddDocumentModal({ isOpen, onClose }: AddDocumentModalProps) {
const isMobile = useIsMobile()
if (isMobile) {
return (
<Drawer
open={isOpen}
onOpenChange={(open: boolean) => !open && onClose()}
shouldScaleBackground
>
<DrawerContent
className={cn(
"flex flex-col gap-0 border-none bg-[#1B1F24] p-0",
"h-[85svh] max-h-[85svh] overflow-hidden",
"[&>div:first-child]:bg-[#3A4252] [&>div:first-child]:h-1 [&>div:first-child]:w-9 [&>div:first-child]:mt-2.5 [&>div:first-child]:mb-1",
dmSansClassName(),
)}
>
<DrawerTitle className="sr-only">Add Document</DrawerTitle>
<div className="min-h-0 flex-1 overflow-hidden">
<AddDocument onClose={onClose} isOpen={isOpen} />
</div>
</DrawerContent>
</Drawer>
)
}
return (
<Dialog open={isOpen} onOpenChange={(open: boolean) => !open && onClose()}>
<DialogContent
className={cn(
"border-none bg-[#1B1F24] flex flex-col",
isMobile
? "top-2! left-2! translate-x-0! translate-y-0! w-[calc(100vw-1rem)]! h-[calc(100dvh-1rem)]! max-w-none! max-h-none! rounded-[18px] p-0 gap-0 overflow-hidden"
: "w-[80%]! max-w-[1000px]! h-[80%]! max-h-[800px]! rounded-[22px] p-4 gap-3",
"w-[80%]! max-w-[1000px]! h-[80%]! max-h-[800px]! rounded-[22px] p-4 gap-3",
dmSansClassName(),
)}
style={{
@ -61,24 +84,28 @@ const tabs = [
id: "note" as const,
icon: FileTextIcon,
title: "Write a note",
compactLabel: "Note",
description: "Save your thoughts, notes and summaries, as memories",
},
{
id: "link" as const,
icon: GlobeIcon,
title: "Save a link",
compactLabel: "Links",
description: "Add any webpage into your searchable knowledge base",
},
{
id: "file" as const,
icon: FileTextIcon,
title: "Upload files",
compactLabel: "Files",
description: "Turn images, PDFs, documents, and markdown into memories",
},
{
id: "connect" as const,
icon: ZapIcon,
title: "Connect knowledge bases",
compactLabel: "Connections",
description: "Sync with Google Drive, Notion and OneDrive and import data",
isPro: true,
},
@ -141,6 +168,8 @@ export function AddDocument({
useEffect(() => {
if (!isOpen) {
setFileData({ items: [], title: "", description: "" })
setNoteContent("")
setLinkData({ url: "", title: "", description: "" })
}
}, [isOpen])
@ -257,112 +286,56 @@ export function AddDocument({
return (
<div className="flex h-full min-h-0 flex-col overflow-hidden text-white md:flex-row md:space-x-5">
<div
className={cn(
"flex flex-col justify-between",
isMobile
? "w-full shrink-0 border-b border-[#0F1621] bg-[#1B1F24] px-3 pt-3 pb-3"
: "w-1/3",
)}
>
{isMobile && (
<div className="mb-3 flex items-center justify-between">
<div>
<p
className={cn(
"text-sm font-medium text-white",
dmSansClassName(),
)}
>
Add memory
</p>
<p className="text-xs text-[#737373]">
Save something to recall later
</p>
</div>
<button
type="button"
onClick={onClose}
disabled={isSubmitting}
className="flex size-9 items-center justify-center rounded-full border border-[#1F2937] bg-[#0D121A] text-[#8B8B8B] transition-colors hover:text-white disabled:opacity-50"
aria-label="Close add memory"
>
<XIcon className="size-4" />
</button>
</div>
)}
<div
className={cn(
isMobile ? "grid grid-cols-4 gap-1" : "flex flex-col gap-1",
)}
>
{tabs.map((tab) => (
<TabButton
key={tab.id}
active={activeTab === tab.id}
onClick={() => setActiveTab(tab.id)}
icon={tab.icon}
title={tab.title}
description={tab.description}
isPro={tab.isPro}
compact={isMobile}
/>
))}
</div>
{isMobile && (
<div className="mt-3 flex flex-col gap-2">
<div className="flex justify-between items-center">
<span
className={cn(
"text-[#FAFAFA] text-sm font-medium",
dmSansClassName(),
)}
>
Plan usage
</span>
<span
className={cn(
"text-sm font-medium tabular-nums",
hasPaidPlan ? "text-[#4BA0FA]" : "text-[#737373]",
dmSansClassName(),
)}
>
{isLoadingUsage
? "…"
: `${planUsagePct < 1 && planUsagePct > 0 ? "< 1" : Math.round(planUsagePct)}% used`}
</span>
</div>
<div className="h-2 w-full rounded-[40px] bg-[#2E353D] p-px overflow-hidden">
<div
className="h-full rounded-[40px]"
style={{
width: `${planUsagePct}%`,
background:
planUsagePct > 80
? "#ef4444"
: hasPaidPlan
? "linear-gradient(to right, #4BA0FA 80%, #002757 100%)"
: "#0054AD",
}}
title={`${formatUsageNumber(tokensUsed)} tokens · ${formatUsageNumber(searchesUsed)} queries`}
/>
</div>
{!isLoadingUsage && (
<p
className={cn(
"text-xs text-[#737373] tabular-nums",
dmSansClassName(),
)}
>
{formatUsageNumber(tokensUsed)} tokens ·{" "}
{formatUsageNumber(searchesUsed)} queries
</p>
{isMobile && !hasPaidPlan && (
<div className="flex shrink-0 justify-end px-4 pb-2">
<button
type="button"
onClick={async () => {
setIsUpgrading(true)
try {
const result = await autumn.attach({
planId: "api_pro",
successUrl: `${window.location.origin}/settings#account`,
})
if (result?.paymentUrl) {
window.open(result.paymentUrl, "_self")
return
}
autumn.refetch?.()
} catch (error) {
console.error(error)
toast.error("Failed to start checkout. Please try again.")
} finally {
setIsUpgrading(false)
}
}}
disabled={isUpgrading}
className={cn(
"shrink-0 cursor-pointer rounded-full bg-[#0054AD]/30 px-2.5 py-1 text-[11px] font-medium text-[#4BA0FA] transition-colors hover:bg-[#0054AD]/50 disabled:opacity-60",
dmSansClassName(),
)}
>
{isUpgrading ? "Upgrading…" : "Upgrade"}
</button>
</div>
)}
{!isMobile && (
<div className="flex w-1/3 flex-col justify-between">
<div className="flex flex-col gap-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}
/>
))}
</div>
)}
{!isMobile && (
<div data-testid="usage-counter" className="flex flex-col gap-3 mr-4">
<div className="flex flex-col gap-2">
<div className="flex justify-between items-center">
@ -463,13 +436,13 @@ export function AddDocument({
</button>
)}
</div>
)}
</div>
</div>
)}
<div
className={cn(
"flex min-h-0 flex-1 flex-col",
isMobile ? "w-full px-3 pt-3" : "w-2/3 px-1",
isMobile ? "w-full px-4 pt-1" : "w-2/3 px-1",
)}
>
<div className="min-h-0 flex-1 overflow-auto scrollbar-thin">
@ -479,6 +452,7 @@ export function AddDocument({
onContentChange={handleNoteContentChange}
isSubmitting={noteMutation.isPending}
isOpen={isOpen}
initialContent={noteContent}
/>
)}
{activeTab === "link" && (
@ -487,6 +461,7 @@ export function AddDocument({
onDataChange={handleLinkDataChange}
isSubmitting={linkMutation.isPending}
isOpen={isOpen}
initialData={linkData}
/>
)}
{activeTab === "file" && (
@ -506,12 +481,29 @@ export function AddDocument({
</div>
<div
className={cn(
"flex shrink-0 gap-2",
"flex shrink-0",
isMobile
? "mx-[-0.75rem] mt-3 border-t border-[#0F1621] bg-[#1B1F24] px-3 py-3 pb-[max(0.75rem,env(safe-area-inset-bottom))]"
: "justify-between pt-3",
? "mx-[-1rem] mt-3 flex-col gap-3 border-t border-[#0F1621] bg-[#1B1F24] px-4 pt-3 pb-[max(0.75rem,env(safe-area-inset-bottom))]"
: "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]}
@ -524,20 +516,19 @@ export function AddDocument({
<div
className={cn(
"flex items-center gap-2",
isMobile && "w-full justify-end",
isMobile ? "w-full" : "justify-end",
)}
>
<Button
variant="ghost"
onClick={onClose}
disabled={isSubmitting}
className={cn(
"cursor-pointer rounded-full text-[#737373]",
isMobile && "h-11 px-4",
)}
>
Cancel
</Button>
{!isMobile && (
<Button
variant="ghost"
onClick={onClose}
disabled={isSubmitting}
className="cursor-pointer rounded-full text-[#737373]"
>
Cancel
</Button>
)}
{activeTab !== "connect" && (
<Button
variant="insideOut"
@ -545,7 +536,7 @@ export function AddDocument({
disabled={
activeTab === "file" ? fileTabSubmitDisabled : isSubmitting
}
className={cn(isMobile && "h-11 min-w-[8rem] px-5")}
className={cn(isMobile && "h-12 w-full px-5 text-[15px]")}
>
{isSubmitting ? (
<>
@ -581,6 +572,7 @@ function TabButton({
onClick,
icon: Icon,
title,
compactLabel,
description,
isPro,
compact,
@ -589,6 +581,7 @@ function TabButton({
onClick: () => void
icon: React.ComponentType<{ className?: string }>
title: string
compactLabel?: string
description: string
isPro?: boolean
compact?: boolean
@ -599,26 +592,27 @@ function TabButton({
type="button"
onClick={onClick}
className={cn(
"relative flex h-14 min-w-0 flex-col items-center justify-center gap-1 rounded-xl px-1 text-center transition-colors focus:outline-none focus:ring-0",
"relative flex h-full min-w-0 flex-1 basis-0 cursor-pointer items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-1 text-center transition-colors focus:outline-none focus:ring-0",
active
? "bg-[#0F141B] text-white shadow-inside-out ring-1 ring-[#2261CA33]"
: "text-[#8B8B8B] hover:bg-[#14161A]/50",
? "border-[#2261CA33] bg-[#00173C] text-white"
: "text-[#8B8B8B] hover:bg-white/5",
dmSansClassName(),
)}
>
<Icon className="size-3.5 shrink-0" />
<span
className={cn(
"min-w-0 truncate text-xs font-medium leading-none",
"min-w-0 truncate text-[13px] font-medium",
dmSansClassName(),
)}
>
{title.split(" ")[0]}
{compactLabel ?? title.split(" ")[0]}
</span>
{isPro && (
<span className="absolute top-1 right-1 rounded bg-[#4BA0FA] px-1 py-0.5 text-[7px] font-semibold leading-none text-black">
PRO
</span>
<span
role="img"
aria-label="Pro"
className="size-1.5 shrink-0 rounded-full bg-[#4BA0FA]"
/>
)}
</button>
)

View file

@ -20,6 +20,7 @@ interface LinkContentProps {
onDataChange?: (data: LinkData) => void
isSubmitting?: boolean
isOpen?: boolean
initialData?: LinkData
}
export function LinkContent({
@ -27,11 +28,12 @@ export function LinkContent({
onDataChange,
isSubmitting,
isOpen,
initialData,
}: LinkContentProps) {
const [url, setUrl] = useState("")
const [title, setTitle] = useState("")
const [description, setDescription] = useState("")
const [image, setImage] = useState<string | undefined>(undefined)
const [url, setUrl] = useState(initialData?.url ?? "")
const [title, setTitle] = useState(initialData?.title ?? "")
const [description, setDescription] = useState(initialData?.description ?? "")
const [image, setImage] = useState<string | undefined>(initialData?.image)
const [isPreviewLoading, setIsPreviewLoading] = useState(false)
const canSubmit = url.trim().length > 0 && !isSubmitting
@ -148,7 +150,12 @@ export function LinkContent({
}, [isOpen, onDataChange])
return (
<div className={cn("flex flex-col space-y-4 pt-4 mb-4", dmSansClassName())}>
<div
className={cn(
"flex flex-col space-y-4 pt-0 mb-4 md:pt-4",
dmSansClassName(),
)}
>
<div>
<p
className={cn("text-[16px] font-medium pl-2 pb-2", dmSansClassName())}

View file

@ -1,6 +1,6 @@
"use client"
import { useState, useEffect } from "react"
import { useState } from "react"
import { TextEditor } from "../text-editor"
interface NoteContentProps {
@ -8,15 +8,17 @@ interface NoteContentProps {
onContentChange?: (content: string) => void
isSubmitting?: boolean
isOpen?: boolean
initialContent?: string
}
export function NoteContent({
onSubmit,
onContentChange,
isSubmitting,
isOpen,
initialContent,
}: NoteContentProps) {
const [content, setContent] = useState("")
const [content, setContent] = useState(initialContent ?? "")
const [seededContent] = useState(initialContent || undefined)
const canSubmit = content.trim().length > 0 && !isSubmitting
@ -31,18 +33,10 @@ export function NoteContent({
onContentChange?.(newContent)
}
// Reset content when modal closes
useEffect(() => {
if (!isOpen) {
setContent("")
onContentChange?.("")
}
}, [isOpen, onContentChange])
return (
<div className="flex h-full min-h-[45dvh] w-full flex-1 overflow-y-auto rounded-[14px] bg-[#10151C] p-3 shadow-inside-out ring-1 ring-[#202A36] md:mb-4! md:bg-[#14161A] md:p-4 md:ring-0">
<TextEditor
content={undefined}
content={seededContent}
onContentChange={handleContentChange}
onSubmit={handleSubmit}
debounceMs={0}

View file

@ -0,0 +1,84 @@
"use client"
import { Search } from "lucide-react"
import NovaOrb from "@/components/nova/nova-orb"
import { cn } from "@lib/utils"
import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
export const DEFAULT_CHAT_PROMPTS = [
"What do you know about me?",
"What have I been working on lately?",
"What themes keep showing up in my memories?",
] as const
const SUGGESTION_PILL_CLASS = cn(
"inline-flex max-w-full items-center gap-2 rounded-full border border-[#2261CA33] bg-[#041127]",
"px-3 py-2 text-left transition-colors cursor-pointer",
"hover:border-[#3374FF]/55 hover:bg-[#0A1A3A] hover:[&_span]:text-white",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#3374FF]/40",
)
export function ChatEmptyStatePlaceholder({
onSuggestionClick,
suggestions = [...DEFAULT_CHAT_PROMPTS],
subtitle,
}: {
onSuggestionClick: (suggestion: string) => void
suggestions?: string[]
subtitle?: string
}) {
const prompts = suggestions.slice(0, 3)
return (
<div
id="chat-empty-state"
className={cn(
"flex min-h-full items-center justify-center px-4 py-10",
dmSansClassName(),
)}
>
<div className="flex w-full max-w-[min(100%,360px)] flex-col items-center gap-5">
<div className="flex flex-col items-center gap-3 text-center">
<NovaOrb size={44} className="blur-[1px]!" />
<p
className={cn(
"text-lg font-semibold text-[#fafafa]",
dmSans125ClassName(),
)}
>
Nova knows you.
</p>
{subtitle ? (
<p className="max-w-[280px] text-sm leading-snug text-[#737373]">
{subtitle}
</p>
) : null}
</div>
<div className="flex w-full flex-col items-center gap-2">
<p className="text-[10px] font-medium uppercase tracking-[0.1em] text-[#525966]">
Try asking
</p>
<div className="flex w-full flex-col items-center gap-2">
{prompts.map((prompt) => (
<button
key={prompt}
type="button"
onClick={() => onSuggestionClick(prompt)}
className={SUGGESTION_PILL_CLASS}
>
<Search
className="size-3.5 shrink-0 text-[#4BA0FA]"
aria-hidden
/>
<span className="text-[12px] font-medium leading-snug text-[#4BA0FA]">
{prompt}
</span>
</button>
))}
</div>
</div>
</div>
</div>
)
}

View file

@ -11,12 +11,18 @@ import { dmSansClassName } from "@/lib/fonts"
export function ChatGraphContextRail({
messages,
containerTags,
className,
}: {
messages: UIMessage[]
containerTags?: string[] | null
className?: string
}) {
const { effectiveContainerTags } = useProject()
const graphContainerTags =
containerTags === undefined
? effectiveContainerTags
: (containerTags ?? undefined)
const highlightIds = useMemo(
() => extractHighlightDocumentIdsFromMessages(messages),
[messages],
@ -45,7 +51,7 @@ export function ChatGraphContextRail({
<div className="pointer-events-none absolute inset-y-0 right-0 z-10 w-24 bg-gradient-to-r from-transparent to-[#05080D]" />
<div className="relative z-[2] min-h-0 flex-1 pt-10">
<MemoryGraph
containerTags={effectiveContainerTags}
containerTags={graphContainerTags}
variant="consumer"
highlightDocumentIds={highlightIds}
highlightsVisible={highlightIds.length > 0}

View file

@ -1,41 +1,33 @@
"use client"
import { useCallback, useMemo, useState } from "react"
import { useCallback, useState } from "react"
import ChatInput from "./input"
import ChatModelSelector from "./model-selector"
import { getChatSpaceDisplayLabel } from "@/lib/chat-space-label"
import { useProject } from "@/stores"
import { useContainerTags } from "@/hooks/use-container-tags"
import { dmSansClassName } from "@/lib/fonts"
import { cn } from "@lib/utils"
import type { ModelId } from "@/lib/models"
import { SpaceSelector } from "@/components/space-selector"
export function HomeChatComposer({
onStartChat,
className,
}: {
onStartChat: (message: string, model: ModelId) => void
onStartChat: (message: string, model: ModelId, projectId: string) => void
className?: string
}) {
const [input, setInput] = useState("")
const [selectedModel, setSelectedModel] = useState<ModelId>("gemini-2.5-pro")
const { selectedProject } = useProject()
const { allProjects } = useContainerTags()
const chatSpaceLabel = useMemo(
() =>
getChatSpaceDisplayLabel({
selectedProject,
allProjects,
}),
[selectedProject, allProjects],
)
const [chatSpaceProjects, setChatSpaceProjects] = useState<string[]>([
selectedProject,
])
const send = useCallback(() => {
const t = input.trim()
if (!t) return
onStartChat(t, selectedModel)
onStartChat(t, selectedModel, chatSpaceProjects[0] ?? selectedProject)
setInput("")
}, [input, onStartChat, selectedModel])
}, [chatSpaceProjects, input, onStartChat, selectedModel, selectedProject])
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
@ -46,7 +38,7 @@ export function HomeChatComposer({
return (
<div className={cn(className)}>
<div className="mx-auto w-full max-w-[720px] px-4 pt-1 pb-3 md:pb-4">
<div className="mx-auto w-full max-w-[720px] px-4 pt-1 pb-[max(1.25rem,calc(env(safe-area-inset-bottom)+1rem))] md:pb-6">
<ChatInput
value={input}
onChange={(e) => setInput(e.target.value)}
@ -62,17 +54,13 @@ export function HomeChatComposer({
onModelChange={setSelectedModel}
minimal
/>
<div
className={cn(
"inline-flex max-w-[min(160px,35vw)] min-w-0 shrink items-center rounded-full bg-fg-primary/5 px-3 py-1.5",
dmSansClassName(),
)}
title={chatSpaceLabel}
>
<span className="truncate text-sm text-fg-primary">
{chatSpaceLabel}
</span>
</div>
<SpaceSelector
selectedProjects={chatSpaceProjects}
onValueChange={setChatSpaceProjects}
variant="insideOut"
includeAuto
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]"
/>
</>
}
/>

View file

@ -1,6 +1,8 @@
"use client"
import { useState, useEffect, useCallback, useRef, useMemo } from "react"
import { useQuery } from "@tanstack/react-query"
import { $fetch } from "@lib/api"
import { useQueryState } from "nuqs"
import type { UIMessage } from "@ai-sdk/react"
import { motion } from "motion/react"
@ -22,7 +24,6 @@ import {
ChevronDownIcon,
HistoryIcon,
Plus,
SearchIcon,
SquarePenIcon,
Trash2,
XIcon,
@ -33,11 +34,11 @@ import { dmSansClassName } from "@/lib/fonts"
import ChatInput from "./input"
import ChatModelSelector from "./model-selector"
import { getNovaChatErrorCopy } from "@/lib/chat-stream-error"
import { GradientLogo, LogoBgGradient } from "@ui/assets/Logo"
import { useProject } from "@/stores"
import { useContainerTags } from "@/hooks/use-container-tags"
import { getChatSpaceDisplayLabel } from "@/lib/chat-space-label"
import { modelNames, type ModelId } from "@/lib/models"
import { SpaceSelector } from "@/components/space-selector"
import { SuperLoader } from "../superloader"
import { UserMessage } from "./message/user-message"
import { AgentMessage } from "./message/agent-message"
@ -49,56 +50,8 @@ import { analytics } from "@/lib/analytics"
import { generateId } from "@lib/generate-id"
import { useViewMode } from "@/lib/view-mode-context"
import { threadParam } from "@/lib/search-params"
const DEFAULT_SUGGESTIONS = [
"Show me all content related to Supermemory.",
"Summarize the key ideas from My Gita.",
"Which memories connect design and AI?",
"What are the main themes across my memories?",
]
function ChatEmptyStatePlaceholder({
onSuggestionClick,
suggestions = DEFAULT_SUGGESTIONS,
}: {
onSuggestionClick: (suggestion: string) => void
suggestions?: string[]
}) {
return (
<div
id="chat-empty-state"
className="flex flex-col items-center justify-center h-full"
>
<div className="relative size-32">
<GradientLogo className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 size-16" />
<LogoBgGradient className="size-full" />
</div>
<div className="gap-3 flex flex-col items-center justify-center">
<p>Ask me anything about your memories</p>
<div
className={cn(
dmSansClassName(),
"flex flex-col gap-2 justify-center items-center",
)}
>
{suggestions.map((suggestion) => (
<Button
key={suggestion}
variant="default"
className="rounded-full text-base gap-1 h-10! border-[#2261CA33] bg-[#041127] border w-fit max-w-[400px] py-[4px] pl-[8px] pr-[12px] hover:bg-[#0A1A3A] hover:[&_span]:text-white hover:[&_svg]:text-white transition-colors cursor-pointer"
onClick={() => onSuggestionClick(suggestion)}
>
<SearchIcon className="size-4 text-[#267BF1] shrink-0" />
<span className="text-[#267BF1] text-[12px] truncate">
{suggestion}
</span>
</Button>
))}
</div>
</div>
</div>
)
}
import { AUTO_CHAT_SPACE_ID } from "@/lib/chat-auto-space"
import { ChatEmptyStatePlaceholder } from "./chat-empty-state"
export function ChatLaunchFab({
onOpen,
@ -112,7 +65,7 @@ export function ChatLaunchFab({
className={cn(
"flex items-start justify-start pointer-events-none",
isMobile
? "fixed bottom-5 right-0 left-0 z-50 justify-center items-center"
? "fixed bottom-safe-5 right-0 left-0 z-50 justify-center items-center pl-safe pr-safe"
: "fixed z-20 top-24 right-4 md:right-6",
dmSansClassName(),
)}
@ -150,6 +103,7 @@ export function ChatSidebar({
onConsumeQueuedMessage,
queuedMessageSource = "highlight",
initialSelectedModel = null,
initialChatProject = null,
emptyStateSuggestions,
layout = "sidebar",
}: {
@ -160,6 +114,7 @@ export function ChatSidebar({
onConsumeQueuedMessage?: () => void
queuedMessageSource?: "highlight" | "home"
initialSelectedModel?: ModelId | null
initialChatProject?: string | null
emptyStateSuggestions?: string[]
layout?: "sidebar" | "page"
}) {
@ -190,23 +145,67 @@ export function ChatSidebar({
)
const messagesContainerRef = useRef<HTMLDivElement>(null)
const isScrolledToBottomRef = useRef(true)
const userJustSentRef = useRef(false)
const sentQueuedMessageRef = useRef<string | null>(null)
const pendingHighlightReplyRef = useRef<string | null>(null)
const awaitingHighlightInjectionRef = useRef(false)
const pendingHighlightMessageRef = useRef<UIMessage[] | null>(null)
const targetHighlightChatIdRef = useRef<string | null>(null)
const { selectedProject } = useProject()
const [chatSpaceProjects, setChatSpaceProjects] = useState<string[]>([
initialChatProject ?? selectedProject,
])
const chatProject = chatSpaceProjects[0] ?? selectedProject
const { allProjects } = useContainerTags()
const selectedProjectRef = useRef(selectedProject)
selectedProjectRef.current = selectedProject
const selectedProjectRef = useRef(chatProject)
selectedProjectRef.current = chatProject
const chatSpaceLabel = useMemo(
() =>
getChatSpaceDisplayLabel({
selectedProject,
allProjects,
}),
[selectedProject, allProjects],
chatProject === AUTO_CHAT_SPACE_ID
? "Auto"
: getChatSpaceDisplayLabel({
selectedProject: chatProject,
allProjects,
}),
[chatProject, allProjects],
)
const isAutoChatSpace = chatProject === AUTO_CHAT_SPACE_ID
const { data: chatSpaceMemoryCount } = useQuery({
queryKey: ["chat-empty-space-count", chatProject],
queryFn: async (): Promise<number> => {
const response = await $fetch("@post/documents/documents", {
body: {
page: 1,
limit: 1,
sort: "createdAt",
order: "desc",
containerTags: [chatProject],
},
disableValidation: true,
})
if (response.error) return 0
const data = response.data as {
pagination?: { totalItems?: number }
} | null
return data?.pagination?.totalItems ?? 0
},
staleTime: 30 * 1000,
enabled: !!chatProject && !isAutoChatSpace,
})
const emptyStateSubtitle = useMemo(() => {
if (isAutoChatSpace) {
return "Picks the best space for each question"
}
if (chatSpaceMemoryCount === undefined) {
return `Grounded in ${chatSpaceLabel}`
}
if (chatSpaceMemoryCount === 0) {
return `Nothing in ${chatSpaceLabel} yet`
}
const countLabel = chatSpaceMemoryCount.toLocaleString()
const memoryWord = chatSpaceMemoryCount === 1 ? "memory" : "memories"
return `${countLabel} ${memoryWord} in ${chatSpaceLabel}`
}, [isAutoChatSpace, chatSpaceLabel, chatSpaceMemoryCount])
const { viewMode } = useViewMode()
const { user: _user } = useAuth()
const [threadId, setThreadId] = useQueryState("thread", threadParam)
@ -232,6 +231,12 @@ export function ChatSidebar({
metadata: {
chatId: chatIdRef.current,
projectId: selectedProjectRef.current,
spaceMode:
selectedProjectRef.current === AUTO_CHAT_SPACE_ID
? "auto"
: "manual",
enableSpaceDiscovery:
selectedProjectRef.current === AUTO_CHAT_SPACE_ID,
model: selectedModelRef.current,
},
},
@ -323,9 +328,30 @@ export function ChatSidebar({
analytics.chatMessageSent({ source: "typed" })
sendMessage({ text: input })
setInput("")
userJustSentRef.current = true
scrollToBottom()
}
const handleSuggestedQuestion = useCallback(
(suggestion: string) => {
if (status === "submitted" || status === "streaming") return
if (!threadId) setThreadId(fallbackChatId)
analytics.chatSuggestedQuestionClicked()
analytics.chatMessageSent({ source: "suggested" })
sendMessage({ text: suggestion })
userJustSentRef.current = true
scrollToBottom()
},
[
fallbackChatId,
sendMessage,
setThreadId,
status,
threadId,
scrollToBottom,
],
)
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
@ -394,7 +420,7 @@ export function ChatSidebar({
setIsLoadingThreads(true)
try {
const response = await fetch(
`${process.env.NEXT_PUBLIC_BACKEND_URL}/chat/threads?projectId=${selectedProject}`,
`${process.env.NEXT_PUBLIC_BACKEND_URL}/chat/threads?projectId=${chatProject}`,
{ credentials: "include" },
)
if (response.ok) {
@ -406,7 +432,7 @@ export function ChatSidebar({
} finally {
setIsLoadingThreads(false)
}
}, [selectedProject])
}, [chatProject])
useEffect(() => {
if (!isHistoryOpen) return
@ -631,13 +657,11 @@ export function ChatSidebar({
useEffect(() => {
const lastMessage = messages[messages.length - 1]
if (lastMessage?.role === "user" && messagesContainerRef.current) {
messagesContainerRef.current.scrollTop =
messagesContainerRef.current.scrollHeight
setIsScrolledToBottom(true)
scrollToBottom()
} else {
checkIfScrolledToBottom()
}
// Always check scroll position when messages change
checkIfScrolledToBottom()
}, [messages, checkIfScrolledToBottom])
}, [messages, checkIfScrolledToBottom, scrollToBottom])
useEffect(() => {
const isStreaming = status === "streaming"
@ -647,7 +671,7 @@ export function ChatSidebar({
if (
isStreaming &&
isLastMessageFromAssistant &&
isScrolledToBottomRef.current
(isScrolledToBottomRef.current || userJustSentRef.current)
) {
scrollToBottom()
}
@ -657,11 +681,14 @@ export function ChatSidebar({
const container = messagesContainerRef.current
if (!container) return
const isStreaming = status === "streaming"
if (!isStreaming) return
const isStreaming = status === "streaming" || status === "submitted"
if (!isStreaming) {
userJustSentRef.current = false
return
}
const mutationObserver = new MutationObserver(() => {
if (isScrolledToBottomRef.current) {
if (isScrolledToBottomRef.current || userJustSentRef.current) {
requestAnimationFrame(() => {
scrollToBottom()
})
@ -687,16 +714,17 @@ export function ChatSidebar({
const handleScroll = () => {
requestAnimationFrame(() => {
checkIfScrolledToBottom()
if (!isScrolledToBottomRef.current) {
userJustSentRef.current = false
}
})
}
container.addEventListener("scroll", handleScroll, { passive: true })
// Initial check with a small delay to ensure DOM is ready
setTimeout(() => {
checkIfScrolledToBottom()
}, 100)
// Also observe resize to detect content height changes
const resizeObserver = new ResizeObserver(() => {
requestAnimationFrame(() => {
checkIfScrolledToBottom()
@ -716,6 +744,9 @@ export function ChatSidebar({
const isStackedInput = layout === "page"
const showHeaderRow = !isPageDesktop || isMobile || !isStackedInput
const isResponding = status === "submitted" || status === "streaming"
const showInputStatusStrip =
!isStackedInput || isResponding || messages.length > 0
const chatHistorySheet = (
<Sheet
@ -730,7 +761,7 @@ export function ChatSidebar({
<SheetContent
side="right"
className={cn(
"flex h-full max-h-dvh w-full flex-col gap-0 overflow-hidden border-[#17181AB2] bg-[#0A0E14] p-0 text-white sm:max-w-md",
"flex h-full max-h-dvh w-[min(100%,92vw)] flex-col gap-0 overflow-hidden border-[#17181AB2] bg-[#0A0E14] p-0 pb-safe text-white sm:max-w-md",
"[&>button]:text-[#FAFAFA]",
dmSansClassName(),
)}
@ -908,123 +939,115 @@ export function ChatSidebar({
selectedModel={selectedModel}
onModelChange={handleModelChange}
/>
<div
className={cn(
"inline-flex h-10 max-w-[min(192px,42vw)] shrink min-w-0 items-center rounded-full border border-[#73737333] bg-[#0D121A] px-3",
dmSansClassName(),
)}
style={{
boxShadow: "1.5px 1.5px 4.5px 0 rgba(0, 0, 0, 0.70) inset",
}}
title={chatSpaceLabel}
>
<span className="truncate text-sm text-white">
{chatSpaceLabel}
</span>
</div>
<SpaceSelector
selectedProjects={chatSpaceProjects}
onValueChange={setChatSpaceProjects}
variant="insideOut"
includeAuto
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]"
/>
</>
)}
</div>
{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={(suggestion) => {
analytics.chatSuggestedQuestionClicked()
analytics.chatMessageSent({ source: "suggested" })
sendMessage({ text: suggestion })
}}
suggestions={emptyStateSuggestions}
/>
)}
<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
@ -1078,14 +1101,20 @@ export function ChatSidebar({
</div>
)}
<div className="shrink-0">
<div
className={cn(
"shrink-0",
isStackedInput &&
"pb-[max(1.25rem,calc(env(safe-area-inset-bottom)+1rem))] md:pb-6",
)}
>
<ChatInput
value={input}
onChange={(e) => setInput(e.target.value)}
onSend={handleSend}
onStop={stop}
onKeyDown={handleKeyDown}
isResponding={status === "submitted" || status === "streaming"}
isResponding={isResponding}
activeStatus={
status === "submitted"
? "Thinking…"
@ -1093,6 +1122,7 @@ export function ChatSidebar({
? "Structuring response…"
: "Waiting for input…"
}
showStatusStrip={showInputStatusStrip}
onExpandedChange={setIsInputExpanded}
chainOfThoughtComponent={
messages.length > 0 ? <ChainOfThought messages={messages} /> : null
@ -1105,17 +1135,13 @@ export function ChatSidebar({
onModelChange={handleModelChange}
minimal
/>
<div
className={cn(
"inline-flex max-w-[min(160px,35vw)] shrink min-w-0 items-center rounded-full border border-[#161F2C] bg-[#000000] px-3 py-1.5",
dmSansClassName(),
)}
title={chatSpaceLabel}
>
<span className="truncate text-sm text-[#FAFAFA]">
{chatSpaceLabel}
</span>
</div>
<SpaceSelector
selectedProjects={chatSpaceProjects}
onValueChange={setChatSpaceProjects}
variant="insideOut"
includeAuto
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]"
/>
</>
) : undefined
}
@ -1130,10 +1156,10 @@ export function ChatSidebar({
className={cn(
"relative flex flex-col backdrop-blur-md",
isMobile
? "fixed inset-0 z-50 m-0 h-dvh w-full rounded-none"
? "fixed inset-0 z-50 m-0 h-dvh w-full rounded-none pb-safe"
: isPageDesktop
? "flex h-full min-h-0 w-full min-w-0 flex-1 flex-col basis-0 rounded-none border-x-0"
: "m-4 mt-2 w-[450px] rounded-2xl",
: "m-4 mt-2 w-[min(450px,calc(100vw-2rem))] md:w-[380px] lg:w-[450px] rounded-2xl",
dmSansClassName(),
)}
style={
@ -1165,10 +1191,15 @@ export function ChatSidebar({
{chatHistorySheet}
{isPageDesktop ? (
<div className="flex h-full min-h-0 w-full flex-1 flex-row">
<ChatGraphContextRail messages={messages} />
<div className="flex h-full min-h-0 w-full min-w-0 max-w-[720px] shrink-0 basis-[min(720px,50vw)] flex-col">
<ChatGraphContextRail
messages={messages}
containerTags={
chatProject === AUTO_CHAT_SPACE_ID ? null : [chatProject]
}
/>
<div className="flex h-full min-h-0 w-full min-w-0 max-w-[min(720px,100%)] shrink-0 basis-[min(720px,50vw)] flex-col">
{pageDesktopToolbarRow}
<div className="relative mx-auto flex h-full min-h-0 w-full min-w-0 max-w-[720px] flex-1 flex-col">
<div className="relative mx-auto flex h-full min-h-0 w-full min-w-0 max-w-[min(720px,100%)] flex-1 flex-col px-3 sm:px-4 md:px-0">
{shell}
</div>
</div>
@ -1181,3 +1212,4 @@ export function ChatSidebar({
}
export { HomeChatComposer } from "./home-chat-composer"
export { ChatEmptyStatePlaceholder } from "./chat-empty-state"

View file

@ -4,7 +4,7 @@ import { ChevronUpIcon } from "lucide-react"
import NovaOrb from "@/components/nova/nova-orb"
import { cn } from "@lib/utils"
import { dmSansClassName } from "@/lib/fonts"
import { type ReactNode, useRef, useState } from "react"
import { type ReactNode, useEffect, useRef, useState } from "react"
import { motion } from "motion/react"
import { SendButton, StopButton } from "./actions"
@ -41,6 +41,13 @@ export default function ChatInput({
const [isExpanded, setIsExpanded] = useState(false)
const textareaRef = useRef<HTMLTextAreaElement>(null)
useEffect(() => {
if (!showStatusStrip && isExpanded) {
setIsExpanded(false)
onExpandedChange?.(false)
}
}, [isExpanded, onExpandedChange, showStatusStrip])
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
onChange(e)
@ -82,7 +89,7 @@ export default function ChatInput({
className={cn(
"absolute bottom-full left-0 right-0 overflow-hidden transition-all duration-300 ease-out bg-[#000B1B]",
isExpanded
? "max-h-[60vh] opacity-100 overflow-y-auto pt-1.5 pb-2 rounded-t-xl px-4"
? "max-h-[min(60dvh,420px)] opacity-100 overflow-y-auto pt-1.5 pb-2 rounded-t-xl px-4"
: "max-h-0 opacity-0",
)}
style={{

View file

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

View file

@ -54,6 +54,26 @@ const fadeUp = {
const CYCLE_INTERVAL_MS = 8_000
const defaultHomeHeadline = (name: string) => `Welcome back, ${name}`
const HOME_HEADLINES: ReadonlyArray<(name: string) => string> = [
defaultHomeHeadline,
(name: string) => `Good to see you, ${name}`,
(name: string) => `${name}, what should we remember next?`,
(name: string) => `${name}, your saved context is ready.`,
(name: string) => `${name}, pick up where you left off.`,
(name: string) => `${name}, search, save, or ask anything here.`,
(name: string) => `${name}, this space is ready for your next thought.`,
(name: string) => `${name}, keep the useful bits here.`,
(name: string) => `${name}, future you will thank you for saving this.`,
(name: string) => `${name}, your notes, links, and context live here.`,
(name: string) => `${name}, add something small. Find it later.`,
(name: string) => `${name}, turn passing context into lasting memory.`,
(name: string) => `${name}, everything worth remembering can live here.`,
(name: string) => `${name}, ask a question, save a link, or write a note.`,
(name: string) => `${name}, build your searchable working memory.`,
]
const PLUGIN_TAGLINES: Record<Profession, Partial<Record<string, string>>> = {
developer: {
mcp: "Ask Claude about your saved docs and specs from any IDE",
@ -121,108 +141,108 @@ export type MemoryOfDay = {
const TIPS: Record<Profession, string[]> = {
developer: [
"Use K to search code snippets and docs by intent, not just keywords",
"Use ⌘K to search code snippets and docs by intent, not just keywords",
"Connect Claude MCP to query your saved knowledge from any IDE",
"Save GitHub repos and READMEs ask questions across all of them",
"Save GitHub repos and READMEs — ask questions across all of them",
"Use 'Related' on highlights to find connected technical concepts",
"Save a Stack Overflow answer once find it again by what it does",
"Save a Stack Overflow answer once — find it again by what it does",
"Drop in your last 3 PRs and ask Supermemory for the review patterns",
"Save your team's RFCs and surface the ones touching your work",
"Save error messages with their fixes search by symptom next time",
"Save framework docs once semantic search beats Cmd+F across pages",
"Save error messages with their fixes — search by symptom next time",
"Save framework docs once — semantic search beats Cmd+F across pages",
"Connect Notion to make your engineering specs instantly findable",
"Save the docs for libraries you keep forgetting and grep them by intent",
"Use Daily Brief to resurface the design doc you skimmed last week",
"Save changelogs as you skim pull breaking changes back later",
"Save a debugging session as a note find it again by the symptom",
"Save changelogs as you skim — pull breaking changes back later",
"Save a debugging session as a note — find it again by the symptom",
],
research: [
"Save papers and ask questions across your entire reading list",
"Use 'Related' on highlights to surface connected research",
"Connect Notion to index your notes alongside your papers",
"Semantic search means you can ask questions, not just search titles",
"Save a paper once Supermemory finds it later by what it argued",
"Save a paper once — Supermemory finds it later by what it argued",
"Drop in 5 papers on a topic and ask for the consensus and disagreements",
"Save citations as you read pull them back out by claim",
"Save citations as you read — pull them back out by claim",
"Connect Google Drive to make your dataset notes searchable",
"Use Daily Brief to resurface a finding you almost forgot",
"Save a methodology note once find it next time you need that protocol",
"Save preprints alongside your reading list ask what's new since last week",
"Save quotes with their source find them later by the idea",
"Save a methodology note once — find it next time you need that protocol",
"Save preprints alongside your reading list — ask what's new since last week",
"Save quotes with their source — find them later by the idea",
],
finance: [
"Save articles and ask follow-up questions across your research",
"Connect Notion to keep your investment thesis searchable",
"Use K to find specific data points across all your saves",
"Use ⌘K to find specific data points across all your saves",
"Daily Brief surfaces connections you may have missed",
"Save earnings call transcripts once pull guidance back by ticker or theme",
"Save a thesis once find it months later by the conviction, not the filename",
"Save earnings call transcripts once — pull guidance back by ticker or theme",
"Save a thesis once — find it months later by the conviction, not the filename",
"Drop in three sell-side reports and ask for the disagreements",
"Save market commentary daily surface the calls that aged well",
"Save market commentary daily — surface the calls that aged well",
"Connect Google Drive to query your models without opening them",
"Save a chart with a note find it again by what it showed",
"Save analyst takes pull them back when the thesis matters again",
"Save a chart with a note — find it again by what it showed",
"Save analyst takes — pull them back when the thesis matters again",
],
design: [
"Save inspiration and search by concept 'minimalist UI' finds the right ones",
"Use K to rediscover references by meaning, not filename",
"Save inspiration and search by concept — 'minimalist UI' finds the right ones",
"Use ⌘K to rediscover references by meaning, not filename",
"Connect Notion to make your briefs and moodboards searchable",
"Chrome extension saves any page in one click while you browse",
"Save a screenshot with a note find it later by what it taught you",
"Save a screenshot with a note — find it later by what it taught you",
"Drop in 10 onboarding flows and ask Supermemory for the common patterns",
"Save your design crits find the feedback on a specific decision later",
"Save a brand guideline once search it by intent, not page number",
"Save your design crits — find the feedback on a specific decision later",
"Save a brand guideline once — search it by intent, not page number",
"Connect Google Drive to index your Figma exports and briefs",
"Use Daily Brief to resurface a reference that fits today's work",
"Save references by mood pull them back when the brief calls for it",
"Save references by mood — pull them back when the brief calls for it",
],
legal: [
"Save documents and search across them semantically in seconds",
"Connect Notion to index your memos and case notes together",
"Use Daily Brief to resurface relevant precedents automatically",
"Google Drive sync keeps your contracts indexed and queryable",
"Save a clause once find it next time by what it does, not where it lives",
"Save case law as you read pull precedents back by argument",
"Save a clause once — find it next time by what it does, not where it lives",
"Save case law as you read — pull precedents back by argument",
"Drop in three contracts and ask for the diffs in indemnity language",
"Save regulator updates surface the ones touching your matter",
"Save a memo once search by issue, not by file name",
"Save deposition notes find specific testimony by claim later",
"Save regulator updates — surface the ones touching your matter",
"Save a memo once — search by issue, not by file name",
"Save deposition notes — find specific testimony by claim later",
],
marketing: [
"Save campaigns and resources ask what worked across all of them",
"Save campaigns and resources — ask what worked across all of them",
"Chrome extension captures competitor pages in one click",
"Use 'Related' to find similar campaigns in your archive",
"Connect Notion to make your campaign briefs instantly searchable",
"Save a competitor's landing page surface their positioning later by claim",
"Save a competitor's landing page — surface their positioning later by claim",
"Drop in five launch retros and ask for the patterns that drove growth",
"Save ad references and find them by mood, not by URL",
"Save your weekly metrics notes pull trends back by quarter",
"Save your weekly metrics notes — pull trends back by quarter",
"Use Daily Brief to resurface a positioning note from last campaign",
"Save creative briefs find similar ones when starting a new one",
"Save creative briefs — find similar ones when starting a new one",
],
medical: [
"Save studies and query across your entire reading list",
"Connect Notion to keep clinical notes alongside research",
"Use K to find specific findings across hundreds of papers",
"Use ⌘K to find specific findings across hundreds of papers",
"Daily Brief surfaces relevant research from your saves automatically",
"Save a guideline once pull it back by clinical scenario",
"Save a guideline once — pull it back by clinical scenario",
"Drop in three trials and ask Supermemory for the methodological diffs",
"Save case reports surface them later by symptom or finding",
"Save case reports — surface them later by symptom or finding",
"Connect Google Drive to index protocols across your team",
"Save teaching points from rounds find them by topic next month",
"Save differentials as notes pull them back when the presentation repeats",
"Save teaching points from rounds — find them by topic next month",
"Save differentials as notes — pull them back when the presentation repeats",
],
default: [
"Use ⌘K to search by meaning — ask questions, not just keywords",
"Use ⌘K to search by meaning — ask questions, not just keywords",
"Daily Brief surfaces insights from your saves each morning",
"Chrome extension saves any page in one click while you browse",
"Connect integrations to make all your knowledge searchable here",
"Save a page once find it later by what it said, not its title",
"Save the thing you'd normally bookmark find it again by intent",
"Save a page once — find it later by what it said, not its title",
"Save the thing you'd normally bookmark — find it again by intent",
"Drop in 10 articles on a topic and ask for the through-line",
"Save an idea Supermemory connects it to your earlier ones",
"Save an idea — Supermemory connects it to your earlier ones",
"Use Daily Brief to resurface something useful you forgot you saved",
"Save a thread you liked pull it back later by what it was about",
"Save a thread you liked — pull it back later by what it was about",
],
}
@ -250,7 +270,7 @@ const PROFESSION_LABELS: {
{ value: "medical", label: "Medical" },
]
// Static plugin metadata shared between PluginPromoCard and RecommendedPluginsCard
// Static plugin metadata — shared between PluginPromoCard and RecommendedPluginsCard
const PLUGIN_STATIC = [
{
id: "mcp",
@ -265,7 +285,7 @@ const PLUGIN_STATIC = [
name: "Chrome Extension",
Icon: ChromeIcon,
accentColor: "#4BA0FA",
tagline: "Save any page in one click findable by meaning, forever",
tagline: "Save any page in one click — findable by meaning, forever",
cta: "Install",
},
{
@ -290,7 +310,7 @@ const PLUGIN_STATIC = [
Icon: GoogleDrive,
accentColor: "#4BA0FA",
tagline:
"Index your Drive files ask questions across docs, slides, sheets",
"Index your Drive files — ask questions across docs, slides, sheets",
cta: "Connect",
},
] as const
@ -459,7 +479,7 @@ function getDocumentPreview(document: DocumentWithMemories): string | null {
})
.filter(Boolean)
if (transcriptTurns.length > 0) return transcriptTurns.join(" · ")
if (transcriptTurns.length > 0) return transcriptTurns.join(" · ")
const cleaned = compactText(
content
@ -778,8 +798,8 @@ function RecommendedPluginsCard({
window.open(CHROME_EXTENSION_URL, "_blank", "noopener,noreferrer"),
raycast: () =>
window.open(RAYCAST_EXTENSION_URL, "_blank", "noopener,noreferrer"),
notion: () => onOpenIntegrations("connections"),
"google-drive": () => onOpenIntegrations("connections"),
notion: () => onOpenIntegrations("notion"),
"google-drive": () => onOpenIntegrations("google-drive"),
}
const connected: Record<string, boolean> = {
mcp: hasMcp,
@ -850,7 +870,7 @@ function RecommendedPluginsCard({
) : suggestions.length === 0 ? (
<div className="flex items-center justify-center py-4">
<p className="text-[11px] text-fg-subtle text-center">
You're all set
You're all set âœ
</p>
</div>
) : (
@ -873,7 +893,7 @@ function RecommendedPluginsCard({
</p>
</div>
<span className="shrink-0 text-[10px] font-medium text-[#5EA8FF] group-hover:text-[#8BC6FF] transition-colors">
{plugin.cta}
{plugin.cta} â
</span>
</button>
</li>
@ -888,7 +908,7 @@ function RecommendedPluginsCard({
{PROFESSION_LABELS.find(
(p) => p.value === profession,
)?.label.toLowerCase()}
? Change
? Change â
</button>
</>
)}
@ -926,7 +946,7 @@ function MemoryOfDayCard({ data }: { data: MemoryOfDay }) {
</div>
<span className="text-[10px] text-fg-faint group-hover:text-fg-muted transition-colors">
View memories
View memories â
</span>
</button>
)
@ -950,8 +970,8 @@ function PluginPromoCard({
window.open(CHROME_EXTENSION_URL, "_blank", "noopener,noreferrer"),
raycast: () =>
window.open(RAYCAST_EXTENSION_URL, "_blank", "noopener,noreferrer"),
notion: () => onOpenIntegrations("connections"),
"google-drive": () => onOpenIntegrations("connections"),
notion: () => onOpenIntegrations("notion"),
"google-drive": () => onOpenIntegrations("google-drive"),
}
const connected: Record<string, boolean> = {
mcp: hasMcp,
@ -1095,7 +1115,7 @@ export function DashboardView({
const { user, org } = useAuth()
const { effectiveContainerTags } = useProject()
const _router = useRouter()
const { data: recentsData } = useQuery({
const { data: recentsData, isPending: isRecentsLoading } = useQuery({
queryKey: ["dashboard-recents", effectiveContainerTags],
queryFn: async (): Promise<DocumentsResponse> => {
const response = await $fetch("@post/documents/documents", {
@ -1203,11 +1223,31 @@ export function DashboardView({
const totalMemories = recentsData?.pagination?.totalItems ?? 0
const hasMcp = mcpData?.previousLogin ?? false
const connectedProviders = new Set(connections.map((c) => c.provider))
const firstName = useMemo(() => {
const displayName =
user?.name?.trim() || user?.email?.split("@")[0] || "there"
return displayName.split(/\s+/)[0] || "there"
}, [user?.email, user?.name])
const [headlineIndex, setHeadlineIndex] = useState(0)
useEffect(() => {
setHeadlineIndex(Math.floor(Math.random() * HOME_HEADLINES.length))
}, [])
const homeHeadline = (HOME_HEADLINES[headlineIndex] ?? defaultHomeHeadline)(
firstName,
)
const [tipIndex, setTipIndex] = useState(0)
useEffect(() => {
setTipIndex(Math.floor(Math.random() * TIPS[profession].length))
}, [profession])
const tip = useMemo(() => {
const tips = TIPS[profession]
return tips[Math.floor(Math.random() * tips.length)]
}, [profession])
return tips[tipIndex % tips.length] ?? tips[0]
}, [profession, tipIndex])
return (
<div
@ -1229,8 +1269,11 @@ export function DashboardView({
<p className="text-[10px] font-medium uppercase tracking-[0.12em] text-fg-faint">
Home
</p>
<h1 className="text-xl font-medium tracking-tight text-white md:text-2xl">
{spaceLabel}
<h1
className="max-w-2xl text-xl font-medium tracking-tight text-white md:text-2xl"
title={spaceLabel}
>
{homeHeadline}
</h1>
</div>
{totalMemories > 0 && (
@ -1258,7 +1301,7 @@ export function DashboardView({
)}
</motion.header>
{/* Daily Brief hero */}
{/* Daily Brief — hero */}
<motion.section
{...fadeUp}
transition={{ ...fadeUp.transition, delay: 0.05 }}
@ -1308,42 +1351,48 @@ export function DashboardView({
</div>
</motion.section>
{/* Actions + connection status single unified row */}
{/* Actions + connection status — single unified row */}
<motion.section
{...fadeUp}
transition={{ ...fadeUp.transition, delay: 0.1 }}
className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
>
{/* Quick actions */}
<div className="flex items-center gap-0.5 -mx-2.5">
<div className="grid grid-cols-3 gap-1 [&>span]:hidden sm:-mx-2.5 sm:flex sm:items-center sm:gap-0.5 sm:[&>span]:inline">
<button
type="button"
onClick={() => onAddMemory("link")}
className="flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-sm text-fg-subtle hover:bg-surface-hover hover:text-white transition-colors cursor-pointer"
className="flex min-w-0 items-center justify-center gap-1 rounded-lg px-1 py-1.5 text-[11px] leading-none text-fg-subtle hover:bg-surface-hover hover:text-white transition-colors cursor-pointer sm:gap-1.5 sm:px-2.5 sm:text-sm sm:leading-tight"
>
<Link2 className="size-3.5 shrink-0" />
{personalizedCopy.saveLink}
<span className="min-w-0 truncate whitespace-nowrap text-center sm:text-left">
{personalizedCopy.saveLink}
</span>
</button>
<span className="text-[#3A4455] select-none">·</span>
<span className="text-[#3A4455] select-none">·</span>
<button
type="button"
onClick={() => onAddMemory("note")}
className="flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-sm text-fg-subtle hover:bg-surface-hover hover:text-white transition-colors cursor-pointer"
className="flex min-w-0 items-center justify-center gap-1 rounded-lg px-1 py-1.5 text-[11px] leading-none text-fg-subtle hover:bg-surface-hover hover:text-white transition-colors cursor-pointer sm:gap-1.5 sm:px-2.5 sm:text-sm sm:leading-tight"
>
<FileText className="size-3.5 shrink-0" />
{personalizedCopy.writeNote}
<span className="min-w-0 truncate whitespace-nowrap text-center sm:text-left">
{personalizedCopy.writeNote}
</span>
</button>
<span className="text-[#3A4455] select-none">·</span>
<span className="text-[#3A4455] select-none">·</span>
<button
type="button"
onClick={() => {
analytics.searchOpened({ source: "header" })
onOpenSearch()
}}
className="flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-sm text-fg-subtle hover:bg-surface-hover hover:text-white transition-colors cursor-pointer"
className="flex min-w-0 items-center justify-center gap-1 rounded-lg px-1 py-1.5 text-[11px] leading-none text-fg-subtle hover:bg-surface-hover hover:text-white transition-colors cursor-pointer sm:gap-1.5 sm:px-2.5 sm:text-sm sm:leading-tight"
>
<SearchIcon className="size-3.5 shrink-0" />
Search
<span className="min-w-0 truncate whitespace-nowrap text-center sm:text-left">
Search
</span>
</button>
</div>
@ -1360,25 +1409,43 @@ export function DashboardView({
transition={{ ...fadeUp.transition, delay: 0.15 }}
className="space-y-2"
>
{recents.length > 0 || recentToolUsageItems.length > 0 ? (
<>
{/* Shared header row — all labels aligned */}
<div className="flex gap-4">
<div className="flex-[4] min-w-0">
<p className="text-[10px] font-medium uppercase tracking-[0.12em] text-fg-faint">
Recents
</p>
</div>
<div className="flex-[2] min-w-0 hidden sm:block">
<p className="text-[10px] font-medium uppercase tracking-[0.12em] text-fg-faint">
Suggested for you
</p>
</div>
</div>
<div className="flex gap-4">
<div className="flex-[4] min-w-0">
<p className="text-[10px] font-medium uppercase tracking-[0.12em] text-fg-faint">
Recents
</p>
</div>
<div className="flex-[2] min-w-0 hidden sm:block">
<p className="text-[10px] font-medium uppercase tracking-[0.12em] text-fg-faint">
Suggested for you
</p>
</div>
</div>
{/* Content row */}
<div className="flex gap-4 items-start">
<ul className="flex-[4] min-w-0 space-y-0.5">
<div className="flex gap-4 items-start">
<div className="flex-[4] min-w-0">
{isRecentsLoading ? (
<ul
className="space-y-0.5"
aria-busy="true"
aria-label="Loading recently saved"
>
{[
"recent-skeleton-1",
"recent-skeleton-2",
"recent-skeleton-3",
].map((skeletonKey) => (
<li
key={skeletonKey}
className="flex items-center gap-3 rounded-lg px-2.5 py-2"
>
<div className="size-6 shrink-0 rounded-md bg-surface-skeleton animate-pulse" />
<div className="h-3.5 min-w-0 flex-1 rounded bg-surface-skeleton animate-pulse" />
</li>
))}
</ul>
) : recents.length > 0 || recentToolUsageItems.length > 0 ? (
<ul className="space-y-0.5">
{recentToolUsageItems.map((item) => (
<ToolUsageRecentRow
key={item.id}
@ -1412,42 +1479,41 @@ export function DashboardView({
)
})}
</ul>
) : (
<p className="px-2.5 py-2 text-sm text-fg-subtle">
No recently saved
</p>
)}
</div>
<div className="flex-[2] min-w-0 hidden sm:block">
<RecommendedPluginsCard
profession={profession}
setProfession={setProfession}
connectedProviders={connectedProviders}
hasMcp={hasMcp}
onOpenPlugins={onOpenPlugins}
onOpenIntegrations={onOpenIntegrations}
/>
</div>
<div className="flex-[2] min-w-0 hidden sm:block">
<RecommendedPluginsCard
profession={profession}
setProfession={setProfession}
connectedProviders={connectedProviders}
hasMcp={hasMcp}
onOpenPlugins={onOpenPlugins}
onOpenIntegrations={onOpenIntegrations}
/>
</div>
</div>
{(isRecentsLoading || recents.length === 0) && (
<div className="space-y-2 sm:hidden">
<p className="text-[10px] font-medium uppercase tracking-[0.12em] text-fg-faint">
Suggested for you
</p>
<div className="max-w-sm">
<RecommendedPluginsCard
profession={profession}
setProfession={setProfession}
connectedProviders={connectedProviders}
hasMcp={hasMcp}
onOpenPlugins={onOpenPlugins}
onOpenIntegrations={onOpenIntegrations}
/>
</div>
</>
) : (
/* No recents yet — show suggestions and tool usage */
<>
<div className="flex gap-4">
<div className="flex-1 min-w-0">
<p className="text-[10px] font-medium uppercase tracking-[0.12em] text-fg-faint">
Suggested for you
</p>
</div>
</div>
<div className="flex gap-4 items-start">
<div className="flex-1 min-w-0 max-w-sm">
<RecommendedPluginsCard
profession={profession}
setProfession={setProfession}
connectedProviders={connectedProviders}
hasMcp={hasMcp}
onOpenPlugins={onOpenPlugins}
onOpenIntegrations={onOpenIntegrations}
/>
</div>
</div>
</>
</div>
)}
</motion.section>
</div>

View file

@ -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 ?? ""} />

View file

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

View file

@ -2,6 +2,7 @@ import { useState } from "react"
import { cn } from "@lib/utils"
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@ui/components/tabs"
import { MemoryGraph } from "../memory-graph"
import { useProject } from "@/stores"
export interface MemoryEntry {
id: string
@ -172,6 +173,7 @@ export function GraphListMemories({
memoryEntries: MemoryEntry[]
documentId?: string
}) {
const { effectiveContainerTags } = useProject()
const [expandedMemories, setExpandedMemories] = useState<Set<string>>(
new Set(),
)
@ -263,7 +265,10 @@ export function GraphListMemories({
<TabsContent value="graph" className="flex-1 min-h-0 mt-3">
<div className="size-full rounded-lg overflow-hidden">
<MemoryGraph
containerTags={effectiveContainerTags}
documentIds={documentId ? [documentId] : undefined}
highlightDocumentIds={documentId ? [documentId] : undefined}
highlightsVisible
variant="consumer"
maxNodes={50}
/>

View file

@ -173,7 +173,7 @@ export function Header({ onAddMemory, onOpenSearch }: HeaderProps) {
role="tablist"
aria-label="Content"
aria-orientation="horizontal"
className="text-muted-foreground z-10! inline-flex h-10 w-fit min-w-0 max-w-full items-center justify-center gap-0.5 overflow-x-auto rounded-full border border-[#161F2C] bg-muted p-1 [scrollbar-width:thin]"
className="text-muted-foreground z-10! inline-flex h-10 w-fit min-w-0 max-w-full items-center justify-center gap-0.5 overflow-x-auto snap-x snap-mandatory scroll-fade-x rounded-full border border-[#161F2C] bg-muted p-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
>
{(
[
@ -210,7 +210,7 @@ export function Header({ onAddMemory, onOpenSearch }: HeaderProps) {
}
onClick={() => void setViewMode(mode)}
className={cn(
"inline-flex h-[calc(100%-1px)] min-h-0 cursor-pointer items-center justify-center gap-1 rounded-full border border-transparent px-2.5 text-xs font-medium whitespace-nowrap transition-colors sm:gap-1.5 sm:px-3 sm:text-sm",
"inline-flex h-[calc(100%-1px)] min-h-0 cursor-pointer snap-start items-center justify-center gap-1 rounded-full border border-transparent px-2.5 text-xs font-medium whitespace-nowrap transition-colors sm:gap-1.5 sm:px-3 sm:text-sm",
(
mode === "integrations"
? [

View file

@ -7,17 +7,11 @@ import { authClient } from "@lib/auth"
import { useAuth } from "@lib/auth-context"
import { generateId } from "@lib/generate-id"
import { RAYCAST_EXTENSION_URL } from "@lib/constants"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogPortal,
} from "@ui/components/dialog"
import { useMutation } from "@tanstack/react-query"
import { Check, Copy, Download, Key, Loader } from "lucide-react"
import { useId, useState } from "react"
import { Download, Key, Loader } from "lucide-react"
import { useState } from "react"
import { toast } from "sonner"
import { RaycastSetupModal } from "./raycast-setup-modal"
function PillButton({
children,
@ -52,19 +46,6 @@ export function RaycastDetail() {
const { org } = useAuth()
const [showModal, setShowModal] = useState(false)
const [apiKey, setApiKey] = useState("")
const [copied, setCopied] = useState(false)
const apiKeyId = useId()
const handleCopy = async (key: string) => {
try {
await navigator.clipboard.writeText(key)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
toast.success("API key copied to clipboard!")
} catch {
toast.error("Failed to copy API key")
}
}
const createKeyMutation = useMutation({
mutationFn: async () => {
@ -74,13 +55,14 @@ export function RaycastDetail() {
name: `raycast-${generateId().slice(0, 8)}`,
prefix: `sm_${org.id}_`,
})
return res.key
if (res.error)
throw new Error(res.error.message ?? "Failed to create API key")
if (!res.data?.key) throw new Error("API key missing from response")
return res.data.key
},
onSuccess: (key) => {
setApiKey(key)
setShowModal(true)
setCopied(false)
handleCopy(key)
},
onError: (error) => {
toast.error("Failed to create API key", {
@ -146,111 +128,14 @@ export function RaycastDetail() {
</div>
</div>
<Dialog
<RaycastSetupModal
open={showModal}
onOpenChange={(open: boolean) => {
onOpenChange={(open) => {
setShowModal(open)
if (!open) {
setApiKey("")
setCopied(false)
}
if (!open) setApiKey("")
}}
>
<DialogPortal>
<DialogContent className="bg-[#14161A] border border-white/10 text-[#FAFAFA] md:max-w-md z-100">
<DialogHeader>
<DialogTitle
className={cn(
dmSans125ClassName(),
"text-[#FAFAFA] text-lg font-semibold",
)}
>
Setup Raycast Extension
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<label
htmlFor={apiKeyId}
className={cn(
dmSans125ClassName(),
"text-sm font-medium text-[#737373]",
)}
>
Your Raycast API Key
</label>
<div className="flex items-center gap-2">
<input
id={apiKeyId}
type="text"
value={apiKey}
readOnly
className={cn(
"flex-1 bg-[#0D121A] border border-white/10 rounded-lg px-3 py-2 text-sm text-[#FAFAFA] font-mono",
dmSans125ClassName(),
)}
/>
<button
type="button"
onClick={() => handleCopy(apiKey)}
className="p-2 rounded-lg bg-[#0D121A] border border-white/10 text-[#737373] hover:text-[#FAFAFA] transition-colors"
>
{copied ? (
<Check className="size-4 text-[#4BA0FA]" />
) : (
<Copy className="size-4" />
)}
</button>
</div>
</div>
<div className="space-y-3">
<h4
className={cn(
dmSans125ClassName(),
"text-sm font-medium text-[#737373]",
)}
>
Follow these steps:
</h4>
<div className="space-y-2">
{[
"Install the Raycast extension from the Raycast Store",
"Open Raycast preferences and paste your API key",
'Use "Add Memory" or "Search Memories" commands!',
].map((text, i) => (
<div key={text} className="flex items-start gap-3">
<div className="shrink-0 size-6 bg-[#FF6363]/20 text-[#FF6363] rounded-full flex items-center justify-center text-xs font-medium">
{i + 1}
</div>
<p
className={cn(
dmSans125ClassName(),
"text-sm text-[#737373]",
)}
>
{text}
</p>
</div>
))}
</div>
</div>
<button
type="button"
onClick={() => window.open(RAYCAST_EXTENSION_URL, "_blank")}
className={cn(
"w-full flex items-center justify-center gap-2",
"bg-[#FF6363] hover:bg-[#FF6363]/90 text-white",
"rounded-lg h-11 px-4 font-medium text-sm transition-colors",
dmSans125ClassName(),
)}
>
<RaycastIcon className="size-4" />
Install Extension
</button>
</div>
</DialogContent>
</DialogPortal>
</Dialog>
apiKey={apiKey}
/>
</>
)
}

View file

@ -0,0 +1,132 @@
"use client"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { Download, X } from "lucide-react"
import { cn } from "@lib/utils"
import { dmSans125ClassName } from "@/lib/fonts"
import { RAYCAST_EXTENSION_URL } from "@lib/constants"
import { RaycastIcon } from "@/components/integration-icons"
import { Dialog, DialogContent, DialogTitle } from "@ui/components/dialog"
import type { InstallStep } from "@/lib/plugin-catalog"
import { INSET, InstallSteps } from "./install-steps"
const RAYCAST_STEPS: InstallStep[] = [
{
title: "Copy your API key",
description: "You won't be able to see it again — store it somewhere safe.",
code: "sm_...",
copyLabel: "API key",
secret: true,
},
{
title: "Install the Raycast extension",
description: "Open the Supermemory extension page in the Raycast Store.",
},
{
title: "Paste your key in Raycast preferences",
description:
"Open Raycast preferences → Extensions → Supermemory, then paste the key above.",
},
{
title: 'Run "Add Memory" or "Search Memories"',
description: "Trigger Raycast and start using Supermemory from anywhere.",
},
]
function RaycastIconBox() {
return (
<div
className={cn(
"flex size-10 shrink-0 items-center justify-center rounded-[10px] bg-[#080B0F]",
"shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.6)]",
)}
>
<RaycastIcon className="size-6" />
</div>
)
}
export function RaycastSetupModal({
open,
onOpenChange,
apiKey,
}: {
open: boolean
onOpenChange: (open: boolean) => void
apiKey: string
}) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
showCloseButton={false}
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",
}}
className={cn(
dmSans125ClassName(),
"flex max-h-[88dvh] flex-col gap-3 overflow-hidden border border-white/[0.12] bg-[#1B1F24] p-0 px-3 pt-3 pb-4 rounded-2xl md:px-4 sm:max-w-[560px] sm:rounded-[22px]",
)}
>
<DialogTitle className="sr-only">Set up Raycast Extension</DialogTitle>
<div className="flex shrink-0 items-center gap-3">
<RaycastIconBox />
<div className="min-w-0 flex-1">
<p
className={cn(
dmSans125ClassName(),
"truncate text-[16px] font-semibold leading-tight text-[#FAFAFA]",
)}
>
Set up Raycast Extension
</p>
<p
className={cn(
dmSans125ClassName(),
"mt-0.5 truncate text-[12px] text-[#A1A1AA]",
)}
>
Copy your key and follow these steps to finish.
</p>
</div>
<DialogPrimitive.Close
type="button"
aria-label="Close"
className={cn(
"flex size-7 items-center justify-center rounded-full bg-[#0D121A] transition-opacity hover:opacity-80 focus:outline-none",
INSET,
)}
>
<X className="size-4 text-[#737373]" />
</DialogPrimitive.Close>
</div>
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto">
<div
className={cn(
"min-w-0 rounded-[14px] bg-[#14161A] p-4 sm:p-5",
INSET,
)}
>
<InstallSteps steps={RAYCAST_STEPS} apiKey={apiKey} />
</div>
</div>
<div className="flex shrink-0 items-center justify-end gap-2">
<button
type="button"
onClick={() => window.open(RAYCAST_EXTENSION_URL, "_blank")}
className={cn(
dmSans125ClassName(),
"flex h-9 items-center gap-1.5 rounded-full bg-[#0D121A] px-5 text-[13px] font-medium text-[#FAFAFA] transition-opacity hover:opacity-80",
INSET,
)}
>
<Download className="size-3.5 text-[#A1A1AA]" /> Install extension
</button>
</div>
</DialogContent>
</Dialog>
)
}

View file

@ -13,6 +13,7 @@ const PAGE_SIZE = 100
interface UseGraphApiOptions {
containerTags?: string[]
documentIds?: string[]
enabled?: boolean
}
@ -81,7 +82,20 @@ function toGraphMemory(mem: ApiMemoryEntry): GraphApiMemory {
}
}
function toGraphDocument(doc: ApiDocument): GraphApiDocument {
function toGraphDocument(
doc: ApiDocument,
containerTags?: string[],
): GraphApiDocument {
const allowedContainerTags = new Set(containerTags?.filter(Boolean) ?? [])
const memoryEntries =
allowedContainerTags.size > 0
? doc.memoryEntries.filter(
(mem) =>
mem.spaceContainerTag != null &&
allowedContainerTags.has(mem.spaceContainerTag),
)
: doc.memoryEntries
return {
id: doc.id,
title: doc.title,
@ -89,12 +103,15 @@ function toGraphDocument(doc: ApiDocument): GraphApiDocument {
documentType: doc.type,
createdAt: doc.createdAt,
updatedAt: doc.updatedAt,
memories: doc.memoryEntries.map(toGraphMemory),
memories: memoryEntries.map(toGraphMemory),
}
}
export function useGraphApi(options: UseGraphApiOptions = {}) {
const { containerTags, enabled = true } = options
const { containerTags, documentIds, enabled = true } = options
const filteredDocumentIds = documentIds?.filter(Boolean)
const hasDocumentIds =
filteredDocumentIds != null && filteredDocumentIds.length > 0
const {
data,
@ -104,19 +121,33 @@ export function useGraphApi(options: UseGraphApiOptions = {}) {
hasNextPage,
fetchNextPage,
} = useInfiniteQuery<ApiDocumentsResponse, Error>({
queryKey: ["documents-with-memories", containerTags, []],
queryKey: [
"documents-with-memories",
containerTags,
[],
filteredDocumentIds,
],
initialPageParam: 1,
queryFn: async ({ pageParam }) => {
const response = await $fetch("@post/documents/documents", {
body: {
page: pageParam as number,
limit: PAGE_SIZE,
sort: "createdAt",
order: "desc",
containerTags,
},
disableValidation: true,
})
const response = hasDocumentIds
? await $fetch("@post/documents/documents/by-ids", {
body: {
ids: filteredDocumentIds,
by: "id",
containerTags,
},
disableValidation: true,
})
: await $fetch("@post/documents/documents", {
body: {
page: pageParam as number,
limit: PAGE_SIZE,
sort: "createdAt",
order: "desc",
containerTags,
},
disableValidation: true,
})
if (response.error) {
throw new Error(response.error?.message || "Failed to fetch documents")
@ -134,8 +165,10 @@ export function useGraphApi(options: UseGraphApiOptions = {}) {
const documents = useMemo(() => {
if (!data?.pages) return []
return data.pages.flatMap((page) => page.documents.map(toGraphDocument))
}, [data])
return data.pages.flatMap((page) =>
page.documents.map((doc) => toGraphDocument(doc, containerTags)),
)
}, [data, containerTags])
const totalCount = data?.pages[0]?.pagination.totalItems ?? 0

View file

@ -29,6 +29,7 @@ export function MemoryGraph({
error: externalError = null,
variant = "console",
containerTags,
documentIds,
maxNodes,
canvasRef,
...rest
@ -57,6 +58,7 @@ export function MemoryGraph({
totalCount,
} = useGraphApi({
containerTags,
documentIds,
enabled: containerSize.width > 0 && containerSize.height > 0,
})

View file

@ -1,24 +0,0 @@
"use client"
import { useIsMobile } from "@hooks/use-mobile"
import { cn } from "@lib/utils"
export function MobileBanner() {
const isMobile = useIsMobile()
if (!isMobile) {
return null
}
return (
<div
className={cn(
"bg-yellow-50 dark:bg-yellow-950/20 border-b border-yellow-200 dark:border-yellow-900/30",
"px-4 py-2 text-xs text-yellow-800 dark:text-yellow-200 text-center",
)}
id="mobile-development-banner"
>
🚧 Mobile responsive in development. Desktop recommended.
</div>
)
}

View file

@ -0,0 +1,37 @@
"use client"
import { Shuffle } from "lucide-react"
import NovaOrb from "./nova-orb"
import { cn } from "@lib/utils"
/** Nova orb with a corner badge — Auto mode (Nova picks across spaces). */
export function AutoSpaceIcon({
size = 20,
className,
}: {
size?: number
className?: string
}) {
const badgeSize = Math.max(10, Math.round(size * 0.5))
const badgeIcon = Math.max(6, Math.round(badgeSize * 0.55))
return (
<span
className={cn("relative shrink-0", className)}
style={{ width: size, height: size }}
aria-hidden
>
<NovaOrb size={size} className="blur-[0.45px]!" />
<span
className="absolute -bottom-px -right-px flex items-center justify-center rounded-full bg-[#4BA0FA] text-[#041127] ring-1 ring-[#14161A]"
style={{ width: badgeSize, height: badgeSize }}
>
<Shuffle
className="shrink-0"
style={{ width: badgeIcon, height: badgeIcon }}
strokeWidth={2.5}
/>
</span>
</span>
)
}

View file

@ -39,7 +39,7 @@ export function NovaEmptyState({
return (
<div
id="nova-empty-state"
className="min-h-[calc(100dvh-12rem)] flex items-center justify-center p-6 md:p-8 opacity-50 hover:opacity-100 transition-opacity duration-300"
className="min-h-[calc(100svh-12rem)] sm:min-h-[calc(100dvh-12rem)] flex items-center justify-center p-4 sm:p-6 md:p-8 opacity-50 hover:opacity-100 transition-opacity duration-300"
>
<div className="max-w-xl w-full flex flex-col items-center text-center">
<NovaOrb size={80} className="blur-[2px]! mb-4" />
@ -119,7 +119,7 @@ export function NovaEmptyState({
</>
)}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 w-full mb-4">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2 sm:gap-3 w-full mb-4">
<button
type="button"
onClick={() => onAddMemory("link")}

View file

@ -0,0 +1,429 @@
"use client"
import { useState, useEffect, useCallback, type ReactNode } from "react"
import Image from "next/image"
import { cn } from "@lib/utils"
import { dmSansClassName, dmSans125ClassName } from "@/lib/fonts"
import { XIcon, Check } from "lucide-react"
import { Drawer, DrawerContent, DrawerTitle } from "@ui/components/drawer"
import { Button } from "@ui/components/button"
const PWA_DISMISS_KEY = "pwa-install-dismissed"
const DISMISS_DURATION_MS = 7 * 24 * 60 * 60 * 1000
const INSET =
"shadow-[inset_0_2px_4px_rgba(0,0,0,0.3),inset_0_1px_2px_rgba(0,0,0,0.1)]"
const CARD_INSET = "shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]"
type DeviceInfo = {
isIOS: boolean
isAndroid: boolean
isSafari: boolean
isChrome: boolean
}
let memoryDismissed = false
function getDeviceInfo(): DeviceInfo {
if (typeof window === "undefined")
return { isIOS: false, isAndroid: false, isSafari: false, isChrome: false }
const ua = navigator.userAgent
const isIOS =
/iPad|iPhone|iPod/.test(ua) ||
(/Macintosh/.test(ua) && navigator.maxTouchPoints > 1)
const isAndroid = /Android/.test(ua)
const isSafari =
/Safari/.test(ua) && !/CriOS|FxiOS|Chrome|Chromium|Edg|OPR|Opera/.test(ua)
const isChrome = /Chrome/.test(ua) && !/Edg|OPR|Opera/.test(ua)
return { isIOS, isAndroid, isSafari, isChrome }
}
function isStandalone() {
if (typeof window === "undefined") return false
return (
window.matchMedia("(display-mode: standalone)").matches ||
(window.navigator as unknown as { standalone?: boolean }).standalone ===
true
)
}
function isDismissed() {
if (typeof window === "undefined") return true
if (memoryDismissed) return true
try {
const dismissed = localStorage.getItem(PWA_DISMISS_KEY)
if (!dismissed) return false
const timestamp = Number.parseInt(dismissed, 10)
if (Date.now() - timestamp < DISMISS_DURATION_MS) return true
localStorage.removeItem(PWA_DISMISS_KEY)
return false
} catch {
return false
}
}
const BENEFITS = [
"Instant launch",
"Full-screen mode",
"Native-app feel",
"Stays signed in",
]
type Step = {
title: string
description: ReactNode
}
function buildSteps(device: DeviceInfo): Step[] {
if (device.isIOS) {
if (device.isSafari) {
return [
{
title: "Open the share menu",
description: (
<>
Tap the share icon{" "}
<ShareIcon className="inline-block size-3.5 align-text-bottom mx-0.5 text-[#4BA0FA]" />{" "}
at the bottom of Safari.
</>
),
},
{
title: "Add to Home Screen",
description:
"Scroll down in the share sheet and tap Add to Home Screen.",
},
{
title: "Confirm",
description: "Tap Add in the top right to install.",
},
]
}
return [
{
title: "Open this page in Safari",
description:
"The install flow only works inside Safari on iPhone and iPad.",
},
{
title: "Open the share menu",
description: (
<>
Tap the share icon{" "}
<ShareIcon className="inline-block size-3.5 align-text-bottom mx-0.5 text-[#4BA0FA]" />{" "}
at the bottom.
</>
),
},
{
title: "Add to Home Screen",
description: "Choose Add to Home Screen and confirm.",
},
]
}
if (device.isChrome) {
return [
{
title: "Open the Chrome menu",
description: (
<>
Tap the menu icon{" "}
<MoreVertIcon className="inline-block size-3.5 align-text-bottom mx-0.5 text-[#4BA0FA]" />{" "}
in the top right.
</>
),
},
{
title: "Add to Home screen",
description: "Tap Add to Home screen from the menu.",
},
{
title: "Confirm",
description: "Tap Install to finish.",
},
]
}
return [
{
title: "Open this page in Chrome",
description: "Install works best from Chrome on Android.",
},
{
title: "Open the Chrome menu",
description: (
<>
Tap the menu icon{" "}
<MoreVertIcon className="inline-block size-3.5 align-text-bottom mx-0.5 text-[#4BA0FA]" />{" "}
in the top right.
</>
),
},
{
title: "Add to Home screen",
description: "Choose Add to Home screen and confirm.",
},
]
}
export function PWAInstallPrompt() {
const [show, setShow] = useState(false)
const [device, setDevice] = useState<DeviceInfo>({
isIOS: false,
isAndroid: false,
isSafari: false,
isChrome: false,
})
const [nativePrompt, setNativePrompt] =
useState<BeforeInstallPromptEvent | null>(null)
useEffect(() => {
const info = getDeviceInfo()
setDevice(info)
const handleBeforeInstall = (e: Event) => {
e.preventDefault()
setNativePrompt(e as BeforeInstallPromptEvent)
}
window.addEventListener("beforeinstallprompt", handleBeforeInstall)
const isMobile = info.isIOS || info.isAndroid
let timer: ReturnType<typeof setTimeout> | undefined
if (isMobile && !isStandalone() && !isDismissed()) {
timer = setTimeout(() => setShow(true), 1500)
}
return () => {
if (timer) clearTimeout(timer)
window.removeEventListener("beforeinstallprompt", handleBeforeInstall)
}
}, [])
const dismiss = useCallback(() => {
setShow(false)
memoryDismissed = true
try {
localStorage.setItem(PWA_DISMISS_KEY, Date.now().toString())
} catch {}
}, [])
const handleInstall = useCallback(async () => {
if (nativePrompt) {
nativePrompt.prompt()
await nativePrompt.userChoice
setNativePrompt(null)
}
dismiss()
}, [nativePrompt, dismiss])
const openShareMenu = useCallback(async () => {
try {
if (typeof navigator !== "undefined" && "share" in navigator) {
await navigator.share({
title: "Supermemory",
url: window.location.href,
})
}
} catch {}
dismiss()
}, [dismiss])
const canNativeInstall = !!nativePrompt && device.isAndroid && device.isChrome
const canShare = typeof navigator !== "undefined" && "share" in navigator
const steps = buildSteps(device)
return (
<Drawer open={show} onOpenChange={(open) => !open && dismiss()}>
<DrawerContent
className={cn(
"flex flex-col gap-0 border-none bg-[#1B1F24] p-0",
"max-h-[92svh] overflow-hidden",
"[&>div:first-child]:bg-[#3A4252] [&>div:first-child]:h-1 [&>div:first-child]:w-9 [&>div:first-child]:mt-2.5 [&>div:first-child]:mb-1",
dmSansClassName(),
)}
>
<DrawerTitle className="sr-only">Install Supermemory</DrawerTitle>
<div
className={cn(
"flex flex-col gap-4 px-4 pt-3",
"pb-[max(1rem,env(safe-area-inset-bottom))]",
)}
>
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-3 min-w-0">
<Image
src="/android-chrome-512x512.png"
alt=""
width={48}
height={48}
className="size-12 shrink-0"
priority
/>
<div className="min-w-0">
<h2
className={cn(
"text-[17px] font-semibold text-[#FAFAFA] leading-tight",
dmSans125ClassName(),
)}
>
Install Supermemory
</h2>
<p
className={cn(
"text-[13px] text-[#737373] leading-snug mt-0.5",
dmSans125ClassName(),
)}
>
Your memories, one tap away.
</p>
</div>
</div>
<button
type="button"
onClick={dismiss}
aria-label="Dismiss install prompt"
className={cn(
"size-7 shrink-0 flex items-center justify-center rounded-full bg-[#0D121A] transition-opacity hover:opacity-80",
INSET,
)}
>
<XIcon className="size-3.5 text-[#737373]" />
</button>
</div>
<div
className={cn(
"rounded-[14px] bg-[#14161A] p-5 flex flex-col gap-5",
CARD_INSET,
)}
>
<div className="grid grid-cols-2 gap-x-5 gap-y-2.5">
{BENEFITS.map((text) => (
<div key={text} className="flex items-start gap-2">
<Check className="size-4 shrink-0 text-[#4BA0FA] mt-0.5" />
<span
className={cn(
"text-[13px] text-[#E4E4E7] leading-snug",
dmSans125ClassName(),
)}
>
{text}
</span>
</div>
))}
</div>
<div className="h-px bg-white/[0.06]" />
<ol className="flex min-w-0 flex-col">
{steps.map((step, i) => (
<li key={step.title} className="flex min-w-0 gap-3">
<div className="flex flex-col items-center">
<span
className={cn(
"flex size-[22px] shrink-0 items-center justify-center rounded-full bg-[#0D121A] text-[11px] font-semibold text-[#4BA0FA]",
INSET,
)}
>
{i + 1}
</span>
{i < steps.length - 1 && (
<span className="w-px flex-1 bg-white/[0.10] my-1" />
)}
</div>
<div
className={cn(
"min-w-0 flex-1 space-y-0.5",
i < steps.length - 1 ? "pb-4" : "",
)}
>
<p
className={cn(
"text-[13px] font-semibold text-[#FAFAFA] leading-tight",
dmSans125ClassName(),
)}
>
{step.title}
</p>
<p
className={cn(
"text-[12px] leading-relaxed text-[#A1A1AA]",
dmSans125ClassName(),
)}
>
{step.description}
</p>
</div>
</li>
))}
</ol>
</div>
<Button
variant="insideOut"
onClick={
canNativeInstall
? handleInstall
: canShare
? openShareMenu
: dismiss
}
className={cn("h-12 w-full px-5 text-[15px]", dmSansClassName())}
>
{canNativeInstall
? "Install now"
: canShare
? "Add to Home Screen"
: "Got it"}
</Button>
</div>
</DrawerContent>
</Drawer>
)
}
function ShareIcon({ className }: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
className={className}
aria-hidden="true"
>
<path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8" />
<polyline points="16 6 12 2 8 6" />
<line x1="12" y1="2" x2="12" y2="15" />
</svg>
)
}
function MoreVertIcon({ className }: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className={className}
aria-hidden="true"
>
<circle cx="12" cy="5" r="2" />
<circle cx="12" cy="12" r="2" />
<circle cx="12" cy="19" r="2" />
</svg>
)
}
interface BeforeInstallPromptEvent extends Event {
prompt(): Promise<void>
userChoice: Promise<{ outcome: "accepted" | "dismissed" }>
}
declare global {
interface WindowEventMap {
beforeinstallprompt: BeforeInstallPromptEvent
}
}

View file

@ -1,6 +1,6 @@
"use client"
import { useState, useMemo, useEffect } from "react"
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"
@ -17,6 +17,8 @@ import {
ArrowRight,
BookOpen,
Loader,
Pencil,
Check,
} from "lucide-react"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { toast } from "sonner"
@ -26,6 +28,7 @@ import { useAuth } from "@lib/auth-context"
import type { ContainerTagListType } from "@lib/types"
import {
compareSpacesUserFirst,
isOwnConversationSpace,
spaceSelectorDisplayName,
} from "@/lib/ingest-auto-space"
import {
@ -40,6 +43,10 @@ import {
type PluginInfo,
} from "@/lib/plugin-catalog"
import { InstallSteps, PillButton } from "./integrations/install-steps"
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"
interface SelectSpacesModalProps {
isOpen: boolean
@ -49,6 +56,7 @@ interface SelectSpacesModalProps {
projects: ContainerTagListType[]
recents?: string[]
showNewSpace?: boolean
includeAuto?: boolean
onNewSpace?: () => void
enableDelete?: boolean
onDeleteRequest?: (project: {
@ -56,6 +64,13 @@ interface SelectSpacesModalProps {
name: string
containerTag: string
}) => void
onBulkDeleteRequest?: (
projects: {
id: string
name: string
containerTag: string
}[],
) => void
}
type CategoryId =
@ -80,11 +95,26 @@ export function SelectSpacesModal({
projects,
recents,
showNewSpace = false,
includeAuto = false,
onNewSpace,
enableDelete = false,
onDeleteRequest,
onBulkDeleteRequest,
}: SelectSpacesModalProps) {
const [searchQuery, setSearchQuery] = useState("")
const [isBulkDeleteMode, setIsBulkDeleteMode] = useState(false)
const [bulkDeleteTags, setBulkDeleteTags] = useState<Set<string>>(new Set())
const [lastBulkDeleteTag, setLastBulkDeleteTag] = useState<string | null>(
null,
)
const [editingProject, setEditingProject] = useState<{
id: string
containerTag: string
originalName: string
name: string
} | null>(null)
const editInputRef = useRef<HTMLInputElement | null>(null)
const editingContainerTag = editingProject?.containerTag
const currentSelection = selectedProjects[0] ?? ""
const pluginTags = useMemo(
@ -174,6 +204,7 @@ export function SelectSpacesModal({
const defaultCategory = useMemo<CategoryId>(() => {
if (!currentSelection) return "all"
if (currentSelection === AUTO_CHAT_SPACE_ID) return "all"
const plugin = detectPluginSpace(currentSelection)
if (plugin) return `plugin:${plugin.pluginId}`
return "my"
@ -181,12 +212,22 @@ export function SelectSpacesModal({
const [activeCategory, setActiveCategory] =
useState<CategoryId>(defaultCategory)
const activeDiscoverId = activeCategory.startsWith("discover:")
? activeCategory.slice("discover:".length)
: null
useEffect(() => {
if (isOpen) setActiveCategory(defaultCategory)
}, [isOpen, defaultCategory])
const { org } = useAuth()
useEffect(() => {
if (!activeDiscoverId) return
setIsBulkDeleteMode(false)
setBulkDeleteTags(new Set())
setLastBulkDeleteTag(null)
}, [activeDiscoverId])
const { org, user } = useAuth()
const queryClient = useQueryClient()
const [connectingPluginId, setConnectingPluginId] = useState<string | null>(
null,
@ -195,6 +236,7 @@ export function SelectSpacesModal({
pluginId: string
key: string
} | null>(null)
const { updateProjectMutation } = useProjectMutations()
const { data: availablePluginsData } = useQuery({
queryKey: ["plugins"],
@ -208,17 +250,17 @@ export function SelectSpacesModal({
return (await res.json()) as { plugins: string[] }
},
staleTime: 5 * 60 * 1000,
enabled: isOpen,
enabled: isOpen && !!activeDiscoverId,
})
const { data: apiKeys = [] } = useQuery({
queryKey: ["api-keys", org?.id],
enabled: isOpen && !!org?.id,
enabled: isOpen && !!activeDiscoverId && !!org?.id,
queryFn: async () => {
if (!org?.id) return []
const data = await authClient.apiKey.list({
const data = (await authClient.apiKey.list({
fetchOptions: { query: { metadata: { organizationId: org.id } } },
})
})) as unknown as { metadata?: Record<string, unknown> | null }[]
return data.filter((key) => key.metadata?.organizationId === org.id)
},
})
@ -303,20 +345,105 @@ export function SelectSpacesModal({
useEffect(() => {
if (!isOpen) {
setNewKey(null)
setEditingProject(null)
setIsBulkDeleteMode(false)
setBulkDeleteTags(new Set())
setLastBulkDeleteTag(null)
}
}, [isOpen])
const handleOpenChange = (open: boolean) => {
if (!open) {
onClose()
setSearchQuery("")
}
}
useEffect(() => {
if (!editingContainerTag) return
const frame = requestAnimationFrame(() => {
editInputRef.current?.focus()
editInputRef.current?.select()
})
return () => cancelAnimationFrame(frame)
}, [editingContainerTag])
const handleSelect = (containerTag: string) => {
onApply([containerTag])
const handleOpenChange = useCallback(
(open: boolean) => {
if (!open) {
onClose()
setSearchQuery("")
setEditingProject(null)
setIsBulkDeleteMode(false)
setBulkDeleteTags(new Set())
setLastBulkDeleteTag(null)
}
},
[onClose],
)
const handleSelect = useCallback(
(containerTag: string) => {
setEditingProject(null)
setIsBulkDeleteMode(false)
setBulkDeleteTags(new Set())
setLastBulkDeleteTag(null)
onApply([containerTag])
setSearchQuery("")
},
[onApply],
)
const handleSelectAuto = useCallback(() => {
setEditingProject(null)
setIsBulkDeleteMode(false)
setBulkDeleteTags(new Set())
setLastBulkDeleteTag(null)
onApply([AUTO_CHAT_SPACE_ID])
setSearchQuery("")
}
}, [onApply])
const handleBulkModeToggle = useCallback(() => {
setEditingProject(null)
setBulkDeleteTags(new Set())
setLastBulkDeleteTag(null)
setIsBulkDeleteMode((prev) => !prev)
}, [])
const startEditing = useCallback((project: ContainerTagListType) => {
const name = project.name ?? project.containerTag
setEditingProject({
id: project.id,
containerTag: project.containerTag,
originalName: name,
name,
})
}, [])
const cancelEditing = useCallback(() => {
setEditingProject(null)
}, [])
const saveEditing = useCallback(() => {
if (!editingProject) return
const nextName = editingProject.name.trim()
const currentName = editingProject.originalName.trim()
if (!nextName || nextName === currentName) return
updateProjectMutation.mutate(
{ containerTag: editingProject.containerTag, name: nextName },
{
onSuccess: () => setEditingProject(null),
},
)
}, [editingProject, updateProjectMutation])
const handleEditKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
e.preventDefault()
saveEditing()
}
if (e.key === "Escape") {
e.preventDefault()
cancelEditing()
}
},
[cancelEditing, saveEditing],
)
const filteredProjects = useMemo(() => {
const byCategory = allSpaces.filter((p) => {
@ -330,15 +457,19 @@ export function SelectSpacesModal({
return byCategory.filter((p) => {
const plugin = detectPluginSpace(p.containerTag)
const projectName = pluginMetaMap.get(p.containerTag)?.projectName
const displayName = spaceSelectorDisplayName(p, p.containerTag, {
currentUserId: user?.id,
})
return (
p.containerTag.toLowerCase().includes(query) ||
(p.name ?? "").toLowerCase().includes(query) ||
displayName.toLowerCase().includes(query) ||
(plugin?.label.toLowerCase().includes(query) ?? false) ||
(plugin?.projectId?.toLowerCase().includes(query) ?? false) ||
(projectName?.toLowerCase().includes(query) ?? false)
)
})
}, [allSpaces, activeCategory, searchQuery, pluginMetaMap])
}, [allSpaces, activeCategory, searchQuery, pluginMetaMap, user?.id])
const recentProjects = useMemo<ContainerTagListType[]>(() => {
if (!recents?.length) return []
@ -367,17 +498,301 @@ export function SelectSpacesModal({
[filteredProjects, recentSet],
)
const renderRow = (project: ContainerTagListType) => {
const isSelected = currentSelection === project.containerTag
const plugin = detectPluginSpace(project.containerTag)
const pluginProjectName = pluginMetaMap.get(
project.containerTag,
)?.projectName
const pluginIdLabel = pluginProjectName || plugin?.projectId
const isDefault = project.containerTag === DEFAULT_PROJECT_ID
const showAutoRow = useMemo(() => {
if (!includeAuto) return false
if (isBulkDeleteMode) return false
if (activeCategory !== "all" && activeCategory !== "my") return false
const query = searchQuery.trim().toLowerCase()
if (!query) return true
return (
"auto".includes(query) ||
"let nova choose the right spaces".includes(query) ||
"discover spaces".includes(query)
)
}, [includeAuto, isBulkDeleteMode, activeCategory, searchQuery])
const visibleBulkDeleteTags = useMemo(
() =>
[...recentProjects, ...mainList]
.filter((project) => project.containerTag !== DEFAULT_PROJECT_ID)
.map((project) => project.containerTag),
[recentProjects, mainList],
)
const toggleBulkDeleteTag = useCallback(
(containerTag: string, shiftKey = false) => {
setBulkDeleteTags((prev) => {
const next = new Set(prev)
const currentIndex = visibleBulkDeleteTags.indexOf(containerTag)
const anchorIndex = lastBulkDeleteTag
? visibleBulkDeleteTags.indexOf(lastBulkDeleteTag)
: -1
if (shiftKey && currentIndex !== -1 && anchorIndex !== -1) {
const start = Math.min(anchorIndex, currentIndex)
const end = Math.max(anchorIndex, currentIndex)
for (const tag of visibleBulkDeleteTags.slice(start, end + 1)) {
next.add(tag)
}
} else if (next.has(containerTag)) {
next.delete(containerTag)
} else {
next.add(containerTag)
}
return next
})
setLastBulkDeleteTag(containerTag)
},
[lastBulkDeleteTag, visibleBulkDeleteTags],
)
const bulkDeleteProjects = useMemo(
() =>
allSpaces
.filter(
(project) =>
project.containerTag !== DEFAULT_PROJECT_ID &&
bulkDeleteTags.has(project.containerTag),
)
.map((project) => ({
id: project.id,
name: spaceSelectorDisplayName(project, project.containerTag, {
currentUserId: user?.id,
}),
containerTag: project.containerTag,
})),
[allSpaces, bulkDeleteTags, user?.id],
)
const bulkDeleteCount = bulkDeleteProjects.length
const renderRow = useCallback(
(project: ContainerTagListType) => {
const isSelected = currentSelection === project.containerTag
const plugin = detectPluginSpace(project.containerTag)
const pluginProjectName = pluginMetaMap.get(
project.containerTag,
)?.projectName
const pluginIdLabel = pluginProjectName || plugin?.projectId
const displayName = spaceSelectorDisplayName(
project,
project.containerTag,
{
currentUserId: user?.id,
},
)
const isDefault = project.containerTag === DEFAULT_PROJECT_ID
const isOwnSpace = isOwnConversationSpace(project, user?.id)
const canEdit = !isDefault && !plugin && !isOwnSpace
const canBulkDelete = enableDelete && !isDefault
const isEditing = editingProject?.containerTag === project.containerTag
const isBulkDeleteSelected = bulkDeleteTags.has(project.containerTag)
const trimmedEditName = editingProject?.name.trim() ?? ""
const isSaveDisabled =
!trimmedEditName ||
trimmedEditName === editingProject?.originalName.trim() ||
updateProjectMutation.isPending
const handleRowAction = (
e: React.MouseEvent<HTMLButtonElement, MouseEvent>,
) => {
if (isEditing) return
if (isBulkDeleteMode) {
if (canBulkDelete) {
toggleBulkDeleteTag(project.containerTag, e.shiftKey)
}
return
}
handleSelect(project.containerTag)
}
return (
<div
key={project.containerTag}
className={cn(
"group flex min-w-0 max-w-full items-center gap-3 w-full px-3 py-2.5 rounded-[12px] transition-colors",
(isBulkDeleteMode ? isBulkDeleteSelected : isSelected)
? "bg-[#14161A] shadow-inside-out"
: "hover:bg-[#14161A]/50",
isBulkDeleteMode &&
!canBulkDelete &&
"cursor-not-allowed opacity-45",
)}
>
<button
type="button"
onClick={handleRowAction}
disabled={isBulkDeleteMode && !canBulkDelete}
aria-label={
isBulkDeleteMode ? "Select space for deletion" : "Select space"
}
aria-pressed={isBulkDeleteMode ? isBulkDeleteSelected : isSelected}
className={cn(
"w-4 h-4 rounded-full border-2 flex items-center justify-center shrink-0 transition-colors cursor-pointer disabled:cursor-not-allowed",
isBulkDeleteMode
? isBulkDeleteSelected
? "border-red-400 bg-red-400/10"
: "border-[#737373]"
: isSelected
? "border-[#4BA0FA]"
: "border-[#737373]",
)}
>
{isBulkDeleteMode ? (
isBulkDeleteSelected && <Check className="size-3 text-red-300" />
) : isSelected ? (
<div className="w-2 h-2 rounded-full bg-[#4BA0FA]" />
) : null}
</button>
{isEditing ? (
<div className="flex min-w-0 flex-1 items-center gap-2">
<span className="shrink-0 text-lg">{project.emoji || "📁"}</span>
<input
type="text"
value={editingProject.name}
ref={editInputRef}
onChange={(e) =>
setEditingProject((prev) =>
prev ? { ...prev, name: e.target.value } : prev,
)
}
onKeyDown={handleEditKeyDown}
className={cn(
"min-w-0 flex-1 rounded-[9px] border border-[rgba(82,89,102,0.35)] bg-[#0D121A] px-2.5 py-1.5 text-sm font-medium text-[#fafafa] shadow-inside-out placeholder:text-[#737373] focus:outline-none focus:ring-1 focus:ring-[rgba(75,160,250,0.45)]",
dmSansClassName(),
)}
aria-label="Space name"
/>
<button
type="button"
onClick={saveEditing}
disabled={isSaveDisabled}
aria-label="Save space name"
className="shrink-0 rounded-full p-1.5 text-[#4BA0FA] transition-colors hover:bg-[#4BA0FA]/15 disabled:cursor-not-allowed disabled:opacity-35"
>
<Check className="size-3.5" />
</button>
<button
type="button"
onClick={cancelEditing}
disabled={updateProjectMutation.isPending}
aria-label="Cancel editing space name"
className="shrink-0 rounded-full p-1.5 text-[#737373] transition-colors hover:bg-[#737373]/15 hover:text-[#fafafa] disabled:cursor-not-allowed disabled:opacity-35"
>
<XIcon className="size-3.5" />
</button>
</div>
) : (
<button
type="button"
onClick={handleRowAction}
disabled={isBulkDeleteMode && !canBulkDelete}
className="flex min-w-0 flex-1 items-center gap-3 text-left cursor-pointer focus:outline-none focus:ring-0 disabled:cursor-not-allowed"
>
{plugin ? (
plugin.iconSrc ? (
<Image
src={plugin.iconSrc}
alt=""
width={20}
height={20}
className="shrink-0 rounded-[4px]"
aria-hidden
/>
) : (
<span
className="shrink-0 flex items-center justify-center w-5 h-5 rounded-[4px] bg-[#1E232B] text-[#FAFAFA] text-[11px] font-semibold uppercase"
aria-hidden
>
{pluginInitial(plugin.label)}
</span>
)
) : isOwnSpace ? (
<NovaOrb size={20} className="shrink-0 blur-[0.55px]!" />
) : (
<span className="shrink-0 text-lg">
{project.emoji || "📁"}
</span>
)}
<span
className="min-w-0 flex-1 truncate text-[#fafafa] text-sm font-medium"
title={plugin ? project.containerTag : displayName}
>
{plugin ? (
<>
{plugin.label}
{pluginIdLabel && (
<span className="ml-1.5 text-[12px] text-[#737373]">
· {pluginIdLabel}
</span>
)}
</>
) : (
displayName
)}
</span>
</button>
)}
{canEdit && !isEditing && !isBulkDeleteMode && (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
startEditing(project)
}}
aria-label="Rename space"
className="shrink-0 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity p-1.5 rounded-full text-[#737373] hover:bg-[#737373]/15 hover:text-[#fafafa] cursor-pointer focus:outline-none"
>
<Pencil className="size-3.5" />
</button>
)}
{enableDelete &&
!isDefault &&
!isEditing &&
!isBulkDeleteMode &&
onDeleteRequest && (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onDeleteRequest({
id: project.id,
name: displayName,
containerTag: project.containerTag,
})
}}
aria-label="Delete space"
className="shrink-0 opacity-0 group-hover:opacity-100 transition-opacity p-1.5 rounded-full hover:bg-red-500/15 cursor-pointer focus:outline-none"
>
<Trash2 className="size-3.5 text-red-400" />
</button>
)}
</div>
)
},
[
cancelEditing,
bulkDeleteTags,
currentSelection,
editingProject,
enableDelete,
handleEditKeyDown,
handleSelect,
isBulkDeleteMode,
onDeleteRequest,
pluginMetaMap,
saveEditing,
startEditing,
toggleBulkDeleteTag,
updateProjectMutation.isPending,
user?.id,
],
)
const renderAutoRow = useCallback(() => {
const isSelected = currentSelection === AUTO_CHAT_SPACE_ID
return (
<div
key={project.containerTag}
key={AUTO_CHAT_SPACE_ID}
className={cn(
"group flex min-w-0 max-w-full items-center gap-3 w-full px-3 py-2.5 rounded-[12px] transition-colors",
isSelected
@ -385,89 +800,40 @@ export function SelectSpacesModal({
: "hover:bg-[#14161A]/50",
)}
>
<div
className={cn(
"w-4 h-4 rounded-full border-2 flex items-center justify-center shrink-0 transition-colors",
isSelected ? "border-[#4BA0FA]" : "border-[#737373]",
)}
>
{isSelected && <div className="w-2 h-2 rounded-full bg-[#4BA0FA]" />}
</div>
<button
type="button"
onClick={() => handleSelect(project.containerTag)}
onClick={handleSelectAuto}
className="flex min-w-0 flex-1 items-center gap-3 text-left cursor-pointer focus:outline-none focus:ring-0"
>
<div
className={cn(
"w-4 h-4 rounded-full border-2 flex items-center justify-center shrink-0 transition-colors",
isSelected ? "border-[#4BA0FA]" : "border-[#737373]",
)}
>
{isSelected && (
<div className="w-2 h-2 rounded-full bg-[#4BA0FA]" />
)}
</div>
{plugin ? (
plugin.iconSrc ? (
<Image
src={plugin.iconSrc}
alt=""
width={20}
height={20}
className="shrink-0 rounded-[4px]"
aria-hidden
/>
) : (
<span
className="shrink-0 flex items-center justify-center w-5 h-5 rounded-[4px] bg-[#1E232B] text-[#FAFAFA] text-[11px] font-semibold uppercase"
aria-hidden
>
{pluginInitial(plugin.label)}
</span>
)
) : (
<span className="shrink-0 text-lg">{project.emoji || "📁"}</span>
)}
<span
className="min-w-0 flex-1 truncate text-[#fafafa] text-sm font-medium"
title={project.containerTag}
>
{plugin ? (
<>
{plugin.label}
{pluginIdLabel && (
<span className="ml-1.5 text-[12px] text-[#737373]">
· {pluginIdLabel}
</span>
)}
</>
) : (
spaceSelectorDisplayName(project, project.containerTag)
)}
<AutoSpaceIcon size={20} />
<span className="min-w-0 flex-1 truncate text-[#fafafa] text-sm font-medium">
Auto
<span className="ml-1.5 text-[12px] text-[#737373]">
· Nova chooses spaces
</span>
</span>
</button>
{enableDelete && !isDefault && onDeleteRequest && (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onDeleteRequest({
id: project.id,
name: project.name,
containerTag: project.containerTag,
})
}}
aria-label="Delete space"
className="shrink-0 opacity-0 group-hover:opacity-100 transition-opacity p-1.5 rounded-full hover:bg-red-500/15 cursor-pointer focus:outline-none"
>
<Trash2 className="size-3.5 text-red-400" />
</button>
)}
</div>
)
}
}, [currentSelection, handleSelectAuto])
return (
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
<DialogContent
className={cn(
"w-[92%]! max-w-[720px]! border-none bg-[#1B1F24] flex flex-col p-0 gap-0 rounded-[22px] overflow-hidden",
"w-[calc(100vw-1rem)]! max-w-[720px]! max-h-[calc(100dvh-1rem)] min-w-0 border-none bg-[#1B1F24] flex flex-col p-0 gap-0 rounded-[22px] overflow-hidden sm:w-[92vw]!",
dmSansClassName(),
)}
style={{
display: "flex",
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",
}}
@ -484,23 +850,44 @@ export function SelectSpacesModal({
Select Space
</p>
<p className="text-[#737373] font-medium text-[14px] leading-[1.35]">
Filter your memories by space
{isBulkDeleteMode
? "Choose spaces to permanently delete"
: "Filter your memories by space"}
</p>
</div>
<DialogPrimitive.Close
className="bg-[#0D121A] w-7 h-7 flex items-center justify-center focus:ring-ring rounded-full transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 border border-[rgba(115,115,115,0.2)] shrink-0"
style={{
boxShadow: "inset 1.313px 1.313px 3.938px 0px rgba(0,0,0,0.7)",
}}
>
<XIcon stroke="#737373" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
<div className="flex shrink-0 items-center gap-2">
{enableDelete && onBulkDeleteRequest && !activeDiscoverId && (
<button
type="button"
onClick={handleBulkModeToggle}
className={cn(
"flex h-7 items-center gap-1.5 rounded-full bg-[#0D121A] px-2.5 text-[12px] font-medium transition-colors hover:bg-[#121820] focus:outline-none",
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>
)}
<DialogPrimitive.Close
className="bg-[#0D121A] w-7 h-7 flex items-center justify-center focus:ring-ring rounded-full transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 border border-[rgba(115,115,115,0.2)] shrink-0"
style={{
boxShadow: "inset 1.313px 1.313px 3.938px 0px rgba(0,0,0,0.7)",
}}
>
<XIcon stroke="#737373" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</div>
</div>
<div className="mt-4 flex min-h-[420px] gap-3 px-4 pb-4">
<div className="w-[200px] shrink-0 overflow-y-auto scrollbar-thin pr-1">
<div className="flex flex-col gap-1">
<div className="mt-4 flex min-h-0 flex-1 flex-col gap-5 overflow-hidden px-4 pb-4 sm:min-h-[420px] sm:flex-row sm:gap-3">
<div className="w-full shrink-0 overflow-x-hidden overflow-y-auto scrollbar-thin sm:w-[200px] sm:pr-1">
<div className="grid grid-cols-2 gap-1 sm:flex sm:flex-col">
{categories.map((category) => {
const isActive = activeCategory === category.id
return (
@ -509,7 +896,7 @@ export function SelectSpacesModal({
type="button"
onClick={() => setActiveCategory(category.id)}
className={cn(
"flex items-center gap-2.5 px-3 py-2 rounded-[12px] text-left transition-colors cursor-pointer focus:outline-none focus:ring-0",
"flex min-w-0 items-center gap-2.5 px-3 py-2 rounded-[12px] text-left transition-colors cursor-pointer focus:outline-none focus:ring-0 sm:w-full",
isActive
? "bg-[#14161A] shadow-inside-out text-[#fafafa]"
: "text-[#A1A1AA] hover:bg-[#14161A]/50 hover:text-[#fafafa]",
@ -563,7 +950,7 @@ export function SelectSpacesModal({
{discoverCategories.length > 0 && (
<>
<div className="mt-2 px-3 pt-2 pb-1 text-[10px] uppercase tracking-[0.08em] text-[#737373]">
<div className="col-span-2 mt-3 px-3 pt-2 pb-1 text-[10px] uppercase tracking-[0.08em] text-[#737373] sm:mt-2 sm:px-3 sm:pt-2 sm:pb-1">
Discover
</div>
{discoverCategories.map((category) => {
@ -574,7 +961,7 @@ export function SelectSpacesModal({
type="button"
onClick={() => setActiveCategory(category.id)}
className={cn(
"flex items-center gap-2.5 px-3 py-2 rounded-[12px] text-left transition-colors cursor-pointer focus:outline-none focus:ring-0",
"flex min-w-0 items-center gap-2.5 px-3 py-2 rounded-[12px] text-left transition-colors cursor-pointer focus:outline-none focus:ring-0 sm:w-full",
isActive
? "bg-[#14161A] shadow-inside-out text-[#fafafa] opacity-100"
: "opacity-55 hover:opacity-100 hover:bg-[#14161A]/50 text-[#A1A1AA] hover:text-[#fafafa]",
@ -612,24 +999,17 @@ export function SelectSpacesModal({
</div>
</div>
<div className="flex-1 flex flex-col min-w-0 gap-3">
<div className="flex min-h-0 min-w-0 flex-1 flex-col gap-3 overflow-hidden">
{activeCategory.startsWith("discover:") ? (
<DiscoverPanel
catalogId={activeCategory.slice("discover:".length)}
isConnecting={
connectingPluginId ===
activeCategory.slice("discover:".length)
}
catalogId={activeDiscoverId ?? ""}
isConnecting={connectingPluginId === activeDiscoverId}
newKey={
newKey?.pluginId === activeCategory.slice("discover:".length)
? newKey.key
: null
}
onConnect={() =>
connectMutation.mutate(
activeCategory.slice("discover:".length),
)
newKey?.pluginId === activeDiscoverId ? newKey.key : null
}
onConnect={() => {
if (activeDiscoverId) connectMutation.mutate(activeDiscoverId)
}}
onDismissKey={() => setNewKey(null)}
/>
) : (
@ -649,13 +1029,22 @@ export function SelectSpacesModal({
/>
</div>
<div className="flex-1 overflow-y-auto scrollbar-thin max-h-[360px] pr-1">
<div className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto scrollbar-thin pr-1 sm:max-h-[360px]">
{filteredProjects.length === 0 ? (
<p className="text-center text-[#737373] text-sm py-8">
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]">
@ -678,21 +1067,67 @@ export function SelectSpacesModal({
</div>
</div>
{showNewSpace &&
onNewSpace &&
!activeCategory.startsWith("discover:") && (
<div className="flex items-center justify-end border-t border-[rgba(82,89,102,0.18)] px-4 py-3">
<button
type="button"
onClick={onNewSpace}
className={cn(
"flex items-center gap-2 px-4 py-2 rounded-full text-[13px] font-medium text-[#fafafa] bg-[#14161A] shadow-inside-out hover:bg-[#121820] transition-colors cursor-pointer focus:outline-none focus:ring-0",
dmSansClassName(),
)}
>
<Plus className="size-4" />
New space
</button>
{!activeCategory.startsWith("discover:") &&
(isBulkDeleteMode || (showNewSpace && onNewSpace)) && (
<div className="flex 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 px-4 py-2 rounded-full text-[13px] font-medium text-[#fafafa] bg-[#14161A] shadow-inside-out hover:bg-[#121820] transition-colors cursor-pointer focus:outline-none focus:ring-0",
dmSansClassName(),
)}
>
<Plus className="size-4" />
New space
</button>
)}
</>
)}
</div>
)}
</DialogContent>
@ -740,7 +1175,7 @@ function DiscoverPanel({
const isConnected = !!newKey
return (
<div className="flex-1 overflow-y-auto scrollbar-thin pr-1 flex flex-col gap-4">
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto scrollbar-thin pr-1">
<div className="flex items-start gap-3">
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-[10px] border border-[#1E293B] bg-[#080B0F]">
<Image

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,579 @@
"use client"
import { dmSans125ClassName } from "@/lib/fonts"
import { cn } from "@lib/utils"
import { PLAN_DISPLAY_NAMES, useTokenUsage } from "@/hooks/use-token-usage"
import {
Dialog,
DialogContent,
DialogTrigger,
DialogClose,
} from "@ui/components/dialog"
import { useCustomer } from "autumn-js/react"
import { Check, X, LoaderIcon, Settings } from "lucide-react"
import { useState } from "react"
import { toast } from "sonner"
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 PlanComparisonCard({
name,
price,
period,
description,
credits,
features,
highlight,
}: {
name: string
price: string
period: string
description: string
credits: string
features: string[]
highlight: boolean
}) {
return (
<div
className={cn(
"relative flex flex-col gap-3 p-4 rounded-[10px] overflow-hidden",
highlight
? "bg-[#1B1F24] border border-[#4BA0FA]/30 shadow-[0px_2.842px_14.211px_rgba(0,0,0,0.25)]"
: "border border-white/10",
)}
>
<div className="flex items-center justify-between">
<p
className={cn(
dmSans125ClassName(),
"font-mono uppercase tracking-[0.12em] text-[10px]",
highlight ? "text-[#4BA0FA]" : "text-[#737373]",
)}
>
{name}
</p>
{highlight && (
<span className="bg-[#4BA0FA] text-[#00171A] text-[10px] font-bold tracking-[0.36px] px-1.5 py-0.5 rounded-[3px]">
RECOMMENDED
</span>
)}
</div>
<div className="flex items-baseline gap-1">
<span
className={cn(
dmSans125ClassName(),
"font-bold text-[28px] leading-none text-[#FAFAFA] tabular-nums",
)}
>
{price}
</span>
{period && (
<span
className={cn(dmSans125ClassName(), "text-[12px] text-[#737373]")}
>
{period}
</span>
)}
</div>
<p
className={cn(
dmSans125ClassName(),
"text-[12px] tracking-[-0.12px] text-[#A3A3A3] leading-snug",
)}
>
{description}
</p>
<div
className={cn(
"flex items-center gap-2 rounded-lg px-3 py-2",
highlight ? "bg-[#4BA0FA]/10" : "bg-white/5",
)}
>
<div className="min-w-0">
<p
className={cn(
dmSans125ClassName(),
"font-semibold text-[12px] tabular-nums leading-none",
highlight ? "text-[#4BA0FA]" : "text-[#A3A3A3]",
)}
>
{credits}
</p>
<p
className={cn(
dmSans125ClassName(),
"mt-1 text-[10px] leading-none",
highlight ? "text-[#4BA0FA]/70" : "text-[#737373]",
)}
>
of usage included
</p>
</div>
</div>
<ul className="flex flex-col gap-2">
{features.map((text) => (
<li
key={text}
className={cn(
dmSans125ClassName(),
"flex items-start gap-2 text-[12px] tracking-[-0.12px] leading-snug text-[#A3A3A3]",
)}
>
<Check
className={cn(
"mt-0.5 size-3 shrink-0",
highlight ? "text-[#4BA0FA]" : "text-[#737373]",
)}
/>
<span>{text}</span>
</li>
))}
</ul>
</div>
)
}
export default function Billing() {
const autumn = useCustomer()
const [isUpgrading, setIsUpgrading] = useState(false)
const [isCancelling, setIsCancelling] = useState(false)
const [isCancelDialogOpen, setIsCancelDialogOpen] = useState(false)
const {
usdIncluded,
usdSpent,
planUsagePct,
currentPlan,
hasPaidPlan,
isLoading: isCheckingStatus,
daysRemaining,
} = useTokenUsage(autumn)
const formatUsd = (n: number) =>
n.toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})
const planDisplayNames = PLAN_DISPLAY_NAMES
const handleUpgrade = async () => {
setIsUpgrading(true)
try {
const result = await autumn.attach({
planId: "api_pro",
successUrl: `${window.location.origin}/settings#billing`,
})
if (result?.paymentUrl) {
window.open(result.paymentUrl, "_self")
return
}
autumn.refetch?.()
} catch (error) {
console.error(error)
toast.error("Failed to start checkout. Please try again.")
} finally {
setIsUpgrading(false)
}
}
const cancellablePlanId =
currentPlan === "pro" || currentPlan === "scale"
? (`api_${currentPlan}` as const)
: null
const handleCancelSubscription = async () => {
if (!cancellablePlanId) return
setIsCancelling(true)
try {
await autumn.updateSubscription({
planId: cancellablePlanId,
cancelAction: "cancel_end_of_cycle",
})
autumn.refetch?.()
setIsCancelDialogOpen(false)
toast.success(
`Subscription cancelled. ${planDisplayNames[currentPlan]} features remain active until the end of your billing period.`,
)
} catch (error) {
console.error(error)
toast.error("Failed to cancel subscription. Please try again.")
} finally {
setIsCancelling(false)
}
}
return (
<div className="flex flex-col gap-8 w-full">
<section id="billing-subscription" className="flex flex-col gap-4">
<SectionTitle>Billing &amp; Subscription</SectionTitle>
<SettingsCard>
<div className="flex flex-col gap-6">
{hasPaidPlan ? (
<>
<div className="flex flex-col gap-1.5">
<div className="flex items-center gap-4">
<p
className={cn(
dmSans125ClassName(),
"font-semibold text-[20px] tracking-[-0.2px] text-[#FAFAFA]",
)}
>
{planDisplayNames[currentPlan]} plan
</p>
<span className="bg-[#4BA0FA] text-[#00171A] text-[12px] font-bold tracking-[0.36px] px-1 py-[3px] rounded-[3px] h-[18px] flex items-center justify-center">
ACTIVE
</span>
</div>
<p
className={cn(
dmSans125ClassName(),
"font-medium text-[16px] tracking-[-0.16px] text-[#FAFAFA]",
)}
>
Expanded memory with connections and more
</p>
</div>
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<p
className={cn(
dmSans125ClassName(),
"font-medium text-[16px] tracking-[-0.16px] text-[#FAFAFA]",
)}
>
Plan usage
</p>
<span
className={cn(
dmSans125ClassName(),
"font-medium text-[16px] tracking-[-0.16px] text-[#4BA0FA] tabular-nums",
)}
>
{planUsagePct < 1 && planUsagePct > 0
? "< 1"
: Math.round(planUsagePct)}
% used
</span>
</div>
<div className="h-3 w-full rounded-[40px] bg-[#2E353D] p-px overflow-hidden">
<div
className="h-full rounded-[40px]"
style={{
width: `${planUsagePct}%`,
background:
planUsagePct > 80
? "#ef4444"
: "linear-gradient(to right, #4BA0FA 80%, #002757 100%)",
}}
title={`$${formatUsd(usdSpent)} of $${formatUsd(usdIncluded)} used`}
/>
</div>
<p
className={cn(
dmSans125ClassName(),
"text-sm tracking-[-0.14px] text-[#737373] tabular-nums",
)}
>
{daysRemaining !== null
? `Resets in ${daysRemaining} day${daysRemaining !== 1 ? "s" : ""}`
: ""}
</p>
</div>
<div className="flex flex-col sm:flex-row gap-3">
<button
type="button"
onClick={() => {
autumn.openCustomerPortal?.({
returnUrl:
"https://app.supermemory.ai/settings#billing",
})
}}
className={cn(
"relative flex-1 h-11 rounded-full flex items-center justify-center gap-2",
"bg-[#0D121A] border border-[rgba(115,115,115,0.2)]",
"text-[#FAFAFA] font-medium text-[14px] tracking-[-0.14px]",
"cursor-pointer transition-opacity hover:opacity-90",
dmSans125ClassName(),
)}
>
<Settings className="size-4" />
Manage billing
<div className="absolute inset-0 pointer-events-none rounded-[inherit] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)]" />
</button>
{cancellablePlanId && (
<Dialog
open={isCancelDialogOpen}
onOpenChange={setIsCancelDialogOpen}
>
<DialogTrigger asChild>
<button
type="button"
className={cn(
"relative flex-1 h-11 rounded-full flex items-center justify-center gap-2",
"bg-[#290F0A] text-[#C73B1B]",
"font-medium text-[14px] tracking-[-0.14px]",
"cursor-pointer transition-opacity hover:opacity-90",
dmSans125ClassName(),
)}
>
Cancel subscription
<div className="absolute inset-0 pointer-events-none rounded-[inherit] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.4)]" />
</button>
</DialogTrigger>
<DialogContent
showCloseButton={false}
className={cn(
"bg-[#1B1F24] rounded-[22px] p-4",
"shadow-[0px_2.842px_14.211px_rgba(0,0,0,0.25)]",
"min-w-xl",
)}
>
<div className="flex flex-col gap-4">
<div className="flex items-start gap-4">
<div className="flex flex-1 flex-col gap-3 pl-1">
<p
className={cn(
dmSans125ClassName(),
"font-semibold text-[16px] tracking-[-0.16px] text-[#FAFAFA]",
)}
>
Cancel {planDisplayNames[currentPlan]}{" "}
subscription?
</p>
<p
className={cn(
dmSans125ClassName(),
"text-[13px] tracking-[-0.13px] text-[#A3A3A3] leading-snug",
)}
>
You&apos;ll keep Pro features until the end of
your current billing period
{daysRemaining !== null
? ` (${daysRemaining} day${daysRemaining !== 1 ? "s" : ""} remaining)`
: ""}
. After that, your account will switch to the
Free plan.
</p>
</div>
<DialogClose asChild>
<button
type="button"
className={cn(
"relative size-7 rounded-full bg-[#0D121A] border border-[#73737333]",
"flex items-center justify-center shrink-0",
"cursor-pointer transition-opacity hover:opacity-80",
)}
>
<X className="size-4 text-[#737373]" />
<div className="absolute inset-0 pointer-events-none rounded-[inherit] shadow-[inset_1.313px_1.313px_3.938px_rgba(0,0,0,0.7)]" />
</button>
</DialogClose>
</div>
<div className="flex items-center justify-end gap-5">
<DialogClose asChild>
<button
type="button"
className={cn(
dmSans125ClassName(),
"font-medium text-[14px] tracking-[-0.14px] text-[#737373]",
"cursor-pointer transition-opacity hover:opacity-80",
)}
>
Keep plan
</button>
</DialogClose>
<button
type="button"
onClick={() => void handleCancelSubscription()}
disabled={isCancelling}
className={cn(
"relative flex items-center gap-1.5 px-4 py-2 rounded-full",
"bg-[#290F0A] text-[#C73B1B]",
"font-normal text-[14px] tracking-[-0.14px]",
"cursor-pointer transition-opacity",
"disabled:opacity-40 disabled:cursor-not-allowed",
!isCancelling && "hover:opacity-90",
dmSans125ClassName(),
)}
>
{isCancelling && (
<LoaderIcon className="size-[18px] animate-spin" />
)}
<span>
{isCancelling
? "Cancelling…"
: "Cancel subscription"}
</span>
<div className="absolute inset-0 pointer-events-none rounded-[inherit] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.4)]" />
</button>
</div>
</div>
<div className="absolute inset-0 pointer-events-none rounded-[inherit] shadow-[inset_0.711px_0.711px_0.711px_rgba(255,255,255,0.1)]" />
</DialogContent>
</Dialog>
)}
</div>
</>
) : (
<>
<div className="flex flex-col gap-1.5">
<p
className={cn(
dmSans125ClassName(),
"font-semibold text-[20px] tracking-[-0.2px] text-[#FAFAFA]",
)}
>
Free Plan
</p>
<p
className={cn(
dmSans125ClassName(),
"font-medium text-[16px] tracking-[-0.16px] text-[#FAFAFA]",
)}
>
You are on basic plan
</p>
</div>
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<p
className={cn(
dmSans125ClassName(),
"font-medium text-[16px] tracking-[-0.16px] text-[#FAFAFA]",
)}
>
Plan usage
</p>
<p
className={cn(
dmSans125ClassName(),
"font-medium text-[16px] tracking-[-0.16px] text-[#737373] tabular-nums",
)}
>
{planUsagePct < 1 && planUsagePct > 0
? "< 1"
: Math.round(planUsagePct)}
% used
</p>
</div>
<div className="h-3 w-full rounded-[40px] bg-[#2E353D] p-px overflow-hidden">
<div
className="h-full rounded-[40px] transition-all"
style={{
width: `${planUsagePct}%`,
background: planUsagePct > 80 ? "#ef4444" : "#0054AD",
}}
title={`$${formatUsd(usdSpent)} of $${formatUsd(usdIncluded)} used`}
/>
</div>
<p
className={cn(
dmSans125ClassName(),
"text-sm tracking-[-0.14px] text-[#737373] tabular-nums",
)}
>
{daysRemaining !== null
? `Resets in ${daysRemaining} day${daysRemaining !== 1 ? "s" : ""}`
: ""}
</p>
</div>
<button
type="button"
onClick={handleUpgrade}
disabled={isUpgrading || isCheckingStatus || autumn.isLoading}
className={cn(
"relative w-full h-11 rounded-[10px] flex items-center justify-center",
"text-[#FAFAFA] font-medium text-[14px] tracking-[-0.14px]",
"shadow-[0px_2px_10px_rgba(5,1,0,0.2)]",
"disabled:opacity-60 disabled:cursor-not-allowed",
"cursor-pointer transition-opacity hover:opacity-90",
dmSans125ClassName(),
)}
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)",
}}
>
{isUpgrading || isCheckingStatus || autumn.isLoading ? (
<>
<LoaderIcon className="size-4 animate-spin mr-2" />
Upgrading
</>
) : (
"Upgrade to Pro - $19/month"
)}
<div className="absolute inset-0 pointer-events-none rounded-[inherit] shadow-[inset_1px_1px_2px_1px_#1A88FF]" />
</button>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<PlanComparisonCard
name="Free"
price="$0"
period=""
description="Try the API with no commitment"
credits="$5"
features={[
"Pay-as-you-go after $5 runs out",
"Full search & memory API access",
"Email support",
]}
highlight={false}
/>
<PlanComparisonCard
name="Pro"
price="$19"
period="/mo"
description="For developers building with AI memory"
credits="$20"
features={[
"Auto top-up when balance runs low",
"All plugins (Claude Code, Cursor, Hermes…)",
"Priority support",
]}
highlight={true}
/>
</div>
</>
)}
</div>
</SettingsCard>
</section>
</div>
)
}

View file

@ -31,6 +31,7 @@ import { DEFAULT_PROJECT_ID } from "@lib/constants"
import type { Project } from "@lib/types"
import { SyncStatusBadge } from "@/components/settings/sync-status-badge"
import { SyncHistoryPanel } from "@/components/settings/sync-history-panel"
import { useConnectionHealth } from "@/hooks/use-connection-health"
import { useTriggerSync } from "@/hooks/use-trigger-sync"
import { formatRelativeTime } from "@/components/settings/sync-utils"
import type { ImportProvider } from "@/components/settings/sync-utils"
@ -48,11 +49,6 @@ function getConnectionMeta(connection: Connection) {
}
}
/** Check if a connection's auth token has expired. */
function isConnectionExpired(connection: Connection): boolean {
return !!connection.expiresAt && new Date(connection.expiresAt) <= new Date()
}
const CONNECTORS = {
"google-drive": {
title: "Google Drive",
@ -166,6 +162,8 @@ function ConnectionRow({
projects,
onTriggerSync,
isSyncing,
onReconnect,
isReconnecting,
}: {
connection: Connection
onDelete: () => void
@ -174,14 +172,17 @@ function ConnectionRow({
projects: Project[]
onTriggerSync: () => void
isSyncing: boolean
onReconnect: () => void
isReconnecting: boolean
}) {
const [historyOpen, setHistoryOpen] = useState(false)
const config = CONNECTORS[connection.provider as ConnectorProvider]
const { needsReauth } = useConnectionHealth(connection.id)
if (!config) return null
const Icon = config.icon
const meta = getConnectionMeta(connection)
const expired = isConnectionExpired(connection)
const expired = needsReauth
const getProjectDisplayName = (containerTag: string): string => {
if (containerTag === DEFAULT_PROJECT_ID) return "Default Project"
@ -234,35 +235,45 @@ function ConnectionRow({
</span>
</div>
<div className="flex items-center gap-0.5">
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onTriggerSync()
}}
disabled={isSyncing || disabled || expired}
className="text-[#737373] hover:text-[#4BA0FA] transition-colors disabled:opacity-50 disabled:cursor-not-allowed p-1.5 rounded-lg hover:bg-white/5"
aria-label={
expired
? "Connection expired"
: isSyncing
? "Sync in progress"
: "Sync now"
}
title={
expired
? "Reconnect to sync"
: isSyncing
? "Sync in progress"
: "Sync now"
}
>
{isSyncing ? (
<Loader2 className="size-[18px] animate-spin" />
) : (
<Play className="size-[18px]" />
)}
</button>
{expired ? (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onReconnect()
}}
disabled={isReconnecting || disabled}
className={cn(
dmSans125ClassName(),
"flex items-center gap-1.5 rounded-full bg-[#EF4444]/15 px-3 py-1.5 text-[12px] font-medium text-[#EF4444] transition-colors hover:bg-[#EF4444]/25 disabled:opacity-60 disabled:cursor-not-allowed",
)}
aria-label="Reconnect"
>
{isReconnecting ? (
<Loader2 className="size-[14px] animate-spin" />
) : (
"Reconnect"
)}
</button>
) : (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onTriggerSync()
}}
disabled={isSyncing || disabled}
className="text-[#737373] hover:text-[#4BA0FA] transition-colors disabled:opacity-50 disabled:cursor-not-allowed p-1.5 rounded-lg hover:bg-white/5"
aria-label={isSyncing ? "Sync in progress" : "Sync now"}
title={isSyncing ? "Sync in progress" : "Sync now"}
>
{isSyncing ? (
<Loader2 className="size-[18px] animate-spin" />
) : (
<Play className="size-[18px]" />
)}
</button>
)}
<button
type="button"
onClick={(e) => {
@ -461,6 +472,42 @@ export default function ConnectionsMCP() {
}
}, [connectionsError])
const reconnectMutation = useMutation({
mutationFn: async ({
connectionId: _connectionId,
provider,
containerTags,
}: {
connectionId: string
provider: ConnectorProvider
containerTags: string[] | undefined
}) => {
const response = await $fetch("@post/connections/:provider", {
params: { provider },
body: {
redirectUrl: window.location.href,
containerTags: containerTags ?? [],
},
})
if ("data" in response && response.data && !("error" in response.data)) {
return response.data
}
throw new Error(response.error?.message || "Failed to reconnect")
},
onSuccess: (data) => {
if (data?.authLink) {
window.location.href = data.authLink
return
}
toast.error("Reconnect link missing — try again.")
},
onError: (error) => {
toast.error("Failed to reconnect", {
description: error instanceof Error ? error.message : "Unknown error",
})
},
})
const deleteConnectionMutation = useMutation({
mutationFn: async ({
connectionId,
@ -512,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 />}>
@ -580,6 +627,18 @@ export default function ConnectionsMCP() {
connection.id) ||
getConnectionMeta(connection).syncInProgress
}
onReconnect={() => {
reconnectMutation.mutate({
connectionId: connection.id,
provider: connection.provider as ConnectorProvider,
containerTags: connection.containerTags,
})
}}
isReconnecting={
reconnectMutation.isPending &&
reconnectMutation.variables?.connectionId ===
connection.id
}
/>
))
) : (

View file

@ -30,6 +30,7 @@ import {
AppleShortcutsIcon,
RaycastIcon,
} from "@/components/integration-icons"
import { RaycastSetupModal } from "@/components/integrations/raycast-setup-modal"
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
@ -128,20 +129,13 @@ export default function Integrations() {
// Raycast state
const [showRaycastApiKeyModal, setShowRaycastApiKeyModal] = useState(false)
const [raycastApiKey, setRaycastApiKey] = useState<string>("")
const [raycastCopied, setRaycastCopied] = useState(false)
const [hasTriggeredRaycast, setHasTriggeredRaycast] = useState(false)
const raycastApiKeyId = useId()
const handleCopyApiKey = async (key: string, isRaycast = false) => {
const handleCopyApiKey = async (key: string) => {
try {
await navigator.clipboard.writeText(key)
if (isRaycast) {
setRaycastCopied(true)
setTimeout(() => setRaycastCopied(false), 2000)
} else {
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
setCopied(true)
setTimeout(() => setCopied(false), 2000)
toast.success("API key copied to clipboard!")
} catch {
toast.error("Failed to copy API key")
@ -187,13 +181,14 @@ export default function Integrations() {
name: `raycast-${generateId().slice(0, 8)}`,
prefix: `sm_${org.id}_`,
})
return res.key
if (res.error)
throw new Error(res.error.message ?? "Failed to create API key")
if (!res.data?.key) throw new Error("API key missing from response")
return res.data.key
},
onSuccess: (key) => {
setRaycastApiKey(key)
setShowRaycastApiKeyModal(true)
setRaycastCopied(false)
handleCopyApiKey(key, true)
},
onError: (error) => {
toast.error("Failed to create Raycast API key", {
@ -260,14 +255,11 @@ export default function Integrations() {
const handleRaycastDialogClose = (open: boolean) => {
setShowRaycastApiKeyModal(open)
if (!open) {
setRaycastApiKey("")
setRaycastCopied(false)
}
if (!open) setRaycastApiKey("")
}
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">
@ -564,134 +556,11 @@ export default function Integrations() {
</DialogPortal>
</Dialog>
<Dialog
<RaycastSetupModal
open={showRaycastApiKeyModal}
onOpenChange={handleRaycastDialogClose}
>
<DialogPortal>
<DialogContent
id="raycast-api-key-modal"
className="bg-[#14161A] border border-white/10 text-[#FAFAFA] md:max-w-md z-100"
>
<DialogHeader>
<DialogTitle
className={cn(
dmSans125ClassName(),
"text-[#FAFAFA] text-lg font-semibold",
)}
>
Setup Raycast Extension
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div id="raycast-api-key-section" className="space-y-2">
<label
htmlFor={raycastApiKeyId}
className={cn(
dmSans125ClassName(),
"text-sm font-medium text-[#737373]",
)}
>
Your Raycast API Key
</label>
<div className="flex items-center gap-2">
<input
id={raycastApiKeyId}
type="text"
value={raycastApiKey}
readOnly
className={cn(
"flex-1 bg-[#0D121A] border border-white/10 rounded-lg px-3 py-2 text-sm text-[#FAFAFA] font-mono",
dmSans125ClassName(),
)}
/>
<button
type="button"
onClick={() => handleCopyApiKey(raycastApiKey, true)}
className="p-2 rounded-lg bg-[#0D121A] border border-white/10 text-[#737373] hover:text-[#FAFAFA] transition-colors"
>
{raycastCopied ? (
<Check className="size-4 text-[#4BA0FA]" />
) : (
<Copy className="size-4" />
)}
</button>
</div>
</div>
<div id="raycast-steps" className="space-y-3">
<h4
className={cn(
dmSans125ClassName(),
"text-sm font-medium text-[#737373]",
)}
>
Follow these steps:
</h4>
<div className="space-y-2">
<div className="flex items-start gap-3">
<div className="shrink-0 size-6 bg-[#FF6363]/20 text-[#FF6363] rounded-full flex items-center justify-center text-xs font-medium">
1
</div>
<p
className={cn(
dmSans125ClassName(),
"text-sm text-[#737373]",
)}
>
Install the Raycast extension from the Raycast Store
</p>
</div>
<div className="flex items-start gap-3">
<div className="shrink-0 size-6 bg-[#FF6363]/20 text-[#FF6363] rounded-full flex items-center justify-center text-xs font-medium">
2
</div>
<p
className={cn(
dmSans125ClassName(),
"text-sm text-[#737373]",
)}
>
Open Raycast preferences and paste your API key
</p>
</div>
<div className="flex items-start gap-3">
<div className="shrink-0 size-6 bg-[#FF6363]/20 text-[#FF6363] rounded-full flex items-center justify-center text-xs font-medium">
3
</div>
<p
className={cn(
dmSans125ClassName(),
"text-sm text-[#737373]",
)}
>
Use "Add Memory" or "Search Memories" commands!
</p>
</div>
</div>
</div>
<div className="flex gap-2 pt-2">
<button
type="button"
onClick={handleRaycastInstall}
className={cn(
"flex-1 flex items-center justify-center gap-2",
"bg-[#FF6363] hover:bg-[#FF6363]/90 text-white",
"rounded-lg h-11 px-4 font-medium text-sm",
"transition-colors",
dmSans125ClassName(),
)}
>
<RaycastIcon className="size-4" />
Install Extension
</button>
</div>
</div>
</DialogContent>
</DialogPortal>
</Dialog>
apiKey={raycastApiKey}
/>
</div>
)
}

View file

@ -32,7 +32,7 @@ function SectionTitle({ children }: { children: React.ReactNode }) {
<p
className={cn(
dmSans125ClassName(),
"font-semibold text-[20px] tracking-[-0.2px] text-[#FAFAFA] px-2",
"font-semibold text-[20px] tracking-[-0.2px] text-[#FAFAFA] px-1 sm:px-2",
)}
>
{children}
@ -44,7 +44,7 @@ function SupportCard({ children }: { children: React.ReactNode }) {
return (
<div
className={cn(
"relative bg-[#14161A] rounded-[14px] p-6 w-full overflow-hidden",
"relative bg-[#14161A] rounded-[14px] p-4 sm:p-6 w-full overflow-hidden",
"shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]",
)}
>
@ -69,7 +69,7 @@ function PillButton({
className={cn(
"relative flex items-center justify-center gap-2",
"bg-[#0D121A]",
"rounded-full h-11 px-4 flex-1",
"rounded-full py-3 sm:py-2.5 px-4 flex-1",
"cursor-pointer transition-opacity hover:opacity-80",
"shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)]",
dmSans125ClassName(),
@ -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 &amp; Help</SectionTitle>
@ -128,7 +128,7 @@ export default function Support() {
reach us.
</p>
</div>
<div className="flex flex-col sm:flex-row gap-4">
<div className="flex flex-col sm:flex-row gap-2.5 sm:gap-4">
<PillButton onClick={handleMessageOnX}>
<span className="text-[14px] tracking-[-0.14px] text-[#FAFAFA] font-medium">
Message us on X

View file

@ -259,7 +259,9 @@ export function SyncHistoryPanel({
{hasRuns && (
<>
<SummaryStats runs={syncRuns} />
<Timeline runs={syncRuns} />
<div className="max-h-[260px] overflow-y-auto scrollbar-thin pr-1 -mr-1">
<Timeline runs={syncRuns} />
</div>
</>
)}
</div>

View file

@ -77,7 +77,7 @@ export function SyncStatusBadge({
"font-medium text-[13px] tracking-[-0.13px] text-[#EF4444]",
)}
>
Disconnected
Needs reauth
</span>
)}
{status === "idle" && (

View file

@ -7,8 +7,9 @@ import { cn } from "@lib/utils"
import { $fetch } from "@lib/api"
import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
import { DEFAULT_PROJECT_ID } from "@lib/constants"
import { XIcon, Loader2 } from "lucide-react"
import { ChevronDownIcon, XIcon, Loader2, Trash2 } from "lucide-react"
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 { useProjectMutations } from "@/hooks/use-project-mutations"
@ -30,13 +31,17 @@ import {
} from "@repo/ui/components/select"
import { Button } from "@repo/ui/components/button"
import { Tooltip, TooltipContent, TooltipTrigger } from "@ui/components/tooltip"
import { useAuth } from "@lib/auth-context"
import { analytics } from "@/lib/analytics"
import {
compareSpacesUserFirst,
isOwnConversationSpace,
spaceSelectorDisplayName,
} from "@/lib/ingest-auto-space"
import { detectPluginSpace, pluginInitial } from "@/lib/plugin-space"
import { usePluginSpaceMeta } from "@/hooks/use-plugin-space-meta"
import NovaOrb from "@/components/nova/nova-orb"
import { AutoSpaceIcon } from "@/components/nova/auto-space-icon"
export interface SpaceSelectorProps {
selectedProjects: string[]
@ -46,6 +51,7 @@ export interface SpaceSelectorProps {
showNewSpace?: boolean
enableDelete?: boolean
compact?: boolean
includeAuto?: boolean
}
const triggerVariants = {
@ -60,6 +66,12 @@ const triggerVariants = {
const RECENTS_KEY = "nova:space-selector:recents"
const RECENTS_MAX = 10
type DeleteProjectTarget = {
id: string
name: string
containerTag: string
}
function readRecents(): string[] {
if (typeof window === "undefined") return []
try {
@ -98,13 +110,14 @@ export function SpaceSelector({
showNewSpace = true,
enableDelete = false,
compact = false,
includeAuto = false,
}: SpaceSelectorProps) {
const [showCreateDialog, setShowCreateDialog] = useState(false)
const [showSelectSpacesModal, setShowSelectSpacesModal] = useState(false)
const [recents, setRecents] = useState<string[]>([])
const [deleteDialog, setDeleteDialog] = useState<{
open: boolean
project: { id: string; name: string; containerTag: string } | null
project: DeleteProjectTarget | null
action: "move" | "delete"
targetProjectId: string
}>({
@ -113,9 +126,20 @@ export function SpaceSelector({
action: "move",
targetProjectId: "",
})
const [bulkDeleteDialog, setBulkDeleteDialog] = useState<{
open: boolean
projects: DeleteProjectTarget[]
confirmation: string
}>({
open: false,
projects: [],
confirmation: "",
})
const { deleteProjectMutation } = useProjectMutations()
const { deleteProjectMutation, deleteProjectsMutation } =
useProjectMutations()
const { allProjects, isLoading } = useContainerTags()
const { user } = useAuth()
useEffect(() => {
setRecents(readRecents())
@ -142,7 +166,7 @@ export function SpaceSelector({
return data?.pagination?.totalItems ?? 0
},
staleTime: 30 * 1000,
enabled: !!activeTag,
enabled: !!activeTag && activeTag !== AUTO_CHAT_SPACE_ID,
})
const pluginTags = useMemo(
@ -160,15 +184,33 @@ export function SpaceSelector({
name: string
emoji: string | null
plugin: ReturnType<typeof detectPluginSpace>
isAuto: boolean
isOwnSpace: boolean
}>(() => {
const containerTag = selectedProjects[0] ?? ""
if (includeAuto && containerTag === AUTO_CHAT_SPACE_ID) {
return {
name: "Auto",
emoji: null,
plugin: null,
isAuto: true,
isOwnSpace: false,
}
}
if (!containerTag || containerTag === DEFAULT_PROJECT_ID) {
return { name: "My Space", emoji: "📁", plugin: null }
return {
name: "My Space",
emoji: "📁",
plugin: null,
isAuto: false,
isOwnSpace: false,
}
}
const found = allProjects.find(
(p: ContainerTagListType) => p.containerTag === containerTag,
)
const plugin = detectPluginSpace(containerTag)
const isOwnSpace = isOwnConversationSpace({ containerTag }, user?.id)
const projectName = pluginMetaMap.get(containerTag)?.projectName
const idForLabel = projectName || plugin?.projectId
return {
@ -176,11 +218,15 @@ export function SpaceSelector({
? idForLabel
? `${plugin.label} · ${idForLabel}`
: plugin.label
: spaceSelectorDisplayName(found, containerTag),
: spaceSelectorDisplayName(found, containerTag, {
currentUserId: user?.id,
}),
emoji: found?.emoji || "📁",
plugin,
isAuto: false,
isOwnSpace,
}
}, [allProjects, selectedProjects, pluginMetaMap])
}, [allProjects, selectedProjects, pluginMetaMap, includeAuto, user?.id])
const pushRecent = useCallback((tag: string) => {
setRecents((prev) => {
@ -193,12 +239,15 @@ export function SpaceSelector({
const handleSelectSpacesApply = useCallback(
(selected: string[]) => {
const next = selected.slice(0, 1)
if (next[0]) {
analytics.spaceSwitched({ space_id: next[0] })
pushRecent(next[0])
}
onValueChange(next)
const selectedTag = next[0]
setShowSelectSpacesModal(false)
onValueChange(next)
if (selectedTag && selectedTag !== AUTO_CHAT_SPACE_ID) {
queueMicrotask(() => {
analytics.spaceSwitched({ space_id: selectedTag })
pushRecent(selectedTag)
})
}
},
[onValueChange, pushRecent],
)
@ -208,14 +257,24 @@ export function SpaceSelector({
setShowCreateDialog(true)
}, [])
const handleDeleteRequest = useCallback(
(project: { id: string; name: string; containerTag: string }) => {
const handleDeleteRequest = useCallback((project: DeleteProjectTarget) => {
setShowSelectSpacesModal(false)
setDeleteDialog({
open: true,
project,
action: "move",
targetProjectId: "",
})
}, [])
const handleBulkDeleteRequest = useCallback(
(projects: DeleteProjectTarget[]) => {
if (projects.length === 0) return
setShowSelectSpacesModal(false)
setDeleteDialog({
setBulkDeleteDialog({
open: true,
project,
action: "move",
targetProjectId: "",
projects,
confirmation: "",
})
},
[],
@ -226,6 +285,7 @@ export function SpaceSelector({
deleteProjectMutation.mutate(
{
projectId: deleteDialog.project.id,
containerTag: deleteDialog.project.containerTag,
action: deleteDialog.action,
targetProjectId:
deleteDialog.action === "move"
@ -254,6 +314,38 @@ export function SpaceSelector({
})
}
const handleBulkDeleteCancel = () => {
setBulkDeleteDialog({
open: false,
projects: [],
confirmation: "",
})
}
const handleBulkDeleteConfirm = () => {
if (
bulkDeleteDialog.confirmation !== "DELETE" ||
bulkDeleteDialog.projects.length === 0
) {
return
}
deleteProjectsMutation.mutate(
{
projects: bulkDeleteDialog.projects,
},
{
onSettled: () => {
setBulkDeleteDialog({
open: false,
projects: [],
confirmation: "",
})
},
},
)
}
const availableTargetProjects = useMemo(() => {
const filtered = allProjects.filter(
(p: ContainerTagListType) =>
@ -294,7 +386,14 @@ export function SpaceSelector({
triggerClassName,
)}
>
{displayInfo.plugin ? (
{displayInfo.isAuto ? (
<AutoSpaceIcon size={compact ? 16 : 18} />
) : displayInfo.isOwnSpace ? (
<NovaOrb
size={compact ? 14 : 16}
className="shrink-0 blur-[0.45px]!"
/>
) : displayInfo.plugin ? (
displayInfo.plugin.iconSrc ? (
<Image
src={displayInfo.plugin.iconSrc}
@ -342,6 +441,12 @@ export function SpaceSelector({
· {formatCount(spaceCountData)}
</span>
)}
{!compact && (
<ChevronDownIcon
className="size-3.5 shrink-0 text-[#737373]"
aria-hidden
/>
)}
{compact && (
<span className="sr-only">
{isLoading ? "Loading" : displayInfo.name}
@ -371,9 +476,11 @@ export function SpaceSelector({
projects={allProjects}
recents={recents}
showNewSpace={showNewSpace}
includeAuto={includeAuto}
onNewSpace={handleNewSpace}
enableDelete={enableDelete}
onDeleteRequest={handleDeleteRequest}
onBulkDeleteRequest={handleBulkDeleteRequest}
/>
<Dialog
@ -494,6 +601,7 @@ export function SpaceSelector({
{availableTargetProjects.map(
(p: ContainerTagListType) => {
const plugin = detectPluginSpace(p.containerTag)
const isOwnSpace = isOwnConversationSpace(p, user?.id)
return (
<SelectItem
key={p.id}
@ -519,6 +627,11 @@ export function SpaceSelector({
{pluginInitial(plugin.label)}
</span>
)
) : isOwnSpace ? (
<NovaOrb
size={16}
className="shrink-0 blur-[0.45px]!"
/>
) : (
<span>{p.emoji || "📁"}</span>
)}
@ -535,7 +648,13 @@ export function SpaceSelector({
)}
</>
) : (
spaceSelectorDisplayName(p, p.containerTag)
spaceSelectorDisplayName(
p,
p.containerTag,
{
currentUserId: user?.id,
},
)
)}
</span>
</span>
@ -631,6 +750,129 @@ export function SpaceSelector({
</div>
</DialogContent>
</Dialog>
<Dialog
open={bulkDeleteDialog.open}
onOpenChange={(open: boolean) => {
if (!open) handleBulkDeleteCancel()
}}
>
<DialogContent
className={cn(
"w-[90%]! max-w-[520px]! border-none bg-[#1B1F24] flex flex-col p-4 gap-4 rounded-[22px]",
dmSansClassName(),
)}
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",
}}
showCloseButton={false}
>
<div className="flex flex-col gap-4">
<div className="flex justify-between items-start gap-4">
<div className="pl-1 space-y-1 flex-1">
<DialogTitle
className={cn(
"font-semibold text-[#fafafa]",
dmSans125ClassName(),
)}
>
Delete {bulkDeleteDialog.projects.length}{" "}
{bulkDeleteDialog.projects.length === 1 ? "space" : "spaces"}?
</DialogTitle>
<DialogDescription className="text-[#737373] font-medium text-[15px] leading-[1.4]">
This permanently deletes the selected container tags and every
document and memory inside them. This cannot be undone.
</DialogDescription>
</div>
<DialogPrimitive.Close
className="bg-[#0D121A] w-7 h-7 flex items-center justify-center focus:ring-ring rounded-full transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 border border-[rgba(115,115,115,0.2)] shrink-0"
style={{
boxShadow:
"inset 1.313px 1.313px 3.938px 0px rgba(0,0,0,0.7)",
}}
>
<XIcon stroke="#737373" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</div>
<div className="rounded-[12px] bg-[#14161A] p-3 shadow-inside-out">
<div className="max-h-36 space-y-1 overflow-y-auto pr-1 scrollbar-thin">
{bulkDeleteDialog.projects.slice(0, 8).map((project) => (
<div
key={project.containerTag}
className="flex min-w-0 items-center gap-2 text-[13px] text-[#fafafa]"
>
<Trash2 className="size-3.5 shrink-0 text-red-400" />
<span className="truncate">{project.name}</span>
</div>
))}
{bulkDeleteDialog.projects.length > 8 && (
<p className="text-[12px] text-[#737373]">
+{bulkDeleteDialog.projects.length - 8} more
</p>
)}
</div>
</div>
<label className="space-y-2">
<span className="block text-[13px] font-medium text-[#FAFAFA]">
Type DELETE to confirm
</span>
<input
type="text"
value={bulkDeleteDialog.confirmation}
onChange={(e) =>
setBulkDeleteDialog((prev) => ({
...prev,
confirmation: e.target.value,
}))
}
className={cn(
"w-full rounded-[12px] border border-[rgba(82,89,102,0.35)] bg-[#0D121A] px-3 py-2.5 text-sm font-medium text-[#fafafa] shadow-inside-out placeholder:text-[#737373] focus:outline-none focus:ring-1 focus:ring-red-400/40",
dmSansClassName(),
)}
placeholder="DELETE"
autoComplete="off"
/>
</label>
<div className="flex items-center justify-end gap-[22px]">
<button
type="button"
onClick={handleBulkDeleteCancel}
disabled={deleteProjectsMutation.isPending}
className={cn(
"text-[#737373] font-medium text-[14px] cursor-pointer transition-colors hover:text-[#999]",
dmSansClassName(),
)}
>
Cancel
</button>
<Button
variant="insideOut"
onClick={handleBulkDeleteConfirm}
disabled={
deleteProjectsMutation.isPending ||
bulkDeleteDialog.confirmation !== "DELETE" ||
bulkDeleteDialog.projects.length === 0
}
className="rounded-full bg-red-600 px-4 py-[10px] hover:bg-red-700 border-red-700"
>
{deleteProjectsMutation.isPending ? (
<>
<Loader2 className="size-4 animate-spin mr-2" />
Deleting...
</>
) : (
"Delete permanently"
)}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</>
)
}

View file

@ -81,6 +81,44 @@
display: none;
}
.pb-safe {
padding-bottom: max(0px, env(safe-area-inset-bottom));
}
.pt-safe {
padding-top: max(0px, env(safe-area-inset-top));
}
.pl-safe {
padding-left: max(0px, env(safe-area-inset-left));
}
.pr-safe {
padding-right: max(0px, env(safe-area-inset-right));
}
.bottom-safe-5 {
bottom: max(1.25rem, env(safe-area-inset-bottom));
}
/* hide scrollbar but keep edge-fade affordance for horizontal nav */
.scroll-fade-x {
-webkit-mask-image: linear-gradient(
to right,
transparent 0,
#000 12px,
#000 calc(100% - 12px),
transparent 100%
);
mask-image: linear-gradient(
to right,
transparent 0,
#000 12px,
#000 calc(100% - 12px),
transparent 100%
);
}
.sm-tweet-theme .react-tweet-theme {
--tweet-container-margin: 0px;
font-size: inherit !important;

View file

@ -10,6 +10,7 @@ export type AccountMembership = {
slug: string
role: string
memberCount: number
plan?: string
}
export function useAccountMemberships() {

View file

@ -0,0 +1,29 @@
"use client"
import { useSyncRuns, type SyncRun } from "@/hooks/use-sync-runs"
// TODO: replace string matching with a discriminated `errorKind` from the backend.
// 403 alone matches per-file ACL denials; 401 alone matches transient retries.
// Require the status code to co-occur with explicit auth/token/grant context.
const AUTH_ERROR_PATTERNS = [
/invalid[_\s-]?grant/i,
/unauthorized[_\s-]?client/i,
/\bunauthenticated\b/i,
/needs?[_\s-]?reauth/i,
/no\s+refresh[_\s-]?token/i,
/(?:access|refresh)[_\s-]?token[^\n]{0,40}(?:expired|revoked|invalid|missing)/i,
/\b(?:401|403)\b[^\n]{0,80}(?:auth|token|grant|credentials?)/i,
/(?:auth|token|grant|credentials?)[^\n]{0,80}\b(?:401|403)\b/i,
]
function isAuthFailure(run: SyncRun): boolean {
if (run.status !== "failed" || !run.error) return false
return AUTH_ERROR_PATTERNS.some((p) => p.test(run.error ?? ""))
}
export function useConnectionHealth(connectionId: string) {
const { data: runs, isLoading } = useSyncRuns(connectionId)
const latest = runs?.[0] ?? null
const needsReauth = !!latest && isAuthFailure(latest)
return { needsReauth, latestRun: latest, isLoading }
}

View file

@ -0,0 +1,38 @@
import { useQuery } from "@tanstack/react-query"
import { useAuth } from "@lib/auth-context"
import { normalizePlanType, type PlanType } from "@/hooks/use-token-usage"
const API_BASE =
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
export type OrgSummary = {
orgId: string
plan: PlanType
activeConnectors: number
containerTagCount: number
documentCount: number
}
export function useOrgSummaries() {
const { user } = useAuth()
return useQuery({
queryKey: ["account", "org-summaries"],
queryFn: async (): Promise<OrgSummary[]> => {
const res = await fetch(`${API_BASE}/v3/auth/org-summaries`, {
credentials: "include",
headers: { "X-App-Source": "nova" },
})
if (!res.ok) {
throw new Error("Failed to load organization plans")
}
const data = (await res.json()) as { summaries: OrgSummary[] }
return (data.summaries ?? []).map((s) => ({
...s,
plan: normalizePlanType(s.plan),
}))
},
enabled: !!user?.id,
staleTime: 60 * 1000,
})
}

View file

@ -2,7 +2,7 @@
import { useState, useEffect, useCallback } from "react"
import { $fetch } from "@lib/api"
import type { SearchResult } from "@repo/lib/api"
import type { SearchResult } from "@repo/validation/api"
const CACHE_KEY = "sm_profession_v1"
const CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000
@ -235,6 +235,15 @@ function pickCopy(p: Profession): PersonalizedCopy {
}
}
function defaultCopy(p: Profession): PersonalizedCopy {
const pool = COPY_POOLS[p]
return {
saveLink: pool.saveLink[0] ?? "",
writeNote: pool.writeNote[0] ?? "",
chatPlaceholder: pool.chatPlaceholder[0] ?? "",
}
}
const sessionCopyCache: Partial<Record<Profession, PersonalizedCopy>> = {}
function getSessionCopy(p: Profession): PersonalizedCopy {
@ -398,7 +407,7 @@ export function usePersonalization(): {
setProfession: (p: Profession) => void
} {
const [copy, setCopy] = useState<PersonalizedCopy>(() =>
getSessionCopy("default"),
defaultCopy("default"),
)
const [profession, setProfessionState] = useState<Profession>("default")
@ -410,8 +419,9 @@ export function usePersonalization(): {
)
} catch {}
// Re-pick on explicit change so the user sees fresh copy for the new identity
sessionCopyCache[p] = pickCopy(p)
setCopy(sessionCopyCache[p]!)
const freshCopy = pickCopy(p)
sessionCopyCache[p] = freshCopy
setCopy(freshCopy)
setProfessionState(p)
}, [])

View file

@ -4,7 +4,14 @@ import { $fetch } from "@lib/api"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { toast } from "sonner"
import { useProject } from "@/stores"
import type { ContainerTagListType } from "@lib/types"
import type { ContainerTagListType, Project } from "@lib/types"
import { DEFAULT_PROJECT_ID } from "@lib/constants"
type ProjectDeleteTarget = {
id: string
containerTag: string
name?: string
}
export function useProjectMutations() {
const queryClient = useQueryClient()
@ -45,16 +52,21 @@ export function useProjectMutations() {
const deleteProjectMutation = useMutation({
mutationFn: async ({
projectId,
containerTag,
action,
targetProjectId,
}: {
projectId: string
containerTag: string
action: "move" | "delete"
targetProjectId?: string
}) => {
const response = await $fetch(`@delete/projects/${projectId}`, {
body: { action, targetProjectId },
})
const response =
action === "delete"
? await $fetch(`@delete/container-tags/${containerTag}`)
: await $fetch(`@delete/projects/${projectId}`, {
body: { action, targetProjectId },
})
if (response.error) {
throw new Error(response.error?.message || "Failed to delete project")
@ -68,7 +80,10 @@ export function useProjectMutations() {
const allTags =
queryClient.getQueryData<ContainerTagListType[]>(["container-tags"]) ||
[]
const deletedProject = allTags.find((p) => p.id === variables.projectId)
const deletedProject =
variables.action === "delete"
? { containerTag: variables.containerTag }
: allTags.find((p) => p.id === variables.projectId)
if (
deletedProject?.containerTag &&
@ -89,6 +104,176 @@ export function useProjectMutations() {
},
})
const deleteProjectsMutation = useMutation({
mutationFn: async ({ projects }: { projects: ProjectDeleteTarget[] }) => {
const results = await Promise.allSettled(
projects.map(async (project) => {
const response = await $fetch(
`@delete/container-tags/${project.containerTag}`,
)
if (response.error) {
throw new Error(
response.error?.message || `Failed to delete ${project.name}`,
)
}
return {
project,
data: response.data,
}
}),
)
return {
successful: results
.filter((result) => result.status === "fulfilled")
.map((result) => result.value),
failed: results
.filter((result) => result.status === "rejected")
.map((result) => result.reason),
}
},
onSuccess: (result, variables) => {
const deletedTags = new Set(
result.successful.map(({ project }) => project.containerTag),
)
if (selectedProjects.some((tag) => deletedTags.has(tag))) {
const remainingSelected = selectedProjects.filter(
(tag) => !deletedTags.has(tag),
)
setSelectedProjects(
remainingSelected.length > 0
? remainingSelected
: [DEFAULT_PROJECT_ID],
)
}
queryClient.invalidateQueries({ queryKey: ["projects"] })
queryClient.invalidateQueries({ queryKey: ["container-tags"] })
if (result.failed.length > 0) {
toast.error(
`Deleted ${result.successful.length} of ${variables.projects.length} spaces`,
{
description:
result.failed[0] instanceof Error
? result.failed[0].message
: "Some spaces could not be deleted.",
},
)
return
}
toast.success(
variables.projects.length === 1
? "Space deleted successfully"
: `${variables.projects.length} spaces deleted successfully`,
)
},
onError: (error) => {
toast.error("Failed to delete spaces", {
description: error instanceof Error ? error.message : "Unknown error",
})
},
})
const updateProjectMutation = useMutation({
mutationFn: async ({
containerTag,
name,
}: {
containerTag: string
name: string
}) => {
const response = await $fetch(`@patch/container-tags/${containerTag}`, {
body: { name },
})
if (response.error) {
throw new Error(response.error?.message || "Failed to update project")
}
const data = response.data as
| { containerTag?: string; name?: string | null }
| undefined
return {
containerTag: data?.containerTag ?? containerTag,
name: data?.name ?? name,
}
},
onMutate: async (variables) => {
await Promise.all([
queryClient.cancelQueries({ queryKey: ["projects"] }),
queryClient.cancelQueries({ queryKey: ["container-tags"] }),
])
const previousProjects = queryClient.getQueryData<Project[]>(["projects"])
const previousContainerTags = queryClient.getQueryData<
ContainerTagListType[]
>(["container-tags"])
queryClient.setQueryData<Project[]>(["projects"], (current) =>
current?.map((project) =>
project.containerTag === variables.containerTag
? { ...project, name: variables.name }
: project,
),
)
queryClient.setQueryData<ContainerTagListType[]>(
["container-tags"],
(current) =>
current?.map((project) =>
project.containerTag === variables.containerTag
? { ...project, name: variables.name }
: project,
),
)
return { previousProjects, previousContainerTags }
},
onSuccess: (data) => {
if (!data) return
queryClient.setQueryData<Project[]>(["projects"], (current) =>
current?.map((project) =>
project.containerTag === data.containerTag
? { ...project, name: data.name }
: project,
),
)
queryClient.setQueryData<ContainerTagListType[]>(
["container-tags"],
(current) =>
current?.map((project) =>
project.containerTag === data.containerTag
? { ...project, name: data.name }
: project,
),
)
toast.success("Space renamed")
},
onError: (error, _variables, context) => {
if (context?.previousProjects) {
queryClient.setQueryData(["projects"], context.previousProjects)
}
if (context?.previousContainerTags) {
queryClient.setQueryData(
["container-tags"],
context.previousContainerTags,
)
}
toast.error("Failed to rename space", {
description: error instanceof Error ? error.message : "Unknown error",
})
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["projects"] })
queryClient.invalidateQueries({ queryKey: ["container-tags"] })
},
})
const switchProject = (containerTag: string) => {
setSelectedProject(containerTag)
toast.success("Project switched successfully")
@ -97,6 +282,8 @@ export function useProjectMutations() {
return {
createProjectMutation,
deleteProjectMutation,
deleteProjectsMutation,
updateProjectMutation,
switchProject,
}
}

View file

@ -34,7 +34,6 @@ export function useSyncRuns(connectionId: string) {
},
enabled: !!connectionId,
staleTime: 30 * 1000,
refetchOnMount: "always",
refetchInterval: (query) => {
const runs = query.state.data as SyncRun[] | undefined
if (runs?.some((r) => r.status === "running")) {

View file

@ -4,6 +4,30 @@ import { calculateUsagePercent, getDaysRemaining } from "@/lib/billing-utils"
export type PlanType = "free" | "pro" | "scale" | "enterprise"
export const PLAN_DISPLAY_NAMES: Record<PlanType, string> = {
free: "Free",
pro: "Pro",
scale: "Scale",
enterprise: "Enterprise",
}
/** Higher rank sorts first in org lists (enterprise at top). */
export const PLAN_RANK: Record<PlanType, number> = {
free: 0,
pro: 1,
scale: 2,
enterprise: 3,
}
export function normalizePlanType(raw: unknown): PlanType {
if (typeof raw !== "string" || !raw.trim()) return "free"
const normalized = raw.toLowerCase().replace(/^api_/, "")
if (normalized === "enterprise") return "enterprise"
if (normalized === "scale") return "scale"
if (normalized === "pro") return "pro"
return "free"
}
const TOKEN_METER_IDS = [
"sm_tokens_text",
"sm_tokens_rich",

View file

@ -0,0 +1 @@
export const AUTO_CHAT_SPACE_ID = "__supermemory_auto_space__"

View file

@ -1,6 +1,8 @@
import { DEFAULT_PROJECT_ID } from "@lib/constants"
import type { ContainerTagListType } from "@lib/types"
export const OWN_CHAT_SPACE_NAME = "Nova chats"
/**
* Spaces auto-created on first ingest use `name === \`Space ${containerTag}\``
* (mono `apps/api/src/routes/memories/handler-effect.ts`). Those are noisy in the
@ -24,10 +26,22 @@ export function compareSpacesUserFirst(
)
}
export function isOwnConversationSpace(
p: Pick<ContainerTagListType, "containerTag"> | undefined,
currentUserId?: string | null,
): boolean {
return !!currentUserId && p?.containerTag === currentUserId
}
export function spaceSelectorDisplayName(
p: Pick<ContainerTagListType, "name" | "containerTag"> | undefined,
fallback: string,
options?: { currentUserId?: string | null },
): string {
const containerTag = p?.containerTag ?? fallback
if (containerTag === options?.currentUserId) {
return OWN_CHAT_SPACE_NAME
}
if (!p) return fallback
const name = p.name ?? p.containerTag
const long = name.length > 44

View file

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

View file

@ -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.
*/

View file

@ -1,21 +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 isDevHost =
url.hostname === "localhost" ||
url.hostname.includes(".localhost") ||
url.hostname.includes(".dev.supermemory.ai")
const sessionCookie = isDevHost
? getSessionCookie(request, { cookiePrefix: "better-auth-dev" })
: getSessionCookie(request)
const sessionCookie = getAuthSessionCookie(request)
console.debug("[PROXY] Session cookie exists:", !!sessionCookie)
// Always allow access to login and waitlist pages
@ -47,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
@ -78,6 +79,6 @@ export default async function proxy(request: Request) {
export const config = {
matcher: [
"/((?!_next/static|_next/image|images|icon.png|manifest.webmanifest|monitoring|opengraph-image.png|bg-rectangle.png|onboarding|ingest|login|api/emails|mcp-supported-tools|mcp-icon.svg).*)",
"/((?!_next/static|_next/image|images|icon.png|favicon.ico|favicon-16x16.png|favicon-32x32.png|apple-touch-icon.png|android-chrome-192x192.png|android-chrome-512x512.png|manifest.webmanifest|site.webmanifest|monitoring|opengraph-image.png|bg-rectangle.png|onboarding|ingest|login|api/emails|mcp-supported-tools|mcp-icon.svg).*)",
],
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 801 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

BIN
apps/web/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View file

@ -0,0 +1,19 @@
{
"name": "",
"short_name": "",
"icons": [
{
"src": "/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}

View file

@ -7,6 +7,7 @@ import {
BulkDeleteMemoriesResponseSchema,
BulkDeleteMemoriesSchema,
ConnectionResponseSchema,
ContainerTagSettingsUpdateSchema,
CreateProjectSchema,
DeleteProjectResponseSchema,
DeleteProjectSchema,
@ -25,6 +26,7 @@ import {
SearchResponseSchema,
type SearchResult,
SettingsRequestSchema,
UpdateContainerTagSettingsRequestSchema,
} from "../validation/api"
// Settings response schema - this is custom to console (not in shared validation)
@ -252,6 +254,24 @@ export const apiSchema = createSchema({
"@get/container-tags/list": {
output: ListContainerTagsResponseSchema,
},
"@patch/container-tags/:containerTag": {
input: UpdateContainerTagSettingsRequestSchema,
output: ContainerTagSettingsUpdateSchema,
params: z.object({
containerTag: z.string(),
}),
},
"@delete/container-tags/:containerTag": {
output: z.object({
success: z.boolean(),
containerTag: z.string(),
deletedDocumentsCount: z.number(),
deletedMemoriesCount: z.number(),
}),
params: z.object({
containerTag: z.string(),
}),
},
"@post/projects": {
input: CreateProjectSchema,
output: ProjectSchema,

View file

@ -2,8 +2,8 @@ export const Logo = ({
className,
id,
}: {
className?: string;
id?: string;
className?: string
id?: string
}) => {
return (
<svg
@ -19,15 +19,15 @@ export const Logo = ({
fill="#ffffff"
/>
</svg>
);
};
)
}
export const LogoFull = ({
className,
id,
}: {
className?: string;
id?: string;
className?: string
id?: string
}) => {
return (
<svg
@ -47,8 +47,8 @@ export const LogoFull = ({
</clipPath>
</defs>
</svg>
);
};
)
}
export const GradientLogo = ({ className = "" }: { className?: string }) => {
return (
@ -106,8 +106,8 @@ export const GradientLogo = ({ className = "" }: { className?: string }) => {
</clipPath>
</defs>
</svg>
);
};
)
}
export const LogoBgGradient = ({ className = "" }: { className?: string }) => {
return (
@ -311,5 +311,5 @@ export const LogoBgGradient = ({ className = "" }: { className?: string }) => {
</filter>
</defs>
</svg>
);
};
)
}

View file

@ -22,7 +22,7 @@ export const OneDrive = ({ className }: { className?: string }) => (
fill="#28A8EA"
/>
</svg>
);
)
export const GoogleDrive = ({ className }: { className?: string }) => (
<svg
@ -56,7 +56,7 @@ export const GoogleDrive = ({ className }: { className?: string }) => (
fill="#FFBA00"
/>
</svg>
);
)
export const Notion = ({ className }: { className?: string }) => (
<svg
@ -71,7 +71,7 @@ export const Notion = ({ className }: { className?: string }) => (
/>
<path d="M164.09.608L16.092 11.538C4.155 12.573 0 20.374 0 29.726v162.245c0 7.284 2.585 13.516 8.826 21.843l34.789 45.237c5.715 7.284 10.912 8.844 21.825 8.327l171.864-10.404c14.532-1.035 18.696-7.801 18.696-19.24V55.207c0-5.911-2.336-7.614-9.21-12.66l-1.185-.856L198.37 8.409C186.94.1 182.27-.952 164.09.608M69.327 52.22c-14.033.945-17.216 1.159-25.186-5.323L23.876 30.778c-2.06-2.086-1.026-4.69 4.163-5.207l142.274-10.395c11.947-1.043 18.17 3.12 22.842 6.758l24.401 17.68c1.043.525 3.638 3.637.517 3.637L71.146 52.095zm-16.36 183.954V81.222c0-6.767 2.077-9.887 8.3-10.413L230.02 60.93c5.724-.517 8.31 3.12 8.31 9.879v153.917c0 6.767-1.044 12.49-10.387 13.008l-161.487 9.361c-9.343.517-13.489-2.594-13.489-10.921M212.377 89.53c1.034 4.681 0 9.362-4.681 9.897l-7.783 1.542v114.404c-6.758 3.637-12.981 5.715-18.18 5.715c-8.308 0-10.386-2.604-16.609-10.396l-50.898-80.079v77.476l16.1 3.646s0 9.362-12.989 9.362l-35.814 2.077c-1.043-2.086 0-7.284 3.63-8.318l9.351-2.595V109.823l-12.98-1.052c-1.044-4.68 1.55-11.439 8.826-11.965l38.426-2.585l52.958 81.113v-71.76l-13.498-1.552c-1.043-5.733 3.111-9.896 8.3-10.404z" />
</svg>
);
)
export const GoogleDocs = ({ className }: { className?: string }) => (
<svg
@ -85,7 +85,7 @@ export const GoogleDocs = ({ className }: { className?: string }) => (
fill="currentColor"
/>
</svg>
);
)
export const GoogleSheets = ({ className }: { className?: string }) => (
<svg
@ -99,7 +99,7 @@ export const GoogleSheets = ({ className }: { className?: string }) => (
fill="currentColor"
/>
</svg>
);
)
export const GoogleSlides = ({ className }: { className?: string }) => (
<svg
@ -113,7 +113,7 @@ export const GoogleSlides = ({ className }: { className?: string }) => (
fill="currentColor"
/>
</svg>
);
)
export const NotionDoc = ({ className }: { className?: string }) => (
<svg
@ -127,7 +127,7 @@ export const NotionDoc = ({ className }: { className?: string }) => (
fill="currentColor"
/>
</svg>
);
)
export const MicrosoftWord = ({ className }: { className?: string }) => (
<svg
@ -141,7 +141,7 @@ export const MicrosoftWord = ({ className }: { className?: string }) => (
fill="currentColor"
/>
</svg>
);
)
export const MicrosoftExcel = ({ className }: { className?: string }) => (
<svg
@ -155,7 +155,7 @@ export const MicrosoftExcel = ({ className }: { className?: string }) => (
fill="currentColor"
/>
</svg>
);
)
export const MicrosoftPowerpoint = ({ className }: { className?: string }) => (
<svg
@ -169,7 +169,7 @@ export const MicrosoftPowerpoint = ({ className }: { className?: string }) => (
fill="currentColor"
/>
</svg>
);
)
export const MicrosoftOneNote = ({ className }: { className?: string }) => (
<svg
@ -183,7 +183,7 @@ export const MicrosoftOneNote = ({ className }: { className?: string }) => (
fill="currentColor"
/>
</svg>
);
)
export const PDF = ({ className }: { className?: string }) => (
<svg
@ -205,7 +205,7 @@ export const PDF = ({ className }: { className?: string }) => (
fill="#DC2626"
/>
</svg>
);
)
export const SyncLogoIcon = ({ className }: { className?: string }) => {
return (
@ -258,8 +258,8 @@ export const SyncLogoIcon = ({ className }: { className?: string }) => {
</clipPath>
</defs>
</svg>
);
};
)
}
export const MCPIcon = ({ className }: { className?: string }) => {
return (
@ -323,8 +323,8 @@ export const MCPIcon = ({ className }: { className?: string }) => {
</linearGradient>
</defs>
</svg>
);
};
)
}
export const ClaudeDesktopIcon = ({ className }: { className?: string }) => {
return (
@ -360,5 +360,5 @@ export const ClaudeDesktopIcon = ({ className }: { className?: string }) => {
/>
</defs>
</svg>
);
};
)
}

View file

@ -1,11 +1,11 @@
import { cn } from "@lib/utils";
import { Button } from "@ui/components/button";
import { cn } from "@lib/utils"
import { Button } from "@ui/components/button"
export type ExternalAuthButtonProps = React.ComponentProps<"button"> &
React.ComponentProps<typeof Button> & {
authProvider: string;
authIcon: React.ReactNode;
};
authProvider: string
authIcon: React.ReactNode
}
export function ExternalAuthButton({
authProvider,
@ -34,5 +34,5 @@ export function ExternalAuthButton({
Continue with {authProvider}
</span>
</Button>
);
)
}

View file

@ -1,14 +1,14 @@
"use client";
"use client"
import { cn } from "@lib/utils";
import * as AccordionPrimitive from "@radix-ui/react-accordion";
import { ChevronDownIcon } from "lucide-react";
import type * as React from "react";
import { cn } from "@lib/utils"
import * as AccordionPrimitive from "@radix-ui/react-accordion"
import { ChevronDownIcon } from "lucide-react"
import type * as React from "react"
function Accordion({
...props
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return <AccordionPrimitive.Root data-slot="accordion" {...props} />;
return <AccordionPrimitive.Root data-slot="accordion" {...props} />
}
function AccordionItem({
@ -21,7 +21,7 @@ function AccordionItem({
data-slot="accordion-item"
{...props}
/>
);
)
}
function AccordionTrigger({
@ -43,7 +43,7 @@ function AccordionTrigger({
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
);
)
}
function AccordionContent({
@ -59,7 +59,7 @@ function AccordionContent({
>
<div className={cn("pt-0 pb-4", className)}>{children}</div>
</AccordionPrimitive.Content>
);
)
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }

View file

@ -1,14 +1,14 @@
"use client";
"use client"
import { cn } from "@lib/utils";
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
import { buttonVariants } from "@ui/components/button";
import type * as React from "react";
import { cn } from "@lib/utils"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { buttonVariants } from "@ui/components/button"
import type * as React from "react"
function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
}
function AlertDialogTrigger({
@ -16,7 +16,7 @@ function AlertDialogTrigger({
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
);
)
}
function AlertDialogPortal({
@ -24,7 +24,7 @@ function AlertDialogPortal({
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
);
)
}
function AlertDialogOverlay({
@ -40,7 +40,7 @@ function AlertDialogOverlay({
data-slot="alert-dialog-overlay"
{...props}
/>
);
)
}
function AlertDialogContent({
@ -59,7 +59,7 @@ function AlertDialogContent({
{...props}
/>
</AlertDialogPortal>
);
)
}
function AlertDialogHeader({
@ -72,7 +72,7 @@ function AlertDialogHeader({
data-slot="alert-dialog-header"
{...props}
/>
);
)
}
function AlertDialogFooter({
@ -88,7 +88,7 @@ function AlertDialogFooter({
data-slot="alert-dialog-footer"
{...props}
/>
);
)
}
function AlertDialogTitle({
@ -101,7 +101,7 @@ function AlertDialogTitle({
data-slot="alert-dialog-title"
{...props}
/>
);
)
}
function AlertDialogDescription({
@ -114,7 +114,7 @@ function AlertDialogDescription({
data-slot="alert-dialog-description"
{...props}
/>
);
)
}
function AlertDialogAction({
@ -126,7 +126,7 @@ function AlertDialogAction({
className={cn(buttonVariants(), className)}
{...props}
/>
);
)
}
function AlertDialogCancel({
@ -138,7 +138,7 @@ function AlertDialogCancel({
className={cn(buttonVariants({ variant: "outline" }), className)}
{...props}
/>
);
)
}
export {
@ -153,4 +153,4 @@ export {
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
};
}

View file

@ -1,8 +1,8 @@
"use client";
"use client"
import { cn } from "@lib/utils";
import * as AvatarPrimitive from "@radix-ui/react-avatar";
import type * as React from "react";
import { cn } from "@lib/utils"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import type * as React from "react"
function Avatar({
className,
@ -17,7 +17,7 @@ function Avatar({
data-slot="avatar"
{...props}
/>
);
)
}
function AvatarImage({
@ -30,7 +30,7 @@ function AvatarImage({
data-slot="avatar-image"
{...props}
/>
);
)
}
function AvatarFallback({
@ -46,7 +46,7 @@ function AvatarFallback({
data-slot="avatar-fallback"
{...props}
/>
);
)
}
export { Avatar, AvatarImage, AvatarFallback };
export { Avatar, AvatarImage, AvatarFallback }

View file

@ -1,7 +1,7 @@
import { cn } from "@lib/utils";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import type * as React from "react";
import { cn } from "@lib/utils"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import type * as React from "react"
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-2 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
@ -22,7 +22,7 @@ const badgeVariants = cva(
variant: "default",
},
},
);
)
function Badge({
className,
@ -38,7 +38,7 @@ function Badge({
data-slot="badge"
{...(props as any)}
/>
);
)
}
return (
@ -47,7 +47,7 @@ function Badge({
data-slot="badge"
{...props}
/>
);
)
}
export { Badge, badgeVariants };
export { Badge, badgeVariants }

View file

@ -1,10 +1,10 @@
import { cn } from "@lib/utils";
import { Slot } from "@radix-ui/react-slot";
import { ChevronRight, MoreHorizontal } from "lucide-react";
import type * as React from "react";
import { cn } from "@lib/utils"
import { Slot } from "@radix-ui/react-slot"
import { ChevronRight, MoreHorizontal } from "lucide-react"
import type * as React from "react"
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />;
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
@ -17,7 +17,7 @@ function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
data-slot="breadcrumb-list"
{...props}
/>
);
)
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
@ -27,7 +27,7 @@ function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
data-slot="breadcrumb-item"
{...props}
/>
);
)
}
function BreadcrumbLink({
@ -35,7 +35,7 @@ function BreadcrumbLink({
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean;
asChild?: boolean
}) {
if (asChild) {
return (
@ -44,7 +44,7 @@ function BreadcrumbLink({
data-slot="breadcrumb-link"
{...(props as any)}
/>
);
)
}
return (
@ -53,7 +53,7 @@ function BreadcrumbLink({
data-slot="breadcrumb-link"
{...props}
/>
);
)
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
@ -67,7 +67,7 @@ function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
tabIndex={0}
{...props}
/>
);
)
}
function BreadcrumbSeparator({
@ -85,7 +85,7 @@ function BreadcrumbSeparator({
>
{children ?? <ChevronRight />}
</li>
);
)
}
function BreadcrumbEllipsis({
@ -103,7 +103,7 @@ function BreadcrumbEllipsis({
<MoreHorizontal className="size-4" />
<span className="sr-only">More</span>
</span>
);
)
}
export {
@ -114,4 +114,4 @@ export {
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
};
}

View file

@ -1,7 +1,7 @@
import { cn } from "@lib/utils";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import type * as React from "react";
import { cn } from "@lib/utils"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import type * as React from "react"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-2 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
@ -42,7 +42,7 @@ const buttonVariants = cva(
size: "default",
},
},
);
)
function Button({
className,
@ -52,7 +52,7 @@ function Button({
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
asChild?: boolean
}) {
if (asChild) {
return (
@ -61,7 +61,7 @@ function Button({
data-slot="button"
{...(props as any)}
/>
);
)
}
return (
@ -70,7 +70,7 @@ function Button({
data-slot="button"
{...props}
/>
);
)
}
export { Button, buttonVariants };
export { Button, buttonVariants }

View file

@ -1,5 +1,5 @@
import { cn } from "@lib/utils";
import type * as React from "react";
import { cn } from "@lib/utils"
import type * as React from "react"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
@ -11,7 +11,7 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
data-slot="card"
{...props}
/>
);
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
@ -24,7 +24,7 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
data-slot="card-header"
{...props}
/>
);
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
@ -34,7 +34,7 @@ function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
data-slot="card-title"
{...props}
/>
);
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
@ -44,7 +44,7 @@ function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
data-slot="card-description"
{...props}
/>
);
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
@ -57,7 +57,7 @@ function CardAction({ className, ...props }: React.ComponentProps<"div">) {
data-slot="card-action"
{...props}
/>
);
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
@ -67,7 +67,7 @@ function CardContent({ className, ...props }: React.ComponentProps<"div">) {
data-slot="card-content"
{...props}
/>
);
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
@ -77,7 +77,7 @@ function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
data-slot="card-footer"
{...props}
/>
);
)
}
export {
@ -88,4 +88,4 @@ export {
CardAction,
CardDescription,
CardContent,
};
}

View file

@ -1,44 +1,44 @@
"use client";
"use client"
import { cn } from "@lib/utils";
import { Button } from "@ui/components/button";
import { cn } from "@lib/utils"
import { Button } from "@ui/components/button"
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from "embla-carousel-react";
import { ArrowLeft, ArrowRight } from "lucide-react";
import * as React from "react";
} from "embla-carousel-react"
import { ArrowLeft, ArrowRight } from "lucide-react"
import * as React from "react"
type CarouselApi = UseEmblaCarouselType[1];
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
type CarouselOptions = UseCarouselParameters[0];
type CarouselPlugin = UseCarouselParameters[1];
type CarouselApi = UseEmblaCarouselType[1]
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
type CarouselOptions = UseCarouselParameters[0]
type CarouselPlugin = UseCarouselParameters[1]
type CarouselProps = {
opts?: CarouselOptions;
plugins?: CarouselPlugin;
orientation?: "horizontal" | "vertical";
setApi?: (api: CarouselApi) => void;
};
opts?: CarouselOptions
plugins?: CarouselPlugin
orientation?: "horizontal" | "vertical"
setApi?: (api: CarouselApi) => void
}
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0];
api: ReturnType<typeof useEmblaCarousel>[1];
scrollPrev: () => void;
scrollNext: () => void;
canScrollPrev: boolean;
canScrollNext: boolean;
} & CarouselProps;
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
api: ReturnType<typeof useEmblaCarousel>[1]
scrollPrev: () => void
scrollNext: () => void
canScrollPrev: boolean
canScrollNext: boolean
} & CarouselProps
const CarouselContext = React.createContext<CarouselContextProps | null>(null);
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
function useCarousel() {
const context = React.useContext(CarouselContext);
const context = React.useContext(CarouselContext)
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />");
throw new Error("useCarousel must be used within a <Carousel />")
}
return context;
return context
}
function Carousel({
@ -56,52 +56,52 @@ function Carousel({
axis: orientation === "horizontal" ? "x" : "y",
},
plugins,
);
const [canScrollPrev, setCanScrollPrev] = React.useState(false);
const [canScrollNext, setCanScrollNext] = React.useState(false);
)
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
const [canScrollNext, setCanScrollNext] = React.useState(false)
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) return;
setCanScrollPrev(api.canScrollPrev());
setCanScrollNext(api.canScrollNext());
}, []);
if (!api) return
setCanScrollPrev(api.canScrollPrev())
setCanScrollNext(api.canScrollNext())
}, [])
const scrollPrev = React.useCallback(() => {
api?.scrollPrev();
}, [api]);
api?.scrollPrev()
}, [api])
const scrollNext = React.useCallback(() => {
api?.scrollNext();
}, [api]);
api?.scrollNext()
}, [api])
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") {
event.preventDefault();
scrollPrev();
event.preventDefault()
scrollPrev()
} else if (event.key === "ArrowRight") {
event.preventDefault();
scrollNext();
event.preventDefault()
scrollNext()
}
},
[scrollPrev, scrollNext],
);
)
React.useEffect(() => {
if (!api || !setApi) return;
setApi(api);
}, [api, setApi]);
if (!api || !setApi) return
setApi(api)
}, [api, setApi])
React.useEffect(() => {
if (!api) return;
onSelect(api);
api.on("reInit", onSelect);
api.on("select", onSelect);
if (!api) return
onSelect(api)
api.on("reInit", onSelect)
api.on("select", onSelect)
return () => {
api?.off("select", onSelect);
};
}, [api, onSelect]);
api?.off("select", onSelect)
}
}, [api, onSelect])
return (
<CarouselContext.Provider
@ -126,11 +126,11 @@ function Carousel({
{children}
</section>
</CarouselContext.Provider>
);
)
}
function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
const { carouselRef, orientation } = useCarousel();
const { carouselRef, orientation } = useCarousel()
return (
<div
@ -147,11 +147,11 @@ function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
{...props}
/>
</div>
);
)
}
function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
const { orientation } = useCarousel();
const { orientation } = useCarousel()
return (
<div
@ -165,7 +165,7 @@ function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
role="group"
{...props}
/>
);
)
}
function CarouselPrevious({
@ -174,7 +174,7 @@ function CarouselPrevious({
size = "icon",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
return (
<Button
@ -195,7 +195,7 @@ function CarouselPrevious({
<ArrowLeft />
<span className="sr-only">Previous slide</span>
</Button>
);
)
}
function CarouselNext({
@ -204,7 +204,7 @@ function CarouselNext({
size = "icon",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollNext, canScrollNext } = useCarousel();
const { orientation, scrollNext, canScrollNext } = useCarousel()
return (
<Button
@ -225,7 +225,7 @@ function CarouselNext({
<ArrowRight />
<span className="sr-only">Next slide</span>
</Button>
);
)
}
export {
@ -235,4 +235,4 @@ export {
CarouselItem,
CarouselPrevious,
CarouselNext,
};
}

View file

@ -1,36 +1,36 @@
"use client";
"use client"
import { cn } from "@lib/utils";
import * as React from "react";
import * as RechartsPrimitive from "recharts";
import { cn } from "@lib/utils"
import * as React from "react"
import * as RechartsPrimitive from "recharts"
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const;
const THEMES = { light: "", dark: ".dark" } as const
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode;
icon?: React.ComponentType;
label?: React.ReactNode
icon?: React.ComponentType
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
);
};
)
}
type ChartContextProps = {
config: ChartConfig;
};
config: ChartConfig
}
const ChartContext = React.createContext<ChartContextProps | null>(null);
const ChartContext = React.createContext<ChartContextProps | null>(null)
function useChart() {
const context = React.useContext(ChartContext);
const context = React.useContext(ChartContext)
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />");
throw new Error("useChart must be used within a <ChartContainer />")
}
return context;
return context
}
function ChartContainer({
@ -40,13 +40,13 @@ function ChartContainer({
config,
...props
}: React.ComponentProps<"div"> & {
config: ChartConfig;
config: ChartConfig
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"];
>["children"]
}) {
const uniqueId = React.useId();
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
const uniqueId = React.useId()
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
return (
<ChartContext.Provider value={{ config }}>
@ -65,16 +65,16 @@ function ChartContainer({
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
);
)
}
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme || config.color,
);
)
if (!colorConfig.length) {
return null;
return null
}
return (
@ -89,8 +89,8 @@ ${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color;
return color ? ` --color-${key}: ${color};` : null;
itemConfig.color
return color ? ` --color-${key}: ${color};` : null
})
.join("\n")}
}
@ -99,10 +99,10 @@ ${colorConfig
.join("\n"),
}}
/>
);
};
)
}
const ChartTooltip = RechartsPrimitive.Tooltip;
const ChartTooltip = RechartsPrimitive.Tooltip
function ChartTooltipContent({
active,
@ -120,40 +120,40 @@ function ChartTooltipContent({
labelKey,
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean;
hideIndicator?: boolean;
indicator?: "line" | "dot" | "dashed";
nameKey?: string;
labelKey?: string;
hideLabel?: boolean
hideIndicator?: boolean
indicator?: "line" | "dot" | "dashed"
nameKey?: string
labelKey?: string
}) {
const { config } = useChart();
const { config } = useChart()
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null;
return null
}
const [item] = payload;
const key = `${labelKey || item?.dataKey || item?.name || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const [item] = payload
const key = `${labelKey || item?.dataKey || item?.name || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label;
: itemConfig?.label
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
);
)
}
if (!value) {
return null;
return null
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
return <div className={cn("font-medium", labelClassName)}>{value}</div>
}, [
label,
labelFormatter,
@ -162,13 +162,13 @@ function ChartTooltipContent({
labelClassName,
config,
labelKey,
]);
])
if (!active || !payload?.length) {
return null;
return null
}
const nestLabel = payload.length === 1 && indicator !== "dot";
const nestLabel = payload.length === 1 && indicator !== "dot"
return (
<div
@ -180,9 +180,9 @@ function ChartTooltipContent({
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const indicatorColor = color || item.payload.fill || item.color;
const key = `${nameKey || item.name || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const indicatorColor = color || item.payload.fill || item.color
return (
<div
@ -241,14 +241,14 @@ function ChartTooltipContent({
</>
)}
</div>
);
)
})}
</div>
</div>
);
)
}
const ChartLegend = RechartsPrimitive.Legend;
const ChartLegend = RechartsPrimitive.Legend
function ChartLegendContent({
className,
@ -258,13 +258,13 @@ function ChartLegendContent({
nameKey,
}: React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean;
nameKey?: string;
hideIcon?: boolean
nameKey?: string
}) {
const { config } = useChart();
const { config } = useChart()
if (!payload?.length) {
return null;
return null
}
return (
@ -276,8 +276,8 @@ function ChartLegendContent({
)}
>
{payload.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const key = `${nameKey || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
return (
<div
@ -298,10 +298,10 @@ function ChartLegendContent({
)}
{itemConfig?.label}
</div>
);
)
})}
</div>
);
)
}
// Helper to extract item config from a payload.
@ -311,7 +311,7 @@ function getPayloadConfigFromPayload(
key: string,
) {
if (typeof payload !== "object" || payload === null) {
return undefined;
return undefined
}
const payloadPayload =
@ -319,15 +319,15 @@ function getPayloadConfigFromPayload(
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined;
: undefined
let configLabelKey: string = key;
let configLabelKey: string = key
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string;
configLabelKey = payload[key as keyof typeof payload] as string
} else if (
payloadPayload &&
key in payloadPayload &&
@ -335,12 +335,12 @@ function getPayloadConfigFromPayload(
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string;
] as string
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config];
: config[key as keyof typeof config]
}
export {
@ -350,4 +350,4 @@ export {
ChartLegend,
ChartLegendContent,
ChartStyle,
};
}

View file

@ -1,9 +1,9 @@
"use client";
"use client"
import { cn } from "@lib/utils";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { CheckIcon } from "lucide-react";
import type * as React from "react";
import { cn } from "@lib/utils"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { CheckIcon } from "lucide-react"
import type * as React from "react"
function Checkbox({
className,
@ -25,7 +25,7 @@ function Checkbox({
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
);
)
}
export { Checkbox };
export { Checkbox }

View file

@ -1,11 +1,11 @@
"use client";
"use client"
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
function Collapsible({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />;
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
}
function CollapsibleTrigger({
@ -16,7 +16,7 @@ function CollapsibleTrigger({
data-slot="collapsible-trigger"
{...props}
/>
);
)
}
function CollapsibleContent({
@ -27,7 +27,7 @@ function CollapsibleContent({
data-slot="collapsible-content"
{...props}
/>
);
)
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
export { Collapsible, CollapsibleTrigger, CollapsibleContent }

View file

@ -1,7 +1,7 @@
"use client";
"use client"
import { cn } from "@lib/utils";
import { Button } from "@ui/components/button";
import { cn } from "@lib/utils"
import { Button } from "@ui/components/button"
import {
Command,
CommandEmpty,
@ -9,29 +9,25 @@ import {
CommandInput,
CommandItem,
CommandList,
} from "@ui/components/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@ui/components/popover";
import { Check, ChevronsUpDown, X } from "lucide-react";
import * as React from "react";
} from "@ui/components/command"
import { Popover, PopoverContent, PopoverTrigger } from "@ui/components/popover"
import { Check, ChevronsUpDown, X } from "lucide-react"
import * as React from "react"
interface Option {
value: string;
label: string;
value: string
label: string
}
interface ComboboxProps {
options: Option[];
onSelect: (value: string) => void;
onSubmit: (newName: string) => void;
selectedValues: string[];
setSelectedValues: React.Dispatch<React.SetStateAction<string[]>>;
className?: string;
placeholder?: string;
triggerClassName?: string;
options: Option[]
onSelect: (value: string) => void
onSubmit: (newName: string) => void
selectedValues: string[]
setSelectedValues: React.Dispatch<React.SetStateAction<string[]>>
className?: string
placeholder?: string
triggerClassName?: string
}
export function Combobox({
@ -44,38 +40,36 @@ export function Combobox({
placeholder = "Select...",
triggerClassName,
}: ComboboxProps) {
const [open, setOpen] = React.useState(false);
const [inputValue, setInputValue] = React.useState("");
const [open, setOpen] = React.useState(false)
const [inputValue, setInputValue] = React.useState("")
const handleSelect = (value: string) => {
onSelect(value);
setOpen(false);
setInputValue("");
};
onSelect(value)
setOpen(false)
setInputValue("")
}
const handleCreate = () => {
if (inputValue.trim()) {
onSubmit(inputValue);
setOpen(false);
setInputValue("");
onSubmit(inputValue)
setOpen(false)
setInputValue("")
}
};
}
const handleRemove = (valueToRemove: string) => {
setSelectedValues((prev) =>
prev.filter((value) => value !== valueToRemove),
);
};
setSelectedValues((prev) => prev.filter((value) => value !== valueToRemove))
}
const filteredOptions = options.filter(
(option) => !selectedValues.includes(option.value),
);
)
const isNewValue =
inputValue.trim() &&
!options.some(
(option) => option.label.toLowerCase() === inputValue.toLowerCase(),
);
)
return (
<Popover onOpenChange={setOpen} open={open}>
@ -93,7 +87,7 @@ export function Combobox({
<div className="flex flex-wrap gap-1 items-center w-full">
{selectedValues.length > 0 ? (
selectedValues.map((value) => {
const option = options.find((opt) => opt.value === value);
const option = options.find((opt) => opt.value === value)
return (
<span
className="inline-flex items-center gap-1 px-2 py-0.5 bg-secondary text-sm rounded-md"
@ -103,15 +97,15 @@ export function Combobox({
<button
className="hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
handleRemove(value);
e.stopPropagation()
handleRemove(value)
}}
type="button"
>
<X className="h-3 w-3" />
</button>
</span>
);
)
})
) : (
<span className="text-muted-foreground">{placeholder}</span>
@ -163,5 +157,5 @@ export function Combobox({
</Command>
</PopoverContent>
</Popover>
);
)
}

View file

@ -1,16 +1,16 @@
"use client";
"use client"
import { cn } from "@lib/utils";
import { cn } from "@lib/utils"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@ui/components/dialog";
import { Command as CommandPrimitive } from "cmdk";
import { SearchIcon } from "lucide-react";
import type * as React from "react";
} from "@ui/components/dialog"
import { Command as CommandPrimitive } from "cmdk"
import { SearchIcon } from "lucide-react"
import type * as React from "react"
function Command({
className,
@ -25,7 +25,7 @@ function Command({
data-slot="command"
{...props}
/>
);
)
}
function CommandDialog({
@ -36,10 +36,10 @@ function CommandDialog({
showCloseButton = true,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string;
description?: string;
className?: string;
showCloseButton?: boolean;
title?: string
description?: string
className?: string
showCloseButton?: boolean
}) {
return (
<Dialog {...props}>
@ -56,7 +56,7 @@ function CommandDialog({
</Command>
</DialogContent>
</Dialog>
);
)
}
function CommandInput({
@ -78,7 +78,7 @@ function CommandInput({
{...props}
/>
</div>
);
)
}
function CommandList({
@ -94,7 +94,7 @@ function CommandList({
data-slot="command-list"
{...props}
/>
);
)
}
function CommandEmpty({
@ -106,7 +106,7 @@ function CommandEmpty({
data-slot="command-empty"
{...props}
/>
);
)
}
function CommandGroup({
@ -122,7 +122,7 @@ function CommandGroup({
data-slot="command-group"
{...props}
/>
);
)
}
function CommandSeparator({
@ -135,7 +135,7 @@ function CommandSeparator({
data-slot="command-separator"
{...props}
/>
);
)
}
function CommandItem({
@ -151,7 +151,7 @@ function CommandItem({
data-slot="command-item"
{...props}
/>
);
)
}
function CommandShortcut({
@ -167,7 +167,7 @@ function CommandShortcut({
data-slot="command-shortcut"
{...props}
/>
);
)
}
export {
@ -180,4 +180,4 @@ export {
CommandItem,
CommandShortcut,
CommandSeparator,
};
}

View file

@ -1,32 +1,32 @@
"use client";
"use client"
import { cn } from "@lib/utils";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { XIcon } from "lucide-react";
import type * as React from "react";
import { cn } from "@lib/utils"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import type * as React from "react"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
@ -42,7 +42,7 @@ function DialogOverlay({
data-slot="dialog-overlay"
{...props}
/>
);
)
}
function DialogContent({
@ -51,7 +51,7 @@ function DialogContent({
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean;
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
@ -76,7 +76,7 @@ function DialogContent({
)}
</DialogPrimitive.Content>
</DialogPortal>
);
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
@ -86,7 +86,7 @@ function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
data-slot="dialog-header"
{...props}
/>
);
)
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
@ -99,7 +99,7 @@ function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
data-slot="dialog-footer"
{...props}
/>
);
)
}
function DialogTitle({
@ -112,7 +112,7 @@ function DialogTitle({
data-slot="dialog-title"
{...props}
/>
);
)
}
function DialogDescription({
@ -125,7 +125,7 @@ function DialogDescription({
data-slot="dialog-description"
{...props}
/>
);
)
}
export {
@ -139,4 +139,4 @@ export {
DialogPortal,
DialogTitle,
DialogTrigger,
};
}

View file

@ -1,31 +1,31 @@
"use client";
"use client"
import { cn } from "@lib/utils";
import type * as React from "react";
import { Drawer as DrawerPrimitive } from "vaul";
import { cn } from "@lib/utils"
import type * as React from "react"
import { Drawer as DrawerPrimitive } from "vaul"
function Drawer({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
return <DrawerPrimitive.Root data-slot="drawer" {...props} />;
return <DrawerPrimitive.Root data-slot="drawer" {...props} />
}
function DrawerTrigger({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />;
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />
}
function DrawerPortal({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />;
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />
}
function DrawerClose({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />;
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />
}
function DrawerOverlay({
@ -41,7 +41,7 @@ function DrawerOverlay({
data-slot="drawer-overlay"
{...props}
/>
);
)
}
function DrawerContent({
@ -56,7 +56,7 @@ function DrawerContent({
className={cn(
"group/drawer-content bg-background fixed z-50 flex h-auto flex-col",
"data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b",
"data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
"data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-xl data-[vaul-drawer-direction=bottom]:border-t",
"data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm",
"data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm",
className,
@ -68,7 +68,7 @@ function DrawerContent({
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
);
)
}
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
@ -81,7 +81,7 @@ function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
data-slot="drawer-header"
{...props}
/>
);
)
}
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
@ -91,7 +91,7 @@ function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
data-slot="drawer-footer"
{...props}
/>
);
)
}
function DrawerTitle({
@ -104,7 +104,7 @@ function DrawerTitle({
data-slot="drawer-title"
{...props}
/>
);
)
}
function DrawerDescription({
@ -117,7 +117,7 @@ function DrawerDescription({
data-slot="drawer-description"
{...props}
/>
);
)
}
export {
@ -131,4 +131,4 @@ export {
DrawerFooter,
DrawerTitle,
DrawerDescription,
};
}

View file

@ -1,14 +1,14 @@
"use client";
"use client"
import { cn } from "@lib/utils";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import type * as React from "react";
import { cn } from "@lib/utils"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import type * as React from "react"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
@ -16,7 +16,7 @@ function DropdownMenuPortal({
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
);
)
}
function DropdownMenuTrigger({
@ -27,7 +27,7 @@ function DropdownMenuTrigger({
data-slot="dropdown-menu-trigger"
{...props}
/>
);
)
}
function DropdownMenuContent({
@ -47,7 +47,7 @@ function DropdownMenuContent({
{...props}
/>
</DropdownMenuPrimitive.Portal>
);
)
}
function DropdownMenuGroup({
@ -55,7 +55,7 @@ function DropdownMenuGroup({
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
);
)
}
function DropdownMenuItem({
@ -64,8 +64,8 @@ function DropdownMenuItem({
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
variant?: "default" | "destructive";
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
@ -78,7 +78,7 @@ function DropdownMenuItem({
data-variant={variant}
{...props}
/>
);
)
}
function DropdownMenuCheckboxItem({
@ -104,7 +104,7 @@ function DropdownMenuCheckboxItem({
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
);
)
}
function DropdownMenuRadioGroup({
@ -115,7 +115,7 @@ function DropdownMenuRadioGroup({
data-slot="dropdown-menu-radio-group"
{...props}
/>
);
)
}
function DropdownMenuRadioItem({
@ -139,7 +139,7 @@ function DropdownMenuRadioItem({
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
);
)
}
function DropdownMenuLabel({
@ -147,7 +147,7 @@ function DropdownMenuLabel({
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
@ -159,7 +159,7 @@ function DropdownMenuLabel({
data-slot="dropdown-menu-label"
{...props}
/>
);
)
}
function DropdownMenuSeparator({
@ -172,7 +172,7 @@ function DropdownMenuSeparator({
data-slot="dropdown-menu-separator"
{...props}
/>
);
)
}
function DropdownMenuShortcut({
@ -188,13 +188,13 @@ function DropdownMenuShortcut({
data-slot="dropdown-menu-shortcut"
{...props}
/>
);
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
@ -203,7 +203,7 @@ function DropdownMenuSubTrigger({
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
@ -218,7 +218,7 @@ function DropdownMenuSubTrigger({
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
);
)
}
function DropdownMenuSubContent({
@ -234,7 +234,7 @@ function DropdownMenuSubContent({
data-slot="dropdown-menu-sub-content"
{...props}
/>
);
)
}
export {
@ -253,4 +253,4 @@ export {
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
};
}

View file

@ -1,11 +1,11 @@
interface PlusPatternBackgroundProps {
plusSize?: number;
plusColor?: string;
backgroundColor?: string;
className?: string;
style?: React.CSSProperties;
fade?: boolean;
[key: string]: any;
plusSize?: number
plusColor?: string
backgroundColor?: string
className?: string
style?: React.CSSProperties
fade?: boolean
[key: string]: any
}
export const BackgroundPlus: React.FC<PlusPatternBackgroundProps> = ({
@ -17,21 +17,21 @@ export const BackgroundPlus: React.FC<PlusPatternBackgroundProps> = ({
style,
...props
}) => {
const encodedPlusColor = encodeURIComponent(plusColor);
const encodedPlusColor = encodeURIComponent(plusColor)
const maskStyle: React.CSSProperties = fade
? {
maskImage: "radial-gradient(circle, white 10%, transparent 90%)",
WebkitMaskImage: "radial-gradient(circle, white 10%, transparent 90%)",
}
: {};
: {}
const backgroundStyle: React.CSSProperties = {
backgroundColor,
backgroundImage: `url("data:image/svg+xml,%3Csvg width='${plusSize}' height='${plusSize}' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='${encodedPlusColor}' fill-opacity='0.2'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E")`,
...maskStyle,
...style,
};
}
return (
<div
@ -39,7 +39,7 @@ export const BackgroundPlus: React.FC<PlusPatternBackgroundProps> = ({
style={backgroundStyle}
{...props}
/>
);
};
)
}
export default BackgroundPlus;
export default BackgroundPlus

View file

@ -1,13 +1,13 @@
"use client";
"use client"
import { cn } from "@lib/utils";
import * as HoverCardPrimitive from "@radix-ui/react-hover-card";
import type * as React from "react";
import { cn } from "@lib/utils"
import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
import type * as React from "react"
function HoverCard({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />;
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />
}
function HoverCardTrigger({
@ -15,7 +15,7 @@ function HoverCardTrigger({
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
return (
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
);
)
}
function HoverCardContent({
@ -37,7 +37,7 @@ function HoverCardContent({
{...props}
/>
</HoverCardPrimitive.Portal>
);
)
}
export { HoverCard, HoverCardTrigger, HoverCardContent };
export { HoverCard, HoverCardTrigger, HoverCardContent }

View file

@ -1,5 +1,5 @@
import { cn } from "@lib/utils";
import type * as React from "react";
import { cn } from "@lib/utils"
import type * as React from "react"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
@ -14,7 +14,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
type={type}
{...props}
/>
);
)
}
export { Input };
export { Input }

View file

@ -1,8 +1,8 @@
"use client";
"use client"
import { cn } from "@lib/utils";
import * as LabelPrimitive from "@radix-ui/react-label";
import type * as React from "react";
import { cn } from "@lib/utils"
import * as LabelPrimitive from "@radix-ui/react-label"
import type * as React from "react"
function Label({
className,
@ -17,7 +17,7 @@ function Label({
data-slot="label"
{...props}
/>
);
)
}
export { Label };
export { Label }

View file

@ -1,19 +1,19 @@
"use client";
"use client"
import { cn } from "@lib/utils";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import type * as React from "react";
import { cn } from "@lib/utils"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import type * as React from "react"
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
@ -35,13 +35,13 @@ function PopoverContent({
{...props}
/>
</PopoverPrimitive.Portal>
);
)
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }

View file

@ -1,8 +1,8 @@
"use client";
"use client"
import { cn } from "@lib/utils";
import * as ProgressPrimitive from "@radix-ui/react-progress";
import type * as React from "react";
import { cn } from "@lib/utils"
import * as ProgressPrimitive from "@radix-ui/react-progress"
import type * as React from "react"
function Progress({
className,
@ -24,7 +24,7 @@ function Progress({
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
);
)
}
export { Progress };
export { Progress }

View file

@ -1,8 +1,8 @@
"use client";
"use client"
import { cn } from "@lib/utils";
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
import type * as React from "react";
import { cn } from "@lib/utils"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import type * as React from "react"
function ScrollArea({
className,
@ -24,7 +24,7 @@ function ScrollArea({
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
);
)
}
function ScrollBar({
@ -51,7 +51,7 @@ function ScrollBar({
data-slot="scroll-area-thumb"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
);
)
}
export { ScrollArea, ScrollBar };
export { ScrollArea, ScrollBar }

View file

@ -1,26 +1,26 @@
"use client";
"use client"
import { cn } from "@lib/utils";
import * as SelectPrimitive from "@radix-ui/react-select";
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import type * as React from "react";
import { cn } from "@lib/utils"
import * as SelectPrimitive from "@radix-ui/react-select"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import type * as React from "react"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />;
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
@ -29,7 +29,7 @@ function SelectTrigger({
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default";
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
@ -46,7 +46,7 @@ function SelectTrigger({
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
)
}
function SelectContent({
@ -81,7 +81,7 @@ function SelectContent({
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
);
)
}
function SelectLabel({
@ -94,7 +94,7 @@ function SelectLabel({
data-slot="select-label"
{...props}
/>
);
)
}
function SelectItem({
@ -118,7 +118,7 @@ function SelectItem({
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
);
)
}
function SelectSeparator({
@ -131,7 +131,7 @@ function SelectSeparator({
data-slot="select-separator"
{...props}
/>
);
)
}
function SelectScrollUpButton({
@ -149,7 +149,7 @@ function SelectScrollUpButton({
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
);
)
}
function SelectScrollDownButton({
@ -167,7 +167,7 @@ function SelectScrollDownButton({
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
);
)
}
export {
@ -181,4 +181,4 @@ export {
SelectSeparator,
SelectTrigger,
SelectValue,
};
}

View file

@ -1,8 +1,8 @@
"use client";
"use client"
import { cn } from "@lib/utils";
import * as SeparatorPrimitive from "@radix-ui/react-separator";
import type * as React from "react";
import { cn } from "@lib/utils"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import type * as React from "react"
function Separator({
className,
@ -21,7 +21,7 @@ function Separator({
orientation={orientation}
{...props}
/>
);
)
}
export { Separator };
export { Separator }

View file

@ -1,48 +1,48 @@
"use client";
"use client"
import { cn } from "@lib/utils";
import { Button } from "@ui/components/button";
import { UploadIcon } from "lucide-react";
import type { ReactNode } from "react";
import { createContext, useContext } from "react";
import type { DropEvent, DropzoneOptions, FileRejection } from "react-dropzone";
import { useDropzone } from "react-dropzone";
import { cn } from "@lib/utils"
import { Button } from "@ui/components/button"
import { UploadIcon } from "lucide-react"
import type { ReactNode } from "react"
import { createContext, useContext } from "react"
import type { DropEvent, DropzoneOptions, FileRejection } from "react-dropzone"
import { useDropzone } from "react-dropzone"
type DropzoneContextType = {
src?: File[];
accept?: DropzoneOptions["accept"];
maxSize?: DropzoneOptions["maxSize"];
minSize?: DropzoneOptions["minSize"];
maxFiles?: DropzoneOptions["maxFiles"];
};
src?: File[]
accept?: DropzoneOptions["accept"]
maxSize?: DropzoneOptions["maxSize"]
minSize?: DropzoneOptions["minSize"]
maxFiles?: DropzoneOptions["maxFiles"]
}
const renderBytes = (bytes: number) => {
const units = ["B", "KB", "MB", "GB", "TB", "PB"];
let size = bytes;
let unitIndex = 0;
const units = ["B", "KB", "MB", "GB", "TB", "PB"]
let size = bytes
let unitIndex = 0
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
size /= 1024
unitIndex++
}
return `${size.toFixed(2)}${units[unitIndex]}`;
};
return `${size.toFixed(2)}${units[unitIndex]}`
}
const DropzoneContext = createContext<DropzoneContextType | undefined>(
undefined,
);
)
export type DropzoneProps = Omit<DropzoneOptions, "onDrop"> & {
src?: File[];
className?: string;
src?: File[]
className?: string
onDrop?: (
acceptedFiles: File[],
fileRejections: FileRejection[],
event: DropEvent,
) => void;
children?: ReactNode;
};
) => void
children?: ReactNode
}
export const Dropzone = ({
accept,
@ -66,15 +66,15 @@ export const Dropzone = ({
disabled,
onDrop: (acceptedFiles, fileRejections, event) => {
if (fileRejections.length > 0) {
const message = fileRejections.at(0)?.errors.at(0)?.message;
onError?.(new Error(message));
return;
const message = fileRejections.at(0)?.errors.at(0)?.message
onError?.(new Error(message))
return
}
onDrop?.(acceptedFiles, fileRejections, event);
onDrop?.(acceptedFiles, fileRejections, event)
},
...props,
});
})
return (
<DropzoneContext.Provider
@ -96,38 +96,38 @@ export const Dropzone = ({
{children}
</Button>
</DropzoneContext.Provider>
);
};
)
}
const useDropzoneContext = () => {
const context = useContext(DropzoneContext);
const context = useContext(DropzoneContext)
if (!context) {
throw new Error("useDropzoneContext must be used within a Dropzone");
throw new Error("useDropzoneContext must be used within a Dropzone")
}
return context;
};
return context
}
export type DropzoneContentProps = {
children?: ReactNode;
className?: string;
};
children?: ReactNode
className?: string
}
const maxLabelItems = 1;
const maxLabelItems = 1
export const DropzoneContent = ({
children,
className,
}: DropzoneContentProps) => {
const { src } = useDropzoneContext();
const { src } = useDropzoneContext()
if (!src) {
return null;
return null
}
if (children) {
return children;
return children
}
return (
@ -146,41 +146,41 @@ export const DropzoneContent = ({
Drag and drop or click to replace
</p>
</div>
);
};
)
}
export type DropzoneEmptyStateProps = {
children?: ReactNode;
className?: string;
};
children?: ReactNode
className?: string
}
export const DropzoneEmptyState = ({
children,
className,
}: DropzoneEmptyStateProps) => {
const { src, accept, maxSize, minSize, maxFiles } = useDropzoneContext();
const { src, accept, maxSize, minSize, maxFiles } = useDropzoneContext()
if (src) {
return null;
return null
}
if (children) {
return children;
return children
}
let caption = "";
let caption = ""
if (accept) {
caption += "Accepts ";
caption += new Intl.ListFormat("en").format(Object.keys(accept));
caption += "Accepts "
caption += new Intl.ListFormat("en").format(Object.keys(accept))
}
if (minSize && maxSize) {
caption += ` between ${renderBytes(minSize)} and ${renderBytes(maxSize)}`;
caption += ` between ${renderBytes(minSize)} and ${renderBytes(maxSize)}`
} else if (minSize) {
caption += ` at least ${renderBytes(minSize)}`;
caption += ` at least ${renderBytes(minSize)}`
} else if (maxSize) {
caption += ` less than ${renderBytes(maxSize)}`;
caption += ` less than ${renderBytes(maxSize)}`
}
return (
@ -198,5 +198,5 @@ export const DropzoneEmptyState = ({
<p className="text-wrap text-muted-foreground text-xs">{caption}.</p>
)}
</div>
);
};
)
}

View file

@ -1,30 +1,30 @@
"use client";
"use client"
import { cn } from "@lib/utils";
import * as SheetPrimitive from "@radix-ui/react-dialog";
import { XIcon } from "lucide-react";
import type * as React from "react";
import { cn } from "@lib/utils"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import type * as React from "react"
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({
@ -40,7 +40,7 @@ function SheetOverlay({
data-slot="sheet-overlay"
{...props}
/>
);
)
}
function SheetContent({
@ -49,7 +49,7 @@ function SheetContent({
side = "right",
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left";
side?: "top" | "right" | "bottom" | "left"
}) {
return (
<SheetPortal>
@ -77,7 +77,7 @@ function SheetContent({
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
);
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
@ -87,7 +87,7 @@ function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
data-slot="sheet-header"
{...props}
/>
);
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
@ -97,7 +97,7 @@ function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
data-slot="sheet-footer"
{...props}
/>
);
)
}
function SheetTitle({
@ -110,7 +110,7 @@ function SheetTitle({
data-slot="sheet-title"
{...props}
/>
);
)
}
function SheetDescription({
@ -123,7 +123,7 @@ function SheetDescription({
data-slot="sheet-description"
{...props}
/>
);
)
}
export {
@ -135,4 +135,4 @@ export {
SheetFooter,
SheetTitle,
SheetDescription,
};
}

View file

@ -1,55 +1,55 @@
"use client";
"use client"
import { useIsMobile } from "@hooks/use-mobile";
import { cn } from "@lib/utils";
import { Slot } from "@radix-ui/react-slot";
import { Button } from "@ui/components/button";
import { Input } from "@ui/components/input";
import { Separator } from "@ui/components/separator";
import { useIsMobile } from "@hooks/use-mobile"
import { cn } from "@lib/utils"
import { Slot } from "@radix-ui/react-slot"
import { Button } from "@ui/components/button"
import { Input } from "@ui/components/input"
import { Separator } from "@ui/components/separator"
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@ui/components/sheet";
import { Skeleton } from "@ui/components/skeleton";
} from "@ui/components/sheet"
import { Skeleton } from "@ui/components/skeleton"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@ui/components/tooltip";
import { cva, type VariantProps } from "class-variance-authority";
import { PanelLeftIcon } from "lucide-react";
import * as React from "react";
} from "@ui/components/tooltip"
import { cva, type VariantProps } from "class-variance-authority"
import { PanelLeftIcon } from "lucide-react"
import * as React from "react"
const SIDEBAR_COOKIE_NAME = "sidebar_state";
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
const SIDEBAR_WIDTH = "16rem";
const SIDEBAR_WIDTH_MOBILE = "18rem";
const SIDEBAR_WIDTH_ICON = "3rem";
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
const SIDEBAR_COOKIE_NAME = "sidebar_state"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = "16rem"
const SIDEBAR_WIDTH_MOBILE = "18rem"
const SIDEBAR_WIDTH_ICON = "3rem"
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
type SidebarContextProps = {
state: "expanded" | "collapsed";
open: boolean;
setOpen: (open: boolean) => void;
openMobile: boolean;
setOpenMobile: (open: boolean) => void;
isMobile: boolean;
toggleSidebar: () => void;
};
state: "expanded" | "collapsed"
open: boolean
setOpen: (open: boolean) => void
openMobile: boolean
setOpenMobile: (open: boolean) => void
isMobile: boolean
toggleSidebar: () => void
}
const SidebarContext = React.createContext<SidebarContextProps | null>(null);
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
function useSidebar() {
const context = React.useContext(SidebarContext);
const context = React.useContext(SidebarContext)
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.");
throw new Error("useSidebar must be used within a SidebarProvider.")
}
return context;
return context
}
function SidebarProvider({
@ -61,36 +61,36 @@ function SidebarProvider({
children,
...props
}: React.ComponentProps<"div"> & {
defaultOpen?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
}) {
const isMobile = useIsMobile();
const [openMobile, setOpenMobile] = React.useState(false);
const isMobile = useIsMobile()
const [openMobile, setOpenMobile] = React.useState(false)
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen);
const open = openProp ?? _open;
const [_open, _setOpen] = React.useState(defaultOpen)
const open = openProp ?? _open
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value;
const openState = typeof value === "function" ? value(open) : value
if (setOpenProp) {
setOpenProp(openState);
setOpenProp(openState)
} else {
_setOpen(openState);
_setOpen(openState)
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
},
[setOpenProp, open],
);
)
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);
}, [isMobile, setOpen]);
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
}, [isMobile, setOpen])
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
@ -99,18 +99,18 @@ function SidebarProvider({
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault();
toggleSidebar();
event.preventDefault()
toggleSidebar()
}
};
}
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [toggleSidebar]);
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [toggleSidebar])
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed";
const state = open ? "expanded" : "collapsed"
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
@ -123,7 +123,7 @@ function SidebarProvider({
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, toggleSidebar],
);
)
return (
<SidebarContext.Provider value={contextValue}>
@ -147,7 +147,7 @@ function SidebarProvider({
</div>
</TooltipProvider>
</SidebarContext.Provider>
);
)
}
function Sidebar({
@ -158,11 +158,11 @@ function Sidebar({
children,
...props
}: React.ComponentProps<"div"> & {
side?: "left" | "right";
variant?: "sidebar" | "floating" | "inset";
collapsible?: "offcanvas" | "icon" | "none";
side?: "left" | "right"
variant?: "sidebar" | "floating" | "inset"
collapsible?: "offcanvas" | "icon" | "none"
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
if (collapsible === "none") {
return (
@ -176,7 +176,7 @@ function Sidebar({
>
{children}
</div>
);
)
}
if (isMobile) {
@ -201,7 +201,7 @@ function Sidebar({
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
);
)
}
return (
@ -249,7 +249,7 @@ function Sidebar({
</div>
</div>
</div>
);
)
}
function SidebarTrigger({
@ -257,7 +257,7 @@ function SidebarTrigger({
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar();
const { toggleSidebar } = useSidebar()
return (
<Button
@ -265,8 +265,8 @@ function SidebarTrigger({
data-sidebar="trigger"
data-slot="sidebar-trigger"
onClick={(event) => {
onClick?.(event);
toggleSidebar();
onClick?.(event)
toggleSidebar()
}}
size="icon"
variant="ghost"
@ -275,11 +275,11 @@ function SidebarTrigger({
<PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span>
</Button>
);
)
}
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar();
const { toggleSidebar } = useSidebar()
return (
<button
@ -300,7 +300,7 @@ function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
title="Toggle Sidebar"
{...props}
/>
);
)
}
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
@ -314,7 +314,7 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
data-slot="sidebar-inset"
{...props}
/>
);
)
}
function SidebarInput({
@ -328,7 +328,7 @@ function SidebarInput({
data-slot="sidebar-input"
{...props}
/>
);
)
}
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
@ -339,7 +339,7 @@ function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
data-slot="sidebar-header"
{...props}
/>
);
)
}
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
@ -350,7 +350,7 @@ function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
data-slot="sidebar-footer"
{...props}
/>
);
)
}
function SidebarSeparator({
@ -364,7 +364,7 @@ function SidebarSeparator({
data-slot="sidebar-separator"
{...props}
/>
);
)
}
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
@ -378,7 +378,7 @@ function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
data-slot="sidebar-content"
{...props}
/>
);
)
}
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
@ -389,7 +389,7 @@ function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
data-slot="sidebar-group"
{...props}
/>
);
)
}
function SidebarGroupLabel({
@ -401,7 +401,7 @@ function SidebarGroupLabel({
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className,
);
)
if (asChild) {
return (
@ -411,7 +411,7 @@ function SidebarGroupLabel({
data-slot="sidebar-group-label"
{...(props as any)}
/>
);
)
}
return (
@ -421,7 +421,7 @@ function SidebarGroupLabel({
data-slot="sidebar-group-label"
{...props}
/>
);
)
}
function SidebarGroupAction({
@ -434,7 +434,7 @@ function SidebarGroupAction({
"after:absolute after:-inset-2 md:after:hidden",
"group-data-[collapsible=icon]:hidden",
className,
);
)
if (asChild) {
return (
@ -444,7 +444,7 @@ function SidebarGroupAction({
data-slot="sidebar-group-action"
{...(props as any)}
/>
);
)
}
return (
@ -454,7 +454,7 @@ function SidebarGroupAction({
data-slot="sidebar-group-action"
{...props}
/>
);
)
}
function SidebarGroupContent({
@ -468,7 +468,7 @@ function SidebarGroupContent({
data-slot="sidebar-group-content"
{...props}
/>
);
)
}
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
@ -479,7 +479,7 @@ function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
data-slot="sidebar-menu"
{...props}
/>
);
)
}
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
@ -490,7 +490,7 @@ function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
data-slot="sidebar-menu-item"
{...props}
/>
);
)
}
const sidebarMenuButtonVariants = cva(
@ -513,7 +513,7 @@ const sidebarMenuButtonVariants = cva(
size: "default",
},
},
);
)
function SidebarMenuButton({
asChild = false,
@ -524,11 +524,11 @@ function SidebarMenuButton({
className,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean;
isActive?: boolean;
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
asChild?: boolean
isActive?: boolean
tooltip?: string | React.ComponentProps<typeof TooltipContent>
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const { isMobile, state } = useSidebar();
const { isMobile, state } = useSidebar()
const buttonProps = {
className: cn(sidebarMenuButtonVariants({ variant, size }), className),
@ -537,22 +537,22 @@ function SidebarMenuButton({
"data-size": size,
"data-slot": "sidebar-menu-button",
...props,
};
}
const button = asChild ? (
<Slot {...(buttonProps as any)} />
) : (
<button {...buttonProps} />
);
)
if (!tooltip) {
return button;
return button
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
};
}
}
return (
@ -565,7 +565,7 @@ function SidebarMenuButton({
{...tooltip}
/>
</Tooltip>
);
)
}
function SidebarMenuAction({
@ -574,8 +574,8 @@ function SidebarMenuAction({
showOnHover = false,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean;
showOnHover?: boolean;
asChild?: boolean
showOnHover?: boolean
}) {
const classes = cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
@ -587,7 +587,7 @@ function SidebarMenuAction({
showOnHover &&
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
className,
);
)
if (asChild) {
return (
@ -597,7 +597,7 @@ function SidebarMenuAction({
data-slot="sidebar-menu-action"
{...(props as any)}
/>
);
)
}
return (
@ -607,7 +607,7 @@ function SidebarMenuAction({
data-slot="sidebar-menu-action"
{...props}
/>
);
)
}
function SidebarMenuBadge({
@ -629,7 +629,7 @@ function SidebarMenuBadge({
data-slot="sidebar-menu-badge"
{...props}
/>
);
)
}
function SidebarMenuSkeleton({
@ -637,12 +637,12 @@ function SidebarMenuSkeleton({
showIcon = false,
...props
}: React.ComponentProps<"div"> & {
showIcon?: boolean;
showIcon?: boolean
}) {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`;
}, []);
return `${Math.floor(Math.random() * 40) + 50}%`
}, [])
return (
<div
@ -667,7 +667,7 @@ function SidebarMenuSkeleton({
}
/>
</div>
);
)
}
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
@ -682,7 +682,7 @@ function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
data-slot="sidebar-menu-sub"
{...props}
/>
);
)
}
function SidebarMenuSubItem({
@ -696,7 +696,7 @@ function SidebarMenuSubItem({
data-slot="sidebar-menu-sub-item"
{...props}
/>
);
)
}
function SidebarMenuSubButton({
@ -706,9 +706,9 @@ function SidebarMenuSubButton({
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean;
size?: "sm" | "md";
isActive?: boolean;
asChild?: boolean
size?: "sm" | "md"
isActive?: boolean
}) {
const classes = cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
@ -717,7 +717,7 @@ function SidebarMenuSubButton({
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className,
);
)
if (asChild) {
return (
@ -729,7 +729,7 @@ function SidebarMenuSubButton({
data-slot="sidebar-menu-sub-button"
{...(props as any)}
/>
);
)
}
return (
@ -741,7 +741,7 @@ function SidebarMenuSubButton({
data-slot="sidebar-menu-sub-button"
{...props}
/>
);
)
}
export {
@ -769,4 +769,4 @@ export {
SidebarSeparator,
SidebarTrigger,
useSidebar,
};
}

View file

@ -1,4 +1,4 @@
import { cn } from "@lib/utils";
import { cn } from "@lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
@ -7,7 +7,7 @@ function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
data-slot="skeleton"
{...props}
/>
);
)
}
export { Skeleton };
export { Skeleton }

Some files were not shown because too many files have changed in this diff Show more