mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 17:43:47 +00:00
feat: Linear-style UI redesign with improved mobile experience
Major changes: - Redesign cabinet with Linear-style components and top navigation - Replace detail modals with dedicated pages (users, broadcasts, email templates) - Add email channel support for broadcasts - Remove pull-to-refresh, improve drag-and-drop on touch devices - Fix Telegram Mini App: fullscreen, back button, scroll restoration - Unify admin pages color scheme to design system - Mobile-first improvements: horizontal tabs for settings, better touch targets
This commit is contained in:
@@ -1,162 +0,0 @@
|
||||
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;
|
||||
}
|
||||
|
||||
export function usePullToRefresh({
|
||||
onRefresh,
|
||||
threshold = 80,
|
||||
disabled = false,
|
||||
}: UsePullToRefreshOptions = {}) {
|
||||
const [isPulling, setIsPulling] = useState(false);
|
||||
const [pullDistance, setPullDistance] = useState(0);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
|
||||
const startY = useRef(0);
|
||||
const currentY = useRef(0);
|
||||
|
||||
const handleRefresh = useCallback(async () => {
|
||||
if (onRefresh) {
|
||||
setIsRefreshing(true);
|
||||
try {
|
||||
await onRefresh();
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
} else {
|
||||
// Default: reload the page
|
||||
window.location.reload();
|
||||
}
|
||||
}, [onRefresh]);
|
||||
|
||||
useEffect(() => {
|
||||
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;
|
||||
// Check for high z-index fixed elements (modals typically use z-index > 50)
|
||||
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);
|
||||
if (zIndex >= 50 && el.clientHeight > window.innerHeight * 0.5) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
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';
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
element = element.parentElement;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const handleTouchStart = (e: TouchEvent) => {
|
||||
// Don't trigger if a modal is open
|
||||
if (isModalOpen()) return;
|
||||
|
||||
// Only trigger if at top of page
|
||||
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;
|
||||
|
||||
startY.current = e.touches[0].clientY;
|
||||
currentY.current = e.touches[0].clientY;
|
||||
};
|
||||
|
||||
const handleTouchMove = (e: TouchEvent) => {
|
||||
if (startY.current === 0) return;
|
||||
|
||||
// Cancel if modal opened during gesture
|
||||
if (isModalOpen()) {
|
||||
startY.current = 0;
|
||||
setPullDistance(0);
|
||||
setIsPulling(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (window.scrollY > 5) {
|
||||
// User scrolled down, reset
|
||||
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;
|
||||
if (isInsideScrollableContainer(target)) {
|
||||
startY.current = 0;
|
||||
setPullDistance(0);
|
||||
setIsPulling(false);
|
||||
return;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
// Prevent default scroll if we're pulling
|
||||
if (distance > 10) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleTouchEnd = () => {
|
||||
if (pullDistance >= threshold && !isRefreshing) {
|
||||
handleRefresh();
|
||||
}
|
||||
|
||||
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 });
|
||||
|
||||
return () => {
|
||||
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),
|
||||
};
|
||||
}
|
||||
@@ -2,6 +2,19 @@ import { useEffect, useState, useCallback } from 'react';
|
||||
|
||||
const FULLSCREEN_CACHE_KEY = 'cabinet_fullscreen_enabled';
|
||||
|
||||
/**
|
||||
* Check if running on mobile Telegram client (iOS/Android)
|
||||
* Fullscreen mode should only be applied on mobile platforms
|
||||
*/
|
||||
export function isTelegramMobile(): boolean {
|
||||
const webApp = typeof window !== 'undefined' ? window.Telegram?.WebApp : undefined;
|
||||
if (!webApp?.platform) return false;
|
||||
|
||||
// Only iOS and Android are mobile platforms
|
||||
// tdesktop, macos, web, unknown - all are desktop/non-mobile
|
||||
return webApp.platform === 'ios' || webApp.platform === 'android';
|
||||
}
|
||||
|
||||
// Get cached fullscreen setting
|
||||
export const getCachedFullscreenEnabled = (): boolean => {
|
||||
try {
|
||||
@@ -27,18 +40,29 @@ 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 inTelegram = isInTelegramWebApp();
|
||||
|
||||
const [isFullscreen, setIsFullscreen] = useState(() => webApp?.isFullscreen || false);
|
||||
const [isTelegramWebApp, setIsTelegramWebApp] = useState(() => !!webApp);
|
||||
const [isFullscreen, setIsFullscreen] = useState(
|
||||
() => (inTelegram && webApp?.isFullscreen) || false,
|
||||
);
|
||||
const [isTelegramWebApp, setIsTelegramWebApp] = useState(() => inTelegram);
|
||||
const [safeAreaInset, setSafeAreaInset] = useState(
|
||||
() => webApp?.safeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 },
|
||||
() => (inTelegram && webApp?.safeAreaInset) || { top: 0, bottom: 0, left: 0, right: 0 },
|
||||
);
|
||||
const [contentSafeAreaInset, setContentSafeAreaInset] = useState(
|
||||
() => webApp?.contentSafeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 },
|
||||
() =>
|
||||
(inTelegram && webApp?.contentSafeAreaInset) || {
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!webApp) {
|
||||
// Only run Telegram-specific code if we're actually in Telegram
|
||||
const isActuallyInTelegram = isInTelegramWebApp();
|
||||
if (!webApp || !isActuallyInTelegram) {
|
||||
setIsTelegramWebApp(false);
|
||||
return;
|
||||
}
|
||||
@@ -148,33 +172,47 @@ export function useTelegramWebApp() {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we're actually running inside Telegram Mini App
|
||||
* (not just the script loaded on a regular webpage)
|
||||
*/
|
||||
export function isInTelegramWebApp(): boolean {
|
||||
const webApp = window.Telegram?.WebApp;
|
||||
// Check if initData exists - it's empty when not in Telegram
|
||||
return Boolean(webApp?.initData && webApp.initData.length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize Telegram WebApp on app start
|
||||
* Call this in main.tsx or App.tsx
|
||||
*/
|
||||
export function initTelegramWebApp() {
|
||||
const webApp = window.Telegram?.WebApp;
|
||||
if (webApp) {
|
||||
webApp.ready();
|
||||
webApp.expand();
|
||||
// Only initialize if we're actually in Telegram
|
||||
if (!webApp || !isInTelegramWebApp()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Disable vertical swipes to prevent accidental closing (requires Bot API 7.7+)
|
||||
try {
|
||||
if (webApp.disableVerticalSwipes && webApp.version && webApp.version >= '7.7') {
|
||||
webApp.disableVerticalSwipes();
|
||||
}
|
||||
} catch {
|
||||
// Swipe control not supported in this version
|
||||
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();
|
||||
}
|
||||
} catch {
|
||||
// Swipe control not supported in this version
|
||||
}
|
||||
|
||||
// Auto-enter fullscreen if enabled in settings (use cached value for instant response)
|
||||
const fullscreenEnabled = getCachedFullscreenEnabled();
|
||||
if (fullscreenEnabled && webApp.requestFullscreen && !webApp.isFullscreen) {
|
||||
try {
|
||||
webApp.requestFullscreen();
|
||||
} catch (e) {
|
||||
console.warn('Auto-fullscreen failed:', e);
|
||||
}
|
||||
// Auto-enter fullscreen if enabled in settings (use cached value for instant response)
|
||||
// Only apply fullscreen on mobile Telegram (iOS/Android) - desktop doesn't need it
|
||||
const fullscreenEnabled = getCachedFullscreenEnabled();
|
||||
if (fullscreenEnabled && isTelegramMobile() && webApp.requestFullscreen && !webApp.isFullscreen) {
|
||||
try {
|
||||
webApp.requestFullscreen();
|
||||
} catch (e) {
|
||||
console.warn('Auto-fullscreen failed:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
187
src/hooks/useUserThemePreferences.ts
Normal file
187
src/hooks/useUserThemePreferences.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { usePlatform } from '@/platform';
|
||||
import {
|
||||
type UserThemePreferences,
|
||||
type BorderRadiusPreset,
|
||||
type ThemeMode,
|
||||
DEFAULT_USER_PREFERENCES,
|
||||
BORDER_RADIUS_VALUES,
|
||||
} from '../types/theme';
|
||||
|
||||
const STORAGE_KEY = 'user_theme_preferences';
|
||||
|
||||
/**
|
||||
* Parse preferences from storage string
|
||||
*/
|
||||
function parsePreferences(value: string | null): UserThemePreferences {
|
||||
if (!value) return DEFAULT_USER_PREFERENCES;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return {
|
||||
theme: parsed.theme ?? DEFAULT_USER_PREFERENCES.theme,
|
||||
borderRadius: parsed.borderRadius ?? DEFAULT_USER_PREFERENCES.borderRadius,
|
||||
animationsEnabled: parsed.animationsEnabled ?? DEFAULT_USER_PREFERENCES.animationsEnabled,
|
||||
};
|
||||
} catch {
|
||||
return DEFAULT_USER_PREFERENCES;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply CSS variables to document
|
||||
*/
|
||||
function applyPreferencesToDOM(preferences: UserThemePreferences): void {
|
||||
const root = document.documentElement;
|
||||
|
||||
// Apply border radius
|
||||
root.style.setProperty('--bento-radius', BORDER_RADIUS_VALUES[preferences.borderRadius]);
|
||||
|
||||
// Apply animations toggle
|
||||
if (preferences.animationsEnabled) {
|
||||
root.classList.remove('reduce-motion');
|
||||
} else {
|
||||
root.classList.add('reduce-motion');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to manage user theme preferences
|
||||
* Stores in localStorage and optionally syncs with Telegram CloudStorage
|
||||
*/
|
||||
export function useUserThemePreferences() {
|
||||
const { cloudStorage, capabilities } = usePlatform();
|
||||
const [preferences, setPreferencesState] =
|
||||
useState<UserThemePreferences>(DEFAULT_USER_PREFERENCES);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// Load preferences on mount
|
||||
useEffect(() => {
|
||||
async function loadPreferences() {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
// Try Telegram CloudStorage first if available
|
||||
if (capabilities.hasCloudStorage && cloudStorage) {
|
||||
const cloudValue = await cloudStorage.getItem(STORAGE_KEY);
|
||||
if (cloudValue) {
|
||||
const prefs = parsePreferences(cloudValue);
|
||||
setPreferencesState(prefs);
|
||||
applyPreferencesToDOM(prefs);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to localStorage
|
||||
const localValue = localStorage.getItem(STORAGE_KEY);
|
||||
const prefs = parsePreferences(localValue);
|
||||
setPreferencesState(prefs);
|
||||
applyPreferencesToDOM(prefs);
|
||||
} catch (error) {
|
||||
console.warn('Failed to load theme preferences:', error);
|
||||
applyPreferencesToDOM(DEFAULT_USER_PREFERENCES);
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
loadPreferences();
|
||||
}, [cloudStorage, capabilities.hasCloudStorage]);
|
||||
|
||||
// Save preferences
|
||||
const savePreferences = useCallback(
|
||||
async (newPreferences: UserThemePreferences) => {
|
||||
const value = JSON.stringify(newPreferences);
|
||||
|
||||
// Save to localStorage
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, value);
|
||||
} catch (error) {
|
||||
console.warn('Failed to save to localStorage:', error);
|
||||
}
|
||||
|
||||
// Sync to Telegram CloudStorage if available
|
||||
if (capabilities.hasCloudStorage && cloudStorage) {
|
||||
try {
|
||||
await cloudStorage.setItem(STORAGE_KEY, value);
|
||||
} catch (error) {
|
||||
console.warn('Failed to sync to CloudStorage:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply to DOM
|
||||
applyPreferencesToDOM(newPreferences);
|
||||
setPreferencesState(newPreferences);
|
||||
},
|
||||
[cloudStorage, capabilities.hasCloudStorage],
|
||||
);
|
||||
|
||||
// Update individual preference
|
||||
const updatePreference = useCallback(
|
||||
<K extends keyof UserThemePreferences>(key: K, value: UserThemePreferences[K]) => {
|
||||
const newPreferences = { ...preferences, [key]: value };
|
||||
savePreferences(newPreferences);
|
||||
},
|
||||
[preferences, savePreferences],
|
||||
);
|
||||
|
||||
// Convenience setters
|
||||
const setTheme = useCallback(
|
||||
(theme: ThemeMode) => updatePreference('theme', theme),
|
||||
[updatePreference],
|
||||
);
|
||||
|
||||
const setBorderRadius = useCallback(
|
||||
(borderRadius: BorderRadiusPreset) => updatePreference('borderRadius', borderRadius),
|
||||
[updatePreference],
|
||||
);
|
||||
|
||||
const setAnimationsEnabled = useCallback(
|
||||
(enabled: boolean) => updatePreference('animationsEnabled', enabled),
|
||||
[updatePreference],
|
||||
);
|
||||
|
||||
// Reset to defaults
|
||||
const resetPreferences = useCallback(() => {
|
||||
savePreferences(DEFAULT_USER_PREFERENCES);
|
||||
}, [savePreferences]);
|
||||
|
||||
return {
|
||||
preferences,
|
||||
isLoading,
|
||||
setTheme,
|
||||
setBorderRadius,
|
||||
setAnimationsEnabled,
|
||||
updatePreference,
|
||||
resetPreferences,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get resolved theme (respects system preference when set to 'system')
|
||||
*/
|
||||
export function useResolvedTheme() {
|
||||
const { preferences } = useUserThemePreferences();
|
||||
const [systemTheme, setSystemTheme] = useState<'dark' | 'light'>('dark');
|
||||
|
||||
useEffect(() => {
|
||||
// Get initial system preference
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
setSystemTheme(mediaQuery.matches ? 'dark' : 'light');
|
||||
|
||||
// Listen for changes
|
||||
const handler = (e: MediaQueryListEvent) => {
|
||||
setSystemTheme(e.matches ? 'dark' : 'light');
|
||||
};
|
||||
|
||||
mediaQuery.addEventListener('change', handler);
|
||||
return () => mediaQuery.removeEventListener('change', handler);
|
||||
}, []);
|
||||
|
||||
if (preferences.theme === 'system') {
|
||||
return systemTheme;
|
||||
}
|
||||
|
||||
return preferences.theme;
|
||||
}
|
||||
Reference in New Issue
Block a user