Add tab bar position setting with edge swipe sidebar
- Add theme_tab_bar_position setting (top/bottom/responsive) - Tab bar is now fixed at top, stays visible during scroll - Bottom position uses fixed positioning with safe-area-inset - Add edge swipe gesture to open sidebar on mobile - Remove backdrop-filter for better scroll performance - Simplify TabsScroller by removing inline style manipulation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -210,6 +210,17 @@ register_setting(SettingDefinition(
|
|||||||
category="theme"
|
category="theme"
|
||||||
))
|
))
|
||||||
|
|
||||||
|
register_setting(SettingDefinition(
|
||||||
|
key="theme_tab_bar_position",
|
||||||
|
type=SettingType.STRING,
|
||||||
|
scope=SettingScope.GLOBAL,
|
||||||
|
storage=SettingStorage.DATABASE,
|
||||||
|
default="top",
|
||||||
|
description="Position of the tab bar (top, bottom, or responsive)",
|
||||||
|
category="theme",
|
||||||
|
choices=["top", "bottom", "responsive"]
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# MODULE/FEATURE SETTINGS (Global, Database)
|
# MODULE/FEATURE SETTINGS (Global, Database)
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ const TabsScroller = forwardRef<HTMLDivElement, TabsScrollerProps>(
|
|||||||
const rafRef = useRef(0);
|
const rafRef = useRef(0);
|
||||||
const [showLeft, setShowLeft] = useState(false);
|
const [showLeft, setShowLeft] = useState(false);
|
||||||
const [showRight, setShowRight] = useState(false);
|
const [showRight, setShowRight] = useState(false);
|
||||||
|
|
||||||
const dragRef = useRef({
|
const dragRef = useRef({
|
||||||
pointerId: null as number | null,
|
pointerId: null as number | null,
|
||||||
startX: 0,
|
startX: 0,
|
||||||
@@ -65,13 +66,23 @@ const TabsScroller = forwardRef<HTMLDivElement, TabsScrollerProps>(
|
|||||||
const updateOverflow = useCallback(() => {
|
const updateOverflow = useCallback(() => {
|
||||||
const node = sliderRef.current;
|
const node = sliderRef.current;
|
||||||
if (!node) return;
|
if (!node) return;
|
||||||
|
const hasTabButtons = Boolean(node.querySelector('.page-tab-btn, .admin-tab-btn'));
|
||||||
|
const hasTabsValue = hasTabButtons ? 'true' : 'false';
|
||||||
|
node.dataset.hasTabs = hasTabsValue;
|
||||||
|
const container = node.closest('.page-tabs-container, .admin-tabs-container');
|
||||||
|
if (container instanceof HTMLElement) {
|
||||||
|
container.dataset.hasTabs = hasTabsValue;
|
||||||
|
}
|
||||||
|
const mainContent = node.closest('.main-content');
|
||||||
|
if (mainContent instanceof HTMLElement) {
|
||||||
|
mainContent.dataset.hasTabs = hasTabsValue;
|
||||||
|
}
|
||||||
if (!showArrows) {
|
if (!showArrows) {
|
||||||
setShowLeft(false);
|
setShowLeft(false);
|
||||||
setShowRight(false);
|
setShowRight(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const hasTabs = Boolean(node.querySelector('.page-tab-btn, .admin-tab-btn'));
|
if (!hasTabButtons) {
|
||||||
if (!hasTabs) {
|
|
||||||
setShowLeft(false);
|
setShowLeft(false);
|
||||||
setShowRight(false);
|
setShowRight(false);
|
||||||
return;
|
return;
|
||||||
@@ -93,6 +104,7 @@ const TabsScroller = forwardRef<HTMLDivElement, TabsScrollerProps>(
|
|||||||
updateOverflow();
|
updateOverflow();
|
||||||
const node = sliderRef.current;
|
const node = sliderRef.current;
|
||||||
if (!node) return undefined;
|
if (!node) return undefined;
|
||||||
|
|
||||||
const handleScroll = () => scheduleOverflowUpdate();
|
const handleScroll = () => scheduleOverflowUpdate();
|
||||||
node.addEventListener('scroll', handleScroll, { passive: true });
|
node.addEventListener('scroll', handleScroll, { passive: true });
|
||||||
let resizeObserver: ResizeObserver | null = null;
|
let resizeObserver: ResizeObserver | null = null;
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
import { createContext, useContext, useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { useAuth } from './AuthContext';
|
import { useAuth } from './AuthContext';
|
||||||
import { settingsAPI } from '../api/client';
|
import { settingsAPI } from '../api/client';
|
||||||
|
|
||||||
|
const EDGE_SWIPE_THRESHOLD = 30; // pixels from left edge to start swipe
|
||||||
|
const SWIPE_MIN_DISTANCE = 50; // minimum swipe distance to trigger
|
||||||
|
|
||||||
export type SidebarMode = 'collapsed' | 'expanded' | 'toggle' | 'dynamic';
|
export type SidebarMode = 'collapsed' | 'expanded' | 'toggle' | 'dynamic';
|
||||||
|
|
||||||
interface SidebarContextType {
|
interface SidebarContextType {
|
||||||
@@ -85,6 +88,12 @@ export function SidebarProvider({ children }: { children: ReactNode }) {
|
|||||||
// Can only toggle if mode is 'toggle'
|
// Can only toggle if mode is 'toggle'
|
||||||
const canToggle = sidebarMode === 'toggle';
|
const canToggle = sidebarMode === 'toggle';
|
||||||
|
|
||||||
|
// Sync collapsed state to document root for CSS selectors
|
||||||
|
useEffect(() => {
|
||||||
|
const root = document.documentElement;
|
||||||
|
root.setAttribute('data-sidebar-collapsed', String(isCollapsed));
|
||||||
|
}, [isCollapsed]);
|
||||||
|
|
||||||
const toggleCollapse = () => {
|
const toggleCollapse = () => {
|
||||||
if (canToggle) {
|
if (canToggle) {
|
||||||
setUserCollapsed((prev) => {
|
setUserCollapsed((prev) => {
|
||||||
@@ -103,6 +112,67 @@ export function SidebarProvider({ children }: { children: ReactNode }) {
|
|||||||
setIsMobileOpen(false);
|
setIsMobileOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openMobileMenu = useCallback(() => {
|
||||||
|
setIsMobileOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Edge swipe detection for mobile
|
||||||
|
const swipeRef = useRef({
|
||||||
|
startX: 0,
|
||||||
|
startY: 0,
|
||||||
|
isEdgeSwipe: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const isMobile = () => window.innerWidth <= 768;
|
||||||
|
|
||||||
|
const handleTouchStart = (e: TouchEvent) => {
|
||||||
|
if (!isMobile()) return;
|
||||||
|
const touch = e.touches[0];
|
||||||
|
if (!touch) return;
|
||||||
|
|
||||||
|
// Check if touch started near left edge
|
||||||
|
if (touch.clientX <= EDGE_SWIPE_THRESHOLD) {
|
||||||
|
swipeRef.current = {
|
||||||
|
startX: touch.clientX,
|
||||||
|
startY: touch.clientY,
|
||||||
|
isEdgeSwipe: true,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
swipeRef.current.isEdgeSwipe = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchMove = (e: TouchEvent) => {
|
||||||
|
if (!swipeRef.current.isEdgeSwipe) return;
|
||||||
|
const touch = e.touches[0];
|
||||||
|
if (!touch) return;
|
||||||
|
|
||||||
|
const dx = touch.clientX - swipeRef.current.startX;
|
||||||
|
const dy = Math.abs(touch.clientY - swipeRef.current.startY);
|
||||||
|
|
||||||
|
// If horizontal movement is greater than vertical and exceeds threshold, open sidebar
|
||||||
|
if (dx > SWIPE_MIN_DISTANCE && dx > dy * 2) {
|
||||||
|
openMobileMenu();
|
||||||
|
swipeRef.current.isEdgeSwipe = false; // Prevent multiple triggers
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchEnd = () => {
|
||||||
|
swipeRef.current.isEdgeSwipe = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('touchstart', handleTouchStart, { passive: true });
|
||||||
|
document.addEventListener('touchmove', handleTouchMove, { passive: true });
|
||||||
|
document.addEventListener('touchend', handleTouchEnd, { passive: true });
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('touchstart', handleTouchStart);
|
||||||
|
document.removeEventListener('touchmove', handleTouchMove);
|
||||||
|
document.removeEventListener('touchend', handleTouchEnd);
|
||||||
|
};
|
||||||
|
}, [openMobileMenu]);
|
||||||
|
|
||||||
const setSidebarMode = useCallback(async (mode: SidebarMode) => {
|
const setSidebarMode = useCallback(async (mode: SidebarMode) => {
|
||||||
try {
|
try {
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export type FontFamily = 'sans' | 'inter' | 'roboto';
|
|||||||
export type ColorPalette = 'default' | 'monochrome' | 'monochromeBlue' | 'sepia' | 'nord' | 'dracula' | 'solarized' | 'github' | 'ocean' | 'forest' | 'midnight' | 'sunset';
|
export type ColorPalette = 'default' | 'monochrome' | 'monochromeBlue' | 'sepia' | 'nord' | 'dracula' | 'solarized' | 'github' | 'ocean' | 'forest' | 'midnight' | 'sunset';
|
||||||
|
|
||||||
export type ControlLocation = 'sidebar' | 'user_menu';
|
export type ControlLocation = 'sidebar' | 'user_menu';
|
||||||
|
export type TabBarPosition = 'top' | 'bottom' | 'responsive';
|
||||||
|
|
||||||
interface ThemeContextType {
|
interface ThemeContextType {
|
||||||
theme: Theme;
|
theme: Theme;
|
||||||
@@ -28,6 +29,7 @@ interface ThemeContextType {
|
|||||||
showLanguageToggle: boolean;
|
showLanguageToggle: boolean;
|
||||||
showDarkModeLogin: boolean;
|
showDarkModeLogin: boolean;
|
||||||
showLanguageLogin: boolean;
|
showLanguageLogin: boolean;
|
||||||
|
tabBarPosition: TabBarPosition;
|
||||||
hasInitializedSettings: boolean;
|
hasInitializedSettings: boolean;
|
||||||
isLoadingSettings: boolean;
|
isLoadingSettings: boolean;
|
||||||
toggleTheme: () => void;
|
toggleTheme: () => void;
|
||||||
@@ -44,6 +46,7 @@ interface ThemeContextType {
|
|||||||
setShowLanguageToggle: (show: boolean) => void;
|
setShowLanguageToggle: (show: boolean) => void;
|
||||||
setShowDarkModeLogin: (show: boolean) => void;
|
setShowDarkModeLogin: (show: boolean) => void;
|
||||||
setShowLanguageLogin: (show: boolean) => void;
|
setShowLanguageLogin: (show: boolean) => void;
|
||||||
|
setTabBarPosition: (position: TabBarPosition) => void;
|
||||||
loadThemeFromBackend: () => Promise<void>;
|
loadThemeFromBackend: () => Promise<void>;
|
||||||
saveThemeToBackend: (overrides?: Partial<{
|
saveThemeToBackend: (overrides?: Partial<{
|
||||||
accentColor: AccentColor;
|
accentColor: AccentColor;
|
||||||
@@ -59,6 +62,7 @@ interface ThemeContextType {
|
|||||||
showLanguageToggle: boolean;
|
showLanguageToggle: boolean;
|
||||||
showDarkModeLogin: boolean;
|
showDarkModeLogin: boolean;
|
||||||
showLanguageLogin: boolean;
|
showLanguageLogin: boolean;
|
||||||
|
tabBarPosition: TabBarPosition;
|
||||||
}>) => Promise<void>;
|
}>) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -480,6 +484,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
|
|||||||
const [showLanguageToggle, setShowLanguageToggleState] = useState<boolean>(false);
|
const [showLanguageToggle, setShowLanguageToggleState] = useState<boolean>(false);
|
||||||
const [showDarkModeLogin, setShowDarkModeLoginState] = useState<boolean>(true);
|
const [showDarkModeLogin, setShowDarkModeLoginState] = useState<boolean>(true);
|
||||||
const [showLanguageLogin, setShowLanguageLoginState] = useState<boolean>(false);
|
const [showLanguageLogin, setShowLanguageLoginState] = useState<boolean>(false);
|
||||||
|
const [tabBarPosition, setTabBarPositionState] = useState<TabBarPosition>('top');
|
||||||
const [hasInitializedSettings, setHasInitializedSettings] = useState<boolean>(false);
|
const [hasInitializedSettings, setHasInitializedSettings] = useState<boolean>(false);
|
||||||
const [isLoadingSettings, setIsLoadingSettings] = useState<boolean>(false);
|
const [isLoadingSettings, setIsLoadingSettings] = useState<boolean>(false);
|
||||||
|
|
||||||
@@ -546,6 +551,11 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
|
|||||||
root.style.setProperty('--font-sans', FONTS[fontFamily]);
|
root.style.setProperty('--font-sans', FONTS[fontFamily]);
|
||||||
}, [fontFamily]);
|
}, [fontFamily]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const root = document.documentElement;
|
||||||
|
root.setAttribute('data-tab-position', tabBarPosition);
|
||||||
|
}, [tabBarPosition]);
|
||||||
|
|
||||||
// Apply color palette with overrides
|
// Apply color palette with overrides
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
@@ -653,6 +663,9 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
|
|||||||
console.error('Failed to parse custom colors:', e);
|
console.error('Failed to parse custom colors:', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (themeData.theme_tab_bar_position) {
|
||||||
|
setTabBarPositionState(themeData.theme_tab_bar_position as TabBarPosition);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load theme from backend:', error);
|
console.error('Failed to load theme from backend:', error);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -676,6 +689,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
|
|||||||
showLanguageToggle: boolean;
|
showLanguageToggle: boolean;
|
||||||
showDarkModeLogin: boolean;
|
showDarkModeLogin: boolean;
|
||||||
showLanguageLogin: boolean;
|
showLanguageLogin: boolean;
|
||||||
|
tabBarPosition: TabBarPosition;
|
||||||
}>) => {
|
}>) => {
|
||||||
try {
|
try {
|
||||||
const payload: Record<string, unknown> = {};
|
const payload: Record<string, unknown> = {};
|
||||||
@@ -694,6 +708,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
|
|||||||
if (overrides.showLanguageToggle !== undefined) payload.theme_show_language_toggle = overrides.showLanguageToggle;
|
if (overrides.showLanguageToggle !== undefined) payload.theme_show_language_toggle = overrides.showLanguageToggle;
|
||||||
if (overrides.showDarkModeLogin !== undefined) payload.theme_show_dark_mode_login = overrides.showDarkModeLogin;
|
if (overrides.showDarkModeLogin !== undefined) payload.theme_show_dark_mode_login = overrides.showDarkModeLogin;
|
||||||
if (overrides.showLanguageLogin !== undefined) payload.theme_show_language_login = overrides.showLanguageLogin;
|
if (overrides.showLanguageLogin !== undefined) payload.theme_show_language_login = overrides.showLanguageLogin;
|
||||||
|
if (overrides.tabBarPosition !== undefined) payload.theme_tab_bar_position = overrides.tabBarPosition;
|
||||||
} else {
|
} else {
|
||||||
payload.theme_accent_color = accentColor;
|
payload.theme_accent_color = accentColor;
|
||||||
payload.theme_border_radius = borderRadius;
|
payload.theme_border_radius = borderRadius;
|
||||||
@@ -708,6 +723,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
|
|||||||
payload.theme_show_language_toggle = showLanguageToggle;
|
payload.theme_show_language_toggle = showLanguageToggle;
|
||||||
payload.theme_show_dark_mode_login = showDarkModeLogin;
|
payload.theme_show_dark_mode_login = showDarkModeLogin;
|
||||||
payload.theme_show_language_login = showLanguageLogin;
|
payload.theme_show_language_login = showLanguageLogin;
|
||||||
|
payload.theme_tab_bar_position = tabBarPosition;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Object.keys(payload).length === 0) return;
|
if (Object.keys(payload).length === 0) return;
|
||||||
@@ -717,7 +733,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
|
|||||||
console.error('Failed to save theme to backend:', error);
|
console.error('Failed to save theme to backend:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}, [accentColor, borderRadius, sidebarStyle, density, fontFamily, colorPalette, customColors, darkModeLocation, languageLocation, showDarkModeToggle, showLanguageToggle, showDarkModeLogin, showLanguageLogin]);
|
}, [accentColor, borderRadius, sidebarStyle, density, fontFamily, colorPalette, customColors, darkModeLocation, languageLocation, showDarkModeToggle, showLanguageToggle, showDarkModeLogin, showLanguageLogin, tabBarPosition]);
|
||||||
|
|
||||||
// Load theme settings for both authenticated and unauthenticated users
|
// Load theme settings for both authenticated and unauthenticated users
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -776,6 +792,10 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
|
|||||||
setShowLanguageLoginState(show);
|
setShowLanguageLoginState(show);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const setTabBarPosition = (position: TabBarPosition) => {
|
||||||
|
setTabBarPositionState(position);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeContext.Provider
|
<ThemeContext.Provider
|
||||||
value={{
|
value={{
|
||||||
@@ -793,6 +813,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
|
|||||||
showLanguageToggle,
|
showLanguageToggle,
|
||||||
showDarkModeLogin,
|
showDarkModeLogin,
|
||||||
showLanguageLogin,
|
showLanguageLogin,
|
||||||
|
tabBarPosition,
|
||||||
hasInitializedSettings,
|
hasInitializedSettings,
|
||||||
isLoadingSettings,
|
isLoadingSettings,
|
||||||
toggleTheme,
|
toggleTheme,
|
||||||
@@ -808,6 +829,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
|
|||||||
setShowLanguageToggle,
|
setShowLanguageToggle,
|
||||||
setShowDarkModeLogin,
|
setShowDarkModeLogin,
|
||||||
setShowLanguageLogin,
|
setShowLanguageLogin,
|
||||||
|
setTabBarPosition,
|
||||||
loadThemeFromBackend,
|
loadThemeFromBackend,
|
||||||
saveThemeToBackend,
|
saveThemeToBackend,
|
||||||
setCustomColors: setCustomColorsState,
|
setCustomColors: setCustomColorsState,
|
||||||
|
|||||||
@@ -271,6 +271,15 @@
|
|||||||
"roboto": "Roboto",
|
"roboto": "Roboto",
|
||||||
"robotoDesc": "Google's classic"
|
"robotoDesc": "Google's classic"
|
||||||
},
|
},
|
||||||
|
"tabBarPosition": "Tab Position",
|
||||||
|
"tabBarPositions": {
|
||||||
|
"top": "Top",
|
||||||
|
"topDesc": "Tab bar always on top",
|
||||||
|
"bottom": "Bottom",
|
||||||
|
"bottomDesc": "Tab bar always on bottom",
|
||||||
|
"responsive": "Responsive",
|
||||||
|
"responsiveDesc": "Top on desktop, bottom on mobile"
|
||||||
|
},
|
||||||
"palettes": {
|
"palettes": {
|
||||||
"default": "Default",
|
"default": "Default",
|
||||||
"defaultDesc": "Standard modern palette",
|
"defaultDesc": "Standard modern palette",
|
||||||
|
|||||||
@@ -271,6 +271,15 @@
|
|||||||
"roboto": "Roboto",
|
"roboto": "Roboto",
|
||||||
"robotoDesc": "Il classico di Google"
|
"robotoDesc": "Il classico di Google"
|
||||||
},
|
},
|
||||||
|
"tabBarPosition": "Posizione Tab",
|
||||||
|
"tabBarPositions": {
|
||||||
|
"top": "Sopra",
|
||||||
|
"topDesc": "Barra dei tab sempre in alto",
|
||||||
|
"bottom": "Sotto",
|
||||||
|
"bottomDesc": "Barra dei tab sempre in basso",
|
||||||
|
"responsive": "Responsivo",
|
||||||
|
"responsiveDesc": "In alto su desktop, in basso su mobile"
|
||||||
|
},
|
||||||
"palettes": {
|
"palettes": {
|
||||||
"default": "Predefinito",
|
"default": "Predefinito",
|
||||||
"defaultDesc": "Palette moderna standard",
|
"defaultDesc": "Palette moderna standard",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useTheme, COLOR_PALETTES } from '../../contexts/ThemeContext';
|
import { useTheme, COLOR_PALETTES } from '../../contexts/ThemeContext';
|
||||||
import type { AccentColor, BorderRadius, SidebarStyle, Density, FontFamily, ColorPalette } from '../../contexts/ThemeContext';
|
import type { AccentColor, BorderRadius, SidebarStyle, Density, FontFamily, ColorPalette, TabBarPosition } from '../../contexts/ThemeContext';
|
||||||
import { useTranslation } from '../../contexts/LanguageContext';
|
import { useTranslation } from '../../contexts/LanguageContext';
|
||||||
import { useAuth } from '../../contexts/AuthContext';
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
import { useSidebar } from '../../contexts/SidebarContext';
|
import { useSidebar } from '../../contexts/SidebarContext';
|
||||||
@@ -39,6 +39,8 @@ export default function ThemeSettings() {
|
|||||||
setColorPalette,
|
setColorPalette,
|
||||||
customColors,
|
customColors,
|
||||||
setCustomColors,
|
setCustomColors,
|
||||||
|
tabBarPosition,
|
||||||
|
setTabBarPosition,
|
||||||
saveThemeToBackend
|
saveThemeToBackend
|
||||||
} = useTheme();
|
} = useTheme();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -96,6 +98,11 @@ export default function ThemeSettings() {
|
|||||||
saveThemeToBackend({ colorPalette: palette }).catch(console.error);
|
saveThemeToBackend({ colorPalette: palette }).catch(console.error);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleTabBarPositionChange = async (position: TabBarPosition) => {
|
||||||
|
setTabBarPosition(position);
|
||||||
|
saveThemeToBackend({ tabBarPosition: position }).catch(console.error);
|
||||||
|
};
|
||||||
|
|
||||||
// Color picker popup state
|
// Color picker popup state
|
||||||
const [colorPickerState, setColorPickerState] = useState<ColorPickerState>(null);
|
const [colorPickerState, setColorPickerState] = useState<ColorPickerState>(null);
|
||||||
const hasUserModifiedCustomColors = useRef(false);
|
const hasUserModifiedCustomColors = useRef(false);
|
||||||
@@ -161,6 +168,12 @@ export default function ThemeSettings() {
|
|||||||
{ id: 'roboto', label: t.theme.fontOptions.roboto, description: t.theme.fontOptions.robotoDesc, fontStyle: 'Roboto' },
|
{ id: 'roboto', label: t.theme.fontOptions.roboto, description: t.theme.fontOptions.robotoDesc, fontStyle: 'Roboto' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const tabBarPositions: { id: TabBarPosition; label: string; description: string }[] = [
|
||||||
|
{ id: 'top', label: t.theme.tabBarPositions?.top || 'Sopra', description: t.theme.tabBarPositions?.topDesc || 'Barra dei tab sempre in alto' },
|
||||||
|
{ id: 'bottom', label: t.theme.tabBarPositions?.bottom || 'Sotto', description: t.theme.tabBarPositions?.bottomDesc || 'Barra dei tab sempre in basso' },
|
||||||
|
{ id: 'responsive', label: t.theme.tabBarPositions?.responsive || 'Responsivo', description: t.theme.tabBarPositions?.responsiveDesc || 'In alto su desktop, in basso su mobile' },
|
||||||
|
];
|
||||||
|
|
||||||
const palettes: { id: ColorPalette; label: string; description: string }[] = [
|
const palettes: { id: ColorPalette; label: string; description: string }[] = [
|
||||||
{ id: 'monochrome', label: t.theme.palettes.monochrome, description: t.theme.palettes.monochromeDesc },
|
{ id: 'monochrome', label: t.theme.palettes.monochrome, description: t.theme.palettes.monochromeDesc },
|
||||||
{ id: 'default', label: t.theme.palettes.default, description: t.theme.palettes.defaultDesc },
|
{ id: 'default', label: t.theme.palettes.default, description: t.theme.palettes.defaultDesc },
|
||||||
@@ -471,6 +484,40 @@ export default function ThemeSettings() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="theme-section">
|
||||||
|
<div className="section-header">
|
||||||
|
<h3 className="section-title">{t.theme.tabBarPosition || 'Posizione Tab'}</h3>
|
||||||
|
</div>
|
||||||
|
<div className="option-cards">
|
||||||
|
{tabBarPositions.map((pos) => (
|
||||||
|
<div
|
||||||
|
key={pos.id}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
className={`option-card ${tabBarPosition === pos.id ? 'active' : ''}`}
|
||||||
|
onClick={() => handleTabBarPositionChange(pos.id)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
handleTabBarPositionChange(pos.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="option-preview">
|
||||||
|
<div className={`tabbar-preview tabbar-preview-${pos.id}`}>
|
||||||
|
<div className="tabbar-line"></div>
|
||||||
|
<div className="tabbar-content"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="option-info">
|
||||||
|
<span className="option-name">{pos.label}</span>
|
||||||
|
<span className="option-description">{pos.description}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -29,13 +29,31 @@
|
|||||||
|
|
||||||
/* ========== PAGE STRUCTURE ========== */
|
/* ========== PAGE STRUCTURE ========== */
|
||||||
|
|
||||||
/* Page header container - centered with symmetric padding */
|
/* Page header container - fixed at top */
|
||||||
.page-tabs-container,
|
.page-tabs-container,
|
||||||
.admin-tabs-container {
|
.admin-tabs-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
background: transparent;
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: var(--sidebar-width);
|
||||||
|
right: 0;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Handle collapsed sidebar for fixed header (desktop only) */
|
||||||
|
@media (min-width: 769px) {
|
||||||
|
[data-sidebar-collapsed='true'] .page-tabs-container,
|
||||||
|
[data-sidebar-collapsed='true'] .admin-tabs-container {
|
||||||
|
left: var(--sidebar-width-collapsed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Add top padding to content to account for fixed header */
|
||||||
|
.page-content,
|
||||||
|
.admin-tab-swipe {
|
||||||
|
padding-top: calc(var(--page-padding-y) + 60px);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Ensure no extra margin from body */
|
/* Ensure no extra margin from body */
|
||||||
@@ -76,7 +94,6 @@ label,
|
|||||||
gap: 4px;
|
gap: 4px;
|
||||||
box-shadow: var(--shadow-md);
|
box-shadow: var(--shadow-md);
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
backdrop-filter: blur(14px) saturate(1.15);
|
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
overflow-y: hidden;
|
overflow-y: hidden;
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
@@ -423,8 +440,9 @@ label,
|
|||||||
padding: var(--page-padding-y-tablet) var(--page-padding-x-tablet);
|
padding: var(--page-padding-y-tablet) var(--page-padding-x-tablet);
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-content {
|
.page-content,
|
||||||
padding: var(--page-padding-y-tablet) var(--page-padding-x-tablet);
|
.admin-tab-swipe {
|
||||||
|
padding-top: calc(var(--page-padding-y-tablet) + 60px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-tab-content {
|
.admin-tab-content {
|
||||||
@@ -472,6 +490,12 @@ label,
|
|||||||
.page-tabs-container,
|
.page-tabs-container,
|
||||||
.admin-tabs-container {
|
.admin-tabs-container {
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-content,
|
||||||
|
.admin-tab-swipe {
|
||||||
|
padding-top: calc(var(--page-padding-y-mobile) + 60px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-tabs-slider,
|
.page-tabs-slider,
|
||||||
@@ -517,29 +541,29 @@ label,
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Hide title section when tabs are present on mobile */
|
/* Hide title section when tabs are present on mobile */
|
||||||
.page-tabs-slider:has(.page-tab-btn) .page-title-section,
|
.page-tabs-slider[data-has-tabs='true'] .page-title-section,
|
||||||
.page-tabs-slider:has(.admin-tab-btn) .page-title-section,
|
.page-tabs-slider[data-has-tabs='true'] .admin-title-section,
|
||||||
.admin-tabs-slider:has(.admin-tab-btn) .admin-title-section,
|
.admin-tabs-slider[data-has-tabs='true'] .page-title-section,
|
||||||
.admin-tabs-slider:has(.page-tab-btn) .admin-title-section {
|
.admin-tabs-slider[data-has-tabs='true'] .admin-title-section {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Add padding-left when tabs are present to avoid logo overlap */
|
/* Add padding-left when tabs are present to avoid logo overlap */
|
||||||
.page-tabs-slider:has(.page-tab-btn),
|
.page-tabs-slider[data-has-tabs='true'],
|
||||||
.page-tabs-slider:has(.admin-tab-btn),
|
.admin-tabs-slider[data-has-tabs='true'] {
|
||||||
.admin-tabs-slider:has(.admin-tab-btn),
|
|
||||||
.admin-tabs-slider:has(.page-tab-btn) {
|
|
||||||
padding-left: 72px;
|
padding-left: 72px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Center title section absolutely when no tabs are present on mobile */
|
/* Center title section absolutely when no tabs are present on mobile */
|
||||||
.page-tabs-slider:not(:has(.page-tab-btn)):not(:has(.admin-tab-btn)),
|
.page-tabs-slider[data-has-tabs='false'],
|
||||||
.admin-tabs-slider:not(:has(.admin-tab-btn)):not(:has(.page-tab-btn)) {
|
.admin-tabs-slider[data-has-tabs='false'] {
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-tabs-slider:not(:has(.page-tab-btn)):not(:has(.admin-tab-btn)) .page-title-section,
|
.page-tabs-slider[data-has-tabs='false'] .page-title-section,
|
||||||
.admin-tabs-slider:not(:has(.admin-tab-btn)):not(:has(.page-tab-btn)) .admin-title-section {
|
.page-tabs-slider[data-has-tabs='false'] .admin-title-section,
|
||||||
|
.admin-tabs-slider[data-has-tabs='false'] .page-title-section,
|
||||||
|
.admin-tabs-slider[data-has-tabs='false'] .admin-title-section {
|
||||||
display: flex;
|
display: flex;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
@@ -550,8 +574,10 @@ label,
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Lighter icon color in dark theme when only title is shown */
|
/* Lighter icon color in dark theme when only title is shown */
|
||||||
.page-tabs-slider:not(:has(.page-tab-btn)):not(:has(.admin-tab-btn)) .page-title-section .material-symbols-outlined,
|
.page-tabs-slider[data-has-tabs='false'] .page-title-section .material-symbols-outlined,
|
||||||
.admin-tabs-slider:not(:has(.admin-tab-btn)):not(:has(.page-tab-btn)) .admin-title-section .material-symbols-outlined {
|
.page-tabs-slider[data-has-tabs='false'] .admin-title-section .material-symbols-outlined,
|
||||||
|
.admin-tabs-slider[data-has-tabs='false'] .page-title-section .material-symbols-outlined,
|
||||||
|
.admin-tabs-slider[data-has-tabs='false'] .admin-title-section .material-symbols-outlined {
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -767,8 +793,7 @@ label,
|
|||||||
@supports (color: color-mix(in srgb, black, white)) {
|
@supports (color: color-mix(in srgb, black, white)) {
|
||||||
[data-theme='dark'] .page-tabs-slider,
|
[data-theme='dark'] .page-tabs-slider,
|
||||||
[data-theme='dark'] .admin-tabs-slider {
|
[data-theme='dark'] .admin-tabs-slider {
|
||||||
background: color-mix(in srgb, var(--color-bg-sidebar) 88%, transparent);
|
background: color-mix(in srgb, var(--color-bg-sidebar) 95%, transparent);
|
||||||
backdrop-filter: blur(18px) saturate(1.15);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -815,3 +840,122 @@ label,
|
|||||||
[data-theme='dark'][data-accent='auto'] .admin-tab-btn.active:focus-visible {
|
[data-theme='dark'][data-accent='auto'] .admin-tab-btn.active:focus-visible {
|
||||||
box-shadow: 0 0 25px 4px rgba(229, 231, 235, 0.5);
|
box-shadow: 0 0 25px 4px rgba(229, 231, 235, 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ========== TAB BAR POSITION ========== */
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Tab bar positioning system:
|
||||||
|
* - 'top': default, no changes needed
|
||||||
|
* - 'bottom': fixed at bottom on all screens (only when tabs are present)
|
||||||
|
* - 'responsive': top on desktop (>768px), bottom on mobile (<=768px)
|
||||||
|
*
|
||||||
|
* Uses data-has-tabs set by TabsScroller to avoid :has() support issues.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Bottom position - fixed at bottom (global setting for all pages) */
|
||||||
|
/* Note: Primary positioning is handled by TabsScroller component with inline styles.
|
||||||
|
These CSS rules serve as fallback/additional styling. */
|
||||||
|
[data-tab-position='bottom'] .page-tabs-container,
|
||||||
|
[data-tab-position='bottom'] .admin-tabs-container {
|
||||||
|
position: fixed;
|
||||||
|
top: auto;
|
||||||
|
bottom: 0;
|
||||||
|
left: var(--sidebar-width);
|
||||||
|
right: 0;
|
||||||
|
z-index: 100;
|
||||||
|
padding: 0.5rem;
|
||||||
|
padding-bottom: calc(0.5rem + env(safe-area-inset-bottom, 0px));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Handle collapsed sidebar */
|
||||||
|
[data-tab-position='bottom'][data-sidebar-collapsed='true'] .page-tabs-container,
|
||||||
|
[data-tab-position='bottom'][data-sidebar-collapsed='true'] .admin-tabs-container {
|
||||||
|
left: var(--sidebar-width-collapsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Adjust slider styling for bottom position */
|
||||||
|
[data-tab-position='bottom'] .page-tabs-slider,
|
||||||
|
[data-tab-position='bottom'] .admin-tabs-slider {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide title section when bar is at bottom (only on pages with tabs) */
|
||||||
|
[data-tab-position='bottom'] .page-tabs-slider[data-has-tabs='true'] .page-title-section,
|
||||||
|
[data-tab-position='bottom'] .page-tabs-slider[data-has-tabs='true'] .admin-title-section,
|
||||||
|
[data-tab-position='bottom'] .admin-tabs-slider[data-has-tabs='true'] .page-title-section,
|
||||||
|
[data-tab-position='bottom'] .admin-tabs-slider[data-has-tabs='true'] .admin-title-section {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide divider when bar is at bottom */
|
||||||
|
[data-tab-position='bottom'] .page-tabs-slider .page-tabs-divider,
|
||||||
|
[data-tab-position='bottom'] .page-tabs-slider .admin-tabs-divider,
|
||||||
|
[data-tab-position='bottom'] .admin-tabs-slider .page-tabs-divider,
|
||||||
|
[data-tab-position='bottom'] .admin-tabs-slider .admin-tabs-divider {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Add bottom padding to main content when bar is at bottom */
|
||||||
|
[data-tab-position='bottom'] .main-content {
|
||||||
|
padding-bottom: calc(72px + env(safe-area-inset-bottom, 0px));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile: full width at bottom */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
[data-tab-position='bottom'] .page-tabs-container,
|
||||||
|
[data-tab-position='bottom'] .admin-tabs-container {
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Override collapsed state on mobile */
|
||||||
|
[data-tab-position='bottom'][data-sidebar-collapsed='true'] .page-tabs-container,
|
||||||
|
[data-tab-position='bottom'][data-sidebar-collapsed='true'] .admin-tabs-container {
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Keep proper spacing for mobile menu button - inherits padding-left: 72px from mobile rules */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive position - top on desktop, bottom on mobile */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
[data-tab-position='responsive'] .page-tabs-container,
|
||||||
|
[data-tab-position='responsive'] .admin-tabs-container {
|
||||||
|
position: fixed;
|
||||||
|
top: auto;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 100;
|
||||||
|
padding: 0.5rem;
|
||||||
|
padding-bottom: calc(0.5rem + env(safe-area-inset-bottom, 0px));
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-tab-position='responsive'] .page-tabs-slider,
|
||||||
|
[data-tab-position='responsive'] .admin-tabs-slider {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-start;
|
||||||
|
/* padding-left: 72px inherited from mobile rules for menu button spacing */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide title section when bar is at bottom (only on pages with tabs) */
|
||||||
|
[data-tab-position='responsive'] .page-tabs-slider[data-has-tabs='true'] .page-title-section,
|
||||||
|
[data-tab-position='responsive'] .page-tabs-slider[data-has-tabs='true'] .admin-title-section,
|
||||||
|
[data-tab-position='responsive'] .admin-tabs-slider[data-has-tabs='true'] .page-title-section,
|
||||||
|
[data-tab-position='responsive'] .admin-tabs-slider[data-has-tabs='true'] .admin-title-section {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide divider when bar is at bottom */
|
||||||
|
[data-tab-position='responsive'] .page-tabs-slider .page-tabs-divider,
|
||||||
|
[data-tab-position='responsive'] .page-tabs-slider .admin-tabs-divider,
|
||||||
|
[data-tab-position='responsive'] .admin-tabs-slider .page-tabs-divider,
|
||||||
|
[data-tab-position='responsive'] .admin-tabs-slider .admin-tabs-divider {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Add bottom padding to main content */
|
||||||
|
[data-tab-position='responsive'] .main-content {
|
||||||
|
padding-bottom: calc(72px + env(safe-area-inset-bottom, 0px));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -455,6 +455,79 @@
|
|||||||
color: var(--color-accent);
|
color: var(--color-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Tab Bar Position Preview */
|
||||||
|
.tabbar-preview {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
position: relative;
|
||||||
|
background: var(--color-bg-elevated);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabbar-preview .tabbar-line {
|
||||||
|
height: 6px;
|
||||||
|
background: var(--color-accent);
|
||||||
|
border-radius: 2px;
|
||||||
|
margin: 3px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabbar-preview .tabbar-content {
|
||||||
|
flex: 1;
|
||||||
|
background: var(--color-bg-card);
|
||||||
|
margin: 0 3px;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Top position */
|
||||||
|
.tabbar-preview-top {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabbar-preview-top .tabbar-line {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabbar-preview-top .tabbar-content {
|
||||||
|
margin-bottom: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bottom position */
|
||||||
|
.tabbar-preview-bottom {
|
||||||
|
flex-direction: column-reverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabbar-preview-bottom .tabbar-line {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabbar-preview-bottom .tabbar-content {
|
||||||
|
margin-top: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive position - shows split layout */
|
||||||
|
.tabbar-preview-responsive {
|
||||||
|
flex-direction: row;
|
||||||
|
padding: 3px;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabbar-preview-responsive .tabbar-line {
|
||||||
|
width: 6px;
|
||||||
|
height: auto;
|
||||||
|
margin: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabbar-preview-responsive .tabbar-content {
|
||||||
|
flex: 1;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* ========== PREVIEW TAB ========== */
|
/* ========== PREVIEW TAB ========== */
|
||||||
|
|
||||||
/* Preview Container - Grid layout for preview cards */
|
/* Preview Container - Grid layout for preview cards */
|
||||||
@@ -1502,3 +1575,8 @@
|
|||||||
border-color: #e5e7eb !important;
|
border-color: #e5e7eb !important;
|
||||||
box-shadow: 0 0 0 3px rgba(229, 231, 235, 0.15) !important;
|
box-shadow: 0 0 0 3px rgba(229, 231, 235, 0.15) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Tab bar preview accent */
|
||||||
|
[data-theme='dark'][data-accent='auto'] .tabbar-preview .tabbar-line {
|
||||||
|
background: #e5e7eb;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user