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:
c0mrade
2026-01-31 22:06:36 +03:00
parent 929634aac4
commit b953ee0b8c
108 changed files with 11711 additions and 4542 deletions

View File

@@ -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),
};
}

View File

@@ -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);
}
}
}

View 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;
}