fix
This commit is contained in:
@@ -61,6 +61,21 @@ html {
|
|||||||
--rdp-accent-background-color: #f4f5f8;
|
--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 {
|
@layer base {
|
||||||
a {
|
a {
|
||||||
color: inherit;
|
color: inherit;
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ export function AddressMapPicker({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-hidden rounded-2xl border border-[#eceef4]">
|
<div className="overflow-hidden rounded-2xl border border-[#eceef4]">
|
||||||
<div ref={mapRef} className="h-[280px] w-full" />
|
<div ref={mapRef} className="embedded-leaflet-map h-[280px] w-full" />
|
||||||
<p className="border-t border-[#eceef4] bg-[#fafbfd] px-4 py-2 text-xs text-[#667085]">
|
<p className="border-t border-[#eceef4] bg-[#fafbfd] px-4 py-2 text-xs text-[#667085]">
|
||||||
Нажмите на карту или перетащите маркер, чтобы указать адрес
|
Нажмите на карту или перетащите маркер, чтобы указать адрес
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export function ChatImageLightbox({
|
|||||||
const media = mediaUrls[current.storageKey];
|
const media = mediaUrls[current.storageKey];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-[250] flex flex-col bg-[#0f1117]/96">
|
<div className="fixed inset-0 z-[5000] flex flex-col bg-[#0f1117]/96">
|
||||||
<div className="flex items-center justify-between px-4 py-3 text-white">
|
<div className="flex items-center justify-between px-4 py-3 text-white">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="truncate text-sm font-medium">{current.senderName}</p>
|
<p className="truncate text-sm font-medium">{current.senderName}</p>
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ export function ChatLocationCard({
|
|||||||
variant === 'mine' ? 'border-[#7ea06a]/35 bg-white' : 'border-[#dce3ec] bg-white'
|
variant === 'mine' ? 'border-[#7ea06a]/35 bg-white' : 'border-[#dce3ec] bg-white'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div ref={mapRef} className="h-[160px] w-full" />
|
<div ref={mapRef} className="embedded-leaflet-map h-[160px] w-full" />
|
||||||
<div className="flex items-start gap-2 px-3 py-2.5">
|
<div className="flex items-start gap-2 px-3 py-2.5">
|
||||||
<MapPin className={cn('mt-0.5 h-4 w-4 shrink-0', variant === 'mine' ? 'text-[#7ea06a]' : 'text-[#3390ec]')} />
|
<MapPin className={cn('mt-0.5 h-4 w-4 shrink-0', variant === 'mine' ? 'text-[#7ea06a]' : 'text-[#3390ec]')} />
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
|
|||||||
269
apps/frontend/components/chat/chat-video-lightbox.tsx
Normal file
269
apps/frontend/components/chat/chat-video-lightbox.tsx
Normal file
@@ -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<HTMLVideoElement | null>(null);
|
||||||
|
const hideControlsTimerRef = useRef<ReturnType<typeof setTimeout> | 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(
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[5500] flex flex-col bg-[#05070d]/96"
|
||||||
|
onMouseMove={revealControls}
|
||||||
|
onClick={revealControls}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex items-center justify-between px-4 py-3 text-white transition-opacity duration-300',
|
||||||
|
controlsVisible ? 'opacity-100' : 'opacity-0'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-sm font-medium">{fileName?.trim() || 'Видео'}</p>
|
||||||
|
<p className="text-xs text-white/55">Полноэкранный просмотр</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="text-white hover:bg-white/10"
|
||||||
|
aria-label="Закрыть"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative flex min-h-0 flex-1 items-center justify-center px-4 pb-4">
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
src={src}
|
||||||
|
className="max-h-full max-w-full rounded-2xl bg-black shadow-[0_24px_80px_rgba(0,0,0,0.45)]"
|
||||||
|
playsInline
|
||||||
|
onClick={togglePlayback}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||||
|
<Loader2 className="h-12 w-12 animate-spin text-white/80" />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!playing && !loading ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={togglePlayback}
|
||||||
|
className="absolute flex h-20 w-20 items-center justify-center rounded-full bg-white/15 text-white backdrop-blur-sm transition hover:bg-white/25"
|
||||||
|
aria-label="Воспроизвести"
|
||||||
|
>
|
||||||
|
<Play className="ml-1 h-10 w-10" />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'px-4 pb-[max(1rem,env(safe-area-inset-bottom))] transition-opacity duration-300',
|
||||||
|
controlsVisible ? 'opacity-100' : 'opacity-0'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="mx-auto w-full max-w-3xl rounded-2xl bg-black/45 px-4 py-3 backdrop-blur-md">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={1000}
|
||||||
|
value={Math.round(progress * 1000)}
|
||||||
|
className="h-1.5 w-full cursor-pointer appearance-none rounded-full bg-white/20 accent-[#3390ec]"
|
||||||
|
onChange={(event) => {
|
||||||
|
setScrubbing(true);
|
||||||
|
seekTo(Number(event.target.value) / 1000);
|
||||||
|
}}
|
||||||
|
onMouseUp={() => setScrubbing(false)}
|
||||||
|
onTouchEnd={() => setScrubbing(false)}
|
||||||
|
aria-label="Позиция воспроизведения"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mt-3 flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={togglePlayback}
|
||||||
|
className="flex h-10 w-10 items-center justify-center rounded-full bg-white/10 text-white transition hover:bg-white/20"
|
||||||
|
aria-label={playing ? 'Пауза' : 'Воспроизвести'}
|
||||||
|
>
|
||||||
|
{playing ? <Pause className="h-5 w-5" /> : <Play className="ml-0.5 h-5 w-5" />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span className="min-w-[88px] text-xs tabular-nums text-white/80">
|
||||||
|
{formatDuration(progress * duration)} / {formatDuration(duration)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div className="flex-1" />
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!video) return;
|
||||||
|
video.muted = !video.muted;
|
||||||
|
setMuted(video.muted);
|
||||||
|
}}
|
||||||
|
className="flex h-10 w-10 items-center justify-center rounded-full bg-white/10 text-white transition hover:bg-white/20"
|
||||||
|
aria-label={muted ? 'Включить звук' : 'Выключить звук'}
|
||||||
|
>
|
||||||
|
{muted ? <VolumeX className="h-5 w-5" /> : <Volume2 className="h-5 w-5" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from 'react';
|
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';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
function formatDuration(seconds: number) {
|
function formatDuration(seconds: number) {
|
||||||
@@ -16,17 +17,20 @@ export function ChatVideoPlayer({
|
|||||||
src,
|
src,
|
||||||
durationMs,
|
durationMs,
|
||||||
fileName,
|
fileName,
|
||||||
variant = 'theirs'
|
variant = 'theirs',
|
||||||
|
enableFullscreen = true
|
||||||
}: {
|
}: {
|
||||||
src: string;
|
src: string;
|
||||||
durationMs?: number;
|
durationMs?: number;
|
||||||
fileName?: string;
|
fileName?: string;
|
||||||
variant?: 'mine' | 'theirs';
|
variant?: 'mine' | 'theirs';
|
||||||
|
enableFullscreen?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||||
const [playing, setPlaying] = useState(false);
|
const [playing, setPlaying] = useState(false);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [duration, setDuration] = useState(durationMs ? durationMs / 1000 : 0);
|
const [duration, setDuration] = useState(durationMs ? durationMs / 1000 : 0);
|
||||||
|
const [fullscreenOpen, setFullscreenOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const video = videoRef.current;
|
const video = videoRef.current;
|
||||||
@@ -65,43 +69,74 @@ export function ChatVideoPlayer({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openFullscreen(event: React.MouseEvent<HTMLButtonElement>) {
|
||||||
|
event.stopPropagation();
|
||||||
|
videoRef.current?.pause();
|
||||||
|
setPlaying(false);
|
||||||
|
setFullscreenOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<>
|
||||||
className={cn(
|
<div
|
||||||
'relative max-w-[320px] overflow-hidden rounded-xl',
|
className={cn(
|
||||||
variant === 'mine' ? 'bg-[#d9f7c8]/40' : 'bg-black/5'
|
'group relative max-w-[320px] overflow-hidden rounded-xl',
|
||||||
)}
|
variant === 'mine' ? 'bg-[#d9f7c8]/40' : 'bg-black/5'
|
||||||
>
|
|
||||||
<video
|
|
||||||
ref={videoRef}
|
|
||||||
src={src}
|
|
||||||
className="max-h-72 w-full bg-black object-contain"
|
|
||||||
playsInline
|
|
||||||
preload="metadata"
|
|
||||||
onClick={togglePlayback}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={togglePlayback}
|
|
||||||
className="absolute inset-0 flex items-center justify-center bg-black/15 transition hover:bg-black/25"
|
|
||||||
aria-label={playing ? 'Пауза' : 'Воспроизвести'}
|
|
||||||
>
|
|
||||||
{loading ? (
|
|
||||||
<Loader2 className="h-10 w-10 animate-spin text-white" />
|
|
||||||
) : playing ? (
|
|
||||||
<Pause className="h-10 w-10 text-white drop-shadow" />
|
|
||||||
) : (
|
|
||||||
<Play className="h-10 w-10 text-white drop-shadow" />
|
|
||||||
)}
|
)}
|
||||||
</button>
|
data-chat-interactive="true"
|
||||||
<div className="absolute bottom-2 left-2 rounded-full bg-black/55 px-2 py-0.5 text-[11px] text-white">
|
>
|
||||||
{formatDuration(duration)}
|
<video
|
||||||
</div>
|
ref={videoRef}
|
||||||
{fileName ? (
|
src={src}
|
||||||
<div className="truncate px-2 py-1 text-xs text-[#667085]" title={fileName}>
|
className="max-h-72 w-full bg-black object-contain"
|
||||||
{fileName}
|
playsInline
|
||||||
|
preload="metadata"
|
||||||
|
onClick={togglePlayback}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={togglePlayback}
|
||||||
|
className="absolute inset-0 flex items-center justify-center bg-black/15 transition hover:bg-black/25"
|
||||||
|
aria-label={playing ? 'Пауза' : 'Воспроизвести'}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<Loader2 className="h-10 w-10 animate-spin text-white" />
|
||||||
|
) : playing ? (
|
||||||
|
<Pause className="h-10 w-10 text-white drop-shadow" />
|
||||||
|
) : (
|
||||||
|
<Play className="h-10 w-10 text-white drop-shadow" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{enableFullscreen ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={openFullscreen}
|
||||||
|
className="absolute right-2 top-2 flex h-8 w-8 items-center justify-center rounded-full bg-black/50 text-white opacity-0 transition group-hover:opacity-100 hover:bg-black/70"
|
||||||
|
aria-label="Открыть на весь экран"
|
||||||
|
title="На весь экран"
|
||||||
|
>
|
||||||
|
<Maximize2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
<div className="absolute bottom-2 left-2 rounded-full bg-black/55 px-2 py-0.5 text-[11px] text-white">
|
||||||
|
{formatDuration(duration)}
|
||||||
</div>
|
</div>
|
||||||
|
{fileName ? (
|
||||||
|
<div className="truncate px-2 py-1 text-xs text-[#667085]" title={fileName}>
|
||||||
|
{fileName}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{enableFullscreen ? (
|
||||||
|
<ChatVideoLightbox
|
||||||
|
open={fullscreenOpen}
|
||||||
|
src={src}
|
||||||
|
fileName={fileName}
|
||||||
|
durationMs={durationMs}
|
||||||
|
onClose={() => setFullscreenOpen(false)}
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
import { DoorOpen, KeyRound } from 'lucide-react';
|
import { DoorOpen, KeyRound } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
|
||||||
import { PinInput } from '@/components/ui/pin-input';
|
import { PinInput } from '@/components/ui/pin-input';
|
||||||
import { isPinInputComplete } from '@/lib/pin-input';
|
import { isPinInputComplete } from '@/lib/pin-input';
|
||||||
|
|
||||||
@@ -17,6 +17,11 @@ interface PinLockModalProps {
|
|||||||
|
|
||||||
export function PinLockModal({ open, isSubmitting, error, onSubmit, onLogout }: PinLockModalProps) {
|
export function PinLockModal({ open, isSubmitting, error, onSubmit, onLogout }: PinLockModalProps) {
|
||||||
const [pin, setPin] = React.useState('');
|
const [pin, setPin] = React.useState('');
|
||||||
|
const [mounted, setMounted] = React.useState(false);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
@@ -24,18 +29,32 @@ export function PinLockModal({ open, isSubmitting, error, onSubmit, onLogout }:
|
|||||||
}
|
}
|
||||||
}, [open]);
|
}, [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<HTMLFormElement>) {
|
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
await onSubmit(pin);
|
await onSubmit(pin);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
if (!open || !mounted) return null;
|
||||||
<Dialog open={open} onOpenChange={() => undefined}>
|
|
||||||
<DialogContent
|
return createPortal(
|
||||||
placement="upper"
|
<div className="fixed inset-0 z-[6000]">
|
||||||
className="relative [&>button]:hidden"
|
<div className="absolute inset-0 bg-black/50 backdrop-blur-[1px]" aria-hidden />
|
||||||
onPointerDownOutside={(event) => event.preventDefault()}
|
|
||||||
onEscapeKeyDown={(event) => event.preventDefault()}
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="pin-lock-title"
|
||||||
|
className="fixed left-1/2 w-[min(92vw,520px)] -translate-x-1/2 rounded-[28px] bg-white p-6 shadow-2xl"
|
||||||
|
style={{ top: 'max(1.5rem, 10vh)' }}
|
||||||
>
|
>
|
||||||
{onLogout ? (
|
{onLogout ? (
|
||||||
<button
|
<button
|
||||||
@@ -50,15 +69,17 @@ export function PinLockModal({ open, isSubmitting, error, onSubmit, onLogout }:
|
|||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<DialogHeader>
|
<div className="mb-5 flex flex-col gap-1">
|
||||||
<div className="mx-auto mb-2 flex h-14 w-14 items-center justify-center rounded-full bg-[#f4f5f8]">
|
<div className="mx-auto mb-2 flex h-14 w-14 items-center justify-center rounded-full bg-[#f4f5f8]">
|
||||||
<KeyRound className="h-7 w-7 text-[#111]" />
|
<KeyRound className="h-7 w-7 text-[#111]" />
|
||||||
</div>
|
</div>
|
||||||
<DialogTitle className="text-center">Введите PIN-код</DialogTitle>
|
<h2 id="pin-lock-title" className="text-center text-xl font-semibold">
|
||||||
|
Введите PIN-код
|
||||||
|
</h2>
|
||||||
<p className="text-center text-sm text-[#6b6f7b]">
|
<p className="text-center text-sm text-[#6b6f7b]">
|
||||||
Сессия заблокирована из-за бездействия. Подтвердите PIN, чтобы продолжить работу.
|
Сессия заблокирована из-за бездействия. Подтвердите PIN, чтобы продолжить работу.
|
||||||
</p>
|
</p>
|
||||||
</DialogHeader>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<PinInput
|
<PinInput
|
||||||
@@ -89,7 +110,8 @@ export function PinLockModal({ open, isSubmitting, error, onSubmit, onLogout }:
|
|||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
</form>
|
</form>
|
||||||
</DialogContent>
|
</div>
|
||||||
</Dialog>
|
</div>,
|
||||||
|
document.body
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,8 +31,8 @@ export function DialogContent({
|
|||||||
margin: 0
|
margin: 0
|
||||||
};
|
};
|
||||||
|
|
||||||
const overlayZ = placement === 'upper' ? 'z-[300]' : 'z-[200]';
|
const overlayZ = 'z-[5000]';
|
||||||
const contentZ = placement === 'upper' ? 'z-[301]' : 'z-[201]';
|
const contentZ = 'z-[5001]';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DialogPrimitive.Portal>
|
<DialogPrimitive.Portal>
|
||||||
|
|||||||
Reference in New Issue
Block a user