mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-04-27 09:46:25 +02:00
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:
commit
cba7923503
69 changed files with 2157 additions and 1248 deletions
|
|
@ -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")
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}</>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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%,
|
||||
|
|
|
|||
|
|
@ -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 [];
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
481
surfsense_web/components/homepage/github-stars-badge.tsx
Normal file
481
surfsense_web/components/homepage/github-stars-badge.tsx
Normal 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 };
|
||||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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()}
|
||||
|
|
|
|||
|
|
@ -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()}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
551
surfsense_web/components/ui/animated-tabs.tsx
Normal file
551
surfsense_web/components/ui/animated-tabs.tsx
Normal 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 };
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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 }) => (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 }) => (
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
};
|
||||
49
surfsense_web/hooks/use-typewriter.ts
Normal file
49
surfsense_web/hooks/use-typewriter.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -685,6 +685,9 @@
|
|||
"system": "सिस्टम",
|
||||
"logout": "लॉगआउट",
|
||||
"loggingOut": "लॉगआउट हो रहा है...",
|
||||
"learn_more": "और जानें",
|
||||
"documentation": "दस्तावेज़ीकरण",
|
||||
"github": "GitHub",
|
||||
"inbox": "इनबॉक्स",
|
||||
"search_inbox": "इनबॉक्स में खोजें",
|
||||
"mark_all_read": "सभी पढ़ा हुआ चिह्नित करें",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -669,6 +669,9 @@
|
|||
"system": "系统",
|
||||
"logout": "退出登录",
|
||||
"loggingOut": "正在退出...",
|
||||
"learn_more": "了解更多",
|
||||
"documentation": "文档",
|
||||
"github": "GitHub",
|
||||
"inbox": "收件箱",
|
||||
"search_inbox": "搜索收件箱",
|
||||
"mark_all_read": "全部标记为已读",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
34
surfsense_web/pnpm-lock.yaml
generated
34
surfsense_web/pnpm-lock.yaml
generated
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue