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
807 lines
28 KiB
TypeScript
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;
|