This commit is contained in:
lendry
2026-07-10 10:37:22 +03:00
parent caf12e64f7
commit a4b4577c55
13 changed files with 669 additions and 48 deletions

View File

@@ -0,0 +1,103 @@
'use client';
import { useEffect, useRef } from 'react';
import L from 'leaflet';
import { ExternalLink, MapPin } from 'lucide-react';
import { cn } from '@/lib/utils';
let iconsFixed = false;
function fixLeafletIcons() {
if (iconsFixed || typeof window === 'undefined') return;
iconsFixed = true;
L.Icon.Default.mergeOptions({
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
iconRetinaUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png',
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
iconSize: [25, 41],
iconAnchor: [12, 41],
popupAnchor: [1, -34],
shadowSize: [41, 41]
});
}
function buildMapsUrl(latitude: number, longitude: number) {
return `https://yandex.ru/maps/?pt=${longitude},${latitude}&z=16&l=map`;
}
export function ChatLocationCard({
latitude,
longitude,
address,
label,
variant = 'theirs'
}: {
latitude: number;
longitude: number;
address?: string;
label?: string;
variant?: 'mine' | 'theirs';
}) {
const mapRef = useRef<HTMLDivElement>(null);
const mapInstance = useRef<L.Map | null>(null);
const title = label?.trim() || address?.trim() || `${latitude.toFixed(5)}, ${longitude.toFixed(5)}`;
const mapsUrl = buildMapsUrl(latitude, longitude);
useEffect(() => {
fixLeafletIcons();
if (!mapRef.current || mapInstance.current) return;
const map = L.map(mapRef.current, {
zoomControl: false,
attributionControl: true,
dragging: false,
scrollWheelZoom: false,
doubleClickZoom: false,
boxZoom: false,
keyboard: false,
touchZoom: false
}).setView([latitude, longitude], 15);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; OpenStreetMap'
}).addTo(map);
L.marker([latitude, longitude]).addTo(map);
mapInstance.current = map;
const resizeObserver = new ResizeObserver(() => {
map.invalidateSize();
});
resizeObserver.observe(mapRef.current);
return () => {
resizeObserver.disconnect();
map.remove();
mapInstance.current = null;
};
}, [latitude, longitude]);
return (
<a
href={mapsUrl}
target="_blank"
rel="noopener noreferrer"
data-chat-interactive="true"
className={cn(
'block w-[min(100%,280px)] overflow-hidden rounded-2xl border transition hover:shadow-md',
variant === 'mine' ? 'border-[#7ea06a]/35 bg-white' : 'border-[#dce3ec] bg-white'
)}
>
<div ref={mapRef} className="h-[160px] w-full" />
<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]')} />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-[#1f2430]">{title}</p>
{address && label ? <p className="truncate text-xs text-[#667085]">{address}</p> : null}
</div>
<ExternalLink className="mt-0.5 h-4 w-4 shrink-0 text-[#667085]" />
</div>
</a>
);
}

View File

