Improve mobile swipe tabs with native scroll-snap
- Rewrite SwipeTabs to use CSS scroll-snap for smoother transitions - Add GPU acceleration hints for fluid animations - Use native scrolling behavior instead of manual touch handling - Add swipe-tabs--snap and swipe-tabs--static variants - Improve height transitions and layout containment - Update dimension variables for better spacing 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
326
frontend/src/components/SwipeTabs.tsx
Normal file
326
frontend/src/components/SwipeTabs.tsx
Normal file
@@ -0,0 +1,326 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { CSSProperties, ReactNode } from 'react';
|
||||
|
||||
type SwipeTabsProps<T extends string> = {
|
||||
tabs: readonly T[];
|
||||
activeTab: T;
|
||||
onTabChange: (tab: T) => void;
|
||||
renderPanel: (tab: T) => ReactNode;
|
||||
className?: string;
|
||||
panelClassName?: string;
|
||||
renderWindow?: number;
|
||||
threshold?: number;
|
||||
directionRatio?: number;
|
||||
respectScrollableTargets?: boolean;
|
||||
};
|
||||
|
||||
const SCROLL_END_DELAY = 120;
|
||||
const COARSE_POINTER_QUERY = '(pointer: coarse)';
|
||||
const NO_HOVER_QUERY = '(hover: none)';
|
||||
const FALLBACK_WIDTH_QUERY = '(max-width: 1024px)';
|
||||
|
||||
const getSwipeEnabled = () => {
|
||||
if (typeof window === 'undefined' || !window.matchMedia) return false;
|
||||
const coarsePointer = window.matchMedia(COARSE_POINTER_QUERY).matches;
|
||||
const noHover = window.matchMedia(NO_HOVER_QUERY).matches;
|
||||
if (coarsePointer || noHover) return true;
|
||||
const supportsTouch = ('ontouchstart' in window)
|
||||
|| (typeof navigator !== 'undefined' && navigator.maxTouchPoints > 0);
|
||||
if (!supportsTouch) return false;
|
||||
return window.matchMedia(FALLBACK_WIDTH_QUERY).matches;
|
||||
};
|
||||
|
||||
export function SwipeTabs<T extends string>({
|
||||
tabs,
|
||||
activeTab,
|
||||
onTabChange,
|
||||
renderPanel,
|
||||
className,
|
||||
panelClassName,
|
||||
renderWindow = 0,
|
||||
}: SwipeTabsProps<T>) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const panelRefs = useRef(new Map<T, HTMLDivElement>());
|
||||
const activeIndex = tabs.indexOf(activeTab);
|
||||
const safeActiveIndex = activeIndex >= 0 ? activeIndex : 0;
|
||||
const maxIndex = Math.max(0, tabs.length - 1);
|
||||
|
||||
const [containerWidth, setContainerWidth] = useState(0);
|
||||
const [containerHeight, setContainerHeight] = useState<number | null>(null);
|
||||
const [isScrolling, setIsScrolling] = useState(false);
|
||||
const [scrollPosition, setScrollPosition] = useState(0);
|
||||
const [swipeEnabled, setSwipeEnabled] = useState(() => getSwipeEnabled());
|
||||
|
||||
const scrollEndTimer = useRef<number | null>(null);
|
||||
const scrollRaf = useRef<number | null>(null);
|
||||
const scrollPositionRef = useRef(0);
|
||||
const isUserScrolling = useRef(false);
|
||||
const hasMounted = useRef(false);
|
||||
const lastReportedIndex = useRef(safeActiveIndex);
|
||||
|
||||
const prefersReducedMotion = useMemo(() => {
|
||||
if (typeof window === 'undefined' || !window.matchMedia) return true;
|
||||
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined' || !window.matchMedia) return;
|
||||
const pointerMql = window.matchMedia(COARSE_POINTER_QUERY);
|
||||
const hoverMql = window.matchMedia(NO_HOVER_QUERY);
|
||||
const widthMql = window.matchMedia(FALLBACK_WIDTH_QUERY);
|
||||
const update = () => setSwipeEnabled(getSwipeEnabled());
|
||||
update();
|
||||
|
||||
const addListener = (mql: MediaQueryList) => {
|
||||
if (mql.addEventListener) {
|
||||
mql.addEventListener('change', update);
|
||||
return;
|
||||
}
|
||||
mql.addListener(update);
|
||||
};
|
||||
|
||||
const removeListener = (mql: MediaQueryList) => {
|
||||
if (mql.removeEventListener) {
|
||||
mql.removeEventListener('change', update);
|
||||
return;
|
||||
}
|
||||
mql.removeListener(update);
|
||||
};
|
||||
|
||||
addListener(pointerMql);
|
||||
addListener(hoverMql);
|
||||
addListener(widthMql);
|
||||
|
||||
return () => {
|
||||
removeListener(pointerMql);
|
||||
removeListener(hoverMql);
|
||||
removeListener(widthMql);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
const element = containerRef.current;
|
||||
const updateSize = () => {
|
||||
const styles = window.getComputedStyle(element);
|
||||
const paddingX = parseFloat(styles.paddingLeft) + parseFloat(styles.paddingRight);
|
||||
const width = element.clientWidth - (Number.isFinite(paddingX) ? paddingX : 0);
|
||||
setContainerWidth(width > 0 ? width : element.clientWidth);
|
||||
};
|
||||
updateSize();
|
||||
|
||||
if (typeof ResizeObserver === 'undefined') {
|
||||
window.addEventListener('resize', updateSize);
|
||||
return () => window.removeEventListener('resize', updateSize);
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver(updateSize);
|
||||
observer.observe(element);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const panel = panelRefs.current.get(activeTab);
|
||||
if (!panel) {
|
||||
setContainerHeight(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const updateHeight = () => setContainerHeight(panel.offsetHeight);
|
||||
updateHeight();
|
||||
|
||||
if (typeof ResizeObserver === 'undefined') {
|
||||
window.addEventListener('resize', updateHeight);
|
||||
return () => window.removeEventListener('resize', updateHeight);
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver(updateHeight);
|
||||
observer.observe(panel);
|
||||
return () => observer.disconnect();
|
||||
}, [activeTab]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (scrollEndTimer.current !== null) {
|
||||
window.clearTimeout(scrollEndTimer.current);
|
||||
}
|
||||
if (scrollRaf.current !== null) {
|
||||
cancelAnimationFrame(scrollRaf.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
lastReportedIndex.current = safeActiveIndex;
|
||||
}, [safeActiveIndex]);
|
||||
|
||||
const scrollToIndex = useCallback((index: number, behavior: ScrollBehavior) => {
|
||||
const container = containerRef.current;
|
||||
if (!container || containerWidth <= 0) return;
|
||||
const targetLeft = index * containerWidth;
|
||||
if (Math.abs(container.scrollLeft - targetLeft) <= 1) return;
|
||||
container.scrollTo({ left: targetLeft, behavior });
|
||||
}, [containerWidth]);
|
||||
|
||||
const scheduleScrollPositionUpdate = useCallback(() => {
|
||||
if (scrollRaf.current !== null) return;
|
||||
scrollRaf.current = window.requestAnimationFrame(() => {
|
||||
scrollRaf.current = null;
|
||||
setScrollPosition(scrollPositionRef.current);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
if (!swipeEnabled) return;
|
||||
const container = containerRef.current;
|
||||
if (!container || containerWidth <= 0) return;
|
||||
|
||||
if (!isUserScrolling.current) {
|
||||
isUserScrolling.current = true;
|
||||
setIsScrolling(true);
|
||||
}
|
||||
|
||||
const rawIndex = container.scrollLeft / containerWidth;
|
||||
scrollPositionRef.current = rawIndex;
|
||||
scheduleScrollPositionUpdate();
|
||||
|
||||
const roundedIndex = Math.round(rawIndex);
|
||||
const clampedIndex = Math.max(0, Math.min(roundedIndex, maxIndex));
|
||||
if (tabs[clampedIndex] && clampedIndex !== lastReportedIndex.current) {
|
||||
lastReportedIndex.current = clampedIndex;
|
||||
onTabChange(tabs[clampedIndex]);
|
||||
}
|
||||
|
||||
if (scrollEndTimer.current !== null) {
|
||||
window.clearTimeout(scrollEndTimer.current);
|
||||
}
|
||||
|
||||
scrollEndTimer.current = window.setTimeout(() => {
|
||||
const rawIndex = container.scrollLeft / containerWidth;
|
||||
const nextIndex = Math.round(rawIndex);
|
||||
const clampedIndex = Math.max(0, Math.min(nextIndex, maxIndex));
|
||||
const targetLeft = clampedIndex * containerWidth;
|
||||
|
||||
if (Math.abs(container.scrollLeft - targetLeft) > 1) {
|
||||
container.scrollTo({ left: targetLeft, behavior: 'auto' });
|
||||
}
|
||||
|
||||
if (tabs[clampedIndex] && clampedIndex !== lastReportedIndex.current) {
|
||||
lastReportedIndex.current = clampedIndex;
|
||||
onTabChange(tabs[clampedIndex]);
|
||||
}
|
||||
|
||||
scrollPositionRef.current = targetLeft / containerWidth;
|
||||
scheduleScrollPositionUpdate();
|
||||
|
||||
isUserScrolling.current = false;
|
||||
setIsScrolling(false);
|
||||
}, SCROLL_END_DELAY);
|
||||
}, [activeTab, containerWidth, maxIndex, onTabChange, scheduleScrollPositionUpdate, swipeEnabled, tabs]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!swipeEnabled) return;
|
||||
const container = containerRef.current;
|
||||
if (!container || containerWidth <= 0) return;
|
||||
if (isUserScrolling.current) return;
|
||||
const behavior: ScrollBehavior = hasMounted.current && !prefersReducedMotion ? 'smooth' : 'auto';
|
||||
scrollToIndex(safeActiveIndex, behavior);
|
||||
hasMounted.current = true;
|
||||
const currentPosition = container.scrollLeft / containerWidth;
|
||||
scrollPositionRef.current = currentPosition;
|
||||
setScrollPosition(currentPosition);
|
||||
}, [containerWidth, prefersReducedMotion, safeActiveIndex, scrollToIndex, swipeEnabled]);
|
||||
|
||||
const visibleIndexes = useMemo(() => {
|
||||
const indexes = new Set<number>();
|
||||
if (!swipeEnabled) {
|
||||
if (safeActiveIndex >= 0) {
|
||||
indexes.add(safeActiveIndex);
|
||||
}
|
||||
return indexes;
|
||||
}
|
||||
const centers = new Set<number>();
|
||||
centers.add(safeActiveIndex);
|
||||
if (containerWidth <= 0) {
|
||||
centers.add(0);
|
||||
}
|
||||
if (containerWidth > 0 && Number.isFinite(scrollPosition)) {
|
||||
const base = Math.floor(scrollPosition);
|
||||
const next = Math.ceil(scrollPosition);
|
||||
centers.add(base);
|
||||
centers.add(next);
|
||||
}
|
||||
centers.forEach((center) => {
|
||||
for (let offset = -renderWindow; offset <= renderWindow; offset += 1) {
|
||||
const index = center + offset;
|
||||
if (index >= 0 && index < tabs.length) {
|
||||
indexes.add(index);
|
||||
}
|
||||
}
|
||||
});
|
||||
return indexes;
|
||||
}, [containerWidth, renderWindow, safeActiveIndex, scrollPosition, swipeEnabled, tabs.length]);
|
||||
|
||||
const containerStyle: CSSProperties = {
|
||||
height: !isScrolling && containerHeight ? `${containerHeight}px` : 'auto',
|
||||
transition: !isScrolling && containerHeight ? 'height 160ms ease-out' : 'none',
|
||||
};
|
||||
|
||||
if (!swipeEnabled) {
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`swipe-tabs swipe-tabs--static${className ? ` ${className}` : ''}`}
|
||||
style={containerStyle}
|
||||
>
|
||||
<div className="swipe-tabs-track">
|
||||
<div className="swipe-tabs-panel" aria-hidden={false}>
|
||||
<div
|
||||
className={`swipe-tabs-panel-inner${panelClassName ? ` ${panelClassName}` : ''}`}
|
||||
ref={(node) => {
|
||||
if (node) {
|
||||
panelRefs.current.set(activeTab, node);
|
||||
} else {
|
||||
panelRefs.current.delete(activeTab);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{renderPanel(activeTab)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`swipe-tabs swipe-tabs--snap${className ? ` ${className}` : ''}${isScrolling ? ' is-scrolling' : ''}`}
|
||||
style={containerStyle}
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
<div className="swipe-tabs-track">
|
||||
{tabs.map((tab, index) => {
|
||||
const shouldRender = visibleIndexes.has(index);
|
||||
return (
|
||||
<div key={tab} className="swipe-tabs-panel" aria-hidden={tab !== activeTab}>
|
||||
<div
|
||||
className={`swipe-tabs-panel-inner${panelClassName ? ` ${panelClassName}` : ''}`}
|
||||
ref={(node) => {
|
||||
if (node) {
|
||||
panelRefs.current.set(tab, node);
|
||||
} else {
|
||||
panelRefs.current.delete(tab);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{shouldRender ? renderPanel(tab) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { useTranslation } from '../contexts/LanguageContext';
|
||||
import { useSidebar } from '../contexts/SidebarContext';
|
||||
import GeneralTab from '../components/admin/GeneralTab';
|
||||
import UsersTab from '../components/admin/UsersTab';
|
||||
import { SwipeTabs } from '../components/SwipeTabs';
|
||||
import '../styles/AdminPanel.css';
|
||||
|
||||
type TabId = 'general' | 'users';
|
||||
@@ -13,6 +14,8 @@ export default function AdminPanel({ initialTab = 'general' }: { initialTab?: Ta
|
||||
const { t } = useTranslation();
|
||||
const { toggleMobileMenu } = useSidebar();
|
||||
const [activeTab, setActiveTab] = useState<TabId>(initialTab);
|
||||
const tabs: TabId[] = ['general', 'users'];
|
||||
const renderPanel = (tab: TabId) => (tab === 'general' ? <GeneralTab /> : <UsersTab />);
|
||||
|
||||
if (!currentUser?.is_superuser) {
|
||||
return null;
|
||||
@@ -46,10 +49,14 @@ export default function AdminPanel({ initialTab = 'general' }: { initialTab?: Ta
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-tab-content">
|
||||
{activeTab === 'general' && <GeneralTab />}
|
||||
{activeTab === 'users' && <UsersTab />}
|
||||
</div>
|
||||
<SwipeTabs
|
||||
className="admin-tab-swipe"
|
||||
tabs={tabs}
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
renderPanel={renderPanel}
|
||||
panelClassName="admin-tab-content swipe-panel-content"
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useSidebar } from '../../contexts/SidebarContext';
|
||||
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 '../../styles/AdminPanel.css';
|
||||
|
||||
type TabId = 'config' | 'dashboard' | 'feature1' | 'feature2' | 'feature3' | 'search' | 'notifications';
|
||||
@@ -281,6 +282,8 @@ export default function Features() {
|
||||
return position === 'bottom';
|
||||
});
|
||||
|
||||
const tabIds = ['config', ...topOrderModules, ...bottomOrderModules] as TabId[];
|
||||
|
||||
const renderConfigTab = () => {
|
||||
return (
|
||||
<div className="theme-tab-content">
|
||||
@@ -380,11 +383,11 @@ export default function Features() {
|
||||
);
|
||||
};
|
||||
|
||||
const renderTabContent = () => {
|
||||
const renderTabContent = (tabId: TabId) => {
|
||||
if (!hasInitialized || isLoading) {
|
||||
return <div className="loading">Loading...</div>;
|
||||
}
|
||||
switch (activeTab) {
|
||||
switch (tabId) {
|
||||
case 'config':
|
||||
return renderConfigTab();
|
||||
case 'dashboard':
|
||||
@@ -501,9 +504,14 @@ export default function Features() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-tab-content">
|
||||
{renderTabContent()}
|
||||
</div>
|
||||
<SwipeTabs
|
||||
className="admin-tab-swipe"
|
||||
tabs={tabIds}
|
||||
activeTab={activeTab}
|
||||
onTabChange={handleTabChange}
|
||||
renderPanel={renderTabContent}
|
||||
panelClassName="admin-tab-content swipe-panel-content"
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useAuth } from '../../contexts/AuthContext';
|
||||
import { useSidebar } from '../../contexts/SidebarContext';
|
||||
import type { SidebarMode } from '../../contexts/SidebarContext';
|
||||
import { ChromePicker, HuePicker } from 'react-color';
|
||||
import { SwipeTabs } from '../../components/SwipeTabs';
|
||||
import '../../styles/ThemeSettings.css';
|
||||
|
||||
type ThemeTab = 'colors' | 'appearance' | 'preview' | 'advanced';
|
||||
@@ -43,6 +44,7 @@ export default function ThemeSettings() {
|
||||
const { user } = useAuth();
|
||||
const { toggleMobileMenu, sidebarMode, setSidebarMode } = useSidebar();
|
||||
const isAdmin = user?.is_superuser || false;
|
||||
const tabs: ThemeTab[] = isAdmin ? ['colors', 'appearance', 'preview', 'advanced'] : ['colors', 'appearance', 'preview'];
|
||||
const [tooltip, setTooltip] = useState<{ text: string; left: number; visible: boolean }>({
|
||||
text: '',
|
||||
left: 0,
|
||||
@@ -262,70 +264,10 @@ export default function ThemeSettings() {
|
||||
setCustomColors({});
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="main-content theme-settings-root">
|
||||
{/* Tab Tooltip */}
|
||||
{tooltip.visible && (
|
||||
<div
|
||||
className="admin-tab-tooltip"
|
||||
style={{ left: tooltip.left }}
|
||||
>
|
||||
{tooltip.text}
|
||||
</div>
|
||||
)}
|
||||
{/* Modern Tab Navigation */}
|
||||
<div className="page-tabs-container">
|
||||
<div 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.theme.title}</span>
|
||||
</div>
|
||||
<div className="admin-tabs-divider"></div>
|
||||
<button
|
||||
className={`admin-tab-btn ${activeTab === 'colors' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('colors')}
|
||||
onMouseEnter={(e) => handleTabMouseEnter(t.theme.colorsTab, e)}
|
||||
onMouseLeave={handleTabMouseLeave}
|
||||
>
|
||||
<span className="material-symbols-outlined">color_lens</span>
|
||||
<span>{t.theme.colorsTab}</span>
|
||||
</button>
|
||||
<button
|
||||
className={`admin-tab-btn ${activeTab === 'appearance' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('appearance')}
|
||||
onMouseEnter={(e) => handleTabMouseEnter(t.theme.appearanceTab, e)}
|
||||
onMouseLeave={handleTabMouseLeave}
|
||||
>
|
||||
<span className="material-symbols-outlined">tune</span>
|
||||
<span>{t.theme.appearanceTab}</span>
|
||||
</button>
|
||||
<button
|
||||
className={`admin-tab-btn ${activeTab === 'preview' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('preview')}
|
||||
onMouseEnter={(e) => handleTabMouseEnter(t.theme.previewTab, e)}
|
||||
onMouseLeave={handleTabMouseLeave}
|
||||
>
|
||||
<span className="material-symbols-outlined">visibility</span>
|
||||
<span>{t.theme.previewTab}</span>
|
||||
</button>
|
||||
{isAdmin && (
|
||||
<button
|
||||
className={`admin-tab-btn ${activeTab === 'advanced' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('advanced')}
|
||||
onMouseEnter={(e) => handleTabMouseEnter(t.theme.advancedTab, e)}
|
||||
onMouseLeave={handleTabMouseLeave}
|
||||
>
|
||||
<span className="material-symbols-outlined">code</span>
|
||||
<span>{t.theme.advancedTab}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-tab-content">
|
||||
{activeTab === 'colors' && (
|
||||
const renderPanel = (tab: ThemeTab) => {
|
||||
switch (tab) {
|
||||
case 'colors':
|
||||
return (
|
||||
<div className="theme-tab-content">
|
||||
<div className="theme-section">
|
||||
<div className="section-header">
|
||||
@@ -394,12 +336,11 @@ export default function ThemeSettings() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'appearance' && (
|
||||
);
|
||||
case 'appearance':
|
||||
return (
|
||||
<div className="theme-tab-content">
|
||||
<div className="appearance-grid">
|
||||
{/* Border Radius Section */}
|
||||
<div className="theme-section">
|
||||
<div className="section-header">
|
||||
<h3 className="section-title">{t.theme.borderRadius}</h3>
|
||||
@@ -426,7 +367,6 @@ export default function ThemeSettings() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar Style Section */}
|
||||
<div className="theme-section">
|
||||
<div className="section-header">
|
||||
<h3 className="section-title">{t.theme.sidebarStyle}</h3>
|
||||
@@ -453,7 +393,6 @@ export default function ThemeSettings() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar Mode Section */}
|
||||
<div className="theme-section">
|
||||
<div className="section-header">
|
||||
<h3 className="section-title">{t.admin.sidebarMode}</h3>
|
||||
@@ -480,7 +419,6 @@ export default function ThemeSettings() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Density Section */}
|
||||
<div className="theme-section">
|
||||
<div className="section-header">
|
||||
<h3 className="section-title">{t.theme.density}</h3>
|
||||
@@ -508,7 +446,6 @@ export default function ThemeSettings() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Font Family Section */}
|
||||
<div className="theme-section">
|
||||
<div className="section-header">
|
||||
<h3 className="section-title">{t.theme.fontFamily}</h3>
|
||||
@@ -535,9 +472,9 @@ export default function ThemeSettings() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'preview' && (
|
||||
);
|
||||
case 'preview':
|
||||
return (
|
||||
<div className="theme-tab-content">
|
||||
<div className="theme-section">
|
||||
<div className="section-header">
|
||||
@@ -579,9 +516,10 @@ export default function ThemeSettings() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'advanced' && isAdmin && (
|
||||
);
|
||||
case 'advanced':
|
||||
if (!isAdmin) return null;
|
||||
return (
|
||||
<div className="theme-tab-content">
|
||||
<div className="theme-section">
|
||||
<div className="section-header">
|
||||
@@ -593,7 +531,6 @@ export default function ThemeSettings() {
|
||||
</div>
|
||||
|
||||
<div className="advanced-colors-grid" style={{ marginTop: '2rem' }}>
|
||||
{/* Light Theme Colors */}
|
||||
<div className="color-theme-section">
|
||||
<h3 className="color-theme-title">{t.theme.lightThemeColors}</h3>
|
||||
<div className="color-controls-list">
|
||||
@@ -648,7 +585,6 @@ export default function ThemeSettings() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dark Theme Colors */}
|
||||
<div className="color-theme-section">
|
||||
<h3 className="color-theme-title">{t.theme.darkThemeColors}</h3>
|
||||
<div className="color-controls-list">
|
||||
@@ -705,9 +641,83 @@ export default function ThemeSettings() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="main-content theme-settings-root">
|
||||
{/* Tab Tooltip */}
|
||||
{tooltip.visible && (
|
||||
<div
|
||||
className="admin-tab-tooltip"
|
||||
style={{ left: tooltip.left }}
|
||||
>
|
||||
{tooltip.text}
|
||||
</div>
|
||||
)}
|
||||
{/* Modern Tab Navigation */}
|
||||
<div className="page-tabs-container">
|
||||
<div 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.theme.title}</span>
|
||||
</div>
|
||||
<div className="admin-tabs-divider"></div>
|
||||
<button
|
||||
className={`admin-tab-btn ${activeTab === 'colors' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('colors')}
|
||||
onMouseEnter={(e) => handleTabMouseEnter(t.theme.colorsTab, e)}
|
||||
onMouseLeave={handleTabMouseLeave}
|
||||
>
|
||||
<span className="material-symbols-outlined">color_lens</span>
|
||||
<span>{t.theme.colorsTab}</span>
|
||||
</button>
|
||||
<button
|
||||
className={`admin-tab-btn ${activeTab === 'appearance' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('appearance')}
|
||||
onMouseEnter={(e) => handleTabMouseEnter(t.theme.appearanceTab, e)}
|
||||
onMouseLeave={handleTabMouseLeave}
|
||||
>
|
||||
<span className="material-symbols-outlined">tune</span>
|
||||
<span>{t.theme.appearanceTab}</span>
|
||||
</button>
|
||||
<button
|
||||
className={`admin-tab-btn ${activeTab === 'preview' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('preview')}
|
||||
onMouseEnter={(e) => handleTabMouseEnter(t.theme.previewTab, e)}
|
||||
onMouseLeave={handleTabMouseLeave}
|
||||
>
|
||||
<span className="material-symbols-outlined">visibility</span>
|
||||
<span>{t.theme.previewTab}</span>
|
||||
</button>
|
||||
{isAdmin && (
|
||||
<button
|
||||
className={`admin-tab-btn ${activeTab === 'advanced' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('advanced')}
|
||||
onMouseEnter={(e) => handleTabMouseEnter(t.theme.advancedTab, e)}
|
||||
onMouseLeave={handleTabMouseLeave}
|
||||
>
|
||||
<span className="material-symbols-outlined">code</span>
|
||||
<span>{t.theme.advancedTab}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SwipeTabs
|
||||
className="admin-tab-swipe"
|
||||
tabs={tabs}
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
renderPanel={renderPanel}
|
||||
panelClassName="admin-tab-content swipe-panel-content"
|
||||
/>
|
||||
|
||||
{/* Color Picker Popup */}
|
||||
{colorPickerState && (
|
||||
<div
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
.api-keys-root .page-content {
|
||||
max-width: var(--container-lg);
|
||||
max-width: var(--page-max-width);
|
||||
}
|
||||
|
||||
/* Section Layout - matches theme-section spacing */
|
||||
|
||||
@@ -80,10 +80,6 @@
|
||||
border-color: rgba(var(--color-accent-rgb), 0.18);
|
||||
}
|
||||
|
||||
.analytics-panel .section-title {
|
||||
margin: 0 0 var(--space-3) 0;
|
||||
}
|
||||
|
||||
.mini-chart {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -175,7 +175,7 @@ body {
|
||||
|
||||
/* Standard Section Title */
|
||||
.section-title {
|
||||
margin: 0 0 1rem 0;
|
||||
margin: 0 0 var(--section-title-gap) 0;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
@@ -185,7 +185,11 @@ body {
|
||||
|
||||
/* Standard Section Header */
|
||||
.section-header {
|
||||
margin-bottom: 1.5rem;
|
||||
margin-bottom: var(--section-title-gap);
|
||||
}
|
||||
|
||||
.section-header .section-title {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Standard Page Section */
|
||||
@@ -253,7 +257,7 @@ body {
|
||||
flex: 1;
|
||||
padding: var(--page-padding-y) var(--page-padding-x);
|
||||
width: 100%;
|
||||
max-width: 1600px;
|
||||
max-width: var(--page-max-width);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
@@ -267,7 +271,7 @@ body {
|
||||
/* Admin tab content (for tabbed interfaces) */
|
||||
.admin-tab-content {
|
||||
padding: var(--page-padding-y) var(--page-padding-x);
|
||||
max-width: 1600px;
|
||||
max-width: var(--page-max-width);
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
@@ -478,18 +482,96 @@ body {
|
||||
/* ========== TAB CONTENT ANIMATIONS ========== */
|
||||
|
||||
/* Prevent layout shift when switching tabs */
|
||||
.admin-tab-content {
|
||||
.admin-tab-content:not(.swipe-tabs):not(.swipe-panel-content) {
|
||||
display: grid;
|
||||
grid-template-rows: 1fr;
|
||||
transition: grid-template-rows 0.25s ease;
|
||||
}
|
||||
|
||||
.admin-tab-content>div {
|
||||
.admin-tab-content:not(.swipe-tabs):not(.swipe-panel-content)>div {
|
||||
opacity: 0;
|
||||
animation: tabFadeIn 0.25s ease forwards;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.admin-tab-swipe {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Swipeable tab panels */
|
||||
.swipe-tabs {
|
||||
position: relative;
|
||||
background: var(--color-bg-main);
|
||||
touch-action: pan-y;
|
||||
}
|
||||
|
||||
.swipe-tabs-track {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.swipe-tabs-panel {
|
||||
width: 100%;
|
||||
min-height: 1px;
|
||||
background: var(--color-bg-main);
|
||||
}
|
||||
|
||||
.swipe-tabs--static {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.swipe-tabs--static .swipe-tabs-panel {
|
||||
flex: 0 0 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.swipe-tabs--snap {
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
touch-action: pan-x pan-y;
|
||||
scroll-snap-type: x mandatory;
|
||||
overscroll-behavior-x: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.swipe-tabs--snap::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.swipe-tabs--snap .swipe-tabs-panel {
|
||||
flex: 0 0 100%;
|
||||
overflow: hidden;
|
||||
contain: paint;
|
||||
scroll-snap-align: start;
|
||||
scroll-snap-stop: always;
|
||||
}
|
||||
|
||||
.swipe-tabs-panel-inner {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.admin-tab-content.swipe-tabs {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.admin-tab-content.swipe-tabs>div {
|
||||
opacity: 1;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.swipe-panel-content {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.swipe-panel-content>div {
|
||||
opacity: 1;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
@keyframes tabFadeIn {
|
||||
0% {
|
||||
opacity: 0;
|
||||
|
||||
@@ -13,10 +13,6 @@
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.settings-page-root .page-content {
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
/* Settings Sections - no card, just spacing */
|
||||
.settings-section {
|
||||
margin-bottom: 3rem;
|
||||
|
||||
@@ -68,6 +68,11 @@
|
||||
transition: grid-template-rows 0.25s ease;
|
||||
}
|
||||
|
||||
/* Extra side spacing just for the theme editor content */
|
||||
.theme-settings-root .admin-tab-content .theme-tab-content {
|
||||
padding: 0 calc(var(--page-padding-x) * 0.5);
|
||||
}
|
||||
|
||||
/* Smooth transition between tabs */
|
||||
.theme-tab-content>div {
|
||||
opacity: 0;
|
||||
@@ -1155,6 +1160,10 @@
|
||||
padding: var(--page-padding-y-tablet) var(--page-padding-x-tablet);
|
||||
}
|
||||
|
||||
.theme-settings-root .admin-tab-content .theme-tab-content {
|
||||
padding: 0 calc(var(--page-padding-x-tablet) * 0.5);
|
||||
}
|
||||
|
||||
.color-grid-enhanced {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 1.25rem;
|
||||
@@ -1182,6 +1191,10 @@
|
||||
padding: var(--page-padding-y-mobile) var(--page-padding-x-mobile);
|
||||
}
|
||||
|
||||
.theme-settings-root .admin-tab-content .theme-tab-content {
|
||||
padding: 0 calc(var(--page-padding-x-mobile) * 0.5);
|
||||
}
|
||||
|
||||
.theme-section {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
@@ -51,15 +51,15 @@
|
||||
--sidebar-mobile-width: 280px;
|
||||
|
||||
/* Page Layout Spacing */
|
||||
--page-padding-x: 2rem;
|
||||
--page-padding-x: 2.5rem;
|
||||
/* Horizontal padding for page content */
|
||||
--page-padding-y: 2rem;
|
||||
--page-padding-y: 2.5rem;
|
||||
/* Vertical padding for page content */
|
||||
--page-padding-x-tablet: 1.5rem;
|
||||
--page-padding-y-tablet: 1.5rem;
|
||||
--page-padding-x-mobile: 1rem;
|
||||
--page-padding-y-mobile: 1rem;
|
||||
--page-max-width: 1400px;
|
||||
--page-padding-x-tablet: 2rem;
|
||||
--page-padding-y-tablet: 2rem;
|
||||
--page-padding-x-mobile: 1.25rem;
|
||||
--page-padding-y-mobile: 1.25rem;
|
||||
--page-max-width: 1200px;
|
||||
/* Maximum content width */
|
||||
|
||||
/* Container Widths */
|
||||
@@ -113,6 +113,8 @@
|
||||
/* increased from 1.25rem */
|
||||
--section-gap-sm: 1.125rem;
|
||||
/* increased from 0.875rem */
|
||||
--section-title-gap: var(--section-gap);
|
||||
/* Title-to-content spacing for section headers */
|
||||
|
||||
/* Semantic Spacing - Elements */
|
||||
--element-gap: 0.375rem;
|
||||
|
||||
Reference in New Issue
Block a user