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

@@ -0,0 +1,75 @@
import { useEffect, useRef, useCallback } from 'react';
import { usePlatform } from '@/platform/hooks/usePlatform';
interface UseBackButtonOptions {
/**
* Whether the back button should be visible
* @default true
*/
visible?: boolean;
}
/**
* Hook to manage the Telegram BackButton
* Automatically shows/hides based on component lifecycle
*
* @param onBack - Callback when back button is pressed
* @param options - Configuration options
*
* @example
* ```tsx
* function MyPage() {
* const navigate = useNavigate();
* useBackButton(() => navigate(-1));
* // ...
* }
* ```
*/
export function useBackButton(
onBack: (() => void) | null | undefined,
options: UseBackButtonOptions = {},
): void {
const { backButton, capabilities } = usePlatform();
const { visible = true } = options;
// Use ref to prevent callback recreation issues
const callbackRef = useRef(onBack);
callbackRef.current = onBack;
// Stable callback wrapper
const handleBack = useCallback(() => {
callbackRef.current?.();
}, []);
useEffect(() => {
// If no native back button support, do nothing
if (!capabilities.hasBackButton) {
return;
}
// If callback is null/undefined or visible is false, hide button
if (!onBack || !visible) {
backButton.hide();
return;
}
// Show the back button with our handler
backButton.show(handleBack);
// Cleanup: hide button when component unmounts
return () => {
backButton.hide();
};
}, [backButton, capabilities.hasBackButton, handleBack, onBack, visible]);
}
/**
* Hook to conditionally show back button based on navigation depth
* Useful for showing back button only when there's history to go back to
*
* @param canGoBack - Whether navigation back is possible
* @param onBack - Callback when back button is pressed
*/
export function useConditionalBackButton(canGoBack: boolean, onBack: () => void): void {
useBackButton(canGoBack ? onBack : null);
}

View File

@@ -0,0 +1,125 @@
import { useCallback } from 'react';
import { usePlatform } from '@/platform/hooks/usePlatform';
import type { HapticImpactStyle, HapticNotificationType } from '@/platform/types';
interface HapticMethods {
/**
* Trigger impact feedback (for button presses, collisions)
*/
impact: (style?: HapticImpactStyle) => void;
/**
* Trigger notification feedback (for success/warning/error events)
*/
notification: (type: HapticNotificationType) => void;
/**
* Trigger selection feedback (for selection changes)
*/
selection: () => void;
/**
* Whether haptic feedback is available
*/
isAvailable: boolean;
}
/**
* Hook to access haptic feedback
* Works in Telegram Mini Apps and falls back to Web Vibration API
*
* @example
* ```tsx
* function MyButton() {
* const haptic = useHaptic();
*
* const handleClick = () => {
* haptic.impact('medium');
* doSomething();
* };
* }
* ```
*/
export function useHaptic(): HapticMethods {
const { haptic, capabilities } = usePlatform();
const impact = useCallback(
(style: HapticImpactStyle = 'medium') => {
haptic.impact(style);
},
[haptic],
);
const notification = useCallback(
(type: HapticNotificationType) => {
haptic.notification(type);
},
[haptic],
);
const selection = useCallback(() => {
haptic.selection();
}, [haptic]);
return {
impact,
notification,
selection,
isAvailable: capabilities.hasHapticFeedback,
};
}
/**
* Hook that returns a click handler with haptic feedback
* Useful for buttons that need haptic on press
*
* @param onClick - Original click handler
* @param style - Haptic impact style
*
* @example
* ```tsx
* function MyButton({ onClick }) {
* const handleClick = useHapticClick(onClick, 'light');
* return <button onClick={handleClick}>Press me</button>;
* }
* ```
*/
export function useHapticClick(
onClick: (() => void) | undefined,
style: HapticImpactStyle = 'light',
): (() => void) | undefined {
const haptic = useHaptic();
return useCallback(() => {
haptic.impact(style);
onClick?.();
}, [haptic, onClick, style]);
}
/**
* Returns individual haptic trigger functions
* Useful when you need specific feedback types
*/
export function useHapticFeedback() {
const haptic = useHaptic();
return {
// Common actions
buttonPress: useCallback(() => haptic.impact('light'), [haptic]),
buttonPressHeavy: useCallback(() => haptic.impact('medium'), [haptic]),
toggle: useCallback(() => haptic.impact('rigid'), [haptic]),
// Notifications
success: useCallback(() => haptic.notification('success'), [haptic]),
warning: useCallback(() => haptic.notification('warning'), [haptic]),
error: useCallback(() => haptic.notification('error'), [haptic]),
// Selection
selectionChanged: useCallback(() => haptic.selection(), [haptic]),
// Raw access
impact: haptic.impact,
notification: haptic.notification,
selection: haptic.selection,
};
}

View File

