diff --git a/apps/frontend/app/globals.css b/apps/frontend/app/globals.css index f364395..3ef315b 100644 --- a/apps/frontend/app/globals.css +++ b/apps/frontend/app/globals.css @@ -61,6 +61,21 @@ html { --rdp-accent-background-color: #f4f5f8; } +/* Leaflet внутри чата/форм не должен перекрывать модальные окна */ +.embedded-leaflet-map { + position: relative; + z-index: 0; + isolation: isolate; +} + +.embedded-leaflet-map .leaflet-container, +.embedded-leaflet-map .leaflet-pane, +.embedded-leaflet-map .leaflet-top, +.embedded-leaflet-map .leaflet-bottom, +.embedded-leaflet-map .leaflet-control { + z-index: 1 !important; +} + @layer base { a { color: inherit; diff --git a/apps/frontend/components/addresses/address-map-picker.tsx b/apps/frontend/components/addresses/address-map-picker.tsx index 6361efe..54266f9 100644 --- a/apps/frontend/components/addresses/address-map-picker.tsx +++ b/apps/frontend/components/addresses/address-map-picker.tsx @@ -93,7 +93,7 @@ export function AddressMapPicker({ return (
-
+

Нажмите на карту или перетащите маркер, чтобы указать адрес

diff --git a/apps/frontend/components/chat/chat-image-lightbox.tsx b/apps/frontend/components/chat/chat-image-lightbox.tsx index a09e1c2..9f2ce45 100644 --- a/apps/frontend/components/chat/chat-image-lightbox.tsx +++ b/apps/frontend/components/chat/chat-image-lightbox.tsx @@ -44,7 +44,7 @@ export function ChatImageLightbox({ const media = mediaUrls[current.storageKey]; return ( -
+

{current.senderName}

diff --git a/apps/frontend/components/chat/chat-location-card.tsx b/apps/frontend/components/chat/chat-location-card.tsx index d2be6e0..79bec5e 100644 --- a/apps/frontend/components/chat/chat-location-card.tsx +++ b/apps/frontend/components/chat/chat-location-card.tsx @@ -89,7 +89,7 @@ export function ChatLocationCard({ variant === 'mine' ? 'border-[#7ea06a]/35 bg-white' : 'border-[#dce3ec] bg-white' )} > -
+
diff --git a/apps/frontend/components/chat/chat-video-lightbox.tsx b/apps/frontend/components/chat/chat-video-lightbox.tsx new file mode 100644 index 0000000..ab41368 --- /dev/null +++ b/apps/frontend/components/chat/chat-video-lightbox.tsx @@ -0,0 +1,269 @@ +'use client'; + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { Loader2, Pause, Play, Volume2, VolumeX, X } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; + +function formatDuration(seconds: number) { + if (!Number.isFinite(seconds) || seconds < 0) return '0:00'; + const whole = Math.floor(seconds); + const mins = Math.floor(whole / 60); + const secs = whole % 60; + return `${mins}:${secs.toString().padStart(2, '0')}`; +} + +export function ChatVideoLightbox({ + open, + src, + fileName, + durationMs, + onClose +}: { + open: boolean; + src: string; + fileName?: string; + durationMs?: number; + onClose: () => void; +}) { + const videoRef = useRef(null); + const hideControlsTimerRef = useRef | null>(null); + const [mounted, setMounted] = useState(false); + const [playing, setPlaying] = useState(false); + const [loading, setLoading] = useState(true); + const [muted, setMuted] = useState(false); + const [progress, setProgress] = useState(0); + const [duration, setDuration] = useState(durationMs ? durationMs / 1000 : 0); + const [controlsVisible, setControlsVisible] = useState(true); + const [scrubbing, setScrubbing] = useState(false); + + useEffect(() => { + setMounted(true); + }, []); + + const revealControls = useCallback(() => { + setControlsVisible(true); + if (hideControlsTimerRef.current) clearTimeout(hideControlsTimerRef.current); + hideControlsTimerRef.current = setTimeout(() => { + if (playing) setControlsVisible(false); + }, 2800); + }, [playing]); + + const togglePlayback = useCallback(() => { + const video = videoRef.current; + if (!video) return; + if (video.paused) { + void video.play(); + } else { + video.pause(); + } + revealControls(); + }, [revealControls]); + + useEffect(() => { + if (!open) return; + function handleKeyDown(event: KeyboardEvent) { + if (event.key === 'Escape') onClose(); + if (event.key === ' ') { + event.preventDefault(); + togglePlayback(); + } + } + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [open, onClose, togglePlayback]); + + useEffect(() => { + if (!open || typeof document === 'undefined') return; + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + return () => { + document.body.style.overflow = previousOverflow; + }; + }, [open]); + + useEffect(() => { + const video = videoRef.current; + if (!video || !open) return; + + const onLoaded = () => { + if (Number.isFinite(video.duration) && video.duration > 0) { + setDuration(video.duration); + } + setLoading(false); + }; + const onTimeUpdate = () => { + if (!scrubbing && video.duration) { + setProgress(video.currentTime / video.duration); + } + }; + const onPlay = () => { + setPlaying(true); + revealControls(); + }; + const onPause = () => { + setPlaying(false); + setControlsVisible(true); + }; + const onEnded = () => { + setPlaying(false); + setControlsVisible(true); + }; + + video.addEventListener('loadedmetadata', onLoaded); + video.addEventListener('timeupdate', onTimeUpdate); + video.addEventListener('play', onPlay); + video.addEventListener('pause', onPause); + video.addEventListener('ended', onEnded); + + return () => { + video.removeEventListener('loadedmetadata', onLoaded); + video.removeEventListener('timeupdate', onTimeUpdate); + video.removeEventListener('play', onPlay); + video.removeEventListener('pause', onPause); + video.removeEventListener('ended', onEnded); + }; + }, [open, revealControls, scrubbing, src]); + + useEffect(() => { + if (!open) return; + const video = videoRef.current; + if (!video) return; + setLoading(true); + setProgress(0); + setPlaying(false); + video.src = src; + video.load(); + void video.play().catch(() => undefined); + }, [open, src]); + + useEffect(() => { + return () => { + if (hideControlsTimerRef.current) clearTimeout(hideControlsTimerRef.current); + }; + }, []); + + function seekTo(ratio: number) { + const video = videoRef.current; + if (!video || !video.duration) return; + const next = Math.min(Math.max(ratio, 0), 1); + video.currentTime = next * video.duration; + setProgress(next); + } + + if (!open || !mounted) return null; + + return createPortal( +
+
+
+

{fileName?.trim() || 'Видео'}

+

Полноэкранный просмотр

+
+ +
+ +
+
+ +
+
+ { + setScrubbing(true); + seekTo(Number(event.target.value) / 1000); + }} + onMouseUp={() => setScrubbing(false)} + onTouchEnd={() => setScrubbing(false)} + aria-label="Позиция воспроизведения" + /> + +
+ + + + {formatDuration(progress * duration)} / {formatDuration(duration)} + + +
+ + +
+
+
+
, + document.body + ); +} diff --git a/apps/frontend/components/chat/chat-video-player.tsx b/apps/frontend/components/chat/chat-video-player.tsx index fa8ead6..bf6f291 100644 --- a/apps/frontend/components/chat/chat-video-player.tsx +++ b/apps/frontend/components/chat/chat-video-player.tsx @@ -1,7 +1,8 @@ 'use client'; import { useEffect, useRef, useState } from 'react'; -import { Loader2, Pause, Play } from 'lucide-react'; +import { Loader2, Maximize2, Pause, Play } from 'lucide-react'; +import { ChatVideoLightbox } from '@/components/chat/chat-video-lightbox'; import { cn } from '@/lib/utils'; function formatDuration(seconds: number) { @@ -16,17 +17,20 @@ export function ChatVideoPlayer({ src, durationMs, fileName, - variant = 'theirs' + variant = 'theirs', + enableFullscreen = true }: { src: string; durationMs?: number; fileName?: string; variant?: 'mine' | 'theirs'; + enableFullscreen?: boolean; }) { const videoRef = useRef(null); const [playing, setPlaying] = useState(false); const [loading, setLoading] = useState(true); const [duration, setDuration] = useState(durationMs ? durationMs / 1000 : 0); + const [fullscreenOpen, setFullscreenOpen] = useState(false); useEffect(() => { const video = videoRef.current; @@ -65,43 +69,74 @@ export function ChatVideoPlayer({ } } + function openFullscreen(event: React.MouseEvent) { + event.stopPropagation(); + videoRef.current?.pause(); + setPlaying(false); + setFullscreenOpen(true); + } + return ( -
-
+ + {enableFullscreen ? ( + setFullscreenOpen(false)} + /> ) : null} -
+ ); } diff --git a/apps/frontend/components/id/pin-lock-modal.tsx b/apps/frontend/components/id/pin-lock-modal.tsx index adf3062..a2cdd96 100644 --- a/apps/frontend/components/id/pin-lock-modal.tsx +++ b/apps/frontend/components/id/pin-lock-modal.tsx @@ -1,9 +1,9 @@ 'use client'; import * as React from 'react'; +import { createPortal } from 'react-dom'; import { DoorOpen, KeyRound } from 'lucide-react'; import { Button } from '@/components/ui/button'; -import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { PinInput } from '@/components/ui/pin-input'; import { isPinInputComplete } from '@/lib/pin-input'; @@ -17,6 +17,11 @@ interface PinLockModalProps { export function PinLockModal({ open, isSubmitting, error, onSubmit, onLogout }: PinLockModalProps) { const [pin, setPin] = React.useState(''); + const [mounted, setMounted] = React.useState(false); + + React.useEffect(() => { + setMounted(true); + }, []); React.useEffect(() => { if (open) { @@ -24,18 +29,32 @@ export function PinLockModal({ open, isSubmitting, error, onSubmit, onLogout }: } }, [open]); + React.useEffect(() => { + if (!open || typeof document === 'undefined') return; + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + return () => { + document.body.style.overflow = previousOverflow; + }; + }, [open]); + async function handleSubmit(event: React.FormEvent) { event.preventDefault(); await onSubmit(pin); } - return ( - undefined}> - event.preventDefault()} - onEscapeKeyDown={(event) => event.preventDefault()} + if (!open || !mounted) return null; + + return createPortal( +
+
+ +
{onLogout ? (
+
+
, + document.body ); } diff --git a/apps/frontend/components/ui/dialog.tsx b/apps/frontend/components/ui/dialog.tsx index 747069b..69cf13b 100644 --- a/apps/frontend/components/ui/dialog.tsx +++ b/apps/frontend/components/ui/dialog.tsx @@ -31,8 +31,8 @@ export function DialogContent({ margin: 0 }; - const overlayZ = placement === 'upper' ? 'z-[300]' : 'z-[200]'; - const contentZ = placement === 'upper' ? 'z-[301]' : 'z-[201]'; + const overlayZ = 'z-[5000]'; + const contentZ = 'z-[5001]'; return (