Merge remote-tracking branch 'origin/main' into feat/personalisation
# Conflicts: # apps/web/components/dashboard-view.tsx # apps/web/middleware.ts
|
|
@ -213,6 +213,7 @@
|
|||
"integrations/openclaw",
|
||||
"integrations/claude-code",
|
||||
"integrations/opencode",
|
||||
"integrations/codex",
|
||||
"integrations/hermes"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"}'
|
||||
```
|
||||
|
||||
|
|
|
|||
169
apps/docs/integrations/codex.mdx
Normal 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 (0–1) |
|
||||
| `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>
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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`, {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" ? (
|
||||
|
|
|
|||
16
apps/web/app/(app)/settings/integrations/page.tsx
Normal 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
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 30 KiB |
|
|
@ -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<{
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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())}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
84
apps/web/components/chat/chat-empty-state.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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]"
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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={{
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -103,7 +103,7 @@ export function DocumentContent({
|
|||
return <TextEditorContent {...textEditorProps} />
|
||||
|
||||
case "pdf":
|
||||
return <PdfViewer url={document.url} />
|
||||
return <PdfViewer url={document.url} documentId={document.id} />
|
||||
|
||||
case "notion":
|
||||
return <NotionDoc content={document.content ?? ""} />
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client"
|
||||
|
||||
import { Document, Page, pdfjs } from "react-pdf"
|
||||
import { useCallback, useState } from "react"
|
||||
import { useCallback, useMemo, useState } from "react"
|
||||
import "react-pdf/dist/Page/AnnotationLayer.css"
|
||||
import "react-pdf/dist/Page/TextLayer.css"
|
||||
|
||||
|
|
@ -13,9 +13,25 @@ pdfjs.GlobalWorkerOptions.workerSrc = new URL(
|
|||
|
||||
interface PdfViewerProps {
|
||||
url: string | null | undefined
|
||||
documentId?: string | null
|
||||
}
|
||||
|
||||
export function PdfViewer({ url }: PdfViewerProps) {
|
||||
export function PdfViewer({ url, documentId }: PdfViewerProps) {
|
||||
const fileSource = useMemo(() => {
|
||||
if (!url) return null
|
||||
try {
|
||||
if (new URL(url).hostname === "www.googleapis.com" && documentId) {
|
||||
const base =
|
||||
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
|
||||
return {
|
||||
url: `${base}/v3/drive-proxy/${documentId}`,
|
||||
withCredentials: true,
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
return url
|
||||
}, [url, documentId])
|
||||
|
||||
const [numPages, setNumPages] = useState<number | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
|
@ -70,7 +86,7 @@ export function PdfViewer({ url }: PdfViewerProps) {
|
|||
<Document
|
||||
key={retryKey}
|
||||
file={
|
||||
url ||
|
||||
fileSource ||
|
||||
"https://corsproxy.io/?" +
|
||||
encodeURIComponent("http://www.pdf995.com/samples/pdf.pdf")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
? [
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
132
apps/web/components/integrations/raycast-setup-modal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
37
apps/web/components/nova/auto-space-icon.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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")}
|
||||
|
|
|
|||
429
apps/web/components/pwa-install-prompt.tsx
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
579
apps/web/components/settings/billing.tsx
Normal 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 & 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'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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 & 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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -77,7 +77,7 @@ export function SyncStatusBadge({
|
|||
"font-medium text-[13px] tracking-[-0.13px] text-[#EF4444]",
|
||||
)}
|
||||
>
|
||||
Disconnected
|
||||
Needs reauth
|
||||
</span>
|
||||
)}
|
||||
{status === "idle" && (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ export type AccountMembership = {
|
|||
slug: string
|
||||
role: string
|
||||
memberCount: number
|
||||
plan?: string
|
||||
}
|
||||
|
||||
export function useAccountMemberships() {
|
||||
|
|
|
|||
29
apps/web/hooks/use-connection-health.ts
Normal 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 }
|
||||
}
|
||||
38
apps/web/hooks/use-org-summaries.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
|
|
@ -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)
|
||||
}, [])
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")) {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
1
apps/web/lib/chat-auto-space.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export const AUTO_CHAT_SPACE_ID = "__supermemory_auto_space__"
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -42,7 +42,8 @@ export type ViewParamValue = (typeof viewLiterals)[number]
|
|||
export const viewParam =
|
||||
parseAsStringLiteral(viewLiterals).withDefault("dashboard")
|
||||
|
||||
// Kept for backwards compat with components that pass integration hints
|
||||
// Kept for backwards compat with components that pass integration hints.
|
||||
// "notion"/"google-drive" are connection providers, not view modes — they open the connect modal.
|
||||
export type IntegrationParamValue =
|
||||
| "mcp"
|
||||
| "plugins"
|
||||
|
|
@ -51,6 +52,8 @@ export type IntegrationParamValue =
|
|||
| "shortcuts"
|
||||
| "raycast"
|
||||
| "import"
|
||||
| "notion"
|
||||
| "google-drive"
|
||||
export const categoriesParam = parseAsArrayOf(parseAsString, ",").withDefault(
|
||||
[],
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,3 +1,47 @@
|
|||
const PROXY_LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "::1"])
|
||||
|
||||
/** Reconstruct the browser-facing URL when running behind portless (or similar). */
|
||||
export function getPublicRequestUrl(request: Request): URL {
|
||||
const internal = new URL(request.url)
|
||||
const forwardedHost = request.headers
|
||||
.get("x-forwarded-host")
|
||||
?.split(",")[0]
|
||||
?.trim()
|
||||
if (forwardedHost) {
|
||||
const proto = request.headers.get("x-forwarded-proto") || "https"
|
||||
return new URL(
|
||||
`${proto}://${forwardedHost}${internal.pathname}${internal.search}`,
|
||||
)
|
||||
}
|
||||
const portlessUrl = process.env.PORTLESS_URL
|
||||
if (portlessUrl) {
|
||||
try {
|
||||
const base = new URL(portlessUrl)
|
||||
return new URL(`${base.origin}${internal.pathname}${internal.search}`)
|
||||
} catch {}
|
||||
}
|
||||
return internal
|
||||
}
|
||||
|
||||
/** Map portless proxy localhost redirects back to the current public origin. */
|
||||
export function resolveAuthRedirectUrl(
|
||||
redirectUrl: string | null,
|
||||
origin: string,
|
||||
): URL {
|
||||
const fallback = new URL(origin)
|
||||
if (!redirectUrl) return fallback
|
||||
try {
|
||||
const target = new URL(redirectUrl)
|
||||
if (PROXY_LOCAL_HOSTS.has(target.hostname)) {
|
||||
return new URL(`${target.pathname}${target.search}`, origin)
|
||||
}
|
||||
if (target.origin === origin) return target
|
||||
return fallback
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates if a string is a valid URL.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -1,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).*)",
|
||||
],
|
||||
}
|
||||
|
|
|
|||
BIN
apps/web/public/android-chrome-192x192.png
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
apps/web/public/android-chrome-512x512.png
Normal file
|
After Width: | Height: | Size: 181 KiB |
BIN
apps/web/public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
apps/web/public/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 801 B |
BIN
apps/web/public/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 2 KiB |
BIN
apps/web/public/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
19
apps/web/public/site.webmanifest
Normal 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"
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
"use client";
|
||||
"use client"
|
||||
|
||||
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
|
||||
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
|
||||
|
||||
function Collapsible({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
|
||||
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />;
|
||||
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
|
||||
}
|
||||
|
||||
function CollapsibleTrigger({
|
||||
|
|
@ -16,7 +16,7 @@ function CollapsibleTrigger({
|
|||
data-slot="collapsible-trigger"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function CollapsibleContent({
|
||||
|
|
@ -27,7 +27,7 @@ function CollapsibleContent({
|
|||
data-slot="collapsible-content"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
||||
|
|
|
|||
|
|
@ -1,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>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||