Improve tab scrolling with arrows
This commit is contained in:
233
frontend/src/components/TabsScroller.tsx
Normal file
233
frontend/src/components/TabsScroller.tsx
Normal file
@@ -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<HTMLDivElement, TabsScrollerProps>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
children,
|
||||
scrollStep,
|
||||
ariaLabelLeft = 'Scroll tabs left',
|
||||
ariaLabelRight = 'Scroll tabs right'
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const sliderRef = useRef<HTMLDivElement | null>(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<HTMLDivElement>) => {
|
||||
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<HTMLDivElement>) => {
|
||||
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<HTMLDivElement>) => {
|
||||
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<HTMLDivElement>) => {
|
||||
if (dragRef.current.didDrag) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
dragRef.current.didDrag = false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="tabs-scroll-shell">
|
||||
<button
|
||||
className={`tabs-scroll-arrow left ${showLeft ? 'visible' : ''}`}
|
||||
type="button"
|
||||
aria-label={ariaLabelLeft}
|
||||
onClick={() => scrollByAmount('left')}
|
||||
>
|
||||
<span className="material-symbols-outlined">chevron_left</span>
|
||||
</button>
|
||||
<div
|
||||
ref={setRefs}
|
||||
className={`${className} tabs-scroll-slider`}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={endPointerDrag}
|
||||
onPointerCancel={endPointerDrag}
|
||||
onClickCapture={handleClickCapture}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
<button
|
||||
className={`tabs-scroll-arrow right ${showRight ? 'visible' : ''}`}
|
||||
type="button"
|
||||
aria-label={ariaLabelRight}
|
||||
onClick={() => scrollByAmount('right')}
|
||||
>
|
||||
<span className="material-symbols-outlined">chevron_right</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
TabsScroller.displayName = 'TabsScroller';
|
||||
|
||||
export default TabsScroller;
|
||||
@@ -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 (
|
||||
<main className="main-content api-keys-root">
|
||||
<div className="page-tabs-container">
|
||||
<div className="page-tabs-slider">
|
||||
<TabsScroller className="page-tabs-slider">
|
||||
<button className="mobile-menu-btn" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
|
||||
<span className="material-symbols-outlined">menu</span>
|
||||
</button>
|
||||
<div className="page-title-section">
|
||||
<span className="page-title-text">{t.apiKeysPage.title}</span>
|
||||
</div>
|
||||
</div>
|
||||
</TabsScroller>
|
||||
</div>
|
||||
|
||||
<div className="page-content">
|
||||
@@ -185,4 +186,3 @@ export default function APIKeys() {
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<main className="main-content admin-panel-root">
|
||||
<div className="page-tabs-container">
|
||||
<div className="page-tabs-slider">
|
||||
<TabsScroller className="page-tabs-slider">
|
||||
<button className="mobile-menu-btn" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
|
||||
<span className="material-symbols-outlined">menu</span>
|
||||
</button>
|
||||
@@ -46,7 +47,7 @@ export default function AdminPanel({ initialTab = 'general' }: { initialTab?: Ta
|
||||
<span className="material-symbols-outlined">group</span>
|
||||
<span>{t.admin.usersTab}</span>
|
||||
</button>
|
||||
</div>
|
||||
</TabsScroller>
|
||||
</div>
|
||||
|
||||
<SwipeTabs
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useTranslation } from '../contexts/LanguageContext';
|
||||
import { useSidebar } from '../contexts/SidebarContext';
|
||||
import TabsScroller from '../components/TabsScroller';
|
||||
|
||||
export default function Dashboard() {
|
||||
const { t } = useTranslation();
|
||||
@@ -8,14 +9,14 @@ export default function Dashboard() {
|
||||
return (
|
||||
<main className="main-content">
|
||||
<div className="page-tabs-container">
|
||||
<div className="page-tabs-slider">
|
||||
<TabsScroller className="page-tabs-slider">
|
||||
<button className="mobile-menu-btn" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
|
||||
<span className="material-symbols-outlined">menu</span>
|
||||
</button>
|
||||
<div className="page-title-section">
|
||||
<span className="page-title-text">{t.dashboard.title}</span>
|
||||
</div>
|
||||
</div>
|
||||
</TabsScroller>
|
||||
</div>
|
||||
|
||||
<div className="page-content">
|
||||
|
||||
@@ -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 (
|
||||
<main className="main-content">
|
||||
<div className="page-tabs-container">
|
||||
<div className="page-tabs-slider">
|
||||
<TabsScroller className="page-tabs-slider">
|
||||
<button className="mobile-menu-btn" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
|
||||
<span className="material-symbols-outlined">menu</span>
|
||||
</button>
|
||||
<div className="page-title-section">
|
||||
<span className="page-title-text">{t.feature1.title}</span>
|
||||
</div>
|
||||
</div>
|
||||
</TabsScroller>
|
||||
</div>
|
||||
|
||||
<div className="page-content">
|
||||
|
||||
@@ -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 (
|
||||
<main className="main-content">
|
||||
<div className="page-tabs-container">
|
||||
<div className="page-tabs-slider">
|
||||
<TabsScroller className="page-tabs-slider">
|
||||
<button className="mobile-menu-btn" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
|
||||
<span className="material-symbols-outlined">menu</span>
|
||||
</button>
|
||||
<div className="page-title-section">
|
||||
<span className="page-title-text">{t.features.feature2}</span>
|
||||
</div>
|
||||
</div>
|
||||
</TabsScroller>
|
||||
</div>
|
||||
|
||||
<div className="page-content">
|
||||
@@ -32,4 +33,3 @@ export default function Feature2() {
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<main className="main-content">
|
||||
<div className="page-tabs-container">
|
||||
<div className="page-tabs-slider">
|
||||
<TabsScroller className="page-tabs-slider">
|
||||
<button className="mobile-menu-btn" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
|
||||
<span className="material-symbols-outlined">menu</span>
|
||||
</button>
|
||||
<div className="page-title-section">
|
||||
<span className="page-title-text">{t.features.feature3}</span>
|
||||
</div>
|
||||
</div>
|
||||
</TabsScroller>
|
||||
</div>
|
||||
|
||||
<div className="page-content">
|
||||
@@ -32,4 +33,3 @@ export default function Feature3() {
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<main className="main-content notifications-root">
|
||||
<div className="page-tabs-container">
|
||||
<div className="page-tabs-slider">
|
||||
<TabsScroller className="page-tabs-slider">
|
||||
<button className="mobile-menu-btn" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
|
||||
<span className="material-symbols-outlined">menu</span>
|
||||
</button>
|
||||
<div className="page-title-section">
|
||||
<span className="page-title-text">{t.notificationsPage.title}</span>
|
||||
</div>
|
||||
</div>
|
||||
</TabsScroller>
|
||||
</div>
|
||||
|
||||
<div className="page-content page-content--narrow">
|
||||
|
||||
@@ -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 (
|
||||
<main className="main-content search-root">
|
||||
<div className="page-tabs-container">
|
||||
<div className="page-tabs-slider">
|
||||
<TabsScroller className="page-tabs-slider">
|
||||
<button className="mobile-menu-btn" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
|
||||
<span className="material-symbols-outlined">menu</span>
|
||||
</button>
|
||||
<div className="page-title-section">
|
||||
<span className="page-title-text">{t.searchPage.title}</span>
|
||||
</div>
|
||||
</div>
|
||||
</TabsScroller>
|
||||
</div>
|
||||
|
||||
<div className="page-content">
|
||||
|
||||
@@ -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 (
|
||||
<main className="main-content settings-page-root">
|
||||
<div className="page-tabs-container">
|
||||
<div className="page-tabs-slider">
|
||||
<TabsScroller className="page-tabs-slider">
|
||||
<button className="mobile-menu-btn" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
|
||||
<span className="material-symbols-outlined">menu</span>
|
||||
</button>
|
||||
<div className="page-title-section">
|
||||
<span className="page-title-text">{t.settings.title}</span>
|
||||
</div>
|
||||
</div>
|
||||
</TabsScroller>
|
||||
</div>
|
||||
|
||||
<div className="page-content page-content--narrow settings-tab-content">
|
||||
|
||||
@@ -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() {
|
||||
<Sidebar />
|
||||
<main className="main-content users-root">
|
||||
<div className="page-tabs-container">
|
||||
<div className="page-tabs-slider">
|
||||
<TabsScroller className="page-tabs-slider">
|
||||
<button className="mobile-menu-btn" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
|
||||
<span className="material-symbols-outlined">menu</span>
|
||||
</button>
|
||||
@@ -192,7 +193,7 @@ export default function Users() {
|
||||
<span className="material-symbols-outlined">add</span>
|
||||
<span>{t.usersPage.addUser}</span>
|
||||
</button>
|
||||
</div>
|
||||
</TabsScroller>
|
||||
</div>
|
||||
|
||||
<div className="page-content users-page">
|
||||
|
||||
@@ -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 (
|
||||
<main className="main-content admin-analytics-root">
|
||||
<div className="page-tabs-container">
|
||||
<div className="page-tabs-slider">
|
||||
<TabsScroller className="page-tabs-slider">
|
||||
<button className="mobile-menu-btn" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
|
||||
<span className="material-symbols-outlined">menu</span>
|
||||
</button>
|
||||
<div className="page-title-section">
|
||||
<span className="page-title-text">{t.analyticsPage.title}</span>
|
||||
</div>
|
||||
</div>
|
||||
</TabsScroller>
|
||||
</div>
|
||||
|
||||
<div className="page-content">
|
||||
@@ -151,4 +152,3 @@ export default function Analytics() {
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<main className="main-content admin-audit-root">
|
||||
<div className="page-tabs-container">
|
||||
<div className="page-tabs-slider">
|
||||
<TabsScroller className="page-tabs-slider">
|
||||
<button className="mobile-menu-btn" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
|
||||
<span className="material-symbols-outlined">menu</span>
|
||||
</button>
|
||||
<div className="page-title-section">
|
||||
<span className="page-title-text">{t.auditPage.title}</span>
|
||||
</div>
|
||||
</div>
|
||||
</TabsScroller>
|
||||
</div>
|
||||
|
||||
<div className="page-content">
|
||||
@@ -163,4 +164,3 @@ export default function AuditLogs() {
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<main className="main-content admin-panel-root">
|
||||
<div className="page-tabs-container">
|
||||
<div className="page-tabs-slider" ref={tabsContainerRef}>
|
||||
<TabsScroller className="page-tabs-slider" ref={tabsContainerRef}>
|
||||
<button className="mobile-menu-btn" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
|
||||
<span className="material-symbols-outlined">menu</span>
|
||||
</button>
|
||||
@@ -774,7 +775,7 @@ export default function Features() {
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</TabsScroller>
|
||||
</div>
|
||||
|
||||
<SwipeTabs
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from '../../contexts/LanguageContext';
|
||||
import { useSidebar } from '../../contexts/SidebarContext';
|
||||
import Sidebar from '../../components/Sidebar';
|
||||
import TabsScroller from '../../components/TabsScroller';
|
||||
import { settingsAPI } from '../../api/client';
|
||||
import '../../styles/Settings.css';
|
||||
|
||||
@@ -60,14 +61,14 @@ export default function Settings() {
|
||||
<Sidebar />
|
||||
<main className="main-content settings-root">
|
||||
<div className="page-tabs-container">
|
||||
<div className="page-tabs-slider">
|
||||
<TabsScroller className="page-tabs-slider">
|
||||
<button className="mobile-menu-btn" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
|
||||
<span className="material-symbols-outlined">menu</span>
|
||||
</button>
|
||||
<div className="page-title-section">
|
||||
<span className="page-title-text">{t.settings.title}</span>
|
||||
</div>
|
||||
</div>
|
||||
</TabsScroller>
|
||||
</div>
|
||||
|
||||
<div className="page-content page-content--narrow">
|
||||
|
||||
@@ -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 (
|
||||
<main className="main-content admin-panel-root">
|
||||
<div className="admin-tabs-container">
|
||||
<div className="admin-tabs-slider">
|
||||
<TabsScroller className="admin-tabs-slider">
|
||||
<button className="mobile-menu-btn" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
|
||||
<span className="material-symbols-outlined">menu</span>
|
||||
</button>
|
||||
<div className="admin-title-section">
|
||||
<span className="admin-title-text">{t.sourcesPage.title}</span>
|
||||
</div>
|
||||
</div>
|
||||
</TabsScroller>
|
||||
</div>
|
||||
|
||||
<div className="admin-tab-content">
|
||||
|
||||
@@ -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 */}
|
||||
<div className="page-tabs-container">
|
||||
<div className="page-tabs-slider">
|
||||
<TabsScroller className="page-tabs-slider">
|
||||
<button className="mobile-menu-btn" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
|
||||
<span className="material-symbols-outlined">menu</span>
|
||||
</button>
|
||||
@@ -706,7 +707,7 @@ export default function ThemeSettings() {
|
||||
<span>{t.theme.advancedTab}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</TabsScroller>
|
||||
</div>
|
||||
|
||||
<SwipeTabs
|
||||
|
||||
@@ -62,6 +62,7 @@ body {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
.page-tabs-slider::-webkit-scrollbar,
|
||||
@@ -69,6 +70,102 @@ body {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tabs-scroll-shell {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tabs-scroll-slider {
|
||||
cursor: grab;
|
||||
overscroll-behavior-x: contain;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tabs-scroll-slider--dragging {
|
||||
cursor: grabbing;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.tabs-scroll-arrow {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-bg-elevated);
|
||||
color: var(--color-text-secondary);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: var(--shadow-md);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s ease, transform 0.2s ease, background 0.2s ease, color 0.2s ease;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.tabs-scroll-arrow .material-symbols-outlined {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.tabs-scroll-arrow.visible {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.tabs-scroll-arrow:hover {
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.tabs-scroll-arrow:active {
|
||||
transform: translateY(-50%) scale(0.96);
|
||||
}
|
||||
|
||||
.tabs-scroll-arrow.left {
|
||||
left: -12px;
|
||||
}
|
||||
|
||||
.tabs-scroll-arrow.right {
|
||||
right: -12px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.tabs-scroll-slider {
|
||||
touch-action: pan-x;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.tabs-scroll-arrow {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.tabs-scroll-arrow.left {
|
||||
left: -8px;
|
||||
}
|
||||
|
||||
.tabs-scroll-arrow.right {
|
||||
right: -8px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.tabs-scroll-arrow.left {
|
||||
left: -6px;
|
||||
}
|
||||
|
||||
.tabs-scroll-arrow.right {
|
||||
right: -6px;
|
||||
}
|
||||
}
|
||||
|
||||
@supports (color: color-mix(in srgb, black, white)) {
|
||||
.page-tabs-slider,
|
||||
.admin-tabs-slider {
|
||||
|
||||
Reference in New Issue
Block a user