@@ -0,0 +1,111 @@
import { useEffect, useRef, useCallback } from 'react';
import { usePlatform } from '@/platform/hooks/usePlatform';
import type { MainButtonConfig } from '@/platform/types';
interface UseMainButtonOptions extends Omit<MainButtonConfig, 'onClick'> {
/**
* Whether the main button should be visible
* @default true
*/
visible?: boolean;
}
/**
* Hook to manage the Telegram MainButton
* Automatically shows/hides based on component lifecycle
*
* @param onClick - Callback when main button is pressed
* @param options - Configuration options
*
* @example
* ```tsx
* function SubmitForm() {
* const mutation = useMutation(...);
*
* useMainButton(handleSubmit, {
* text: t('actions.submit'),
* isLoading: mutation.isPending,
* isActive: isFormValid,
* });
* }
* ```
*/
export function useMainButton(
onClick: (() => void) | null | undefined,
options: UseMainButtonOptions = { text: '' },
): void {
const { mainButton, capabilities } = usePlatform();
const { visible = true, text, isLoading, isActive, color, textColor } = options;
// Use ref to prevent callback recreation issues
const callbackRef = useRef(onClick);
callbackRef.current = onClick;
// Stable callback wrapper
const handleClick = useCallback(() => {
callbackRef.current?.();
}, []);
useEffect(() => {
// If no native main button support, do nothing
// UI components will render their own submit buttons
if (!capabilities.hasMainButton) {
return;
}
// If callback is null/undefined or visible is false, hide button
if (!onClick || !visible || !text) {
mainButton.hide();
return;
}
// Show the main button with configuration
mainButton.show({
text,
onClick: handleClick,
isLoading,
isActive,
color,
textColor,
});
// Cleanup: hide button when component unmounts
return () => {
mainButton.hide();
};
}, [
mainButton,
capabilities.hasMainButton,
handleClick,
onClick,
visible,
text,
isLoading,
isActive,
color,
textColor,
]);
}
/**
* Hook for simple main button usage
* Shows button only when conditions are met
*
* @param text - Button text
* @param onClick - Click handler
* @param enabled - Whether button should be shown and active
* @param loading - Whether to show loading state
*/
export function useSimpleMainButton(
text: string,
onClick: () => void,
enabled: boolean = true,
loading: boolean = false,
): void {
useMainButton(enabled ? onClick : null, {
text,
isLoading: loading,
isActive: enabled && !loading,
visible: enabled,
});
}

View File

@@ -0,0 +1,116 @@
import { useCallback } from 'react';
import { usePlatform } from '@/platform/hooks/usePlatform';
import type { PopupOptions, PopupButton } from '@/platform/types';
interface DialogMethods {
/**
* Show a simple alert dialog
* @param message - Alert message
* @param title - Optional title (only shown if platform supports it)
*/
alert: (message: string, title?: string) => Promise<void>;
/**
* Show a confirmation dialog
* @param message - Confirmation message
* @param title - Optional title
* @returns true if confirmed, false otherwise
*/
confirm: (message: string, title?: string) => Promise<boolean>;
/**
* Show a popup with custom buttons
* @param options - Popup configuration
* @returns ID of the pressed button, or null if cancelled
*/
popup: (options: PopupOptions) => Promise<string | null>;
/**
* Whether native dialogs are available
*/
isNative: boolean;
}
/**
* Hook to access native dialog functionality
* Uses Telegram popups in Mini Apps, browser dialogs in web
*
* @example
* ```tsx
* function MyComponent() {
* const dialog = useNativeDialog();
*
* const handleDelete = async () => {
* const confirmed = await dialog.confirm('Delete this item?');
* if (confirmed) {
* deleteItem();
* }
* };
* }
* ```
*/
export function useNativeDialog(): DialogMethods {
const { dialog, capabilities } = usePlatform();
const alert = useCallback(
async (message: string, title?: string) => {
await dialog.alert(message, title);
},
[dialog],
);
const confirm = useCallback(
async (message: string, title?: string) => {
return dialog.confirm(message, title);
},
[dialog],
);
const popup = useCallback(
async (options: PopupOptions) => {
return dialog.popup(options);
},
[dialog],
);
return {
alert,
confirm,
popup,
isNative: capabilities.hasNativeDialogs,
};
}
/**
* Helper to create popup button configurations
*/
export const PopupButtons = {
ok: (text = 'OK'): PopupButton => ({ id: 'ok', type: 'ok', text }),
cancel: (text = 'Cancel'): PopupButton => ({ id: 'cancel', type: 'cancel', text }),
close: (text = 'Close'): PopupButton => ({ id: 'close', type: 'close', text }),
destructive: (id: string, text: string): PopupButton => ({ id, type: 'destructive', text }),
default: (id: string, text: string): PopupButton => ({ id, type: 'default', text }),
};
/**
* Show a destructive action confirmation
* Red "Delete" button style in Telegram
*/
export function useDestructiveConfirm() {
const dialog = useNativeDialog();
return useCallback(
async (message: string, actionText = 'Delete', title?: string) => {
if (dialog.isNative) {
const result = await dialog.popup({
title,
message,
buttons: [PopupButtons.cancel(), PopupButtons.destructive('confirm', actionText)],
});
return result === 'confirm';
}
return dialog.confirm(message, title);
},
[dialog],
);
}

View File

@@ -0,0 +1,35 @@
import { useContext } from 'react';
import { PlatformContext } from '@/platform/PlatformContext';
import type { PlatformContext as PlatformContextType } from '@/platform/types';
/**
* Hook to access the platform context
* Provides platform-aware APIs for Telegram Mini Apps and web fallback
*/
export function usePlatform(): PlatformContextType {
const context = useContext(PlatformContext);
if (!context) {
throw new Error('usePlatform must be used within a PlatformProvider');
}
return context;
}
/**
* Check if running in Telegram Mini App
*/
export function useIsTelegram(): boolean {
const { platform } = usePlatform();
return platform === 'telegram';
}
/**
* Check if a specific capability is available
*/
export function useCapability(capability: keyof PlatformContextType['capabilities']): boolean {
const { capabilities } = usePlatform();
const value = capabilities[capability];
// version is the only string capability, all others are boolean
return typeof value === 'boolean' ? value : !!value;
}