2025-12-07 19:32:53 +02:00
|
|
|
import React from 'react';
|
|
|
|
|
import { cn } from '@/lib/utils';
|
|
|
|
|
import { ErrorBoundary } from '../ui/ErrorBoundary';
|
2026-04-26 14:03:39 +03:00
|
|
|
import { useI18n } from '@/lib/i18n';
|
2025-12-07 19:32:53 +02:00
|
|
|
import { useUIStore } from '@/stores/useUIStore';
|
|
|
|
|
|
2026-04-20 23:32:32 +03:00
|
|
|
export const SIDEBAR_CONTENT_WIDTH = 280;
|
|
|
|
|
const SIDEBAR_MIN_WIDTH = 280;
|
2025-12-07 19:32:53 +02:00
|
|
|
const SIDEBAR_MAX_WIDTH = 500;
|
|
|
|
|
|
|
|
|
|
interface SidebarProps {
|
|
|
|
|
isOpen: boolean;
|
|
|
|
|
isMobile: boolean;
|
|
|
|
|
children: React.ReactNode;
|
2026-03-20 01:01:03 +02:00
|
|
|
className?: string;
|
2025-12-07 19:32:53 +02:00
|
|
|
}
|
|
|
|
|
|
2026-03-20 01:01:03 +02:00
|
|
|
export const Sidebar: React.FC<SidebarProps> = ({ isOpen, isMobile, children, className }) => {
|
2026-04-26 14:03:39 +03:00
|
|
|
const { t } = useI18n();
|
2026-04-04 02:19:55 +03:00
|
|
|
const sidebarWidth = useUIStore((state) => state.sidebarWidth);
|
|
|
|
|
const setSidebarWidth = useUIStore((state) => state.setSidebarWidth);
|
2025-12-07 19:32:53 +02:00
|
|
|
const [isResizing, setIsResizing] = React.useState(false);
|
|
|
|
|
const startXRef = React.useRef(0);
|
|
|
|
|
const startWidthRef = React.useRef(sidebarWidth || SIDEBAR_CONTENT_WIDTH);
|
2026-03-02 02:11:33 +02:00
|
|
|
const resizingWidthRef = React.useRef<number | null>(null);
|
|
|
|
|
const activeResizePointerIDRef = React.useRef<number | null>(null);
|
|
|
|
|
const sidebarRef = React.useRef<HTMLElement | null>(null);
|
2025-12-17 18:57:20 +02:00
|
|
|
|
2026-03-02 02:11:33 +02:00
|
|
|
const clampSidebarWidth = React.useCallback((value: number) => {
|
|
|
|
|
return Math.min(SIDEBAR_MAX_WIDTH, Math.max(SIDEBAR_MIN_WIDTH, value));
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const applyLiveWidth = React.useCallback((nextWidth: number) => {
|
|
|
|
|
const sidebar = sidebarRef.current;
|
|
|
|
|
if (!sidebar) {
|
2025-12-07 19:32:53 +02:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-20 20:43:13 +03:00
|
|
|
sidebar.style.width = `${nextWidth}px`;
|
|
|
|
|
sidebar.style.minWidth = `${nextWidth}px`;
|
|
|
|
|
sidebar.style.maxWidth = `${nextWidth}px`;
|
2026-03-02 02:11:33 +02:00
|
|
|
}, []);
|
2025-12-07 19:32:53 +02:00
|
|
|
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
if (isMobile && isResizing) {
|
|
|
|
|
setIsResizing(false);
|
|
|
|
|
}
|
|
|
|
|
}, [isMobile, isResizing]);
|
|
|
|
|
|
2026-03-02 02:11:33 +02:00
|
|
|
React.useEffect(() => {
|
|
|
|
|
if (!isResizing) {
|
|
|
|
|
resizingWidthRef.current = null;
|
|
|
|
|
activeResizePointerIDRef.current = null;
|
|
|
|
|
}
|
|
|
|
|
}, [isResizing]);
|
|
|
|
|
|
2025-12-07 19:32:53 +02:00
|
|
|
if (isMobile) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-20 20:43:13 +03:00
|
|
|
const openWidth = Math.min(
|
2025-12-07 19:32:53 +02:00
|
|
|
SIDEBAR_MAX_WIDTH,
|
|
|
|
|
Math.max(SIDEBAR_MIN_WIDTH, sidebarWidth || SIDEBAR_CONTENT_WIDTH)
|
2026-05-20 20:43:13 +03:00
|
|
|
);
|
|
|
|
|
const appliedWidth = isOpen ? openWidth : 0;
|
2025-12-07 19:32:53 +02:00
|
|
|
|
|
|
|
|
const handlePointerDown = (event: React.PointerEvent) => {
|
|
|
|
|
if (!isOpen) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-03-02 02:11:33 +02:00
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
event.currentTarget.setPointerCapture(event.pointerId);
|
|
|
|
|
} catch {
|
|
|
|
|
// ignore
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
activeResizePointerIDRef.current = event.pointerId;
|
2025-12-07 19:32:53 +02:00
|
|
|
setIsResizing(true);
|
|
|
|
|
startXRef.current = event.clientX;
|
|
|
|
|
startWidthRef.current = appliedWidth;
|
2026-03-02 02:11:33 +02:00
|
|
|
resizingWidthRef.current = appliedWidth;
|
|
|
|
|
applyLiveWidth(appliedWidth);
|
2025-12-07 19:32:53 +02:00
|
|
|
event.preventDefault();
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-02 02:11:33 +02:00
|
|
|
const handlePointerMove = (event: React.PointerEvent) => {
|
|
|
|
|
if (isMobile || !isResizing || activeResizePointerIDRef.current !== event.pointerId) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const delta = event.clientX - startXRef.current;
|
|
|
|
|
const nextWidth = clampSidebarWidth(startWidthRef.current + delta);
|
|
|
|
|
if (resizingWidthRef.current === nextWidth) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
resizingWidthRef.current = nextWidth;
|
|
|
|
|
applyLiveWidth(nextWidth);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handlePointerEnd = (event: React.PointerEvent) => {
|
|
|
|
|
if (activeResizePointerIDRef.current !== event.pointerId || isMobile) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
event.currentTarget.releasePointerCapture(event.pointerId);
|
|
|
|
|
} catch {
|
|
|
|
|
// ignore
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const finalWidth = clampSidebarWidth(resizingWidthRef.current ?? appliedWidth);
|
|
|
|
|
activeResizePointerIDRef.current = null;
|
|
|
|
|
resizingWidthRef.current = null;
|
|
|
|
|
setIsResizing(false);
|
|
|
|
|
setSidebarWidth(finalWidth);
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-20 20:43:13 +03:00
|
|
|
const currentWidth = isResizing ? (resizingWidthRef.current ?? appliedWidth) : appliedWidth;
|
|
|
|
|
|
2025-12-07 19:32:53 +02:00
|
|
|
return (
|
|
|
|
|
<aside
|
2026-03-02 02:11:33 +02:00
|
|
|
ref={sidebarRef}
|
2025-12-07 19:32:53 +02:00
|
|
|
className={cn(
|
2026-05-20 20:43:13 +03:00
|
|
|
'relative flex h-full overflow-hidden border-r border-border/40 will-change-[width] motion-reduce:transition-none',
|
2026-04-07 21:46:38 +03:00
|
|
|
'bg-sidebar',
|
2026-03-20 01:01:03 +02:00
|
|
|
!isOpen && 'border-r-0',
|
|
|
|
|
className,
|
2025-12-07 19:32:53 +02:00
|
|
|
)}
|
|
|
|
|
style={{
|
2026-05-20 20:43:13 +03:00
|
|
|
width: `${currentWidth}px`,
|
|
|
|
|
minWidth: `${currentWidth}px`,
|
|
|
|
|
maxWidth: `${currentWidth}px`,
|
2025-12-07 19:32:53 +02:00
|
|
|
overflowX: 'clip',
|
2026-05-20 20:43:13 +03:00
|
|
|
transitionProperty: isResizing ? 'none' : 'width, min-width, max-width',
|
|
|
|
|
transitionDuration: '200ms',
|
|
|
|
|
transitionTimingFunction: 'cubic-bezier(0.22, 1, 0.36, 1)',
|
2025-12-07 19:32:53 +02:00
|
|
|
}}
|
|
|
|
|
aria-hidden={!isOpen || appliedWidth === 0}
|
|
|
|
|
>
|
|
|
|
|
{isOpen && (
|
|
|
|
|
<div
|
|
|
|
|
className={cn(
|
2026-03-20 01:01:03 +02:00
|
|
|
'absolute right-0 top-0 z-20 h-full w-[3px] cursor-col-resize hover:bg-[var(--interactive-border)]/80 transition-colors',
|
|
|
|
|
isResizing && 'bg-[var(--interactive-border)]'
|
2025-12-07 19:32:53 +02:00
|
|
|
)}
|
|
|
|
|
onPointerDown={handlePointerDown}
|
2026-03-02 02:11:33 +02:00
|
|
|
onPointerMove={handlePointerMove}
|
|
|
|
|
onPointerUp={handlePointerEnd}
|
|
|
|
|
onPointerCancel={handlePointerEnd}
|
2025-12-07 19:32:53 +02:00
|
|
|
role="separator"
|
|
|
|
|
aria-orientation="vertical"
|
2026-04-26 14:03:39 +03:00
|
|
|
aria-label={t('sidebar.resize.leftPanelAria')}
|
2025-12-07 19:32:53 +02:00
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
<div
|
|
|
|
|
className={cn(
|
2026-05-20 20:43:13 +03:00
|
|
|
'relative z-10 flex h-full shrink-0 flex-col transition-opacity duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none',
|
2026-03-02 02:11:33 +02:00
|
|
|
isResizing && 'pointer-events-none',
|
2025-12-07 19:32:53 +02:00
|
|
|
!isOpen && 'pointer-events-none select-none opacity-0'
|
|
|
|
|
)}
|
2026-05-20 20:43:13 +03:00
|
|
|
style={{ width: `${openWidth}px`, overflowX: 'hidden' }}
|
2025-12-07 19:32:53 +02:00
|
|
|
aria-hidden={!isOpen}
|
|
|
|
|
>
|
2026-04-26 16:24:07 +03:00
|
|
|
<div className="flex-1 overflow-y-auto">
|
2025-12-07 19:32:53 +02:00
|
|
|
<ErrorBoundary>{children}</ErrorBoundary>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</aside>
|
|
|
|
|
);
|
|
|
|
|
};
|