@@ -0,0 +1,174 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { Loader2, LocateFixed, MapPin, Send } from 'lucide-react';
import { AddressMapPicker } from '@/components/addresses/address-map-picker';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { reverseGeocode } from '@/lib/geocoding';
export interface ChatLocationSharePayload {
latitude: number;
longitude: number;
address?: string;
label?: string;
}
export function ChatLocationShareDialog({
open,
sending,
onOpenChange,
onSend,
onLocateError
}: {
open: boolean;
sending?: boolean;
onOpenChange: (open: boolean) => void;
onSend: (payload: ChatLocationSharePayload) => Promise<void>;
onLocateError?: (message: string) => void;
}) {
const [latitude, setLatitude] = useState<number | undefined>();
const [longitude, setLongitude] = useState<number | undefined>();
const [address, setAddress] = useState('');
const [label, setLabel] = useState('');
const [locating, setLocating] = useState(false);
const [resolvingAddress, setResolvingAddress] = useState(false);
const resetState = useCallback(() => {
setLatitude(undefined);
setLongitude(undefined);
setAddress('');
setLabel('');
setLocating(false);
setResolvingAddress(false);
}, []);
useEffect(() => {
if (!open) {
resetState();
}
}, [open, resetState]);
const handlePick = useCallback(async (lat: number, lng: number) => {
setLatitude(lat);
setLongitude(lng);
setResolvingAddress(true);
try {
const parsed = await reverseGeocode(lat, lng);
if (parsed?.fullAddress) {
setAddress(parsed.fullAddress);
}
} finally {
setResolvingAddress(false);
}
}, []);
async function handleUseCurrentLocation() {
if (!navigator.geolocation) return;
setLocating(true);
try {
const position = await new Promise<GeolocationPosition>((resolve, reject) => {
navigator.geolocation.getCurrentPosition(resolve, reject, {
enableHighAccuracy: true,
timeout: 12_000,
maximumAge: 30_000
});
});
await handlePick(position.coords.latitude, position.coords.longitude);
} catch {
// ошибка геолокации обрабатывается на уровне вызывающего через toast
throw new Error('Не удалось определить текущее местоположение');
} finally {
setLocating(false);
}
}
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (latitude == null || longitude == null) return;
await onSend({
latitude,
longitude,
address: address.trim() || undefined,
label: label.trim() || undefined
});
}
const canSend = latitude != null && longitude != null && !sending;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[min(92vw,480px)]">
<DialogHeader>
<div className="mx-auto mb-2 flex h-12 w-12 items-center justify-center rounded-full bg-[#f4f5f8]">
<MapPin className="h-6 w-6 text-[#3390ec]" />
</div>
<DialogTitle className="text-center">Отправить геопозицию</DialogTitle>
<p className="text-center text-sm text-[#6b6f7b]">
Выберите точку на карте или используйте текущее местоположение
</p>
</DialogHeader>
<form onSubmit={(event) => void handleSubmit(event)} className="space-y-3">
<div className="overflow-hidden rounded-2xl border border-[#dce3ec]">
<AddressMapPicker latitude={latitude} longitude={longitude} onPick={(lat, lng) => void handlePick(lat, lng)} />
</div>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
className="rounded-xl"
disabled={locating || sending}
onClick={() => {
void handleUseCurrentLocation().catch((error) => {
onLocateError?.(error instanceof Error ? error.message : 'Не удалось определить текущее местоположение');
});
}}
>
{locating ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <LocateFixed className="mr-2 h-4 w-4" />}
Моё местоположение
</Button>
{resolvingAddress ? (
<span className="flex items-center text-xs text-[#667085]">
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
Определяем адрес...
</span>
) : null}
</div>
<Input
value={label}
onChange={(event) => setLabel(event.target.value)}
placeholder="Подпись (необязательно)"
className="h-11 rounded-xl"
disabled={sending}
/>
<Input
value={address}
onChange={(event) => setAddress(event.target.value)}
placeholder="Адрес"
className="h-11 rounded-xl"
disabled={sending || resolvingAddress}
/>
<Button type="submit" size="lg" className="w-full rounded-[18px]" disabled={!canSend}>
{sending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Отправляем...
</>
) : (
<>
<Send className="mr-2 h-4 w-4" />
Отправить
</>
)}
</Button>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,9 +1,14 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { Loader2, Pause, Play } from 'lucide-react';
import { cn } from '@/lib/utils';
const SIZE = 240;
const RING_WIDTH = 4;
const RADIUS = (SIZE - RING_WIDTH) / 2;
const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
function formatDuration(seconds: number) {
if (!Number.isFinite(seconds) || seconds <= 0) return '0:00';
const whole = Math.floor(seconds);
@@ -12,6 +17,16 @@ function formatDuration(seconds: number) {
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
function progressFromPointer(clientX: number, clientY: number, rect: DOMRect) {
const cx = rect.left + rect.width / 2;
const cy = rect.top + rect.height / 2;
const dx = clientX - cx;
const dy = clientY - cy;
let angle = Math.atan2(dy, dx) + Math.PI / 2;
if (angle < 0) angle += Math.PI * 2;
return Math.min(Math.max(angle / (Math.PI * 2), 0), 1);
}
export function ChatVideoNotePlayer({
src,
durationMs,
@@ -21,11 +36,27 @@ export function ChatVideoNotePlayer({
durationMs?: number;
variant?: 'mine' | 'theirs';
}) {
const rootRef = useRef<HTMLDivElement | null>(null);
const videoRef = useRef<HTMLVideoElement | null>(null);
const scrubbingRef = useRef(false);
const wasPlayingRef = useRef(false);
const [playing, setPlaying] = useState(false);
const [loading, setLoading] = useState(true);
const [scrubbing, setScrubbing] = useState(false);
const [progress, setProgress] = useState(0);
const [duration, setDuration] = useState(durationMs ? durationMs / 1000 : 0);
const ringColor = variant === 'mine' ? '#7ea06a' : '#3390ec';
const seekTo = useCallback((ratio: number) => {
const video = videoRef.current;
if (!video || !Number.isFinite(video.duration) || video.duration <= 0) return;
const next = Math.min(Math.max(ratio, 0), 1);
video.currentTime = next * video.duration;
setProgress(next);
}, []);
useEffect(() => {
const video = videoRef.current;
if (!video) return;
@@ -36,26 +67,64 @@ export function ChatVideoNotePlayer({
}
setLoading(false);
};
const onTimeUpdate = () => {
if (scrubbingRef.current || !video.duration) return;
setProgress(video.currentTime / video.duration);
};
const onPlay = () => setPlaying(true);
const onPause = () => setPlaying(false);
const onEnded = () => setPlaying(false);
const onEnded = () => {
setPlaying(false);
setProgress(0);
video.currentTime = 0;
};
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);
};
}, [src]);
useEffect(() => {
function handlePointerMove(event: PointerEvent) {
if (!scrubbingRef.current || !rootRef.current) return;
const rect = rootRef.current.getBoundingClientRect();
seekTo(progressFromPointer(event.clientX, event.clientY, rect));
}
function handlePointerUp() {
if (!scrubbingRef.current) return;
scrubbingRef.current = false;
setScrubbing(false);
const video = videoRef.current;
if (video && wasPlayingRef.current) {
void video.play();
}
}
window.addEventListener('pointermove', handlePointerMove);
window.addEventListener('pointerup', handlePointerUp);
window.addEventListener('pointercancel', handlePointerUp);
return () => {
window.removeEventListener('pointermove', handlePointerMove);
window.removeEventListener('pointerup', handlePointerUp);
window.removeEventListener('pointercancel', handlePointerUp);
};
}, [seekTo]);
function togglePlayback() {
const video = videoRef.current;
if (!video) return;
if (!video || scrubbingRef.current) return;
if (video.paused) {
void video.play();
} else {
@@ -63,35 +132,103 @@ export function ChatVideoNotePlayer({
}
}
function handleScrubStart(event: React.PointerEvent<HTMLDivElement>) {
if (!rootRef.current) return;
event.preventDefault();
event.stopPropagation();
scrubbingRef.current = true;
setScrubbing(true);
const video = videoRef.current;
wasPlayingRef.current = Boolean(video && !video.paused);
video?.pause();
const rect = rootRef.current.getBoundingClientRect();
seekTo(progressFromPointer(event.clientX, event.clientY, rect));
}
const showControls = !playing || scrubbing || loading;
const remaining = duration * (1 - progress);
return (
<button
type="button"
onClick={togglePlayback}
<div
ref={rootRef}
className={cn(
'group relative h-[240px] w-[240px] overflow-hidden rounded-full shadow-md transition hover:shadow-lg',
variant === 'mine' ? 'ring-2 ring-[#7ea06a]/40' : 'ring-2 ring-[#dce3ec]'
'group relative h-[240px] w-[240px] select-none',
variant === 'mine' ? 'text-[#7ea06a]' : 'text-[#3390ec]'
)}
aria-label={playing ? 'Пауза' : 'Воспроизвести кружок'}
data-chat-interactive="true"
>
<video
ref={videoRef}
src={src}
className="h-full w-full object-cover"
playsInline
preload="metadata"
/>
<span className="absolute inset-0 flex items-center justify-center bg-black/20 transition group-hover:bg-black/30">
{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" />
<div
className={cn(
'relative h-full w-full overflow-hidden rounded-full shadow-md transition hover:shadow-lg',
variant === 'mine' ? 'ring-2 ring-[#7ea06a]/40' : 'ring-2 ring-[#dce3ec]'
)}
</span>
<span className="absolute bottom-4 left-1/2 -translate-x-1/2 rounded-full bg-black/55 px-2 py-0.5 text-[11px] text-white">
{formatDuration(duration)}
</span>
</button>
>
<video
ref={videoRef}
src={src}
className="h-full w-full object-cover"
playsInline
preload="metadata"
/>
<svg
className="pointer-events-none absolute inset-0"
width={SIZE}
height={SIZE}
viewBox={`0 0 ${SIZE} ${SIZE}`}
aria-hidden
>
<circle
cx={SIZE / 2}
cy={SIZE / 2}
r={RADIUS}
fill="none"
stroke="rgba(255,255,255,0.28)"
strokeWidth={RING_WIDTH}
/>
<circle
cx={SIZE / 2}
cy={SIZE / 2}
r={RADIUS}
fill="none"
stroke={ringColor}
strokeWidth={RING_WIDTH}
strokeLinecap="round"
strokeDasharray={CIRCUMFERENCE}
strokeDashoffset={CIRCUMFERENCE * (1 - progress)}
transform={`rotate(-90 ${SIZE / 2} ${SIZE / 2})`}
className="transition-[stroke-dashoffset] duration-75"
/>
</svg>
<div
className="absolute inset-0 rounded-full"
onPointerDown={handleScrubStart}
aria-hidden
/>
<button
type="button"
onClick={togglePlayback}
className={cn(
'absolute inset-0 flex items-center justify-center rounded-full transition',
showControls ? 'bg-black/25 opacity-100' : 'bg-transparent opacity-0 group-hover:bg-black/20 group-hover:opacity-100'
)}
aria-label={playing ? 'Пауза' : 'Воспроизвести кружок'}
>
{loading ? (
<Loader2 className="h-10 w-10 animate-spin text-white drop-shadow" />
) : playing && !scrubbing ? (
<Pause className="h-10 w-10 text-white drop-shadow" />
) : (
<Play className="h-10 w-10 text-white drop-shadow" />
)}
</button>
<span className="pointer-events-none absolute bottom-4 left-1/2 -translate-x-1/2 rounded-full bg-black/55 px-2 py-0.5 text-[11px] text-white">
{formatDuration(scrubbing || playing || progress > 0 ? remaining : duration)}
</span>
</div>
</div>
);
}