268 lines
7.3 KiB
TypeScript
268 lines
7.3 KiB
TypeScript
/**
|
|
* Telegram Mini App React Components
|
|
* Copy-paste into your project
|
|
*
|
|
* Requires: hooks.ts from this same skill
|
|
*/
|
|
|
|
import React, { useState, useCallback, useEffect, ReactNode, CSSProperties } from 'react';
|
|
import { createPortal } from 'react-dom';
|
|
import { useSafeAreaInset, useFullscreen, useTelegramTheme, getWebApp, isDevMode } from './hooks';
|
|
|
|
// ============================================================================
|
|
// SafeAreaHeader
|
|
// ============================================================================
|
|
|
|
interface SafeAreaHeaderProps {
|
|
children: ReactNode;
|
|
className?: string;
|
|
style?: CSSProperties;
|
|
transparent?: boolean;
|
|
backgroundColor?: string;
|
|
position?: 'fixed' | 'sticky';
|
|
blur?: boolean;
|
|
height?: number;
|
|
border?: boolean;
|
|
zIndex?: number;
|
|
}
|
|
|
|
/**
|
|
* Header with proper safe area handling
|
|
*
|
|
* @example
|
|
* <SafeAreaHeader blur border>
|
|
* <h1>My App</h1>
|
|
* </SafeAreaHeader>
|
|
*/
|
|
export function SafeAreaHeader({
|
|
children,
|
|
className = '',
|
|
style = {},
|
|
transparent = false,
|
|
backgroundColor,
|
|
position = 'sticky',
|
|
blur = false,
|
|
height = 56,
|
|
border = false,
|
|
zIndex = 1000,
|
|
}: SafeAreaHeaderProps) {
|
|
const { totalTop } = useSafeAreaInset();
|
|
const { params, isDark } = useTelegramTheme();
|
|
|
|
const bgColor = backgroundColor
|
|
?? (transparent ? 'transparent' : (params.bg_color ?? '#0f0f1a'));
|
|
|
|
const headerStyle: CSSProperties = {
|
|
position,
|
|
top: 0,
|
|
left: 0,
|
|
right: 0,
|
|
zIndex,
|
|
paddingTop: totalTop,
|
|
backgroundColor: blur ? `${bgColor}cc` : bgColor,
|
|
backdropFilter: blur ? 'blur(20px)' : undefined,
|
|
WebkitBackdropFilter: blur ? 'blur(20px)' : undefined,
|
|
borderBottom: border
|
|
? `1px solid ${isDark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)'}`
|
|
: undefined,
|
|
...style,
|
|
};
|
|
|
|
const contentStyle: CSSProperties = {
|
|
height,
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
paddingLeft: 16,
|
|
paddingRight: 16,
|
|
};
|
|
|
|
return (
|
|
<header style={headerStyle} className={className}>
|
|
<div style={contentStyle}>{children}</div>
|
|
</header>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Spacer for fixed SafeAreaHeader
|
|
*/
|
|
export function SafeAreaHeaderSpacer({ height = 56 }: { height?: number }) {
|
|
const { totalTop } = useSafeAreaInset();
|
|
return <div style={{ height: totalTop + height }} />;
|
|
}
|
|
|
|
// ============================================================================
|
|
// DebugOverlay
|
|
// ============================================================================
|
|
|
|
interface DebugOverlayProps {
|
|
forceShow?: boolean;
|
|
defaultOpen?: boolean;
|
|
position?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
|
|
}
|
|
|
|
/**
|
|
* Debug panel showing safe areas, viewport, platform info
|
|
* Only visible on localhost or with ?debug=1
|
|
*
|
|
* @example
|
|
* <DebugOverlay /> // Add to app root
|
|
*/
|
|
export function DebugOverlay({
|
|
forceShow = false,
|
|
defaultOpen = false,
|
|
position = 'bottom-right',
|
|
}: DebugOverlayProps) {
|
|
const [isOpen, setIsOpen] = useState(defaultOpen);
|
|
const [copiedKey, setCopiedKey] = useState<string | null>(null);
|
|
|
|
const insets = useSafeAreaInset();
|
|
const { isFullscreen } = useFullscreen();
|
|
|
|
if (!forceShow && !isDevMode()) return null;
|
|
|
|
const webApp = getWebApp();
|
|
|
|
const values = [
|
|
{ label: 'Platform', value: webApp?.platform ?? 'unknown' },
|
|
{ label: 'Version', value: webApp?.version ?? 'N/A' },
|
|
{ label: 'Fullscreen', value: isFullscreen ? '✓' : '✗' },
|
|
{ label: 'Viewport', value: `${window.innerWidth}×${webApp?.viewportHeight ?? window.innerHeight}` },
|
|
{ label: 'Safe Top', value: insets.device.top },
|
|
{ label: 'Safe Bottom', value: insets.device.bottom },
|
|
{ label: 'Content Top', value: insets.content.top },
|
|
{ label: 'Content Bottom', value: insets.content.bottom },
|
|
{ label: '⚡ Total Top', value: insets.totalTop },
|
|
{ label: '⚡ Total Bottom', value: insets.totalBottom },
|
|
];
|
|
|
|
const handleCopy = async (label: string, value: any) => {
|
|
await navigator.clipboard.writeText(String(value));
|
|
setCopiedKey(label);
|
|
setTimeout(() => setCopiedKey(null), 1000);
|
|
};
|
|
|
|
const positionStyle: CSSProperties = {
|
|
'top-left': { top: 8, left: 8 },
|
|
'top-right': { top: 8, right: 8 },
|
|
'bottom-left': { bottom: 8, left: 8 },
|
|
'bottom-right': { bottom: 8, right: 8 },
|
|
}[position];
|
|
|
|
return createPortal(
|
|
<>
|
|
<button
|
|
onClick={() => setIsOpen(!isOpen)}
|
|
style={{
|
|
position: 'fixed',
|
|
...positionStyle,
|
|
zIndex: 100000,
|
|
width: 44,
|
|
height: 44,
|
|
borderRadius: '50%',
|
|
border: 'none',
|
|
backgroundColor: isOpen ? '#ef4444' : 'rgba(0,0,0,0.7)',
|
|
color: 'white',
|
|
fontSize: 20,
|
|
cursor: 'pointer',
|
|
}}
|
|
>
|
|
{isOpen ? '✕' : '🐛'}
|
|
</button>
|
|
|
|
{isOpen && (
|
|
<div
|
|
style={{
|
|
position: 'fixed',
|
|
...positionStyle,
|
|
[position.includes('right') ? 'right' : 'left']: 60,
|
|
zIndex: 99999,
|
|
backgroundColor: 'rgba(0,0,0,0.9)',
|
|
color: '#fff',
|
|
borderRadius: 12,
|
|
padding: 12,
|
|
fontSize: 12,
|
|
fontFamily: 'monospace',
|
|
minWidth: 180,
|
|
}}
|
|
>
|
|
<div style={{ fontWeight: 600, marginBottom: 8, opacity: 0.7 }}>TG DEBUG</div>
|
|
{values.map(({ label, value }) => (
|
|
<div
|
|
key={label}
|
|
onClick={() => handleCopy(label, value)}
|
|
style={{
|
|
display: 'flex',
|
|
justifyContent: 'space-between',
|
|
padding: '4px 6px',
|
|
cursor: 'pointer',
|
|
backgroundColor: copiedKey === label ? 'rgba(34,197,94,0.3)' : 'transparent',
|
|
borderRadius: 4,
|
|
}}
|
|
>
|
|
<span style={{ opacity: 0.7 }}>{label}</span>
|
|
<span>{value}</span>
|
|
</div>
|
|
))}
|
|
<div style={{ marginTop: 8, opacity: 0.5, fontSize: 10, textAlign: 'center' }}>
|
|
Tap to copy
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>,
|
|
document.body
|
|
);
|
|
}
|
|
|
|
// ============================================================================
|
|
// Modal (uses createPortal to fix position:fixed issue)
|
|
// ============================================================================
|
|
|
|
interface ModalProps {
|
|
children: ReactNode;
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
zIndex?: number;
|
|
}
|
|
|
|
/**
|
|
* Modal that works in Telegram Mini Apps
|
|
* Uses createPortal to avoid position:fixed issues
|
|
*
|
|
* @example
|
|
* <Modal isOpen={show} onClose={() => setShow(false)}>
|
|
* <div>Modal content</div>
|
|
* </Modal>
|
|
*/
|
|
export function Modal({ children, isOpen, onClose, zIndex = 9999 }: ModalProps) {
|
|
const { totalBottom } = useSafeAreaInset();
|
|
|
|
useEffect(() => {
|
|
if (isOpen) {
|
|
document.body.style.overflow = 'hidden';
|
|
return () => { document.body.style.overflow = ''; };
|
|
}
|
|
}, [isOpen]);
|
|
|
|
if (!isOpen) return null;
|
|
|
|
return createPortal(
|
|
<div
|
|
style={{
|
|
position: 'fixed',
|
|
inset: 0,
|
|
zIndex,
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
backgroundColor: 'rgba(0,0,0,0.5)',
|
|
paddingBottom: totalBottom,
|
|
}}
|
|
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
|
>
|
|
{children}
|
|
</div>,
|
|
document.body
|
|
);
|
|
}
|