mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-04-27 17:56: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)
|
display_name = Column(String, nullable=True)
|
||||||
avatar_url = 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 for this user
|
||||||
refresh_tokens = relationship(
|
refresh_tokens = relationship(
|
||||||
"RefreshToken",
|
"RefreshToken",
|
||||||
|
|
@ -1820,6 +1822,8 @@ else:
|
||||||
display_name = Column(String, nullable=True)
|
display_name = Column(String, nullable=True)
|
||||||
avatar_url = 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 for this user
|
||||||
refresh_tokens = relationship(
|
refresh_tokens = relationship(
|
||||||
"RefreshToken",
|
"RefreshToken",
|
||||||
|
|
|
||||||
|
|
@ -109,12 +109,12 @@ SUMMARY_PROMPT_TEMPLATE = PromptTemplate(
|
||||||
# Chat Title Generation Prompt
|
# 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>
|
<rules>
|
||||||
- The title MUST be between 1 and 6 words
|
- The title MUST be between 1 and 6 words
|
||||||
- The title MUST be on a single line
|
- 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 use quotes, punctuation, or formatting
|
||||||
- Do NOT include words like "Chat about" or "Discussion of"
|
- Do NOT include words like "Chat about" or "Discussion of"
|
||||||
- Return ONLY the title, nothing else
|
- 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}
|
||||||
</user_query>
|
</user_query>
|
||||||
|
|
||||||
<assistant_response>
|
|
||||||
{assistant_response}
|
|
||||||
</assistant_response>
|
|
||||||
|
|
||||||
Title:"""
|
Title:"""
|
||||||
|
|
||||||
TITLE_GENERATION_PROMPT_TEMPLATE = PromptTemplate(
|
TITLE_GENERATION_PROMPT_TEMPLATE = PromptTemplate(
|
||||||
input_variables=["user_query", "assistant_response"],
|
input_variables=["user_query"],
|
||||||
template=TITLE_GENERATION_PROMPT,
|
template=TITLE_GENERATION_PROMPT,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -510,6 +510,7 @@ async def list_members(
|
||||||
"user_email": member_user.email if member_user else None,
|
"user_email": member_user.email if member_user else None,
|
||||||
"user_display_name": member_user.display_name 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_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)
|
response.append(membership_dict)
|
||||||
|
|
||||||
|
|
@ -602,6 +603,7 @@ async def update_member_role(
|
||||||
"created_at": db_membership.created_at,
|
"created_at": db_membership.created_at,
|
||||||
"role": db_membership.role,
|
"role": db_membership.role,
|
||||||
"user_email": member_user.email if member_user else None,
|
"user_email": member_user.email if member_user else None,
|
||||||
|
"user_last_login": member_user.last_login if member_user else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,7 @@ class MembershipRead(BaseModel):
|
||||||
user_email: str | None = None
|
user_email: str | None = None
|
||||||
user_display_name: str | None = None
|
user_display_name: str | None = None
|
||||||
user_avatar_url: str | None = None
|
user_avatar_url: str | None = None
|
||||||
|
user_last_login: datetime | None = None
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|
|
||||||
|
|
@ -1366,6 +1366,38 @@ async def stream_new_chat(
|
||||||
del mentioned_documents, mentioned_surfsense_docs, recent_reports
|
del mentioned_documents, mentioned_surfsense_docs, recent_reports
|
||||||
del langchain_messages, final_query
|
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()
|
_t_stream_start = time.perf_counter()
|
||||||
_first_event_logged = False
|
_first_event_logged = False
|
||||||
async for sse in _stream_agent_events(
|
async for sse in _stream_agent_events(
|
||||||
|
|
@ -1390,6 +1422,23 @@ async def stream_new_chat(
|
||||||
_first_event_logged = True
|
_first_event_logged = True
|
||||||
yield sse
|
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(
|
_perf_log.info(
|
||||||
"[stream_new_chat] Agent stream completed in %.3fs (chat_id=%s)",
|
"[stream_new_chat] Agent stream completed in %.3fs (chat_id=%s)",
|
||||||
time.perf_counter() - _t_stream_start,
|
time.perf_counter() - _t_stream_start,
|
||||||
|
|
@ -1398,62 +1447,28 @@ async def stream_new_chat(
|
||||||
log_system_snapshot("stream_new_chat_END")
|
log_system_snapshot("stream_new_chat_END")
|
||||||
|
|
||||||
if stream_result.is_interrupted:
|
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_step()
|
||||||
yield streaming_service.format_finish()
|
yield streaming_service.format_finish()
|
||||||
yield streaming_service.format_done()
|
yield streaming_service.format_done()
|
||||||
return
|
return
|
||||||
|
|
||||||
accumulated_text = stream_result.accumulated_text
|
# If the title task didn't finish during streaming, await it now
|
||||||
|
if title_task is not None and not title_emitted:
|
||||||
assistant_count_result = await session.execute(
|
generated_title = await title_task
|
||||||
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 generated_title:
|
if generated_title:
|
||||||
# Fetch thread and update title
|
async with shielded_async_session() as title_session:
|
||||||
thread_result = await session.execute(
|
title_thread_result = await title_session.execute(
|
||||||
select(NewChatThread).filter(NewChatThread.id == chat_id)
|
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
|
|
||||||
)
|
)
|
||||||
|
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
|
# Finish the step and message
|
||||||
yield streaming_service.format_finish_step()
|
yield streaming_service.format_finish_step()
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import logging
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import Depends, Request, Response
|
from fastapi import Depends, Request, Response
|
||||||
|
|
@ -12,6 +13,7 @@ from fastapi_users.authentication import (
|
||||||
)
|
)
|
||||||
from fastapi_users.db import SQLAlchemyUserDatabase
|
from fastapi_users.db import SQLAlchemyUserDatabase
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy import update
|
||||||
|
|
||||||
from app.config import config
|
from app.config import config
|
||||||
from app.db import (
|
from app.db import (
|
||||||
|
|
@ -123,6 +125,23 @@ class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
|
||||||
|
|
||||||
return user
|
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):
|
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
|
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">
|
<PopoverContent className="w-64 !p-0 overflow-hidden" align="end">
|
||||||
<div>
|
<div>
|
||||||
{/* Search input */}
|
{/* 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">
|
<div className="relative">
|
||||||
<Search className="absolute left-0.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
<Search className="absolute left-0.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
|
|
@ -108,7 +108,7 @@ export function DocumentsFilters({
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
key={value}
|
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))}
|
onClick={() => onToggleType(value, !activeTypes.includes(value))}
|
||||||
>
|
>
|
||||||
{/* Icon */}
|
{/* Icon */}
|
||||||
|
|
@ -137,11 +137,11 @@ export function DocumentsFilters({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{activeTypes.length > 0 && (
|
{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
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
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={() => {
|
onClick={() => {
|
||||||
activeTypes.forEach((t) => {
|
activeTypes.forEach((t) => {
|
||||||
onToggleType(t, false);
|
onToggleType(t, false);
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ import {
|
||||||
FileX,
|
FileX,
|
||||||
Network,
|
Network,
|
||||||
PenLine,
|
PenLine,
|
||||||
Plus,
|
SearchX,
|
||||||
Trash2,
|
Trash2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
|
@ -19,6 +19,7 @@ import { useTranslations } from "next-intl";
|
||||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useDocumentUploadDialog } from "@/components/assistant-ui/document-upload-popup";
|
import { useDocumentUploadDialog } from "@/components/assistant-ui/document-upload-popup";
|
||||||
|
import { JsonMetadataViewer } from "@/components/json-metadata-viewer";
|
||||||
import { MarkdownViewer } from "@/components/markdown-viewer";
|
import { MarkdownViewer } from "@/components/markdown-viewer";
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
|
|
@ -222,12 +223,14 @@ function RowContextMenu({
|
||||||
onPreview,
|
onPreview,
|
||||||
onDelete,
|
onDelete,
|
||||||
searchSpaceId,
|
searchSpaceId,
|
||||||
|
onEditNavigate,
|
||||||
}: {
|
}: {
|
||||||
doc: Document;
|
doc: Document;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
onPreview: (doc: Document) => void;
|
onPreview: (doc: Document) => void;
|
||||||
onDelete: (doc: Document) => void;
|
onDelete: (doc: Document) => void;
|
||||||
searchSpaceId: string;
|
searchSpaceId: string;
|
||||||
|
onEditNavigate?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
|
@ -252,9 +255,12 @@ function RowContextMenu({
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
{isEditable && (
|
{isEditable && (
|
||||||
<ContextMenuItem
|
<ContextMenuItem
|
||||||
onClick={() =>
|
onClick={() => {
|
||||||
!isEditDisabled && router.push(`/dashboard/${searchSpaceId}/editor/${doc.id}`)
|
if (!isEditDisabled) {
|
||||||
}
|
onEditNavigate?.();
|
||||||
|
router.push(`/dashboard/${searchSpaceId}/editor/${doc.id}`);
|
||||||
|
}
|
||||||
|
}}
|
||||||
disabled={isEditDisabled}
|
disabled={isEditDisabled}
|
||||||
>
|
>
|
||||||
<PenLine className="h-4 w-4" />
|
<PenLine className="h-4 w-4" />
|
||||||
|
|
@ -318,9 +324,10 @@ export function DocumentsTableShell({
|
||||||
hasMore = false,
|
hasMore = false,
|
||||||
loadingMore = false,
|
loadingMore = false,
|
||||||
onLoadMore,
|
onLoadMore,
|
||||||
isSearchMode = false,
|
|
||||||
mentionedDocIds,
|
mentionedDocIds,
|
||||||
onToggleChatMention,
|
onToggleChatMention,
|
||||||
|
onEditNavigate,
|
||||||
|
isSearchMode = false,
|
||||||
}: {
|
}: {
|
||||||
documents: Document[];
|
documents: Document[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
|
|
@ -333,11 +340,14 @@ export function DocumentsTableShell({
|
||||||
hasMore?: boolean;
|
hasMore?: boolean;
|
||||||
loadingMore?: boolean;
|
loadingMore?: boolean;
|
||||||
onLoadMore?: () => void;
|
onLoadMore?: () => void;
|
||||||
isSearchMode?: boolean;
|
|
||||||
/** IDs of documents currently mentioned as chips in the chat composer */
|
/** IDs of documents currently mentioned as chips in the chat composer */
|
||||||
mentionedDocIds?: Set<number>;
|
mentionedDocIds?: Set<number>;
|
||||||
/** Toggle a document's mention in the chat (add if not mentioned, remove if mentioned) */
|
/** Toggle a document's mention in the chat (add if not mentioned, remove if mentioned) */
|
||||||
onToggleChatMention?: (doc: Document, mentioned: boolean) => void;
|
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 t = useTranslations("documents");
|
||||||
const { openDialog } = useDocumentUploadDialog();
|
const { openDialog } = useDocumentUploadDialog();
|
||||||
|
|
@ -345,6 +355,10 @@ export function DocumentsTableShell({
|
||||||
const [viewingDoc, setViewingDoc] = useState<Document | null>(null);
|
const [viewingDoc, setViewingDoc] = useState<Document | null>(null);
|
||||||
const [viewingContent, setViewingContent] = useState<string>("");
|
const [viewingContent, setViewingContent] = useState<string>("");
|
||||||
const [viewingLoading, setViewingLoading] = useState(false);
|
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 [previewScrollPos, setPreviewScrollPos] = useState<"top" | "middle" | "bottom">("top");
|
||||||
const handlePreviewScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
|
const handlePreviewScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
|
||||||
const el = e.currentTarget;
|
const el = e.currentTarget;
|
||||||
|
|
@ -412,6 +426,20 @@ export function DocumentsTableShell({
|
||||||
setViewingLoading(false);
|
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 () => {
|
const handleDeleteFromMenu = useCallback(async () => {
|
||||||
if (!deleteDoc) return;
|
if (!deleteDoc) return;
|
||||||
setIsDeleting(true);
|
setIsDeleting(true);
|
||||||
|
|
@ -544,21 +572,34 @@ export function DocumentsTableShell({
|
||||||
</div>
|
</div>
|
||||||
) : sorted.length === 0 ? (
|
) : sorted.length === 0 ? (
|
||||||
<div className="flex flex-1 w-full items-center justify-center">
|
<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">
|
{isSearchMode ? (
|
||||||
<div className="rounded-full bg-muted/50 p-4">
|
<div className="flex flex-col items-center gap-3 max-w-md px-4 text-center">
|
||||||
<FileX className="h-8 w-8 text-muted-foreground" />
|
<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>
|
||||||
<div className="space-y-1.5">
|
) : (
|
||||||
<h3 className="text-lg font-semibold">{t("no_documents")}</h3>
|
<div className="flex flex-col items-center gap-4 max-w-md px-4 text-center">
|
||||||
<p className="text-sm text-muted-foreground">
|
<div className="rounded-full bg-muted/50 p-4">
|
||||||
Get started by uploading your first document.
|
<FileX className="h-8 w-8 text-muted-foreground" />
|
||||||
</p>
|
</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>
|
</div>
|
||||||
<Button onClick={openDialog} className="mt-2">
|
)}
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
|
||||||
Upload Documents
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div ref={desktopScrollRef} className="flex-1 overflow-auto">
|
<div ref={desktopScrollRef} className="flex-1 overflow-auto">
|
||||||
|
|
@ -567,11 +608,20 @@ export function DocumentsTableShell({
|
||||||
{sorted.map((doc) => {
|
{sorted.map((doc) => {
|
||||||
const isMentioned = mentionedDocIds?.has(doc.id) ?? false;
|
const isMentioned = mentionedDocIds?.has(doc.id) ?? false;
|
||||||
const canInteract = isSelectable(doc);
|
const canInteract = isSelectable(doc);
|
||||||
const handleRowClick = () => {
|
const handleRowToggle = () => {
|
||||||
if (canInteract && onToggleChatMention) {
|
if (canInteract && onToggleChatMention) {
|
||||||
onToggleChatMention(doc, isMentioned);
|
onToggleChatMention(doc, isMentioned);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
const handleRowClick = (e: React.MouseEvent) => {
|
||||||
|
if (e.ctrlKey || e.metaKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
handleViewMetadata(doc);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handleRowToggle();
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<RowContextMenu
|
<RowContextMenu
|
||||||
key={doc.id}
|
key={doc.id}
|
||||||
|
|
@ -579,6 +629,7 @@ export function DocumentsTableShell({
|
||||||
onPreview={handleViewDocument}
|
onPreview={handleViewDocument}
|
||||||
onDelete={setDeleteDoc}
|
onDelete={setDeleteDoc}
|
||||||
searchSpaceId={searchSpaceId}
|
searchSpaceId={searchSpaceId}
|
||||||
|
onEditNavigate={onEditNavigate}
|
||||||
>
|
>
|
||||||
<tr
|
<tr
|
||||||
className={`border-b border-border/50 transition-colors ${
|
className={`border-b border-border/50 transition-colors ${
|
||||||
|
|
@ -593,7 +644,7 @@ export function DocumentsTableShell({
|
||||||
<div className="flex items-center justify-center h-full">
|
<div className="flex items-center justify-center h-full">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={isMentioned}
|
checked={isMentioned}
|
||||||
onCheckedChange={() => handleRowClick()}
|
onCheckedChange={() => handleRowToggle()}
|
||||||
disabled={!canInteract}
|
disabled={!canInteract}
|
||||||
aria-label={isMentioned ? "Remove from chat" : "Add to chat"}
|
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" : ""}`}
|
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>
|
</div>
|
||||||
) : sorted.length === 0 ? (
|
) : sorted.length === 0 ? (
|
||||||
<div className="md:hidden flex flex-1 w-full items-center justify-center">
|
<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">
|
{isSearchMode ? (
|
||||||
<div className="rounded-full bg-muted/50 p-4">
|
<div className="flex flex-col items-center gap-3 max-w-md px-4 text-center">
|
||||||
<FileX className="h-8 w-8 text-muted-foreground" />
|
<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>
|
||||||
<div className="space-y-1.5">
|
) : (
|
||||||
<h3 className="text-lg font-semibold">{t("no_documents")}</h3>
|
<div className="flex flex-col items-center gap-4 max-w-md px-4 text-center">
|
||||||
<p className="text-sm text-muted-foreground">
|
<div className="rounded-full bg-muted/50 p-4">
|
||||||
Get started by uploading your first document.
|
<FileX className="h-8 w-8 text-muted-foreground" />
|
||||||
</p>
|
</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>
|
</div>
|
||||||
<Button onClick={openDialog} className="mt-2">
|
)}
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
|
||||||
Upload Documents
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
|
|
@ -683,7 +745,13 @@ export function DocumentsTableShell({
|
||||||
{sorted.map((doc) => {
|
{sorted.map((doc) => {
|
||||||
const isMentioned = mentionedDocIds?.has(doc.id) ?? false;
|
const isMentioned = mentionedDocIds?.has(doc.id) ?? false;
|
||||||
const canInteract = isSelectable(doc);
|
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) {
|
if (canInteract && onToggleChatMention) {
|
||||||
onToggleChatMention(doc, isMentioned);
|
onToggleChatMention(doc, isMentioned);
|
||||||
}
|
}
|
||||||
|
|
@ -769,6 +837,21 @@ export function DocumentsTableShell({
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</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 */}
|
{/* Delete Confirmation Dialog */}
|
||||||
<AlertDialog open={!!deleteDoc} onOpenChange={(open) => !open && setDeleteDoc(null)}>
|
<AlertDialog open={!!deleteDoc} onOpenChange={(open) => !open && setDeleteDoc(null)}>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
|
|
@ -839,6 +922,7 @@ export function DocumentsTableShell({
|
||||||
}
|
}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (mobileActionDoc) {
|
if (mobileActionDoc) {
|
||||||
|
onEditNavigate?.();
|
||||||
router.push(`/dashboard/${searchSpaceId}/editor/${mobileActionDoc.id}`);
|
router.push(`/dashboard/${searchSpaceId}/editor/${mobileActionDoc.id}`);
|
||||||
setMobileActionDoc(null);
|
setMobileActionDoc(null);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ import {
|
||||||
AlertDialogHeader,
|
AlertDialogHeader,
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from "@/components/ui/alert-dialog";
|
} 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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { notesApiService } from "@/lib/apis/notes-api.service";
|
import { notesApiService } from "@/lib/apis/notes-api.service";
|
||||||
|
|
@ -83,6 +83,7 @@ export default function EditorPage() {
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||||
const [showUnsavedDialog, setShowUnsavedDialog] = useState(false);
|
const [showUnsavedDialog, setShowUnsavedDialog] = useState(false);
|
||||||
|
const [editorTitle, setEditorTitle] = useState<string>("Untitled");
|
||||||
|
|
||||||
// Store the latest markdown from the editor
|
// Store the latest markdown from the editor
|
||||||
const markdownRef = useRef<string>("");
|
const markdownRef = useRef<string>("");
|
||||||
|
|
@ -117,20 +118,18 @@ export default function EditorPage() {
|
||||||
}
|
}
|
||||||
}, [pendingNavigation, hasUnsavedChanges, router, setPendingNavigation]);
|
}, [pendingNavigation, hasUnsavedChanges, router, setPendingNavigation]);
|
||||||
|
|
||||||
// Reset state when documentId changes
|
// Reset state and fetch document content when documentId changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setDocument(null);
|
setDocument(null);
|
||||||
setError(null);
|
setError(null);
|
||||||
setHasUnsavedChanges(false);
|
setHasUnsavedChanges(false);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
initialLoadDone.current = false;
|
initialLoadDone.current = false;
|
||||||
}, [documentId]);
|
|
||||||
|
|
||||||
// Fetch document content
|
|
||||||
useEffect(() => {
|
|
||||||
async function fetchDocument() {
|
async function fetchDocument() {
|
||||||
if (isNewNote) {
|
if (isNewNote) {
|
||||||
markdownRef.current = "";
|
markdownRef.current = "";
|
||||||
|
setEditorTitle("Untitled");
|
||||||
setDocument({
|
setDocument({
|
||||||
document_id: 0,
|
document_id: 0,
|
||||||
title: "Untitled",
|
title: "Untitled",
|
||||||
|
|
@ -173,6 +172,7 @@ export default function EditorPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
markdownRef.current = data.source_markdown;
|
markdownRef.current = data.source_markdown;
|
||||||
|
setEditorTitle(extractTitleFromMarkdown(data.source_markdown));
|
||||||
setDocument(data);
|
setDocument(data);
|
||||||
setError(null);
|
setError(null);
|
||||||
initialLoadDone.current = true;
|
initialLoadDone.current = true;
|
||||||
|
|
@ -193,20 +193,17 @@ export default function EditorPage() {
|
||||||
|
|
||||||
const isNote = isNewNote || document?.document_type === "NOTE";
|
const isNote = isNewNote || document?.document_type === "NOTE";
|
||||||
|
|
||||||
// Extract title dynamically from current markdown for notes
|
|
||||||
const displayTitle = useMemo(() => {
|
const displayTitle = useMemo(() => {
|
||||||
if (isNote) {
|
if (isNote) return editorTitle;
|
||||||
return extractTitleFromMarkdown(markdownRef.current || document?.source_markdown);
|
|
||||||
}
|
|
||||||
return document?.title || "Untitled";
|
return document?.title || "Untitled";
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
}, [isNote, document?.title, editorTitle]);
|
||||||
}, [isNote, document?.title, document?.source_markdown, hasUnsavedChanges]);
|
|
||||||
|
|
||||||
// Handle markdown changes from the Plate editor
|
// Handle markdown changes from the Plate editor
|
||||||
const handleMarkdownChange = useCallback((md: string) => {
|
const handleMarkdownChange = useCallback((md: string) => {
|
||||||
markdownRef.current = md;
|
markdownRef.current = md;
|
||||||
if (initialLoadDone.current) {
|
if (initialLoadDone.current) {
|
||||||
setHasUnsavedChanges(true);
|
setHasUnsavedChanges(true);
|
||||||
|
setEditorTitle(extractTitleFromMarkdown(md));
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
@ -493,13 +490,13 @@ export default function EditorPage() {
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel onClick={handleCancelLeave}>Cancel</AlertDialogCancel>
|
<AlertDialogCancel onClick={handleCancelLeave}>Cancel</AlertDialogCancel>
|
||||||
<AlertDialogAction onClick={handleSaveAndLeave}>Save</AlertDialogAction>
|
|
||||||
<AlertDialogAction
|
<AlertDialogAction
|
||||||
onClick={handleConfirmLeave}
|
onClick={handleConfirmLeave}
|
||||||
className="border border-input bg-background text-foreground hover:bg-accent hover:text-accent-foreground"
|
className={buttonVariants({ variant: "secondary" })}
|
||||||
>
|
>
|
||||||
Leave without saving
|
Leave without saving
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
|
<AlertDialogAction onClick={handleSaveAndLeave}>Save</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
|
|
||||||
|
|
@ -465,10 +465,7 @@ export default function NewChatPage() {
|
||||||
let isNewThread = false;
|
let isNewThread = false;
|
||||||
if (!currentThreadId) {
|
if (!currentThreadId) {
|
||||||
try {
|
try {
|
||||||
// Create thread with truncated prompt as initial title
|
const newThread = await createThread(searchSpaceId, "New Chat");
|
||||||
const initialTitle =
|
|
||||||
userQuery.trim().slice(0, 100) + (userQuery.trim().length > 100 ? "..." : "");
|
|
||||||
const newThread = await createThread(searchSpaceId, initialTitle);
|
|
||||||
currentThreadId = newThread.id;
|
currentThreadId = newThread.id;
|
||||||
setThreadId(currentThreadId);
|
setThreadId(currentThreadId);
|
||||||
// Set currentThread so share button in header appears immediately
|
// 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{" "}
|
You can add more configurations and customize settings anytime in{" "}
|
||||||
<button
|
<button
|
||||||
type="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"
|
className="text-violet-500 hover:underline"
|
||||||
>
|
>
|
||||||
Settings
|
Settings
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,5 @@
|
||||||
import type React from "react";
|
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 }) {
|
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";
|
"use client";
|
||||||
|
|
||||||
import {
|
import { Bot, Brain, FileText, Globe, ImageIcon, MessageSquare, Shield } from "lucide-react";
|
||||||
ArrowLeft,
|
|
||||||
Bot,
|
|
||||||
Brain,
|
|
||||||
ChevronRight,
|
|
||||||
FileText,
|
|
||||||
Globe,
|
|
||||||
ImageIcon,
|
|
||||||
type LucideIcon,
|
|
||||||
Menu,
|
|
||||||
MessageSquare,
|
|
||||||
Settings,
|
|
||||||
Shield,
|
|
||||||
X,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { AnimatePresence, motion } from "motion/react";
|
|
||||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
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 { PublicChatSnapshotsManager } from "@/components/public-chat-snapshots/public-chat-snapshots-manager";
|
||||||
import { GeneralSettingsManager } from "@/components/settings/general-settings-manager";
|
import { GeneralSettingsManager } from "@/components/settings/general-settings-manager";
|
||||||
import { ImageModelManager } from "@/components/settings/image-model-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 { ModelConfigManager } from "@/components/settings/model-config-manager";
|
||||||
import { PromptConfigManager } from "@/components/settings/prompt-config-manager";
|
import { PromptConfigManager } from "@/components/settings/prompt-config-manager";
|
||||||
import { RolesManager } from "@/components/settings/roles-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 { trackSettingsViewed } from "@/lib/posthog/events";
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
interface SettingsNavItem {
|
const VALID_TABS = [
|
||||||
id: string;
|
"general",
|
||||||
labelKey: string;
|
"models",
|
||||||
descriptionKey: string;
|
"roles",
|
||||||
icon: LucideIcon;
|
"image-models",
|
||||||
}
|
"prompts",
|
||||||
|
"public-links",
|
||||||
|
"team-roles",
|
||||||
|
] as const;
|
||||||
|
|
||||||
const settingsNavItems: SettingsNavItem[] = [
|
const DEFAULT_TAB = "general";
|
||||||
{
|
|
||||||
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";
|
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
|
const t = useTranslations("searchSpaceSettings");
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const searchSpaceId = Number(params.search_space_id);
|
const searchSpaceId = Number(params.search_space_id);
|
||||||
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
|
|
||||||
|
|
||||||
const sectionParam = searchParams.get("section");
|
const tabParam = searchParams.get("tab") ?? "";
|
||||||
const activeSection =
|
const activeTab = VALID_TABS.includes(tabParam as (typeof VALID_TABS)[number])
|
||||||
sectionParam && VALID_SECTIONS.has(sectionParam) ? sectionParam : DEFAULT_SECTION;
|
? tabParam
|
||||||
|
: DEFAULT_TAB;
|
||||||
|
|
||||||
const handleSectionChange = useCallback(
|
const handleTabChange = useCallback(
|
||||||
(section: string) => {
|
(value: string) => {
|
||||||
router.replace(`/dashboard/${searchSpaceId}/settings?section=${section}`, { scroll: false });
|
const p = new URLSearchParams(searchParams.toString());
|
||||||
|
p.set("tab", value);
|
||||||
|
router.replace(`?${p.toString()}`, { scroll: false });
|
||||||
},
|
},
|
||||||
[router, searchSpaceId]
|
[router, searchParams]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
trackSettingsViewed(searchSpaceId, activeSection);
|
trackSettingsViewed(searchSpaceId, activeTab);
|
||||||
}, [searchSpaceId, activeSection]);
|
}, [searchSpaceId, activeTab]);
|
||||||
|
|
||||||
const handleBackToApp = useCallback(() => {
|
|
||||||
router.push(`/dashboard/${searchSpaceId}/new-chat`);
|
|
||||||
}, [router, searchSpaceId]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<div className="h-full overflow-y-auto">
|
||||||
initial={{ opacity: 0 }}
|
<div className="mx-auto w-full max-w-4xl px-4 py-10">
|
||||||
animate={{ opacity: 1 }}
|
<Tabs value={activeTab} onValueChange={handleTabChange} className="w-full">
|
||||||
transition={{ duration: 0.3 }}
|
<TabsList showBottomBorder>
|
||||||
className="fixed inset-0 z-50 flex bg-muted/40"
|
<TabsTrigger value="general">
|
||||||
>
|
<FileText className="mr-2 h-4 w-4" />
|
||||||
<div className="flex h-full w-full p-0 md:p-2">
|
{t("nav_general")}
|
||||||
<div className="flex h-full w-full overflow-hidden bg-background md:rounded-xl md:border md:shadow-sm">
|
</TabsTrigger>
|
||||||
<SettingsSidebar
|
<TabsTrigger value="models">
|
||||||
activeSection={activeSection}
|
<Bot className="mr-2 h-4 w-4" />
|
||||||
onSectionChange={handleSectionChange}
|
{t("nav_agent_configs")}
|
||||||
onBackToApp={handleBackToApp}
|
</TabsTrigger>
|
||||||
isOpen={isSidebarOpen}
|
<TabsTrigger value="roles">
|
||||||
onClose={() => setIsSidebarOpen(false)}
|
<Brain className="mr-2 h-4 w-4" />
|
||||||
/>
|
{t("nav_role_assignments")}
|
||||||
<SettingsContent
|
</TabsTrigger>
|
||||||
activeSection={activeSection}
|
<TabsTrigger value="image-models">
|
||||||
searchSpaceId={searchSpaceId}
|
<ImageIcon className="mr-2 h-4 w-4" />
|
||||||
onMenuClick={() => setIsSidebarOpen(true)}
|
{t("nav_image_models")}
|
||||||
/>
|
</TabsTrigger>
|
||||||
</div>
|
<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>
|
</div>
|
||||||
</motion.div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -138,6 +138,7 @@ function getAvatarInitials(member: Membership): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
const PAGE_SIZE = 5;
|
const PAGE_SIZE = 5;
|
||||||
|
const SKELETON_KEYS = Array.from({ length: PAGE_SIZE }, (_, i) => `skeleton-${i}`);
|
||||||
|
|
||||||
export default function TeamManagementPage() {
|
export default function TeamManagementPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
|
|
@ -290,11 +291,8 @@ export default function TeamManagementPage() {
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{Array.from({ length: PAGE_SIZE }).map((_, i) => (
|
{SKELETON_KEYS.map((id) => (
|
||||||
<TableRow
|
<TableRow key={id} className="border-b border-border/40 hover:bg-transparent">
|
||||||
key={`skeleton-${i}`}
|
|
||||||
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">
|
<TableCell className="w-[45%] py-2.5 px-4 md:px-6 border-r border-border/40">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Skeleton className="h-10 w-10 rounded-full shrink-0" />
|
<Skeleton className="h-10 w-10 rounded-full shrink-0" />
|
||||||
|
|
@ -546,7 +544,7 @@ function MemberRow({
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
<TableCell className="hidden md:table-cell w-[25%] py-2.5 text-sm text-foreground border-r border-border/40">
|
<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>
|
||||||
|
|
||||||
<TableCell className="w-[30%] text-right py-2.5 px-4 md:px-6">
|
<TableCell className="w-[30%] text-right py-2.5 px-4 md:px-6">
|
||||||
|
|
@ -564,7 +562,7 @@ function MemberRow({
|
||||||
<DropdownMenuContent
|
<DropdownMenuContent
|
||||||
align="end"
|
align="end"
|
||||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||||
className="min-w-[120px]"
|
className="min-w-[120px] dark:bg-neutral-900 dark:border dark:border-white/5"
|
||||||
>
|
>
|
||||||
{canManageRoles &&
|
{canManageRoles &&
|
||||||
roles
|
roles
|
||||||
|
|
@ -607,11 +605,9 @@ function MemberRow({
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
)}
|
)}
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator className="dark:bg-white/5" />
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() =>
|
onClick={() => router.push(`/dashboard/${searchSpaceId}/settings?tab=team-roles`)}
|
||||||
router.push(`/dashboard/${searchSpaceId}/settings?section=team-roles`)
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
Manage Roles
|
Manage Roles
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
@ -832,7 +828,7 @@ function CreateInviteDialog({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter className="gap-3 sm:gap-2">
|
||||||
<Button variant="secondary" onClick={handleClose}>
|
<Button variant="secondary" onClick={handleClose}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -876,10 +872,10 @@ function AllInvitesDialog({
|
||||||
return (
|
return (
|
||||||
<Dialog>
|
<Dialog>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button variant="outline" className="gap-2">
|
<Button variant="secondary" className="gap-2">
|
||||||
<Link2 className="h-4 w-4 rotate-315" />
|
<Link2 className="h-4 w-4 rotate-315" />
|
||||||
Active invites
|
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}
|
{invites.length}
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</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);
|
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 */
|
/* Human-in-the-loop approval card animations */
|
||||||
@keyframes pulse-subtle {
|
@keyframes pulse-subtle {
|
||||||
0%,
|
0%,
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ export const membersAtom = atomWithQuery((get) => {
|
||||||
queryKey: cacheKeys.members.all(searchSpaceId?.toString() ?? ""),
|
queryKey: cacheKeys.members.all(searchSpaceId?.toString() ?? ""),
|
||||||
enabled: !!searchSpaceId,
|
enabled: !!searchSpaceId,
|
||||||
staleTime: 3 * 1000, // 3 seconds - short staleness for live collaboration
|
staleTime: 3 * 1000, // 3 seconds - short staleness for live collaboration
|
||||||
|
refetchInterval: 2 * 60 * 1000, // 2 minutes
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!searchSpaceId) {
|
if (!searchSpaceId) {
|
||||||
return [];
|
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."}
|
: "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>
|
</p>
|
||||||
<Button asChild size="sm" variant="outline">
|
<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" />
|
<Settings className="mr-2 h-4 w-4" />
|
||||||
Go to Settings
|
Go to Settings
|
||||||
</Link>
|
</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."}
|
: "You need to configure a Document Summary LLM before uploading files. This LLM is used to process and summarize your uploaded documents."}
|
||||||
</p>
|
</p>
|
||||||
<Button asChild size="sm" variant="outline">
|
<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" />
|
<Settings className="mr-2 h-4 w-4" />
|
||||||
Go to Settings
|
Go to Settings
|
||||||
</Link>
|
</Link>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { ActionBarPrimitive, MessagePrimitive, useAssistantState } from "@assistant-ui/react";
|
import { ActionBarPrimitive, MessagePrimitive, useAssistantState } from "@assistant-ui/react";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { FileText, PencilIcon } from "lucide-react";
|
import { FileText, Pen } from "lucide-react";
|
||||||
import { type FC, useState } from "react";
|
import { type FC, useState } from "react";
|
||||||
import { messageDocumentsMapAtom } from "@/atoms/chat/mentioned-documents.atom";
|
import { messageDocumentsMapAtom } from "@/atoms/chat/mentioned-documents.atom";
|
||||||
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
|
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
|
||||||
|
|
@ -125,7 +125,7 @@ const UserActionBar: FC = () => {
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<ActionBarPrimitive.Edit asChild>
|
<ActionBarPrimitive.Edit asChild>
|
||||||
<TooltipIconButton tooltip="Edit" className="aui-user-action-edit p-4">
|
<TooltipIconButton tooltip="Edit" className="aui-user-action-edit p-4">
|
||||||
<PencilIcon />
|
<Pen />
|
||||||
</TooltipIconButton>
|
</TooltipIconButton>
|
||||||
</ActionBarPrimitive.Edit>
|
</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";
|
"use client";
|
||||||
import {
|
import { IconBrandDiscord, IconBrandReddit, IconMenu2, IconX } from "@tabler/icons-react";
|
||||||
IconBrandDiscord,
|
|
||||||
IconBrandGithub,
|
|
||||||
IconBrandReddit,
|
|
||||||
IconMenu2,
|
|
||||||
IconX,
|
|
||||||
} from "@tabler/icons-react";
|
|
||||||
import { AnimatePresence, motion } from "motion/react";
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
import Link from "next/link";
|
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 { SignInButton } from "@/components/auth/sign-in-button";
|
||||||
|
import { NavbarGitHubStars } from "@/components/homepage/github-stars-badge";
|
||||||
import { Logo } from "@/components/Logo";
|
import { Logo } from "@/components/Logo";
|
||||||
import { ThemeTogglerComponent } from "@/components/theme/theme-toggle";
|
import { ThemeTogglerComponent } from "@/components/theme/theme-toggle";
|
||||||
import { useGithubStars } from "@/hooks/use-github-stars";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export const Navbar = () => {
|
export const Navbar = () => {
|
||||||
|
|
@ -38,7 +32,7 @@ export const Navbar = () => {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
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} />
|
<DesktopNav navItems={navItems} isScrolled={isScrolled} />
|
||||||
<MobileNav navItems={navItems} isScrolled={isScrolled} />
|
<MobileNav navItems={navItems} isScrolled={isScrolled} />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -47,7 +41,6 @@ export const Navbar = () => {
|
||||||
|
|
||||||
const DesktopNav = ({ navItems, isScrolled }: any) => {
|
const DesktopNav = ({ navItems, isScrolled }: any) => {
|
||||||
const [hovered, setHovered] = useState<number | null>(null);
|
const [hovered, setHovered] = useState<number | null>(null);
|
||||||
const { compactFormat: githubStars, loading: loadingGithubStars } = useGithubStars();
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
onMouseLeave={() => {
|
onMouseLeave={() => {
|
||||||
|
|
@ -103,21 +96,7 @@ const DesktopNav = ({ navItems, isScrolled }: any) => {
|
||||||
>
|
>
|
||||||
<IconBrandReddit className="h-5 w-5 text-neutral-600 dark:text-neutral-300" />
|
<IconBrandReddit className="h-5 w-5 text-neutral-600 dark:text-neutral-300" />
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<NavbarGitHubStars className="hidden md:flex" />
|
||||||
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>
|
|
||||||
<ThemeTogglerComponent />
|
<ThemeTogglerComponent />
|
||||||
<SignInButton variant="desktop" />
|
<SignInButton variant="desktop" />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -127,10 +106,28 @@ const DesktopNav = ({ navItems, isScrolled }: any) => {
|
||||||
|
|
||||||
const MobileNav = ({ navItems, isScrolled }: any) => {
|
const MobileNav = ({ navItems, isScrolled }: any) => {
|
||||||
const [open, setOpen] = useState(false);
|
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 (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
|
ref={navRef}
|
||||||
animate={{ borderRadius: open ? "4px" : "2rem" }}
|
animate={{ borderRadius: open ? "4px" : "2rem" }}
|
||||||
key={String(open)}
|
key={String(open)}
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
@ -197,21 +194,7 @@ const MobileNav = ({ navItems, isScrolled }: any) => {
|
||||||
>
|
>
|
||||||
<IconBrandReddit className="h-5 w-5 text-neutral-600 dark:text-neutral-300" />
|
<IconBrandReddit className="h-5 w-5 text-neutral-600 dark:text-neutral-300" />
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<NavbarGitHubStars className="rounded-lg" />
|
||||||
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>
|
|
||||||
<ThemeTogglerComponent />
|
<ThemeTogglerComponent />
|
||||||
</div>
|
</div>
|
||||||
<SignInButton variant="mobile" />
|
<SignInButton variant="mobile" />
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import { AlertTriangle, Inbox, Megaphone, SquareLibrary } from "lucide-react";
|
||||||
import { useParams, usePathname, useRouter } from "next/navigation";
|
import { useParams, usePathname, useRouter } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useTheme } from "next-themes";
|
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 { toast } from "sonner";
|
||||||
import { currentThreadAtom, resetCurrentThreadAtom } from "@/atoms/chat/current-thread.atom";
|
import { currentThreadAtom, resetCurrentThreadAtom } from "@/atoms/chat/current-thread.atom";
|
||||||
import { documentsSidebarOpenAtom } from "@/atoms/documents/ui.atoms";
|
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
|
// State for handling new chat navigation when router is out of sync
|
||||||
const [pendingNewChat, setPendingNewChat] = useState(false);
|
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
|
// Current IDs from URL, with fallback to atom for replaceState updates
|
||||||
const currentChatId = params?.chat_id
|
const currentChatId = params?.chat_id
|
||||||
? Number(Array.isArray(params.chat_id) ? params.chat_id[0] : 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;
|
const totalUnreadCount = commentsInbox.unreadCount + statusInbox.unreadCount;
|
||||||
|
|
||||||
// Whether any documents are currently being uploaded/indexed — drives sidebar spinner
|
// Document processing status — drives sidebar status indicator (spinner / check / error)
|
||||||
const isDocumentsProcessing = useDocumentsProcessing(numericSpaceId);
|
const documentsProcessingStatus = useDocumentsProcessing(numericSpaceId);
|
||||||
|
|
||||||
// Track seen notification IDs to detect new page_limit_exceeded notifications
|
// Track seen notification IDs to detect new page_limit_exceeded notifications
|
||||||
const seenPageLimitNotifications = useRef<Set<number>>(new Set());
|
const seenPageLimitNotifications = useRef<Set<number>>(new Set());
|
||||||
|
|
@ -271,7 +275,7 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
||||||
url: "#documents",
|
url: "#documents",
|
||||||
icon: SquareLibrary,
|
icon: SquareLibrary,
|
||||||
isActive: isDocumentsSidebarOpen,
|
isActive: isDocumentsSidebarOpen,
|
||||||
showSpinner: isDocumentsProcessing,
|
statusIndicator: documentsProcessingStatus,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Announcements",
|
title: "Announcements",
|
||||||
|
|
@ -287,7 +291,7 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
||||||
totalUnreadCount,
|
totalUnreadCount,
|
||||||
isAnnouncementsSidebarOpen,
|
isAnnouncementsSidebarOpen,
|
||||||
announcementUnreadCount,
|
announcementUnreadCount,
|
||||||
isDocumentsProcessing,
|
documentsProcessingStatus,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -304,12 +308,12 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleUserSettings = useCallback(() => {
|
const handleUserSettings = useCallback(() => {
|
||||||
router.push("/dashboard/user/settings");
|
router.push(`/dashboard/${searchSpaceId}/user-settings?tab=profile`);
|
||||||
}, [router]);
|
}, [router, searchSpaceId]);
|
||||||
|
|
||||||
const handleSearchSpaceSettings = useCallback(
|
const handleSearchSpaceSettings = useCallback(
|
||||||
(space: SearchSpace) => {
|
(space: SearchSpace) => {
|
||||||
router.push(`/dashboard/${space.id}/settings?section=general`);
|
router.push(`/dashboard/${space.id}/settings?tab=general`);
|
||||||
},
|
},
|
||||||
[router]
|
[router]
|
||||||
);
|
);
|
||||||
|
|
@ -478,7 +482,7 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSettings = useCallback(() => {
|
const handleSettings = useCallback(() => {
|
||||||
router.push(`/dashboard/${searchSpaceId}/settings?section=general`);
|
router.push(`/dashboard/${searchSpaceId}/settings?tab=general`);
|
||||||
}, [router, searchSpaceId]);
|
}, [router, searchSpaceId]);
|
||||||
|
|
||||||
const handleManageMembers = useCallback(() => {
|
const handleManageMembers = useCallback(() => {
|
||||||
|
|
@ -535,7 +539,14 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
||||||
await deleteThread(chatToDelete.id);
|
await deleteThread(chatToDelete.id);
|
||||||
queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId] });
|
queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId] });
|
||||||
if (currentChatId === chatToDelete.id) {
|
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) {
|
} catch (error) {
|
||||||
console.error("Error deleting thread:", error);
|
console.error("Error deleting thread:", error);
|
||||||
|
|
@ -544,7 +555,16 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
||||||
setShowDeleteChatDialog(false);
|
setShowDeleteChatDialog(false);
|
||||||
setChatToDelete(null);
|
setChatToDelete(null);
|
||||||
}
|
}
|
||||||
}, [chatToDelete, queryClient, searchSpaceId, router, currentChatId]);
|
}, [
|
||||||
|
chatToDelete,
|
||||||
|
queryClient,
|
||||||
|
searchSpaceId,
|
||||||
|
resetCurrentThread,
|
||||||
|
currentChatId,
|
||||||
|
currentThreadState.id,
|
||||||
|
params?.chat_id,
|
||||||
|
router,
|
||||||
|
]);
|
||||||
|
|
||||||
// Rename handler
|
// Rename handler
|
||||||
const confirmRenameChat = useCallback(async () => {
|
const confirmRenameChat = useCallback(async () => {
|
||||||
|
|
@ -660,7 +680,7 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
||||||
onOpenChange: setIsDocumentsSidebarOpen,
|
onOpenChange: setIsDocumentsSidebarOpen,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
<Fragment key={chatResetKey}>{children}</Fragment>
|
||||||
</LayoutShell>
|
</LayoutShell>
|
||||||
|
|
||||||
{/* Delete Chat Dialog */}
|
{/* Delete Chat Dialog */}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import type { LucideIcon } from "lucide-react";
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
import type { DocumentsProcessingStatus } from "@/hooks/use-documents-processing";
|
||||||
|
|
||||||
export interface SearchSpace {
|
export interface SearchSpace {
|
||||||
id: number;
|
id: number;
|
||||||
|
|
@ -21,7 +22,7 @@ export interface NavItem {
|
||||||
icon: LucideIcon;
|
icon: LucideIcon;
|
||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
badge?: string | number;
|
badge?: string | number;
|
||||||
showSpinner?: boolean;
|
statusIndicator?: DocumentsProcessingStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatItem {
|
export interface ChatItem {
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ export function Header({ mobileMenuTrigger }: HeaderProps) {
|
||||||
const handleVisibilityChange = (_visibility: ChatVisibility) => {};
|
const handleVisibilityChange = (_visibility: ChatVisibility) => {};
|
||||||
|
|
||||||
return (
|
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 */}
|
{/* Left side - Mobile menu trigger + Model selector */}
|
||||||
<div className="flex flex-1 items-center gap-2 min-w-0">
|
<div className="flex flex-1 items-center gap-2 min-w-0">
|
||||||
{mobileMenuTrigger}
|
{mobileMenuTrigger}
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import {
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { useLongPress } from "@/hooks/use-long-press";
|
import { useLongPress } from "@/hooks/use-long-press";
|
||||||
import { useIsMobile } from "@/hooks/use-mobile";
|
import { useIsMobile } from "@/hooks/use-mobile";
|
||||||
|
import { useTypewriter } from "@/hooks/use-typewriter";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface ChatListItemProps {
|
interface ChatListItemProps {
|
||||||
|
|
@ -44,6 +45,7 @@ export function ChatListItem({
|
||||||
const t = useTranslations("sidebar");
|
const t = useTranslations("sidebar");
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||||
|
const animatedName = useTypewriter(name);
|
||||||
|
|
||||||
const { handlers: longPressHandlers, wasLongPress } = useLongPress(
|
const { handlers: longPressHandlers, wasLongPress } = useLongPress(
|
||||||
useCallback(() => setDropdownOpen(true), [])
|
useCallback(() => setDropdownOpen(true), [])
|
||||||
|
|
@ -69,7 +71,7 @@ export function ChatListItem({
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<MessageSquare className="h-4 w-4 shrink-0 text-muted-foreground" />
|
<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>
|
</button>
|
||||||
|
|
||||||
{/* Actions dropdown - trigger hidden on mobile, long-press opens it instead */}
|
{/* 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>
|
<span className="sr-only">{t("more_options")}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" side="right">
|
<DropdownMenuContent align="end" side="bottom">
|
||||||
{onRename && (
|
{onRename && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
|
|
|
||||||
|
|
@ -190,9 +190,10 @@ export function DocumentsSidebar({ open, onOpenChange }: DocumentsSidebarProps)
|
||||||
hasMore={hasMore}
|
hasMore={hasMore}
|
||||||
loadingMore={loadingMore}
|
loadingMore={loadingMore}
|
||||||
onLoadMore={onLoadMore}
|
onLoadMore={onLoadMore}
|
||||||
isSearchMode={isSearchMode}
|
|
||||||
mentionedDocIds={mentionedDocIds}
|
mentionedDocIds={mentionedDocIds}
|
||||||
onToggleChatMention={handleToggleChatMention}
|
onToggleChatMention={handleToggleChatMention}
|
||||||
|
onEditNavigate={() => onOpenChange(false)}
|
||||||
|
isSearchMode={isSearchMode || activeTypes.length > 0}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -166,9 +166,30 @@ export function MobileSidebar({
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
user={user}
|
user={user}
|
||||||
onSettings={onSettings}
|
onSettings={
|
||||||
onManageMembers={onManageMembers}
|
onSettings
|
||||||
onUserSettings={onUserSettings}
|
? () => {
|
||||||
|
onOpenChange(false);
|
||||||
|
onSettings();
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onManageMembers={
|
||||||
|
onManageMembers
|
||||||
|
? () => {
|
||||||
|
onOpenChange(false);
|
||||||
|
onManageMembers();
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onUserSettings={
|
||||||
|
onUserSettings
|
||||||
|
? () => {
|
||||||
|
onOpenChange(false);
|
||||||
|
onUserSettings();
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
onLogout={onLogout}
|
onLogout={onLogout}
|
||||||
pageUsage={pageUsage}
|
pageUsage={pageUsage}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { CheckCircle2, CircleAlert } from "lucide-react";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
@ -11,13 +12,67 @@ interface NavSectionProps {
|
||||||
isCollapsed?: boolean;
|
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) {
|
export function NavSection({ items, onItemClick, isCollapsed = false }: NavSectionProps) {
|
||||||
return (
|
return (
|
||||||
<div className={cn("flex flex-col gap-0.5 py-2", isCollapsed && "items-center")}>
|
<div className={cn("flex flex-col gap-0.5 py-2", isCollapsed && "items-center")}>
|
||||||
{items.map((item) => {
|
{items.map((item) => {
|
||||||
const Icon = item.icon;
|
const Icon = item.icon;
|
||||||
|
const indicator = item.statusIndicator;
|
||||||
|
|
||||||
// Add data-joyride for onboarding tour
|
|
||||||
const joyrideAttr =
|
const joyrideAttr =
|
||||||
item.title === "Documents" || item.title.toLowerCase().includes("documents")
|
item.title === "Documents" || item.title.toLowerCase().includes("documents")
|
||||||
? { "data-joyride": "documents-sidebar" }
|
? { "data-joyride": "documents-sidebar" }
|
||||||
|
|
@ -40,10 +95,8 @@ export function NavSection({ items, onItemClick, isCollapsed = false }: NavSecti
|
||||||
{...joyrideAttr}
|
{...joyrideAttr}
|
||||||
>
|
>
|
||||||
<Icon className="h-4 w-4" />
|
<Icon className="h-4 w-4" />
|
||||||
{item.showSpinner ? (
|
{indicator && indicator !== "idle" ? (
|
||||||
<span className="absolute top-0.5 right-0.5 inline-flex items-center justify-center h-[14px] w-[14px] rounded-full bg-primary/15">
|
<StatusBadge status={indicator} />
|
||||||
<Spinner size="xs" className="text-primary" />
|
|
||||||
</span>
|
|
||||||
) : item.badge ? (
|
) : 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">
|
<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}
|
{item.badge}
|
||||||
|
|
@ -72,11 +125,7 @@ export function NavSection({ items, onItemClick, isCollapsed = false }: NavSecti
|
||||||
)}
|
)}
|
||||||
{...joyrideAttr}
|
{...joyrideAttr}
|
||||||
>
|
>
|
||||||
{item.showSpinner ? (
|
<StatusIcon status={indicator} FallbackIcon={Icon} className="h-4 w-4" />
|
||||||
<Spinner size="sm" className="shrink-0 text-primary" />
|
|
||||||
) : (
|
|
||||||
<Icon className="h-4 w-4 shrink-0" />
|
|
||||||
)}
|
|
||||||
<span className="flex-1 truncate">{item.title}</span>
|
<span className="flex-1 truncate">{item.title}</span>
|
||||||
{item.badge && (
|
{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">
|
<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";
|
"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 { useTranslations } from "next-intl";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import {
|
import {
|
||||||
|
|
@ -16,8 +28,8 @@ import {
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
|
||||||
import { useLocaleContext } from "@/contexts/LocaleContext";
|
import { useLocaleContext } from "@/contexts/LocaleContext";
|
||||||
|
import { APP_VERSION } from "@/lib/env-config";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { User } from "../../types/layout.types";
|
import type { User } from "../../types/layout.types";
|
||||||
|
|
||||||
|
|
@ -37,6 +49,11 @@ const THEMES = [
|
||||||
{ value: "system" as const, name: "System", icon: Laptop },
|
{ 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 {
|
interface SidebarUserProfileProps {
|
||||||
user: User;
|
user: User;
|
||||||
onUserSettings?: () => void;
|
onUserSettings?: () => void;
|
||||||
|
|
@ -100,11 +117,14 @@ function UserAvatar({
|
||||||
}) {
|
}) {
|
||||||
if (avatarUrl) {
|
if (avatarUrl) {
|
||||||
return (
|
return (
|
||||||
<img
|
<Image
|
||||||
src={avatarUrl}
|
src={avatarUrl}
|
||||||
alt="User avatar"
|
alt="User avatar"
|
||||||
|
width={32}
|
||||||
|
height={32}
|
||||||
className="h-8 w-8 shrink-0 rounded-lg object-cover"
|
className="h-8 w-8 shrink-0 rounded-lg object-cover"
|
||||||
referrerPolicy="no-referrer"
|
referrerPolicy="no-referrer"
|
||||||
|
unoptimized
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -157,25 +177,20 @@ export function SidebarUserProfile({
|
||||||
return (
|
return (
|
||||||
<div className="border-t p-2">
|
<div className="border-t p-2">
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<Tooltip>
|
<DropdownMenuTrigger asChild>
|
||||||
<TooltipTrigger asChild>
|
<button
|
||||||
<DropdownMenuTrigger asChild>
|
type="button"
|
||||||
<button
|
className={cn(
|
||||||
type="button"
|
"flex h-10 w-full items-center justify-center rounded-md",
|
||||||
className={cn(
|
"hover:bg-accent transition-colors",
|
||||||
"flex h-10 w-full items-center justify-center rounded-md",
|
"focus:outline-none focus-visible:outline-none",
|
||||||
"hover:bg-accent transition-colors",
|
"data-[state=open]:bg-transparent"
|
||||||
"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>
|
||||||
<UserAvatar avatarUrl={user.avatarUrl} initials={initials} bgColor={bgColor} />
|
</button>
|
||||||
<span className="sr-only">{displayName}</span>
|
</DropdownMenuTrigger>
|
||||||
</button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="right">{displayName}</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
<DropdownMenuContent className="w-48" side="right" align="center" sideOffset={8}>
|
<DropdownMenuContent className="w-48" side="right" align="center" sideOffset={8}>
|
||||||
<DropdownMenuLabel className="font-normal">
|
<DropdownMenuLabel className="font-normal">
|
||||||
|
|
@ -256,6 +271,29 @@ export function SidebarUserProfile({
|
||||||
</DropdownMenuPortal>
|
</DropdownMenuPortal>
|
||||||
</DropdownMenuSub>
|
</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" />
|
<DropdownMenuSeparator className="dark:bg-neutral-700" />
|
||||||
|
|
||||||
<DropdownMenuItem onClick={handleLogout} disabled={isLoggingOut}>
|
<DropdownMenuItem onClick={handleLogout} disabled={isLoggingOut}>
|
||||||
|
|
@ -378,6 +416,29 @@ export function SidebarUserProfile({
|
||||||
</DropdownMenuPortal>
|
</DropdownMenuPortal>
|
||||||
</DropdownMenuSub>
|
</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" />
|
<DropdownMenuSeparator className="dark:bg-neutral-700" />
|
||||||
|
|
||||||
<DropdownMenuItem onClick={handleLogout} disabled={isLoggingOut}>
|
<DropdownMenuItem onClick={handleLogout} disabled={isLoggingOut}>
|
||||||
|
|
|
||||||
|
|
@ -148,7 +148,7 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() =>
|
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"
|
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 (
|
return (
|
||||||
<div
|
<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={{
|
style={{
|
||||||
zIndex: 9999,
|
zIndex: 9999,
|
||||||
...containerStyle,
|
...containerStyle,
|
||||||
|
|
@ -486,6 +486,9 @@ export const DocumentMentionPicker = forwardRef<
|
||||||
{/* User Documents */}
|
{/* User Documents */}
|
||||||
{userDocsList.length > 0 && (
|
{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">
|
<div className="px-3 py-2 text-xs font-bold text-muted-foreground/55">
|
||||||
Your Documents
|
Your Documents
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -243,8 +243,8 @@ export function ImageConfigDialog({
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative w-full max-w-lg h-[85vh]",
|
"relative w-full max-w-lg h-[85vh]",
|
||||||
"rounded-xl bg-background shadow-2xl ring-1 ring-border/50",
|
"rounded-xl bg-background shadow-2xl",
|
||||||
"dark:bg-neutral-900 dark:ring-white/5",
|
"dark:bg-neutral-900",
|
||||||
"flex flex-col overflow-hidden"
|
"flex flex-col overflow-hidden"
|
||||||
)}
|
)}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
|
|
||||||
|
|
@ -195,8 +195,8 @@ export function ModelConfigDialog({
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative w-full max-w-lg h-[85vh]",
|
"relative w-full max-w-lg h-[85vh]",
|
||||||
"rounded-xl bg-background shadow-2xl ring-1 ring-border/50",
|
"rounded-xl bg-background shadow-2xl",
|
||||||
"dark:bg-neutral-900 dark:ring-white/5",
|
"dark:bg-neutral-900",
|
||||||
"flex flex-col overflow-hidden"
|
"flex flex-col overflow-hidden"
|
||||||
)}
|
)}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
|
|
||||||
|
|
@ -291,7 +291,7 @@ export function ModelSelector({
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
|
|
||||||
<PopoverContent
|
<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"
|
align="start"
|
||||||
sideOffset={8}
|
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">
|
<TabsList className="w-full grid grid-cols-2 rounded-none rounded-t-lg bg-transparent h-11 p-0 gap-0">
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="llm"
|
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" />
|
<Zap className="size-4" />
|
||||||
LLM
|
LLM
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="image"
|
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" />
|
<ImageIcon className="size-4" />
|
||||||
Image
|
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";
|
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
|
// Upload limits — files are sent in batches of 5 to avoid proxy timeouts
|
||||||
|
|
@ -122,7 +127,7 @@ export function DocumentUploadTab({
|
||||||
onAccordionStateChange,
|
onAccordionStateChange,
|
||||||
}: DocumentUploadTabProps) {
|
}: DocumentUploadTabProps) {
|
||||||
const t = useTranslations("upload_documents");
|
const t = useTranslations("upload_documents");
|
||||||
const [files, setFiles] = useState<File[]>([]);
|
const [files, setFiles] = useState<FileWithId[]>([]);
|
||||||
const [uploadProgress, setUploadProgress] = useState(0);
|
const [uploadProgress, setUploadProgress] = useState(0);
|
||||||
const [accordionValue, setAccordionValue] = useState<string>("");
|
const [accordionValue, setAccordionValue] = useState<string>("");
|
||||||
const [shouldSummarize, setShouldSummarize] = useState(false);
|
const [shouldSummarize, setShouldSummarize] = useState(false);
|
||||||
|
|
@ -143,9 +148,12 @@ export function DocumentUploadTab({
|
||||||
const onDrop = useCallback(
|
const onDrop = useCallback(
|
||||||
(acceptedFiles: File[]) => {
|
(acceptedFiles: File[]) => {
|
||||||
setFiles((prev) => {
|
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) {
|
if (newFiles.length > MAX_FILES) {
|
||||||
toast.error(t("max_files_exceeded"), {
|
toast.error(t("max_files_exceeded"), {
|
||||||
description: t("max_files_exceeded_desc", { max: MAX_FILES }),
|
description: t("max_files_exceeded_desc", { max: MAX_FILES }),
|
||||||
|
|
@ -153,8 +161,7 @@ export function DocumentUploadTab({
|
||||||
return prev;
|
return prev;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check total size limit
|
const newTotalSize = newFiles.reduce((sum, entry) => sum + entry.file.size, 0);
|
||||||
const newTotalSize = newFiles.reduce((sum, file) => sum + file.size, 0);
|
|
||||||
if (newTotalSize > MAX_TOTAL_SIZE_BYTES) {
|
if (newTotalSize > MAX_TOTAL_SIZE_BYTES) {
|
||||||
toast.error(t("max_size_exceeded"), {
|
toast.error(t("max_size_exceeded"), {
|
||||||
description: t("max_size_exceeded_desc", { max: MAX_TOTAL_SIZE_MB }),
|
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]}`;
|
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
|
// Check if limits are reached
|
||||||
const isFileCountLimitReached = files.length >= MAX_FILES;
|
const isFileCountLimitReached = files.length >= MAX_FILES;
|
||||||
|
|
@ -217,8 +224,13 @@ export function DocumentUploadTab({
|
||||||
setUploadProgress((prev) => (prev >= 90 ? prev : prev + Math.random() * 10));
|
setUploadProgress((prev) => (prev >= 90 ? prev : prev + Math.random() * 10));
|
||||||
}, 200);
|
}, 200);
|
||||||
|
|
||||||
|
const rawFiles = files.map((entry) => entry.file);
|
||||||
uploadDocuments(
|
uploadDocuments(
|
||||||
{ files, search_space_id: Number(searchSpaceId), should_summarize: shouldSummarize },
|
{
|
||||||
|
files: rawFiles,
|
||||||
|
search_space_id: Number(searchSpaceId),
|
||||||
|
should_summarize: shouldSummarize,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
clearInterval(progressInterval);
|
clearInterval(progressInterval);
|
||||||
|
|
@ -303,7 +315,7 @@ export function DocumentUploadTab({
|
||||||
{!isFileCountLimitReached && (
|
{!isFileCountLimitReached && (
|
||||||
<div className="mt-2 sm:mt-4">
|
<div className="mt-2 sm:mt-4">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="text-xs sm:text-sm"
|
className="text-xs sm:text-sm"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
|
|
@ -345,21 +357,21 @@ export function DocumentUploadTab({
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-4 sm:p-6 pt-0">
|
<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">
|
<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
|
<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`}
|
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">
|
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||||
<FileType className="h-5 w-5 text-muted-foreground flex-shrink-0" />
|
<FileType className="h-5 w-5 text-muted-foreground flex-shrink-0" />
|
||||||
<div className="flex-1 min-w-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">
|
<div className="flex items-center gap-2 mt-1">
|
||||||
<Badge variant="secondary" className="text-xs">
|
<Badge variant="secondary" className="text-xs">
|
||||||
{formatFileSize(file.size)}
|
{formatFileSize(entry.file.size)}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge variant="outline" className="text-xs">
|
<Badge variant="outline" className="text-xs">
|
||||||
{file.type || "Unknown type"}
|
{entry.file.type || "Unknown type"}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -367,7 +379,7 @@ export function DocumentUploadTab({
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => setFiles((prev) => prev.filter((_, i) => i !== index))}
|
onClick={() => setFiles((prev) => prev.filter((e) => e.id !== entry.id))}
|
||||||
disabled={isUploading}
|
disabled={isUploading}
|
||||||
className="h-8 w-8"
|
className="h-8 w-8"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import {
|
||||||
CheckIcon,
|
CheckIcon,
|
||||||
FileIcon,
|
FileIcon,
|
||||||
Loader2Icon,
|
Loader2Icon,
|
||||||
PencilIcon,
|
Pen,
|
||||||
RefreshCwIcon,
|
RefreshCwIcon,
|
||||||
XIcon,
|
XIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
@ -400,7 +400,7 @@ function ApprovalCard({
|
||||||
)}
|
)}
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<Button size="sm" variant="outline" onClick={() => setIsEditing(true)}>
|
<Button size="sm" variant="outline" onClick={() => setIsEditing(true)}>
|
||||||
<PencilIcon />
|
<Pen />
|
||||||
Edit
|
Edit
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { makeAssistantToolUI } from "@assistant-ui/react";
|
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 { useMemo, useState } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
|
@ -495,7 +495,7 @@ function ApprovalCard({
|
||||||
)}
|
)}
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<Button size="sm" variant="outline" onClick={() => setIsEditing(true)}>
|
<Button size="sm" variant="outline" onClick={() => setIsEditing(true)}>
|
||||||
<PencilIcon />
|
<Pen />
|
||||||
Edit
|
Edit
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { makeAssistantToolUI } from "@assistant-ui/react";
|
import { makeAssistantToolUI } from "@assistant-ui/react";
|
||||||
import {
|
import { AlertTriangleIcon, CheckIcon, InfoIcon, Loader2Icon, Pen, XIcon } from "lucide-react";
|
||||||
AlertTriangleIcon,
|
|
||||||
CheckIcon,
|
|
||||||
InfoIcon,
|
|
||||||
Loader2Icon,
|
|
||||||
PencilIcon,
|
|
||||||
XIcon,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
|
@ -618,7 +611,7 @@ function ApprovalCard({
|
||||||
)}
|
)}
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<Button size="sm" variant="outline" onClick={() => setIsEditing(true)}>
|
<Button size="sm" variant="outline" onClick={() => setIsEditing(true)}>
|
||||||
<PencilIcon />
|
<Pen />
|
||||||
Edit
|
Edit
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { makeAssistantToolUI } from "@assistant-ui/react";
|
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 { useMemo, useState } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
|
@ -373,7 +373,7 @@ function ApprovalCard({
|
||||||
)}
|
)}
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<Button size="sm" variant="outline" onClick={() => setIsEditing(true)}>
|
<Button size="sm" variant="outline" onClick={() => setIsEditing(true)}>
|
||||||
<PencilIcon />
|
<Pen />
|
||||||
Edit
|
Edit
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import {
|
||||||
Loader2Icon,
|
Loader2Icon,
|
||||||
MaximizeIcon,
|
MaximizeIcon,
|
||||||
MinimizeIcon,
|
MinimizeIcon,
|
||||||
PencilIcon,
|
Pen,
|
||||||
XIcon,
|
XIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
|
|
@ -336,7 +336,7 @@ function ApprovalCard({
|
||||||
)}
|
)}
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<Button size="sm" variant="outline" onClick={() => setIsEditing(true)}>
|
<Button size="sm" variant="outline" onClick={() => setIsEditing(true)}>
|
||||||
<PencilIcon />
|
<Pen />
|
||||||
Edit
|
Edit
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ function AlertDialogContent({
|
||||||
<AlertDialogPrimitive.Content
|
<AlertDialogPrimitive.Content
|
||||||
data-slot="alert-dialog-content"
|
data-slot="alert-dialog-content"
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...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
|
<DialogPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,7 @@ function DropdownMenuItem({
|
||||||
data-inset={inset}
|
data-inset={inset}
|
||||||
data-variant={variant}
|
data-variant={variant}
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
@ -79,7 +79,7 @@ function DropdownMenuCheckboxItem({
|
||||||
<DropdownMenuPrimitive.CheckboxItem
|
<DropdownMenuPrimitive.CheckboxItem
|
||||||
data-slot="dropdown-menu-checkbox-item"
|
data-slot="dropdown-menu-checkbox-item"
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
checked={checked}
|
checked={checked}
|
||||||
|
|
@ -110,7 +110,7 @@ function DropdownMenuRadioItem({
|
||||||
<DropdownMenuPrimitive.RadioItem
|
<DropdownMenuPrimitive.RadioItem
|
||||||
data-slot="dropdown-menu-radio-item"
|
data-slot="dropdown-menu-radio-item"
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
@ -182,7 +182,7 @@ function DropdownMenuSubTrigger({
|
||||||
data-slot="dropdown-menu-sub-trigger"
|
data-slot="dropdown-menu-sub-trigger"
|
||||||
data-inset={inset}
|
data-inset={inset}
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ export function FixedToolbar({
|
||||||
return (
|
return (
|
||||||
<Toolbar
|
<Toolbar
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
|
||||||
|
|
@ -65,7 +65,7 @@ export function FloatingToolbar({
|
||||||
{...rootProps}
|
{...rootProps}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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]",
|
"max-w-[80vw]",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -189,7 +189,7 @@ export function InsertToolbarButton(props: DropdownMenuProps) {
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
|
|
||||||
<DropdownMenuContent
|
<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"
|
align="start"
|
||||||
>
|
>
|
||||||
{groups.map(({ group, items }) => (
|
{groups.map(({ group, items }) => (
|
||||||
|
|
|
||||||
|
|
@ -9,11 +9,14 @@ export function ShortcutKbd({ keys, className }: ShortcutKbdProps) {
|
||||||
if (keys.length === 0) return null;
|
if (keys.length === 0) return null;
|
||||||
|
|
||||||
return (
|
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) => (
|
{keys.map((key) => (
|
||||||
<kbd
|
<kbd
|
||||||
key={key}
|
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}
|
{key}
|
||||||
</kbd>
|
</kbd>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { SlashInputPlugin } from "@platejs/slash-command/react";
|
|
||||||
import {
|
import {
|
||||||
ChevronRightIcon,
|
ChevronRightIcon,
|
||||||
Code2Icon,
|
Code2Icon,
|
||||||
|
|
@ -177,7 +176,7 @@ export function SlashInputElement({ children, ...props }: PlateElementProps) {
|
||||||
<InlineCombobox element={props.element} trigger="/">
|
<InlineCombobox element={props.element} trigger="/">
|
||||||
<InlineComboboxInput />
|
<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>
|
<InlineComboboxEmpty>No results found.</InlineComboboxEmpty>
|
||||||
|
|
||||||
{slashCommandGroups.map(({ heading, items }) => (
|
{slashCommandGroups.map(({ heading, items }) => (
|
||||||
|
|
|
||||||
|
|
@ -150,7 +150,7 @@ export function TurnIntoToolbarButton({
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
|
|
||||||
<DropdownMenuContent
|
<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) => {
|
onCloseAutoFocus={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
editor.tf.focus();
|
editor.tf.focus();
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ export const membership = z.object({
|
||||||
user_email: z.string().nullable().optional(),
|
user_email: z.string().nullable().optional(),
|
||||||
user_display_name: z.string().nullable().optional(),
|
user_display_name: z.string().nullable().optional(),
|
||||||
user_avatar_url: 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(),
|
user_is_active: z.boolean().nullable().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,20 +3,23 @@
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useElectricClient } from "@/lib/electric/context";
|
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
|
* Returns the processing status of documents in the search space:
|
||||||
* uploaded or indexed (status = "pending" | "processing").
|
* - "processing" — at least one doc is pending/processing (show spinner)
|
||||||
*
|
* - "error" — nothing processing, but failed docs exist (show red icon)
|
||||||
* Covers both manual file uploads (2-phase pattern) and all connector indexers,
|
* - "success" — just transitioned from processing → all clear (green check, auto-dismisses)
|
||||||
* since both create documents with status = pending before processing.
|
* - "idle" — nothing noteworthy (show normal icon)
|
||||||
*
|
|
||||||
* The sync shape uses the same columns as useDocuments so Electric can share
|
|
||||||
* the subscription when both hooks are active simultaneously.
|
|
||||||
*/
|
*/
|
||||||
export function useDocumentsProcessing(searchSpaceId: number | null): boolean {
|
export function useDocumentsProcessing(searchSpaceId: number | null): DocumentsProcessingStatus {
|
||||||
const electricClient = useElectricClient();
|
const electricClient = useElectricClient();
|
||||||
const [isProcessing, setIsProcessing] = useState(false);
|
const [status, setStatus] = useState<DocumentsProcessingStatus>("idle");
|
||||||
const liveQueryRef = useRef<{ unsubscribe?: () => void } | null>(null);
|
const liveQueryRef = useRef<{ unsubscribe?: () => void } | null>(null);
|
||||||
|
const wasProcessingRef = useRef(false);
|
||||||
|
const successTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!searchSpaceId || !electricClient) return;
|
if (!searchSpaceId || !electricClient) return;
|
||||||
|
|
@ -76,10 +79,15 @@ export function useDocumentsProcessing(searchSpaceId: number | null): boolean {
|
||||||
|
|
||||||
if (!db.live?.query) return;
|
if (!db.live?.query) return;
|
||||||
|
|
||||||
const liveQuery = await db.live.query<{ count: number | string }>(
|
const liveQuery = await db.live.query<{
|
||||||
`SELECT COUNT(*) as count FROM documents
|
processing_count: number | string;
|
||||||
WHERE search_space_id = $1
|
failed_count: number | string;
|
||||||
AND (status->>'state' = 'pending' OR status->>'state' = 'processing')`,
|
}>(
|
||||||
|
`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]
|
[spaceId]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -88,10 +96,46 @@ export function useDocumentsProcessing(searchSpaceId: number | null): boolean {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
liveQuery.subscribe((result: { rows: Array<{ count: number | string }> }) => {
|
liveQuery.subscribe(
|
||||||
if (!mounted || !result.rows?.[0]) return;
|
(result: {
|
||||||
setIsProcessing((Number(result.rows[0].count) || 0) > 0);
|
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;
|
liveQueryRef.current = liveQuery;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -103,6 +147,10 @@ export function useDocumentsProcessing(searchSpaceId: number | null): boolean {
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
mounted = false;
|
mounted = false;
|
||||||
|
if (successTimerRef.current) {
|
||||||
|
clearTimeout(successTimerRef.current);
|
||||||
|
successTimerRef.current = null;
|
||||||
|
}
|
||||||
if (liveQueryRef.current) {
|
if (liveQueryRef.current) {
|
||||||
try {
|
try {
|
||||||
liveQueryRef.current.unsubscribe?.();
|
liveQueryRef.current.unsubscribe?.();
|
||||||
|
|
@ -114,5 +162,5 @@ export function useDocumentsProcessing(searchSpaceId: number | null): boolean {
|
||||||
};
|
};
|
||||||
}, [searchSpaceId, electricClient]);
|
}, [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",
|
"system": "System",
|
||||||
"logout": "Logout",
|
"logout": "Logout",
|
||||||
"loggingOut": "Logging out...",
|
"loggingOut": "Logging out...",
|
||||||
|
"learn_more": "Learn more",
|
||||||
|
"documentation": "Documentation",
|
||||||
|
"github": "GitHub",
|
||||||
"inbox": "Inbox",
|
"inbox": "Inbox",
|
||||||
"search_inbox": "Search inbox",
|
"search_inbox": "Search inbox",
|
||||||
"mark_all_read": "Mark all as read",
|
"mark_all_read": "Mark all as read",
|
||||||
|
|
|
||||||
|
|
@ -685,6 +685,9 @@
|
||||||
"system": "Sistema",
|
"system": "Sistema",
|
||||||
"logout": "Cerrar sesión",
|
"logout": "Cerrar sesión",
|
||||||
"loggingOut": "Cerrando sesión...",
|
"loggingOut": "Cerrando sesión...",
|
||||||
|
"learn_more": "Más información",
|
||||||
|
"documentation": "Documentación",
|
||||||
|
"github": "GitHub",
|
||||||
"inbox": "Bandeja de entrada",
|
"inbox": "Bandeja de entrada",
|
||||||
"search_inbox": "Buscar en bandeja de entrada",
|
"search_inbox": "Buscar en bandeja de entrada",
|
||||||
"mark_all_read": "Marcar todo como leído",
|
"mark_all_read": "Marcar todo como leído",
|
||||||
|
|
|
||||||
|
|
@ -685,6 +685,9 @@
|
||||||
"system": "सिस्टम",
|
"system": "सिस्टम",
|
||||||
"logout": "लॉगआउट",
|
"logout": "लॉगआउट",
|
||||||
"loggingOut": "लॉगआउट हो रहा है...",
|
"loggingOut": "लॉगआउट हो रहा है...",
|
||||||
|
"learn_more": "और जानें",
|
||||||
|
"documentation": "दस्तावेज़ीकरण",
|
||||||
|
"github": "GitHub",
|
||||||
"inbox": "इनबॉक्स",
|
"inbox": "इनबॉक्स",
|
||||||
"search_inbox": "इनबॉक्स में खोजें",
|
"search_inbox": "इनबॉक्स में खोजें",
|
||||||
"mark_all_read": "सभी पढ़ा हुआ चिह्नित करें",
|
"mark_all_read": "सभी पढ़ा हुआ चिह्नित करें",
|
||||||
|
|
|
||||||
|
|
@ -685,6 +685,9 @@
|
||||||
"system": "Sistema",
|
"system": "Sistema",
|
||||||
"logout": "Sair",
|
"logout": "Sair",
|
||||||
"loggingOut": "Saindo...",
|
"loggingOut": "Saindo...",
|
||||||
|
"learn_more": "Saiba mais",
|
||||||
|
"documentation": "Documentação",
|
||||||
|
"github": "GitHub",
|
||||||
"inbox": "Caixa de entrada",
|
"inbox": "Caixa de entrada",
|
||||||
"search_inbox": "Pesquisar caixa de entrada",
|
"search_inbox": "Pesquisar caixa de entrada",
|
||||||
"mark_all_read": "Marcar tudo como lido",
|
"mark_all_read": "Marcar tudo como lido",
|
||||||
|
|
|
||||||
|
|
@ -669,6 +669,9 @@
|
||||||
"system": "系统",
|
"system": "系统",
|
||||||
"logout": "退出登录",
|
"logout": "退出登录",
|
||||||
"loggingOut": "正在退出...",
|
"loggingOut": "正在退出...",
|
||||||
|
"learn_more": "了解更多",
|
||||||
|
"documentation": "文档",
|
||||||
|
"github": "GitHub",
|
||||||
"inbox": "收件箱",
|
"inbox": "收件箱",
|
||||||
"search_inbox": "搜索收件箱",
|
"search_inbox": "搜索收件箱",
|
||||||
"mark_all_read": "全部标记为已读",
|
"mark_all_read": "全部标记为已读",
|
||||||
|
|
|
||||||
|
|
@ -100,7 +100,7 @@
|
||||||
"katex": "^0.16.28",
|
"katex": "^0.16.28",
|
||||||
"lenis": "^1.3.17",
|
"lenis": "^1.3.17",
|
||||||
"lowlight": "^3.3.0",
|
"lowlight": "^3.3.0",
|
||||||
"lucide-react": "^0.477.0",
|
"lucide-react": "^0.577.0",
|
||||||
"motion": "^12.23.22",
|
"motion": "^12.23.22",
|
||||||
"next": "^16.1.0",
|
"next": "^16.1.0",
|
||||||
"next-intl": "^4.6.1",
|
"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)
|
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:
|
fumadocs-core:
|
||||||
specifier: ^16.3.1
|
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:
|
fumadocs-mdx:
|
||||||
specifier: ^14.2.1
|
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:
|
fumadocs-ui:
|
||||||
specifier: ^16.3.1
|
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:
|
geist:
|
||||||
specifier: ^1.4.2
|
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))
|
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
|
specifier: ^3.3.0
|
||||||
version: 3.3.0
|
version: 3.3.0
|
||||||
lucide-react:
|
lucide-react:
|
||||||
specifier: ^0.477.0
|
specifier: ^0.577.0
|
||||||
version: 0.477.0(react@19.2.4)
|
version: 0.577.0(react@19.2.4)
|
||||||
motion:
|
motion:
|
||||||
specifier: ^12.23.22
|
specifier: ^12.23.22
|
||||||
version: 12.34.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
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:
|
lru-cache@5.1.1:
|
||||||
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
|
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
|
||||||
|
|
||||||
lucide-react@0.477.0:
|
lucide-react@0.570.0:
|
||||||
resolution: {integrity: sha512-yCf7aYxerFZAbd8jHJxjwe1j7jEMPptjnaOqdYeirFnEy85cNR3/L+o0I875CYFYya+eEVzZSbNuRk8BZPDpVw==}
|
resolution: {integrity: sha512-qGnQ8bEPJLMseKo7kI6jK6GW6Y2Yl4PpqoWbroNsobZ8+tZR4SUuO4EXK3oWCdZr48SZ7PnaulTkvzkKvG/Iqg==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
|
|
||||||
lucide-react@0.570.0:
|
lucide-react@0.577.0:
|
||||||
resolution: {integrity: sha512-qGnQ8bEPJLMseKo7kI6jK6GW6Y2Yl4PpqoWbroNsobZ8+tZR4SUuO4EXK3oWCdZr48SZ7PnaulTkvzkKvG/Iqg==}
|
resolution: {integrity: sha512-4LjoFv2eEPwYDPg/CUdBJQSDfPyzXCRrVW1X7jrx/trgxnxkHFjnVZINbzvzxjN70dxychOfg+FTYwBiS3pQ5A==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
|
|
||||||
|
|
@ -11768,7 +11768,7 @@ snapshots:
|
||||||
fsevents@2.3.3:
|
fsevents@2.3.3:
|
||||||
optional: true
|
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:
|
dependencies:
|
||||||
'@formatjs/intl-localematcher': 0.8.1
|
'@formatjs/intl-localematcher': 0.8.1
|
||||||
'@orama/orama': 3.1.18
|
'@orama/orama': 3.1.18
|
||||||
|
|
@ -11799,7 +11799,7 @@ snapshots:
|
||||||
'@types/hast': 3.0.4
|
'@types/hast': 3.0.4
|
||||||
'@types/mdast': 4.0.4
|
'@types/mdast': 4.0.4
|
||||||
'@types/react': 19.2.14
|
'@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)
|
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: 19.2.4
|
||||||
react-dom: 19.2.4(react@19.2.4)
|
react-dom: 19.2.4(react@19.2.4)
|
||||||
|
|
@ -11807,14 +11807,14 @@ snapshots:
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- 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:
|
dependencies:
|
||||||
'@mdx-js/mdx': 3.1.1
|
'@mdx-js/mdx': 3.1.1
|
||||||
'@standard-schema/spec': 1.1.0
|
'@standard-schema/spec': 1.1.0
|
||||||
chokidar: 5.0.0
|
chokidar: 5.0.0
|
||||||
esbuild: 0.27.3
|
esbuild: 0.27.3
|
||||||
estree-util-value-to-estree: 3.5.0
|
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
|
js-yaml: 4.1.1
|
||||||
mdast-util-mdx: 3.0.0
|
mdast-util-mdx: 3.0.0
|
||||||
mdast-util-to-markdown: 2.1.2
|
mdast-util-to-markdown: 2.1.2
|
||||||
|
|
@ -11837,7 +11837,7 @@ snapshots:
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- 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:
|
dependencies:
|
||||||
'@fumadocs/tailwind': 0.0.2(tailwindcss@4.2.1)
|
'@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)
|
'@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-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)
|
'@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
|
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)
|
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)
|
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)
|
next-themes: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
|
|
@ -12519,11 +12519,11 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
yallist: 3.1.1
|
yallist: 3.1.1
|
||||||
|
|
||||||
lucide-react@0.477.0(react@19.2.4):
|
lucide-react@0.570.0(react@19.2.4):
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 19.2.4
|
react: 19.2.4
|
||||||
|
|
||||||
lucide-react@0.570.0(react@19.2.4):
|
lucide-react@0.577.0(react@19.2.4):
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 19.2.4
|
react: 19.2.4
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue