Files
gooseek/services/web-svc/src/components/Sidebar.tsx
home 06fe57c765 feat: Go backend, enhanced search, new widgets, Docker deploy
Major changes:
- Add Go backend (backend/) with microservices architecture
- Enhanced master-agents-svc: reranker, content-classifier, stealth-crawler,
  proxy-manager, media-search, fastClassifier, language detection
- New web-svc widgets: KnowledgeCard, ProductCard, ProfileCard, VideoCard,
  UnifiedCard, CardGallery, InlineImageGallery, SourcesPanel, RelatedQuestions
- Improved discover-svc with discover-db integration
- Docker deployment improvements (Caddyfile, vendor.sh, BUILD.md)
- Library-svc: project_id schema migration
- Remove deprecated finance-svc and travel-svc
- Localization improvements across services

Made-with: Cursor
2026-02-27 04:15:32 +03:00

807 lines
28 KiB
TypeScript

'use client';
import { cn } from '@/lib/utils';
import {
MessagesSquare,
Plus,
Newspaper,
TrendingUp,
Plane,
LayoutPanelLeft,
User,
Stethoscope,
Building2,
Package,
SlidersHorizontal,
Baby,
GraduationCap,
HeartPulse,
Brain,
Trophy,
ShoppingCart,
Gamepad2,
Receipt,
Scale,
MoreHorizontal,
} from 'lucide-react';
import Link from 'next/link';
import { useSelectedLayoutSegments } from 'next/navigation';
import React, { useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from '@/lib/localization/context';
import { getSidebarMenuConfig } from '@/lib/config/sidebarMenu';
import MenuSettingsPanel from './Sidebar/MenuSettingsPanel';
import Layout from './Layout';
interface ChatItem {
id: string;
title: string;
createdAt: string;
}
const VerticalIconContainer = ({ children }: { children: ReactNode }) => {
return <div className="flex flex-col items-center w-full">{children}</div>;
};
interface HistorySubmenuProps {
onMouseEnter: () => void;
onMouseLeave: () => void;
}
interface MoreSubmenuProps {
onMouseEnter: () => void;
onMouseLeave: () => void;
restIds: string[];
linkMap: Record<string, NavLink>;
itemLabels: Record<string, string>;
submenuStyle: { top: number; left: number };
isMobile: boolean;
cancelMoreHide: () => void;
}
const MoreSubmenu = ({
onMouseEnter,
onMouseLeave,
restIds,
linkMap,
itemLabels,
submenuStyle,
isMobile,
cancelMoreHide,
}: MoreSubmenuProps) => {
const { t } = useTranslation();
const [settingsHovered, setSettingsHovered] = useState(false);
return (
<div
className="min-w-[180px] w-56 h-screen overflow-y-auto bg-light-secondary dark:bg-dark-secondary border border-light-200 dark:border-dark-200 shadow-xl py-2 z-[9999]"
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
<nav className="flex flex-col">
{restIds.map((id) => {
const link = linkMap[id];
if (!link) return null;
const Icon = link.icon;
if (link.href) {
return (
<Link
key={id}
href={link.href}
className="px-4 py-2.5 text-sm text-black dark:text-white hover:bg-light-200 dark:hover:bg-dark-200 text-fade flex items-center gap-2 transition-colors"
title={link.label}
>
<Icon size={18} />
{link.label}
</Link>
);
}
return (
<span
key={id}
className="px-4 py-2.5 text-sm text-black/50 dark:text-white/50 flex items-center gap-2 cursor-default"
>
<Icon size={18} />
{link.label}
</span>
);
})}
<div
className="relative"
onMouseEnter={() => setSettingsHovered(true)}
onMouseLeave={() => setSettingsHovered(false)}
>
<button
type="button"
className="w-full px-4 py-2.5 text-sm text-left text-black dark:text-white hover:bg-light-200 dark:hover:bg-dark-200 transition-colors flex items-center gap-2"
>
<SlidersHorizontal size={18} />
{t('nav.configureMenu')}
</button>
{settingsHovered &&
typeof document !== 'undefined' &&
createPortal(
isMobile ? (
<div
className="fixed inset-0 z-[10000] flex items-center justify-center bg-black/20 dark:bg-black/40"
onClick={() => setSettingsHovered(false)}
role="button"
tabIndex={0}
aria-label={t('common.close')}
>
<div
className="mx-4 max-h-[85vh] overflow-y-auto rounded-xl bg-light-secondary dark:bg-dark-secondary shadow-xl"
onClick={(e) => e.stopPropagation()}
onMouseEnter={cancelMoreHide}
onMouseLeave={() => setSettingsHovered(false)}
>
<MenuSettingsPanel
itemLabels={itemLabels}
onMouseEnter={cancelMoreHide}
onMouseLeave={() => {}}
/>
</div>
</div>
) : (
<div
style={{
top: submenuStyle.top,
left: submenuStyle.left + 220,
}}
className="fixed z-[10000]"
onMouseEnter={cancelMoreHide}
onMouseLeave={() => setSettingsHovered(false)}
>
<MenuSettingsPanel
itemLabels={itemLabels}
onMouseEnter={cancelMoreHide}
onMouseLeave={() => setSettingsHovered(false)}
/>
</div>
),
document.body,
)}
</div>
</nav>
</div>
);
};
const HistorySubmenu = ({ onMouseEnter, onMouseLeave }: HistorySubmenuProps) => {
const { t } = useTranslation();
const [chats, setChats] = useState<ChatItem[]>([]);
const [loading, setLoading] = useState(true);
const fetchChats = useCallback(async () => {
try {
const token =
typeof window !== 'undefined'
? localStorage.getItem('auth_token') ?? localStorage.getItem('access_token')
: null;
if (!token) {
const { getGuestChats } = await import('@/lib/guest-storage');
const guestChats = getGuestChats();
setChats(
guestChats.map((c) => ({
id: c.id,
title: c.title,
createdAt: c.createdAt,
sources: c.sources,
files: c.files,
})),
);
setLoading(false);
return;
}
const res = await fetch('/api/v1/library/threads', {
headers: { Authorization: `Bearer ${token}` },
});
const data = await res.json();
setChats((data.chats ?? []).reverse());
} catch {
setChats([]);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchChats();
}, [fetchChats]);
useEffect(() => {
const onMigrated = () => fetchChats();
window.addEventListener('gooseek:guest-migrated', onMigrated);
return () => window.removeEventListener('gooseek:guest-migrated', onMigrated);
}, []);
return (
<div
className="min-w-[180px] w-56 h-screen overflow-y-auto bg-light-secondary dark:bg-dark-secondary border border-light-200 dark:border-dark-200 shadow-xl py-2 z-[9999]"
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
{loading ? (
<div className="px-4 py-3 text-sm text-black/60 dark:text-white/60">
{t('common.loading')}
</div>
) : chats.length === 0 ? (
<div className="px-4 py-3 text-sm text-black/60 dark:text-white/60">
</div>
) : (
<nav className="flex flex-col">
{chats.map((chat) => (
<Link
key={chat.id}
href={`/c/${chat.id}`}
className="px-4 py-2.5 text-sm text-black dark:text-white hover:bg-light-200 dark:hover:bg-dark-200 text-fade block transition-colors"
title={chat.title}
>
{chat.title}
</Link>
))}
</nav>
)}
</div>
);
};
type NavLink = {
icon: React.ComponentType<{ size?: number | string; className?: string }>;
href?: string;
active: boolean;
label: string;
};
const Sidebar = ({ children }: { children: React.ReactNode }) => {
const segments = useSelectedLayoutSegments();
const { t } = useTranslation();
const [historyOpen, setHistoryOpen] = useState(false);
const [moreOpen, setMoreOpen] = useState(false);
const historyRef = useRef<HTMLDivElement>(null);
const moreRef = useRef<HTMLDivElement>(null);
const hideTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const moreHideRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [submenuStyle, setSubmenuStyle] = useState({ top: 0, left: 0 });
const [tooltip, setTooltip] = useState<{ label: string; x: number; y: number } | null>(null);
const [menuConfig, setMenuConfig] = useState(() =>
typeof window !== 'undefined' ? getSidebarMenuConfig() : { order: [], visible: {} },
);
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
if (typeof window === 'undefined') return;
const mq = window.matchMedia('(max-width: 1023px)');
setIsMobile(mq.matches);
const handler = () => setIsMobile(mq.matches);
mq.addEventListener('change', handler);
return () => mq.removeEventListener('change', handler);
}, []);
const discoverLink = {
icon: Newspaper,
href: '/discover',
active: segments.includes('discover'),
label: t('nav.discover'),
};
const libraryLink = {
icon: MessagesSquare,
href: '/library',
active: segments.includes('library'),
label: t('nav.messageHistory'),
};
const financeLink = {
icon: TrendingUp,
href: '/finance',
active: segments.includes('finance'),
label: t('nav.finance'),
};
const travelLink = {
icon: Plane,
href: '/travel',
active: segments.includes('travel'),
label: t('nav.travel'),
};
const spacesLink = {
icon: LayoutPanelLeft,
href: '/spaces',
active: segments.includes('spaces'),
label: t('nav.spaces'),
};
const profileLink = {
icon: User,
href: '/profile',
active: segments.includes('profile'),
label: t('nav.profile'),
};
const placeholderLink = (
icon: React.ComponentType<{ size?: number | string; className?: string }>,
labelKey: string,
) => ({
icon,
href: undefined as string | undefined,
active: false,
label: t(labelKey),
});
const medicineLink = placeholderLink(Stethoscope, 'nav.medicine');
const realEstateLink = placeholderLink(Building2, 'nav.realEstate');
const goodsLink = placeholderLink(Package, 'nav.goods');
const childrenLink = placeholderLink(Baby, 'nav.children');
const educationLink = placeholderLink(GraduationCap, 'nav.education');
const healthLink = placeholderLink(HeartPulse, 'nav.health');
const psychologyLink = placeholderLink(Brain, 'nav.psychology');
const sportsLink = placeholderLink(Trophy, 'nav.sports');
const shoppingLink = placeholderLink(ShoppingCart, 'nav.shopping');
const gamesLink = placeholderLink(Gamepad2, 'nav.games');
const taxesLink = placeholderLink(Receipt, 'nav.taxes');
const legislationLink = placeholderLink(Scale, 'nav.legislation');
const placeholderLinks = [
medicineLink,
realEstateLink,
goodsLink,
childrenLink,
educationLink,
healthLink,
psychologyLink,
sportsLink,
shoppingLink,
gamesLink,
taxesLink,
legislationLink,
];
const linkMap: Record<string, NavLink> = {
discover: discoverLink,
library: libraryLink,
finance: financeLink,
travel: travelLink,
spaces: spacesLink,
medicine: medicineLink,
realEstate: realEstateLink,
goods: goodsLink,
children: childrenLink,
education: educationLink,
health: healthLink,
psychology: psychologyLink,
sports: sportsLink,
shopping: shoppingLink,
games: gamesLink,
taxes: taxesLink,
legislation: legislationLink,
profile: profileLink,
};
const itemLabels: Record<string, string> = Object.fromEntries(
Object.entries(linkMap).map(([id, link]) => [id, link.label]),
);
const orderedVisibleIds = menuConfig.order.filter((id) => menuConfig.visible[id] !== false);
const mainIds = orderedVisibleIds.filter((id) => id !== 'profile' && id !== 'spaces');
const topMainIds = mainIds.slice(0, 5);
const restMainIds = mainIds.slice(5);
const showSpaces = menuConfig.visible['spaces'] !== false;
const showProfile = menuConfig.visible['profile'] !== false;
const refreshMenuConfig = useCallback(() => {
setMenuConfig(getSidebarMenuConfig());
}, []);
useEffect(() => {
const handler = () => refreshMenuConfig();
window.addEventListener('client-config-changed', handler);
return () => window.removeEventListener('client-config-changed', handler);
}, [refreshMenuConfig]);
const handleHistoryMouseEnter = () => {
setTooltip(null);
if (hideTimeoutRef.current) {
clearTimeout(hideTimeoutRef.current);
hideTimeoutRef.current = null;
}
if (historyRef.current) {
const rect = historyRef.current.getBoundingClientRect();
setSubmenuStyle({ top: 0, left: rect.right + 4 });
}
setHistoryOpen(true);
};
const handleHistoryMouseLeave = () => {
hideTimeoutRef.current = setTimeout(() => setHistoryOpen(false), 150);
};
const cancelHide = () => {
if (hideTimeoutRef.current) {
clearTimeout(hideTimeoutRef.current);
hideTimeoutRef.current = null;
}
};
const handleMoreEnter = () => {
setTooltip(null);
if (moreHideRef.current) {
clearTimeout(moreHideRef.current);
moreHideRef.current = null;
}
if (moreRef.current) {
const rect = moreRef.current.getBoundingClientRect();
setSubmenuStyle({ top: 0, left: rect.right + 4 });
}
setMoreOpen(true);
};
const handleMoreLeave = () => {
moreHideRef.current = setTimeout(() => setMoreOpen(false), 150);
};
const cancelMoreHide = () => {
if (moreHideRef.current) {
clearTimeout(moreHideRef.current);
moreHideRef.current = null;
}
};
useEffect(() => {
return () => {
if (hideTimeoutRef.current) clearTimeout(hideTimeoutRef.current);
if (moreHideRef.current) clearTimeout(moreHideRef.current);
};
}, []);
useEffect(() => {
if (!moreOpen || !isMobile) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') setMoreOpen(false);
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [moreOpen, isMobile]);
const showTooltip = (label: string, e: React.MouseEvent<HTMLElement>) => {
const rect = e.currentTarget.getBoundingClientRect();
setTooltip({ label, x: rect.right + 8, y: rect.top + rect.height / 2 });
};
const hideTooltip = () => setTooltip(null);
return (
<div>
{tooltip &&
typeof document !== 'undefined' &&
createPortal(
<div
className="fixed z-[9998] -translate-y-1/2 px-2.5 py-1.5 rounded-md bg-black/90 dark:bg-white/90 text-white dark:text-black text-xs font-medium whitespace-nowrap pointer-events-none"
style={{ left: tooltip.x, top: tooltip.y }}
>
{tooltip.label}
</div>,
document.body,
)}
<div className="hidden lg:fixed lg:inset-y-0 lg:z-50 lg:flex lg:w-[56px] lg:flex-col border-r border-light-200 dark:border-dark-200">
<div className="flex grow flex-col items-center justify-start gap-y-1 overflow-y-auto bg-light-secondary dark:bg-dark-secondary px-2 py-6 shadow-sm shadow-light-200/10 dark:shadow-black/25">
<a
className="p-2.5 rounded-full bg-light-200 text-black/70 dark:bg-dark-200 dark:text-white/70 hover:opacity-70 hover:scale-105 transition duration-200 cursor-pointer"
href="/"
onMouseEnter={(e) => showTooltip(t('chat.newChat'), e)}
onMouseLeave={hideTooltip}
>
<Plus size={19} className="cursor-pointer" />
</a>
{showSpaces && (
<Link
href={spacesLink.href}
className={cn(
'relative flex flex-col items-center justify-center cursor-pointer w-full py-2 rounded-lg',
spacesLink.active
? 'text-black/70 dark:text-white/70'
: 'text-black/60 dark:text-white/60',
)}
onMouseEnter={(e) => showTooltip(spacesLink.label, e)}
onMouseLeave={hideTooltip}
>
<div
className={cn(
spacesLink.active && 'bg-light-200 dark:bg-dark-200',
'group rounded-lg hover:bg-light-200 hover:dark:bg-dark-200 transition duration-200',
)}
>
<spacesLink.icon
size={25}
className={cn(
!spacesLink.active && 'group-hover:scale-105',
'transition duration:200 m-1.5',
)}
/>
</div>
</Link>
)}
<VerticalIconContainer>
{topMainIds.map((id) => {
if (id === 'library') {
return (
<div
key={id}
ref={historyRef}
className="relative w-full"
onMouseEnter={handleHistoryMouseEnter}
onMouseLeave={handleHistoryMouseLeave}
>
<Link
href={libraryLink.href}
className={cn(
'relative flex flex-col items-center justify-center cursor-pointer w-full py-2 rounded-lg',
libraryLink.active
? 'text-black/70 dark:text-white/70'
: 'text-black/60 dark:text-white/60',
)}
onMouseEnter={(e) => showTooltip(libraryLink.label, e)}
onMouseLeave={hideTooltip}
>
<div
className={cn(
libraryLink.active && 'bg-light-200 dark:bg-dark-200',
'group rounded-lg hover:bg-light-200 hover:dark:bg-dark-200 transition duration-200',
)}
>
<libraryLink.icon
size={25}
className={cn(
!libraryLink.active && 'group-hover:scale-105',
'transition duration:200 m-1.5',
)}
/>
</div>
</Link>
{historyOpen &&
typeof document !== 'undefined' &&
createPortal(
<div
style={{ top: submenuStyle.top, left: submenuStyle.left }}
className="fixed z-[9999]"
>
<HistorySubmenu
onMouseEnter={cancelHide}
onMouseLeave={handleHistoryMouseLeave}
/>
</div>,
document.body,
)}
</div>
);
}
const link = linkMap[id];
if (!link) return null;
if (link.href) {
return (
<Link
key={id}
href={link.href}
className={cn(
'relative flex flex-col items-center justify-center cursor-pointer w-full py-2 rounded-lg',
link.active
? 'text-black/70 dark:text-white/70'
: 'text-black/60 dark:text-white/60',
)}
onMouseEnter={(e) => showTooltip(link.label, e)}
onMouseLeave={hideTooltip}
>
<div
className={cn(
link.active && 'bg-light-200 dark:bg-dark-200',
'group rounded-lg hover:bg-light-200 hover:dark:bg-dark-200 transition duration-200',
)}
>
<link.icon
size={25}
className={cn(
!link.active && 'group-hover:scale-105',
'transition duration:200 m-1.5',
)}
/>
</div>
</Link>
);
}
return (
<span
key={id}
className={cn(
'relative flex flex-col items-center justify-center w-full py-2 rounded-lg cursor-default',
'text-black/50 dark:text-white/50',
)}
onMouseEnter={(e) => showTooltip(link.label, e)}
onMouseLeave={hideTooltip}
>
<div className="group rounded-lg opacity-80">
<link.icon size={25} className="m-1.5" />
</div>
</span>
);
})}
<div
ref={moreRef}
className="relative w-full"
onMouseEnter={handleMoreEnter}
onMouseLeave={handleMoreLeave}
>
<button
type="button"
onClick={() => {
if (moreRef.current) {
const rect = moreRef.current.getBoundingClientRect();
setSubmenuStyle({ top: 0, left: rect.right + 4 });
}
setMoreOpen((prev) => !prev);
}}
className={cn(
'relative flex flex-col items-center justify-center w-full py-2 rounded-lg cursor-pointer',
'text-black/60 dark:text-white/60 hover:text-black/70 dark:hover:text-white/70',
)}
onMouseEnter={(e) => showTooltip(t('nav.more'), e)}
onMouseLeave={hideTooltip}
>
<div className="group rounded-lg hover:bg-light-200 hover:dark:bg-dark-200 transition duration-200">
<MoreHorizontal
size={25}
className="group-hover:scale-105 transition duration:200 m-1.5"
/>
</div>
</button>
{moreOpen &&
typeof document !== 'undefined' &&
createPortal(
isMobile ? (
<div
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/20 dark:bg-black/40"
onClick={() => setMoreOpen(false)}
role="button"
tabIndex={0}
aria-label={t('common.close')}
>
<div
className="mx-4 max-w-sm w-full max-h-[85vh] overflow-y-auto rounded-xl bg-light-secondary dark:bg-dark-secondary shadow-xl border border-light-200 dark:border-dark-200"
onClick={(e) => e.stopPropagation()}
onMouseEnter={cancelMoreHide}
onMouseLeave={() => {}}
>
<MoreSubmenu
restIds={restMainIds}
linkMap={linkMap}
itemLabels={itemLabels}
submenuStyle={submenuStyle}
isMobile={isMobile}
onMouseEnter={cancelMoreHide}
onMouseLeave={() => setMoreOpen(false)}
cancelMoreHide={cancelMoreHide}
/>
</div>
</div>
) : (
<div
style={{ top: submenuStyle.top, left: submenuStyle.left }}
className="fixed z-[9999]"
>
<MoreSubmenu
restIds={restMainIds}
linkMap={linkMap}
itemLabels={itemLabels}
submenuStyle={submenuStyle}
isMobile={isMobile}
onMouseEnter={cancelMoreHide}
onMouseLeave={handleMoreLeave}
cancelMoreHide={cancelMoreHide}
/>
</div>
),
document.body,
)}
</div>
</VerticalIconContainer>
<div className="mt-auto pt-4 flex flex-col items-center gap-y-1">
{showProfile && (
<Link
href={profileLink.href}
className={cn(
'relative flex flex-col items-center justify-center cursor-pointer w-full py-2 rounded-lg',
profileLink.active
? 'text-black/70 dark:text-white/70'
: 'text-black/60 dark:text-white/60',
)}
onMouseEnter={(e) => showTooltip(profileLink.label, e)}
onMouseLeave={hideTooltip}
>
<div
className={cn(
profileLink.active && 'bg-light-200 dark:bg-dark-200',
'group rounded-lg hover:bg-light-200 hover:dark:bg-dark-200 transition duration-200',
)}
>
<profileLink.icon
size={25}
className={cn(
!profileLink.active && 'group-hover:scale-105',
'transition duration:200 m-1.5',
)}
/>
</div>
</Link>
)}
</div>
</div>
</div>
<div className="fixed bottom-0 w-full z-50 flex flex-row items-center justify-around gap-x-1 bg-light-secondary dark:bg-dark-secondary px-2 py-2 shadow-sm lg:hidden">
{topMainIds.map((id) => {
const link = linkMap[id];
if (!link) return null;
return link.href ? (
<Link
href={link.href}
key={id}
className={cn(
'relative flex flex-col items-center gap-0.5 text-center flex-1 min-w-0',
link.active
? 'text-black dark:text-white'
: 'text-black dark:text-white/70',
)}
>
{link.active && (
<div className="absolute top-0 -mt-2 h-0.5 w-full rounded-b bg-black dark:bg-white" />
)}
<link.icon size={18} />
<p className="text-[10px] leading-tight text-fade min-w-0 truncate">{link.label}</p>
</Link>
) : (
<span
key={id}
className="relative flex flex-col items-center gap-0.5 text-center flex-1 min-w-0 text-black/50 dark:text-white/50 cursor-default"
>
<link.icon size={18} />
<p className="text-[10px] leading-tight text-fade min-w-0 truncate">{link.label}</p>
</span>
);
})}
<button
type="button"
onClick={() => setMoreOpen(true)}
className="relative flex flex-col items-center gap-0.5 text-center shrink-0 text-black/70 dark:text-white/70"
title={t('nav.more')}
aria-label={t('nav.more')}
>
<MoreHorizontal size={18} />
<p className="text-[10px] leading-tight text-fade">{t('nav.more')}</p>
</button>
{showProfile && (
<Link
href={profileLink.href}
className={cn(
'relative flex flex-col items-center gap-0.5 text-center shrink-0',
profileLink.active
? 'text-black dark:text-white'
: 'text-black dark:text-white/70',
)}
title={profileLink.label}
>
{profileLink.active && (
<div className="absolute top-0 -mt-2 h-0.5 w-full rounded-b bg-black dark:bg-white" />
)}
<profileLink.icon size={18} />
<p className="text-[10px] leading-tight text-fade min-w-0 truncate">{profileLink.label}</p>
</Link>
)}
</div>
<Layout>{children}</Layout>
</div>
);
};
export default Sidebar;