diff --git a/frontend/src/components/TabsScroller.tsx b/frontend/src/components/TabsScroller.tsx new file mode 100644 index 0000000..96931c1 --- /dev/null +++ b/frontend/src/components/TabsScroller.tsx @@ -0,0 +1,233 @@ +import { + forwardRef, + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, + type ReactNode, + type PointerEvent, + type MouseEvent +} from 'react'; + +type TabsScrollerProps = { + className: string; + children: ReactNode; + scrollStep?: number; + ariaLabelLeft?: string; + ariaLabelRight?: string; +}; + +const SCROLL_EDGE_THRESHOLD = 8; +const DRAG_START_DISTANCE = 6; +const DEFAULT_SCROLL_RATIO = 0.65; +const MIN_SCROLL_STEP = 180; + +const TabsScroller = forwardRef( + ( + { + className, + children, + scrollStep, + ariaLabelLeft = 'Scroll tabs left', + ariaLabelRight = 'Scroll tabs right' + }, + ref + ) => { + const sliderRef = useRef(null); + const rafRef = useRef(0); + const [showLeft, setShowLeft] = useState(false); + const [showRight, setShowRight] = useState(false); + const dragRef = useRef({ + pointerId: null as number | null, + startX: 0, + startY: 0, + startScrollLeft: 0, + isDragging: false, + didDrag: false + }); + + const setRefs = useCallback( + (node: HTMLDivElement | null) => { + sliderRef.current = node; + if (!ref) return; + if (typeof ref === 'function') { + ref(node); + } else { + ref.current = node; + } + }, + [ref] + ); + + const updateOverflow = useCallback(() => { + const node = sliderRef.current; + if (!node) return; + const maxScroll = node.scrollWidth - node.clientWidth; + setShowLeft(node.scrollLeft > SCROLL_EDGE_THRESHOLD); + setShowRight(node.scrollLeft < maxScroll - SCROLL_EDGE_THRESHOLD); + }, []); + + const scheduleOverflowUpdate = useCallback(() => { + if (rafRef.current) return; + rafRef.current = requestAnimationFrame(() => { + rafRef.current = 0; + updateOverflow(); + }); + }, [updateOverflow]); + + useLayoutEffect(() => { + updateOverflow(); + const node = sliderRef.current; + if (!node) return undefined; + const handleScroll = () => scheduleOverflowUpdate(); + node.addEventListener('scroll', handleScroll, { passive: true }); + let resizeObserver: ResizeObserver | null = null; + if (typeof ResizeObserver !== 'undefined') { + resizeObserver = new ResizeObserver(() => scheduleOverflowUpdate()); + resizeObserver.observe(node); + } else if (typeof window !== 'undefined') { + window.addEventListener('resize', scheduleOverflowUpdate); + } + return () => { + node.removeEventListener('scroll', handleScroll); + resizeObserver?.disconnect(); + if (typeof window !== 'undefined') { + window.removeEventListener('resize', scheduleOverflowUpdate); + } + }; + }, [scheduleOverflowUpdate, updateOverflow]); + + useEffect(() => { + updateOverflow(); + }, [children, updateOverflow]); + + useEffect(() => { + return () => { + if (rafRef.current) { + cancelAnimationFrame(rafRef.current); + } + }; + }, []); + + const scrollByAmount = useCallback( + (direction: 'left' | 'right') => { + const node = sliderRef.current; + if (!node) return; + const width = node.clientWidth || 0; + const step = scrollStep ?? Math.max(MIN_SCROLL_STEP, Math.round(width * DEFAULT_SCROLL_RATIO)); + node.scrollBy({ + left: direction === 'left' ? -step : step, + behavior: 'smooth' + }); + }, + [scrollStep] + ); + + const handlePointerDown = useCallback((event: PointerEvent) => { + if (event.pointerType !== 'mouse') return; + if (event.button !== 0) return; + const node = sliderRef.current; + if (!node) return; + dragRef.current.pointerId = event.pointerId; + dragRef.current.startX = event.clientX; + dragRef.current.startY = event.clientY; + dragRef.current.startScrollLeft = node.scrollLeft; + dragRef.current.isDragging = false; + dragRef.current.didDrag = false; + }, []); + + const handlePointerMove = useCallback((event: PointerEvent) => { + const node = sliderRef.current; + if (!node) return; + const state = dragRef.current; + if (state.pointerId !== event.pointerId) return; + const dx = event.clientX - state.startX; + const dy = event.clientY - state.startY; + if (!state.isDragging) { + if (Math.abs(dx) < DRAG_START_DISTANCE || Math.abs(dx) < Math.abs(dy)) { + return; + } + state.isDragging = true; + state.didDrag = true; + node.classList.add('tabs-scroll-slider--dragging'); + if (event.currentTarget.setPointerCapture) { + try { + event.currentTarget.setPointerCapture(event.pointerId); + } catch { + // Ignore pointer capture errors. + } + } + } + if (state.isDragging) { + node.scrollLeft = state.startScrollLeft - dx; + scheduleOverflowUpdate(); + if (event.cancelable) { + event.preventDefault(); + } + } + }, [scheduleOverflowUpdate]); + + const endPointerDrag = useCallback((event: PointerEvent) => { + const node = sliderRef.current; + if (!node) return; + const state = dragRef.current; + if (state.pointerId !== event.pointerId) return; + if (event.currentTarget.hasPointerCapture?.(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + state.pointerId = null; + state.isDragging = false; + node.classList.remove('tabs-scroll-slider--dragging'); + if (state.didDrag && typeof window !== 'undefined') { + window.setTimeout(() => { + dragRef.current.didDrag = false; + }, 0); + } + }, []); + + const handleClickCapture = useCallback((event: MouseEvent) => { + if (dragRef.current.didDrag) { + event.preventDefault(); + event.stopPropagation(); + dragRef.current.didDrag = false; + } + }, []); + + return ( +
+ +
+ {children} +
+ +
+ ); + } +); + +TabsScroller.displayName = 'TabsScroller'; + +export default TabsScroller; diff --git a/frontend/src/pages/APIKeys.tsx b/frontend/src/pages/APIKeys.tsx index bc163fe..faf8c15 100644 --- a/frontend/src/pages/APIKeys.tsx +++ b/frontend/src/pages/APIKeys.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react'; import { useTranslation } from '../contexts/LanguageContext'; import { useSidebar } from '../contexts/SidebarContext'; +import TabsScroller from '../components/TabsScroller'; import { apiKeysAPI } from '../api/client'; import type { ApiKeyItem } from '../api/client'; import '../styles/APIKeys.css'; @@ -89,14 +90,14 @@ export default function APIKeys() { return (
-
+
{t.apiKeysPage.title}
-
+
@@ -185,4 +186,3 @@ export default function APIKeys() {
); } - diff --git a/frontend/src/pages/AdminPanel.tsx b/frontend/src/pages/AdminPanel.tsx index 7a52017..bd3a328 100644 --- a/frontend/src/pages/AdminPanel.tsx +++ b/frontend/src/pages/AdminPanel.tsx @@ -2,6 +2,7 @@ import { useState } from 'react'; import { useAuth } from '../contexts/AuthContext'; import { useTranslation } from '../contexts/LanguageContext'; import { useSidebar } from '../contexts/SidebarContext'; +import TabsScroller from '../components/TabsScroller'; import GeneralTab from '../components/admin/GeneralTab'; import UsersTab from '../components/admin/UsersTab'; import { SwipeTabs } from '../components/SwipeTabs'; @@ -24,7 +25,7 @@ export default function AdminPanel({ initialTab = 'general' }: { initialTab?: Ta return (
-
+ @@ -46,7 +47,7 @@ export default function AdminPanel({ initialTab = 'general' }: { initialTab?: Ta group {t.admin.usersTab} -
+
-
+
{t.dashboard.title}
-
+
diff --git a/frontend/src/pages/Feature1.tsx b/frontend/src/pages/Feature1.tsx index d844055..5263d76 100644 --- a/frontend/src/pages/Feature1.tsx +++ b/frontend/src/pages/Feature1.tsx @@ -1,5 +1,6 @@ import { useTranslation } from '../contexts/LanguageContext'; import { useSidebar } from '../contexts/SidebarContext'; +import TabsScroller from '../components/TabsScroller'; import '../styles/AdminPanel.css'; export default function Feature1() { @@ -9,14 +10,14 @@ export default function Feature1() { return (
-
+
{t.feature1.title}
-
+
diff --git a/frontend/src/pages/Feature2.tsx b/frontend/src/pages/Feature2.tsx index 88ec747..0aca801 100644 --- a/frontend/src/pages/Feature2.tsx +++ b/frontend/src/pages/Feature2.tsx @@ -1,5 +1,6 @@ import { useTranslation } from '../contexts/LanguageContext'; import { useSidebar } from '../contexts/SidebarContext'; +import TabsScroller from '../components/TabsScroller'; import '../styles/AdminPanel.css'; export default function Feature2() { @@ -9,14 +10,14 @@ export default function Feature2() { return (
-
+
{t.features.feature2}
-
+
@@ -32,4 +33,3 @@ export default function Feature2() {
); } - diff --git a/frontend/src/pages/Feature3.tsx b/frontend/src/pages/Feature3.tsx index 2920b32..1c692f2 100644 --- a/frontend/src/pages/Feature3.tsx +++ b/frontend/src/pages/Feature3.tsx @@ -1,5 +1,6 @@ import { useTranslation } from '../contexts/LanguageContext'; import { useSidebar } from '../contexts/SidebarContext'; +import TabsScroller from '../components/TabsScroller'; import '../styles/AdminPanel.css'; export default function Feature3() { @@ -9,14 +10,14 @@ export default function Feature3() { return (
-
+
{t.features.feature3}
-
+
@@ -32,4 +33,3 @@ export default function Feature3() {
); } - diff --git a/frontend/src/pages/Notifications.tsx b/frontend/src/pages/Notifications.tsx index 2d764fb..fbc4452 100644 --- a/frontend/src/pages/Notifications.tsx +++ b/frontend/src/pages/Notifications.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react'; import { useTranslation } from '../contexts/LanguageContext'; import { useSidebar } from '../contexts/SidebarContext'; +import TabsScroller from '../components/TabsScroller'; import { useNotifications } from '../contexts/NotificationsContext'; import { notificationsAPI } from '../api/client'; import type { NotificationItem } from '../api/client'; @@ -95,14 +96,14 @@ export default function Notifications() { return (
-
+
{t.notificationsPage.title}
-
+
diff --git a/frontend/src/pages/Search.tsx b/frontend/src/pages/Search.tsx index aea7d53..b9414ab 100644 --- a/frontend/src/pages/Search.tsx +++ b/frontend/src/pages/Search.tsx @@ -1,6 +1,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from '../contexts/LanguageContext'; import { useSidebar } from '../contexts/SidebarContext'; +import TabsScroller from '../components/TabsScroller'; import '../styles/Search.css'; type SearchResult = { @@ -77,14 +78,14 @@ export default function Search() { return (
-
+
{t.searchPage.title}
-
+
diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 43b301d..9ebab58 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react'; import { useTranslation } from '../contexts/LanguageContext'; import { useSidebar } from '../contexts/SidebarContext'; +import TabsScroller from '../components/TabsScroller'; import { sessionsAPI, twoFactorAPI } from '../api/client'; import type { UserSession } from '../api/client'; import '../styles/SettingsPage.css'; @@ -152,14 +153,14 @@ export default function Settings() { return (
-
+
{t.settings.title}
-
+
diff --git a/frontend/src/pages/Users.tsx b/frontend/src/pages/Users.tsx index a582068..1171e84 100644 --- a/frontend/src/pages/Users.tsx +++ b/frontend/src/pages/Users.tsx @@ -1,6 +1,7 @@ import { useEffect, useMemo, useState } from 'react'; import type { FormEvent } from 'react'; import Sidebar from '../components/Sidebar'; +import TabsScroller from '../components/TabsScroller'; import { useAuth } from '../contexts/AuthContext'; import { useTranslation } from '../contexts/LanguageContext'; import { useSidebar } from '../contexts/SidebarContext'; @@ -181,7 +182,7 @@ export default function Users() {
-
+ @@ -192,7 +193,7 @@ export default function Users() { add {t.usersPage.addUser} -
+
diff --git a/frontend/src/pages/admin/Analytics.tsx b/frontend/src/pages/admin/Analytics.tsx index 8ac0597..674735d 100644 --- a/frontend/src/pages/admin/Analytics.tsx +++ b/frontend/src/pages/admin/Analytics.tsx @@ -1,6 +1,7 @@ import { useEffect, useMemo, useState } from 'react'; import { useTranslation } from '../../contexts/LanguageContext'; import { useSidebar } from '../../contexts/SidebarContext'; +import TabsScroller from '../../components/TabsScroller'; import { analyticsAPI } from '../../api/client'; import type { AnalyticsOverview } from '../../api/client'; import '../../styles/AdminAnalytics.css'; @@ -46,14 +47,14 @@ export default function Analytics() { return (
-
+
{t.analyticsPage.title}
-
+
@@ -151,4 +152,3 @@ export default function Analytics() {
); } - diff --git a/frontend/src/pages/admin/AuditLogs.tsx b/frontend/src/pages/admin/AuditLogs.tsx index 62916e6..f8cb357 100644 --- a/frontend/src/pages/admin/AuditLogs.tsx +++ b/frontend/src/pages/admin/AuditLogs.tsx @@ -1,6 +1,7 @@ import { useEffect, useMemo, useState } from 'react'; import { useTranslation } from '../../contexts/LanguageContext'; import { useSidebar } from '../../contexts/SidebarContext'; +import TabsScroller from '../../components/TabsScroller'; import { auditAPI } from '../../api/client'; import type { AuditLogItem } from '../../api/client'; import '../../styles/AdminAudit.css'; @@ -59,14 +60,14 @@ export default function AuditLogs() { return (
-
+
{t.auditPage.title}
-
+
@@ -163,4 +164,3 @@ export default function AuditLogs() {
); } - diff --git a/frontend/src/pages/admin/Features.tsx b/frontend/src/pages/admin/Features.tsx index f6a8140..454d091 100644 --- a/frontend/src/pages/admin/Features.tsx +++ b/frontend/src/pages/admin/Features.tsx @@ -6,6 +6,7 @@ import { useModules, TOGGLEABLE_MODULES } from '../../contexts/ModulesContext'; import type { ModuleId } from '../../contexts/ModulesContext'; import Feature1Tab from '../../components/admin/Feature1Tab'; import { SwipeTabs } from '../../components/SwipeTabs'; +import TabsScroller from '../../components/TabsScroller'; import '../../styles/AdminPanel.css'; type TabId = 'config' | 'dashboard' | 'feature1' | 'feature2' | 'feature3' | 'search' | 'notifications'; @@ -743,7 +744,7 @@ export default function Features() { return (
-
+ @@ -774,7 +775,7 @@ export default function Features() { ); })} -
+
-
+
{t.settings.title}
-
+
diff --git a/frontend/src/pages/admin/Sources.tsx b/frontend/src/pages/admin/Sources.tsx index b00a20b..aee18bd 100644 --- a/frontend/src/pages/admin/Sources.tsx +++ b/frontend/src/pages/admin/Sources.tsx @@ -1,6 +1,7 @@ import { useAuth } from '../../contexts/AuthContext'; import { useTranslation } from '../../contexts/LanguageContext'; import { useSidebar } from '../../contexts/SidebarContext'; +import TabsScroller from '../../components/TabsScroller'; import '../../styles/AdminPanel.css'; export default function Sources() { @@ -15,14 +16,14 @@ export default function Sources() { return (
-
+
{t.sourcesPage.title}
-
+
diff --git a/frontend/src/pages/admin/ThemeSettings.tsx b/frontend/src/pages/admin/ThemeSettings.tsx index 9cdf5b6..2229e17 100644 --- a/frontend/src/pages/admin/ThemeSettings.tsx +++ b/frontend/src/pages/admin/ThemeSettings.tsx @@ -7,6 +7,7 @@ import { useSidebar } from '../../contexts/SidebarContext'; import type { SidebarMode } from '../../contexts/SidebarContext'; import { ChromePicker, HuePicker } from 'react-color'; import { SwipeTabs } from '../../components/SwipeTabs'; +import TabsScroller from '../../components/TabsScroller'; import '../../styles/ThemeSettings.css'; type ThemeTab = 'colors' | 'appearance' | 'preview' | 'advanced'; @@ -660,7 +661,7 @@ export default function ThemeSettings() { )} {/* Modern Tab Navigation */}
-
+ @@ -706,7 +707,7 @@ export default function ThemeSettings() { {t.theme.advancedTab} )} -
+