refactor: migrate to eslint flat config and format codebase with prettier

- Remove legacy .eslintrc.cjs and .eslintignore
- Add eslint.config.js with flat config, security rules (no-eval, no-implied-eval, no-new-func, no-script-url)
- Add .prettierrc and .prettierignore
- Format entire codebase with prettier
This commit is contained in:
c0mrade
2026-01-27 17:37:31 +03:00
parent 111ccc4e7a
commit bc90ba3779
133 changed files with 19972 additions and 15523 deletions

View File

@@ -1,21 +1,21 @@
import { useEffect } from 'react'
import { useQuery } from '@tanstack/react-query'
import { brandingApi } from '../api/branding'
import { useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { brandingApi } from '../api/branding';
const YM_SCRIPT_ID = 'ym-counter-script'
const GTAG_LOADER_ID = 'gtag-loader-script'
const GTAG_INIT_ID = 'gtag-init-script'
const YM_SCRIPT_ID = 'ym-counter-script';
const GTAG_LOADER_ID = 'gtag-loader-script';
const GTAG_INIT_ID = 'gtag-init-script';
function removeElement(id: string) {
document.getElementById(id)?.remove()
document.getElementById(id)?.remove();
}
function injectYandexMetrika(counterId: string) {
if (document.getElementById(YM_SCRIPT_ID)) return
if (document.getElementById(YM_SCRIPT_ID)) return;
const script = document.createElement('script')
script.id = YM_SCRIPT_ID
script.type = 'text/javascript'
const script = document.createElement('script');
script.id = YM_SCRIPT_ID;
script.type = 'text/javascript';
script.textContent = `
(function(m,e,t,r,i,k,a){m[i]=m[i]||function(){(m[i].a=m[i].a||[]).push(arguments)};
m[i].l=1*new Date();
@@ -26,30 +26,30 @@ function injectYandexMetrika(counterId: string) {
trackLinks:true,
accurateTrackBounce:true
});
`
document.head.appendChild(script)
`;
document.head.appendChild(script);
}
function injectGoogleAds(conversionId: string) {
if (document.getElementById(GTAG_LOADER_ID)) return
if (document.getElementById(GTAG_LOADER_ID)) return;
// External gtag.js loader
const loader = document.createElement('script')
loader.id = GTAG_LOADER_ID
loader.async = true
loader.src = `https://www.googletagmanager.com/gtag/js?id=${conversionId}`
document.head.appendChild(loader)
const loader = document.createElement('script');
loader.id = GTAG_LOADER_ID;
loader.async = true;
loader.src = `https://www.googletagmanager.com/gtag/js?id=${conversionId}`;
document.head.appendChild(loader);
// Init script
const init = document.createElement('script')
init.id = GTAG_INIT_ID
const init = document.createElement('script');
init.id = GTAG_INIT_ID;
init.textContent = `
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', '${conversionId}');
`
document.head.appendChild(init)
`;
document.head.appendChild(init);
}
/**
@@ -61,24 +61,24 @@ export function useAnalyticsCounters() {
queryKey: ['analytics-counters'],
queryFn: brandingApi.getAnalyticsCounters,
staleTime: 5 * 60 * 1000, // 5 min
})
});
useEffect(() => {
if (!data) return
if (!data) return;
// Yandex Metrika
if (data.yandex_metrika_id) {
injectYandexMetrika(data.yandex_metrika_id)
injectYandexMetrika(data.yandex_metrika_id);
} else {
removeElement(YM_SCRIPT_ID)
removeElement(YM_SCRIPT_ID);
}
// Google Ads
if (data.google_ads_id) {
injectGoogleAds(data.google_ads_id)
injectGoogleAds(data.google_ads_id);
} else {
removeElement(GTAG_LOADER_ID)
removeElement(GTAG_INIT_ID)
removeElement(GTAG_LOADER_ID);
removeElement(GTAG_INIT_ID);
}
}, [data])
}, [data]);
}

View File

@@ -1,6 +1,6 @@
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { currencyApi, type ExchangeRates } from '../api/currency'
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { currencyApi, type ExchangeRates } from '../api/currency';
// Map language to currency
const LANGUAGE_CURRENCY_MAP: Record<string, keyof ExchangeRates | 'RUB'> = {
@@ -8,17 +8,17 @@ const LANGUAGE_CURRENCY_MAP: Record<string, keyof ExchangeRates | 'RUB'> = {
en: 'USD',
zh: 'CNY',
fa: 'IRR',
}
};
// Default rates for fallback
const DEFAULT_RATES: ExchangeRates = {
USD: 100,
CNY: 14,
IRR: 0.0024,
}
};
export function useCurrency() {
const { i18n, t } = useTranslation()
const { i18n, t } = useTranslation();
// Fetch exchange rates
const { data: exchangeRates = DEFAULT_RATES } = useQuery({
@@ -27,72 +27,68 @@ export function useCurrency() {
staleTime: 60 * 60 * 1000, // 1 hour
refetchOnWindowFocus: false,
retry: 2,
})
});
// Get current language and currency
const currentLanguage = i18n.language
const targetCurrency = LANGUAGE_CURRENCY_MAP[currentLanguage] || 'USD'
const currentLanguage = i18n.language;
const targetCurrency = LANGUAGE_CURRENCY_MAP[currentLanguage] || 'USD';
// Check if current language is Russian (no conversion needed)
const isRussian = currentLanguage === 'ru'
const isRussian = currentLanguage === 'ru';
// Get currency symbol from translations
const currencySymbol = t('common.currency')
const currencySymbol = t('common.currency');
// Format amount with currency conversion
const formatAmount = (rubAmount: number, decimals: number = 2): string => {
if (isRussian) {
return rubAmount.toFixed(decimals)
return rubAmount.toFixed(decimals);
}
// Convert to target currency
const convertedAmount = currencyApi.convertFromRub(
rubAmount,
targetCurrency as keyof ExchangeRates,
exchangeRates
)
exchangeRates,
);
// For IRR (Iranian Toman), use no decimals as amounts are large
if (targetCurrency === 'IRR') {
return Math.round(convertedAmount).toLocaleString('fa-IR')
return Math.round(convertedAmount).toLocaleString('fa-IR');
}
return convertedAmount.toFixed(decimals)
}
return convertedAmount.toFixed(decimals);
};
// Format amount with currency symbol
const formatWithCurrency = (rubAmount: number, decimals: number = 2): string => {
return `${formatAmount(rubAmount, decimals)} ${currencySymbol}`
}
return `${formatAmount(rubAmount, decimals)} ${currencySymbol}`;
};
// Format amount with + sign (for earnings/bonuses)
const formatPositive = (rubAmount: number, decimals: number = 2): string => {
return `+${formatAmount(rubAmount, decimals)} ${currencySymbol}`
}
return `+${formatAmount(rubAmount, decimals)} ${currencySymbol}`;
};
// Get raw converted amount (for calculations)
const convertAmount = (rubAmount: number): number => {
if (isRussian) {
return rubAmount
return rubAmount;
}
return currencyApi.convertFromRub(
rubAmount,
targetCurrency as keyof ExchangeRates,
exchangeRates
)
}
exchangeRates,
);
};
// Convert from user's currency back to rubles
const convertToRub = (amount: number): number => {
if (isRussian) {
return amount
return amount;
}
return currencyApi.convertToRub(
amount,
targetCurrency as keyof ExchangeRates,
exchangeRates
)
}
return currencyApi.convertToRub(amount, targetCurrency as keyof ExchangeRates, exchangeRates);
};
return {
exchangeRates,
@@ -104,5 +100,5 @@ export function useCurrency() {
formatPositive,
convertAmount,
convertToRub,
}
};
}

View File

@@ -1,49 +1,52 @@
import { useState, useEffect, useCallback } from 'react'
import { useState, useEffect, useCallback } from 'react';
const STORAGE_KEY = 'admin_favorite_settings'
const STORAGE_KEY = 'admin_favorite_settings';
export function useFavoriteSettings() {
const [favorites, setFavorites] = useState<string[]>(() => {
try {
const stored = localStorage.getItem(STORAGE_KEY)
return stored ? JSON.parse(stored) : []
const stored = localStorage.getItem(STORAGE_KEY);
return stored ? JSON.parse(stored) : [];
} catch {
return []
return [];
}
})
});
// Sync to localStorage
useEffect(() => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(favorites))
localStorage.setItem(STORAGE_KEY, JSON.stringify(favorites));
} catch {
// Ignore storage errors
}
}, [favorites])
}, [favorites]);
const toggleFavorite = useCallback((settingKey: string) => {
setFavorites(prev => {
setFavorites((prev) => {
if (prev.includes(settingKey)) {
return prev.filter(key => key !== settingKey)
return prev.filter((key) => key !== settingKey);
} else {
return [...prev, settingKey]
return [...prev, settingKey];
}
})
}, [])
});
}, []);
const isFavorite = useCallback((settingKey: string) => {
return favorites.includes(settingKey)
}, [favorites])
const isFavorite = useCallback(
(settingKey: string) => {
return favorites.includes(settingKey);
},
[favorites],
);
const clearFavorites = useCallback(() => {
setFavorites([])
}, [])
setFavorites([]);
}, []);
return {
favorites,
toggleFavorite,
isFavorite,
clearFavorites,
count: favorites.length
}
count: favorites.length,
};
}

View File

@@ -1,9 +1,9 @@
import { useEffect, useRef, useState, useCallback } from 'react'
import { useEffect, useRef, useState, useCallback } from 'react';
interface UsePullToRefreshOptions {
onRefresh?: () => void | Promise<void>
threshold?: number // How far to pull before triggering refresh (px)
disabled?: boolean
onRefresh?: () => void | Promise<void>;
threshold?: number; // How far to pull before triggering refresh (px)
disabled?: boolean;
}
export function usePullToRefresh({
@@ -11,150 +11,152 @@ export function usePullToRefresh({
threshold = 80,
disabled = false,
}: UsePullToRefreshOptions = {}) {
const [isPulling, setIsPulling] = useState(false)
const [pullDistance, setPullDistance] = useState(0)
const [isRefreshing, setIsRefreshing] = useState(false)
const [isPulling, setIsPulling] = useState(false);
const [pullDistance, setPullDistance] = useState(0);
const [isRefreshing, setIsRefreshing] = useState(false);
const startY = useRef(0)
const currentY = useRef(0)
const startY = useRef(0);
const currentY = useRef(0);
const handleRefresh = useCallback(async () => {
if (onRefresh) {
setIsRefreshing(true)
setIsRefreshing(true);
try {
await onRefresh()
await onRefresh();
} finally {
setIsRefreshing(false)
setIsRefreshing(false);
}
} else {
// Default: reload the page
window.location.reload()
window.location.reload();
}
}, [onRefresh])
}, [onRefresh]);
useEffect(() => {
if (disabled) return
if (disabled) return;
// Check if a modal/overlay is currently open
const isModalOpen = (): boolean => {
// Check if body scroll is locked (common pattern for modals)
if (document.body.style.overflow === 'hidden') return true
if (document.body.style.overflow === 'hidden') return true;
// Check for high z-index fixed elements (modals typically use z-index > 50)
const fixedElements = document.querySelectorAll('[style*="position: fixed"], [style*="position:fixed"]')
const fixedElements = document.querySelectorAll(
'[style*="position: fixed"], [style*="position:fixed"]',
);
for (const el of fixedElements) {
const style = window.getComputedStyle(el)
const zIndex = parseInt(style.zIndex, 10)
const style = window.getComputedStyle(el);
const zIndex = parseInt(style.zIndex, 10);
if (zIndex >= 50 && el.clientHeight > window.innerHeight * 0.5) {
return true
return true;
}
}
return false
}
return false;
};
// Check if element or any parent is scrollable and not at top
const isInsideScrollableContainer = (element: HTMLElement | null): boolean => {
while (element && element !== document.body) {
const style = window.getComputedStyle(element)
const overflowY = style.overflowY
const isScrollable = overflowY === 'auto' || overflowY === 'scroll'
const style = window.getComputedStyle(element);
const overflowY = style.overflowY;
const isScrollable = overflowY === 'auto' || overflowY === 'scroll';
if (isScrollable && element.scrollHeight > element.clientHeight) {
// Element is scrollable - check if it's at the top
if (element.scrollTop > 0) {
return true // Not at top, don't trigger pull-to-refresh
return true; // Not at top, don't trigger pull-to-refresh
}
}
element = element.parentElement
element = element.parentElement;
}
return false
}
return false;
};
const handleTouchStart = (e: TouchEvent) => {
// Don't trigger if a modal is open
if (isModalOpen()) return
if (isModalOpen()) return;
// Only trigger if at top of page
if (window.scrollY > 5) return
if (window.scrollY > 5) return;
// Don't trigger if touch started inside a scrollable container that's not at top
const target = e.target as HTMLElement
if (isInsideScrollableContainer(target)) return
const target = e.target as HTMLElement;
if (isInsideScrollableContainer(target)) return;
startY.current = e.touches[0].clientY
currentY.current = e.touches[0].clientY
}
startY.current = e.touches[0].clientY;
currentY.current = e.touches[0].clientY;
};
const handleTouchMove = (e: TouchEvent) => {
if (startY.current === 0) return
if (startY.current === 0) return;
// Cancel if modal opened during gesture
if (isModalOpen()) {
startY.current = 0
setPullDistance(0)
setIsPulling(false)
return
startY.current = 0;
setPullDistance(0);
setIsPulling(false);
return;
}
if (window.scrollY > 5) {
// User scrolled down, reset
startY.current = 0
setPullDistance(0)
setIsPulling(false)
return
startY.current = 0;
setPullDistance(0);
setIsPulling(false);
return;
}
// Also check during move - user might start at top then scroll inside container
const target = e.target as HTMLElement
const target = e.target as HTMLElement;
if (isInsideScrollableContainer(target)) {
startY.current = 0
setPullDistance(0)
setIsPulling(false)
return
startY.current = 0;
setPullDistance(0);
setIsPulling(false);
return;
}
currentY.current = e.touches[0].clientY
const diff = currentY.current - startY.current
currentY.current = e.touches[0].clientY;
const diff = currentY.current - startY.current;
// Only track downward pulls
if (diff > 0) {
// Apply resistance - pull distance is less than actual finger movement
const resistance = 0.4
const distance = Math.min(diff * resistance, threshold * 1.5)
setPullDistance(distance)
setIsPulling(true)
const resistance = 0.4;
const distance = Math.min(diff * resistance, threshold * 1.5);
setPullDistance(distance);
setIsPulling(true);
// Prevent default scroll if we're pulling
if (distance > 10) {
e.preventDefault()
e.preventDefault();
}
}
}
};
const handleTouchEnd = () => {
if (pullDistance >= threshold && !isRefreshing) {
handleRefresh()
handleRefresh();
}
startY.current = 0
setPullDistance(0)
setIsPulling(false)
}
startY.current = 0;
setPullDistance(0);
setIsPulling(false);
};
document.addEventListener('touchstart', handleTouchStart, { passive: true })
document.addEventListener('touchmove', handleTouchMove, { passive: false })
document.addEventListener('touchend', handleTouchEnd, { passive: true })
document.addEventListener('touchstart', handleTouchStart, { passive: true });
document.addEventListener('touchmove', handleTouchMove, { passive: false });
document.addEventListener('touchend', handleTouchEnd, { passive: true });
return () => {
document.removeEventListener('touchstart', handleTouchStart)
document.removeEventListener('touchmove', handleTouchMove)
document.removeEventListener('touchend', handleTouchEnd)
}
}, [disabled, threshold, pullDistance, isRefreshing, handleRefresh])
document.removeEventListener('touchstart', handleTouchStart);
document.removeEventListener('touchmove', handleTouchMove);
document.removeEventListener('touchend', handleTouchEnd);
};
}, [disabled, threshold, pullDistance, isRefreshing, handleRefresh]);
return {
isPulling,
pullDistance,
isRefreshing,
progress: Math.min(pullDistance / threshold, 1),
}
};
}

View File

@@ -1,24 +1,24 @@
import { useEffect, useState, useCallback } from 'react'
import { useEffect, useState, useCallback } from 'react';
const FULLSCREEN_CACHE_KEY = 'cabinet_fullscreen_enabled'
const FULLSCREEN_CACHE_KEY = 'cabinet_fullscreen_enabled';
// Get cached fullscreen setting
export const getCachedFullscreenEnabled = (): boolean => {
try {
return localStorage.getItem(FULLSCREEN_CACHE_KEY) === 'true'
return localStorage.getItem(FULLSCREEN_CACHE_KEY) === 'true';
} catch {
return false
return false;
}
}
};
// Set cached fullscreen setting
export const setCachedFullscreenEnabled = (enabled: boolean) => {
try {
localStorage.setItem(FULLSCREEN_CACHE_KEY, String(enabled))
localStorage.setItem(FULLSCREEN_CACHE_KEY, String(enabled));
} catch {
// localStorage not available
}
}
};
/**
* Hook for Telegram WebApp API integration
@@ -26,102 +26,112 @@ export const setCachedFullscreenEnabled = (enabled: boolean) => {
*/
export function useTelegramWebApp() {
// Initialize synchronously to avoid flash/flicker on first render
const webApp = typeof window !== 'undefined' ? window.Telegram?.WebApp : undefined
const webApp = typeof window !== 'undefined' ? window.Telegram?.WebApp : undefined;
const [isFullscreen, setIsFullscreen] = useState(() => webApp?.isFullscreen || false)
const [isTelegramWebApp, setIsTelegramWebApp] = useState(() => !!webApp)
const [safeAreaInset, setSafeAreaInset] = useState(() => webApp?.safeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 })
const [contentSafeAreaInset, setContentSafeAreaInset] = useState(() => webApp?.contentSafeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 })
const [isFullscreen, setIsFullscreen] = useState(() => webApp?.isFullscreen || false);
const [isTelegramWebApp, setIsTelegramWebApp] = useState(() => !!webApp);
const [safeAreaInset, setSafeAreaInset] = useState(
() => webApp?.safeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 },
);
const [contentSafeAreaInset, setContentSafeAreaInset] = useState(
() => webApp?.contentSafeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 },
);
useEffect(() => {
if (!webApp) {
setIsTelegramWebApp(false)
return
setIsTelegramWebApp(false);
return;
}
setIsTelegramWebApp(true)
setIsFullscreen(webApp.isFullscreen || false)
setSafeAreaInset(webApp.safeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 })
setContentSafeAreaInset(webApp.contentSafeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 })
setIsTelegramWebApp(true);
setIsFullscreen(webApp.isFullscreen || false);
setSafeAreaInset(webApp.safeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 });
setContentSafeAreaInset(
webApp.contentSafeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 },
);
// Expand WebApp to full height
webApp.expand()
webApp.ready()
webApp.expand();
webApp.ready();
// Listen for fullscreen changes
const handleFullscreenChanged = () => {
setIsFullscreen(webApp.isFullscreen || false)
setSafeAreaInset(webApp.safeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 })
setContentSafeAreaInset(webApp.contentSafeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 })
}
setIsFullscreen(webApp.isFullscreen || false);
setSafeAreaInset(webApp.safeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 });
setContentSafeAreaInset(
webApp.contentSafeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 },
);
};
// Listen for safe area changes
const handleSafeAreaChanged = () => {
setSafeAreaInset(webApp.safeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 })
setContentSafeAreaInset(webApp.contentSafeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 })
}
setSafeAreaInset(webApp.safeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 });
setContentSafeAreaInset(
webApp.contentSafeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 },
);
};
webApp.onEvent('fullscreenChanged', handleFullscreenChanged)
webApp.onEvent('safeAreaChanged', handleSafeAreaChanged)
webApp.onEvent('contentSafeAreaChanged', handleSafeAreaChanged)
webApp.onEvent('fullscreenChanged', handleFullscreenChanged);
webApp.onEvent('safeAreaChanged', handleSafeAreaChanged);
webApp.onEvent('contentSafeAreaChanged', handleSafeAreaChanged);
return () => {
webApp.offEvent('fullscreenChanged', handleFullscreenChanged)
webApp.offEvent('safeAreaChanged', handleSafeAreaChanged)
webApp.offEvent('contentSafeAreaChanged', handleSafeAreaChanged)
}
}, [webApp])
webApp.offEvent('fullscreenChanged', handleFullscreenChanged);
webApp.offEvent('safeAreaChanged', handleSafeAreaChanged);
webApp.offEvent('contentSafeAreaChanged', handleSafeAreaChanged);
};
}, [webApp]);
const requestFullscreen = useCallback(() => {
if (webApp?.requestFullscreen) {
try {
webApp.requestFullscreen()
webApp.requestFullscreen();
} catch (e) {
console.warn('Fullscreen not supported:', e)
console.warn('Fullscreen not supported:', e);
}
}
}, [webApp])
}, [webApp]);
const exitFullscreen = useCallback(() => {
if (webApp?.exitFullscreen) {
try {
webApp.exitFullscreen()
webApp.exitFullscreen();
} catch (e) {
console.warn('Exit fullscreen failed:', e)
console.warn('Exit fullscreen failed:', e);
}
}
}, [webApp])
}, [webApp]);
const toggleFullscreen = useCallback(() => {
if (isFullscreen) {
exitFullscreen()
exitFullscreen();
} else {
requestFullscreen()
requestFullscreen();
}
}, [isFullscreen, requestFullscreen, exitFullscreen])
}, [isFullscreen, requestFullscreen, exitFullscreen]);
const disableVerticalSwipes = useCallback(() => {
try {
if (webApp?.disableVerticalSwipes && webApp.version && webApp.version >= '7.7') {
webApp.disableVerticalSwipes()
webApp.disableVerticalSwipes();
}
} catch {
// Not supported in this version
}
}, [webApp])
}, [webApp]);
const enableVerticalSwipes = useCallback(() => {
try {
if (webApp?.enableVerticalSwipes && webApp.version && webApp.version >= '7.7') {
webApp.enableVerticalSwipes()
webApp.enableVerticalSwipes();
}
} catch {
// Not supported in this version
}
}, [webApp])
}, [webApp]);
// Check if fullscreen is supported (Bot API 8.0+)
const isFullscreenSupported = Boolean(webApp?.requestFullscreen)
const isFullscreenSupported = Boolean(webApp?.requestFullscreen);
return {
isTelegramWebApp,
@@ -135,7 +145,7 @@ export function useTelegramWebApp() {
disableVerticalSwipes,
enableVerticalSwipes,
webApp,
}
};
}
/**
@@ -143,27 +153,27 @@ export function useTelegramWebApp() {
* Call this in main.tsx or App.tsx
*/
export function initTelegramWebApp() {
const webApp = window.Telegram?.WebApp
const webApp = window.Telegram?.WebApp;
if (webApp) {
webApp.ready()
webApp.expand()
webApp.ready();
webApp.expand();
// Disable vertical swipes to prevent accidental closing (requires Bot API 7.7+)
try {
if (webApp.disableVerticalSwipes && webApp.version && webApp.version >= '7.7') {
webApp.disableVerticalSwipes()
webApp.disableVerticalSwipes();
}
} catch {
// Swipe control not supported in this version
}
// Auto-enter fullscreen if enabled in settings (use cached value for instant response)
const fullscreenEnabled = getCachedFullscreenEnabled()
const fullscreenEnabled = getCachedFullscreenEnabled();
if (fullscreenEnabled && webApp.requestFullscreen && !webApp.isFullscreen) {
try {
webApp.requestFullscreen()
webApp.requestFullscreen();
} catch (e) {
console.warn('Auto-fullscreen failed:', e)
console.warn('Auto-fullscreen failed:', e);
}
}
}

View File

@@ -1,214 +1,224 @@
import { useState, useEffect, useCallback } from 'react'
import { EnabledThemes, DEFAULT_ENABLED_THEMES } from '../types/theme'
import { themeColorsApi } from '../api/themeColors'
import { useState, useEffect, useCallback } from 'react';
import { EnabledThemes, DEFAULT_ENABLED_THEMES } from '../types/theme';
import { themeColorsApi } from '../api/themeColors';
type Theme = 'dark' | 'light'
type Theme = 'dark' | 'light';
const THEME_KEY = 'cabinet-theme'
const ENABLED_THEMES_KEY = 'cabinet-enabled-themes'
const THEME_KEY = 'cabinet-theme';
const ENABLED_THEMES_KEY = 'cabinet-enabled-themes';
// Fetch enabled themes from API
async function fetchEnabledThemes(): Promise<EnabledThemes> {
try {
const data = await themeColorsApi.getEnabledThemes()
const data = await themeColorsApi.getEnabledThemes();
// Cache in localStorage for faster subsequent loads
localStorage.setItem(ENABLED_THEMES_KEY, JSON.stringify(data))
return data
localStorage.setItem(ENABLED_THEMES_KEY, JSON.stringify(data));
return data;
} catch {
// Ignore errors, use cached or default
}
// Try to get from cache
const cached = localStorage.getItem(ENABLED_THEMES_KEY)
const cached = localStorage.getItem(ENABLED_THEMES_KEY);
if (cached) {
try {
return JSON.parse(cached)
return JSON.parse(cached);
} catch {
// Ignore parse errors
}
}
return DEFAULT_ENABLED_THEMES
return DEFAULT_ENABLED_THEMES;
}
// Get cached enabled themes synchronously
function getCachedEnabledThemes(): EnabledThemes {
const cached = localStorage.getItem(ENABLED_THEMES_KEY)
const cached = localStorage.getItem(ENABLED_THEMES_KEY);
if (cached) {
try {
return JSON.parse(cached)
return JSON.parse(cached);
} catch {
// Ignore parse errors
}
}
return DEFAULT_ENABLED_THEMES
return DEFAULT_ENABLED_THEMES;
}
// Custom event for same-tab updates
const ENABLED_THEMES_CHANGED_EVENT = 'enabledThemesChanged'
const ENABLED_THEMES_CHANGED_EVENT = 'enabledThemesChanged';
// Update cache (called from admin settings)
export function updateEnabledThemesCache(themes: EnabledThemes) {
localStorage.setItem(ENABLED_THEMES_KEY, JSON.stringify(themes))
localStorage.setItem(ENABLED_THEMES_KEY, JSON.stringify(themes));
// Dispatch custom event for same-tab updates
window.dispatchEvent(new CustomEvent(ENABLED_THEMES_CHANGED_EVENT, { detail: themes }))
window.dispatchEvent(new CustomEvent(ENABLED_THEMES_CHANGED_EVENT, { detail: themes }));
}
export function useTheme() {
const [enabledThemes, setEnabledThemes] = useState<EnabledThemes>(getCachedEnabledThemes)
const [isLoading, setIsLoading] = useState(true)
const [enabledThemes, setEnabledThemes] = useState<EnabledThemes>(getCachedEnabledThemes);
const [isLoading, setIsLoading] = useState(true);
const [theme, setThemeState] = useState<Theme>(() => {
const enabled = getCachedEnabledThemes()
const enabled = getCachedEnabledThemes();
// Check localStorage first
if (typeof window !== 'undefined') {
const stored = localStorage.getItem(THEME_KEY) as Theme | null
const stored = localStorage.getItem(THEME_KEY) as Theme | null;
if (stored === 'light' && enabled.light) {
return 'light'
return 'light';
}
if (stored === 'dark' && enabled.dark) {
return 'dark'
return 'dark';
}
// If stored theme is disabled, use the enabled one
if (stored && !enabled[stored]) {
return enabled.dark ? 'dark' : 'light'
return enabled.dark ? 'dark' : 'light';
}
// Check system preference
if (window.matchMedia('(prefers-color-scheme: light)').matches && enabled.light) {
return 'light'
return 'light';
}
}
// Default to dark if enabled, otherwise light
return enabled.dark ? 'dark' : 'light'
})
return enabled.dark ? 'dark' : 'light';
});
// Fetch enabled themes on mount
useEffect(() => {
fetchEnabledThemes().then((data) => {
setEnabledThemes(data)
setIsLoading(false)
setEnabledThemes(data);
setIsLoading(false);
// If current theme is disabled, switch to enabled one
if (!data[theme]) {
const newTheme = data.dark ? 'dark' : 'light'
setThemeState(newTheme)
const newTheme = data.dark ? 'dark' : 'light';
setThemeState(newTheme);
}
})
}, []) // eslint-disable-line react-hooks/exhaustive-deps
});
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// Listen for localStorage changes (when admin updates enabled themes from other tabs)
useEffect(() => {
const handleStorageChange = (e: StorageEvent) => {
if (e.key === ENABLED_THEMES_KEY && e.newValue) {
try {
const data = JSON.parse(e.newValue) as EnabledThemes
setEnabledThemes(data)
const data = JSON.parse(e.newValue) as EnabledThemes;
setEnabledThemes(data);
// If current theme is now disabled, switch to enabled one
if (!data[theme]) {
const newTheme = data.dark ? 'dark' : 'light'
setThemeState(newTheme)
const newTheme = data.dark ? 'dark' : 'light';
setThemeState(newTheme);
}
} catch {
// Ignore parse errors
}
}
}
};
window.addEventListener('storage', handleStorageChange)
return () => window.removeEventListener('storage', handleStorageChange)
}, [theme])
window.addEventListener('storage', handleStorageChange);
return () => window.removeEventListener('storage', handleStorageChange);
}, [theme]);
// Listen for same-tab enabled themes changes (from admin settings)
useEffect(() => {
const handleEnabledThemesChange = (e: CustomEvent<EnabledThemes>) => {
const data = e.detail
setEnabledThemes(data)
const data = e.detail;
setEnabledThemes(data);
// If current theme is now disabled, switch to enabled one
if (!data[theme]) {
const newTheme = data.dark ? 'dark' : 'light'
setThemeState(newTheme)
const newTheme = data.dark ? 'dark' : 'light';
setThemeState(newTheme);
}
}
};
window.addEventListener(ENABLED_THEMES_CHANGED_EVENT, handleEnabledThemesChange as EventListener)
return () => window.removeEventListener(ENABLED_THEMES_CHANGED_EVENT, handleEnabledThemesChange as EventListener)
}, [theme])
window.addEventListener(
ENABLED_THEMES_CHANGED_EVENT,
handleEnabledThemesChange as EventListener,
);
return () =>
window.removeEventListener(
ENABLED_THEMES_CHANGED_EVENT,
handleEnabledThemesChange as EventListener,
);
}, [theme]);
// Apply theme to document - also check if theme is disabled and switch
useEffect(() => {
const root = document.documentElement
const root = document.documentElement;
// If current theme is disabled, switch to the enabled one
if (!enabledThemes[theme]) {
const newTheme = enabledThemes.dark ? 'dark' : 'light'
const newTheme = enabledThemes.dark ? 'dark' : 'light';
if (newTheme !== theme) {
setThemeState(newTheme)
return // Will re-run with correct theme
setThemeState(newTheme);
return; // Will re-run with correct theme
}
}
if (theme === 'light') {
root.classList.remove('dark')
root.classList.add('light')
root.classList.remove('dark');
root.classList.add('light');
} else {
root.classList.remove('light')
root.classList.add('dark')
root.classList.remove('light');
root.classList.add('dark');
}
localStorage.setItem(THEME_KEY, theme)
}, [theme, enabledThemes])
localStorage.setItem(THEME_KEY, theme);
}, [theme, enabledThemes]);
// Listen for system theme changes
useEffect(() => {
const mediaQuery = window.matchMedia('(prefers-color-scheme: light)')
const mediaQuery = window.matchMedia('(prefers-color-scheme: light)');
const handleChange = (e: MediaQueryListEvent) => {
const stored = localStorage.getItem(THEME_KEY)
const stored = localStorage.getItem(THEME_KEY);
// Only auto-switch if user hasn't set a preference and theme is enabled
if (!stored) {
const newTheme = e.matches ? 'light' : 'dark'
const newTheme = e.matches ? 'light' : 'dark';
if (enabledThemes[newTheme]) {
setThemeState(newTheme)
setThemeState(newTheme);
}
}
}
};
mediaQuery.addEventListener('change', handleChange)
return () => mediaQuery.removeEventListener('change', handleChange)
}, [enabledThemes])
mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
}, [enabledThemes]);
const setTheme = useCallback((newTheme: Theme) => {
// Only allow setting if theme is enabled
if (enabledThemes[newTheme]) {
setThemeState(newTheme)
}
}, [enabledThemes])
const setTheme = useCallback(
(newTheme: Theme) => {
// Only allow setting if theme is enabled
if (enabledThemes[newTheme]) {
setThemeState(newTheme);
}
},
[enabledThemes],
);
const toggleTheme = useCallback(() => {
setThemeState((prev) => {
const newTheme = prev === 'dark' ? 'light' : 'dark'
const newTheme = prev === 'dark' ? 'light' : 'dark';
// Only toggle if the new theme is enabled
if (enabledThemes[newTheme]) {
return newTheme
return newTheme;
}
return prev
})
}, [enabledThemes])
return prev;
});
}, [enabledThemes]);
const isDark = theme === 'dark'
const isLight = theme === 'light'
const isDark = theme === 'dark';
const isLight = theme === 'light';
// Check if theme switching is available (both themes enabled and loaded)
const canToggle = !isLoading && enabledThemes.dark && enabledThemes.light
const canToggle = !isLoading && enabledThemes.dark && enabledThemes.light;
// Refresh enabled themes from API
const refreshEnabledThemes = useCallback(() => {
fetchEnabledThemes().then((data) => {
setEnabledThemes(data)
setEnabledThemes(data);
if (!data[theme]) {
const newTheme = data.dark ? 'dark' : 'light'
setThemeState(newTheme)
const newTheme = data.dark ? 'dark' : 'light';
setThemeState(newTheme);
}
})
}, [theme])
});
}, [theme]);
return {
theme,
@@ -220,5 +230,5 @@ export function useTheme() {
canToggle,
isLoading,
refreshEnabledThemes,
}
};
}

View File

@@ -1,76 +1,76 @@
import { useEffect } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { themeColorsApi } from '../api/themeColors'
import { ThemeColors, DEFAULT_THEME_COLORS, SHADE_LEVELS, ColorPalette } from '../types/theme'
import { useEffect } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { themeColorsApi } from '../api/themeColors';
import { ThemeColors, DEFAULT_THEME_COLORS, SHADE_LEVELS, ColorPalette } from '../types/theme';
// Convert hex to RGB values
function hexToRgb(hex: string): { r: number; g: number; b: number } {
// Handle shorthand hex
if (hex.length === 4) {
hex = '#' + hex[1] + hex[1] + hex[2] + hex[2] + hex[3] + hex[3]
hex = '#' + hex[1] + hex[1] + hex[2] + hex[2] + hex[3] + hex[3];
}
const r = parseInt(hex.slice(1, 3), 16)
const g = parseInt(hex.slice(3, 5), 16)
const b = parseInt(hex.slice(5, 7), 16)
return { r, g, b }
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return { r, g, b };
}
// Convert hex to HSL
function hexToHsl(hex: string): { h: number; s: number; l: number } {
const { r, g, b } = hexToRgb(hex)
const rNorm = r / 255
const gNorm = g / 255
const bNorm = b / 255
const { r, g, b } = hexToRgb(hex);
const rNorm = r / 255;
const gNorm = g / 255;
const bNorm = b / 255;
const max = Math.max(rNorm, gNorm, bNorm)
const min = Math.min(rNorm, gNorm, bNorm)
let h = 0
let s = 0
const l = (max + min) / 2
const max = Math.max(rNorm, gNorm, bNorm);
const min = Math.min(rNorm, gNorm, bNorm);
let h = 0;
let s = 0;
const l = (max + min) / 2;
if (max !== min) {
const d = max - min
s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case rNorm:
h = ((gNorm - bNorm) / d + (gNorm < bNorm ? 6 : 0)) / 6
break
h = ((gNorm - bNorm) / d + (gNorm < bNorm ? 6 : 0)) / 6;
break;
case gNorm:
h = ((bNorm - rNorm) / d + 2) / 6
break
h = ((bNorm - rNorm) / d + 2) / 6;
break;
case bNorm:
h = ((rNorm - gNorm) / d + 4) / 6
break
h = ((rNorm - gNorm) / d + 4) / 6;
break;
}
}
return { h: h * 360, s: s * 100, l: l * 100 }
return { h: h * 360, s: s * 100, l: l * 100 };
}
// Convert HSL to RGB values
function hslToRgb(h: number, s: number, l: number): { r: number; g: number; b: number } {
s /= 100
l /= 100
s /= 100;
l /= 100;
const a = s * Math.min(l, 1 - l)
const a = s * Math.min(l, 1 - l);
const f = (n: number) => {
const k = (n + h / 30) % 12
const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1)
return Math.round(255 * color)
}
const k = (n + h / 30) % 12;
const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
return Math.round(255 * color);
};
return { r: f(0), g: f(8), b: f(4) }
return { r: f(0), g: f(8), b: f(4) };
}
// Convert RGB to string format for CSS variable
function rgbToString(r: number, g: number, b: number): string {
return `${r}, ${g}, ${b}`
return `${r}, ${g}, ${b}`;
}
// Generate color palette from base color (returns RGB strings)
function generatePalette(baseHex: string): ColorPalette {
const { h, s } = hexToHsl(baseHex)
const { h, s } = hexToHsl(baseHex);
// Lightness values for each shade level (from light to dark)
const lightnessMap: Record<number, number> = {
@@ -85,118 +85,151 @@ function generatePalette(baseHex: string): ColorPalette {
800: 26,
900: 18,
950: 10,
}
};
const palette: Partial<ColorPalette> = {}
const palette: Partial<ColorPalette> = {};
for (const shade of SHADE_LEVELS) {
const lightness = lightnessMap[shade]
const lightness = lightnessMap[shade];
// Adjust saturation slightly for very light/dark shades
const adjustedS = shade <= 100 ? s * 0.7 : shade >= 900 ? s * 0.8 : s
const { r, g, b } = hslToRgb(h, adjustedS, lightness)
palette[shade] = rgbToString(r, g, b)
const adjustedS = shade <= 100 ? s * 0.7 : shade >= 900 ? s * 0.8 : s;
const { r, g, b } = hslToRgb(h, adjustedS, lightness);
palette[shade] = rgbToString(r, g, b);
}
return palette as ColorPalette
return palette as ColorPalette;
}
// Interpolate between two RGB colors
function interpolateRgb(
rgb1: { r: number; g: number; b: number },
rgb2: { r: number; g: number; b: number },
factor: number
factor: number,
): string {
return rgbToString(
Math.round(rgb1.r + (rgb2.r - rgb1.r) * factor),
Math.round(rgb1.g + (rgb2.g - rgb1.g) * factor),
Math.round(rgb1.b + (rgb2.b - rgb1.b) * factor)
)
Math.round(rgb1.b + (rgb2.b - rgb1.b) * factor),
);
}
// Apply theme colors as CSS variables (RGB format for Tailwind opacity support)
export function applyThemeColors(colors: ThemeColors): void {
const root = document.documentElement
const root = document.documentElement;
// Generate palettes from status colors
const accentPalette = generatePalette(colors.accent)
const successPalette = generatePalette(colors.success)
const warningPalette = generatePalette(colors.warning)
const errorPalette = generatePalette(colors.error)
const accentPalette = generatePalette(colors.accent);
const successPalette = generatePalette(colors.success);
const warningPalette = generatePalette(colors.warning);
const errorPalette = generatePalette(colors.error);
// === DARK THEME PALETTE ===
// Convert hex colors to RGB
const darkBgRgb = hexToRgb(colors.darkBackground)
const darkSurfaceRgb = hexToRgb(colors.darkSurface)
const darkTextRgb = hexToRgb(colors.darkText)
const darkTextSecRgb = hexToRgb(colors.darkTextSecondary)
const darkBgRgb = hexToRgb(colors.darkBackground);
const darkSurfaceRgb = hexToRgb(colors.darkSurface);
const darkTextRgb = hexToRgb(colors.darkText);
const darkTextSecRgb = hexToRgb(colors.darkTextSecondary);
// Apply dark palette with actual user colors:
// Text colors (light shades): 50-100 = primary text, 200-300 = mixed, 400 = secondary text
root.style.setProperty('--color-dark-50', rgbToString(darkTextRgb.r, darkTextRgb.g, darkTextRgb.b))
root.style.setProperty('--color-dark-100', rgbToString(darkTextRgb.r, darkTextRgb.g, darkTextRgb.b))
root.style.setProperty('--color-dark-200', interpolateRgb(darkTextRgb, darkTextSecRgb, 0.33))
root.style.setProperty('--color-dark-300', interpolateRgb(darkTextRgb, darkTextSecRgb, 0.66))
root.style.setProperty('--color-dark-400', rgbToString(darkTextSecRgb.r, darkTextSecRgb.g, darkTextSecRgb.b))
root.style.setProperty(
'--color-dark-50',
rgbToString(darkTextRgb.r, darkTextRgb.g, darkTextRgb.b),
);
root.style.setProperty(
'--color-dark-100',
rgbToString(darkTextRgb.r, darkTextRgb.g, darkTextRgb.b),
);
root.style.setProperty('--color-dark-200', interpolateRgb(darkTextRgb, darkTextSecRgb, 0.33));
root.style.setProperty('--color-dark-300', interpolateRgb(darkTextRgb, darkTextSecRgb, 0.66));
root.style.setProperty(
'--color-dark-400',
rgbToString(darkTextSecRgb.r, darkTextSecRgb.g, darkTextSecRgb.b),
);
// Transition colors (500-700): interpolate between secondary text and surface
root.style.setProperty('--color-dark-500', interpolateRgb(darkTextSecRgb, darkSurfaceRgb, 0.4))
root.style.setProperty('--color-dark-600', interpolateRgb(darkTextSecRgb, darkSurfaceRgb, 0.6))
root.style.setProperty('--color-dark-700', interpolateRgb(darkTextSecRgb, darkSurfaceRgb, 0.8))
root.style.setProperty('--color-dark-500', interpolateRgb(darkTextSecRgb, darkSurfaceRgb, 0.4));
root.style.setProperty('--color-dark-600', interpolateRgb(darkTextSecRgb, darkSurfaceRgb, 0.6));
root.style.setProperty('--color-dark-700', interpolateRgb(darkTextSecRgb, darkSurfaceRgb, 0.8));
// Surface/card colors (800-850): surface color
root.style.setProperty('--color-dark-800', rgbToString(darkSurfaceRgb.r, darkSurfaceRgb.g, darkSurfaceRgb.b))
root.style.setProperty('--color-dark-850', interpolateRgb(darkSurfaceRgb, darkBgRgb, 0.5))
root.style.setProperty(
'--color-dark-800',
rgbToString(darkSurfaceRgb.r, darkSurfaceRgb.g, darkSurfaceRgb.b),
);
root.style.setProperty('--color-dark-850', interpolateRgb(darkSurfaceRgb, darkBgRgb, 0.5));
// Background colors (900-950): background color
root.style.setProperty('--color-dark-900', interpolateRgb(darkSurfaceRgb, darkBgRgb, 0.7))
root.style.setProperty('--color-dark-950', rgbToString(darkBgRgb.r, darkBgRgb.g, darkBgRgb.b))
root.style.setProperty('--color-dark-900', interpolateRgb(darkSurfaceRgb, darkBgRgb, 0.7));
root.style.setProperty('--color-dark-950', rgbToString(darkBgRgb.r, darkBgRgb.g, darkBgRgb.b));
// === LIGHT THEME PALETTE ===
const lightBgRgb = hexToRgb(colors.lightBackground)
const lightSurfaceRgb = hexToRgb(colors.lightSurface)
const lightTextRgb = hexToRgb(colors.lightText)
const lightTextSecRgb = hexToRgb(colors.lightTextSecondary)
const lightBgRgb = hexToRgb(colors.lightBackground);
const lightSurfaceRgb = hexToRgb(colors.lightSurface);
const lightTextRgb = hexToRgb(colors.lightText);
const lightTextSecRgb = hexToRgb(colors.lightTextSecondary);
// Apply champagne palette with actual user colors:
// Background colors (light shades): 50-100 = surface, 200-400 = background tones
root.style.setProperty('--color-champagne-50', rgbToString(lightSurfaceRgb.r, lightSurfaceRgb.g, lightSurfaceRgb.b))
root.style.setProperty('--color-champagne-100', interpolateRgb(lightSurfaceRgb, lightBgRgb, 0.3))
root.style.setProperty('--color-champagne-200', rgbToString(lightBgRgb.r, lightBgRgb.g, lightBgRgb.b))
root.style.setProperty('--color-champagne-300', interpolateRgb(lightBgRgb, lightTextSecRgb, 0.2))
root.style.setProperty('--color-champagne-400', interpolateRgb(lightBgRgb, lightTextSecRgb, 0.4))
root.style.setProperty(
'--color-champagne-50',
rgbToString(lightSurfaceRgb.r, lightSurfaceRgb.g, lightSurfaceRgb.b),
);
root.style.setProperty('--color-champagne-100', interpolateRgb(lightSurfaceRgb, lightBgRgb, 0.3));
root.style.setProperty(
'--color-champagne-200',
rgbToString(lightBgRgb.r, lightBgRgb.g, lightBgRgb.b),
);
root.style.setProperty('--color-champagne-300', interpolateRgb(lightBgRgb, lightTextSecRgb, 0.2));
root.style.setProperty('--color-champagne-400', interpolateRgb(lightBgRgb, lightTextSecRgb, 0.4));
// Transition colors (500-600): between bg and text
root.style.setProperty('--color-champagne-500', interpolateRgb(lightBgRgb, lightTextSecRgb, 0.6))
root.style.setProperty('--color-champagne-600', rgbToString(lightTextSecRgb.r, lightTextSecRgb.g, lightTextSecRgb.b))
root.style.setProperty('--color-champagne-500', interpolateRgb(lightBgRgb, lightTextSecRgb, 0.6));
root.style.setProperty(
'--color-champagne-600',
rgbToString(lightTextSecRgb.r, lightTextSecRgb.g, lightTextSecRgb.b),
);
// Text colors (700-950): secondary to primary text
root.style.setProperty('--color-champagne-700', interpolateRgb(lightTextSecRgb, lightTextRgb, 0.33))
root.style.setProperty('--color-champagne-800', interpolateRgb(lightTextSecRgb, lightTextRgb, 0.66))
root.style.setProperty('--color-champagne-900', rgbToString(lightTextRgb.r, lightTextRgb.g, lightTextRgb.b))
root.style.setProperty('--color-champagne-950', rgbToString(lightTextRgb.r, lightTextRgb.g, lightTextRgb.b))
root.style.setProperty(
'--color-champagne-700',
interpolateRgb(lightTextSecRgb, lightTextRgb, 0.33),
);
root.style.setProperty(
'--color-champagne-800',
interpolateRgb(lightTextSecRgb, lightTextRgb, 0.66),
);
root.style.setProperty(
'--color-champagne-900',
rgbToString(lightTextRgb.r, lightTextRgb.g, lightTextRgb.b),
);
root.style.setProperty(
'--color-champagne-950',
rgbToString(lightTextRgb.r, lightTextRgb.g, lightTextRgb.b),
);
// === STATUS COLOR PALETTES ===
for (const shade of SHADE_LEVELS) {
root.style.setProperty(`--color-accent-${shade}`, accentPalette[shade])
root.style.setProperty(`--color-success-${shade}`, successPalette[shade])
root.style.setProperty(`--color-warning-${shade}`, warningPalette[shade])
root.style.setProperty(`--color-error-${shade}`, errorPalette[shade])
root.style.setProperty(`--color-accent-${shade}`, accentPalette[shade]);
root.style.setProperty(`--color-success-${shade}`, successPalette[shade]);
root.style.setProperty(`--color-warning-${shade}`, warningPalette[shade]);
root.style.setProperty(`--color-error-${shade}`, errorPalette[shade]);
}
// Apply semantic colors (hex for direct use)
root.style.setProperty('--color-dark-bg', colors.darkBackground)
root.style.setProperty('--color-dark-surface', colors.darkSurface)
root.style.setProperty('--color-dark-text', colors.darkText)
root.style.setProperty('--color-dark-text-secondary', colors.darkTextSecondary)
root.style.setProperty('--color-dark-bg', colors.darkBackground);
root.style.setProperty('--color-dark-surface', colors.darkSurface);
root.style.setProperty('--color-dark-text', colors.darkText);
root.style.setProperty('--color-dark-text-secondary', colors.darkTextSecondary);
root.style.setProperty('--color-light-bg', colors.lightBackground)
root.style.setProperty('--color-light-surface', colors.lightSurface)
root.style.setProperty('--color-light-text', colors.lightText)
root.style.setProperty('--color-light-text-secondary', colors.lightTextSecondary)
root.style.setProperty('--color-light-bg', colors.lightBackground);
root.style.setProperty('--color-light-surface', colors.lightSurface);
root.style.setProperty('--color-light-text', colors.lightText);
root.style.setProperty('--color-light-text-secondary', colors.lightTextSecondary);
}
export function useThemeColors() {
const queryClient = useQueryClient()
const queryClient = useQueryClient();
const {
data: colors,
@@ -208,22 +241,22 @@ export function useThemeColors() {
staleTime: 5 * 60 * 1000, // 5 minutes
refetchOnWindowFocus: false,
retry: 1,
})
});
// Apply colors when loaded or changed
useEffect(() => {
const colorsToApply = colors || DEFAULT_THEME_COLORS
applyThemeColors(colorsToApply)
}, [colors])
const colorsToApply = colors || DEFAULT_THEME_COLORS;
applyThemeColors(colorsToApply);
}, [colors]);
const invalidateColors = () => {
queryClient.invalidateQueries({ queryKey: ['theme-colors'] })
}
queryClient.invalidateQueries({ queryKey: ['theme-colors'] });
};
return {
colors: colors || DEFAULT_THEME_COLORS,
isLoading,
error,
invalidateColors,
}
};
}

View File

@@ -1,156 +1,159 @@
import { useEffect, useRef, useCallback, useState } from 'react'
import { useAuthStore } from '../store/auth'
import { useEffect, useRef, useCallback, useState } from 'react';
import { useAuthStore } from '../store/auth';
const isDev = import.meta.env.DEV
const isDev = import.meta.env.DEV;
export interface WSMessage {
type: string
ticket_id?: number
message?: string
title?: string
user_id?: number
is_admin?: boolean
type: string;
ticket_id?: number;
message?: string;
title?: string;
user_id?: number;
is_admin?: boolean;
}
interface UseWebSocketOptions {
onMessage?: (message: WSMessage) => void
onConnect?: () => void
onDisconnect?: () => void
onMessage?: (message: WSMessage) => void;
onConnect?: () => void;
onDisconnect?: () => void;
}
export function useWebSocket(options: UseWebSocketOptions = {}) {
const { accessToken, isAuthenticated } = useAuthStore()
const wsRef = useRef<WebSocket | null>(null)
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const pingIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
const [isConnected, setIsConnected] = useState(false)
const reconnectAttemptsRef = useRef(0)
const maxReconnectAttempts = 5
const optionsRef = useRef(options)
const { accessToken, isAuthenticated } = useAuthStore();
const wsRef = useRef<WebSocket | null>(null);
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pingIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const [isConnected, setIsConnected] = useState(false);
const reconnectAttemptsRef = useRef(0);
const maxReconnectAttempts = 5;
const optionsRef = useRef(options);
// Update options ref when they change
useEffect(() => {
optionsRef.current = options
}, [options])
optionsRef.current = options;
}, [options]);
const cleanup = useCallback(() => {
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current)
reconnectTimeoutRef.current = null
clearTimeout(reconnectTimeoutRef.current);
reconnectTimeoutRef.current = null;
}
if (pingIntervalRef.current) {
clearInterval(pingIntervalRef.current)
pingIntervalRef.current = null
clearInterval(pingIntervalRef.current);
pingIntervalRef.current = null;
}
if (wsRef.current) {
wsRef.current.close()
wsRef.current = null
wsRef.current.close();
wsRef.current = null;
}
}, [])
}, []);
const connect = useCallback(() => {
if (!accessToken || !isAuthenticated) {
return
return;
}
// Don't reconnect if already connected
if (wsRef.current?.readyState === WebSocket.OPEN) {
return
return;
}
cleanup()
cleanup();
// Build WebSocket URL
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
let host = window.location.host
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
let host = window.location.host;
// Handle VITE_API_URL - can be absolute URL or relative path
const apiUrl = import.meta.env.VITE_API_URL
const apiUrl = import.meta.env.VITE_API_URL;
if (apiUrl && (apiUrl.startsWith('http://') || apiUrl.startsWith('https://'))) {
try {
host = new URL(apiUrl).host
host = new URL(apiUrl).host;
} catch {
// If URL parsing fails, use window.location.host
}
}
// If apiUrl is relative (like /api), use window.location.host
const wsUrl = `${protocol}//${host}/cabinet/ws?token=${accessToken}`
const wsUrl = `${protocol}//${host}/cabinet/ws?token=${accessToken}`;
try {
const ws = new WebSocket(wsUrl)
wsRef.current = ws
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
ws.onopen = () => {
if (isDev) console.log('[WS] Connected')
setIsConnected(true)
reconnectAttemptsRef.current = 0
optionsRef.current.onConnect?.()
if (isDev) console.log('[WS] Connected');
setIsConnected(true);
reconnectAttemptsRef.current = 0;
optionsRef.current.onConnect?.();
// Setup ping interval (every 25 seconds)
pingIntervalRef.current = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'ping' }))
ws.send(JSON.stringify({ type: 'ping' }));
}
}, 25000)
}
}, 25000);
};
ws.onmessage = (event) => {
try {
const message = JSON.parse(event.data) as WSMessage
const message = JSON.parse(event.data) as WSMessage;
// Ignore pong messages
if (message.type === 'pong' || message.type === 'connected') {
return
return;
}
optionsRef.current.onMessage?.(message)
optionsRef.current.onMessage?.(message);
} catch (e) {
if (isDev) console.error('[WS] Failed to parse message:', e)
if (isDev) console.error('[WS] Failed to parse message:', e);
}
}
};
ws.onclose = (event) => {
if (isDev) console.log('[WS] Disconnected:', event.code, event.reason)
setIsConnected(false)
optionsRef.current.onDisconnect?.()
if (isDev) console.log('[WS] Disconnected:', event.code, event.reason);
setIsConnected(false);
optionsRef.current.onDisconnect?.();
if (pingIntervalRef.current) {
clearInterval(pingIntervalRef.current)
pingIntervalRef.current = null
clearInterval(pingIntervalRef.current);
pingIntervalRef.current = null;
}
// Attempt to reconnect if not closed intentionally
if (event.code !== 1000 && reconnectAttemptsRef.current < maxReconnectAttempts) {
const delay = Math.min(1000 * Math.pow(2, reconnectAttemptsRef.current), 30000)
if (isDev) console.log(`[WS] Reconnecting in ${delay}ms (attempt ${reconnectAttemptsRef.current + 1})`)
const delay = Math.min(1000 * Math.pow(2, reconnectAttemptsRef.current), 30000);
if (isDev)
console.log(
`[WS] Reconnecting in ${delay}ms (attempt ${reconnectAttemptsRef.current + 1})`,
);
reconnectTimeoutRef.current = setTimeout(() => {
reconnectAttemptsRef.current++
connect()
}, delay)
reconnectAttemptsRef.current++;
connect();
}, delay);
}
}
};
ws.onerror = (error) => {
if (isDev) console.error('[WS] Error:', error)
}
if (isDev) console.error('[WS] Error:', error);
};
} catch (e) {
if (isDev) console.error('[WS] Failed to connect:', e)
if (isDev) console.error('[WS] Failed to connect:', e);
}
}, [accessToken, isAuthenticated, cleanup])
}, [accessToken, isAuthenticated, cleanup]);
// Connect when authenticated
useEffect(() => {
if (isAuthenticated && accessToken) {
connect()
connect();
} else {
cleanup()
setIsConnected(false)
cleanup();
setIsConnected(false);
}
return cleanup
}, [isAuthenticated, accessToken, connect, cleanup])
return cleanup;
}, [isAuthenticated, accessToken, connect, cleanup]);
return { isConnected }
return { isConnected };
}