diff --git a/apps/docs/docs.json b/apps/docs/docs.json index 1e29f968..873fd3fe 100644 --- a/apps/docs/docs.json +++ b/apps/docs/docs.json @@ -213,6 +213,7 @@ "integrations/openclaw", "integrations/claude-code", "integrations/opencode", + "integrations/codex", "integrations/hermes" ] } diff --git a/apps/docs/install.md b/apps/docs/install.md index 78830cc5..2968ea08 100644 --- a/apps/docs/install.md +++ b/apps/docs/install.md @@ -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"}' ``` diff --git a/apps/docs/integrations/codex.mdx b/apps/docs/integrations/codex.mdx new file mode 100644 index 00000000..46eef945 --- /dev/null +++ b/apps/docs/integrations/codex.mdx @@ -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: + + + + ```bash + echo 'export SUPERMEMORY_CODEX_API_KEY="sm_..."' >> ~/.zshrc + source ~/.zshrc + ``` + + + ```bash + echo 'export SUPERMEMORY_CODEX_API_KEY="sm_..."' >> ~/.bashrc + source ~/.bashrc + ``` + + + ```powershell + [System.Environment]::SetEnvironmentVariable("SUPERMEMORY_CODEX_API_KEY", "sm_...", "User") + ``` + Restart your terminal after running this. + + + +## 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 `...` 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 + + + + Source code, issues, and detailed README. + + + + Memory plugin for Claude Code. + + diff --git a/apps/docs/vibe-coding.mdx b/apps/docs/vibe-coding.mdx index def08fdf..0899692c 100644 --- a/apps/docs/vibe-coding.mdx +++ b/apps/docs/vibe-coding.mdx @@ -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: diff --git a/apps/mcp/src/client.ts b/apps/mcp/src/client.ts index ace65912..8fdb6748 100644 --- a/apps/mcp/src/client.ts +++ b/apps/mcp/src/client.ts @@ -329,7 +329,7 @@ export class SupermemoryClient { async getDocuments( containerTags?: string[], page = 1, - limit = 200, + limit = 10, ): Promise { try { const response = await fetch(`${this.apiUrl}/v3/documents/documents`, { diff --git a/apps/mcp/src/server.ts b/apps/mcp/src/server.ts index 682e75c1..510c1481 100644 --- a/apps/mcp/src/server.ts +++ b/apps/mcp/src/server.ts @@ -318,7 +318,7 @@ export class SupermemoryMCP extends McpAgent { ? [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 { 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: { diff --git a/apps/web/app/(app)/layout.tsx b/apps/web/app/(app)/layout.tsx index 542c0a65..b2a27e07 100644 --- a/apps/web/app/(app)/layout.tsx +++ b/apps/web/app/(app)/layout.tsx @@ -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 ( <> - {children} + ) } diff --git a/apps/web/app/(app)/page.tsx b/apps/web/app/(app)/page.tsx index 24e986d7..c8eb58bd 100644 --- a/apps/web/app/(app)/page.tsx +++ b/apps/web/app/(app)/page.tsx @@ -159,6 +159,9 @@ export default function NewPage() { const [fullscreenInitialContent, setFullscreenInitialContent] = useState("") const [queuedChatSeed, setQueuedChatSeed] = useState(null) const [queuedChatModel, setQueuedChatModel] = useState(null) + const [queuedChatProject, setQueuedChatProject] = useState( + 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 => { - 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 && ( - <> +
-
+
- +
)} {!session && viewMode === "mcp" ? ( @@ -623,7 +630,7 @@ export default function NewPage() { onConsumeQueuedMessage={consumeQueuedChat} queuedMessageSource={queuedMessageSource} initialSelectedModel={queuedChatModel} - emptyStateSuggestions={highlightsData?.questions} + initialChatProject={queuedChatProject} />
) : viewMode === "integrations" ? ( diff --git a/apps/web/app/(app)/settings/integrations/page.tsx b/apps/web/app/(app)/settings/integrations/page.tsx new file mode 100644 index 00000000..3984fb52 --- /dev/null +++ b/apps/web/app/(app)/settings/integrations/page.tsx @@ -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 +} diff --git a/apps/web/app/(app)/settings/page.tsx b/apps/web/app/(app)/settings/page.tsx index ddf8a524..f5d8c333 100644 --- a/apps/web/app/(app)/settings/page.tsx +++ b/apps/web/app/(app)/settings/page.tsx @@ -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: ( - - ), + label: "Account", + description: "Your profile and organization", + icon: , + }, + { + id: "billing", + label: "Billing", + description: "Plan, usage and payments", + icon: , }, { id: "integrations", label: "Integrations", - description: "Save, sync and search memories across tools", - icon: , + description: "Save, sync and search across tools", + icon: , }, { id: "connections", label: "Connections & MCP", - description: "Sync with Google Drive, Notion, OneDrive and MCP client", - icon: ( - - ), + description: "Drive, Notion, OneDrive, MCP", + icon: , }, { id: "support", label: "Support & Help", - description: "Find answers or share feedback. We're here to help.", - icon: ( - - ), + description: "Get help or share feedback", + icon: , }, ] -const DANGER_ITEMS: DangerItem[] = [ - { - id: "logout", - label: "Log out", - description: "Sign out of your account on this device", - icon: , - color: "neutral", - }, - { - id: "reset", - label: "Reset data", - description: "Erase all memories, connections and spaces", - icon: , - color: "amber", - }, - { - id: "delete", - label: "Delete account", - description: "Permanently delete your account and all data", - icon: , - 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 = { + 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 ( - - -
-

- {name.split(" ")[0]}'s -

-

- supermemory -

+ {PLAN_DISPLAY_NAMES[plan]} + + ) +} + +function resolveOrgPlan( + orgId: string, + isCurrent: boolean, + currentPlan: PlanType, + planByOrgId: Map, +): PlanType { + const fromSummary = planByOrgId.get(orgId) + if (fromSummary) return fromSummary + if (isCurrent) return currentPlan + return "free" +} + +function SectionLabel({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ) +} + +function IdentityCard({ displayName }: { displayName: string }) { + const firstName = displayName?.split(" ")[0] || "" + + return ( +
+ +
+ +
+

+ {firstName ? `${firstName}'s` : "Your"} +

+

+ supermemory +

+
- +
) } export default function SettingsPage() { - const { user, org } = useAuth() + const { user, org, organizations, setActiveOrg } = useAuth() const [activeTab, setActiveTab] = useState("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(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() + 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 ( -
-
+
+
- -
-
-
-
- {!isMobile && ( - + {!isMobile && ( + { + if (canSwitchOrg) setOrgSwitcherOpen(open) + }} + > + + + + - - - + {[...(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 ( + + ) + })} + + + )} + +
+
+ +
+
+ {/* Left rail */} +
-
+ + + {/* Content */} +
{activeTab === "account" && } + {activeTab === "billing" && } {activeTab === "integrations" && } {activeTab === "connections" && } {activeTab === "support" && } -
+
diff --git a/apps/web/app/(auth)/login/new/page.tsx b/apps/web/app/(auth)/login/new/page.tsx index 82d0fd48..6272cd96 100644 --- a/apps/web/app/(auth)/login/new/page.tsx +++ b/apps/web/app/(auth)/login/new/page.tsx @@ -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): 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() diff --git a/apps/web/app/icon.png b/apps/web/app/icon.png index 549d267d..29d7e45a 100644 Binary files a/apps/web/app/icon.png and b/apps/web/app/icon.png differ diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 3a2af064..5218f989 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -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<{ diff --git a/apps/web/app/manifest.ts b/apps/web/app/manifest.ts index 01381381..b618ff25 100644 --- a/apps/web/app/manifest.ts +++ b/apps/web/app/manifest.ts @@ -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", + }, ], } } diff --git a/apps/web/components/add-document/connections.tsx b/apps/web/components/add-document/connections.tsx index 4c455c63..7aa0250d 100644 --- a/apps/web/components/add-document/connections.tsx +++ b/apps/web/components/add-document/connections.tsx @@ -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({ )} >
-
+
-
-
+
+
{config.title} @@ -156,41 +157,54 @@ function ConnectionRow({ />
{connection.email || "Unknown"}
-
- +
+ {expired ? ( + + ) : ( + + )}
diff --git a/apps/web/components/add-document/file.tsx b/apps/web/components/add-document/file.tsx index b9212bc3..92e56751 100644 --- a/apps/web/components/add-document/file.tsx +++ b/apps/web/components/add-document/file.tsx @@ -187,7 +187,12 @@ export function FileContent({ const hasItems = data.items.length > 0 return ( -
+

Upload files (images, PDF, documents, sheets, markdown) diff --git a/apps/web/components/add-document/index.tsx b/apps/web/components/add-document/index.tsx index fe74f61a..cc308f90 100644 --- a/apps/web/components/add-document/index.tsx +++ b/apps/web/components/add-document/index.tsx @@ -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 ( + !open && onClose()} + shouldScaleBackground + > + 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(), + )} + > + Add Document +

+ +
+ + + ) + } + return ( !open && onClose()}> { if (!isOpen) { setFileData({ items: [], title: "", description: "" }) + setNoteContent("") + setLinkData({ url: "", title: "", description: "" }) } }, [isOpen]) @@ -257,112 +286,56 @@ export function AddDocument({ return (
-
- {isMobile && ( -
-
-

- Add memory -

-

- Save something to recall later -

-
- -
- )} -
- {tabs.map((tab) => ( - setActiveTab(tab.id)} - icon={tab.icon} - title={tab.title} - description={tab.description} - isPro={tab.isPro} - compact={isMobile} - /> - ))} -
- - {isMobile && ( -
-
- - Plan usage - - - {isLoadingUsage - ? "…" - : `${planUsagePct < 1 && planUsagePct > 0 ? "< 1" : Math.round(planUsagePct)}% used`} - -
-
-
80 - ? "#ef4444" - : hasPaidPlan - ? "linear-gradient(to right, #4BA0FA 80%, #002757 100%)" - : "#0054AD", - }} - title={`${formatUsageNumber(tokensUsed)} tokens · ${formatUsageNumber(searchesUsed)} queries`} - /> -
- {!isLoadingUsage && ( -

- {formatUsageNumber(tokensUsed)} tokens ·{" "} - {formatUsageNumber(searchesUsed)} queries -

+ {isMobile && !hasPaidPlan && ( +
+ +
+ )} + {!isMobile && ( +
+
+ {tabs.map((tab) => ( + setActiveTab(tab.id)} + icon={tab.icon} + title={tab.title} + compactLabel={tab.compactLabel} + description={tab.description} + isPro={tab.isPro} + /> + ))}
- )} - {!isMobile && (
@@ -463,13 +436,13 @@ export function AddDocument({ )}
- )} -
+
+ )}
@@ -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({
+ {isMobile && ( +
+ {tabs.map((tab) => ( + setActiveTab(tab.id)} + icon={tab.icon} + title={tab.title} + compactLabel={tab.compactLabel} + description={tab.description} + isPro={tab.isPro} + compact + /> + ))} +
+ )} {!isMobile && ( - + {!isMobile && ( + + )} {activeTab !== "connect" && ( ) diff --git a/apps/web/components/add-document/link.tsx b/apps/web/components/add-document/link.tsx index 93e8821a..f2c7bc16 100644 --- a/apps/web/components/add-document/link.tsx +++ b/apps/web/components/add-document/link.tsx @@ -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(undefined) + const [url, setUrl] = useState(initialData?.url ?? "") + const [title, setTitle] = useState(initialData?.title ?? "") + const [description, setDescription] = useState(initialData?.description ?? "") + const [image, setImage] = useState(initialData?.image) const [isPreviewLoading, setIsPreviewLoading] = useState(false) const canSubmit = url.trim().length > 0 && !isSubmitting @@ -148,7 +150,12 @@ export function LinkContent({ }, [isOpen, onDataChange]) return ( -
+

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 (

void + suggestions?: string[] + subtitle?: string +}) { + const prompts = suggestions.slice(0, 3) + + return ( +
+
+
+ +

+ Nova knows you. +

+ {subtitle ? ( +

+ {subtitle} +

+ ) : null} +
+ +
+

+ Try asking +

+
+ {prompts.map((prompt) => ( + + ))} +
+
+
+
+ ) +} diff --git a/apps/web/components/chat/chat-graph-context-rail.tsx b/apps/web/components/chat/chat-graph-context-rail.tsx index 5e712cf7..cb83141b 100644 --- a/apps/web/components/chat/chat-graph-context-rail.tsx +++ b/apps/web/components/chat/chat-graph-context-rail.tsx @@ -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({
0} diff --git a/apps/web/components/chat/home-chat-composer.tsx b/apps/web/components/chat/home-chat-composer.tsx index 00179658..9e50fe33 100644 --- a/apps/web/components/chat/home-chat-composer.tsx +++ b/apps/web/components/chat/home-chat-composer.tsx @@ -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("gemini-2.5-pro") const { selectedProject } = useProject() - const { allProjects } = useContainerTags() - const chatSpaceLabel = useMemo( - () => - getChatSpaceDisplayLabel({ - selectedProject, - allProjects, - }), - [selectedProject, allProjects], - ) + const [chatSpaceProjects, setChatSpaceProjects] = useState([ + 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 (
-
+
setInput(e.target.value)} @@ -62,17 +54,13 @@ export function HomeChatComposer({ onModelChange={setSelectedModel} minimal /> -
- - {chatSpaceLabel} - -
+ } /> diff --git a/apps/web/components/chat/index.tsx b/apps/web/components/chat/index.tsx index cc0ea763..19103410 100644 --- a/apps/web/components/chat/index.tsx +++ b/apps/web/components/chat/index.tsx @@ -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 ( -
-
- - -
-
-

Ask me anything about your memories…

-
- {suggestions.map((suggestion) => ( - - ))} -
-
-
- ) -} +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(null) const isScrolledToBottomRef = useRef(true) + const userJustSentRef = useRef(false) const sentQueuedMessageRef = useRef(null) const pendingHighlightReplyRef = useRef(null) const awaitingHighlightInjectionRef = useRef(false) const pendingHighlightMessageRef = useRef(null) const targetHighlightChatIdRef = useRef(null) const { selectedProject } = useProject() + const [chatSpaceProjects, setChatSpaceProjects] = useState([ + 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 => { + 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 = ( button]:text-[#FAFAFA]", dmSansClassName(), )} @@ -908,123 +939,115 @@ export function ChatSidebar({ selectedModel={selectedModel} onModelChange={handleModelChange} /> -
- - {chatSpaceLabel} - -
+ )}
{chatToolbarActions}
) : null} -
- {isInputExpanded && ( -
- )} - {messages.length === 0 && ( - { - analytics.chatSuggestedQuestionClicked() - analytics.chatMessageSent({ source: "suggested" }) - sendMessage({ text: suggestion }) - }} - suggestions={emptyStateSuggestions} - /> - )} +
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 -
- message.role === "assistant" && setHoveredMessageId(message.id) - } - onMouseLeave={() => - message.role === "assistant" && setHoveredMessageId(null) - } - > - {message.role === "user" ? ( - - ) : ( - - )} -
- ))} - {(status === "submitted" || status === "streaming") && ( -
- -
+ ref={messagesContainerRef} + className={cn( + "relative h-full overflow-y-auto scrollbar-thin", + "px-4", + dmSansClassName(), )} -
-
- - {!isScrolledToBottom && messages.length > 0 && ( -
- + {messages.map((message, index) => ( + // biome-ignore lint/a11y/noStaticElementInteractions: Hover detection for message actions +
+ message.role === "assistant" && + setHoveredMessageId(message.id) + } + onMouseLeave={() => + message.role === "assistant" && setHoveredMessageId(null) + } + > + {message.role === "user" ? ( + + ) : ( + + )} +
+ ))} + {(status === "submitted" || status === "streaming") && ( +
+ +
+ )} +
- )} + + {!isScrolledToBottom && messages.length > 0 && ( +
+ +
+ )} +
{chatStreamError && (
)} -
+
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 ? : null @@ -1105,17 +1135,13 @@ export function ChatSidebar({ onModelChange={handleModelChange} minimal /> -
- - {chatSpaceLabel} - -
+ ) : 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 ? (
- -
+ +
{pageDesktopToolbarRow} -
+
{shell}
@@ -1181,3 +1212,4 @@ export function ChatSidebar({ } export { HomeChatComposer } from "./home-chat-composer" +export { ChatEmptyStatePlaceholder } from "./chat-empty-state" diff --git a/apps/web/components/chat/input/index.tsx b/apps/web/components/chat/input/index.tsx index b1ee1109..d24276d0 100644 --- a/apps/web/components/chat/input/index.tsx +++ b/apps/web/components/chat/input/index.tsx @@ -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(null) + useEffect(() => { + if (!showStatusStrip && isExpanded) { + setIsExpanded(false) + onExpandedChange?.(false) + } + }, [isExpanded, onExpandedChange, showStatusStrip]) + const handleChange = (e: React.ChangeEvent) => { 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={{ diff --git a/apps/web/components/chat/model-selector.tsx b/apps/web/components/chat/model-selector.tsx index 74f3a1ad..a9dd2d45 100644 --- a/apps/web/components/chat/model-selector.tsx +++ b/apps/web/components/chat/model-selector.tsx @@ -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("claude-sonnet-4.6") const [isOpen, setIsOpen] = useState(false) + const containerRef = useRef(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 ( -
+
{trigger} {isOpen && ( - <> - - ) - })} -
+
+
+ {models.map((model) => { + const modelData = modelNames[model.id] + return ( + + ) + })}
- +
)}
) diff --git a/apps/web/components/dashboard-view.tsx b/apps/web/components/dashboard-view.tsx index 295c8f26..123f2aa6 100644 --- a/apps/web/components/dashboard-view.tsx +++ b/apps/web/components/dashboard-view.tsx @@ -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>> = { developer: { mcp: "Ask Claude about your saved docs and specs from any IDE", @@ -121,108 +141,108 @@ export type MemoryOfDay = { const TIPS: Record = { 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 = { mcp: hasMcp, @@ -850,7 +870,7 @@ function RecommendedPluginsCard({ ) : suggestions.length === 0 ? (

- You're all set ✓ + You're all set ✓

) : ( @@ -873,7 +893,7 @@ function RecommendedPluginsCard({

- {plugin.cta} → + {plugin.cta} → @@ -888,7 +908,7 @@ function RecommendedPluginsCard({ {PROFESSION_LABELS.find( (p) => p.value === profession, )?.label.toLowerCase()} - ? Change → + ? Change → )} @@ -926,7 +946,7 @@ function MemoryOfDayCard({ data }: { data: MemoryOfDay }) {
- View memories → + View memories → ) @@ -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 = { 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 => { 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 (
Home

-

- {spaceLabel} +

+ {homeHeadline}

{totalMemories > 0 && ( @@ -1258,7 +1301,7 @@ export function DashboardView({ )} - {/* Daily Brief — hero */} + {/* Daily Brief — hero */} - {/* Actions + connection status — single unified row */} + {/* Actions + connection status — single unified row */} {/* Quick actions */} -
+
- · + · - · + ·
@@ -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 */} -
-
-

- Recents -

-
-
-

- Suggested for you -

-
-
+
+
+

+ Recents +

+
+
+

+ Suggested for you +

+
+
- {/* Content row */} -
-
    +
    +
    + {isRecentsLoading ? ( +
      + {[ + "recent-skeleton-1", + "recent-skeleton-2", + "recent-skeleton-3", + ].map((skeletonKey) => ( +
    • +
      +
      +
    • + ))} +
    + ) : recents.length > 0 || recentToolUsageItems.length > 0 ? ( +
      {recentToolUsageItems.map((item) => ( + ) : ( +

      + No recently saved +

      + )} +
    -
    - -
    +
    + +
    +
    + + {(isRecentsLoading || recents.length === 0) && ( +
    +

    + Suggested for you +

    +
    +
    - - ) : ( - /* No recents yet — show suggestions and tool usage */ - <> -
    -
    -

    - Suggested for you -

    -
    -
    -
    -
    - -
    -
    - +
    )}
diff --git a/apps/web/components/document-modal/content/index.tsx b/apps/web/components/document-modal/content/index.tsx index b3df9fe7..81f29b29 100644 --- a/apps/web/components/document-modal/content/index.tsx +++ b/apps/web/components/document-modal/content/index.tsx @@ -103,7 +103,7 @@ export function DocumentContent({ return case "pdf": - return + return case "notion": return diff --git a/apps/web/components/document-modal/content/pdf.tsx b/apps/web/components/document-modal/content/pdf.tsx index d096b767..e633db86 100644 --- a/apps/web/components/document-modal/content/pdf.tsx +++ b/apps/web/components/document-modal/content/pdf.tsx @@ -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(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) @@ -70,7 +86,7 @@ export function PdfViewer({ url }: PdfViewerProps) { >( new Set(), ) @@ -263,7 +265,10 @@ export function GraphListMemories({
diff --git a/apps/web/components/header.tsx b/apps/web/components/header.tsx index ed8f6a11..3c1706e4 100644 --- a/apps/web/components/header.tsx +++ b/apps/web/components/header.tsx @@ -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" ? [ diff --git a/apps/web/components/integrations/raycast-detail.tsx b/apps/web/components/integrations/raycast-detail.tsx index 94769cb5..7557471c 100644 --- a/apps/web/components/integrations/raycast-detail.tsx +++ b/apps/web/components/integrations/raycast-detail.tsx @@ -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() {
- { + onOpenChange={(open) => { setShowModal(open) - if (!open) { - setApiKey("") - setCopied(false) - } + if (!open) setApiKey("") }} - > - - - - - Setup Raycast Extension - - -
-
- -
- - -
-
-
-

- Follow these steps: -

-
- {[ - "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) => ( -
-
- {i + 1} -
-

- {text} -

-
- ))} -
-
- -
-
-
-
+ apiKey={apiKey} + /> ) } diff --git a/apps/web/components/integrations/raycast-setup-modal.tsx b/apps/web/components/integrations/raycast-setup-modal.tsx new file mode 100644 index 00000000..3d4a2a66 --- /dev/null +++ b/apps/web/components/integrations/raycast-setup-modal.tsx @@ -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 ( +
+ +
+ ) +} + +export function RaycastSetupModal({ + open, + onOpenChange, + apiKey, +}: { + open: boolean + onOpenChange: (open: boolean) => void + apiKey: string +}) { + return ( + + + Set up Raycast Extension + +
+ +
+

+ Set up Raycast Extension +

+

+ Copy your key and follow these steps to finish. +

+
+ + + +
+ +
+
+ +
+
+ +
+ +
+
+
+ ) +} diff --git a/apps/web/components/memory-graph/hooks/use-graph-api.ts b/apps/web/components/memory-graph/hooks/use-graph-api.ts index 94c08e33..7991f600 100644 --- a/apps/web/components/memory-graph/hooks/use-graph-api.ts +++ b/apps/web/components/memory-graph/hooks/use-graph-api.ts @@ -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({ - 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 diff --git a/apps/web/components/memory-graph/memory-graph-wrapper.tsx b/apps/web/components/memory-graph/memory-graph-wrapper.tsx index ae2bf327..0b2ca826 100644 --- a/apps/web/components/memory-graph/memory-graph-wrapper.tsx +++ b/apps/web/components/memory-graph/memory-graph-wrapper.tsx @@ -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, }) diff --git a/apps/web/components/mobile-banner.tsx b/apps/web/components/mobile-banner.tsx deleted file mode 100644 index 245b6952..00000000 --- a/apps/web/components/mobile-banner.tsx +++ /dev/null @@ -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 ( -
- 🚧 Mobile responsive in development. Desktop recommended. -
- ) -} diff --git a/apps/web/components/nova/auto-space-icon.tsx b/apps/web/components/nova/auto-space-icon.tsx new file mode 100644 index 00000000..88c53b45 --- /dev/null +++ b/apps/web/components/nova/auto-space-icon.tsx @@ -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 ( + + + + + + + ) +} diff --git a/apps/web/components/nova/nova-empty-state.tsx b/apps/web/components/nova/nova-empty-state.tsx index d336f5f3..46a79e60 100644 --- a/apps/web/components/nova/nova-empty-state.tsx +++ b/apps/web/components/nova/nova-empty-state.tsx @@ -39,7 +39,7 @@ export function NovaEmptyState({ return (
@@ -119,7 +119,7 @@ export function NovaEmptyState({ )} -
+
+
+ +
+
+ {BENEFITS.map((text) => ( +
+ + + {text} + +
+ ))} +
+ +
+ +
    + {steps.map((step, i) => ( +
  1. +
    + + {i + 1} + + {i < steps.length - 1 && ( + + )} +
    +
    +

    + {step.title} +

    +

    + {step.description} +

    +
    +
  2. + ))} +
+
+ + +
+ + + ) +} + +function ShareIcon({ className }: { className?: string }) { + return ( + + ) +} + +function MoreVertIcon({ className }: { className?: string }) { + return ( + + ) +} + +interface BeforeInstallPromptEvent extends Event { + prompt(): Promise + userChoice: Promise<{ outcome: "accepted" | "dismissed" }> +} + +declare global { + interface WindowEventMap { + beforeinstallprompt: BeforeInstallPromptEvent + } +} diff --git a/apps/web/components/select-spaces-modal.tsx b/apps/web/components/select-spaces-modal.tsx index e8fff2fa..5fde6122 100644 --- a/apps/web/components/select-spaces-modal.tsx +++ b/apps/web/components/select-spaces-modal.tsx @@ -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>(new Set()) + const [lastBulkDeleteTag, setLastBulkDeleteTag] = useState( + null, + ) + const [editingProject, setEditingProject] = useState<{ + id: string + containerTag: string + originalName: string + name: string + } | null>(null) + const editInputRef = useRef(null) + const editingContainerTag = editingProject?.containerTag const currentSelection = selectedProjects[0] ?? "" const pluginTags = useMemo( @@ -174,6 +204,7 @@ export function SelectSpacesModal({ const defaultCategory = useMemo(() => { 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(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( 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 | 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) => { + 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(() => { 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, + ) => { + if (isEditing) return + if (isBulkDeleteMode) { + if (canBulkDelete) { + toggleBulkDeleteTag(project.containerTag, e.shiftKey) + } + return + } + handleSelect(project.containerTag) + } + return ( +
+ + {isEditing ? ( +
+ {project.emoji || "📁"} + + 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" + /> + + +
+ ) : ( + + )} + {canEdit && !isEditing && !isBulkDeleteMode && ( + + )} + {enableDelete && + !isDefault && + !isEditing && + !isBulkDeleteMode && + onDeleteRequest && ( + + )} +
+ ) + }, + [ + 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 (
+
+ {isSelected &&
} +
- {enableDelete && !isDefault && onDeleteRequest && ( - - )}
) - } + }, [currentSelection, handleSelectAuto]) return (

- Filter your memories by space + {isBulkDeleteMode + ? "Choose spaces to permanently delete" + : "Filter your memories by space"}

- - - Close - +
+ {enableDelete && onBulkDeleteRequest && !activeDiscoverId && ( + + )} + + + Close + +
-
-
-
+
+
+
{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 && ( <> -
+
Discover
{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({
-
+
{activeCategory.startsWith("discover:") ? ( - 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({ />
-
+
{filteredProjects.length === 0 ? (

No spaces found

) : (
+ {showAutoRow && ( + <> +
+ Mode +
+ {renderAutoRow()} +
+ + )} {recentProjects.length > 0 && ( <>
@@ -678,21 +1067,67 @@ export function SelectSpacesModal({
- {showNewSpace && - onNewSpace && - !activeCategory.startsWith("discover:") && ( -
- + {!activeCategory.startsWith("discover:") && + (isBulkDeleteMode || (showNewSpace && onNewSpace)) && ( +
+ {isBulkDeleteMode ? ( + <> +

+ {bulkDeleteCount === 0 + ? "No spaces selected" + : `${bulkDeleteCount} ${ + bulkDeleteCount === 1 ? "space" : "spaces" + } selected`} +

+
+ + +
+ + ) : ( + <> + + {showNewSpace && onNewSpace && ( + + )} + + )}
)} @@ -740,7 +1175,7 @@ function DiscoverPanel({ const isConnected = !!newKey return ( -
+
= { + 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 ( -
-
-

- {name} -

- {highlight && ( - - RECOMMENDED - - )} -
- -
- - {price} - - {period && ( - - {period} - - )} -
- -

- {description} -

- -
-
-

- {credits} -

-

- of usage included -

-
-
- -
    - {features.map((text) => ( -
  • - - {text} -
  • - ))} -
-
+ + {PLAN_DISPLAY_NAMES[plan]} + ) } -function formatOrgRole(role: string): string { - const r = role.toLowerCase() - if (r === "owner") return "Owner" - if (r === "admin") return "Admin" - if (r === "member") return "Member" - return role - ? role.charAt(0).toUpperCase() + role.slice(1).toLowerCase() - : "Member" +const ROLE_LABELS: Record = { + owner: "Owner", + admin: "Admin", + member: "Member", +} + +function formatRole(role: string): string { + const r = role?.toLowerCase() ?? "" + if (ROLE_LABELS[r]) return ROLE_LABELS[r] + return r ? r.charAt(0).toUpperCase() + r.slice(1) : "Member" +} + +function RolePill({ role }: { role: string }) { + const r = role?.toLowerCase() ?? "" + const isOwner = r === "owner" + return ( + + {formatRole(role)} + + ) +} + +function resolveOrgPlan( + orgId: string, + isCurrent: boolean, + currentPlan: PlanType, + planByOrgId: Map, +): PlanType { + const fromSummary = planByOrgId.get(orgId) + if (fromSummary) return fromSummary + if (isCurrent) return currentPlan + return "free" } export default function Account() { - const { - user, - org, - organizations: allOrgs, - setActiveOrg, - clearActiveOrg, - } = useAuth() + const { user, org, organizations: allOrgs, setActiveOrg } = useAuth() const autumn = useCustomer() - const [isUpgrading, setIsUpgrading] = useState(false) - const [isCancelling, setIsCancelling] = useState(false) - const [isCancelDialogOpen, setIsCancelDialogOpen] = useState(false) - const [emailConfirm, setEmailConfirm] = useState("") - const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false) - const [isClosingAccount, setIsClosingAccount] = useState(false) const [switchingOrgId, setSwitchingOrgId] = useState(null) const [orgMenuOpen, setOrgMenuOpen] = useState(false) const canSwitchOrg = (allOrgs?.length ?? 0) > 1 - const { data: memberships, isPending: membershipsPending } = - useAccountMemberships() - - const sortedMemberships = useMemo(() => { - if (!memberships?.length) return [] - return [...memberships].sort((a, b) => a.name.localeCompare(b.name)) - }, [memberships]) - - const ownedOrgs = useMemo( - () => memberships?.filter((m) => m.role === "owner") ?? [], - [memberships], - ) - - const hasOwnedOrgWithTeammates = useMemo( - () => ownedOrgs.some((m) => m.memberCount > 1), - [ownedOrgs], - ) - - const showMembershipsOverview = - !membershipsPending && - (sortedMemberships.length > 1 || hasOwnedOrgWithTeammates) - - const deleteUserAccount = useDeleteUserAccount() - - const emailMatches = user?.email - ? emailConfirm.trim().toLowerCase() === user.email.trim().toLowerCase() - : false + const { data: orgSummaries } = useOrgSummaries() const handleOrgSwitch = async (orgSlug: string, orgId: string) => { if (orgId === org?.id) return @@ -245,103 +125,37 @@ export default function Account() { } } - const { - usdIncluded, - usdSpent, - planUsagePct, - currentPlan, - hasPaidPlan, - isLoading: isCheckingStatus, - daysRemaining, - } = useTokenUsage(autumn) + const { currentPlan } = useTokenUsage(autumn) - const formatUsd = (n: number) => - n.toLocaleString(undefined, { - minimumFractionDigits: 2, - maximumFractionDigits: 2, - }) - - const planDisplayNames: Record = { - free: "Free", - pro: "Pro", - scale: "Scale", - enterprise: "Enterprise", - } - - // Handlers - const handleUpgrade = 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) + const planByOrgId = useMemo(() => { + const map = new Map() + for (const summary of orgSummaries ?? []) { + map.set(summary.orgId, summary.plan) } - } + return map + }, [orgSummaries]) - // Enterprise is contract-based — direct those users to the portal/sales. - const cancellablePlanId = - currentPlan === "pro" || currentPlan === "scale" - ? (`api_${currentPlan}` as const) - : null - - const handleCancelSubscription = async () => { - if (!cancellablePlanId) return - setIsCancelling(true) - try { - await autumn.updateSubscription({ - planId: cancellablePlanId, - cancelAction: "cancel_end_of_cycle", - }) - autumn.refetch?.() - setIsCancelDialogOpen(false) - toast.success( - `Subscription cancelled. ${planDisplayNames[currentPlan]} features remain active until the end of your billing period.`, + const sortedOrgsForMenu = useMemo(() => { + if (!allOrgs?.length) return [] + return [...allOrgs].sort((a, b) => { + const planA = resolveOrgPlan( + a.id, + a.id === org?.id, + currentPlan, + planByOrgId, ) - } catch (error) { - console.error(error) - toast.error("Failed to cancel subscription. Please try again.") - } finally { - setIsCancelling(false) - } - } + const planB = resolveOrgPlan( + b.id, + b.id === org?.id, + currentPlan, + planByOrgId, + ) + const rankDiff = PLAN_RANK[planB] - PLAN_RANK[planA] + if (rankDiff !== 0) return rankDiff + return a.name.localeCompare(b.name) + }) + }, [allOrgs, org?.id, currentPlan, planByOrgId]) - const handleDeleteAccount = async () => { - if (!user?.email || !emailMatches || membershipsPending) return - setIsClosingAccount(true) - try { - await deleteUserAccount.mutateAsync({ - confirmation: user.email, - }) - clearActiveOrg() - try { - await authClient.signOut() - } catch { - window.location.assign("/login/new") - return - } - setIsDeleteDialogOpen(false) - setEmailConfirm("") - window.location.assign("/login/new") - } catch (e) { - const msg = e instanceof Error ? e.message : "Something went wrong" - toast.error(msg) - } finally { - setIsClosingAccount(false) - } - } - - // Format member since date const memberSince = user?.createdAt ? new Date(user.createdAt).toLocaleDateString("en-US", { month: "short", @@ -350,7 +164,7 @@ export default function Account() { : "—" return ( -
+
Profile Details @@ -430,11 +244,17 @@ export default function Account() { {canSwitchOrg && ( - {allOrgs?.map((organization) => { + {sortedOrgsForMenu.map((organization) => { const isCurrent = organization.id === org?.id const isSwitching = switchingOrgId === organization.id + const plan = resolveOrgPlan( + organization.id, + isCurrent, + currentPlan, + planByOrgId, + ) return ( ) })} @@ -497,628 +317,117 @@ export default function Account() {
-
- Billing & Subscription - -
- {hasPaidPlan ? ( - <> -
-
-

- {planDisplayNames[currentPlan]} plan -

- - ACTIVE - -
-

- Expanded memory with connections and more -

-
- - {/* Plan usage (unified) */} -
-
-

- Plan usage -

- - {planUsagePct < 1 && planUsagePct > 0 - ? "< 1" - : Math.round(planUsagePct)} - % used - -
-
-
80 - ? "#ef4444" - : "linear-gradient(to right, #4BA0FA 80%, #002757 100%)", - }} - title={`$${formatUsd(usdSpent)} of $${formatUsd(usdIncluded)} used`} - /> -
-

- {daysRemaining !== null - ? `Resets in ${daysRemaining} day${daysRemaining !== 1 ? "s" : ""}` - : ""} -

-
- -
- - {cancellablePlanId && ( - - - - - -
-
-
-

- Cancel {planDisplayNames[currentPlan]}{" "} - subscription? -

-

- 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. -

-
- - - -
- -
- - - - -
-
-
- -
- )} -
- - ) : ( - <> -
-

- Free Plan -

-

- You are on basic plan -

-
- - {/* Plan usage (unified) */} -
-
-

- Plan usage -

-

- {planUsagePct < 1 && planUsagePct > 0 - ? "< 1" - : Math.round(planUsagePct)} - % used -

-
-
-
80 ? "#ef4444" : "#0054AD", - }} - title={`$${formatUsd(usdSpent)} of $${formatUsd(usdIncluded)} used`} - /> -
-

- {daysRemaining !== null - ? `Resets in ${daysRemaining} day${daysRemaining !== 1 ? "s" : ""}` - : ""} -

-
- - - -
- - -
- - )} -
- -
- -
- Delete Account - -
-

+

+ Team members + {(org?.members?.length ?? 0) > 0 && ( + - Permanently delete all your data and cancel any active - subscriptions -

- { - setIsDeleteDialogOpen(open) - if (!open) { - setEmailConfirm("") - } - }} - > - - - - -
- {/* Header */} -
-
-
-

- Delete account? -

-

- This cannot be undone. -

- {hasOwnedOrgWithTeammates && ( -

- You own at least one organization that still has - other members. Those organizations will be deleted - for everyone when you confirm. -

- )} -
- - What happens next? - - -
-

- Your account is locked immediately; data removal - runs in the background. -

-
    -
  • - Removes memories, conversations, and settings; - cancels active subscriptions. -
  • -
  • - Orgs where you're only a member: - you're removed; the org continues. -
  • -
  • Orgs you own: deleted for all members.
  • -
-
-
-
- - - -
- - {showMembershipsOverview && ( -
-

- Your organizations -

-
- {sortedMemberships.map((m) => ( -
-
-

- {m.name} -

- {m.slug ? ( -

- {m.slug} -

- ) : null} -
-
- - {formatOrgRole(m.role)} - - - {m.memberCount} member - {m.memberCount === 1 ? "" : "s"} - -
-
- ))} -
-
- )} - - {/* Confirmation input */} -
-

- Type your account email to confirm: -

-
- setEmailConfirm(e.target.value)} - placeholder={user?.email ?? "you@example.com"} - className={cn( - "w-full px-4 py-3 bg-transparent", - "text-[#FAFAFA] placeholder:text-[#737373]", - "text-[14px] tracking-[-0.14px]", - "outline-none", - dmSans125ClassName(), - )} - /> -
-
-
-
- - {/* Footer */} -
- - - -
+ + {org?.members && org.members.length > 0 ? ( +
    + {[...org.members] + .sort((a, b) => { + const rolePriority = (r: string) => + r === "owner" ? 0 : r === "admin" ? 1 : 2 + const diff = + rolePriority(a.role.toLowerCase()) - + rolePriority(b.role.toLowerCase()) + if (diff !== 0) return diff + return (a.user?.name ?? "").localeCompare(b.user?.name ?? "") + }) + .map((m, idx) => { + const isYou = m.userId === user?.id + const name = m.user?.name ?? m.user?.email ?? "Unknown" + return ( +
  • 0 && "border-t border-white/[0.04]", )} > - {isClosingAccount ? ( - - ) : ( - - )} - {isClosingAccount ? "Deleting…" : "Delete"} -
    - -
    -
- {/* Modal inset highlight */} -
- -
-
+ + + + {(name.charAt(0) || "U").toUpperCase()} + + +
+
+ + {name} + + {isYou && ( + + You + + )} +
+ {m.user?.email && ( + + {m.user.email} + + )} +
+ + + ) + })} + + ) : ( +
+
+ +
+
+ + Just you for now + + + Invite teammates from your organization settings. + +
+
+ )}
diff --git a/apps/web/components/settings/billing.tsx b/apps/web/components/settings/billing.tsx new file mode 100644 index 00000000..aca23307 --- /dev/null +++ b/apps/web/components/settings/billing.tsx @@ -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 ( +

+ {children} +

+ ) +} + +function SettingsCard({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ) +} + +function PlanComparisonCard({ + name, + price, + period, + description, + credits, + features, + highlight, +}: { + name: string + price: string + period: string + description: string + credits: string + features: string[] + highlight: boolean +}) { + return ( +
+
+

+ {name} +

+ {highlight && ( + + RECOMMENDED + + )} +
+ +
+ + {price} + + {period && ( + + {period} + + )} +
+ +

+ {description} +

+ +
+
+

+ {credits} +

+

+ of usage included +

+
+
+ +
    + {features.map((text) => ( +
  • + + {text} +
  • + ))} +
+
+ ) +} + +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 ( +
+
+ Billing & Subscription + +
+ {hasPaidPlan ? ( + <> +
+
+

+ {planDisplayNames[currentPlan]} plan +

+ + ACTIVE + +
+

+ Expanded memory with connections and more +

+
+ +
+
+

+ Plan usage +

+ + {planUsagePct < 1 && planUsagePct > 0 + ? "< 1" + : Math.round(planUsagePct)} + % used + +
+
+
80 + ? "#ef4444" + : "linear-gradient(to right, #4BA0FA 80%, #002757 100%)", + }} + title={`$${formatUsd(usdSpent)} of $${formatUsd(usdIncluded)} used`} + /> +
+

+ {daysRemaining !== null + ? `Resets in ${daysRemaining} day${daysRemaining !== 1 ? "s" : ""}` + : ""} +

+
+ +
+ + {cancellablePlanId && ( + + + + + +
+
+
+

+ Cancel {planDisplayNames[currentPlan]}{" "} + subscription? +

+

+ 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. +

+
+ + + +
+ +
+ + + + +
+
+
+ +
+ )} +
+ + ) : ( + <> +
+

+ Free Plan +

+

+ You are on basic plan +

+
+ +
+
+

+ Plan usage +

+

+ {planUsagePct < 1 && planUsagePct > 0 + ? "< 1" + : Math.round(planUsagePct)} + % used +

+
+
+
80 ? "#ef4444" : "#0054AD", + }} + title={`$${formatUsd(usdSpent)} of $${formatUsd(usdIncluded)} used`} + /> +
+

+ {daysRemaining !== null + ? `Resets in ${daysRemaining} day${daysRemaining !== 1 ? "s" : ""}` + : ""} +

+
+ + + +
+ + +
+ + )} +
+ +
+
+ ) +} diff --git a/apps/web/components/settings/connections-mcp.tsx b/apps/web/components/settings/connections-mcp.tsx index b2cbb389..3f149753 100644 --- a/apps/web/components/settings/connections-mcp.tsx +++ b/apps/web/components/settings/connections-mcp.tsx @@ -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({
- + {expired ? ( + + ) : ( + + )}
- - - - - - Setup Raycast Extension - - - -
-
- -
- - -
-
- -
-

- Follow these steps: -

-
-
-
- 1 -
-

- Install the Raycast extension from the Raycast Store -

-
-
-
- 2 -
-

- Open Raycast preferences and paste your API key -

-
-
-
- 3 -
-

- Use "Add Memory" or "Search Memories" commands! -

-
-
-
- -
- -
-
-
-
-
+ apiKey={raycastApiKey} + />
) } diff --git a/apps/web/components/settings/support.tsx b/apps/web/components/settings/support.tsx index 5868f356..921759ef 100644 --- a/apps/web/components/settings/support.tsx +++ b/apps/web/components/settings/support.tsx @@ -32,7 +32,7 @@ function SectionTitle({ children }: { children: React.ReactNode }) {

{children} @@ -44,7 +44,7 @@ function SupportCard({ children }: { children: React.ReactNode }) { return (

@@ -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 ( -
+
{/* Support & Help Section */}
Support & Help @@ -128,7 +128,7 @@ export default function Support() { reach us.

-
+
Message us on X diff --git a/apps/web/components/settings/sync-history-panel.tsx b/apps/web/components/settings/sync-history-panel.tsx index 04044acc..d8b3f034 100644 --- a/apps/web/components/settings/sync-history-panel.tsx +++ b/apps/web/components/settings/sync-history-panel.tsx @@ -259,7 +259,9 @@ export function SyncHistoryPanel({ {hasRuns && ( <> - +
+ +
)}
diff --git a/apps/web/components/settings/sync-status-badge.tsx b/apps/web/components/settings/sync-status-badge.tsx index c4d64aa5..2959908e 100644 --- a/apps/web/components/settings/sync-status-badge.tsx +++ b/apps/web/components/settings/sync-status-badge.tsx @@ -77,7 +77,7 @@ export function SyncStatusBadge({ "font-medium text-[13px] tracking-[-0.13px] text-[#EF4444]", )} > - Disconnected + Needs reauth )} {status === "idle" && ( diff --git a/apps/web/components/space-selector.tsx b/apps/web/components/space-selector.tsx index bc6ddc2e..60f65c53 100644 --- a/apps/web/components/space-selector.tsx +++ b/apps/web/components/space-selector.tsx @@ -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([]) 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 + 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 ? ( + + ) : displayInfo.isOwnSpace ? ( + + ) : displayInfo.plugin ? ( displayInfo.plugin.iconSrc ? ( )} + {!compact && ( + + )} {compact && ( {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} /> { const plugin = detectPluginSpace(p.containerTag) + const isOwnSpace = isOwnConversationSpace(p, user?.id) return ( ) + ) : isOwnSpace ? ( + ) : ( {p.emoji || "📁"} )} @@ -535,7 +648,13 @@ export function SpaceSelector({ )} ) : ( - spaceSelectorDisplayName(p, p.containerTag) + spaceSelectorDisplayName( + p, + p.containerTag, + { + currentUserId: user?.id, + }, + ) )} @@ -631,6 +750,129 @@ export function SpaceSelector({
+ + { + if (!open) handleBulkDeleteCancel() + }} + > + +
+
+
+ + Delete {bulkDeleteDialog.projects.length}{" "} + {bulkDeleteDialog.projects.length === 1 ? "space" : "spaces"}? + + + This permanently deletes the selected container tags and every + document and memory inside them. This cannot be undone. + +
+ + + Close + +
+ +
+
+ {bulkDeleteDialog.projects.slice(0, 8).map((project) => ( +
+ + {project.name} +
+ ))} + {bulkDeleteDialog.projects.length > 8 && ( +

+ +{bulkDeleteDialog.projects.length - 8} more +

+ )} +
+
+ + + +
+ + +
+
+
+
) } diff --git a/apps/web/globals.css b/apps/web/globals.css index 8c008725..dfb81616 100644 --- a/apps/web/globals.css +++ b/apps/web/globals.css @@ -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; diff --git a/apps/web/hooks/use-account-settings.ts b/apps/web/hooks/use-account-settings.ts index ca2fae62..d073d36f 100644 --- a/apps/web/hooks/use-account-settings.ts +++ b/apps/web/hooks/use-account-settings.ts @@ -10,6 +10,7 @@ export type AccountMembership = { slug: string role: string memberCount: number + plan?: string } export function useAccountMemberships() { diff --git a/apps/web/hooks/use-connection-health.ts b/apps/web/hooks/use-connection-health.ts new file mode 100644 index 00000000..f23892f5 --- /dev/null +++ b/apps/web/hooks/use-connection-health.ts @@ -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 } +} diff --git a/apps/web/hooks/use-org-summaries.ts b/apps/web/hooks/use-org-summaries.ts new file mode 100644 index 00000000..af2bb6ff --- /dev/null +++ b/apps/web/hooks/use-org-summaries.ts @@ -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 => { + 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, + }) +} diff --git a/apps/web/hooks/use-personalization.ts b/apps/web/hooks/use-personalization.ts index 256fab3c..619b3ac2 100644 --- a/apps/web/hooks/use-personalization.ts +++ b/apps/web/hooks/use-personalization.ts @@ -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> = {} function getSessionCopy(p: Profession): PersonalizedCopy { @@ -398,7 +407,7 @@ export function usePersonalization(): { setProfession: (p: Profession) => void } { const [copy, setCopy] = useState(() => - getSessionCopy("default"), + defaultCopy("default"), ) const [profession, setProfessionState] = useState("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) }, []) diff --git a/apps/web/hooks/use-project-mutations.ts b/apps/web/hooks/use-project-mutations.ts index b6af4df1..b15699bd 100644 --- a/apps/web/hooks/use-project-mutations.ts +++ b/apps/web/hooks/use-project-mutations.ts @@ -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(["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(["projects"]) + const previousContainerTags = queryClient.getQueryData< + ContainerTagListType[] + >(["container-tags"]) + + queryClient.setQueryData(["projects"], (current) => + current?.map((project) => + project.containerTag === variables.containerTag + ? { ...project, name: variables.name } + : project, + ), + ) + queryClient.setQueryData( + ["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(["projects"], (current) => + current?.map((project) => + project.containerTag === data.containerTag + ? { ...project, name: data.name } + : project, + ), + ) + queryClient.setQueryData( + ["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, } } diff --git a/apps/web/hooks/use-sync-runs.ts b/apps/web/hooks/use-sync-runs.ts index 664587ab..449c2c85 100644 --- a/apps/web/hooks/use-sync-runs.ts +++ b/apps/web/hooks/use-sync-runs.ts @@ -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")) { diff --git a/apps/web/hooks/use-token-usage.ts b/apps/web/hooks/use-token-usage.ts index c903d7e7..898d89ac 100644 --- a/apps/web/hooks/use-token-usage.ts +++ b/apps/web/hooks/use-token-usage.ts @@ -4,6 +4,30 @@ import { calculateUsagePercent, getDaysRemaining } from "@/lib/billing-utils" export type PlanType = "free" | "pro" | "scale" | "enterprise" +export const PLAN_DISPLAY_NAMES: Record = { + free: "Free", + pro: "Pro", + scale: "Scale", + enterprise: "Enterprise", +} + +/** Higher rank sorts first in org lists (enterprise at top). */ +export const PLAN_RANK: Record = { + 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", diff --git a/apps/web/lib/chat-auto-space.ts b/apps/web/lib/chat-auto-space.ts new file mode 100644 index 00000000..8f766248 --- /dev/null +++ b/apps/web/lib/chat-auto-space.ts @@ -0,0 +1 @@ +export const AUTO_CHAT_SPACE_ID = "__supermemory_auto_space__" diff --git a/apps/web/lib/ingest-auto-space.ts b/apps/web/lib/ingest-auto-space.ts index 9768a27e..a4d2f8dc 100644 --- a/apps/web/lib/ingest-auto-space.ts +++ b/apps/web/lib/ingest-auto-space.ts @@ -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 | undefined, + currentUserId?: string | null, +): boolean { + return !!currentUserId && p?.containerTag === currentUserId +} + export function spaceSelectorDisplayName( p: Pick | 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 diff --git a/apps/web/lib/search-params.ts b/apps/web/lib/search-params.ts index b283baf5..3978bc37 100644 --- a/apps/web/lib/search-params.ts +++ b/apps/web/lib/search-params.ts @@ -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( [], ) diff --git a/apps/web/lib/url-helpers.ts b/apps/web/lib/url-helpers.ts index 832b8722..cfd57209 100644 --- a/apps/web/lib/url-helpers.ts +++ b/apps/web/lib/url-helpers.ts @@ -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. */ diff --git a/apps/web/middleware.ts b/apps/web/middleware.ts index 9d42e1bb..4715c571 100644 --- a/apps/web/middleware.ts +++ b/apps/web/middleware.ts @@ -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).*)", ], } diff --git a/apps/web/public/android-chrome-192x192.png b/apps/web/public/android-chrome-192x192.png new file mode 100644 index 00000000..dbabef62 Binary files /dev/null and b/apps/web/public/android-chrome-192x192.png differ diff --git a/apps/web/public/android-chrome-512x512.png b/apps/web/public/android-chrome-512x512.png new file mode 100644 index 00000000..b92f2fd2 Binary files /dev/null and b/apps/web/public/android-chrome-512x512.png differ diff --git a/apps/web/public/apple-touch-icon.png b/apps/web/public/apple-touch-icon.png new file mode 100644 index 00000000..29d7e45a Binary files /dev/null and b/apps/web/public/apple-touch-icon.png differ diff --git a/apps/web/public/favicon-16x16.png b/apps/web/public/favicon-16x16.png new file mode 100644 index 00000000..9e49fdb9 Binary files /dev/null and b/apps/web/public/favicon-16x16.png differ diff --git a/apps/web/public/favicon-32x32.png b/apps/web/public/favicon-32x32.png new file mode 100644 index 00000000..315238b6 Binary files /dev/null and b/apps/web/public/favicon-32x32.png differ diff --git a/apps/web/public/favicon.ico b/apps/web/public/favicon.ico new file mode 100644 index 00000000..c3041708 Binary files /dev/null and b/apps/web/public/favicon.ico differ diff --git a/apps/web/public/site.webmanifest b/apps/web/public/site.webmanifest new file mode 100644 index 00000000..95911504 --- /dev/null +++ b/apps/web/public/site.webmanifest @@ -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" +} diff --git a/packages/lib/api.ts b/packages/lib/api.ts index 721f818f..76d62fda 100644 --- a/packages/lib/api.ts +++ b/packages/lib/api.ts @@ -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, diff --git a/packages/ui/assets/Logo.tsx b/packages/ui/assets/Logo.tsx index 6741acf9..c6413ad2 100644 --- a/packages/ui/assets/Logo.tsx +++ b/packages/ui/assets/Logo.tsx @@ -2,8 +2,8 @@ export const Logo = ({ className, id, }: { - className?: string; - id?: string; + className?: string + id?: string }) => { return ( - ); -}; + ) +} export const LogoFull = ({ className, id, }: { - className?: string; - id?: string; + className?: string + id?: string }) => { return ( - ); -}; + ) +} export const GradientLogo = ({ className = "" }: { className?: string }) => { return ( @@ -106,8 +106,8 @@ export const GradientLogo = ({ className = "" }: { className?: string }) => { - ); -}; + ) +} export const LogoBgGradient = ({ className = "" }: { className?: string }) => { return ( @@ -311,5 +311,5 @@ export const LogoBgGradient = ({ className = "" }: { className?: string }) => { - ); -}; + ) +} diff --git a/packages/ui/assets/icons.tsx b/packages/ui/assets/icons.tsx index c2ae59fc..9c30d2e3 100644 --- a/packages/ui/assets/icons.tsx +++ b/packages/ui/assets/icons.tsx @@ -22,7 +22,7 @@ export const OneDrive = ({ className }: { className?: string }) => ( fill="#28A8EA" /> -); +) export const GoogleDrive = ({ className }: { className?: string }) => ( ( fill="#FFBA00" /> -); +) export const Notion = ({ className }: { className?: string }) => ( ( /> -); +) export const GoogleDocs = ({ className }: { className?: string }) => ( ( fill="currentColor" /> -); +) export const GoogleSheets = ({ className }: { className?: string }) => ( ( fill="currentColor" /> -); +) export const GoogleSlides = ({ className }: { className?: string }) => ( ( fill="currentColor" /> -); +) export const NotionDoc = ({ className }: { className?: string }) => ( ( fill="currentColor" /> -); +) export const MicrosoftWord = ({ className }: { className?: string }) => ( ( fill="currentColor" /> -); +) export const MicrosoftExcel = ({ className }: { className?: string }) => ( ( fill="currentColor" /> -); +) export const MicrosoftPowerpoint = ({ className }: { className?: string }) => ( ( fill="currentColor" /> -); +) export const MicrosoftOneNote = ({ className }: { className?: string }) => ( ( fill="currentColor" /> -); +) export const PDF = ({ className }: { className?: string }) => ( ( fill="#DC2626" /> -); +) export const SyncLogoIcon = ({ className }: { className?: string }) => { return ( @@ -258,8 +258,8 @@ export const SyncLogoIcon = ({ className }: { className?: string }) => { - ); -}; + ) +} export const MCPIcon = ({ className }: { className?: string }) => { return ( @@ -323,8 +323,8 @@ export const MCPIcon = ({ className }: { className?: string }) => { - ); -}; + ) +} export const ClaudeDesktopIcon = ({ className }: { className?: string }) => { return ( @@ -360,5 +360,5 @@ export const ClaudeDesktopIcon = ({ className }: { className?: string }) => { /> - ); -}; + ) +} diff --git a/packages/ui/button/external-auth.tsx b/packages/ui/button/external-auth.tsx index 4696ecbf..c496ddcd 100644 --- a/packages/ui/button/external-auth.tsx +++ b/packages/ui/button/external-auth.tsx @@ -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 & { - authProvider: string; - authIcon: React.ReactNode; - }; + authProvider: string + authIcon: React.ReactNode + } export function ExternalAuthButton({ authProvider, @@ -34,5 +34,5 @@ export function ExternalAuthButton({ Continue with {authProvider} - ); + ) } diff --git a/packages/ui/components/accordion.tsx b/packages/ui/components/accordion.tsx index 0a5ef2b8..1a33121e 100644 --- a/packages/ui/components/accordion.tsx +++ b/packages/ui/components/accordion.tsx @@ -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) { - return ; + return } function AccordionItem({ @@ -21,7 +21,7 @@ function AccordionItem({ data-slot="accordion-item" {...props} /> - ); + ) } function AccordionTrigger({ @@ -43,7 +43,7 @@ function AccordionTrigger({ - ); + ) } function AccordionContent({ @@ -59,7 +59,7 @@ function AccordionContent({ >
{children}
- ); + ) } -export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }; +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } diff --git a/packages/ui/components/alert-dialog.tsx b/packages/ui/components/alert-dialog.tsx index b4765eec..619f8516 100644 --- a/packages/ui/components/alert-dialog.tsx +++ b/packages/ui/components/alert-dialog.tsx @@ -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) { - return ; + return } function AlertDialogTrigger({ @@ -16,7 +16,7 @@ function AlertDialogTrigger({ }: React.ComponentProps) { return ( - ); + ) } function AlertDialogPortal({ @@ -24,7 +24,7 @@ function AlertDialogPortal({ }: React.ComponentProps) { return ( - ); + ) } function AlertDialogOverlay({ @@ -40,7 +40,7 @@ function AlertDialogOverlay({ data-slot="alert-dialog-overlay" {...props} /> - ); + ) } function AlertDialogContent({ @@ -59,7 +59,7 @@ function AlertDialogContent({ {...props} /> - ); + ) } 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, -}; +} diff --git a/packages/ui/components/avatar.tsx b/packages/ui/components/avatar.tsx index ba7bdc54..7c94143a 100644 --- a/packages/ui/components/avatar.tsx +++ b/packages/ui/components/avatar.tsx @@ -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 } diff --git a/packages/ui/components/badge.tsx b/packages/ui/components/badge.tsx index 49490bf9..783281f2 100644 --- a/packages/ui/components/badge.tsx +++ b/packages/ui/components/badge.tsx @@ -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 } diff --git a/packages/ui/components/breadcrumb.tsx b/packages/ui/components/breadcrumb.tsx index a09a2f92..b901adfd 100644 --- a/packages/ui/components/breadcrumb.tsx +++ b/packages/ui/components/breadcrumb.tsx @@ -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
- ); + ) } -const ChartLegend = RechartsPrimitive.Legend; +const ChartLegend = RechartsPrimitive.Legend function ChartLegendContent({ className, @@ -258,13 +258,13 @@ function ChartLegendContent({ nameKey, }: React.ComponentProps<"div"> & Pick & { - 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 (
- ); + ) })}
- ); + ) } // 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, -}; +} diff --git a/packages/ui/components/checkbox.tsx b/packages/ui/components/checkbox.tsx index 064a7627..45a968f9 100644 --- a/packages/ui/components/checkbox.tsx +++ b/packages/ui/components/checkbox.tsx @@ -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({ - ); + ) } -export { Checkbox }; +export { Checkbox } diff --git a/packages/ui/components/collapsible.tsx b/packages/ui/components/collapsible.tsx index 0551ffdd..f8de4e4c 100644 --- a/packages/ui/components/collapsible.tsx +++ b/packages/ui/components/collapsible.tsx @@ -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) { - return ; + return } 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 } diff --git a/packages/ui/components/combobox.tsx b/packages/ui/components/combobox.tsx index 657e38e2..8c9f9c97 100644 --- a/packages/ui/components/combobox.tsx +++ b/packages/ui/components/combobox.tsx @@ -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>; - className?: string; - placeholder?: string; - triggerClassName?: string; + options: Option[] + onSelect: (value: string) => void + onSubmit: (newName: string) => void + selectedValues: string[] + setSelectedValues: React.Dispatch> + 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 ( @@ -93,7 +87,7 @@ export function Combobox({
{selectedValues.length > 0 ? ( selectedValues.map((value) => { - const option = options.find((opt) => opt.value === value); + const option = options.find((opt) => opt.value === value) return ( { - e.stopPropagation(); - handleRemove(value); + e.stopPropagation() + handleRemove(value) }} type="button" > - ); + ) }) ) : ( {placeholder} @@ -163,5 +157,5 @@ export function Combobox({ - ); + ) } diff --git a/packages/ui/components/command.tsx b/packages/ui/components/command.tsx index 7fa7aed6..4c04c8c3 100644 --- a/packages/ui/components/command.tsx +++ b/packages/ui/components/command.tsx @@ -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 & { - title?: string; - description?: string; - className?: string; - showCloseButton?: boolean; + title?: string + description?: string + className?: string + showCloseButton?: boolean }) { return ( @@ -56,7 +56,7 @@ function CommandDialog({ - ); + ) } function CommandInput({ @@ -78,7 +78,7 @@ function CommandInput({ {...props} />
- ); + ) } 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, -}; +} diff --git a/packages/ui/components/dialog.tsx b/packages/ui/components/dialog.tsx index d90285ae..b40fabba 100644 --- a/packages/ui/components/dialog.tsx +++ b/packages/ui/components/dialog.tsx @@ -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) { - return ; + return } function DialogTrigger({ ...props }: React.ComponentProps) { - return ; + return } function DialogPortal({ ...props }: React.ComponentProps) { - return ; + return } function DialogClose({ ...props }: React.ComponentProps) { - return ; + return } 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 & { - showCloseButton?: boolean; + showCloseButton?: boolean }) { return ( @@ -76,7 +76,7 @@ function DialogContent({ )} - ); + ) } 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, -}; +} diff --git a/packages/ui/components/drawer.tsx b/packages/ui/components/drawer.tsx index 3e1b1c5e..ad3e771e 100644 --- a/packages/ui/components/drawer.tsx +++ b/packages/ui/components/drawer.tsx @@ -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) { - return ; + return } function DrawerTrigger({ ...props }: React.ComponentProps) { - return ; + return } function DrawerPortal({ ...props }: React.ComponentProps) { - return ; + return } function DrawerClose({ ...props }: React.ComponentProps) { - return ; + return } 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} - ); + ) } 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, -}; +} diff --git a/packages/ui/components/dropdown-menu.tsx b/packages/ui/components/dropdown-menu.tsx index 5af01088..fbd09e9c 100644 --- a/packages/ui/components/dropdown-menu.tsx +++ b/packages/ui/components/dropdown-menu.tsx @@ -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) { - return ; + return } function DropdownMenuPortal({ @@ -16,7 +16,7 @@ function DropdownMenuPortal({ }: React.ComponentProps) { return ( - ); + ) } function DropdownMenuTrigger({ @@ -27,7 +27,7 @@ function DropdownMenuTrigger({ data-slot="dropdown-menu-trigger" {...props} /> - ); + ) } function DropdownMenuContent({ @@ -47,7 +47,7 @@ function DropdownMenuContent({ {...props} /> - ); + ) } function DropdownMenuGroup({ @@ -55,7 +55,7 @@ function DropdownMenuGroup({ }: React.ComponentProps) { return ( - ); + ) } function DropdownMenuItem({ @@ -64,8 +64,8 @@ function DropdownMenuItem({ variant = "default", ...props }: React.ComponentProps & { - inset?: boolean; - variant?: "default" | "destructive"; + inset?: boolean + variant?: "default" | "destructive" }) { return ( - ); + ) } function DropdownMenuCheckboxItem({ @@ -104,7 +104,7 @@ function DropdownMenuCheckboxItem({ {children} - ); + ) } function DropdownMenuRadioGroup({ @@ -115,7 +115,7 @@ function DropdownMenuRadioGroup({ data-slot="dropdown-menu-radio-group" {...props} /> - ); + ) } function DropdownMenuRadioItem({ @@ -139,7 +139,7 @@ function DropdownMenuRadioItem({ {children} - ); + ) } function DropdownMenuLabel({ @@ -147,7 +147,7 @@ function DropdownMenuLabel({ inset, ...props }: React.ComponentProps & { - inset?: boolean; + inset?: boolean }) { return ( - ); + ) } 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) { - return ; + return } function DropdownMenuSubTrigger({ @@ -203,7 +203,7 @@ function DropdownMenuSubTrigger({ children, ...props }: React.ComponentProps & { - inset?: boolean; + inset?: boolean }) { return ( - ); + ) } function DropdownMenuSubContent({ @@ -234,7 +234,7 @@ function DropdownMenuSubContent({ data-slot="dropdown-menu-sub-content" {...props} /> - ); + ) } export { @@ -253,4 +253,4 @@ export { DropdownMenuSub, DropdownMenuSubTrigger, DropdownMenuSubContent, -}; +} diff --git a/packages/ui/components/grid-plus.tsx b/packages/ui/components/grid-plus.tsx index 5517e5b3..719b854a 100644 --- a/packages/ui/components/grid-plus.tsx +++ b/packages/ui/components/grid-plus.tsx @@ -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 = ({ @@ -17,21 +17,21 @@ export const BackgroundPlus: React.FC = ({ 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 (
= ({ style={backgroundStyle} {...props} /> - ); -}; + ) +} -export default BackgroundPlus; +export default BackgroundPlus diff --git a/packages/ui/components/hover-card.tsx b/packages/ui/components/hover-card.tsx index d75f45f6..2150c1cc 100644 --- a/packages/ui/components/hover-card.tsx +++ b/packages/ui/components/hover-card.tsx @@ -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) { - return ; + return } function HoverCardTrigger({ @@ -15,7 +15,7 @@ function HoverCardTrigger({ }: React.ComponentProps) { return ( - ); + ) } function HoverCardContent({ @@ -37,7 +37,7 @@ function HoverCardContent({ {...props} /> - ); + ) } -export { HoverCard, HoverCardTrigger, HoverCardContent }; +export { HoverCard, HoverCardTrigger, HoverCardContent } diff --git a/packages/ui/components/input.tsx b/packages/ui/components/input.tsx index 4fb1bd7e..cb670693 100644 --- a/packages/ui/components/input.tsx +++ b/packages/ui/components/input.tsx @@ -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 } diff --git a/packages/ui/components/label.tsx b/packages/ui/components/label.tsx index 97961968..f1c2a2f4 100644 --- a/packages/ui/components/label.tsx +++ b/packages/ui/components/label.tsx @@ -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 } diff --git a/packages/ui/components/popover.tsx b/packages/ui/components/popover.tsx index b3dbe9bc..bbb8885a 100644 --- a/packages/ui/components/popover.tsx +++ b/packages/ui/components/popover.tsx @@ -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) { - return ; + return } function PopoverTrigger({ ...props }: React.ComponentProps) { - return ; + return } function PopoverContent({ @@ -35,13 +35,13 @@ function PopoverContent({ {...props} /> - ); + ) } function PopoverAnchor({ ...props }: React.ComponentProps) { - return ; + return } -export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }; +export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor } diff --git a/packages/ui/components/progress.tsx b/packages/ui/components/progress.tsx index d3300cef..c42d1c0c 100644 --- a/packages/ui/components/progress.tsx +++ b/packages/ui/components/progress.tsx @@ -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)}%)` }} /> - ); + ) } -export { Progress }; +export { Progress } diff --git a/packages/ui/components/scroll-area.tsx b/packages/ui/components/scroll-area.tsx index 3d4ccf97..26ce58ca 100644 --- a/packages/ui/components/scroll-area.tsx +++ b/packages/ui/components/scroll-area.tsx @@ -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({ - ); + ) } function ScrollBar({ @@ -51,7 +51,7 @@ function ScrollBar({ data-slot="scroll-area-thumb" /> - ); + ) } -export { ScrollArea, ScrollBar }; +export { ScrollArea, ScrollBar } diff --git a/packages/ui/components/select.tsx b/packages/ui/components/select.tsx index 7a20f4c4..ff905ed0 100644 --- a/packages/ui/components/select.tsx +++ b/packages/ui/components/select.tsx @@ -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) { - return ; + return } function SelectGroup({ ...props }: React.ComponentProps) { - return ; + return } function SelectValue({ ...props }: React.ComponentProps) { - return ; + return } function SelectTrigger({ @@ -29,7 +29,7 @@ function SelectTrigger({ children, ...props }: React.ComponentProps & { - size?: "sm" | "default"; + size?: "sm" | "default" }) { return ( - ); + ) } function SelectContent({ @@ -81,7 +81,7 @@ function SelectContent({ - ); + ) } function SelectLabel({ @@ -94,7 +94,7 @@ function SelectLabel({ data-slot="select-label" {...props} /> - ); + ) } function SelectItem({ @@ -118,7 +118,7 @@ function SelectItem({ {children} - ); + ) } function SelectSeparator({ @@ -131,7 +131,7 @@ function SelectSeparator({ data-slot="select-separator" {...props} /> - ); + ) } function SelectScrollUpButton({ @@ -149,7 +149,7 @@ function SelectScrollUpButton({ > - ); + ) } function SelectScrollDownButton({ @@ -167,7 +167,7 @@ function SelectScrollDownButton({ > - ); + ) } export { @@ -181,4 +181,4 @@ export { SelectSeparator, SelectTrigger, SelectValue, -}; +} diff --git a/packages/ui/components/separator.tsx b/packages/ui/components/separator.tsx index 629bf4d5..670b6944 100644 --- a/packages/ui/components/separator.tsx +++ b/packages/ui/components/separator.tsx @@ -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 } diff --git a/packages/ui/components/shadcn-io/dropzone.tsx b/packages/ui/components/shadcn-io/dropzone.tsx index 4d78d907..05b12023 100644 --- a/packages/ui/components/shadcn-io/dropzone.tsx +++ b/packages/ui/components/shadcn-io/dropzone.tsx @@ -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( undefined, -); +) export type DropzoneProps = Omit & { - 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 ( - ); -}; + ) +} 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

- ); -}; + ) +} 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 = ({

{caption}.

)}
- ); -}; + ) +} diff --git a/packages/ui/components/sheet.tsx b/packages/ui/components/sheet.tsx index fc49af38..7fd6004b 100644 --- a/packages/ui/components/sheet.tsx +++ b/packages/ui/components/sheet.tsx @@ -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) { - return ; + return } function SheetTrigger({ ...props }: React.ComponentProps) { - return ; + return } function SheetClose({ ...props }: React.ComponentProps) { - return ; + return } function SheetPortal({ ...props }: React.ComponentProps) { - return ; + return } 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 & { - side?: "top" | "right" | "bottom" | "left"; + side?: "top" | "right" | "bottom" | "left" }) { return ( @@ -77,7 +77,7 @@ function SheetContent({ - ); + ) } 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, -}; +} diff --git a/packages/ui/components/sidebar.tsx b/packages/ui/components/sidebar.tsx index 5412a196..dbceb782 100644 --- a/packages/ui/components/sidebar.tsx +++ b/packages/ui/components/sidebar.tsx @@ -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(null); +const SidebarContext = React.createContext(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( () => ({ @@ -123,7 +123,7 @@ function SidebarProvider({ toggleSidebar, }), [state, open, setOpen, isMobile, openMobile, toggleSidebar], - ); + ) return ( @@ -147,7 +147,7 @@ function SidebarProvider({
- ); + ) } 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}
- ); + ) } if (isMobile) { @@ -201,7 +201,7 @@ function Sidebar({
{children}
- ); + ) } return ( @@ -249,7 +249,7 @@ function Sidebar({
- ); + ) } function SidebarTrigger({ @@ -257,7 +257,7 @@ function SidebarTrigger({ onClick, ...props }: React.ComponentProps) { - const { toggleSidebar } = useSidebar(); + const { toggleSidebar } = useSidebar() return ( - ); + ) } function SidebarRail({ className, ...props }: React.ComponentProps<"button">) { - const { toggleSidebar } = useSidebar(); + const { toggleSidebar } = useSidebar() return (
- ); + ) } function TableHeader({ className, ...props }: React.ComponentProps<"thead">) { @@ -25,7 +25,7 @@ function TableHeader({ className, ...props }: React.ComponentProps<"thead">) { data-slot="table-header" {...props} /> - ); + ) } function TableBody({ className, ...props }: React.ComponentProps<"tbody">) { @@ -35,7 +35,7 @@ function TableBody({ className, ...props }: React.ComponentProps<"tbody">) { data-slot="table-body" {...props} /> - ); + ) } function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) { @@ -48,7 +48,7 @@ function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) { data-slot="table-footer" {...props} /> - ); + ) } function TableRow({ className, ...props }: React.ComponentProps<"tr">) { @@ -61,7 +61,7 @@ function TableRow({ className, ...props }: React.ComponentProps<"tr">) { data-slot="table-row" {...props} /> - ); + ) } function TableHead({ className, ...props }: React.ComponentProps<"th">) { @@ -74,7 +74,7 @@ function TableHead({ className, ...props }: React.ComponentProps<"th">) { data-slot="table-head" {...props} /> - ); + ) } function TableCell({ className, ...props }: React.ComponentProps<"td">) { @@ -87,7 +87,7 @@ function TableCell({ className, ...props }: React.ComponentProps<"td">) { data-slot="table-cell" {...props} /> - ); + ) } function TableCaption({ @@ -100,7 +100,7 @@ function TableCaption({ data-slot="table-caption" {...props} /> - ); + ) } export { @@ -112,4 +112,4 @@ export { TableRow, TableCell, TableCaption, -}; +} diff --git a/packages/ui/components/tabs.tsx b/packages/ui/components/tabs.tsx index 42aae355..22e576fa 100644 --- a/packages/ui/components/tabs.tsx +++ b/packages/ui/components/tabs.tsx @@ -1,8 +1,8 @@ -"use client"; +"use client" -import { cn } from "@lib/utils"; -import * as TabsPrimitive from "@radix-ui/react-tabs"; -import type * as React from "react"; +import { cn } from "@lib/utils" +import * as TabsPrimitive from "@radix-ui/react-tabs" +import type * as React from "react" function Tabs({ className, @@ -14,7 +14,7 @@ function Tabs({ data-slot="tabs" {...props} /> - ); + ) } function TabsList({ @@ -30,7 +30,7 @@ function TabsList({ data-slot="tabs-list" {...props} /> - ); + ) } function TabsTrigger({ @@ -46,7 +46,7 @@ function TabsTrigger({ data-slot="tabs-trigger" {...props} /> - ); + ) } function TabsContent({ @@ -59,7 +59,7 @@ function TabsContent({ data-slot="tabs-content" {...props} /> - ); + ) } -export { Tabs, TabsList, TabsTrigger, TabsContent }; +export { Tabs, TabsList, TabsTrigger, TabsContent } diff --git a/packages/ui/components/text-separator.tsx b/packages/ui/components/text-separator.tsx index bd448233..adf6a886 100644 --- a/packages/ui/components/text-separator.tsx +++ b/packages/ui/components/text-separator.tsx @@ -1,7 +1,7 @@ -import { cn } from "@lib/utils"; +import { cn } from "@lib/utils" interface TextSeparatorProps extends React.ComponentProps<"div"> { - text: string; + text: string } export function TextSeparator({ @@ -18,5 +18,5 @@ export function TextSeparator({ {text}
- ); + ) } diff --git a/packages/ui/components/textarea.tsx b/packages/ui/components/textarea.tsx index 9e0e3146..6ca3b1f8 100644 --- a/packages/ui/components/textarea.tsx +++ b/packages/ui/components/textarea.tsx @@ -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 Textarea({ className, ...props }: React.ComponentProps<"textarea">) { return ( @@ -11,7 +11,7 @@ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) { data-slot="textarea" {...props} /> - ); + ) } -export { Textarea }; +export { Textarea } diff --git a/packages/ui/components/toggle-group.tsx b/packages/ui/components/toggle-group.tsx index 5fa25b8a..b8621419 100644 --- a/packages/ui/components/toggle-group.tsx +++ b/packages/ui/components/toggle-group.tsx @@ -1,17 +1,17 @@ -"use client"; +"use client" -import { cn } from "@lib/utils"; -import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"; -import { toggleVariants } from "@ui/components/toggle"; -import type { VariantProps } from "class-variance-authority"; -import * as React from "react"; +import { cn } from "@lib/utils" +import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group" +import { toggleVariants } from "@ui/components/toggle" +import type { VariantProps } from "class-variance-authority" +import * as React from "react" const ToggleGroupContext = React.createContext< VariantProps >({ size: "default", variant: "default", -}); +}) function ToggleGroup({ className, @@ -36,7 +36,7 @@ function ToggleGroup({ {children} - ); + ) } function ToggleGroupItem({ @@ -47,7 +47,7 @@ function ToggleGroupItem({ ...props }: React.ComponentProps & VariantProps) { - const context = React.useContext(ToggleGroupContext); + const context = React.useContext(ToggleGroupContext) return ( {children} - ); + ) } -export { ToggleGroup, ToggleGroupItem }; +export { ToggleGroup, ToggleGroupItem } diff --git a/packages/ui/components/toggle.tsx b/packages/ui/components/toggle.tsx index 1a240825..ca855d28 100644 --- a/packages/ui/components/toggle.tsx +++ b/packages/ui/components/toggle.tsx @@ -1,9 +1,9 @@ -"use client"; +"use client" -import { cn } from "@lib/utils"; -import * as TogglePrimitive from "@radix-ui/react-toggle"; -import { cva, type VariantProps } from "class-variance-authority"; -import type * as React from "react"; +import { cn } from "@lib/utils" +import * as TogglePrimitive from "@radix-ui/react-toggle" +import { cva, type VariantProps } from "class-variance-authority" +import type * as React from "react" const toggleVariants = cva( "inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-2 outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap", @@ -25,7 +25,7 @@ const toggleVariants = cva( size: "default", }, }, -); +) function Toggle({ className, @@ -40,7 +40,7 @@ function Toggle({ data-slot="toggle" {...props} /> - ); + ) } -export { Toggle, toggleVariants }; +export { Toggle, toggleVariants } diff --git a/packages/ui/components/tooltip.tsx b/packages/ui/components/tooltip.tsx index daea283d..973a107f 100644 --- a/packages/ui/components/tooltip.tsx +++ b/packages/ui/components/tooltip.tsx @@ -1,8 +1,8 @@ -"use client"; +"use client" -import { cn } from "@lib/utils"; -import * as TooltipPrimitive from "@radix-ui/react-tooltip"; -import type * as React from "react"; +import { cn } from "@lib/utils" +import * as TooltipPrimitive from "@radix-ui/react-tooltip" +import type * as React from "react" function TooltipProvider({ delayDuration = 0, @@ -14,7 +14,7 @@ function TooltipProvider({ delayDuration={delayDuration} {...props} /> - ); + ) } function Tooltip({ @@ -24,13 +24,13 @@ function Tooltip({ - ); + ) } function TooltipTrigger({ ...props }: React.ComponentProps) { - return ; + return } function TooltipContent({ @@ -54,7 +54,7 @@ function TooltipContent({ - ); + ) } -export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }; +export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } diff --git a/packages/ui/copy-button.tsx b/packages/ui/copy-button.tsx index 3e5fa75d..92ea4914 100644 --- a/packages/ui/copy-button.tsx +++ b/packages/ui/copy-button.tsx @@ -1,17 +1,17 @@ -"use client"; +"use client" -import { cn } from "@lib/utils"; -import { Button, type buttonVariants } from "@ui/components/button"; -import type { VariantProps } from "class-variance-authority"; -import { CheckIcon, ClipboardIcon } from "lucide-react"; -import * as React from "react"; -import { useEffect } from "react"; +import { cn } from "@lib/utils" +import { Button, type buttonVariants } from "@ui/components/button" +import type { VariantProps } from "class-variance-authority" +import { CheckIcon, ClipboardIcon } from "lucide-react" +import * as React from "react" +import { useEffect } from "react" interface CopyButtonProps extends React.ComponentProps<"button">, VariantProps { - value: string; - src?: string; + value: string + src?: string } export function CopyButton({ @@ -21,13 +21,13 @@ export function CopyButton({ variant = "ghost", ...props }: CopyButtonProps) { - const [hasCopied, setHasCopied] = React.useState(false); + const [hasCopied, setHasCopied] = React.useState(false) useEffect(() => { setTimeout(() => { - setHasCopied(false); - }, 2000); - }, []); + setHasCopied(false) + }, 2000) + }, []) return ( - ); + ) } diff --git a/packages/ui/copyable-cell.tsx b/packages/ui/copyable-cell.tsx index 6b2dbc89..d23c9c6f 100644 --- a/packages/ui/copyable-cell.tsx +++ b/packages/ui/copyable-cell.tsx @@ -1,13 +1,13 @@ -"use client"; +"use client" -import { cn } from "@lib/utils"; -import { Label1Regular } from "@ui/text/label/label-1-regular"; -import { AnimatePresence, motion } from "motion/react"; -import * as React from "react"; +import { cn } from "@lib/utils" +import { Label1Regular } from "@ui/text/label/label-1-regular" +import { AnimatePresence, motion } from "motion/react" +import * as React from "react" interface CopyableCellProps extends React.HTMLAttributes { - value: string; - displayValue?: React.ReactNode; + value: string + displayValue?: React.ReactNode } export function CopyableCell({ @@ -17,26 +17,26 @@ export function CopyableCell({ children, ...props }: CopyableCellProps) { - const [hasCopied, setHasCopied] = React.useState(false); + const [hasCopied, setHasCopied] = React.useState(false) React.useEffect(() => { if (hasCopied) { const timeout = setTimeout(() => { - setHasCopied(false); - }, 2000); - return () => clearTimeout(timeout); + setHasCopied(false) + }, 2000) + return () => clearTimeout(timeout) } - }, [hasCopied]); + }, [hasCopied]) const handleCopy = async (e: React.MouseEvent) => { - e.stopPropagation(); + e.stopPropagation() try { - await navigator.clipboard.writeText(value); - setHasCopied(true); + await navigator.clipboard.writeText(value) + setHasCopied(true) } catch (err) { - console.error("Failed to copy:", err); + console.error("Failed to copy:", err) } - }; + } return ( // biome-ignore lint/a11y/noStaticElementInteractions: shadcn @@ -80,5 +80,5 @@ export function CopyableCell({ )}
- ); + ) } diff --git a/packages/ui/globals.css b/packages/ui/globals.css index d312a1eb..30030b16 100644 --- a/packages/ui/globals.css +++ b/packages/ui/globals.css @@ -260,4 +260,17 @@ font-size: 14px; letter-spacing: var(--tracking-normal); } + + @media (max-width: 767px) { + input:not([type="button"]):not([type="checkbox"]):not([type="color"]):not( + [type="file"] + ):not([type="hidden"]):not([type="image"]):not([type="radio"]):not( + [type="range"] + ):not([type="reset"]):not([type="submit"]), + textarea, + select, + [contenteditable]:not([contenteditable="false"]) { + font-size: 16px !important; + } + } } diff --git a/packages/ui/input/labeled-input.tsx b/packages/ui/input/labeled-input.tsx index c21e06c6..7e603719 100644 --- a/packages/ui/input/labeled-input.tsx +++ b/packages/ui/input/labeled-input.tsx @@ -1,13 +1,13 @@ -import { cn } from "@lib/utils"; -import { Input } from "@ui/components/input"; -import { Label1Regular } from "@ui/text/label/label-1-regular"; +import { cn } from "@lib/utils" +import { Input } from "@ui/components/input" +import { Label1Regular } from "@ui/text/label/label-1-regular" interface LabeledInputProps extends React.ComponentProps<"div"> { - label?: string; - inputType: string; - inputPlaceholder: string; - error?: string | null; - inputProps?: React.ComponentProps; + label?: string + inputType: string + inputPlaceholder: string + error?: string | null + inputProps?: React.ComponentProps } export function LabeledInput({ @@ -47,5 +47,5 @@ export function LabeledInput({

)}
- ); + ) } diff --git a/packages/ui/other/anonymous-auth.tsx b/packages/ui/other/anonymous-auth.tsx index 32db8664..009902f9 100644 --- a/packages/ui/other/anonymous-auth.tsx +++ b/packages/ui/other/anonymous-auth.tsx @@ -1,53 +1,53 @@ -"use client"; +"use client" -import { authClient } from "@lib/auth"; -import { useRouter } from "next/navigation"; -import { useEffect } from "react"; +import { authClient } from "@lib/auth" +import { useRouter } from "next/navigation" +import { useEffect } from "react" export const AnonymousAuth = ({ dashboardPath = "/dashboard", loginPath = "/login", }) => { - const router = useRouter(); + const router = useRouter() useEffect(() => { const createAnonymousSession = async () => { - const session = await authClient.getSession(); + const session = await authClient.getSession() if (!session?.session) { console.debug( "[ANONYMOUS_AUTH] No session found, creating anonymous session...", - ); + ) try { // Create anonymous session - console.debug("[ANONYMOUS_AUTH] Calling signIn.anonymous()..."); - const res = await authClient.signIn.anonymous(); + console.debug("[ANONYMOUS_AUTH] Calling signIn.anonymous()...") + const res = await authClient.signIn.anonymous() if (!res.token) { - throw new Error("Failed to get anonymous token"); + throw new Error("Failed to get anonymous token") } // Get the new session console.debug( "[ANONYMOUS_AUTH] Getting new session with anonymous token...", - ); - const newSession = await authClient.getSession(); + ) + const newSession = await authClient.getSession() - console.debug("[ANONYMOUS_AUTH] New session retrieved:", newSession); + console.debug("[ANONYMOUS_AUTH] New session retrieved:", newSession) if (!newSession?.session || !newSession?.user) { console.error( "[ANONYMOUS_AUTH] Failed to create anonymous session - missing session or user", - ); - throw new Error("Failed to create anonymous session"); + ) + throw new Error("Failed to create anonymous session") } // Get the user's organization console.debug( "[ANONYMOUS_AUTH] Fetching organizations for anonymous user...", - ); - const orgs = await authClient.organization.list(); + ) + const orgs = await authClient.organization.list() console.debug("[ANONYMOUS_AUTH] Organizations retrieved:", { count: orgs?.length || 0, @@ -56,43 +56,43 @@ export const AnonymousAuth = ({ name: o.name, slug: o.slug, })), - }); + }) - const org = orgs?.[0]; + const org = orgs?.[0] if (!org) { console.error( "[ANONYMOUS_AUTH] No organization found for anonymous user", - ); - throw new Error("Failed to get organization for anonymous user"); + ) + throw new Error("Failed to get organization for anonymous user") } // Redirect to the organization dashboard console.debug( `[ANONYMOUS_AUTH] Redirecting anonymous user to /${org.slug}${dashboardPath}`, - ); - router.push(dashboardPath); + ) + router.push(dashboardPath) } catch (error) { console.error( "[ANONYMOUS_AUTH] Anonymous session creation error:", error, - ); + ) console.error("[ANONYMOUS_AUTH] Error details:", { message: error instanceof Error ? error.message : "Unknown error", stack: error instanceof Error ? error.stack : undefined, - }); - router.push(loginPath); + }) + router.push(loginPath) } } else if (session.session) { // Session exists, handle organization routing console.debug( "[ANONYMOUS_AUTH] Session exists, checking organization...", - ); + ) if (!session.session.activeOrganizationId) { console.debug( "[ANONYMOUS_AUTH] No active organization ID, fetching organizations...", - ); - const orgs = await authClient.organization.list(); + ) + const orgs = await authClient.organization.list() console.debug("[ANONYMOUS_AUTH] Organizations for existing user:", { count: orgs?.length || 0, @@ -101,50 +101,50 @@ export const AnonymousAuth = ({ name: o.name, slug: o.slug, })), - }); + }) if (orgs?.[0]) { console.debug( `[ANONYMOUS_AUTH] Setting active organization to ${orgs[0].id}`, - ); + ) await authClient.organization.setActive({ organizationId: orgs[0].id, - }); + }) console.debug( `[ANONYMOUS_AUTH] Redirecting to /${orgs[0].slug}${dashboardPath}`, - ); - router.push(dashboardPath); + ) + router.push(dashboardPath) } } else { console.debug( `[ANONYMOUS_AUTH] Active organization ID: ${session.session.activeOrganizationId}`, - ); + ) console.debug( "[ANONYMOUS_AUTH] Fetching full organization details...", - ); + ) const org = await authClient.organization.getFullOrganization({ query: { organizationId: session.session.activeOrganizationId, }, - }); + }) console.debug("[ANONYMOUS_AUTH] Full organization retrieved:", { id: org.id, name: org.name, slug: org.slug, - }); + }) console.debug( `[ANONYMOUS_AUTH] Redirecting to /${org.slug}${dashboardPath}`, - ); - router.push(dashboardPath); + ) + router.push(dashboardPath) } } - }; + } - createAnonymousSession(); - }, [router.push]); + createAnonymousSession() + }, [router.push]) // Return null as this component only handles the redirect logic - return null; -}; + return null +} diff --git a/packages/ui/other/glass-effect.tsx b/packages/ui/other/glass-effect.tsx index 089aa864..7f735134 100644 --- a/packages/ui/other/glass-effect.tsx +++ b/packages/ui/other/glass-effect.tsx @@ -1,6 +1,6 @@ interface GlassMenuEffectProps { - rounded?: string; - className?: string; + rounded?: string + className?: string } export function GlassMenuEffect({ @@ -14,5 +14,5 @@ export function GlassMenuEffect({ className={`absolute inset-0 backdrop-blur-md bg-white/5 border border-white/10 ${rounded}`} />
- ); + ) } diff --git a/packages/ui/text/heading/heading-h1-bold.tsx b/packages/ui/text/heading/heading-h1-bold.tsx index b76f3b9b..c8019a0b 100644 --- a/packages/ui/text/heading/heading-h1-bold.tsx +++ b/packages/ui/text/heading/heading-h1-bold.tsx @@ -1,12 +1,12 @@ -import { cn } from "@lib/utils"; -import { Root } from "@radix-ui/react-slot"; +import { cn } from "@lib/utils" +import { Root } from "@radix-ui/react-slot" export function HeadingH1Bold({ className, asChild, ...props }: React.ComponentProps<"h1"> & { asChild?: boolean }) { - const Comp = asChild ? Root : "h1"; + const Comp = asChild ? Root : "h1" return ( - ); + ) } diff --git a/packages/ui/text/heading/heading-h1-medium.tsx b/packages/ui/text/heading/heading-h1-medium.tsx index 5724e1f1..52ddda1c 100644 --- a/packages/ui/text/heading/heading-h1-medium.tsx +++ b/packages/ui/text/heading/heading-h1-medium.tsx @@ -1,12 +1,12 @@ -import { cn } from "@lib/utils"; -import { Root } from "@radix-ui/react-slot"; +import { cn } from "@lib/utils" +import { Root } from "@radix-ui/react-slot" export function HeadingH1Medium({ className, asChild, ...props }: React.ComponentProps<"h1"> & { asChild?: boolean }) { - const Comp = asChild ? Root : "h1"; + const Comp = asChild ? Root : "h1" return ( - ); + ) } diff --git a/packages/ui/text/heading/heading-h2-bold.tsx b/packages/ui/text/heading/heading-h2-bold.tsx index 6711de50..3da9a399 100644 --- a/packages/ui/text/heading/heading-h2-bold.tsx +++ b/packages/ui/text/heading/heading-h2-bold.tsx @@ -1,12 +1,12 @@ -import { cn } from "@lib/utils"; -import { Root } from "@radix-ui/react-slot"; +import { cn } from "@lib/utils" +import { Root } from "@radix-ui/react-slot" export function HeadingH2Bold({ className, asChild, ...props }: React.ComponentProps<"h2"> & { asChild?: boolean }) { - const Comp = asChild ? Root : "h2"; + const Comp = asChild ? Root : "h2" return ( - ); + ) } diff --git a/packages/ui/text/heading/heading-h2-medium.tsx b/packages/ui/text/heading/heading-h2-medium.tsx index afac0a42..6324fe86 100644 --- a/packages/ui/text/heading/heading-h2-medium.tsx +++ b/packages/ui/text/heading/heading-h2-medium.tsx @@ -1,12 +1,12 @@ -import { cn } from "@lib/utils"; -import { Root } from "@radix-ui/react-slot"; +import { cn } from "@lib/utils" +import { Root } from "@radix-ui/react-slot" export function HeadingH2Medium({ className, asChild, ...props }: React.ComponentProps<"h2"> & { asChild?: boolean }) { - const Comp = asChild ? Root : "h2"; + const Comp = asChild ? Root : "h2" return ( - ); + ) } diff --git a/packages/ui/text/heading/heading-h3-bold.tsx b/packages/ui/text/heading/heading-h3-bold.tsx index be15a33c..bb97b323 100644 --- a/packages/ui/text/heading/heading-h3-bold.tsx +++ b/packages/ui/text/heading/heading-h3-bold.tsx @@ -1,12 +1,12 @@ -import { cn } from "@lib/utils"; -import { Root } from "@radix-ui/react-slot"; +import { cn } from "@lib/utils" +import { Root } from "@radix-ui/react-slot" export function HeadingH3Bold({ className, asChild, ...props }: React.ComponentProps<"h3"> & { asChild?: boolean }) { - const Comp = asChild ? Root : "h3"; + const Comp = asChild ? Root : "h3" return ( - ); + ) } diff --git a/packages/ui/text/heading/heading-h3-medium.tsx b/packages/ui/text/heading/heading-h3-medium.tsx index cdaa24a2..de1a5919 100644 --- a/packages/ui/text/heading/heading-h3-medium.tsx +++ b/packages/ui/text/heading/heading-h3-medium.tsx @@ -1,12 +1,12 @@ -import { cn } from "@lib/utils"; -import { Root } from "@radix-ui/react-slot"; +import { cn } from "@lib/utils" +import { Root } from "@radix-ui/react-slot" export function HeadingH3Medium({ className, asChild, ...props }: React.ComponentProps<"h3"> & { asChild?: boolean }) { - const Comp = asChild ? Root : "h3"; + const Comp = asChild ? Root : "h3" return ( - ); + ) } diff --git a/packages/ui/text/heading/heading-h4-bold.tsx b/packages/ui/text/heading/heading-h4-bold.tsx index 5e99c031..271e86db 100644 --- a/packages/ui/text/heading/heading-h4-bold.tsx +++ b/packages/ui/text/heading/heading-h4-bold.tsx @@ -1,12 +1,12 @@ -import { cn } from "@lib/utils"; -import { Root } from "@radix-ui/react-slot"; +import { cn } from "@lib/utils" +import { Root } from "@radix-ui/react-slot" export function HeadingH4Bold({ className, asChild, ...props }: React.ComponentProps<"h4"> & { asChild?: boolean }) { - const Comp = asChild ? Root : "h4"; + const Comp = asChild ? Root : "h4" return ( - ); + ) } diff --git a/packages/ui/text/heading/heading-h4-medium.tsx b/packages/ui/text/heading/heading-h4-medium.tsx index 1a536508..83e1ec66 100644 --- a/packages/ui/text/heading/heading-h4-medium.tsx +++ b/packages/ui/text/heading/heading-h4-medium.tsx @@ -1,12 +1,12 @@ -import { cn } from "@lib/utils"; -import { Root } from "@radix-ui/react-slot"; +import { cn } from "@lib/utils" +import { Root } from "@radix-ui/react-slot" export function HeadingH4Medium({ className, asChild, ...props }: React.ComponentProps<"h4"> & { asChild?: boolean }) { - const Comp = asChild ? Root : "h4"; + const Comp = asChild ? Root : "h4" return ( - ); + ) } diff --git a/packages/ui/text/label/label-1-medium.tsx b/packages/ui/text/label/label-1-medium.tsx index e599f3e7..a2aa10fe 100644 --- a/packages/ui/text/label/label-1-medium.tsx +++ b/packages/ui/text/label/label-1-medium.tsx @@ -1,12 +1,12 @@ -import { cn } from "@lib/utils"; -import { Root } from "@radix-ui/react-slot"; +import { cn } from "@lib/utils" +import { Root } from "@radix-ui/react-slot" export function Label1Medium({ className, asChild, ...props }: React.ComponentProps<"p"> & { asChild?: boolean }) { - const Comp = asChild ? Root : "p"; + const Comp = asChild ? Root : "p" return ( - ); + ) } diff --git a/packages/ui/text/label/label-1-regular.tsx b/packages/ui/text/label/label-1-regular.tsx index ad9ea319..e740c754 100644 --- a/packages/ui/text/label/label-1-regular.tsx +++ b/packages/ui/text/label/label-1-regular.tsx @@ -1,12 +1,12 @@ -import { cn } from "@lib/utils"; -import { Root } from "@radix-ui/react-slot"; +import { cn } from "@lib/utils" +import { Root } from "@radix-ui/react-slot" export function Label1Regular({ className, asChild, ...props }: React.ComponentProps<"p"> & { asChild?: boolean }) { - const Comp = asChild ? Root : "p"; + const Comp = asChild ? Root : "p" return ( - ); + ) } diff --git a/packages/ui/text/label/label-2-medium.tsx b/packages/ui/text/label/label-2-medium.tsx index 89aa2f2d..b0929a89 100644 --- a/packages/ui/text/label/label-2-medium.tsx +++ b/packages/ui/text/label/label-2-medium.tsx @@ -1,12 +1,12 @@ -import { cn } from "@lib/utils"; -import { Root } from "@radix-ui/react-slot"; +import { cn } from "@lib/utils" +import { Root } from "@radix-ui/react-slot" export function Label2Medium({ className, asChild, ...props }: React.ComponentProps<"p"> & { asChild?: boolean }) { - const Comp = asChild ? Root : "p"; + const Comp = asChild ? Root : "p" return ( - ); + ) } diff --git a/packages/ui/text/label/label-2-regular.tsx b/packages/ui/text/label/label-2-regular.tsx index 951dc5bf..54087c71 100644 --- a/packages/ui/text/label/label-2-regular.tsx +++ b/packages/ui/text/label/label-2-regular.tsx @@ -1,12 +1,12 @@ -import { cn } from "@lib/utils"; -import { Root } from "@radix-ui/react-slot"; +import { cn } from "@lib/utils" +import { Root } from "@radix-ui/react-slot" export function Label2Regular({ className, asChild, ...props }: React.ComponentProps<"p"> & { asChild?: boolean }) { - const Comp = asChild ? Root : "p"; + const Comp = asChild ? Root : "p" return ( - ); + ) } diff --git a/packages/ui/text/label/label-3-medium.tsx b/packages/ui/text/label/label-3-medium.tsx index 5308452e..4203636e 100644 --- a/packages/ui/text/label/label-3-medium.tsx +++ b/packages/ui/text/label/label-3-medium.tsx @@ -1,12 +1,12 @@ -import { cn } from "@lib/utils"; -import { Root } from "@radix-ui/react-slot"; +import { cn } from "@lib/utils" +import { Root } from "@radix-ui/react-slot" export function Label3Medium({ className, asChild, ...props }: React.ComponentProps<"p"> & { asChild?: boolean }) { - const Comp = asChild ? Root : "p"; + const Comp = asChild ? Root : "p" return ( - ); + ) } diff --git a/packages/ui/text/label/label-3-regular.tsx b/packages/ui/text/label/label-3-regular.tsx index 9ca0d65e..f8986631 100644 --- a/packages/ui/text/label/label-3-regular.tsx +++ b/packages/ui/text/label/label-3-regular.tsx @@ -1,12 +1,12 @@ -import { cn } from "@lib/utils"; -import { Root } from "@radix-ui/react-slot"; +import { cn } from "@lib/utils" +import { Root } from "@radix-ui/react-slot" export function Label3Regular({ className, asChild, ...props }: React.ComponentProps<"p"> & { asChild?: boolean }) { - const Comp = asChild ? Root : "p"; + const Comp = asChild ? Root : "p" return ( - ); + ) } diff --git a/packages/ui/text/title/title-1-bold.tsx b/packages/ui/text/title/title-1-bold.tsx index a87e637b..7ae48df6 100644 --- a/packages/ui/text/title/title-1-bold.tsx +++ b/packages/ui/text/title/title-1-bold.tsx @@ -1,12 +1,12 @@ -import { cn } from "@lib/utils"; -import { Root } from "@radix-ui/react-slot"; +import { cn } from "@lib/utils" +import { Root } from "@radix-ui/react-slot" export function Title1Bold({ className, asChild, ...props }: React.ComponentProps<"h1"> & { asChild?: boolean }) { - const Comp = asChild ? Root : "h1"; + const Comp = asChild ? Root : "h1" return ( - ); + ) } diff --git a/packages/ui/text/title/title-1-medium.tsx b/packages/ui/text/title/title-1-medium.tsx index 2ac13520..da231407 100644 --- a/packages/ui/text/title/title-1-medium.tsx +++ b/packages/ui/text/title/title-1-medium.tsx @@ -1,12 +1,12 @@ -import { cn } from "@lib/utils"; -import { Root } from "@radix-ui/react-slot"; +import { cn } from "@lib/utils" +import { Root } from "@radix-ui/react-slot" export function Title1Medium({ className, asChild, ...props }: React.ComponentProps<"h1"> & { asChild?: boolean }) { - const Comp = asChild ? Root : "h1"; + const Comp = asChild ? Root : "h1" return ( - ); + ) } diff --git a/packages/ui/text/title/title-2-bold.tsx b/packages/ui/text/title/title-2-bold.tsx index 38bbe34e..b32dcdbd 100644 --- a/packages/ui/text/title/title-2-bold.tsx +++ b/packages/ui/text/title/title-2-bold.tsx @@ -1,12 +1,12 @@ -import { cn } from "@lib/utils"; -import { Root } from "@radix-ui/react-slot"; +import { cn } from "@lib/utils" +import { Root } from "@radix-ui/react-slot" export function Title2Bold({ className, asChild, ...props }: React.ComponentProps<"h2"> & { asChild?: boolean }) { - const Comp = asChild ? Root : "h2"; + const Comp = asChild ? Root : "h2" return ( - ); + ) } diff --git a/packages/ui/text/title/title-2-medium.tsx b/packages/ui/text/title/title-2-medium.tsx index c5a5deae..d931cff7 100644 --- a/packages/ui/text/title/title-2-medium.tsx +++ b/packages/ui/text/title/title-2-medium.tsx @@ -1,12 +1,12 @@ -import { cn } from "@lib/utils"; -import { Root } from "@radix-ui/react-slot"; +import { cn } from "@lib/utils" +import { Root } from "@radix-ui/react-slot" export function Title2Medium({ className, asChild, ...props }: React.ComponentProps<"h2"> & { asChild?: boolean }) { - const Comp = asChild ? Root : "h2"; + const Comp = asChild ? Root : "h2" return ( - ); + ) } diff --git a/packages/ui/text/title/title-3-bold.tsx b/packages/ui/text/title/title-3-bold.tsx index cf9ab777..6a4a6008 100644 --- a/packages/ui/text/title/title-3-bold.tsx +++ b/packages/ui/text/title/title-3-bold.tsx @@ -1,12 +1,12 @@ -import { cn } from "@lib/utils"; -import { Root } from "@radix-ui/react-slot"; +import { cn } from "@lib/utils" +import { Root } from "@radix-ui/react-slot" export function Title3Bold({ className, asChild, ...props }: React.ComponentProps<"h3"> & { asChild?: boolean }) { - const Comp = asChild ? Root : "h3"; + const Comp = asChild ? Root : "h3" return ( - ); + ) } diff --git a/packages/ui/text/title/title-3-medium.tsx b/packages/ui/text/title/title-3-medium.tsx index f862e618..5e1b13f0 100644 --- a/packages/ui/text/title/title-3-medium.tsx +++ b/packages/ui/text/title/title-3-medium.tsx @@ -1,12 +1,12 @@ -import { cn } from "@lib/utils"; -import { Root } from "@radix-ui/react-slot"; +import { cn } from "@lib/utils" +import { Root } from "@radix-ui/react-slot" export function Title3Medium({ className, asChild, ...props }: React.ComponentProps<"h3"> & { asChild?: boolean }) { - const Comp = asChild ? Root : "h3"; + const Comp = asChild ? Root : "h3" return ( - ); + ) } diff --git a/packages/validation/api.ts b/packages/validation/api.ts index 76f04b68..e1e8c8ef 100644 --- a/packages/validation/api.ts +++ b/packages/validation/api.ts @@ -1280,6 +1280,51 @@ export const CreateProjectSchema = z description: "Request body for creating a new project", }) +export const ContainerTagSettingsUpdateSchema = z + .object({ + containerTag: z.string().openapi({ + description: "The container tag identifier", + example: "sm_project_default", + }), + name: z.string().nullable().openapi({ + description: "Display name for this container tag", + example: "Research Notes", + }), + entityContext: z.string().nullable().openapi({ + description: "Custom context prompt for this container tag", + example: "This project contains research papers about machine learning.", + }), + memoryFilesystemPaths: z.array(z.string()).nullable(), + updatedAt: z.string().datetime().openapi({ + description: "Last update timestamp", + format: "datetime", + }), + }) + .openapi({ + description: "Response after updating container tag settings", + }) + +export const UpdateContainerTagSettingsRequestSchema = z + .object({ + name: z.string().trim().min(1).max(100).optional().openapi({ + description: + "Display name for this container tag. This does not change the container tag identifier.", + example: "Research Notes", + minLength: 1, + maxLength: 100, + }), + entityContext: z.string().max(1500).nullable().optional().openapi({ + description: + "Custom context prompt for this container tag. Used to provide additional context when processing documents in this container. Maximum 1500 characters.", + example: "This project contains research papers about machine learning.", + maxLength: 1500, + }), + memoryFilesystemPaths: z.array(z.string()).nullable().optional(), + }) + .openapi({ + description: "Request body for updating container tag settings", + }) + export const ListProjectsResponseSchema = z .object({ projects: z.array(ProjectSchema).openapi({