Merge pull request #864 from AnishSarkar22/fix/github-and-ui-fixes

feat: add `last_login` column for last login info & UI/UX improvements
This commit is contained in:
Rohan Verma 2026-03-08 16:05:43 -07:00 committed by GitHub
commit cba7923503
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
69 changed files with 2157 additions and 1248 deletions

View file

@ -0,0 +1,39 @@
"""103_add_last_login_to_user
Revision ID: 103
Revises: 102
Create Date: 2026-03-08
Adds last_login timestamp column to the user table so we can track
when each user last authenticated. The column is nullable existing
rows will have NULL until the user's next login.
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "103"
down_revision: str | None = "102"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
conn = op.get_bind()
existing_columns = [col["name"] for col in sa.inspect(conn).get_columns("user")]
if "last_login" not in existing_columns:
op.add_column(
"user",
sa.Column("last_login", sa.TIMESTAMP(timezone=True), nullable=True),
)
def downgrade() -> None:
op.drop_column("user", "last_login")

View file

@ -1720,6 +1720,8 @@ if config.AUTH_TYPE == "GOOGLE":
display_name = Column(String, nullable=True)
avatar_url = Column(String, nullable=True)
last_login = Column(TIMESTAMP(timezone=True), nullable=True)
# Refresh tokens for this user
refresh_tokens = relationship(
"RefreshToken",
@ -1820,6 +1822,8 @@ else:
display_name = Column(String, nullable=True)
avatar_url = Column(String, nullable=True)
last_login = Column(TIMESTAMP(timezone=True), nullable=True)
# Refresh tokens for this user
refresh_tokens = relationship(
"RefreshToken",

View file

@ -109,12 +109,12 @@ SUMMARY_PROMPT_TEMPLATE = PromptTemplate(
# Chat Title Generation Prompt
# =============================================================================
TITLE_GENERATION_PROMPT = """Generate a concise, descriptive title for the following conversation.
TITLE_GENERATION_PROMPT = """Generate a concise, descriptive title for the following user query.
<rules>
- The title MUST be between 1 and 6 words
- The title MUST be on a single line
- Capture the main topic or intent of the conversation
- Capture the main topic or intent of the query
- Do NOT use quotes, punctuation, or formatting
- Do NOT include words like "Chat about" or "Discussion of"
- Return ONLY the title, nothing else
@ -124,13 +124,9 @@ TITLE_GENERATION_PROMPT = """Generate a concise, descriptive title for the follo
{user_query}
</user_query>
<assistant_response>
{assistant_response}
</assistant_response>
Title:"""
TITLE_GENERATION_PROMPT_TEMPLATE = PromptTemplate(
input_variables=["user_query", "assistant_response"],
input_variables=["user_query"],
template=TITLE_GENERATION_PROMPT,
)

View file

@ -510,6 +510,7 @@ async def list_members(
"user_email": member_user.email if member_user else None,
"user_display_name": member_user.display_name if member_user else None,
"user_avatar_url": member_user.avatar_url if member_user else None,
"user_last_login": member_user.last_login if member_user else None,
}
response.append(membership_dict)
@ -602,6 +603,7 @@ async def update_member_role(
"created_at": db_membership.created_at,
"role": db_membership.role,
"user_email": member_user.email if member_user else None,
"user_last_login": member_user.last_login if member_user else None,
}
except HTTPException:

View file

@ -77,6 +77,7 @@ class MembershipRead(BaseModel):
user_email: str | None = None
user_display_name: str | None = None
user_avatar_url: str | None = None
user_last_login: datetime | None = None
class Config:
from_attributes = True

View file

@ -1366,6 +1366,38 @@ async def stream_new_chat(
del mentioned_documents, mentioned_surfsense_docs, recent_reports
del langchain_messages, final_query
# Check if this is the first assistant response so we can generate
# a title in parallel with the agent stream (better UX than waiting
# until after the full response).
assistant_count_result = await session.execute(
select(func.count(NewChatMessage.id)).filter(
NewChatMessage.thread_id == chat_id,
NewChatMessage.role == "assistant",
)
)
is_first_response = (assistant_count_result.scalar() or 0) == 0
title_task: asyncio.Task[str | None] | None = None
if is_first_response:
async def _generate_title() -> str | None:
try:
title_chain = TITLE_GENERATION_PROMPT_TEMPLATE | llm
title_result = await title_chain.ainvoke(
{"user_query": user_query[:500]}
)
if title_result and hasattr(title_result, "content"):
raw_title = title_result.content.strip()
if raw_title and len(raw_title) <= 100:
return raw_title.strip("\"'")
except Exception:
pass
return None
title_task = asyncio.create_task(_generate_title())
title_emitted = False
_t_stream_start = time.perf_counter()
_first_event_logged = False
async for sse in _stream_agent_events(
@ -1390,6 +1422,23 @@ async def stream_new_chat(
_first_event_logged = True
yield sse
# Inject title update mid-stream as soon as the background task finishes
if title_task is not None and title_task.done() and not title_emitted:
generated_title = title_task.result()
if generated_title:
async with shielded_async_session() as title_session:
title_thread_result = await title_session.execute(
select(NewChatThread).filter(NewChatThread.id == chat_id)
)
title_thread = title_thread_result.scalars().first()
if title_thread:
title_thread.title = generated_title
await title_session.commit()
yield streaming_service.format_thread_title_update(
chat_id, generated_title
)
title_emitted = True
_perf_log.info(
"[stream_new_chat] Agent stream completed in %.3fs (chat_id=%s)",
time.perf_counter() - _t_stream_start,
@ -1398,62 +1447,28 @@ async def stream_new_chat(
log_system_snapshot("stream_new_chat_END")
if stream_result.is_interrupted:
if title_task is not None and not title_task.done():
title_task.cancel()
yield streaming_service.format_finish_step()
yield streaming_service.format_finish()
yield streaming_service.format_done()
return
accumulated_text = stream_result.accumulated_text
assistant_count_result = await session.execute(
select(func.count(NewChatMessage.id)).filter(
NewChatMessage.thread_id == chat_id,
NewChatMessage.role == "assistant",
)
)
assistant_message_count = assistant_count_result.scalar() or 0
# Only generate title on the first response (no prior assistant messages)
if assistant_message_count == 0:
generated_title = None
try:
# Generate title using the same LLM
title_chain = TITLE_GENERATION_PROMPT_TEMPLATE | llm
# Truncate inputs to avoid context length issues
truncated_query = user_query[:500]
truncated_response = accumulated_text[:1000]
title_result = await title_chain.ainvoke(
{
"user_query": truncated_query,
"assistant_response": truncated_response,
}
)
# Extract and clean the title
if title_result and hasattr(title_result, "content"):
raw_title = title_result.content.strip()
# Validate the title (reasonable length)
if raw_title and len(raw_title) <= 100:
# Remove any quotes or extra formatting
generated_title = raw_title.strip("\"'")
except Exception:
generated_title = None
# Only update if LLM succeeded (keep truncated prompt title as fallback)
# If the title task didn't finish during streaming, await it now
if title_task is not None and not title_emitted:
generated_title = await title_task
if generated_title:
# Fetch thread and update title
thread_result = await session.execute(
select(NewChatThread).filter(NewChatThread.id == chat_id)
)
thread = thread_result.scalars().first()
if thread:
thread.title = generated_title
await session.commit()
# Notify frontend of the title update
yield streaming_service.format_thread_title_update(
chat_id, generated_title
async with shielded_async_session() as title_session:
title_thread_result = await title_session.execute(
select(NewChatThread).filter(NewChatThread.id == chat_id)
)
title_thread = title_thread_result.scalars().first()
if title_thread:
title_thread.title = generated_title
await title_session.commit()
yield streaming_service.format_thread_title_update(
chat_id, generated_title
)
# Finish the step and message
yield streaming_service.format_finish_step()

View file

@ -1,5 +1,6 @@
import logging
import uuid
from datetime import UTC, datetime
import httpx
from fastapi import Depends, Request, Response
@ -12,6 +13,7 @@ from fastapi_users.authentication import (
)
from fastapi_users.db import SQLAlchemyUserDatabase
from pydantic import BaseModel
from sqlalchemy import update
from app.config import config
from app.db import (
@ -123,6 +125,23 @@ class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
return user
async def on_after_login(
self,
user: User,
request: Request | None = None,
response: Response | None = None,
) -> None:
try:
async with async_session_maker() as session:
await session.execute(
update(User)
.where(User.id == user.id)
.values(last_login=datetime.now(UTC))
)
await session.commit()
except Exception as e:
logger.warning(f"Failed to update last_login for user {user.id}: {e}")
async def on_after_register(self, user: User, request: Request | None = None):
"""
Called after a user registers. Creates a default search space for the user

View file

