mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-02 19:55:18 +02:00
Merge pull request #697 from CREDO23/implement-surfsense-docs-mentions
[Feat] Capture Google profile data, add user profile settings & document mentions picker improvements
This commit is contained in:
commit
f3f52170a0
27 changed files with 1054 additions and 613 deletions
|
|
@ -0,0 +1,72 @@
|
||||||
|
"""Add display_name and avatar_url columns to user table
|
||||||
|
|
||||||
|
This migration adds:
|
||||||
|
- display_name column for user's full name from OAuth
|
||||||
|
- avatar_url column for user's profile picture URL from OAuth
|
||||||
|
|
||||||
|
Revision ID: 62
|
||||||
|
Revises: 61
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "62"
|
||||||
|
down_revision: str | None = "61"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Add display_name and avatar_url columns to user table."""
|
||||||
|
|
||||||
|
# Add display_name column (nullable for existing users)
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_name = 'user' AND column_name = 'display_name'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE "user"
|
||||||
|
ADD COLUMN display_name VARCHAR;
|
||||||
|
END IF;
|
||||||
|
END$$;
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add avatar_url column (nullable for existing users)
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_name = 'user' AND column_name = 'avatar_url'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE "user"
|
||||||
|
ADD COLUMN avatar_url VARCHAR;
|
||||||
|
END IF;
|
||||||
|
END$$;
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Remove display_name and avatar_url columns from user table."""
|
||||||
|
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
ALTER TABLE "user"
|
||||||
|
DROP COLUMN IF EXISTS avatar_url;
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
ALTER TABLE "user"
|
||||||
|
DROP COLUMN IF EXISTS display_name;
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
"""Add author_id column to new_chat_messages table
|
||||||
|
|
||||||
|
Revision ID: 63
|
||||||
|
Revises: 62
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "63"
|
||||||
|
down_revision: str | None = "62"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Add author_id column to new_chat_messages table."""
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_name = 'new_chat_messages' AND column_name = 'author_id'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE new_chat_messages
|
||||||
|
ADD COLUMN author_id UUID REFERENCES "user"(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_new_chat_messages_author_id
|
||||||
|
ON new_chat_messages(author_id);
|
||||||
|
END IF;
|
||||||
|
END$$;
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Remove author_id column from new_chat_messages table."""
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
DROP INDEX IF EXISTS ix_new_chat_messages_author_id;
|
||||||
|
ALTER TABLE new_chat_messages
|
||||||
|
DROP COLUMN IF EXISTS author_id;
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
@ -413,8 +413,17 @@ class NewChatMessage(BaseModel, TimestampMixin):
|
||||||
index=True,
|
index=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Relationship
|
# Track who sent this message (for shared chats)
|
||||||
|
author_id = Column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("user.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
thread = relationship("NewChatThread", back_populates="messages")
|
thread = relationship("NewChatThread", back_populates="messages")
|
||||||
|
author = relationship("User")
|
||||||
|
|
||||||
|
|
||||||
class Document(BaseModel, TimestampMixin):
|
class Document(BaseModel, TimestampMixin):
|
||||||
|
|
@ -876,6 +885,10 @@ if config.AUTH_TYPE == "GOOGLE":
|
||||||
)
|
)
|
||||||
pages_used = Column(Integer, nullable=False, default=0, server_default="0")
|
pages_used = Column(Integer, nullable=False, default=0, server_default="0")
|
||||||
|
|
||||||
|
# User profile from OAuth
|
||||||
|
display_name = Column(String, nullable=True)
|
||||||
|
avatar_url = Column(String, nullable=True)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
|
|
||||||
class User(SQLAlchemyBaseUserTableUUID, Base):
|
class User(SQLAlchemyBaseUserTableUUID, Base):
|
||||||
|
|
@ -909,6 +922,10 @@ else:
|
||||||
)
|
)
|
||||||
pages_used = Column(Integer, nullable=False, default=0, server_default="0")
|
pages_used = Column(Integer, nullable=False, default=0, server_default="0")
|
||||||
|
|
||||||
|
# User profile (can be set manually for non-OAuth users)
|
||||||
|
display_name = Column(String, nullable=True)
|
||||||
|
avatar_url = Column(String, nullable=True)
|
||||||
|
|
||||||
|
|
||||||
engine = create_async_engine(DATABASE_URL)
|
engine = create_async_engine(DATABASE_URL)
|
||||||
async_session_maker = async_sessionmaker(engine, expire_on_commit=False)
|
async_session_maker = async_sessionmaker(engine, expire_on_commit=False)
|
||||||
|
|
|
||||||
|
|
@ -411,11 +411,9 @@ async def get_thread_messages(
|
||||||
Requires CHATS_READ permission.
|
Requires CHATS_READ permission.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Get thread with messages
|
# Get thread first
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(NewChatThread)
|
select(NewChatThread).filter(NewChatThread.id == thread_id)
|
||||||
.options(selectinload(NewChatThread.messages))
|
|
||||||
.filter(NewChatThread.id == thread_id)
|
|
||||||
)
|
)
|
||||||
thread = result.scalars().first()
|
thread = result.scalars().first()
|
||||||
|
|
||||||
|
|
@ -434,6 +432,15 @@ async def get_thread_messages(
|
||||||
# Check thread-level access based on visibility
|
# Check thread-level access based on visibility
|
||||||
await check_thread_access(session, thread, user)
|
await check_thread_access(session, thread, user)
|
||||||
|
|
||||||
|
# Get messages with their authors loaded
|
||||||
|
messages_result = await session.execute(
|
||||||
|
select(NewChatMessage)
|
||||||
|
.options(selectinload(NewChatMessage.author))
|
||||||
|
.filter(NewChatMessage.thread_id == thread_id)
|
||||||
|
.order_by(NewChatMessage.created_at)
|
||||||
|
)
|
||||||
|
db_messages = messages_result.scalars().all()
|
||||||
|
|
||||||
# Return messages in the format expected by assistant-ui
|
# Return messages in the format expected by assistant-ui
|
||||||
messages = [
|
messages = [
|
||||||
NewChatMessageRead(
|
NewChatMessageRead(
|
||||||
|
|
@ -442,8 +449,11 @@ async def get_thread_messages(
|
||||||
role=msg.role,
|
role=msg.role,
|
||||||
content=msg.content,
|
content=msg.content,
|
||||||
created_at=msg.created_at,
|
created_at=msg.created_at,
|
||||||
|
author_id=msg.author_id,
|
||||||
|
author_display_name=msg.author.display_name if msg.author else None,
|
||||||
|
author_avatar_url=msg.author.avatar_url if msg.author else None,
|
||||||
)
|
)
|
||||||
for msg in thread.messages
|
for msg in db_messages
|
||||||
]
|
]
|
||||||
|
|
||||||
return ThreadHistoryLoadResponse(messages=messages)
|
return ThreadHistoryLoadResponse(messages=messages)
|
||||||
|
|
@ -782,6 +792,7 @@ async def append_message(
|
||||||
thread_id=thread_id,
|
thread_id=thread_id,
|
||||||
role=message_role,
|
role=message_role,
|
||||||
content=message.content,
|
content=message.content,
|
||||||
|
author_id=user.id,
|
||||||
)
|
)
|
||||||
session.add(db_message)
|
session.add(db_message)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,9 @@ class NewChatMessageRead(NewChatMessageBase, IDModel, TimestampModel):
|
||||||
"""Schema for reading a message."""
|
"""Schema for reading a message."""
|
||||||
|
|
||||||
thread_id: int
|
thread_id: int
|
||||||
|
author_id: UUID | None = None
|
||||||
|
author_display_name: str | None = None
|
||||||
|
author_avatar_url: str | None = None
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ from fastapi_users import schemas
|
||||||
class UserRead(schemas.BaseUser[uuid.UUID]):
|
class UserRead(schemas.BaseUser[uuid.UUID]):
|
||||||
pages_limit: int
|
pages_limit: int
|
||||||
pages_used: int
|
pages_used: int
|
||||||
|
display_name: str | None = None
|
||||||
|
avatar_url: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class UserCreate(schemas.BaseUserCreate):
|
class UserCreate(schemas.BaseUserCreate):
|
||||||
|
|
@ -13,4 +15,5 @@ class UserCreate(schemas.BaseUserCreate):
|
||||||
|
|
||||||
|
|
||||||
class UserUpdate(schemas.BaseUserUpdate):
|
class UserUpdate(schemas.BaseUserUpdate):
|
||||||
pass
|
display_name: str | None = None
|
||||||
|
avatar_url: str | None = None
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import logging
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
|
import httpx
|
||||||
from fastapi import Depends, Request, Response
|
from fastapi import Depends, Request, Response
|
||||||
from fastapi.responses import JSONResponse, RedirectResponse
|
from fastapi.responses import JSONResponse, RedirectResponse
|
||||||
from fastapi_users import BaseUserManager, FastAPIUsers, UUIDIDMixin, models
|
from fastapi_users import BaseUserManager, FastAPIUsers, UUIDIDMixin, models
|
||||||
|
|
@ -46,6 +47,71 @@ class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
|
||||||
reset_password_token_secret = SECRET
|
reset_password_token_secret = SECRET
|
||||||
verification_token_secret = SECRET
|
verification_token_secret = SECRET
|
||||||
|
|
||||||
|
async def oauth_callback(
|
||||||
|
self,
|
||||||
|
oauth_name: str,
|
||||||
|
access_token: str,
|
||||||
|
account_id: str,
|
||||||
|
account_email: str,
|
||||||
|
expires_at: int | None = None,
|
||||||
|
refresh_token: str | None = None,
|
||||||
|
request: Request | None = None,
|
||||||
|
*,
|
||||||
|
associate_by_email: bool = False,
|
||||||
|
is_verified_by_default: bool = False,
|
||||||
|
) -> User:
|
||||||
|
"""
|
||||||
|
Override OAuth callback to capture Google profile data (name, avatar).
|
||||||
|
"""
|
||||||
|
# Call parent implementation to create/get user
|
||||||
|
user = await super().oauth_callback(
|
||||||
|
oauth_name,
|
||||||
|
access_token,
|
||||||
|
account_id,
|
||||||
|
account_email,
|
||||||
|
expires_at,
|
||||||
|
refresh_token,
|
||||||
|
request,
|
||||||
|
associate_by_email=associate_by_email,
|
||||||
|
is_verified_by_default=is_verified_by_default,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fetch and store Google profile data if not already set
|
||||||
|
if oauth_name == "google" and (not user.display_name or not user.avatar_url):
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
response = await client.get(
|
||||||
|
"https://people.googleapis.com/v1/people/me",
|
||||||
|
params={"personFields": "names,photos"},
|
||||||
|
headers={"Authorization": f"Bearer {access_token}"},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
profile = response.json()
|
||||||
|
|
||||||
|
update_dict = {}
|
||||||
|
|
||||||
|
# Extract name from names array
|
||||||
|
names = profile.get("names", [])
|
||||||
|
if not user.display_name and names:
|
||||||
|
display_name = names[0].get("displayName")
|
||||||
|
if display_name:
|
||||||
|
update_dict["display_name"] = display_name
|
||||||
|
|
||||||
|
# Extract photo URL from photos array
|
||||||
|
photos = profile.get("photos", [])
|
||||||
|
if not user.avatar_url and photos:
|
||||||
|
photo_url = photos[0].get("url")
|
||||||
|
if photo_url:
|
||||||
|
update_dict["avatar_url"] = photo_url
|
||||||
|
|
||||||
|
if update_dict:
|
||||||
|
user = await self.user_db.update(user, update_dict)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to fetch Google profile: {e}")
|
||||||
|
|
||||||
|
return user
|
||||||
|
|
||||||
async def on_after_register(self, user: User, request: Request | None = None):
|
async def on_after_register(self, user: User, request: Request | None = None):
|
||||||
"""
|
"""
|
||||||
Called after a user registers. Creates a default search space for the user
|
Called after a user registers. Creates a default search space for the user
|
||||||
|
|
|
||||||
|
|
@ -79,25 +79,17 @@ export function DocumentsTableShell({
|
||||||
[documents, sortKey, sortDesc]
|
[documents, sortKey, sortDesc]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Filter out SURFSENSE_DOCS for selection purposes
|
const allSelectedOnPage = sorted.length > 0 && sorted.every((d) => selectedIds.has(d.id));
|
||||||
const selectableDocs = React.useMemo(
|
const someSelectedOnPage = sorted.some((d) => selectedIds.has(d.id)) && !allSelectedOnPage;
|
||||||
() => sorted.filter((d) => d.document_type !== "SURFSENSE_DOCS"),
|
|
||||||
[sorted]
|
|
||||||
);
|
|
||||||
|
|
||||||
const allSelectedOnPage =
|
|
||||||
selectableDocs.length > 0 && selectableDocs.every((d) => selectedIds.has(d.id));
|
|
||||||
const someSelectedOnPage =
|
|
||||||
selectableDocs.some((d) => selectedIds.has(d.id)) && !allSelectedOnPage;
|
|
||||||
|
|
||||||
const toggleAll = (checked: boolean) => {
|
const toggleAll = (checked: boolean) => {
|
||||||
const next = new Set(selectedIds);
|
const next = new Set(selectedIds);
|
||||||
if (checked)
|
if (checked)
|
||||||
selectableDocs.forEach((d) => {
|
sorted.forEach((d) => {
|
||||||
next.add(d.id);
|
next.add(d.id);
|
||||||
});
|
});
|
||||||
else
|
else
|
||||||
selectableDocs.forEach((d) => {
|
sorted.forEach((d) => {
|
||||||
next.delete(d.id);
|
next.delete(d.id);
|
||||||
});
|
});
|
||||||
setSelectedIds(next);
|
setSelectedIds(next);
|
||||||
|
|
@ -238,10 +230,9 @@ export function DocumentsTableShell({
|
||||||
const icon = getDocumentTypeIcon(doc.document_type);
|
const icon = getDocumentTypeIcon(doc.document_type);
|
||||||
const title = doc.title;
|
const title = doc.title;
|
||||||
const truncatedTitle = title.length > 30 ? `${title.slice(0, 30)}...` : title;
|
const truncatedTitle = title.length > 30 ? `${title.slice(0, 30)}...` : title;
|
||||||
const isSurfsenseDoc = doc.document_type === "SURFSENSE_DOCS";
|
|
||||||
return (
|
return (
|
||||||
<motion.tr
|
<motion.tr
|
||||||
key={`${doc.document_type}-${doc.id}`}
|
key={doc.id}
|
||||||
initial={{ opacity: 0, y: 10 }}
|
initial={{ opacity: 0, y: 10 }}
|
||||||
animate={{
|
animate={{
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
|
|
@ -258,9 +249,8 @@ export function DocumentsTableShell({
|
||||||
>
|
>
|
||||||
<TableCell className="px-4 py-3">
|
<TableCell className="px-4 py-3">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={selectedIds.has(doc.id) && !isSurfsenseDoc}
|
checked={selectedIds.has(doc.id)}
|
||||||
onCheckedChange={(v) => !isSurfsenseDoc && toggleOne(doc.id, !!v)}
|
onCheckedChange={(v) => toggleOne(doc.id, !!v)}
|
||||||
disabled={isSurfsenseDoc}
|
|
||||||
aria-label="Select row"
|
aria-label="Select row"
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ import { DocumentsFilters } from "./components/DocumentsFilters";
|
||||||
import { DocumentsTableShell, type SortKey } from "./components/DocumentsTableShell";
|
import { DocumentsTableShell, type SortKey } from "./components/DocumentsTableShell";
|
||||||
import { PaginationControls } from "./components/PaginationControls";
|
import { PaginationControls } from "./components/PaginationControls";
|
||||||
import { ProcessingIndicator } from "./components/ProcessingIndicator";
|
import { ProcessingIndicator } from "./components/ProcessingIndicator";
|
||||||
import type { ColumnVisibility, Document } from "./components/types";
|
import type { ColumnVisibility } from "./components/types";
|
||||||
|
|
||||||
function useDebounced<T>(value: T, delay = 250) {
|
function useDebounced<T>(value: T, delay = 250) {
|
||||||
const [debounced, setDebounced] = useState(value);
|
const [debounced, setDebounced] = useState(value);
|
||||||
|
|
@ -60,39 +60,30 @@ export default function DocumentsTable() {
|
||||||
const { data: rawTypeCounts } = useAtomValue(documentTypeCountsAtom);
|
const { data: rawTypeCounts } = useAtomValue(documentTypeCountsAtom);
|
||||||
const { mutateAsync: deleteDocumentMutation } = useAtomValue(deleteDocumentMutationAtom);
|
const { mutateAsync: deleteDocumentMutation } = useAtomValue(deleteDocumentMutationAtom);
|
||||||
|
|
||||||
// Filter out SURFSENSE_DOCS from active types for regular documents API
|
// Build query parameters for fetching documents
|
||||||
const regularDocumentTypes = useMemo(
|
|
||||||
() => activeTypes.filter((t) => t !== "SURFSENSE_DOCS"),
|
|
||||||
[activeTypes]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Check if only SURFSENSE_DOCS is selected (skip regular docs query)
|
|
||||||
const onlySurfsenseDocsSelected = activeTypes.length === 1 && activeTypes[0] === "SURFSENSE_DOCS";
|
|
||||||
|
|
||||||
// Build query parameters for fetching documents (excluding SURFSENSE_DOCS type)
|
|
||||||
const queryParams = useMemo(
|
const queryParams = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
search_space_id: searchSpaceId,
|
search_space_id: searchSpaceId,
|
||||||
page: pageIndex,
|
page: pageIndex,
|
||||||
page_size: pageSize,
|
page_size: pageSize,
|
||||||
...(regularDocumentTypes.length > 0 && { document_types: regularDocumentTypes }),
|
...(activeTypes.length > 0 && { document_types: activeTypes }),
|
||||||
}),
|
}),
|
||||||
[searchSpaceId, pageIndex, pageSize, regularDocumentTypes]
|
[searchSpaceId, pageIndex, pageSize, activeTypes]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Build search query parameters (excluding SURFSENSE_DOCS type)
|
// Build search query parameters
|
||||||
const searchQueryParams = useMemo(
|
const searchQueryParams = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
search_space_id: searchSpaceId,
|
search_space_id: searchSpaceId,
|
||||||
page: pageIndex,
|
page: pageIndex,
|
||||||
page_size: pageSize,
|
page_size: pageSize,
|
||||||
title: debouncedSearch.trim(),
|
title: debouncedSearch.trim(),
|
||||||
...(regularDocumentTypes.length > 0 && { document_types: regularDocumentTypes }),
|
...(activeTypes.length > 0 && { document_types: activeTypes }),
|
||||||
}),
|
}),
|
||||||
[searchSpaceId, pageIndex, pageSize, regularDocumentTypes, debouncedSearch]
|
[searchSpaceId, pageIndex, pageSize, activeTypes, debouncedSearch]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Use query for fetching documents (disabled when only SURFSENSE_DOCS is selected)
|
// Use query for fetching documents
|
||||||
const {
|
const {
|
||||||
data: documentsResponse,
|
data: documentsResponse,
|
||||||
isLoading: isDocumentsLoading,
|
isLoading: isDocumentsLoading,
|
||||||
|
|
@ -102,10 +93,10 @@ export default function DocumentsTable() {
|
||||||
queryKey: cacheKeys.documents.globalQueryParams(queryParams),
|
queryKey: cacheKeys.documents.globalQueryParams(queryParams),
|
||||||
queryFn: () => documentsApiService.getDocuments({ queryParams }),
|
queryFn: () => documentsApiService.getDocuments({ queryParams }),
|
||||||
staleTime: 3 * 60 * 1000, // 3 minutes
|
staleTime: 3 * 60 * 1000, // 3 minutes
|
||||||
enabled: !!searchSpaceId && !debouncedSearch.trim() && !onlySurfsenseDocsSelected,
|
enabled: !!searchSpaceId && !debouncedSearch.trim(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Use query for searching documents (disabled when only SURFSENSE_DOCS is selected)
|
// Use query for searching documents
|
||||||
const {
|
const {
|
||||||
data: searchResponse,
|
data: searchResponse,
|
||||||
isLoading: isSearchLoading,
|
isLoading: isSearchLoading,
|
||||||
|
|
@ -115,114 +106,20 @@ export default function DocumentsTable() {
|
||||||
queryKey: cacheKeys.documents.globalQueryParams(searchQueryParams),
|
queryKey: cacheKeys.documents.globalQueryParams(searchQueryParams),
|
||||||
queryFn: () => documentsApiService.searchDocuments({ queryParams: searchQueryParams }),
|
queryFn: () => documentsApiService.searchDocuments({ queryParams: searchQueryParams }),
|
||||||
staleTime: 3 * 60 * 1000, // 3 minutes
|
staleTime: 3 * 60 * 1000, // 3 minutes
|
||||||
enabled: !!searchSpaceId && !!debouncedSearch.trim() && !onlySurfsenseDocsSelected,
|
enabled: !!searchSpaceId && !!debouncedSearch.trim(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Determine if we should show SurfSense docs (when no type filter or SURFSENSE_DOCS is selected)
|
|
||||||
const showSurfsenseDocs =
|
|
||||||
activeTypes.length === 0 || activeTypes.includes("SURFSENSE_DOCS" as DocumentTypeEnum);
|
|
||||||
|
|
||||||
// Use query for fetching SurfSense docs
|
|
||||||
const {
|
|
||||||
data: surfsenseDocsResponse,
|
|
||||||
isLoading: isSurfsenseDocsLoading,
|
|
||||||
refetch: refetchSurfsenseDocs,
|
|
||||||
} = useQuery({
|
|
||||||
queryKey: ["surfsense-docs", debouncedSearch, pageIndex, pageSize],
|
|
||||||
queryFn: () =>
|
|
||||||
documentsApiService.getSurfsenseDocs({
|
|
||||||
queryParams: {
|
|
||||||
page: pageIndex,
|
|
||||||
page_size: pageSize,
|
|
||||||
title: debouncedSearch.trim() || undefined,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
staleTime: 3 * 60 * 1000, // 3 minutes
|
|
||||||
enabled: showSurfsenseDocs,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Transform SurfSense docs to match the Document type
|
|
||||||
const surfsenseDocsAsDocuments: Document[] = useMemo(() => {
|
|
||||||
if (!surfsenseDocsResponse?.items) return [];
|
|
||||||
return surfsenseDocsResponse.items.map((doc) => ({
|
|
||||||
id: doc.id,
|
|
||||||
title: doc.title,
|
|
||||||
document_type: "SURFSENSE_DOCS",
|
|
||||||
document_metadata: { source: doc.source },
|
|
||||||
content: doc.content,
|
|
||||||
created_at: doc.created_at || doc.updated_at || new Date().toISOString(),
|
|
||||||
search_space_id: -1, // Special value for global docs
|
|
||||||
}));
|
|
||||||
}, [surfsenseDocsResponse]);
|
|
||||||
|
|
||||||
// Merge type counts with SURFSENSE_DOCS count
|
|
||||||
const typeCounts = useMemo(() => {
|
|
||||||
const counts = { ...(rawTypeCounts || {}) };
|
|
||||||
if (surfsenseDocsResponse?.total) {
|
|
||||||
counts.SURFSENSE_DOCS = surfsenseDocsResponse.total;
|
|
||||||
}
|
|
||||||
return counts;
|
|
||||||
}, [rawTypeCounts, surfsenseDocsResponse?.total]);
|
|
||||||
|
|
||||||
// Extract documents and total based on search state
|
// Extract documents and total based on search state
|
||||||
const regularDocuments = debouncedSearch.trim()
|
const documents = debouncedSearch.trim()
|
||||||
? searchResponse?.items || []
|
? searchResponse?.items || []
|
||||||
: documentsResponse?.items || [];
|
: documentsResponse?.items || [];
|
||||||
const regularTotal = debouncedSearch.trim()
|
const total = debouncedSearch.trim() ? searchResponse?.total || 0 : documentsResponse?.total || 0;
|
||||||
? searchResponse?.total || 0
|
|
||||||
: documentsResponse?.total || 0;
|
|
||||||
|
|
||||||
// Merge regular documents with SurfSense docs
|
const loading = debouncedSearch.trim() ? isSearchLoading : isDocumentsLoading;
|
||||||
const documents = useMemo(() => {
|
const error = debouncedSearch.trim() ? searchError : documentsError;
|
||||||
// If filtering by type and not including SURFSENSE_DOCS, only show regular docs
|
|
||||||
if (activeTypes.length > 0 && !activeTypes.includes("SURFSENSE_DOCS" as DocumentTypeEnum)) {
|
|
||||||
return regularDocuments;
|
|
||||||
}
|
|
||||||
// If filtering only by SURFSENSE_DOCS, only show surfsense docs
|
|
||||||
if (activeTypes.length === 1 && activeTypes[0] === "SURFSENSE_DOCS") {
|
|
||||||
return surfsenseDocsAsDocuments;
|
|
||||||
}
|
|
||||||
// Otherwise, merge both (surfsense docs first)
|
|
||||||
return [...surfsenseDocsAsDocuments, ...regularDocuments];
|
|
||||||
}, [regularDocuments, surfsenseDocsAsDocuments, activeTypes]);
|
|
||||||
|
|
||||||
const total = useMemo(() => {
|
// Display results directly
|
||||||
if (activeTypes.length > 0 && !activeTypes.includes("SURFSENSE_DOCS" as DocumentTypeEnum)) {
|
const displayDocs = documents;
|
||||||
return regularTotal;
|
|
||||||
}
|
|
||||||
if (activeTypes.length === 1 && activeTypes[0] === "SURFSENSE_DOCS") {
|
|
||||||
return surfsenseDocsResponse?.total || 0;
|
|
||||||
}
|
|
||||||
return regularTotal + (surfsenseDocsResponse?.total || 0);
|
|
||||||
}, [regularTotal, surfsenseDocsResponse?.total, activeTypes]);
|
|
||||||
|
|
||||||
const loading = useMemo(() => {
|
|
||||||
// If only SURFSENSE_DOCS selected, only check surfsense loading
|
|
||||||
if (onlySurfsenseDocsSelected) {
|
|
||||||
return isSurfsenseDocsLoading;
|
|
||||||
}
|
|
||||||
// Otherwise check both regular docs and surfsense docs loading
|
|
||||||
const regularLoading = debouncedSearch.trim() ? isSearchLoading : isDocumentsLoading;
|
|
||||||
return regularLoading || (showSurfsenseDocs && isSurfsenseDocsLoading);
|
|
||||||
}, [
|
|
||||||
onlySurfsenseDocsSelected,
|
|
||||||
isSurfsenseDocsLoading,
|
|
||||||
debouncedSearch,
|
|
||||||
isSearchLoading,
|
|
||||||
isDocumentsLoading,
|
|
||||||
showSurfsenseDocs,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const error = useMemo(() => {
|
|
||||||
// If only SURFSENSE_DOCS selected, no regular docs errors
|
|
||||||
if (onlySurfsenseDocsSelected) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return debouncedSearch.trim() ? searchError : documentsError;
|
|
||||||
}, [onlySurfsenseDocsSelected, debouncedSearch, searchError, documentsError]);
|
|
||||||
|
|
||||||
// Display server-filtered results directly
|
|
||||||
const displayDocs = documents || [];
|
|
||||||
const displayTotal = total;
|
const displayTotal = total;
|
||||||
const pageStart = pageIndex * pageSize;
|
const pageStart = pageIndex * pageSize;
|
||||||
const pageEnd = Math.min(pageStart + pageSize, displayTotal);
|
const pageEnd = Math.min(pageStart + pageSize, displayTotal);
|
||||||
|
|
@ -242,33 +139,16 @@ export default function DocumentsTable() {
|
||||||
if (isRefreshing) return;
|
if (isRefreshing) return;
|
||||||
setIsRefreshing(true);
|
setIsRefreshing(true);
|
||||||
try {
|
try {
|
||||||
const refetchPromises: Promise<unknown>[] = [];
|
if (debouncedSearch.trim()) {
|
||||||
// Only refetch regular documents if not in "only surfsense docs" mode
|
await refetchSearch();
|
||||||
if (!onlySurfsenseDocsSelected) {
|
} else {
|
||||||
if (debouncedSearch.trim()) {
|
await refetchDocuments();
|
||||||
refetchPromises.push(refetchSearch());
|
|
||||||
} else {
|
|
||||||
refetchPromises.push(refetchDocuments());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (showSurfsenseDocs) {
|
|
||||||
refetchPromises.push(refetchSurfsenseDocs());
|
|
||||||
}
|
|
||||||
await Promise.all(refetchPromises);
|
|
||||||
toast.success(t("refresh_success") || "Documents refreshed");
|
toast.success(t("refresh_success") || "Documents refreshed");
|
||||||
} finally {
|
} finally {
|
||||||
setIsRefreshing(false);
|
setIsRefreshing(false);
|
||||||
}
|
}
|
||||||
}, [
|
}, [debouncedSearch, refetchSearch, refetchDocuments, t, isRefreshing]);
|
||||||
debouncedSearch,
|
|
||||||
refetchSearch,
|
|
||||||
refetchDocuments,
|
|
||||||
refetchSurfsenseDocs,
|
|
||||||
showSurfsenseDocs,
|
|
||||||
onlySurfsenseDocsSelected,
|
|
||||||
t,
|
|
||||||
isRefreshing,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Set up smart polling for active tasks - only polls when tasks are in progress
|
// Set up smart polling for active tasks - only polls when tasks are in progress
|
||||||
const { summary } = useLogsSummary(searchSpaceId, 24, {
|
const { summary } = useLogsSummary(searchSpaceId, 24, {
|
||||||
|
|
@ -385,7 +265,7 @@ export default function DocumentsTable() {
|
||||||
<ProcessingIndicator documentProcessorTasksCount={documentProcessorTasksCount} />
|
<ProcessingIndicator documentProcessorTasksCount={documentProcessorTasksCount} />
|
||||||
|
|
||||||
<DocumentsFilters
|
<DocumentsFilters
|
||||||
typeCounts={typeCounts ?? {}}
|
typeCounts={rawTypeCounts ?? {}}
|
||||||
selectedIds={selectedIds}
|
selectedIds={selectedIds}
|
||||||
onSearch={setSearch}
|
onSearch={setSearch}
|
||||||
searchValue={search}
|
searchValue={search}
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ import {
|
||||||
// extractWriteTodosFromContent,
|
// extractWriteTodosFromContent,
|
||||||
hydratePlanStateAtom,
|
hydratePlanStateAtom,
|
||||||
} from "@/atoms/chat/plan-state.atom";
|
} from "@/atoms/chat/plan-state.atom";
|
||||||
|
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 { ChatHeader } from "@/components/new-chat/chat-header";
|
||||||
import type { ThinkingStep } from "@/components/tool-ui/deepagent-thinking";
|
import type { ThinkingStep } from "@/components/tool-ui/deepagent-thinking";
|
||||||
|
|
@ -185,12 +186,25 @@ function convertToThreadMessage(msg: MessageRecord): ThreadMessageLike {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build metadata.custom for author display in shared chats
|
||||||
|
const metadata = msg.author_id
|
||||||
|
? {
|
||||||
|
custom: {
|
||||||
|
author: {
|
||||||
|
displayName: msg.author_display_name ?? null,
|
||||||
|
avatarUrl: msg.author_avatar_url ?? null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: `msg-${msg.id}`,
|
id: `msg-${msg.id}`,
|
||||||
role: msg.role,
|
role: msg.role,
|
||||||
content,
|
content,
|
||||||
createdAt: new Date(msg.created_at),
|
createdAt: new Date(msg.created_at),
|
||||||
attachments,
|
attachments,
|
||||||
|
metadata,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -238,6 +252,9 @@ export default function NewChatPage() {
|
||||||
const setMessageDocumentsMap = useSetAtom(messageDocumentsMapAtom);
|
const setMessageDocumentsMap = useSetAtom(messageDocumentsMapAtom);
|
||||||
const hydratePlanState = useSetAtom(hydratePlanStateAtom);
|
const hydratePlanState = useSetAtom(hydratePlanStateAtom);
|
||||||
|
|
||||||
|
// Get current user for author info in shared chats
|
||||||
|
const { data: currentUser } = useAtomValue(currentUserAtom);
|
||||||
|
|
||||||
// Create the attachment adapter for file processing
|
// Create the attachment adapter for file processing
|
||||||
const attachmentAdapter = useMemo(() => createAttachmentAdapter(), []);
|
const attachmentAdapter = useMemo(() => createAttachmentAdapter(), []);
|
||||||
|
|
||||||
|
|
@ -306,12 +323,6 @@ export default function NewChatPage() {
|
||||||
if (steps.length > 0) {
|
if (steps.length > 0) {
|
||||||
restoredThinkingSteps.set(`msg-${msg.id}`, steps);
|
restoredThinkingSteps.set(`msg-${msg.id}`, steps);
|
||||||
}
|
}
|
||||||
// Hydrate write_todos plan state from persisted tool calls
|
|
||||||
// Disabled for now
|
|
||||||
// const writeTodosCalls = extractWriteTodosFromContent(msg.content);
|
|
||||||
// for (const todoData of writeTodosCalls) {
|
|
||||||
// hydratePlanState(todoData);
|
|
||||||
// }
|
|
||||||
}
|
}
|
||||||
if (msg.role === "user") {
|
if (msg.role === "user") {
|
||||||
const docs = extractMentionedDocuments(msg.content);
|
const docs = extractMentionedDocuments(msg.content);
|
||||||
|
|
@ -448,13 +459,27 @@ export default function NewChatPage() {
|
||||||
|
|
||||||
// Add user message to state
|
// Add user message to state
|
||||||
const userMsgId = `msg-user-${Date.now()}`;
|
const userMsgId = `msg-user-${Date.now()}`;
|
||||||
|
|
||||||
|
// Include author metadata for shared chats
|
||||||
|
const authorMetadata =
|
||||||
|
currentThread?.visibility === "SEARCH_SPACE" && currentUser
|
||||||
|
? {
|
||||||
|
custom: {
|
||||||
|
author: {
|
||||||
|
displayName: currentUser.display_name ?? null,
|
||||||
|
avatarUrl: currentUser.avatar_url ?? null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const userMessage: ThreadMessageLike = {
|
const userMessage: ThreadMessageLike = {
|
||||||
id: userMsgId,
|
id: userMsgId,
|
||||||
role: "user",
|
role: "user",
|
||||||
content: message.content,
|
content: message.content,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
// Include attachments so they can be displayed
|
|
||||||
attachments: message.attachments || [],
|
attachments: message.attachments || [],
|
||||||
|
metadata: authorMetadata,
|
||||||
};
|
};
|
||||||
setMessages((prev) => [...prev, userMessage]);
|
setMessages((prev) => [...prev, userMessage]);
|
||||||
|
|
||||||
|
|
@ -884,6 +909,8 @@ export default function NewChatPage() {
|
||||||
setMentionedDocuments,
|
setMentionedDocuments,
|
||||||
setMessageDocumentsMap,
|
setMessageDocumentsMap,
|
||||||
queryClient,
|
queryClient,
|
||||||
|
currentThread,
|
||||||
|
currentUser,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,123 @@
|
||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,181 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import { Loader2, 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 { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
||||||
|
import { updateUserMutationAtom } from "@/atoms/user/user-mutation.atoms";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
||||||
|
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">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin 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 && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
{t("profile_save")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,155 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ArrowLeft, ChevronRight, X } from "lucide-react";
|
||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
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>
|
||||||
|
</aside>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -1,286 +1,27 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {
|
import { Key, User } from "lucide-react";
|
||||||
ArrowLeft,
|
import { motion } from "motion/react";
|
||||||
Check,
|
|
||||||
ChevronRight,
|
|
||||||
Copy,
|
|
||||||
Key,
|
|
||||||
type LucideIcon,
|
|
||||||
Menu,
|
|
||||||
Shield,
|
|
||||||
X,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { AnimatePresence, motion } from "motion/react";
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
import { ApiKeyContent } from "./components/ApiKeyContent";
|
||||||
import { Button } from "@/components/ui/button";
|
import { ProfileContent } from "./components/ProfileContent";
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
import { UserSettingsSidebar, type SettingsNavItem } from "./components/UserSettingsSidebar";
|
||||||
import { useApiKey } from "@/hooks/use-api-key";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
interface SettingsNavItem {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
description: string;
|
|
||||||
icon: LucideIcon;
|
|
||||||
}
|
|
||||||
|
|
||||||
function UserSettingsSidebar({
|
|
||||||
activeSection,
|
|
||||||
onSectionChange,
|
|
||||||
onBackToApp,
|
|
||||||
isOpen,
|
|
||||||
onClose,
|
|
||||||
navItems,
|
|
||||||
}: {
|
|
||||||
activeSection: string;
|
|
||||||
onSectionChange: (section: string) => void;
|
|
||||||
onBackToApp: () => void;
|
|
||||||
isOpen: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
navItems: SettingsNavItem[];
|
|
||||||
}) {
|
|
||||||
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>
|
|
||||||
</aside>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ApiKeyContent({ onMenuClick }: { onMenuClick: () => void }) {
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function UserSettingsPage() {
|
export default function UserSettingsPage() {
|
||||||
const t = useTranslations("userSettings");
|
const t = useTranslations("userSettings");
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [activeSection, setActiveSection] = useState("api-key");
|
const [activeSection, setActiveSection] = useState("profile");
|
||||||
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
|
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
|
||||||
|
|
||||||
const navItems: SettingsNavItem[] = [
|
const navItems: SettingsNavItem[] = [
|
||||||
|
{
|
||||||
|
id: "profile",
|
||||||
|
label: t("profile_nav_label"),
|
||||||
|
description: t("profile_nav_description"),
|
||||||
|
icon: User,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "api-key",
|
id: "api-key",
|
||||||
label: t("api_key_nav_label"),
|
label: t("api_key_nav_label"),
|
||||||
|
|
@ -310,6 +51,9 @@ export default function UserSettingsPage() {
|
||||||
onClose={() => setIsSidebarOpen(false)}
|
onClose={() => setIsSidebarOpen(false)}
|
||||||
navItems={navItems}
|
navItems={navItems}
|
||||||
/>
|
/>
|
||||||
|
{activeSection === "profile" && (
|
||||||
|
<ProfileContent onMenuClick={() => setIsSidebarOpen(true)} />
|
||||||
|
)}
|
||||||
{activeSection === "api-key" && (
|
{activeSection === "api-key" && (
|
||||||
<ApiKeyContent onMenuClick={() => setIsSidebarOpen(true)} />
|
<ApiKeyContent onMenuClick={() => setIsSidebarOpen(true)} />
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
19
surfsense_web/atoms/user/user-mutation.atoms.ts
Normal file
19
surfsense_web/atoms/user/user-mutation.atoms.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { atomWithMutation, queryClientAtom } from "jotai-tanstack-query";
|
||||||
|
import type { UpdateUserRequest } from "@/contracts/types/user.types";
|
||||||
|
import { userApiService } from "@/lib/apis/user-api.service";
|
||||||
|
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||||
|
|
||||||
|
export const updateUserMutationAtom = atomWithMutation((get) => {
|
||||||
|
const queryClient = get(queryClientAtom);
|
||||||
|
|
||||||
|
return {
|
||||||
|
mutationKey: cacheKeys.user.current(),
|
||||||
|
mutationFn: async (request: UpdateUserRequest) => {
|
||||||
|
return userApiService.updateMe(request);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: cacheKeys.user.current() });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
|
@ -119,10 +119,7 @@ const DocumentUploadPopupContent: FC<{
|
||||||
<div className="flex-1 min-h-0 relative overflow-hidden">
|
<div className="flex-1 min-h-0 relative overflow-hidden">
|
||||||
<div className="h-full overflow-y-auto">
|
<div className="h-full overflow-y-auto">
|
||||||
<div className="px-6 sm:px-12 pb-5 sm:pb-16">
|
<div className="px-6 sm:px-12 pb-5 sm:pb-16">
|
||||||
<DocumentUploadTab
|
<DocumentUploadTab searchSpaceId={searchSpaceId} onSuccess={handleSuccess} />
|
||||||
searchSpaceId={searchSpaceId}
|
|
||||||
onSuccess={handleSuccess}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Bottom fade shadow */}
|
{/* Bottom fade shadow */}
|
||||||
|
|
|
||||||
|
|
@ -19,9 +19,7 @@ import {
|
||||||
ChevronRightIcon,
|
ChevronRightIcon,
|
||||||
CopyIcon,
|
CopyIcon,
|
||||||
DownloadIcon,
|
DownloadIcon,
|
||||||
FileText,
|
|
||||||
Loader2,
|
Loader2,
|
||||||
PencilIcon,
|
|
||||||
RefreshCwIcon,
|
RefreshCwIcon,
|
||||||
SquareIcon,
|
SquareIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
@ -31,7 +29,6 @@ import { createPortal } from "react-dom";
|
||||||
import {
|
import {
|
||||||
mentionedDocumentIdsAtom,
|
mentionedDocumentIdsAtom,
|
||||||
mentionedDocumentsAtom,
|
mentionedDocumentsAtom,
|
||||||
messageDocumentsMapAtom,
|
|
||||||
} from "@/atoms/chat/mentioned-documents.atom";
|
} from "@/atoms/chat/mentioned-documents.atom";
|
||||||
import {
|
import {
|
||||||
globalNewLLMConfigsAtom,
|
globalNewLLMConfigsAtom,
|
||||||
|
|
@ -42,8 +39,8 @@ import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
||||||
import {
|
import {
|
||||||
ComposerAddAttachment,
|
ComposerAddAttachment,
|
||||||
ComposerAttachments,
|
ComposerAttachments,
|
||||||
UserMessageAttachments,
|
|
||||||
} from "@/components/assistant-ui/attachment";
|
} from "@/components/assistant-ui/attachment";
|
||||||
|
import { UserMessage } from "@/components/assistant-ui/user-message";
|
||||||
import { ConnectorIndicator } from "@/components/assistant-ui/connector-popup";
|
import { ConnectorIndicator } from "@/components/assistant-ui/connector-popup";
|
||||||
import {
|
import {
|
||||||
InlineMentionEditor,
|
InlineMentionEditor,
|
||||||
|
|
@ -639,69 +636,6 @@ const AssistantActionBar: FC = () => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const UserMessage: FC = () => {
|
|
||||||
const messageId = useAssistantState(({ message }) => message?.id);
|
|
||||||
const messageDocumentsMap = useAtomValue(messageDocumentsMapAtom);
|
|
||||||
const mentionedDocs = messageId ? messageDocumentsMap[messageId] : undefined;
|
|
||||||
const hasAttachments = useAssistantState(
|
|
||||||
({ message }) => message?.attachments && message.attachments.length > 0
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<MessagePrimitive.Root
|
|
||||||
className="aui-user-message-root fade-in slide-in-from-bottom-1 mx-auto grid w-full max-w-(--thread-max-width) animate-in auto-rows-auto grid-cols-[minmax(72px,1fr)_auto] content-start gap-y-2 px-2 py-3 duration-150 [&:where(>*)]:col-start-2"
|
|
||||||
data-role="user"
|
|
||||||
>
|
|
||||||
<div className="aui-user-message-content-wrapper col-start-2 min-w-0">
|
|
||||||
{/* Display attachments and mentioned documents */}
|
|
||||||
{(hasAttachments || (mentionedDocs && mentionedDocs.length > 0)) && (
|
|
||||||
<div className="flex flex-wrap items-end gap-2 mb-2 justify-end">
|
|
||||||
{/* Attachments (images show as thumbnails, documents as chips) */}
|
|
||||||
<UserMessageAttachments />
|
|
||||||
{/* Mentioned documents as chips */}
|
|
||||||
{mentionedDocs?.map((doc) => (
|
|
||||||
<span
|
|
||||||
key={`${doc.document_type}:${doc.id}`}
|
|
||||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-primary/10 text-xs font-medium text-primary border border-primary/20"
|
|
||||||
title={doc.title}
|
|
||||||
>
|
|
||||||
<FileText className="size-3" />
|
|
||||||
<span className="max-w-[150px] truncate">{doc.title}</span>
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{/* Message bubble with action bar positioned relative to it */}
|
|
||||||
<div className="relative">
|
|
||||||
<div className="aui-user-message-content wrap-break-word rounded-2xl bg-muted px-4 py-2.5 text-foreground">
|
|
||||||
<MessagePrimitive.Parts />
|
|
||||||
</div>
|
|
||||||
<div className="aui-user-action-bar-wrapper absolute top-1/2 right-full -translate-y-1/2 pr-1">
|
|
||||||
<UserActionBar />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<BranchPicker className="aui-user-branch-picker -mr-1 col-span-full col-start-1 row-start-3 justify-end" />
|
|
||||||
</MessagePrimitive.Root>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const UserActionBar: FC = () => {
|
|
||||||
return (
|
|
||||||
<ActionBarPrimitive.Root
|
|
||||||
hideWhenRunning
|
|
||||||
autohide="not-last"
|
|
||||||
className="aui-user-action-bar-root flex flex-col items-end"
|
|
||||||
>
|
|
||||||
<ActionBarPrimitive.Edit asChild>
|
|
||||||
<TooltipIconButton tooltip="Edit" className="aui-user-action-edit p-4">
|
|
||||||
<PencilIcon />
|
|
||||||
</TooltipIconButton>
|
|
||||||
</ActionBarPrimitive.Edit>
|
|
||||||
</ActionBarPrimitive.Root>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const EditComposer: FC = () => {
|
const EditComposer: FC = () => {
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,54 @@
|
||||||
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, PencilIcon } from "lucide-react";
|
||||||
import type { FC } 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 { UserMessageAttachments } from "@/components/assistant-ui/attachment";
|
import { UserMessageAttachments } from "@/components/assistant-ui/attachment";
|
||||||
import { BranchPicker } from "@/components/assistant-ui/branch-picker";
|
import { BranchPicker } from "@/components/assistant-ui/branch-picker";
|
||||||
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
|
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
|
||||||
|
|
||||||
|
interface AuthorMetadata {
|
||||||
|
displayName: string | null;
|
||||||
|
avatarUrl: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const UserAvatar: FC<AuthorMetadata> = ({ displayName, avatarUrl }) => {
|
||||||
|
const [hasError, setHasError] = useState(false);
|
||||||
|
|
||||||
|
const initials = displayName
|
||||||
|
? displayName
|
||||||
|
.split(" ")
|
||||||
|
.map((n) => n[0])
|
||||||
|
.join("")
|
||||||
|
.toUpperCase()
|
||||||
|
.slice(0, 2)
|
||||||
|
: "U";
|
||||||
|
|
||||||
|
if (avatarUrl && !hasError) {
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={avatarUrl}
|
||||||
|
alt={displayName || "User"}
|
||||||
|
className="size-8 rounded-full object-cover"
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
|
onError={() => setHasError(true)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex size-8 items-center justify-center rounded-full bg-primary/10 text-xs font-medium text-primary">
|
||||||
|
{initials}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const UserMessage: FC = () => {
|
export const UserMessage: FC = () => {
|
||||||
const messageId = useAssistantState(({ message }) => message?.id);
|
const messageId = useAssistantState(({ message }) => message?.id);
|
||||||
const messageDocumentsMap = useAtomValue(messageDocumentsMapAtom);
|
const messageDocumentsMap = useAtomValue(messageDocumentsMapAtom);
|
||||||
const mentionedDocs = messageId ? messageDocumentsMap[messageId] : undefined;
|
const mentionedDocs = messageId ? messageDocumentsMap[messageId] : undefined;
|
||||||
|
const metadata = useAssistantState(({ message }) => message?.metadata);
|
||||||
|
const author = metadata?.custom?.author as AuthorMetadata | undefined;
|
||||||
const hasAttachments = useAssistantState(
|
const hasAttachments = useAssistantState(
|
||||||
({ message }) => message?.attachments && message.attachments.length > 0
|
({ message }) => message?.attachments && message.attachments.length > 0
|
||||||
);
|
);
|
||||||
|
|
@ -20,34 +58,42 @@ export const UserMessage: FC = () => {
|
||||||
className="aui-user-message-root fade-in slide-in-from-bottom-1 mx-auto grid w-full max-w-(--thread-max-width) animate-in auto-rows-auto grid-cols-[minmax(72px,1fr)_auto] content-start gap-y-2 px-2 py-3 duration-150 [&:where(>*)]:col-start-2"
|
className="aui-user-message-root fade-in slide-in-from-bottom-1 mx-auto grid w-full max-w-(--thread-max-width) animate-in auto-rows-auto grid-cols-[minmax(72px,1fr)_auto] content-start gap-y-2 px-2 py-3 duration-150 [&:where(>*)]:col-start-2"
|
||||||
data-role="user"
|
data-role="user"
|
||||||
>
|
>
|
||||||
<div className="aui-user-message-content-wrapper col-start-2 min-w-0">
|
<div className="aui-user-message-content-wrapper col-start-2 min-w-0 flex items-end gap-2">
|
||||||
{/* Display attachments and mentioned documents */}
|
<div className="flex-1 min-w-0">
|
||||||
{(hasAttachments || (mentionedDocs && mentionedDocs.length > 0)) && (
|
{/* Display attachments and mentioned documents */}
|
||||||
<div className="flex flex-wrap items-end gap-2 mb-2 justify-end">
|
{(hasAttachments || (mentionedDocs && mentionedDocs.length > 0)) && (
|
||||||
{/* Attachments (images show as thumbnails, documents as chips) */}
|
<div className="flex flex-wrap items-end gap-2 mb-2 justify-end">
|
||||||
<UserMessageAttachments />
|
{/* Attachments (images show as thumbnails, documents as chips) */}
|
||||||
{/* Mentioned documents as chips */}
|
<UserMessageAttachments />
|
||||||
{mentionedDocs?.map((doc) => (
|
{/* Mentioned documents as chips */}
|
||||||
<span
|
{mentionedDocs?.map((doc) => (
|
||||||
key={`${doc.document_type}:${doc.id}`}
|
<span
|
||||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-primary/10 text-xs font-medium text-primary border border-primary/20"
|
key={`${doc.document_type}:${doc.id}`}
|
||||||
title={doc.title}
|
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-primary/10 text-xs font-medium text-primary border border-primary/20"
|
||||||
>
|
title={doc.title}
|
||||||
<FileText className="size-3" />
|
>
|
||||||
<span className="max-w-[150px] truncate">{doc.title}</span>
|
<FileText className="size-3" />
|
||||||
</span>
|
<span className="max-w-[150px] truncate">{doc.title}</span>
|
||||||
))}
|
</span>
|
||||||
</div>
|
))}
|
||||||
)}
|
</div>
|
||||||
{/* Message bubble with action bar positioned relative to it */}
|
)}
|
||||||
<div className="relative">
|
{/* Message bubble with action bar positioned relative to it */}
|
||||||
<div className="aui-user-message-content wrap-break-word rounded-2xl bg-muted px-4 py-2.5 text-foreground">
|
<div className="relative">
|
||||||
<MessagePrimitive.Parts />
|
<div className="aui-user-message-content wrap-break-word rounded-2xl bg-muted px-4 py-2.5 text-foreground">
|
||||||
</div>
|
<MessagePrimitive.Parts />
|
||||||
<div className="aui-user-action-bar-wrapper absolute top-1/2 right-full -translate-y-1/2 pr-1">
|
</div>
|
||||||
<UserActionBar />
|
<div className="aui-user-action-bar-wrapper absolute top-1/2 right-full -translate-y-1/2 pr-1">
|
||||||
|
<UserActionBar />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/* User avatar - only shown in shared chats */}
|
||||||
|
{author && (
|
||||||
|
<div className="shrink-0">
|
||||||
|
<UserAvatar displayName={author.displayName} avatarUrl={author.avatarUrl} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<BranchPicker className="aui-user-branch-picker -mr-1 col-span-full col-start-1 row-start-3 justify-end" />
|
<BranchPicker className="aui-user-branch-picker -mr-1 col-span-full col-start-1 row-start-3 justify-end" />
|
||||||
|
|
|
||||||
|
|
@ -354,7 +354,11 @@ export function LayoutDataProvider({
|
||||||
onChatDelete={handleChatDelete}
|
onChatDelete={handleChatDelete}
|
||||||
onViewAllSharedChats={handleViewAllSharedChats}
|
onViewAllSharedChats={handleViewAllSharedChats}
|
||||||
onViewAllPrivateChats={handleViewAllPrivateChats}
|
onViewAllPrivateChats={handleViewAllPrivateChats}
|
||||||
user={{ email: user?.email || "", name: user?.email?.split("@")[0] }}
|
user={{
|
||||||
|
email: user?.email || "",
|
||||||
|
name: user?.display_name || user?.email?.split("@")[0],
|
||||||
|
avatarUrl: user?.avatar_url || undefined,
|
||||||
|
}}
|
||||||
onSettings={handleSettings}
|
onSettings={handleSettings}
|
||||||
onManageMembers={handleManageMembers}
|
onManageMembers={handleManageMembers}
|
||||||
onUserSettings={handleUserSettings}
|
onUserSettings={handleUserSettings}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ export interface SearchSpace {
|
||||||
export interface User {
|
export interface User {
|
||||||
email: string;
|
email: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
|
avatarUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NavItem {
|
export interface NavItem {
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,39 @@ function getInitials(email: string): string {
|
||||||
return name.slice(0, 2).toUpperCase();
|
return name.slice(0, 2).toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User avatar component - shows image if available, otherwise falls back to initials
|
||||||
|
*/
|
||||||
|
function UserAvatar({
|
||||||
|
avatarUrl,
|
||||||
|
initials,
|
||||||
|
bgColor,
|
||||||
|
}: {
|
||||||
|
avatarUrl?: string;
|
||||||
|
initials: string;
|
||||||
|
bgColor: string;
|
||||||
|
}) {
|
||||||
|
if (avatarUrl) {
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={avatarUrl}
|
||||||
|
alt="User avatar"
|
||||||
|
className="h-8 w-8 shrink-0 rounded-lg object-cover"
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-xs font-semibold text-white"
|
||||||
|
style={{ backgroundColor: bgColor }}
|
||||||
|
>
|
||||||
|
{initials}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function SidebarUserProfile({
|
export function SidebarUserProfile({
|
||||||
user,
|
user,
|
||||||
onUserSettings,
|
onUserSettings,
|
||||||
|
|
@ -88,12 +121,7 @@ export function SidebarUserProfile({
|
||||||
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div
|
<UserAvatar avatarUrl={user.avatarUrl} initials={initials} bgColor={bgColor} />
|
||||||
className="flex h-8 w-8 items-center justify-center rounded-lg text-xs font-semibold text-white"
|
|
||||||
style={{ backgroundColor: bgColor }}
|
|
||||||
>
|
|
||||||
{initials}
|
|
||||||
</div>
|
|
||||||
<span className="sr-only">{displayName}</span>
|
<span className="sr-only">{displayName}</span>
|
||||||
</button>
|
</button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
|
|
@ -104,12 +132,7 @@ export function SidebarUserProfile({
|
||||||
<DropdownMenuContent className="w-56" side="right" align="end" sideOffset={8}>
|
<DropdownMenuContent className="w-56" side="right" align="end" sideOffset={8}>
|
||||||
<DropdownMenuLabel className="font-normal">
|
<DropdownMenuLabel className="font-normal">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div
|
<UserAvatar avatarUrl={user.avatarUrl} initials={initials} bgColor={bgColor} />
|
||||||
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-xs font-semibold text-white"
|
|
||||||
style={{ backgroundColor: bgColor }}
|
|
||||||
>
|
|
||||||
{initials}
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="truncate text-sm font-medium">{displayName}</p>
|
<p className="truncate text-sm font-medium">{displayName}</p>
|
||||||
<p className="truncate text-xs text-muted-foreground">{user.email}</p>
|
<p className="truncate text-xs text-muted-foreground">{user.email}</p>
|
||||||
|
|
@ -149,13 +172,7 @@ export function SidebarUserProfile({
|
||||||
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Avatar */}
|
<UserAvatar avatarUrl={user.avatarUrl} initials={initials} bgColor={bgColor} />
|
||||||
<div
|
|
||||||
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-xs font-semibold text-white"
|
|
||||||
style={{ backgroundColor: bgColor }}
|
|
||||||
>
|
|
||||||
{initials}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Name and email */}
|
{/* Name and email */}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
|
|
@ -171,12 +188,7 @@ export function SidebarUserProfile({
|
||||||
<DropdownMenuContent className="w-56" side="top" align="start" sideOffset={4}>
|
<DropdownMenuContent className="w-56" side="top" align="start" sideOffset={4}>
|
||||||
<DropdownMenuLabel className="font-normal">
|
<DropdownMenuLabel className="font-normal">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div
|
<UserAvatar avatarUrl={user.avatarUrl} initials={initials} bgColor={bgColor} />
|
||||||
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-xs font-semibold text-white"
|
|
||||||
style={{ backgroundColor: bgColor }}
|
|
||||||
>
|
|
||||||
{initials}
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="truncate text-sm font-medium">{displayName}</p>
|
<p className="truncate text-sm font-medium">{displayName}</p>
|
||||||
<p className="truncate text-xs text-muted-foreground">{user.email}</p>
|
<p className="truncate text-xs text-muted-foreground">{user.email}</p>
|
||||||
|
|
|
||||||
|
|
@ -215,6 +215,16 @@ export const DocumentMentionPicker = forwardRef<
|
||||||
isSurfsenseDocsLoading) &&
|
isSurfsenseDocsLoading) &&
|
||||||
currentPage === 0;
|
currentPage === 0;
|
||||||
|
|
||||||
|
// Split documents into SurfSense docs and user docs for grouped rendering
|
||||||
|
const surfsenseDocsList = useMemo(
|
||||||
|
() => actualDocuments.filter((doc) => doc.document_type === "SURFSENSE_DOCS"),
|
||||||
|
[actualDocuments]
|
||||||
|
);
|
||||||
|
const userDocsList = useMemo(
|
||||||
|
() => actualDocuments.filter((doc) => doc.document_type !== "SURFSENSE_DOCS"),
|
||||||
|
[actualDocuments]
|
||||||
|
);
|
||||||
|
|
||||||
// Track already selected documents using unique key (document_type:id) to avoid ID collisions
|
// Track already selected documents using unique key (document_type:id) to avoid ID collisions
|
||||||
const selectedKeys = useMemo(
|
const selectedKeys = useMemo(
|
||||||
() => new Set(initialSelectedDocuments.map((d) => `${d.document_type}:${d.id}`)),
|
() => new Set(initialSelectedDocuments.map((d) => `${d.document_type}:${d.id}`)),
|
||||||
|
|
@ -324,47 +334,102 @@ export const DocumentMentionPicker = forwardRef<
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="py-1">
|
<div className="py-1">
|
||||||
{actualDocuments.map((doc) => {
|
{/* SurfSense Documentation Section */}
|
||||||
const docKey = `${doc.document_type}:${doc.id}`;
|
{surfsenseDocsList.length > 0 && (
|
||||||
const isAlreadySelected = selectedKeys.has(docKey);
|
<>
|
||||||
const selectableIndex = selectableDocuments.findIndex(
|
<div className="sticky top-0 z-10 px-3 py-2 text-xs font-bold uppercase tracking-wider bg-muted text-foreground/80 border-b border-border">
|
||||||
(d) => d.document_type === doc.document_type && d.id === doc.id
|
SurfSense Docs
|
||||||
);
|
</div>
|
||||||
const isHighlighted = !isAlreadySelected && selectableIndex === highlightedIndex;
|
{surfsenseDocsList.map((doc) => {
|
||||||
|
const docKey = `${doc.document_type}:${doc.id}`;
|
||||||
|
const isAlreadySelected = selectedKeys.has(docKey);
|
||||||
|
const selectableIndex = selectableDocuments.findIndex(
|
||||||
|
(d) => d.document_type === doc.document_type && d.id === doc.id
|
||||||
|
);
|
||||||
|
const isHighlighted = !isAlreadySelected && selectableIndex === highlightedIndex;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={docKey}
|
||||||
|
ref={(el) => {
|
||||||
|
if (el && selectableIndex >= 0) {
|
||||||
|
itemRefs.current.set(selectableIndex, el);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
onClick={() => !isAlreadySelected && handleSelectDocument(doc)}
|
||||||
|
onMouseEnter={() => {
|
||||||
|
if (!isAlreadySelected && selectableIndex >= 0) {
|
||||||
|
setHighlightedIndex(selectableIndex);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={isAlreadySelected}
|
||||||
|
className={cn(
|
||||||
|
"w-full flex items-center gap-2 px-3 py-2 text-left transition-colors",
|
||||||
|
isAlreadySelected ? "opacity-50 cursor-not-allowed" : "cursor-pointer",
|
||||||
|
isHighlighted && "bg-accent"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="shrink-0 text-muted-foreground text-sm">
|
||||||
|
{getConnectorIcon(doc.document_type)}
|
||||||
|
</span>
|
||||||
|
<span className="flex-1 text-sm truncate" title={doc.title}>
|
||||||
|
{doc.title}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* User Documents Section */}
|
||||||
|
{userDocsList.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="sticky top-0 z-10 px-3 py-2 text-xs font-bold uppercase tracking-wider bg-muted text-foreground/80 border-b border-border">
|
||||||
|
Your Documents
|
||||||
|
</div>
|
||||||
|
{userDocsList.map((doc) => {
|
||||||
|
const docKey = `${doc.document_type}:${doc.id}`;
|
||||||
|
const isAlreadySelected = selectedKeys.has(docKey);
|
||||||
|
const selectableIndex = selectableDocuments.findIndex(
|
||||||
|
(d) => d.document_type === doc.document_type && d.id === doc.id
|
||||||
|
);
|
||||||
|
const isHighlighted = !isAlreadySelected && selectableIndex === highlightedIndex;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={docKey}
|
||||||
|
ref={(el) => {
|
||||||
|
if (el && selectableIndex >= 0) {
|
||||||
|
itemRefs.current.set(selectableIndex, el);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
onClick={() => !isAlreadySelected && handleSelectDocument(doc)}
|
||||||
|
onMouseEnter={() => {
|
||||||
|
if (!isAlreadySelected && selectableIndex >= 0) {
|
||||||
|
setHighlightedIndex(selectableIndex);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={isAlreadySelected}
|
||||||
|
className={cn(
|
||||||
|
"w-full flex items-center gap-2 px-3 py-2 text-left transition-colors",
|
||||||
|
isAlreadySelected ? "opacity-50 cursor-not-allowed" : "cursor-pointer",
|
||||||
|
isHighlighted && "bg-accent"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="shrink-0 text-muted-foreground text-sm">
|
||||||
|
{getConnectorIcon(doc.document_type)}
|
||||||
|
</span>
|
||||||
|
<span className="flex-1 text-sm truncate" title={doc.title}>
|
||||||
|
{doc.title}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={docKey}
|
|
||||||
ref={(el) => {
|
|
||||||
if (el && selectableIndex >= 0) {
|
|
||||||
itemRefs.current.set(selectableIndex, el);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
type="button"
|
|
||||||
onClick={() => !isAlreadySelected && handleSelectDocument(doc)}
|
|
||||||
onMouseEnter={() => {
|
|
||||||
if (!isAlreadySelected && selectableIndex >= 0) {
|
|
||||||
setHighlightedIndex(selectableIndex);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={isAlreadySelected}
|
|
||||||
className={cn(
|
|
||||||
"w-full flex items-center gap-2 px-3 py-2 text-left transition-colors",
|
|
||||||
isAlreadySelected ? "opacity-50 cursor-not-allowed" : "cursor-pointer",
|
|
||||||
isHighlighted && "bg-accent"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{/* Type icon */}
|
|
||||||
<span className="flex-shrink-0 text-muted-foreground text-sm">
|
|
||||||
{getConnectorIcon(doc.document_type)}
|
|
||||||
</span>
|
|
||||||
{/* Title */}
|
|
||||||
<span className="flex-1 text-sm truncate" title={doc.title}>
|
|
||||||
{doc.title}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{/* Loading indicator for additional pages */}
|
{/* Loading indicator for additional pages */}
|
||||||
{isLoadingMore && (
|
{isLoadingMore && (
|
||||||
<div className="flex items-center justify-center py-2">
|
<div className="flex items-center justify-center py-2">
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@ export const user = z.object({
|
||||||
is_verified: z.boolean(),
|
is_verified: z.boolean(),
|
||||||
pages_limit: z.number(),
|
pages_limit: z.number(),
|
||||||
pages_used: z.number(),
|
pages_used: z.number(),
|
||||||
|
display_name: z.string().nullish(),
|
||||||
|
avatar_url: z.string().nullish(),
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -15,5 +17,20 @@ export const user = z.object({
|
||||||
*/
|
*/
|
||||||
export const getMeResponse = user;
|
export const getMeResponse = user;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update current user request
|
||||||
|
*/
|
||||||
|
export const updateUserRequest = z.object({
|
||||||
|
display_name: z.string().nullish(),
|
||||||
|
avatar_url: z.string().nullish(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update current user response
|
||||||
|
*/
|
||||||
|
export const updateUserResponse = user;
|
||||||
|
|
||||||
export type User = z.infer<typeof user>;
|
export type User = z.infer<typeof user>;
|
||||||
export type GetMeResponse = z.infer<typeof getMeResponse>;
|
export type GetMeResponse = z.infer<typeof getMeResponse>;
|
||||||
|
export type UpdateUserRequest = z.infer<typeof updateUserRequest>;
|
||||||
|
export type UpdateUserResponse = z.infer<typeof updateUserResponse>;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,8 @@
|
||||||
import { getMeResponse } from "@/contracts/types/user.types";
|
import {
|
||||||
|
getMeResponse,
|
||||||
|
updateUserResponse,
|
||||||
|
type UpdateUserRequest,
|
||||||
|
} from "@/contracts/types/user.types";
|
||||||
import { baseApiService } from "./base-api.service";
|
import { baseApiService } from "./base-api.service";
|
||||||
|
|
||||||
class UserApiService {
|
class UserApiService {
|
||||||
|
|
@ -8,6 +12,15 @@ class UserApiService {
|
||||||
getMe = async () => {
|
getMe = async () => {
|
||||||
return baseApiService.get(`/users/me`, getMeResponse);
|
return baseApiService.get(`/users/me`, getMeResponse);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update current authenticated user
|
||||||
|
*/
|
||||||
|
updateMe = async (request: UpdateUserRequest) => {
|
||||||
|
return baseApiService.patch(`/users/me`, updateUserResponse, {
|
||||||
|
body: request,
|
||||||
|
});
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const userApiService = new UserApiService();
|
export const userApiService = new UserApiService();
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,9 @@ export interface MessageRecord {
|
||||||
role: "user" | "assistant" | "system";
|
role: "user" | "assistant" | "system";
|
||||||
content: unknown;
|
content: unknown;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
author_id?: string | null;
|
||||||
|
author_display_name?: string | null;
|
||||||
|
author_avatar_url?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ThreadListResponse {
|
export interface ThreadListResponse {
|
||||||
|
|
|
||||||
|
|
@ -109,6 +109,17 @@
|
||||||
"title": "User Settings",
|
"title": "User Settings",
|
||||||
"description": "Manage your account settings and API access",
|
"description": "Manage your account settings and API access",
|
||||||
"back_to_app": "Back to app",
|
"back_to_app": "Back to app",
|
||||||
|
"profile_nav_label": "Profile",
|
||||||
|
"profile_nav_description": "Manage your display name and avatar",
|
||||||
|
"profile_title": "Profile",
|
||||||
|
"profile_description": "Update your personal information",
|
||||||
|
"profile_avatar": "Profile Picture",
|
||||||
|
"profile_display_name": "Display Name",
|
||||||
|
"profile_display_name_hint": "This is how your name appears across the app",
|
||||||
|
"profile_email": "Email",
|
||||||
|
"profile_save": "Save Changes",
|
||||||
|
"profile_saved": "Profile updated successfully",
|
||||||
|
"profile_save_error": "Failed to update profile",
|
||||||
"api_key_nav_label": "API Key",
|
"api_key_nav_label": "API Key",
|
||||||
"api_key_nav_description": "Manage your API access token",
|
"api_key_nav_description": "Manage your API access token",
|
||||||
"api_key_title": "API Key",
|
"api_key_title": "API Key",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue