mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-07-08 22:22:17 +02:00
- Added the ensure_publication function to create and verify the zero_publication if it is missing, ensuring idempotency during database initialization. - Integrated ensure_publication into the create_db_and_tables function to prevent zero-cache crash loops on startup. - Introduced a self-check script to validate the ensure_publication functionality on a create_all-bootstrapped database. - Updated various components to reflect the transition from search space to workspace, including adjustments in imports and routing paths.
1081 lines
36 KiB
TypeScript
1081 lines
36 KiB
TypeScript
"use client";
|
|
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { useAtom } from "jotai";
|
|
import {
|
|
AlertCircle,
|
|
AlertTriangle,
|
|
BellDot,
|
|
Check,
|
|
CheckCheck,
|
|
CheckCircle2,
|
|
ChevronLeft,
|
|
History,
|
|
Inbox,
|
|
LayoutGrid,
|
|
ListFilter,
|
|
MessageCircleReply,
|
|
Search,
|
|
X,
|
|
} from "lucide-react";
|
|
import { useParams, useRouter } from "next/navigation";
|
|
import { useTranslations } from "next-intl";
|
|
import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from "react";
|
|
import { setTargetCommentIdAtom } from "@/atoms/chat/current-thread.atom";
|
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/animated-tabs";
|
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Drawer,
|
|
DrawerContent,
|
|
DrawerHandle,
|
|
DrawerHeader,
|
|
DrawerTitle,
|
|
} from "@/components/ui/drawer";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuLabel,
|
|
DropdownMenuTrigger,
|
|
} from "@/components/ui/dropdown-menu";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Skeleton } from "@/components/ui/skeleton";
|
|
import { Spinner } from "@/components/ui/spinner";
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
|
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
|
import {
|
|
isCommentReplyMetadata,
|
|
isConnectorIndexingMetadata,
|
|
isDocumentProcessingMetadata,
|
|
isInsufficientCreditsMetadata,
|
|
isNewMentionMetadata,
|
|
} from "@/contracts/types/inbox.types";
|
|
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
|
import type { InboxItem } from "@/hooks/use-inbox";
|
|
import { useMediaQuery } from "@/hooks/use-media-query";
|
|
import { notificationsApiService } from "@/lib/apis/notifications-api.service";
|
|
import { convertRenderedToDisplay } from "@/lib/comments/utils";
|
|
import { getDocumentTypeLabel } from "@/lib/documents/document-type-labels";
|
|
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
|
import { cn } from "@/lib/utils";
|
|
import { SidebarSlideOutPanel } from "./SidebarSlideOutPanel";
|
|
|
|
function getInitials(name: string | null | undefined, email: string | null | undefined): string {
|
|
if (name) {
|
|
return name
|
|
.split(" ")
|
|
.map((n) => n[0])
|
|
.join("")
|
|
.toUpperCase()
|
|
.slice(0, 2);
|
|
}
|
|
if (email) {
|
|
const localPart = email.split("@")[0];
|
|
return localPart.slice(0, 2).toUpperCase();
|
|
}
|
|
return "U";
|
|
}
|
|
|
|
function formatInboxCount(count: number): string {
|
|
if (count <= 999) {
|
|
return count.toString();
|
|
}
|
|
const thousands = Math.floor(count / 1000);
|
|
return `${thousands}k+`;
|
|
}
|
|
|
|
function getConnectorTypeDisplayName(connectorType: string): string {
|
|
const displayNames: Record<string, string> = {
|
|
GITHUB_CONNECTOR: "GitHub",
|
|
GOOGLE_CALENDAR_CONNECTOR: "Google Calendar",
|
|
GOOGLE_GMAIL_CONNECTOR: "Gmail",
|
|
GOOGLE_DRIVE_CONNECTOR: "Google Drive",
|
|
COMPOSIO_GOOGLE_DRIVE_CONNECTOR: "Composio Google Drive",
|
|
COMPOSIO_GMAIL_CONNECTOR: "Composio Gmail",
|
|
COMPOSIO_GOOGLE_CALENDAR_CONNECTOR: "Composio Google Calendar",
|
|
LINEAR_CONNECTOR: "Linear",
|
|
NOTION_CONNECTOR: "Notion",
|
|
SLACK_CONNECTOR: "Slack",
|
|
TEAMS_CONNECTOR: "Microsoft Teams",
|
|
DISCORD_CONNECTOR: "Discord",
|
|
JIRA_CONNECTOR: "Jira",
|
|
CONFLUENCE_CONNECTOR: "Confluence",
|
|
BOOKSTACK_CONNECTOR: "BookStack",
|
|
CLICKUP_CONNECTOR: "ClickUp",
|
|
AIRTABLE_CONNECTOR: "Airtable",
|
|
LUMA_CONNECTOR: "Luma",
|
|
ELASTICSEARCH_CONNECTOR: "Elasticsearch",
|
|
WEBCRAWLER_CONNECTOR: "Web Crawler",
|
|
YOUTUBE_CONNECTOR: "YouTube",
|
|
CIRCLEBACK_CONNECTOR: "Circleback",
|
|
MCP_CONNECTOR: "MCP",
|
|
OBSIDIAN_CONNECTOR: "Obsidian",
|
|
ONEDRIVE_CONNECTOR: "OneDrive",
|
|
DROPBOX_CONNECTOR: "Dropbox",
|
|
TAVILY_API: "Tavily",
|
|
SEARXNG_API: "SearXNG",
|
|
LINKUP_API: "Linkup",
|
|
BAIDU_SEARCH_API: "Baidu",
|
|
};
|
|
|
|
return (
|
|
displayNames[connectorType] ||
|
|
connectorType
|
|
.replace(/_/g, " ")
|
|
.replace(/CONNECTOR|API/gi, "")
|
|
.trim()
|
|
);
|
|
}
|
|
|
|
type InboxTab = "comments" | "status";
|
|
type InboxFilter = "all" | "unread" | "errors";
|
|
|
|
interface TabDataSource {
|
|
items: InboxItem[];
|
|
unreadCount: number;
|
|
loading: boolean;
|
|
loadingMore: boolean;
|
|
hasMore: boolean;
|
|
loadMore: () => void;
|
|
markAsRead: (id: number) => Promise<boolean>;
|
|
markAllAsRead: () => Promise<boolean>;
|
|
}
|
|
|
|
export interface InboxSidebarContentProps {
|
|
onOpenChange: (open: boolean) => void;
|
|
comments: TabDataSource;
|
|
status: TabDataSource;
|
|
totalUnreadCount: number;
|
|
onCloseMobileSidebar?: () => void;
|
|
}
|
|
|
|
interface InboxSidebarProps extends InboxSidebarContentProps {
|
|
open: boolean;
|
|
}
|
|
|
|
export function InboxSidebarContent({
|
|
onOpenChange,
|
|
comments,
|
|
status,
|
|
totalUnreadCount,
|
|
onCloseMobileSidebar,
|
|
}: InboxSidebarContentProps) {
|
|
const t = useTranslations("sidebar");
|
|
const router = useRouter();
|
|
const params = useParams();
|
|
const isMobile = !useMediaQuery("(min-width: 640px)");
|
|
const searchSpaceId = params?.workspace_id ? Number(params.workspace_id) : null;
|
|
|
|
const [, setTargetCommentId] = useAtom(setTargetCommentIdAtom);
|
|
|
|
const [searchQuery, setSearchQuery] = useState("");
|
|
const debouncedSearch = useDebouncedValue(searchQuery, 300);
|
|
const isSearchMode = !!debouncedSearch.trim();
|
|
const [activeTab, setActiveTab] = useState<InboxTab>("comments");
|
|
const [activeFilter, setActiveFilter] = useState<InboxFilter>("all");
|
|
const [selectedSource, setSelectedSource] = useState<string | null>(null);
|
|
const [mounted, setMounted] = useState(false);
|
|
const [openDropdown, setOpenDropdown] = useState<"filter" | null>(null);
|
|
const [connectorScrollPos, setConnectorScrollPos] = useState<"top" | "middle" | "bottom">("top");
|
|
const connectorRafRef = useRef<number | null>(null);
|
|
const handleConnectorScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
|
|
const el = e.currentTarget;
|
|
if (connectorRafRef.current) return;
|
|
connectorRafRef.current = requestAnimationFrame(() => {
|
|
const atTop = el.scrollTop <= 2;
|
|
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2;
|
|
setConnectorScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle");
|
|
connectorRafRef.current = null;
|
|
});
|
|
}, []);
|
|
useEffect(
|
|
() => () => {
|
|
if (connectorRafRef.current) cancelAnimationFrame(connectorRafRef.current);
|
|
},
|
|
[]
|
|
);
|
|
const [filterDrawerOpen, setFilterDrawerOpen] = useState(false);
|
|
const [markingAsReadId, setMarkingAsReadId] = useState<number | null>(null);
|
|
|
|
const prefetchTriggerRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Server-side search query
|
|
const searchTypeFilter = activeTab === "comments" ? ("new_mention" as const) : undefined;
|
|
const { data: searchResponse, isLoading: isSearchLoading } = useQuery({
|
|
queryKey: cacheKeys.notifications.search(searchSpaceId, debouncedSearch.trim(), activeTab),
|
|
queryFn: () =>
|
|
notificationsApiService.getNotifications({
|
|
queryParams: {
|
|
workspace_id: searchSpaceId ?? undefined,
|
|
type: searchTypeFilter,
|
|
search: debouncedSearch.trim(),
|
|
limit: 50,
|
|
},
|
|
}),
|
|
staleTime: 30 * 1000,
|
|
enabled: isSearchMode,
|
|
});
|
|
|
|
useEffect(() => {
|
|
setMounted(true);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!isMobile) return;
|
|
const originalOverflow = document.body.style.overflow;
|
|
document.body.style.overflow = "hidden";
|
|
return () => {
|
|
document.body.style.overflow = originalOverflow;
|
|
};
|
|
}, [isMobile]);
|
|
|
|
useEffect(() => {
|
|
if (activeTab !== "status") {
|
|
setSelectedSource(null);
|
|
}
|
|
}, [activeTab]);
|
|
|
|
// Active tab's data source — fully independent loading, pagination, and counts
|
|
const activeSource = activeTab === "comments" ? comments : status;
|
|
|
|
// Fetch source types for the status tab filter
|
|
const { data: sourceTypesData } = useQuery({
|
|
queryKey: cacheKeys.notifications.sourceTypes(searchSpaceId),
|
|
queryFn: () => notificationsApiService.getSourceTypes(searchSpaceId ?? undefined),
|
|
staleTime: 60 * 1000,
|
|
enabled: activeTab === "status",
|
|
});
|
|
|
|
const statusSourceOptions = useMemo(() => {
|
|
if (!sourceTypesData?.sources) return [];
|
|
|
|
return sourceTypesData.sources.map((source) => ({
|
|
key: source.key,
|
|
type: source.type,
|
|
category: source.category,
|
|
displayName:
|
|
source.category === "connector"
|
|
? getConnectorTypeDisplayName(source.type)
|
|
: getDocumentTypeLabel(source.type),
|
|
}));
|
|
}, [sourceTypesData]);
|
|
|
|
// Client-side filter: source type
|
|
const matchesSourceFilter = useCallback(
|
|
(item: InboxItem): boolean => {
|
|
if (!selectedSource) return true;
|
|
if (selectedSource.startsWith("connector:")) {
|
|
const connectorType = selectedSource.slice("connector:".length);
|
|
return (
|
|
item.type === "connector_indexing" &&
|
|
isConnectorIndexingMetadata(item.metadata) &&
|
|
item.metadata.connector_type === connectorType
|
|
);
|
|
}
|
|
if (selectedSource.startsWith("doctype:")) {
|
|
const docType = selectedSource.slice("doctype:".length);
|
|
return (
|
|
item.type === "document_processing" &&
|
|
isDocumentProcessingMetadata(item.metadata) &&
|
|
item.metadata.document_type === docType
|
|
);
|
|
}
|
|
return true;
|
|
},
|
|
[selectedSource]
|
|
);
|
|
|
|
// Client-side filter: unread / errors
|
|
const matchesActiveFilter = useCallback(
|
|
(item: InboxItem): boolean => {
|
|
if (activeFilter === "unread") return !item.read;
|
|
if (activeFilter === "errors") {
|
|
if (item.type === "insufficient_credits") return true;
|
|
const meta = item.metadata as Record<string, unknown> | undefined;
|
|
return typeof meta?.status === "string" && meta.status === "failed";
|
|
}
|
|
return true;
|
|
},
|
|
[activeFilter]
|
|
);
|
|
|
|
// Defer non-urgent list updates so the search input stays responsive.
|
|
// The deferred snapshot lags one render behind the live value intentionally.
|
|
const deferredTabItems = useDeferredValue(activeSource.items);
|
|
const deferredSearchItems = useDeferredValue(searchResponse?.items ?? []);
|
|
|
|
// Two data paths: search mode (API) or default (per-tab data source)
|
|
const filteredItems = useMemo(() => {
|
|
const tabItems: InboxItem[] = isSearchMode ? deferredSearchItems : deferredTabItems;
|
|
|
|
let result = tabItems;
|
|
if (activeFilter !== "all") {
|
|
result = result.filter(matchesActiveFilter);
|
|
}
|
|
if (activeTab === "status" && selectedSource) {
|
|
result = result.filter(matchesSourceFilter);
|
|
}
|
|
|
|
return result;
|
|
}, [
|
|
isSearchMode,
|
|
deferredSearchItems,
|
|
deferredTabItems,
|
|
activeTab,
|
|
activeFilter,
|
|
selectedSource,
|
|
matchesActiveFilter,
|
|
matchesSourceFilter,
|
|
]);
|
|
|
|
// Infinite scroll — uses active tab's pagination
|
|
useEffect(() => {
|
|
if (!activeSource.hasMore || activeSource.loadingMore || isSearchMode) return;
|
|
|
|
const observer = new IntersectionObserver(
|
|
(entries) => {
|
|
if (entries[0]?.isIntersecting) {
|
|
activeSource.loadMore();
|
|
}
|
|
},
|
|
{
|
|
root: null,
|
|
rootMargin: "100px",
|
|
threshold: 0,
|
|
}
|
|
);
|
|
|
|
if (prefetchTriggerRef.current) {
|
|
observer.observe(prefetchTriggerRef.current);
|
|
}
|
|
|
|
return () => observer.disconnect();
|
|
}, [activeSource.hasMore, activeSource.loadingMore, activeSource.loadMore, isSearchMode]);
|
|
|
|
const handleItemClick = useCallback(
|
|
async (item: InboxItem) => {
|
|
if (!item.read) {
|
|
setMarkingAsReadId(item.id);
|
|
await activeSource.markAsRead(item.id);
|
|
setMarkingAsReadId(null);
|
|
}
|
|
|
|
if (item.type === "new_mention") {
|
|
if (isNewMentionMetadata(item.metadata)) {
|
|
const searchSpaceId = item.workspace_id;
|
|
const threadId = item.metadata.thread_id;
|
|
const commentId = item.metadata.comment_id;
|
|
|
|
if (searchSpaceId && threadId) {
|
|
if (commentId) {
|
|
setTargetCommentId(commentId);
|
|
}
|
|
const url = commentId
|
|
? `/dashboard/${searchSpaceId}/new-chat/${threadId}?commentId=${commentId}`
|
|
: `/dashboard/${searchSpaceId}/new-chat/${threadId}`;
|
|
onOpenChange(false);
|
|
onCloseMobileSidebar?.();
|
|
router.push(url);
|
|
}
|
|
}
|
|
} else if (item.type === "comment_reply") {
|
|
if (isCommentReplyMetadata(item.metadata)) {
|
|
const searchSpaceId = item.workspace_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 === "insufficient_credits") {
|
|
if (isInsufficientCreditsMetadata(item.metadata)) {
|
|
const actionUrl = item.metadata.action_url;
|
|
if (actionUrl) {
|
|
onOpenChange(false);
|
|
onCloseMobileSidebar?.();
|
|
router.push(actionUrl);
|
|
}
|
|
}
|
|
}
|
|
},
|
|
[activeSource.markAsRead, router, onOpenChange, onCloseMobileSidebar, setTargetCommentId]
|
|
);
|
|
|
|
const handleMarkAllAsRead = useCallback(async () => {
|
|
await Promise.all([comments.markAllAsRead(), status.markAllAsRead()]);
|
|
}, [comments.markAllAsRead, status.markAllAsRead]);
|
|
|
|
const handleClearSearch = useCallback(() => {
|
|
setSearchQuery("");
|
|
}, []);
|
|
|
|
const formatTime = (dateString: string) => {
|
|
try {
|
|
const date = new Date(dateString);
|
|
const now = new Date();
|
|
const diffMs = now.getTime() - date.getTime();
|
|
const diffMins = Math.floor(diffMs / (1000 * 60));
|
|
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
|
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
|
|
|
if (diffMins < 1) return "now";
|
|
if (diffMins < 60) return `${diffMins}m`;
|
|
if (diffHours < 24) return `${diffHours}h`;
|
|
if (diffDays < 7) return `${diffDays}d`;
|
|
return `${Math.floor(diffDays / 7)}w`;
|
|
} catch {
|
|
return "now";
|
|
}
|
|
};
|
|
|
|
const getStatusIcon = (item: InboxItem) => {
|
|
if (item.type === "new_mention" || item.type === "comment_reply") {
|
|
const metadata =
|
|
item.type === "new_mention"
|
|
? isNewMentionMetadata(item.metadata)
|
|
? item.metadata
|
|
: null
|
|
: isCommentReplyMetadata(item.metadata)
|
|
? item.metadata
|
|
: null;
|
|
|
|
if (metadata) {
|
|
return (
|
|
<Avatar className="h-8 w-8">
|
|
{metadata.author_avatar_url && (
|
|
<AvatarImage src={metadata.author_avatar_url} alt={metadata.author_name || "User"} />
|
|
)}
|
|
<AvatarFallback className="text-[10px] bg-primary/10 text-primary">
|
|
{getInitials(metadata.author_name, metadata.author_email)}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
);
|
|
}
|
|
return (
|
|
<Avatar className="h-8 w-8">
|
|
<AvatarFallback className="text-[10px] bg-primary/10 text-primary">
|
|
{getInitials(null, null)}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
);
|
|
}
|
|
|
|
if (item.type === "insufficient_credits") {
|
|
return (
|
|
<div className="h-8 w-8 flex items-center justify-center rounded-full bg-amber-500/10">
|
|
<AlertTriangle className="h-4 w-4 text-amber-500" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const metadata = item.metadata as Record<string, unknown>;
|
|
const status = typeof metadata?.status === "string" ? metadata.status : undefined;
|
|
|
|
switch (status) {
|
|
case "in_progress":
|
|
return (
|
|
<div className="h-8 w-8 flex items-center justify-center rounded-full bg-muted">
|
|
<Spinner size="sm" className="text-foreground" />
|
|
</div>
|
|
);
|
|
case "completed":
|
|
return (
|
|
<div className="h-8 w-8 flex items-center justify-center rounded-full bg-green-500/10">
|
|
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
|
</div>
|
|
);
|
|
case "failed":
|
|
return (
|
|
<div className="h-8 w-8 flex items-center justify-center rounded-full bg-red-500/10">
|
|
<AlertCircle className="h-4 w-4 text-red-500" />
|
|
</div>
|
|
);
|
|
default:
|
|
return (
|
|
<div className="h-8 w-8 flex items-center justify-center rounded-full bg-muted">
|
|
<History className="h-4 w-4 text-muted-foreground" />
|
|
</div>
|
|
);
|
|
}
|
|
};
|
|
|
|
const getEmptyStateMessage = () => {
|
|
if (activeTab === "comments") {
|
|
return {
|
|
title: t("no_comments") || "No comments",
|
|
hint: t("no_comments_hint") || "You'll see mentions and replies here",
|
|
};
|
|
}
|
|
return {
|
|
title: t("no_status_updates") || "No status updates",
|
|
hint: t("no_status_updates_hint") || "Document and connector updates will appear here",
|
|
};
|
|
};
|
|
|
|
if (!mounted) return null;
|
|
|
|
const isLoading = isSearchMode ? isSearchLoading : activeSource.loading;
|
|
|
|
return (
|
|
<>
|
|
<div className="shrink-0 p-3 pb-1.5 space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
{isMobile && (
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8 rounded-full text-muted-foreground hover:text-accent-foreground"
|
|
onClick={() => onOpenChange(false)}
|
|
>
|
|
<ChevronLeft className="h-4 w-4" />
|
|
<span className="sr-only">{t("close") || "Close"}</span>
|
|
</Button>
|
|
)}
|
|
<h2 className="text-lg font-semibold">{t("inbox") || "Inbox"}</h2>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
{isMobile ? (
|
|
<>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7 rounded-full"
|
|
onClick={() => setFilterDrawerOpen(true)}
|
|
>
|
|
<ListFilter className="h-4 w-4 text-muted-foreground" />
|
|
<span className="sr-only">{t("filter") || "Filter"}</span>
|
|
</Button>
|
|
<Drawer
|
|
open={filterDrawerOpen}
|
|
onOpenChange={setFilterDrawerOpen}
|
|
shouldScaleBackground={false}
|
|
>
|
|
<DrawerContent
|
|
className="max-h-[70vh] z-80 bg-popover text-popover-foreground"
|
|
overlayClassName="z-80"
|
|
>
|
|
<DrawerHandle />
|
|
<DrawerHeader className="px-4 pb-3 pt-2">
|
|
<DrawerTitle className="flex items-center gap-2 text-base font-semibold">
|
|
<ListFilter className="size-5" />
|
|
{t("filter") || "Filter"}
|
|
</DrawerTitle>
|
|
</DrawerHeader>
|
|
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
|
<div className="space-y-2">
|
|
<p className="text-xs text-muted-foreground/80 font-medium px-1">
|
|
{t("filter") || "Filter"}
|
|
</p>
|
|
<div className="space-y-1">
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
onClick={() => {
|
|
setActiveFilter("all");
|
|
setFilterDrawerOpen(false);
|
|
}}
|
|
className={cn(
|
|
"h-auto w-full justify-between rounded-lg px-3 py-2.5 text-sm transition-colors",
|
|
activeFilter === "all"
|
|
? "bg-primary/10 text-primary"
|
|
: "hover:bg-muted"
|
|
)}
|
|
>
|
|
<span className="flex items-center gap-2">
|
|
<Inbox className="h-4 w-4" />
|
|
<span>{t("all") || "All"}</span>
|
|
</span>
|
|
{activeFilter === "all" && <Check className="h-4 w-4" />}
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
onClick={() => {
|
|
setActiveFilter("unread");
|
|
setFilterDrawerOpen(false);
|
|
}}
|
|
className={cn(
|
|
"h-auto w-full justify-between rounded-lg px-3 py-2.5 text-sm transition-colors",
|
|
activeFilter === "unread"
|
|
? "bg-primary/10 text-primary"
|
|
: "hover:bg-muted"
|
|
)}
|
|
>
|
|
<span className="flex items-center gap-2">
|
|
<BellDot className="h-4 w-4" />
|
|
<span>{t("unread") || "Unread"}</span>
|
|
</span>
|
|
{activeFilter === "unread" && <Check className="h-4 w-4" />}
|
|
</Button>
|
|
{activeTab === "status" && (
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
onClick={() => {
|
|
setActiveFilter("errors");
|
|
setFilterDrawerOpen(false);
|
|
}}
|
|
className={cn(
|
|
"h-auto w-full justify-between rounded-lg px-3 py-2.5 text-sm transition-colors",
|
|
activeFilter === "errors"
|
|
? "bg-primary/10 text-primary"
|
|
: "hover:bg-muted"
|
|
)}
|
|
>
|
|
<span className="flex items-center gap-2">
|
|
<AlertCircle className="h-4 w-4" />
|
|
<span>{t("errors_only") || "Errors only"}</span>
|
|
</span>
|
|
{activeFilter === "errors" && <Check className="h-4 w-4" />}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{activeTab === "status" && statusSourceOptions.length > 0 && (
|
|
<div className="space-y-2">
|
|
<p className="text-xs text-muted-foreground/80 font-medium px-1">
|
|
{t("sources") || "Sources"}
|
|
</p>
|
|
<div className="space-y-1">
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
onClick={() => {
|
|
setSelectedSource(null);
|
|
setFilterDrawerOpen(false);
|
|
}}
|
|
className={cn(
|
|
"h-auto w-full justify-between rounded-lg px-3 py-2.5 text-sm transition-colors",
|
|
selectedSource === null
|
|
? "bg-primary/10 text-primary"
|
|
: "hover:bg-muted"
|
|
)}
|
|
>
|
|
<span className="flex items-center gap-2">
|
|
<LayoutGrid className="h-4 w-4" />
|
|
<span>{t("all_sources") || "All sources"}</span>
|
|
</span>
|
|
{selectedSource === null && <Check className="h-4 w-4" />}
|
|
</Button>
|
|
{statusSourceOptions.map((source) => (
|
|
<Button
|
|
key={source.key}
|
|
type="button"
|
|
variant="ghost"
|
|
onClick={() => {
|
|
setSelectedSource(source.key);
|
|
setFilterDrawerOpen(false);
|
|
}}
|
|
className={cn(
|
|
"h-auto w-full justify-between rounded-lg px-3 py-2.5 text-sm transition-colors",
|
|
selectedSource === source.key
|
|
? "bg-primary/10 text-primary"
|
|
: "hover:bg-muted"
|
|
)}
|
|
>
|
|
<span className="flex items-center gap-2">
|
|
{getConnectorIcon(source.type, "h-4 w-4")}
|
|
<span>{source.displayName}</span>
|
|
</span>
|
|
{selectedSource === source.key && <Check className="h-4 w-4" />}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</DrawerContent>
|
|
</Drawer>
|
|
</>
|
|
) : (
|
|
<DropdownMenu
|
|
open={openDropdown === "filter"}
|
|
onOpenChange={(isOpen) => setOpenDropdown(isOpen ? "filter" : null)}
|
|
>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button variant="ghost" size="icon" className="h-7 w-7 rounded-full">
|
|
<ListFilter className="h-4 w-4 text-muted-foreground" />
|
|
<span className="sr-only">{t("filter") || "Filter"}</span>
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
</TooltipTrigger>
|
|
<TooltipContent className="z-80">{t("filter") || "Filter"}</TooltipContent>
|
|
</Tooltip>
|
|
<DropdownMenuContent
|
|
align="end"
|
|
className={cn(
|
|
"z-80 select-none max-h-[60vh] overflow-hidden flex flex-col",
|
|
activeTab === "status" ? "w-52" : "w-44"
|
|
)}
|
|
>
|
|
<DropdownMenuLabel className="text-xs text-muted-foreground/80 font-normal">
|
|
{t("filter") || "Filter"}
|
|
</DropdownMenuLabel>
|
|
<DropdownMenuItem
|
|
onClick={() => setActiveFilter("all")}
|
|
className="flex items-center justify-between"
|
|
>
|
|
<span className="flex items-center gap-2">
|
|
<Inbox className="h-4 w-4" />
|
|
<span>{t("all") || "All"}</span>
|
|
</span>
|
|
{activeFilter === "all" && <Check className="h-4 w-4" />}
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
onClick={() => setActiveFilter("unread")}
|
|
className="flex items-center justify-between"
|
|
>
|
|
<span className="flex items-center gap-2">
|
|
<BellDot className="h-4 w-4" />
|
|
<span>{t("unread") || "Unread"}</span>
|
|
</span>
|
|
{activeFilter === "unread" && <Check className="h-4 w-4" />}
|
|
</DropdownMenuItem>
|
|
{activeTab === "status" && (
|
|
<DropdownMenuItem
|
|
onClick={() => setActiveFilter("errors")}
|
|
className="flex items-center justify-between"
|
|
>
|
|
<span className="flex items-center gap-2">
|
|
<AlertCircle className="h-4 w-4" />
|
|
<span>{t("errors_only") || "Errors only"}</span>
|
|
</span>
|
|
{activeFilter === "errors" && <Check className="h-4 w-4" />}
|
|
</DropdownMenuItem>
|
|
)}
|
|
{activeTab === "status" && statusSourceOptions.length > 0 && (
|
|
<>
|
|
<DropdownMenuLabel className="text-xs text-muted-foreground/80 font-normal mt-2">
|
|
{t("sources") || "Sources"}
|
|
</DropdownMenuLabel>
|
|
<div
|
|
className="relative max-h-[30vh] overflow-y-auto overflow-x-hidden -mb-1"
|
|
onScroll={handleConnectorScroll}
|
|
style={{
|
|
maskImage: `linear-gradient(to bottom, ${connectorScrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${connectorScrollPos === "bottom" ? "black" : "transparent"})`,
|
|
WebkitMaskImage: `linear-gradient(to bottom, ${connectorScrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${connectorScrollPos === "bottom" ? "black" : "transparent"})`,
|
|
}}
|
|
>
|
|
<DropdownMenuItem
|
|
onClick={() => setSelectedSource(null)}
|
|
className="flex items-center justify-between"
|
|
>
|
|
<span className="flex items-center gap-2">
|
|
<LayoutGrid className="h-4 w-4" />
|
|
<span>{t("all_sources") || "All sources"}</span>
|
|
</span>
|
|
{selectedSource === null && <Check className="h-4 w-4" />}
|
|
</DropdownMenuItem>
|
|
{statusSourceOptions.map((source) => (
|
|
<DropdownMenuItem
|
|
key={source.key}
|
|
onClick={() => setSelectedSource(source.key)}
|
|
className="flex items-center justify-between"
|
|
>
|
|
<span className="flex items-center gap-2">
|
|
{getConnectorIcon(source.type, "h-4 w-4")}
|
|
<span>{source.displayName}</span>
|
|
</span>
|
|
{selectedSource === source.key && <Check className="h-4 w-4" />}
|
|
</DropdownMenuItem>
|
|
))}
|
|
</div>
|
|
</>
|
|
)}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
)}
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7 rounded-full"
|
|
onClick={handleMarkAllAsRead}
|
|
disabled={totalUnreadCount === 0}
|
|
>
|
|
<CheckCheck className="h-4 w-4 text-muted-foreground" />
|
|
<span className="sr-only">{t("mark_all_read") || "Mark all as read"}</span>
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent className="z-80">
|
|
{t("mark_all_read") || "Mark all as read"}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="relative">
|
|
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
|
<Input
|
|
type="text"
|
|
placeholder={t("search_inbox") || "Search inbox"}
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
className="h-8 border-0 bg-muted pl-8 pr-7 text-sm shadow-none"
|
|
/>
|
|
{searchQuery && (
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="absolute right-1 top-1/2 h-5 w-5 -translate-y-1/2 rounded-sm text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
|
|
onClick={handleClearSearch}
|
|
>
|
|
<X className="h-3.5 w-3.5" />
|
|
<span className="sr-only">{t("clear_search") || "Clear search"}</span>
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<Tabs
|
|
value={activeTab}
|
|
onValueChange={(value) => {
|
|
const tab = value as InboxTab;
|
|
setActiveTab(tab);
|
|
if (tab !== "status" && activeFilter === "errors") {
|
|
setActiveFilter("all");
|
|
}
|
|
}}
|
|
className="shrink-0 mx-3 mt-1.5"
|
|
>
|
|
<TabsList stretch showBottomBorder size="sm">
|
|
<TabsTrigger value="comments">
|
|
<span className="inline-flex items-center gap-1.5">
|
|
<MessageCircleReply className="h-3.5 w-3.5" />
|
|
<span>{t("comments") || "Comments"}</span>
|
|
<span className="inline-flex h-4.5 min-w-4.5 items-center justify-center rounded-full bg-primary/20 px-1 text-[10px] font-medium text-muted-foreground">
|
|
{formatInboxCount(comments.unreadCount)}
|
|
</span>
|
|
</span>
|
|
</TabsTrigger>
|
|
<TabsTrigger value="status">
|
|
<span className="inline-flex items-center gap-1.5">
|
|
<History className="h-3.5 w-3.5" />
|
|
<span>{t("status") || "Status"}</span>
|
|
<span className="inline-flex h-4.5 min-w-4.5 items-center justify-center rounded-full bg-primary/20 px-1 text-[10px] font-medium text-muted-foreground">
|
|
{formatInboxCount(status.unreadCount)}
|
|
</span>
|
|
</span>
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
</Tabs>
|
|
|
|
<div className="flex-1 overflow-y-auto overflow-x-hidden p-2">
|
|
{isLoading ? (
|
|
<div className="space-y-2">
|
|
{activeTab === "comments"
|
|
? [85, 60, 90, 70, 50, 75].map((titleWidth) => (
|
|
<div
|
|
key={`skeleton-comment-${titleWidth}`}
|
|
className="flex items-center gap-3 rounded-lg px-3 py-3 h-[80px]"
|
|
>
|
|
<Skeleton className="h-8 w-8 rounded-full shrink-0" />
|
|
<div className="flex-1 min-w-0 space-y-2">
|
|
<Skeleton className="h-3 rounded" style={{ width: `${titleWidth}%` }} />
|
|
<Skeleton className="h-2.5 w-[70%] rounded" />
|
|
</div>
|
|
<Skeleton className="h-3 w-6 shrink-0 rounded" />
|
|
</div>
|
|
))
|
|
: [75, 90, 55, 80, 65, 85].map((titleWidth) => (
|
|
<div
|
|
key={`skeleton-status-${titleWidth}`}
|
|
className="flex items-center gap-3 rounded-lg px-3 py-3 h-[80px]"
|
|
>
|
|
<Skeleton className="h-8 w-8 rounded-full shrink-0" />
|
|
<div className="flex-1 min-w-0 space-y-2">
|
|
<Skeleton className="h-3 rounded" style={{ width: `${titleWidth}%` }} />
|
|
<Skeleton className="h-2.5 w-[60%] rounded" />
|
|
</div>
|
|
<div className="flex items-center gap-1.5 shrink-0">
|
|
<Skeleton className="h-3 w-6 rounded" />
|
|
<Skeleton className="h-2 w-2 rounded-full" />
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : filteredItems.length > 0 ? (
|
|
<div className="space-y-2">
|
|
{filteredItems.map((item, index) => {
|
|
const isMarkingAsRead = markingAsReadId === item.id;
|
|
const isPrefetchTrigger =
|
|
!isSearchMode && activeSource.hasMore && index === filteredItems.length - 5;
|
|
|
|
return (
|
|
<div
|
|
key={item.id}
|
|
ref={isPrefetchTrigger ? prefetchTriggerRef : undefined}
|
|
className={cn(
|
|
"group flex items-center gap-3 rounded-lg px-3 py-3 text-sm h-[80px] overflow-hidden",
|
|
"hover:bg-accent hover:text-accent-foreground",
|
|
"transition-colors cursor-pointer",
|
|
isMarkingAsRead && "opacity-50 pointer-events-none"
|
|
)}
|
|
style={{ contentVisibility: "auto", containIntrinsicSize: "0 80px" }}
|
|
>
|
|
{activeTab === "status" ? (
|
|
<Tooltip delayDuration={600}>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
onClick={() => handleItemClick(item)}
|
|
disabled={isMarkingAsRead}
|
|
className="h-auto flex-1 justify-start gap-3 overflow-hidden bg-transparent p-0 text-left hover:bg-transparent"
|
|
>
|
|
<div className="shrink-0">{getStatusIcon(item)}</div>
|
|
<div className="flex-1 min-w-0 overflow-hidden">
|
|
<p
|
|
className={cn(
|
|
"text-xs font-medium line-clamp-2",
|
|
!item.read && "font-semibold"
|
|
)}
|
|
>
|
|
{item.title}
|
|
</p>
|
|
<p className="text-[11px] text-muted-foreground line-clamp-2 mt-0.5">
|
|
{convertRenderedToDisplay(item.message)}
|
|
</p>
|
|
</div>
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom" align="start" className="max-w-[250px]">
|
|
<p className="font-medium">{item.title}</p>
|
|
<p className="text-muted-foreground mt-1">
|
|
{convertRenderedToDisplay(item.message)}
|
|
</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
) : (
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
onClick={() => handleItemClick(item)}
|
|
disabled={isMarkingAsRead}
|
|
className="h-auto flex-1 justify-start gap-3 overflow-hidden bg-transparent p-0 text-left hover:bg-transparent"
|
|
>
|
|
<div className="shrink-0">{getStatusIcon(item)}</div>
|
|
<div className="flex-1 min-w-0 overflow-hidden">
|
|
<p
|
|
className={cn(
|
|
"text-xs font-medium line-clamp-2",
|
|
!item.read && "font-semibold"
|
|
)}
|
|
>
|
|
{item.title}
|
|
</p>
|
|
<p className="text-[11px] text-muted-foreground line-clamp-2 mt-0.5">
|
|
{convertRenderedToDisplay(item.message)}
|
|
</p>
|
|
</div>
|
|
</Button>
|
|
)}
|
|
|
|
<div className="flex items-center justify-end gap-1.5 shrink-0 w-10">
|
|
<span className="text-[10px] text-muted-foreground">
|
|
{formatTime(item.created_at)}
|
|
</span>
|
|
{!item.read && <span className="h-2 w-2 rounded-full bg-blue-500 shrink-0" />}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
{!isSearchMode && filteredItems.length < 5 && activeSource.hasMore && (
|
|
<div ref={prefetchTriggerRef} className="h-1" />
|
|
)}
|
|
{activeSource.loadingMore &&
|
|
(activeTab === "comments"
|
|
? [80, 60, 90].map((titleWidth) => (
|
|
<div
|
|
key={`loading-more-comment-${titleWidth}`}
|
|
className="flex items-center gap-3 rounded-lg px-3 py-3 h-[80px]"
|
|
>
|
|
<Skeleton className="h-8 w-8 rounded-full shrink-0" />
|
|
<div className="flex-1 min-w-0 space-y-2">
|
|
<Skeleton className="h-3 rounded" style={{ width: `${titleWidth}%` }} />
|
|
<Skeleton className="h-2.5 w-[70%] rounded" />
|
|
</div>
|
|
<Skeleton className="h-3 w-6 shrink-0 rounded" />
|
|
</div>
|
|
))
|
|
: [70, 85, 55].map((titleWidth) => (
|
|
<div
|
|
key={`loading-more-status-${titleWidth}`}
|
|
className="flex items-center gap-3 rounded-lg px-3 py-3 h-[80px]"
|
|
>
|
|
<Skeleton className="h-8 w-8 rounded-full shrink-0" />
|
|
<div className="flex-1 min-w-0 space-y-2">
|
|
<Skeleton className="h-3 rounded" style={{ width: `${titleWidth}%` }} />
|
|
<Skeleton className="h-2.5 w-[60%] rounded" />
|
|
</div>
|
|
<div className="flex items-center gap-1.5 shrink-0">
|
|
<Skeleton className="h-3 w-6 rounded" />
|
|
<Skeleton className="h-2 w-2 rounded-full" />
|
|
</div>
|
|
</div>
|
|
)))}
|
|
</div>
|
|
) : isSearchMode ? (
|
|
<div className="text-center py-8">
|
|
<Search className="mx-auto mb-2.5 h-10 w-10 text-muted-foreground" />
|
|
<p className="text-xs text-muted-foreground">
|
|
{t("no_results_found") || "No results found"}
|
|
</p>
|
|
<p className="mt-1 text-[11px] text-muted-foreground/70">
|
|
{t("try_different_search") || "Try a different search term"}
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="text-center py-8">
|
|
{activeTab === "comments" ? (
|
|
<MessageCircleReply className="mx-auto mb-2.5 h-10 w-10 text-muted-foreground" />
|
|
) : (
|
|
<History className="mx-auto mb-2.5 h-10 w-10 text-muted-foreground" />
|
|
)}
|
|
<p className="text-xs text-muted-foreground">{getEmptyStateMessage().title}</p>
|
|
<p className="mt-1 text-[11px] text-muted-foreground/70">
|
|
{getEmptyStateMessage().hint}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
export function InboxSidebar({
|
|
open,
|
|
onOpenChange,
|
|
comments,
|
|
status,
|
|
totalUnreadCount,
|
|
onCloseMobileSidebar,
|
|
}: InboxSidebarProps) {
|
|
const t = useTranslations("sidebar");
|
|
|
|
return (
|
|
<SidebarSlideOutPanel open={open} onOpenChange={onOpenChange} ariaLabel={t("inbox") || "Inbox"}>
|
|
<InboxSidebarContent
|
|
onOpenChange={onOpenChange}
|
|
comments={comments}
|
|
status={status}
|
|
totalUnreadCount={totalUnreadCount}
|
|
onCloseMobileSidebar={onCloseMobileSidebar}
|
|
/>
|
|
</SidebarSlideOutPanel>
|
|
);
|
|
}
|