@ -79,7 +79,7 @@ export function DocumentsFilters({
<PopoverContent className="w-64 !p-0 overflow-hidden" align="end">
<div>
{/* Search input */}
<div className="p-2 border-b border-neutral-700">
<div className="p-2 border-b border-border dark:border-neutral-700">
<div className="relative">
<Search className="absolute left-0.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
@ -108,7 +108,7 @@ export function DocumentsFilters({
<button
type="button"
key={value}
className="flex w-full items-center gap-2.5 py-2 px-3 rounded-md hover:bg-muted/50 transition-colors cursor-pointer text-left"
className="flex w-full items-center gap-2.5 py-2 px-3 rounded-md hover:bg-neutral-200 dark:hover:bg-neutral-700 transition-colors cursor-pointer text-left"
onClick={() => onToggleType(value, !activeTypes.includes(value))}
>
{/* Icon */}
@ -137,11 +137,11 @@ export function DocumentsFilters({
)}
</div>
{activeTypes.length > 0 && (
<div className="px-3 pt-1.5 pb-1.5 border-t border-neutral-700">
<div className="px-3 pt-1.5 pb-1.5 border-t border-border dark:border-neutral-700">
<Button
variant="ghost"
size="sm"
className="w-full h-7 text-[11px] text-muted-foreground hover:text-foreground hover:bg-neutral-700"
className="w-full h-7 text-[11px] text-muted-foreground hover:text-foreground hover:bg-neutral-200 dark:hover:bg-neutral-700"
onClick={() => {
activeTypes.forEach((t) => {
onToggleType(t, false);

View file

@ -11,7 +11,7 @@ import {
FileX,
Network,
PenLine,
Plus,
SearchX,
Trash2,
} from "lucide-react";
import { useRouter } from "next/navigation";
@ -19,6 +19,7 @@ import { useTranslations } from "next-intl";
import React, { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { useDocumentUploadDialog } from "@/components/assistant-ui/document-upload-popup";
import { JsonMetadataViewer } from "@/components/json-metadata-viewer";
import { MarkdownViewer } from "@/components/markdown-viewer";
import {
AlertDialog,
@ -222,12 +223,14 @@ function RowContextMenu({
onPreview,
onDelete,
searchSpaceId,
onEditNavigate,
}: {
doc: Document;
children: React.ReactNode;
onPreview: (doc: Document) => void;
onDelete: (doc: Document) => void;
searchSpaceId: string;
onEditNavigate?: () => void;
}) {
const router = useRouter();
@ -252,9 +255,12 @@ function RowContextMenu({
</ContextMenuItem>
{isEditable && (
<ContextMenuItem
onClick={() =>
!isEditDisabled && router.push(`/dashboard/${searchSpaceId}/editor/${doc.id}`)
}
onClick={() => {
if (!isEditDisabled) {
onEditNavigate?.();
router.push(`/dashboard/${searchSpaceId}/editor/${doc.id}`);
}
}}
disabled={isEditDisabled}
>
<PenLine className="h-4 w-4" />
@ -318,9 +324,10 @@ export function DocumentsTableShell({
hasMore = false,
loadingMore = false,
onLoadMore,
isSearchMode = false,
mentionedDocIds,
onToggleChatMention,
onEditNavigate,
isSearchMode = false,
}: {
documents: Document[];
loading: boolean;
@ -333,11 +340,14 @@ export function DocumentsTableShell({
hasMore?: boolean;
loadingMore?: boolean;
onLoadMore?: () => void;
isSearchMode?: boolean;
/** IDs of documents currently mentioned as chips in the chat composer */
mentionedDocIds?: Set<number>;
/** Toggle a document's mention in the chat (add if not mentioned, remove if mentioned) */
onToggleChatMention?: (doc: Document, mentioned: boolean) => void;
/** Called when user navigates to the editor via Edit — use to close containing sidebar/panel */
onEditNavigate?: () => void;
/** Whether results are filtered by a search query or type filters */
isSearchMode?: boolean;
}) {
const t = useTranslations("documents");
const { openDialog } = useDocumentUploadDialog();
@ -345,6 +355,10 @@ export function DocumentsTableShell({
const [viewingDoc, setViewingDoc] = useState<Document | null>(null);
const [viewingContent, setViewingContent] = useState<string>("");
const [viewingLoading, setViewingLoading] = useState(false);
const [metadataDoc, setMetadataDoc] = useState<Document | null>(null);
const [metadataJson, setMetadataJson] = useState<Record<string, unknown> | null>(null);
const [metadataLoading, setMetadataLoading] = useState(false);
const [previewScrollPos, setPreviewScrollPos] = useState<"top" | "middle" | "bottom">("top");
const handlePreviewScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
const el = e.currentTarget;
@ -412,6 +426,20 @@ export function DocumentsTableShell({
setViewingLoading(false);
}, []);
const handleViewMetadata = useCallback(async (doc: Document) => {
setMetadataDoc(doc);
setMetadataLoading(true);
try {
const fullDoc = await documentsApiService.getDocument({ id: doc.id });
setMetadataJson(fullDoc.document_metadata ?? {});
} catch (err) {
console.error("[DocumentsTableShell] Failed to fetch document metadata:", err);
setMetadataJson({ error: "Failed to load document metadata" });
} finally {
setMetadataLoading(false);
}
}, []);
const handleDeleteFromMenu = useCallback(async () => {
if (!deleteDoc) return;
setIsDeleting(true);
@ -544,21 +572,34 @@ export function DocumentsTableShell({
</div>
) : sorted.length === 0 ? (
<div className="flex flex-1 w-full items-center justify-center">
<div className="flex flex-col items-center gap-4 max-w-md px-4 text-center">
<div className="rounded-full bg-muted/50 p-4">
<FileX className="h-8 w-8 text-muted-foreground" />
{isSearchMode ? (
<div className="flex flex-col items-center gap-3 max-w-md px-4 text-center">
<SearchX className="h-8 w-8 text-muted-foreground" />
<div className="space-y-1">
<h3 className="text-sm font-medium text-muted-foreground">
No matching documents
</h3>
<p className="text-xs text-muted-foreground/70">
Try a different search term or adjust your filters.
</p>
</div>
</div>
<div className="space-y-1.5">
<h3 className="text-lg font-semibold">{t("no_documents")}</h3>
<p className="text-sm text-muted-foreground">
Get started by uploading your first document.
</p>
) : (
<div className="flex flex-col items-center gap-4 max-w-md px-4 text-center">
<div className="rounded-full bg-muted/50 p-4">
<FileX className="h-8 w-8 text-muted-foreground" />
</div>
<div className="space-y-1.5">
<h3 className="text-lg font-semibold">{t("no_documents")}</h3>
<p className="text-sm text-muted-foreground">
Get started by uploading your first document.
</p>
</div>
<Button onClick={openDialog} className="mt-2">
Upload Documents
</Button>
</div>
<Button onClick={openDialog} className="mt-2">
<Plus className="mr-2 h-4 w-4" />
Upload Documents
</Button>
</div>
)}
</div>
) : (
<div ref={desktopScrollRef} className="flex-1 overflow-auto">
@ -567,11 +608,20 @@ export function DocumentsTableShell({
{sorted.map((doc) => {
const isMentioned = mentionedDocIds?.has(doc.id) ?? false;
const canInteract = isSelectable(doc);
const handleRowClick = () => {
const handleRowToggle = () => {
if (canInteract && onToggleChatMention) {
onToggleChatMention(doc, isMentioned);
}
};
const handleRowClick = (e: React.MouseEvent) => {
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
e.stopPropagation();
handleViewMetadata(doc);
return;
}
handleRowToggle();
};
return (
<RowContextMenu
key={doc.id}
@ -579,6 +629,7 @@ export function DocumentsTableShell({
onPreview={handleViewDocument}
onDelete={setDeleteDoc}
searchSpaceId={searchSpaceId}
onEditNavigate={onEditNavigate}
>
<tr
className={`border-b border-border/50 transition-colors ${
@ -593,7 +644,7 @@ export function DocumentsTableShell({
<div className="flex items-center justify-center h-full">
<Checkbox
checked={isMentioned}
onCheckedChange={() => handleRowClick()}
onCheckedChange={() => handleRowToggle()}
disabled={!canInteract}
aria-label={isMentioned ? "Remove from chat" : "Add to chat"}
className={`border-foreground data-[state=checked]:bg-primary data-[state=checked]:border-primary ${!canInteract ? "opacity-40 cursor-not-allowed" : ""}`}
@ -659,21 +710,32 @@ export function DocumentsTableShell({
</div>
) : sorted.length === 0 ? (
<div className="md:hidden flex flex-1 w-full items-center justify-center">
<div className="flex flex-col items-center gap-4 max-w-md px-4 text-center">
<div className="rounded-full bg-muted/50 p-4">
<FileX className="h-8 w-8 text-muted-foreground" />
{isSearchMode ? (
<div className="flex flex-col items-center gap-3 max-w-md px-4 text-center">
<SearchX className="h-8 w-8 text-muted-foreground" />
<div className="space-y-1">
<h3 className="text-sm font-medium text-muted-foreground">No matching documents</h3>
<p className="text-xs text-muted-foreground/70">
Try a different search term or adjust your filters.
</p>
</div>
</div>
<div className="space-y-1.5">
<h3 className="text-lg font-semibold">{t("no_documents")}</h3>
<p className="text-sm text-muted-foreground">
Get started by uploading your first document.
</p>
) : (
<div className="flex flex-col items-center gap-4 max-w-md px-4 text-center">
<div className="rounded-full bg-muted/50 p-4">
<FileX className="h-8 w-8 text-muted-foreground" />
</div>
<div className="space-y-1.5">
<h3 className="text-lg font-semibold">{t("no_documents")}</h3>
<p className="text-sm text-muted-foreground">
Get started by uploading your first document.
</p>
</div>
<Button onClick={openDialog} className="mt-2">
Upload Documents
</Button>
</div>
<Button onClick={openDialog} className="mt-2">
<Plus className="mr-2 h-4 w-4" />
Upload Documents
</Button>
</div>
)}
</div>
) : (
<div
@ -683,7 +745,13 @@ export function DocumentsTableShell({
{sorted.map((doc) => {
const isMentioned = mentionedDocIds?.has(doc.id) ?? false;
const canInteract = isSelectable(doc);
const handleCardClick = () => {
const handleCardClick = (e?: React.MouseEvent) => {
if (e && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
e.stopPropagation();
handleViewMetadata(doc);
return;
}
if (canInteract && onToggleChatMention) {
onToggleChatMention(doc, isMentioned);
}
@ -769,6 +837,21 @@ export function DocumentsTableShell({
</DialogContent>
</Dialog>
{/* Document Metadata Viewer (Ctrl+Click) */}
<JsonMetadataViewer
title={metadataDoc?.title ?? "Document"}
metadata={metadataJson}
loading={metadataLoading}
open={!!metadataDoc}
onOpenChange={(open) => {
if (!open) {
setMetadataDoc(null);
setMetadataJson(null);
setMetadataLoading(false);
}
}}
/>
{/* Delete Confirmation Dialog */}
<AlertDialog open={!!deleteDoc} onOpenChange={(open) => !open && setDeleteDoc(null)}>
<AlertDialogContent>
@ -839,6 +922,7 @@ export function DocumentsTableShell({
}
onClick={() => {
if (mobileActionDoc) {
onEditNavigate?.();
router.push(`/dashboard/${searchSpaceId}/editor/${mobileActionDoc.id}`);
setMobileActionDoc(null);
}

View file

@ -18,7 +18,7 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { Button, buttonVariants } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { notesApiService } from "@/lib/apis/notes-api.service";
@ -83,6 +83,7 @@ export default function EditorPage() {
const [error, setError] = useState<string | null>(null);
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const [showUnsavedDialog, setShowUnsavedDialog] = useState(false);
const [editorTitle, setEditorTitle] = useState<string>("Untitled");
// Store the latest markdown from the editor
const markdownRef = useRef<string>("");
@ -117,20 +118,18 @@ export default function EditorPage() {
}
}, [pendingNavigation, hasUnsavedChanges, router, setPendingNavigation]);
// Reset state when documentId changes
// Reset state and fetch document content when documentId changes
useEffect(() => {
setDocument(null);
setError(null);
setHasUnsavedChanges(false);
setLoading(true);
initialLoadDone.current = false;
}, [documentId]);
// Fetch document content
useEffect(() => {
async function fetchDocument() {
if (isNewNote) {
markdownRef.current = "";
setEditorTitle("Untitled");
setDocument({
document_id: 0,
title: "Untitled",
@ -173,6 +172,7 @@ export default function EditorPage() {
}
markdownRef.current = data.source_markdown;
setEditorTitle(extractTitleFromMarkdown(data.source_markdown));
setDocument(data);
setError(null);
initialLoadDone.current = true;
@ -193,20 +193,17 @@ export default function EditorPage() {
const isNote = isNewNote || document?.document_type === "NOTE";
// Extract title dynamically from current markdown for notes
const displayTitle = useMemo(() => {
if (isNote) {
return extractTitleFromMarkdown(markdownRef.current || document?.source_markdown);
}
if (isNote) return editorTitle;
return document?.title || "Untitled";
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isNote, document?.title, document?.source_markdown, hasUnsavedChanges]);
}, [isNote, document?.title, editorTitle]);
// Handle markdown changes from the Plate editor
const handleMarkdownChange = useCallback((md: string) => {
markdownRef.current = md;
if (initialLoadDone.current) {
setHasUnsavedChanges(true);
setEditorTitle(extractTitleFromMarkdown(md));
}
}, []);
@ -493,13 +490,13 @@ export default function EditorPage() {
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={handleCancelLeave}>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleSaveAndLeave}>Save</AlertDialogAction>
<AlertDialogAction
onClick={handleConfirmLeave}
className="border border-input bg-background text-foreground hover:bg-accent hover:text-accent-foreground"
className={buttonVariants({ variant: "secondary" })}
>
Leave without saving
</AlertDialogAction>
<AlertDialogAction onClick={handleSaveAndLeave}>Save</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>

View file

@ -465,10 +465,7 @@ export default function NewChatPage() {
let isNewThread = false;
if (!currentThreadId) {
try {
// Create thread with truncated prompt as initial title
const initialTitle =
userQuery.trim().slice(0, 100) + (userQuery.trim().length > 100 ? "..." : "");
const newThread = await createThread(searchSpaceId, initialTitle);
const newThread = await createThread(searchSpaceId, "New Chat");
currentThreadId = newThread.id;
setThreadId(currentThreadId);
// Set currentThread so share button in header appears immediately

View file

@ -259,7 +259,7 @@ export default function OnboardPage() {
You can add more configurations and customize settings anytime in{" "}
<button
type="button"
onClick={() => router.push(`/dashboard/${searchSpaceId}/settings?section=general`)}
onClick={() => router.push(`/dashboard/${searchSpaceId}/settings?tab=general`)}
className="text-violet-500 hover:underline"
>
Settings

View file

@ -1,9 +1,5 @@
import type React from "react";
/**
* Settings layout - renders children directly without the parent sidebar
* This creates a full-screen settings experience
*/
export default function SettingsLayout({ children }: { children: React.ReactNode }) {
return <div className="fixed inset-0 z-50 bg-background">{children}</div>;
return <>{children}</>;
}

View file

@ -1,24 +1,9 @@
"use client";
import {
ArrowLeft,
Bot,
Brain,
ChevronRight,
FileText,
Globe,
ImageIcon,
type LucideIcon,
Menu,
MessageSquare,
Settings,
Shield,
X,
} from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { Bot, Brain, FileText, Globe, ImageIcon, MessageSquare, Shield } from "lucide-react";
import { useParams, useRouter, useSearchParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect } from "react";
import { PublicChatSnapshotsManager } from "@/components/public-chat-snapshots/public-chat-snapshots-manager";
import { GeneralSettingsManager } from "@/components/settings/general-settings-manager";
import { ImageModelManager } from "@/components/settings/image-model-manager";
@ -26,347 +11,103 @@ import { LLMRoleManager } from "@/components/settings/llm-role-manager";
import { ModelConfigManager } from "@/components/settings/model-config-manager";
import { PromptConfigManager } from "@/components/settings/prompt-config-manager";
import { RolesManager } from "@/components/settings/roles-manager";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/animated-tabs";
import { trackSettingsViewed } from "@/lib/posthog/events";
import { cn } from "@/lib/utils";
interface SettingsNavItem {
id: string;
labelKey: string;
descriptionKey: string;
icon: LucideIcon;
}
const VALID_TABS = [
"general",
"models",
"roles",
"image-models",
"prompts",
"public-links",
"team-roles",
] as const;
const settingsNavItems: SettingsNavItem[] = [
{
id: "general",
labelKey: "nav_general",
descriptionKey: "nav_general_desc",
icon: FileText,
},
{
id: "models",
labelKey: "nav_agent_configs",
descriptionKey: "nav_agent_configs_desc",
icon: Bot,
},
{
id: "roles",
labelKey: "nav_role_assignments",
descriptionKey: "nav_role_assignments_desc",
icon: Brain,
},
{
id: "image-models",
labelKey: "nav_image_models",
descriptionKey: "nav_image_models_desc",
icon: ImageIcon,
},
{
id: "prompts",
labelKey: "nav_system_instructions",
descriptionKey: "nav_system_instructions_desc",
icon: MessageSquare,
},
{
id: "public-links",
labelKey: "nav_public_links",
descriptionKey: "nav_public_links_desc",
icon: Globe,
},
{
id: "team-roles",
labelKey: "nav_team_roles",
descriptionKey: "nav_team_roles_desc",
icon: Shield,
},
];
function SettingsSidebar({
activeSection,
onSectionChange,
onBackToApp,
isOpen,
onClose,
}: {
activeSection: string;
onSectionChange: (section: string) => void;
onBackToApp: () => void;
isOpen: boolean;
onClose: () => void;
}) {
const t = useTranslations("searchSpaceSettings");
const handleNavClick = (sectionId: string) => {
onSectionChange(sectionId);
onClose(); // Close sidebar on mobile after selection
};
return (
<>
{/* Mobile overlay */}
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="fixed inset-0 bg-background/80 backdrop-blur-sm z-40 md:hidden"
onClick={onClose}
/>
)}
</AnimatePresence>
{/* Sidebar */}
<aside
className={cn(
"fixed md:relative left-0 top-0 z-50 md:z-auto",
"w-72 shrink-0 bg-background md:bg-muted/30 h-full flex flex-col",
"md:border-r",
"transition-transform duration-300 ease-out",
"md:translate-x-0",
isOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"
)}
>
{/* Header with title */}
<div className="p-4 space-y-3">
<div className="flex items-center justify-between">
<Button
variant="ghost"
onClick={onBackToApp}
className="justify-start gap-3 h-11 px-3 hover:bg-muted group"
>
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-primary/10 group-hover:bg-primary/20 transition-colors">
<ArrowLeft className="h-4 w-4 text-primary" />
</div>
<span className="font-medium">{t("back_to_app")}</span>
</Button>
{/* Mobile close button */}
<Button variant="ghost" size="icon" onClick={onClose} className="md:hidden h-9 w-9">
<X className="h-5 w-5" />
</Button>
</div>
{/* Settings Title */}
<div className="px-3">
<h2 className="text-lg font-semibold text-foreground">{t("title")}</h2>
</div>
</div>
{/* Navigation Items */}
<nav className="flex-1 px-3 py-2 space-y-1 overflow-y-auto">
{settingsNavItems.map((item, index) => {
const isActive = activeSection === item.id;
const Icon = item.icon;
return (
<motion.button
key={item.id}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.1 + index * 0.05, duration: 0.3 }}
onClick={() => handleNavClick(item.id)}
whileHover={{ scale: 1.01 }}
whileTap={{ scale: 0.99 }}
className={cn(
"relative w-full flex items-center gap-3 px-3 py-3 rounded-xl text-left transition-all duration-200",
isActive ? "bg-muted shadow-sm border border-border" : "hover:bg-muted/60"
)}
>
{isActive && (
<motion.div
layoutId="settingsActiveIndicator"
className="absolute left-0 top-1/2 -translate-y-1/2 w-1 h-8 bg-primary rounded-r-full"
initial={false}
transition={{
type: "spring",
stiffness: 500,
damping: 35,
}}
/>
)}
<div
className={cn(
"flex items-center justify-center w-9 h-9 rounded-lg transition-colors",
isActive ? "bg-primary/10 text-primary" : "bg-muted text-muted-foreground"
)}
>
<Icon className="h-4 w-4" />
</div>
<div className="flex-1 min-w-0">
<p
className={cn(
"text-sm font-medium truncate transition-colors",
isActive ? "text-foreground" : "text-muted-foreground"
)}
>
{t(item.labelKey)}
</p>
<p className="text-xs text-muted-foreground/70 truncate">
{t(item.descriptionKey)}
</p>
</div>
<ChevronRight
className={cn(
"h-4 w-4 shrink-0 transition-all",
isActive
? "text-primary opacity-100 translate-x-0"
: "text-muted-foreground/40 opacity-0 -translate-x-1"
)}
/>
</motion.button>
);
})}
</nav>
</aside>
</>
);
}
function SettingsContent({
activeSection,
searchSpaceId,
onMenuClick,
}: {
activeSection: string;
searchSpaceId: number;
onMenuClick: () => void;
}) {
const t = useTranslations("searchSpaceSettings");
const activeItem = settingsNavItems.find((item) => item.id === activeSection);
const Icon = activeItem?.icon || Settings;
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.2, duration: 0.4 }}
className="flex-1 min-w-0 h-full overflow-hidden bg-background"
>
<div className="h-full overflow-y-auto">
<div className="max-w-4xl mx-auto p-4 md:p-6 lg:p-10">
{/* Section Header */}
<AnimatePresence mode="wait">
<motion.div
key={`${activeSection}-header`}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.3 }}
className="mb-6 md:mb-8"
>
<div className="flex items-center gap-3 md:gap-4">
{/* Mobile menu button */}
<Button
variant="outline"
size="icon"
onClick={onMenuClick}
className="md:hidden h-10 w-10 shrink-0"
>
<Menu className="h-5 w-5" />
</Button>
<motion.div
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ delay: 0.1, duration: 0.3 }}
className="flex items-center justify-center w-10 h-10 md:w-14 md:h-14 rounded-lg md:rounded-2xl bg-gradient-to-br from-primary/20 to-primary/5 border border-primary/10 shadow-sm shrink-0"
>
<Icon className="h-5 w-5 md:h-7 md:w-7 text-primary" />
</motion.div>
<div className="min-w-0">
<h1 className="text-lg md:text-2xl font-bold tracking-tight truncate">
{activeItem ? t(activeItem.labelKey) : ""}
</h1>
</div>
</div>
</motion.div>
</AnimatePresence>
{/* Section Content */}
<AnimatePresence mode="wait">
<motion.div
key={activeSection}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{
duration: 0.35,
ease: [0.4, 0, 0.2, 1],
}}
>
{activeSection === "general" && (
<GeneralSettingsManager searchSpaceId={searchSpaceId} />
)}
{activeSection === "models" && <ModelConfigManager searchSpaceId={searchSpaceId} />}
{activeSection === "roles" && <LLMRoleManager searchSpaceId={searchSpaceId} />}
{activeSection === "image-models" && (
<ImageModelManager searchSpaceId={searchSpaceId} />
)}
{activeSection === "prompts" && <PromptConfigManager searchSpaceId={searchSpaceId} />}
{activeSection === "public-links" && (
<PublicChatSnapshotsManager searchSpaceId={searchSpaceId} />
)}
{activeSection === "team-roles" && <RolesManager searchSpaceId={searchSpaceId} />}
</motion.div>
</AnimatePresence>
</div>
</div>
</motion.div>
);
}
const VALID_SECTIONS = new Set(settingsNavItems.map((item) => item.id));
const DEFAULT_SECTION = "general";
const DEFAULT_TAB = "general";
export default function SettingsPage() {
const t = useTranslations("searchSpaceSettings");
const router = useRouter();
const params = useParams();
const searchParams = useSearchParams();
const searchSpaceId = Number(params.search_space_id);
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const sectionParam = searchParams.get("section");
const activeSection =
sectionParam && VALID_SECTIONS.has(sectionParam) ? sectionParam : DEFAULT_SECTION;
const tabParam = searchParams.get("tab") ?? "";
const activeTab = VALID_TABS.includes(tabParam as (typeof VALID_TABS)[number])
? tabParam
: DEFAULT_TAB;
const handleSectionChange = useCallback(
(section: string) => {
router.replace(`/dashboard/${searchSpaceId}/settings?section=${section}`, { scroll: false });
const handleTabChange = useCallback(
(value: string) => {
const p = new URLSearchParams(searchParams.toString());
p.set("tab", value);
router.replace(`?${p.toString()}`, { scroll: false });
},
[router, searchSpaceId]
[router, searchParams]
);
useEffect(() => {
trackSettingsViewed(searchSpaceId, activeSection);
}, [searchSpaceId, activeSection]);
const handleBackToApp = useCallback(() => {
router.push(`/dashboard/${searchSpaceId}/new-chat`);
}, [router, searchSpaceId]);
trackSettingsViewed(searchSpaceId, activeTab);
}, [searchSpaceId, activeTab]);
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
className="fixed inset-0 z-50 flex bg-muted/40"
>
<div className="flex h-full w-full p-0 md:p-2">
<div className="flex h-full w-full overflow-hidden bg-background md:rounded-xl md:border md:shadow-sm">
<SettingsSidebar
activeSection={activeSection}
onSectionChange={handleSectionChange}
onBackToApp={handleBackToApp}
isOpen={isSidebarOpen}
onClose={() => setIsSidebarOpen(false)}
/>
<SettingsContent
activeSection={activeSection}
searchSpaceId={searchSpaceId}
onMenuClick={() => setIsSidebarOpen(true)}
/>
</div>
<div className="h-full overflow-y-auto">
<div className="mx-auto w-full max-w-4xl px-4 py-10">
<Tabs value={activeTab} onValueChange={handleTabChange} className="w-full">
<TabsList showBottomBorder>
<TabsTrigger value="general">
<FileText className="mr-2 h-4 w-4" />
{t("nav_general")}
</TabsTrigger>
<TabsTrigger value="models">
<Bot className="mr-2 h-4 w-4" />
{t("nav_agent_configs")}
</TabsTrigger>
<TabsTrigger value="roles">
<Brain className="mr-2 h-4 w-4" />
{t("nav_role_assignments")}
</TabsTrigger>
<TabsTrigger value="image-models">
<ImageIcon className="mr-2 h-4 w-4" />
{t("nav_image_models")}
</TabsTrigger>
<TabsTrigger value="team-roles">
<Shield className="mr-2 h-4 w-4" />
{t("nav_team_roles")}
</TabsTrigger>
<TabsTrigger value="prompts">
<MessageSquare className="mr-2 h-4 w-4" />
{t("nav_system_instructions")}
</TabsTrigger>
<TabsTrigger value="public-links">
<Globe className="mr-2 h-4 w-4" />
{t("nav_public_links")}
</TabsTrigger>
</TabsList>
<TabsContent value="general" className="mt-6">
<GeneralSettingsManager searchSpaceId={searchSpaceId} />
</TabsContent>
<TabsContent value="models" className="mt-6">
<ModelConfigManager searchSpaceId={searchSpaceId} />
</TabsContent>
<TabsContent value="roles" className="mt-6">
<LLMRoleManager searchSpaceId={searchSpaceId} />
</TabsContent>
<TabsContent value="image-models" className="mt-6">
<ImageModelManager searchSpaceId={searchSpaceId} />
</TabsContent>
<TabsContent value="prompts" className="mt-6">
<PromptConfigManager searchSpaceId={searchSpaceId} />
</TabsContent>
<TabsContent value="public-links" className="mt-6">
<PublicChatSnapshotsManager searchSpaceId={searchSpaceId} />
</TabsContent>
<TabsContent value="team-roles" className="mt-6">
<RolesManager searchSpaceId={searchSpaceId} />
</TabsContent>
</Tabs>
</div>
</motion.div>
</div>
);
}

View file

@ -138,6 +138,7 @@ function getAvatarInitials(member: Membership): string {
}
const PAGE_SIZE = 5;
const SKELETON_KEYS = Array.from({ length: PAGE_SIZE }, (_, i) => `skeleton-${i}`);
export default function TeamManagementPage() {
const params = useParams();
@ -290,11 +291,8 @@ export default function TeamManagementPage() {
</TableRow>
</TableHeader>
<TableBody>
{Array.from({ length: PAGE_SIZE }).map((_, i) => (
<TableRow
key={`skeleton-${i}`}
className="border-b border-border/40 hover:bg-transparent"
>
{SKELETON_KEYS.map((id) => (
<TableRow key={id} className="border-b border-border/40 hover:bg-transparent">
<TableCell className="w-[45%] py-2.5 px-4 md:px-6 border-r border-border/40">
<div className="flex items-center gap-3">
<Skeleton className="h-10 w-10 rounded-full shrink-0" />
@ -546,7 +544,7 @@ function MemberRow({
</TableCell>
<TableCell className="hidden md:table-cell w-[25%] py-2.5 text-sm text-foreground border-r border-border/40">
{formatRelativeDate(member.joined_at)}
{member.user_last_login ? formatRelativeDate(member.user_last_login) : "Never"}
</TableCell>
<TableCell className="w-[30%] text-right py-2.5 px-4 md:px-6">
@ -564,7 +562,7 @@ function MemberRow({
<DropdownMenuContent
align="end"
onCloseAutoFocus={(e) => e.preventDefault()}
className="min-w-[120px]"
className="min-w-[120px] dark:bg-neutral-900 dark:border dark:border-white/5"
>
{canManageRoles &&
roles
@ -607,11 +605,9 @@ function MemberRow({
</AlertDialogContent>
</AlertDialog>
)}
<DropdownMenuSeparator />
<DropdownMenuSeparator className="dark:bg-white/5" />
<DropdownMenuItem
onClick={() =>
router.push(`/dashboard/${searchSpaceId}/settings?section=team-roles`)
}
onClick={() => router.push(`/dashboard/${searchSpaceId}/settings?tab=team-roles`)}
>
Manage Roles
</DropdownMenuItem>
@ -832,7 +828,7 @@ function CreateInviteDialog({
</div>
</div>
</div>
<DialogFooter>
<DialogFooter className="gap-3 sm:gap-2">
<Button variant="secondary" onClick={handleClose}>
Cancel
</Button>
@ -876,10 +872,10 @@ function AllInvitesDialog({
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" className="gap-2">
<Button variant="secondary" className="gap-2">
<Link2 className="h-4 w-4 rotate-315" />
Active invites
<span className="inline-flex items-center justify-center h-5 min-w-5 px-1 rounded-full bg-muted text-xs font-medium">
<span className="inline-flex items-center justify-center h-5 min-w-5 px-1 rounded-full bg-neutral-700 text-neutral-200 text-xs font-medium">
{invites.length}
</span>
</Button>

View file

@ -0,0 +1,116 @@
"use client";
import { Check, Copy, Info } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useTranslations } from "next-intl";
import { useCallback, useRef, useState } from "react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { useApiKey } from "@/hooks/use-api-key";
import { copyToClipboard as copyToClipboardUtil } from "@/lib/utils";
export function ApiKeyContent() {
const t = useTranslations("userSettings");
const { apiKey, isLoading, copied, copyToClipboard } = useApiKey();
const [copiedUsage, setCopiedUsage] = useState(false);
const usageCopyTimeoutRef = useRef<ReturnType<typeof setTimeout>>(null);
const copyUsageToClipboard = useCallback(async () => {
const text = `Authorization: Bearer ${apiKey || "YOUR_API_KEY"}`;
const success = await copyToClipboardUtil(text);
if (success) {
setCopiedUsage(true);
if (usageCopyTimeoutRef.current) clearTimeout(usageCopyTimeoutRef.current);
usageCopyTimeoutRef.current = setTimeout(() => setCopiedUsage(false), 2000);
}
}, [apiKey]);
return (
<AnimatePresence mode="wait">
<motion.div
key="api-key-content"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.35, ease: [0.4, 0, 0.2, 1] }}
className="space-y-6"
>
<Alert className="border-border/60 bg-muted/30 text-muted-foreground">
<Info className="h-4 w-4 text-muted-foreground" />
<AlertTitle className="text-muted-foreground">{t("api_key_warning_title")}</AlertTitle>
<AlertDescription className="text-muted-foreground/60">
{t("api_key_warning_description")}
</AlertDescription>
</Alert>
<div className="rounded-lg border border-border/60 bg-card p-6">
<h3 className="mb-4 text-sm font-semibold tracking-tight">{t("your_api_key")}</h3>
{isLoading ? (
<div className="h-12 w-full animate-pulse rounded-md border border-border/60 bg-muted/30" />
) : apiKey ? (
<div className="flex items-center gap-2 rounded-md border border-border/60 bg-muted/30 px-2.5 py-1.5">
<div className="min-w-0 flex-1 overflow-x-auto scrollbar-hide">
<p className="font-mono text-[10px] text-muted-foreground whitespace-nowrap select-all cursor-text">
{apiKey}
</p>
</div>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={copyToClipboard}
className="h-6 w-6 shrink-0 text-muted-foreground hover:text-foreground"
>
{copied ? (
<Check className="h-3 w-3 text-green-500" />
) : (
<Copy className="h-3 w-3" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>{copied ? t("copied") : t("copy")}</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
) : (
<p className="text-center text-muted-foreground/60">{t("no_api_key")}</p>
)}
</div>
<div className="rounded-lg border border-border/60 bg-card p-6">
<h3 className="mb-2 text-sm font-semibold tracking-tight">{t("usage_title")}</h3>
<p className="mb-4 text-[11px] text-muted-foreground/60">{t("usage_description")}</p>
<div className="flex items-center gap-2 rounded-md border border-border/60 bg-muted/30 px-2.5 py-1.5">
<div className="min-w-0 flex-1 overflow-x-auto scrollbar-hide">
<pre className="font-mono text-[10px] text-muted-foreground whitespace-nowrap select-all cursor-text">
<code>Authorization: Bearer {apiKey || "YOUR_API_KEY"}</code>
</pre>
</div>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={copyUsageToClipboard}
className="h-6 w-6 shrink-0 text-muted-foreground hover:text-foreground"
>
{copiedUsage ? (
<Check className="h-3 w-3 text-green-500" />
) : (
<Copy className="h-3 w-3" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>{copiedUsage ? t("copied") : t("copy")}</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
</motion.div>
</AnimatePresence>
);
}

View file

@ -0,0 +1,129 @@
"use client";
import { useAtomValue } from "jotai";
import { AnimatePresence, motion } from "motion/react";
import Image from "next/image";
import { useTranslations } from "next-intl";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { updateUserMutationAtom } from "@/atoms/user/user-mutation.atoms";
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Spinner } from "@/components/ui/spinner";
function AvatarDisplay({ url, fallback }: { url?: string; fallback: string }) {
const [errorUrl, setErrorUrl] = useState<string>();
const hasError = errorUrl === url;
if (url && !hasError) {
return (
<Image
src={url}
alt="Avatar"
width={64}
height={64}
className="h-16 w-16 rounded-xl object-cover"
onError={() => setErrorUrl(url)}
unoptimized
/>
);
}
return (
<div className="flex h-16 w-16 items-center justify-center rounded-xl bg-muted text-xl font-semibold text-muted-foreground">
{fallback}
</div>
);
}
export function ProfileContent() {
const t = useTranslations("userSettings");
const { data: user, isLoading: isUserLoading } = useAtomValue(currentUserAtom);
const { mutateAsync: updateUser, isPending } = useAtomValue(updateUserMutationAtom);
const [displayName, setDisplayName] = useState("");
useEffect(() => {
if (user) {
setDisplayName(user.display_name || "");
}
}, [user]);
const getInitials = (email: string) => {
const name = email.split("@")[0];
return name.slice(0, 2).toUpperCase();
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
await updateUser({
display_name: displayName || null,
});
toast.success(t("profile_saved"));
} catch {
toast.error(t("profile_save_error"));
}
};
const hasChanges = displayName !== (user?.display_name || "");
return (
<AnimatePresence mode="wait">
<motion.div
key="profile-content"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.35, ease: [0.4, 0, 0.2, 1] }}
>
{isUserLoading ? (
<div className="flex items-center justify-center py-12">
<Spinner size="md" className="text-muted-foreground" />
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-6">
<div className="rounded-lg border bg-card p-6">
<div className="flex flex-col gap-6">
<div className="space-y-2">
<Label>{t("profile_avatar")}</Label>
<AvatarDisplay
url={user?.avatar_url || undefined}
fallback={getInitials(user?.email || "")}
/>
</div>
<div className="space-y-2">
<Label htmlFor="display-name">{t("profile_display_name")}</Label>
<Input
id="display-name"
type="text"
placeholder={user?.email?.split("@")[0]}
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
/>
<p className="text-xs text-muted-foreground">{t("profile_display_name_hint")}</p>
</div>
<div className="space-y-2">
<Label>{t("profile_email")}</Label>
<Input type="email" value={user?.email || ""} disabled />
</div>
</div>
</div>
<div className="flex justify-end">
<Button type="submit" disabled={isPending || !hasChanges}>
{isPending && <Spinner size="sm" className="mr-2" />}
{t("profile_save")}
</Button>
</div>
</form>
)}
</motion.div>
</AnimatePresence>
);
}

View file

@ -0,0 +1,57 @@
"use client";
import { User, UserKey } from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { useCallback } from "react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/animated-tabs";
import { ApiKeyContent } from "./components/ApiKeyContent";
import { ProfileContent } from "./components/ProfileContent";
const VALID_TABS = ["profile", "api-key"] as const;
const DEFAULT_TAB = "profile";
export default function UserSettingsPage() {
const t = useTranslations("userSettings");
const router = useRouter();
const searchParams = useSearchParams();
const tabParam = searchParams.get("tab") ?? "";
const activeTab = VALID_TABS.includes(tabParam as (typeof VALID_TABS)[number])
? tabParam
: DEFAULT_TAB;
const handleTabChange = useCallback(
(value: string) => {
const params = new URLSearchParams(searchParams.toString());
params.set("tab", value);
router.replace(`?${params.toString()}`, { scroll: false });
},
[router, searchParams]
);
return (
<div className="h-full overflow-y-auto">
<div className="mx-auto w-full max-w-4xl px-4 py-10">
<Tabs value={activeTab} onValueChange={handleTabChange} className="w-full">
<TabsList showBottomBorder>
<TabsTrigger value="profile">
<User className="mr-2 h-4 w-4" />
{t("profile_nav_label")}
</TabsTrigger>
<TabsTrigger value="api-key">
<UserKey className="mr-2 h-4 w-4" />
{t("api_key_nav_label")}
</TabsTrigger>
</TabsList>
<TabsContent value="profile" className="mt-6">
<ProfileContent />
</TabsContent>
<TabsContent value="api-key" className="mt-6">
<ApiKeyContent />
</TabsContent>
</Tabs>
</div>
</div>
);
}

View file

@ -1,122 +0,0 @@
"use client";
import { Check, Copy, Key, Menu, Shield } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useTranslations } from "next-intl";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { useApiKey } from "@/hooks/use-api-key";
interface ApiKeyContentProps {
onMenuClick: () => void;
}
export function ApiKeyContent({ onMenuClick }: ApiKeyContentProps) {
const t = useTranslations("userSettings");
const { apiKey, isLoading, copied, copyToClipboard } = useApiKey();
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.2, duration: 0.4 }}
className="h-full min-w-0 flex-1 overflow-hidden bg-background"
>
<div className="h-full overflow-y-auto">
<div className="mx-auto max-w-4xl p-4 md:p-6 lg:p-10">
<AnimatePresence mode="wait">
<motion.div
key="api-key-header"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.3 }}
className="mb-6 md:mb-8"
>
<div className="flex items-center gap-3 md:gap-4">
<Button
variant="outline"
size="icon"
onClick={onMenuClick}
className="h-10 w-10 shrink-0 md:hidden"
>
<Menu className="h-5 w-5" />
</Button>
<motion.div
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ delay: 0.1, duration: 0.3 }}
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border border-primary/10 bg-gradient-to-br from-primary/20 to-primary/5 shadow-sm md:h-14 md:w-14 md:rounded-2xl"
>
<Key className="h-5 w-5 text-primary md:h-7 md:w-7" />
</motion.div>
<div className="min-w-0">
<h1 className="truncate text-lg font-bold tracking-tight md:text-2xl">
{t("api_key_title")}
</h1>
<p className="text-sm text-muted-foreground">{t("api_key_description")}</p>
</div>
</div>
</motion.div>
</AnimatePresence>
<AnimatePresence mode="wait">
<motion.div
key="api-key-content"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.35, ease: [0.4, 0, 0.2, 1] }}
className="space-y-6"
>
<Alert>
<Shield className="h-4 w-4" />
<AlertTitle>{t("api_key_warning_title")}</AlertTitle>
<AlertDescription>{t("api_key_warning_description")}</AlertDescription>
</Alert>
<div className="rounded-lg border bg-card p-6">
<h3 className="mb-4 font-medium">{t("your_api_key")}</h3>
{isLoading ? (
<div className="h-12 w-full animate-pulse rounded-md bg-muted" />
) : apiKey ? (
<div className="flex items-center gap-2">
<div className="flex-1 overflow-x-auto rounded-md bg-muted p-3 font-mono text-sm">
{apiKey}
</div>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={copyToClipboard}
className="shrink-0"
>
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</Button>
</TooltipTrigger>
<TooltipContent>{copied ? t("copied") : t("copy")}</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
) : (
<p className="text-center text-muted-foreground">{t("no_api_key")}</p>
)}
</div>
<div className="rounded-lg border bg-card p-6">
<h3 className="mb-2 font-medium">{t("usage_title")}</h3>
<p className="mb-4 text-sm text-muted-foreground">{t("usage_description")}</p>
<pre className="overflow-x-auto rounded-md bg-muted p-3 text-sm">
<code>Authorization: Bearer {apiKey || "YOUR_API_KEY"}</code>
</pre>
</div>
</motion.div>
</AnimatePresence>
</div>
</div>
</motion.div>
);
}

View file

@ -1,182 +0,0 @@
"use client";
import { useAtomValue } from "jotai";
import { Menu, User } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useTranslations } from "next-intl";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { updateUserMutationAtom } from "@/atoms/user/user-mutation.atoms";
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Spinner } from "@/components/ui/spinner";
interface ProfileContentProps {
onMenuClick: () => void;
}
function AvatarDisplay({ url, fallback }: { url?: string; fallback: string }) {
const [hasError, setHasError] = useState(false);
useEffect(() => {
setHasError(false);
}, [url]);
if (url && !hasError) {
return (
<img
src={url}
alt="Avatar"
className="h-16 w-16 rounded-xl object-cover"
onError={() => setHasError(true)}
/>
);
}
return (
<div className="flex h-16 w-16 items-center justify-center rounded-xl bg-muted text-xl font-semibold text-muted-foreground">
{fallback}
</div>
);
}
export function ProfileContent({ onMenuClick }: ProfileContentProps) {
const t = useTranslations("userSettings");
const { data: user, isLoading: isUserLoading } = useAtomValue(currentUserAtom);
const { mutateAsync: updateUser, isPending } = useAtomValue(updateUserMutationAtom);
const [displayName, setDisplayName] = useState("");
useEffect(() => {
if (user) {
setDisplayName(user.display_name || "");
}
}, [user]);
const getInitials = (email: string) => {
const name = email.split("@")[0];
return name.slice(0, 2).toUpperCase();
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
await updateUser({
display_name: displayName || null,
});
toast.success(t("profile_saved"));
} catch {
toast.error(t("profile_save_error"));
}
};
const hasChanges = displayName !== (user?.display_name || "");
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.2, duration: 0.4 }}
className="h-full min-w-0 flex-1 overflow-hidden bg-background"
>
<div className="h-full overflow-y-auto">
<div className="mx-auto max-w-4xl p-4 md:p-6 lg:p-10">
<AnimatePresence mode="wait">
<motion.div
key="profile-header"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.3 }}
className="mb-6 md:mb-8"
>
<div className="flex items-center gap-3 md:gap-4">
<Button
variant="outline"
size="icon"
onClick={onMenuClick}
className="h-10 w-10 shrink-0 md:hidden"
>
<Menu className="h-5 w-5" />
</Button>
<motion.div
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ delay: 0.1, duration: 0.3 }}
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border border-primary/10 bg-gradient-to-br from-primary/20 to-primary/5 shadow-sm md:h-14 md:w-14 md:rounded-2xl"
>
<User className="h-5 w-5 text-primary md:h-7 md:w-7" />
</motion.div>
<div className="min-w-0">
<h1 className="truncate text-lg font-bold tracking-tight md:text-2xl">
{t("profile_title")}
</h1>
<p className="text-sm text-muted-foreground">{t("profile_description")}</p>
</div>
</div>
</motion.div>
</AnimatePresence>
<AnimatePresence mode="wait">
<motion.div
key="profile-content"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.35, ease: [0.4, 0, 0.2, 1] }}
>
{isUserLoading ? (
<div className="flex items-center justify-center py-12">
<Spinner size="md" className="text-muted-foreground" />
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-6">
<div className="rounded-lg border bg-card p-6">
<div className="flex flex-col gap-6">
<div className="space-y-2">
<Label>{t("profile_avatar")}</Label>
<AvatarDisplay
url={user?.avatar_url || undefined}
fallback={getInitials(user?.email || "")}
/>
</div>
<div className="space-y-2">
<Label htmlFor="display-name">{t("profile_display_name")}</Label>
<Input
id="display-name"
type="text"
placeholder={user?.email?.split("@")[0]}
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
{t("profile_display_name_hint")}
</p>
</div>
<div className="space-y-2">
<Label>{t("profile_email")}</Label>
<Input type="email" value={user?.email || ""} disabled />
</div>
</div>
</div>
<div className="flex justify-end">
<Button type="submit" disabled={isPending || !hasChanges}>
{isPending && <Spinner size="sm" className="mr-2" />}
{t("profile_save")}
</Button>
</div>
</form>
)}
</motion.div>
</AnimatePresence>
</div>
</div>
</motion.div>
);
}

View file

@ -1,160 +0,0 @@
"use client";
import type { LucideIcon } from "lucide-react";
import { ArrowLeft, ChevronRight, X } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { APP_VERSION } from "@/lib/env-config";
import { cn } from "@/lib/utils";
export interface SettingsNavItem {
id: string;
label: string;
description: string;
icon: LucideIcon;
}
interface UserSettingsSidebarProps {
activeSection: string;
onSectionChange: (section: string) => void;
onBackToApp: () => void;
isOpen: boolean;
onClose: () => void;
navItems: SettingsNavItem[];
}
export function UserSettingsSidebar({
activeSection,
onSectionChange,
onBackToApp,
isOpen,
onClose,
navItems,
}: UserSettingsSidebarProps) {
const t = useTranslations("userSettings");
const handleNavClick = (sectionId: string) => {
onSectionChange(sectionId);
onClose();
};
return (
<>
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="fixed inset-0 z-40 bg-background/80 backdrop-blur-sm md:hidden"
onClick={onClose}
/>
)}
</AnimatePresence>
<aside
className={cn(
"fixed left-0 top-0 z-50 md:relative md:z-auto",
"flex h-full w-72 shrink-0 flex-col bg-background md:bg-muted/30",
"md:border-r",
"transition-transform duration-300 ease-out",
"md:translate-x-0",
isOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"
)}
>
{/* Header with title */}
<div className="space-y-3 p-4">
<div className="flex items-center justify-between">
<Button
variant="ghost"
onClick={onBackToApp}
className="group h-11 justify-start gap-3 px-3 hover:bg-muted"
>
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/10 transition-colors group-hover:bg-primary/20">
<ArrowLeft className="h-4 w-4 text-primary" />
</div>
<span className="font-medium">{t("back_to_app")}</span>
</Button>
<Button variant="ghost" size="icon" onClick={onClose} className="h-9 w-9 md:hidden">
<X className="h-5 w-5" />
</Button>
</div>
{/* Settings Title */}
<div className="px-3">
<h2 className="text-lg font-semibold text-foreground">{t("title")}</h2>
</div>
</div>
<nav className="flex-1 space-y-1 overflow-y-auto px-3 py-2">
{navItems.map((item, index) => {
const isActive = activeSection === item.id;
const Icon = item.icon;
return (
<motion.button
key={item.id}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.1 + index * 0.05, duration: 0.3 }}
onClick={() => handleNavClick(item.id)}
whileHover={{ scale: 1.01 }}
whileTap={{ scale: 0.99 }}
className={cn(
"relative flex w-full items-center gap-3 rounded-xl px-3 py-3 text-left transition-all duration-200",
isActive ? "border border-border bg-muted shadow-sm" : "hover:bg-muted/60"
)}
>
{isActive && (
<motion.div
layoutId="userSettingsActiveIndicator"
className="absolute left-0 top-1/2 h-8 w-1 -translate-y-1/2 rounded-r-full bg-primary"
initial={false}
transition={{
type: "spring",
stiffness: 500,
damping: 35,
}}
/>
)}
<div
className={cn(
"flex h-9 w-9 items-center justify-center rounded-lg transition-colors",
isActive ? "bg-primary/10 text-primary" : "bg-muted text-muted-foreground"
)}
>
<Icon className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<p
className={cn(
"truncate text-sm font-medium transition-colors",
isActive ? "text-foreground" : "text-muted-foreground"
)}
>
{item.label}
</p>
<p className="truncate text-xs text-muted-foreground/70">{item.description}</p>
</div>
<ChevronRight
className={cn(
"h-4 w-4 shrink-0 transition-all",
isActive
? "translate-x-0 text-primary opacity-100"
: "-translate-x-1 text-muted-foreground/40 opacity-0"
)}
/>
</motion.button>
);
})}
</nav>
{/* Version display */}
<div className="mt-auto border-t px-6 py-3">
<p className="text-xs text-muted-foreground/50">v{APP_VERSION}</p>
</div>
</aside>
</>
);
}

View file

@ -1,64 +0,0 @@
"use client";
import { Key, User } from "lucide-react";
import { motion } from "motion/react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useCallback, useState } from "react";
import { ApiKeyContent } from "./components/ApiKeyContent";
import { ProfileContent } from "./components/ProfileContent";
import { type SettingsNavItem, UserSettingsSidebar } from "./components/UserSettingsSidebar";
export default function UserSettingsPage() {
const t = useTranslations("userSettings");
const router = useRouter();
const [activeSection, setActiveSection] = useState("profile");
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const navItems: SettingsNavItem[] = [
{
id: "profile",
label: t("profile_nav_label"),
description: t("profile_nav_description"),
icon: User,
},
{
id: "api-key",
label: t("api_key_nav_label"),
description: t("api_key_nav_description"),
icon: Key,
},
];
const handleBackToApp = useCallback(() => {
router.back();
}, [router]);
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
className="fixed inset-0 z-50 flex bg-muted/40"
>
<div className="flex h-full w-full p-0 md:p-2">
<div className="flex h-full w-full overflow-hidden bg-background md:rounded-xl md:border md:shadow-sm">
<UserSettingsSidebar
activeSection={activeSection}
onSectionChange={setActiveSection}
onBackToApp={handleBackToApp}
isOpen={isSidebarOpen}
onClose={() => setIsSidebarOpen(false)}
navItems={navItems}
/>
{activeSection === "profile" && (
<ProfileContent onMenuClick={() => setIsSidebarOpen(true)} />
)}
{activeSection === "api-key" && (
<ApiKeyContent onMenuClick={() => setIsSidebarOpen(true)} />
)}
</div>
</div>
</motion.div>
);
}

View file

@ -195,6 +195,18 @@ button {
background-color: hsl(var(--muted-foreground) / 0.4);
}
/* Hide scrollbar on mobile, only visible while scrolling */
@media (max-width: 767px) {
.scrollbar-thin {
scrollbar-width: none;
}
.scrollbar-thin::-webkit-scrollbar {
width: 0;
display: none;
}
}
/* Human-in-the-loop approval card animations */
@keyframes pulse-subtle {
0%,

View file

@ -10,6 +10,7 @@ export const membersAtom = atomWithQuery((get) => {
queryKey: cacheKeys.members.all(searchSpaceId?.toString() ?? ""),
enabled: !!searchSpaceId,
staleTime: 3 * 1000, // 3 seconds - short staleness for live collaboration
refetchInterval: 2 * 60 * 1000, // 2 minutes
queryFn: async () => {
if (!searchSpaceId) {
return [];

View file

@ -379,7 +379,7 @@ export const ConnectorIndicator: FC = () => {
: "You need to configure a Document Summary LLM before adding connectors. This LLM is used to process and summarize documents from your connected sources."}
</p>
<Button asChild size="sm" variant="outline">
<Link href={`/dashboard/${searchSpaceId}/settings?section=models`}>
<Link href={`/dashboard/${searchSpaceId}/settings?tab=models`}>
<Settings className="mr-2 h-4 w-4" />
Go to Settings
</Link>

View file

@ -158,7 +158,7 @@ const DocumentUploadPopupContent: FC<{
: "You need to configure a Document Summary LLM before uploading files. This LLM is used to process and summarize your uploaded documents."}
</p>
<Button asChild size="sm" variant="outline">
<Link href={`/dashboard/${searchSpaceId}/settings?section=models`}>
<Link href={`/dashboard/${searchSpaceId}/settings?tab=models`}>
<Settings className="mr-2 h-4 w-4" />
Go to Settings
</Link>

View file

@ -1,6 +1,6 @@
import { ActionBarPrimitive, MessagePrimitive, useAssistantState } from "@assistant-ui/react";
import { useAtomValue } from "jotai";
import { FileText, PencilIcon } from "lucide-react";
import { FileText, Pen } from "lucide-react";
import { type FC, useState } from "react";
import { messageDocumentsMapAtom } from "@/atoms/chat/mentioned-documents.atom";
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
@ -125,7 +125,7 @@ const UserActionBar: FC = () => {
{canEdit && (
<ActionBarPrimitive.Edit asChild>
<TooltipIconButton tooltip="Edit" className="aui-user-action-edit p-4">
<PencilIcon />
<Pen />
</TooltipIconButton>
</ActionBarPrimitive.Edit>
)}

View file

@ -0,0 +1,481 @@
"use client";
import { IconBrandGithub } from "@tabler/icons-react";
import { StarIcon } from "lucide-react";
import type { HTMLMotionProps, UseInViewOptions } from "motion/react";
import {
AnimatePresence,
motion,
useInView,
useMotionValue,
useSpring,
useTransform,
} from "motion/react";
import * as React from "react";
import { cn } from "@/lib/utils";
// ---------------------------------------------------------------------------
// Utilities
// ---------------------------------------------------------------------------
function getStrictContext<T>(name?: string) {
const Context = React.createContext<T | undefined>(undefined);
const Provider = ({ value, children }: { value: T; children?: React.ReactNode }) => (
<Context.Provider value={value}>{children}</Context.Provider>
);
const useSafeContext = () => {
const ctx = React.useContext(Context);
if (ctx === undefined) {
throw new Error(`useContext must be used within ${name ?? "a Provider"}`);
}
return ctx;
};
return [Provider, useSafeContext] as const;
}
interface UseIsInViewOptions {
inView?: boolean;
inViewOnce?: boolean;
inViewMargin?: UseInViewOptions["margin"];
}
function useIsInView<T extends HTMLElement = HTMLElement>(
ref: React.Ref<T>,
options: UseIsInViewOptions = {}
) {
const { inView, inViewOnce = false, inViewMargin = "0px" } = options;
const localRef = React.useRef<T>(null);
React.useImperativeHandle(ref, () => localRef.current as T);
const inViewResult = useInView(localRef, {
once: inViewOnce,
margin: inViewMargin,
});
const isInView = !inView || inViewResult;
return { ref: localRef, isInView };
}
// ---------------------------------------------------------------------------
// Particles (for star burst effect on completion)
// ---------------------------------------------------------------------------
type ParticlesContextType = { animate: boolean; isInView: boolean };
const [ParticlesProvider, useParticles] =
getStrictContext<ParticlesContextType>("ParticlesContext");
function Particles({
ref,
animate = true,
inView = false,
inViewMargin = "0px",
inViewOnce = true,
children,
style,
...props
}: Omit<HTMLMotionProps<"div">, "children"> & {
animate?: boolean;
children: React.ReactNode;
} & UseIsInViewOptions) {
const { ref: localRef, isInView } = useIsInView(ref as React.Ref<HTMLDivElement>, {
inView,
inViewOnce,
inViewMargin,
});
return (
<ParticlesProvider value={{ animate, isInView }}>
<motion.div ref={localRef} style={{ position: "relative", ...style }} {...props}>
{children}
</motion.div>
</ParticlesProvider>
);
}
function ParticlesEffect({
side = "top",
align = "center",
count = 6,
radius = 30,
spread = 360,
duration = 0.8,
holdDelay = 0.05,
sideOffset = 0,
alignOffset = 0,
delay = 0,
transition,
style,
...props
}: Omit<HTMLMotionProps<"div">, "children"> & {
side?: "top" | "bottom" | "left" | "right";
align?: "start" | "center" | "end";
count?: number;
radius?: number;
spread?: number;
duration?: number;
holdDelay?: number;
sideOffset?: number;
alignOffset?: number;
delay?: number;
}) {
const { animate, isInView } = useParticles();
const isVertical = side === "top" || side === "bottom";
const alignPct = align === "start" ? "0%" : align === "end" ? "100%" : "50%";
const top = isVertical
? side === "top"
? `calc(0% - ${sideOffset}px)`
: `calc(100% + ${sideOffset}px)`
: `calc(${alignPct} + ${alignOffset}px)`;
const left = isVertical
? `calc(${alignPct} + ${alignOffset}px)`
: side === "left"
? `calc(0% - ${sideOffset}px)`
: `calc(100% + ${sideOffset}px)`;
const containerStyle: React.CSSProperties = {
position: "absolute",
top,
left,
transform: "translate(-50%, -50%)",
};
const angleStep = (spread * (Math.PI / 180)) / Math.max(1, count - 1);
return (
<AnimatePresence>
{animate &&
isInView &&
[...Array(count)].map((_, i) => {
const angle = i * angleStep;
const x = Math.cos(angle) * radius;
const y = Math.sin(angle) * radius;
return (
<motion.div
key={`particle-${angle}`}
style={{ ...containerStyle, ...style }}
initial={{ scale: 0, opacity: 0 }}
animate={{
x: `${x}px`,
y: `${y}px`,
scale: [0, 1, 0],
opacity: [0, 1, 0],
}}
transition={{
duration,
delay: delay + i * holdDelay,
ease: "easeOut",
...transition,
}}
{...props}
/>
);
})}
</AnimatePresence>
);
}
// ---------------------------------------------------------------------------
// Per-digit scrolling wheel
// ---------------------------------------------------------------------------
const ROLLING_ITEM_COUNT = 200;
function DigitWheel({
digit,
itemSize = 22,
delay = 0,
cycles = 5,
isRolling = false,
reverse = false,
className,
onSettled,
}: {
digit: number;
itemSize?: number;
delay?: number;
cycles?: number;
isRolling?: boolean;
reverse?: boolean;
className?: string;
onSettled?: () => void;
}) {
const sequence = React.useMemo(() => {
if (isRolling) {
return Array.from({ length: ROLLING_ITEM_COUNT }, (_, i) => ({
id: `r${i}`,
value: i % 10,
}));
}
const seq = Array.from({ length: cycles * 10 }, (_, i) => ({
id: `s${i}`,
value: Math.floor(Math.random() * 10),
}));
const target = { id: "target", value: digit };
if (reverse) {
seq.unshift(target);
} else {
seq.push(target);
}
return seq;
}, [digit, cycles, isRolling, reverse]);
const maxOffset = (sequence.length - 1) * itemSize;
const endY = reverse ? 0 : -maxOffset;
const rollingStartItem = React.useRef(Math.floor(Math.random() * 10));
const startOffset = rollingStartItem.current * itemSize;
const y = useMotionValue(
isRolling ? (reverse ? -(maxOffset - startOffset) : -startOffset) : reverse ? -maxOffset : 0
);
const ySpring = useSpring(
y,
isRolling ? { stiffness: 10000, damping: 500 } : { stiffness: 70, damping: 20 }
);
const settledRef = React.useRef(false);
const wasRollingRef = React.useRef(isRolling);
// Jump y to settling start position when transitioning from rolling → settled
React.useLayoutEffect(() => {
if (wasRollingRef.current && !isRolling) {
y.jump(reverse ? -maxOffset : 0);
}
wasRollingRef.current = isRolling;
}, [isRolling, reverse, maxOffset, y]);
// Rolling: drive y continuously via RAF (stiff spring tracks it transparently)
React.useEffect(() => {
if (!isRolling) return;
const cycleHeight = 10 * itemSize;
const msPerCycle = 1000;
let startTime: number | null = null;
let rafId: number;
const tick = (time: number) => {
if (startTime === null) startTime = time;
const elapsed = time - startTime;
const speed = cycleHeight / msPerCycle;
const travel = elapsed * speed + startOffset;
if (reverse) {
y.set(Math.min(-maxOffset + travel, 0));
} else {
y.set(Math.max(-travel, -maxOffset));
}
rafId = requestAnimationFrame(tick);
};
rafId = requestAnimationFrame(tick);
return () => cancelAnimationFrame(rafId);
}, [isRolling, itemSize, reverse, y, maxOffset, startOffset]);
// Settling: spring to endY after delay
React.useEffect(() => {
if (isRolling) return;
settledRef.current = false;
const timer = setTimeout(() => y.set(endY), delay);
return () => clearTimeout(timer);
}, [endY, y, delay, isRolling]);
// Detect settled
React.useEffect(() => {
if (isRolling) return;
const unsub = ySpring.on("change", (latest) => {
if (!settledRef.current && Math.abs(latest - endY) < 0.5) {
settledRef.current = true;
onSettled?.();
}
});
return unsub;
}, [ySpring, endY, onSettled, isRolling]);
return (
<div style={{ height: itemSize, overflow: "hidden" }}>
<motion.div style={{ y: ySpring }}>
{sequence.map((item) => (
<div
key={item.id}
className={className}
style={{
height: itemSize,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{item.value}
</div>
))}
</motion.div>
</div>
);
}
// ---------------------------------------------------------------------------
// Animated star count with per-digit alternating wheels
// ---------------------------------------------------------------------------
const numberFormatter = new Intl.NumberFormat("en-US");
function AnimatedStarCount({
value,
itemSize = 22,
isRolling = false,
className,
onComplete,
}: {
value: number;
itemSize?: number;
isRolling?: boolean;
className?: string;
onComplete?: () => void;
}) {
const formatted = numberFormatter.format(value);
const chars = formatted.split("");
let totalDigits = 0;
for (const c of chars) {
if (c >= "0" && c <= "9") totalDigits++;
}
const settledCount = React.useRef(0);
const completedRef = React.useRef(false);
const handleDigitSettled = React.useCallback(() => {
settledCount.current++;
if (!completedRef.current && settledCount.current >= totalDigits) {
completedRef.current = true;
onComplete?.();
}
}, [totalDigits, onComplete]);
let digitIndex = 0;
let separatorIndex = 0;
return (
<div className="flex items-center">
{chars.map((char) => {
if (char < "0" || char > "9") {
const sepKey = `sep-${separatorIndex++}`;
return (
<div
key={sepKey}
className={className}
style={{
height: itemSize,
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "0.3em",
}}
>
{char}
</div>
);
}
const digit = parseInt(char, 10);
const idx = digitIndex++;
return (
<DigitWheel
key={`digit-${idx}`}
digit={digit}
itemSize={itemSize}
delay={idx * 150}
cycles={5}
isRolling={isRolling}
reverse={idx % 2 === 1}
className={className}
onSettled={handleDigitSettled}
/>
);
})}
</div>
);
}
// ---------------------------------------------------------------------------
// NavbarGitHubStars — the exported component
// ---------------------------------------------------------------------------
const ITEM_SIZE = 22;
type NavbarGitHubStarsProps = {
username?: string;
repo?: string;
href?: string;
className?: string;
};
function NavbarGitHubStars({
username = "MODSetter",
repo = "SurfSense",
href = "https://github.com/MODSetter/SurfSense",
className,
}: NavbarGitHubStarsProps) {
const [stars, setStars] = React.useState(0);
const [isLoading, setIsLoading] = React.useState(true);
const [isCompleted, setIsCompleted] = React.useState(false);
const fillRaw = useMotionValue(0);
const fillSpring = useSpring(fillRaw, { stiffness: 12, damping: 14 });
const clipPath = useTransform(fillSpring, (v) => `inset(${100 - v * 100}% 0 0 0)`);
React.useEffect(() => {
const abortController = new AbortController();
fetch(`https://api.github.com/repos/${username}/${repo}`, {
signal: abortController.signal,
})
.then((res) => res.json())
.then((data) => {
if (data && typeof data.stargazers_count === "number") {
setStars(data.stargazers_count);
fillRaw.set(1);
}
})
.catch((err) => {
if (err instanceof Error && err.name !== "AbortError") {
console.error("Error fetching stars:", err);
}
})
.finally(() => setIsLoading(false));
return () => abortController.abort();
}, [username, repo, fillRaw]);
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className={cn(
"group flex items-center gap-2 rounded-full px-3 py-1.5 transition-colors",
className
)}
>
<IconBrandGithub className="h-5 w-5 text-neutral-600 dark:text-neutral-300 shrink-0" />
<div className="flex items-center gap-1 rounded-md bg-neutral-100 dark:bg-neutral-800 group-hover:bg-neutral-200 dark:group-hover:bg-neutral-700 px-2 py-0.5 transition-colors">
<AnimatedStarCount
value={isLoading ? 10000 : stars}
itemSize={ITEM_SIZE}
isRolling={isLoading}
className="text-sm font-semibold tabular-nums text-neutral-500 dark:text-neutral-400 group-hover:text-neutral-800 dark:group-hover:text-neutral-200 transition-colors"
onComplete={() => setIsCompleted(true)}
/>
<Particles animate={isCompleted}>
<div className="relative size-4">
<StarIcon
aria-hidden="true"
className="absolute inset-0 size-4 fill-neutral-400 stroke-neutral-400 dark:fill-neutral-700 dark:stroke-neutral-700 group-hover:fill-neutral-600 group-hover:stroke-neutral-600 dark:group-hover:fill-neutral-300 dark:group-hover:stroke-neutral-300 transition-colors"
/>
<motion.div className="absolute inset-0" style={{ clipPath }}>
<StarIcon
aria-hidden="true"
className="size-4 fill-neutral-300 stroke-neutral-300 dark:fill-neutral-400 dark:stroke-neutral-400 group-hover:fill-neutral-500 group-hover:stroke-neutral-500 dark:group-hover:fill-neutral-200 dark:group-hover:stroke-neutral-200 transition-colors"
/>
</motion.div>
</div>
<ParticlesEffect
delay={0.3}
className="size-1 rounded-full bg-neutral-300 dark:bg-neutral-400"
/>
</Particles>
</div>
</a>
);
}
export { NavbarGitHubStars, type NavbarGitHubStarsProps };

View file

@ -1,18 +1,12 @@
"use client";
import {
IconBrandDiscord,
IconBrandGithub,
IconBrandReddit,
IconMenu2,
IconX,
} from "@tabler/icons-react";
import { IconBrandDiscord, IconBrandReddit, IconMenu2, IconX } from "@tabler/icons-react";
import { AnimatePresence, motion } from "motion/react";
import Link from "next/link";
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { SignInButton } from "@/components/auth/sign-in-button";
import { NavbarGitHubStars } from "@/components/homepage/github-stars-badge";
import { Logo } from "@/components/Logo";
import { ThemeTogglerComponent } from "@/components/theme/theme-toggle";
import { useGithubStars } from "@/hooks/use-github-stars";
import { cn } from "@/lib/utils";
export const Navbar = () => {
@ -38,7 +32,7 @@ export const Navbar = () => {
}, []);
return (
<div className="fixed top-1 left-0 right-0 z-60 w-full">
<div className="fixed top-1 left-0 right-0 z-60 w-full select-none">
<DesktopNav navItems={navItems} isScrolled={isScrolled} />
<MobileNav navItems={navItems} isScrolled={isScrolled} />
</div>
@ -47,7 +41,6 @@ export const Navbar = () => {
const DesktopNav = ({ navItems, isScrolled }: any) => {
const [hovered, setHovered] = useState<number | null>(null);
const { compactFormat: githubStars, loading: loadingGithubStars } = useGithubStars();
return (
<motion.div
onMouseLeave={() => {
@ -103,21 +96,7 @@ const DesktopNav = ({ navItems, isScrolled }: any) => {
>
<IconBrandReddit className="h-5 w-5 text-neutral-600 dark:text-neutral-300" />
</Link>
<Link
href="https://github.com/MODSetter/SurfSense"
target="_blank"
rel="noopener noreferrer"
className="hidden rounded-full px-3 py-2 hover:bg-gray-100 dark:hover:bg-neutral-800 transition-colors md:flex items-center gap-1.5"
>
<IconBrandGithub className="h-5 w-5 text-neutral-600 dark:text-neutral-300" />
{loadingGithubStars ? (
<div className="w-6 h-5 dark:bg-neutral-800 animate-pulse"></div>
) : (
<span className="text-sm font-medium text-neutral-600 dark:text-neutral-300">
{githubStars}
</span>
)}
</Link>
<NavbarGitHubStars className="hidden md:flex" />
<ThemeTogglerComponent />
<SignInButton variant="desktop" />
</div>
@ -127,10 +106,28 @@ const DesktopNav = ({ navItems, isScrolled }: any) => {
const MobileNav = ({ navItems, isScrolled }: any) => {
const [open, setOpen] = useState(false);
const { compactFormat: githubStars, loading: loadingGithubStars } = useGithubStars();
const navRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const handleClickOutside = (e: MouseEvent | TouchEvent) => {
if (navRef.current && !navRef.current.contains(e.target as Node)) {
setOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
document.addEventListener("touchstart", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("touchstart", handleClickOutside);
};
}, [open]);
return (
<motion.div
ref={navRef}
animate={{ borderRadius: open ? "4px" : "2rem" }}
key={String(open)}
className={cn(
@ -197,21 +194,7 @@ const MobileNav = ({ navItems, isScrolled }: any) => {
>
<IconBrandReddit className="h-5 w-5 text-neutral-600 dark:text-neutral-300" />
</Link>
<Link
href="https://github.com/MODSetter/SurfSense"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 rounded-lg px-3 py-2 hover:bg-gray-100 dark:hover:bg-neutral-800 transition-colors touch-manipulation"
>
<IconBrandGithub className="h-5 w-5 text-neutral-600 dark:text-neutral-300" />
{loadingGithubStars ? (
<div className="w-6 h-5 dark:bg-neutral-800 animate-pulse"></div>
) : (
<span className="text-sm font-medium text-neutral-600 dark:text-neutral-300">
{githubStars}
</span>
)}
</Link>
<NavbarGitHubStars className="rounded-lg" />
<ThemeTogglerComponent />
</div>
<SignInButton variant="mobile" />

View file

@ -6,7 +6,7 @@ import { AlertTriangle, Inbox, Megaphone, SquareLibrary } from "lucide-react";
import { useParams, usePathname, useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useTheme } from "next-themes";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { currentThreadAtom, resetCurrentThreadAtom } from "@/atoms/chat/current-thread.atom";
import { documentsSidebarOpenAtom } from "@/atoms/documents/ui.atoms";
@ -86,6 +86,10 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
// State for handling new chat navigation when router is out of sync
const [pendingNewChat, setPendingNewChat] = useState(false);
// Key used to force-remount the page component (e.g. after deleting the active chat
// when the router is out of sync due to replaceState)
const [chatResetKey, setChatResetKey] = useState(0);
// Current IDs from URL, with fallback to atom for replaceState updates
const currentChatId = params?.chat_id
? Number(Array.isArray(params.chat_id) ? params.chat_id[0] : params.chat_id)
@ -132,8 +136,8 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
const totalUnreadCount = commentsInbox.unreadCount + statusInbox.unreadCount;
// Whether any documents are currently being uploaded/indexed — drives sidebar spinner
const isDocumentsProcessing = useDocumentsProcessing(numericSpaceId);
// Document processing status — drives sidebar status indicator (spinner / check / error)
const documentsProcessingStatus = useDocumentsProcessing(numericSpaceId);
// Track seen notification IDs to detect new page_limit_exceeded notifications
const seenPageLimitNotifications = useRef<Set<number>>(new Set());
@ -271,7 +275,7 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
url: "#documents",
icon: SquareLibrary,
isActive: isDocumentsSidebarOpen,
showSpinner: isDocumentsProcessing,
statusIndicator: documentsProcessingStatus,
},
{
title: "Announcements",
@ -287,7 +291,7 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
totalUnreadCount,
isAnnouncementsSidebarOpen,
announcementUnreadCount,
isDocumentsProcessing,
documentsProcessingStatus,
]
);
@ -304,12 +308,12 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
}, []);
const handleUserSettings = useCallback(() => {
router.push("/dashboard/user/settings");
}, [router]);
router.push(`/dashboard/${searchSpaceId}/user-settings?tab=profile`);
}, [router, searchSpaceId]);
const handleSearchSpaceSettings = useCallback(
(space: SearchSpace) => {
router.push(`/dashboard/${space.id}/settings?section=general`);
router.push(`/dashboard/${space.id}/settings?tab=general`);
},
[router]
);
@ -478,7 +482,7 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
);
const handleSettings = useCallback(() => {
router.push(`/dashboard/${searchSpaceId}/settings?section=general`);
router.push(`/dashboard/${searchSpaceId}/settings?tab=general`);
}, [router, searchSpaceId]);
const handleManageMembers = useCallback(() => {
@ -535,7 +539,14 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
await deleteThread(chatToDelete.id);
queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId] });
if (currentChatId === chatToDelete.id) {
router.push(`/dashboard/${searchSpaceId}/new-chat`);
resetCurrentThread();
const isOutOfSync = currentThreadState.id !== null && !params?.chat_id;
if (isOutOfSync) {
window.history.replaceState(null, "", `/dashboard/${searchSpaceId}/new-chat`);
setChatResetKey((k) => k + 1);
} else {
router.push(`/dashboard/${searchSpaceId}/new-chat`);
}
}
} catch (error) {
console.error("Error deleting thread:", error);
@ -544,7 +555,16 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
setShowDeleteChatDialog(false);
setChatToDelete(null);
}
}, [chatToDelete, queryClient, searchSpaceId, router, currentChatId]);
}, [
chatToDelete,
queryClient,
searchSpaceId,
resetCurrentThread,
currentChatId,
currentThreadState.id,
params?.chat_id,
router,
]);
// Rename handler
const confirmRenameChat = useCallback(async () => {
@ -660,7 +680,7 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
onOpenChange: setIsDocumentsSidebarOpen,
}}
>
{children}
<Fragment key={chatResetKey}>{children}</Fragment>
</LayoutShell>
{/* Delete Chat Dialog */}

View file

@ -1,4 +1,5 @@
import type { LucideIcon } from "lucide-react";
import type { DocumentsProcessingStatus } from "@/hooks/use-documents-processing";
export interface SearchSpace {
id: number;
@ -21,7 +22,7 @@ export interface NavItem {
icon: LucideIcon;
isActive?: boolean;
badge?: string | number;
showSpinner?: boolean;
statusIndicator?: DocumentsProcessingStatus;
}
export interface ChatItem {

View file

@ -39,7 +39,7 @@ export function Header({ mobileMenuTrigger }: HeaderProps) {
const handleVisibilityChange = (_visibility: ChatVisibility) => {};
return (
<header className="sticky top-0 z-10 flex h-14 shrink-0 items-center gap-2 bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60 px-4 md:border-b md:border-border">
<header className="sticky top-0 z-10 flex h-14 shrink-0 items-center gap-2 bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60 px-4">
{/* Left side - Mobile menu trigger + Model selector */}
<div className="flex flex-1 items-center gap-2 min-w-0">
{mobileMenuTrigger}

View file

@ -20,6 +20,7 @@ import {
} from "@/components/ui/dropdown-menu";
import { useLongPress } from "@/hooks/use-long-press";
import { useIsMobile } from "@/hooks/use-mobile";
import { useTypewriter } from "@/hooks/use-typewriter";
import { cn } from "@/lib/utils";
interface ChatListItemProps {
@ -44,6 +45,7 @@ export function ChatListItem({
const t = useTranslations("sidebar");
const isMobile = useIsMobile();
const [dropdownOpen, setDropdownOpen] = useState(false);
const animatedName = useTypewriter(name);
const { handlers: longPressHandlers, wasLongPress } = useLongPress(
useCallback(() => setDropdownOpen(true), [])
@ -69,7 +71,7 @@ export function ChatListItem({
)}
>
<MessageSquare className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="w-[calc(100%-3rem)] ">{name}</span>
<span className="w-[calc(100%-3rem)] ">{animatedName}</span>
</button>
{/* Actions dropdown - trigger hidden on mobile, long-press opens it instead */}
@ -86,7 +88,7 @@ export function ChatListItem({
<span className="sr-only">{t("more_options")}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" side="right">
<DropdownMenuContent align="end" side="bottom">
{onRename && (
<DropdownMenuItem
onClick={(e) => {

View file

@ -190,9 +190,10 @@ export function DocumentsSidebar({ open, onOpenChange }: DocumentsSidebarProps)
hasMore={hasMore}
loadingMore={loadingMore}
onLoadMore={onLoadMore}
isSearchMode={isSearchMode}
mentionedDocIds={mentionedDocIds}
onToggleChatMention={handleToggleChatMention}
onEditNavigate={() => onOpenChange(false)}
isSearchMode={isSearchMode || activeTypes.length > 0}
/>
</div>
</>

View file

@ -166,9 +166,30 @@ export function MobileSidebar({
: undefined
}
user={user}
onSettings={onSettings}
onManageMembers={onManageMembers}
onUserSettings={onUserSettings}
onSettings={
onSettings
? () => {
onOpenChange(false);
onSettings();
}
: undefined
}
onManageMembers={
onManageMembers
? () => {
onOpenChange(false);
onManageMembers();
}
: undefined
}
onUserSettings={
onUserSettings
? () => {
onOpenChange(false);
onUserSettings();
}
: undefined
}
onLogout={onLogout}
pageUsage={pageUsage}
theme={theme}

View file

@ -1,5 +1,6 @@
"use client";
import { CheckCircle2, CircleAlert } from "lucide-react";
import { Spinner } from "@/components/ui/spinner";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
@ -11,13 +12,67 @@ interface NavSectionProps {
isCollapsed?: boolean;
}
function StatusBadge({ status }: { status: NavItem["statusIndicator"] }) {
if (status === "processing") {
return (
<span className="absolute top-0.5 right-0.5 inline-flex items-center justify-center h-[14px] w-[14px] rounded-full bg-primary/15">
<Spinner size="xs" className="text-primary" />
</span>
);
}
if (status === "success") {
return (
<span className="absolute top-0.5 right-0.5 inline-flex items-center justify-center h-[14px] w-[14px] rounded-full bg-emerald-500/15 animate-in fade-in duration-300">
<CheckCircle2 className="h-[10px] w-[10px] text-emerald-500" />
</span>
);
}
if (status === "error") {
return (
<span className="absolute top-0.5 right-0.5 inline-flex items-center justify-center h-[14px] w-[14px] rounded-full bg-destructive/15 animate-in fade-in duration-300">
<CircleAlert className="h-[10px] w-[10px] text-destructive" />
</span>
);
}
return null;
}
function StatusIcon({
status,
FallbackIcon,
className,
}: {
status: NavItem["statusIndicator"];
FallbackIcon: NavItem["icon"];
className?: string;
}) {
if (status === "processing") {
return <Spinner size="sm" className={cn("shrink-0 text-primary", className)} />;
}
if (status === "success") {
return (
<CheckCircle2
className={cn("shrink-0 text-emerald-500 animate-in fade-in duration-300", className)}
/>
);
}
if (status === "error") {
return (
<CircleAlert
className={cn("shrink-0 text-destructive animate-in fade-in duration-300", className)}
/>
);
}
return <FallbackIcon className={cn("shrink-0", className)} />;
}
export function NavSection({ items, onItemClick, isCollapsed = false }: NavSectionProps) {
return (
<div className={cn("flex flex-col gap-0.5 py-2", isCollapsed && "items-center")}>
{items.map((item) => {
const Icon = item.icon;
const indicator = item.statusIndicator;
// Add data-joyride for onboarding tour
const joyrideAttr =
item.title === "Documents" || item.title.toLowerCase().includes("documents")
? { "data-joyride": "documents-sidebar" }
@ -40,10 +95,8 @@ export function NavSection({ items, onItemClick, isCollapsed = false }: NavSecti
{...joyrideAttr}
>
<Icon className="h-4 w-4" />
{item.showSpinner ? (
<span className="absolute top-0.5 right-0.5 inline-flex items-center justify-center h-[14px] w-[14px] rounded-full bg-primary/15">
<Spinner size="xs" className="text-primary" />
</span>
{indicator && indicator !== "idle" ? (
<StatusBadge status={indicator} />
) : item.badge ? (
<span className="absolute top-0.5 right-0.5 inline-flex items-center justify-center min-w-[14px] h-[14px] px-0.5 rounded-full bg-red-500 text-white text-[9px] font-medium">
{item.badge}
@ -72,11 +125,7 @@ export function NavSection({ items, onItemClick, isCollapsed = false }: NavSecti
)}
{...joyrideAttr}
>
{item.showSpinner ? (
<Spinner size="sm" className="shrink-0 text-primary" />
) : (
<Icon className="h-4 w-4 shrink-0" />
)}
<StatusIcon status={indicator} FallbackIcon={Icon} className="h-4 w-4" />
<span className="flex-1 truncate">{item.title}</span>
{item.badge && (
<span className="inline-flex items-center justify-center min-w-4 h-4 px-1 rounded-full bg-red-500 text-white text-[10px] font-medium">

View file

@ -1,6 +1,18 @@
"use client";
import { Check, ChevronUp, Languages, Laptop, LogOut, Moon, Settings, Sun } from "lucide-react";
import {
Check,
ChevronUp,
ExternalLink,
Info,
Languages,
Laptop,
LogOut,
Moon,
Settings,
Sun,
} from "lucide-react";
import Image from "next/image";
import { useTranslations } from "next-intl";
import { useState } from "react";
import {
@ -16,8 +28,8 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Spinner } from "@/components/ui/spinner";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useLocaleContext } from "@/contexts/LocaleContext";
import { APP_VERSION } from "@/lib/env-config";
import { cn } from "@/lib/utils";
import type { User } from "../../types/layout.types";
@ -37,6 +49,11 @@ const THEMES = [
{ value: "system" as const, name: "System", icon: Laptop },
];
const LEARN_MORE_LINKS = [
{ key: "documentation" as const, href: "https://surfsense.com/docs" },
{ key: "github" as const, href: "https://github.com/MODSetter/SurfSense" },
];
interface SidebarUserProfileProps {
user: User;
onUserSettings?: () => void;
@ -100,11 +117,14 @@ function UserAvatar({
}) {
if (avatarUrl) {
return (
<img
<Image
src={avatarUrl}
alt="User avatar"
width={32}
height={32}
className="h-8 w-8 shrink-0 rounded-lg object-cover"
referrerPolicy="no-referrer"
unoptimized
/>
);
}
@ -157,25 +177,20 @@ export function SidebarUserProfile({
return (
<div className="border-t p-2">
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<button
type="button"
className={cn(
"flex h-10 w-full items-center justify-center rounded-md",
"hover:bg-accent transition-colors",
"focus:outline-none focus-visible:outline-none",
"data-[state=open]:bg-transparent"
)}
>
<UserAvatar avatarUrl={user.avatarUrl} initials={initials} bgColor={bgColor} />
<span className="sr-only">{displayName}</span>
</button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="right">{displayName}</TooltipContent>
</Tooltip>
<DropdownMenuTrigger asChild>
<button
type="button"
className={cn(
"flex h-10 w-full items-center justify-center rounded-md",
"hover:bg-accent transition-colors",
"focus:outline-none focus-visible:outline-none",
"data-[state=open]:bg-transparent"
)}
>
<UserAvatar avatarUrl={user.avatarUrl} initials={initials} bgColor={bgColor} />
<span className="sr-only">{displayName}</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-48" side="right" align="center" sideOffset={8}>
<DropdownMenuLabel className="font-normal">
@ -256,6 +271,29 @@ export function SidebarUserProfile({
</DropdownMenuPortal>
</DropdownMenuSub>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Info className="h-4 w-4" />
{t("learn_more")}
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent className="min-w-[180px] gap-1">
{LEARN_MORE_LINKS.map((link) => (
<DropdownMenuItem key={link.key} asChild>
<a href={link.href} target="_blank" rel="noopener noreferrer">
<span className="flex-1">{t(link.key)}</span>
<ExternalLink className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
</a>
</DropdownMenuItem>
))}
<DropdownMenuSeparator className="dark:bg-neutral-700" />
<p className="select-none px-2 py-1.5 text-xs text-muted-foreground/50">
v{APP_VERSION}
</p>
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
<DropdownMenuSeparator className="dark:bg-neutral-700" />
<DropdownMenuItem onClick={handleLogout} disabled={isLoggingOut}>
@ -378,6 +416,29 @@ export function SidebarUserProfile({
</DropdownMenuPortal>
</DropdownMenuSub>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Info className="h-4 w-4" />
{t("learn_more")}
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent className="min-w-[180px] gap-1">
{LEARN_MORE_LINKS.map((link) => (
<DropdownMenuItem key={link.key} asChild>
<a href={link.href} target="_blank" rel="noopener noreferrer">
<span className="flex-1">{t(link.key)}</span>
<ExternalLink className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
</a>
</DropdownMenuItem>
))}
<DropdownMenuSeparator className="dark:bg-neutral-700" />
<p className="select-none px-2 py-1.5 text-xs text-muted-foreground/50">
v{APP_VERSION}
</p>
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
<DropdownMenuSeparator className="dark:bg-neutral-700" />
<DropdownMenuItem onClick={handleLogout} disabled={isLoggingOut}>

View file

@ -148,7 +148,7 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
<button
type="button"
onClick={() =>
router.push(`/dashboard/${params.search_space_id}/settings?section=public-links`)
router.push(`/dashboard/${params.search_space_id}/settings?tab=public-links`)
}
className="flex items-center justify-center h-8 w-8 rounded-md bg-muted/50 hover:bg-muted transition-colors"
>

View file

@ -396,7 +396,7 @@ export const DocumentMentionPicker = forwardRef<
return (
<div
className="fixed shadow-2xl rounded-lg border border-border overflow-hidden bg-popover flex flex-col w-[280px] sm:w-[320px]"
className="fixed shadow-2xl rounded-lg border border-border dark:border-white/5 overflow-hidden bg-popover dark:bg-neutral-900 flex flex-col w-[280px] sm:w-[320px] select-none"
style={{
zIndex: 9999,
...containerStyle,
@ -486,6 +486,9 @@ export const DocumentMentionPicker = forwardRef<
{/* User Documents */}
{userDocsList.length > 0 && (
<>
{surfsenseDocsList.length > 0 && (
<div className="mx-2 my-4 border-t border-border dark:border-white/5" />
)}
<div className="px-3 py-2 text-xs font-bold text-muted-foreground/55">
Your Documents
</div>

View file

@ -243,8 +243,8 @@ export function ImageConfigDialog({
aria-modal="true"
className={cn(
"relative w-full max-w-lg h-[85vh]",
"rounded-xl bg-background shadow-2xl ring-1 ring-border/50",
"dark:bg-neutral-900 dark:ring-white/5",
"rounded-xl bg-background shadow-2xl",
"dark:bg-neutral-900",
"flex flex-col overflow-hidden"
)}
onClick={(e) => e.stopPropagation()}

View file

@ -195,8 +195,8 @@ export function ModelConfigDialog({
aria-modal="true"
className={cn(
"relative w-full max-w-lg h-[85vh]",
"rounded-xl bg-background shadow-2xl ring-1 ring-border/50",
"dark:bg-neutral-900 dark:ring-white/5",
"rounded-xl bg-background shadow-2xl",
"dark:bg-neutral-900",
"flex flex-col overflow-hidden"
)}
onClick={(e) => e.stopPropagation()}

View file

@ -291,7 +291,7 @@ export function ModelSelector({
</PopoverTrigger>
<PopoverContent
className="w-[280px] md:w-[360px] p-0 rounded-lg shadow-lg border-border/60 dark:bg-neutral-900 dark:border dark:border-white/5 select-none"
className="w-[280px] md:w-[360px] p-0 rounded-lg shadow-lg bg-white border-border/60 dark:bg-neutral-900 dark:border dark:border-white/5 select-none"
align="start"
sideOffset={8}
>
@ -304,14 +304,14 @@ export function ModelSelector({
<TabsList className="w-full grid grid-cols-2 rounded-none rounded-t-lg bg-transparent h-11 p-0 gap-0">
<TabsTrigger
value="llm"
className="gap-2 text-sm font-medium rounded-none text-muted-foreground/60 transition-all duration-200 h-full bg-transparent data-[state=active]:bg-transparent shadow-none data-[state=active]:shadow-none border-b-[1.5px] border-transparent data-[state=active]:border-foreground dark:data-[state=active]:border-white data-[state=active]:text-foreground"
className="gap-2 text-sm font-medium rounded-none text-muted-foreground transition-all duration-200 h-full bg-transparent data-[state=active]:bg-transparent shadow-none data-[state=active]:shadow-none border-b-[1.5px] border-transparent data-[state=active]:border-foreground dark:data-[state=active]:border-white data-[state=active]:text-foreground"
>
<Zap className="size-4" />
LLM
</TabsTrigger>
<TabsTrigger
value="image"
className="gap-2 text-sm font-medium rounded-none text-muted-foreground/60 transition-all duration-200 h-full bg-transparent data-[state=active]:bg-transparent shadow-none data-[state=active]:shadow-none border-b-[1.5px] border-transparent data-[state=active]:border-foreground dark:data-[state=active]:border-white data-[state=active]:text-foreground"
className="gap-2 text-sm font-medium rounded-none text-muted-foreground transition-all duration-200 h-full bg-transparent data-[state=active]:bg-transparent shadow-none data-[state=active]:shadow-none border-b-[1.5px] border-transparent data-[state=active]:border-foreground dark:data-[state=active]:border-white data-[state=active]:text-foreground"
>
<ImageIcon className="size-4" />
Image

View file

@ -109,6 +109,11 @@ const FILE_TYPE_CONFIG: Record<string, Record<string, string[]>> = {
},
};
interface FileWithId {
id: string;
file: File;
}
const cardClass = "border border-border bg-slate-400/5 dark:bg-white/5";
// Upload limits — files are sent in batches of 5 to avoid proxy timeouts
@ -122,7 +127,7 @@ export function DocumentUploadTab({
onAccordionStateChange,
}: DocumentUploadTabProps) {
const t = useTranslations("upload_documents");
const [files, setFiles] = useState<File[]>([]);
const [files, setFiles] = useState<FileWithId[]>([]);
const [uploadProgress, setUploadProgress] = useState(0);
const [accordionValue, setAccordionValue] = useState<string>("");
const [shouldSummarize, setShouldSummarize] = useState(false);
@ -143,9 +148,12 @@ export function DocumentUploadTab({
const onDrop = useCallback(
(acceptedFiles: File[]) => {
setFiles((prev) => {
const newFiles = [...prev, ...acceptedFiles];
const newEntries = acceptedFiles.map((f) => ({
id: crypto.randomUUID?.() ?? `file-${Date.now()}-${Math.random().toString(36)}`,
file: f,
}));
const newFiles = [...prev, ...newEntries];
// Check file count limit
if (newFiles.length > MAX_FILES) {
toast.error(t("max_files_exceeded"), {
description: t("max_files_exceeded_desc", { max: MAX_FILES }),
@ -153,8 +161,7 @@ export function DocumentUploadTab({
return prev;
}
// Check total size limit
const newTotalSize = newFiles.reduce((sum, file) => sum + file.size, 0);
const newTotalSize = newFiles.reduce((sum, entry) => sum + entry.file.size, 0);
if (newTotalSize > MAX_TOTAL_SIZE_BYTES) {
toast.error(t("max_size_exceeded"), {
description: t("max_size_exceeded_desc", { max: MAX_TOTAL_SIZE_MB }),
@ -189,7 +196,7 @@ export function DocumentUploadTab({
return `${parseFloat((bytes / k ** i).toFixed(2))} ${sizes[i]}`;
};
const totalFileSize = files.reduce((total, file) => total + file.size, 0);
const totalFileSize = files.reduce((total, entry) => total + entry.file.size, 0);
// Check if limits are reached
const isFileCountLimitReached = files.length >= MAX_FILES;
@ -217,8 +224,13 @@ export function DocumentUploadTab({
setUploadProgress((prev) => (prev >= 90 ? prev : prev + Math.random() * 10));
}, 200);
const rawFiles = files.map((entry) => entry.file);
uploadDocuments(
{ files, search_space_id: Number(searchSpaceId), should_summarize: shouldSummarize },
{
files: rawFiles,
search_space_id: Number(searchSpaceId),
should_summarize: shouldSummarize,
},
{
onSuccess: () => {
clearInterval(progressInterval);
@ -303,7 +315,7 @@ export function DocumentUploadTab({
{!isFileCountLimitReached && (
<div className="mt-2 sm:mt-4">
<Button
variant="outline"
variant="secondary"
size="sm"
className="text-xs sm:text-sm"
onClick={(e) => {
@ -345,21 +357,21 @@ export function DocumentUploadTab({
</CardHeader>
<CardContent className="p-4 sm:p-6 pt-0">
<div className="space-y-2 sm:space-y-3 max-h-[250px] sm:max-h-[400px] overflow-y-auto">
{files.map((file, index) => (
{files.map((entry) => (
<div
key={`${file.name}-${index}`}
key={entry.id}
className={`flex items-center justify-between p-2 sm:p-4 rounded-lg border border-border ${cardClass} hover:bg-slate-400/10 dark:hover:bg-white/10 transition-colors`}
>
<div className="flex items-center gap-3 flex-1 min-w-0">
<FileType className="h-5 w-5 text-muted-foreground flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm sm:text-base font-medium truncate">{file.name}</p>
<p className="text-sm sm:text-base font-medium truncate">{entry.file.name}</p>
<div className="flex items-center gap-2 mt-1">
<Badge variant="secondary" className="text-xs">
{formatFileSize(file.size)}
{formatFileSize(entry.file.size)}
</Badge>
<Badge variant="outline" className="text-xs">
{file.type || "Unknown type"}
{entry.file.type || "Unknown type"}
</Badge>
</div>
</div>
@ -367,7 +379,7 @@ export function DocumentUploadTab({
<Button
variant="ghost"
size="icon"
onClick={() => setFiles((prev) => prev.filter((_, i) => i !== index))}
onClick={() => setFiles((prev) => prev.filter((e) => e.id !== entry.id))}
disabled={isUploading}
className="h-8 w-8"
>

View file

@ -6,7 +6,7 @@ import {
CheckIcon,
FileIcon,
Loader2Icon,
PencilIcon,
Pen,
RefreshCwIcon,
XIcon,
} from "lucide-react";
@ -400,7 +400,7 @@ function ApprovalCard({
)}
{canEdit && (
<Button size="sm" variant="outline" onClick={() => setIsEditing(true)}>
<PencilIcon />
<Pen />
Edit
</Button>
)}

View file

@ -1,7 +1,7 @@
"use client";
import { makeAssistantToolUI } from "@assistant-ui/react";
import { AlertTriangleIcon, CheckIcon, Loader2Icon, PencilIcon, XIcon } from "lucide-react";
import { AlertTriangleIcon, CheckIcon, Loader2Icon, Pen, XIcon } from "lucide-react";
import { useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@ -495,7 +495,7 @@ function ApprovalCard({
)}
{canEdit && (
<Button size="sm" variant="outline" onClick={() => setIsEditing(true)}>
<PencilIcon />
<Pen />
Edit
</Button>
)}

View file

@ -1,14 +1,7 @@
"use client";
import { makeAssistantToolUI } from "@assistant-ui/react";
import {
AlertTriangleIcon,
CheckIcon,
InfoIcon,
Loader2Icon,
PencilIcon,
XIcon,
} from "lucide-react";
import { AlertTriangleIcon, CheckIcon, InfoIcon, Loader2Icon, Pen, XIcon } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@ -618,7 +611,7 @@ function ApprovalCard({
)}
{canEdit && (
<Button size="sm" variant="outline" onClick={() => setIsEditing(true)}>
<PencilIcon />
<Pen />
Edit
</Button>
)}

View file

@ -1,7 +1,7 @@
"use client";
import { makeAssistantToolUI } from "@assistant-ui/react";
import { AlertTriangleIcon, CheckIcon, Loader2Icon, PencilIcon, XIcon } from "lucide-react";
import { AlertTriangleIcon, CheckIcon, Loader2Icon, Pen, XIcon } from "lucide-react";
import { useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@ -373,7 +373,7 @@ function ApprovalCard({
)}
{canEdit && (
<Button size="sm" variant="outline" onClick={() => setIsEditing(true)}>
<PencilIcon />
<Pen />
Edit
</Button>
)}

View file

@ -8,7 +8,7 @@ import {
Loader2Icon,
MaximizeIcon,
MinimizeIcon,
PencilIcon,
Pen,
XIcon,
} from "lucide-react";
import { useMemo, useState } from "react";
@ -336,7 +336,7 @@ function ApprovalCard({
)}
{canEdit && (
<Button size="sm" variant="outline" onClick={() => setIsEditing(true)}>
<PencilIcon />
<Pen />
Edit
</Button>
)}

View file

@ -45,7 +45,7 @@ function AlertDialogContent({
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
className={cn(
"bg-background dark:bg-neutral-900 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-xl ring-1 ring-border/50 dark:ring-white/5 p-6 shadow-2xl duration-200 sm:max-w-lg",
"bg-background dark:bg-neutral-900 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-xl p-6 shadow-2xl duration-200 sm:max-w-lg",
className
)}
{...props}

View file

@ -0,0 +1,551 @@
"use client";
import React, {
createContext,
forwardRef,
type ReactNode,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from "react";
import { cn } from "@/lib/utils";
/*
Context (replaces cloneElement)
*/
interface TabsContextValue {
activeValue: string;
onValueChange: (value: string) => void;
}
const TabsContext = createContext<TabsContextValue | null>(null);
function useTabsContext() {
const ctx = useContext(TabsContext);
if (!ctx) {
throw new Error("AnimatedTabs compound components must be rendered inside <Tabs>");
}
return ctx;
}
/*
Constants (hoisted out of render)
*/
const SIZE_CLASSES = {
sm: "h-[32px] text-sm",
md: "h-[40px] text-base",
lg: "h-[48px] text-lg",
} as const;
const VARIANT_CLASSES = {
default: "",
pills: "rounded-full",
underlined: "",
} as const;
const ACTIVE_INDICATOR_CLASSES = {
default: "h-[4px] bg-primary dark:bg-primary",
pills: "hidden",
underlined: "h-[4px] bg-primary dark:bg-primary",
} as const;
const HOVER_INDICATOR_CLASSES = {
default: "bg-muted dark:bg-muted rounded-[6px]",
pills: "bg-muted dark:bg-muted rounded-full",
underlined: "bg-muted dark:bg-muted rounded-[6px]",
} as const;
/*
XScrollable (internal)
*/
const XScrollable = forwardRef<
HTMLDivElement,
{
className?: string;
children?: ReactNode;
showScrollbar?: boolean;
contentClassName?: string;
} & React.HTMLAttributes<HTMLDivElement>
>(({ className, children, showScrollbar = true, contentClassName, ...props }, ref) => {
const scrollRef = useRef<HTMLDivElement | null>(null);
const dragging = useRef(false);
const startX = useRef(0);
const startScrollLeft = useRef(0);
const [scrollPos, setScrollPos] = useState<"start" | "middle" | "end">("start");
const updateScrollPos = useCallback(() => {
const el = scrollRef.current;
if (!el) return;
const canScroll = el.scrollWidth > el.clientWidth + 1;
if (!canScroll) {
setScrollPos("start");
return;
}
const atStart = el.scrollLeft <= 2;
const atEnd = el.scrollWidth - el.scrollLeft - el.clientWidth <= 2;
setScrollPos(atStart ? "start" : atEnd ? "end" : "middle");
}, []);
useEffect(() => {
updateScrollPos();
const el = scrollRef.current;
if (!el) return;
const ro = new ResizeObserver(updateScrollPos);
ro.observe(el);
return () => ro.disconnect();
}, [updateScrollPos]);
const onMouseDown = (e: React.MouseEvent) => {
if (!scrollRef.current) return;
dragging.current = true;
startX.current = e.clientX;
startScrollLeft.current = scrollRef.current.scrollLeft;
};
const endDrag = () => {
dragging.current = false;
};
const onMouseMove = (e: React.MouseEvent) => {
if (!dragging.current || !scrollRef.current) return;
e.preventDefault();
const dx = e.clientX - startX.current;
scrollRef.current.scrollLeft = startScrollLeft.current - dx;
};
const onWheel = (e: React.WheelEvent) => {
if (!scrollRef.current) return;
const delta = Math.abs(e.deltaY) > Math.abs(e.deltaX) ? e.deltaY : e.deltaX;
if (delta !== 0) {
e.preventDefault();
scrollRef.current.scrollLeft += delta;
}
};
const handleScroll = useCallback(() => {
updateScrollPos();
}, [updateScrollPos]);
const maskStart = scrollPos === "start" ? "black" : "transparent";
const maskEnd = scrollPos === "end" ? "black" : "transparent";
const maskImage = `linear-gradient(to right, ${maskStart}, black 24px, black calc(100% - 24px), ${maskEnd})`;
return (
// biome-ignore lint/a11y/noStaticElementInteractions: drag-scroll container needs mouse events
<div
ref={ref}
className={cn("relative", className)}
{...props}
onMouseLeave={endDrag}
onMouseUp={endDrag}
onMouseMove={onMouseMove}
>
{/* biome-ignore lint/a11y/noStaticElementInteractions: drag-scroll requires onMouseDown */}
<div
ref={scrollRef}
className={cn(
"overflow-x-auto overflow-y-hidden whitespace-nowrap [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden",
!showScrollbar && "scrollbar-none",
contentClassName
)}
style={{
maskImage,
WebkitMaskImage: maskImage,
}}
onWheel={onWheel}
onMouseDown={onMouseDown}
onScroll={handleScroll}
>
{children}
</div>
</div>
);
});
XScrollable.displayName = "XScrollable";
/*
Tabs (root)
*/
const Tabs = forwardRef<
HTMLDivElement,
{
defaultValue?: string;
value?: string;
onValueChange?: (value: string) => void;
className?: string;
children?: ReactNode;
}
>(({ defaultValue, value, onValueChange, className, children, ...props }, ref) => {
const [activeValue, setActiveValue] = useState(value || defaultValue || "");
useEffect(() => {
if (value !== undefined) {
setActiveValue(value);
}
}, [value]);
const handleValueChange = useCallback(
(newValue: string) => {
if (value === undefined) {
setActiveValue(newValue);
}
onValueChange?.(newValue);
},
[onValueChange, value]
);
return (
<TabsContext.Provider value={{ activeValue, onValueChange: handleValueChange }}>
<div ref={ref} className={cn("tabs-container", className)} {...props}>
{children}
</div>
</TabsContext.Provider>
);
});
Tabs.displayName = "Tabs";
/*
TabsList
*/
type TabsListVariant = "default" | "pills" | "underlined";
type TabsListSize = "sm" | "md" | "lg";
const TabsList = forwardRef<
HTMLDivElement,
{
className?: string;
children?: ReactNode;
showHoverEffect?: boolean;
showActiveIndicator?: boolean;
activeIndicatorPosition?: "top" | "bottom";
activeIndicatorOffset?: number;
size?: TabsListSize;
variant?: TabsListVariant;
stretch?: boolean;
ariaLabel?: string;
showBottomBorder?: boolean;
bottomBorderClassName?: string;
activeIndicatorClassName?: string;
hoverIndicatorClassName?: string;
}
>(
(
{
className,
children,
showHoverEffect = true,
showActiveIndicator = true,
activeIndicatorPosition = "bottom",
activeIndicatorOffset = 0,
size = "sm",
variant = "default",
stretch = false,
ariaLabel = "Tabs",
showBottomBorder = false,
bottomBorderClassName,
activeIndicatorClassName,
hoverIndicatorClassName,
...props
},
ref
) => {
const { activeValue, onValueChange } = useTabsContext();
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
const [hoverStyle, setHoverStyle] = useState({});
const [activeStyle, setActiveStyle] = useState({
left: "0px",
width: "0px",
});
const tabRefs = useRef<(HTMLDivElement | null)[]>([]);
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
const activeIndex = React.Children.toArray(children).findIndex(
(child) =>
React.isValidElement(child) &&
(child as React.ReactElement<{ value: string }>).props.value === activeValue
);
useEffect(() => {
if (hoveredIndex !== null && showHoverEffect) {
const hoveredElement = tabRefs.current[hoveredIndex];
if (hoveredElement) {
const { offsetLeft, offsetWidth } = hoveredElement;
setHoverStyle({
left: `${offsetLeft}px`,
width: `${offsetWidth}px`,
});
}
}
}, [hoveredIndex, showHoverEffect]);
const updateActiveIndicator = useCallback(() => {
if (showActiveIndicator && activeIndex >= 0) {
const activeElement = tabRefs.current[activeIndex];
if (activeElement) {
const { offsetLeft, offsetWidth } = activeElement;
setActiveStyle({
left: `${offsetLeft}px`,
width: `${offsetWidth}px`,
});
}
}
}, [showActiveIndicator, activeIndex]);
useEffect(() => {
updateActiveIndicator();
}, [updateActiveIndicator]);
useEffect(() => {
requestAnimationFrame(updateActiveIndicator);
}, [updateActiveIndicator]);
const scrollTabToCenter = useCallback((index: number) => {
const tabElement = tabRefs.current[index];
const scrollContainer = scrollContainerRef.current;
if (tabElement && scrollContainer) {
const containerWidth = scrollContainer.offsetWidth;
const tabWidth = tabElement.offsetWidth;
const tabLeft = tabElement.offsetLeft;
const scrollTarget = tabLeft - containerWidth / 2 + tabWidth / 2;
scrollContainer.scrollTo({ left: scrollTarget, behavior: "smooth" });
}
}, []);
const setTabRef = useCallback((el: HTMLDivElement | null, index: number) => {
tabRefs.current[index] = el;
}, []);
const handleScrollableRef = useCallback((node: HTMLDivElement | null) => {
if (node) {
const scrollableDiv = node.querySelector('div[class*="overflow-x-auto"]');
if (scrollableDiv) {
scrollContainerRef.current = scrollableDiv as HTMLDivElement;
}
}
}, []);
useEffect(() => {
if (activeIndex >= 0) {
const timer = setTimeout(() => {
scrollTabToCenter(activeIndex);
}, 100);
return () => clearTimeout(timer);
}
}, [activeIndex, scrollTabToCenter]);
return (
<div
ref={handleScrollableRef}
className={cn("relative", className)}
role="tablist"
aria-label={ariaLabel}
{...props}
>
{showBottomBorder && (
<div
className={cn(
"absolute bottom-0 left-0 right-0 h-px bg-border dark:bg-border z-0",
bottomBorderClassName
)}
/>
)}
<XScrollable showScrollbar={false}>
<div className={cn("relative", showBottomBorder && "pb-px")}>
{showHoverEffect && (
<div
className={cn(
"absolute transition-all duration-300 ease-out flex items-center z-0",
SIZE_CLASSES[size],
HOVER_INDICATOR_CLASSES[variant],
hoverIndicatorClassName
)}
style={{
...hoverStyle,
opacity: hoveredIndex !== null ? 1 : 0,
transition: "all 300ms ease-out",
}}
aria-hidden="true"
/>
)}
<div
ref={ref}
className={cn(
"relative flex items-center",
stretch ? "w-full" : "",
variant === "default" ? "space-x-[6px]" : "space-x-[2px]"
)}
>
{React.Children.map(children, (child, index) => {
if (!React.isValidElement(child)) return child;
const childProps = (
child as React.ReactElement<{
value: string;
disabled?: boolean;
label?: string;
className?: string;
activeClassName?: string;
inactiveClassName?: string;
disabledClassName?: string;
}>
).props;
const { value, disabled } = childProps;
const isActive = value === activeValue;
return (
<div
key={value}
ref={(el) => setTabRef(el, index)}
className={cn(
"px-3 py-2 sm:mb-1.5 mb-2 cursor-pointer transition-colors duration-300",
SIZE_CLASSES[size],
variant === "pills" && isActive
? "bg-[#0e0f1114] dark:bg-[#ffffff1a] rounded-full"
: "",
disabled ? "opacity-50 cursor-not-allowed" : "",
stretch ? "flex-1 text-center" : "",
isActive
? childProps.activeClassName || "text-foreground dark:text-foreground"
: childProps.inactiveClassName ||
"text-muted-foreground dark:text-muted-foreground",
disabled && childProps.disabledClassName,
VARIANT_CLASSES[variant],
childProps.className
)}
onMouseEnter={() => !disabled && setHoveredIndex(index)}
onMouseLeave={() => setHoveredIndex(null)}
onClick={() => {
if (!disabled) {
onValueChange(value);
scrollTabToCenter(index);
}
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
if (!disabled) {
onValueChange(value);
scrollTabToCenter(index);
}
}
}}
role="tab"
aria-selected={isActive}
aria-disabled={disabled}
aria-controls={`tabpanel-${value}`}
id={`tab-${value}`}
tabIndex={isActive ? 0 : -1}
>
<div className="whitespace-nowrap flex items-center justify-center h-full">
{child}
</div>
</div>
);
})}
</div>
{showActiveIndicator && variant !== "pills" && activeIndex >= 0 && (
<div
className={cn(
"absolute transition-all duration-300 ease-out z-10",
ACTIVE_INDICATOR_CLASSES[variant],
activeIndicatorPosition === "top" ? "top-[-1px]" : "bottom-[-1px]",
activeIndicatorClassName
)}
style={{
...activeStyle,
transition: "all 300ms ease-out",
[activeIndicatorPosition]: `${activeIndicatorOffset}px`,
}}
aria-hidden="true"
/>
)}
</div>
</XScrollable>
</div>
);
}
);
TabsList.displayName = "TabsList";
/*
TabsTrigger
*/
const TabsTrigger = forwardRef<
HTMLDivElement,
{
value: string;
disabled?: boolean;
label?: string;
className?: string;
activeClassName?: string;
inactiveClassName?: string;
disabledClassName?: string;
children?: ReactNode;
}
>(
(
{
value,
disabled = false,
label,
className,
activeClassName,
inactiveClassName,
disabledClassName,
children,
...props
},
ref
) => {
return (
<div ref={ref} className={cn("flex items-center", className)} {...props}>
{label || children}
</div>
);
}
);
TabsTrigger.displayName = "TabsTrigger";
/*
TabsContent
*/
const TabsContent = forwardRef<
HTMLDivElement,
{
value: string;
className?: string;
children: ReactNode;
}
>(({ value, className, children, ...props }, ref) => {
const { activeValue } = useTabsContext();
if (value !== activeValue) return null;
return (
<div
ref={ref}
role="tabpanel"
id={`tabpanel-${value}`}
aria-labelledby={`tab-${value}`}
className={className}
{...props}
>
{children}
</div>
);
});
TabsContent.displayName = "TabsContent";
export { Tabs, TabsList, TabsTrigger, TabsContent };

View file

@ -38,7 +38,7 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 ring-1 ring-border/50 dark:ring-white/5 bg-background dark:bg-neutral-900 p-6 shadow-2xl duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 rounded-xl focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0",
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 bg-background dark:bg-neutral-900 p-6 shadow-2xl duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 rounded-xl focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0",
className
)}
{...props}

View file

@ -61,7 +61,7 @@ function DropdownMenuItem({
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground dark:focus:bg-neutral-700 [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"focus:bg-neutral-200 focus:text-accent-foreground dark:focus:bg-neutral-700 [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
@ -79,7 +79,7 @@ function DropdownMenuCheckboxItem({
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground dark:focus:bg-neutral-700 relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"focus:bg-neutral-200 focus:text-accent-foreground dark:focus:bg-neutral-700 relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
@ -110,7 +110,7 @@ function DropdownMenuRadioItem({
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground dark:focus:bg-neutral-700 relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"focus:bg-neutral-200 focus:text-accent-foreground dark:focus:bg-neutral-700 relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
@ -182,7 +182,7 @@ function DropdownMenuSubTrigger({
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground dark:focus:bg-neutral-700 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground dark:data-[state=open]:bg-neutral-700 [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"focus:bg-neutral-200 focus:text-accent-foreground dark:focus:bg-neutral-700 data-[state=open]:bg-neutral-200 data-[state=open]:text-accent-foreground dark:data-[state=open]:bg-neutral-700 [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}

View file

@ -14,7 +14,7 @@ export function FixedToolbar({
return (
<Toolbar
className={cn(
"scrollbar-hide sticky top-0 left-0 z-50 w-full justify-between overflow-x-auto rounded-t-lg border-b bg-background/95 p-1 backdrop-blur supports-backdrop-filter:bg-background/60",
"scrollbar-hide sticky top-0 left-0 z-10 w-full justify-between overflow-x-auto rounded-t-lg border-b bg-background/95 p-1 backdrop-blur supports-backdrop-filter:bg-background/60",
className
)}
{...props}

View file

@ -65,7 +65,7 @@ export function FloatingToolbar({
{...rootProps}
ref={ref}
className={cn(
"scrollbar-hide absolute z-50 overflow-x-auto whitespace-nowrap rounded-md border bg-popover p-1 opacity-100 shadow-md print:hidden dark:bg-neutral-800 dark:border-neutral-700",
"scrollbar-hide absolute z-50 overflow-x-auto whitespace-nowrap rounded-md border bg-popover p-1 opacity-100 shadow-md print:hidden dark:bg-neutral-900 dark:border-white/5",
"max-w-[80vw]",
className
)}

View file

@ -189,7 +189,7 @@ export function InsertToolbarButton(props: DropdownMenuProps) {
</DropdownMenuTrigger>
<DropdownMenuContent
className="z-[100] flex max-h-[60vh] min-w-0 flex-col overflow-y-auto dark:bg-neutral-800 dark:border dark:border-neutral-700"
className="z-[100] flex max-h-[60vh] min-w-0 flex-col overflow-y-auto dark:bg-neutral-900 dark:border dark:border-white/5"
align="start"
>
{groups.map(({ group, items }) => (

View file

@ -9,11 +9,14 @@ export function ShortcutKbd({ keys, className }: ShortcutKbdProps) {
if (keys.length === 0) return null;
return (
<span className={cn("ml-2 inline-flex items-center gap-0.5 text-white/50", className)}>
<span className={cn("ml-2 inline-flex items-center gap-0.5 text-white/85", className)}>
{keys.map((key) => (
<kbd
key={key}
className="inline-flex size-[16px] items-center justify-center rounded-[3px] bg-white/[0.08] font-sans text-[10px] leading-none"
className={cn(
"inline-flex h-[18px] items-center justify-center rounded-[4px] bg-white/[0.18] font-sans text-[11px] leading-none",
key.length > 1 ? "px-1" : "w-[18px]"
)}
>
{key}
</kbd>

View file

@ -1,6 +1,5 @@
"use client";
import { SlashInputPlugin } from "@platejs/slash-command/react";
import {
ChevronRightIcon,
Code2Icon,
@ -177,7 +176,7 @@ export function SlashInputElement({ children, ...props }: PlateElementProps) {
<InlineCombobox element={props.element} trigger="/">
<InlineComboboxInput />
<InlineComboboxContent className="dark:bg-neutral-800 dark:border dark:border-neutral-700">
<InlineComboboxContent className="dark:bg-neutral-900 dark:border dark:border-white/5">
<InlineComboboxEmpty>No results found.</InlineComboboxEmpty>
{slashCommandGroups.map(({ heading, items }) => (

View file

@ -150,7 +150,7 @@ export function TurnIntoToolbarButton({
</DropdownMenuTrigger>
<DropdownMenuContent
className="z-[100] ignore-click-outside/toolbar min-w-0 max-h-[60vh] overflow-y-auto dark:bg-neutral-800 dark:border dark:border-neutral-700"
className="z-[100] ignore-click-outside/toolbar min-w-0 max-h-[60vh] overflow-y-auto dark:bg-neutral-900 dark:border dark:border-white/5"
onCloseAutoFocus={(e) => {
e.preventDefault();
editor.tf.focus();

View file

@ -13,6 +13,7 @@ export const membership = z.object({
user_email: z.string().nullable().optional(),
user_display_name: z.string().nullable().optional(),
user_avatar_url: z.string().nullable().optional(),
user_last_login: z.string().nullable().optional(),
user_is_active: z.boolean().nullable().optional(),
});

View file

@ -3,20 +3,23 @@
import { useEffect, useRef, useState } from "react";
import { useElectricClient } from "@/lib/electric/context";
export type DocumentsProcessingStatus = "idle" | "processing" | "success" | "error";
const SUCCESS_LINGER_MS = 5000;
/**
* Returns whether any documents in the search space are currently being
* uploaded or indexed (status = "pending" | "processing").
*
* Covers both manual file uploads (2-phase pattern) and all connector indexers,
* since both create documents with status = pending before processing.
*
* The sync shape uses the same columns as useDocuments so Electric can share
* the subscription when both hooks are active simultaneously.
* Returns the processing status of documents in the search space:
* - "processing" at least one doc is pending/processing (show spinner)
* - "error" nothing processing, but failed docs exist (show red icon)
* - "success" just transitioned from processing all clear (green check, auto-dismisses)
* - "idle" nothing noteworthy (show normal icon)
*/
export function useDocumentsProcessing(searchSpaceId: number | null): boolean {
export function useDocumentsProcessing(searchSpaceId: number | null): DocumentsProcessingStatus {
const electricClient = useElectricClient();
const [isProcessing, setIsProcessing] = useState(false);
const [status, setStatus] = useState<DocumentsProcessingStatus>("idle");
const liveQueryRef = useRef<{ unsubscribe?: () => void } | null>(null);
const wasProcessingRef = useRef(false);
const successTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (!searchSpaceId || !electricClient) return;
@ -76,10 +79,15 @@ export function useDocumentsProcessing(searchSpaceId: number | null): boolean {
if (!db.live?.query) return;
const liveQuery = await db.live.query<{ count: number | string }>(
`SELECT COUNT(*) as count FROM documents
WHERE search_space_id = $1
AND (status->>'state' = 'pending' OR status->>'state' = 'processing')`,
const liveQuery = await db.live.query<{
processing_count: number | string;
failed_count: number | string;
}>(
`SELECT
SUM(CASE WHEN status->>'state' IN ('pending', 'processing') THEN 1 ELSE 0 END) AS processing_count,
SUM(CASE WHEN status->>'state' = 'failed' THEN 1 ELSE 0 END) AS failed_count
FROM documents
WHERE search_space_id = $1`,
[spaceId]
);
@ -88,10 +96,46 @@ export function useDocumentsProcessing(searchSpaceId: number | null): boolean {
return;
}
liveQuery.subscribe((result: { rows: Array<{ count: number | string }> }) => {
if (!mounted || !result.rows?.[0]) return;
setIsProcessing((Number(result.rows[0].count) || 0) > 0);
});
liveQuery.subscribe(
(result: {
rows: Array<{ processing_count: number | string; failed_count: number | string }>;
}) => {
if (!mounted || !result.rows?.[0]) return;
const processingCount = Number(result.rows[0].processing_count) || 0;
const failedCount = Number(result.rows[0].failed_count) || 0;
if (processingCount > 0) {
wasProcessingRef.current = true;
if (successTimerRef.current) {
clearTimeout(successTimerRef.current);
successTimerRef.current = null;
}
setStatus("processing");
} else if (failedCount > 0) {
wasProcessingRef.current = false;
if (successTimerRef.current) {
clearTimeout(successTimerRef.current);
successTimerRef.current = null;
}
setStatus("error");
} else if (wasProcessingRef.current) {
wasProcessingRef.current = false;
setStatus("success");
if (successTimerRef.current) {
clearTimeout(successTimerRef.current);
}
successTimerRef.current = setTimeout(() => {
if (mounted) {
setStatus("idle");
successTimerRef.current = null;
}
}, SUCCESS_LINGER_MS);
} else {
setStatus("idle");
}
}
);
liveQueryRef.current = liveQuery;
} catch (err) {
@ -103,6 +147,10 @@ export function useDocumentsProcessing(searchSpaceId: number | null): boolean {
return () => {
mounted = false;
if (successTimerRef.current) {
clearTimeout(successTimerRef.current);
successTimerRef.current = null;
}
if (liveQueryRef.current) {
try {
liveQueryRef.current.unsubscribe?.();
@ -114,5 +162,5 @@ export function useDocumentsProcessing(searchSpaceId: number | null): boolean {
};
}, [searchSpaceId, electricClient]);
return isProcessing;
return status;
}

View file

@ -1,58 +0,0 @@
"use client";
import { useEffect, useState } from "react";
export const useGithubStars = () => {
const [stars, setStars] = useState<number | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const abortController = new AbortController();
const getStars = async () => {
try {
setError(null);
const response = await fetch(`https://api.github.com/repos/MODSetter/SurfSense`, {
signal: abortController.signal,
});
if (!response.ok) {
throw new Error(`Failed to fetch stars: ${response.statusText}`);
}
const data = await response.json();
setStars(data?.stargazers_count);
} catch (err) {
// Ignore abort errors (expected on unmount)
if (err instanceof Error && err.name === "AbortError") {
return;
}
if (err instanceof Error) {
console.error("Error fetching stars:", err);
setError(err.message);
}
} finally {
setLoading(false);
}
};
getStars();
return () => {
abortController.abort("Component unmounted");
};
}, []);
return {
stars,
loading,
error,
compactFormat: Intl.NumberFormat("en-US", {
notation: "compact",
maximumFractionDigits: 1,
minimumFractionDigits: 1,
}).format(stars || 0),
};
};

View file

@ -0,0 +1,49 @@
import { useEffect, useRef, useState } from "react";
/**
* Animates text changes with a typewriter reveal effect, but only when
* transitioning away from the `skipFor` placeholder (default "New Chat").
* All other text values are shown instantly without animation.
*/
export function useTypewriter(text: string, speed = 35, skipFor = "New Chat"): string {
const [displayed, setDisplayed] = useState(text);
const prevTextRef = useRef(text);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
const prevText = prevTextRef.current;
prevTextRef.current = text;
const shouldAnimate = prevText === skipFor && text !== skipFor && !!text;
if (!shouldAnimate) {
setDisplayed(text);
return;
}
let i = 0;
setDisplayed("");
intervalRef.current = setInterval(() => {
i++;
setDisplayed(text.slice(0, i));
if (i >= text.length) {
if (intervalRef.current) clearInterval(intervalRef.current);
intervalRef.current = null;
}
}, speed);
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
};
}, [text, speed, skipFor]);
return displayed;
}

View file

@ -685,6 +685,9 @@
"system": "System",
"logout": "Logout",
"loggingOut": "Logging out...",
"learn_more": "Learn more",
"documentation": "Documentation",
"github": "GitHub",
"inbox": "Inbox",
"search_inbox": "Search inbox",
"mark_all_read": "Mark all as read",

View file

@ -685,6 +685,9 @@
"system": "Sistema",
"logout": "Cerrar sesión",
"loggingOut": "Cerrando sesión...",
"learn_more": "Más información",
"documentation": "Documentación",
"github": "GitHub",
"inbox": "Bandeja de entrada",
"search_inbox": "Buscar en bandeja de entrada",
"mark_all_read": "Marcar todo como leído",

View file

@ -685,6 +685,9 @@
"system": "सिस्टम",
"logout": "लॉगआउट",
"loggingOut": "लॉगआउट हो रहा है...",
"learn_more": "और जानें",
"documentation": "दस्तावेज़ीकरण",
"github": "GitHub",
"inbox": "इनबॉक्स",
"search_inbox": "इनबॉक्स में खोजें",
"mark_all_read": "सभी पढ़ा हुआ चिह्नित करें",

View file

@ -685,6 +685,9 @@
"system": "Sistema",
"logout": "Sair",
"loggingOut": "Saindo...",
"learn_more": "Saiba mais",
"documentation": "Documentação",
"github": "GitHub",
"inbox": "Caixa de entrada",
"search_inbox": "Pesquisar caixa de entrada",
"mark_all_read": "Marcar tudo como lido",

View file

@ -669,6 +669,9 @@
"system": "系统",
"logout": "退出登录",
"loggingOut": "正在退出...",
"learn_more": "了解更多",
"documentation": "文档",
"github": "GitHub",
"inbox": "收件箱",
"search_inbox": "搜索收件箱",
"mark_all_read": "全部标记为已读",

View file

@ -100,7 +100,7 @@
"katex": "^0.16.28",
"lenis": "^1.3.17",
"lowlight": "^3.3.0",
"lucide-react": "^0.477.0",
"lucide-react": "^0.577.0",
"motion": "^12.23.22",
"next": "^16.1.0",
"next-intl": "^4.6.1",

View file

@ -220,13 +220,13 @@ importers:
version: 1.4.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
fumadocs-core:
specifier: ^16.3.1
version: 16.6.5(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.477.0(react@19.2.4))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6)
version: 16.6.5(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.577.0(react@19.2.4))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6)
fumadocs-mdx:
specifier: ^14.2.1
version: 14.2.8(@types/mdast@4.0.4)(@types/mdx@2.0.13)(@types/react@19.2.14)(fumadocs-core@16.6.5(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.477.0(react@19.2.4))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@20.19.33)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0))
version: 14.2.8(@types/mdast@4.0.4)(@types/mdx@2.0.13)(@types/react@19.2.14)(fumadocs-core@16.6.5(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.577.0(react@19.2.4))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@20.19.33)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0))
fumadocs-ui:
specifier: ^16.3.1
version: 16.6.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(fumadocs-core@16.6.5(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.477.0(react@19.2.4))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(tailwindcss@4.2.1)
version: 16.6.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(fumadocs-core@16.6.5(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.577.0(react@19.2.4))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(tailwindcss@4.2.1)
geist:
specifier: ^1.4.2
version: 1.7.0(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))
@ -246,8 +246,8 @@ importers:
specifier: ^3.3.0
version: 3.3.0
lucide-react:
specifier: ^0.477.0
version: 0.477.0(react@19.2.4)
specifier: ^0.577.0
version: 0.577.0(react@19.2.4)
motion:
specifier: ^12.23.22
version: 12.34.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@ -5641,13 +5641,13 @@ packages:
lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
lucide-react@0.477.0:
resolution: {integrity: sha512-yCf7aYxerFZAbd8jHJxjwe1j7jEMPptjnaOqdYeirFnEy85cNR3/L+o0I875CYFYya+eEVzZSbNuRk8BZPDpVw==}
lucide-react@0.570.0:
resolution: {integrity: sha512-qGnQ8bEPJLMseKo7kI6jK6GW6Y2Yl4PpqoWbroNsobZ8+tZR4SUuO4EXK3oWCdZr48SZ7PnaulTkvzkKvG/Iqg==}
peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
lucide-react@0.570.0:
resolution: {integrity: sha512-qGnQ8bEPJLMseKo7kI6jK6GW6Y2Yl4PpqoWbroNsobZ8+tZR4SUuO4EXK3oWCdZr48SZ7PnaulTkvzkKvG/Iqg==}
lucide-react@0.577.0:
resolution: {integrity: sha512-4LjoFv2eEPwYDPg/CUdBJQSDfPyzXCRrVW1X7jrx/trgxnxkHFjnVZINbzvzxjN70dxychOfg+FTYwBiS3pQ5A==}
peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
@ -11768,7 +11768,7 @@ snapshots:
fsevents@2.3.3:
optional: true
fumadocs-core@16.6.5(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.477.0(react@19.2.4))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6):
fumadocs-core@16.6.5(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.577.0(react@19.2.4))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6):
dependencies:
'@formatjs/intl-localematcher': 0.8.1
'@orama/orama': 3.1.18
@ -11799,7 +11799,7 @@ snapshots:
'@types/hast': 3.0.4
'@types/mdast': 4.0.4
'@types/react': 19.2.14
lucide-react: 0.477.0(react@19.2.4)
lucide-react: 0.577.0(react@19.2.4)
next: 16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
@ -11807,14 +11807,14 @@ snapshots:
transitivePeerDependencies:
- supports-color
fumadocs-mdx@14.2.8(@types/mdast@4.0.4)(@types/mdx@2.0.13)(@types/react@19.2.14)(fumadocs-core@16.6.5(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.477.0(react@19.2.4))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@20.19.33)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)):
fumadocs-mdx@14.2.8(@types/mdast@4.0.4)(@types/mdx@2.0.13)(@types/react@19.2.14)(fumadocs-core@16.6.5(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.577.0(react@19.2.4))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@20.19.33)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)):
dependencies:
'@mdx-js/mdx': 3.1.1
'@standard-schema/spec': 1.1.0
chokidar: 5.0.0
esbuild: 0.27.3
estree-util-value-to-estree: 3.5.0
fumadocs-core: 16.6.5(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.477.0(react@19.2.4))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6)
fumadocs-core: 16.6.5(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.577.0(react@19.2.4))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6)
js-yaml: 4.1.1
mdast-util-mdx: 3.0.0
mdast-util-to-markdown: 2.1.2
@ -11837,7 +11837,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
fumadocs-ui@16.6.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(fumadocs-core@16.6.5(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.477.0(react@19.2.4))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(tailwindcss@4.2.1):
fumadocs-ui@16.6.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(fumadocs-core@16.6.5(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.577.0(react@19.2.4))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(tailwindcss@4.2.1):
dependencies:
'@fumadocs/tailwind': 0.0.2(tailwindcss@4.2.1)
'@radix-ui/react-accordion': 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@ -11851,7 +11851,7 @@ snapshots:
'@radix-ui/react-slot': 1.2.4(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
class-variance-authority: 0.7.1
fumadocs-core: 16.6.5(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.477.0(react@19.2.4))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6)
fumadocs-core: 16.6.5(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.577.0(react@19.2.4))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6)
lucide-react: 0.570.0(react@19.2.4)
motion: 12.34.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
next-themes: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@ -12519,11 +12519,11 @@ snapshots:
dependencies:
yallist: 3.1.1
lucide-react@0.477.0(react@19.2.4):
lucide-react@0.570.0(react@19.2.4):
dependencies:
react: 19.2.4
lucide-react@0.570.0(react@19.2.4):
lucide-react@0.577.0(react@19.2.4):
dependencies:
react: 19.2.4