This commit is contained in:
2025-10-20 17:09:24 +05:00
15 changed files with 314 additions and 178 deletions
+1 -1
View File
@@ -1,2 +1,2 @@
# VITE_API_URL=http://192.168.1.23:3000
VITE_API_URL=http://localhost:3000
VITE_API_URL=http://192.168.1.224:3000
+6 -1
View File
@@ -15,7 +15,12 @@ function ModalWrapper({
className,
}: ModalWrapperProps) {
return (
<div className={clsx("bg-white rounded-[1.111vw] relative", className)}>
<div
className={clsx(
"bg-white 2xl:rounded-[2.222vw] rounded-[32px] relative",
className
)}
>
<ModalHeader title={title} leftButton={leftButton} />
<div className={clsx("2xl:p-[1.389vw] p-5", !title && "!pt-0")}>
{children}
+27 -6
View File
@@ -2,17 +2,38 @@ import { AnimatePresence, motion } from "motion/react";
import usePopupStore from "../store/popupStore";
function PopupContainer() {
const { popup, position } = usePopupStore();
const { popup, position, setPopup } = usePopupStore();
const isMobile = innerWidth < 640;
function handleDragEnd(
_event: unknown,
info: { offset: { y: number }; velocity: { y: number } }
) {
// Закрываем попап если свайпнули вниз больше чем на 100px или со скоростью > 500
if (info.offset.y > 100 || info.velocity.y > 500) {
setPopup(null);
}
}
return (
<AnimatePresence>
{popup && (
<motion.div
className="absolute"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
style={{ top: position.y, left: position.x }}
className="absolute max-sm:fixed"
initial={{ opacity: 0, y: isMobile ? "100%" : undefined }}
animate={{ opacity: 1, y: isMobile ? "0%" : undefined }}
exit={{ opacity: 0, y: isMobile ? "100%" : undefined }}
drag={isMobile ? "y" : false}
dragConstraints={{ top: 0, bottom: 0 }}
dragElastic={{ top: 0, bottom: 0.5 }}
onDragEnd={isMobile ? handleDragEnd : undefined}
transition={{ bounce: 0 }}
style={
!isMobile
? { top: position.y, left: position.x }
: { bottom: 0, left: 0, right: 0 }
}
>
{popup}
</motion.div>
+1 -1
View File
@@ -22,7 +22,7 @@ function PopupHeader({
<div
ref={headerRef}
className={clsx(
"2xl:p-[1.111vw] p-4 flex justify-between items-center cursor-graba select-none relative",
"2xl:p-[1.111vw] p-4 flex justify-between items-center select-none relative",
draggable && "cursor-grab active:cursor-grabbing"
)}
>
+6 -1
View File
@@ -99,10 +99,15 @@ function PopupWrapper({
<div
ref={wrapperRef}
className={clsx(
"2xl:rounded-[2.222vw] rounded-[32px] relative bg-white shadow-[0_4px_40px_0_rgba(15,16,17,0.1)]",
"2xl:rounded-[2.222vw] relative bg-white shadow-[0_4px_40px_0_rgba(15,16,17,0.1)] 2xl:w-[21.667vw] sm:rounded-[32px] max-sm:w-screen max-sm:rounded-t-[32px]",
className
)}
>
{/* Полоска-ручка для свайпа на мобильных */}
<div className="hidden max-sm:flex justify-center pt-1 pb-1 absolute -top-3 left-1/2 -translate-x-1/2">
<div className="w-8 h-1 bg-[#141414] rounded-full opacity-50" />
</div>
<PopupHeader
headerRef={headerRef}
title={title}
+28 -14
View File
@@ -53,13 +53,15 @@ export default function SessionUsersPanel() {
const isDragStarted = useRef(false);
const DRAG_THRESHOLD = 15;
const handleMouseMove = (e: MouseEvent) => {
const handleMove = (e: MouseEvent | TouchEvent) => {
if (!containerRef.current) return;
if (!isDragStarted.current) {
const distance = Math.hypot(
e.clientX - dragStartPos.current.x,
e.clientY - dragStartPos.current.y
("clientX" in e ? e.clientX : e.touches[0].clientX) -
dragStartPos.current.x,
("clientY" in e ? e.clientY : e.touches[0].clientY) -
dragStartPos.current.y
);
if (distance >= DRAG_THRESHOLD) {
@@ -72,8 +74,12 @@ export default function SessionUsersPanel() {
if (isDragStarted.current) {
setDragPosition({
x: e.clientX - dragOffset.current.x,
y: e.clientY - dragOffset.current.y,
x:
("clientX" in e ? e.clientX : e.touches[0].clientX) -
dragOffset.current.x,
y:
("clientY" in e ? e.clientY : e.touches[0].clientY) -
dragOffset.current.y,
});
}
};
@@ -94,30 +100,39 @@ export default function SessionUsersPanel() {
setIsLeft(shouldBeLeft);
isDragStarted.current = false;
window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("mousemove", handleMove);
window.removeEventListener("mouseup", handleMouseUp);
};
const handleMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
const handleMouseDown = (
e: React.MouseEvent<HTMLDivElement> | React.TouchEvent<HTMLDivElement>
) => {
if (!containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
const r_pos = { x: rect.left, y: rect.top };
const c_pos = { x: e.clientX, y: e.clientY };
const c_pos = {
x: "clientX" in e ? e.clientX : e.touches[0].clientX,
y: "clientY" in e ? e.clientY : e.touches[0].clientY,
};
dragStartPos.current = c_pos;
dragOffset.current = { x: c_pos.x - r_pos.x, y: c_pos.y - r_pos.y };
setDragPosition({ x: r_pos.x, y: r_pos.y });
isDragStarted.current = false;
window.addEventListener("mousemove", handleMouseMove);
window.addEventListener("mousemove", handleMove);
window.addEventListener("touchmove", handleMove);
window.addEventListener("mouseup", handleMouseUp);
window.addEventListener("touchend", handleMouseUp);
};
useEffect(() => {
return () => {
window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("mousemove", handleMove);
window.removeEventListener("mouseup", handleMouseUp);
window.removeEventListener("touchmove", handleMove);
window.removeEventListener("touchend", handleMouseUp);
};
}, []);
@@ -135,7 +150,7 @@ export default function SessionUsersPanel() {
transform: `translate(${isLeft ? "0" : "-100%"}, ${
isTop ? "0" : "-100%"
})`,
transition: "all 0.3s ease-in-out",
transition: "all 0.5s cubic-bezier(.63,.08,.37,.89)",
};
};
@@ -143,9 +158,8 @@ export default function SessionUsersPanel() {
<div
ref={containerRef}
onMouseDown={handleMouseDown}
className={`flex gap-4 absolute ${
isDragStarted.current ? "cursor-grabbing" : "cursor-grab"
}`}
onTouchStart={handleMouseDown}
className="flex gap-4 active:cursor-grabbing cursor-grab absolute"
style={getStyle()}
>
<div
@@ -333,7 +333,7 @@ function SettingsModal() {
const openMicrophoneCheck = () => {
setModal(
<VoiceCheckModal
selectedMicrophone={selectedMicrophone}
initialMicrophone={selectedMicrophone}
microphones={microphones}
microphoneVolume={microphoneVolume}
onSelectMicrophone={setSelectedMicrophone}
@@ -345,10 +345,10 @@ function SettingsModal() {
const openSpeakerCheck = () => {
setModal(
<SoundCheckModal
selectedSpeaker={selectedSpeaker}
initialSpeaker={selectedSpeaker}
speakers={speakers}
onSelectSpeaker={setSelectedSpeaker}
speakerVolume={speakerVolume}
onSelectSpeaker={setSelectedSpeaker}
/>
);
};
+116 -18
View File
@@ -7,29 +7,42 @@ import useModalStore from "../../store/modalStore";
import Select from "../ui/Select";
interface SoundCheckModalProps {
selectedSpeaker: string;
initialSpeaker: string;
speakers: { deviceId: string; label: string }[];
speakerVolume: number;
onSelectSpeaker: (label: string) => void;
}
function SoundCheckModal({
selectedSpeaker,
initialSpeaker,
speakers,
speakerVolume,
onSelectSpeaker,
}: SoundCheckModalProps) {
const { setModal } = useModalStore();
const [selectedSpeaker, setSelectedSpeaker] = useState(initialSpeaker);
const audioContextRef = useRef<AudioContext | null>(null);
const animationFrameRef = useRef<number | null>(null);
const audioElementRef = useRef<HTMLAudioElement | null>(null);
const mediaStreamDestinationRef =
useRef<MediaStreamAudioDestinationNode | null>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [playProgress, setPlayProgress] = useState(0); // Прогресс воспроизведения 0-1
const playStartTimeRef = useRef<number>(0);
// Высоты баров эквалайзера (16 баров как в дизайне)
const barCount = 16;
// Обработчик выбора динамика
const handleSelectSpeaker = (label: string) => {
setSelectedSpeaker(label);
onSelectSpeaker(label);
};
// Высоты баров эквалайзера (40 баров с несколькими волнами как на макете)
const barCount = 40;
const barHeights = [
3, 12, 16, 20, 44, 28, 34, 60, 8, 40, 18, 36, 24, 13, 10, 6,
3, 12, 16, 20, 44, 28, 12, 12, 16, 20, 34, 60, 28, 12, 8, 16, 12, 20, 40,
28, 12, 12, 16, 12, 20, 18, 36, 24, 16, 3, 8, 12, 20, 44, 28, 16, 13, 10, 6,
3,
];
// Обновление прогресса воспроизведения
@@ -61,6 +74,14 @@ function SoundCheckModal({
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
}
if (audioElementRef.current) {
audioElementRef.current.pause();
audioElementRef.current.srcObject = null;
audioElementRef.current = null;
}
if (mediaStreamDestinationRef.current) {
mediaStreamDestinationRef.current = null;
}
if (audioContextRef.current) {
audioContextRef.current.close();
audioContextRef.current = null;
@@ -68,30 +89,99 @@ function SoundCheckModal({
};
}, []);
const playTestSound = () => {
const playTestSound = async () => {
// Создаём AudioContext если его нет
if (!audioContextRef.current) {
audioContextRef.current = new AudioContext();
}
const audioContext = audioContextRef.current;
// Создаём MediaStreamDestination для вывода на выбранное устройство
if (!mediaStreamDestinationRef.current) {
mediaStreamDestinationRef.current =
audioContext.createMediaStreamDestination();
}
const destination = mediaStreamDestinationRef.current;
// Создаём аудио элемент для вывода на конкретное устройство
if (!audioElementRef.current) {
audioElementRef.current = new Audio();
audioElementRef.current.srcObject = destination.stream;
}
// Устанавливаем выбранное устройство вывода
const selectedDevice = speakers.find((s) => s.label === selectedSpeaker);
if (
selectedDevice &&
audioElementRef.current &&
"setSinkId" in audioElementRef.current
) {
try {
await (
audioElementRef.current as HTMLAudioElement & {
setSinkId: (sinkId: string) => Promise<void>;
}
).setSinkId(selectedDevice.deviceId);
} catch (error) {
console.error("Ошибка при установке устройства вывода:", error);
}
}
// Запускаем воспроизведение через аудио элемент
audioElementRef.current
.play()
.catch((e) => console.error("Ошибка воспроизведения:", e));
const oscillator = audioContext.createOscillator();
const gainNode = audioContext.createGain();
// Подключаем напрямую
// Подключаем к MediaStreamDestination вместо audioContext.destination
oscillator.connect(gainNode);
gainNode.connect(audioContext.destination);
gainNode.connect(destination);
// Настраиваем звук (440 Hz - нота A)
oscillator.frequency.value = 440;
oscillator.type = "sine";
// Применяем громкость из настроек и плавное затухание
// Применяем громкость из настроек
const baseVolume = speakerVolume / 100;
gainNode.gain.setValueAtTime(baseVolume * 0.3, audioContext.currentTime);
// Создаём динамическое изменение частоты на основе высот баров
const duration = 3; // 3 секунды
const timePerBar = duration / barCount;
// Начальная частота
const minFreq = 200; // Минимальная частота (низкая нота)
const maxFreq = 1200; // Максимальная частота (высокая нота)
const minHeight = 3;
const maxHeight = 60;
// Устанавливаем начальную частоту
const initialFreq =
minFreq +
((barHeights[0] - minHeight) / (maxHeight - minHeight)) *
(maxFreq - minFreq);
oscillator.frequency.setValueAtTime(initialFreq, audioContext.currentTime);
// Создаём плавное изменение частоты для каждого бара
barHeights.forEach((height, index) => {
const time = audioContext.currentTime + index * timePerBar;
// Преобразуем высоту бара в частоту
const frequency =
minFreq +
((height - minHeight) / (maxHeight - minHeight)) * (maxFreq - minFreq);
oscillator.frequency.linearRampToValueAtTime(frequency, time);
});
// Плавное затухание громкости к концу
gainNode.gain.linearRampToValueAtTime(
baseVolume * 0.3,
audioContext.currentTime + duration * 0.8
);
gainNode.gain.exponentialRampToValueAtTime(
0.01,
audioContext.currentTime + 3
audioContext.currentTime + duration
);
// Запускаем анимацию прогресса
@@ -101,13 +191,13 @@ function SoundCheckModal({
// Играем звук 3 секунды
oscillator.start(audioContext.currentTime);
oscillator.stop(audioContext.currentTime + 3);
oscillator.stop(audioContext.currentTime + duration);
// Останавливаем анимацию после окончания звука
setTimeout(() => {
setIsPlaying(false);
setPlayProgress(0);
}, 3000);
}, duration * 1000);
};
return (
@@ -116,9 +206,10 @@ function SoundCheckModal({
<div className="flex flex-col items-center 2xl:gap-[0.278vw] gap-1">
<p className="caption-xs text-[#7D7D7D] font-medium">Динамик</p>
<Select
size="small"
options={speakers.map((s) => s.label)}
defaultOption={selectedSpeaker}
onSelect={onSelectSpeaker}
onSelect={handleSelectSpeaker}
/>
</div>
@@ -127,15 +218,22 @@ function SoundCheckModal({
{barHeights.map((height, index) => {
// Определяем, заполнен ли бар синим (прогресс слева направо)
const barProgress = (index + 1) / barCount;
const isActivated = playProgress >= barProgress || !isPlaying;
const isActivated = playProgress >= barProgress;
// Создаём эффект волны звука - бары впереди прогресса тоже окрашиваются
const distanceFromProgress = Math.abs(
index / barCount - playProgress
);
const isInWave = isPlaying && distanceFromProgress < 0.15; // Волна охватывает 15% баров
return (
<div
key={index}
className="2xl:w-[0.208vw] w-[3px] 2xl:rounded-[0.556vw] rounded-lg transition-all duration-150 ease-out"
className="2xl:w-[0.208vw] w-[3px] 2xl:rounded-[0.556vw] rounded-lg transition-colors duration-150 ease-out"
style={{
height: `${height}px`,
backgroundColor: isActivated ? "#7B60F3" : "#D6D6D6",
backgroundColor:
isActivated || isInWave ? "#7B60F3" : "#D6D6D6",
}}
/>
);
@@ -3,32 +3,39 @@ import { useState, useRef, useEffect } from "react";
import ModalWrapper from "../ModalWrapper";
import Button from "../ui/Button";
import Select from "../ui/Select";
import RestartIcon from "../icons/RestartIcon";
import useModalStore from "../../store/modalStore";
import SettingsModal from "./SettingsModal";
import clsx from "clsx";
import { isMediaDevicesSupported } from "../../lib/mediaDevices";
interface VoiceCheckModalProps {
selectedMicrophone: string;
initialMicrophone: string;
microphones: { deviceId: string; label: string }[];
microphoneVolume: number;
onSelectMicrophone: (label: string) => void;
}
function VoiceCheckModal({
selectedMicrophone,
initialMicrophone,
microphones,
microphoneVolume,
onSelectMicrophone,
}: VoiceCheckModalProps) {
const { setModal } = useModalStore();
const [status, setStatus] = useState<"default" | "success" | "error">(
"default"
);
const [isTestRunning, setIsTestRunning] = useState(false);
const [soundDetected, setSoundDetected] = useState(false);
const [selectedMicrophone, setSelectedMicrophone] =
useState(initialMicrophone);
const [isActive, setIsActive] = useState(false);
const [currentVolume, setCurrentVolume] = useState(0);
const [detectionStatus, setDetectionStatus] = useState<
"waiting" | "detected" | "not_detected"
>("waiting");
// Обработчик выбора микрофона
const handleSelectMicrophone = (label: string) => {
setSelectedMicrophone(label);
onSelectMicrophone(label);
};
const audioContextRef = useRef<AudioContext | null>(null);
const analyserRef = useRef<AnalyserNode | null>(null);
@@ -36,17 +43,18 @@ function VoiceCheckModal({
const sourceRef = useRef<MediaStreamAudioSourceNode | null>(null);
const gainNodeRef = useRef<GainNode | null>(null);
const animationFrameRef = useRef<number | null>(null);
const testTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const statusRef = useRef<"default" | "success" | "error">("default");
const detectionTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
null
);
const soundDetectedRef = useRef(false);
async function startMicrophoneTest() {
async function startMicrophoneMonitoring() {
// Проверяем доступность API
if (!isMediaDevicesSupported()) {
console.error(
"navigator.mediaDevices недоступен. Возможно, требуется HTTPS или поддержка браузера."
);
setStatus("error");
setIsTestRunning(false);
setIsActive(false);
return;
}
@@ -68,10 +76,10 @@ function VoiceCheckModal({
audioContextRef.current = audioContext;
const analyser = audioContext.createAnalyser();
analyser.fftSize = 2048; // Увеличиваем для более точного анализа
analyser.smoothingTimeConstant = 0.85; // Увеличиваем сглаживание для плавных волн
analyser.minDecibels = -90; // Минимальный уровень для лучшей чувствительности
analyser.maxDecibels = -10; // Максимальный уровень
analyser.fftSize = 2048;
analyser.smoothingTimeConstant = 0.85;
analyser.minDecibels = -90;
analyser.maxDecibels = -10;
analyserRef.current = analyser;
const gainNode = audioContext.createGain();
@@ -86,30 +94,22 @@ function VoiceCheckModal({
gainNode.connect(analyser);
analyser.connect(audioContext.destination);
// Сбрасываем статус при новом тесте
statusRef.current = "default";
setStatus("default");
setSoundDetected(false);
setIsTestRunning(true);
// Сбрасываем состояние обнаружения
soundDetectedRef.current = false;
setDetectionStatus("waiting");
setIsActive(true);
// Останавливаем проверку через 3 секунды и устанавливаем результат
testTimeoutRef.current = setTimeout(() => {
// Устанавливаем финальный статус
if (statusRef.current === "default") {
statusRef.current = "error";
setStatus("error");
// Через 3 секунды проверяем, был ли обнаружен звук
detectionTimeoutRef.current = setTimeout(() => {
if (soundDetectedRef.current) {
setDetectionStatus("detected");
} else {
setDetectionStatus("not_detected");
}
// Если статус уже success, он остаётся success
setIsTestRunning(false);
// Очищаем ресурсы после завершения теста
cleanupAudioResources();
}, 3000);
} catch (error) {
console.error("Ошибка доступа к микрофону:", error);
setStatus("error");
setIsTestRunning(false);
setIsActive(false);
}
}
@@ -157,32 +157,28 @@ function VoiceCheckModal({
}
}
function stopMicrophoneTest() {
function stopMicrophoneMonitoring() {
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
animationFrameRef.current = null;
}
if (testTimeoutRef.current) {
clearTimeout(testTimeoutRef.current);
testTimeoutRef.current = null;
if (detectionTimeoutRef.current) {
clearTimeout(detectionTimeoutRef.current);
detectionTimeoutRef.current = null;
}
cleanupAudioResources();
setSoundDetected(false);
setIsTestRunning(false);
}
function restartMicrophoneTest() {
stopMicrophoneTest();
// Небольшая задержка перед запуском нового теста
setTimeout(startMicrophoneTest, 100);
setCurrentVolume(0);
setIsActive(false);
soundDetectedRef.current = false;
}
useEffect(() => {
startMicrophoneTest();
startMicrophoneMonitoring();
return stopMicrophoneTest;
return stopMicrophoneMonitoring;
}, [selectedMicrophone]);
// Обновляем громкость микрофона при изменении слайдера
@@ -200,7 +196,7 @@ function VoiceCheckModal({
// Обновляем бары эквалайзера в режиме реального времени
useEffect(() => {
if (!isTestRunning || !analyserRef.current) return;
if (!isActive || !analyserRef.current) return;
const updateBars = () => {
if (!analyserRef.current) return;
@@ -258,15 +254,13 @@ function VoiceCheckModal({
setBarHeights(newBarHeights);
// Определяем наличие звука по максимальному бару
// Определяем текущую громкость по максимальному бару
const maxBar = Math.max(...newBarHeights);
setCurrentVolume(maxBar);
// Отмечаем, что звук был обнаружен (для проверки через 3 секунды)
if (maxBar > 10) {
// Если есть бары выше 10px, значит есть звук
if (statusRef.current !== "success") {
statusRef.current = "success";
setStatus("success");
}
setSoundDetected(true);
soundDetectedRef.current = true;
}
animationFrameRef.current = requestAnimationFrame(updateBars);
@@ -275,11 +269,10 @@ function VoiceCheckModal({
updateBars();
return () => {
if (animationFrameRef.current) {
if (animationFrameRef.current)
cancelAnimationFrame(animationFrameRef.current);
}
};
}, [isTestRunning, barCount]);
}, [isActive, barCount]);
return (
<ModalWrapper className="2xl:max-w-[21.111vw] max-w-[304px]">
@@ -288,9 +281,10 @@ function VoiceCheckModal({
<div className="flex flex-col items-center 2xl:gap-[0.278vw] gap-1">
<p className="caption-xs text-[#7D7D7D] font-medium">Микрофон</p>
<Select
size="small"
options={microphones.map((m) => m.label)}
defaultOption={selectedMicrophone}
onSelect={onSelectMicrophone}
onSelect={handleSelectMicrophone}
/>
</div>
@@ -304,7 +298,7 @@ function VoiceCheckModal({
key={index}
className={clsx(
"2xl:w-[0.208vw] w-[3px] 2xl:rounded-[0.556vw] rounded-lg transition-all duration-150 ease-out",
soundDetected ? "bg-[#7B60F3]" : "bg-[#D6D6D6]"
currentVolume > 10 ? "bg-[#7B60F3]" : "bg-[#D6D6D6]"
)}
style={{
height: `${Math.max(3, height)}px`,
@@ -314,17 +308,22 @@ function VoiceCheckModal({
</div>
{/* Статус */}
{!isTestRunning && status === "success" && (
{isActive && detectionStatus === "waiting" && (
<p className="caption-s text-[#7D7D7D] font-medium">
Говорите...
</p>
)}
{isActive && detectionStatus === "detected" && (
<p className="caption-s text-[#29AF61] font-medium">Звук есть!</p>
)}
{!isTestRunning && status === "error" && (
{isActive && detectionStatus === "not_detected" && (
<p className="caption-s text-[#FF4517] font-medium">
Звук не обнаружен
</p>
)}
{isTestRunning && (
<p className="caption-s text-[#7D7D7D] font-medium">
Проверка...
{!isActive && (
<p className="caption-s text-[#FF4517] font-medium">
Ошибка подключения
</p>
)}
</div>
@@ -337,29 +336,14 @@ function VoiceCheckModal({
</p>
</div>
<div className="flex flex-col 2xl:gap-[0.556vw] gap-2">
<Button
variant="secondary"
size="large"
onClick={restartMicrophoneTest}
disabled={isTestRunning}
className="w-full"
>
<div className="2xl:size-[1.389vw] size-5">
<RestartIcon />
</div>
Повторить проверку
</Button>
<Button
variant="primary"
size="large"
onClick={() => setModal(<SettingsModal />)}
className="w-full"
>
Завершить
</Button>
</div>
<Button
variant="primary"
size="large"
onClick={() => setModal(<SettingsModal />)}
className="w-full"
>
Завершить
</Button>
</div>
</ModalWrapper>
);
+16 -29
View File
@@ -32,12 +32,8 @@ export default function ChatPopup() {
}
return (
<PopupWrapper
title="Чат"
draggable
className="max-h-[40] 2xl:w-[21.667vw] 2xl:h-[27.778vw]a overflow-hidden"
>
<div className="flex flex-col h-[27.778vw] relative -m-[1.389vw]">
<PopupWrapper title="Чат" draggable className="sm:overflow-hidden">
<div className="flex flex-col 2xl:h-[27.778vw] relative 2xl:-m-[1.389vw] -m-5">
<MessageFeed messages={messages} />
<MessageInput onMessageSend={onMessageSend} />
</div>
@@ -55,28 +51,21 @@ function MessageFeed({ messages }: { messages: MessageItemProps[] }) {
return (
<div
className="flex flex-col w-full h-[calc(100%-4.444vw)] bg-[#F0F0F0] p-[1.111vw] pb-[0] overflow-y-auto"
className="flex flex-col w-full 2xl:h-[calc(100%-4.444vw)] bg-[#F0F0F0] 2xl:p-[1.111vw] p-4 pb-0 overflow-y-auto [-webkit-scrollbar]:hidden"
style={{ scrollbarWidth: "none", msOverflowStyle: "none" }}
>
<style>
{`
.flex::-webkit-scrollbar {
display: none;
}
`}
</style>
{messages.length === 0 ? (
<div className="w-full flex flex-col gap-[1.667vw] items-center justify-center px-[2.778vw] m-auto">
<div className="w-full flex flex-col 2xl:gap-[1.667vw] gap-6 items-center justify-center 2xl:px-[2.778vw] px-10 m-auto">
<img
src="/img/popups/EmptyMessageFeed.svg"
className="size-[8.333vw]"
className="2xl:size-[8.333vw] size-[120px]"
/>
<span className="text-center text-s">
Здесь пока нет сообщений. <br /> Можно начать беседу с приветствия
</span>
</div>
) : (
<div className="flex flex-col gap-[1.111vw] items-end mt-auto">
<div className="flex flex-col 2xl:gap-[1.111vw] gap-4 items-end mt-auto">
{messages.map((message, index) => (
<MessageItem key={index} {...message} />
))}
@@ -95,35 +84,34 @@ interface MessageItemProps {
function MessageItem({ senderId, timestamp, content }: MessageItemProps) {
const { data: user } = useMe();
const isFromMe = senderId === "1";
return (
<div
className={clsx(
"flex w-full items-end gap-[0.556vw] justify-end",
"flex w-full items-end 2xl:gap-[0.556vw] gap-2 justify-end",
isFromMe ? "" : "flex-row-reverse"
)}
>
<div
className={clsx(
"p-[1.111vw] w-[16.667vw] flex items-end justify-between",
"2xl:p-[1.111vw] p-4 2xl:w-[16.667vw] w-[240px] flex items-end justify-between",
isFromMe
? "bg-[#7B60F3] rounded-[1.111vw_1.111vw_0_1.111vw] text-white"
: "bg-[#FFFFFF] rounded-[1.111vw_1.111vw_1.111vw_0] c text-[#141414]"
? "bg-[#7B60F3] 2xl:rounded-[1.111vw_1.111vw_0_1.111vw] rounded-[16px_16px_0_16px] text-white"
: "bg-[#FFFFFF] 2xl:rounded-[1.111vw_1.111vw_1.111vw_0] rounded-[16px_16px_16px_0] text-[#141414]"
)}
>
<div className="break-words text-s w-[12.917vw]">
<div className="break-words text-s 2xl:space-y-[0.278vw] space-y-1">
{!isFromMe && (
<div className="caption-s text-[#7B60F3] mb-[0.278vw]">
{user?.fullName}
</div>
<div className="caption-s text-[#7B60F3]">{user?.fullName}</div>
)}
<div>{content}</div>
</div>
<span className="caption-xs opacity-30">{timestamp}</span>
</div>
<div className="size-[2.222vw rounded-full">
<div className="2xl:size-[2.222vw] size-8 rounded-full">
<img
src="/img/popups/MessageUserPfp.png"
className="size-full object-cover"
@@ -169,7 +157,7 @@ function MessageInput({
return (
<div
onClick={() => textareaRef.current?.focus()}
className="flex w-full min-h-[4.444vw] p-[1.111vw] items-end justify-between absolute bottom-0 left-0 bg-white"
className="flex w-full 2xl:min-h-[4.444vw] min-h-16 2xl:p-[1.111vw] p-4 items-end justify-between absolute bottom-0 left-0 bg-white"
>
<textarea
ref={textareaRef}
@@ -183,10 +171,9 @@ function MessageInput({
size="small"
variant="cta"
disabled={message.length === 0}
className="size-[2.222vw]"
onClick={sendMessage}
>
<div className="size-full">
<div className="2xl:size-[1.111vw] size-4">
<SendIcon />
</div>
</Button>
+2 -3
View File
@@ -25,7 +25,6 @@ function SharePopup({ link }: { link: string }) {
<PopupWrapper
title="Пригласить"
draggable
className="w-[21.667vw]"
leftButton={
<Button
variant="secondary"
@@ -38,8 +37,8 @@ function SharePopup({ link }: { link: string }) {
</Button>
}
>
<div className="mb-[1.389vw]">
<p className="title-s mb-[0.833vw] font-medium">Скопировать ссылку</p>
<div className="2xl:mb-[1.389vw] mb-3 2xl:space-y-[0.833vw] space-y-3">
<p className="title-s font-medium">Скопировать ссылку</p>
<LinkShare link={link} />
</div>
<Button
+6 -6
View File
@@ -18,8 +18,8 @@ export default function LinkShare({ link }: { link: string }) {
}
return (
<div className="flex flex-col gap-[0.556vw]">
<div className="w-full h-[3.75vw] bg-[#F3F3F3] flex items-center justify-between gap-[0.833vw] px-[1.111vw] rounded-[1.111vw] relative">
<div className="flex flex-col 2xl:gap-[0.556vw] gap-2">
<div className="w-full 2xl:h-[3.75vw] h-[54px] bg-[#F3F3F3] flex items-center justify-between 2xl:gap-[0.833vw] gap-3 2xl:px-[1.111vw] px-4 2xl:rounded-[1.111vw] rounded-2xl relative">
<span
className="text-ellipsis text-s hover:cursor-pointer overflow-hidden text-nowrap"
onClick={handleCopy}
@@ -31,7 +31,7 @@ export default function LinkShare({ link }: { link: string }) {
<Button
variant="cta"
size="medium"
className="translate-x-[0.556vw]"
className="2xl:translate-x-[0.556vw] translate-x-2"
onClick={handleCopy}
>
Копировать
@@ -39,19 +39,19 @@ export default function LinkShare({ link }: { link: string }) {
)}
{shareState === "loading" && (
<div className="size-[1.389vw] text-[#7B60F3] animate-spin">
<div className="2xl:size-[1.389vw] size-5 text-[#7B60F3] animate-spin">
<LoaderIcon />
</div>
)}
{shareState === "done" && (
<div className="size-[1.389vw] text-[#7B60F3]">
<div className="2xl:size-[1.389vw] size-5 text-[#7B60F3]">
<CheckIcon />
</div>
)}
</div>
{shareState === "done" && (
<div className="caption-s absolutea bottom-[-1.25vw] text-[#29AF61] left-0">
<div className="caption-s absolutea 2xl:bottom-[-1.25vw] bottom-[-18px] text-[#29AF61] left-0">
Ссылка скопирована
</div>
)}
+14 -2
View File
@@ -1,3 +1,4 @@
/* eslint-disable react-hooks/exhaustive-deps */
import { useClickAway } from "@uidotdev/usehooks";
import clsx from "clsx";
import { useEffect, useRef, useState } from "react";
@@ -10,6 +11,7 @@ interface SelectProps {
onSelect: (value: string) => void;
className?: string;
defaultOption?: string;
size?: "small" | "large";
}
function Select({
@@ -17,6 +19,7 @@ function Select({
onSelect,
className,
defaultOption = "",
size = "large",
}: SelectProps) {
const [isOpen, setIsOpen] = useState(false);
const [selectedOption, setSelectedOption] = useState(defaultOption);
@@ -27,7 +30,7 @@ function Select({
useEffect(() => setSelectedOption(defaultOption), [defaultOption]);
useEffect(() => onSelect(selectedOption), [onSelect, selectedOption]);
useEffect(() => onSelect(selectedOption), [selectedOption]);
function handleScroll() {
if (!dropDownRef.current) return;
@@ -51,16 +54,25 @@ function Select({
"w-full bg-[#F3F3F3] 2xl:py-[0.972vw] py-3.5 2xl:px-[1.111vw] px-4 flex justify-between items-center 2xl:gap-[0.833vw] gap-3 select-none cursor-pointer transition-colors",
"2xl:rounded-[1.111vw] rounded-2xl",
"hover:bg-[#F0F0F0]",
size === "small" &&
"2xl:gap-[0.139vw] gap-0.5 !p-0 !bg-transparent !ring-transparent outline-none",
isOpen && "ring-1 ring-[#7B60F3]"
)}
onClick={() => setIsOpen(!isOpen)}
>
<p className="text-m text-ellipsis line-clamp-1 text-left">
<p
className={clsx(
"text-ellipsis line-clamp-1 text-left",
size === "small" && "button-m",
size === "large" && "text-m"
)}
>
{selectedOption || "Не выбрано"}
</p>
<div
className={clsx(
"2xl:size-[1.389vw] size-5 text-[#7D7D7D] shrink-0 transition-transform",
size === "small" && "2xl:!size-[1.111vw] !size-4",
isOpen && "rotate-180"
)}
>
+12 -2
View File
@@ -7,10 +7,12 @@ import usePopupStore from "../store/popupStore";
import SettingsModal from "../components/modals/SettingsModal";
import useModalStore from "../store/modalStore";
import CogFilledIcon from "../components/icons/CogFilledIcon";
import SessionUsersPanel from "../components/SessionUsersPanel";
// import SessionUsersPanel from "../components/SessionUsersPanel";
import SharePopup from "../components/popups/SharePopup";
import { useEffect } from "react";
import useToastsStore from "../store/toastsStore";
import ChatPopup from "../components/popups/ChatPopup";
import ChatFilledIcon from "../components/icons/ChatFilledIcon";
function HomePage() {
const { data: user } = useMe();
@@ -68,7 +70,15 @@ function HomePage() {
<ShareFilledIcon />
</div>
</FloatingActionButton>
<SessionUsersPanel />
<FloatingActionButton
variant="default"
onClick={() => setPopup(<ChatPopup />)}
>
<div className="2xl:size-[1.111vw] size-4 text-white">
<ChatFilledIcon />
</div>
</FloatingActionButton>
{/* <SessionUsersPanel /> */}
<FloatingActionButton
variant="default"
+1
View File
@@ -5,6 +5,7 @@ export default {
extend: {},
screens: {
"2xl": "1440px",
sm: "640px",
},
},
plugins: [],