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 { useEffect, useState } from 'react';
|
||||||
import { useTranslation } from '../contexts/LanguageContext';
|
import { useTranslation } from '../contexts/LanguageContext';
|
||||||
import { useSidebar } from '../contexts/SidebarContext';
|
import { useSidebar } from '../contexts/SidebarContext';
|
||||||
|
import TabsScroller from '../components/TabsScroller';
|
||||||
import { apiKeysAPI } from '../api/client';
|
import { apiKeysAPI } from '../api/client';
|
||||||
import type { ApiKeyItem } from '../api/client';
|
import type { ApiKeyItem } from '../api/client';
|
||||||
import '../styles/APIKeys.css';
|
import '../styles/APIKeys.css';
|
||||||
@@ -89,14 +90,14 @@ export default function APIKeys() {
|
|||||||
return (
|
return (
|
||||||
<main className="main-content api-keys-root">
|
<main className="main-content api-keys-root">
|
||||||
<div className="page-tabs-container">
|
<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}>
|
<button className="mobile-menu-btn" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
|
||||||
<span className="material-symbols-outlined">menu</span>
|
<span className="material-symbols-outlined">menu</span>
|
||||||
</button>
|
</button>
|
||||||
<div className="page-title-section">
|
<div className="page-title-section">
|
||||||
<span className="page-title-text">{t.apiKeysPage.title}</span>
|
<span className="page-title-text">{t.apiKeysPage.title}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</TabsScroller>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="page-content">
|
<div className="page-content">
|
||||||
@@ -185,4 +186,3 @@ export default function APIKeys() {
|
|||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState } from 'react';
|
|||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
import { useTranslation } from '../contexts/LanguageContext';
|
import { useTranslation } from '../contexts/LanguageContext';
|
||||||
import { useSidebar } from '../contexts/SidebarContext';
|
import { useSidebar } from '../contexts/SidebarContext';
|
||||||
|
import TabsScroller from '../components/TabsScroller';
|
||||||
import GeneralTab from '../components/admin/GeneralTab';
|
import GeneralTab from '../components/admin/GeneralTab';
|
||||||
import UsersTab from '../components/admin/UsersTab';
|
import UsersTab from '../components/admin/UsersTab';
|
||||||
import { SwipeTabs } from '../components/SwipeTabs';
|
import { SwipeTabs } from '../components/SwipeTabs';
|
||||||
@@ -24,7 +25,7 @@ export default function AdminPanel({ initialTab = 'general' }: { initialTab?: Ta
|
|||||||
return (
|
return (
|
||||||
<main className="main-content admin-panel-root">
|
<main className="main-content admin-panel-root">
|
||||||
<div className="page-tabs-container">
|
<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}>
|
<button className="mobile-menu-btn" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
|
||||||
<span className="material-symbols-outlined">menu</span>
|
<span className="material-symbols-outlined">menu</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -46,7 +47,7 @@ export default function AdminPanel({ initialTab = 'general' }: { initialTab?: Ta
|
|||||||
<span className="material-symbols-outlined">group</span>
|
<span className="material-symbols-outlined">group</span>
|
||||||
<span>{t.admin.usersTab}</span>
|
<span>{t.admin.usersTab}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</TabsScroller>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SwipeTabs
|
<SwipeTabs
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useTranslation } from '../contexts/LanguageContext';
|
import { useTranslation } from '../contexts/LanguageContext';
|
||||||
import { useSidebar } from '../contexts/SidebarContext';
|
import { useSidebar } from '../contexts/SidebarContext';
|
||||||
|
import TabsScroller from '../components/TabsScroller';
|
||||||
|
|
||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -8,14 +9,14 @@ export default function Dashboard() {
|
|||||||
return (
|
return (
|
||||||
<main className="main-content">
|
<main className="main-content">
|
||||||
<div className="page-tabs-container">
|
<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}>
|
<button className="mobile-menu-btn" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
|
||||||
<span className="material-symbols-outlined">menu</span>
|
<span className="material-symbols-outlined">menu</span>
|
||||||
</button>
|
</button>
|
||||||
<div className="page-title-section">
|
<div className="page-title-section">
|
||||||
<span className="page-title-text">{t.dashboard.title}</span>
|
<span className="page-title-text">{t.dashboard.title}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</TabsScroller>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="page-content">
|
<div className="page-content">
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useTranslation } from '../contexts/LanguageContext';
|
import { useTranslation } from '../contexts/LanguageContext';
|
||||||
import { useSidebar } from '../contexts/SidebarContext';
|
import { useSidebar } from '../contexts/SidebarContext';
|
||||||
|
import TabsScroller from '../components/TabsScroller';
|
||||||
import '../styles/AdminPanel.css';
|
import '../styles/AdminPanel.css';
|
||||||
|
|
||||||
export default function Feature1() {
|
export default function Feature1() {
|
||||||
@@ -9,14 +10,14 @@ export default function Feature1() {
|
|||||||
return (
|
return (
|
||||||
<main className="main-content">
|
<main className="main-content">
|
||||||
<div className="page-tabs-container">
|
<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}>
|
<button className="mobile-menu-btn" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
|
||||||
<span className="material-symbols-outlined">menu</span>
|
<span className="material-symbols-outlined">menu</span>
|
||||||
</button>
|
</button>
|
||||||
<div className="page-title-section">
|
<div className="page-title-section">
|
||||||
<span className="page-title-text">{t.feature1.title}</span>
|
<span className="page-title-text">{t.feature1.title}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</TabsScroller>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="page-content">
|
<div className="page-content">
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useTranslation } from '../contexts/LanguageContext';
|
import { useTranslation } from '../contexts/LanguageContext';
|
||||||
import { useSidebar } from '../contexts/SidebarContext';
|
import { useSidebar } from '../contexts/SidebarContext';
|
||||||
|
import TabsScroller from '../components/TabsScroller';
|
||||||
import '../styles/AdminPanel.css';
|
import '../styles/AdminPanel.css';
|
||||||
|
|
||||||
export default function Feature2() {
|
export default function Feature2() {
|
||||||
@@ -9,14 +10,14 @@ export default function Feature2() {
|
|||||||
return (
|
return (
|
||||||
<main className="main-content">
|
<main className="main-content">
|
||||||
<div className="page-tabs-container">
|
<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}>
|
<button className="mobile-menu-btn" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
|
||||||
<span className="material-symbols-outlined">menu</span>
|
<span className="material-symbols-outlined">menu</span>
|
||||||
</button>
|
</button>
|
||||||
<div className="page-title-section">
|
<div className="page-title-section">
|
||||||
<span className="page-title-text">{t.features.feature2}</span>
|
<span className="page-title-text">{t.features.feature2}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</TabsScroller>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="page-content">
|
<div className="page-content">
|
||||||
@@ -32,4 +33,3 @@ export default function Feature2() {
|
|||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useTranslation } from '../contexts/LanguageContext';
|
import { useTranslation } from '../contexts/LanguageContext';
|
||||||
import { useSidebar } from '../contexts/SidebarContext';
|
import { useSidebar } from '../contexts/SidebarContext';
|
||||||
|
import TabsScroller from '../components/TabsScroller';
|
||||||
import '../styles/AdminPanel.css';
|
import '../styles/AdminPanel.css';
|
||||||
|
|
||||||
export default function Feature3() {
|
export default function Feature3() {
|
||||||
@@ -9,14 +10,14 @@ export default function Feature3() {
|
|||||||
return (
|
return (
|
||||||
<main className="main-content">
|
<main className="main-content">
|
||||||
<div className="page-tabs-container">
|
<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}>
|
<button className="mobile-menu-btn" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
|
||||||
<span className="material-symbols-outlined">menu</span>
|
<span className="material-symbols-outlined">menu</span>
|
||||||
</button>
|
</button>
|
||||||
<div className="page-title-section">
|
<div className="page-title-section">
|
||||||
<span className="page-title-text">{t.features.feature3}</span>
|
<span className="page-title-text">{t.features.feature3}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</TabsScroller>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="page-content">
|
<div className="page-content">
|
||||||
@@ -32,4 +33,3 @@ export default function Feature3() {
|
|||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useTranslation } from '../contexts/LanguageContext';
|
import { useTranslation } from '../contexts/LanguageContext';
|
||||||
import { useSidebar } from '../contexts/SidebarContext';
|
import { useSidebar } from '../contexts/SidebarContext';
|
||||||
|
import TabsScroller from '../components/TabsScroller';
|
||||||
import { useNotifications } from '../contexts/NotificationsContext';
|
import { useNotifications } from '../contexts/NotificationsContext';
|
||||||
import { notificationsAPI } from '../api/client';
|
import { notificationsAPI } from '../api/client';
|
||||||
import type { NotificationItem } from '../api/client';
|
import type { NotificationItem } from '../api/client';
|
||||||
@@ -95,14 +96,14 @@ export default function Notifications() {
|
|||||||
return (
|
return (
|
||||||
<main className="main-content notifications-root">
|
<main className="main-content notifications-root">
|
||||||
<div className="page-tabs-container">
|
<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}>
|
<button className="mobile-menu-btn" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
|
||||||
<span className="material-symbols-outlined">menu</span>
|
<span className="material-symbols-outlined">menu</span>
|
||||||
</button>
|
</button>
|
||||||
<div className="page-title-section">
|
<div className="page-title-section">
|
||||||
<span className="page-title-text">{t.notificationsPage.title}</span>
|
<span className="page-title-text">{t.notificationsPage.title}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</TabsScroller>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="page-content page-content--narrow">
|
<div className="page-content page-content--narrow">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useTranslation } from '../contexts/LanguageContext';
|
import { useTranslation } from '../contexts/LanguageContext';
|
||||||
import { useSidebar } from '../contexts/SidebarContext';
|
import { useSidebar } from '../contexts/SidebarContext';
|
||||||
|
import TabsScroller from '../components/TabsScroller';
|
||||||
import '../styles/Search.css';
|
import '../styles/Search.css';
|
||||||
|
|
||||||
type SearchResult = {
|
type SearchResult = {
|
||||||
@@ -77,14 +78,14 @@ export default function Search() {
|
|||||||
return (
|
return (
|
||||||
<main className="main-content search-root">
|
<main className="main-content search-root">
|
||||||
<div className="page-tabs-container">
|
<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}>
|
<button className="mobile-menu-btn" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
|
||||||
<span className="material-symbols-outlined">menu</span>
|
<span className="material-symbols-outlined">menu</span>
|
||||||
</button>
|
</button>
|
||||||
<div className="page-title-section">
|
<div className="page-title-section">
|
||||||
<span className="page-title-text">{t.searchPage.title}</span>
|
<span className="page-title-text">{t.searchPage.title}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</TabsScroller>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="page-content">
|
<div className="page-content">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useTranslation } from '../contexts/LanguageContext';
|
import { useTranslation } from '../contexts/LanguageContext';
|
||||||
import { useSidebar } from '../contexts/SidebarContext';
|
import { useSidebar } from '../contexts/SidebarContext';
|
||||||
|
import TabsScroller from '../components/TabsScroller';
|
||||||
import { sessionsAPI, twoFactorAPI } from '../api/client';
|
import { sessionsAPI, twoFactorAPI } from '../api/client';
|
||||||
import type { UserSession } from '../api/client';
|
import type { UserSession } from '../api/client';
|
||||||
import '../styles/SettingsPage.css';
|
import '../styles/SettingsPage.css';
|
||||||
@@ -152,14 +153,14 @@ export default function Settings() {
|
|||||||
return (
|
return (
|
||||||
<main className="main-content settings-page-root">
|
<main className="main-content settings-page-root">
|
||||||
<div className="page-tabs-container">
|
<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}>
|
<button className="mobile-menu-btn" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
|
||||||
<span className="material-symbols-outlined">menu</span>
|
<span className="material-symbols-outlined">menu</span>
|
||||||
</button>
|
</button>
|
||||||
<div className="page-title-section">
|
<div className="page-title-section">
|
||||||
<span className="page-title-text">{t.settings.title}</span>
|
<span className="page-title-text">{t.settings.title}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</TabsScroller>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="page-content page-content--narrow settings-tab-content">
|
<div className="page-content page-content--narrow settings-tab-content">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import type { FormEvent } from 'react';
|
import type { FormEvent } from 'react';
|
||||||
import Sidebar from '../components/Sidebar';
|
import Sidebar from '../components/Sidebar';
|
||||||
|
import TabsScroller from '../components/TabsScroller';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
import { useTranslation } from '../contexts/LanguageContext';
|
import { useTranslation } from '../contexts/LanguageContext';
|
||||||
import { useSidebar } from '../contexts/SidebarContext';
|
import { useSidebar } from '../contexts/SidebarContext';
|
||||||
@@ -181,7 +182,7 @@ export default function Users() {
|
|||||||
<Sidebar />
|
<Sidebar />
|
||||||
<main className="main-content users-root">
|
<main className="main-content users-root">
|
||||||
<div className="page-tabs-container">
|
<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}>
|
<button className="mobile-menu-btn" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
|
||||||
<span className="material-symbols-outlined">menu</span>
|
<span className="material-symbols-outlined">menu</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -192,7 +193,7 @@ export default function Users() {
|
|||||||
<span className="material-symbols-outlined">add</span>
|
<span className="material-symbols-outlined">add</span>
|
||||||
<span>{t.usersPage.addUser}</span>
|
<span>{t.usersPage.addUser}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</TabsScroller>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="page-content users-page">
|
<div className="page-content users-page">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { useTranslation } from '../../contexts/LanguageContext';
|
import { useTranslation } from '../../contexts/LanguageContext';
|
||||||
import { useSidebar } from '../../contexts/SidebarContext';
|
import { useSidebar } from '../../contexts/SidebarContext';
|
||||||
|
import TabsScroller from '../../components/TabsScroller';
|
||||||
import { analyticsAPI } from '../../api/client';
|
import { analyticsAPI } from '../../api/client';
|
||||||
import type { AnalyticsOverview } from '../../api/client';
|
import type { AnalyticsOverview } from '../../api/client';
|
||||||
import '../../styles/AdminAnalytics.css';
|
import '../../styles/AdminAnalytics.css';
|
||||||
@@ -46,14 +47,14 @@ export default function Analytics() {
|
|||||||
return (
|
return (
|
||||||
<main className="main-content admin-analytics-root">
|
<main className="main-content admin-analytics-root">
|
||||||
<div className="page-tabs-container">
|
<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}>
|
<button className="mobile-menu-btn" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
|
||||||
<span className="material-symbols-outlined">menu</span>
|
<span className="material-symbols-outlined">menu</span>
|
||||||
</button>
|
</button>
|
||||||
<div className="page-title-section">
|
<div className="page-title-section">
|
||||||
<span className="page-title-text">{t.analyticsPage.title}</span>
|
<span className="page-title-text">{t.analyticsPage.title}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</TabsScroller>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="page-content">
|
<div className="page-content">
|
||||||
@@ -151,4 +152,3 @@ export default function Analytics() {
|
|||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { useTranslation } from '../../contexts/LanguageContext';
|
import { useTranslation } from '../../contexts/LanguageContext';
|
||||||
import { useSidebar } from '../../contexts/SidebarContext';
|
import { useSidebar } from '../../contexts/SidebarContext';
|
||||||
|
import TabsScroller from '../../components/TabsScroller';
|
||||||
import { auditAPI } from '../../api/client';
|
import { auditAPI } from '../../api/client';
|
||||||
import type { AuditLogItem } from '../../api/client';
|
import type { AuditLogItem } from '../../api/client';
|
||||||
import '../../styles/AdminAudit.css';
|
import '../../styles/AdminAudit.css';
|
||||||
@@ -59,14 +60,14 @@ export default function AuditLogs() {
|
|||||||
return (
|
return (
|
||||||
<main className="main-content admin-audit-root">
|
<main className="main-content admin-audit-root">
|
||||||
<div className="page-tabs-container">
|
<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}>
|
<button className="mobile-menu-btn" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
|
||||||
<span className="material-symbols-outlined">menu</span>
|
<span className="material-symbols-outlined">menu</span>
|
||||||
</button>
|
</button>
|
||||||
<div className="page-title-section">
|
<div className="page-title-section">
|
||||||
<span className="page-title-text">{t.auditPage.title}</span>
|
<span className="page-title-text">{t.auditPage.title}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</TabsScroller>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="page-content">
|
<div className="page-content">
|
||||||
@@ -163,4 +164,3 @@ export default function AuditLogs() {
|
|||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useModules, TOGGLEABLE_MODULES } from '../../contexts/ModulesContext';
|
|||||||
import type { ModuleId } from '../../contexts/ModulesContext';
|
import type { ModuleId } from '../../contexts/ModulesContext';
|
||||||
import Feature1Tab from '../../components/admin/Feature1Tab';
|
import Feature1Tab from '../../components/admin/Feature1Tab';
|
||||||
import { SwipeTabs } from '../../components/SwipeTabs';
|
import { SwipeTabs } from '../../components/SwipeTabs';
|
||||||
|
import TabsScroller from '../../components/TabsScroller';
|
||||||
import '../../styles/AdminPanel.css';
|
import '../../styles/AdminPanel.css';
|
||||||
|
|
||||||
type TabId = 'config' | 'dashboard' | 'feature1' | 'feature2' | 'feature3' | 'search' | 'notifications';
|
type TabId = 'config' | 'dashboard' | 'feature1' | 'feature2' | 'feature3' | 'search' | 'notifications';
|
||||||
@@ -743,7 +744,7 @@ export default function Features() {
|
|||||||
return (
|
return (
|
||||||
<main className="main-content admin-panel-root">
|
<main className="main-content admin-panel-root">
|
||||||
<div className="page-tabs-container">
|
<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}>
|
<button className="mobile-menu-btn" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
|
||||||
<span className="material-symbols-outlined">menu</span>
|
<span className="material-symbols-outlined">menu</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -774,7 +775,7 @@ export default function Features() {
|
|||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</TabsScroller>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SwipeTabs
|
<SwipeTabs
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
|
|||||||
import { useTranslation } from '../../contexts/LanguageContext';
|
import { useTranslation } from '../../contexts/LanguageContext';
|
||||||
import { useSidebar } from '../../contexts/SidebarContext';
|
import { useSidebar } from '../../contexts/SidebarContext';
|
||||||
import Sidebar from '../../components/Sidebar';
|
import Sidebar from '../../components/Sidebar';
|
||||||
|
import TabsScroller from '../../components/TabsScroller';
|
||||||
import { settingsAPI } from '../../api/client';
|
import { settingsAPI } from '../../api/client';
|
||||||
import '../../styles/Settings.css';
|
import '../../styles/Settings.css';
|
||||||
|
|
||||||
@@ -60,14 +61,14 @@ export default function Settings() {
|
|||||||
<Sidebar />
|
<Sidebar />
|
||||||
<main className="main-content settings-root">
|
<main className="main-content settings-root">
|
||||||
<div className="page-tabs-container">
|
<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}>
|
<button className="mobile-menu-btn" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
|
||||||
<span className="material-symbols-outlined">menu</span>
|
<span className="material-symbols-outlined">menu</span>
|
||||||
</button>
|
</button>
|
||||||
<div className="page-title-section">
|
<div className="page-title-section">
|
||||||
<span className="page-title-text">{t.settings.title}</span>
|
<span className="page-title-text">{t.settings.title}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</TabsScroller>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="page-content page-content--narrow">
|
<div className="page-content page-content--narrow">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useAuth } from '../../contexts/AuthContext';
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
import { useTranslation } from '../../contexts/LanguageContext';
|
import { useTranslation } from '../../contexts/LanguageContext';
|
||||||
import { useSidebar } from '../../contexts/SidebarContext';
|
import { useSidebar } from '../../contexts/SidebarContext';
|
||||||
|
import TabsScroller from '../../components/TabsScroller';
|
||||||
import '../../styles/AdminPanel.css';
|
import '../../styles/AdminPanel.css';
|
||||||
|
|
||||||
export default function Sources() {
|
export default function Sources() {
|
||||||
@@ -15,14 +16,14 @@ export default function Sources() {
|
|||||||
return (
|
return (
|
||||||
<main className="main-content admin-panel-root">
|
<main className="main-content admin-panel-root">
|
||||||
<div className="admin-tabs-container">
|
<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}>
|
<button className="mobile-menu-btn" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
|
||||||
<span className="material-symbols-outlined">menu</span>
|
<span className="material-symbols-outlined">menu</span>
|
||||||
</button>
|
</button>
|
||||||
<div className="admin-title-section">
|
<div className="admin-title-section">
|
||||||
<span className="admin-title-text">{t.sourcesPage.title}</span>
|
<span className="admin-title-text">{t.sourcesPage.title}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</TabsScroller>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="admin-tab-content">
|
<div className="admin-tab-content">
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useSidebar } from '../../contexts/SidebarContext';
|
|||||||
import type { SidebarMode } from '../../contexts/SidebarContext';
|
import type { SidebarMode } from '../../contexts/SidebarContext';
|
||||||
import { ChromePicker, HuePicker } from 'react-color';
|
import { ChromePicker, HuePicker } from 'react-color';
|
||||||
import { SwipeTabs } from '../../components/SwipeTabs';
|
import { SwipeTabs } from '../../components/SwipeTabs';
|
||||||
|
import TabsScroller from '../../components/TabsScroller';
|
||||||
import '../../styles/ThemeSettings.css';
|
import '../../styles/ThemeSettings.css';
|
||||||
|
|
||||||
type ThemeTab = 'colors' | 'appearance' | 'preview' | 'advanced';
|
type ThemeTab = 'colors' | 'appearance' | 'preview' | 'advanced';
|
||||||
@@ -660,7 +661,7 @@ export default function ThemeSettings() {
|
|||||||
)}
|
)}
|
||||||
{/* Modern Tab Navigation */}
|
{/* Modern Tab Navigation */}
|
||||||
<div className="page-tabs-container">
|
<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}>
|
<button className="mobile-menu-btn" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
|
||||||
<span className="material-symbols-outlined">menu</span>
|
<span className="material-symbols-outlined">menu</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -706,7 +707,7 @@ export default function ThemeSettings() {
|
|||||||
<span>{t.theme.advancedTab}</span>
|
<span>{t.theme.advancedTab}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</TabsScroller>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SwipeTabs
|
<SwipeTabs
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ body {
|
|||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
-ms-overflow-style: none;
|
-ms-overflow-style: none;
|
||||||
|
scroll-behavior: smooth;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-tabs-slider::-webkit-scrollbar,
|
.page-tabs-slider::-webkit-scrollbar,
|
||||||
@@ -69,6 +70,102 @@ body {
|
|||||||
display: none;
|
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)) {
|
@supports (color: color-mix(in srgb, black, white)) {
|
||||||
.page-tabs-slider,
|
.page-tabs-slider,
|
||||||
.admin-tabs-slider {
|
.admin-tabs-slider {
|
||||||
|
|||||||
Reference in New Issue
Block a user