feat: default locale Russian, geo determines language for other countries
- localization-svc: defaultLocale ru, resolveLocale only by geo - web-svc: DEFAULT_LOCALE ru, layout lang=ru, embeddedTranslations fallback ru - countryToLocale: default ru when no country or unknown country Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
806
services/web-svc/src/components/Sidebar.tsx
Normal file
806
services/web-svc/src/components/Sidebar.tsx
Normal file
@@ -0,0 +1,806 @@
|
||||
'use client';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
MessagesSquare,
|
||||
Plus,
|
||||
Search,
|
||||
TrendingUp,
|
||||
Plane,
|
||||
FolderOpen,
|
||||
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: Search,
|
||||
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: FolderOpen,
|
||||
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 gap-x-6 bg-light-secondary dark:bg-dark-secondary px-4 py-4 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 space-y-1 text-center w-full',
|
||||
link.active
|
||||
? 'text-black dark:text-white'
|
||||
: 'text-black dark:text-white/70',
|
||||
)}
|
||||
>
|
||||
{link.active && (
|
||||
<div className="absolute top-0 -mt-4 h-1 w-full rounded-b-lg bg-black dark:bg-white" />
|
||||
)}
|
||||
<link.icon />
|
||||
<p className="text-xs text-fade min-w-0">{link.label}</p>
|
||||
</Link>
|
||||
) : (
|
||||
<span
|
||||
key={id}
|
||||
className="relative flex flex-col items-center space-y-1 text-center w-full text-black/50 dark:text-white/50 cursor-default"
|
||||
>
|
||||
<link.icon />
|
||||
<p className="text-xs text-fade min-w-0">{link.label}</p>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMoreOpen(true)}
|
||||
className="relative flex flex-col items-center space-y-1 text-center shrink-0 text-black/70 dark:text-white/70"
|
||||
title={t('nav.more')}
|
||||
aria-label={t('nav.more')}
|
||||
>
|
||||
<MoreHorizontal size={20} />
|
||||
<p className="text-xs text-fade">{t('nav.more')}</p>
|
||||
</button>
|
||||
{showProfile && (
|
||||
<Link
|
||||
href={profileLink.href}
|
||||
className={cn(
|
||||
'relative flex flex-col items-center space-y-1 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-4 h-1 w-full rounded-b-lg bg-black dark:bg-white" />
|
||||
)}
|
||||
<profileLink.icon size={20} />
|
||||
<p className="text-xs text-fade min-w-0">{profileLink.label}</p>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Layout>{children}</Layout>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Sidebar;
|
||||
Reference in New Issue
Block a user