mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-04-30 19:36:25 +02:00
Merge remote-tracking branch 'upstream/dev'
This commit is contained in:
commit
4e7e8ccd7e
141 changed files with 5771 additions and 5223 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,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -320,6 +320,8 @@ async def read_documents(
|
||||||
page_size: int = 50,
|
page_size: int = 50,
|
||||||
search_space_id: int | None = None,
|
search_space_id: int | None = None,
|
||||||
document_types: str | None = None,
|
document_types: str | None = None,
|
||||||
|
sort_by: str = "created_at",
|
||||||
|
sort_order: str = "desc",
|
||||||
session: AsyncSession = Depends(get_async_session),
|
session: AsyncSession = Depends(get_async_session),
|
||||||
user: User = Depends(current_active_user),
|
user: User = Depends(current_active_user),
|
||||||
):
|
):
|
||||||
|
|
@ -392,6 +394,19 @@ async def read_documents(
|
||||||
total_result = await session.execute(count_query)
|
total_result = await session.execute(count_query)
|
||||||
total = total_result.scalar() or 0
|
total = total_result.scalar() or 0
|
||||||
|
|
||||||
|
# Apply sorting
|
||||||
|
from sqlalchemy import asc as sa_asc, desc as sa_desc
|
||||||
|
|
||||||
|
sort_column_map = {
|
||||||
|
"created_at": Document.created_at,
|
||||||
|
"title": Document.title,
|
||||||
|
"document_type": Document.document_type,
|
||||||
|
}
|
||||||
|
sort_col = sort_column_map.get(sort_by, Document.created_at)
|
||||||
|
query = query.order_by(
|
||||||
|
sa_desc(sort_col) if sort_order == "desc" else sa_asc(sort_col)
|
||||||
|
)
|
||||||
|
|
||||||
# Calculate offset
|
# Calculate offset
|
||||||
offset = 0
|
offset = 0
|
||||||
if skip is not None:
|
if skip is not None:
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ from typing import Literal
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import desc, func, select, update
|
from sqlalchemy import desc, func, literal, literal_column, select, update
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.db import Notification, User, get_async_session
|
from app.db import Notification, User, get_async_session
|
||||||
|
|
@ -23,9 +23,26 @@ SYNC_WINDOW_DAYS = 14
|
||||||
|
|
||||||
# Valid notification types - must match frontend InboxItemTypeEnum
|
# Valid notification types - must match frontend InboxItemTypeEnum
|
||||||
NotificationType = Literal[
|
NotificationType = Literal[
|
||||||
"connector_indexing", "document_processing", "new_mention", "page_limit_exceeded"
|
"connector_indexing",
|
||||||
|
"connector_deletion",
|
||||||
|
"document_processing",
|
||||||
|
"new_mention",
|
||||||
|
"comment_reply",
|
||||||
|
"page_limit_exceeded",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Category-to-types mapping for filtering by tab
|
||||||
|
NotificationCategory = Literal["comments", "status"]
|
||||||
|
CATEGORY_TYPES: dict[str, tuple[str, ...]] = {
|
||||||
|
"comments": ("new_mention", "comment_reply"),
|
||||||
|
"status": (
|
||||||
|
"connector_indexing",
|
||||||
|
"connector_deletion",
|
||||||
|
"document_processing",
|
||||||
|
"page_limit_exceeded",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class NotificationResponse(BaseModel):
|
class NotificationResponse(BaseModel):
|
||||||
"""Response model for a single notification."""
|
"""Response model for a single notification."""
|
||||||
|
|
@ -69,6 +86,21 @@ class MarkAllReadResponse(BaseModel):
|
||||||
updated_count: int
|
updated_count: int
|
||||||
|
|
||||||
|
|
||||||
|
class SourceTypeItem(BaseModel):
|
||||||
|
"""A single source type with its category and count."""
|
||||||
|
|
||||||
|
key: str
|
||||||
|
type: str
|
||||||
|
category: str # "connector" or "document"
|
||||||
|
count: int
|
||||||
|
|
||||||
|
|
||||||
|
class SourceTypesResponse(BaseModel):
|
||||||
|
"""Response for notification source types used in status tab filter."""
|
||||||
|
|
||||||
|
sources: list[SourceTypeItem]
|
||||||
|
|
||||||
|
|
||||||
class UnreadCountResponse(BaseModel):
|
class UnreadCountResponse(BaseModel):
|
||||||
"""Response for unread count with split between recent and older items."""
|
"""Response for unread count with split between recent and older items."""
|
||||||
|
|
||||||
|
|
@ -76,12 +108,86 @@ class UnreadCountResponse(BaseModel):
|
||||||
recent_unread: int # Within SYNC_WINDOW_DAYS
|
recent_unread: int # Within SYNC_WINDOW_DAYS
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/source-types", response_model=SourceTypesResponse)
|
||||||
|
async def get_notification_source_types(
|
||||||
|
search_space_id: int | None = Query(None, description="Filter by search space ID"),
|
||||||
|
user: User = Depends(current_active_user),
|
||||||
|
session: AsyncSession = Depends(get_async_session),
|
||||||
|
) -> SourceTypesResponse:
|
||||||
|
"""
|
||||||
|
Get all distinct connector types and document types from the user's
|
||||||
|
status notifications. Used to populate the filter dropdown in the
|
||||||
|
inbox Status tab so that all types are shown regardless of pagination.
|
||||||
|
"""
|
||||||
|
base_filter = [Notification.user_id == user.id]
|
||||||
|
|
||||||
|
if search_space_id is not None:
|
||||||
|
base_filter.append(
|
||||||
|
(Notification.search_space_id == search_space_id)
|
||||||
|
| (Notification.search_space_id.is_(None))
|
||||||
|
)
|
||||||
|
|
||||||
|
connector_type_expr = Notification.notification_metadata["connector_type"].astext
|
||||||
|
connector_query = (
|
||||||
|
select(
|
||||||
|
connector_type_expr.label("source_type"),
|
||||||
|
literal("connector").label("category"),
|
||||||
|
func.count(Notification.id).label("cnt"),
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
*base_filter,
|
||||||
|
Notification.type.in_(("connector_indexing", "connector_deletion")),
|
||||||
|
connector_type_expr.isnot(None),
|
||||||
|
)
|
||||||
|
.group_by(literal_column("source_type"))
|
||||||
|
)
|
||||||
|
|
||||||
|
document_type_expr = Notification.notification_metadata["document_type"].astext
|
||||||
|
document_query = (
|
||||||
|
select(
|
||||||
|
document_type_expr.label("source_type"),
|
||||||
|
literal("document").label("category"),
|
||||||
|
func.count(Notification.id).label("cnt"),
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
*base_filter,
|
||||||
|
Notification.type.in_(("document_processing",)),
|
||||||
|
document_type_expr.isnot(None),
|
||||||
|
)
|
||||||
|
.group_by(literal_column("source_type"))
|
||||||
|
)
|
||||||
|
|
||||||
|
connector_result = await session.execute(connector_query)
|
||||||
|
document_result = await session.execute(document_query)
|
||||||
|
|
||||||
|
sources = []
|
||||||
|
for source_type, category, count in [
|
||||||
|
*connector_result.all(),
|
||||||
|
*document_result.all(),
|
||||||
|
]:
|
||||||
|
if not source_type:
|
||||||
|
continue
|
||||||
|
sources.append(
|
||||||
|
SourceTypeItem(
|
||||||
|
key=f"{category}:{source_type}",
|
||||||
|
type=source_type,
|
||||||
|
category=category,
|
||||||
|
count=count,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return SourceTypesResponse(sources=sources)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/unread-count", response_model=UnreadCountResponse)
|
@router.get("/unread-count", response_model=UnreadCountResponse)
|
||||||
async def get_unread_count(
|
async def get_unread_count(
|
||||||
search_space_id: int | None = Query(None, description="Filter by search space ID"),
|
search_space_id: int | None = Query(None, description="Filter by search space ID"),
|
||||||
type_filter: NotificationType | None = Query(
|
type_filter: NotificationType | None = Query(
|
||||||
None, alias="type", description="Filter by notification type"
|
None, alias="type", description="Filter by notification type"
|
||||||
),
|
),
|
||||||
|
category: NotificationCategory | None = Query(
|
||||||
|
None, description="Filter by category: 'comments' or 'status'"
|
||||||
|
),
|
||||||
user: User = Depends(current_active_user),
|
user: User = Depends(current_active_user),
|
||||||
session: AsyncSession = Depends(get_async_session),
|
session: AsyncSession = Depends(get_async_session),
|
||||||
) -> UnreadCountResponse:
|
) -> UnreadCountResponse:
|
||||||
|
|
@ -116,6 +222,10 @@ async def get_unread_count(
|
||||||
if type_filter:
|
if type_filter:
|
||||||
base_filter.append(Notification.type == type_filter)
|
base_filter.append(Notification.type == type_filter)
|
||||||
|
|
||||||
|
# Filter by category (maps to multiple types)
|
||||||
|
if category:
|
||||||
|
base_filter.append(Notification.type.in_(CATEGORY_TYPES[category]))
|
||||||
|
|
||||||
# Total unread count (all time)
|
# Total unread count (all time)
|
||||||
total_query = select(func.count(Notification.id)).where(*base_filter)
|
total_query = select(func.count(Notification.id)).where(*base_filter)
|
||||||
total_result = await session.execute(total_query)
|
total_result = await session.execute(total_query)
|
||||||
|
|
@ -141,6 +251,17 @@ async def list_notifications(
|
||||||
type_filter: NotificationType | None = Query(
|
type_filter: NotificationType | None = Query(
|
||||||
None, alias="type", description="Filter by notification type"
|
None, alias="type", description="Filter by notification type"
|
||||||
),
|
),
|
||||||
|
category: NotificationCategory | None = Query(
|
||||||
|
None, description="Filter by category: 'comments' or 'status'"
|
||||||
|
),
|
||||||
|
source_type: str | None = Query(
|
||||||
|
None,
|
||||||
|
description="Filter by source type, e.g. 'connector:GITHUB_CONNECTOR' or 'doctype:FILE'",
|
||||||
|
),
|
||||||
|
filter: str | None = Query(
|
||||||
|
None,
|
||||||
|
description="Filter preset: 'unread' for unread only, 'errors' for failed/error items only",
|
||||||
|
),
|
||||||
before_date: str | None = Query(
|
before_date: str | None = Query(
|
||||||
None, description="Get notifications before this ISO date (for pagination)"
|
None, description="Get notifications before this ISO date (for pagination)"
|
||||||
),
|
),
|
||||||
|
|
@ -182,6 +303,45 @@ async def list_notifications(
|
||||||
query = query.where(Notification.type == type_filter)
|
query = query.where(Notification.type == type_filter)
|
||||||
count_query = count_query.where(Notification.type == type_filter)
|
count_query = count_query.where(Notification.type == type_filter)
|
||||||
|
|
||||||
|
# Filter by category (maps to multiple types)
|
||||||
|
if category:
|
||||||
|
cat_types = CATEGORY_TYPES[category]
|
||||||
|
query = query.where(Notification.type.in_(cat_types))
|
||||||
|
count_query = count_query.where(Notification.type.in_(cat_types))
|
||||||
|
|
||||||
|
# Filter by source type (connector or document type from JSONB metadata)
|
||||||
|
if source_type:
|
||||||
|
if source_type.startswith("connector:"):
|
||||||
|
connector_val = source_type[len("connector:") :]
|
||||||
|
source_filter = Notification.type.in_(
|
||||||
|
("connector_indexing", "connector_deletion")
|
||||||
|
) & (
|
||||||
|
Notification.notification_metadata["connector_type"].astext
|
||||||
|
== connector_val
|
||||||
|
)
|
||||||
|
query = query.where(source_filter)
|
||||||
|
count_query = count_query.where(source_filter)
|
||||||
|
elif source_type.startswith("doctype:"):
|
||||||
|
doctype_val = source_type[len("doctype:") :]
|
||||||
|
source_filter = Notification.type.in_(("document_processing",)) & (
|
||||||
|
Notification.notification_metadata["document_type"].astext
|
||||||
|
== doctype_val
|
||||||
|
)
|
||||||
|
query = query.where(source_filter)
|
||||||
|
count_query = count_query.where(source_filter)
|
||||||
|
|
||||||
|
# Filter by preset: 'unread' or 'errors'
|
||||||
|
if filter == "unread":
|
||||||
|
unread_filter = Notification.read == False # noqa: E712
|
||||||
|
query = query.where(unread_filter)
|
||||||
|
count_query = count_query.where(unread_filter)
|
||||||
|
elif filter == "errors":
|
||||||
|
error_filter = (Notification.type == "page_limit_exceeded") | (
|
||||||
|
Notification.notification_metadata["status"].astext == "failed"
|
||||||
|
)
|
||||||
|
query = query.where(error_filter)
|
||||||
|
count_query = count_query.where(error_filter)
|
||||||
|
|
||||||
# Filter by date (for efficient pagination of older items)
|
# Filter by date (for efficient pagination of older items)
|
||||||
if before_date:
|
if before_date:
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,29 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { CTAHomepage } from "@/components/homepage/cta";
|
import dynamic from "next/dynamic";
|
||||||
import { FeaturesBentoGrid } from "@/components/homepage/features-bento-grid";
|
|
||||||
import { FeaturesCards } from "@/components/homepage/features-card";
|
|
||||||
import { HeroSection } from "@/components/homepage/hero-section";
|
import { HeroSection } from "@/components/homepage/hero-section";
|
||||||
import ExternalIntegrations from "@/components/homepage/integrations";
|
|
||||||
|
const FeaturesCards = dynamic(
|
||||||
|
() => import("@/components/homepage/features-card").then((m) => ({ default: m.FeaturesCards })),
|
||||||
|
{ ssr: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
const FeaturesBentoGrid = dynamic(
|
||||||
|
() =>
|
||||||
|
import("@/components/homepage/features-bento-grid").then((m) => ({
|
||||||
|
default: m.FeaturesBentoGrid,
|
||||||
|
})),
|
||||||
|
{ ssr: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
const ExternalIntegrations = dynamic(() => import("@/components/homepage/integrations"), {
|
||||||
|
ssr: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const CTAHomepage = dynamic(
|
||||||
|
() => import("@/components/homepage/cta").then((m) => ({ default: m.CTAHomepage })),
|
||||||
|
{ ssr: false }
|
||||||
|
);
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,7 @@ import {
|
||||||
llmPreferencesAtom,
|
llmPreferencesAtom,
|
||||||
} from "@/atoms/new-llm-config/new-llm-config-query.atoms";
|
} from "@/atoms/new-llm-config/new-llm-config-query.atoms";
|
||||||
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
||||||
import { ConnectorIndicator } from "@/components/assistant-ui/connector-popup";
|
|
||||||
import { DocumentUploadDialogProvider } from "@/components/assistant-ui/document-upload-popup";
|
import { DocumentUploadDialogProvider } from "@/components/assistant-ui/document-upload-popup";
|
||||||
import { DashboardBreadcrumb } from "@/components/dashboard-breadcrumb";
|
|
||||||
import { LayoutDataProvider } from "@/components/layout";
|
import { LayoutDataProvider } from "@/components/layout";
|
||||||
import { OnboardingTour } from "@/components/onboarding-tour";
|
import { OnboardingTour } from "@/components/onboarding-tour";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
|
@ -27,8 +25,6 @@ export function DashboardClientLayout({
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
searchSpaceId: string;
|
searchSpaceId: string;
|
||||||
navSecondary?: any[];
|
|
||||||
navMain?: any[];
|
|
||||||
}) {
|
}) {
|
||||||
const t = useTranslations("dashboard");
|
const t = useTranslations("dashboard");
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
@ -190,11 +186,7 @@ export function DashboardClientLayout({
|
||||||
return (
|
return (
|
||||||
<DocumentUploadDialogProvider>
|
<DocumentUploadDialogProvider>
|
||||||
<OnboardingTour />
|
<OnboardingTour />
|
||||||
<LayoutDataProvider searchSpaceId={searchSpaceId} breadcrumb={<DashboardBreadcrumb />}>
|
<LayoutDataProvider searchSpaceId={searchSpaceId}>{children}</LayoutDataProvider>
|
||||||
{children}
|
|
||||||
</LayoutDataProvider>
|
|
||||||
{/* Global connector dialog - triggered from documents page */}
|
|
||||||
<ConnectorIndicator hideTrigger />
|
|
||||||
</DocumentUploadDialogProvider>
|
</DocumentUploadDialogProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,9 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useSetAtom } from "jotai";
|
import { ListFilter, Search, Upload, X } from "lucide-react";
|
||||||
import {
|
|
||||||
CircleAlert,
|
|
||||||
FileType,
|
|
||||||
ListFilter,
|
|
||||||
Search,
|
|
||||||
SlidersHorizontal,
|
|
||||||
Trash,
|
|
||||||
Upload,
|
|
||||||
X,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { motion } from "motion/react";
|
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import React, { useMemo, useRef, useState } from "react";
|
import React, { useCallback, useMemo, useRef, useState } from "react";
|
||||||
import { connectorDialogOpenAtom } from "@/atoms/connector-dialog/connector-dialog.atoms";
|
|
||||||
import { useDocumentUploadDialog } from "@/components/assistant-ui/document-upload-popup";
|
import { useDocumentUploadDialog } from "@/components/assistant-ui/document-upload-popup";
|
||||||
import {
|
|
||||||
AlertDialog,
|
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
AlertDialogTrigger,
|
|
||||||
} from "@/components/ui/alert-dialog";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
|
@ -36,18 +13,14 @@ import { getDocumentTypeIcon, getDocumentTypeLabel } from "./DocumentTypeIcon";
|
||||||
|
|
||||||
export function DocumentsFilters({
|
export function DocumentsFilters({
|
||||||
typeCounts: typeCountsRecord,
|
typeCounts: typeCountsRecord,
|
||||||
selectedIds,
|
|
||||||
onSearch,
|
onSearch,
|
||||||
searchValue,
|
searchValue,
|
||||||
onBulkDelete,
|
|
||||||
onToggleType,
|
onToggleType,
|
||||||
activeTypes,
|
activeTypes,
|
||||||
}: {
|
}: {
|
||||||
typeCounts: Partial<Record<DocumentTypeEnum, number>>;
|
typeCounts: Partial<Record<DocumentTypeEnum, number>>;
|
||||||
selectedIds: Set<number>;
|
|
||||||
onSearch: (v: string) => void;
|
onSearch: (v: string) => void;
|
||||||
searchValue: string;
|
searchValue: string;
|
||||||
onBulkDelete: () => Promise<void>;
|
|
||||||
onToggleType: (type: DocumentTypeEnum, checked: boolean) => void;
|
onToggleType: (type: DocumentTypeEnum, checked: boolean) => void;
|
||||||
activeTypes: DocumentTypeEnum[];
|
activeTypes: DocumentTypeEnum[];
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -55,11 +28,16 @@ export function DocumentsFilters({
|
||||||
const id = React.useId();
|
const id = React.useId();
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
// Dialog hooks for action buttons
|
|
||||||
const { openDialog: openUploadDialog } = useDocumentUploadDialog();
|
const { openDialog: openUploadDialog } = useDocumentUploadDialog();
|
||||||
const setConnectorDialogOpen = useSetAtom(connectorDialogOpenAtom);
|
|
||||||
|
|
||||||
const [typeSearchQuery, setTypeSearchQuery] = useState("");
|
const [typeSearchQuery, setTypeSearchQuery] = useState("");
|
||||||
|
const [scrollPos, setScrollPos] = useState<"top" | "middle" | "bottom">("top");
|
||||||
|
const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
|
||||||
|
const el = e.currentTarget;
|
||||||
|
const atTop = el.scrollTop <= 2;
|
||||||
|
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2;
|
||||||
|
setScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle");
|
||||||
|
}, []);
|
||||||
|
|
||||||
const uniqueTypes = useMemo(() => {
|
const uniqueTypes = useMemo(() => {
|
||||||
return Object.keys(typeCountsRecord).sort() as DocumentTypeEnum[];
|
return Object.keys(typeCountsRecord).sort() as DocumentTypeEnum[];
|
||||||
|
|
@ -80,235 +58,153 @@ export function DocumentsFilters({
|
||||||
}, [typeCountsRecord]);
|
}, [typeCountsRecord]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<div className="flex select-none">
|
||||||
className="flex flex-col gap-4 select-none"
|
<div className="flex items-center gap-2 w-full">
|
||||||
initial={{ opacity: 0, y: 10 }}
|
{/* Type Filter */}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
<Popover>
|
||||||
transition={{ type: "spring", stiffness: 300, damping: 30, delay: 0.1 }}
|
<PopoverTrigger asChild>
|
||||||
>
|
<Button
|
||||||
{/* Main toolbar row */}
|
variant="outline"
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
size="icon"
|
||||||
{/* Action Buttons - Left Side */}
|
className="h-9 w-9 shrink-0 border-dashed border-sidebar-border text-sidebar-foreground/60 hover:text-sidebar-foreground hover:border-sidebar-border bg-sidebar"
|
||||||
<div className="flex items-center gap-2">
|
>
|
||||||
<Button
|
<ListFilter size={14} />
|
||||||
onClick={openUploadDialog}
|
{activeTypes.length > 0 && (
|
||||||
variant="outline"
|
<span className="absolute -top-1 -right-1 flex h-4 w-4 items-center justify-center rounded-full bg-primary text-[9px] font-medium text-primary-foreground">
|
||||||
size="sm"
|
{activeTypes.length}
|
||||||
className="h-9 gap-2 bg-white text-gray-700 border-white hover:bg-gray-50 dark:bg-white dark:text-gray-800 dark:hover:bg-gray-100"
|
</span>
|
||||||
>
|
)}
|
||||||
<Upload size={16} />
|
</Button>
|
||||||
<span>Upload documents</span>
|
</PopoverTrigger>
|
||||||
</Button>
|
<PopoverContent className="w-64 !p-0 overflow-hidden" align="end">
|
||||||
<Button
|
<div>
|
||||||
onClick={() => setConnectorDialogOpen(true)}
|
{/* Search input */}
|
||||||
variant="outline"
|
<div className="p-2 border-b border-border dark:border-neutral-700">
|
||||||
size="sm"
|
<div className="relative">
|
||||||
className="h-9 gap-2 bg-white text-gray-700 border-white hover:bg-gray-50 dark:bg-white dark:text-gray-800 dark:hover:bg-gray-100"
|
<Search className="absolute left-0.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
>
|
<Input
|
||||||
<SlidersHorizontal size={16} />
|
placeholder="Search types"
|
||||||
<span>Manage connectors</span>
|
value={typeSearchQuery}
|
||||||
</Button>
|
onChange={(e) => setTypeSearchQuery(e.target.value)}
|
||||||
</div>
|
className="h-6 pl-6 text-sm bg-transparent border-0 shadow-none focus-visible:ring-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Spacer */}
|
<div
|
||||||
<div className="flex-1" />
|
className="max-h-[300px] overflow-y-auto overflow-x-hidden py-1.5 px-1.5"
|
||||||
|
onScroll={handleScroll}
|
||||||
|
style={{
|
||||||
|
maskImage: `linear-gradient(to bottom, ${scrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${scrollPos === "bottom" ? "black" : "transparent"})`,
|
||||||
|
WebkitMaskImage: `linear-gradient(to bottom, ${scrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${scrollPos === "bottom" ? "black" : "transparent"})`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{filteredTypes.length === 0 ? (
|
||||||
|
<div className="py-6 text-center text-sm text-muted-foreground">
|
||||||
|
No types found
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filteredTypes.map((value: DocumentTypeEnum, i) => (
|
||||||
|
<div
|
||||||
|
role="option"
|
||||||
|
aria-selected={activeTypes.includes(value)}
|
||||||
|
tabIndex={0}
|
||||||
|
key={value}
|
||||||
|
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))}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
onToggleType(value, !activeTypes.includes(value));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Icon */}
|
||||||
|
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-muted/50 text-foreground/80">
|
||||||
|
{getDocumentTypeIcon(value, "h-4 w-4")}
|
||||||
|
</div>
|
||||||
|
{/* Text content */}
|
||||||
|
<div className="flex flex-col min-w-0 flex-1 gap-0.5">
|
||||||
|
<span className="text-[13px] font-medium text-foreground truncate leading-tight">
|
||||||
|
{getDocumentTypeLabel(value)}
|
||||||
|
</span>
|
||||||
|
<span className="text-[11px] text-muted-foreground leading-tight">
|
||||||
|
{typeCounts.get(value)} document
|
||||||
|
{(typeCounts.get(value) ?? 0) !== 1 ? "s" : ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/* Checkbox */}
|
||||||
|
<Checkbox
|
||||||
|
id={`${id}-${i}`}
|
||||||
|
checked={activeTypes.includes(value)}
|
||||||
|
onCheckedChange={(checked: boolean) => onToggleType(value, !!checked)}
|
||||||
|
className="h-4 w-4 shrink-0 rounded border-muted-foreground/30 data-[state=checked]:bg-primary data-[state=checked]:border-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{activeTypes.length > 0 && (
|
||||||
|
<div className="px-3 pt-1.5 pb-1.5 border-t border-border dark:border-neutral-700">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="w-full h-7 text-[11px] text-muted-foreground hover:text-foreground hover:bg-neutral-200 dark:hover:bg-neutral-700"
|
||||||
|
onClick={() => {
|
||||||
|
activeTypes.forEach((t) => {
|
||||||
|
onToggleType(t, false);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear filters
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
|
||||||
{/* Search Input */}
|
{/* Search Input */}
|
||||||
<motion.div
|
<div className="relative flex-1 min-w-0">
|
||||||
className="relative w-[180px]"
|
|
||||||
initial={{ opacity: 0, y: -10 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ type: "spring", stiffness: 300, damping: 30 }}
|
|
||||||
>
|
|
||||||
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-muted-foreground">
|
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-muted-foreground">
|
||||||
<ListFilter size={14} aria-hidden="true" />
|
<Search size={14} aria-hidden="true" />
|
||||||
</div>
|
</div>
|
||||||
<Input
|
<Input
|
||||||
id={`${id}-input`}
|
id={`${id}-input`}
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
className="peer h-9 w-full pl-9 pr-9 text-sm bg-background border-border/60 focus-visible:ring-1 focus-visible:ring-ring/30 select-none focus:select-text"
|
className="peer h-9 w-full pl-9 pr-9 text-sm bg-sidebar border-border/60 focus-visible:ring-1 focus-visible:ring-ring/30 select-none focus:select-text"
|
||||||
value={searchValue}
|
value={searchValue}
|
||||||
onChange={(e) => onSearch(e.target.value)}
|
onChange={(e) => onSearch(e.target.value)}
|
||||||
placeholder="Filter by title"
|
placeholder="Search docs"
|
||||||
type="text"
|
type="text"
|
||||||
aria-label={t("filter_placeholder")}
|
aria-label={t("filter_placeholder")}
|
||||||
/>
|
/>
|
||||||
{Boolean(searchValue) && (
|
{Boolean(searchValue) && (
|
||||||
<motion.button
|
<button
|
||||||
|
type="button"
|
||||||
className="absolute inset-y-0 right-0 flex h-full w-9 items-center justify-center rounded-r-md text-muted-foreground hover:text-foreground transition-colors"
|
className="absolute inset-y-0 right-0 flex h-full w-9 items-center justify-center rounded-r-md text-muted-foreground hover:text-foreground transition-colors"
|
||||||
aria-label="Clear filter"
|
aria-label="Clear filter"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onSearch("");
|
onSearch("");
|
||||||
inputRef.current?.focus();
|
inputRef.current?.focus();
|
||||||
}}
|
}}
|
||||||
initial={{ opacity: 0, scale: 0.8 }}
|
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
|
||||||
exit={{ opacity: 0, scale: 0.8 }}
|
|
||||||
whileHover={{ scale: 1.1 }}
|
|
||||||
whileTap={{ scale: 0.9 }}
|
|
||||||
>
|
>
|
||||||
<X size={14} strokeWidth={2} aria-hidden="true" />
|
<X size={14} strokeWidth={2} aria-hidden="true" />
|
||||||
</motion.button>
|
</button>
|
||||||
)}
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* Filter Buttons Group */}
|
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
|
||||||
{/* Type Filter */}
|
|
||||||
<Popover>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="h-9 gap-2 border-dashed border-border/60 text-muted-foreground hover:text-foreground hover:border-border"
|
|
||||||
>
|
|
||||||
<FileType size={14} className="text-muted-foreground" />
|
|
||||||
<span className="hidden sm:inline">Type</span>
|
|
||||||
{activeTypes.length > 0 && (
|
|
||||||
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-primary text-[10px] font-medium text-primary-foreground">
|
|
||||||
{activeTypes.length}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="w-64 !p-0 overflow-hidden" align="end">
|
|
||||||
<div>
|
|
||||||
{/* Search input */}
|
|
||||||
<div className="p-2 border-b border-border/50">
|
|
||||||
<div className="relative">
|
|
||||||
<Search className="absolute left-0.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
||||||
<Input
|
|
||||||
placeholder="Search types"
|
|
||||||
value={typeSearchQuery}
|
|
||||||
onChange={(e) => setTypeSearchQuery(e.target.value)}
|
|
||||||
className="h-6 pl-6 text-sm bg-transparent border-0 focus-visible:ring-0"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="max-h-[300px] overflow-y-auto overflow-x-hidden py-1.5 px-1.5">
|
|
||||||
{filteredTypes.length === 0 ? (
|
|
||||||
<div className="py-6 text-center text-sm text-muted-foreground">
|
|
||||||
No types found
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
filteredTypes.map((value: DocumentTypeEnum, i) => (
|
|
||||||
<div
|
|
||||||
key={value}
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
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"
|
|
||||||
onClick={() => onToggleType(value, !activeTypes.includes(value))}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
|
||||||
e.preventDefault();
|
|
||||||
onToggleType(value, !activeTypes.includes(value));
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Icon */}
|
|
||||||
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-muted/50 text-foreground/80">
|
|
||||||
{getDocumentTypeIcon(value, "h-4 w-4")}
|
|
||||||
</div>
|
|
||||||
{/* Text content */}
|
|
||||||
<div className="flex flex-col min-w-0 flex-1 gap-0.5">
|
|
||||||
<span className="text-[13px] font-medium text-foreground truncate leading-tight">
|
|
||||||
{getDocumentTypeLabel(value)}
|
|
||||||
</span>
|
|
||||||
<span className="text-[11px] text-muted-foreground leading-tight">
|
|
||||||
{typeCounts.get(value)} document
|
|
||||||
{(typeCounts.get(value) ?? 0) !== 1 ? "s" : ""}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{/* Checkbox */}
|
|
||||||
<Checkbox
|
|
||||||
id={`${id}-${i}`}
|
|
||||||
checked={activeTypes.includes(value)}
|
|
||||||
onCheckedChange={(checked: boolean) => onToggleType(value, !!checked)}
|
|
||||||
className="h-4 w-4 shrink-0 rounded border-muted-foreground/30 data-[state=checked]:bg-primary data-[state=checked]:border-primary"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{activeTypes.length > 0 && (
|
|
||||||
<div className="px-3 pt-1.5 pb-1.5 border-t border-border/50">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="w-full h-7 text-[11px] text-muted-foreground hover:text-foreground"
|
|
||||||
onClick={() => {
|
|
||||||
activeTypes.forEach((t) => {
|
|
||||||
onToggleType(t, false);
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Clear filters
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
|
|
||||||
{/* Bulk Delete Button */}
|
|
||||||
{selectedIds.size > 0 && (
|
|
||||||
<AlertDialog>
|
|
||||||
<AlertDialogTrigger asChild>
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, scale: 0.9 }}
|
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
|
||||||
exit={{ opacity: 0, scale: 0.9 }}
|
|
||||||
>
|
|
||||||
{/* Mobile: icon with count */}
|
|
||||||
<Button variant="destructive" size="sm" className="h-9 gap-1.5 px-2.5 md:hidden">
|
|
||||||
<Trash size={14} />
|
|
||||||
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-destructive-foreground/20 text-[10px] font-medium">
|
|
||||||
{selectedIds.size}
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
{/* Desktop: full button */}
|
|
||||||
<Button variant="destructive" size="sm" className="h-9 gap-2 hidden md:flex">
|
|
||||||
<Trash size={14} />
|
|
||||||
Delete
|
|
||||||
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-destructive-foreground/20 text-[10px] font-medium">
|
|
||||||
{selectedIds.size}
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
</motion.div>
|
|
||||||
</AlertDialogTrigger>
|
|
||||||
<AlertDialogContent className="max-w-md">
|
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:gap-4">
|
|
||||||
<div
|
|
||||||
className="flex size-10 shrink-0 items-center justify-center rounded-full bg-destructive/10 text-destructive"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<CircleAlert size={18} strokeWidth={2} />
|
|
||||||
</div>
|
|
||||||
<AlertDialogHeader className="flex-1">
|
|
||||||
<AlertDialogTitle>
|
|
||||||
Delete {selectedIds.size} document{selectedIds.size !== 1 ? "s" : ""}?
|
|
||||||
</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
This action cannot be undone. This will permanently delete the selected{" "}
|
|
||||||
{selectedIds.size === 1 ? "document" : "documents"} from your search space.
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
</div>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
||||||
<AlertDialogAction
|
|
||||||
onClick={onBulkDelete}
|
|
||||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Upload Button */}
|
||||||
|
<Button
|
||||||
|
onClick={openUploadDialog}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-9 shrink-0 gap-1.5 bg-white text-gray-700 border-white hover:bg-gray-50 dark:bg-white dark:text-gray-800 dark:hover:bg-gray-100"
|
||||||
|
>
|
||||||
|
<Upload size={14} />
|
||||||
|
<span>Upload</span>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -95,7 +95,6 @@ export function RowActions({
|
||||||
{/* Desktop Actions */}
|
{/* Desktop Actions */}
|
||||||
<div className="hidden md:inline-flex items-center justify-center">
|
<div className="hidden md:inline-flex items-center justify-center">
|
||||||
{isEditable ? (
|
{isEditable ? (
|
||||||
// Editable documents: show 3-dot dropdown with edit + delete
|
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -123,9 +122,7 @@ export function RowActions({
|
||||||
onClick={() => !isDeleteDisabled && setIsDeleteOpen(true)}
|
onClick={() => !isDeleteDisabled && setIsDeleteOpen(true)}
|
||||||
disabled={isDeleteDisabled}
|
disabled={isDeleteDisabled}
|
||||||
className={
|
className={
|
||||||
isDeleteDisabled
|
isDeleteDisabled ? "text-muted-foreground cursor-not-allowed opacity-50" : ""
|
||||||
? "text-muted-foreground cursor-not-allowed opacity-50"
|
|
||||||
: "text-destructive focus:text-destructive"
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
|
@ -135,12 +132,11 @@ export function RowActions({
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
) : (
|
) : (
|
||||||
// Non-editable documents: show only delete button directly
|
|
||||||
shouldShowDelete && (
|
shouldShowDelete && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className={`h-8 w-8 ${isDeleteDisabled ? "text-muted-foreground cursor-not-allowed" : "text-muted-foreground hover:text-destructive hover:bg-destructive/10"}`}
|
className={`h-8 w-8 ${isDeleteDisabled ? "text-muted-foreground cursor-not-allowed" : "text-muted-foreground hover:text-foreground"}`}
|
||||||
onClick={() => !isDeleteDisabled && setIsDeleteOpen(true)}
|
onClick={() => !isDeleteDisabled && setIsDeleteOpen(true)}
|
||||||
disabled={isDeleting || isDeleteDisabled}
|
disabled={isDeleting || isDeleteDisabled}
|
||||||
>
|
>
|
||||||
|
|
@ -154,7 +150,6 @@ export function RowActions({
|
||||||
{/* Mobile Actions Dropdown */}
|
{/* Mobile Actions Dropdown */}
|
||||||
<div className="inline-flex md:hidden items-center justify-center">
|
<div className="inline-flex md:hidden items-center justify-center">
|
||||||
{isEditable ? (
|
{isEditable ? (
|
||||||
// Editable documents: show 3-dot dropdown
|
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground">
|
<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground">
|
||||||
|
|
@ -178,9 +173,7 @@ export function RowActions({
|
||||||
onClick={() => !isDeleteDisabled && setIsDeleteOpen(true)}
|
onClick={() => !isDeleteDisabled && setIsDeleteOpen(true)}
|
||||||
disabled={isDeleteDisabled}
|
disabled={isDeleteDisabled}
|
||||||
className={
|
className={
|
||||||
isDeleteDisabled
|
isDeleteDisabled ? "text-muted-foreground cursor-not-allowed opacity-50" : ""
|
||||||
? "text-muted-foreground cursor-not-allowed opacity-50"
|
|
||||||
: "text-destructive focus:text-destructive"
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
|
@ -190,12 +183,11 @@ export function RowActions({
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
) : (
|
) : (
|
||||||
// Non-editable documents: show only delete button directly
|
|
||||||
shouldShowDelete && (
|
shouldShowDelete && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className={`h-8 w-8 ${isDeleteDisabled ? "text-muted-foreground cursor-not-allowed" : "text-muted-foreground hover:text-destructive hover:bg-destructive/10"}`}
|
className={`h-8 w-8 ${isDeleteDisabled ? "text-muted-foreground cursor-not-allowed" : "text-muted-foreground hover:text-foreground"}`}
|
||||||
onClick={() => !isDeleteDisabled && setIsDeleteOpen(true)}
|
onClick={() => !isDeleteDisabled && setIsDeleteOpen(true)}
|
||||||
disabled={isDeleting || isDeleteDisabled}
|
disabled={isDeleting || isDeleteDisabled}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -1,317 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import { motion } from "motion/react";
|
|
||||||
import { useParams } from "next/navigation";
|
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { deleteDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms";
|
|
||||||
import type { DocumentTypeEnum } from "@/contracts/types/document.types";
|
|
||||||
import { useDocuments } from "@/hooks/use-documents";
|
|
||||||
import { documentsApiService } from "@/lib/apis/documents-api.service";
|
|
||||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
|
||||||
import { DocumentsFilters } from "./components/DocumentsFilters";
|
|
||||||
import { DocumentsTableShell, type SortKey } from "./components/DocumentsTableShell";
|
|
||||||
import { PAGE_SIZE, PaginationControls } from "./components/PaginationControls";
|
|
||||||
import type { ColumnVisibility } from "./components/types";
|
|
||||||
|
|
||||||
function useDebounced<T>(value: T, delay = 250) {
|
|
||||||
const [debounced, setDebounced] = useState(value);
|
|
||||||
useEffect(() => {
|
|
||||||
const t = setTimeout(() => setDebounced(value), delay);
|
|
||||||
return () => clearTimeout(t);
|
|
||||||
}, [value, delay]);
|
|
||||||
return debounced;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function DocumentsTable() {
|
|
||||||
const t = useTranslations("documents");
|
|
||||||
const params = useParams();
|
|
||||||
const searchSpaceId = Number(params.search_space_id);
|
|
||||||
|
|
||||||
const [search, setSearch] = useState("");
|
|
||||||
const debouncedSearch = useDebounced(search, 250);
|
|
||||||
const [activeTypes, setActiveTypes] = useState<DocumentTypeEnum[]>([]);
|
|
||||||
const [columnVisibility, setColumnVisibility] = useState<ColumnVisibility>({
|
|
||||||
document_type: true,
|
|
||||||
created_by: true,
|
|
||||||
created_at: true,
|
|
||||||
status: true,
|
|
||||||
});
|
|
||||||
const [pageIndex, setPageIndex] = useState(0);
|
|
||||||
const [sortKey, setSortKey] = useState<SortKey>("created_at");
|
|
||||||
const [sortDesc, setSortDesc] = useState(true);
|
|
||||||
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
|
|
||||||
const { mutateAsync: deleteDocumentMutation } = useAtomValue(deleteDocumentMutationAtom);
|
|
||||||
|
|
||||||
// REAL-TIME: Use Electric SQL hook for live document updates (when not searching)
|
|
||||||
const {
|
|
||||||
documents: realtimeDocuments,
|
|
||||||
typeCounts: realtimeTypeCounts,
|
|
||||||
loading: realtimeLoading,
|
|
||||||
error: realtimeError,
|
|
||||||
} = useDocuments(searchSpaceId, activeTypes);
|
|
||||||
|
|
||||||
// Check if we're in search mode
|
|
||||||
const isSearchMode = !!debouncedSearch.trim();
|
|
||||||
|
|
||||||
// Build search query parameters (only used when searching)
|
|
||||||
const searchQueryParams = useMemo(
|
|
||||||
() => ({
|
|
||||||
search_space_id: searchSpaceId,
|
|
||||||
page: pageIndex,
|
|
||||||
page_size: PAGE_SIZE,
|
|
||||||
title: debouncedSearch.trim(),
|
|
||||||
...(activeTypes.length > 0 && { document_types: activeTypes }),
|
|
||||||
}),
|
|
||||||
[searchSpaceId, pageIndex, activeTypes, debouncedSearch]
|
|
||||||
);
|
|
||||||
|
|
||||||
// API search query (only enabled when searching - Electric doesn't do full-text search)
|
|
||||||
const {
|
|
||||||
data: searchResponse,
|
|
||||||
isLoading: isSearchLoading,
|
|
||||||
refetch: refetchSearch,
|
|
||||||
error: searchError,
|
|
||||||
} = useQuery({
|
|
||||||
queryKey: cacheKeys.documents.globalQueryParams(searchQueryParams),
|
|
||||||
queryFn: () => documentsApiService.searchDocuments({ queryParams: searchQueryParams }),
|
|
||||||
staleTime: 30 * 1000, // 30 seconds for search (shorter since it's on-demand)
|
|
||||||
enabled: !!searchSpaceId && isSearchMode,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Client-side sorting for real-time documents
|
|
||||||
const sortedRealtimeDocuments = useMemo(() => {
|
|
||||||
const docs = [...realtimeDocuments];
|
|
||||||
docs.sort((a, b) => {
|
|
||||||
const av = a[sortKey] ?? "";
|
|
||||||
const bv = b[sortKey] ?? "";
|
|
||||||
let cmp: number;
|
|
||||||
if (sortKey === "created_at") {
|
|
||||||
cmp = new Date(av as string).getTime() - new Date(bv as string).getTime();
|
|
||||||
} else {
|
|
||||||
cmp = String(av).localeCompare(String(bv));
|
|
||||||
}
|
|
||||||
return sortDesc ? -cmp : cmp;
|
|
||||||
});
|
|
||||||
return docs;
|
|
||||||
}, [realtimeDocuments, sortKey, sortDesc]);
|
|
||||||
|
|
||||||
// Client-side pagination for real-time documents
|
|
||||||
const paginatedRealtimeDocuments = useMemo(() => {
|
|
||||||
const start = pageIndex * PAGE_SIZE;
|
|
||||||
const end = start + PAGE_SIZE;
|
|
||||||
return sortedRealtimeDocuments.slice(start, end);
|
|
||||||
}, [sortedRealtimeDocuments, pageIndex]);
|
|
||||||
|
|
||||||
// Determine what to display based on search mode
|
|
||||||
const displayDocs = isSearchMode
|
|
||||||
? (searchResponse?.items || []).map((item) => ({
|
|
||||||
id: item.id,
|
|
||||||
search_space_id: item.search_space_id,
|
|
||||||
document_type: item.document_type,
|
|
||||||
title: item.title,
|
|
||||||
created_by_id: item.created_by_id ?? null,
|
|
||||||
created_by_name: item.created_by_name ?? null,
|
|
||||||
created_by_email: item.created_by_email ?? null,
|
|
||||||
created_at: item.created_at,
|
|
||||||
status: (
|
|
||||||
item as {
|
|
||||||
status?: { state: "ready" | "pending" | "processing" | "failed"; reason?: string };
|
|
||||||
}
|
|
||||||
).status ?? { state: "ready" as const },
|
|
||||||
}))
|
|
||||||
: paginatedRealtimeDocuments;
|
|
||||||
|
|
||||||
const displayTotal = isSearchMode ? searchResponse?.total || 0 : sortedRealtimeDocuments.length;
|
|
||||||
|
|
||||||
const loading = isSearchMode ? isSearchLoading : realtimeLoading;
|
|
||||||
const error = isSearchMode ? searchError : realtimeError;
|
|
||||||
|
|
||||||
const pageEnd = Math.min((pageIndex + 1) * PAGE_SIZE, displayTotal);
|
|
||||||
|
|
||||||
const onToggleType = (type: DocumentTypeEnum, checked: boolean) => {
|
|
||||||
setActiveTypes((prev) => {
|
|
||||||
if (checked) {
|
|
||||||
return prev.includes(type) ? prev : [...prev, type];
|
|
||||||
} else {
|
|
||||||
return prev.filter((t) => t !== type);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
setPageIndex(0);
|
|
||||||
// Clear selections when filter changes — selected IDs from the previous
|
|
||||||
// filter view are no longer visible and would cause misleading bulk actions
|
|
||||||
setSelectedIds(new Set());
|
|
||||||
};
|
|
||||||
|
|
||||||
const onBulkDelete = async () => {
|
|
||||||
if (selectedIds.size === 0) {
|
|
||||||
toast.error(t("no_rows_selected"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter out pending/processing documents - they cannot be deleted
|
|
||||||
// For real-time mode, use sortedRealtimeDocuments (which has status)
|
|
||||||
// For search mode, use searchResponse items (need to safely access status)
|
|
||||||
const allDocs = isSearchMode
|
|
||||||
? (searchResponse?.items || []).map((item) => ({
|
|
||||||
id: item.id,
|
|
||||||
status: (item as { status?: { state: string } }).status,
|
|
||||||
}))
|
|
||||||
: sortedRealtimeDocuments.map((doc) => ({ id: doc.id, status: doc.status }));
|
|
||||||
|
|
||||||
const selectedDocs = allDocs.filter((doc) => selectedIds.has(doc.id));
|
|
||||||
const deletableIds = selectedDocs
|
|
||||||
.filter((doc) => doc.status?.state !== "pending" && doc.status?.state !== "processing")
|
|
||||||
.map((doc) => doc.id);
|
|
||||||
const inProgressCount = selectedIds.size - deletableIds.length;
|
|
||||||
|
|
||||||
if (inProgressCount > 0) {
|
|
||||||
toast.warning(
|
|
||||||
`${inProgressCount} document(s) are pending or processing and cannot be deleted.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (deletableIds.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Delete documents one by one using the mutation
|
|
||||||
// Track 409 conflicts separately (document started processing after UI loaded)
|
|
||||||
let conflictCount = 0;
|
|
||||||
const results = await Promise.all(
|
|
||||||
deletableIds.map(async (id) => {
|
|
||||||
try {
|
|
||||||
await deleteDocumentMutation({ id });
|
|
||||||
return true;
|
|
||||||
} catch (error: unknown) {
|
|
||||||
const status =
|
|
||||||
(error as { response?: { status?: number } })?.response?.status ??
|
|
||||||
(error as { status?: number })?.status;
|
|
||||||
if (status === 409) conflictCount++;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
const okCount = results.filter((r) => r === true).length;
|
|
||||||
if (okCount === deletableIds.length) {
|
|
||||||
toast.success(t("delete_success_count", { count: okCount }));
|
|
||||||
} else if (conflictCount > 0) {
|
|
||||||
toast.error(`${conflictCount} document(s) started processing. Please try again later.`);
|
|
||||||
} else {
|
|
||||||
toast.error(t("delete_partial_failed"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// If in search mode, refetch search results to reflect deletion
|
|
||||||
if (isSearchMode) {
|
|
||||||
await refetchSearch();
|
|
||||||
}
|
|
||||||
// Real-time mode: Electric will sync the deletion automatically
|
|
||||||
|
|
||||||
setSelectedIds(new Set());
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
toast.error(t("delete_error"));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Single document delete handler for RowActions
|
|
||||||
const handleDeleteDocument = useCallback(
|
|
||||||
async (id: number): Promise<boolean> => {
|
|
||||||
try {
|
|
||||||
await deleteDocumentMutation({ id });
|
|
||||||
toast.success(t("delete_success") || "Document deleted");
|
|
||||||
// If in search mode, refetch search results to reflect deletion
|
|
||||||
if (isSearchMode) {
|
|
||||||
await refetchSearch();
|
|
||||||
}
|
|
||||||
// Real-time mode: Electric will sync the deletion automatically
|
|
||||||
return true;
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Error deleting document:", e);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[deleteDocumentMutation, isSearchMode, refetchSearch, t]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleSortChange = useCallback((key: SortKey) => {
|
|
||||||
setSortKey((currentKey) => {
|
|
||||||
if (currentKey === key) {
|
|
||||||
setSortDesc((v) => !v);
|
|
||||||
return currentKey;
|
|
||||||
}
|
|
||||||
setSortDesc(false);
|
|
||||||
return key;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Reset page when search changes (type filter already resets via onToggleType)
|
|
||||||
// biome-ignore lint/correctness/useExhaustiveDependencies: Intentionally reset page on search change
|
|
||||||
useEffect(() => {
|
|
||||||
setPageIndex(0);
|
|
||||||
}, [debouncedSearch]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const mq = window.matchMedia("(max-width: 768px)");
|
|
||||||
const apply = (isSmall: boolean) => {
|
|
||||||
setColumnVisibility((prev) => ({ ...prev, created_by: !isSmall, created_at: !isSmall }));
|
|
||||||
};
|
|
||||||
apply(mq.matches);
|
|
||||||
const onChange = (e: MediaQueryListEvent) => apply(e.matches);
|
|
||||||
mq.addEventListener("change", onChange);
|
|
||||||
return () => mq.removeEventListener("change", onChange);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ duration: 0.3 }}
|
|
||||||
className="w-full max-w-7xl mx-auto px-6 pt-17 pb-6 space-y-6 min-h-[calc(100vh-64px)]"
|
|
||||||
>
|
|
||||||
{/* Filters - use real-time type counts */}
|
|
||||||
<DocumentsFilters
|
|
||||||
typeCounts={realtimeTypeCounts}
|
|
||||||
selectedIds={selectedIds}
|
|
||||||
onSearch={setSearch}
|
|
||||||
searchValue={search}
|
|
||||||
onBulkDelete={onBulkDelete}
|
|
||||||
onToggleType={onToggleType}
|
|
||||||
activeTypes={activeTypes}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Table */}
|
|
||||||
<DocumentsTableShell
|
|
||||||
documents={displayDocs}
|
|
||||||
loading={!!loading}
|
|
||||||
error={!!error}
|
|
||||||
selectedIds={selectedIds}
|
|
||||||
setSelectedIds={setSelectedIds}
|
|
||||||
columnVisibility={columnVisibility}
|
|
||||||
sortKey={sortKey}
|
|
||||||
sortDesc={sortDesc}
|
|
||||||
onSortChange={handleSortChange}
|
|
||||||
deleteDocument={handleDeleteDocument}
|
|
||||||
searchSpaceId={String(searchSpaceId)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Pagination */}
|
|
||||||
<PaginationControls
|
|
||||||
pageIndex={pageIndex}
|
|
||||||
total={displayTotal}
|
|
||||||
onFirst={() => setPageIndex(0)}
|
|
||||||
onPrev={() => setPageIndex((i) => Math.max(0, i - 1))}
|
|
||||||
onNext={() => setPageIndex((i) => (pageEnd < displayTotal ? i + 1 : i))}
|
|
||||||
onLast={() => setPageIndex(Math.max(0, Math.ceil(displayTotal / PAGE_SIZE) - 1))}
|
|
||||||
canPrev={pageIndex > 0}
|
|
||||||
canNext={pageEnd < displayTotal}
|
|
||||||
/>
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export { DocumentsTable };
|
|
||||||
|
|
@ -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));
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
@ -256,7 +253,7 @@ export default function EditorPage() {
|
||||||
|
|
||||||
setHasUnsavedChanges(false);
|
setHasUnsavedChanges(false);
|
||||||
toast.success("Note created successfully! Reindexing in background...");
|
toast.success("Note created successfully! Reindexing in background...");
|
||||||
router.push(`/dashboard/${searchSpaceId}/documents`);
|
router.push(`/dashboard/${searchSpaceId}/new-chat`);
|
||||||
} else {
|
} else {
|
||||||
// Existing document — save
|
// Existing document — save
|
||||||
const response = await authenticatedFetch(
|
const response = await authenticatedFetch(
|
||||||
|
|
@ -277,7 +274,7 @@ export default function EditorPage() {
|
||||||
|
|
||||||
setHasUnsavedChanges(false);
|
setHasUnsavedChanges(false);
|
||||||
toast.success("Document saved! Reindexing in background...");
|
toast.success("Document saved! Reindexing in background...");
|
||||||
router.push(`/dashboard/${searchSpaceId}/documents`);
|
router.push(`/dashboard/${searchSpaceId}/new-chat`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error saving document:", error);
|
console.error("Error saving document:", error);
|
||||||
|
|
@ -298,7 +295,7 @@ export default function EditorPage() {
|
||||||
if (hasUnsavedChanges) {
|
if (hasUnsavedChanges) {
|
||||||
setShowUnsavedDialog(true);
|
setShowUnsavedDialog(true);
|
||||||
} else {
|
} else {
|
||||||
router.push(`/dashboard/${searchSpaceId}/documents`);
|
router.push(`/dashboard/${searchSpaceId}/new-chat`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -311,7 +308,7 @@ export default function EditorPage() {
|
||||||
router.push(pendingNavigation);
|
router.push(pendingNavigation);
|
||||||
setPendingNavigation(null);
|
setPendingNavigation(null);
|
||||||
} else {
|
} else {
|
||||||
router.push(`/dashboard/${searchSpaceId}/documents`);
|
router.push(`/dashboard/${searchSpaceId}/new-chat`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -10,44 +10,7 @@ export default function DashboardLayout({
|
||||||
params: Promise<{ search_space_id: string }>;
|
params: Promise<{ search_space_id: string }>;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
// Use React.use to unwrap the params Promise
|
|
||||||
const { search_space_id } = use(params);
|
const { search_space_id } = use(params);
|
||||||
|
|
||||||
const customNavSecondary = [
|
return <DashboardClientLayout searchSpaceId={search_space_id}>{children}</DashboardClientLayout>;
|
||||||
{
|
|
||||||
title: `All Search Spaces`,
|
|
||||||
url: `#`,
|
|
||||||
icon: "Info",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: `All Search Spaces`,
|
|
||||||
url: "/dashboard",
|
|
||||||
icon: "Undo2",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const customNavMain = [
|
|
||||||
{
|
|
||||||
title: "Chat",
|
|
||||||
url: `/dashboard/${search_space_id}/new-chat`,
|
|
||||||
icon: "MessageCircle",
|
|
||||||
items: [],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Documents",
|
|
||||||
url: `/dashboard/${search_space_id}/documents`,
|
|
||||||
icon: "SquareLibrary",
|
|
||||||
items: [],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DashboardClientLayout
|
|
||||||
searchSpaceId={search_space_id}
|
|
||||||
navSecondary={customNavSecondary}
|
|
||||||
navMain={customNavMain}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</DashboardClientLayout>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1222,7 +1222,6 @@ function LogRowActions({ row, t }: { row: Row<Log>; t: (key: string) => string }
|
||||||
<AlertDialog open={isOpen} onOpenChange={setIsOpen}>
|
<AlertDialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
<AlertDialogTrigger asChild>
|
<AlertDialogTrigger asChild>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="text-destructive focus:text-destructive"
|
|
||||||
onSelect={(e) => {
|
onSelect={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setIsOpen(true);
|
setIsOpen(true);
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import {
|
||||||
mentionedDocumentIdsAtom,
|
mentionedDocumentIdsAtom,
|
||||||
mentionedDocumentsAtom,
|
mentionedDocumentsAtom,
|
||||||
messageDocumentsMapAtom,
|
messageDocumentsMapAtom,
|
||||||
|
sidebarSelectedDocumentsAtom,
|
||||||
} from "@/atoms/chat/mentioned-documents.atom";
|
} from "@/atoms/chat/mentioned-documents.atom";
|
||||||
import {
|
import {
|
||||||
clearPlanOwnerRegistry,
|
clearPlanOwnerRegistry,
|
||||||
|
|
@ -31,7 +32,6 @@ import { closeReportPanelAtom } from "@/atoms/chat/report-panel.atom";
|
||||||
import { membersAtom } from "@/atoms/members/members-query.atoms";
|
import { membersAtom } from "@/atoms/members/members-query.atoms";
|
||||||
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
||||||
import { Thread } from "@/components/assistant-ui/thread";
|
import { Thread } from "@/components/assistant-ui/thread";
|
||||||
import { ChatHeader } from "@/components/new-chat/chat-header";
|
|
||||||
import { ReportPanel } from "@/components/report-panel/report-panel";
|
import { ReportPanel } from "@/components/report-panel/report-panel";
|
||||||
import type { ThinkingStep } from "@/components/tool-ui/deepagent-thinking";
|
import type { ThinkingStep } from "@/components/tool-ui/deepagent-thinking";
|
||||||
import { DisplayImageToolUI } from "@/components/tool-ui/display-image";
|
import { DisplayImageToolUI } from "@/components/tool-ui/display-image";
|
||||||
|
|
@ -180,11 +180,12 @@ export default function NewChatPage() {
|
||||||
interruptData: Record<string, unknown>;
|
interruptData: Record<string, unknown>;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
// Get mentioned document IDs from the composer
|
// Get mentioned document IDs from the composer (derived from @ mentions + sidebar selections)
|
||||||
const mentionedDocumentIds = useAtomValue(mentionedDocumentIdsAtom);
|
const mentionedDocumentIds = useAtomValue(mentionedDocumentIdsAtom);
|
||||||
const mentionedDocuments = useAtomValue(mentionedDocumentsAtom);
|
const mentionedDocuments = useAtomValue(mentionedDocumentsAtom);
|
||||||
const setMentionedDocumentIds = useSetAtom(mentionedDocumentIdsAtom);
|
const sidebarDocuments = useAtomValue(sidebarSelectedDocumentsAtom);
|
||||||
const setMentionedDocuments = useSetAtom(mentionedDocumentsAtom);
|
const setMentionedDocuments = useSetAtom(mentionedDocumentsAtom);
|
||||||
|
const setSidebarDocuments = useSetAtom(sidebarSelectedDocumentsAtom);
|
||||||
const setMessageDocumentsMap = useSetAtom(messageDocumentsMapAtom);
|
const setMessageDocumentsMap = useSetAtom(messageDocumentsMapAtom);
|
||||||
const setCurrentThreadState = useSetAtom(currentThreadAtom);
|
const setCurrentThreadState = useSetAtom(currentThreadAtom);
|
||||||
const setTargetCommentId = useSetAtom(setTargetCommentIdAtom);
|
const setTargetCommentId = useSetAtom(setTargetCommentIdAtom);
|
||||||
|
|
@ -276,11 +277,8 @@ export default function NewChatPage() {
|
||||||
setThreadId(null);
|
setThreadId(null);
|
||||||
setCurrentThread(null);
|
setCurrentThread(null);
|
||||||
setMessageThinkingSteps(new Map());
|
setMessageThinkingSteps(new Map());
|
||||||
setMentionedDocumentIds({
|
|
||||||
surfsense_doc_ids: [],
|
|
||||||
document_ids: [],
|
|
||||||
});
|
|
||||||
setMentionedDocuments([]);
|
setMentionedDocuments([]);
|
||||||
|
setSidebarDocuments([]);
|
||||||
setMessageDocumentsMap({});
|
setMessageDocumentsMap({});
|
||||||
clearPlanOwnerRegistry(); // Reset plan ownership for new chat
|
clearPlanOwnerRegistry(); // Reset plan ownership for new chat
|
||||||
closeReportPanel(); // Close report panel when switching chats
|
closeReportPanel(); // Close report panel when switching chats
|
||||||
|
|
@ -345,8 +343,8 @@ export default function NewChatPage() {
|
||||||
}, [
|
}, [
|
||||||
urlChatId,
|
urlChatId,
|
||||||
setMessageDocumentsMap,
|
setMessageDocumentsMap,
|
||||||
setMentionedDocumentIds,
|
|
||||||
setMentionedDocuments,
|
setMentionedDocuments,
|
||||||
|
setSidebarDocuments,
|
||||||
closeReportPanel,
|
closeReportPanel,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
@ -467,13 +465,10 @@ 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 ChatHeader can show share button immediately
|
// Set currentThread so share button in header appears immediately
|
||||||
setCurrentThread(newThread);
|
setCurrentThread(newThread);
|
||||||
|
|
||||||
// Track chat creation
|
// Track chat creation
|
||||||
|
|
@ -528,31 +523,30 @@ export default function NewChatPage() {
|
||||||
messageLength: userQuery.length,
|
messageLength: userQuery.length,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Store mentioned documents with this message for display
|
// Combine @-mention chips + sidebar selections for display & persistence
|
||||||
if (mentionedDocuments.length > 0) {
|
const allMentionedDocs: MentionedDocumentInfo[] = [];
|
||||||
const docsInfo: MentionedDocumentInfo[] = mentionedDocuments.map((doc) => ({
|
const seenDocKeys = new Set<string>();
|
||||||
id: doc.id,
|
for (const doc of [...mentionedDocuments, ...sidebarDocuments]) {
|
||||||
title: doc.title,
|
const key = `${doc.document_type}:${doc.id}`;
|
||||||
document_type: doc.document_type,
|
if (!seenDocKeys.has(key)) {
|
||||||
}));
|
seenDocKeys.add(key);
|
||||||
|
allMentionedDocs.push({ id: doc.id, title: doc.title, document_type: doc.document_type });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allMentionedDocs.length > 0) {
|
||||||
setMessageDocumentsMap((prev) => ({
|
setMessageDocumentsMap((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[userMsgId]: docsInfo,
|
[userMsgId]: allMentionedDocs,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Persist user message with mentioned documents (don't await, fire and forget)
|
|
||||||
const persistContent: unknown[] = [...message.content];
|
const persistContent: unknown[] = [...message.content];
|
||||||
|
|
||||||
// Add mentioned documents for persistence
|
if (allMentionedDocs.length > 0) {
|
||||||
if (mentionedDocuments.length > 0) {
|
|
||||||
persistContent.push({
|
persistContent.push({
|
||||||
type: "mentioned-documents",
|
type: "mentioned-documents",
|
||||||
documents: mentionedDocuments.map((doc) => ({
|
documents: allMentionedDocs,
|
||||||
id: doc.id,
|
|
||||||
title: doc.title,
|
|
||||||
document_type: doc.document_type,
|
|
||||||
})),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -560,7 +554,17 @@ export default function NewChatPage() {
|
||||||
role: "user",
|
role: "user",
|
||||||
content: persistContent,
|
content: persistContent,
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then((savedMessage) => {
|
||||||
|
const newUserMsgId = `msg-${savedMessage.id}`;
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.map((m) => (m.id === userMsgId ? { ...m, id: newUserMsgId } : m))
|
||||||
|
);
|
||||||
|
setMessageDocumentsMap((prev) => {
|
||||||
|
const docs = prev[userMsgId];
|
||||||
|
if (!docs) return prev;
|
||||||
|
const { [userMsgId]: _, ...rest } = prev;
|
||||||
|
return { ...rest, [newUserMsgId]: docs };
|
||||||
|
});
|
||||||
if (isNewThread) {
|
if (isNewThread) {
|
||||||
queryClient.invalidateQueries({ queryKey: ["threads", String(searchSpaceId)] });
|
queryClient.invalidateQueries({ queryKey: ["threads", String(searchSpaceId)] });
|
||||||
}
|
}
|
||||||
|
|
@ -618,11 +622,8 @@ export default function NewChatPage() {
|
||||||
|
|
||||||
// Clear mentioned documents after capturing them
|
// Clear mentioned documents after capturing them
|
||||||
if (hasDocumentIds || hasSurfsenseDocIds) {
|
if (hasDocumentIds || hasSurfsenseDocIds) {
|
||||||
setMentionedDocumentIds({
|
|
||||||
surfsense_doc_ids: [],
|
|
||||||
document_ids: [],
|
|
||||||
});
|
|
||||||
setMentionedDocuments([]);
|
setMentionedDocuments([]);
|
||||||
|
setSidebarDocuments([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(`${backendUrl}/api/v1/new_chat`, {
|
const response = await fetch(`${backendUrl}/api/v1/new_chat`, {
|
||||||
|
|
@ -747,15 +748,6 @@ export default function NewChatPage() {
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ["threads", String(searchSpaceId)],
|
queryKey: ["threads", String(searchSpaceId)],
|
||||||
});
|
});
|
||||||
// Invalidate thread detail for breadcrumb update
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: [
|
|
||||||
"threads",
|
|
||||||
String(searchSpaceId),
|
|
||||||
"detail",
|
|
||||||
String(titleData.threadId),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -920,8 +912,9 @@ export default function NewChatPage() {
|
||||||
messages,
|
messages,
|
||||||
mentionedDocumentIds,
|
mentionedDocumentIds,
|
||||||
mentionedDocuments,
|
mentionedDocuments,
|
||||||
setMentionedDocumentIds,
|
sidebarDocuments,
|
||||||
setMentionedDocuments,
|
setMentionedDocuments,
|
||||||
|
setSidebarDocuments,
|
||||||
setMessageDocumentsMap,
|
setMessageDocumentsMap,
|
||||||
queryClient,
|
queryClient,
|
||||||
currentThread,
|
currentThread,
|
||||||
|
|
@ -1674,10 +1667,7 @@ export default function NewChatPage() {
|
||||||
{/* <WriteTodosToolUI /> Disabled for now */}
|
{/* <WriteTodosToolUI /> Disabled for now */}
|
||||||
<div className="flex h-[calc(100dvh-64px)] overflow-hidden">
|
<div className="flex h-[calc(100dvh-64px)] overflow-hidden">
|
||||||
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||||
<Thread
|
<Thread messageThinkingSteps={messageThinkingSteps} />
|
||||||
messageThinkingSteps={messageThinkingSteps}
|
|
||||||
header={<ChatHeader searchSpaceId={searchSpaceId} />}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<ReportPanel />
|
<ReportPanel />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -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] bg-muted dark:border dark:border-neutral-700"
|
className="min-w-[120px] dark:bg-neutral-900 dark:border dark:border-white/5"
|
||||||
>
|
>
|
||||||
{canManageRoles &&
|
{canManageRoles &&
|
||||||
roles
|
roles
|
||||||
|
|
@ -581,8 +579,8 @@ function MemberRow({
|
||||||
<AlertDialog>
|
<AlertDialog>
|
||||||
<AlertDialogTrigger asChild>
|
<AlertDialogTrigger asChild>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="text-destructive focus:text-destructive"
|
|
||||||
onSelect={(e) => e.preventDefault()}
|
onSelect={(e) => e.preventDefault()}
|
||||||
|
className="text-destructive focus:text-destructive"
|
||||||
>
|
>
|
||||||
Remove
|
Remove
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
@ -607,11 +605,9 @@ function MemberRow({
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
)}
|
)}
|
||||||
<DropdownMenuSeparator className="dark:bg-neutral-700" />
|
<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>
|
||||||
|
|
@ -811,7 +807,7 @@ function CreateInviteDialog({
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full justify-start text-left font-normal",
|
"w-full justify-start text-left font-normal bg-transparent",
|
||||||
!expiresAt && "text-muted-foreground"
|
!expiresAt && "text-muted-foreground"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|
@ -832,8 +828,8 @@ function CreateInviteDialog({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter className="gap-3 sm:gap-2">
|
||||||
<Button variant="outline" onClick={handleClose}>
|
<Button variant="secondary" onClick={handleClose}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleCreate} disabled={creating}>
|
<Button onClick={handleCreate} disabled={creating}>
|
||||||
|
|
@ -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%,
|
||||||
|
|
@ -231,7 +243,7 @@ button {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@source '../node_modules/@llamaindex/chat-ui/**/*.{ts,tsx}';
|
@source "../node_modules/@llamaindex/chat-ui/**/*.{ts,tsx}";
|
||||||
@source '../node_modules/streamdown/dist/*.js';
|
@source "../node_modules/streamdown/dist/*.js";
|
||||||
@source '../node_modules/@streamdown/code/dist/*.js';
|
@source "../node_modules/@streamdown/code/dist/*.js";
|
||||||
@source '../node_modules/@streamdown/math/dist/*.js';
|
@source "../node_modules/@streamdown/math/dist/*.js";
|
||||||
|
|
|
||||||
|
|
@ -108,6 +108,9 @@ export default function RootLayout({
|
||||||
// Locale state is managed by LocaleContext and persisted in localStorage
|
// Locale state is managed by LocaleContext and persisted in localStorage
|
||||||
return (
|
return (
|
||||||
<html lang="en" suppressHydrationWarning>
|
<html lang="en" suppressHydrationWarning>
|
||||||
|
<head>
|
||||||
|
<link rel="preconnect" href="https://api.github.com" />
|
||||||
|
</head>
|
||||||
<body className={cn(roboto.className, "bg-white dark:bg-black antialiased h-full w-full ")}>
|
<body className={cn(roboto.className, "bg-white dark:bg-black antialiased h-full w-full ")}>
|
||||||
<PostHogProvider>
|
<PostHogProvider>
|
||||||
<LocaleProvider>
|
<LocaleProvider>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
const backendBaseUrl = (process.env.INTERNAL_FASTAPI_BACKEND_URL || "http://backend:8000").replace(
|
const backendBaseUrl = (process.env.INTERNAL_FASTAPI_BACKEND_URL || "http://backend:8000").replace(
|
||||||
/\/+$/,
|
/\/+$/,
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,47 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { atom } from "jotai";
|
import { atom } from "jotai";
|
||||||
import type { Document, SurfsenseDocsDocument } from "@/contracts/types/document.types";
|
import type { Document } from "@/contracts/types/document.types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Atom to store the IDs of documents mentioned in the current chat composer.
|
* Atom to store the full document objects mentioned via @-mention chips
|
||||||
* This is used to pass document context to the backend when sending a message.
|
* in the current chat composer. This persists across component remounts.
|
||||||
*/
|
|
||||||
export const mentionedDocumentIdsAtom = atom<{
|
|
||||||
surfsense_doc_ids: number[];
|
|
||||||
document_ids: number[];
|
|
||||||
}>({
|
|
||||||
surfsense_doc_ids: [],
|
|
||||||
document_ids: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Atom to store the full document objects mentioned in the current chat composer.
|
|
||||||
* This persists across component remounts.
|
|
||||||
*/
|
*/
|
||||||
export const mentionedDocumentsAtom = atom<Pick<Document, "id" | "title" | "document_type">[]>([]);
|
export const mentionedDocumentsAtom = atom<Pick<Document, "id" | "title" | "document_type">[]>([]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Atom to store documents selected via the sidebar checkboxes / row clicks.
|
||||||
|
* These are NOT inserted as chips – the composer shows a count badge instead.
|
||||||
|
*/
|
||||||
|
export const sidebarSelectedDocumentsAtom = atom<
|
||||||
|
Pick<Document, "id" | "title" | "document_type">[]
|
||||||
|
>([]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derived read-only atom that merges @-mention chips and sidebar selections
|
||||||
|
* into a single deduplicated set of document IDs for the backend.
|
||||||
|
*/
|
||||||
|
export const mentionedDocumentIdsAtom = atom((get) => {
|
||||||
|
const chipDocs = get(mentionedDocumentsAtom);
|
||||||
|
const sidebarDocs = get(sidebarSelectedDocumentsAtom);
|
||||||
|
const allDocs = [...chipDocs, ...sidebarDocs];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const deduped = allDocs.filter((d) => {
|
||||||
|
const key = `${d.document_type}:${d.id}`;
|
||||||
|
if (seen.has(key)) return false;
|
||||||
|
seen.add(key);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
surfsense_doc_ids: deduped
|
||||||
|
.filter((doc) => doc.document_type === "SURFSENSE_DOCS")
|
||||||
|
.map((doc) => doc.id),
|
||||||
|
document_ids: deduped
|
||||||
|
.filter((doc) => doc.document_type !== "SURFSENSE_DOCS")
|
||||||
|
.map((doc) => doc.id),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Simplified document info for display purposes
|
* Simplified document info for display purposes
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,6 @@ export const uploadDocumentMutationAtom = atomWithMutation((get) => {
|
||||||
|
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
// Note: Toast notification is handled by the caller (DocumentUploadTab) to use i18n
|
// Note: Toast notification is handled by the caller (DocumentUploadTab) to use i18n
|
||||||
// Invalidate logs summary to show new processing tasks immediately on documents page
|
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: cacheKeys.logs.summary(searchSpaceId ?? undefined),
|
queryKey: cacheKeys.logs.summary(searchSpaceId ?? undefined),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -5,3 +5,5 @@ export const globalDocumentsQueryParamsAtom = atom<GetDocumentsRequest["queryPar
|
||||||
page_size: 10,
|
page_size: 10,
|
||||||
page: 0,
|
page: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const documentsSidebarOpenAtom = atom(false);
|
||||||
|
|
|
||||||
|
|
@ -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 [];
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"$schema": "https://biomejs.dev/schemas/2.1.2/schema.json",
|
"$schema": "https://biomejs.dev/schemas/2.4.6/schema.json",
|
||||||
"vcs": {
|
"vcs": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"clientKind": "git",
|
"clientKind": "git",
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
},
|
},
|
||||||
"files": {
|
"files": {
|
||||||
"ignoreUnknown": true,
|
"ignoreUnknown": true,
|
||||||
"experimentalScannerIgnores": ["node_modules", ".git", ".next", "dist", "build", "coverage"],
|
"includes": ["**", "!!node_modules", "!!.git", "!!.next", "!!dist", "!!build", "!!coverage"],
|
||||||
"maxSize": 1048576
|
"maxSize": 1048576
|
||||||
},
|
},
|
||||||
"formatter": {
|
"formatter": {
|
||||||
|
|
@ -65,6 +65,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"css": {
|
"css": {
|
||||||
|
"parser": {
|
||||||
|
"tailwindDirectives": true
|
||||||
|
},
|
||||||
"formatter": {
|
"formatter": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"indentStyle": "tab",
|
"indentStyle": "tab",
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {
|
import { Bell, ExternalLink, Info, type LucideIcon, Rocket, Wrench, Zap } from "lucide-react";
|
||||||
Bell,
|
|
||||||
ExternalLink,
|
|
||||||
Info,
|
|
||||||
type LucideIcon,
|
|
||||||
Rocket,
|
|
||||||
Wrench,
|
|
||||||
Zap,
|
|
||||||
} from "lucide-react";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
@ -114,4 +106,3 @@ export function AnnouncementCard({ announcement }: { announcement: AnnouncementW
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,4 +15,3 @@ export function AnnouncementsEmptyState() {
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { useAtomValue } from "jotai";
|
||||||
import { AlertTriangle, Cable, Settings } from "lucide-react";
|
import { AlertTriangle, Cable, Settings } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useSearchParams } from "next/navigation";
|
import { useSearchParams } from "next/navigation";
|
||||||
import type { FC } from "react";
|
import { type FC, useMemo } from "react";
|
||||||
import { documentTypeCountsAtom } from "@/atoms/documents/document-query.atoms";
|
import { documentTypeCountsAtom } from "@/atoms/documents/document-query.atoms";
|
||||||
import {
|
import {
|
||||||
globalNewLLMConfigsAtom,
|
globalNewLLMConfigsAtom,
|
||||||
|
|
@ -37,7 +37,7 @@ import { AllConnectorsTab } from "./connector-popup/tabs/all-connectors-tab";
|
||||||
import { ConnectorAccountsListView } from "./connector-popup/views/connector-accounts-list-view";
|
import { ConnectorAccountsListView } from "./connector-popup/views/connector-accounts-list-view";
|
||||||
import { YouTubeCrawlerView } from "./connector-popup/views/youtube-crawler-view";
|
import { YouTubeCrawlerView } from "./connector-popup/views/youtube-crawler-view";
|
||||||
|
|
||||||
export const ConnectorIndicator: FC<{ hideTrigger?: boolean }> = ({ hideTrigger = false }) => {
|
export const ConnectorIndicator: FC = () => {
|
||||||
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
|
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const { data: currentUser } = useAtomValue(currentUserAtom);
|
const { data: currentUser } = useAtomValue(currentUserAtom);
|
||||||
|
|
@ -66,11 +66,15 @@ export const ConnectorIndicator: FC<{ hideTrigger?: boolean }> = ({ hideTrigger
|
||||||
const { data: documentTypeCounts, isFetching: documentTypesLoading } =
|
const { data: documentTypeCounts, isFetching: documentTypesLoading } =
|
||||||
useAtomValue(documentTypeCountsAtom);
|
useAtomValue(documentTypeCountsAtom);
|
||||||
|
|
||||||
// Fetch notifications to detect indexing failures
|
// Fetch status notifications to detect indexing failures
|
||||||
const { inboxItems = [] } = useInbox(
|
const { inboxItems: statusInboxItems = [] } = useInbox(
|
||||||
currentUser?.id ?? null,
|
currentUser?.id ?? null,
|
||||||
searchSpaceId ? Number(searchSpaceId) : null,
|
searchSpaceId ? Number(searchSpaceId) : null,
|
||||||
"connector_indexing"
|
"status"
|
||||||
|
);
|
||||||
|
const inboxItems = useMemo(
|
||||||
|
() => statusInboxItems.filter((item) => item.type === "connector_indexing"),
|
||||||
|
[statusInboxItems]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Check if YouTube view is active
|
// Check if YouTube view is active
|
||||||
|
|
@ -189,40 +193,36 @@ export const ConnectorIndicator: FC<{ hideTrigger?: boolean }> = ({ hideTrigger
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
|
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
|
||||||
{!hideTrigger && (
|
<TooltipIconButton
|
||||||
<TooltipIconButton
|
data-joyride="connector-icon"
|
||||||
data-joyride="connector-icon"
|
tooltip={hasConnectors ? `Manage ${activeConnectorsCount} connectors` : "Connect your data"}
|
||||||
tooltip={
|
side="bottom"
|
||||||
hasConnectors ? `Manage ${activeConnectorsCount} connectors` : "Connect your data"
|
className={cn(
|
||||||
}
|
"size-[34px] rounded-full p-1 flex items-center justify-center transition-colors relative",
|
||||||
side="bottom"
|
"hover:bg-muted-foreground/15 dark:hover:bg-muted-foreground/30",
|
||||||
className={cn(
|
"outline-none focus:outline-none focus-visible:outline-none font-semibold text-xs",
|
||||||
"size-[34px] rounded-full p-1 flex items-center justify-center transition-colors relative",
|
"border-0 ring-0 focus:ring-0 shadow-none focus:shadow-none"
|
||||||
"hover:bg-muted-foreground/15 dark:hover:bg-muted-foreground/30",
|
)}
|
||||||
"outline-none focus:outline-none focus-visible:outline-none font-semibold text-xs",
|
aria-label={
|
||||||
"border-0 ring-0 focus:ring-0 shadow-none focus:shadow-none"
|
hasConnectors ? `View ${activeConnectorsCount} connectors` : "Add your first connector"
|
||||||
)}
|
}
|
||||||
aria-label={
|
onClick={() => handleOpenChange(true)}
|
||||||
hasConnectors ? `View ${activeConnectorsCount} connectors` : "Add your first connector"
|
>
|
||||||
}
|
{isLoading ? (
|
||||||
onClick={() => handleOpenChange(true)}
|
<Spinner size="sm" />
|
||||||
>
|
) : (
|
||||||
{isLoading ? (
|
<>
|
||||||
<Spinner size="sm" />
|
<Cable className="size-4 stroke-[1.5px]" />
|
||||||
) : (
|
{activeConnectorsCount > 0 && (
|
||||||
<>
|
<span className="absolute -top-0.5 right-0 flex items-center justify-center min-w-[16px] h-4 px-1 text-[10px] font-medium rounded-full bg-primary text-primary-foreground shadow-sm select-none">
|
||||||
<Cable className="size-4 stroke-[1.5px]" />
|
{activeConnectorsCount > 99 ? "99+" : activeConnectorsCount}
|
||||||
{activeConnectorsCount > 0 && (
|
</span>
|
||||||
<span className="absolute -top-0.5 right-0 flex items-center justify-center min-w-[16px] h-4 px-1 text-[10px] font-medium rounded-full bg-primary text-primary-foreground shadow-sm">
|
)}
|
||||||
{activeConnectorsCount > 99 ? "99+" : activeConnectorsCount}
|
</>
|
||||||
</span>
|
)}
|
||||||
)}
|
</TooltipIconButton>
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</TooltipIconButton>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DialogContent className="max-w-3xl w-[95vw] sm:w-full h-[75vh] sm:h-[85vh] flex flex-col p-0 gap-0 overflow-hidden border border-border bg-muted text-foreground focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 [&>button]:right-4 sm:[&>button]:right-12 [&>button]:top-6 sm:[&>button]:top-10 [&>button]:opacity-80 hover:[&>button]:opacity-100 [&>button_svg]:size-5 select-none">
|
<DialogContent className="max-w-3xl w-[95vw] sm:w-full h-[75vh] sm:h-[85vh] flex flex-col p-0 gap-0 overflow-hidden border border-border ring-0 dark:ring-0 bg-muted dark:bg-muted text-foreground focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 [&>button]:right-4 sm:[&>button]:right-12 [&>button]:top-6 sm:[&>button]:top-10 [&>button]:opacity-80 hover:[&>button]:opacity-100 [&>button_svg]:size-5 select-none">
|
||||||
<DialogTitle className="sr-only">Manage Connectors</DialogTitle>
|
<DialogTitle className="sr-only">Manage Connectors</DialogTitle>
|
||||||
{/* YouTube Crawler View - shown when adding YouTube videos */}
|
{/* YouTube Crawler View - shown when adding YouTube videos */}
|
||||||
{isYouTubeView && searchSpaceId ? (
|
{isYouTubeView && searchSpaceId ? (
|
||||||
|
|
@ -379,7 +379,7 @@ export const ConnectorIndicator: FC<{ hideTrigger?: boolean }> = ({ hideTrigger
|
||||||
: "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>
|
||||||
|
|
@ -415,7 +415,6 @@ export const ConnectorIndicator: FC<{ hideTrigger?: boolean }> = ({ hideTrigger
|
||||||
activeDocumentTypes={activeDocumentTypes}
|
activeDocumentTypes={activeDocumentTypes}
|
||||||
connectors={connectors as SearchSourceConnector[]}
|
connectors={connectors as SearchSourceConnector[]}
|
||||||
indexingConnectorIds={indexingConnectorIds}
|
indexingConnectorIds={indexingConnectorIds}
|
||||||
searchSpaceId={searchSpaceId}
|
|
||||||
onTabChange={handleTabChange}
|
onTabChange={handleTabChange}
|
||||||
onManage={handleStartEdit}
|
onManage={handleStartEdit}
|
||||||
onViewAccountsList={handleViewAccountsList}
|
onViewAccountsList={handleViewAccountsList}
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,13 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ArrowRight, Cable } from "lucide-react";
|
import { Cable } from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
import { useState } from "react";
|
|
||||||
import { getDocumentTypeLabel } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentTypeIcon";
|
import { getDocumentTypeLabel } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentTypeIcon";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { Switch } from "@/components/ui/switch";
|
|
||||||
import { TabsContent } from "@/components/ui/tabs";
|
import { TabsContent } from "@/components/ui/tabs";
|
||||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||||
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
||||||
import type { LogActiveTask, LogSummary } from "@/contracts/types/log.types";
|
|
||||||
import { connectorsApiService } from "@/lib/apis/connectors-api.service";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { COMPOSIO_CONNECTORS, OAUTH_CONNECTORS } from "../constants/connector-constants";
|
import { COMPOSIO_CONNECTORS, OAUTH_CONNECTORS } from "../constants/connector-constants";
|
||||||
import { getDocumentCountForConnector } from "../utils/connector-document-mapping";
|
import { getDocumentCountForConnector } from "../utils/connector-document-mapping";
|
||||||
|
|
@ -25,37 +20,21 @@ interface ActiveConnectorsTabProps {
|
||||||
activeDocumentTypes: Array<[string, number]>;
|
activeDocumentTypes: Array<[string, number]>;
|
||||||
connectors: SearchSourceConnector[];
|
connectors: SearchSourceConnector[];
|
||||||
indexingConnectorIds: Set<number>;
|
indexingConnectorIds: Set<number>;
|
||||||
searchSpaceId: string;
|
|
||||||
onTabChange: (value: string) => void;
|
onTabChange: (value: string) => void;
|
||||||
onManage?: (connector: SearchSourceConnector) => void;
|
onManage?: (connector: SearchSourceConnector) => void;
|
||||||
onViewAccountsList?: (connectorType: string, connectorTitle: string) => void;
|
onViewAccountsList?: (connectorType: string, connectorTitle: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a connector type is indexable
|
|
||||||
*/
|
|
||||||
function isIndexableConnector(connectorType: string): boolean {
|
|
||||||
const nonIndexableTypes = ["MCP_CONNECTOR"];
|
|
||||||
return !nonIndexableTypes.includes(connectorType);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
|
export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
|
||||||
searchQuery,
|
searchQuery,
|
||||||
hasSources,
|
hasSources,
|
||||||
activeDocumentTypes,
|
activeDocumentTypes,
|
||||||
connectors,
|
connectors,
|
||||||
indexingConnectorIds,
|
indexingConnectorIds,
|
||||||
searchSpaceId,
|
onTabChange: _onTabChange,
|
||||||
onTabChange,
|
|
||||||
onManage,
|
onManage,
|
||||||
onViewAccountsList,
|
onViewAccountsList,
|
||||||
}) => {
|
}) => {
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const handleViewAllDocuments = () => {
|
|
||||||
router.push(`/dashboard/${searchSpaceId}/documents`);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Convert activeDocumentTypes array to Record for utility function
|
// Convert activeDocumentTypes array to Record for utility function
|
||||||
const documentTypeCounts = activeDocumentTypes.reduce(
|
const documentTypeCounts = activeDocumentTypes.reduce(
|
||||||
(acc, [docType, count]) => {
|
(acc, [docType, count]) => {
|
||||||
|
|
@ -300,15 +279,6 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="text-sm font-semibold text-muted-foreground">Documents</h3>
|
<h3 className="text-sm font-semibold text-muted-foreground">Documents</h3>
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleViewAllDocuments}
|
|
||||||
className="h-7 text-xs text-muted-foreground hover:text-foreground gap-1.5"
|
|
||||||
>
|
|
||||||
View all documents
|
|
||||||
<ArrowRight className="size-3" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{standaloneDocuments.map((doc) => (
|
{standaloneDocuments.map((doc) => (
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@
|
||||||
import { TagInput, type Tag as TagType } from "emblor";
|
import { TagInput, type Tag as TagType } from "emblor";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { ArrowLeft } from "lucide-react";
|
import { ArrowLeft } from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { type FC, useState } from "react";
|
import { type FC, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
@ -24,7 +23,6 @@ interface YouTubeCrawlerViewProps {
|
||||||
|
|
||||||
export const YouTubeCrawlerView: FC<YouTubeCrawlerViewProps> = ({ searchSpaceId, onBack }) => {
|
export const YouTubeCrawlerView: FC<YouTubeCrawlerViewProps> = ({ searchSpaceId, onBack }) => {
|
||||||
const t = useTranslations("add_youtube");
|
const t = useTranslations("add_youtube");
|
||||||
const router = useRouter();
|
|
||||||
const [videoTags, setVideoTags] = useState<TagType[]>([]);
|
const [videoTags, setVideoTags] = useState<TagType[]>([]);
|
||||||
const [activeTagIndex, setActiveTagIndex] = useState<number | null>(null);
|
const [activeTagIndex, setActiveTagIndex] = useState<number | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
@ -74,9 +72,7 @@ export const YouTubeCrawlerView: FC<YouTubeCrawlerViewProps> = ({ searchSpaceId,
|
||||||
toast(t("success_toast"), {
|
toast(t("success_toast"), {
|
||||||
description: t("success_toast_desc"),
|
description: t("success_toast_desc"),
|
||||||
});
|
});
|
||||||
// Close the popup and navigate to documents
|
|
||||||
onBack();
|
onBack();
|
||||||
router.push(`/dashboard/${searchSpaceId}/documents`);
|
|
||||||
},
|
},
|
||||||
onError: (error: unknown) => {
|
onError: (error: unknown) => {
|
||||||
const errorMessage = error instanceof Error ? error.message : t("error_generic");
|
const errorMessage = error instanceof Error ? error.message : t("error_generic");
|
||||||
|
|
|
||||||
|
|
@ -120,7 +120,12 @@ const DocumentUploadPopupContent: FC<{
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="select-none max-w-4xl w-[95vw] sm:w-full h-[calc(100dvh-2rem)] sm:h-[85vh] flex flex-col p-0 gap-0 overflow-hidden border border-border bg-muted text-foreground [&>button]:right-3 sm:[&>button]:right-12 [&>button]:top-3 sm:[&>button]:top-10 [&>button]:opacity-80 hover:[&>button]:opacity-100 [&>button]:z-[100] [&>button_svg]:size-4 sm:[&>button_svg]:size-5">
|
<DialogContent
|
||||||
|
onPointerDownOutside={(e) => e.preventDefault()}
|
||||||
|
onInteractOutside={(e) => e.preventDefault()}
|
||||||
|
onEscapeKeyDown={(e) => e.preventDefault()}
|
||||||
|
className="select-none max-w-4xl w-[95vw] sm:w-full h-[calc(100dvh-2rem)] sm:h-[85vh] flex flex-col p-0 gap-0 overflow-hidden border border-border ring-0 bg-muted dark:bg-muted text-foreground [&>button]:right-3 sm:[&>button]:right-12 [&>button]:top-3 sm:[&>button]:top-10 [&>button]:opacity-80 hover:[&>button]:opacity-100 [&>button]:z-[100] [&>button_svg]:size-4 sm:[&>button_svg]:size-5"
|
||||||
|
>
|
||||||
<DialogTitle className="sr-only">Upload Document</DialogTitle>
|
<DialogTitle className="sr-only">Upload Document</DialogTitle>
|
||||||
|
|
||||||
{/* Scrollable container for mobile */}
|
{/* Scrollable container for mobile */}
|
||||||
|
|
@ -153,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>
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ export interface InlineMentionEditorRef {
|
||||||
getText: () => string;
|
getText: () => string;
|
||||||
getMentionedDocuments: () => MentionedDocument[];
|
getMentionedDocuments: () => MentionedDocument[];
|
||||||
insertDocumentChip: (doc: Pick<Document, "id" | "title" | "document_type">) => void;
|
insertDocumentChip: (doc: Pick<Document, "id" | "title" | "document_type">) => void;
|
||||||
|
removeDocumentChip: (docId: number, docType?: string) => void;
|
||||||
setDocumentChipStatus: (
|
setDocumentChipStatus: (
|
||||||
docId: number,
|
docId: number,
|
||||||
docType: string | undefined,
|
docType: string | undefined,
|
||||||
|
|
@ -175,33 +176,27 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
|
||||||
chip.setAttribute(CHIP_DOCTYPE_ATTR, doc.document_type ?? "UNKNOWN");
|
chip.setAttribute(CHIP_DOCTYPE_ATTR, doc.document_type ?? "UNKNOWN");
|
||||||
chip.contentEditable = "false";
|
chip.contentEditable = "false";
|
||||||
chip.className =
|
chip.className =
|
||||||
"inline-flex items-center gap-1 mx-0.5 pl-1 pr-0.5 py-0.5 rounded bg-primary/10 text-xs font-bold text-primary/60 select-none";
|
"inline-flex items-center gap-1 mx-0.5 px-1 py-0.5 rounded bg-primary/10 text-xs font-bold text-primary/60 select-none cursor-default";
|
||||||
chip.style.userSelect = "none";
|
chip.style.userSelect = "none";
|
||||||
chip.style.verticalAlign = "baseline";
|
chip.style.verticalAlign = "baseline";
|
||||||
|
|
||||||
// Add document type icon
|
// Container that swaps between icon and remove button on hover
|
||||||
|
const iconContainer = document.createElement("span");
|
||||||
|
iconContainer.className = "shrink-0 flex items-center size-3 relative";
|
||||||
|
|
||||||
const iconSpan = document.createElement("span");
|
const iconSpan = document.createElement("span");
|
||||||
iconSpan.className = "shrink-0 flex items-center text-muted-foreground";
|
iconSpan.className = "flex items-center text-muted-foreground";
|
||||||
iconSpan.innerHTML = ReactDOMServer.renderToString(
|
iconSpan.innerHTML = ReactDOMServer.renderToString(
|
||||||
getConnectorIcon(doc.document_type ?? "UNKNOWN", "h-3 w-3")
|
getConnectorIcon(doc.document_type ?? "UNKNOWN", "h-3 w-3")
|
||||||
);
|
);
|
||||||
|
|
||||||
const titleSpan = document.createElement("span");
|
|
||||||
titleSpan.className = "max-w-[120px] truncate";
|
|
||||||
titleSpan.textContent = doc.title;
|
|
||||||
titleSpan.title = doc.title;
|
|
||||||
titleSpan.setAttribute("data-mention-title", "true");
|
|
||||||
|
|
||||||
const statusSpan = document.createElement("span");
|
|
||||||
statusSpan.setAttribute(CHIP_STATUS_ATTR, "true");
|
|
||||||
statusSpan.className = "text-[10px] font-semibold opacity-80 hidden";
|
|
||||||
|
|
||||||
const removeBtn = document.createElement("button");
|
const removeBtn = document.createElement("button");
|
||||||
removeBtn.type = "button";
|
removeBtn.type = "button";
|
||||||
removeBtn.className =
|
removeBtn.className =
|
||||||
"size-3 flex items-center justify-center rounded-full hover:bg-primary/20 transition-colors ml-0.5";
|
"size-3 items-center justify-center rounded-full text-muted-foreground transition-colors";
|
||||||
|
removeBtn.style.display = "none";
|
||||||
removeBtn.innerHTML = ReactDOMServer.renderToString(
|
removeBtn.innerHTML = ReactDOMServer.renderToString(
|
||||||
createElement(X, { className: "h-2.5 w-2.5", strokeWidth: 2.5 })
|
createElement(X, { className: "h-3 w-3", strokeWidth: 2.5 })
|
||||||
);
|
);
|
||||||
removeBtn.onclick = (e) => {
|
removeBtn.onclick = (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -213,15 +208,45 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
|
||||||
next.delete(docKey);
|
next.delete(docKey);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
// Notify parent that a document was removed
|
|
||||||
onDocumentRemove?.(doc.id, doc.document_type);
|
onDocumentRemove?.(doc.id, doc.document_type);
|
||||||
focusAtEnd();
|
focusAtEnd();
|
||||||
};
|
};
|
||||||
|
|
||||||
chip.appendChild(iconSpan);
|
const titleSpan = document.createElement("span");
|
||||||
chip.appendChild(titleSpan);
|
titleSpan.className = "max-w-[120px] truncate";
|
||||||
chip.appendChild(statusSpan);
|
titleSpan.textContent = doc.title;
|
||||||
chip.appendChild(removeBtn);
|
titleSpan.title = doc.title;
|
||||||
|
titleSpan.setAttribute("data-mention-title", "true");
|
||||||
|
|
||||||
|
const statusSpan = document.createElement("span");
|
||||||
|
statusSpan.setAttribute(CHIP_STATUS_ATTR, "true");
|
||||||
|
statusSpan.className = "text-[10px] font-semibold opacity-80 hidden";
|
||||||
|
|
||||||
|
const isTouchDevice = window.matchMedia("(hover: none)").matches;
|
||||||
|
if (isTouchDevice) {
|
||||||
|
// Mobile: icon on left, title, X on right
|
||||||
|
chip.appendChild(iconSpan);
|
||||||
|
chip.appendChild(titleSpan);
|
||||||
|
chip.appendChild(statusSpan);
|
||||||
|
removeBtn.style.display = "flex";
|
||||||
|
removeBtn.className += " ml-0.5";
|
||||||
|
chip.appendChild(removeBtn);
|
||||||
|
} else {
|
||||||
|
// Desktop: icon/X swap on hover in the same slot
|
||||||
|
iconContainer.appendChild(iconSpan);
|
||||||
|
iconContainer.appendChild(removeBtn);
|
||||||
|
chip.addEventListener("mouseenter", () => {
|
||||||
|
iconSpan.style.display = "none";
|
||||||
|
removeBtn.style.display = "flex";
|
||||||
|
});
|
||||||
|
chip.addEventListener("mouseleave", () => {
|
||||||
|
iconSpan.style.display = "";
|
||||||
|
removeBtn.style.display = "none";
|
||||||
|
});
|
||||||
|
chip.appendChild(iconContainer);
|
||||||
|
chip.appendChild(titleSpan);
|
||||||
|
chip.appendChild(statusSpan);
|
||||||
|
}
|
||||||
|
|
||||||
return chip;
|
return chip;
|
||||||
},
|
},
|
||||||
|
|
@ -388,6 +413,32 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const removeDocumentChip = useCallback(
|
||||||
|
(docId: number, docType?: string) => {
|
||||||
|
if (!editorRef.current) return;
|
||||||
|
const chipKey = `${docType ?? "UNKNOWN"}:${docId}`;
|
||||||
|
const chips = editorRef.current.querySelectorAll<HTMLSpanElement>(
|
||||||
|
`span[${CHIP_DATA_ATTR}="true"]`
|
||||||
|
);
|
||||||
|
for (const chip of chips) {
|
||||||
|
if (getChipId(chip) === docId && getChipDocType(chip) === (docType ?? "UNKNOWN")) {
|
||||||
|
chip.remove();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setMentionedDocs((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.delete(chipKey);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = getText();
|
||||||
|
const empty = text.length === 0 && mentionedDocs.size <= 1;
|
||||||
|
setIsEmpty(empty);
|
||||||
|
},
|
||||||
|
[getText, mentionedDocs.size]
|
||||||
|
);
|
||||||
|
|
||||||
// Expose methods via ref
|
// Expose methods via ref
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
focus: () => editorRef.current?.focus(),
|
focus: () => editorRef.current?.focus(),
|
||||||
|
|
@ -395,6 +446,7 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
|
||||||
getText,
|
getText,
|
||||||
getMentionedDocuments,
|
getMentionedDocuments,
|
||||||
insertDocumentChip,
|
insertDocumentChip,
|
||||||
|
removeDocumentChip,
|
||||||
setDocumentChipStatus,
|
setDocumentChipStatus,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -268,7 +268,7 @@ function ThreadListItemComponent({
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem onClick={onDelete} className="text-destructive focus:text-destructive">
|
<DropdownMenuItem onClick={onDelete}>
|
||||||
<TrashIcon className="mr-2 size-4" />
|
<TrashIcon className="mr-2 size-4" />
|
||||||
Delete
|
Delete
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
|
||||||
|
|
@ -18,23 +18,21 @@ import {
|
||||||
ChevronLeftIcon,
|
ChevronLeftIcon,
|
||||||
ChevronRightIcon,
|
ChevronRightIcon,
|
||||||
CopyIcon,
|
CopyIcon,
|
||||||
Dot,
|
|
||||||
DownloadIcon,
|
DownloadIcon,
|
||||||
FileWarning,
|
PlusIcon,
|
||||||
Paperclip,
|
|
||||||
RefreshCwIcon,
|
RefreshCwIcon,
|
||||||
SquareIcon,
|
SquareIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
import { type FC, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
import { type FC, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { toast } from "sonner";
|
|
||||||
import { chatSessionStateAtom } from "@/atoms/chat/chat-session-state.atom";
|
import { chatSessionStateAtom } from "@/atoms/chat/chat-session-state.atom";
|
||||||
import { showCommentsGutterAtom } from "@/atoms/chat/current-thread.atom";
|
import { showCommentsGutterAtom } from "@/atoms/chat/current-thread.atom";
|
||||||
import {
|
import {
|
||||||
mentionedDocumentIdsAtom,
|
|
||||||
mentionedDocumentsAtom,
|
mentionedDocumentsAtom,
|
||||||
|
sidebarSelectedDocumentsAtom,
|
||||||
} from "@/atoms/chat/mentioned-documents.atom";
|
} from "@/atoms/chat/mentioned-documents.atom";
|
||||||
|
import { documentsSidebarOpenAtom } from "@/atoms/documents/ui.atoms";
|
||||||
import { membersAtom } from "@/atoms/members/members-query.atoms";
|
import { membersAtom } from "@/atoms/members/members-query.atoms";
|
||||||
import {
|
import {
|
||||||
globalNewLLMConfigsAtom,
|
globalNewLLMConfigsAtom,
|
||||||
|
|
@ -45,6 +43,7 @@ import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
||||||
import { AssistantMessage } from "@/components/assistant-ui/assistant-message";
|
import { AssistantMessage } from "@/components/assistant-ui/assistant-message";
|
||||||
import { ChatSessionStatus } from "@/components/assistant-ui/chat-session-status";
|
import { ChatSessionStatus } from "@/components/assistant-ui/chat-session-status";
|
||||||
import { ConnectorIndicator } from "@/components/assistant-ui/connector-popup";
|
import { ConnectorIndicator } from "@/components/assistant-ui/connector-popup";
|
||||||
|
import { useDocumentUploadDialog } from "@/components/assistant-ui/document-upload-popup";
|
||||||
import {
|
import {
|
||||||
InlineMentionEditor,
|
InlineMentionEditor,
|
||||||
type InlineMentionEditorRef,
|
type InlineMentionEditorRef,
|
||||||
|
|
@ -63,11 +62,9 @@ import {
|
||||||
} from "@/components/new-chat/document-mention-picker";
|
} from "@/components/new-chat/document-mention-picker";
|
||||||
import type { ThinkingStep } from "@/components/tool-ui/deepagent-thinking";
|
import type { ThinkingStep } from "@/components/tool-ui/deepagent-thinking";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
|
||||||
import type { Document } from "@/contracts/types/document.types";
|
import type { Document } from "@/contracts/types/document.types";
|
||||||
import { useBatchCommentsPreload } from "@/hooks/use-comments";
|
import { useBatchCommentsPreload } from "@/hooks/use-comments";
|
||||||
import { useCommentsElectric } from "@/hooks/use-comments-electric";
|
import { useCommentsElectric } from "@/hooks/use-comments-electric";
|
||||||
import { documentsApiService } from "@/lib/apis/documents-api.service";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
/** Placeholder texts that cycle in new chats when input is empty */
|
/** Placeholder texts that cycle in new chats when input is empty */
|
||||||
|
|
@ -80,37 +77,19 @@ const CYCLING_PLACEHOLDERS = [
|
||||||
"Check if this week's Slack messages reference any GitHub issues.",
|
"Check if this week's Slack messages reference any GitHub issues.",
|
||||||
];
|
];
|
||||||
|
|
||||||
const CHAT_UPLOAD_ACCEPT =
|
|
||||||
".pdf,.doc,.docx,.txt,.md,.markdown,.ppt,.pptx,.xls,.xlsx,.xlsm,.xlsb,.csv,.html,.htm,.xml,.rtf,.epub,.jpg,.jpeg,.png,.bmp,.webp,.tiff,.tif,.mp3,.mp4,.mpeg,.mpga,.m4a,.wav,.webm";
|
|
||||||
|
|
||||||
const CHAT_MAX_FILES = 10;
|
|
||||||
const CHAT_MAX_FILE_SIZE_BYTES = 50 * 1024 * 1024; // 50 MB per file
|
|
||||||
const CHAT_MAX_TOTAL_SIZE_BYTES = 200 * 1024 * 1024; // 200 MB total
|
|
||||||
|
|
||||||
type UploadState = "pending" | "processing" | "ready" | "failed";
|
|
||||||
|
|
||||||
interface UploadedMentionDoc {
|
|
||||||
id: number;
|
|
||||||
title: string;
|
|
||||||
document_type: Document["document_type"];
|
|
||||||
state: UploadState;
|
|
||||||
reason?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ThreadProps {
|
interface ThreadProps {
|
||||||
messageThinkingSteps?: Map<string, ThinkingStep[]>;
|
messageThinkingSteps?: Map<string, ThinkingStep[]>;
|
||||||
header?: React.ReactNode;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Thread: FC<ThreadProps> = ({ messageThinkingSteps = new Map(), header }) => {
|
export const Thread: FC<ThreadProps> = ({ messageThinkingSteps = new Map() }) => {
|
||||||
return (
|
return (
|
||||||
<ThinkingStepsContext.Provider value={messageThinkingSteps}>
|
<ThinkingStepsContext.Provider value={messageThinkingSteps}>
|
||||||
<ThreadContent header={header} />
|
<ThreadContent />
|
||||||
</ThinkingStepsContext.Provider>
|
</ThinkingStepsContext.Provider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const ThreadContent: FC<{ header?: React.ReactNode }> = ({ header }) => {
|
const ThreadContent: FC = () => {
|
||||||
const showGutter = useAtomValue(showCommentsGutterAtom);
|
const showGutter = useAtomValue(showCommentsGutterAtom);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -122,14 +101,11 @@ const ThreadContent: FC<{ header?: React.ReactNode }> = ({ header }) => {
|
||||||
>
|
>
|
||||||
<ThreadPrimitive.Viewport
|
<ThreadPrimitive.Viewport
|
||||||
turnAnchor="top"
|
turnAnchor="top"
|
||||||
autoScroll
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"aui-thread-viewport relative flex flex-1 min-h-0 flex-col overflow-y-auto px-4 pt-4 transition-[padding] duration-300 ease-out",
|
"aui-thread-viewport relative flex flex-1 min-h-0 flex-col overflow-y-auto px-4 pt-4 transition-[padding] duration-300 ease-out",
|
||||||
showGutter && "lg:pr-30"
|
showGutter && "lg:pr-30"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{header && <div className="sticky top-0 z-10 mb-4">{header}</div>}
|
|
||||||
|
|
||||||
<AssistantIf condition={({ thread }) => thread.isEmpty}>
|
<AssistantIf condition={({ thread }) => thread.isEmpty}>
|
||||||
<ThreadWelcome />
|
<ThreadWelcome />
|
||||||
</AssistantIf>
|
</AssistantIf>
|
||||||
|
|
@ -250,19 +226,13 @@ const ThreadWelcome: FC = () => {
|
||||||
const Composer: FC = () => {
|
const Composer: FC = () => {
|
||||||
// Document mention state (atoms persist across component remounts)
|
// Document mention state (atoms persist across component remounts)
|
||||||
const [mentionedDocuments, setMentionedDocuments] = useAtom(mentionedDocumentsAtom);
|
const [mentionedDocuments, setMentionedDocuments] = useAtom(mentionedDocumentsAtom);
|
||||||
|
const setSidebarDocs = useSetAtom(sidebarSelectedDocumentsAtom);
|
||||||
const [showDocumentPopover, setShowDocumentPopover] = useState(false);
|
const [showDocumentPopover, setShowDocumentPopover] = useState(false);
|
||||||
const [mentionQuery, setMentionQuery] = useState("");
|
const [mentionQuery, setMentionQuery] = useState("");
|
||||||
const [uploadedMentionDocs, setUploadedMentionDocs] = useState<
|
|
||||||
Record<number, UploadedMentionDoc>
|
|
||||||
>({});
|
|
||||||
const [isUploadingDocs, setIsUploadingDocs] = useState(false);
|
|
||||||
const editorRef = useRef<InlineMentionEditorRef>(null);
|
const editorRef = useRef<InlineMentionEditorRef>(null);
|
||||||
const editorContainerRef = useRef<HTMLDivElement>(null);
|
const editorContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const uploadInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const isFileDialogOpenRef = useRef(false);
|
|
||||||
const documentPickerRef = useRef<DocumentMentionPickerRef>(null);
|
const documentPickerRef = useRef<DocumentMentionPickerRef>(null);
|
||||||
const { search_space_id, chat_id } = useParams();
|
const { search_space_id, chat_id } = useParams();
|
||||||
const setMentionedDocumentIds = useSetAtom(mentionedDocumentIdsAtom);
|
|
||||||
const composerRuntime = useComposerRuntime();
|
const composerRuntime = useComposerRuntime();
|
||||||
const hasAutoFocusedRef = useRef(false);
|
const hasAutoFocusedRef = useRef(false);
|
||||||
|
|
||||||
|
|
@ -317,7 +287,7 @@ const Composer: FC = () => {
|
||||||
const assistantIdsKey = useAssistantState(({ thread }) =>
|
const assistantIdsKey = useAssistantState(({ thread }) =>
|
||||||
thread.messages
|
thread.messages
|
||||||
.filter((m) => m.role === "assistant" && m.id?.startsWith("msg-"))
|
.filter((m) => m.role === "assistant" && m.id?.startsWith("msg-"))
|
||||||
.map((m) => m.id!.replace("msg-", ""))
|
.map((m) => m.id?.replace("msg-", ""))
|
||||||
.join(",")
|
.join(",")
|
||||||
);
|
);
|
||||||
const assistantDbMessageIds = useMemo(
|
const assistantDbMessageIds = useMemo(
|
||||||
|
|
@ -337,18 +307,6 @@ const Composer: FC = () => {
|
||||||
}
|
}
|
||||||
}, [isThreadEmpty]);
|
}, [isThreadEmpty]);
|
||||||
|
|
||||||
// Sync mentioned document IDs to atom for inclusion in chat request payload
|
|
||||||
useEffect(() => {
|
|
||||||
setMentionedDocumentIds({
|
|
||||||
surfsense_doc_ids: mentionedDocuments
|
|
||||||
.filter((doc) => doc.document_type === "SURFSENSE_DOCS")
|
|
||||||
.map((doc) => doc.id),
|
|
||||||
document_ids: mentionedDocuments
|
|
||||||
.filter((doc) => doc.document_type !== "SURFSENSE_DOCS")
|
|
||||||
.map((doc) => doc.id),
|
|
||||||
});
|
|
||||||
}, [mentionedDocuments, setMentionedDocumentIds]);
|
|
||||||
|
|
||||||
// Sync editor text with assistant-ui composer runtime
|
// Sync editor text with assistant-ui composer runtime
|
||||||
const handleEditorChange = useCallback(
|
const handleEditorChange = useCallback(
|
||||||
(text: string) => {
|
(text: string) => {
|
||||||
|
|
@ -401,75 +359,35 @@ const Composer: FC = () => {
|
||||||
[showDocumentPopover]
|
[showDocumentPopover]
|
||||||
);
|
);
|
||||||
|
|
||||||
const uploadedMentionedDocs = useMemo(
|
|
||||||
() => mentionedDocuments.filter((doc) => uploadedMentionDocs[doc.id]),
|
|
||||||
[mentionedDocuments, uploadedMentionDocs]
|
|
||||||
);
|
|
||||||
|
|
||||||
const blockingUploadedMentions = useMemo(
|
|
||||||
() =>
|
|
||||||
uploadedMentionedDocs.filter((doc) => {
|
|
||||||
const state = uploadedMentionDocs[doc.id]?.state;
|
|
||||||
return state === "pending" || state === "processing" || state === "failed";
|
|
||||||
}),
|
|
||||||
[uploadedMentionedDocs, uploadedMentionDocs]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Submit message (blocked during streaming, document picker open, or AI responding to another user)
|
// Submit message (blocked during streaming, document picker open, or AI responding to another user)
|
||||||
const handleSubmit = useCallback(() => {
|
const handleSubmit = useCallback(() => {
|
||||||
if (
|
if (isThreadRunning || isBlockedByOtherUser) {
|
||||||
isThreadRunning ||
|
|
||||||
isBlockedByOtherUser ||
|
|
||||||
isUploadingDocs ||
|
|
||||||
blockingUploadedMentions.length > 0
|
|
||||||
) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!showDocumentPopover) {
|
if (!showDocumentPopover) {
|
||||||
composerRuntime.send();
|
composerRuntime.send();
|
||||||
editorRef.current?.clear();
|
editorRef.current?.clear();
|
||||||
setMentionedDocuments([]);
|
setMentionedDocuments([]);
|
||||||
setMentionedDocumentIds({
|
setSidebarDocs([]);
|
||||||
surfsense_doc_ids: [],
|
|
||||||
document_ids: [],
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
showDocumentPopover,
|
showDocumentPopover,
|
||||||
isThreadRunning,
|
isThreadRunning,
|
||||||
isBlockedByOtherUser,
|
isBlockedByOtherUser,
|
||||||
isUploadingDocs,
|
|
||||||
blockingUploadedMentions.length,
|
|
||||||
composerRuntime,
|
composerRuntime,
|
||||||
setMentionedDocuments,
|
setMentionedDocuments,
|
||||||
setMentionedDocumentIds,
|
setSidebarDocs,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Remove document from mentions and sync IDs to atom
|
|
||||||
const handleDocumentRemove = useCallback(
|
const handleDocumentRemove = useCallback(
|
||||||
(docId: number, docType?: string) => {
|
(docId: number, docType?: string) => {
|
||||||
setMentionedDocuments((prev) => {
|
setMentionedDocuments((prev) =>
|
||||||
const updated = prev.filter((doc) => !(doc.id === docId && doc.document_type === docType));
|
prev.filter((doc) => !(doc.id === docId && doc.document_type === docType))
|
||||||
setMentionedDocumentIds({
|
);
|
||||||
surfsense_doc_ids: updated
|
|
||||||
.filter((doc) => doc.document_type === "SURFSENSE_DOCS")
|
|
||||||
.map((doc) => doc.id),
|
|
||||||
document_ids: updated
|
|
||||||
.filter((doc) => doc.document_type !== "SURFSENSE_DOCS")
|
|
||||||
.map((doc) => doc.id),
|
|
||||||
});
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
setUploadedMentionDocs((prev) => {
|
|
||||||
if (!(docId in prev)) return prev;
|
|
||||||
const { [docId]: _removed, ...rest } = prev;
|
|
||||||
return rest;
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
[setMentionedDocuments, setMentionedDocumentIds]
|
[setMentionedDocuments]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Add selected documents from picker, insert chips, and sync IDs to atom
|
|
||||||
const handleDocumentsMention = useCallback(
|
const handleDocumentsMention = useCallback(
|
||||||
(documents: Pick<Document, "id" | "title" | "document_type">[]) => {
|
(documents: Pick<Document, "id" | "title" | "document_type">[]) => {
|
||||||
const existingKeys = new Set(mentionedDocuments.map((d) => `${d.document_type}:${d.id}`));
|
const existingKeys = new Set(mentionedDocuments.map((d) => `${d.document_type}:${d.id}`));
|
||||||
|
|
@ -486,185 +404,14 @@ const Composer: FC = () => {
|
||||||
const uniqueNewDocs = documents.filter(
|
const uniqueNewDocs = documents.filter(
|
||||||
(doc) => !existingKeySet.has(`${doc.document_type}:${doc.id}`)
|
(doc) => !existingKeySet.has(`${doc.document_type}:${doc.id}`)
|
||||||
);
|
);
|
||||||
const updated = [...prev, ...uniqueNewDocs];
|
return [...prev, ...uniqueNewDocs];
|
||||||
setMentionedDocumentIds({
|
|
||||||
surfsense_doc_ids: updated
|
|
||||||
.filter((doc) => doc.document_type === "SURFSENSE_DOCS")
|
|
||||||
.map((doc) => doc.id),
|
|
||||||
document_ids: updated
|
|
||||||
.filter((doc) => doc.document_type !== "SURFSENSE_DOCS")
|
|
||||||
.map((doc) => doc.id),
|
|
||||||
});
|
|
||||||
return updated;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
setMentionQuery("");
|
setMentionQuery("");
|
||||||
},
|
},
|
||||||
[mentionedDocuments, setMentionedDocuments, setMentionedDocumentIds]
|
[mentionedDocuments, setMentionedDocuments]
|
||||||
);
|
);
|
||||||
|
|
||||||
const refreshUploadedDocStatuses = useCallback(
|
|
||||||
async (documentIds: number[]) => {
|
|
||||||
if (!search_space_id || documentIds.length === 0) return;
|
|
||||||
const statusResponse = await documentsApiService.getDocumentsStatus({
|
|
||||||
queryParams: {
|
|
||||||
search_space_id: Number(search_space_id),
|
|
||||||
document_ids: documentIds,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
setUploadedMentionDocs((prev) => {
|
|
||||||
const next = { ...prev };
|
|
||||||
for (const item of statusResponse.items) {
|
|
||||||
next[item.id] = {
|
|
||||||
id: item.id,
|
|
||||||
title: item.title,
|
|
||||||
document_type: item.document_type,
|
|
||||||
state: item.status.state,
|
|
||||||
reason: item.status.reason,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
|
|
||||||
handleDocumentsMention(
|
|
||||||
statusResponse.items.map((item) => ({
|
|
||||||
id: item.id,
|
|
||||||
title: item.title,
|
|
||||||
document_type: item.document_type,
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
},
|
|
||||||
[search_space_id, handleDocumentsMention]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleUploadClick = useCallback(() => {
|
|
||||||
if (isFileDialogOpenRef.current) return;
|
|
||||||
isFileDialogOpenRef.current = true;
|
|
||||||
uploadInputRef.current?.click();
|
|
||||||
// Reset after a delay to handle cancellation (which doesn't fire the change event).
|
|
||||||
setTimeout(() => {
|
|
||||||
isFileDialogOpenRef.current = false;
|
|
||||||
}, 1000);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleUploadInputChange = useCallback(
|
|
||||||
async (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
isFileDialogOpenRef.current = false;
|
|
||||||
const files = Array.from(event.target.files ?? []);
|
|
||||||
event.target.value = "";
|
|
||||||
if (files.length === 0 || !search_space_id) return;
|
|
||||||
|
|
||||||
if (files.length > CHAT_MAX_FILES) {
|
|
||||||
toast.error(`Too many files. Maximum ${CHAT_MAX_FILES} files per upload.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let totalSize = 0;
|
|
||||||
for (const file of files) {
|
|
||||||
if (file.size > CHAT_MAX_FILE_SIZE_BYTES) {
|
|
||||||
toast.error(
|
|
||||||
`File "${file.name}" (${(file.size / (1024 * 1024)).toFixed(1)} MB) exceeds the ${CHAT_MAX_FILE_SIZE_BYTES / (1024 * 1024)} MB per-file limit.`
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
totalSize += file.size;
|
|
||||||
}
|
|
||||||
if (totalSize > CHAT_MAX_TOTAL_SIZE_BYTES) {
|
|
||||||
toast.error(
|
|
||||||
`Total upload size (${(totalSize / (1024 * 1024)).toFixed(1)} MB) exceeds the ${CHAT_MAX_TOTAL_SIZE_BYTES / (1024 * 1024)} MB limit.`
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsUploadingDocs(true);
|
|
||||||
try {
|
|
||||||
const uploadResponse = await documentsApiService.uploadDocument({
|
|
||||||
files,
|
|
||||||
search_space_id: Number(search_space_id),
|
|
||||||
});
|
|
||||||
const uploadedIds = uploadResponse.document_ids ?? [];
|
|
||||||
const duplicateIds = uploadResponse.duplicate_document_ids ?? [];
|
|
||||||
const idsToMention = Array.from(new Set([...uploadedIds, ...duplicateIds]));
|
|
||||||
if (idsToMention.length === 0) {
|
|
||||||
toast.warning("No documents were created or matched from selected files.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await refreshUploadedDocStatuses(idsToMention);
|
|
||||||
if (uploadedIds.length > 0 && duplicateIds.length > 0) {
|
|
||||||
toast.success(
|
|
||||||
`Uploaded ${uploadedIds.length} file${uploadedIds.length > 1 ? "s" : ""} and matched ${duplicateIds.length} existing file${duplicateIds.length > 1 ? "s" : ""}.`
|
|
||||||
);
|
|
||||||
} else if (uploadedIds.length > 0) {
|
|
||||||
toast.success(`Uploaded ${uploadedIds.length} file${uploadedIds.length > 1 ? "s" : ""}`);
|
|
||||||
} else {
|
|
||||||
toast.success(
|
|
||||||
`Matched ${duplicateIds.length} existing file${duplicateIds.length > 1 ? "s" : ""} and added mention${duplicateIds.length > 1 ? "s" : ""}.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : "Upload failed";
|
|
||||||
toast.error(`Upload failed: ${message}`);
|
|
||||||
} finally {
|
|
||||||
setIsUploadingDocs(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[search_space_id, refreshUploadedDocStatuses]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Poll status for uploaded mentioned documents until all are ready or removed.
|
|
||||||
useEffect(() => {
|
|
||||||
const trackedIds = uploadedMentionedDocs.map((doc) => doc.id);
|
|
||||||
const needsPolling = trackedIds.some((id) => {
|
|
||||||
const state = uploadedMentionDocs[id]?.state;
|
|
||||||
return state === "pending" || state === "processing";
|
|
||||||
});
|
|
||||||
if (!needsPolling) return;
|
|
||||||
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
refreshUploadedDocStatuses(trackedIds).catch((error) => {
|
|
||||||
console.error("[Composer] Failed to refresh uploaded mention statuses:", error);
|
|
||||||
});
|
|
||||||
}, 2500);
|
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, [uploadedMentionedDocs, uploadedMentionDocs, refreshUploadedDocStatuses]);
|
|
||||||
|
|
||||||
// Push upload status directly onto mention chips (instead of separate status rows).
|
|
||||||
useEffect(() => {
|
|
||||||
for (const doc of uploadedMentionedDocs) {
|
|
||||||
const state = uploadedMentionDocs[doc.id]?.state ?? "pending";
|
|
||||||
const statusLabel =
|
|
||||||
state === "ready"
|
|
||||||
? null
|
|
||||||
: state === "failed"
|
|
||||||
? "failed"
|
|
||||||
: state === "processing"
|
|
||||||
? "indexing"
|
|
||||||
: "queued";
|
|
||||||
editorRef.current?.setDocumentChipStatus(doc.id, doc.document_type, statusLabel, state);
|
|
||||||
}
|
|
||||||
}, [uploadedMentionedDocs, uploadedMentionDocs]);
|
|
||||||
|
|
||||||
// Prune upload status entries that are no longer mentioned in the composer.
|
|
||||||
useEffect(() => {
|
|
||||||
const activeIds = new Set(mentionedDocuments.map((doc) => doc.id));
|
|
||||||
setUploadedMentionDocs((prev) => {
|
|
||||||
let changed = false;
|
|
||||||
const next: Record<number, UploadedMentionDoc> = {};
|
|
||||||
for (const [key, value] of Object.entries(prev)) {
|
|
||||||
const id = Number(key);
|
|
||||||
if (activeIds.has(id)) {
|
|
||||||
next[id] = value;
|
|
||||||
} else {
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return changed ? next : prev;
|
|
||||||
});
|
|
||||||
}, [mentionedDocuments]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ComposerPrimitive.Root className="aui-composer-root relative flex w-full flex-col gap-2">
|
<ComposerPrimitive.Root className="aui-composer-root relative flex w-full flex-col gap-2">
|
||||||
<ChatSessionStatus
|
<ChatSessionStatus
|
||||||
|
|
@ -688,15 +435,6 @@ const Composer: FC = () => {
|
||||||
className="min-h-[24px]"
|
className="min-h-[24px]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<input
|
|
||||||
ref={uploadInputRef}
|
|
||||||
type="file"
|
|
||||||
multiple
|
|
||||||
accept={CHAT_UPLOAD_ACCEPT}
|
|
||||||
onChange={handleUploadInputChange}
|
|
||||||
className="hidden"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Document picker popover (portal to body for proper z-index stacking) */}
|
{/* Document picker popover (portal to body for proper z-index stacking) */}
|
||||||
{showDocumentPopover &&
|
{showDocumentPopover &&
|
||||||
typeof document !== "undefined" &&
|
typeof document !== "undefined" &&
|
||||||
|
|
@ -722,15 +460,7 @@ const Composer: FC = () => {
|
||||||
/>,
|
/>,
|
||||||
document.body
|
document.body
|
||||||
)}
|
)}
|
||||||
<ComposerAction
|
<ComposerAction isBlockedByOtherUser={isBlockedByOtherUser} />
|
||||||
isBlockedByOtherUser={isBlockedByOtherUser}
|
|
||||||
onUploadClick={handleUploadClick}
|
|
||||||
isUploadingDocs={isUploadingDocs}
|
|
||||||
blockingUploadedMentionsCount={blockingUploadedMentions.length}
|
|
||||||
hasFailedUploadedMentions={blockingUploadedMentions.some(
|
|
||||||
(doc) => uploadedMentionDocs[doc.id]?.state === "failed"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</ComposerPrimitive.Root>
|
</ComposerPrimitive.Root>
|
||||||
);
|
);
|
||||||
|
|
@ -738,29 +468,20 @@ const Composer: FC = () => {
|
||||||
|
|
||||||
interface ComposerActionProps {
|
interface ComposerActionProps {
|
||||||
isBlockedByOtherUser?: boolean;
|
isBlockedByOtherUser?: boolean;
|
||||||
onUploadClick: () => void;
|
|
||||||
isUploadingDocs: boolean;
|
|
||||||
blockingUploadedMentionsCount: number;
|
|
||||||
hasFailedUploadedMentions: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ComposerAction: FC<ComposerActionProps> = ({
|
const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false }) => {
|
||||||
isBlockedByOtherUser = false,
|
|
||||||
onUploadClick,
|
|
||||||
isUploadingDocs,
|
|
||||||
blockingUploadedMentionsCount,
|
|
||||||
hasFailedUploadedMentions,
|
|
||||||
}) => {
|
|
||||||
const mentionedDocuments = useAtomValue(mentionedDocumentsAtom);
|
const mentionedDocuments = useAtomValue(mentionedDocumentsAtom);
|
||||||
|
const sidebarDocs = useAtomValue(sidebarSelectedDocumentsAtom);
|
||||||
|
const setDocumentsSidebarOpen = useSetAtom(documentsSidebarOpenAtom);
|
||||||
|
const { openDialog: openUploadDialog } = useDocumentUploadDialog();
|
||||||
|
|
||||||
// Check if composer text is empty (chips are represented in mentionedDocuments atom)
|
|
||||||
const isComposerTextEmpty = useAssistantState(({ composer }) => {
|
const isComposerTextEmpty = useAssistantState(({ composer }) => {
|
||||||
const text = composer.text?.trim() || "";
|
const text = composer.text?.trim() || "";
|
||||||
return text.length === 0;
|
return text.length === 0;
|
||||||
});
|
});
|
||||||
const isComposerEmpty = isComposerTextEmpty && mentionedDocuments.length === 0;
|
const isComposerEmpty = isComposerTextEmpty && mentionedDocuments.length === 0;
|
||||||
|
|
||||||
// Check if a model is configured
|
|
||||||
const { data: userConfigs } = useAtomValue(newLLMConfigsAtom);
|
const { data: userConfigs } = useAtomValue(newLLMConfigsAtom);
|
||||||
const { data: globalConfigs } = useAtomValue(globalNewLLMConfigsAtom);
|
const { data: globalConfigs } = useAtomValue(globalNewLLMConfigsAtom);
|
||||||
const { data: preferences } = useAtomValue(llmPreferencesAtom);
|
const { data: preferences } = useAtomValue(llmPreferencesAtom);
|
||||||
|
|
@ -770,121 +491,91 @@ const ComposerAction: FC<ComposerActionProps> = ({
|
||||||
const agentLlmId = preferences.agent_llm_id;
|
const agentLlmId = preferences.agent_llm_id;
|
||||||
if (agentLlmId === null || agentLlmId === undefined) return false;
|
if (agentLlmId === null || agentLlmId === undefined) return false;
|
||||||
|
|
||||||
// Check if the configured model actually exists
|
|
||||||
// Auto mode (ID 0) and global configs (negative IDs) are in globalConfigs
|
|
||||||
if (agentLlmId <= 0) {
|
if (agentLlmId <= 0) {
|
||||||
return globalConfigs?.some((c) => c.id === agentLlmId) ?? false;
|
return globalConfigs?.some((c) => c.id === agentLlmId) ?? false;
|
||||||
}
|
}
|
||||||
return userConfigs?.some((c) => c.id === agentLlmId) ?? false;
|
return userConfigs?.some((c) => c.id === agentLlmId) ?? false;
|
||||||
}, [preferences, globalConfigs, userConfigs]);
|
}, [preferences, globalConfigs, userConfigs]);
|
||||||
|
|
||||||
const isSendDisabled =
|
const isSendDisabled = isComposerEmpty || !hasModelConfigured || isBlockedByOtherUser;
|
||||||
isComposerEmpty ||
|
|
||||||
!hasModelConfigured ||
|
|
||||||
isBlockedByOtherUser ||
|
|
||||||
isUploadingDocs ||
|
|
||||||
blockingUploadedMentionsCount > 0;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="aui-composer-action-wrapper relative mx-2 mb-2 flex items-center justify-between">
|
<div className="aui-composer-action-wrapper relative mx-2 mb-2 flex items-center justify-between">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<TooltipIconButton
|
<TooltipIconButton
|
||||||
tooltip={
|
tooltip="Upload"
|
||||||
isUploadingDocs ? (
|
|
||||||
"Uploading documents..."
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col gap-0.5">
|
|
||||||
<span className="font-medium">Upload and mention files</span>
|
|
||||||
<span className="text-xs text-muted-foreground flex items-center">
|
|
||||||
Max 10 files <Dot className="size-3" /> 50 MB each
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-muted-foreground">Total upload limit: 200 MB</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
side="bottom"
|
side="bottom"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="size-[34px] rounded-full p-1 font-semibold text-xs hover:bg-muted-foreground/15 dark:border-muted-foreground/15 dark:hover:bg-muted-foreground/30"
|
className="size-[34px] rounded-full p-1 font-semibold text-xs hover:bg-muted-foreground/15 dark:border-muted-foreground/15 dark:hover:bg-muted-foreground/30"
|
||||||
aria-label="Upload files"
|
aria-label="Upload documents"
|
||||||
onClick={onUploadClick}
|
onClick={openUploadDialog}
|
||||||
disabled={isUploadingDocs}
|
|
||||||
>
|
>
|
||||||
{isUploadingDocs ? (
|
<PlusIcon className="size-4" />
|
||||||
<Spinner size="sm" className="text-muted-foreground" />
|
|
||||||
) : (
|
|
||||||
<Paperclip className="size-4" />
|
|
||||||
)}
|
|
||||||
</TooltipIconButton>
|
</TooltipIconButton>
|
||||||
<ConnectorIndicator />
|
<ConnectorIndicator />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{blockingUploadedMentionsCount > 0 && (
|
{!hasModelConfigured && (
|
||||||
<div className="flex items-center gap-1.5 text-muted-foreground text-xs">
|
|
||||||
{hasFailedUploadedMentions ? <FileWarning className="size-3" /> : <Spinner size="xs" />}
|
|
||||||
<span>
|
|
||||||
{hasFailedUploadedMentions
|
|
||||||
? "Remove or retry failed uploads"
|
|
||||||
: "Waiting for uploaded files to finish indexing"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Show warning when no model is configured */}
|
|
||||||
{!hasModelConfigured && blockingUploadedMentionsCount === 0 && (
|
|
||||||
<div className="flex items-center gap-1.5 text-amber-600 dark:text-amber-400 text-xs">
|
<div className="flex items-center gap-1.5 text-amber-600 dark:text-amber-400 text-xs">
|
||||||
<AlertCircle className="size-3" />
|
<AlertCircle className="size-3" />
|
||||||
<span>Select a model</span>
|
<span>Select a model</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<AssistantIf condition={({ thread }) => !thread.isRunning}>
|
<div className="flex items-center gap-2">
|
||||||
<ComposerPrimitive.Send asChild disabled={isSendDisabled}>
|
{sidebarDocs.length > 0 && (
|
||||||
<TooltipIconButton
|
<button
|
||||||
tooltip={
|
|
||||||
isBlockedByOtherUser
|
|
||||||
? "Wait for AI to finish responding"
|
|
||||||
: hasFailedUploadedMentions
|
|
||||||
? "Remove or retry failed uploads before sending"
|
|
||||||
: blockingUploadedMentionsCount > 0
|
|
||||||
? "Waiting for uploaded files to finish indexing"
|
|
||||||
: isUploadingDocs
|
|
||||||
? "Uploading documents..."
|
|
||||||
: !hasModelConfigured
|
|
||||||
? "Please select a model from the header to start chatting"
|
|
||||||
: isComposerEmpty
|
|
||||||
? "Enter a message to send"
|
|
||||||
: "Send message"
|
|
||||||
}
|
|
||||||
side="bottom"
|
|
||||||
type="submit"
|
|
||||||
variant="default"
|
|
||||||
size="icon"
|
|
||||||
className={cn(
|
|
||||||
"aui-composer-send size-8 rounded-full",
|
|
||||||
isSendDisabled && "cursor-not-allowed opacity-50"
|
|
||||||
)}
|
|
||||||
aria-label="Send message"
|
|
||||||
disabled={isSendDisabled}
|
|
||||||
>
|
|
||||||
<ArrowUpIcon className="aui-composer-send-icon size-4" />
|
|
||||||
</TooltipIconButton>
|
|
||||||
</ComposerPrimitive.Send>
|
|
||||||
</AssistantIf>
|
|
||||||
|
|
||||||
<AssistantIf condition={({ thread }) => thread.isRunning}>
|
|
||||||
<ComposerPrimitive.Cancel asChild>
|
|
||||||
<Button
|
|
||||||
type="button"
|
type="button"
|
||||||
variant="default"
|
onClick={() => setDocumentsSidebarOpen(true)}
|
||||||
size="icon"
|
className="rounded-full border border-border/60 bg-accent/50 px-2.5 py-1 text-xs font-medium text-foreground/80 transition-colors hover:bg-accent"
|
||||||
className="aui-composer-cancel size-8 rounded-full"
|
|
||||||
aria-label="Stop generating"
|
|
||||||
>
|
>
|
||||||
<SquareIcon className="aui-composer-cancel-icon size-3 fill-current" />
|
{sidebarDocs.length} {sidebarDocs.length === 1 ? "source" : "sources"} selected
|
||||||
</Button>
|
</button>
|
||||||
</ComposerPrimitive.Cancel>
|
)}
|
||||||
</AssistantIf>
|
|
||||||
|
<AssistantIf condition={({ thread }) => !thread.isRunning}>
|
||||||
|
<ComposerPrimitive.Send asChild disabled={isSendDisabled}>
|
||||||
|
<TooltipIconButton
|
||||||
|
tooltip={
|
||||||
|
isBlockedByOtherUser
|
||||||
|
? "Wait for AI to finish responding"
|
||||||
|
: !hasModelConfigured
|
||||||
|
? "Please select a model from the header to start chatting"
|
||||||
|
: isComposerEmpty
|
||||||
|
? "Enter a message to send"
|
||||||
|
: "Send message"
|
||||||
|
}
|
||||||
|
side="bottom"
|
||||||
|
type="submit"
|
||||||
|
variant="default"
|
||||||
|
size="icon"
|
||||||
|
className={cn(
|
||||||
|
"aui-composer-send size-8 rounded-full",
|
||||||
|
isSendDisabled && "cursor-not-allowed opacity-50"
|
||||||
|
)}
|
||||||
|
aria-label="Send message"
|
||||||
|
disabled={isSendDisabled}
|
||||||
|
>
|
||||||
|
<ArrowUpIcon className="aui-composer-send-icon size-4" />
|
||||||
|
</TooltipIconButton>
|
||||||
|
</ComposerPrimitive.Send>
|
||||||
|
</AssistantIf>
|
||||||
|
|
||||||
|
<AssistantIf condition={({ thread }) => thread.isRunning}>
|
||||||
|
<ComposerPrimitive.Cancel asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="default"
|
||||||
|
size="icon"
|
||||||
|
className="aui-composer-cancel size-8 rounded-full"
|
||||||
|
aria-label="Stop generating"
|
||||||
|
>
|
||||||
|
<SquareIcon className="aui-composer-cancel-icon size-3 fill-current" />
|
||||||
|
</Button>
|
||||||
|
</ComposerPrimitive.Cancel>
|
||||||
|
</AssistantIf>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ export function CommentActions({ canEdit, canDelete, onEdit, onDelete }: Comment
|
||||||
)}
|
)}
|
||||||
{canEdit && canDelete && <DropdownMenuSeparator />}
|
{canEdit && canDelete && <DropdownMenuSeparator />}
|
||||||
{canDelete && (
|
{canDelete && (
|
||||||
<DropdownMenuItem onClick={onDelete} className="text-destructive focus:text-destructive">
|
<DropdownMenuItem onClick={onDelete}>
|
||||||
<Trash2 className="mr-2 size-4" />
|
<Trash2 className="mr-2 size-4" />
|
||||||
Delete
|
Delete
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
|
||||||
|
|
@ -1,216 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { usePathname } from "next/navigation";
|
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
import React, { useEffect, useState } from "react";
|
|
||||||
import {
|
|
||||||
Breadcrumb,
|
|
||||||
BreadcrumbItem,
|
|
||||||
BreadcrumbLink,
|
|
||||||
BreadcrumbList,
|
|
||||||
BreadcrumbPage,
|
|
||||||
BreadcrumbSeparator,
|
|
||||||
} from "@/components/ui/breadcrumb";
|
|
||||||
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
|
|
||||||
import { authenticatedFetch, getBearerToken } from "@/lib/auth-utils";
|
|
||||||
import { getThreadFull } from "@/lib/chat/thread-persistence";
|
|
||||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
|
||||||
|
|
||||||
interface BreadcrumbItemInterface {
|
|
||||||
label: string;
|
|
||||||
href?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DashboardBreadcrumb() {
|
|
||||||
const t = useTranslations("breadcrumb");
|
|
||||||
const pathname = usePathname();
|
|
||||||
// Extract search space ID and chat ID from pathname
|
|
||||||
const segments = pathname.split("/").filter(Boolean);
|
|
||||||
const searchSpaceId = segments[0] === "dashboard" && segments[1] ? segments[1] : null;
|
|
||||||
|
|
||||||
const { data: searchSpace } = useQuery({
|
|
||||||
queryKey: cacheKeys.searchSpaces.detail(searchSpaceId || ""),
|
|
||||||
queryFn: () => searchSpacesApiService.getSearchSpace({ id: Number(searchSpaceId) }),
|
|
||||||
enabled: !!searchSpaceId,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Extract chat thread ID from pathname for chat pages
|
|
||||||
const chatThreadId = segments[2] === "new-chat" && segments[3] ? segments[3] : null;
|
|
||||||
|
|
||||||
// Fetch thread details when on a chat page with a thread ID
|
|
||||||
const { data: threadData } = useQuery({
|
|
||||||
queryKey: ["threads", searchSpaceId, "detail", chatThreadId],
|
|
||||||
queryFn: () => getThreadFull(Number(chatThreadId)),
|
|
||||||
enabled: !!chatThreadId && !!searchSpaceId,
|
|
||||||
});
|
|
||||||
|
|
||||||
// State to store document title for editor breadcrumb
|
|
||||||
const [documentTitle, setDocumentTitle] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// Fetch document title when on editor page
|
|
||||||
useEffect(() => {
|
|
||||||
if (segments[2] === "editor" && segments[3] && searchSpaceId) {
|
|
||||||
const documentId = segments[3];
|
|
||||||
|
|
||||||
// Skip fetch for "new" notes
|
|
||||||
if (documentId === "new") {
|
|
||||||
setDocumentTitle(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const token = getBearerToken();
|
|
||||||
|
|
||||||
if (token) {
|
|
||||||
authenticatedFetch(
|
|
||||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/editor-content`,
|
|
||||||
{ method: "GET" }
|
|
||||||
)
|
|
||||||
.then((res) => res.json())
|
|
||||||
.then((data) => {
|
|
||||||
if (data.title) {
|
|
||||||
setDocumentTitle(data.title);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
// If fetch fails, just use the document ID
|
|
||||||
setDocumentTitle(null);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setDocumentTitle(null);
|
|
||||||
}
|
|
||||||
}, [segments, searchSpaceId]);
|
|
||||||
|
|
||||||
// Parse the pathname to create breadcrumb items
|
|
||||||
const generateBreadcrumbs = (path: string): BreadcrumbItemInterface[] => {
|
|
||||||
const segments = path.split("/").filter(Boolean);
|
|
||||||
const breadcrumbs: BreadcrumbItemInterface[] = [];
|
|
||||||
|
|
||||||
// Handle search space (start directly with search space, skip "Dashboard")
|
|
||||||
if (segments[0] === "dashboard" && segments[1]) {
|
|
||||||
// Use the actual search space name if available, otherwise fall back to the ID
|
|
||||||
const searchSpaceLabel = searchSpace?.name || `${t("search_space")} ${segments[1]}`;
|
|
||||||
breadcrumbs.push({
|
|
||||||
label: searchSpaceLabel,
|
|
||||||
href: `/dashboard/${segments[1]}`,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle specific sections
|
|
||||||
if (segments[2]) {
|
|
||||||
const section = segments[2];
|
|
||||||
let sectionLabel = section.charAt(0).toUpperCase() + section.slice(1);
|
|
||||||
|
|
||||||
// Map section names to more readable labels
|
|
||||||
const sectionLabels: Record<string, string> = {
|
|
||||||
"new-chat": t("chat") || "Chat",
|
|
||||||
documents: t("documents"),
|
|
||||||
logs: t("logs"),
|
|
||||||
settings: t("settings"),
|
|
||||||
editor: t("editor"),
|
|
||||||
};
|
|
||||||
|
|
||||||
sectionLabel = sectionLabels[section] || sectionLabel;
|
|
||||||
|
|
||||||
// Handle sub-sections
|
|
||||||
if (segments[3]) {
|
|
||||||
const subSection = segments[3];
|
|
||||||
|
|
||||||
// Handle editor sub-sections (document ID)
|
|
||||||
if (section === "editor") {
|
|
||||||
// Handle special cases for editor
|
|
||||||
let documentLabel: string;
|
|
||||||
if (subSection === "new") {
|
|
||||||
documentLabel = "New Note";
|
|
||||||
} else {
|
|
||||||
documentLabel = documentTitle || subSection;
|
|
||||||
}
|
|
||||||
|
|
||||||
breadcrumbs.push({
|
|
||||||
label: t("documents"),
|
|
||||||
href: `/dashboard/${segments[1]}/documents`,
|
|
||||||
});
|
|
||||||
breadcrumbs.push({
|
|
||||||
label: sectionLabel,
|
|
||||||
href: `/dashboard/${segments[1]}/documents`,
|
|
||||||
});
|
|
||||||
breadcrumbs.push({ label: documentLabel });
|
|
||||||
return breadcrumbs;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle documents sub-sections
|
|
||||||
if (section === "documents") {
|
|
||||||
const documentLabels: Record<string, string> = {
|
|
||||||
upload: t("upload_documents"),
|
|
||||||
webpage: t("add_webpages"),
|
|
||||||
};
|
|
||||||
|
|
||||||
const documentLabel = documentLabels[subSection] || subSection;
|
|
||||||
breadcrumbs.push({
|
|
||||||
label: t("documents"),
|
|
||||||
href: `/dashboard/${segments[1]}/documents`,
|
|
||||||
});
|
|
||||||
breadcrumbs.push({ label: documentLabel });
|
|
||||||
return breadcrumbs;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle new-chat sub-sections (thread IDs)
|
|
||||||
// Show the chat title if available, otherwise fall back to "Chat"
|
|
||||||
if (section === "new-chat") {
|
|
||||||
const chatLabel = threadData?.title || t("chat") || "Chat";
|
|
||||||
breadcrumbs.push({
|
|
||||||
label: chatLabel,
|
|
||||||
});
|
|
||||||
return breadcrumbs;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle other sub-sections
|
|
||||||
let subSectionLabel = subSection.charAt(0).toUpperCase() + subSection.slice(1);
|
|
||||||
const subSectionLabels: Record<string, string> = {
|
|
||||||
upload: t("upload_documents"),
|
|
||||||
youtube: t("add_youtube"),
|
|
||||||
webpage: t("add_webpages"),
|
|
||||||
manage: t("manage"),
|
|
||||||
};
|
|
||||||
|
|
||||||
subSectionLabel = subSectionLabels[subSection] || subSectionLabel;
|
|
||||||
|
|
||||||
breadcrumbs.push({
|
|
||||||
label: sectionLabel,
|
|
||||||
href: `/dashboard/${segments[1]}/${section}`,
|
|
||||||
});
|
|
||||||
breadcrumbs.push({ label: subSectionLabel });
|
|
||||||
} else {
|
|
||||||
breadcrumbs.push({ label: sectionLabel });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return breadcrumbs;
|
|
||||||
};
|
|
||||||
|
|
||||||
const breadcrumbs = generateBreadcrumbs(pathname);
|
|
||||||
|
|
||||||
if (breadcrumbs.length === 0) {
|
|
||||||
return null; // Don't show breadcrumbs for root dashboard
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Breadcrumb className="select-none">
|
|
||||||
<BreadcrumbList>
|
|
||||||
{breadcrumbs.map((item, index) => (
|
|
||||||
<React.Fragment key={`${index}-${item.href || item.label}`}>
|
|
||||||
<BreadcrumbItem>
|
|
||||||
{index === breadcrumbs.length - 1 ? (
|
|
||||||
<BreadcrumbPage>{item.label}</BreadcrumbPage>
|
|
||||||
) : (
|
|
||||||
<BreadcrumbLink href={item.href}>{item.label}</BreadcrumbLink>
|
|
||||||
)}
|
|
||||||
</BreadcrumbItem>
|
|
||||||
{index < breadcrumbs.length - 1 && <BreadcrumbSeparator />}
|
|
||||||
</React.Fragment>
|
|
||||||
))}
|
|
||||||
</BreadcrumbList>
|
|
||||||
</Breadcrumb>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
363
surfsense_web/components/homepage/github-stars-badge.tsx
Normal file
363
surfsense_web/components/homepage/github-stars-badge.tsx
Normal file
|
|
@ -0,0 +1,363 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { IconBrandGithub } from "@tabler/icons-react";
|
||||||
|
import type { HTMLMotionProps, UseInViewOptions } from "motion/react";
|
||||||
|
import { motion, useInView, useMotionValue, useSpring } 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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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,
|
||||||
|
animated = true,
|
||||||
|
className,
|
||||||
|
onComplete,
|
||||||
|
}: {
|
||||||
|
value: number;
|
||||||
|
itemSize?: number;
|
||||||
|
isRolling?: boolean;
|
||||||
|
animated?: boolean;
|
||||||
|
className?: string;
|
||||||
|
onComplete?: () => void;
|
||||||
|
}) {
|
||||||
|
const formatted = numberFormatter.format(value);
|
||||||
|
const chars = formatted.split("");
|
||||||
|
|
||||||
|
if (!animated) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center">
|
||||||
|
{chars.map((char, idx) => (
|
||||||
|
<div
|
||||||
|
key={`static-${idx}-${char}`}
|
||||||
|
className={className}
|
||||||
|
style={{
|
||||||
|
height: itemSize,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
width: char >= "0" && char <= "9" ? undefined : "0.3em",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{char}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 [hasMounted, setHasMounted] = React.useState(false);
|
||||||
|
const [stars, setStars] = React.useState(0);
|
||||||
|
const [isLoading, setIsLoading] = React.useState(true);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
setHasMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (err instanceof Error && err.name !== "AbortError") {
|
||||||
|
console.error("Error fetching stars:", err);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => setIsLoading(false));
|
||||||
|
return () => abortController.abort();
|
||||||
|
}, [username, repo]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className={cn(
|
||||||
|
"group inline-flex items-center rounded-full border border-neutral-200 bg-white/80 px-3 py-1.5 text-sm backdrop-blur-sm transition-colors dark:border-neutral-800 dark:bg-neutral-950/80",
|
||||||
|
"hover:bg-neutral-100 dark:hover:bg-neutral-900",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<IconBrandGithub className="h-5 w-5 shrink-0 text-neutral-600 transition-colors dark:text-neutral-300 group-hover:text-neutral-800 dark:group-hover:text-neutral-100" />
|
||||||
|
<div className="ml-2 flex items-center text-neutral-500 transition-colors dark:text-neutral-400 group-hover:text-neutral-800 dark:group-hover:text-neutral-200">
|
||||||
|
<AnimatedStarCount
|
||||||
|
value={isLoading ? 10000 : stars}
|
||||||
|
itemSize={ITEM_SIZE}
|
||||||
|
isRolling={hasMounted && isLoading}
|
||||||
|
animated={hasMounted}
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { NavbarGitHubStars, type NavbarGitHubStarsProps };
|
||||||
|
|
@ -32,11 +32,24 @@ const GoogleLogo = ({ className }: { className?: string }) => (
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
function useIsDesktop(breakpoint = 1024) {
|
||||||
|
const [isDesktop, setIsDesktop] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
const mql = window.matchMedia(`(min-width: ${breakpoint}px)`);
|
||||||
|
setIsDesktop(mql.matches);
|
||||||
|
const handler = (e: MediaQueryListEvent) => setIsDesktop(e.matches);
|
||||||
|
mql.addEventListener("change", handler);
|
||||||
|
return () => mql.removeEventListener("change", handler);
|
||||||
|
}, [breakpoint]);
|
||||||
|
return isDesktop;
|
||||||
|
}
|
||||||
|
|
||||||
export function HeroSection() {
|
export function HeroSection() {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const parentRef = useRef<HTMLDivElement>(null);
|
const parentRef = useRef<HTMLDivElement>(null);
|
||||||
const heroVariant = useFeatureFlagVariantKey("notebooklm_superpowers_flag");
|
const heroVariant = useFeatureFlagVariantKey("notebooklm_superpowers_flag");
|
||||||
const isNotebookLMVariant = heroVariant === "superpowers";
|
const isNotebookLMVariant = heroVariant === "superpowers";
|
||||||
|
const isDesktop = useIsDesktop();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -44,42 +57,46 @@ export function HeroSection() {
|
||||||
className="relative flex min-h-screen flex-col items-center justify-center overflow-hidden px-4 py-24 md:px-8 md:py-48"
|
className="relative flex min-h-screen flex-col items-center justify-center overflow-hidden px-4 py-24 md:px-8 md:py-48"
|
||||||
>
|
>
|
||||||
<BackgroundGrids />
|
<BackgroundGrids />
|
||||||
<CollisionMechanism
|
{isDesktop && (
|
||||||
parentRef={parentRef}
|
<>
|
||||||
beamOptions={{
|
<CollisionMechanism
|
||||||
initialX: -400,
|
parentRef={parentRef}
|
||||||
translateX: 600,
|
beamOptions={{
|
||||||
duration: 7,
|
initialX: -400,
|
||||||
repeatDelay: 3,
|
translateX: 600,
|
||||||
}}
|
duration: 7,
|
||||||
/>
|
repeatDelay: 3,
|
||||||
<CollisionMechanism
|
}}
|
||||||
parentRef={parentRef}
|
/>
|
||||||
beamOptions={{
|
<CollisionMechanism
|
||||||
initialX: -200,
|
parentRef={parentRef}
|
||||||
translateX: 800,
|
beamOptions={{
|
||||||
duration: 4,
|
initialX: -200,
|
||||||
repeatDelay: 3,
|
translateX: 800,
|
||||||
}}
|
duration: 4,
|
||||||
/>
|
repeatDelay: 3,
|
||||||
<CollisionMechanism
|
}}
|
||||||
parentRef={parentRef}
|
/>
|
||||||
beamOptions={{
|
<CollisionMechanism
|
||||||
initialX: 200,
|
parentRef={parentRef}
|
||||||
translateX: 1200,
|
beamOptions={{
|
||||||
duration: 5,
|
initialX: 200,
|
||||||
repeatDelay: 3,
|
translateX: 1200,
|
||||||
}}
|
duration: 5,
|
||||||
/>
|
repeatDelay: 3,
|
||||||
<CollisionMechanism
|
}}
|
||||||
parentRef={parentRef}
|
/>
|
||||||
beamOptions={{
|
<CollisionMechanism
|
||||||
initialX: 400,
|
parentRef={parentRef}
|
||||||
translateX: 1400,
|
beamOptions={{
|
||||||
duration: 6,
|
initialX: 400,
|
||||||
repeatDelay: 3,
|
translateX: 1400,
|
||||||
}}
|
duration: 6,
|
||||||
/>
|
repeatDelay: 3,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<h2 className="relative z-50 mx-auto mb-4 mt-8 max-w-4xl text-balance text-center text-3xl font-semibold tracking-tight text-gray-700 md:text-7xl dark:text-neutral-300">
|
<h2 className="relative z-50 mx-auto mb-4 mt-8 max-w-4xl text-balance text-center text-3xl font-semibold tracking-tight text-gray-700 md:text-7xl dark:text-neutral-300">
|
||||||
{isNotebookLMVariant ? (
|
{isNotebookLMVariant ? (
|
||||||
|
|
|
||||||
|
|
@ -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" />
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Plus, Trash2 } from "lucide-react";
|
import { Trash2 } 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";
|
||||||
|
|
@ -76,7 +76,7 @@ export default function InferenceParamsEditor({ params, setParams }: InferencePa
|
||||||
<SelectTrigger id="param-key" className="w-full">
|
<SelectTrigger id="param-key" className="w-full">
|
||||||
<SelectValue placeholder="Select parameter" />
|
<SelectValue placeholder="Select parameter" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent className="bg-muted dark:border-neutral-700">
|
||||||
{PARAM_KEYS.map((key) => (
|
{PARAM_KEYS.map((key) => (
|
||||||
<SelectItem key={key} value={key}>
|
<SelectItem key={key} value={key}>
|
||||||
{key}
|
{key}
|
||||||
|
|
@ -104,7 +104,7 @@ export default function InferenceParamsEditor({ params, setParams }: InferencePa
|
||||||
onClick={handleAdd}
|
onClick={handleAdd}
|
||||||
disabled={!selectedKey || value === ""}
|
disabled={!selectedKey || value === ""}
|
||||||
>
|
>
|
||||||
<Plus className="w-3.5 h-3.5 sm:w-4 sm:h-4 mr-2" /> Add Parameter
|
Add Parameter
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,28 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useAtomValue, useSetAtom } from "jotai";
|
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
||||||
import {
|
import { AlertTriangle, Inbox, Megaphone, SquareLibrary } from "lucide-react";
|
||||||
AlertTriangle,
|
|
||||||
Inbox,
|
|
||||||
LogOut,
|
|
||||||
Megaphone,
|
|
||||||
PencilIcon,
|
|
||||||
SquareLibrary,
|
|
||||||
Trash2,
|
|
||||||
} 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 { deleteSearchSpaceMutationAtom } from "@/atoms/search-spaces/search-space-mutation.atoms";
|
import { deleteSearchSpaceMutationAtom } from "@/atoms/search-spaces/search-space-mutation.atoms";
|
||||||
import { searchSpacesAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
import { searchSpacesAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
||||||
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
|
@ -32,6 +35,7 @@ import {
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { isPageLimitExceededMetadata } from "@/contracts/types/inbox.types";
|
import { isPageLimitExceededMetadata } from "@/contracts/types/inbox.types";
|
||||||
import { useAnnouncements } from "@/hooks/use-announcements";
|
import { useAnnouncements } from "@/hooks/use-announcements";
|
||||||
|
import { useDocumentsProcessing } from "@/hooks/use-documents-processing";
|
||||||
import { useInbox } from "@/hooks/use-inbox";
|
import { useInbox } from "@/hooks/use-inbox";
|
||||||
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
|
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
|
||||||
import { logout } from "@/lib/auth-utils";
|
import { logout } from "@/lib/auth-utils";
|
||||||
|
|
@ -46,7 +50,6 @@ import { LayoutShell } from "../ui/shell";
|
||||||
interface LayoutDataProviderProps {
|
interface LayoutDataProviderProps {
|
||||||
searchSpaceId: string;
|
searchSpaceId: string;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
breadcrumb?: React.ReactNode;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -60,11 +63,7 @@ function formatInboxCount(count: number): string {
|
||||||
return `${thousands}k+`;
|
return `${thousands}k+`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LayoutDataProvider({
|
export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProviderProps) {
|
||||||
searchSpaceId,
|
|
||||||
children,
|
|
||||||
breadcrumb,
|
|
||||||
}: LayoutDataProviderProps) {
|
|
||||||
const t = useTranslations("dashboard");
|
const t = useTranslations("dashboard");
|
||||||
const tCommon = useTranslations("common");
|
const tCommon = useTranslations("common");
|
||||||
const tSidebar = useTranslations("sidebar");
|
const tSidebar = useTranslations("sidebar");
|
||||||
|
|
@ -87,6 +86,10 @@ export function LayoutDataProvider({
|
||||||
// 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)
|
||||||
|
|
@ -114,40 +117,27 @@ export function LayoutDataProvider({
|
||||||
const [isInboxSidebarOpen, setIsInboxSidebarOpen] = useState(false);
|
const [isInboxSidebarOpen, setIsInboxSidebarOpen] = useState(false);
|
||||||
const [isInboxDocked, setIsInboxDocked] = useState(false);
|
const [isInboxDocked, setIsInboxDocked] = useState(false);
|
||||||
|
|
||||||
|
// Documents sidebar state (shared atom so Composer can toggle it)
|
||||||
|
const [isDocumentsSidebarOpen, setIsDocumentsSidebarOpen] = useAtom(documentsSidebarOpenAtom);
|
||||||
|
|
||||||
// Announcements sidebar state
|
// Announcements sidebar state
|
||||||
const [isAnnouncementsSidebarOpen, setIsAnnouncementsSidebarOpen] = useState(false);
|
const [isAnnouncementsSidebarOpen, setIsAnnouncementsSidebarOpen] = useState(false);
|
||||||
|
|
||||||
// Search space dialog state
|
// Search space dialog state
|
||||||
const [isCreateSearchSpaceDialogOpen, setIsCreateSearchSpaceDialogOpen] = useState(false);
|
const [isCreateSearchSpaceDialogOpen, setIsCreateSearchSpaceDialogOpen] = useState(false);
|
||||||
|
|
||||||
// Inbox hooks - separate data sources for mentions and status tabs
|
// Per-tab inbox hooks — each has independent API loading, pagination,
|
||||||
// This ensures each tab has independent pagination and data loading
|
// and Electric live queries. The Electric sync shape is shared (client-level cache).
|
||||||
const userId = user?.id ? String(user.id) : null;
|
const userId = user?.id ? String(user.id) : null;
|
||||||
|
const numericSpaceId = Number(searchSpaceId) || null;
|
||||||
|
|
||||||
const {
|
const commentsInbox = useInbox(userId, numericSpaceId, "comments");
|
||||||
inboxItems: mentionItems,
|
const statusInbox = useInbox(userId, numericSpaceId, "status");
|
||||||
unreadCount: mentionUnreadCount,
|
|
||||||
loading: mentionLoading,
|
|
||||||
loadingMore: mentionLoadingMore,
|
|
||||||
hasMore: mentionHasMore,
|
|
||||||
loadMore: mentionLoadMore,
|
|
||||||
markAsRead: markMentionAsRead,
|
|
||||||
markAllAsRead: markAllMentionsAsRead,
|
|
||||||
} = useInbox(userId, Number(searchSpaceId) || null, "new_mention");
|
|
||||||
|
|
||||||
const {
|
const totalUnreadCount = commentsInbox.unreadCount + statusInbox.unreadCount;
|
||||||
inboxItems: statusItems,
|
|
||||||
unreadCount: allUnreadCount,
|
|
||||||
loading: statusLoading,
|
|
||||||
loadingMore: statusLoadingMore,
|
|
||||||
hasMore: statusHasMore,
|
|
||||||
loadMore: statusLoadMore,
|
|
||||||
markAsRead: markStatusAsRead,
|
|
||||||
markAllAsRead: markAllStatusAsRead,
|
|
||||||
} = useInbox(userId, Number(searchSpaceId) || null, null);
|
|
||||||
|
|
||||||
const totalUnreadCount = allUnreadCount;
|
// Document processing status — drives sidebar status indicator (spinner / check / error)
|
||||||
const statusOnlyUnreadCount = Math.max(0, allUnreadCount - mentionUnreadCount);
|
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());
|
||||||
|
|
@ -155,14 +145,12 @@ export function LayoutDataProvider({
|
||||||
|
|
||||||
// Effect to show toast for new page_limit_exceeded notifications
|
// Effect to show toast for new page_limit_exceeded notifications
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (statusLoading) return;
|
if (statusInbox.loading) return;
|
||||||
|
|
||||||
// Get page_limit_exceeded notifications
|
const pageLimitNotifications = statusInbox.inboxItems.filter(
|
||||||
const pageLimitNotifications = statusItems.filter(
|
|
||||||
(item) => item.type === "page_limit_exceeded"
|
(item) => item.type === "page_limit_exceeded"
|
||||||
);
|
);
|
||||||
|
|
||||||
// On initial load, just mark all as seen without showing toasts
|
|
||||||
if (isInitialLoad.current) {
|
if (isInitialLoad.current) {
|
||||||
for (const notification of pageLimitNotifications) {
|
for (const notification of pageLimitNotifications) {
|
||||||
seenPageLimitNotifications.current.add(notification.id);
|
seenPageLimitNotifications.current.add(notification.id);
|
||||||
|
|
@ -171,16 +159,13 @@ export function LayoutDataProvider({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find new notifications (not yet seen)
|
|
||||||
const newNotifications = pageLimitNotifications.filter(
|
const newNotifications = pageLimitNotifications.filter(
|
||||||
(notification) => !seenPageLimitNotifications.current.has(notification.id)
|
(notification) => !seenPageLimitNotifications.current.has(notification.id)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Show toast for each new page_limit_exceeded notification
|
|
||||||
for (const notification of newNotifications) {
|
for (const notification of newNotifications) {
|
||||||
seenPageLimitNotifications.current.add(notification.id);
|
seenPageLimitNotifications.current.add(notification.id);
|
||||||
|
|
||||||
// Extract metadata for navigation
|
|
||||||
const actionUrl = isPageLimitExceededMetadata(notification.metadata)
|
const actionUrl = isPageLimitExceededMetadata(notification.metadata)
|
||||||
? notification.metadata.action_url
|
? notification.metadata.action_url
|
||||||
: `/dashboard/${searchSpaceId}/more-pages`;
|
: `/dashboard/${searchSpaceId}/more-pages`;
|
||||||
|
|
@ -195,24 +180,7 @@ export function LayoutDataProvider({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [statusItems, statusLoading, searchSpaceId, router]);
|
}, [statusInbox.inboxItems, statusInbox.loading, searchSpaceId, router]);
|
||||||
|
|
||||||
// Unified mark as read that delegates to the correct hook
|
|
||||||
const markAsRead = useCallback(
|
|
||||||
async (id: number) => {
|
|
||||||
// Try both - one will succeed based on which list has the item
|
|
||||||
const mentionResult = await markMentionAsRead(id);
|
|
||||||
if (mentionResult) return true;
|
|
||||||
return markStatusAsRead(id);
|
|
||||||
},
|
|
||||||
[markMentionAsRead, markStatusAsRead]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Mark all as read for both types
|
|
||||||
const markAllAsRead = useCallback(async () => {
|
|
||||||
await Promise.all([markAllMentionsAsRead(), markAllStatusAsRead()]);
|
|
||||||
return true;
|
|
||||||
}, [markAllMentionsAsRead, markAllStatusAsRead]);
|
|
||||||
|
|
||||||
// Delete dialogs state
|
// Delete dialogs state
|
||||||
const [showDeleteChatDialog, setShowDeleteChatDialog] = useState(false);
|
const [showDeleteChatDialog, setShowDeleteChatDialog] = useState(false);
|
||||||
|
|
@ -295,34 +263,35 @@ export function LayoutDataProvider({
|
||||||
// Navigation items
|
// Navigation items
|
||||||
const navItems: NavItem[] = useMemo(
|
const navItems: NavItem[] = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{
|
|
||||||
title: "Documents",
|
|
||||||
url: `/dashboard/${searchSpaceId}/documents`,
|
|
||||||
icon: SquareLibrary,
|
|
||||||
isActive: pathname?.includes("/documents"),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: "Inbox",
|
title: "Inbox",
|
||||||
url: "#inbox", // Special URL to indicate this is handled differently
|
url: "#inbox",
|
||||||
icon: Inbox,
|
icon: Inbox,
|
||||||
isActive: isInboxSidebarOpen,
|
isActive: isInboxSidebarOpen,
|
||||||
badge: totalUnreadCount > 0 ? formatInboxCount(totalUnreadCount) : undefined,
|
badge: totalUnreadCount > 0 ? formatInboxCount(totalUnreadCount) : undefined,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Documents",
|
||||||
|
url: "#documents",
|
||||||
|
icon: SquareLibrary,
|
||||||
|
isActive: isDocumentsSidebarOpen,
|
||||||
|
statusIndicator: documentsProcessingStatus,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Announcements",
|
title: "Announcements",
|
||||||
url: "#announcements", // Special URL to indicate this is handled differently
|
url: "#announcements",
|
||||||
icon: Megaphone,
|
icon: Megaphone,
|
||||||
isActive: isAnnouncementsSidebarOpen,
|
isActive: isAnnouncementsSidebarOpen,
|
||||||
badge: announcementUnreadCount > 0 ? formatInboxCount(announcementUnreadCount) : undefined,
|
badge: announcementUnreadCount > 0 ? formatInboxCount(announcementUnreadCount) : undefined,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
searchSpaceId,
|
|
||||||
pathname,
|
|
||||||
isInboxSidebarOpen,
|
isInboxSidebarOpen,
|
||||||
|
isDocumentsSidebarOpen,
|
||||||
totalUnreadCount,
|
totalUnreadCount,
|
||||||
isAnnouncementsSidebarOpen,
|
isAnnouncementsSidebarOpen,
|
||||||
announcementUnreadCount,
|
announcementUnreadCount,
|
||||||
|
documentsProcessingStatus,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -339,12 +308,12 @@ export function LayoutDataProvider({
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
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]
|
||||||
);
|
);
|
||||||
|
|
@ -415,10 +384,22 @@ export function LayoutDataProvider({
|
||||||
|
|
||||||
const handleNavItemClick = useCallback(
|
const handleNavItemClick = useCallback(
|
||||||
(item: NavItem) => {
|
(item: NavItem) => {
|
||||||
// Handle inbox specially - toggle sidebar instead of navigating
|
|
||||||
if (item.url === "#inbox") {
|
if (item.url === "#inbox") {
|
||||||
setIsInboxSidebarOpen((prev) => {
|
setIsInboxSidebarOpen((prev) => {
|
||||||
if (!prev) {
|
if (!prev) {
|
||||||
|
setIsAllSharedChatsSidebarOpen(false);
|
||||||
|
setIsAllPrivateChatsSidebarOpen(false);
|
||||||
|
setIsDocumentsSidebarOpen(false);
|
||||||
|
setIsAnnouncementsSidebarOpen(false);
|
||||||
|
}
|
||||||
|
return !prev;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (item.url === "#documents") {
|
||||||
|
setIsDocumentsSidebarOpen((prev) => {
|
||||||
|
if (!prev) {
|
||||||
|
setIsInboxSidebarOpen(false);
|
||||||
setIsAllSharedChatsSidebarOpen(false);
|
setIsAllSharedChatsSidebarOpen(false);
|
||||||
setIsAllPrivateChatsSidebarOpen(false);
|
setIsAllPrivateChatsSidebarOpen(false);
|
||||||
setIsAnnouncementsSidebarOpen(false);
|
setIsAnnouncementsSidebarOpen(false);
|
||||||
|
|
@ -427,13 +408,13 @@ export function LayoutDataProvider({
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Handle announcements specially - toggle sidebar instead of navigating
|
|
||||||
if (item.url === "#announcements") {
|
if (item.url === "#announcements") {
|
||||||
setIsAnnouncementsSidebarOpen((prev) => {
|
setIsAnnouncementsSidebarOpen((prev) => {
|
||||||
if (!prev) {
|
if (!prev) {
|
||||||
setIsInboxSidebarOpen(false);
|
setIsInboxSidebarOpen(false);
|
||||||
setIsAllSharedChatsSidebarOpen(false);
|
setIsAllSharedChatsSidebarOpen(false);
|
||||||
setIsAllPrivateChatsSidebarOpen(false);
|
setIsAllPrivateChatsSidebarOpen(false);
|
||||||
|
setIsDocumentsSidebarOpen(false);
|
||||||
}
|
}
|
||||||
return !prev;
|
return !prev;
|
||||||
});
|
});
|
||||||
|
|
@ -441,13 +422,7 @@ export function LayoutDataProvider({
|
||||||
}
|
}
|
||||||
router.push(item.url);
|
router.push(item.url);
|
||||||
},
|
},
|
||||||
[
|
[router, setIsDocumentsSidebarOpen]
|
||||||
router,
|
|
||||||
setIsAllPrivateChatsSidebarOpen,
|
|
||||||
setIsAllSharedChatsSidebarOpen,
|
|
||||||
setIsAnnouncementsSidebarOpen,
|
|
||||||
setIsInboxSidebarOpen,
|
|
||||||
]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleNewChat = useCallback(() => {
|
const handleNewChat = useCallback(() => {
|
||||||
|
|
@ -507,7 +482,7 @@ export function LayoutDataProvider({
|
||||||
);
|
);
|
||||||
|
|
||||||
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(() => {
|
||||||
|
|
@ -544,15 +519,17 @@ export function LayoutDataProvider({
|
||||||
setIsAllSharedChatsSidebarOpen(true);
|
setIsAllSharedChatsSidebarOpen(true);
|
||||||
setIsAllPrivateChatsSidebarOpen(false);
|
setIsAllPrivateChatsSidebarOpen(false);
|
||||||
setIsInboxSidebarOpen(false);
|
setIsInboxSidebarOpen(false);
|
||||||
|
setIsDocumentsSidebarOpen(false);
|
||||||
setIsAnnouncementsSidebarOpen(false);
|
setIsAnnouncementsSidebarOpen(false);
|
||||||
}, []);
|
}, [setIsDocumentsSidebarOpen]);
|
||||||
|
|
||||||
const handleViewAllPrivateChats = useCallback(() => {
|
const handleViewAllPrivateChats = useCallback(() => {
|
||||||
setIsAllPrivateChatsSidebarOpen(true);
|
setIsAllPrivateChatsSidebarOpen(true);
|
||||||
setIsAllSharedChatsSidebarOpen(false);
|
setIsAllSharedChatsSidebarOpen(false);
|
||||||
setIsInboxSidebarOpen(false);
|
setIsInboxSidebarOpen(false);
|
||||||
|
setIsDocumentsSidebarOpen(false);
|
||||||
setIsAnnouncementsSidebarOpen(false);
|
setIsAnnouncementsSidebarOpen(false);
|
||||||
}, []);
|
}, [setIsDocumentsSidebarOpen]);
|
||||||
|
|
||||||
// Delete handlers
|
// Delete handlers
|
||||||
const confirmDeleteChat = useCallback(async () => {
|
const confirmDeleteChat = useCallback(async () => {
|
||||||
|
|
@ -562,7 +539,14 @@ export function LayoutDataProvider({
|
||||||
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);
|
||||||
|
|
@ -571,7 +555,16 @@ export function LayoutDataProvider({
|
||||||
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 () => {
|
||||||
|
|
@ -583,10 +576,6 @@ export function LayoutDataProvider({
|
||||||
queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId] });
|
queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["all-threads", searchSpaceId] });
|
queryClient.invalidateQueries({ queryKey: ["all-threads", searchSpaceId] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["search-threads", searchSpaceId] });
|
queryClient.invalidateQueries({ queryKey: ["search-threads", searchSpaceId] });
|
||||||
// Invalidate thread detail for breadcrumb update
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ["threads", searchSpaceId, "detail", String(chatToRename.id)],
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error renaming thread:", error);
|
console.error("Error renaming thread:", error);
|
||||||
toast.error(tSidebar("error_renaming_chat") || "Failed to rename chat");
|
toast.error(tSidebar("error_renaming_chat") || "Failed to rename chat");
|
||||||
|
|
@ -641,7 +630,6 @@ export function LayoutDataProvider({
|
||||||
onUserSettings={handleUserSettings}
|
onUserSettings={handleUserSettings}
|
||||||
onLogout={handleLogout}
|
onLogout={handleLogout}
|
||||||
pageUsage={pageUsage}
|
pageUsage={pageUsage}
|
||||||
breadcrumb={breadcrumb}
|
|
||||||
theme={theme}
|
theme={theme}
|
||||||
setTheme={setTheme}
|
setTheme={setTheme}
|
||||||
isChatPage={isChatPage}
|
isChatPage={isChatPage}
|
||||||
|
|
@ -649,26 +637,27 @@ export function LayoutDataProvider({
|
||||||
inbox={{
|
inbox={{
|
||||||
isOpen: isInboxSidebarOpen,
|
isOpen: isInboxSidebarOpen,
|
||||||
onOpenChange: setIsInboxSidebarOpen,
|
onOpenChange: setIsInboxSidebarOpen,
|
||||||
// Separate data sources for each tab
|
totalUnreadCount,
|
||||||
mentions: {
|
comments: {
|
||||||
items: mentionItems,
|
items: commentsInbox.inboxItems,
|
||||||
unreadCount: mentionUnreadCount,
|
unreadCount: commentsInbox.unreadCount,
|
||||||
loading: mentionLoading,
|
loading: commentsInbox.loading,
|
||||||
loadingMore: mentionLoadingMore,
|
loadingMore: commentsInbox.loadingMore,
|
||||||
hasMore: mentionHasMore,
|
hasMore: commentsInbox.hasMore,
|
||||||
loadMore: mentionLoadMore,
|
loadMore: commentsInbox.loadMore,
|
||||||
|
markAsRead: commentsInbox.markAsRead,
|
||||||
|
markAllAsRead: commentsInbox.markAllAsRead,
|
||||||
},
|
},
|
||||||
status: {
|
status: {
|
||||||
items: statusItems,
|
items: statusInbox.inboxItems,
|
||||||
unreadCount: statusOnlyUnreadCount,
|
unreadCount: statusInbox.unreadCount,
|
||||||
loading: statusLoading,
|
loading: statusInbox.loading,
|
||||||
loadingMore: statusLoadingMore,
|
loadingMore: statusInbox.loadingMore,
|
||||||
hasMore: statusHasMore,
|
hasMore: statusInbox.hasMore,
|
||||||
loadMore: statusLoadMore,
|
loadMore: statusInbox.loadMore,
|
||||||
|
markAsRead: statusInbox.markAsRead,
|
||||||
|
markAllAsRead: statusInbox.markAllAsRead,
|
||||||
},
|
},
|
||||||
totalUnreadCount,
|
|
||||||
markAsRead,
|
|
||||||
markAllAsRead,
|
|
||||||
isDocked: isInboxDocked,
|
isDocked: isInboxDocked,
|
||||||
onDockedChange: setIsInboxDocked,
|
onDockedChange: setIsInboxDocked,
|
||||||
}}
|
}}
|
||||||
|
|
@ -686,36 +675,33 @@ export function LayoutDataProvider({
|
||||||
onOpenChange: setIsAllPrivateChatsSidebarOpen,
|
onOpenChange: setIsAllPrivateChatsSidebarOpen,
|
||||||
searchSpaceId,
|
searchSpaceId,
|
||||||
}}
|
}}
|
||||||
|
documentsPanel={{
|
||||||
|
open: isDocumentsSidebarOpen,
|
||||||
|
onOpenChange: setIsDocumentsSidebarOpen,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
<Fragment key={chatResetKey}>{children}</Fragment>
|
||||||
</LayoutShell>
|
</LayoutShell>
|
||||||
|
|
||||||
{/* Delete Chat Dialog */}
|
{/* Delete Chat Dialog */}
|
||||||
<Dialog open={showDeleteChatDialog} onOpenChange={setShowDeleteChatDialog}>
|
<AlertDialog open={showDeleteChatDialog} onOpenChange={setShowDeleteChatDialog}>
|
||||||
<DialogContent className="sm:max-w-md">
|
<AlertDialogContent className="sm:max-w-md">
|
||||||
<DialogHeader>
|
<AlertDialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<AlertDialogTitle>{t("delete_chat")}</AlertDialogTitle>
|
||||||
<Trash2 className="h-5 w-5 text-destructive" />
|
<AlertDialogDescription>
|
||||||
<span>{t("delete_chat")}</span>
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
{t("delete_chat_confirm")} <span className="font-medium">{chatToDelete?.name}</span>?{" "}
|
{t("delete_chat_confirm")} <span className="font-medium">{chatToDelete?.name}</span>?{" "}
|
||||||
{t("action_cannot_undone")}
|
{t("action_cannot_undone")}
|
||||||
</DialogDescription>
|
</AlertDialogDescription>
|
||||||
</DialogHeader>
|
</AlertDialogHeader>
|
||||||
<DialogFooter className="flex gap-2 sm:justify-end">
|
<AlertDialogFooter>
|
||||||
<Button
|
<AlertDialogCancel disabled={isDeletingChat}>{tCommon("cancel")}</AlertDialogCancel>
|
||||||
variant="outline"
|
<AlertDialogAction
|
||||||
onClick={() => setShowDeleteChatDialog(false)}
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
confirmDeleteChat();
|
||||||
|
}}
|
||||||
disabled={isDeletingChat}
|
disabled={isDeletingChat}
|
||||||
>
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90 gap-2"
|
||||||
{tCommon("cancel")}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
onClick={confirmDeleteChat}
|
|
||||||
disabled={isDeletingChat}
|
|
||||||
className="gap-2"
|
|
||||||
>
|
>
|
||||||
{isDeletingChat ? (
|
{isDeletingChat ? (
|
||||||
<>
|
<>
|
||||||
|
|
@ -723,15 +709,12 @@ export function LayoutDataProvider({
|
||||||
{t("deleting")}
|
{t("deleting")}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
tCommon("delete")
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
{tCommon("delete")}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</Button>
|
</AlertDialogAction>
|
||||||
</DialogFooter>
|
</AlertDialogFooter>
|
||||||
</DialogContent>
|
</AlertDialogContent>
|
||||||
</Dialog>
|
</AlertDialog>
|
||||||
|
|
||||||
{/* Rename Chat Dialog */}
|
{/* Rename Chat Dialog */}
|
||||||
<Dialog open={showRenameChatDialog} onOpenChange={setShowRenameChatDialog}>
|
<Dialog open={showRenameChatDialog} onOpenChange={setShowRenameChatDialog}>
|
||||||
|
|
@ -756,7 +739,7 @@ export function LayoutDataProvider({
|
||||||
/>
|
/>
|
||||||
<DialogFooter className="flex gap-2 sm:justify-end">
|
<DialogFooter className="flex gap-2 sm:justify-end">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="secondary"
|
||||||
onClick={() => setShowRenameChatDialog(false)}
|
onClick={() => setShowRenameChatDialog(false)}
|
||||||
disabled={isRenamingChat}
|
disabled={isRenamingChat}
|
||||||
>
|
>
|
||||||
|
|
@ -773,10 +756,7 @@ export function LayoutDataProvider({
|
||||||
{tSidebar("renaming") || "Renaming"}
|
{tSidebar("renaming") || "Renaming"}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
tSidebar("rename") || "Rename"
|
||||||
<PencilIcon className="h-4 w-4" />
|
|
||||||
{tSidebar("rename") || "Rename"}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|
@ -784,30 +764,25 @@ export function LayoutDataProvider({
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
{/* Delete Search Space Dialog */}
|
{/* Delete Search Space Dialog */}
|
||||||
<Dialog open={showDeleteSearchSpaceDialog} onOpenChange={setShowDeleteSearchSpaceDialog}>
|
<AlertDialog open={showDeleteSearchSpaceDialog} onOpenChange={setShowDeleteSearchSpaceDialog}>
|
||||||
<DialogContent className="sm:max-w-md">
|
<AlertDialogContent className="sm:max-w-md">
|
||||||
<DialogHeader>
|
<AlertDialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<AlertDialogTitle>{t("delete_search_space")}</AlertDialogTitle>
|
||||||
<Trash2 className="h-5 w-5 text-destructive" />
|
<AlertDialogDescription>
|
||||||
<span>{t("delete_search_space")}</span>
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
{t("delete_space_confirm", { name: searchSpaceToDelete?.name || "" })}
|
{t("delete_space_confirm", { name: searchSpaceToDelete?.name || "" })}
|
||||||
</DialogDescription>
|
</AlertDialogDescription>
|
||||||
</DialogHeader>
|
</AlertDialogHeader>
|
||||||
<DialogFooter className="flex gap-2 sm:justify-end">
|
<AlertDialogFooter>
|
||||||
<Button
|
<AlertDialogCancel disabled={isDeletingSearchSpace}>
|
||||||
variant="outline"
|
|
||||||
onClick={() => setShowDeleteSearchSpaceDialog(false)}
|
|
||||||
disabled={isDeletingSearchSpace}
|
|
||||||
>
|
|
||||||
{tCommon("cancel")}
|
{tCommon("cancel")}
|
||||||
</Button>
|
</AlertDialogCancel>
|
||||||
<Button
|
<AlertDialogAction
|
||||||
variant="destructive"
|
onClick={(e) => {
|
||||||
onClick={confirmDeleteSearchSpace}
|
e.preventDefault();
|
||||||
|
confirmDeleteSearchSpace();
|
||||||
|
}}
|
||||||
disabled={isDeletingSearchSpace}
|
disabled={isDeletingSearchSpace}
|
||||||
className="gap-2"
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90 gap-2"
|
||||||
>
|
>
|
||||||
{isDeletingSearchSpace ? (
|
{isDeletingSearchSpace ? (
|
||||||
<>
|
<>
|
||||||
|
|
@ -815,41 +790,33 @@ export function LayoutDataProvider({
|
||||||
{t("deleting")}
|
{t("deleting")}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
tCommon("delete")
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
{tCommon("delete")}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</Button>
|
</AlertDialogAction>
|
||||||
</DialogFooter>
|
</AlertDialogFooter>
|
||||||
</DialogContent>
|
</AlertDialogContent>
|
||||||
</Dialog>
|
</AlertDialog>
|
||||||
|
|
||||||
{/* Leave Search Space Dialog */}
|
{/* Leave Search Space Dialog */}
|
||||||
<Dialog open={showLeaveSearchSpaceDialog} onOpenChange={setShowLeaveSearchSpaceDialog}>
|
<AlertDialog open={showLeaveSearchSpaceDialog} onOpenChange={setShowLeaveSearchSpaceDialog}>
|
||||||
<DialogContent className="sm:max-w-md">
|
<AlertDialogContent className="sm:max-w-md">
|
||||||
<DialogHeader>
|
<AlertDialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<AlertDialogTitle>{t("leave_title")}</AlertDialogTitle>
|
||||||
<LogOut className="h-5 w-5 text-destructive" />
|
<AlertDialogDescription>
|
||||||
<span>{t("leave_title")}</span>
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
{t("leave_confirm", { name: searchSpaceToLeave?.name || "" })}
|
{t("leave_confirm", { name: searchSpaceToLeave?.name || "" })}
|
||||||
</DialogDescription>
|
</AlertDialogDescription>
|
||||||
</DialogHeader>
|
</AlertDialogHeader>
|
||||||
<DialogFooter className="flex gap-2 sm:justify-end">
|
<AlertDialogFooter>
|
||||||
<Button
|
<AlertDialogCancel disabled={isLeavingSearchSpace}>
|
||||||
variant="outline"
|
|
||||||
onClick={() => setShowLeaveSearchSpaceDialog(false)}
|
|
||||||
disabled={isLeavingSearchSpace}
|
|
||||||
>
|
|
||||||
{tCommon("cancel")}
|
{tCommon("cancel")}
|
||||||
</Button>
|
</AlertDialogCancel>
|
||||||
<Button
|
<AlertDialogAction
|
||||||
variant="destructive"
|
onClick={(e) => {
|
||||||
onClick={confirmLeaveSearchSpace}
|
e.preventDefault();
|
||||||
|
confirmLeaveSearchSpace();
|
||||||
|
}}
|
||||||
disabled={isLeavingSearchSpace}
|
disabled={isLeavingSearchSpace}
|
||||||
className="gap-2"
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90 gap-2"
|
||||||
>
|
>
|
||||||
{isLeavingSearchSpace ? (
|
{isLeavingSearchSpace ? (
|
||||||
<>
|
<>
|
||||||
|
|
@ -857,15 +824,12 @@ export function LayoutDataProvider({
|
||||||
{t("leaving")}
|
{t("leaving")}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
t("leave")
|
||||||
<LogOut className="h-4 w-4" />
|
|
||||||
{t("leave")}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</Button>
|
</AlertDialogAction>
|
||||||
</DialogFooter>
|
</AlertDialogFooter>
|
||||||
</DialogContent>
|
</AlertDialogContent>
|
||||||
</Dialog>
|
</AlertDialog>
|
||||||
|
|
||||||
{/* Create Search Space Dialog */}
|
{/* Create Search Space Dialog */}
|
||||||
<CreateSearchSpaceDialog
|
<CreateSearchSpaceDialog
|
||||||
|
|
|
||||||
|
|
@ -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,6 +22,7 @@ export interface NavItem {
|
||||||
icon: LucideIcon;
|
icon: LucideIcon;
|
||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
badge?: string | number;
|
badge?: string | number;
|
||||||
|
statusIndicator?: DocumentsProcessingStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatItem {
|
export interface ChatItem {
|
||||||
|
|
|
||||||
|
|
@ -138,20 +138,20 @@ export function CreateSearchSpaceDialog({ open, onOpenChange }: CreateSearchSpac
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DialogFooter className="flex-row gap-2 pt-2 sm:pt-3">
|
<DialogFooter className="flex-row justify-end gap-2 pt-2 sm:pt-3">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="secondary"
|
||||||
onClick={() => handleOpenChange(false)}
|
onClick={() => handleOpenChange(false)}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
className="flex-1 sm:flex-none sm:w-auto h-8 sm:h-10 text-xs sm:text-sm"
|
className="h-8 sm:h-9 text-xs sm:text-sm"
|
||||||
>
|
>
|
||||||
{tCommon("cancel")}
|
{tCommon("cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
className="flex-1 sm:flex-none sm:w-auto h-8 sm:h-10 text-xs sm:text-sm"
|
className="h-8 sm:h-9 text-xs sm:text-sm"
|
||||||
>
|
>
|
||||||
{isSubmitting ? (
|
{isSubmitting ? (
|
||||||
<>
|
<>
|
||||||
|
|
|
||||||
|
|
@ -3,33 +3,30 @@
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { currentThreadAtom } from "@/atoms/chat/current-thread.atom";
|
import { currentThreadAtom } from "@/atoms/chat/current-thread.atom";
|
||||||
|
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
||||||
|
import { ChatHeader } from "@/components/new-chat/chat-header";
|
||||||
import { ChatShareButton } from "@/components/new-chat/chat-share-button";
|
import { ChatShareButton } from "@/components/new-chat/chat-share-button";
|
||||||
import type { ChatVisibility, ThreadRecord } from "@/lib/chat/thread-persistence";
|
import type { ChatVisibility, ThreadRecord } from "@/lib/chat/thread-persistence";
|
||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
breadcrumb?: React.ReactNode;
|
|
||||||
mobileMenuTrigger?: React.ReactNode;
|
mobileMenuTrigger?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Header({ breadcrumb, mobileMenuTrigger }: HeaderProps) {
|
export function Header({ mobileMenuTrigger }: HeaderProps) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
|
||||||
|
|
||||||
// Check if we're on a chat page
|
|
||||||
const isChatPage = pathname?.includes("/new-chat") ?? false;
|
const isChatPage = pathname?.includes("/new-chat") ?? false;
|
||||||
|
|
||||||
// Use Jotai atom for thread state (synced from chat page)
|
|
||||||
const currentThreadState = useAtomValue(currentThreadAtom);
|
const currentThreadState = useAtomValue(currentThreadAtom);
|
||||||
|
|
||||||
// Show button only when we have a thread id (thread exists and is synced to Jotai)
|
|
||||||
const hasThread = isChatPage && currentThreadState.id !== null;
|
const hasThread = isChatPage && currentThreadState.id !== null;
|
||||||
|
|
||||||
// Create minimal thread object for ChatShareButton (used for API calls)
|
|
||||||
const threadForButton: ThreadRecord | null =
|
const threadForButton: ThreadRecord | null =
|
||||||
hasThread && currentThreadState.id !== null
|
hasThread && currentThreadState.id !== null
|
||||||
? {
|
? {
|
||||||
id: currentThreadState.id,
|
id: currentThreadState.id,
|
||||||
visibility: currentThreadState.visibility ?? "PRIVATE",
|
visibility: currentThreadState.visibility ?? "PRIVATE",
|
||||||
// These fields are not used by ChatShareButton for display, only for checks
|
|
||||||
created_by_id: null,
|
created_by_id: null,
|
||||||
search_space_id: 0,
|
search_space_id: 0,
|
||||||
title: "",
|
title: "",
|
||||||
|
|
@ -39,22 +36,20 @@ export function Header({ breadcrumb, mobileMenuTrigger }: HeaderProps) {
|
||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const handleVisibilityChange = (_visibility: ChatVisibility) => {
|
const handleVisibilityChange = (_visibility: ChatVisibility) => {};
|
||||||
// Visibility change is handled by ChatShareButton internally via Jotai
|
|
||||||
// This callback can be used for additional side effects if needed
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-10 flex h-14 shrink-0 items-center gap-2 border-b bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60 px-4">
|
<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 + Breadcrumb */}
|
{/* 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}
|
||||||
<div className="hidden md:block">{breadcrumb}</div>
|
{isChatPage && searchSpaceId && (
|
||||||
|
<ChatHeader searchSpaceId={Number(searchSpaceId)} className="md:h-9 md:px-4 md:text-sm" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right side - Actions */}
|
{/* Right side - Actions */}
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
{/* Share button - only show on chat pages when thread exists */}
|
|
||||||
{hasThread && (
|
{hasThread && (
|
||||||
<ChatShareButton thread={threadForButton} onVisibilityChange={handleVisibilityChange} />
|
<ChatShareButton thread={threadForButton} onVisibilityChange={handleVisibilityChange} />
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -159,13 +159,13 @@ export function SearchSpaceAvatar({
|
||||||
)}
|
)}
|
||||||
{onSettings && onDelete && <DropdownMenuSeparator />}
|
{onSettings && onDelete && <DropdownMenuSeparator />}
|
||||||
{onDelete && isOwner && (
|
{onDelete && isOwner && (
|
||||||
<DropdownMenuItem variant="destructive" onClick={onDelete}>
|
<DropdownMenuItem onClick={onDelete}>
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
{tCommon("delete")}
|
{tCommon("delete")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
{onDelete && !isOwner && (
|
{onDelete && !isOwner && (
|
||||||
<DropdownMenuItem variant="destructive" onClick={onDelete}>
|
<DropdownMenuItem onClick={onDelete}>
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
{t("leave")}
|
{t("leave")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
@ -217,13 +217,13 @@ export function SearchSpaceAvatar({
|
||||||
)}
|
)}
|
||||||
{onSettings && onDelete && <ContextMenuSeparator />}
|
{onSettings && onDelete && <ContextMenuSeparator />}
|
||||||
{onDelete && isOwner && (
|
{onDelete && isOwner && (
|
||||||
<ContextMenuItem variant="destructive" onClick={onDelete}>
|
<ContextMenuItem onClick={onDelete}>
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
{tCommon("delete")}
|
{tCommon("delete")}
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
)}
|
)}
|
||||||
{onDelete && !isOwner && (
|
{onDelete && !isOwner && (
|
||||||
<ContextMenuItem variant="destructive" onClick={onDelete}>
|
<ContextMenuItem onClick={onDelete}>
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
{t("leave")}
|
{t("leave")}
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
|
|
|
||||||
|
|
@ -14,37 +14,33 @@ import {
|
||||||
AllPrivateChatsSidebar,
|
AllPrivateChatsSidebar,
|
||||||
AllSharedChatsSidebar,
|
AllSharedChatsSidebar,
|
||||||
AnnouncementsSidebar,
|
AnnouncementsSidebar,
|
||||||
|
DocumentsSidebar,
|
||||||
InboxSidebar,
|
InboxSidebar,
|
||||||
MobileSidebar,
|
MobileSidebar,
|
||||||
MobileSidebarTrigger,
|
MobileSidebarTrigger,
|
||||||
Sidebar,
|
Sidebar,
|
||||||
} from "../sidebar";
|
} from "../sidebar";
|
||||||
|
|
||||||
// Tab-specific data source props
|
// Per-tab data source
|
||||||
interface TabDataSource {
|
interface TabDataSource {
|
||||||
items: InboxItem[];
|
items: InboxItem[];
|
||||||
unreadCount: number;
|
unreadCount: number;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
loadingMore?: boolean;
|
loadingMore: boolean;
|
||||||
hasMore?: boolean;
|
hasMore: boolean;
|
||||||
loadMore?: () => void;
|
loadMore: () => void;
|
||||||
|
markAsRead: (id: number) => Promise<boolean>;
|
||||||
|
markAllAsRead: () => Promise<boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inbox-related props with separate data sources per tab
|
// Inbox-related props — per-tab data sources with independent loading/pagination
|
||||||
interface InboxProps {
|
interface InboxProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
/** Mentions tab data source with independent pagination */
|
|
||||||
mentions: TabDataSource;
|
|
||||||
/** Status tab data source with independent pagination */
|
|
||||||
status: TabDataSource;
|
|
||||||
/** Combined unread count for nav badge */
|
|
||||||
totalUnreadCount: number;
|
totalUnreadCount: number;
|
||||||
markAsRead: (id: number) => Promise<boolean>;
|
comments: TabDataSource;
|
||||||
markAllAsRead: () => Promise<boolean>;
|
status: TabDataSource;
|
||||||
/** Whether the inbox is docked (permanent) */
|
|
||||||
isDocked?: boolean;
|
isDocked?: boolean;
|
||||||
/** Callback to change docked state */
|
|
||||||
onDockedChange?: (docked: boolean) => void;
|
onDockedChange?: (docked: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -74,7 +70,6 @@ interface LayoutShellProps {
|
||||||
onUserSettings?: () => void;
|
onUserSettings?: () => void;
|
||||||
onLogout?: () => void;
|
onLogout?: () => void;
|
||||||
pageUsage?: PageUsage;
|
pageUsage?: PageUsage;
|
||||||
breadcrumb?: React.ReactNode;
|
|
||||||
theme?: string;
|
theme?: string;
|
||||||
setTheme?: (theme: "light" | "dark" | "system") => void;
|
setTheme?: (theme: "light" | "dark" | "system") => void;
|
||||||
defaultCollapsed?: boolean;
|
defaultCollapsed?: boolean;
|
||||||
|
|
@ -99,6 +94,10 @@ interface LayoutShellProps {
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
searchSpaceId: string;
|
searchSpaceId: string;
|
||||||
};
|
};
|
||||||
|
documentsPanel?: {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LayoutShell({
|
export function LayoutShell({
|
||||||
|
|
@ -127,7 +126,6 @@ export function LayoutShell({
|
||||||
onUserSettings,
|
onUserSettings,
|
||||||
onLogout,
|
onLogout,
|
||||||
pageUsage,
|
pageUsage,
|
||||||
breadcrumb,
|
|
||||||
theme,
|
theme,
|
||||||
setTheme,
|
setTheme,
|
||||||
defaultCollapsed = false,
|
defaultCollapsed = false,
|
||||||
|
|
@ -139,6 +137,7 @@ export function LayoutShell({
|
||||||
isLoadingChats = false,
|
isLoadingChats = false,
|
||||||
allSharedChatsPanel,
|
allSharedChatsPanel,
|
||||||
allPrivateChatsPanel,
|
allPrivateChatsPanel,
|
||||||
|
documentsPanel,
|
||||||
}: LayoutShellProps) {
|
}: LayoutShellProps) {
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||||
|
|
@ -162,7 +161,6 @@ export function LayoutShell({
|
||||||
<TooltipProvider delayDuration={0}>
|
<TooltipProvider delayDuration={0}>
|
||||||
<div className={cn("flex h-screen w-full flex-col bg-background", className)}>
|
<div className={cn("flex h-screen w-full flex-col bg-background", className)}>
|
||||||
<Header
|
<Header
|
||||||
breadcrumb={breadcrumb}
|
|
||||||
mobileMenuTrigger={<MobileSidebarTrigger onClick={() => setMobileMenuOpen(true)} />}
|
mobileMenuTrigger={<MobileSidebarTrigger onClick={() => setMobileMenuOpen(true)} />}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
@ -208,16 +206,22 @@ export function LayoutShell({
|
||||||
<InboxSidebar
|
<InboxSidebar
|
||||||
open={inbox.isOpen}
|
open={inbox.isOpen}
|
||||||
onOpenChange={inbox.onOpenChange}
|
onOpenChange={inbox.onOpenChange}
|
||||||
mentions={inbox.mentions}
|
comments={inbox.comments}
|
||||||
status={inbox.status}
|
status={inbox.status}
|
||||||
totalUnreadCount={inbox.totalUnreadCount}
|
totalUnreadCount={inbox.totalUnreadCount}
|
||||||
markAsRead={inbox.markAsRead}
|
|
||||||
markAllAsRead={inbox.markAllAsRead}
|
|
||||||
onCloseMobileSidebar={() => setMobileMenuOpen(false)}
|
onCloseMobileSidebar={() => setMobileMenuOpen(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Mobile Announcements Sidebar - only render when open to avoid scroll blocking */}
|
{/* Mobile Documents Sidebar - slide-out panel */}
|
||||||
|
{documentsPanel && (
|
||||||
|
<DocumentsSidebar
|
||||||
|
open={documentsPanel.open}
|
||||||
|
onOpenChange={documentsPanel.onOpenChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Mobile Announcements Sidebar */}
|
||||||
{announcementsPanel?.open && (
|
{announcementsPanel?.open && (
|
||||||
<AnnouncementsSidebar
|
<AnnouncementsSidebar
|
||||||
open={announcementsPanel.open}
|
open={announcementsPanel.open}
|
||||||
|
|
@ -307,18 +311,16 @@ export function LayoutShell({
|
||||||
<InboxSidebar
|
<InboxSidebar
|
||||||
open={inbox.isOpen}
|
open={inbox.isOpen}
|
||||||
onOpenChange={inbox.onOpenChange}
|
onOpenChange={inbox.onOpenChange}
|
||||||
mentions={inbox.mentions}
|
comments={inbox.comments}
|
||||||
status={inbox.status}
|
status={inbox.status}
|
||||||
totalUnreadCount={inbox.totalUnreadCount}
|
totalUnreadCount={inbox.totalUnreadCount}
|
||||||
markAsRead={inbox.markAsRead}
|
|
||||||
markAllAsRead={inbox.markAllAsRead}
|
|
||||||
isDocked={inbox.isDocked}
|
isDocked={inbox.isDocked}
|
||||||
onDockedChange={inbox.onDockedChange}
|
onDockedChange={inbox.onDockedChange}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<main className="flex-1 flex flex-col min-w-0">
|
<main className="flex-1 flex flex-col min-w-0">
|
||||||
<Header breadcrumb={breadcrumb} />
|
<Header />
|
||||||
|
|
||||||
<div className={cn("flex-1", isChatPage ? "overflow-hidden" : "overflow-auto")}>
|
<div className={cn("flex-1", isChatPage ? "overflow-hidden" : "overflow-auto")}>
|
||||||
{children}
|
{children}
|
||||||
|
|
@ -330,17 +332,23 @@ export function LayoutShell({
|
||||||
<InboxSidebar
|
<InboxSidebar
|
||||||
open={inbox.isOpen}
|
open={inbox.isOpen}
|
||||||
onOpenChange={inbox.onOpenChange}
|
onOpenChange={inbox.onOpenChange}
|
||||||
mentions={inbox.mentions}
|
comments={inbox.comments}
|
||||||
status={inbox.status}
|
status={inbox.status}
|
||||||
totalUnreadCount={inbox.totalUnreadCount}
|
totalUnreadCount={inbox.totalUnreadCount}
|
||||||
markAsRead={inbox.markAsRead}
|
|
||||||
markAllAsRead={inbox.markAllAsRead}
|
|
||||||
isDocked={false}
|
isDocked={false}
|
||||||
onDockedChange={inbox.onDockedChange}
|
onDockedChange={inbox.onDockedChange}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Announcements Sidebar - positioned absolutely on top of content */}
|
{/* Documents Sidebar - slide-out panel */}
|
||||||
|
{documentsPanel && (
|
||||||
|
<DocumentsSidebar
|
||||||
|
open={documentsPanel.open}
|
||||||
|
onOpenChange={documentsPanel.onOpenChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Announcements Sidebar */}
|
||||||
{announcementsPanel && (
|
{announcementsPanel && (
|
||||||
<AnnouncementsSidebar
|
<AnnouncementsSidebar
|
||||||
open={announcementsPanel.open}
|
open={announcementsPanel.open}
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ import {
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
|
|
@ -40,6 +40,7 @@ import { Spinner } from "@/components/ui/spinner";
|
||||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
||||||
|
import { useLongPress } from "@/hooks/use-long-press";
|
||||||
import { useIsMobile } from "@/hooks/use-mobile";
|
import { useIsMobile } from "@/hooks/use-mobile";
|
||||||
import {
|
import {
|
||||||
deleteThread,
|
deleteThread,
|
||||||
|
|
@ -85,6 +86,15 @@ export function AllPrivateChatsSidebar({
|
||||||
const [isRenaming, setIsRenaming] = useState(false);
|
const [isRenaming, setIsRenaming] = useState(false);
|
||||||
const debouncedSearchQuery = useDebouncedValue(searchQuery, 300);
|
const debouncedSearchQuery = useDebouncedValue(searchQuery, 300);
|
||||||
|
|
||||||
|
const pendingThreadIdRef = useRef<number | null>(null);
|
||||||
|
const { handlers: longPressHandlers, wasLongPress } = useLongPress(
|
||||||
|
useCallback(() => {
|
||||||
|
if (pendingThreadIdRef.current !== null) {
|
||||||
|
setOpenDropdownId(pendingThreadIdRef.current);
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
);
|
||||||
|
|
||||||
const isSearchMode = !!debouncedSearchQuery.trim();
|
const isSearchMode = !!debouncedSearchQuery.trim();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -357,7 +367,16 @@ export function AllPrivateChatsSidebar({
|
||||||
{isMobile ? (
|
{isMobile ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleThreadClick(thread.id)}
|
onClick={() => {
|
||||||
|
if (wasLongPress()) return;
|
||||||
|
handleThreadClick(thread.id);
|
||||||
|
}}
|
||||||
|
onTouchStart={() => {
|
||||||
|
pendingThreadIdRef.current = thread.id;
|
||||||
|
longPressHandlers.onTouchStart();
|
||||||
|
}}
|
||||||
|
onTouchEnd={longPressHandlers.onTouchEnd}
|
||||||
|
onTouchMove={longPressHandlers.onTouchMove}
|
||||||
disabled={isBusy}
|
disabled={isBusy}
|
||||||
className="flex items-center gap-2 flex-1 min-w-0 text-left overflow-hidden"
|
className="flex items-center gap-2 flex-1 min-w-0 text-left overflow-hidden"
|
||||||
>
|
>
|
||||||
|
|
@ -396,7 +415,9 @@ export function AllPrivateChatsSidebar({
|
||||||
size="icon"
|
size="icon"
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-6 w-6 shrink-0",
|
"h-6 w-6 shrink-0",
|
||||||
"md:opacity-0 md:group-hover:opacity-100 md:focus:opacity-100",
|
isMobile
|
||||||
|
? "opacity-0 pointer-events-none absolute"
|
||||||
|
: "md:opacity-0 md:group-hover:opacity-100 md:focus:opacity-100",
|
||||||
"transition-opacity"
|
"transition-opacity"
|
||||||
)}
|
)}
|
||||||
disabled={isBusy}
|
disabled={isBusy}
|
||||||
|
|
@ -435,10 +456,7 @@ export function AllPrivateChatsSidebar({
|
||||||
)}
|
)}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem onClick={() => handleDeleteThread(thread.id)}>
|
||||||
onClick={() => handleDeleteThread(thread.id)}
|
|
||||||
className="text-destructive focus:text-destructive"
|
|
||||||
>
|
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
<span>{t("delete") || "Delete"}</span>
|
<span>{t("delete") || "Delete"}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
@ -496,7 +514,7 @@ export function AllPrivateChatsSidebar({
|
||||||
/>
|
/>
|
||||||
<DialogFooter className="flex gap-2 sm:justify-end">
|
<DialogFooter className="flex gap-2 sm:justify-end">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="secondary"
|
||||||
onClick={() => setShowRenameDialog(false)}
|
onClick={() => setShowRenameDialog(false)}
|
||||||
disabled={isRenaming}
|
disabled={isRenaming}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ import {
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
|
|
@ -40,6 +40,7 @@ import { Spinner } from "@/components/ui/spinner";
|
||||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
||||||
|
import { useLongPress } from "@/hooks/use-long-press";
|
||||||
import { useIsMobile } from "@/hooks/use-mobile";
|
import { useIsMobile } from "@/hooks/use-mobile";
|
||||||
import {
|
import {
|
||||||
deleteThread,
|
deleteThread,
|
||||||
|
|
@ -85,6 +86,15 @@ export function AllSharedChatsSidebar({
|
||||||
const [isRenaming, setIsRenaming] = useState(false);
|
const [isRenaming, setIsRenaming] = useState(false);
|
||||||
const debouncedSearchQuery = useDebouncedValue(searchQuery, 300);
|
const debouncedSearchQuery = useDebouncedValue(searchQuery, 300);
|
||||||
|
|
||||||
|
const pendingThreadIdRef = useRef<number | null>(null);
|
||||||
|
const { handlers: longPressHandlers, wasLongPress } = useLongPress(
|
||||||
|
useCallback(() => {
|
||||||
|
if (pendingThreadIdRef.current !== null) {
|
||||||
|
setOpenDropdownId(pendingThreadIdRef.current);
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
);
|
||||||
|
|
||||||
const isSearchMode = !!debouncedSearchQuery.trim();
|
const isSearchMode = !!debouncedSearchQuery.trim();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -357,7 +367,16 @@ export function AllSharedChatsSidebar({
|
||||||
{isMobile ? (
|
{isMobile ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleThreadClick(thread.id)}
|
onClick={() => {
|
||||||
|
if (wasLongPress()) return;
|
||||||
|
handleThreadClick(thread.id);
|
||||||
|
}}
|
||||||
|
onTouchStart={() => {
|
||||||
|
pendingThreadIdRef.current = thread.id;
|
||||||
|
longPressHandlers.onTouchStart();
|
||||||
|
}}
|
||||||
|
onTouchEnd={longPressHandlers.onTouchEnd}
|
||||||
|
onTouchMove={longPressHandlers.onTouchMove}
|
||||||
disabled={isBusy}
|
disabled={isBusy}
|
||||||
className="flex items-center gap-2 flex-1 min-w-0 text-left overflow-hidden"
|
className="flex items-center gap-2 flex-1 min-w-0 text-left overflow-hidden"
|
||||||
>
|
>
|
||||||
|
|
@ -396,7 +415,9 @@ export function AllSharedChatsSidebar({
|
||||||
size="icon"
|
size="icon"
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-6 w-6 shrink-0",
|
"h-6 w-6 shrink-0",
|
||||||
"md:opacity-0 md:group-hover:opacity-100 md:focus:opacity-100",
|
isMobile
|
||||||
|
? "opacity-0 pointer-events-none absolute"
|
||||||
|
: "md:opacity-0 md:group-hover:opacity-100 md:focus:opacity-100",
|
||||||
"transition-opacity"
|
"transition-opacity"
|
||||||
)}
|
)}
|
||||||
disabled={isBusy}
|
disabled={isBusy}
|
||||||
|
|
@ -435,10 +456,7 @@ export function AllSharedChatsSidebar({
|
||||||
)}
|
)}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem onClick={() => handleDeleteThread(thread.id)}>
|
||||||
onClick={() => handleDeleteThread(thread.id)}
|
|
||||||
className="text-destructive focus:text-destructive"
|
|
||||||
>
|
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
<span>{t("delete") || "Delete"}</span>
|
<span>{t("delete") || "Delete"}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
@ -496,7 +514,7 @@ export function AllSharedChatsSidebar({
|
||||||
/>
|
/>
|
||||||
<DialogFooter className="flex gap-2 sm:justify-end">
|
<DialogFooter className="flex gap-2 sm:justify-end">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="secondary"
|
||||||
onClick={() => setShowRenameDialog(false)}
|
onClick={() => setShowRenameDialog(false)}
|
||||||
disabled={isRenaming}
|
disabled={isRenaming}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,8 @@
|
||||||
|
|
||||||
import { ChevronLeft } from "lucide-react";
|
import { ChevronLeft } from "lucide-react";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { AnnouncementsEmptyState } from "@/components/announcements/AnnouncementsEmptyState";
|
|
||||||
import { AnnouncementCard } from "@/components/announcements/AnnouncementCard";
|
import { AnnouncementCard } from "@/components/announcements/AnnouncementCard";
|
||||||
|
import { AnnouncementsEmptyState } from "@/components/announcements/AnnouncementsEmptyState";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useAnnouncements } from "@/hooks/use-announcements";
|
import { useAnnouncements } from "@/hooks/use-announcements";
|
||||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||||
|
|
@ -72,4 +72,3 @@ export function AnnouncementsSidebar({
|
||||||
</SidebarSlideOutPanel>
|
</SidebarSlideOutPanel>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import {
|
||||||
Trash2,
|
Trash2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useCallback, useState } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
|
|
@ -17,6 +18,9 @@ import {
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { useLongPress } from "@/hooks/use-long-press";
|
||||||
|
import { useIsMobile } from "@/hooks/use-mobile";
|
||||||
|
import { useTypewriter } from "@/hooks/use-typewriter";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface ChatListItemProps {
|
interface ChatListItemProps {
|
||||||
|
|
@ -39,12 +43,25 @@ export function ChatListItem({
|
||||||
onDelete,
|
onDelete,
|
||||||
}: ChatListItemProps) {
|
}: ChatListItemProps) {
|
||||||
const t = useTranslations("sidebar");
|
const t = useTranslations("sidebar");
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||||
|
const animatedName = useTypewriter(name);
|
||||||
|
|
||||||
|
const { handlers: longPressHandlers, wasLongPress } = useLongPress(
|
||||||
|
useCallback(() => setDropdownOpen(true), [])
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleClick = useCallback(() => {
|
||||||
|
if (wasLongPress()) return;
|
||||||
|
onClick?.();
|
||||||
|
}, [onClick, wasLongPress]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="group/item relative w-full">
|
<div className="group/item relative w-full">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClick}
|
onClick={handleClick}
|
||||||
|
{...(isMobile ? longPressHandlers : {})}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-sm text-left transition-colors",
|
"flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-sm text-left transition-colors",
|
||||||
"[&>span:last-child]:truncate",
|
"[&>span:last-child]:truncate",
|
||||||
|
|
@ -54,19 +71,24 @@ 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 */}
|
{/* Actions dropdown - trigger hidden on mobile, long-press opens it instead */}
|
||||||
<div className="absolute right-1 top-1/2 -translate-y-1/2 opacity-100 md:opacity-0 md:group-hover/item:opacity-100 transition-opacity">
|
<div
|
||||||
<DropdownMenu>
|
className={cn(
|
||||||
|
"absolute right-1 top-1/2 -translate-y-1/2 transition-opacity",
|
||||||
|
isMobile ? "opacity-0 pointer-events-none" : "opacity-0 group-hover/item:opacity-100"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="ghost" size="icon" className="h-6 w-6">
|
<Button variant="ghost" size="icon" className="h-6 w-6">
|
||||||
<MoreHorizontal className="h-3.5 w-3.5 text-muted-foreground" />
|
<MoreHorizontal className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
<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) => {
|
||||||
|
|
@ -105,7 +127,6 @@ export function ChatListItem({
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onDelete();
|
onDelete();
|
||||||
}}
|
}}
|
||||||
className="text-destructive focus:text-destructive"
|
|
||||||
>
|
>
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
<span>{t("delete")}</span>
|
<span>{t("delete")}</span>
|
||||||
|
|
|
||||||
212
surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx
Normal file
212
surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx
Normal file
|
|
@ -0,0 +1,212 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useAtom, useAtomValue } from "jotai";
|
||||||
|
import { ChevronLeft } from "lucide-react";
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { DocumentsFilters } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsFilters";
|
||||||
|
import {
|
||||||
|
DocumentsTableShell,
|
||||||
|
type SortKey,
|
||||||
|
} from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell";
|
||||||
|
import { sidebarSelectedDocumentsAtom } from "@/atoms/chat/mentioned-documents.atom";
|
||||||
|
import { deleteDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import type { DocumentTypeEnum } from "@/contracts/types/document.types";
|
||||||
|
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
||||||
|
import { useDocumentSearch } from "@/hooks/use-document-search";
|
||||||
|
import { useDocuments } from "@/hooks/use-documents";
|
||||||
|
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||||
|
import { SidebarSlideOutPanel } from "./SidebarSlideOutPanel";
|
||||||
|
|
||||||
|
interface DocumentsSidebarProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DocumentsSidebar({ open, onOpenChange }: DocumentsSidebarProps) {
|
||||||
|
const t = useTranslations("documents");
|
||||||
|
const tSidebar = useTranslations("sidebar");
|
||||||
|
const params = useParams();
|
||||||
|
const isMobile = !useMediaQuery("(min-width: 640px)");
|
||||||
|
const searchSpaceId = Number(params.search_space_id);
|
||||||
|
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const debouncedSearch = useDebouncedValue(search, 250);
|
||||||
|
const [activeTypes, setActiveTypes] = useState<DocumentTypeEnum[]>([]);
|
||||||
|
const [sortKey, setSortKey] = useState<SortKey>("created_at");
|
||||||
|
const [sortDesc, setSortDesc] = useState(true);
|
||||||
|
const { mutateAsync: deleteDocumentMutation } = useAtomValue(deleteDocumentMutationAtom);
|
||||||
|
|
||||||
|
const [sidebarDocs, setSidebarDocs] = useAtom(sidebarSelectedDocumentsAtom);
|
||||||
|
const mentionedDocIds = useMemo(() => new Set(sidebarDocs.map((d) => d.id)), [sidebarDocs]);
|
||||||
|
|
||||||
|
const handleToggleChatMention = useCallback(
|
||||||
|
(doc: { id: number; title: string; document_type: string }, isMentioned: boolean) => {
|
||||||
|
if (isMentioned) {
|
||||||
|
setSidebarDocs((prev) => prev.filter((d) => d.id !== doc.id));
|
||||||
|
} else {
|
||||||
|
setSidebarDocs((prev) => {
|
||||||
|
if (prev.some((d) => d.id === doc.id)) return prev;
|
||||||
|
return [
|
||||||
|
...prev,
|
||||||
|
{ id: doc.id, title: doc.title, document_type: doc.document_type as DocumentTypeEnum },
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[setSidebarDocs]
|
||||||
|
);
|
||||||
|
|
||||||
|
const isSearchMode = !!debouncedSearch.trim();
|
||||||
|
|
||||||
|
const {
|
||||||
|
documents: realtimeDocuments,
|
||||||
|
typeCounts: realtimeTypeCounts,
|
||||||
|
loading: realtimeLoading,
|
||||||
|
loadingMore: realtimeLoadingMore,
|
||||||
|
hasMore: realtimeHasMore,
|
||||||
|
loadMore: realtimeLoadMore,
|
||||||
|
error: realtimeError,
|
||||||
|
} = useDocuments(searchSpaceId, activeTypes, sortKey, sortDesc ? "desc" : "asc");
|
||||||
|
|
||||||
|
const {
|
||||||
|
documents: searchDocuments,
|
||||||
|
loading: searchLoading,
|
||||||
|
loadingMore: searchLoadingMore,
|
||||||
|
hasMore: searchHasMore,
|
||||||
|
loadMore: searchLoadMore,
|
||||||
|
error: searchError,
|
||||||
|
removeItems: searchRemoveItems,
|
||||||
|
} = useDocumentSearch(searchSpaceId, debouncedSearch, activeTypes, isSearchMode && open);
|
||||||
|
|
||||||
|
const displayDocs = isSearchMode ? searchDocuments : realtimeDocuments;
|
||||||
|
const loading = isSearchMode ? searchLoading : realtimeLoading;
|
||||||
|
const error = isSearchMode ? searchError : !!realtimeError;
|
||||||
|
const hasMore = isSearchMode ? searchHasMore : realtimeHasMore;
|
||||||
|
const loadingMore = isSearchMode ? searchLoadingMore : realtimeLoadingMore;
|
||||||
|
const onLoadMore = isSearchMode ? searchLoadMore : realtimeLoadMore;
|
||||||
|
|
||||||
|
const onToggleType = (type: DocumentTypeEnum, checked: boolean) => {
|
||||||
|
setActiveTypes((prev) => {
|
||||||
|
if (checked) {
|
||||||
|
return prev.includes(type) ? prev : [...prev, type];
|
||||||
|
}
|
||||||
|
return prev.filter((t) => t !== type);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteDocument = useCallback(
|
||||||
|
async (id: number): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
await deleteDocumentMutation({ id });
|
||||||
|
toast.success(t("delete_success") || "Document deleted");
|
||||||
|
setSidebarDocs((prev) => prev.filter((d) => d.id !== id));
|
||||||
|
if (isSearchMode) {
|
||||||
|
searchRemoveItems([id]);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error deleting document:", e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[deleteDocumentMutation, isSearchMode, t, searchRemoveItems, setSidebarDocs]
|
||||||
|
);
|
||||||
|
|
||||||
|
const sortKeyRef = useRef(sortKey);
|
||||||
|
const sortDescRef = useRef(sortDesc);
|
||||||
|
sortKeyRef.current = sortKey;
|
||||||
|
sortDescRef.current = sortDesc;
|
||||||
|
|
||||||
|
const handleSortChange = useCallback((key: SortKey) => {
|
||||||
|
const currentKey = sortKeyRef.current;
|
||||||
|
const currentDesc = sortDescRef.current;
|
||||||
|
|
||||||
|
if (currentKey === key && currentDesc) {
|
||||||
|
setSortKey("created_at");
|
||||||
|
setSortDesc(true);
|
||||||
|
} else if (currentKey === key) {
|
||||||
|
setSortDesc(true);
|
||||||
|
} else {
|
||||||
|
setSortKey(key);
|
||||||
|
setSortDesc(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleEscape = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape" && open) {
|
||||||
|
onOpenChange(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("keydown", handleEscape);
|
||||||
|
return () => document.removeEventListener("keydown", handleEscape);
|
||||||
|
}, [open, onOpenChange]);
|
||||||
|
|
||||||
|
const documentsContent = (
|
||||||
|
<>
|
||||||
|
<div className="shrink-0 p-4 pb-10">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{isMobile && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 rounded-full"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="sr-only">{tSidebar("close") || "Close"}</span>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<h2 className="text-lg font-semibold">{t("title") || "Documents"}</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-h-0 overflow-x-hidden pt-0 flex flex-col">
|
||||||
|
<div className="px-4 pb-2">
|
||||||
|
<DocumentsFilters
|
||||||
|
typeCounts={realtimeTypeCounts}
|
||||||
|
onSearch={setSearch}
|
||||||
|
searchValue={search}
|
||||||
|
onToggleType={onToggleType}
|
||||||
|
activeTypes={activeTypes}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DocumentsTableShell
|
||||||
|
documents={displayDocs}
|
||||||
|
loading={!!loading}
|
||||||
|
error={!!error}
|
||||||
|
sortKey={sortKey}
|
||||||
|
sortDesc={sortDesc}
|
||||||
|
onSortChange={handleSortChange}
|
||||||
|
deleteDocument={handleDeleteDocument}
|
||||||
|
searchSpaceId={String(searchSpaceId)}
|
||||||
|
hasMore={hasMore}
|
||||||
|
loadingMore={loadingMore}
|
||||||
|
onLoadMore={onLoadMore}
|
||||||
|
mentionedDocIds={mentionedDocIds}
|
||||||
|
onToggleChatMention={handleToggleChatMention}
|
||||||
|
onEditNavigate={() => onOpenChange(false)}
|
||||||
|
isSearchMode={isSearchMode || activeTypes.length > 0}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarSlideOutPanel
|
||||||
|
open={open}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
ariaLabel={t("title") || "Documents"}
|
||||||
|
width={isMobile ? undefined : 480}
|
||||||
|
>
|
||||||
|
{documentsContent}
|
||||||
|
</SidebarSlideOutPanel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -22,6 +22,7 @@ import {
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { getDocumentTypeLabel } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentTypeIcon";
|
||||||
import { setCommentsCollapsedAtom, setTargetCommentIdAtom } from "@/atoms/chat/current-thread.atom";
|
import { setCommentsCollapsedAtom, setTargetCommentIdAtom } from "@/atoms/chat/current-thread.atom";
|
||||||
import { convertRenderedToDisplay } from "@/components/chat-comments/comment-item/comment-item";
|
import { convertRenderedToDisplay } from "@/components/chat-comments/comment-item/comment-item";
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
|
|
@ -49,6 +50,7 @@ import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||||
import {
|
import {
|
||||||
isCommentReplyMetadata,
|
isCommentReplyMetadata,
|
||||||
isConnectorIndexingMetadata,
|
isConnectorIndexingMetadata,
|
||||||
|
isDocumentProcessingMetadata,
|
||||||
isNewMentionMetadata,
|
isNewMentionMetadata,
|
||||||
isPageLimitExceededMetadata,
|
isPageLimitExceededMetadata,
|
||||||
} from "@/contracts/types/inbox.types";
|
} from "@/contracts/types/inbox.types";
|
||||||
|
|
@ -60,9 +62,6 @@ import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { SidebarSlideOutPanel } from "./SidebarSlideOutPanel";
|
import { SidebarSlideOutPanel } from "./SidebarSlideOutPanel";
|
||||||
|
|
||||||
/**
|
|
||||||
* Get initials from name or email for avatar fallback
|
|
||||||
*/
|
|
||||||
function getInitials(name: string | null | undefined, email: string | null | undefined): string {
|
function getInitials(name: string | null | undefined, email: string | null | undefined): string {
|
||||||
if (name) {
|
if (name) {
|
||||||
return name
|
return name
|
||||||
|
|
@ -79,9 +78,6 @@ function getInitials(name: string | null | undefined, email: string | null | und
|
||||||
return "U";
|
return "U";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Format count for display: shows numbers up to 999, then "1k+", "2k+", etc.
|
|
||||||
*/
|
|
||||||
function formatInboxCount(count: number): string {
|
function formatInboxCount(count: number): string {
|
||||||
if (count <= 999) {
|
if (count <= 999) {
|
||||||
return count.toString();
|
return count.toString();
|
||||||
|
|
@ -90,9 +86,6 @@ function formatInboxCount(count: number): string {
|
||||||
return `${thousands}k+`;
|
return `${thousands}k+`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get display name for connector type
|
|
||||||
*/
|
|
||||||
function getConnectorTypeDisplayName(connectorType: string): string {
|
function getConnectorTypeDisplayName(connectorType: string): string {
|
||||||
const displayNames: Record<string, string> = {
|
const displayNames: Record<string, string> = {
|
||||||
GITHUB_CONNECTOR: "GitHub",
|
GITHUB_CONNECTOR: "GitHub",
|
||||||
|
|
@ -135,44 +128,36 @@ function getConnectorTypeDisplayName(connectorType: string): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
type InboxTab = "comments" | "status";
|
type InboxTab = "comments" | "status";
|
||||||
type InboxFilter = "all" | "unread";
|
type InboxFilter = "all" | "unread" | "errors";
|
||||||
|
|
||||||
// Tab-specific data source with independent pagination
|
|
||||||
interface TabDataSource {
|
interface TabDataSource {
|
||||||
items: InboxItem[];
|
items: InboxItem[];
|
||||||
unreadCount: number;
|
unreadCount: number;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
loadingMore?: boolean;
|
loadingMore: boolean;
|
||||||
hasMore?: boolean;
|
hasMore: boolean;
|
||||||
loadMore?: () => void;
|
loadMore: () => void;
|
||||||
|
markAsRead: (id: number) => Promise<boolean>;
|
||||||
|
markAllAsRead: () => Promise<boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface InboxSidebarProps {
|
interface InboxSidebarProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
/** Mentions tab data source with independent pagination */
|
comments: TabDataSource;
|
||||||
mentions: TabDataSource;
|
|
||||||
/** Status tab data source with independent pagination */
|
|
||||||
status: TabDataSource;
|
status: TabDataSource;
|
||||||
/** Combined unread count for mark all as read */
|
|
||||||
totalUnreadCount: number;
|
totalUnreadCount: number;
|
||||||
markAsRead: (id: number) => Promise<boolean>;
|
|
||||||
markAllAsRead: () => Promise<boolean>;
|
|
||||||
onCloseMobileSidebar?: () => void;
|
onCloseMobileSidebar?: () => void;
|
||||||
/** Whether the inbox is docked (permanent) or floating */
|
|
||||||
isDocked?: boolean;
|
isDocked?: boolean;
|
||||||
/** Callback to toggle docked state */
|
|
||||||
onDockedChange?: (docked: boolean) => void;
|
onDockedChange?: (docked: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function InboxSidebar({
|
export function InboxSidebar({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
mentions,
|
comments,
|
||||||
status,
|
status,
|
||||||
totalUnreadCount,
|
totalUnreadCount,
|
||||||
markAsRead,
|
|
||||||
markAllAsRead,
|
|
||||||
onCloseMobileSidebar,
|
onCloseMobileSidebar,
|
||||||
isDocked = false,
|
isDocked = false,
|
||||||
onDockedChange,
|
onDockedChange,
|
||||||
|
|
@ -183,9 +168,7 @@ export function InboxSidebar({
|
||||||
const isMobile = !useMediaQuery("(min-width: 640px)");
|
const isMobile = !useMediaQuery("(min-width: 640px)");
|
||||||
const searchSpaceId = params?.search_space_id ? Number(params.search_space_id) : null;
|
const searchSpaceId = params?.search_space_id ? Number(params.search_space_id) : null;
|
||||||
|
|
||||||
// Comments collapsed state (desktop only, when docked)
|
|
||||||
const [, setCommentsCollapsed] = useAtom(setCommentsCollapsedAtom);
|
const [, setCommentsCollapsed] = useAtom(setCommentsCollapsedAtom);
|
||||||
// Target comment for navigation - also ensures comments panel is visible
|
|
||||||
const [, setTargetCommentId] = useAtom(setTargetCommentIdAtom);
|
const [, setTargetCommentId] = useAtom(setTargetCommentIdAtom);
|
||||||
|
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
|
@ -193,11 +176,9 @@ export function InboxSidebar({
|
||||||
const isSearchMode = !!debouncedSearch.trim();
|
const isSearchMode = !!debouncedSearch.trim();
|
||||||
const [activeTab, setActiveTab] = useState<InboxTab>("comments");
|
const [activeTab, setActiveTab] = useState<InboxTab>("comments");
|
||||||
const [activeFilter, setActiveFilter] = useState<InboxFilter>("all");
|
const [activeFilter, setActiveFilter] = useState<InboxFilter>("all");
|
||||||
const [selectedConnector, setSelectedConnector] = useState<string | null>(null);
|
const [selectedSource, setSelectedSource] = useState<string | null>(null);
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
// Dropdown state for filter menu (desktop only)
|
|
||||||
const [openDropdown, setOpenDropdown] = useState<"filter" | null>(null);
|
const [openDropdown, setOpenDropdown] = useState<"filter" | null>(null);
|
||||||
// Scroll shadow state for connector list
|
|
||||||
const [connectorScrollPos, setConnectorScrollPos] = useState<"top" | "middle" | "bottom">("top");
|
const [connectorScrollPos, setConnectorScrollPos] = useState<"top" | "middle" | "bottom">("top");
|
||||||
const handleConnectorScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
|
const handleConnectorScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
|
||||||
const el = e.currentTarget;
|
const el = e.currentTarget;
|
||||||
|
|
@ -205,15 +186,12 @@ export function InboxSidebar({
|
||||||
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2;
|
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2;
|
||||||
setConnectorScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle");
|
setConnectorScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle");
|
||||||
}, []);
|
}, []);
|
||||||
// Drawer state for filter menu (mobile only)
|
|
||||||
const [filterDrawerOpen, setFilterDrawerOpen] = useState(false);
|
const [filterDrawerOpen, setFilterDrawerOpen] = useState(false);
|
||||||
const [markingAsReadId, setMarkingAsReadId] = useState<number | null>(null);
|
const [markingAsReadId, setMarkingAsReadId] = useState<number | null>(null);
|
||||||
|
|
||||||
// Prefetch trigger ref - placed on item near the end
|
|
||||||
const prefetchTriggerRef = useRef<HTMLDivElement>(null);
|
const prefetchTriggerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Server-side search query (enabled only when user is typing a search)
|
// Server-side search query
|
||||||
// Determines which notification types to search based on active tab
|
|
||||||
const searchTypeFilter = activeTab === "comments" ? ("new_mention" as const) : undefined;
|
const searchTypeFilter = activeTab === "comments" ? ("new_mention" as const) : undefined;
|
||||||
const { data: searchResponse, isLoading: isSearchLoading } = useQuery({
|
const { data: searchResponse, isLoading: isSearchLoading } = useQuery({
|
||||||
queryKey: cacheKeys.notifications.search(searchSpaceId, debouncedSearch.trim(), activeTab),
|
queryKey: cacheKeys.notifications.search(searchSpaceId, debouncedSearch.trim(), activeTab),
|
||||||
|
|
@ -226,7 +204,7 @@ export function InboxSidebar({
|
||||||
limit: 50,
|
limit: 50,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
staleTime: 30 * 1000, // 30 seconds (search results don't need to be super fresh)
|
staleTime: 30 * 1000,
|
||||||
enabled: isSearchMode && open,
|
enabled: isSearchMode && open,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -244,129 +222,128 @@ export function InboxSidebar({
|
||||||
return () => document.removeEventListener("keydown", handleEscape);
|
return () => document.removeEventListener("keydown", handleEscape);
|
||||||
}, [open, onOpenChange]);
|
}, [open, onOpenChange]);
|
||||||
|
|
||||||
// Only lock body scroll on mobile when inbox is open
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || !isMobile) return;
|
if (!open || !isMobile) return;
|
||||||
|
|
||||||
// Store original overflow to restore on cleanup
|
|
||||||
const originalOverflow = document.body.style.overflow;
|
const originalOverflow = document.body.style.overflow;
|
||||||
document.body.style.overflow = "hidden";
|
document.body.style.overflow = "hidden";
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
document.body.style.overflow = originalOverflow;
|
document.body.style.overflow = originalOverflow;
|
||||||
};
|
};
|
||||||
}, [open, isMobile]);
|
}, [open, isMobile]);
|
||||||
|
|
||||||
// Reset connector filter when switching away from status tab
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeTab !== "status") {
|
if (activeTab !== "status") {
|
||||||
setSelectedConnector(null);
|
setSelectedSource(null);
|
||||||
}
|
}
|
||||||
}, [activeTab]);
|
}, [activeTab]);
|
||||||
|
|
||||||
// Each tab uses its own data source for independent pagination
|
// Active tab's data source — fully independent loading, pagination, and counts
|
||||||
// Comments tab: uses mentions data source (fetches only mention/reply types from server)
|
const activeSource = activeTab === "comments" ? comments : status;
|
||||||
const commentsItems = mentions.items;
|
|
||||||
|
|
||||||
// Status tab: filters status data source (fetches all types) to status-specific types
|
// Fetch source types for the status tab filter
|
||||||
const statusItems = useMemo(
|
const { data: sourceTypesData } = useQuery({
|
||||||
() =>
|
queryKey: cacheKeys.notifications.sourceTypes(searchSpaceId),
|
||||||
status.items.filter(
|
queryFn: () => notificationsApiService.getSourceTypes(searchSpaceId ?? undefined),
|
||||||
(item) =>
|
staleTime: 60 * 1000,
|
||||||
item.type === "connector_indexing" ||
|
enabled: open && activeTab === "status",
|
||||||
item.type === "document_processing" ||
|
});
|
||||||
item.type === "page_limit_exceeded" ||
|
|
||||||
item.type === "connector_deletion"
|
const statusSourceOptions = useMemo(() => {
|
||||||
),
|
if (!sourceTypesData?.sources) return [];
|
||||||
[status.items]
|
|
||||||
|
return sourceTypesData.sources.map((source) => ({
|
||||||
|
key: source.key,
|
||||||
|
type: source.type,
|
||||||
|
category: source.category,
|
||||||
|
displayName:
|
||||||
|
source.category === "connector"
|
||||||
|
? getConnectorTypeDisplayName(source.type)
|
||||||
|
: getDocumentTypeLabel(source.type),
|
||||||
|
}));
|
||||||
|
}, [sourceTypesData]);
|
||||||
|
|
||||||
|
// Client-side filter: source type
|
||||||
|
const matchesSourceFilter = useCallback(
|
||||||
|
(item: InboxItem): boolean => {
|
||||||
|
if (!selectedSource) return true;
|
||||||
|
if (selectedSource.startsWith("connector:")) {
|
||||||
|
const connectorType = selectedSource.slice("connector:".length);
|
||||||
|
return (
|
||||||
|
item.type === "connector_indexing" &&
|
||||||
|
isConnectorIndexingMetadata(item.metadata) &&
|
||||||
|
item.metadata.connector_type === connectorType
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (selectedSource.startsWith("doctype:")) {
|
||||||
|
const docType = selectedSource.slice("doctype:".length);
|
||||||
|
return (
|
||||||
|
item.type === "document_processing" &&
|
||||||
|
isDocumentProcessingMetadata(item.metadata) &&
|
||||||
|
item.metadata.document_type === docType
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
[selectedSource]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Pagination switches based on active tab
|
// Client-side filter: unread / errors
|
||||||
const loading = activeTab === "comments" ? mentions.loading : status.loading;
|
const matchesActiveFilter = useCallback(
|
||||||
const loadingMore =
|
(item: InboxItem): boolean => {
|
||||||
activeTab === "comments" ? (mentions.loadingMore ?? false) : (status.loadingMore ?? false);
|
if (activeFilter === "unread") return !item.read;
|
||||||
const hasMore =
|
if (activeFilter === "errors") {
|
||||||
activeTab === "comments" ? (mentions.hasMore ?? false) : (status.hasMore ?? false);
|
if (item.type === "page_limit_exceeded") return true;
|
||||||
const loadMore = activeTab === "comments" ? mentions.loadMore : status.loadMore;
|
const meta = item.metadata as Record<string, unknown> | undefined;
|
||||||
|
return typeof meta?.status === "string" && meta.status === "failed";
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
[activeFilter]
|
||||||
|
);
|
||||||
|
|
||||||
// Get unique connector types from status items for filtering
|
// Two data paths: search mode (API) or default (per-tab data source)
|
||||||
const uniqueConnectorTypes = useMemo(() => {
|
|
||||||
const connectorTypes = new Set<string>();
|
|
||||||
|
|
||||||
statusItems
|
|
||||||
.filter((item) => item.type === "connector_indexing")
|
|
||||||
.forEach((item) => {
|
|
||||||
// Use type guard for safe metadata access
|
|
||||||
if (isConnectorIndexingMetadata(item.metadata)) {
|
|
||||||
connectorTypes.add(item.metadata.connector_type);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return Array.from(connectorTypes).map((type) => ({
|
|
||||||
type,
|
|
||||||
displayName: getConnectorTypeDisplayName(type),
|
|
||||||
}));
|
|
||||||
}, [statusItems]);
|
|
||||||
|
|
||||||
// Get items for current tab
|
|
||||||
const displayItems = activeTab === "comments" ? commentsItems : statusItems;
|
|
||||||
|
|
||||||
// Filter items based on filter type, connector filter, and search mode
|
|
||||||
// When searching: use server-side API results (searches ALL notifications)
|
|
||||||
// When not searching: use Electric real-time items (fast, local)
|
|
||||||
const filteredItems = useMemo(() => {
|
const filteredItems = useMemo(() => {
|
||||||
// In search mode, use API results
|
let tabItems: InboxItem[];
|
||||||
let items: InboxItem[] = isSearchMode ? (searchResponse?.items ?? []) : displayItems;
|
|
||||||
|
|
||||||
// For status tab search results, filter to status-specific types
|
if (isSearchMode) {
|
||||||
if (isSearchMode && activeTab === "status") {
|
tabItems = searchResponse?.items ?? [];
|
||||||
items = items.filter(
|
} else {
|
||||||
(item) =>
|
tabItems = activeSource.items;
|
||||||
item.type === "connector_indexing" ||
|
|
||||||
item.type === "document_processing" ||
|
|
||||||
item.type === "page_limit_exceeded" ||
|
|
||||||
item.type === "connector_deletion"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply read/unread filter
|
let result = tabItems;
|
||||||
if (activeFilter === "unread") {
|
if (activeFilter !== "all") {
|
||||||
items = items.filter((item) => !item.read);
|
result = result.filter(matchesActiveFilter);
|
||||||
|
}
|
||||||
|
if (activeTab === "status" && selectedSource) {
|
||||||
|
result = result.filter(matchesSourceFilter);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply connector filter (only for status tab)
|
return result;
|
||||||
if (activeTab === "status" && selectedConnector) {
|
}, [
|
||||||
items = items.filter((item) => {
|
isSearchMode,
|
||||||
if (item.type === "connector_indexing") {
|
searchResponse,
|
||||||
// Use type guard for safe metadata access
|
activeSource.items,
|
||||||
if (isConnectorIndexingMetadata(item.metadata)) {
|
activeTab,
|
||||||
return item.metadata.connector_type === selectedConnector;
|
activeFilter,
|
||||||
}
|
selectedSource,
|
||||||
return false;
|
matchesActiveFilter,
|
||||||
}
|
matchesSourceFilter,
|
||||||
return false; // Hide document_processing when a specific connector is selected
|
]);
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return items;
|
// Infinite scroll — uses active tab's pagination
|
||||||
}, [displayItems, searchResponse, isSearchMode, activeFilter, activeTab, selectedConnector]);
|
|
||||||
|
|
||||||
// Intersection Observer for infinite scroll with prefetching
|
|
||||||
// Re-runs when active tab changes so each tab gets its own pagination
|
|
||||||
// Disabled during server-side search (search results are not paginated via infinite scroll)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!loadMore || !hasMore || loadingMore || !open || isSearchMode) return;
|
if (!activeSource.hasMore || activeSource.loadingMore || !open || isSearchMode) return;
|
||||||
|
|
||||||
const observer = new IntersectionObserver(
|
const observer = new IntersectionObserver(
|
||||||
(entries) => {
|
(entries) => {
|
||||||
// When trigger element is visible, load more
|
|
||||||
if (entries[0]?.isIntersecting) {
|
if (entries[0]?.isIntersecting) {
|
||||||
loadMore();
|
activeSource.loadMore();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
root: null, // viewport
|
root: null,
|
||||||
rootMargin: "100px", // Start loading 100px before visible
|
rootMargin: "100px",
|
||||||
threshold: 0,
|
threshold: 0,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
@ -376,17 +353,13 @@ export function InboxSidebar({
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, [loadMore, hasMore, loadingMore, open, isSearchMode, activeTab]);
|
}, [activeSource.hasMore, activeSource.loadingMore, activeSource.loadMore, open, isSearchMode]);
|
||||||
|
|
||||||
// Unread counts from server-side accurate totals (passed via props)
|
|
||||||
const unreadCommentsCount = mentions.unreadCount;
|
|
||||||
const unreadStatusCount = status.unreadCount;
|
|
||||||
|
|
||||||
const handleItemClick = useCallback(
|
const handleItemClick = useCallback(
|
||||||
async (item: InboxItem) => {
|
async (item: InboxItem) => {
|
||||||
if (!item.read) {
|
if (!item.read) {
|
||||||
setMarkingAsReadId(item.id);
|
setMarkingAsReadId(item.id);
|
||||||
await markAsRead(item.id);
|
await activeSource.markAsRead(item.id);
|
||||||
setMarkingAsReadId(null);
|
setMarkingAsReadId(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -427,7 +400,6 @@ export function InboxSidebar({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (item.type === "page_limit_exceeded") {
|
} else if (item.type === "page_limit_exceeded") {
|
||||||
// Navigate to the upgrade/more-pages page
|
|
||||||
if (isPageLimitExceededMetadata(item.metadata)) {
|
if (isPageLimitExceededMetadata(item.metadata)) {
|
||||||
const actionUrl = item.metadata.action_url;
|
const actionUrl = item.metadata.action_url;
|
||||||
if (actionUrl) {
|
if (actionUrl) {
|
||||||
|
|
@ -438,12 +410,12 @@ export function InboxSidebar({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[markAsRead, router, onOpenChange, onCloseMobileSidebar, setTargetCommentId]
|
[activeSource.markAsRead, router, onOpenChange, onCloseMobileSidebar, setTargetCommentId]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleMarkAllAsRead = useCallback(async () => {
|
const handleMarkAllAsRead = useCallback(async () => {
|
||||||
await markAllAsRead();
|
await Promise.all([comments.markAllAsRead(), status.markAllAsRead()]);
|
||||||
}, [markAllAsRead]);
|
}, [comments.markAllAsRead, status.markAllAsRead]);
|
||||||
|
|
||||||
const handleClearSearch = useCallback(() => {
|
const handleClearSearch = useCallback(() => {
|
||||||
setSearchQuery("");
|
setSearchQuery("");
|
||||||
|
|
@ -469,7 +441,6 @@ export function InboxSidebar({
|
||||||
};
|
};
|
||||||
|
|
||||||
const getStatusIcon = (item: InboxItem) => {
|
const getStatusIcon = (item: InboxItem) => {
|
||||||
// For mentions and comment replies, show the author's avatar
|
|
||||||
if (item.type === "new_mention" || item.type === "comment_reply") {
|
if (item.type === "new_mention" || item.type === "comment_reply") {
|
||||||
const metadata =
|
const metadata =
|
||||||
item.type === "new_mention"
|
item.type === "new_mention"
|
||||||
|
|
@ -501,7 +472,6 @@ export function InboxSidebar({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// For page limit exceeded, show a warning icon with amber/orange color
|
|
||||||
if (item.type === "page_limit_exceeded") {
|
if (item.type === "page_limit_exceeded") {
|
||||||
return (
|
return (
|
||||||
<div className="h-8 w-8 flex items-center justify-center rounded-full bg-amber-500/10">
|
<div className="h-8 w-8 flex items-center justify-center rounded-full bg-amber-500/10">
|
||||||
|
|
@ -510,8 +480,6 @@ export function InboxSidebar({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// For status items (connector/document), show status icons
|
|
||||||
// Safely access status from metadata
|
|
||||||
const metadata = item.metadata as Record<string, unknown>;
|
const metadata = item.metadata as Record<string, unknown>;
|
||||||
const status = typeof metadata?.status === "string" ? metadata.status : undefined;
|
const status = typeof metadata?.status === "string" ? metadata.status : undefined;
|
||||||
|
|
||||||
|
|
@ -558,13 +526,13 @@ export function InboxSidebar({
|
||||||
|
|
||||||
if (!mounted) return null;
|
if (!mounted) return null;
|
||||||
|
|
||||||
// Shared content component for both docked and floating modes
|
const isLoading = isSearchMode ? isSearchLoading : activeSource.loading;
|
||||||
|
|
||||||
const inboxContent = (
|
const inboxContent = (
|
||||||
<>
|
<>
|
||||||
<div className="shrink-0 p-4 pb-2 space-y-3">
|
<div className="shrink-0 p-4 pb-2 space-y-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{/* Back button - mobile only */}
|
|
||||||
{isMobile && (
|
{isMobile && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|
@ -579,7 +547,6 @@ export function InboxSidebar({
|
||||||
<h2 className="text-lg font-semibold">{t("inbox") || "Inbox"}</h2>
|
<h2 className="text-lg font-semibold">{t("inbox") || "Inbox"}</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{/* Mobile: Button that opens bottom drawer */}
|
|
||||||
{isMobile ? (
|
{isMobile ? (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -605,7 +572,6 @@ export function InboxSidebar({
|
||||||
</DrawerTitle>
|
</DrawerTitle>
|
||||||
</DrawerHeader>
|
</DrawerHeader>
|
||||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||||
{/* Filter section */}
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<p className="text-xs text-muted-foreground/80 font-medium px-1">
|
<p className="text-xs text-muted-foreground/80 font-medium px-1">
|
||||||
{t("filter") || "Filter"}
|
{t("filter") || "Filter"}
|
||||||
|
|
@ -649,56 +615,74 @@ export function InboxSidebar({
|
||||||
</span>
|
</span>
|
||||||
{activeFilter === "unread" && <Check className="h-4 w-4" />}
|
{activeFilter === "unread" && <Check className="h-4 w-4" />}
|
||||||
</button>
|
</button>
|
||||||
|
{activeTab === "status" && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setActiveFilter("errors");
|
||||||
|
setFilterDrawerOpen(false);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-sm transition-colors",
|
||||||
|
activeFilter === "errors"
|
||||||
|
? "bg-primary/10 text-primary"
|
||||||
|
: "hover:bg-muted"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<span>{t("errors_only") || "Errors only"}</span>
|
||||||
|
</span>
|
||||||
|
{activeFilter === "errors" && <Check className="h-4 w-4" />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Connectors section - only for status tab */}
|
{activeTab === "status" && statusSourceOptions.length > 0 && (
|
||||||
{activeTab === "status" && uniqueConnectorTypes.length > 0 && (
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<p className="text-xs text-muted-foreground/80 font-medium px-1">
|
<p className="text-xs text-muted-foreground/80 font-medium px-1">
|
||||||
{t("connectors") || "Connectors"}
|
{t("sources") || "Sources"}
|
||||||
</p>
|
</p>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedConnector(null);
|
setSelectedSource(null);
|
||||||
setFilterDrawerOpen(false);
|
setFilterDrawerOpen(false);
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-sm transition-colors",
|
"flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-sm transition-colors",
|
||||||
selectedConnector === null
|
selectedSource === null
|
||||||
? "bg-primary/10 text-primary"
|
? "bg-primary/10 text-primary"
|
||||||
: "hover:bg-muted"
|
: "hover:bg-muted"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<LayoutGrid className="h-4 w-4" />
|
<LayoutGrid className="h-4 w-4" />
|
||||||
<span>{t("all_connectors") || "All connectors"}</span>
|
<span>{t("all_sources") || "All sources"}</span>
|
||||||
</span>
|
</span>
|
||||||
{selectedConnector === null && <Check className="h-4 w-4" />}
|
{selectedSource === null && <Check className="h-4 w-4" />}
|
||||||
</button>
|
</button>
|
||||||
{uniqueConnectorTypes.map((connector) => (
|
{statusSourceOptions.map((source) => (
|
||||||
<button
|
<button
|
||||||
key={connector.type}
|
key={source.key}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedConnector(connector.type);
|
setSelectedSource(source.key);
|
||||||
setFilterDrawerOpen(false);
|
setFilterDrawerOpen(false);
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-sm transition-colors",
|
"flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-sm transition-colors",
|
||||||
selectedConnector === connector.type
|
selectedSource === source.key
|
||||||
? "bg-primary/10 text-primary"
|
? "bg-primary/10 text-primary"
|
||||||
: "hover:bg-muted"
|
: "hover:bg-muted"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
{getConnectorIcon(connector.type, "h-4 w-4")}
|
{getConnectorIcon(source.type, "h-4 w-4")}
|
||||||
<span>{connector.displayName}</span>
|
<span>{source.displayName}</span>
|
||||||
</span>
|
</span>
|
||||||
{selectedConnector === connector.type && (
|
{selectedSource === source.key && <Check className="h-4 w-4" />}
|
||||||
<Check className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -709,7 +693,6 @@ export function InboxSidebar({
|
||||||
</Drawer>
|
</Drawer>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
/* Desktop: Dropdown menu */
|
|
||||||
<DropdownMenu
|
<DropdownMenu
|
||||||
open={openDropdown === "filter"}
|
open={openDropdown === "filter"}
|
||||||
onOpenChange={(isOpen) => setOpenDropdown(isOpen ? "filter" : null)}
|
onOpenChange={(isOpen) => setOpenDropdown(isOpen ? "filter" : null)}
|
||||||
|
|
@ -727,7 +710,10 @@ export function InboxSidebar({
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<DropdownMenuContent
|
<DropdownMenuContent
|
||||||
align="end"
|
align="end"
|
||||||
className={cn("z-80 select-none", activeTab === "status" ? "w-52" : "w-44")}
|
className={cn(
|
||||||
|
"z-80 select-none max-h-[60vh] overflow-hidden flex flex-col",
|
||||||
|
activeTab === "status" ? "w-52" : "w-44"
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<DropdownMenuLabel className="text-xs text-muted-foreground/80 font-normal">
|
<DropdownMenuLabel className="text-xs text-muted-foreground/80 font-normal">
|
||||||
{t("filter") || "Filter"}
|
{t("filter") || "Filter"}
|
||||||
|
|
@ -752,13 +738,25 @@ export function InboxSidebar({
|
||||||
</span>
|
</span>
|
||||||
{activeFilter === "unread" && <Check className="h-4 w-4" />}
|
{activeFilter === "unread" && <Check className="h-4 w-4" />}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
{activeTab === "status" && uniqueConnectorTypes.length > 0 && (
|
{activeTab === "status" && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => setActiveFilter("errors")}
|
||||||
|
className="flex items-center justify-between"
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<span>{t("errors_only") || "Errors only"}</span>
|
||||||
|
</span>
|
||||||
|
{activeFilter === "errors" && <Check className="h-4 w-4" />}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
{activeTab === "status" && statusSourceOptions.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<DropdownMenuLabel className="text-xs text-muted-foreground/80 font-normal mt-2">
|
<DropdownMenuLabel className="text-xs text-muted-foreground/80 font-normal mt-2">
|
||||||
{t("connectors") || "Connectors"}
|
{t("sources") || "Sources"}
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
<div
|
<div
|
||||||
className="relative max-h-[30vh] overflow-y-auto -mb-1"
|
className="relative max-h-[30vh] overflow-y-auto overflow-x-hidden -mb-1"
|
||||||
onScroll={handleConnectorScroll}
|
onScroll={handleConnectorScroll}
|
||||||
style={{
|
style={{
|
||||||
maskImage: `linear-gradient(to bottom, ${connectorScrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${connectorScrollPos === "bottom" ? "black" : "transparent"})`,
|
maskImage: `linear-gradient(to bottom, ${connectorScrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${connectorScrollPos === "bottom" ? "black" : "transparent"})`,
|
||||||
|
|
@ -766,26 +764,26 @@ export function InboxSidebar({
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => setSelectedConnector(null)}
|
onClick={() => setSelectedSource(null)}
|
||||||
className="flex items-center justify-between"
|
className="flex items-center justify-between"
|
||||||
>
|
>
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<LayoutGrid className="h-4 w-4" />
|
<LayoutGrid className="h-4 w-4" />
|
||||||
<span>{t("all_connectors") || "All connectors"}</span>
|
<span>{t("all_sources") || "All sources"}</span>
|
||||||
</span>
|
</span>
|
||||||
{selectedConnector === null && <Check className="h-4 w-4" />}
|
{selectedSource === null && <Check className="h-4 w-4" />}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
{uniqueConnectorTypes.map((connector) => (
|
{statusSourceOptions.map((source) => (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
key={connector.type}
|
key={source.key}
|
||||||
onClick={() => setSelectedConnector(connector.type)}
|
onClick={() => setSelectedSource(source.key)}
|
||||||
className="flex items-center justify-between"
|
className="flex items-center justify-between"
|
||||||
>
|
>
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
{getConnectorIcon(connector.type, "h-4 w-4")}
|
{getConnectorIcon(source.type, "h-4 w-4")}
|
||||||
<span>{connector.displayName}</span>
|
<span>{source.displayName}</span>
|
||||||
</span>
|
</span>
|
||||||
{selectedConnector === connector.type && <Check className="h-4 w-4" />}
|
{selectedSource === source.key && <Check className="h-4 w-4" />}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -824,7 +822,6 @@ export function InboxSidebar({
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
{/* Dock/Undock button - desktop only */}
|
|
||||||
{!isMobile && onDockedChange && (
|
{!isMobile && onDockedChange && (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
|
|
@ -834,12 +831,10 @@ export function InboxSidebar({
|
||||||
className="h-8 w-8 rounded-full"
|
className="h-8 w-8 rounded-full"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (isDocked) {
|
if (isDocked) {
|
||||||
// Collapse: show comments immediately, then close inbox
|
|
||||||
setCommentsCollapsed(false);
|
setCommentsCollapsed(false);
|
||||||
onDockedChange(false);
|
onDockedChange(false);
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
} else {
|
} else {
|
||||||
// Expand: hide comments immediately
|
|
||||||
setCommentsCollapsed(true);
|
setCommentsCollapsed(true);
|
||||||
onDockedChange(true);
|
onDockedChange(true);
|
||||||
}
|
}
|
||||||
|
|
@ -886,7 +881,13 @@ export function InboxSidebar({
|
||||||
|
|
||||||
<Tabs
|
<Tabs
|
||||||
value={activeTab}
|
value={activeTab}
|
||||||
onValueChange={(value) => setActiveTab(value as InboxTab)}
|
onValueChange={(value) => {
|
||||||
|
const tab = value as InboxTab;
|
||||||
|
setActiveTab(tab);
|
||||||
|
if (tab !== "status" && activeFilter === "errors") {
|
||||||
|
setActiveFilter("all");
|
||||||
|
}
|
||||||
|
}}
|
||||||
className="shrink-0 mx-4"
|
className="shrink-0 mx-4"
|
||||||
>
|
>
|
||||||
<TabsList className="w-full h-auto p-0 bg-transparent rounded-none border-b">
|
<TabsList className="w-full h-auto p-0 bg-transparent rounded-none border-b">
|
||||||
|
|
@ -898,7 +899,7 @@ export function InboxSidebar({
|
||||||
<MessageSquare className="h-4 w-4" />
|
<MessageSquare className="h-4 w-4" />
|
||||||
<span>{t("comments") || "Comments"}</span>
|
<span>{t("comments") || "Comments"}</span>
|
||||||
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-primary/20 text-muted-foreground text-xs font-medium">
|
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-primary/20 text-muted-foreground text-xs font-medium">
|
||||||
{formatInboxCount(unreadCommentsCount)}
|
{formatInboxCount(comments.unreadCount)}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
|
@ -910,7 +911,7 @@ export function InboxSidebar({
|
||||||
<History className="h-4 w-4" />
|
<History className="h-4 w-4" />
|
||||||
<span>{t("status") || "Status"}</span>
|
<span>{t("status") || "Status"}</span>
|
||||||
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-primary/20 text-muted-foreground text-xs font-medium">
|
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-primary/20 text-muted-foreground text-xs font-medium">
|
||||||
{formatInboxCount(unreadStatusCount)}
|
{formatInboxCount(status.unreadCount)}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
|
@ -918,11 +919,10 @@ export function InboxSidebar({
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto overflow-x-hidden p-2">
|
<div className="flex-1 overflow-y-auto overflow-x-hidden p-2">
|
||||||
{(isSearchMode ? isSearchLoading : loading) ? (
|
{isLoading ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{activeTab === "comments"
|
{activeTab === "comments"
|
||||||
? /* Comments skeleton: avatar + two-line text + time */
|
? [85, 60, 90, 70, 50, 75].map((titleWidth, i) => (
|
||||||
[85, 60, 90, 70, 50, 75].map((titleWidth, i) => (
|
|
||||||
<div
|
<div
|
||||||
key={`skeleton-comment-${i}`}
|
key={`skeleton-comment-${i}`}
|
||||||
className="flex items-center gap-3 rounded-lg px-3 py-3 h-[80px]"
|
className="flex items-center gap-3 rounded-lg px-3 py-3 h-[80px]"
|
||||||
|
|
@ -935,8 +935,7 @@ export function InboxSidebar({
|
||||||
<Skeleton className="h-3 w-6 shrink-0 rounded" />
|
<Skeleton className="h-3 w-6 shrink-0 rounded" />
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
: /* Status skeleton: status icon circle + two-line text + time */
|
: [75, 90, 55, 80, 65, 85].map((titleWidth, i) => (
|
||||||
[75, 90, 55, 80, 65, 85].map((titleWidth, i) => (
|
|
||||||
<div
|
<div
|
||||||
key={`skeleton-status-${i}`}
|
key={`skeleton-status-${i}`}
|
||||||
className="flex items-center gap-3 rounded-lg px-3 py-3 h-[80px]"
|
className="flex items-center gap-3 rounded-lg px-3 py-3 h-[80px]"
|
||||||
|
|
@ -957,9 +956,8 @@ export function InboxSidebar({
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{filteredItems.map((item, index) => {
|
{filteredItems.map((item, index) => {
|
||||||
const isMarkingAsRead = markingAsReadId === item.id;
|
const isMarkingAsRead = markingAsReadId === item.id;
|
||||||
// Place prefetch trigger on 5th item from end (only when not searching)
|
|
||||||
const isPrefetchTrigger =
|
const isPrefetchTrigger =
|
||||||
!isSearchMode && hasMore && index === filteredItems.length - 5;
|
!isSearchMode && activeSource.hasMore && index === filteredItems.length - 5;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -1028,7 +1026,6 @@ export function InboxSidebar({
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Time and unread dot - fixed width to prevent content shift */}
|
|
||||||
<div className="flex items-center justify-end gap-1.5 shrink-0 w-10">
|
<div className="flex items-center justify-end gap-1.5 shrink-0 w-10">
|
||||||
<span className="text-[10px] text-muted-foreground">
|
<span className="text-[10px] text-muted-foreground">
|
||||||
{formatTime(item.created_at)}
|
{formatTime(item.created_at)}
|
||||||
|
|
@ -1038,12 +1035,10 @@ export function InboxSidebar({
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{/* Fallback trigger at the very end if less than 5 items and not searching */}
|
{!isSearchMode && filteredItems.length < 5 && activeSource.hasMore && (
|
||||||
{!isSearchMode && filteredItems.length < 5 && hasMore && (
|
|
||||||
<div ref={prefetchTriggerRef} className="h-1" />
|
<div ref={prefetchTriggerRef} className="h-1" />
|
||||||
)}
|
)}
|
||||||
{/* Loading more skeletons at the bottom during infinite scroll */}
|
{activeSource.loadingMore &&
|
||||||
{loadingMore &&
|
|
||||||
(activeTab === "comments"
|
(activeTab === "comments"
|
||||||
? [80, 60, 90].map((titleWidth, i) => (
|
? [80, 60, 90].map((titleWidth, i) => (
|
||||||
<div
|
<div
|
||||||
|
|
@ -1100,11 +1095,10 @@ export function InboxSidebar({
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
// DOCKED MODE: Render as a static flex child (no animation, no click-away)
|
|
||||||
if (isDocked && open && !isMobile) {
|
if (isDocked && open && !isMobile) {
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
className="h-full w-[360px] shrink-0 bg-background flex flex-col border-r"
|
className="h-full w-[360px] shrink-0 bg-sidebar text-sidebar-foreground flex flex-col border-r"
|
||||||
aria-label={t("inbox") || "Inbox"}
|
aria-label={t("inbox") || "Inbox"}
|
||||||
>
|
>
|
||||||
{inboxContent}
|
{inboxContent}
|
||||||
|
|
@ -1112,7 +1106,6 @@ export function InboxSidebar({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// FLOATING MODE: Render with animation and click-away layer
|
|
||||||
return (
|
return (
|
||||||
<SidebarSlideOutPanel open={open} onOpenChange={onOpenChange} ariaLabel={t("inbox") || "Inbox"}>
|
<SidebarSlideOutPanel open={open} onOpenChange={onOpenChange} ariaLabel={t("inbox") || "Inbox"}>
|
||||||
{inboxContent}
|
{inboxContent}
|
||||||
|
|
|
||||||
|
|
@ -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,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { CheckCircle2, CircleAlert } from "lucide-react";
|
||||||
|
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";
|
||||||
import type { NavItem } from "../../types/layout.types";
|
import type { NavItem } from "../../types/layout.types";
|
||||||
|
|
@ -10,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" }
|
||||||
|
|
@ -39,11 +95,13 @@ export function NavSection({ items, onItemClick, isCollapsed = false }: NavSecti
|
||||||
{...joyrideAttr}
|
{...joyrideAttr}
|
||||||
>
|
>
|
||||||
<Icon className="h-4 w-4" />
|
<Icon className="h-4 w-4" />
|
||||||
{item.badge && (
|
{indicator && indicator !== "idle" ? (
|
||||||
|
<StatusBadge status={indicator} />
|
||||||
|
) : item.badge ? (
|
||||||
<span className="absolute top-0.5 right-0.5 inline-flex items-center justify-center min-w-[14px] h-[14px] px-0.5 rounded-full bg-red-500 text-white text-[9px] font-medium">
|
<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}
|
||||||
</span>
|
</span>
|
||||||
)}
|
) : null}
|
||||||
<span className="sr-only">{item.title}</span>
|
<span className="sr-only">{item.title}</span>
|
||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
|
|
@ -67,7 +125,7 @@ export function NavSection({ items, onItemClick, isCollapsed = false }: NavSecti
|
||||||
)}
|
)}
|
||||||
{...joyrideAttr}
|
{...joyrideAttr}
|
||||||
>
|
>
|
||||||
<Icon className="h-4 w-4 shrink-0" />
|
<StatusIcon status={indicator} FallbackIcon={Icon} className="h-4 w-4" />
|
||||||
<span className="flex-1 truncate">{item.title}</span>
|
<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">
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import { PanelLeft, PanelLeftClose } from "lucide-react";
|
import { PanelLeft, PanelLeftClose } from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { ShortcutKbd } from "@/components/ui/shortcut-kbd";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { usePlatformShortcut } from "@/hooks/use-platform-shortcut";
|
import { usePlatformShortcut } from "@/hooks/use-platform-shortcut";
|
||||||
|
|
||||||
|
|
@ -18,7 +19,7 @@ export function SidebarCollapseButton({
|
||||||
disableTooltip = false,
|
disableTooltip = false,
|
||||||
}: SidebarCollapseButtonProps) {
|
}: SidebarCollapseButtonProps) {
|
||||||
const t = useTranslations("sidebar");
|
const t = useTranslations("sidebar");
|
||||||
const { shortcut } = usePlatformShortcut();
|
const { shortcutKeys } = usePlatformShortcut();
|
||||||
|
|
||||||
const button = (
|
const button = (
|
||||||
<Button variant="ghost" size="icon" onClick={onToggle} className="h-8 w-8 shrink-0">
|
<Button variant="ghost" size="icon" onClick={onToggle} className="h-8 w-8 shrink-0">
|
||||||
|
|
@ -35,9 +36,10 @@ export function SidebarCollapseButton({
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||||
<TooltipContent side={isCollapsed ? "right" : "bottom"}>
|
<TooltipContent side={isCollapsed ? "right" : "bottom"}>
|
||||||
{isCollapsed
|
<span className="flex items-center">
|
||||||
? `${t("expand_sidebar")} ${shortcut("Mod", "\\")}`
|
{isCollapsed ? t("expand_sidebar") : t("collapse_sidebar")}
|
||||||
: `${t("collapse_sidebar")} ${shortcut("Mod", "\\")}`}
|
<ShortcutKbd keys={shortcutKeys("Mod", "\\")} />
|
||||||
|
</span>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
@ -56,7 +55,6 @@ export function SidebarHeader({
|
||||||
<UserPen className="h-4 w-4" />
|
<UserPen className="h-4 w-4" />
|
||||||
{t("manage_members")}
|
{t("manage_members")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem onClick={onSettings}>
|
<DropdownMenuItem onClick={onSettings}>
|
||||||
<Settings className="h-4 w-4" />
|
<Settings className="h-4 w-4" />
|
||||||
{t("search_space_settings")}
|
{t("search_space_settings")}
|
||||||
|
|
|
||||||
|
|
@ -65,7 +65,7 @@ export function SidebarSlideOutPanel({
|
||||||
exit={{ x: "-100%" }}
|
exit={{ x: "-100%" }}
|
||||||
transition={{ type: "tween", duration: 0.2, ease: [0.4, 0, 0.2, 1] }}
|
transition={{ type: "tween", duration: 0.2, ease: [0.4, 0, 0.2, 1] }}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-full w-full bg-background flex flex-col pointer-events-auto select-none",
|
"h-full w-full bg-sidebar text-sidebar-foreground flex flex-col pointer-events-auto select-none",
|
||||||
"sm:border-r sm:shadow-xl"
|
"sm:border-r sm:shadow-xl"
|
||||||
)}
|
)}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
@ -188,7 +203,7 @@ export function SidebarUserProfile({
|
||||||
</div>
|
</div>
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
|
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator className="dark:bg-neutral-700" />
|
||||||
|
|
||||||
<DropdownMenuItem onClick={onUserSettings}>
|
<DropdownMenuItem onClick={onUserSettings}>
|
||||||
<Settings className="h-4 w-4" />
|
<Settings className="h-4 w-4" />
|
||||||
|
|
@ -256,7 +271,30 @@ export function SidebarUserProfile({
|
||||||
</DropdownMenuPortal>
|
</DropdownMenuPortal>
|
||||||
</DropdownMenuSub>
|
</DropdownMenuSub>
|
||||||
|
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSub>
|
||||||
|
<DropdownMenuSubTrigger>
|
||||||
|
<Info className="h-4 w-4" />
|
||||||
|
{t("learn_more")}
|
||||||
|
</DropdownMenuSubTrigger>
|
||||||
|
<DropdownMenuPortal>
|
||||||
|
<DropdownMenuSubContent className="min-w-[180px] gap-1">
|
||||||
|
{LEARN_MORE_LINKS.map((link) => (
|
||||||
|
<DropdownMenuItem key={link.key} asChild>
|
||||||
|
<a href={link.href} target="_blank" rel="noopener noreferrer">
|
||||||
|
<span className="flex-1">{t(link.key)}</span>
|
||||||
|
<ExternalLink className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||||
|
</a>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
<DropdownMenuSeparator className="dark:bg-neutral-700" />
|
||||||
|
<p className="select-none px-2 py-1.5 text-xs text-muted-foreground/50">
|
||||||
|
v{APP_VERSION}
|
||||||
|
</p>
|
||||||
|
</DropdownMenuSubContent>
|
||||||
|
</DropdownMenuPortal>
|
||||||
|
</DropdownMenuSub>
|
||||||
|
|
||||||
|
<DropdownMenuSeparator className="dark:bg-neutral-700" />
|
||||||
|
|
||||||
<DropdownMenuItem onClick={handleLogout} disabled={isLoggingOut}>
|
<DropdownMenuItem onClick={handleLogout} disabled={isLoggingOut}>
|
||||||
{isLoggingOut ? (
|
{isLoggingOut ? (
|
||||||
|
|
@ -310,7 +348,7 @@ export function SidebarUserProfile({
|
||||||
</div>
|
</div>
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
|
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator className="dark:bg-neutral-700" />
|
||||||
|
|
||||||
<DropdownMenuItem onClick={onUserSettings}>
|
<DropdownMenuItem onClick={onUserSettings}>
|
||||||
<Settings className="h-4 w-4" />
|
<Settings className="h-4 w-4" />
|
||||||
|
|
@ -378,7 +416,30 @@ export function SidebarUserProfile({
|
||||||
</DropdownMenuPortal>
|
</DropdownMenuPortal>
|
||||||
</DropdownMenuSub>
|
</DropdownMenuSub>
|
||||||
|
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSub>
|
||||||
|
<DropdownMenuSubTrigger>
|
||||||
|
<Info className="h-4 w-4" />
|
||||||
|
{t("learn_more")}
|
||||||
|
</DropdownMenuSubTrigger>
|
||||||
|
<DropdownMenuPortal>
|
||||||
|
<DropdownMenuSubContent className="min-w-[180px] gap-1">
|
||||||
|
{LEARN_MORE_LINKS.map((link) => (
|
||||||
|
<DropdownMenuItem key={link.key} asChild>
|
||||||
|
<a href={link.href} target="_blank" rel="noopener noreferrer">
|
||||||
|
<span className="flex-1">{t(link.key)}</span>
|
||||||
|
<ExternalLink className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||||
|
</a>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
<DropdownMenuSeparator className="dark:bg-neutral-700" />
|
||||||
|
<p className="select-none px-2 py-1.5 text-xs text-muted-foreground/50">
|
||||||
|
v{APP_VERSION}
|
||||||
|
</p>
|
||||||
|
</DropdownMenuSubContent>
|
||||||
|
</DropdownMenuPortal>
|
||||||
|
</DropdownMenuSub>
|
||||||
|
|
||||||
|
<DropdownMenuSeparator className="dark:bg-neutral-700" />
|
||||||
|
|
||||||
<DropdownMenuItem onClick={handleLogout} disabled={isLoggingOut}>
|
<DropdownMenuItem onClick={handleLogout} disabled={isLoggingOut}>
|
||||||
{isLoggingOut ? <Spinner size="sm" className="mr-2" /> : <LogOut className="h-4 w-4" />}
|
{isLoggingOut ? <Spinner size="sm" className="mr-2" /> : <LogOut className="h-4 w-4" />}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ export { AllPrivateChatsSidebar } from "./AllPrivateChatsSidebar";
|
||||||
export { AllSharedChatsSidebar } from "./AllSharedChatsSidebar";
|
export { AllSharedChatsSidebar } from "./AllSharedChatsSidebar";
|
||||||
export { AnnouncementsSidebar } from "./AnnouncementsSidebar";
|
export { AnnouncementsSidebar } from "./AnnouncementsSidebar";
|
||||||
export { ChatListItem } from "./ChatListItem";
|
export { ChatListItem } from "./ChatListItem";
|
||||||
|
export { DocumentsSidebar } from "./DocumentsSidebar";
|
||||||
export { InboxSidebar } from "./InboxSidebar";
|
export { InboxSidebar } from "./InboxSidebar";
|
||||||
export { MobileSidebar, MobileSidebarTrigger } from "./MobileSidebar";
|
export { MobileSidebar, MobileSidebarTrigger } from "./MobileSidebar";
|
||||||
export { NavSection } from "./NavSection";
|
export { NavSection } from "./NavSection";
|
||||||
|
|
|
||||||
|
|
@ -7,38 +7,39 @@ import type {
|
||||||
ImageGenerationConfig,
|
ImageGenerationConfig,
|
||||||
NewLLMConfigPublic,
|
NewLLMConfigPublic,
|
||||||
} from "@/contracts/types/new-llm-config.types";
|
} from "@/contracts/types/new-llm-config.types";
|
||||||
import { ImageConfigSidebar } from "./image-config-sidebar";
|
import { ImageConfigDialog } from "./image-config-dialog";
|
||||||
import { ModelConfigSidebar } from "./model-config-sidebar";
|
import { ModelConfigDialog } from "./model-config-dialog";
|
||||||
import { ModelSelector } from "./model-selector";
|
import { ModelSelector } from "./model-selector";
|
||||||
|
|
||||||
interface ChatHeaderProps {
|
interface ChatHeaderProps {
|
||||||
searchSpaceId: number;
|
searchSpaceId: number;
|
||||||
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatHeader({ searchSpaceId }: ChatHeaderProps) {
|
export function ChatHeader({ searchSpaceId, className }: ChatHeaderProps) {
|
||||||
// LLM config sidebar state
|
// LLM config dialog state
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
const [selectedConfig, setSelectedConfig] = useState<
|
const [selectedConfig, setSelectedConfig] = useState<
|
||||||
NewLLMConfigPublic | GlobalNewLLMConfig | null
|
NewLLMConfigPublic | GlobalNewLLMConfig | null
|
||||||
>(null);
|
>(null);
|
||||||
const [isGlobal, setIsGlobal] = useState(false);
|
const [isGlobal, setIsGlobal] = useState(false);
|
||||||
const [sidebarMode, setSidebarMode] = useState<"create" | "edit" | "view">("view");
|
const [dialogMode, setDialogMode] = useState<"create" | "edit" | "view">("view");
|
||||||
|
|
||||||
// Image config sidebar state
|
// Image config dialog state
|
||||||
const [imageSidebarOpen, setImageSidebarOpen] = useState(false);
|
const [imageDialogOpen, setImageDialogOpen] = useState(false);
|
||||||
const [selectedImageConfig, setSelectedImageConfig] = useState<
|
const [selectedImageConfig, setSelectedImageConfig] = useState<
|
||||||
ImageGenerationConfig | GlobalImageGenConfig | null
|
ImageGenerationConfig | GlobalImageGenConfig | null
|
||||||
>(null);
|
>(null);
|
||||||
const [isImageGlobal, setIsImageGlobal] = useState(false);
|
const [isImageGlobal, setIsImageGlobal] = useState(false);
|
||||||
const [imageSidebarMode, setImageSidebarMode] = useState<"create" | "edit" | "view">("view");
|
const [imageDialogMode, setImageDialogMode] = useState<"create" | "edit" | "view">("view");
|
||||||
|
|
||||||
// LLM handlers
|
// LLM handlers
|
||||||
const handleEditLLMConfig = useCallback(
|
const handleEditLLMConfig = useCallback(
|
||||||
(config: NewLLMConfigPublic | GlobalNewLLMConfig, global: boolean) => {
|
(config: NewLLMConfigPublic | GlobalNewLLMConfig, global: boolean) => {
|
||||||
setSelectedConfig(config);
|
setSelectedConfig(config);
|
||||||
setIsGlobal(global);
|
setIsGlobal(global);
|
||||||
setSidebarMode(global ? "view" : "edit");
|
setDialogMode(global ? "view" : "edit");
|
||||||
setSidebarOpen(true);
|
setDialogOpen(true);
|
||||||
},
|
},
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
@ -46,12 +47,12 @@ export function ChatHeader({ searchSpaceId }: ChatHeaderProps) {
|
||||||
const handleAddNewLLM = useCallback(() => {
|
const handleAddNewLLM = useCallback(() => {
|
||||||
setSelectedConfig(null);
|
setSelectedConfig(null);
|
||||||
setIsGlobal(false);
|
setIsGlobal(false);
|
||||||
setSidebarMode("create");
|
setDialogMode("create");
|
||||||
setSidebarOpen(true);
|
setDialogOpen(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSidebarClose = useCallback((open: boolean) => {
|
const handleDialogClose = useCallback((open: boolean) => {
|
||||||
setSidebarOpen(open);
|
setDialogOpen(open);
|
||||||
if (!open) setSelectedConfig(null);
|
if (!open) setSelectedConfig(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
@ -59,22 +60,22 @@ export function ChatHeader({ searchSpaceId }: ChatHeaderProps) {
|
||||||
const handleAddImageModel = useCallback(() => {
|
const handleAddImageModel = useCallback(() => {
|
||||||
setSelectedImageConfig(null);
|
setSelectedImageConfig(null);
|
||||||
setIsImageGlobal(false);
|
setIsImageGlobal(false);
|
||||||
setImageSidebarMode("create");
|
setImageDialogMode("create");
|
||||||
setImageSidebarOpen(true);
|
setImageDialogOpen(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleEditImageConfig = useCallback(
|
const handleEditImageConfig = useCallback(
|
||||||
(config: ImageGenerationConfig | GlobalImageGenConfig, global: boolean) => {
|
(config: ImageGenerationConfig | GlobalImageGenConfig, global: boolean) => {
|
||||||
setSelectedImageConfig(config);
|
setSelectedImageConfig(config);
|
||||||
setIsImageGlobal(global);
|
setIsImageGlobal(global);
|
||||||
setImageSidebarMode(global ? "view" : "edit");
|
setImageDialogMode(global ? "view" : "edit");
|
||||||
setImageSidebarOpen(true);
|
setImageDialogOpen(true);
|
||||||
},
|
},
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleImageSidebarClose = useCallback((open: boolean) => {
|
const handleImageDialogClose = useCallback((open: boolean) => {
|
||||||
setImageSidebarOpen(open);
|
setImageDialogOpen(open);
|
||||||
if (!open) setSelectedImageConfig(null);
|
if (!open) setSelectedImageConfig(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
@ -85,22 +86,23 @@ export function ChatHeader({ searchSpaceId }: ChatHeaderProps) {
|
||||||
onAddNewLLM={handleAddNewLLM}
|
onAddNewLLM={handleAddNewLLM}
|
||||||
onEditImage={handleEditImageConfig}
|
onEditImage={handleEditImageConfig}
|
||||||
onAddNewImage={handleAddImageModel}
|
onAddNewImage={handleAddImageModel}
|
||||||
|
className={className}
|
||||||
/>
|
/>
|
||||||
<ModelConfigSidebar
|
<ModelConfigDialog
|
||||||
open={sidebarOpen}
|
open={dialogOpen}
|
||||||
onOpenChange={handleSidebarClose}
|
onOpenChange={handleDialogClose}
|
||||||
config={selectedConfig}
|
config={selectedConfig}
|
||||||
isGlobal={isGlobal}
|
isGlobal={isGlobal}
|
||||||
searchSpaceId={searchSpaceId}
|
searchSpaceId={searchSpaceId}
|
||||||
mode={sidebarMode}
|
mode={dialogMode}
|
||||||
/>
|
/>
|
||||||
<ImageConfigSidebar
|
<ImageConfigDialog
|
||||||
open={imageSidebarOpen}
|
open={imageDialogOpen}
|
||||||
onOpenChange={handleImageSidebarClose}
|
onOpenChange={handleImageDialogClose}
|
||||||
config={selectedImageConfig}
|
config={selectedImageConfig}
|
||||||
isGlobal={isImageGlobal}
|
isGlobal={isImageGlobal}
|
||||||
searchSpaceId={searchSpaceId}
|
searchSpaceId={searchSpaceId}
|
||||||
mode={imageSidebarMode}
|
mode={imageDialogMode}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -72,12 +72,15 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
|
||||||
// Query to check if thread has public snapshots
|
// Query to check if thread has public snapshots
|
||||||
const { data: snapshotsData } = useQuery({
|
const { data: snapshotsData } = useQuery({
|
||||||
queryKey: ["thread-snapshots", thread?.id],
|
queryKey: ["thread-snapshots", thread?.id],
|
||||||
queryFn: () => chatThreadsApiService.listPublicChatSnapshots({ thread_id: thread!.id }),
|
queryFn: () => {
|
||||||
|
const id = thread?.id;
|
||||||
|
if (id == null) throw new Error("Missing thread id");
|
||||||
|
return chatThreadsApiService.listPublicChatSnapshots({ thread_id: id });
|
||||||
|
},
|
||||||
enabled: !!thread?.id,
|
enabled: !!thread?.id,
|
||||||
staleTime: 30000, // Cache for 30 seconds
|
staleTime: 30000, // Cache for 30 seconds
|
||||||
});
|
});
|
||||||
const hasPublicSnapshots = (snapshotsData?.snapshots?.length ?? 0) > 0;
|
const hasPublicSnapshots = (snapshotsData?.snapshots?.length ?? 0) > 0;
|
||||||
const snapshotCount = snapshotsData?.snapshots?.length ?? 0;
|
|
||||||
|
|
||||||
// Use Jotai visibility if available (synced from chat page), otherwise fall back to thread prop
|
// Use Jotai visibility if available (synced from chat page), otherwise fall back to thread prop
|
||||||
const currentVisibility = currentThreadState.visibility ?? thread?.visibility ?? "PRIVATE";
|
const currentVisibility = currentThreadState.visibility ?? thread?.visibility ?? "PRIVATE";
|
||||||
|
|
@ -145,18 +148,14 @@ 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"
|
||||||
>
|
>
|
||||||
<Earth className="h-4 w-4 text-muted-foreground" />
|
<Earth className="h-4 w-4 text-muted-foreground" />
|
||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>Manage public links</TooltipContent>
|
||||||
{snapshotCount === 1
|
|
||||||
? "This chat has a public link"
|
|
||||||
: `This chat has ${snapshotCount} public links`}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -167,7 +166,7 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8 md:w-auto md:px-3 md:gap-2 relative bg-muted hover:bg-muted/80 border-0"
|
className="h-8 w-8 md:w-auto md:px-3 md:gap-2 relative bg-muted hover:bg-muted/80 border-0 select-none"
|
||||||
>
|
>
|
||||||
<CurrentIcon className="h-4 w-4" />
|
<CurrentIcon className="h-4 w-4" />
|
||||||
<span className="hidden md:inline text-sm">{buttonLabel}</span>
|
<span className="hidden md:inline text-sm">{buttonLabel}</span>
|
||||||
|
|
@ -178,12 +177,12 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<PopoverContent
|
<PopoverContent
|
||||||
className="w-[280px] md:w-[320px] p-0 rounded-lg shadow-lg border-border/60"
|
className="w-[280px] md:w-[320px] p-0 rounded-lg shadow-lg border-border/60 dark:bg-neutral-900 dark:border dark:border-white/5 select-none"
|
||||||
align="end"
|
align="end"
|
||||||
sideOffset={8}
|
sideOffset={8}
|
||||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||||
>
|
>
|
||||||
<div className="p-1.5 space-y-1 select-none">
|
<div className="p-1.5 space-y-1">
|
||||||
{/* Visibility Options */}
|
{/* Visibility Options */}
|
||||||
{visibilityOptions.map((option) => {
|
{visibilityOptions.map((option) => {
|
||||||
const isSelected = currentVisibility === option.value;
|
const isSelected = currentVisibility === option.value;
|
||||||
|
|
@ -196,27 +195,32 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
|
||||||
onClick={() => handleVisibilityChange(option.value)}
|
onClick={() => handleVisibilityChange(option.value)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full flex items-center gap-2.5 px-2.5 py-2 rounded-md transition-all",
|
"w-full flex items-center gap-2.5 px-2.5 py-2 rounded-md transition-all",
|
||||||
"hover:bg-accent/50 cursor-pointer",
|
"hover:bg-accent/50 dark:hover:bg-white/10 cursor-pointer",
|
||||||
"focus:outline-none",
|
"focus:outline-none",
|
||||||
isSelected && "bg-accent/80"
|
isSelected && "bg-accent/80 dark:bg-white/10"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"size-7 rounded-md shrink-0 grid place-items-center",
|
"size-7 rounded-md shrink-0 grid place-items-center",
|
||||||
isSelected ? "bg-primary/10" : "bg-muted"
|
isSelected ? "bg-primary/10 dark:bg-white/10" : "bg-muted dark:bg-white/5"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon
|
||||||
className={cn(
|
className={cn(
|
||||||
"size-4 block",
|
"size-4 block",
|
||||||
isSelected ? "text-primary" : "text-muted-foreground"
|
isSelected ? "text-primary dark:text-white" : "text-muted-foreground"
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 text-left min-w-0">
|
<div className="flex-1 text-left min-w-0">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className={cn("text-sm font-medium", isSelected && "text-primary")}>
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-sm font-medium",
|
||||||
|
isSelected && "text-primary dark:text-white"
|
||||||
|
)}
|
||||||
|
>
|
||||||
{option.label}
|
{option.label}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -231,7 +235,7 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
|
||||||
{canCreatePublicLink && (
|
{canCreatePublicLink && (
|
||||||
<>
|
<>
|
||||||
{/* Divider */}
|
{/* Divider */}
|
||||||
<div className="border-t border-border my-1" />
|
<div className="border-t border-border dark:border-white/5 my-1" />
|
||||||
|
|
||||||
{/* Public Link Option */}
|
{/* Public Link Option */}
|
||||||
<button
|
<button
|
||||||
|
|
@ -240,12 +244,12 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
|
||||||
disabled={isCreatingSnapshot}
|
disabled={isCreatingSnapshot}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full flex items-center gap-2.5 px-2.5 py-2 rounded-md transition-all",
|
"w-full flex items-center gap-2.5 px-2.5 py-2 rounded-md transition-all",
|
||||||
"hover:bg-accent/50 cursor-pointer",
|
"hover:bg-accent/50 dark:hover:bg-white/10 cursor-pointer",
|
||||||
"focus:outline-none",
|
"focus:outline-none",
|
||||||
"disabled:opacity-50 disabled:cursor-not-allowed"
|
"disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="size-7 rounded-md shrink-0 grid place-items-center bg-muted">
|
<div className="size-7 rounded-md shrink-0 grid place-items-center bg-muted dark:bg-white/5">
|
||||||
<Earth className="size-4 block text-muted-foreground" />
|
<Earth className="size-4 block text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 text-left min-w-0">
|
<div className="flex-1 text-left min-w-0">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,9 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import {
|
import { AlertCircle, Check, ChevronsUpDown, X } from "lucide-react";
|
||||||
AlertCircle,
|
|
||||||
Check,
|
|
||||||
ChevronsUpDown,
|
|
||||||
Globe,
|
|
||||||
ImageIcon,
|
|
||||||
Key,
|
|
||||||
Shuffle,
|
|
||||||
X,
|
|
||||||
Zap,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { AnimatePresence, motion } from "motion/react";
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
|
|
@ -48,10 +38,11 @@ import { IMAGE_GEN_MODELS, IMAGE_GEN_PROVIDERS } from "@/contracts/enums/image-g
|
||||||
import type {
|
import type {
|
||||||
GlobalImageGenConfig,
|
GlobalImageGenConfig,
|
||||||
ImageGenerationConfig,
|
ImageGenerationConfig,
|
||||||
|
ImageGenProvider,
|
||||||
} from "@/contracts/types/new-llm-config.types";
|
} from "@/contracts/types/new-llm-config.types";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface ImageConfigSidebarProps {
|
interface ImageConfigDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
config: ImageGenerationConfig | GlobalImageGenConfig | null;
|
config: ImageGenerationConfig | GlobalImageGenConfig | null;
|
||||||
|
|
@ -70,24 +61,25 @@ const INITIAL_FORM = {
|
||||||
api_version: "",
|
api_version: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ImageConfigSidebar({
|
export function ImageConfigDialog({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
config,
|
config,
|
||||||
isGlobal,
|
isGlobal,
|
||||||
searchSpaceId,
|
searchSpaceId,
|
||||||
mode,
|
mode,
|
||||||
}: ImageConfigSidebarProps) {
|
}: ImageConfigDialogProps) {
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
const [formData, setFormData] = useState(INITIAL_FORM);
|
const [formData, setFormData] = useState(INITIAL_FORM);
|
||||||
const [modelComboboxOpen, setModelComboboxOpen] = useState(false);
|
const [modelComboboxOpen, setModelComboboxOpen] = useState(false);
|
||||||
|
const [scrollPos, setScrollPos] = useState<"top" | "middle" | "bottom">("top");
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMounted(true);
|
setMounted(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Reset form when opening
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
if (mode === "edit" && config && !isGlobal) {
|
if (mode === "edit" && config && !isGlobal) {
|
||||||
|
|
@ -103,15 +95,14 @@ export function ImageConfigSidebar({
|
||||||
} else if (mode === "create") {
|
} else if (mode === "create") {
|
||||||
setFormData(INITIAL_FORM);
|
setFormData(INITIAL_FORM);
|
||||||
}
|
}
|
||||||
|
setScrollPos("top");
|
||||||
}
|
}
|
||||||
}, [open, mode, config, isGlobal]);
|
}, [open, mode, config, isGlobal]);
|
||||||
|
|
||||||
// Mutations
|
|
||||||
const { mutateAsync: createConfig } = useAtomValue(createImageGenConfigMutationAtom);
|
const { mutateAsync: createConfig } = useAtomValue(createImageGenConfigMutationAtom);
|
||||||
const { mutateAsync: updateConfig } = useAtomValue(updateImageGenConfigMutationAtom);
|
const { mutateAsync: updateConfig } = useAtomValue(updateImageGenConfigMutationAtom);
|
||||||
const { mutateAsync: updatePreferences } = useAtomValue(updateLLMPreferencesMutationAtom);
|
const { mutateAsync: updatePreferences } = useAtomValue(updateLLMPreferencesMutationAtom);
|
||||||
|
|
||||||
// Escape key
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleEscape = (e: KeyboardEvent) => {
|
const handleEscape = (e: KeyboardEvent) => {
|
||||||
if (e.key === "Escape" && open) onOpenChange(false);
|
if (e.key === "Escape" && open) onOpenChange(false);
|
||||||
|
|
@ -120,6 +111,13 @@ export function ImageConfigSidebar({
|
||||||
return () => window.removeEventListener("keydown", handleEscape);
|
return () => window.removeEventListener("keydown", handleEscape);
|
||||||
}, [open, onOpenChange]);
|
}, [open, onOpenChange]);
|
||||||
|
|
||||||
|
const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
|
||||||
|
const el = e.currentTarget;
|
||||||
|
const atTop = el.scrollTop <= 2;
|
||||||
|
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2;
|
||||||
|
setScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle");
|
||||||
|
}, []);
|
||||||
|
|
||||||
const isAutoMode = config && "is_auto_mode" in config && config.is_auto_mode;
|
const isAutoMode = config && "is_auto_mode" in config && config.is_auto_mode;
|
||||||
|
|
||||||
const suggestedModels = useMemo(() => {
|
const suggestedModels = useMemo(() => {
|
||||||
|
|
@ -134,13 +132,20 @@ export function ImageConfigSidebar({
|
||||||
return "Edit Image Model";
|
return "Edit Image Model";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getSubtitle = () => {
|
||||||
|
if (mode === "create") return "Set up a new image generation provider";
|
||||||
|
if (isAutoMode) return "Automatically routes requests across providers";
|
||||||
|
if (isGlobal) return "Read-only global configuration";
|
||||||
|
return "Update your image model settings";
|
||||||
|
};
|
||||||
|
|
||||||
const handleSubmit = useCallback(async () => {
|
const handleSubmit = useCallback(async () => {
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
try {
|
try {
|
||||||
if (mode === "create") {
|
if (mode === "create") {
|
||||||
const result = await createConfig({
|
const result = await createConfig({
|
||||||
name: formData.name,
|
name: formData.name,
|
||||||
provider: formData.provider,
|
provider: formData.provider as ImageGenProvider,
|
||||||
model_name: formData.model_name,
|
model_name: formData.model_name,
|
||||||
api_key: formData.api_key,
|
api_key: formData.api_key,
|
||||||
api_base: formData.api_base || undefined,
|
api_base: formData.api_base || undefined,
|
||||||
|
|
@ -148,7 +153,6 @@ export function ImageConfigSidebar({
|
||||||
description: formData.description || undefined,
|
description: formData.description || undefined,
|
||||||
search_space_id: searchSpaceId,
|
search_space_id: searchSpaceId,
|
||||||
});
|
});
|
||||||
// Set as active image model
|
|
||||||
if (result?.id) {
|
if (result?.id) {
|
||||||
await updatePreferences({
|
await updatePreferences({
|
||||||
search_space_id: searchSpaceId,
|
search_space_id: searchSpaceId,
|
||||||
|
|
@ -163,7 +167,7 @@ export function ImageConfigSidebar({
|
||||||
data: {
|
data: {
|
||||||
name: formData.name,
|
name: formData.name,
|
||||||
description: formData.description || undefined,
|
description: formData.description || undefined,
|
||||||
provider: formData.provider,
|
provider: formData.provider as ImageGenProvider,
|
||||||
model_name: formData.model_name,
|
model_name: formData.model_name,
|
||||||
api_key: formData.api_key,
|
api_key: formData.api_key,
|
||||||
api_base: formData.api_base || undefined,
|
api_base: formData.api_base || undefined,
|
||||||
|
|
@ -214,126 +218,96 @@ export function ImageConfigSidebar({
|
||||||
|
|
||||||
if (!mounted) return null;
|
if (!mounted) return null;
|
||||||
|
|
||||||
const sidebarContent = (
|
const dialogContent = (
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{open && (
|
{open && (
|
||||||
<>
|
<>
|
||||||
{/* Backdrop */}
|
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
transition={{ duration: 0.2 }}
|
transition={{ duration: 0.15 }}
|
||||||
className="fixed inset-0 z-50 bg-black/20 backdrop-blur-sm"
|
className="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm"
|
||||||
onClick={() => onOpenChange(false)}
|
onClick={() => onOpenChange(false)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Sidebar */}
|
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ x: "100%", opacity: 0 }}
|
initial={{ opacity: 0, scale: 0.96 }}
|
||||||
animate={{ x: 0, opacity: 1 }}
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
exit={{ x: "100%", opacity: 0 }}
|
exit={{ opacity: 0, scale: 0.96 }}
|
||||||
transition={{ type: "spring", damping: 30, stiffness: 300 }}
|
transition={{ duration: 0.15, ease: "easeOut" }}
|
||||||
className={cn(
|
className="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6"
|
||||||
"fixed right-0 top-0 z-50 h-full w-full sm:w-[480px] lg:w-[540px]",
|
|
||||||
"bg-background border-l border-border/50 shadow-2xl",
|
|
||||||
"flex flex-col"
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{/* Header */}
|
|
||||||
<div
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center justify-between px-6 py-4 border-b border-border/50",
|
"relative w-full max-w-lg h-[85vh]",
|
||||||
isAutoMode
|
"rounded-xl bg-background shadow-2xl",
|
||||||
? "bg-gradient-to-r from-violet-500/10 to-purple-500/10"
|
"dark:bg-neutral-900",
|
||||||
: "bg-gradient-to-r from-teal-500/10 to-cyan-500/10"
|
"flex flex-col overflow-hidden"
|
||||||
)}
|
)}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Escape") onOpenChange(false);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
{/* Header */}
|
||||||
<div
|
<div className="flex items-start justify-between px-6 pt-6 pb-4">
|
||||||
className={cn(
|
<div className="space-y-1 pr-8">
|
||||||
"flex items-center justify-center size-10 rounded-xl",
|
<div className="flex items-center gap-2">
|
||||||
isAutoMode
|
<h2 className="text-lg font-semibold tracking-tight">{getTitle()}</h2>
|
||||||
? "bg-gradient-to-br from-violet-500 to-purple-600"
|
{isAutoMode && (
|
||||||
: "bg-gradient-to-br from-teal-500 to-cyan-600"
|
<Badge variant="secondary" className="text-[10px]">
|
||||||
)}
|
|
||||||
>
|
|
||||||
{isAutoMode ? (
|
|
||||||
<Shuffle className="size-5 text-white" />
|
|
||||||
) : (
|
|
||||||
<ImageIcon className="size-5 text-white" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h2 className="text-base sm:text-lg font-semibold">{getTitle()}</h2>
|
|
||||||
<div className="flex items-center gap-2 mt-0.5">
|
|
||||||
{isAutoMode ? (
|
|
||||||
<Badge
|
|
||||||
variant="secondary"
|
|
||||||
className="gap-1 text-xs bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300"
|
|
||||||
>
|
|
||||||
<Zap className="size-3" />
|
|
||||||
Recommended
|
Recommended
|
||||||
</Badge>
|
</Badge>
|
||||||
) : isGlobal ? (
|
)}
|
||||||
<Badge variant="secondary" className="gap-1 text-xs">
|
{isGlobal && !isAutoMode && mode !== "create" && (
|
||||||
<Globe className="size-3" />
|
<Badge variant="secondary" className="text-[10px]">
|
||||||
Global
|
Global
|
||||||
</Badge>
|
</Badge>
|
||||||
) : null}
|
|
||||||
{config && !isAutoMode && (
|
|
||||||
<span className="text-xs text-muted-foreground">{config.model_name}</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">{getSubtitle()}</p>
|
||||||
|
{config && !isAutoMode && mode !== "create" && (
|
||||||
|
<p className="text-xs font-mono text-muted-foreground/70">
|
||||||
|
{config.model_name}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
className="absolute right-4 top-4 h-8 w-8 rounded-full text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => onOpenChange(false)}
|
|
||||||
className="h-8 w-8 rounded-full"
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
<span className="sr-only">Close</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content */}
|
{/* Scrollable content */}
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div
|
||||||
<div className="p-6">
|
ref={scrollRef}
|
||||||
{/* Auto mode */}
|
onScroll={handleScroll}
|
||||||
|
className="flex-1 overflow-y-auto px-6 py-5"
|
||||||
|
style={{
|
||||||
|
maskImage: `linear-gradient(to bottom, ${scrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${scrollPos === "bottom" ? "black" : "transparent"})`,
|
||||||
|
WebkitMaskImage: `linear-gradient(to bottom, ${scrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${scrollPos === "bottom" ? "black" : "transparent"})`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
{isAutoMode && (
|
{isAutoMode && (
|
||||||
<>
|
<Alert className="mb-5 border-violet-500/30 bg-violet-500/5">
|
||||||
<Alert className="mb-6 border-violet-500/30 bg-violet-500/5">
|
<AlertDescription className="text-sm text-violet-700 dark:text-violet-400">
|
||||||
<Shuffle className="size-4 text-violet-500" />
|
Auto mode distributes image generation requests across all configured
|
||||||
<AlertDescription className="text-sm text-violet-700 dark:text-violet-400">
|
providers for optimal performance and rate limit protection.
|
||||||
Auto mode distributes image generation requests across all configured
|
</AlertDescription>
|
||||||
providers for optimal performance and rate limit protection.
|
</Alert>
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
<div className="flex gap-3 pt-4 border-t border-border/50">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="flex-1"
|
|
||||||
onClick={() => onOpenChange(false)}
|
|
||||||
>
|
|
||||||
Close
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
className="flex-1 gap-2 bg-gradient-to-r from-violet-500 to-purple-600 hover:from-violet-600 hover:to-purple-700"
|
|
||||||
onClick={handleUseGlobalConfig}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
>
|
|
||||||
{isSubmitting ? "Loading..." : "Use Auto Mode"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Global config (read-only) */}
|
|
||||||
{isGlobal && !isAutoMode && config && (
|
{isGlobal && !isAutoMode && config && (
|
||||||
<>
|
<>
|
||||||
<Alert className="mb-6 border-amber-500/30 bg-amber-500/5">
|
<Alert className="mb-5 border-amber-500/30 bg-amber-500/5">
|
||||||
<AlertCircle className="size-4 text-amber-500" />
|
<AlertCircle className="size-4 text-amber-500" />
|
||||||
<AlertDescription className="text-sm text-amber-700 dark:text-amber-400">
|
<AlertDescription className="text-sm text-amber-700 dark:text-amber-400">
|
||||||
Global configurations are read-only. To customize, create a new model.
|
Global configurations are read-only. To customize, create a new model.
|
||||||
|
|
@ -372,29 +346,11 @@ export function ImageConfigSidebar({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3 pt-6 border-t border-border/50 mt-6">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="flex-1"
|
|
||||||
onClick={() => onOpenChange(false)}
|
|
||||||
>
|
|
||||||
Close
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
className="flex-1 gap-2"
|
|
||||||
onClick={handleUseGlobalConfig}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
>
|
|
||||||
{isSubmitting ? "Loading..." : "Use This Model"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Create / Edit form */}
|
|
||||||
{(mode === "create" || (mode === "edit" && !isGlobal)) && (
|
{(mode === "create" || (mode === "edit" && !isGlobal)) && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Name */}
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-sm font-medium">Name *</Label>
|
<Label className="text-sm font-medium">Name *</Label>
|
||||||
<Input
|
<Input
|
||||||
|
|
@ -404,7 +360,6 @@ export function ImageConfigSidebar({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Description */}
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-sm font-medium">Description</Label>
|
<Label className="text-sm font-medium">Description</Label>
|
||||||
<Input
|
<Input
|
||||||
|
|
@ -418,7 +373,6 @@ export function ImageConfigSidebar({
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
{/* Provider */}
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-sm font-medium">Provider *</Label>
|
<Label className="text-sm font-medium">Provider *</Label>
|
||||||
<Select
|
<Select
|
||||||
|
|
@ -430,20 +384,16 @@ export function ImageConfigSidebar({
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Select a provider" />
|
<SelectValue placeholder="Select a provider" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent className="bg-muted dark:border-neutral-700">
|
||||||
{IMAGE_GEN_PROVIDERS.map((p) => (
|
{IMAGE_GEN_PROVIDERS.map((p) => (
|
||||||
<SelectItem key={p.value} value={p.value}>
|
<SelectItem key={p.value} value={p.value} description={p.example}>
|
||||||
<div className="flex flex-col">
|
{p.label}
|
||||||
<span className="font-medium">{p.label}</span>
|
|
||||||
<span className="text-xs text-muted-foreground">{p.example}</span>
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Model Name */}
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-sm font-medium">Model Name *</Label>
|
<Label className="text-sm font-medium">Model Name *</Label>
|
||||||
{suggestedModels.length > 0 ? (
|
{suggestedModels.length > 0 ? (
|
||||||
|
|
@ -452,14 +402,17 @@ export function ImageConfigSidebar({
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
role="combobox"
|
role="combobox"
|
||||||
className="w-full justify-between font-normal"
|
className="w-full justify-between font-normal bg-transparent"
|
||||||
>
|
>
|
||||||
{formData.model_name || "Select or type a model..."}
|
{formData.model_name || "Select or type a model..."}
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-full p-0" align="start">
|
<PopoverContent
|
||||||
<Command>
|
className="w-full p-0 bg-muted dark:border-neutral-700"
|
||||||
|
align="start"
|
||||||
|
>
|
||||||
|
<Command className="bg-transparent">
|
||||||
<CommandInput
|
<CommandInput
|
||||||
placeholder="Search or type model..."
|
placeholder="Search or type model..."
|
||||||
value={formData.model_name}
|
value={formData.model_name}
|
||||||
|
|
@ -513,11 +466,8 @@ export function ImageConfigSidebar({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* API Key */}
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-sm font-medium flex items-center gap-1.5">
|
<Label className="text-sm font-medium">API Key *</Label>
|
||||||
<Key className="h-3.5 w-3.5" /> API Key *
|
|
||||||
</Label>
|
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="sk-..."
|
placeholder="sk-..."
|
||||||
|
|
@ -526,7 +476,6 @@ export function ImageConfigSidebar({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* API Base */}
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-sm font-medium">API Base URL</Label>
|
<Label className="text-sm font-medium">API Base URL</Label>
|
||||||
<Input
|
<Input
|
||||||
|
|
@ -536,7 +485,6 @@ export function ImageConfigSidebar({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Azure API Version */}
|
|
||||||
{formData.provider === "AZURE_OPENAI" && (
|
{formData.provider === "AZURE_OPENAI" && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-sm font-medium">API Version (Azure)</Label>
|
<Label className="text-sm font-medium">API Version (Azure)</Label>
|
||||||
|
|
@ -549,28 +497,56 @@ export function ImageConfigSidebar({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<div className="flex gap-3 pt-4 border-t">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="flex-1"
|
|
||||||
onClick={() => onOpenChange(false)}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
className="flex-1 gap-2 bg-gradient-to-r from-teal-500 to-cyan-600 hover:from-teal-600 hover:to-cyan-700"
|
|
||||||
onClick={handleSubmit}
|
|
||||||
disabled={isSubmitting || !isFormValid}
|
|
||||||
>
|
|
||||||
{isSubmitting ? <Spinner size="sm" className="mr-2" /> : null}
|
|
||||||
{mode === "edit" ? "Save Changes" : "Create & Use"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Fixed footer */}
|
||||||
|
<div className="shrink-0 px-6 py-4 flex items-center justify-end gap-3">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="text-sm h-9"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
{mode === "create" || (mode === "edit" && !isGlobal) ? (
|
||||||
|
<Button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={isSubmitting || !isFormValid}
|
||||||
|
className="text-sm h-9 min-w-[120px]"
|
||||||
|
>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<>
|
||||||
|
<Spinner size="sm" />
|
||||||
|
{mode === "edit" ? "Saving" : "Creating"}
|
||||||
|
</>
|
||||||
|
) : mode === "edit" ? (
|
||||||
|
"Save Changes"
|
||||||
|
) : (
|
||||||
|
"Create & Use"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
) : isAutoMode ? (
|
||||||
|
<Button
|
||||||
|
className="text-sm h-9 gap-2 bg-gradient-to-r from-violet-500 to-purple-600 hover:from-violet-600 hover:to-purple-700"
|
||||||
|
onClick={handleUseGlobalConfig}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
{isSubmitting ? "Loading..." : "Use Auto Mode"}
|
||||||
|
</Button>
|
||||||
|
) : isGlobal && config ? (
|
||||||
|
<Button
|
||||||
|
className="text-sm h-9 gap-2"
|
||||||
|
onClick={handleUseGlobalConfig}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
{isSubmitting ? "Loading..." : "Use This Model"}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</>
|
</>
|
||||||
|
|
@ -578,5 +554,5 @@ export function ImageConfigSidebar({
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
);
|
);
|
||||||
|
|
||||||
return typeof document !== "undefined" ? createPortal(sidebarContent, document.body) : null;
|
return typeof document !== "undefined" ? createPortal(dialogContent, document.body) : null;
|
||||||
}
|
}
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { AlertCircle, Bot, ChevronRight, Globe, Shuffle, User, X, Zap } from "lucide-react";
|
import { AlertCircle, X, Zap } from "lucide-react";
|
||||||
import { AnimatePresence, motion } from "motion/react";
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
|
|
@ -15,13 +15,15 @@ import { LLMConfigForm, type LLMConfigFormData } from "@/components/shared/llm-c
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import type {
|
import type {
|
||||||
GlobalNewLLMConfig,
|
GlobalNewLLMConfig,
|
||||||
|
LiteLLMProvider,
|
||||||
NewLLMConfigPublic,
|
NewLLMConfigPublic,
|
||||||
} from "@/contracts/types/new-llm-config.types";
|
} from "@/contracts/types/new-llm-config.types";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface ModelConfigSidebarProps {
|
interface ModelConfigDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
config: NewLLMConfigPublic | GlobalNewLLMConfig | null;
|
config: NewLLMConfigPublic | GlobalNewLLMConfig | null;
|
||||||
|
|
@ -30,28 +32,34 @@ interface ModelConfigSidebarProps {
|
||||||
mode: "create" | "edit" | "view";
|
mode: "create" | "edit" | "view";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ModelConfigSidebar({
|
export function ModelConfigDialog({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
config,
|
config,
|
||||||
isGlobal,
|
isGlobal,
|
||||||
searchSpaceId,
|
searchSpaceId,
|
||||||
mode,
|
mode,
|
||||||
}: ModelConfigSidebarProps) {
|
}: ModelConfigDialogProps) {
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
|
const [scrollPos, setScrollPos] = useState<"top" | "middle" | "bottom">("top");
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Handle SSR - only render portal on client
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMounted(true);
|
setMounted(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Mutations - use mutateAsync from the atom value
|
const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
|
||||||
|
const el = e.currentTarget;
|
||||||
|
const atTop = el.scrollTop <= 2;
|
||||||
|
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2;
|
||||||
|
setScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle");
|
||||||
|
}, []);
|
||||||
|
|
||||||
const { mutateAsync: createConfig } = useAtomValue(createNewLLMConfigMutationAtom);
|
const { mutateAsync: createConfig } = useAtomValue(createNewLLMConfigMutationAtom);
|
||||||
const { mutateAsync: updateConfig } = useAtomValue(updateNewLLMConfigMutationAtom);
|
const { mutateAsync: updateConfig } = useAtomValue(updateNewLLMConfigMutationAtom);
|
||||||
const { mutateAsync: updatePreferences } = useAtomValue(updateLLMPreferencesMutationAtom);
|
const { mutateAsync: updatePreferences } = useAtomValue(updateLLMPreferencesMutationAtom);
|
||||||
|
|
||||||
// Handle escape key
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleEscape = (e: KeyboardEvent) => {
|
const handleEscape = (e: KeyboardEvent) => {
|
||||||
if (e.key === "Escape" && open) {
|
if (e.key === "Escape" && open) {
|
||||||
|
|
@ -62,10 +70,8 @@ export function ModelConfigSidebar({
|
||||||
return () => window.removeEventListener("keydown", handleEscape);
|
return () => window.removeEventListener("keydown", handleEscape);
|
||||||
}, [open, onOpenChange]);
|
}, [open, onOpenChange]);
|
||||||
|
|
||||||
// Check if this is Auto mode
|
|
||||||
const isAutoMode = config && "is_auto_mode" in config && config.is_auto_mode;
|
const isAutoMode = config && "is_auto_mode" in config && config.is_auto_mode;
|
||||||
|
|
||||||
// Get title based on mode
|
|
||||||
const getTitle = () => {
|
const getTitle = () => {
|
||||||
if (mode === "create") return "Add New Configuration";
|
if (mode === "create") return "Add New Configuration";
|
||||||
if (isAutoMode) return "Auto Mode (Fastest)";
|
if (isAutoMode) return "Auto Mode (Fastest)";
|
||||||
|
|
@ -73,19 +79,23 @@ export function ModelConfigSidebar({
|
||||||
return "Edit Configuration";
|
return "Edit Configuration";
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle form submit
|
const getSubtitle = () => {
|
||||||
|
if (mode === "create") return "Set up a new LLM provider for this search space";
|
||||||
|
if (isAutoMode) return "Automatically routes requests across providers";
|
||||||
|
if (isGlobal) return "Read-only global configuration";
|
||||||
|
return "Update your configuration settings";
|
||||||
|
};
|
||||||
|
|
||||||
const handleSubmit = useCallback(
|
const handleSubmit = useCallback(
|
||||||
async (data: LLMConfigFormData) => {
|
async (data: LLMConfigFormData) => {
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
try {
|
try {
|
||||||
if (mode === "create") {
|
if (mode === "create") {
|
||||||
// Create new config
|
|
||||||
const result = await createConfig({
|
const result = await createConfig({
|
||||||
...data,
|
...data,
|
||||||
search_space_id: searchSpaceId,
|
search_space_id: searchSpaceId,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Assign the new config to the agent role
|
|
||||||
if (result?.id) {
|
if (result?.id) {
|
||||||
await updatePreferences({
|
await updatePreferences({
|
||||||
search_space_id: searchSpaceId,
|
search_space_id: searchSpaceId,
|
||||||
|
|
@ -98,7 +108,6 @@ export function ModelConfigSidebar({
|
||||||
toast.success("Configuration created and assigned!");
|
toast.success("Configuration created and assigned!");
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
} else if (!isGlobal && config) {
|
} else if (!isGlobal && config) {
|
||||||
// Update existing user config
|
|
||||||
await updateConfig({
|
await updateConfig({
|
||||||
id: config.id,
|
id: config.id,
|
||||||
data: {
|
data: {
|
||||||
|
|
@ -137,7 +146,6 @@ export function ModelConfigSidebar({
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Handle "Use this model" for global configs
|
|
||||||
const handleUseGlobalConfig = useCallback(async () => {
|
const handleUseGlobalConfig = useCallback(async () => {
|
||||||
if (!config || !isGlobal) return;
|
if (!config || !isGlobal) return;
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
|
|
@ -160,7 +168,7 @@ export function ModelConfigSidebar({
|
||||||
|
|
||||||
if (!mounted) return null;
|
if (!mounted) return null;
|
||||||
|
|
||||||
const sidebarContent = (
|
const dialogContent = (
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{open && (
|
{open && (
|
||||||
<>
|
<>
|
||||||
|
|
@ -169,93 +177,84 @@ export function ModelConfigSidebar({
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
transition={{ duration: 0.2 }}
|
transition={{ duration: 0.15 }}
|
||||||
className="fixed inset-0 z-[24] bg-black/20 backdrop-blur-sm"
|
className="fixed inset-0 z-[24] bg-black/50 backdrop-blur-sm"
|
||||||
onClick={() => onOpenChange(false)}
|
onClick={() => onOpenChange(false)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Sidebar Panel */}
|
{/* Dialog */}
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ x: "100%", opacity: 0 }}
|
initial={{ opacity: 0, scale: 0.96 }}
|
||||||
animate={{ x: 0, opacity: 1 }}
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
exit={{ x: "100%", opacity: 0 }}
|
exit={{ opacity: 0, scale: 0.96 }}
|
||||||
transition={{
|
transition={{ duration: 0.15, ease: "easeOut" }}
|
||||||
type: "spring",
|
className="fixed inset-0 z-[25] flex items-center justify-center p-4 sm:p-6"
|
||||||
damping: 30,
|
|
||||||
stiffness: 300,
|
|
||||||
}}
|
|
||||||
className={cn(
|
|
||||||
"fixed right-0 top-0 z-[25] h-full w-full sm:w-[480px] lg:w-[540px]",
|
|
||||||
"bg-background border-l border-border/50 shadow-2xl",
|
|
||||||
"flex flex-col"
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{/* Header */}
|
|
||||||
<div
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center justify-between px-6 py-4 border-b border-border/50",
|
"relative w-full max-w-lg h-[85vh]",
|
||||||
isAutoMode ? "bg-gradient-to-r from-violet-500/10 to-purple-500/10" : "bg-muted/20"
|
"rounded-xl bg-background shadow-2xl",
|
||||||
|
"dark:bg-neutral-900",
|
||||||
|
"flex flex-col overflow-hidden"
|
||||||
)}
|
)}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Escape") onOpenChange(false);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
{/* Header */}
|
||||||
<div
|
<div className="flex items-start justify-between px-6 pt-6 pb-4">
|
||||||
className={cn(
|
<div className="space-y-1 pr-8">
|
||||||
"flex items-center justify-center size-10 rounded-xl",
|
<div className="flex items-center gap-2">
|
||||||
isAutoMode ? "bg-gradient-to-br from-violet-500 to-purple-600" : "bg-primary/10"
|
<h2 className="text-lg font-semibold tracking-tight">{getTitle()}</h2>
|
||||||
)}
|
{isAutoMode && (
|
||||||
>
|
<Badge variant="secondary" className="text-[10px]">
|
||||||
{isAutoMode ? (
|
|
||||||
<Shuffle className="size-5 text-white" />
|
|
||||||
) : (
|
|
||||||
<Bot className="size-5 text-primary" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h2 className="text-base sm:text-lg font-semibold">{getTitle()}</h2>
|
|
||||||
<div className="flex items-center gap-2 mt-0.5">
|
|
||||||
{isAutoMode ? (
|
|
||||||
<Badge
|
|
||||||
variant="secondary"
|
|
||||||
className="gap-1 text-xs bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300"
|
|
||||||
>
|
|
||||||
<Zap className="size-3" />
|
|
||||||
Recommended
|
Recommended
|
||||||
</Badge>
|
</Badge>
|
||||||
) : isGlobal ? (
|
)}
|
||||||
<Badge variant="secondary" className="gap-1 text-xs">
|
{isGlobal && !isAutoMode && mode !== "create" && (
|
||||||
<Globe className="size-3" />
|
<Badge variant="secondary" className="text-[10px]">
|
||||||
Global
|
Global
|
||||||
</Badge>
|
</Badge>
|
||||||
) : mode !== "create" ? (
|
)}
|
||||||
<Badge variant="outline" className="gap-1 text-xs">
|
{!isGlobal && mode !== "create" && !isAutoMode && (
|
||||||
<User className="size-3" />
|
<Badge variant="outline" className="text-[10px]">
|
||||||
Custom
|
Custom
|
||||||
</Badge>
|
</Badge>
|
||||||
) : null}
|
|
||||||
{config && !isAutoMode && (
|
|
||||||
<span className="text-xs text-muted-foreground">{config.model_name}</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">{getSubtitle()}</p>
|
||||||
|
{config && !isAutoMode && mode !== "create" && (
|
||||||
|
<p className="text-xs font-mono text-muted-foreground/70">
|
||||||
|
{config.model_name}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
className="absolute right-4 top-4 h-8 w-8 rounded-full text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => onOpenChange(false)}
|
|
||||||
className="h-8 w-8 rounded-full"
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
<span className="sr-only">Close</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content - use overflow-y-auto instead of ScrollArea for better compatibility */}
|
{/* Scrollable content */}
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div
|
||||||
<div className="p-6">
|
ref={scrollRef}
|
||||||
{/* Auto mode info banner */}
|
onScroll={handleScroll}
|
||||||
|
className="flex-1 overflow-y-auto px-6 py-5"
|
||||||
|
style={{
|
||||||
|
maskImage: `linear-gradient(to bottom, ${scrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${scrollPos === "bottom" ? "black" : "transparent"})`,
|
||||||
|
WebkitMaskImage: `linear-gradient(to bottom, ${scrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${scrollPos === "bottom" ? "black" : "transparent"})`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
{isAutoMode && (
|
{isAutoMode && (
|
||||||
<Alert className="mb-6 border-violet-500/30 bg-violet-500/5">
|
<Alert className="mb-5 border-violet-500/30 bg-violet-500/5">
|
||||||
<Shuffle className="size-4 text-violet-500" />
|
|
||||||
<AlertDescription className="text-sm text-violet-700 dark:text-violet-400">
|
<AlertDescription className="text-sm text-violet-700 dark:text-violet-400">
|
||||||
Auto mode automatically distributes requests across all available LLM
|
Auto mode automatically distributes requests across all available LLM
|
||||||
providers to optimize performance and avoid rate limits.
|
providers to optimize performance and avoid rate limits.
|
||||||
|
|
@ -263,9 +262,8 @@ export function ModelConfigSidebar({
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Global config notice */}
|
|
||||||
{isGlobal && !isAutoMode && mode !== "create" && (
|
{isGlobal && !isAutoMode && mode !== "create" && (
|
||||||
<Alert className="mb-6 border-amber-500/30 bg-amber-500/5">
|
<Alert className="mb-5 border-amber-500/30 bg-amber-500/5">
|
||||||
<AlertCircle className="size-4 text-amber-500" />
|
<AlertCircle className="size-4 text-amber-500" />
|
||||||
<AlertDescription className="text-sm text-amber-700 dark:text-amber-400">
|
<AlertDescription className="text-sm text-amber-700 dark:text-amber-400">
|
||||||
Global configurations are read-only. To customize settings, create a new
|
Global configurations are read-only. To customize settings, create a new
|
||||||
|
|
@ -274,20 +272,17 @@ export function ModelConfigSidebar({
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Form */}
|
|
||||||
{mode === "create" ? (
|
{mode === "create" ? (
|
||||||
<LLMConfigForm
|
<LLMConfigForm
|
||||||
searchSpaceId={searchSpaceId}
|
searchSpaceId={searchSpaceId}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
onCancel={() => onOpenChange(false)}
|
|
||||||
isSubmitting={isSubmitting}
|
isSubmitting={isSubmitting}
|
||||||
mode="create"
|
mode="create"
|
||||||
submitLabel="Create & Use"
|
formId="model-config-form"
|
||||||
|
hideActions
|
||||||
/>
|
/>
|
||||||
) : isAutoMode && config ? (
|
) : isAutoMode && config ? (
|
||||||
// Special view for Auto mode
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Auto Mode Features */}
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
|
|
@ -339,36 +334,9 @@ export function ModelConfigSidebar({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Action Buttons */}
|
|
||||||
<div className="flex gap-3 pt-4 border-t border-border/50">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="flex-1"
|
|
||||||
onClick={() => onOpenChange(false)}
|
|
||||||
>
|
|
||||||
Close
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
className="flex-1 gap-2 bg-gradient-to-r from-violet-500 to-purple-600 hover:from-violet-600 hover:to-purple-700"
|
|
||||||
onClick={handleUseGlobalConfig}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
>
|
|
||||||
{isSubmitting ? (
|
|
||||||
<>Loading...</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<ChevronRight className="size-4" />
|
|
||||||
Use Auto Mode
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
) : isGlobal && config ? (
|
) : isGlobal && config ? (
|
||||||
// Read-only view for global configs
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Config Details */}
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
|
|
@ -436,43 +404,17 @@ export function ModelConfigSidebar({
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Action Buttons */}
|
|
||||||
<div className="flex gap-3 pt-4 border-t border-border/50">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="flex-1"
|
|
||||||
onClick={() => onOpenChange(false)}
|
|
||||||
>
|
|
||||||
Close
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
className="flex-1 gap-2"
|
|
||||||
onClick={handleUseGlobalConfig}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
>
|
|
||||||
{isSubmitting ? (
|
|
||||||
<>Loading...</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<ChevronRight className="size-4" />
|
|
||||||
Use This Model
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
) : config ? (
|
) : config ? (
|
||||||
// Edit form for user configs
|
|
||||||
<LLMConfigForm
|
<LLMConfigForm
|
||||||
searchSpaceId={searchSpaceId}
|
searchSpaceId={searchSpaceId}
|
||||||
initialData={{
|
initialData={{
|
||||||
name: config.name,
|
name: config.name,
|
||||||
description: config.description,
|
description: config.description,
|
||||||
provider: config.provider,
|
provider: config.provider as LiteLLMProvider,
|
||||||
custom_provider: config.custom_provider,
|
custom_provider: config.custom_provider,
|
||||||
model_name: config.model_name,
|
model_name: config.model_name,
|
||||||
api_key: config.api_key,
|
api_key: "api_key" in config ? (config.api_key as string) : "",
|
||||||
api_base: config.api_base,
|
api_base: config.api_base,
|
||||||
litellm_params: config.litellm_params,
|
litellm_params: config.litellm_params,
|
||||||
system_instructions: config.system_instructions,
|
system_instructions: config.system_instructions,
|
||||||
|
|
@ -481,13 +423,61 @@ export function ModelConfigSidebar({
|
||||||
search_space_id: searchSpaceId,
|
search_space_id: searchSpaceId,
|
||||||
}}
|
}}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
onCancel={() => onOpenChange(false)}
|
|
||||||
isSubmitting={isSubmitting}
|
isSubmitting={isSubmitting}
|
||||||
mode="edit"
|
mode="edit"
|
||||||
submitLabel="Save Changes"
|
formId="model-config-form"
|
||||||
|
hideActions
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Fixed footer */}
|
||||||
|
<div className="shrink-0 px-6 py-4 flex items-center justify-end gap-3">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="text-sm h-9"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
{mode === "create" || (!isGlobal && !isAutoMode && config) ? (
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
form="model-config-form"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="text-sm h-9 min-w-[120px]"
|
||||||
|
>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<>
|
||||||
|
<Spinner size="sm" />
|
||||||
|
{mode === "edit" ? "Saving" : "Creating"}
|
||||||
|
</>
|
||||||
|
) : mode === "edit" ? (
|
||||||
|
"Save Changes"
|
||||||
|
) : (
|
||||||
|
"Create & Use"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
) : isAutoMode ? (
|
||||||
|
<Button
|
||||||
|
className="text-sm h-9 gap-2 bg-gradient-to-r from-violet-500 to-purple-600 hover:from-violet-600 hover:to-purple-700"
|
||||||
|
onClick={handleUseGlobalConfig}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
{isSubmitting ? "Loading..." : "Use Auto Mode"}
|
||||||
|
</Button>
|
||||||
|
) : isGlobal && config ? (
|
||||||
|
<Button
|
||||||
|
className="text-sm h-9 gap-2"
|
||||||
|
onClick={handleUseGlobalConfig}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
{isSubmitting ? "Loading..." : "Use This Model"}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</>
|
</>
|
||||||
|
|
@ -495,5 +485,5 @@ export function ModelConfigSidebar({
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
);
|
);
|
||||||
|
|
||||||
return typeof document !== "undefined" ? createPortal(sidebarContent, document.body) : null;
|
return typeof document !== "undefined" ? createPortal(dialogContent, document.body) : null;
|
||||||
}
|
}
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { Bot, Check, ChevronDown, Edit3, ImageIcon, Plus, Zap } from "lucide-react";
|
import { Bot, Check, ChevronDown, Edit3, ImageIcon, Plus, Zap } from "lucide-react";
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { type UIEvent, useCallback, useMemo, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
globalImageGenConfigsAtom,
|
globalImageGenConfigsAtom,
|
||||||
|
|
@ -57,6 +57,17 @@ export function ModelSelector({
|
||||||
const [activeTab, setActiveTab] = useState<"llm" | "image">("llm");
|
const [activeTab, setActiveTab] = useState<"llm" | "image">("llm");
|
||||||
const [llmSearchQuery, setLlmSearchQuery] = useState("");
|
const [llmSearchQuery, setLlmSearchQuery] = useState("");
|
||||||
const [imageSearchQuery, setImageSearchQuery] = useState("");
|
const [imageSearchQuery, setImageSearchQuery] = useState("");
|
||||||
|
const [llmScrollPos, setLlmScrollPos] = useState<"top" | "middle" | "bottom">("top");
|
||||||
|
const [imageScrollPos, setImageScrollPos] = useState<"top" | "middle" | "bottom">("top");
|
||||||
|
const handleListScroll = useCallback(
|
||||||
|
(setter: typeof setLlmScrollPos) => (e: UIEvent<HTMLDivElement>) => {
|
||||||
|
const el = e.currentTarget;
|
||||||
|
const atTop = el.scrollTop <= 2;
|
||||||
|
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2;
|
||||||
|
setter(atTop ? "top" : atBottom ? "bottom" : "middle");
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
// LLM data
|
// LLM data
|
||||||
const { data: llmUserConfigs, isLoading: llmUserLoading } = useAtomValue(newLLMConfigsAtom);
|
const { data: llmUserConfigs, isLoading: llmUserLoading } = useAtomValue(newLLMConfigsAtom);
|
||||||
|
|
@ -253,7 +264,7 @@ export function ModelSelector({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Divider */}
|
{/* Divider */}
|
||||||
<div className="h-4 w-px bg-border/60 mx-0.5" />
|
<div className="h-4 w-px bg-border/60 dark:bg-white/10 mx-0.5" />
|
||||||
|
|
||||||
{/* Image section */}
|
{/* Image section */}
|
||||||
{currentImageConfig ? (
|
{currentImageConfig ? (
|
||||||
|
|
@ -280,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-muted dark:border dark:border-neutral-700 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}
|
||||||
>
|
>
|
||||||
|
|
@ -289,18 +300,18 @@ export function ModelSelector({
|
||||||
onValueChange={(v) => setActiveTab(v as "llm" | "image")}
|
onValueChange={(v) => setActiveTab(v as "llm" | "image")}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
>
|
>
|
||||||
<div className="border-b border-border/80 dark:border-white/5">
|
<div className="border-b border-border/80 dark:border-neutral-800">
|
||||||
<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
|
||||||
|
|
@ -312,7 +323,7 @@ export function ModelSelector({
|
||||||
<TabsContent value="llm" className="mt-0">
|
<TabsContent value="llm" className="mt-0">
|
||||||
<Command
|
<Command
|
||||||
shouldFilter={false}
|
shouldFilter={false}
|
||||||
className="rounded-none rounded-b-lg relative dark:bg-muted [&_[data-slot=command-input-wrapper]]:border-0 [&_[data-slot=command-input-wrapper]]:px-0 [&_[data-slot=command-input-wrapper]]:gap-2"
|
className="rounded-none rounded-b-lg relative dark:bg-neutral-900 [&_[data-slot=command-input-wrapper]]:border-0 [&_[data-slot=command-input-wrapper]]:px-0 [&_[data-slot=command-input-wrapper]]:gap-2"
|
||||||
>
|
>
|
||||||
{totalLLMModels > 3 && (
|
{totalLLMModels > 3 && (
|
||||||
<div className="px-2 md:px-3 py-1.5 md:py-2">
|
<div className="px-2 md:px-3 py-1.5 md:py-2">
|
||||||
|
|
@ -325,7 +336,14 @@ export function ModelSelector({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<CommandList className="max-h-[300px] md:max-h-[400px] overflow-y-auto">
|
<CommandList
|
||||||
|
className="max-h-[300px] md:max-h-[400px] overflow-y-auto"
|
||||||
|
onScroll={handleListScroll(setLlmScrollPos)}
|
||||||
|
style={{
|
||||||
|
maskImage: `linear-gradient(to bottom, ${llmScrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${llmScrollPos === "bottom" ? "black" : "transparent"})`,
|
||||||
|
WebkitMaskImage: `linear-gradient(to bottom, ${llmScrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${llmScrollPos === "bottom" ? "black" : "transparent"})`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<CommandEmpty className="py-8 text-center">
|
<CommandEmpty className="py-8 text-center">
|
||||||
<div className="flex flex-col items-center gap-2">
|
<div className="flex flex-col items-center gap-2">
|
||||||
<Bot className="size-8 text-muted-foreground" />
|
<Bot className="size-8 text-muted-foreground" />
|
||||||
|
|
@ -350,8 +368,8 @@ export function ModelSelector({
|
||||||
onSelect={() => handleSelectLLM(config)}
|
onSelect={() => handleSelectLLM(config)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"mx-2 rounded-lg mb-1 cursor-pointer group transition-all",
|
"mx-2 rounded-lg mb-1 cursor-pointer group transition-all",
|
||||||
"hover:bg-accent/50 dark:hover:bg-white/10",
|
"hover:bg-accent/50 dark:hover:bg-white/[0.06]",
|
||||||
isSelected && "bg-accent/80 dark:bg-white/10",
|
isSelected && "bg-accent/80 dark:bg-white/[0.06]",
|
||||||
isAutoMode && ""
|
isAutoMode && ""
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|
@ -426,8 +444,8 @@ export function ModelSelector({
|
||||||
onSelect={() => handleSelectLLM(config)}
|
onSelect={() => handleSelectLLM(config)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"mx-2 rounded-lg mb-1 cursor-pointer group transition-all",
|
"mx-2 rounded-lg mb-1 cursor-pointer group transition-all",
|
||||||
"hover:bg-accent/50 dark:hover:bg-white/10",
|
"hover:bg-accent/50 dark:hover:bg-white/[0.06]",
|
||||||
isSelected && "bg-accent/80 dark:bg-white/10"
|
isSelected && "bg-accent/80 dark:bg-white/[0.06]"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between w-full gap-2">
|
<div className="flex items-center justify-between w-full gap-2">
|
||||||
|
|
@ -471,11 +489,11 @@ export function ModelSelector({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Add New LLM Config */}
|
{/* Add New LLM Config */}
|
||||||
<div className="p-2 bg-muted/20 dark:bg-muted">
|
<div className="p-2 bg-muted/20 dark:bg-neutral-900">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="w-full justify-start gap-2 h-9 rounded-lg hover:bg-accent/50 dark:hover:bg-white/10"
|
className="w-full justify-start gap-2 h-9 rounded-lg hover:bg-accent/50 dark:hover:bg-white/[0.06]"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
onAddNewLLM();
|
onAddNewLLM();
|
||||||
|
|
@ -493,7 +511,7 @@ export function ModelSelector({
|
||||||
<TabsContent value="image" className="mt-0">
|
<TabsContent value="image" className="mt-0">
|
||||||
<Command
|
<Command
|
||||||
shouldFilter={false}
|
shouldFilter={false}
|
||||||
className="rounded-none rounded-b-lg dark:bg-muted [&_[data-slot=command-input-wrapper]]:border-0 [&_[data-slot=command-input-wrapper]]:px-0 [&_[data-slot=command-input-wrapper]]:gap-2"
|
className="rounded-none rounded-b-lg dark:bg-neutral-900 [&_[data-slot=command-input-wrapper]]:border-0 [&_[data-slot=command-input-wrapper]]:px-0 [&_[data-slot=command-input-wrapper]]:gap-2"
|
||||||
>
|
>
|
||||||
{totalImageModels > 3 && (
|
{totalImageModels > 3 && (
|
||||||
<div className="px-2 md:px-3 py-1.5 md:py-2">
|
<div className="px-2 md:px-3 py-1.5 md:py-2">
|
||||||
|
|
@ -505,7 +523,14 @@ export function ModelSelector({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<CommandList className="max-h-[300px] md:max-h-[400px] overflow-y-auto">
|
<CommandList
|
||||||
|
className="max-h-[300px] md:max-h-[400px] overflow-y-auto"
|
||||||
|
onScroll={handleListScroll(setImageScrollPos)}
|
||||||
|
style={{
|
||||||
|
maskImage: `linear-gradient(to bottom, ${imageScrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${imageScrollPos === "bottom" ? "black" : "transparent"})`,
|
||||||
|
WebkitMaskImage: `linear-gradient(to bottom, ${imageScrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${imageScrollPos === "bottom" ? "black" : "transparent"})`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<CommandEmpty className="py-8 text-center">
|
<CommandEmpty className="py-8 text-center">
|
||||||
<div className="flex flex-col items-center gap-2">
|
<div className="flex flex-col items-center gap-2">
|
||||||
<ImageIcon className="size-8 text-muted-foreground" />
|
<ImageIcon className="size-8 text-muted-foreground" />
|
||||||
|
|
@ -528,8 +553,8 @@ export function ModelSelector({
|
||||||
value={`img-g-${config.id}`}
|
value={`img-g-${config.id}`}
|
||||||
onSelect={() => handleSelectImage(config.id)}
|
onSelect={() => handleSelectImage(config.id)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"mx-2 rounded-lg mb-1 cursor-pointer group transition-all hover:bg-accent/50 dark:hover:bg-white/10",
|
"mx-2 rounded-lg mb-1 cursor-pointer group transition-all hover:bg-accent/50 dark:hover:bg-white/[0.06]",
|
||||||
isSelected && "bg-accent/80 dark:bg-white/10",
|
isSelected && "bg-accent/80 dark:bg-white/[0.06]",
|
||||||
isAuto && ""
|
isAuto && ""
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|
@ -593,8 +618,8 @@ export function ModelSelector({
|
||||||
value={`img-u-${config.id}`}
|
value={`img-u-${config.id}`}
|
||||||
onSelect={() => handleSelectImage(config.id)}
|
onSelect={() => handleSelectImage(config.id)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"mx-2 rounded-lg mb-1 cursor-pointer group transition-all hover:bg-accent/50 dark:hover:bg-white/10",
|
"mx-2 rounded-lg mb-1 cursor-pointer group transition-all hover:bg-accent/50 dark:hover:bg-white/[0.06]",
|
||||||
isSelected && "bg-accent/80 dark:bg-white/10"
|
isSelected && "bg-accent/80 dark:bg-white/[0.06]"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||||
|
|
@ -634,11 +659,11 @@ export function ModelSelector({
|
||||||
|
|
||||||
{/* Add New Image Config */}
|
{/* Add New Image Config */}
|
||||||
{onAddNewImage && (
|
{onAddNewImage && (
|
||||||
<div className="p-2 bg-muted/20 dark:bg-muted">
|
<div className="p-2 bg-muted/20 dark:bg-neutral-900">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="w-full justify-start gap-2 h-9 rounded-lg hover:bg-accent/50 dark:hover:bg-white/10"
|
className="w-full justify-start gap-2 h-9 rounded-lg hover:bg-accent/50 dark:hover:bg-white/[0.06]"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
onAddNewImage();
|
onAddNewImage();
|
||||||
|
|
|
||||||
|
|
@ -334,7 +334,7 @@ function ReportPanelContent({
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent
|
<DropdownMenuContent
|
||||||
align="start"
|
align="start"
|
||||||
className={`min-w-[180px] bg-muted dark:border dark:border-neutral-700${insideDrawer ? " z-[100]" : ""}`}
|
className={`min-w-[180px] dark:bg-neutral-900 dark:border dark:border-white/5${insideDrawer ? " z-[100]" : ""}`}
|
||||||
>
|
>
|
||||||
<DropdownMenuItem onClick={() => handleExport("md")}>
|
<DropdownMenuItem onClick={() => handleExport("md")}>
|
||||||
Download Markdown
|
Download Markdown
|
||||||
|
|
@ -371,7 +371,7 @@ function ReportPanelContent({
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent
|
<DropdownMenuContent
|
||||||
align="start"
|
align="start"
|
||||||
className={`min-w-[120px] bg-muted dark:border dark:border-neutral-700${insideDrawer ? " z-[100]" : ""}`}
|
className={`min-w-[120px] dark:bg-neutral-900 dark:border dark:border-white/5${insideDrawer ? " z-[100]" : ""}`}
|
||||||
>
|
>
|
||||||
{versions.map((v, i) => (
|
{versions.map((v, i) => (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
|
|
|
||||||
|
|
@ -578,10 +578,7 @@ function RolesContent({
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<AlertDialog>
|
<AlertDialog>
|
||||||
<AlertDialogTrigger asChild>
|
<AlertDialogTrigger asChild>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
|
||||||
className="text-destructive focus:text-destructive"
|
|
||||||
onSelect={(e) => e.preventDefault()}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4 mr-2" />
|
<Trash2 className="h-4 w-4 mr-2" />
|
||||||
Delete Role
|
Delete Role
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
|
||||||
|
|
@ -2,16 +2,7 @@
|
||||||
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import {
|
import { Check, ChevronDown, ChevronsUpDown } from "lucide-react";
|
||||||
Bot,
|
|
||||||
Check,
|
|
||||||
ChevronDown,
|
|
||||||
ChevronsUpDown,
|
|
||||||
Key,
|
|
||||||
MessageSquareQuote,
|
|
||||||
Rocket,
|
|
||||||
Sparkles,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { AnimatePresence, motion } from "motion/react";
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
|
|
@ -88,6 +79,8 @@ interface LLMConfigFormProps {
|
||||||
submitLabel?: string;
|
submitLabel?: string;
|
||||||
showAdvanced?: boolean;
|
showAdvanced?: boolean;
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
|
formId?: string;
|
||||||
|
hideActions?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LLMConfigForm({
|
export function LLMConfigForm({
|
||||||
|
|
@ -100,6 +93,8 @@ export function LLMConfigForm({
|
||||||
submitLabel,
|
submitLabel,
|
||||||
showAdvanced = true,
|
showAdvanced = true,
|
||||||
compact = false,
|
compact = false,
|
||||||
|
formId,
|
||||||
|
hideActions = false,
|
||||||
}: LLMConfigFormProps) {
|
}: LLMConfigFormProps) {
|
||||||
const { data: defaultInstructions, isSuccess: defaultInstructionsLoaded } = useAtomValue(
|
const { data: defaultInstructions, isSuccess: defaultInstructionsLoaded } = useAtomValue(
|
||||||
defaultSystemInstructionsAtom
|
defaultSystemInstructionsAtom
|
||||||
|
|
@ -164,11 +159,10 @@ export function LLMConfigForm({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(handleFormSubmit)} className="space-y-6">
|
<form id={formId} onSubmit={form.handleSubmit(handleFormSubmit)} className="space-y-6">
|
||||||
{/* Model Configuration Section */}
|
{/* Model Configuration Section */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center gap-2 text-xs sm:text-sm font-medium text-muted-foreground">
|
<div className="text-xs sm:text-sm font-medium text-muted-foreground">
|
||||||
<Bot className="h-3.5 w-3.5 sm:h-4 sm:w-4" />
|
|
||||||
Model Configuration
|
Model Configuration
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -179,16 +173,9 @@ export function LLMConfigForm({
|
||||||
name="name"
|
name="name"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel className="flex items-center gap-2 text-xs sm:text-sm">
|
<FormLabel className="text-xs sm:text-sm">Configuration Name</FormLabel>
|
||||||
<Sparkles className="h-3.5 w-3.5 text-violet-500" />
|
|
||||||
Configuration Name
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input placeholder="e.g., My GPT-4 Agent" {...field} />
|
||||||
placeholder="e.g., My GPT-4 Agent"
|
|
||||||
className="transition-all focus-visible:ring-violet-500/50"
|
|
||||||
{...field}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
|
@ -224,19 +211,18 @@ export function LLMConfigForm({
|
||||||
<FormLabel className="text-xs sm:text-sm">LLM Provider</FormLabel>
|
<FormLabel className="text-xs sm:text-sm">LLM Provider</FormLabel>
|
||||||
<Select value={field.value} onValueChange={handleProviderChange}>
|
<Select value={field.value} onValueChange={handleProviderChange}>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<SelectTrigger className="transition-all focus:ring-violet-500/50">
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Select a provider" />
|
<SelectValue placeholder="Select a provider" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<SelectContent className="max-h-[300px]">
|
<SelectContent className="max-h-[300px] bg-muted dark:border-neutral-700">
|
||||||
{LLM_PROVIDERS.map((provider) => (
|
{LLM_PROVIDERS.map((provider) => (
|
||||||
<SelectItem key={provider.value} value={provider.value}>
|
<SelectItem
|
||||||
<div className="flex flex-col py-0.5">
|
key={provider.value}
|
||||||
<span className="font-medium">{provider.label}</span>
|
value={provider.value}
|
||||||
<span className="text-xs text-muted-foreground">
|
description={provider.description}
|
||||||
{provider.description}
|
>
|
||||||
</span>
|
{provider.label}
|
||||||
</div>
|
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
|
|
@ -290,7 +276,7 @@ export function LLMConfigForm({
|
||||||
role="combobox"
|
role="combobox"
|
||||||
aria-expanded={modelComboboxOpen}
|
aria-expanded={modelComboboxOpen}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full justify-between font-normal",
|
"w-full justify-between font-normal bg-transparent",
|
||||||
!field.value && "text-muted-foreground"
|
!field.value && "text-muted-foreground"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|
@ -299,8 +285,11 @@ export function LLMConfigForm({
|
||||||
</Button>
|
</Button>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-full p-0" align="start">
|
<PopoverContent
|
||||||
<Command shouldFilter={false}>
|
className="w-full p-0 bg-muted dark:border-neutral-700"
|
||||||
|
align="start"
|
||||||
|
>
|
||||||
|
<Command shouldFilter={false} className="bg-transparent">
|
||||||
<CommandInput
|
<CommandInput
|
||||||
placeholder={selectedProvider?.example || "Type model name..."}
|
placeholder={selectedProvider?.example || "Type model name..."}
|
||||||
value={field.value}
|
value={field.value}
|
||||||
|
|
@ -371,10 +360,7 @@ export function LLMConfigForm({
|
||||||
name="api_key"
|
name="api_key"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel className="flex items-center gap-2 text-xs sm:text-sm">
|
<FormLabel className="text-xs sm:text-sm">API Key</FormLabel>
|
||||||
<Key className="h-3.5 w-3.5 text-amber-500" />
|
|
||||||
API Key
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
|
|
@ -460,10 +446,7 @@ export function LLMConfigForm({
|
||||||
type="button"
|
type="button"
|
||||||
className="flex w-full items-center justify-between py-2 text-xs sm:text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
|
className="flex w-full items-center justify-between py-2 text-xs sm:text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<span>Advanced Parameters</span>
|
||||||
<Sparkles className="h-3.5 w-3.5 sm:h-4 sm:w-4" />
|
|
||||||
Advanced Parameters
|
|
||||||
</div>
|
|
||||||
<ChevronDown
|
<ChevronDown
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-4 w-4 transition-transform duration-200",
|
"h-4 w-4 transition-transform duration-200",
|
||||||
|
|
@ -501,10 +484,7 @@ export function LLMConfigForm({
|
||||||
type="button"
|
type="button"
|
||||||
className="flex w-full items-center justify-between py-2 text-xs sm:text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
|
className="flex w-full items-center justify-between py-2 text-xs sm:text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<span>System Instructions</span>
|
||||||
<MessageSquareQuote className="h-3.5 w-3.5 sm:h-4 sm:w-4" />
|
|
||||||
System Instructions
|
|
||||||
</div>
|
|
||||||
<ChevronDown
|
<ChevronDown
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-4 w-4 transition-transform duration-200",
|
"h-4 w-4 transition-transform duration-200",
|
||||||
|
|
@ -575,42 +555,43 @@ export function LLMConfigForm({
|
||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
|
|
||||||
{/* Action Buttons */}
|
{!hideActions && (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex gap-3 pt-4",
|
"flex gap-3 pt-4",
|
||||||
compact ? "justify-end" : "justify-center sm:justify-end"
|
compact ? "justify-end" : "justify-center sm:justify-end"
|
||||||
)}
|
|
||||||
>
|
|
||||||
{onCancel && (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={onCancel}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
className="text-xs sm:text-sm h-9 sm:h-10"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
disabled={isSubmitting}
|
|
||||||
className="gap-2 min-w-[140px] sm:min-w-[160px] text-xs sm:text-sm h-9 sm:h-10"
|
|
||||||
>
|
|
||||||
{isSubmitting ? (
|
|
||||||
<>
|
|
||||||
<Spinner size="sm" />
|
|
||||||
{mode === "edit" ? "Updating..." : "Creating"}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{!compact && <Rocket className="h-3.5 w-3.5 sm:h-4 sm:w-4" />}
|
|
||||||
{submitLabel ?? (mode === "edit" ? "Update Configuration" : "Create Configuration")}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</Button>
|
>
|
||||||
</div>
|
{onCancel && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={onCancel}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="text-xs sm:text-sm h-9 sm:h-10"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="gap-2 min-w-[140px] sm:min-w-[160px] text-xs sm:text-sm h-9 sm:h-10"
|
||||||
|
>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<>
|
||||||
|
<Spinner size="sm" />
|
||||||
|
{mode === "edit" ? "Updating..." : "Creating"}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{submitLabel ??
|
||||||
|
(mode === "edit" ? "Update Configuration" : "Create Configuration")}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { CheckCircle2, FileType, Info, Upload, X } from "lucide-react";
|
import { CheckCircle2, FileType, Info, Upload, X } from "lucide-react";
|
||||||
import { AnimatePresence, motion } from "motion/react";
|
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useCallback, useMemo, useRef, useState } from "react";
|
import { useCallback, useMemo, useRef, useState } from "react";
|
||||||
import { useDropzone } from "react-dropzone";
|
import { useDropzone } from "react-dropzone";
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -241,12 +253,7 @@ export function DocumentUploadTab({
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<div className="space-y-3 sm:space-y-6 max-w-4xl mx-auto pt-0">
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ duration: 0.3 }}
|
|
||||||
className="space-y-3 sm:space-y-6 max-w-4xl mx-auto pt-0"
|
|
||||||
>
|
|
||||||
<Alert className="border border-border bg-slate-400/5 dark:bg-white/5 flex items-start gap-3 [&>svg]:relative [&>svg]:left-0 [&>svg]:top-0 [&>svg~*]:pl-0">
|
<Alert className="border border-border bg-slate-400/5 dark:bg-white/5 flex items-start gap-3 [&>svg]:relative [&>svg]:left-0 [&>svg]:top-0 [&>svg~*]:pl-0">
|
||||||
<Info className="h-4 w-4 shrink-0 mt-0.5" />
|
<Info className="h-4 w-4 shrink-0 mt-0.5" />
|
||||||
<AlertDescription className="text-xs sm:text-sm leading-relaxed pt-0.5">
|
<AlertDescription className="text-xs sm:text-sm leading-relaxed pt-0.5">
|
||||||
|
|
@ -287,14 +294,10 @@ export function DocumentUploadTab({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : isDragActive ? (
|
) : isDragActive ? (
|
||||||
<motion.div
|
<div className="flex flex-col items-center gap-2 sm:gap-4">
|
||||||
initial={{ opacity: 0, scale: 0.8 }}
|
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
|
||||||
className="flex flex-col items-center gap-2 sm:gap-4"
|
|
||||||
>
|
|
||||||
<Upload className="h-8 w-8 sm:h-12 sm:w-12 text-primary" />
|
<Upload className="h-8 w-8 sm:h-12 sm:w-12 text-primary" />
|
||||||
<p className="text-sm sm:text-lg font-medium text-primary">{t("drop_files")}</p>
|
<p className="text-sm sm:text-lg font-medium text-primary">{t("drop_files")}</p>
|
||||||
</motion.div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col items-center gap-2 sm:gap-4">
|
<div className="flex flex-col items-center gap-2 sm:gap-4">
|
||||||
<Upload className="h-8 w-8 sm:h-12 sm:w-12 text-muted-foreground" />
|
<Upload className="h-8 w-8 sm:h-12 sm:w-12 text-muted-foreground" />
|
||||||
|
|
@ -312,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) => {
|
||||||
|
|
@ -329,124 +332,102 @@ export function DocumentUploadTab({
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<AnimatePresence mode="wait">
|
{files.length > 0 && (
|
||||||
{files.length > 0 && (
|
<Card className={cardClass}>
|
||||||
<motion.div
|
<CardHeader className="p-4 sm:p-6">
|
||||||
initial={{ opacity: 0, height: 0 }}
|
<div className="flex items-center justify-between gap-2">
|
||||||
animate={{ opacity: 1, height: "auto" }}
|
<div className="min-w-0 flex-1">
|
||||||
exit={{ opacity: 0, height: 0 }}
|
<CardTitle className="text-base sm:text-2xl">
|
||||||
transition={{ duration: 0.3 }}
|
{t("selected_files", { count: files.length })}
|
||||||
>
|
</CardTitle>
|
||||||
<Card className={cardClass}>
|
<CardDescription className="text-xs sm:text-sm">
|
||||||
<CardHeader className="p-4 sm:p-6">
|
{t("total_size")}: {formatFileSize(totalFileSize)}
|
||||||
<div className="flex items-center justify-between gap-2">
|
</CardDescription>
|
||||||
<div className="min-w-0 flex-1">
|
</div>
|
||||||
<CardTitle className="text-base sm:text-2xl">
|
<Button
|
||||||
{t("selected_files", { count: files.length })}
|
variant="outline"
|
||||||
</CardTitle>
|
size="sm"
|
||||||
<CardDescription className="text-xs sm:text-sm">
|
className="text-xs sm:text-sm shrink-0"
|
||||||
{t("total_size")}: {formatFileSize(totalFileSize)}
|
onClick={() => setFiles([])}
|
||||||
</CardDescription>
|
disabled={isUploading}
|
||||||
|
>
|
||||||
|
{t("clear_all")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-4 sm:p-6 pt-0">
|
||||||
|
<div className="space-y-2 sm:space-y-3 max-h-[250px] sm:max-h-[400px] overflow-y-auto">
|
||||||
|
{files.map((entry) => (
|
||||||
|
<div
|
||||||
|
key={entry.id}
|
||||||
|
className={`flex items-center justify-between p-2 sm:p-4 rounded-lg border border-border ${cardClass} hover:bg-slate-400/10 dark:hover:bg-white/10 transition-colors`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||||
|
<FileType className="h-5 w-5 text-muted-foreground flex-shrink-0" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm sm:text-base font-medium truncate">{entry.file.name}</p>
|
||||||
|
<div className="flex items-center gap-2 mt-1">
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
{formatFileSize(entry.file.size)}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{entry.file.type || "Unknown type"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="ghost"
|
||||||
size="sm"
|
size="icon"
|
||||||
className="text-xs sm:text-sm shrink-0"
|
onClick={() => setFiles((prev) => prev.filter((e) => e.id !== entry.id))}
|
||||||
onClick={() => setFiles([])}
|
|
||||||
disabled={isUploading}
|
disabled={isUploading}
|
||||||
|
className="h-8 w-8"
|
||||||
>
|
>
|
||||||
{t("clear_all")}
|
<X className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
))}
|
||||||
<CardContent className="p-4 sm:p-6 pt-0">
|
</div>
|
||||||
<div className="space-y-2 sm:space-y-3 max-h-[250px] sm:max-h-[400px] overflow-y-auto">
|
|
||||||
<AnimatePresence>
|
|
||||||
{files.map((file, index) => (
|
|
||||||
<motion.div
|
|
||||||
key={`${file.name}-${index}`}
|
|
||||||
initial={{ opacity: 0, x: -20 }}
|
|
||||||
animate={{ opacity: 1, x: 0 }}
|
|
||||||
exit={{ opacity: 0, x: 20 }}
|
|
||||||
className={`flex items-center justify-between p-2 sm:p-4 rounded-lg border border-border ${cardClass} hover:bg-slate-400/10 dark:hover:bg-white/10 transition-colors`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
|
||||||
<FileType className="h-5 w-5 text-muted-foreground flex-shrink-0" />
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<p className="text-sm sm:text-base font-medium truncate">{file.name}</p>
|
|
||||||
<div className="flex items-center gap-2 mt-1">
|
|
||||||
<Badge variant="secondary" className="text-xs">
|
|
||||||
{formatFileSize(file.size)}
|
|
||||||
</Badge>
|
|
||||||
<Badge variant="outline" className="text-xs">
|
|
||||||
{file.type || "Unknown type"}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => setFiles((prev) => prev.filter((_, i) => i !== index))}
|
|
||||||
disabled={isUploading}
|
|
||||||
className="h-8 w-8"
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</motion.div>
|
|
||||||
))}
|
|
||||||
</AnimatePresence>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isUploading && (
|
{isUploading && (
|
||||||
<motion.div
|
<div className="mt-3 sm:mt-6 space-y-2 sm:space-y-3">
|
||||||
initial={{ opacity: 0, y: 10 }}
|
<Separator className="bg-border" />
|
||||||
animate={{ opacity: 1, y: 0 }}
|
<div className="space-y-2">
|
||||||
className="mt-3 sm:mt-6 space-y-2 sm:space-y-3"
|
<div className="flex items-center justify-between text-xs sm:text-sm">
|
||||||
>
|
<span>{t("uploading_files")}</span>
|
||||||
<Separator className="bg-border" />
|
<span>{Math.round(uploadProgress)}%</span>
|
||||||
<div className="space-y-2">
|
</div>
|
||||||
<div className="flex items-center justify-between text-xs sm:text-sm">
|
<Progress value={uploadProgress} className="h-2" />
|
||||||
<span>{t("uploading_files")}</span>
|
</div>
|
||||||
<span>{Math.round(uploadProgress)}%</span>
|
</div>
|
||||||
</div>
|
)}
|
||||||
<Progress value={uploadProgress} className="h-2" />
|
|
||||||
</div>
|
<div className="mt-3 sm:mt-6">
|
||||||
</motion.div>
|
<SummaryConfig enabled={shouldSummarize} onEnabledChange={setShouldSummarize} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 sm:mt-6">
|
||||||
|
<Button
|
||||||
|
className="w-full py-3 sm:py-6 text-xs sm:text-base font-medium"
|
||||||
|
onClick={handleUpload}
|
||||||
|
disabled={isUploading || files.length === 0}
|
||||||
|
>
|
||||||
|
{isUploading ? (
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<Spinner size="sm" />
|
||||||
|
{t("uploading")}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<CheckCircle2 className="h-4 w-4 sm:h-5 sm:w-5" />
|
||||||
|
{t("upload_button", { count: files.length })}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
|
</Button>
|
||||||
<div className="mt-3 sm:mt-6">
|
</div>
|
||||||
<SummaryConfig enabled={shouldSummarize} onEnabledChange={setShouldSummarize} />
|
</CardContent>
|
||||||
</div>
|
</Card>
|
||||||
|
)}
|
||||||
<motion.div
|
|
||||||
className="mt-3 sm:mt-6"
|
|
||||||
initial={{ opacity: 0, y: 10 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
className="w-full py-3 sm:py-6 text-xs sm:text-base font-medium"
|
|
||||||
onClick={handleUpload}
|
|
||||||
disabled={isUploading || files.length === 0}
|
|
||||||
>
|
|
||||||
{isUploading ? (
|
|
||||||
<span className="flex items-center gap-2">
|
|
||||||
<Spinner size="sm" />
|
|
||||||
{t("uploading")}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="flex items-center gap-2">
|
|
||||||
<CheckCircle2 className="h-4 w-4 sm:h-5 sm:w-5" />
|
|
||||||
{t("upload_button", { count: files.length })}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</motion.div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
|
|
||||||
<Accordion
|
<Accordion
|
||||||
type="single"
|
type="single"
|
||||||
|
|
@ -479,6 +460,6 @@ export function DocumentUploadTab({
|
||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
</Accordion>
|
</Accordion>
|
||||||
</motion.div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ function AlertDialogOverlay({
|
||||||
<AlertDialogPrimitive.Overlay
|
<AlertDialogPrimitive.Overlay
|
||||||
data-slot="alert-dialog-overlay"
|
data-slot="alert-dialog-overlay"
|
||||||
className={cn(
|
className={cn(
|
||||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80",
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50 backdrop-blur-sm",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
@ -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 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-lg border p-6 shadow-lg 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}
|
||||||
|
|
@ -113,7 +113,7 @@ function AlertDialogCancel({
|
||||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
|
||||||
return (
|
return (
|
||||||
<AlertDialogPrimitive.Cancel
|
<AlertDialogPrimitive.Cancel
|
||||||
className={cn(buttonVariants({ variant: "outline" }), className)}
|
className={cn(buttonVariants({ variant: "secondary" }), 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 };
|
||||||
|
|
@ -1,100 +0,0 @@
|
||||||
import { Slot } from "@radix-ui/react-slot";
|
|
||||||
import { ChevronRight, MoreHorizontal } from "lucide-react";
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
|
|
||||||
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
|
|
||||||
return (
|
|
||||||
<ol
|
|
||||||
data-slot="breadcrumb-list"
|
|
||||||
className={cn(
|
|
||||||
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
|
|
||||||
return (
|
|
||||||
<li
|
|
||||||
data-slot="breadcrumb-item"
|
|
||||||
className={cn("inline-flex items-center gap-1.5", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function BreadcrumbLink({
|
|
||||||
asChild,
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"a"> & {
|
|
||||||
asChild?: boolean;
|
|
||||||
}) {
|
|
||||||
const Comp = asChild ? Slot : "a";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Comp
|
|
||||||
data-slot="breadcrumb-link"
|
|
||||||
className={cn("hover:text-foreground transition-colors", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
data-slot="breadcrumb-page"
|
|
||||||
aria-disabled="true"
|
|
||||||
aria-current="page"
|
|
||||||
className={cn("text-foreground font-normal", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function BreadcrumbSeparator({ children, className, ...props }: React.ComponentProps<"li">) {
|
|
||||||
return (
|
|
||||||
<li
|
|
||||||
data-slot="breadcrumb-separator"
|
|
||||||
role="presentation"
|
|
||||||
aria-hidden="true"
|
|
||||||
className={cn("[&>svg]:size-3.5", className)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children ?? <ChevronRight />}
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function BreadcrumbEllipsis({ className, ...props }: React.ComponentProps<"span">) {
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
data-slot="breadcrumb-ellipsis"
|
|
||||||
role="presentation"
|
|
||||||
aria-hidden="true"
|
|
||||||
className={cn("flex size-9 items-center justify-center", className)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<MoreHorizontal className="size-4" />
|
|
||||||
<span className="sr-only">More</span>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export {
|
|
||||||
Breadcrumb,
|
|
||||||
BreadcrumbList,
|
|
||||||
BreadcrumbItem,
|
|
||||||
BreadcrumbLink,
|
|
||||||
BreadcrumbPage,
|
|
||||||
BreadcrumbSeparator,
|
|
||||||
BreadcrumbEllipsis,
|
|
||||||
};
|
|
||||||
|
|
@ -88,11 +88,14 @@ function Calendar({
|
||||||
"relative w-full h-full p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
|
"relative w-full h-full p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
|
||||||
defaultClassNames.day
|
defaultClassNames.day
|
||||||
),
|
),
|
||||||
range_start: cn("rounded-l-md bg-accent", defaultClassNames.range_start),
|
range_start: cn(
|
||||||
|
"rounded-l-md bg-accent dark:bg-neutral-700",
|
||||||
|
defaultClassNames.range_start
|
||||||
|
),
|
||||||
range_middle: cn("rounded-none", defaultClassNames.range_middle),
|
range_middle: cn("rounded-none", defaultClassNames.range_middle),
|
||||||
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
|
range_end: cn("rounded-r-md bg-accent dark:bg-neutral-700", defaultClassNames.range_end),
|
||||||
today: cn(
|
today: cn(
|
||||||
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
|
"bg-accent dark:bg-neutral-700 text-accent-foreground rounded-md data-[selected=true]:rounded-none",
|
||||||
defaultClassNames.today
|
defaultClassNames.today
|
||||||
),
|
),
|
||||||
outside: cn(
|
outside: cn(
|
||||||
|
|
@ -164,7 +167,7 @@ function CalendarDayButton({
|
||||||
data-range-end={modifiers.range_end}
|
data-range-end={modifiers.range_end}
|
||||||
data-range-middle={modifiers.range_middle}
|
data-range-middle={modifiers.range_middle}
|
||||||
className={cn(
|
className={cn(
|
||||||
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
|
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent dark:data-[range-middle=true]:bg-neutral-700 data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:bg-neutral-700 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
|
||||||
defaultClassNames.day,
|
defaultClassNames.day,
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,7 @@ function ContextMenuSubContent({
|
||||||
<ContextMenuPrimitive.SubContent
|
<ContextMenuPrimitive.SubContent
|
||||||
data-slot="context-menu-sub-content"
|
data-slot="context-menu-sub-content"
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-popover text-popover-foreground 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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-lg",
|
"bg-muted text-popover-foreground 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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border dark:border-neutral-700 p-1 shadow-lg",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
@ -83,7 +83,7 @@ function ContextMenuContent({
|
||||||
<ContextMenuPrimitive.Content
|
<ContextMenuPrimitive.Content
|
||||||
data-slot="context-menu-content"
|
data-slot="context-menu-content"
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-popover text-popover-foreground 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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-md",
|
"bg-muted text-popover-foreground 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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border dark:border-neutral-700 p-1 shadow-md",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
@ -107,8 +107,7 @@ function ContextMenuItem({
|
||||||
data-inset={inset}
|
data-inset={inset}
|
||||||
data-variant={variant}
|
data-variant={variant}
|
||||||
className={cn(
|
className={cn(
|
||||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-pointer 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 [&_svg:not([class*='text-'])]:text-muted-foreground",
|
"focus:bg-accent focus:text-accent-foreground dark:focus:bg-neutral-700 relative flex cursor-pointer 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 [&_svg:not([class*='text-'])]:text-muted-foreground",
|
||||||
"data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive",
|
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
@ -190,7 +189,7 @@ function ContextMenuSeparator({
|
||||||
return (
|
return (
|
||||||
<ContextMenuPrimitive.Separator
|
<ContextMenuPrimitive.Separator
|
||||||
data-slot="context-menu-separator"
|
data-slot="context-menu-separator"
|
||||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
className={cn("bg-border dark:bg-neutral-700 -mx-1 my-1 h-px", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ const DialogOverlay = React.forwardRef<
|
||||||
<DialogPrimitive.Overlay
|
<DialogPrimitive.Overlay
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
"fixed inset-0 z-50 bg-black/50 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
@ -38,13 +38,13 @@ 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 border bg-background p-6 shadow-lg 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-lg 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}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<DialogPrimitive.Close className="absolute right-4 top-4 z-50 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
<DialogPrimitive.Close className="absolute right-4 top-4 z-50 h-8 w-8 rounded-full inline-flex items-center justify-center text-muted-foreground transition-colors hover:text-foreground hover:bg-accent focus:outline-none disabled:pointer-events-none">
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
<span className="sr-only">Close</span>
|
<span className="sr-only">Close</span>
|
||||||
</DialogPrimitive.Close>
|
</DialogPrimitive.Close>
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ function DropdownMenuContent({
|
||||||
data-slot="dropdown-menu-content"
|
data-slot="dropdown-menu-content"
|
||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-popover text-popover-foreground 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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
"bg-muted text-popover-foreground 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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border dark:border-neutral-700 p-1 shadow-md",
|
||||||
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 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_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}
|
||||||
|
|
@ -149,7 +149,7 @@ function DropdownMenuSeparator({
|
||||||
return (
|
return (
|
||||||
<DropdownMenuPrimitive.Separator
|
<DropdownMenuPrimitive.Separator
|
||||||
data-slot="dropdown-menu-separator"
|
data-slot="dropdown-menu-separator"
|
||||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
className={cn("bg-border dark:bg-neutral-700 -mx-1 my-1 h-px", 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}
|
||||||
|
|
@ -201,7 +201,7 @@ function DropdownMenuSubContent({
|
||||||
<DropdownMenuPrimitive.SubContent
|
<DropdownMenuPrimitive.SubContent
|
||||||
data-slot="dropdown-menu-sub-content"
|
data-slot="dropdown-menu-sub-content"
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-popover text-popover-foreground 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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
"bg-muted text-popover-foreground 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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border dark:border-neutral-700 p-1 shadow-lg",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,11 @@ import { AnimatePresence, motion } from "motion/react";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
|
|
||||||
function ExpandedGifOverlay({
|
function isVideoSrc(src: string) {
|
||||||
|
return /\.(mp4|webm|ogg)(\?|$)/i.test(src);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ExpandedMediaOverlay({
|
||||||
src,
|
src,
|
||||||
alt,
|
alt,
|
||||||
onClose,
|
onClose,
|
||||||
|
|
@ -21,6 +25,31 @@ function ExpandedGifOverlay({
|
||||||
return () => document.removeEventListener("keydown", handleKey);
|
return () => document.removeEventListener("keydown", handleKey);
|
||||||
}, [onClose]);
|
}, [onClose]);
|
||||||
|
|
||||||
|
const mediaElement = isVideoSrc(src) ? (
|
||||||
|
<motion.video
|
||||||
|
initial={{ scale: 0.85, opacity: 0 }}
|
||||||
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
|
exit={{ scale: 0.85, opacity: 0 }}
|
||||||
|
transition={{ duration: 0.25, ease: "easeOut" }}
|
||||||
|
src={src}
|
||||||
|
autoPlay
|
||||||
|
loop
|
||||||
|
muted
|
||||||
|
playsInline
|
||||||
|
className="max-h-[90vh] max-w-[90vw] cursor-pointer rounded-2xl shadow-2xl"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<motion.img
|
||||||
|
initial={{ scale: 0.85, opacity: 0 }}
|
||||||
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
|
exit={{ scale: 0.85, opacity: 0 }}
|
||||||
|
transition={{ duration: 0.25, ease: "easeOut" }}
|
||||||
|
src={src}
|
||||||
|
alt={alt}
|
||||||
|
className="max-h-[90vh] max-w-[90vw] cursor-pointer rounded-2xl shadow-2xl"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
|
|
@ -30,25 +59,22 @@ function ExpandedGifOverlay({
|
||||||
className="fixed inset-0 z-100 flex items-center justify-center bg-black/70 p-4 backdrop-blur-sm sm:p-8"
|
className="fixed inset-0 z-100 flex items-center justify-center bg-black/70 p-4 backdrop-blur-sm sm:p-8"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
>
|
>
|
||||||
<motion.img
|
{mediaElement}
|
||||||
initial={{ scale: 0.85, opacity: 0 }}
|
|
||||||
animate={{ scale: 1, opacity: 1 }}
|
|
||||||
exit={{ scale: 0.85, opacity: 0 }}
|
|
||||||
transition={{ duration: 0.25, ease: "easeOut" }}
|
|
||||||
src={src}
|
|
||||||
alt={alt}
|
|
||||||
className="max-h-[90vh] max-w-[90vw] cursor-pointer rounded-2xl shadow-2xl"
|
|
||||||
/>
|
|
||||||
</motion.div>,
|
</motion.div>,
|
||||||
document.body
|
document.body
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function useExpandedGif() {
|
function useExpandedMedia() {
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
const open = useCallback(() => setExpanded(true), []);
|
const open = useCallback(() => setExpanded(true), []);
|
||||||
const close = useCallback(() => setExpanded(false), []);
|
const close = useCallback(() => setExpanded(false), []);
|
||||||
return { expanded, open, close };
|
return { expanded, open, close };
|
||||||
}
|
}
|
||||||
|
|
||||||
export { ExpandedGifOverlay, useExpandedGif };
|
/** @deprecated Use ExpandedMediaOverlay instead */
|
||||||
|
const ExpandedGifOverlay = ExpandedMediaOverlay;
|
||||||
|
/** @deprecated Use useExpandedMedia instead */
|
||||||
|
const useExpandedGif = useExpandedMedia;
|
||||||
|
|
||||||
|
export { ExpandedMediaOverlay, useExpandedMedia, ExpandedGifOverlay, useExpandedGif };
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,9 @@ import {
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { KEYS } from "platejs";
|
import { KEYS } from "platejs";
|
||||||
import { useEditorReadOnly, useEditorRef } from "platejs/react";
|
import { useEditorReadOnly, useEditorRef } from "platejs/react";
|
||||||
import * as React from "react";
|
|
||||||
|
|
||||||
import { useEditorSave } from "@/components/editor/editor-save-context";
|
import { useEditorSave } from "@/components/editor/editor-save-context";
|
||||||
|
import { ShortcutKbd } from "@/components/ui/shortcut-kbd";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { usePlatformShortcut } from "@/hooks/use-platform-shortcut";
|
import { usePlatformShortcut } from "@/hooks/use-platform-shortcut";
|
||||||
|
|
||||||
|
|
@ -26,11 +26,20 @@ import { ModeToolbarButton } from "./mode-toolbar-button";
|
||||||
import { ToolbarButton, ToolbarGroup } from "./toolbar";
|
import { ToolbarButton, ToolbarGroup } from "./toolbar";
|
||||||
import { TurnIntoToolbarButton } from "./turn-into-toolbar-button";
|
import { TurnIntoToolbarButton } from "./turn-into-toolbar-button";
|
||||||
|
|
||||||
|
function TooltipWithShortcut({ label, keys }: { label: string; keys: string[] }) {
|
||||||
|
return (
|
||||||
|
<span className="flex items-center">
|
||||||
|
{label}
|
||||||
|
<ShortcutKbd keys={keys} />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function FixedToolbarButtons() {
|
export function FixedToolbarButtons() {
|
||||||
const readOnly = useEditorReadOnly();
|
const readOnly = useEditorReadOnly();
|
||||||
const editor = useEditorRef();
|
const editor = useEditorRef();
|
||||||
const { onSave, hasUnsavedChanges, isSaving, canToggleMode } = useEditorSave();
|
const { onSave, hasUnsavedChanges, isSaving, canToggleMode } = useEditorSave();
|
||||||
const { shortcut } = usePlatformShortcut();
|
const { shortcutKeys } = usePlatformShortcut();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full items-center">
|
<div className="flex w-full items-center">
|
||||||
|
|
@ -40,7 +49,7 @@ export function FixedToolbarButtons() {
|
||||||
<>
|
<>
|
||||||
<ToolbarGroup>
|
<ToolbarGroup>
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
tooltip={`Undo ${shortcut("Mod", "Z")}`}
|
tooltip={<TooltipWithShortcut label="Undo" keys={shortcutKeys("Mod", "Z")} />}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
editor.undo();
|
editor.undo();
|
||||||
editor.tf.focus();
|
editor.tf.focus();
|
||||||
|
|
@ -50,7 +59,9 @@ export function FixedToolbarButtons() {
|
||||||
</ToolbarButton>
|
</ToolbarButton>
|
||||||
|
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
tooltip={`Redo ${shortcut("Mod", "Shift", "Z")}`}
|
tooltip={
|
||||||
|
<TooltipWithShortcut label="Redo" keys={shortcutKeys("Mod", "Shift", "Z")} />
|
||||||
|
}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
editor.redo();
|
editor.redo();
|
||||||
editor.tf.focus();
|
editor.tf.focus();
|
||||||
|
|
@ -66,35 +77,51 @@ export function FixedToolbarButtons() {
|
||||||
</ToolbarGroup>
|
</ToolbarGroup>
|
||||||
|
|
||||||
<ToolbarGroup>
|
<ToolbarGroup>
|
||||||
<MarkToolbarButton nodeType={KEYS.bold} tooltip={`Bold ${shortcut("Mod", "B")}`}>
|
<MarkToolbarButton
|
||||||
|
nodeType={KEYS.bold}
|
||||||
|
tooltip={<TooltipWithShortcut label="Bold" keys={shortcutKeys("Mod", "B")} />}
|
||||||
|
>
|
||||||
<BoldIcon />
|
<BoldIcon />
|
||||||
</MarkToolbarButton>
|
</MarkToolbarButton>
|
||||||
|
|
||||||
<MarkToolbarButton nodeType={KEYS.italic} tooltip={`Italic ${shortcut("Mod", "I")}`}>
|
<MarkToolbarButton
|
||||||
|
nodeType={KEYS.italic}
|
||||||
|
tooltip={<TooltipWithShortcut label="Italic" keys={shortcutKeys("Mod", "I")} />}
|
||||||
|
>
|
||||||
<ItalicIcon />
|
<ItalicIcon />
|
||||||
</MarkToolbarButton>
|
</MarkToolbarButton>
|
||||||
|
|
||||||
<MarkToolbarButton
|
<MarkToolbarButton
|
||||||
nodeType={KEYS.underline}
|
nodeType={KEYS.underline}
|
||||||
tooltip={`Underline ${shortcut("Mod", "U")}`}
|
tooltip={<TooltipWithShortcut label="Underline" keys={shortcutKeys("Mod", "U")} />}
|
||||||
>
|
>
|
||||||
<UnderlineIcon />
|
<UnderlineIcon />
|
||||||
</MarkToolbarButton>
|
</MarkToolbarButton>
|
||||||
|
|
||||||
<MarkToolbarButton
|
<MarkToolbarButton
|
||||||
nodeType={KEYS.strikethrough}
|
nodeType={KEYS.strikethrough}
|
||||||
tooltip={`Strikethrough ${shortcut("Mod", "Shift", "X")}`}
|
tooltip={
|
||||||
|
<TooltipWithShortcut
|
||||||
|
label="Strikethrough"
|
||||||
|
keys={shortcutKeys("Mod", "Shift", "X")}
|
||||||
|
/>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<StrikethroughIcon />
|
<StrikethroughIcon />
|
||||||
</MarkToolbarButton>
|
</MarkToolbarButton>
|
||||||
|
|
||||||
<MarkToolbarButton nodeType={KEYS.code} tooltip={`Code ${shortcut("Mod", "E")}`}>
|
<MarkToolbarButton
|
||||||
|
nodeType={KEYS.code}
|
||||||
|
tooltip={<TooltipWithShortcut label="Code" keys={shortcutKeys("Mod", "E")} />}
|
||||||
|
>
|
||||||
<Code2Icon />
|
<Code2Icon />
|
||||||
</MarkToolbarButton>
|
</MarkToolbarButton>
|
||||||
|
|
||||||
<MarkToolbarButton
|
<MarkToolbarButton
|
||||||
nodeType={KEYS.highlight}
|
nodeType={KEYS.highlight}
|
||||||
tooltip={`Highlight ${shortcut("Mod", "Shift", "H")}`}
|
tooltip={
|
||||||
|
<TooltipWithShortcut label="Highlight" keys={shortcutKeys("Mod", "Shift", "H")} />
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<HighlighterIcon />
|
<HighlighterIcon />
|
||||||
</MarkToolbarButton>
|
</MarkToolbarButton>
|
||||||
|
|
@ -113,7 +140,13 @@ export function FixedToolbarButtons() {
|
||||||
{!readOnly && onSave && hasUnsavedChanges && (
|
{!readOnly && onSave && hasUnsavedChanges && (
|
||||||
<ToolbarGroup>
|
<ToolbarGroup>
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
tooltip={isSaving ? "Saving..." : `Save ${shortcut("Mod", "S")}`}
|
tooltip={
|
||||||
|
isSaving ? (
|
||||||
|
"Saving..."
|
||||||
|
) : (
|
||||||
|
<TooltipWithShortcut label="Save" keys={shortcutKeys("Mod", "S")} />
|
||||||
|
)
|
||||||
|
}
|
||||||
onClick={onSave}
|
onClick={onSave}
|
||||||
disabled={isSaving}
|
disabled={isSaving}
|
||||||
className="bg-primary text-primary-foreground hover:bg-primary/90"
|
className="bg-primary text-primary-foreground hover:bg-primary/90"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -9,59 +9,57 @@ const carouselItems = [
|
||||||
title: "Connect & Sync",
|
title: "Connect & Sync",
|
||||||
description:
|
description:
|
||||||
"Connect data sources like Notion, Drive and Gmail. Automatically sync to keep them updated.",
|
"Connect data sources like Notion, Drive and Gmail. Automatically sync to keep them updated.",
|
||||||
src: "/homepage/hero_tutorial/ConnectorFlowGif.gif",
|
src: "/homepage/hero_tutorial/ConnectorFlowGif.mp4",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Upload Documents",
|
title: "Upload Documents",
|
||||||
description: "Upload documents directly, from images to massive PDFs.",
|
description: "Upload documents directly, from images to massive PDFs.",
|
||||||
src: "/homepage/hero_tutorial/DocUploadGif.gif",
|
src: "/homepage/hero_tutorial/DocUploadGif.mp4",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Search & Citation",
|
title: "Search & Citation",
|
||||||
description: "Ask questions and get cited responses from your knowledge base.",
|
description: "Ask questions and get cited responses from your knowledge base.",
|
||||||
src: "/homepage/hero_tutorial/BSNCGif.gif",
|
src: "/homepage/hero_tutorial/BSNCGif.mp4",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Targeted Document Q&A",
|
title: "Targeted Document Q&A",
|
||||||
description: "Mention specific documents in chat for targeted answers.",
|
description: "Mention specific documents in chat for targeted answers.",
|
||||||
src: "/homepage/hero_tutorial/BQnaGif_compressed.gif",
|
src: "/homepage/hero_tutorial/BQnaGif_compressed.mp4",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Produce Reports Instantly",
|
title: "Produce Reports Instantly",
|
||||||
description: "Generate reports from your sources in many formats.",
|
description: "Generate reports from your sources in many formats.",
|
||||||
src: "/homepage/hero_tutorial/ReportGenGif_compressed.gif",
|
src: "/homepage/hero_tutorial/ReportGenGif_compressed.mp4",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Create Podcasts",
|
title: "Create Podcasts",
|
||||||
description: "Turn anything into a podcast in under 20 seconds.",
|
description: "Turn anything into a podcast in under 20 seconds.",
|
||||||
src: "/homepage/hero_tutorial/PodcastGenGif.gif",
|
src: "/homepage/hero_tutorial/PodcastGenGif.mp4",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Image Generation",
|
title: "Image Generation",
|
||||||
description: "Generate high-quality images easily from your conversations.",
|
description: "Generate high-quality images easily from your conversations.",
|
||||||
src: "/homepage/hero_tutorial/ImageGenGif.gif",
|
src: "/homepage/hero_tutorial/ImageGenGif.mp4",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Collaborative AI Chat",
|
title: "Collaborative AI Chat",
|
||||||
description: "Collaborate on AI-powered conversations in realtime with your team.",
|
description: "Collaborate on AI-powered conversations in realtime with your team.",
|
||||||
src: "/homepage/hero_realtime/RealTimeChatGif.gif",
|
src: "/homepage/hero_realtime/RealTimeChatGif.mp4",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Realtime Comments",
|
title: "Realtime Comments",
|
||||||
description: "Add comments and tag teammates on any message.",
|
description: "Add comments and tag teammates on any message.",
|
||||||
src: "/homepage/hero_realtime/RealTimeCommentsFlow.gif",
|
src: "/homepage/hero_realtime/RealTimeCommentsFlow.mp4",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
function HeroCarouselCard({
|
function HeroCarouselCard({
|
||||||
index,
|
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
src,
|
src,
|
||||||
isActive,
|
isActive,
|
||||||
onExpandedChange,
|
onExpandedChange,
|
||||||
}: {
|
}: {
|
||||||
index: number;
|
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
src: string;
|
src: string;
|
||||||
|
|
@ -69,53 +67,50 @@ function HeroCarouselCard({
|
||||||
onExpandedChange?: (expanded: boolean) => void;
|
onExpandedChange?: (expanded: boolean) => void;
|
||||||
}) {
|
}) {
|
||||||
const { expanded, open, close } = useExpandedGif();
|
const { expanded, open, close } = useExpandedGif();
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
const [frozenFrame, setFrozenFrame] = useState<string | null>(null);
|
||||||
|
const [hasLoaded, setHasLoaded] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onExpandedChange?.(expanded);
|
onExpandedChange?.(expanded);
|
||||||
}, [expanded, onExpandedChange]);
|
}, [expanded, onExpandedChange]);
|
||||||
const imgRef = useRef<HTMLImageElement>(null);
|
|
||||||
const [frozenFrame, setFrozenFrame] = useState<string | null>(null);
|
|
||||||
const [playKey, setPlayKey] = useState(0);
|
|
||||||
|
|
||||||
const captureFrame = useCallback((img: HTMLImageElement) => {
|
const captureFrame = useCallback((video: HTMLVideoElement) => {
|
||||||
try {
|
try {
|
||||||
const canvas = document.createElement("canvas");
|
const canvas = document.createElement("canvas");
|
||||||
canvas.width = img.naturalWidth;
|
canvas.width = video.videoWidth;
|
||||||
canvas.height = img.naturalHeight;
|
canvas.height = video.videoHeight;
|
||||||
canvas.getContext("2d")?.drawImage(img, 0, 0);
|
canvas.getContext("2d")?.drawImage(video, 0, 0);
|
||||||
setFrozenFrame(canvas.toDataURL());
|
setFrozenFrame(canvas.toDataURL("image/jpeg", 0.85));
|
||||||
} catch {
|
} catch {
|
||||||
/* cross-origin or other issue */
|
/* tainted canvas */
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const video = videoRef.current;
|
||||||
if (isActive) {
|
if (isActive) {
|
||||||
setPlayKey((k) => k + 1);
|
setHasLoaded(false);
|
||||||
setFrozenFrame(null);
|
if (video) {
|
||||||
|
video.currentTime = 0;
|
||||||
|
video.play().catch(() => {});
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
const img = imgRef.current;
|
if (video) {
|
||||||
if (img && img.complete && img.naturalWidth > 0) {
|
if (video.readyState >= 2) captureFrame(video);
|
||||||
captureFrame(img);
|
video.pause();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [isActive, captureFrame]);
|
}, [isActive, captureFrame]);
|
||||||
|
|
||||||
useEffect(() => {
|
const handleCanPlay = useCallback(() => {
|
||||||
if (!isActive && !frozenFrame) {
|
setHasLoaded(true);
|
||||||
const img = new Image();
|
}, []);
|
||||||
img.onload = () => captureFrame(img);
|
|
||||||
img.src = src;
|
|
||||||
}
|
|
||||||
}, [isActive, frozenFrame, src, captureFrame]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="rounded-2xl border border-neutral-200/60 bg-white shadow-xl sm:rounded-3xl dark:border-neutral-700/60 dark:bg-neutral-900">
|
<div className="rounded-2xl border border-neutral-200/60 bg-white shadow-xl sm:rounded-3xl dark:border-neutral-700/60 dark:bg-neutral-900">
|
||||||
<div className="flex items-center gap-3 border-b border-neutral-200/60 px-4 py-3 sm:px-6 sm:py-4 dark:border-neutral-700/60">
|
<div className="flex items-center gap-3 border-b border-neutral-200/60 px-4 py-3 sm:px-6 sm:py-4 dark:border-neutral-700/60">
|
||||||
{/* <span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-neutral-900 text-xs font-semibold text-white sm:h-8 sm:w-8 sm:text-sm dark:bg-white dark:text-neutral-900">
|
|
||||||
{index + 1}
|
|
||||||
</span> */}
|
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h3 className="truncate text-base font-semibold text-neutral-900 sm:text-xl dark:text-white">
|
<h3 className="truncate text-base font-semibold text-neutral-900 sm:text-xl dark:text-white">
|
||||||
{title}
|
{title}
|
||||||
|
|
@ -130,13 +125,28 @@ function HeroCarouselCard({
|
||||||
onClick={isActive ? open : undefined}
|
onClick={isActive ? open : undefined}
|
||||||
>
|
>
|
||||||
{isActive ? (
|
{isActive ? (
|
||||||
<img
|
<div className="relative">
|
||||||
ref={imgRef}
|
<video
|
||||||
key={`gif_${index}_${playKey}`}
|
ref={videoRef}
|
||||||
src={src}
|
src={src}
|
||||||
alt={title}
|
autoPlay
|
||||||
className="w-full rounded-lg sm:rounded-xl"
|
loop
|
||||||
/>
|
muted
|
||||||
|
playsInline
|
||||||
|
onCanPlay={handleCanPlay}
|
||||||
|
className="w-full rounded-lg sm:rounded-xl"
|
||||||
|
/>
|
||||||
|
{!hasLoaded && frozenFrame && (
|
||||||
|
<img
|
||||||
|
src={frozenFrame}
|
||||||
|
alt={title}
|
||||||
|
className="absolute inset-0 w-full rounded-lg sm:rounded-xl"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!hasLoaded && !frozenFrame && (
|
||||||
|
<div className="aspect-video w-full animate-pulse rounded-lg bg-neutral-100 sm:rounded-xl dark:bg-neutral-800" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
) : frozenFrame ? (
|
) : frozenFrame ? (
|
||||||
<img src={frozenFrame} alt={title} className="w-full rounded-lg sm:rounded-xl" />
|
<img src={frozenFrame} alt={title} className="w-full rounded-lg sm:rounded-xl" />
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -284,7 +294,6 @@ function HeroCarousel() {
|
||||||
transition={{ duration: 0.7, ease: [0.32, 0.72, 0, 1] }}
|
transition={{ duration: 0.7, ease: [0.32, 0.72, 0, 1] }}
|
||||||
>
|
>
|
||||||
<HeroCarouselCard
|
<HeroCarouselCard
|
||||||
index={i}
|
|
||||||
title={item.title}
|
title={item.title}
|
||||||
description={item.description}
|
description={item.description}
|
||||||
src={item.src}
|
src={item.src}
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue