mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-29 19:35:20 +02:00
Merge pull request #782 from CREDO23/sur-107-comment-reply-notifications
[Feat] Comment reply notifications and chat page & sharing improvements
This commit is contained in:
commit
1ef3fd4ce9
16 changed files with 888 additions and 651 deletions
|
|
@ -5,7 +5,7 @@ Service layer for chat comments and mentions.
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from sqlalchemy import delete, select
|
from sqlalchemy import delete, or_, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
|
@ -103,6 +103,37 @@ async def process_mentions(
|
||||||
return mentions_map
|
return mentions_map
|
||||||
|
|
||||||
|
|
||||||
|
async def get_comment_thread_participants(
|
||||||
|
session: AsyncSession,
|
||||||
|
parent_comment_id: int,
|
||||||
|
exclude_user_ids: set[UUID],
|
||||||
|
) -> list[UUID]:
|
||||||
|
"""
|
||||||
|
Get all unique authors in a comment thread (parent + replies), excluding specified users.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Database session
|
||||||
|
parent_comment_id: ID of the parent comment
|
||||||
|
exclude_user_ids: Set of user IDs to exclude (e.g., replier, mentioned users)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of user UUIDs who have participated in the thread
|
||||||
|
"""
|
||||||
|
query = select(ChatComment.author_id).where(
|
||||||
|
or_(
|
||||||
|
ChatComment.id == parent_comment_id,
|
||||||
|
ChatComment.parent_id == parent_comment_id,
|
||||||
|
),
|
||||||
|
ChatComment.author_id.isnot(None),
|
||||||
|
)
|
||||||
|
|
||||||
|
if exclude_user_ids:
|
||||||
|
query = query.where(ChatComment.author_id.notin_(list(exclude_user_ids)))
|
||||||
|
|
||||||
|
result = await session.execute(query.distinct())
|
||||||
|
return [row[0] for row in result.fetchall()]
|
||||||
|
|
||||||
|
|
||||||
async def get_comments_for_message(
|
async def get_comments_for_message(
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
message_id: int,
|
message_id: int,
|
||||||
|
|
@ -436,6 +467,31 @@ async def create_reply(
|
||||||
search_space_id=search_space_id,
|
search_space_id=search_space_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Notify thread participants (excluding replier and mentioned users)
|
||||||
|
mentioned_user_ids = set(mentions_map.keys())
|
||||||
|
exclude_ids = {user.id} | mentioned_user_ids
|
||||||
|
participants = await get_comment_thread_participants(
|
||||||
|
session, comment_id, exclude_ids
|
||||||
|
)
|
||||||
|
for participant_id in participants:
|
||||||
|
if participant_id in mentioned_user_ids:
|
||||||
|
continue
|
||||||
|
await NotificationService.comment_reply.notify_comment_reply(
|
||||||
|
session=session,
|
||||||
|
user_id=participant_id,
|
||||||
|
reply_id=reply.id,
|
||||||
|
parent_comment_id=comment_id,
|
||||||
|
message_id=parent_comment.message_id,
|
||||||
|
thread_id=thread.id,
|
||||||
|
thread_title=thread.title or "Untitled thread",
|
||||||
|
author_id=str(user.id),
|
||||||
|
author_name=author_name,
|
||||||
|
author_avatar_url=user.avatar_url,
|
||||||
|
author_email=user.email,
|
||||||
|
content_preview=content_preview[:200],
|
||||||
|
search_space_id=search_space_id,
|
||||||
|
)
|
||||||
|
|
||||||
author = AuthorResponse(
|
author = AuthorResponse(
|
||||||
id=user.id,
|
id=user.id,
|
||||||
display_name=user.display_name,
|
display_name=user.display_name,
|
||||||
|
|
|
||||||
|
|
@ -861,6 +861,98 @@ class MentionNotificationHandler(BaseNotificationHandler):
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
class CommentReplyNotificationHandler(BaseNotificationHandler):
|
||||||
|
"""Handler for comment reply notifications."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__("comment_reply")
|
||||||
|
|
||||||
|
async def find_notification_by_reply(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
reply_id: int,
|
||||||
|
user_id: UUID,
|
||||||
|
) -> Notification | None:
|
||||||
|
query = select(Notification).where(
|
||||||
|
Notification.type == self.notification_type,
|
||||||
|
Notification.user_id == user_id,
|
||||||
|
Notification.notification_metadata["reply_id"].astext == str(reply_id),
|
||||||
|
)
|
||||||
|
result = await session.execute(query)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
async def notify_comment_reply(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
user_id: UUID,
|
||||||
|
reply_id: int,
|
||||||
|
parent_comment_id: int,
|
||||||
|
message_id: int,
|
||||||
|
thread_id: int,
|
||||||
|
thread_title: str,
|
||||||
|
author_id: str,
|
||||||
|
author_name: str,
|
||||||
|
author_avatar_url: str | None,
|
||||||
|
author_email: str,
|
||||||
|
content_preview: str,
|
||||||
|
search_space_id: int,
|
||||||
|
) -> Notification:
|
||||||
|
existing = await self.find_notification_by_reply(session, reply_id, user_id)
|
||||||
|
if existing:
|
||||||
|
logger.info(
|
||||||
|
f"Notification already exists for reply {reply_id} to user {user_id}"
|
||||||
|
)
|
||||||
|
return existing
|
||||||
|
|
||||||
|
title = f"{author_name} replied in a thread"
|
||||||
|
message = content_preview[:100] + ("..." if len(content_preview) > 100 else "")
|
||||||
|
|
||||||
|
metadata = {
|
||||||
|
"reply_id": reply_id,
|
||||||
|
"parent_comment_id": parent_comment_id,
|
||||||
|
"message_id": message_id,
|
||||||
|
"thread_id": thread_id,
|
||||||
|
"thread_title": thread_title,
|
||||||
|
"author_id": author_id,
|
||||||
|
"author_name": author_name,
|
||||||
|
"author_avatar_url": author_avatar_url,
|
||||||
|
"author_email": author_email,
|
||||||
|
"content_preview": content_preview[:200],
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
notification = Notification(
|
||||||
|
user_id=user_id,
|
||||||
|
search_space_id=search_space_id,
|
||||||
|
type=self.notification_type,
|
||||||
|
title=title,
|
||||||
|
message=message,
|
||||||
|
notification_metadata=metadata,
|
||||||
|
)
|
||||||
|
session.add(notification)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(notification)
|
||||||
|
logger.info(
|
||||||
|
f"Created comment_reply notification {notification.id} for user {user_id}"
|
||||||
|
)
|
||||||
|
return notification
|
||||||
|
except Exception as e:
|
||||||
|
await session.rollback()
|
||||||
|
if (
|
||||||
|
"duplicate key" in str(e).lower()
|
||||||
|
or "unique constraint" in str(e).lower()
|
||||||
|
):
|
||||||
|
logger.warning(
|
||||||
|
f"Duplicate notification for reply {reply_id} to user {user_id}"
|
||||||
|
)
|
||||||
|
existing = await self.find_notification_by_reply(
|
||||||
|
session, reply_id, user_id
|
||||||
|
)
|
||||||
|
if existing:
|
||||||
|
return existing
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
class PageLimitNotificationHandler(BaseNotificationHandler):
|
class PageLimitNotificationHandler(BaseNotificationHandler):
|
||||||
"""Handler for page limit exceeded notifications."""
|
"""Handler for page limit exceeded notifications."""
|
||||||
|
|
||||||
|
|
@ -959,6 +1051,7 @@ class NotificationService:
|
||||||
connector_indexing = ConnectorIndexingNotificationHandler()
|
connector_indexing = ConnectorIndexingNotificationHandler()
|
||||||
document_processing = DocumentProcessingNotificationHandler()
|
document_processing = DocumentProcessingNotificationHandler()
|
||||||
mention = MentionNotificationHandler()
|
mention = MentionNotificationHandler()
|
||||||
|
comment_reply = CommentReplyNotificationHandler()
|
||||||
page_limit = PageLimitNotificationHandler()
|
page_limit = PageLimitNotificationHandler()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|
|
||||||
|
|
@ -366,11 +366,14 @@ async def list_snapshots_for_thread(
|
||||||
if not thread:
|
if not thread:
|
||||||
raise HTTPException(status_code=404, detail="Thread not found")
|
raise HTTPException(status_code=404, detail="Thread not found")
|
||||||
|
|
||||||
if thread.created_by_id != user.id:
|
# Check permission to view public share links
|
||||||
raise HTTPException(
|
await check_permission(
|
||||||
status_code=403,
|
session,
|
||||||
detail="Only the creator can view snapshots",
|
user,
|
||||||
)
|
thread.search_space_id,
|
||||||
|
Permission.PUBLIC_SHARING_VIEW.value,
|
||||||
|
"You don't have permission to view public share links",
|
||||||
|
)
|
||||||
|
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(PublicChatSnapshot)
|
select(PublicChatSnapshot)
|
||||||
|
|
|
||||||
|
|
@ -4,20 +4,19 @@ import {
|
||||||
ErrorPrimitive,
|
ErrorPrimitive,
|
||||||
MessagePrimitive,
|
MessagePrimitive,
|
||||||
useAssistantState,
|
useAssistantState,
|
||||||
|
useMessage,
|
||||||
} from "@assistant-ui/react";
|
} from "@assistant-ui/react";
|
||||||
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
import { useAtom, useAtomValue } from "jotai";
|
||||||
import { CheckIcon, CopyIcon, DownloadIcon, MessageSquare, RefreshCwIcon } from "lucide-react";
|
import { CheckIcon, CopyIcon, DownloadIcon, MessageSquare, RefreshCwIcon } from "lucide-react";
|
||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
import { useContext, useEffect, useMemo, useRef, useState } from "react";
|
import { useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
addingCommentToMessageIdAtom,
|
addingCommentToMessageIdAtom,
|
||||||
clearTargetCommentIdAtom,
|
|
||||||
commentsCollapsedAtom,
|
commentsCollapsedAtom,
|
||||||
commentsEnabledAtom,
|
commentsEnabledAtom,
|
||||||
targetCommentIdAtom,
|
targetCommentIdAtom,
|
||||||
} from "@/atoms/chat/current-thread.atom";
|
} from "@/atoms/chat/current-thread.atom";
|
||||||
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
||||||
import { BranchPicker } from "@/components/assistant-ui/branch-picker";
|
|
||||||
import { MarkdownText } from "@/components/assistant-ui/markdown-text";
|
import { MarkdownText } from "@/components/assistant-ui/markdown-text";
|
||||||
import {
|
import {
|
||||||
ThinkingStepsContext,
|
ThinkingStepsContext,
|
||||||
|
|
@ -84,7 +83,6 @@ const AssistantMessageInner: FC = () => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="aui-assistant-message-footer mt-1 mb-5 ml-2 flex">
|
<div className="aui-assistant-message-footer mt-1 mb-5 ml-2 flex">
|
||||||
<BranchPicker />
|
|
||||||
<AssistantActionBar />
|
<AssistantActionBar />
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|
@ -126,7 +124,6 @@ export const AssistantMessage: FC = () => {
|
||||||
|
|
||||||
// Target comment navigation - read target from global atom
|
// Target comment navigation - read target from global atom
|
||||||
const targetCommentId = useAtomValue(targetCommentIdAtom);
|
const targetCommentId = useAtomValue(targetCommentIdAtom);
|
||||||
const clearTargetCommentId = useSetAtom(clearTargetCommentIdAtom);
|
|
||||||
|
|
||||||
// Check if target comment belongs to this message (including replies)
|
// Check if target comment belongs to this message (including replies)
|
||||||
const hasTargetComment = useMemo(() => {
|
const hasTargetComment = useMemo(() => {
|
||||||
|
|
@ -263,6 +260,8 @@ export const AssistantMessage: FC = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const AssistantActionBar: FC = () => {
|
const AssistantActionBar: FC = () => {
|
||||||
|
const { isLast } = useMessage();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ActionBarPrimitive.Root
|
<ActionBarPrimitive.Root
|
||||||
hideWhenRunning
|
hideWhenRunning
|
||||||
|
|
@ -285,11 +284,14 @@ const AssistantActionBar: FC = () => {
|
||||||
<DownloadIcon />
|
<DownloadIcon />
|
||||||
</TooltipIconButton>
|
</TooltipIconButton>
|
||||||
</ActionBarPrimitive.ExportMarkdown>
|
</ActionBarPrimitive.ExportMarkdown>
|
||||||
<ActionBarPrimitive.Reload asChild>
|
{/* Only allow regenerating the last assistant message */}
|
||||||
<TooltipIconButton tooltip="Refresh">
|
{isLast && (
|
||||||
<RefreshCwIcon />
|
<ActionBarPrimitive.Reload asChild>
|
||||||
</TooltipIconButton>
|
<TooltipIconButton tooltip="Refresh">
|
||||||
</ActionBarPrimitive.Reload>
|
<RefreshCwIcon />
|
||||||
|
</TooltipIconButton>
|
||||||
|
</ActionBarPrimitive.Reload>
|
||||||
|
)}
|
||||||
</ActionBarPrimitive.Root>
|
</ActionBarPrimitive.Root>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
import { BranchPickerPrimitive } from "@assistant-ui/react";
|
|
||||||
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
|
|
||||||
import type { FC } from "react";
|
|
||||||
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
export const BranchPicker: FC<BranchPickerPrimitive.Root.Props> = ({ className, ...rest }) => {
|
|
||||||
return (
|
|
||||||
<BranchPickerPrimitive.Root
|
|
||||||
hideWhenSingleBranch
|
|
||||||
className={cn(
|
|
||||||
"aui-branch-picker-root -ml-2 mr-2 inline-flex items-center text-muted-foreground text-xs",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...rest}
|
|
||||||
>
|
|
||||||
<BranchPickerPrimitive.Previous asChild>
|
|
||||||
<TooltipIconButton tooltip="Previous">
|
|
||||||
<ChevronLeftIcon />
|
|
||||||
</TooltipIconButton>
|
|
||||||
</BranchPickerPrimitive.Previous>
|
|
||||||
<span className="aui-branch-picker-state font-medium">
|
|
||||||
<BranchPickerPrimitive.Number /> / <BranchPickerPrimitive.Count />
|
|
||||||
</span>
|
|
||||||
<BranchPickerPrimitive.Next asChild>
|
|
||||||
<TooltipIconButton tooltip="Next">
|
|
||||||
<ChevronRightIcon />
|
|
||||||
</TooltipIconButton>
|
|
||||||
</BranchPickerPrimitive.Next>
|
|
||||||
</BranchPickerPrimitive.Root>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -4,7 +4,6 @@ import { FileText, PencilIcon } from "lucide-react";
|
||||||
import { type FC, useState } from "react";
|
import { type FC, useState } from "react";
|
||||||
import { messageDocumentsMapAtom } from "@/atoms/chat/mentioned-documents.atom";
|
import { messageDocumentsMapAtom } from "@/atoms/chat/mentioned-documents.atom";
|
||||||
import { UserMessageAttachments } from "@/components/assistant-ui/attachment";
|
import { UserMessageAttachments } from "@/components/assistant-ui/attachment";
|
||||||
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 {
|
interface AuthorMetadata {
|
||||||
|
|
@ -95,24 +94,47 @@ export const UserMessage: FC = () => {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<BranchPicker className="aui-user-branch-picker -mr-1 col-span-full col-start-1 row-start-3 justify-end" />
|
|
||||||
</MessagePrimitive.Root>
|
</MessagePrimitive.Root>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const UserActionBar: FC = () => {
|
const UserActionBar: FC = () => {
|
||||||
|
const isThreadRunning = useAssistantState(({ thread }) => thread.isRunning);
|
||||||
|
|
||||||
|
// Get current message ID
|
||||||
|
const currentMessageId = useAssistantState(({ message }) => message?.id);
|
||||||
|
|
||||||
|
// Find the last user message ID in the thread (computed once, memoized by selector)
|
||||||
|
const lastUserMessageId = useAssistantState(({ thread }) => {
|
||||||
|
const messages = thread.messages;
|
||||||
|
for (let i = messages.length - 1; i >= 0; i--) {
|
||||||
|
if (messages[i].role === "user") {
|
||||||
|
return messages[i].id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Simple comparison - no iteration needed per message
|
||||||
|
const isLastUserMessage = currentMessageId === lastUserMessageId;
|
||||||
|
|
||||||
|
// Show edit button only on the last user message and when thread is not running
|
||||||
|
const canEdit = isLastUserMessage && !isThreadRunning;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ActionBarPrimitive.Root
|
<ActionBarPrimitive.Root
|
||||||
hideWhenRunning
|
hideWhenRunning
|
||||||
autohide="not-last"
|
autohide="not-last"
|
||||||
className="aui-user-action-bar-root flex flex-col items-end"
|
className="aui-user-action-bar-root flex flex-col items-end"
|
||||||
>
|
>
|
||||||
<ActionBarPrimitive.Edit asChild>
|
{/* Only allow editing the last user message */}
|
||||||
<TooltipIconButton tooltip="Edit" className="aui-user-action-edit p-4">
|
{canEdit && (
|
||||||
<PencilIcon />
|
<ActionBarPrimitive.Edit asChild>
|
||||||
</TooltipIconButton>
|
<TooltipIconButton tooltip="Edit" className="aui-user-action-edit p-4">
|
||||||
</ActionBarPrimitive.Edit>
|
<PencilIcon />
|
||||||
|
</TooltipIconButton>
|
||||||
|
</ActionBarPrimitive.Edit>
|
||||||
|
)}
|
||||||
</ActionBarPrimitive.Root>
|
</ActionBarPrimitive.Root>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -110,7 +110,6 @@ export function LayoutDataProvider({
|
||||||
// This ensures each tab has independent pagination and data loading
|
// This ensures each tab has independent pagination and data loading
|
||||||
const userId = user?.id ? String(user.id) : null;
|
const userId = user?.id ? String(user.id) : null;
|
||||||
|
|
||||||
// Mentions: Only fetch "new_mention" type notifications
|
|
||||||
const {
|
const {
|
||||||
inboxItems: mentionItems,
|
inboxItems: mentionItems,
|
||||||
unreadCount: mentionUnreadCount,
|
unreadCount: mentionUnreadCount,
|
||||||
|
|
@ -122,11 +121,9 @@ export function LayoutDataProvider({
|
||||||
markAllAsRead: markAllMentionsAsRead,
|
markAllAsRead: markAllMentionsAsRead,
|
||||||
} = useInbox(userId, Number(searchSpaceId) || null, "new_mention");
|
} = useInbox(userId, Number(searchSpaceId) || null, "new_mention");
|
||||||
|
|
||||||
// Status: Fetch all types (will be filtered client-side to status types)
|
|
||||||
// We pass null to get all, then InboxSidebar filters to status types
|
|
||||||
const {
|
const {
|
||||||
inboxItems: statusItems,
|
inboxItems: statusItems,
|
||||||
unreadCount: statusUnreadCount,
|
unreadCount: allUnreadCount,
|
||||||
loading: statusLoading,
|
loading: statusLoading,
|
||||||
loadingMore: statusLoadingMore,
|
loadingMore: statusLoadingMore,
|
||||||
hasMore: statusHasMore,
|
hasMore: statusHasMore,
|
||||||
|
|
@ -135,8 +132,8 @@ export function LayoutDataProvider({
|
||||||
markAllAsRead: markAllStatusAsRead,
|
markAllAsRead: markAllStatusAsRead,
|
||||||
} = useInbox(userId, Number(searchSpaceId) || null, null);
|
} = useInbox(userId, Number(searchSpaceId) || null, null);
|
||||||
|
|
||||||
// Combined unread count for nav badge (mentions take priority for visibility)
|
const totalUnreadCount = allUnreadCount;
|
||||||
const totalUnreadCount = mentionUnreadCount + statusUnreadCount;
|
const statusOnlyUnreadCount = Math.max(0, allUnreadCount - mentionUnreadCount);
|
||||||
|
|
||||||
// Track seen notification IDs to detect new page_limit_exceeded notifications
|
// Track seen notification IDs to detect new page_limit_exceeded notifications
|
||||||
const seenPageLimitNotifications = useRef<Set<number>>(new Set());
|
const seenPageLimitNotifications = useRef<Set<number>>(new Set());
|
||||||
|
|
@ -598,7 +595,7 @@ export function LayoutDataProvider({
|
||||||
},
|
},
|
||||||
status: {
|
status: {
|
||||||
items: statusItems,
|
items: statusItems,
|
||||||
unreadCount: statusUnreadCount,
|
unreadCount: statusOnlyUnreadCount,
|
||||||
loading: statusLoading,
|
loading: statusLoading,
|
||||||
loadingMore: statusLoadingMore,
|
loadingMore: statusLoadingMore,
|
||||||
hasMore: statusHasMore,
|
hasMore: statusHasMore,
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import { useAtom } from "jotai";
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
AtSign,
|
|
||||||
BellDot,
|
BellDot,
|
||||||
Check,
|
Check,
|
||||||
CheckCheck,
|
CheckCheck,
|
||||||
|
|
@ -15,6 +14,7 @@ import {
|
||||||
Inbox,
|
Inbox,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
ListFilter,
|
ListFilter,
|
||||||
|
MessageSquare,
|
||||||
Search,
|
Search,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
@ -46,6 +46,7 @@ import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||||
import {
|
import {
|
||||||
|
isCommentReplyMetadata,
|
||||||
isConnectorIndexingMetadata,
|
isConnectorIndexingMetadata,
|
||||||
isNewMentionMetadata,
|
isNewMentionMetadata,
|
||||||
isPageLimitExceededMetadata,
|
isPageLimitExceededMetadata,
|
||||||
|
|
@ -133,7 +134,7 @@ function getConnectorTypeDisplayName(connectorType: string): string {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type InboxTab = "mentions" | "status";
|
type InboxTab = "comments" | "status";
|
||||||
type InboxFilter = "all" | "unread";
|
type InboxFilter = "all" | "unread";
|
||||||
|
|
||||||
// Tab-specific data source with independent pagination
|
// Tab-specific data source with independent pagination
|
||||||
|
|
@ -186,7 +187,7 @@ export function InboxSidebar({
|
||||||
const [, setTargetCommentId] = useAtom(setTargetCommentIdAtom);
|
const [, setTargetCommentId] = useAtom(setTargetCommentIdAtom);
|
||||||
|
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [activeTab, setActiveTab] = useState<InboxTab>("mentions");
|
const [activeTab, setActiveTab] = useState<InboxTab>("comments");
|
||||||
const [activeFilter, setActiveFilter] = useState<InboxFilter>("all");
|
const [activeFilter, setActiveFilter] = useState<InboxFilter>("all");
|
||||||
const [selectedConnector, setSelectedConnector] = useState<string | null>(null);
|
const [selectedConnector, setSelectedConnector] = useState<string | null>(null);
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
@ -233,12 +234,17 @@ export function InboxSidebar({
|
||||||
}
|
}
|
||||||
}, [activeTab]);
|
}, [activeTab]);
|
||||||
|
|
||||||
// Get current tab's data source - each tab has independent data and pagination
|
// Both tabs now derive items from status (all types), so use status for pagination
|
||||||
const currentDataSource = activeTab === "mentions" ? mentions : status;
|
const { loading, loadingMore = false, hasMore = false, loadMore } = status;
|
||||||
const { loading, loadingMore = false, hasMore = false, loadMore } = currentDataSource;
|
|
||||||
|
|
||||||
// Status tab includes: connector indexing, document processing, page limit exceeded, connector deletion
|
// Comments tab: mentions and comment replies
|
||||||
// Filter to only show status notification types
|
const commentsItems = useMemo(
|
||||||
|
() =>
|
||||||
|
status.items.filter((item) => item.type === "new_mention" || item.type === "comment_reply"),
|
||||||
|
[status.items]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Status tab: connector indexing, document processing, page limit exceeded, connector deletion
|
||||||
const statusItems = useMemo(
|
const statusItems = useMemo(
|
||||||
() =>
|
() =>
|
||||||
status.items.filter(
|
status.items.filter(
|
||||||
|
|
@ -270,8 +276,8 @@ export function InboxSidebar({
|
||||||
}));
|
}));
|
||||||
}, [statusItems]);
|
}, [statusItems]);
|
||||||
|
|
||||||
// Get items for current tab - mentions use their source directly, status uses filtered items
|
// Get items for current tab
|
||||||
const displayItems = activeTab === "mentions" ? mentions.items : statusItems;
|
const displayItems = activeTab === "comments" ? commentsItems : statusItems;
|
||||||
|
|
||||||
// Filter items based on filter type, connector filter, and search query
|
// Filter items based on filter type, connector filter, and search query
|
||||||
const filteredItems = useMemo(() => {
|
const filteredItems = useMemo(() => {
|
||||||
|
|
@ -334,9 +340,15 @@ export function InboxSidebar({
|
||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, [loadMore, hasMore, loadingMore, open, searchQuery]);
|
}, [loadMore, hasMore, loadingMore, open, searchQuery]);
|
||||||
|
|
||||||
// Use unread counts from data sources (more accurate than client-side counting)
|
// Unread counts derived from filtered items
|
||||||
const unreadMentionsCount = mentions.unreadCount;
|
const unreadCommentsCount = useMemo(
|
||||||
const unreadStatusCount = status.unreadCount;
|
() => commentsItems.filter((item) => !item.read).length,
|
||||||
|
[commentsItems]
|
||||||
|
);
|
||||||
|
const unreadStatusCount = useMemo(
|
||||||
|
() => statusItems.filter((item) => !item.read).length,
|
||||||
|
[statusItems]
|
||||||
|
);
|
||||||
|
|
||||||
const handleItemClick = useCallback(
|
const handleItemClick = useCallback(
|
||||||
async (item: InboxItem) => {
|
async (item: InboxItem) => {
|
||||||
|
|
@ -347,19 +359,15 @@ export function InboxSidebar({
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.type === "new_mention") {
|
if (item.type === "new_mention") {
|
||||||
// Use type guard for safe metadata access
|
|
||||||
if (isNewMentionMetadata(item.metadata)) {
|
if (isNewMentionMetadata(item.metadata)) {
|
||||||
const searchSpaceId = item.search_space_id;
|
const searchSpaceId = item.search_space_id;
|
||||||
const threadId = item.metadata.thread_id;
|
const threadId = item.metadata.thread_id;
|
||||||
const commentId = item.metadata.comment_id;
|
const commentId = item.metadata.comment_id;
|
||||||
|
|
||||||
if (searchSpaceId && threadId) {
|
if (searchSpaceId && threadId) {
|
||||||
// Pre-set target comment ID before navigation
|
|
||||||
// This also ensures comments panel is not collapsed
|
|
||||||
if (commentId) {
|
if (commentId) {
|
||||||
setTargetCommentId(commentId);
|
setTargetCommentId(commentId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = commentId
|
const url = commentId
|
||||||
? `/dashboard/${searchSpaceId}/new-chat/${threadId}?commentId=${commentId}`
|
? `/dashboard/${searchSpaceId}/new-chat/${threadId}?commentId=${commentId}`
|
||||||
: `/dashboard/${searchSpaceId}/new-chat/${threadId}`;
|
: `/dashboard/${searchSpaceId}/new-chat/${threadId}`;
|
||||||
|
|
@ -368,6 +376,24 @@ export function InboxSidebar({
|
||||||
router.push(url);
|
router.push(url);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if (item.type === "comment_reply") {
|
||||||
|
if (isCommentReplyMetadata(item.metadata)) {
|
||||||
|
const searchSpaceId = item.search_space_id;
|
||||||
|
const threadId = item.metadata.thread_id;
|
||||||
|
const replyId = item.metadata.reply_id;
|
||||||
|
|
||||||
|
if (searchSpaceId && threadId) {
|
||||||
|
if (replyId) {
|
||||||
|
setTargetCommentId(replyId);
|
||||||
|
}
|
||||||
|
const url = replyId
|
||||||
|
? `/dashboard/${searchSpaceId}/new-chat/${threadId}?commentId=${replyId}`
|
||||||
|
: `/dashboard/${searchSpaceId}/new-chat/${threadId}`;
|
||||||
|
onOpenChange(false);
|
||||||
|
onCloseMobileSidebar?.();
|
||||||
|
router.push(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
} else if (item.type === "page_limit_exceeded") {
|
} else if (item.type === "page_limit_exceeded") {
|
||||||
// Navigate to the upgrade/more-pages page
|
// Navigate to the upgrade/more-pages page
|
||||||
if (isPageLimitExceededMetadata(item.metadata)) {
|
if (isPageLimitExceededMetadata(item.metadata)) {
|
||||||
|
|
@ -411,24 +437,29 @@ export function InboxSidebar({
|
||||||
};
|
};
|
||||||
|
|
||||||
const getStatusIcon = (item: InboxItem) => {
|
const getStatusIcon = (item: InboxItem) => {
|
||||||
// For mentions, show the author's avatar with initials fallback
|
// For mentions and comment replies, show the author's avatar
|
||||||
if (item.type === "new_mention") {
|
if (item.type === "new_mention" || item.type === "comment_reply") {
|
||||||
// Use type guard for safe metadata access
|
const metadata =
|
||||||
if (isNewMentionMetadata(item.metadata)) {
|
item.type === "new_mention"
|
||||||
const authorName = item.metadata.author_name;
|
? isNewMentionMetadata(item.metadata)
|
||||||
const avatarUrl = item.metadata.author_avatar_url;
|
? item.metadata
|
||||||
const authorEmail = item.metadata.author_email;
|
: null
|
||||||
|
: isCommentReplyMetadata(item.metadata)
|
||||||
|
? item.metadata
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (metadata) {
|
||||||
return (
|
return (
|
||||||
<Avatar className="h-8 w-8">
|
<Avatar className="h-8 w-8">
|
||||||
{avatarUrl && <AvatarImage src={avatarUrl} alt={authorName || "User"} />}
|
{metadata.author_avatar_url && (
|
||||||
|
<AvatarImage src={metadata.author_avatar_url} alt={metadata.author_name || "User"} />
|
||||||
|
)}
|
||||||
<AvatarFallback className="text-[10px] bg-primary/10 text-primary">
|
<AvatarFallback className="text-[10px] bg-primary/10 text-primary">
|
||||||
{getInitials(authorName, authorEmail)}
|
{getInitials(metadata.author_name, metadata.author_email)}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// Fallback for invalid metadata
|
|
||||||
return (
|
return (
|
||||||
<Avatar className="h-8 w-8">
|
<Avatar className="h-8 w-8">
|
||||||
<AvatarFallback className="text-[10px] bg-primary/10 text-primary">
|
<AvatarFallback className="text-[10px] bg-primary/10 text-primary">
|
||||||
|
|
@ -481,10 +512,10 @@ export function InboxSidebar({
|
||||||
};
|
};
|
||||||
|
|
||||||
const getEmptyStateMessage = () => {
|
const getEmptyStateMessage = () => {
|
||||||
if (activeTab === "mentions") {
|
if (activeTab === "comments") {
|
||||||
return {
|
return {
|
||||||
title: t("no_mentions") || "No mentions",
|
title: t("no_comments") || "No comments",
|
||||||
hint: t("no_mentions_hint") || "You'll see mentions from others here",
|
hint: t("no_comments_hint") || "You'll see mentions and replies here",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
|
|
@ -823,14 +854,14 @@ export function InboxSidebar({
|
||||||
>
|
>
|
||||||
<TabsList className="w-full h-auto p-0 bg-transparent rounded-none border-b">
|
<TabsList className="w-full h-auto p-0 bg-transparent rounded-none border-b">
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="mentions"
|
value="comments"
|
||||||
className="flex-1 rounded-none border-b-2 border-transparent px-1 py-2 text-xs font-medium data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none"
|
className="flex-1 rounded-none border-b-2 border-transparent px-1 py-2 text-xs font-medium data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none"
|
||||||
>
|
>
|
||||||
<span className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg hover:bg-muted transition-colors">
|
<span className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg hover:bg-muted transition-colors">
|
||||||
<AtSign className="h-4 w-4" />
|
<MessageSquare className="h-4 w-4" />
|
||||||
<span>{t("mentions") || "Mentions"}</span>
|
<span>{t("comments") || "Comments"}</span>
|
||||||
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-primary/20 text-muted-foreground text-xs font-medium">
|
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-primary/20 text-muted-foreground text-xs font-medium">
|
||||||
{formatInboxCount(unreadMentionsCount)}
|
{formatInboxCount(unreadCommentsCount)}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
|
@ -932,8 +963,8 @@ export function InboxSidebar({
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-center py-8">
|
<div className="text-center py-8">
|
||||||
{activeTab === "mentions" ? (
|
{activeTab === "comments" ? (
|
||||||
<AtSign className="h-12 w-12 mx-auto text-muted-foreground mb-3" />
|
<MessageSquare className="h-12 w-12 mx-auto text-muted-foreground mb-3" />
|
||||||
) : (
|
) : (
|
||||||
<History className="h-12 w-12 mx-auto text-muted-foreground mb-3" />
|
<History className="h-12 w-12 mx-auto text-muted-foreground mb-3" />
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useAtomValue, useSetAtom } from "jotai";
|
import { useAtomValue, useSetAtom } from "jotai";
|
||||||
import { Globe, User, Users } from "lucide-react";
|
import { Globe, User, Users } from "lucide-react";
|
||||||
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { currentThreadAtom, setThreadVisibilityAtom } from "@/atoms/chat/current-thread.atom";
|
import { currentThreadAtom, setThreadVisibilityAtom } from "@/atoms/chat/current-thread.atom";
|
||||||
|
|
@ -11,6 +12,7 @@ import { createPublicChatSnapshotMutationAtom } from "@/atoms/public-chat-snapsh
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
import { chatThreadsApiService } from "@/lib/apis/chat-threads-api.service";
|
||||||
import {
|
import {
|
||||||
type ChatVisibility,
|
type ChatVisibility,
|
||||||
type ThreadRecord,
|
type ThreadRecord,
|
||||||
|
|
@ -46,6 +48,8 @@ const visibilityOptions: {
|
||||||
|
|
||||||
export function ChatShareButton({ thread, onVisibilityChange, className }: ChatShareButtonProps) {
|
export function ChatShareButton({ thread, onVisibilityChange, className }: ChatShareButtonProps) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const router = useRouter();
|
||||||
|
const params = useParams();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
// Use Jotai atom for visibility (single source of truth)
|
// Use Jotai atom for visibility (single source of truth)
|
||||||
|
|
@ -65,6 +69,16 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
|
||||||
return access.permissions?.includes("public_sharing:create") ?? false;
|
return access.permissions?.includes("public_sharing:create") ?? false;
|
||||||
}, [access]);
|
}, [access]);
|
||||||
|
|
||||||
|
// Query to check if thread has public snapshots
|
||||||
|
const { data: snapshotsData } = useQuery({
|
||||||
|
queryKey: ["thread-snapshots", thread?.id],
|
||||||
|
queryFn: () => chatThreadsApiService.listPublicChatSnapshots({ thread_id: thread!.id }),
|
||||||
|
enabled: !!thread?.id,
|
||||||
|
staleTime: 30000, // Cache for 30 seconds
|
||||||
|
});
|
||||||
|
const hasPublicSnapshots = (snapshotsData?.snapshots?.length ?? 0) > 0;
|
||||||
|
const snapshotCount = snapshotsData?.snapshots?.length ?? 0;
|
||||||
|
|
||||||
// Use Jotai visibility if available (synced from chat page), otherwise fall back to thread prop
|
// Use Jotai visibility if available (synced from chat page), otherwise fall back to thread prop
|
||||||
const currentVisibility = currentThreadState.visibility ?? thread?.visibility ?? "PRIVATE";
|
const currentVisibility = currentThreadState.visibility ?? thread?.visibility ?? "PRIVATE";
|
||||||
|
|
||||||
|
|
@ -106,11 +120,13 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await createSnapshot({ thread_id: thread.id });
|
await createSnapshot({ thread_id: thread.id });
|
||||||
|
// Refetch snapshots to show the globe indicator
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["thread-snapshots", thread.id] });
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to create public link:", error);
|
console.error("Failed to create public link:", error);
|
||||||
}
|
}
|
||||||
}, [thread, createSnapshot]);
|
}, [thread, createSnapshot, queryClient]);
|
||||||
|
|
||||||
// Don't show if no thread (new chat that hasn't been created yet)
|
// Don't show if no thread (new chat that hasn't been created yet)
|
||||||
if (!thread) {
|
if (!thread) {
|
||||||
|
|
@ -121,112 +137,131 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
|
||||||
const buttonLabel = currentVisibility === "PRIVATE" ? "Private" : "Shared";
|
const buttonLabel = currentVisibility === "PRIVATE" ? "Private" : "Shared";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover open={open} onOpenChange={setOpen}>
|
<div className={cn("flex items-center gap-1", className)}>
|
||||||
<Tooltip>
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
<TooltipTrigger asChild>
|
<Tooltip>
|
||||||
<PopoverTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<PopoverTrigger asChild>
|
||||||
variant="outline"
|
<Button
|
||||||
size="icon"
|
variant="outline"
|
||||||
className={cn(
|
size="icon"
|
||||||
"h-8 w-8 md:w-auto md:px-3 md:gap-2 relative bg-muted hover:bg-muted/80 border-0",
|
className="h-8 w-8 md:w-auto md:px-3 md:gap-2 relative bg-muted hover:bg-muted/80 border-0"
|
||||||
className
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<CurrentIcon className="h-4 w-4" />
|
|
||||||
<span className="hidden md:inline text-sm">{buttonLabel}</span>
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>Share settings</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
<PopoverContent
|
|
||||||
className="w-[280px] md:w-[320px] p-0 rounded-lg shadow-lg border-border/60"
|
|
||||||
align="end"
|
|
||||||
sideOffset={8}
|
|
||||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
|
||||||
>
|
|
||||||
<div className="p-1.5 space-y-1">
|
|
||||||
{/* Visibility Options */}
|
|
||||||
{visibilityOptions.map((option) => {
|
|
||||||
const isSelected = currentVisibility === option.value;
|
|
||||||
const Icon = option.icon;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
key={option.value}
|
|
||||||
onClick={() => handleVisibilityChange(option.value)}
|
|
||||||
className={cn(
|
|
||||||
"w-full flex items-center gap-2.5 px-2.5 py-2 rounded-md transition-all",
|
|
||||||
"hover:bg-accent/50 cursor-pointer",
|
|
||||||
"focus:outline-none",
|
|
||||||
isSelected && "bg-accent/80"
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<div
|
<CurrentIcon className="h-4 w-4" />
|
||||||
|
<span className="hidden md:inline text-sm">{buttonLabel}</span>
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Share settings</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<PopoverContent
|
||||||
|
className="w-[280px] md:w-[320px] p-0 rounded-lg shadow-lg border-border/60"
|
||||||
|
align="end"
|
||||||
|
sideOffset={8}
|
||||||
|
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
<div className="p-1.5 space-y-1">
|
||||||
|
{/* Visibility Options */}
|
||||||
|
{visibilityOptions.map((option) => {
|
||||||
|
const isSelected = currentVisibility === option.value;
|
||||||
|
const Icon = option.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={option.value}
|
||||||
|
onClick={() => handleVisibilityChange(option.value)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"size-7 rounded-md shrink-0 grid place-items-center",
|
"w-full flex items-center gap-2.5 px-2.5 py-2 rounded-md transition-all",
|
||||||
isSelected ? "bg-primary/10" : "bg-muted"
|
"hover:bg-accent/50 cursor-pointer",
|
||||||
|
"focus:outline-none",
|
||||||
|
isSelected && "bg-accent/80"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"size-4 block",
|
"size-7 rounded-md shrink-0 grid place-items-center",
|
||||||
isSelected ? "text-primary" : "text-muted-foreground"
|
isSelected ? "bg-primary/10" : "bg-muted"
|
||||||
)}
|
)}
|
||||||
/>
|
>
|
||||||
</div>
|
<Icon
|
||||||
<div className="flex-1 text-left min-w-0">
|
className={cn(
|
||||||
<div className="flex items-center gap-1.5">
|
"size-4 block",
|
||||||
<span className={cn("text-sm font-medium", isSelected && "text-primary")}>
|
isSelected ? "text-primary" : "text-muted-foreground"
|
||||||
{option.label}
|
)}
|
||||||
</span>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground mt-0.5 leading-snug">
|
<div className="flex-1 text-left min-w-0">
|
||||||
{option.description}
|
<div className="flex items-center gap-1.5">
|
||||||
</p>
|
<span className={cn("text-sm font-medium", isSelected && "text-primary")}>
|
||||||
</div>
|
{option.label}
|
||||||
</button>
|
</span>
|
||||||
);
|
</div>
|
||||||
})}
|
<p className="text-xs text-muted-foreground mt-0.5 leading-snug">
|
||||||
|
{option.description}
|
||||||
{canCreatePublicLink && (
|
</p>
|
||||||
<>
|
|
||||||
{/* Divider */}
|
|
||||||
<div className="border-t border-border my-1" />
|
|
||||||
|
|
||||||
{/* Public Link Option */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleCreatePublicLink}
|
|
||||||
disabled={isCreatingSnapshot}
|
|
||||||
className={cn(
|
|
||||||
"w-full flex items-center gap-2.5 px-2.5 py-2 rounded-md transition-all",
|
|
||||||
"hover:bg-accent/50 cursor-pointer",
|
|
||||||
"focus:outline-none",
|
|
||||||
"disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="size-7 rounded-md shrink-0 grid place-items-center bg-muted">
|
|
||||||
<Globe className="size-4 block text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 text-left min-w-0">
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<span className="text-sm font-medium">
|
|
||||||
{isCreatingSnapshot ? "Creating link..." : "Create public link"}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground mt-0.5 leading-snug">
|
</button>
|
||||||
Creates a shareable snapshot of this chat
|
);
|
||||||
</p>
|
})}
|
||||||
</div>
|
|
||||||
</button>
|
{canCreatePublicLink && (
|
||||||
</>
|
<>
|
||||||
)}
|
{/* Divider */}
|
||||||
</div>
|
<div className="border-t border-border my-1" />
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
{/* Public Link Option */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCreatePublicLink}
|
||||||
|
disabled={isCreatingSnapshot}
|
||||||
|
className={cn(
|
||||||
|
"w-full flex items-center gap-2.5 px-2.5 py-2 rounded-md transition-all",
|
||||||
|
"hover:bg-accent/50 cursor-pointer",
|
||||||
|
"focus:outline-none",
|
||||||
|
"disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="size-7 rounded-md shrink-0 grid place-items-center bg-muted">
|
||||||
|
<Globe className="size-4 block text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 text-left min-w-0">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{isCreatingSnapshot ? "Creating link..." : "Create public link"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5 leading-snug">
|
||||||
|
Creates a shareable snapshot of this chat
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
|
||||||
|
{/* Globe indicator when public snapshots exist - clicks to settings */}
|
||||||
|
{hasPublicSnapshots && (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => router.push(`/dashboard/${params.search_space_id}/settings`)}
|
||||||
|
className="flex items-center justify-center h-8 w-8 rounded-md bg-muted/50 hover:bg-muted transition-colors"
|
||||||
|
>
|
||||||
|
<Globe className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
{snapshotCount === 1
|
||||||
|
? "This chat has a public link"
|
||||||
|
: `This chat has ${snapshotCount} public links`}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,13 @@ export function PublicChatSnapshotRow({
|
||||||
{snapshot.message_count}
|
{snapshot.message_count}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
readOnly
|
||||||
|
value={snapshot.public_url}
|
||||||
|
className="mt-2 w-full text-xs text-muted-foreground bg-muted/50 border rounded px-2 py-1 select-all focus:outline-none focus:ring-1 focus:ring-ring"
|
||||||
|
onClick={(e) => (e.target as HTMLInputElement).select()}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
|
|
|
||||||
|
|
@ -8,172 +8,167 @@ import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
// ///////////////////////////////////////////////////////////////////////////
|
// ///////////////////////////////////////////////////////////////////////////
|
||||||
// Types
|
// Types
|
||||||
export type AnimationVariant =
|
export type AnimationVariant = "circle" | "rectangle" | "gif" | "polygon" | "circle-blur";
|
||||||
| "circle"
|
|
||||||
| "rectangle"
|
|
||||||
| "gif"
|
|
||||||
| "polygon"
|
|
||||||
| "circle-blur";
|
|
||||||
export type AnimationStart =
|
export type AnimationStart =
|
||||||
| "top-left"
|
| "top-left"
|
||||||
| "top-right"
|
| "top-right"
|
||||||
| "bottom-left"
|
| "bottom-left"
|
||||||
| "bottom-right"
|
| "bottom-right"
|
||||||
| "center"
|
| "center"
|
||||||
| "top-center"
|
| "top-center"
|
||||||
| "bottom-center"
|
| "bottom-center"
|
||||||
| "bottom-up"
|
| "bottom-up"
|
||||||
| "top-down"
|
| "top-down"
|
||||||
| "left-right"
|
| "left-right"
|
||||||
| "right-left";
|
| "right-left";
|
||||||
|
|
||||||
interface Animation {
|
interface Animation {
|
||||||
name: string;
|
name: string;
|
||||||
css: string;
|
css: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ///////////////////////////////////////////////////////////////////////////
|
// ///////////////////////////////////////////////////////////////////////////
|
||||||
// Helper functions
|
// Helper functions
|
||||||
|
|
||||||
const getPositionCoords = (position: AnimationStart) => {
|
const getPositionCoords = (position: AnimationStart) => {
|
||||||
switch (position) {
|
switch (position) {
|
||||||
case "top-left":
|
case "top-left":
|
||||||
return { cx: "0", cy: "0" };
|
return { cx: "0", cy: "0" };
|
||||||
case "top-right":
|
case "top-right":
|
||||||
return { cx: "40", cy: "0" };
|
return { cx: "40", cy: "0" };
|
||||||
case "bottom-left":
|
case "bottom-left":
|
||||||
return { cx: "0", cy: "40" };
|
return { cx: "0", cy: "40" };
|
||||||
case "bottom-right":
|
case "bottom-right":
|
||||||
return { cx: "40", cy: "40" };
|
return { cx: "40", cy: "40" };
|
||||||
case "top-center":
|
case "top-center":
|
||||||
return { cx: "20", cy: "0" };
|
return { cx: "20", cy: "0" };
|
||||||
case "bottom-center":
|
case "bottom-center":
|
||||||
return { cx: "20", cy: "40" };
|
return { cx: "20", cy: "40" };
|
||||||
case "bottom-up":
|
case "bottom-up":
|
||||||
case "top-down":
|
case "top-down":
|
||||||
case "left-right":
|
case "left-right":
|
||||||
case "right-left":
|
case "right-left":
|
||||||
return { cx: "20", cy: "20" };
|
return { cx: "20", cy: "20" };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const generateSVG = (variant: AnimationVariant, start: AnimationStart) => {
|
const generateSVG = (variant: AnimationVariant, start: AnimationStart) => {
|
||||||
if (variant === "circle-blur") {
|
if (variant === "circle-blur") {
|
||||||
if (start === "center") {
|
if (start === "center") {
|
||||||
return `data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40"><defs><filter id="blur"><feGaussianBlur stdDeviation="2"/></filter></defs><circle cx="20" cy="20" r="18" fill="white" filter="url(%23blur)"/></svg>`;
|
return `data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40"><defs><filter id="blur"><feGaussianBlur stdDeviation="2"/></filter></defs><circle cx="20" cy="20" r="18" fill="white" filter="url(%23blur)"/></svg>`;
|
||||||
}
|
}
|
||||||
const positionCoords = getPositionCoords(start);
|
const positionCoords = getPositionCoords(start);
|
||||||
if (!positionCoords) {
|
if (!positionCoords) {
|
||||||
throw new Error(`Invalid start position: ${start}`);
|
throw new Error(`Invalid start position: ${start}`);
|
||||||
}
|
}
|
||||||
const { cx, cy } = positionCoords;
|
const { cx, cy } = positionCoords;
|
||||||
return `data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40"><defs><filter id="blur"><feGaussianBlur stdDeviation="2"/></filter></defs><circle cx="${cx}" cy="${cy}" r="18" fill="white" filter="url(%23blur)"/></svg>`;
|
return `data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40"><defs><filter id="blur"><feGaussianBlur stdDeviation="2"/></filter></defs><circle cx="${cx}" cy="${cy}" r="18" fill="white" filter="url(%23blur)"/></svg>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (start === "center") return;
|
if (start === "center") return;
|
||||||
|
|
||||||
if (variant === "rectangle") return "";
|
if (variant === "rectangle") return "";
|
||||||
|
|
||||||
const positionCoords = getPositionCoords(start);
|
const positionCoords = getPositionCoords(start);
|
||||||
if (!positionCoords) {
|
if (!positionCoords) {
|
||||||
throw new Error(`Invalid start position: ${start}`);
|
throw new Error(`Invalid start position: ${start}`);
|
||||||
}
|
}
|
||||||
const { cx, cy } = positionCoords;
|
const { cx, cy } = positionCoords;
|
||||||
|
|
||||||
if (variant === "circle") {
|
if (variant === "circle") {
|
||||||
return `data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40"><circle cx="${cx}" cy="${cy}" r="20" fill="white"/></svg>`;
|
return `data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40"><circle cx="${cx}" cy="${cy}" r="20" fill="white"/></svg>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return "";
|
return "";
|
||||||
};
|
};
|
||||||
|
|
||||||
const getTransformOrigin = (start: AnimationStart) => {
|
const getTransformOrigin = (start: AnimationStart) => {
|
||||||
switch (start) {
|
switch (start) {
|
||||||
case "top-left":
|
case "top-left":
|
||||||
return "top left";
|
return "top left";
|
||||||
case "top-right":
|
case "top-right":
|
||||||
return "top right";
|
return "top right";
|
||||||
case "bottom-left":
|
case "bottom-left":
|
||||||
return "bottom left";
|
return "bottom left";
|
||||||
case "bottom-right":
|
case "bottom-right":
|
||||||
return "bottom right";
|
return "bottom right";
|
||||||
case "top-center":
|
case "top-center":
|
||||||
return "top center";
|
return "top center";
|
||||||
case "bottom-center":
|
case "bottom-center":
|
||||||
return "bottom center";
|
return "bottom center";
|
||||||
case "bottom-up":
|
case "bottom-up":
|
||||||
case "top-down":
|
case "top-down":
|
||||||
case "left-right":
|
case "left-right":
|
||||||
case "right-left":
|
case "right-left":
|
||||||
return "center";
|
return "center";
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createAnimation = (
|
export const createAnimation = (
|
||||||
variant: AnimationVariant,
|
variant: AnimationVariant,
|
||||||
start: AnimationStart = "center",
|
start: AnimationStart = "center",
|
||||||
blur = false,
|
blur = false,
|
||||||
url?: string,
|
url?: string
|
||||||
): Animation => {
|
): Animation => {
|
||||||
const svg = generateSVG(variant, start);
|
const svg = generateSVG(variant, start);
|
||||||
const transformOrigin = getTransformOrigin(start);
|
const transformOrigin = getTransformOrigin(start);
|
||||||
|
|
||||||
if (variant === "rectangle") {
|
if (variant === "rectangle") {
|
||||||
const getClipPath = (direction: AnimationStart) => {
|
const getClipPath = (direction: AnimationStart) => {
|
||||||
switch (direction) {
|
switch (direction) {
|
||||||
case "bottom-up":
|
case "bottom-up":
|
||||||
return {
|
return {
|
||||||
from: "polygon(0% 100%, 100% 100%, 100% 100%, 0% 100%)",
|
from: "polygon(0% 100%, 100% 100%, 100% 100%, 0% 100%)",
|
||||||
to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)",
|
to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)",
|
||||||
};
|
};
|
||||||
case "top-down":
|
case "top-down":
|
||||||
return {
|
return {
|
||||||
from: "polygon(0% 0%, 100% 0%, 100% 0%, 0% 0%)",
|
from: "polygon(0% 0%, 100% 0%, 100% 0%, 0% 0%)",
|
||||||
to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)",
|
to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)",
|
||||||
};
|
};
|
||||||
case "left-right":
|
case "left-right":
|
||||||
return {
|
return {
|
||||||
from: "polygon(0% 0%, 0% 0%, 0% 100%, 0% 100%)",
|
from: "polygon(0% 0%, 0% 0%, 0% 100%, 0% 100%)",
|
||||||
to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)",
|
to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)",
|
||||||
};
|
};
|
||||||
case "right-left":
|
case "right-left":
|
||||||
return {
|
return {
|
||||||
from: "polygon(100% 0%, 100% 0%, 100% 100%, 100% 100%)",
|
from: "polygon(100% 0%, 100% 0%, 100% 100%, 100% 100%)",
|
||||||
to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)",
|
to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)",
|
||||||
};
|
};
|
||||||
case "top-left":
|
case "top-left":
|
||||||
return {
|
return {
|
||||||
from: "polygon(0% 0%, 0% 0%, 0% 0%, 0% 0%)",
|
from: "polygon(0% 0%, 0% 0%, 0% 0%, 0% 0%)",
|
||||||
to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)",
|
to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)",
|
||||||
};
|
};
|
||||||
case "top-right":
|
case "top-right":
|
||||||
return {
|
return {
|
||||||
from: "polygon(100% 0%, 100% 0%, 100% 0%, 100% 0%)",
|
from: "polygon(100% 0%, 100% 0%, 100% 0%, 100% 0%)",
|
||||||
to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)",
|
to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)",
|
||||||
};
|
};
|
||||||
case "bottom-left":
|
case "bottom-left":
|
||||||
return {
|
return {
|
||||||
from: "polygon(0% 100%, 0% 100%, 0% 100%, 0% 100%)",
|
from: "polygon(0% 100%, 0% 100%, 0% 100%, 0% 100%)",
|
||||||
to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)",
|
to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)",
|
||||||
};
|
};
|
||||||
case "bottom-right":
|
case "bottom-right":
|
||||||
return {
|
return {
|
||||||
from: "polygon(100% 100%, 100% 100%, 100% 100%, 100% 100%)",
|
from: "polygon(100% 100%, 100% 100%, 100% 100%, 100% 100%)",
|
||||||
to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)",
|
to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)",
|
||||||
};
|
};
|
||||||
default:
|
default:
|
||||||
return {
|
return {
|
||||||
from: "polygon(0% 100%, 100% 100%, 100% 100%, 0% 100%)",
|
from: "polygon(0% 100%, 100% 100%, 100% 100%, 0% 100%)",
|
||||||
to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)",
|
to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const clipPath = getClipPath(start);
|
const clipPath = getClipPath(start);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: `${variant}-${start}${blur ? "-blur" : ""}`,
|
name: `${variant}-${start}${blur ? "-blur" : ""}`,
|
||||||
css: `
|
css: `
|
||||||
::view-transition-group(root) {
|
::view-transition-group(root) {
|
||||||
animation-duration: 0.7s;
|
animation-duration: 0.7s;
|
||||||
animation-timing-function: var(--expo-out);
|
animation-timing-function: var(--expo-out);
|
||||||
|
|
@ -218,12 +213,12 @@ export const createAnimation = (
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (variant === "circle" && start == "center") {
|
if (variant === "circle" && start == "center") {
|
||||||
return {
|
return {
|
||||||
name: `${variant}-${start}${blur ? "-blur" : ""}`,
|
name: `${variant}-${start}${blur ? "-blur" : ""}`,
|
||||||
css: `
|
css: `
|
||||||
::view-transition-group(root) {
|
::view-transition-group(root) {
|
||||||
animation-duration: 0.7s;
|
animation-duration: 0.7s;
|
||||||
animation-timing-function: var(--expo-out);
|
animation-timing-function: var(--expo-out);
|
||||||
|
|
@ -268,12 +263,12 @@ export const createAnimation = (
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (variant === "gif") {
|
if (variant === "gif") {
|
||||||
return {
|
return {
|
||||||
name: `${variant}-${start}`,
|
name: `${variant}-${start}`,
|
||||||
css: `
|
css: `
|
||||||
::view-transition-group(root) {
|
::view-transition-group(root) {
|
||||||
animation-timing-function: var(--expo-in);
|
animation-timing-function: var(--expo-in);
|
||||||
}
|
}
|
||||||
|
|
@ -302,14 +297,14 @@ export const createAnimation = (
|
||||||
mask-size: 2000vmax;
|
mask-size: 2000vmax;
|
||||||
}
|
}
|
||||||
}`,
|
}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (variant === "circle-blur") {
|
if (variant === "circle-blur") {
|
||||||
if (start === "center") {
|
if (start === "center") {
|
||||||
return {
|
return {
|
||||||
name: `${variant}-${start}`,
|
name: `${variant}-${start}`,
|
||||||
css: `
|
css: `
|
||||||
::view-transition-group(root) {
|
::view-transition-group(root) {
|
||||||
animation-timing-function: var(--expo-out);
|
animation-timing-function: var(--expo-out);
|
||||||
}
|
}
|
||||||
|
|
@ -334,12 +329,12 @@ export const createAnimation = (
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: `${variant}-${start}`,
|
name: `${variant}-${start}`,
|
||||||
css: `
|
css: `
|
||||||
::view-transition-group(root) {
|
::view-transition-group(root) {
|
||||||
animation-timing-function: var(--expo-out);
|
animation-timing-function: var(--expo-out);
|
||||||
}
|
}
|
||||||
|
|
@ -364,41 +359,41 @@ export const createAnimation = (
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (variant === "polygon") {
|
if (variant === "polygon") {
|
||||||
const getPolygonClipPaths = (position: AnimationStart) => {
|
const getPolygonClipPaths = (position: AnimationStart) => {
|
||||||
switch (position) {
|
switch (position) {
|
||||||
case "top-left":
|
case "top-left":
|
||||||
return {
|
return {
|
||||||
darkFrom: "polygon(50% -71%, -50% 71%, -50% 71%, 50% -71%)",
|
darkFrom: "polygon(50% -71%, -50% 71%, -50% 71%, 50% -71%)",
|
||||||
darkTo: "polygon(50% -71%, -50% 71%, 50% 171%, 171% 50%)",
|
darkTo: "polygon(50% -71%, -50% 71%, 50% 171%, 171% 50%)",
|
||||||
lightFrom: "polygon(171% 50%, 50% 171%, 50% 171%, 171% 50%)",
|
lightFrom: "polygon(171% 50%, 50% 171%, 50% 171%, 171% 50%)",
|
||||||
lightTo: "polygon(171% 50%, 50% 171%, -50% 71%, 50% -71%)",
|
lightTo: "polygon(171% 50%, 50% 171%, -50% 71%, 50% -71%)",
|
||||||
};
|
};
|
||||||
case "top-right":
|
case "top-right":
|
||||||
return {
|
return {
|
||||||
darkFrom: "polygon(150% -71%, 250% 71%, 250% 71%, 150% -71%)",
|
darkFrom: "polygon(150% -71%, 250% 71%, 250% 71%, 150% -71%)",
|
||||||
darkTo: "polygon(150% -71%, 250% 71%, 50% 171%, -71% 50%)",
|
darkTo: "polygon(150% -71%, 250% 71%, 50% 171%, -71% 50%)",
|
||||||
lightFrom: "polygon(-71% 50%, 50% 171%, 50% 171%, -71% 50%)",
|
lightFrom: "polygon(-71% 50%, 50% 171%, 50% 171%, -71% 50%)",
|
||||||
lightTo: "polygon(-71% 50%, 50% 171%, 250% 71%, 150% -71%)",
|
lightTo: "polygon(-71% 50%, 50% 171%, 250% 71%, 150% -71%)",
|
||||||
};
|
};
|
||||||
default:
|
default:
|
||||||
return {
|
return {
|
||||||
darkFrom: "polygon(50% -71%, -50% 71%, -50% 71%, 50% -71%)",
|
darkFrom: "polygon(50% -71%, -50% 71%, -50% 71%, 50% -71%)",
|
||||||
darkTo: "polygon(50% -71%, -50% 71%, 50% 171%, 171% 50%)",
|
darkTo: "polygon(50% -71%, -50% 71%, 50% 171%, 171% 50%)",
|
||||||
lightFrom: "polygon(171% 50%, 50% 171%, 50% 171%, 171% 50%)",
|
lightFrom: "polygon(171% 50%, 50% 171%, 50% 171%, 171% 50%)",
|
||||||
lightTo: "polygon(171% 50%, 50% 171%, -50% 71%, 50% -71%)",
|
lightTo: "polygon(171% 50%, 50% 171%, -50% 71%, 50% -71%)",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const clipPaths = getPolygonClipPaths(start);
|
const clipPaths = getPolygonClipPaths(start);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: `${variant}-${start}${blur ? "-blur" : ""}`,
|
name: `${variant}-${start}${blur ? "-blur" : ""}`,
|
||||||
css: `
|
css: `
|
||||||
::view-transition-group(root) {
|
::view-transition-group(root) {
|
||||||
animation-duration: 0.7s;
|
animation-duration: 0.7s;
|
||||||
animation-timing-function: var(--expo-out);
|
animation-timing-function: var(--expo-out);
|
||||||
|
|
@ -443,35 +438,35 @@ export const createAnimation = (
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle circle variants with start positions using clip-path
|
// Handle circle variants with start positions using clip-path
|
||||||
if (variant === "circle" && start !== "center") {
|
if (variant === "circle" && start !== "center") {
|
||||||
const getClipPathPosition = (position: AnimationStart) => {
|
const getClipPathPosition = (position: AnimationStart) => {
|
||||||
switch (position) {
|
switch (position) {
|
||||||
case "top-left":
|
case "top-left":
|
||||||
return "0% 0%";
|
return "0% 0%";
|
||||||
case "top-right":
|
case "top-right":
|
||||||
return "100% 0%";
|
return "100% 0%";
|
||||||
case "bottom-left":
|
case "bottom-left":
|
||||||
return "0% 100%";
|
return "0% 100%";
|
||||||
case "bottom-right":
|
case "bottom-right":
|
||||||
return "100% 100%";
|
return "100% 100%";
|
||||||
case "top-center":
|
case "top-center":
|
||||||
return "50% 0%";
|
return "50% 0%";
|
||||||
case "bottom-center":
|
case "bottom-center":
|
||||||
return "50% 100%";
|
return "50% 100%";
|
||||||
default:
|
default:
|
||||||
return "50% 50%";
|
return "50% 50%";
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const clipPosition = getClipPathPosition(start);
|
const clipPosition = getClipPathPosition(start);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: `${variant}-${start}${blur ? "-blur" : ""}`,
|
name: `${variant}-${start}${blur ? "-blur" : ""}`,
|
||||||
css: `
|
css: `
|
||||||
::view-transition-group(root) {
|
::view-transition-group(root) {
|
||||||
animation-duration: 1s;
|
animation-duration: 1s;
|
||||||
animation-timing-function: var(--expo-out);
|
animation-timing-function: var(--expo-out);
|
||||||
|
|
@ -516,12 +511,12 @@ export const createAnimation = (
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: `${variant}-${start}${blur ? "-blur" : ""}`,
|
name: `${variant}-${start}${blur ? "-blur" : ""}`,
|
||||||
css: `
|
css: `
|
||||||
::view-transition-group(root) {
|
::view-transition-group(root) {
|
||||||
animation-timing-function: var(--expo-in);
|
animation-timing-function: var(--expo-in);
|
||||||
}
|
}
|
||||||
|
|
@ -549,237 +544,229 @@ export const createAnimation = (
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
// ///////////////////////////////////////////////////////////////////////////
|
// ///////////////////////////////////////////////////////////////////////////
|
||||||
// Custom hook for theme toggle functionality
|
// Custom hook for theme toggle functionality
|
||||||
export const useThemeToggle = ({
|
export const useThemeToggle = ({
|
||||||
variant = "circle",
|
variant = "circle",
|
||||||
start = "center",
|
start = "center",
|
||||||
blur = false,
|
blur = false,
|
||||||
gifUrl = "",
|
gifUrl = "",
|
||||||
}: {
|
}: {
|
||||||
variant?: AnimationVariant;
|
variant?: AnimationVariant;
|
||||||
start?: AnimationStart;
|
start?: AnimationStart;
|
||||||
blur?: boolean;
|
blur?: boolean;
|
||||||
gifUrl?: string;
|
gifUrl?: string;
|
||||||
} = {}) => {
|
} = {}) => {
|
||||||
const { theme, setTheme, resolvedTheme } = useTheme();
|
const { theme, setTheme, resolvedTheme } = useTheme();
|
||||||
|
|
||||||
const [isDark, setIsDark] = useState(false);
|
const [isDark, setIsDark] = useState(false);
|
||||||
|
|
||||||
// Sync isDark state with resolved theme after hydration
|
// Sync isDark state with resolved theme after hydration
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsDark(resolvedTheme === "dark");
|
setIsDark(resolvedTheme === "dark");
|
||||||
}, [resolvedTheme]);
|
}, [resolvedTheme]);
|
||||||
|
|
||||||
const styleId = "theme-transition-styles";
|
const styleId = "theme-transition-styles";
|
||||||
|
|
||||||
const updateStyles = useCallback((css: string) => {
|
const updateStyles = useCallback((css: string) => {
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
|
|
||||||
let styleElement = document.getElementById(styleId) as HTMLStyleElement;
|
let styleElement = document.getElementById(styleId) as HTMLStyleElement;
|
||||||
|
|
||||||
if (!styleElement) {
|
if (!styleElement) {
|
||||||
styleElement = document.createElement("style");
|
styleElement = document.createElement("style");
|
||||||
styleElement.id = styleId;
|
styleElement.id = styleId;
|
||||||
document.head.appendChild(styleElement);
|
document.head.appendChild(styleElement);
|
||||||
}
|
}
|
||||||
|
|
||||||
styleElement.textContent = css;
|
styleElement.textContent = css;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const toggleTheme = useCallback(() => {
|
const toggleTheme = useCallback(() => {
|
||||||
setIsDark(!isDark);
|
setIsDark(!isDark);
|
||||||
|
|
||||||
const animation = createAnimation(variant, start, blur, gifUrl);
|
const animation = createAnimation(variant, start, blur, gifUrl);
|
||||||
|
|
||||||
updateStyles(animation.css);
|
updateStyles(animation.css);
|
||||||
|
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
|
|
||||||
const switchTheme = () => {
|
const switchTheme = () => {
|
||||||
setTheme(theme === "light" ? "dark" : "light");
|
setTheme(theme === "light" ? "dark" : "light");
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!document.startViewTransition) {
|
if (!document.startViewTransition) {
|
||||||
switchTheme();
|
switchTheme();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
document.startViewTransition(switchTheme);
|
document.startViewTransition(switchTheme);
|
||||||
}, [theme, setTheme, variant, start, blur, gifUrl, updateStyles, isDark]);
|
}, [theme, setTheme, variant, start, blur, gifUrl, updateStyles, isDark]);
|
||||||
|
|
||||||
const setCrazyLightTheme = useCallback(() => {
|
const setCrazyLightTheme = useCallback(() => {
|
||||||
setIsDark(false);
|
setIsDark(false);
|
||||||
|
|
||||||
const animation = createAnimation(variant, start, blur, gifUrl);
|
const animation = createAnimation(variant, start, blur, gifUrl);
|
||||||
|
|
||||||
updateStyles(animation.css);
|
updateStyles(animation.css);
|
||||||
|
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
|
|
||||||
const switchTheme = () => {
|
const switchTheme = () => {
|
||||||
setTheme("light");
|
setTheme("light");
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!document.startViewTransition) {
|
if (!document.startViewTransition) {
|
||||||
switchTheme();
|
switchTheme();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
document.startViewTransition(switchTheme);
|
document.startViewTransition(switchTheme);
|
||||||
}, [setTheme, variant, start, blur, gifUrl, updateStyles]);
|
}, [setTheme, variant, start, blur, gifUrl, updateStyles]);
|
||||||
|
|
||||||
const setCrazyDarkTheme = useCallback(() => {
|
const setCrazyDarkTheme = useCallback(() => {
|
||||||
setIsDark(true);
|
setIsDark(true);
|
||||||
|
|
||||||
const animation = createAnimation(variant, start, blur, gifUrl);
|
const animation = createAnimation(variant, start, blur, gifUrl);
|
||||||
|
|
||||||
updateStyles(animation.css);
|
updateStyles(animation.css);
|
||||||
|
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
|
|
||||||
const switchTheme = () => {
|
const switchTheme = () => {
|
||||||
setTheme("dark");
|
setTheme("dark");
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!document.startViewTransition) {
|
if (!document.startViewTransition) {
|
||||||
switchTheme();
|
switchTheme();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
document.startViewTransition(switchTheme);
|
document.startViewTransition(switchTheme);
|
||||||
}, [setTheme, variant, start, blur, gifUrl, updateStyles]);
|
}, [setTheme, variant, start, blur, gifUrl, updateStyles]);
|
||||||
|
|
||||||
const setCrazySystemTheme = useCallback(() => {
|
const setCrazySystemTheme = useCallback(() => {
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
|
|
||||||
const prefersDark = window.matchMedia(
|
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||||
"(prefers-color-scheme: dark)",
|
setIsDark(prefersDark);
|
||||||
).matches;
|
|
||||||
setIsDark(prefersDark);
|
|
||||||
|
|
||||||
const animation = createAnimation(variant, start, blur, gifUrl);
|
const animation = createAnimation(variant, start, blur, gifUrl);
|
||||||
|
|
||||||
updateStyles(animation.css);
|
updateStyles(animation.css);
|
||||||
|
|
||||||
const switchTheme = () => {
|
const switchTheme = () => {
|
||||||
setTheme("system");
|
setTheme("system");
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!document.startViewTransition) {
|
if (!document.startViewTransition) {
|
||||||
switchTheme();
|
switchTheme();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
document.startViewTransition(switchTheme);
|
document.startViewTransition(switchTheme);
|
||||||
}, [setTheme, variant, start, blur, gifUrl, updateStyles]);
|
}, [setTheme, variant, start, blur, gifUrl, updateStyles]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isDark,
|
isDark,
|
||||||
setIsDark,
|
setIsDark,
|
||||||
toggleTheme,
|
toggleTheme,
|
||||||
setCrazyLightTheme,
|
setCrazyLightTheme,
|
||||||
setCrazyDarkTheme,
|
setCrazyDarkTheme,
|
||||||
setCrazySystemTheme,
|
setCrazySystemTheme,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
// ///////////////////////////////////////////////////////////////////////////
|
// ///////////////////////////////////////////////////////////////////////////
|
||||||
// Theme Toggle Button Component (Sun/Moon Style)
|
// Theme Toggle Button Component (Sun/Moon Style)
|
||||||
|
|
||||||
export const ThemeToggleButton = ({
|
export const ThemeToggleButton = ({
|
||||||
className = "",
|
className = "",
|
||||||
variant = "circle",
|
variant = "circle",
|
||||||
start = "center",
|
start = "center",
|
||||||
blur = false,
|
blur = false,
|
||||||
gifUrl = "",
|
gifUrl = "",
|
||||||
}: {
|
}: {
|
||||||
className?: string;
|
className?: string;
|
||||||
variant?: AnimationVariant;
|
variant?: AnimationVariant;
|
||||||
start?: AnimationStart;
|
start?: AnimationStart;
|
||||||
blur?: boolean;
|
blur?: boolean;
|
||||||
gifUrl?: string;
|
gifUrl?: string;
|
||||||
}) => {
|
}) => {
|
||||||
const { isDark, toggleTheme } = useThemeToggle({
|
const { isDark, toggleTheme } = useThemeToggle({
|
||||||
variant,
|
variant,
|
||||||
start,
|
start,
|
||||||
blur,
|
blur,
|
||||||
gifUrl,
|
gifUrl,
|
||||||
});
|
});
|
||||||
const clipId = useId();
|
const clipId = useId();
|
||||||
const clipPathId = `theme-toggle-clip-${clipId}`;
|
const clipPathId = `theme-toggle-clip-${clipId}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={cn(
|
className={cn(
|
||||||
"size-10 cursor-pointer rounded-full p-2 transition-all duration-300 active:scale-95 bg-transparent",
|
"size-10 cursor-pointer rounded-full p-2 transition-all duration-300 active:scale-95 bg-transparent",
|
||||||
isDark ? "text-white" : "text-black",
|
isDark ? "text-white" : "text-black",
|
||||||
className,
|
className
|
||||||
)}
|
)}
|
||||||
onClick={toggleTheme}
|
onClick={toggleTheme}
|
||||||
aria-label="Toggle theme"
|
aria-label="Toggle theme"
|
||||||
>
|
>
|
||||||
<span className="sr-only">Toggle theme</span>
|
<span className="sr-only">Toggle theme</span>
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
viewBox="0 0 32 32"
|
viewBox="0 0 32 32"
|
||||||
>
|
>
|
||||||
<clipPath id={clipPathId}>
|
<clipPath id={clipPathId}>
|
||||||
<motion.path
|
<motion.path
|
||||||
animate={{ y: isDark ? 10 : 0, x: isDark ? -12 : 0 }}
|
animate={{ y: isDark ? 10 : 0, x: isDark ? -12 : 0 }}
|
||||||
transition={{ ease: "easeInOut", duration: 0.35 }}
|
transition={{ ease: "easeInOut", duration: 0.35 }}
|
||||||
d="M0-5h30a1 1 0 0 0 9 13v24H0Z"
|
d="M0-5h30a1 1 0 0 0 9 13v24H0Z"
|
||||||
/>
|
/>
|
||||||
</clipPath>
|
</clipPath>
|
||||||
<g clipPath={`url(#${clipPathId})`}>
|
<g clipPath={`url(#${clipPathId})`}>
|
||||||
<motion.circle
|
<motion.circle
|
||||||
animate={{ r: isDark ? 10 : 8 }}
|
animate={{ r: isDark ? 10 : 8 }}
|
||||||
transition={{ ease: "easeInOut", duration: 0.35 }}
|
transition={{ ease: "easeInOut", duration: 0.35 }}
|
||||||
cx="16"
|
cx="16"
|
||||||
cy="16"
|
cy="16"
|
||||||
/>
|
/>
|
||||||
<motion.g
|
<motion.g
|
||||||
animate={{
|
animate={{
|
||||||
rotate: isDark ? -100 : 0,
|
rotate: isDark ? -100 : 0,
|
||||||
scale: isDark ? 0.5 : 1,
|
scale: isDark ? 0.5 : 1,
|
||||||
opacity: isDark ? 0 : 1,
|
opacity: isDark ? 0 : 1,
|
||||||
}}
|
}}
|
||||||
transition={{ ease: "easeInOut", duration: 0.35 }}
|
transition={{ ease: "easeInOut", duration: 0.35 }}
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
strokeWidth="1.5"
|
strokeWidth="1.5"
|
||||||
>
|
>
|
||||||
<path d="M16 5.5v-4" />
|
<path d="M16 5.5v-4" />
|
||||||
<path d="M16 30.5v-4" />
|
<path d="M16 30.5v-4" />
|
||||||
<path d="M1.5 16h4" />
|
<path d="M1.5 16h4" />
|
||||||
<path d="M26.5 16h4" />
|
<path d="M26.5 16h4" />
|
||||||
<path d="m23.4 8.6 2.8-2.8" />
|
<path d="m23.4 8.6 2.8-2.8" />
|
||||||
<path d="m5.7 26.3 2.9-2.9" />
|
<path d="m5.7 26.3 2.9-2.9" />
|
||||||
<path d="m5.8 5.8 2.8 2.8" />
|
<path d="m5.8 5.8 2.8 2.8" />
|
||||||
<path d="m23.4 23.4 2.9 2.9" />
|
<path d="m23.4 23.4 2.9 2.9" />
|
||||||
</motion.g>
|
</motion.g>
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// ///////////////////////////////////////////////////////////////////////////
|
// ///////////////////////////////////////////////////////////////////////////
|
||||||
// Backwards compatible export (alias for ThemeToggleButton with default settings)
|
// Backwards compatible export (alias for ThemeToggleButton with default settings)
|
||||||
export function ThemeTogglerComponent() {
|
export function ThemeTogglerComponent() {
|
||||||
return (
|
return <ThemeToggleButton variant="circle" start="top-right" className="size-8" />;
|
||||||
<ThemeToggleButton
|
|
||||||
variant="circle"
|
|
||||||
start="top-right"
|
|
||||||
className="size-8"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ export const inboxItemTypeEnum = z.enum([
|
||||||
"connector_deletion",
|
"connector_deletion",
|
||||||
"document_processing",
|
"document_processing",
|
||||||
"new_mention",
|
"new_mention",
|
||||||
|
"comment_reply",
|
||||||
"page_limit_exceeded",
|
"page_limit_exceeded",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
@ -101,6 +102,19 @@ export const newMentionMetadata = z.object({
|
||||||
content_preview: z.string(),
|
content_preview: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const commentReplyMetadata = z.object({
|
||||||
|
reply_id: z.number(),
|
||||||
|
parent_comment_id: z.number(),
|
||||||
|
message_id: z.number(),
|
||||||
|
thread_id: z.number(),
|
||||||
|
thread_title: z.string(),
|
||||||
|
author_id: z.string(),
|
||||||
|
author_name: z.string(),
|
||||||
|
author_avatar_url: z.string().nullable().optional(),
|
||||||
|
author_email: z.string().optional(),
|
||||||
|
content_preview: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Page limit exceeded metadata schema
|
* Page limit exceeded metadata schema
|
||||||
*/
|
*/
|
||||||
|
|
@ -125,6 +139,7 @@ export const inboxItemMetadata = z.union([
|
||||||
connectorDeletionMetadata,
|
connectorDeletionMetadata,
|
||||||
documentProcessingMetadata,
|
documentProcessingMetadata,
|
||||||
newMentionMetadata,
|
newMentionMetadata,
|
||||||
|
commentReplyMetadata,
|
||||||
pageLimitExceededMetadata,
|
pageLimitExceededMetadata,
|
||||||
baseInboxItemMetadata,
|
baseInboxItemMetadata,
|
||||||
]);
|
]);
|
||||||
|
|
@ -168,6 +183,11 @@ export const newMentionInboxItem = inboxItem.extend({
|
||||||
metadata: newMentionMetadata,
|
metadata: newMentionMetadata,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const commentReplyInboxItem = inboxItem.extend({
|
||||||
|
type: z.literal("comment_reply"),
|
||||||
|
metadata: commentReplyMetadata,
|
||||||
|
});
|
||||||
|
|
||||||
export const pageLimitExceededInboxItem = inboxItem.extend({
|
export const pageLimitExceededInboxItem = inboxItem.extend({
|
||||||
type: z.literal("page_limit_exceeded"),
|
type: z.literal("page_limit_exceeded"),
|
||||||
metadata: pageLimitExceededMetadata,
|
metadata: pageLimitExceededMetadata,
|
||||||
|
|
@ -278,6 +298,10 @@ export function isNewMentionMetadata(metadata: unknown): metadata is NewMentionM
|
||||||
return newMentionMetadata.safeParse(metadata).success;
|
return newMentionMetadata.safeParse(metadata).success;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isCommentReplyMetadata(metadata: unknown): metadata is CommentReplyMetadata {
|
||||||
|
return commentReplyMetadata.safeParse(metadata).success;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Type guard for PageLimitExceededMetadata
|
* Type guard for PageLimitExceededMetadata
|
||||||
*/
|
*/
|
||||||
|
|
@ -298,6 +322,7 @@ export function parseInboxItemMetadata(
|
||||||
| ConnectorDeletionMetadata
|
| ConnectorDeletionMetadata
|
||||||
| DocumentProcessingMetadata
|
| DocumentProcessingMetadata
|
||||||
| NewMentionMetadata
|
| NewMentionMetadata
|
||||||
|
| CommentReplyMetadata
|
||||||
| PageLimitExceededMetadata
|
| PageLimitExceededMetadata
|
||||||
| null {
|
| null {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
|
|
@ -317,6 +342,10 @@ export function parseInboxItemMetadata(
|
||||||
const result = newMentionMetadata.safeParse(metadata);
|
const result = newMentionMetadata.safeParse(metadata);
|
||||||
return result.success ? result.data : null;
|
return result.success ? result.data : null;
|
||||||
}
|
}
|
||||||
|
case "comment_reply": {
|
||||||
|
const result = commentReplyMetadata.safeParse(metadata);
|
||||||
|
return result.success ? result.data : null;
|
||||||
|
}
|
||||||
case "page_limit_exceeded": {
|
case "page_limit_exceeded": {
|
||||||
const result = pageLimitExceededMetadata.safeParse(metadata);
|
const result = pageLimitExceededMetadata.safeParse(metadata);
|
||||||
return result.success ? result.data : null;
|
return result.success ? result.data : null;
|
||||||
|
|
@ -338,6 +367,7 @@ export type ConnectorIndexingMetadata = z.infer<typeof connectorIndexingMetadata
|
||||||
export type ConnectorDeletionMetadata = z.infer<typeof connectorDeletionMetadata>;
|
export type ConnectorDeletionMetadata = z.infer<typeof connectorDeletionMetadata>;
|
||||||
export type DocumentProcessingMetadata = z.infer<typeof documentProcessingMetadata>;
|
export type DocumentProcessingMetadata = z.infer<typeof documentProcessingMetadata>;
|
||||||
export type NewMentionMetadata = z.infer<typeof newMentionMetadata>;
|
export type NewMentionMetadata = z.infer<typeof newMentionMetadata>;
|
||||||
|
export type CommentReplyMetadata = z.infer<typeof commentReplyMetadata>;
|
||||||
export type PageLimitExceededMetadata = z.infer<typeof pageLimitExceededMetadata>;
|
export type PageLimitExceededMetadata = z.infer<typeof pageLimitExceededMetadata>;
|
||||||
export type InboxItemMetadata = z.infer<typeof inboxItemMetadata>;
|
export type InboxItemMetadata = z.infer<typeof inboxItemMetadata>;
|
||||||
export type InboxItem = z.infer<typeof inboxItem>;
|
export type InboxItem = z.infer<typeof inboxItem>;
|
||||||
|
|
@ -345,6 +375,7 @@ export type ConnectorIndexingInboxItem = z.infer<typeof connectorIndexingInboxIt
|
||||||
export type ConnectorDeletionInboxItem = z.infer<typeof connectorDeletionInboxItem>;
|
export type ConnectorDeletionInboxItem = z.infer<typeof connectorDeletionInboxItem>;
|
||||||
export type DocumentProcessingInboxItem = z.infer<typeof documentProcessingInboxItem>;
|
export type DocumentProcessingInboxItem = z.infer<typeof documentProcessingInboxItem>;
|
||||||
export type NewMentionInboxItem = z.infer<typeof newMentionInboxItem>;
|
export type NewMentionInboxItem = z.infer<typeof newMentionInboxItem>;
|
||||||
|
export type CommentReplyInboxItem = z.infer<typeof commentReplyInboxItem>;
|
||||||
export type PageLimitExceededInboxItem = z.infer<typeof pageLimitExceededInboxItem>;
|
export type PageLimitExceededInboxItem = z.infer<typeof pageLimitExceededInboxItem>;
|
||||||
|
|
||||||
// API Request/Response types
|
// API Request/Response types
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { getBearerToken } from "@/lib/auth-utils";
|
import { getBearerToken } from "@/lib/auth-utils";
|
||||||
|
import { copyToClipboard as copyToClipboardUtil } from "@/lib/utils";
|
||||||
|
|
||||||
interface UseApiKeyReturn {
|
interface UseApiKeyReturn {
|
||||||
apiKey: string | null;
|
apiKey: string | null;
|
||||||
|
|
@ -33,60 +34,17 @@ export function useApiKey(): UseApiKeyReturn {
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const fallbackCopyTextToClipboard = (text: string) => {
|
|
||||||
const textArea = document.createElement("textarea");
|
|
||||||
textArea.value = text;
|
|
||||||
|
|
||||||
// Avoid scrolling to bottom
|
|
||||||
textArea.style.top = "0";
|
|
||||||
textArea.style.left = "0";
|
|
||||||
textArea.style.position = "fixed";
|
|
||||||
textArea.style.opacity = "0";
|
|
||||||
|
|
||||||
document.body.appendChild(textArea);
|
|
||||||
textArea.focus();
|
|
||||||
textArea.select();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const successful = document.execCommand("copy");
|
|
||||||
document.body.removeChild(textArea);
|
|
||||||
|
|
||||||
if (successful) {
|
|
||||||
setCopied(true);
|
|
||||||
toast.success("API key copied to clipboard");
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
setCopied(false);
|
|
||||||
}, 2000);
|
|
||||||
} else {
|
|
||||||
toast.error("Failed to copy API key");
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Fallback: Oops, unable to copy", err);
|
|
||||||
document.body.removeChild(textArea);
|
|
||||||
toast.error("Failed to copy API key");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const copyToClipboard = useCallback(async () => {
|
const copyToClipboard = useCallback(async () => {
|
||||||
if (!apiKey) return;
|
if (!apiKey) return;
|
||||||
|
|
||||||
try {
|
const success = await copyToClipboardUtil(apiKey);
|
||||||
if (navigator.clipboard && window.isSecureContext) {
|
if (success) {
|
||||||
// Use Clipboard API if available and in secure context
|
setCopied(true);
|
||||||
await navigator.clipboard.writeText(apiKey);
|
toast.success("API key copied to clipboard");
|
||||||
setCopied(true);
|
setTimeout(() => {
|
||||||
toast.success("API key copied to clipboard");
|
setCopied(false);
|
||||||
|
}, 2000);
|
||||||
setTimeout(() => {
|
} else {
|
||||||
setCopied(false);
|
|
||||||
}, 2000);
|
|
||||||
} else {
|
|
||||||
// Fallback for non-secure contexts or browsers without clipboard API
|
|
||||||
fallbackCopyTextToClipboard(apiKey);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to copy:", err);
|
|
||||||
toast.error("Failed to copy API key");
|
toast.error("Failed to copy API key");
|
||||||
}
|
}
|
||||||
}, [apiKey]);
|
}, [apiKey]);
|
||||||
|
|
|
||||||
|
|
@ -12,3 +12,44 @@ export const formatDate = (date: Date): string => {
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy text to clipboard with fallback for older browsers and non-secure contexts.
|
||||||
|
* Returns true if successful, false otherwise.
|
||||||
|
*/
|
||||||
|
export async function copyToClipboard(text: string): Promise<boolean> {
|
||||||
|
// Use modern Clipboard API if available and in secure context
|
||||||
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Clipboard API failed:", err);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback for non-secure contexts or browsers without Clipboard API
|
||||||
|
const textArea = document.createElement("textarea");
|
||||||
|
textArea.value = text;
|
||||||
|
|
||||||
|
// Avoid scrolling to bottom
|
||||||
|
textArea.style.top = "0";
|
||||||
|
textArea.style.left = "0";
|
||||||
|
textArea.style.position = "fixed";
|
||||||
|
textArea.style.opacity = "0";
|
||||||
|
|
||||||
|
document.body.appendChild(textArea);
|
||||||
|
textArea.focus();
|
||||||
|
textArea.select();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const successful = document.execCommand("copy");
|
||||||
|
document.body.removeChild(textArea);
|
||||||
|
return successful;
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Fallback copy failed:", err);
|
||||||
|
document.body.removeChild(textArea);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -705,10 +705,13 @@
|
||||||
"mark_all_read": "Mark all as read",
|
"mark_all_read": "Mark all as read",
|
||||||
"mark_as_read": "Mark as read",
|
"mark_as_read": "Mark as read",
|
||||||
"mentions": "Mentions",
|
"mentions": "Mentions",
|
||||||
|
"comments": "Comments",
|
||||||
"status": "Status",
|
"status": "Status",
|
||||||
"no_results_found": "No results found",
|
"no_results_found": "No results found",
|
||||||
"no_mentions": "No mentions",
|
"no_mentions": "No mentions",
|
||||||
"no_mentions_hint": "You'll see mentions from others here",
|
"no_mentions_hint": "You'll see mentions from others here",
|
||||||
|
"no_comments": "No comments",
|
||||||
|
"no_comments_hint": "You'll see mentions and replies here",
|
||||||
"no_status_updates": "No status updates",
|
"no_status_updates": "No status updates",
|
||||||
"no_status_updates_hint": "Document and connector updates will appear here",
|
"no_status_updates_hint": "Document and connector updates will appear here",
|
||||||
"filter": "Filter",
|
"filter": "Filter",
|
||||||
|
|
|
||||||
|
|
@ -690,10 +690,13 @@
|
||||||
"mark_all_read": "全部标记为已读",
|
"mark_all_read": "全部标记为已读",
|
||||||
"mark_as_read": "标记为已读",
|
"mark_as_read": "标记为已读",
|
||||||
"mentions": "提及",
|
"mentions": "提及",
|
||||||
|
"comments": "评论",
|
||||||
"status": "状态",
|
"status": "状态",
|
||||||
"no_results_found": "未找到结果",
|
"no_results_found": "未找到结果",
|
||||||
"no_mentions": "没有提及",
|
"no_mentions": "没有提及",
|
||||||
"no_mentions_hint": "您会在这里看到他人的提及",
|
"no_mentions_hint": "您会在这里看到他人的提及",
|
||||||
|
"no_comments": "没有评论",
|
||||||
|
"no_comments_hint": "您会在这里看到提及和回复",
|
||||||
"no_status_updates": "没有状态更新",
|
"no_status_updates": "没有状态更新",
|
||||||
"no_status_updates_hint": "文档和连接器更新将显示在这里",
|
"no_status_updates_hint": "文档和连接器更新将显示在这里",
|
||||||
"filter": "筛选",
|
"filter": "筛选",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue