Add framer-motion and motion dependencies; implement animations in ModalContainer and PopupContainer components; enhance PopupWrapper for touch support; update SoundCheckModal and VoiceCheckModal for improved audio testing; refactor Select and ActionsPopover components for animation support.
This commit is contained in:
@@ -9,6 +9,7 @@
|
||||
"@uidotdev/usehooks": "^2.4.1",
|
||||
"clsx": "^2.1.1",
|
||||
"ky": "^1.11.0",
|
||||
"motion": "^12.23.24",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-qr-code": "^2.0.18",
|
||||
@@ -377,6 +378,8 @@
|
||||
|
||||
"fraction.js": ["fraction.js@4.3.7", "", {}, "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew=="],
|
||||
|
||||
"framer-motion": ["framer-motion@12.23.24", "", { "dependencies": { "motion-dom": "^12.23.23", "motion-utils": "^12.23.6", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-HMi5HRoRCTou+3fb3h9oTLyJGBxHfW+HnNE25tAXOvVx/IvwMHK0cx7IR4a2ZU6sh3IX1Z+4ts32PcYBOqka8w=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||
@@ -453,6 +456,12 @@
|
||||
|
||||
"minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="],
|
||||
|
||||
"motion": ["motion@12.23.24", "", { "dependencies": { "framer-motion": "^12.23.24", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-Rc5E7oe2YZ72N//S3QXGzbnXgqNrTESv8KKxABR20q2FLch9gHLo0JLyYo2hZ238bZ9Gx6cWhj9VO0IgwbMjCw=="],
|
||||
|
||||
"motion-dom": ["motion-dom@12.23.23", "", { "dependencies": { "motion-utils": "^12.23.6" } }, "sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA=="],
|
||||
|
||||
"motion-utils": ["motion-utils@12.23.6", "", {}, "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="],
|
||||
@@ -591,6 +600,8 @@
|
||||
|
||||
"ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="],
|
||||
|
||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"@uidotdev/usehooks": "^2.4.1",
|
||||
"clsx": "^2.1.1",
|
||||
"ky": "^1.11.0",
|
||||
"motion": "^12.23.24",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-qr-code": "^2.0.18",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import useModalStore from "../store/modalStore";
|
||||
import clsx from "clsx";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
|
||||
function ModalContainer() {
|
||||
const { modal, setModal, position } = useModalStore();
|
||||
@@ -35,28 +36,35 @@ function ModalContainer() {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
modal && (
|
||||
<div className="h-full">
|
||||
<div
|
||||
ref={modalRef}
|
||||
className={clsx(
|
||||
"bg-black/70 max-md:top-14 flex overflow-y-auto fixed inset-0 z-10 items-center",
|
||||
position === "center" ? "justify-center" : "justify-end"
|
||||
)}
|
||||
<AnimatePresence>
|
||||
{modal && (
|
||||
<motion.div
|
||||
className="h-full"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
>
|
||||
<div className="max-h-full">
|
||||
<div ref={divRef} className="2xl:p-[1.111vw]">
|
||||
<div
|
||||
ref={backdropRef}
|
||||
className="absolute inset-0 cursor-pointer"
|
||||
onClick={() => setModal(null)}
|
||||
/>
|
||||
{modal}
|
||||
<div
|
||||
ref={modalRef}
|
||||
className={clsx(
|
||||
"bg-black/70 max-md:top-14 flex overflow-y-auto fixed inset-0 z-10 items-center",
|
||||
position === "center" ? "justify-center" : "justify-end"
|
||||
)}
|
||||
>
|
||||
<div className="max-h-full">
|
||||
<div ref={divRef} className="2xl:p-[1.111vw]">
|
||||
<div
|
||||
ref={backdropRef}
|
||||
className="absolute inset-0 cursor-pointer"
|
||||
onClick={() => setModal(null)}
|
||||
/>
|
||||
{modal}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,23 @@
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import usePopupStore from "../store/popupStore";
|
||||
|
||||
function PopupContainer() {
|
||||
const { popup, position } = usePopupStore();
|
||||
|
||||
return (
|
||||
<div className="absolute" style={{ top: position.y, left: position.x }}>
|
||||
{popup}
|
||||
</div>
|
||||
<AnimatePresence>
|
||||
{popup && (
|
||||
<motion.div
|
||||
className="absolute"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
style={{ top: position.y, left: position.x }}
|
||||
>
|
||||
{popup}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -29,30 +29,40 @@ function PopupWrapper({
|
||||
|
||||
useEffect(() => {
|
||||
addEventListener("mouseup", () => setMouseDown(false));
|
||||
return () => removeEventListener("mouseup", () => setMouseDown(false));
|
||||
addEventListener("touchend", () => setMouseDown(false));
|
||||
return () => {
|
||||
removeEventListener("mouseup", () => setMouseDown(false));
|
||||
removeEventListener("touchend", () => setMouseDown(false));
|
||||
};
|
||||
}, []);
|
||||
|
||||
function handleMouseMove(e: MouseEvent) {
|
||||
function handleMove(e: MouseEvent | TouchEvent) {
|
||||
if (draggable && mouseDown && wrapperRef.current) {
|
||||
e.preventDefault();
|
||||
const x = "clientX" in e ? e.clientX : e.touches[0].clientX;
|
||||
const y = "clientY" in e ? e.clientY : e.touches[0].clientY;
|
||||
setPosition({
|
||||
x: Math.min(
|
||||
Math.max(0, position.x + e.clientX - mouseDownPosition.x),
|
||||
Math.max(0, position.x + x - mouseDownPosition.x),
|
||||
window.innerWidth - wrapperRef.current.clientWidth
|
||||
),
|
||||
y: Math.min(
|
||||
Math.max(0, position.y + e.clientY - mouseDownPosition.y),
|
||||
Math.max(0, position.y + y - mouseDownPosition.y),
|
||||
window.innerHeight - wrapperRef.current.clientHeight
|
||||
),
|
||||
});
|
||||
setMouseDownPosition({ x: e.clientX, y: e.clientY });
|
||||
setMouseDownPosition({ x, y });
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
addEventListener("mousemove", handleMouseMove);
|
||||
return () => removeEventListener("mousemove", handleMouseMove);
|
||||
}, [handleMouseMove]);
|
||||
addEventListener("mousemove", handleMove);
|
||||
addEventListener("touchmove", handleMove);
|
||||
return () => {
|
||||
removeEventListener("mousemove", handleMove);
|
||||
removeEventListener("touchmove", handleMove);
|
||||
};
|
||||
}, [handleMove]);
|
||||
|
||||
useEffect(() => {
|
||||
if (headerRef.current) {
|
||||
@@ -60,6 +70,13 @@ function PopupWrapper({
|
||||
setMouseDown(true);
|
||||
setMouseDownPosition({ x: e.clientX, y: e.clientY });
|
||||
});
|
||||
headerRef.current.addEventListener("touchstart", (e) => {
|
||||
setMouseDown(true);
|
||||
setMouseDownPosition({
|
||||
x: e.touches[0].clientX,
|
||||
y: e.touches[0].clientY,
|
||||
});
|
||||
});
|
||||
}
|
||||
return () => {
|
||||
if (headerRef.current) {
|
||||
@@ -67,6 +84,13 @@ function PopupWrapper({
|
||||
setMouseDown(true);
|
||||
setMouseDownPosition({ x: e.clientX, y: e.clientY });
|
||||
});
|
||||
headerRef.current.removeEventListener("touchstart", (e) => {
|
||||
setMouseDown(true);
|
||||
setMouseDownPosition({
|
||||
x: e.touches[0].clientX,
|
||||
y: e.touches[0].clientY,
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
@@ -75,7 +99,7 @@ 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)] overflow-hid den",
|
||||
"2xl:rounded-[2.222vw] rounded-[32px] relative bg-white shadow-[0_4px_40px_0_rgba(15,16,17,0.1)]",
|
||||
className
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -168,8 +168,7 @@ function SettingsModal() {
|
||||
if (device.kind === "videoinput") {
|
||||
const deviceInfo: MediaDevice = {
|
||||
deviceId: device.deviceId,
|
||||
label:
|
||||
device.label || `${device.kind} (${device.deviceId.slice(0, 8)})`,
|
||||
label: device.label || `${device.kind} (${device.deviceId})`,
|
||||
};
|
||||
videoInputs.push(deviceInfo);
|
||||
}
|
||||
@@ -299,8 +298,8 @@ function SettingsModal() {
|
||||
<VoiceCheckModal
|
||||
selectedMicrophone={selectedMicrophone}
|
||||
microphones={microphones}
|
||||
microphoneVolume={microphoneVolume}
|
||||
onSelectMicrophone={setSelectedMicrophone}
|
||||
onClose={() => setModal(null)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -312,13 +311,16 @@ function SettingsModal() {
|
||||
selectedSpeaker={selectedSpeaker}
|
||||
speakers={speakers}
|
||||
onSelectSpeaker={setSelectedSpeaker}
|
||||
onClose={() => setModal(null)}
|
||||
speakerVolume={speakerVolume}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalWrapper title="Настройки" className="max-w-[27.778vw]">
|
||||
<ModalWrapper
|
||||
title="Настройки"
|
||||
className="2xl:max-w-[27.778vw] max-w-[400px]"
|
||||
>
|
||||
<div className="2xl:space-y-[1.389vw] space-y-5 2xl:-mt-[1.389vw] -mt-5">
|
||||
<div className="flex">
|
||||
<Button
|
||||
@@ -328,17 +330,15 @@ function SettingsModal() {
|
||||
onClick={() => setMediaType("sound")}
|
||||
isActive={mediaType === "sound"}
|
||||
>
|
||||
<div className="flex 2xl:gap-[0.556vw] items-center">
|
||||
<div
|
||||
className={clsx(
|
||||
"2xl:size-[1.111vw] size-4",
|
||||
mediaType === "sound" ? "text-[#7B60F3]" : "text-[#7D7D7D]"
|
||||
)}
|
||||
>
|
||||
<SoundIcon />
|
||||
</div>
|
||||
<p className="font-medium">Звук</p>
|
||||
<div
|
||||
className={clsx(
|
||||
"2xl:size-[1.111vw] size-4",
|
||||
mediaType === "sound" ? "text-[#7B60F3]" : "text-[#7D7D7D]"
|
||||
)}
|
||||
>
|
||||
<SoundIcon />
|
||||
</div>
|
||||
<p className="font-medium">Звук</p>
|
||||
</Button>
|
||||
<Button
|
||||
variant="menu"
|
||||
@@ -347,17 +347,15 @@ function SettingsModal() {
|
||||
onClick={() => setMediaType("video")}
|
||||
isActive={mediaType === "video"}
|
||||
>
|
||||
<div className="flex 2xl:gap-[0.556vw] items-center">
|
||||
<div
|
||||
className={clsx(
|
||||
"2xl:size-[1.111vw] size-4",
|
||||
mediaType === "video" ? "text-[#7B60F3]" : "text-[#7D7D7D]"
|
||||
)}
|
||||
>
|
||||
<VideoFilledIcon />
|
||||
</div>
|
||||
<p className="font-medium">Видео</p>
|
||||
<div
|
||||
className={clsx(
|
||||
"2xl:size-[1.111vw] size-4",
|
||||
mediaType === "video" ? "text-[#7B60F3]" : "text-[#7D7D7D]"
|
||||
)}
|
||||
>
|
||||
<VideoFilledIcon />
|
||||
</div>
|
||||
<p className="font-medium">Видео</p>
|
||||
</Button>
|
||||
</div>
|
||||
{mediaType === "sound" && (
|
||||
@@ -390,7 +388,7 @@ function SettingsModal() {
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex 2xl:gap-[0.556vw]">
|
||||
<div className="flex 2xl:gap-[0.556vw] gap-2">
|
||||
<Select
|
||||
className="flex-1"
|
||||
options={microphones.map((m) => m.label)}
|
||||
@@ -407,7 +405,7 @@ function SettingsModal() {
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center 2xl:gap-[0.833vw]">
|
||||
<div className="flex items-center 2xl:gap-[0.833vw] gap-3">
|
||||
<div className="2xl:size-[1.111vw] size-4 text-[#7D7D7D]">
|
||||
<MicrophoneFilledIcon />
|
||||
</div>
|
||||
@@ -450,7 +448,7 @@ function SettingsModal() {
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex 2xl:gap-[0.556vw]">
|
||||
<div className="flex 2xl:gap-[0.556vw] gap-2">
|
||||
<Select
|
||||
className="flex-1"
|
||||
options={speakers.map((s) => s.label)}
|
||||
@@ -467,7 +465,7 @@ function SettingsModal() {
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center 2xl:gap-[0.833vw]">
|
||||
<div className="flex items-center 2xl:gap-[0.833vw] gap-3">
|
||||
<div className="2xl:size-[1.111vw] size-4 text-[#7D7D7D]">
|
||||
<SoundIcon />
|
||||
</div>
|
||||
@@ -494,28 +492,13 @@ function SettingsModal() {
|
||||
</div>
|
||||
<p className="text-s text-[#7D7D7D]">Загрузка камер...</p>
|
||||
</div>
|
||||
) : cameras.length === 0 ? (
|
||||
<div className="bg-[#FEF3F2] 2xl:p-[1.111vw] p-4 2xl:rounded-[1.111vw] rounded-2xl 2xl:space-y-[0.556vw] space-y-2">
|
||||
<p className="text-s text-[#FF4517]">
|
||||
Камеры не найдены. Проверьте подключение устройства и
|
||||
разрешения браузера.
|
||||
</p>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="medium"
|
||||
onClick={loadVideoDevices}
|
||||
className="w-full"
|
||||
>
|
||||
Обновить устройства
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
) : cameras.length > 0 ? (
|
||||
<Select
|
||||
options={cameras.map((c) => c.label)}
|
||||
onSelect={setSelectedCamera}
|
||||
defaultOption={selectedCamera}
|
||||
/>
|
||||
)}
|
||||
) : null}
|
||||
<video
|
||||
ref={videoRef}
|
||||
muted
|
||||
@@ -526,7 +509,14 @@ function SettingsModal() {
|
||||
/>
|
||||
{!isVideoTesting && (
|
||||
<div className="bg-[#F3F3F3] 2xl:w-[25vw] w-[360px] aspect-[360/202] flex justify-center items-center 2xl:rounded-[1.111vw] rounded-2xl">
|
||||
{isVideoTestingError && (
|
||||
{cameras.length === 0 && !isLoadingCameras ? (
|
||||
<div className="2xl:space-y-[0.556vw] space-y-2 text-center 2xl:px-[1.111vw] px-4">
|
||||
<p className="title-s font-medium">Камеры не найдены</p>
|
||||
<p className="text-[#7D7D7D] caption-s font-medium">
|
||||
Проверьте подключение устройства и разрешения браузера
|
||||
</p>
|
||||
</div>
|
||||
) : isVideoTestingError ? (
|
||||
<div className="2xl:space-y-[0.556vw] space-y-2">
|
||||
<p className="title-s font-medium text-center">
|
||||
Включить видео не удалось
|
||||
@@ -535,14 +525,13 @@ function SettingsModal() {
|
||||
Проверьте подключение камеры и разрешения
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{isVideoTestingLoading && (
|
||||
) : isVideoTestingLoading ? (
|
||||
<div className="2xl:space-y-[0.556vw] space-y-2">
|
||||
<p className="title-s font-medium text-center">
|
||||
Проверка видео...
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,28 +1,26 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { useClickAway } from "@uidotdev/usehooks";
|
||||
import { useRef, useEffect } from "react";
|
||||
import ModalWrapper from "../ModalWrapper";
|
||||
import Button from "../ui/Button";
|
||||
import ChevronDownIcon from "../icons/ChevronDownIcon";
|
||||
import RestartIcon from "../icons/RestartIcon";
|
||||
import SettingsModal from "./SettingsModal";
|
||||
import useModalStore from "../../store/modalStore";
|
||||
import Select from "../ui/Select";
|
||||
|
||||
interface SoundCheckModalProps {
|
||||
selectedSpeaker: string;
|
||||
speakers: { deviceId: string; label: string }[];
|
||||
speakerVolume: number;
|
||||
onSelectSpeaker: (label: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function SoundCheckModal({
|
||||
selectedSpeaker,
|
||||
speakers,
|
||||
speakerVolume,
|
||||
onSelectSpeaker,
|
||||
onClose,
|
||||
}: SoundCheckModalProps) {
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
const { setModal } = useModalStore();
|
||||
const audioContextRef = useRef<AudioContext | null>(null);
|
||||
const dropdownRef = useClickAway<HTMLDivElement>(() =>
|
||||
setIsDropdownOpen(false)
|
||||
);
|
||||
|
||||
// Очистка AudioContext при размонтировании
|
||||
useEffect(() => {
|
||||
@@ -51,8 +49,9 @@ function SoundCheckModal({
|
||||
oscillator.frequency.value = 440;
|
||||
oscillator.type = "sine";
|
||||
|
||||
// Плавное затухание
|
||||
gainNode.gain.setValueAtTime(0.3, audioContext.currentTime);
|
||||
// Применяем громкость из настроек и плавное затухание
|
||||
const baseVolume = speakerVolume / 100;
|
||||
gainNode.gain.setValueAtTime(baseVolume * 0.3, audioContext.currentTime);
|
||||
gainNode.gain.exponentialRampToValueAtTime(
|
||||
0.01,
|
||||
audioContext.currentTime + 1
|
||||
@@ -64,59 +63,25 @@ function SoundCheckModal({
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalWrapper title="" className="max-w-[21.111vw]">
|
||||
<div className="flex flex-col 2xl:gap-[1.667vw] gap-6">
|
||||
<div className="flex flex-col items-center 2xl:gap-[0.556vw] gap-2 text-center">
|
||||
<h2 className="title-l font-medium">Вы слышите звук?</h2>
|
||||
<p className="text-s text-[#7D7D7D]">
|
||||
Если нет, проверьте громкость и подключение динамиков
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ModalWrapper className="2xl:max-w-[21.111vw] max-w-[304px]">
|
||||
<div className="flex flex-col 2xl:gap-[1.667vw] gap-6 2xl:py-[1.667vw] py-6">
|
||||
<div className="flex flex-col items-center 2xl:gap-[0.278vw] gap-1">
|
||||
<p className="caption-xs text-[#7D7D7D]">Динамик</p>
|
||||
<div ref={dropdownRef} className="relative w-full">
|
||||
<button
|
||||
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
|
||||
className="w-full bg-[#F3F3F3] 2xl:py-[0.972vw] py-3.5 2xl:px-[1.111vw] px-4 2xl:rounded-[1.111vw] rounded-2xl flex items-center justify-between"
|
||||
>
|
||||
<span className="button-m">{selectedSpeaker}</span>
|
||||
<div
|
||||
className={`2xl:size-[1.111vw] size-4 text-[#7D7D7D] transition-transform ${
|
||||
isDropdownOpen ? "rotate-180" : ""
|
||||
}`}
|
||||
>
|
||||
<ChevronDownIcon />
|
||||
</div>
|
||||
</button>
|
||||
{isDropdownOpen && (
|
||||
<div className="absolute top-full left-0 w-full bg-[#F3F3F3] 2xl:rounded-b-[1.111vw] rounded-b-2xl overflow-hidden z-10">
|
||||
{speakers.map((speaker) => (
|
||||
<button
|
||||
key={speaker.deviceId}
|
||||
onClick={() => {
|
||||
onSelectSpeaker(speaker.label);
|
||||
setIsDropdownOpen(false);
|
||||
}}
|
||||
className="w-full text-left 2xl:py-[0.972vw] py-3.5 2xl:px-[1.111vw] px-4 button-m hover:bg-[#f0f0f0] transition-colors"
|
||||
>
|
||||
{speaker.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="caption-xs text-[#7D7D7D] font-medium">Динамик</p>
|
||||
<Select
|
||||
options={speakers.map((s) => s.label)}
|
||||
defaultOption={selectedSpeaker}
|
||||
onSelect={onSelectSpeaker}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Sound Wave Visualization */}
|
||||
<div className="flex flex-col items-center 2xl:gap-[0.833vw] gap-3">
|
||||
<div className="flex items-end justify-center 2xl:gap-[0.278vw] gap-1 2xl:h-[4.167vw] h-[60px]">
|
||||
<div className="flex items-center justify-center 2xl:gap-[0.208vw] gap-[3px] 2xl:h-[4.167vw] h-[60px]">
|
||||
{[3, 12, 16, 20, 44, 28, 34, 60, 8, 40, 18, 36, 24, 13, 10, 6].map(
|
||||
(height, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="2xl:w-[0.208vw] w-[3px] bg-[#D6D6D6] 2xl:rounded-[0.556vw] rounded-lg"
|
||||
style={{ height: `${height}px` }}
|
||||
style={{ height: `${(height / 1440) * innerWidth}px` }}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
@@ -132,7 +97,18 @@ function SoundCheckModal({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Button variant="primary" size="large" onClick={onClose}>
|
||||
<div className="flex flex-col items-center 2xl:gap-[0.556vw] gap-2 text-center">
|
||||
<h2 className="title-l font-medium">Вы слышите звук?</h2>
|
||||
<p className="text-s text-[#7D7D7D]">
|
||||
Если нет, проверьте громкость и подключение динамиков
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="primary"
|
||||
size="large"
|
||||
onClick={() => setModal(<SettingsModal />)}
|
||||
>
|
||||
Завершить
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,35 +1,44 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
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";
|
||||
|
||||
interface VoiceCheckModalProps {
|
||||
selectedMicrophone: string;
|
||||
microphones: { deviceId: string; label: string }[];
|
||||
microphoneVolume: number;
|
||||
onSelectMicrophone: (label: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function VoiceCheckModal({
|
||||
selectedMicrophone,
|
||||
microphones,
|
||||
microphoneVolume,
|
||||
onSelectMicrophone,
|
||||
onClose,
|
||||
}: VoiceCheckModalProps) {
|
||||
const { setModal } = useModalStore();
|
||||
|
||||
const [audioLevel, setAudioLevel] = useState(0);
|
||||
const [status, setStatus] = useState<"default" | "success" | "error">(
|
||||
"default"
|
||||
);
|
||||
const [isTestRunning, setIsTestRunning] = useState(false);
|
||||
const [soundDetected, setSoundDetected] = useState(false);
|
||||
const [maxAudioLevel, setMaxAudioLevel] = useState(0);
|
||||
|
||||
const audioContextRef = useRef<AudioContext | null>(null);
|
||||
const analyserRef = useRef<AnalyserNode | null>(null);
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
const sourceRef = useRef<MediaStreamAudioSourceNode | null>(null);
|
||||
const gainNodeRef = useRef<GainNode | null>(null);
|
||||
const animationFrameRef = useRef<number | null>(null);
|
||||
const testTimeoutRef = useRef<number | null>(null);
|
||||
const testTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const statusRef = useRef<"default" | "success" | "error">("default");
|
||||
const [isTestRunning, setIsTestRunning] = useState(false);
|
||||
|
||||
function detectAudioLevel() {
|
||||
if (!analyserRef.current) return;
|
||||
@@ -40,11 +49,16 @@ function VoiceCheckModal({
|
||||
const average = dataArray.reduce((a, b) => a + b) / dataArray.length;
|
||||
setAudioLevel(average);
|
||||
|
||||
// Определяем статус на основе уровня звука
|
||||
// Обновляем максимальный уровень звука
|
||||
setMaxAudioLevel((prev) => Math.max(prev, average));
|
||||
|
||||
// Если звук обнаружен и статус ещё не success, устанавливаем success
|
||||
if (average > 10 && statusRef.current !== "success") {
|
||||
statusRef.current = "success";
|
||||
setStatus("success");
|
||||
if (average > 5) {
|
||||
if (statusRef.current !== "success") {
|
||||
statusRef.current = "success";
|
||||
setStatus("success");
|
||||
}
|
||||
setSoundDetected(true);
|
||||
}
|
||||
|
||||
animationFrameRef.current = requestAnimationFrame(detectAudioLevel);
|
||||
@@ -72,13 +86,24 @@ function VoiceCheckModal({
|
||||
analyser.fftSize = 256;
|
||||
analyserRef.current = analyser;
|
||||
|
||||
const gainNode = audioContext.createGain();
|
||||
gainNode.gain.value = microphoneVolume / 100;
|
||||
gainNodeRef.current = gainNode;
|
||||
|
||||
const source = audioContext.createMediaStreamSource(stream);
|
||||
sourceRef.current = source;
|
||||
source.connect(analyser);
|
||||
|
||||
// Подключаем: source -> gainNode -> analyser -> destination
|
||||
source.connect(gainNode);
|
||||
gainNode.connect(analyser);
|
||||
analyser.connect(audioContext.destination);
|
||||
|
||||
// Сбрасываем статус при новом тесте
|
||||
statusRef.current = "default";
|
||||
setStatus("default");
|
||||
setSoundDetected(false);
|
||||
setAudioLevel(0);
|
||||
setMaxAudioLevel(0);
|
||||
setIsTestRunning(true);
|
||||
|
||||
// Запускаем проверку звука
|
||||
@@ -100,7 +125,6 @@ function VoiceCheckModal({
|
||||
// Если статус уже success, он остаётся success
|
||||
|
||||
setIsTestRunning(false);
|
||||
setAudioLevel(0);
|
||||
|
||||
// Очищаем ресурсы после завершения теста
|
||||
cleanupAudioResources();
|
||||
@@ -113,8 +137,8 @@ function VoiceCheckModal({
|
||||
}
|
||||
|
||||
function cleanupAudioResources() {
|
||||
// Отключаем source от analyser
|
||||
if (sourceRef.current && analyserRef.current) {
|
||||
// Отключаем source
|
||||
if (sourceRef.current) {
|
||||
try {
|
||||
sourceRef.current.disconnect();
|
||||
} catch {
|
||||
@@ -123,6 +147,26 @@ function VoiceCheckModal({
|
||||
sourceRef.current = null;
|
||||
}
|
||||
|
||||
// Отключаем gainNode
|
||||
if (gainNodeRef.current) {
|
||||
try {
|
||||
gainNodeRef.current.disconnect();
|
||||
} catch {
|
||||
// Ignore if already disconnected
|
||||
}
|
||||
gainNodeRef.current = null;
|
||||
}
|
||||
|
||||
// Отключаем analyser
|
||||
if (analyserRef.current) {
|
||||
try {
|
||||
analyserRef.current.disconnect();
|
||||
} catch {
|
||||
// Ignore if already disconnected
|
||||
}
|
||||
analyserRef.current = null;
|
||||
}
|
||||
|
||||
// Останавливаем все треки медиа-потока
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach((track) => track.stop());
|
||||
@@ -134,8 +178,6 @@ function VoiceCheckModal({
|
||||
audioContextRef.current.close();
|
||||
audioContextRef.current = null;
|
||||
}
|
||||
|
||||
analyserRef.current = null;
|
||||
}
|
||||
|
||||
function stopMicrophoneTest() {
|
||||
@@ -150,49 +192,48 @@ function VoiceCheckModal({
|
||||
|
||||
cleanupAudioResources();
|
||||
|
||||
setAudioLevel(0);
|
||||
// НЕ сбрасываем audioLevel и maxAudioLevel - они сохраняются до нового теста
|
||||
setSoundDetected(false);
|
||||
setIsTestRunning(false);
|
||||
}
|
||||
|
||||
function restartMicrophoneTest() {
|
||||
stopMicrophoneTest();
|
||||
// Небольшая задержка перед запуском нового теста
|
||||
setTimeout(() => {
|
||||
startMicrophoneTest();
|
||||
}, 100);
|
||||
setTimeout(startMicrophoneTest, 100);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
startMicrophoneTest();
|
||||
|
||||
return () => {
|
||||
stopMicrophoneTest();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
return stopMicrophoneTest;
|
||||
}, [selectedMicrophone]);
|
||||
|
||||
// Обновляем громкость микрофона при изменении слайдера
|
||||
useEffect(() => {
|
||||
if (gainNodeRef.current) {
|
||||
gainNodeRef.current.gain.value = microphoneVolume / 100;
|
||||
}
|
||||
}, [microphoneVolume]);
|
||||
|
||||
// Генерируем высоты для баров на основе уровня звука
|
||||
function generateBarHeights() {
|
||||
const baseHeights = [
|
||||
3, 12, 16, 20, 44, 28, 34, 60, 8, 40, 18, 36, 24, 13, 10, 6,
|
||||
];
|
||||
const multiplier = Math.min(audioLevel / 50, 1.5);
|
||||
// Используем maxAudioLevel для сохранения максимальной высоты
|
||||
const levelToUse = isTestRunning ? audioLevel : maxAudioLevel;
|
||||
const multiplier = Math.min(levelToUse / 5);
|
||||
return baseHeights.map((h) => Math.max(3, Math.min(60, h * multiplier)));
|
||||
}
|
||||
|
||||
const barHeights = generateBarHeights();
|
||||
|
||||
return (
|
||||
<ModalWrapper title="" className="max-w-[21.111vw]">
|
||||
<div className="flex flex-col 2xl:gap-[1.667vw] gap-6 2xl:py-[1.667vw] py-6">
|
||||
{/* Заголовок и описание */}
|
||||
<div className="flex flex-col items-center 2xl:gap-[0.556vw] gap-2 text-center">
|
||||
<h2 className="title-l font-medium">Говорите</h2>
|
||||
<p className="text-s text-[#7D7D7D]">
|
||||
Вы должны видеть движение на индикаторе
|
||||
</p>
|
||||
</div>
|
||||
console.log(barHeights);
|
||||
|
||||
return (
|
||||
<ModalWrapper className="2xl:max-w-[21.111vw] max-w-[304px]">
|
||||
<div className="flex flex-col 2xl:gap-[1.667vw] gap-6 2xl:py-[1.667vw] py-6">
|
||||
{/* Выбор микрофона */}
|
||||
<div className="flex flex-col items-center 2xl:gap-[0.278vw] gap-1">
|
||||
<p className="caption-xs text-[#7D7D7D] font-medium">Микрофон</p>
|
||||
@@ -205,12 +246,15 @@ function VoiceCheckModal({
|
||||
|
||||
{/* Визуализация уровня звука */}
|
||||
<div className="flex flex-col items-center 2xl:gap-[0.556vw] gap-2">
|
||||
<div className="flex items-end justify-center 2xl:gap-[0.208vw] gap-[3px] 2xl:h-[4.167vw] h-[60px]">
|
||||
<div className="flex items-center justify-center 2xl:gap-[0.208vw] gap-[3px] 2xl:h-[4.167vw] h-[60px]">
|
||||
{barHeights.map((height, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="2xl:w-[0.208vw] w-[3px] bg-[#D6D6D6] 2xl:rounded-[0.556vw] rounded-lg transition-all duration-100"
|
||||
style={{ height }}
|
||||
className={clsx(
|
||||
`2xl:rounded-[0.556vw] rounded-lg transition-all duration-150 2xl:w-[0.208vw] w-[3px]`,
|
||||
soundDetected ? "bg-[#7B60F3]" : "bg-[#D6D6D6]"
|
||||
)}
|
||||
style={{ height: `${(height / 1440) * innerWidth}px` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -227,9 +271,14 @@ function VoiceCheckModal({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Кнопки */}
|
||||
<div className="flex flex-col items-center 2xl:gap-[0.556vw] gap-2 text-center">
|
||||
<h2 className="title-l font-medium">Говорите</h2>
|
||||
<p className="text-s text-[#7D7D7D]">
|
||||
Вы должны видеть движение на индикаторе
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col 2xl:gap-[0.556vw] gap-2">
|
||||
{/* Кнопка повторной проверки */}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="large"
|
||||
@@ -243,11 +292,10 @@ function VoiceCheckModal({
|
||||
Повторить проверку
|
||||
</Button>
|
||||
|
||||
{/* Кнопка завершения */}
|
||||
<Button
|
||||
variant="primary"
|
||||
size="large"
|
||||
onClick={onClose}
|
||||
onClick={() => setModal(<SettingsModal />)}
|
||||
className="w-full"
|
||||
>
|
||||
Завершить
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import Button from "./Button";
|
||||
import MoreIcon from "../icons/MoreIcon";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
|
||||
interface ActionsPopoverProps {
|
||||
options: {
|
||||
@@ -19,7 +20,7 @@ export default function ActionsPopover({ options }: ActionsPopoverProps) {
|
||||
const [menuHeight, setMenuHeight] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const handleClickOutside = (event: MouseEvent | TouchEvent) => {
|
||||
if (
|
||||
popoverRef.current &&
|
||||
!popoverRef.current.contains(event.target as Node)
|
||||
@@ -29,8 +30,10 @@ export default function ActionsPopover({ options }: ActionsPopoverProps) {
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
document.addEventListener("touchstart", handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
document.removeEventListener("touchstart", handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -39,11 +42,7 @@ export default function ActionsPopover({ options }: ActionsPopoverProps) {
|
||||
const buttonRect = buttonRef.current.getBoundingClientRect();
|
||||
const spaceBelow = window.innerHeight - buttonRect.bottom;
|
||||
const spaceAbove = buttonRect.top;
|
||||
if (spaceBelow < menuHeight && spaceAbove > menuHeight) {
|
||||
setOpenUpwards(true);
|
||||
} else {
|
||||
setOpenUpwards(false);
|
||||
}
|
||||
setOpenUpwards(spaceBelow < menuHeight && spaceAbove > menuHeight);
|
||||
}
|
||||
}, [isOpened, menuHeight, options.length]);
|
||||
|
||||
@@ -54,34 +53,39 @@ export default function ActionsPopover({ options }: ActionsPopoverProps) {
|
||||
className="flex items-center justify-center gap-[0.139vw] size-[1.667vw] rounded-[0.556vw] text-[#CCCCCC] hover:text-[#7D7D7D] hover:bg-[#F3F3F3] active:text-[#141414] "
|
||||
onClick={() => setIsOpened(!isOpened)}
|
||||
>
|
||||
<div className="size-[1.111vw] rounded-[0.556vw]">
|
||||
<div className="2xl:size-[1.111vw] size-4 2xl:rounded-[0.556vw] rounded-2xl">
|
||||
<MoreIcon />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{isOpened && (
|
||||
<div
|
||||
ref={(el) => {
|
||||
setMenuHeight(el?.offsetHeight || 0);
|
||||
}}
|
||||
className={`absolute z-10 right-0 w-[13.333vw] bg-white rounded-[1.111vw] shadow-[0_4px_40px_0_rgba(15,16,17,0.1)] overflow-hidden ${
|
||||
openUpwards ? "bottom-[100%]" : "top-[100%]"
|
||||
}`}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<Button
|
||||
variant="tertiary"
|
||||
className="p-[0.833vw] button-s w-full flex items-center !rounded-none !justify-start gap-[0.556vw]"
|
||||
key={option.label}
|
||||
onClick={option.onClick}
|
||||
disabled={option.disabled}
|
||||
>
|
||||
<div className="size-[1.111vw] ">{option.icon}</div>
|
||||
{option.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<AnimatePresence>
|
||||
{isOpened && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
ref={(el) => {
|
||||
setMenuHeight(el?.offsetHeight || 0);
|
||||
}}
|
||||
className={`absolute z-10 right-0 w-[13.333vw] bg-white rounded-[1.111vw] shadow-[0_4px_40px_0_rgba(15,16,17,0.1)] overflow-hidden ${
|
||||
openUpwards ? "bottom-[100%]" : "top-[100%]"
|
||||
}`}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<Button
|
||||
variant="tertiary"
|
||||
className="p-[0.833vw] button-s w-full flex items-center !rounded-none !justify-start gap-[0.556vw]"
|
||||
key={option.label}
|
||||
onClick={option.onClick}
|
||||
disabled={option.disabled}
|
||||
>
|
||||
<div className="size-[1.111vw] ">{option.icon}</div>
|
||||
{option.label}
|
||||
</Button>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,20 +20,25 @@ function RangeInput({
|
||||
|
||||
useEffect(() => {
|
||||
addEventListener("mouseup", () => setMouseDown(false));
|
||||
addEventListener("mousemove", handleMouseMove);
|
||||
addEventListener("touchend", () => setMouseDown(false));
|
||||
addEventListener("mousemove", handleMove);
|
||||
addEventListener("touchmove", handleMove);
|
||||
return () => {
|
||||
removeEventListener("mouseup", () => setMouseDown(false));
|
||||
removeEventListener("mousemove", handleMouseMove);
|
||||
removeEventListener("touchend", () => setMouseDown(false));
|
||||
removeEventListener("mousemove", handleMove);
|
||||
removeEventListener("touchmove", handleMove);
|
||||
};
|
||||
}, [handleMouseMove]);
|
||||
}, [handleMove]);
|
||||
|
||||
function handleMouseMove(e: MouseEvent) {
|
||||
function handleMove(e: MouseEvent | TouchEvent) {
|
||||
if (mouseDown && ref.current) {
|
||||
onChange(
|
||||
Math.min(
|
||||
Math.max(
|
||||
min,
|
||||
((e.clientX - ref.current.getBoundingClientRect().left) /
|
||||
((("clientX" in e ? e.clientX : e.touches[0].clientX) -
|
||||
ref.current.getBoundingClientRect().left) /
|
||||
ref.current.clientWidth) *
|
||||
(max - min) +
|
||||
min
|
||||
@@ -44,14 +49,17 @@ function RangeInput({
|
||||
}
|
||||
}
|
||||
|
||||
function handleMouseDown(e: React.MouseEvent<HTMLDivElement>) {
|
||||
function handleMouseDownOrTouchStart(
|
||||
e: React.MouseEvent<HTMLDivElement> | React.TouchEvent<HTMLDivElement>
|
||||
) {
|
||||
e.preventDefault();
|
||||
if (ref.current) {
|
||||
onChange(
|
||||
Math.min(
|
||||
Math.max(
|
||||
min,
|
||||
((e.clientX - ref.current.getBoundingClientRect().left) /
|
||||
((("clientX" in e ? e.clientX : e.touches[0].clientX) -
|
||||
ref.current.getBoundingClientRect().left) /
|
||||
ref.current.clientWidth) *
|
||||
(max - min) +
|
||||
min
|
||||
@@ -67,7 +75,8 @@ function RangeInput({
|
||||
<div
|
||||
ref={ref}
|
||||
className="2xl:w-[21.111vw] w-[304px] 2xl:h-[0.139vw] h-[2px] relative bg-[#F0F0F0] 2xl:rounded-[0.556vw] rounded-lg cursor-grab active:cursor-grabbing"
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseDown={handleMouseDownOrTouchStart}
|
||||
onTouchStart={handleMouseDownOrTouchStart}
|
||||
>
|
||||
<div
|
||||
className="bg-[#7B60F3] 2xl:rounded-[0.556vw] h-full absolute left-0 top-0 rounded-lg"
|
||||
|
||||
@@ -3,6 +3,7 @@ import clsx from "clsx";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import ChevronDownIcon from "../icons/ChevronDownIcon";
|
||||
import CheckIcon from "../icons/CheckIcon";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
|
||||
interface SelectProps {
|
||||
options: string[];
|
||||
@@ -68,35 +69,40 @@ function Select({
|
||||
</button>
|
||||
|
||||
{/* Dropdown Menu */}
|
||||
{isOpen && (
|
||||
<div
|
||||
ref={dropDownRef}
|
||||
className="absolute left-0 2xl:top-[calc(100%+0.556vw)] top-[calc(100%+8px)] z-10 w-full bg-white 2xl:rounded-[1.111vw] rounded-2xl shadow-[0px_4px_40px_0px_rgba(0,0,0,0.05),0px_2px_2px_0px_rgba(0,0,0,0.05)] 2xl:p-[0.833vw] p-3 2xl:space-y-[0.278vw] space-y-1 overflow-auto"
|
||||
>
|
||||
{options.map((option) => (
|
||||
<button
|
||||
key={option}
|
||||
className={clsx(
|
||||
"w-full flex items-center 2xl:gap-[0.278vw] gap-1 2xl:p-[0.833vw] p-3 2xl:rounded-[0.556vw] rounded-lg text-left transition-colors hover:bg-[#F3F3F3]"
|
||||
)}
|
||||
onClick={() => {
|
||||
setSelectedOption(option);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
ref={dropDownRef}
|
||||
className="absolute left-0 2xl:top-[calc(100%+0.556vw)] top-[calc(100%+8px)] z-10 w-full bg-white 2xl:rounded-[1.111vw] rounded-2xl shadow-[0px_4px_40px_0px_rgba(0,0,0,0.05),0px_2px_2px_0px_rgba(0,0,0,0.05)] 2xl:p-[0.833vw] p-3 2xl:space-y-[0.278vw] space-y-1 overflow-auto"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<button
|
||||
key={option}
|
||||
className={clsx(
|
||||
"2xl:size-[1.111vw] size-4 shrink-0 text-[#7B60F3]",
|
||||
option !== selectedOption && "opacity-0"
|
||||
"w-full flex items-center 2xl:gap-[0.278vw] gap-1 2xl:p-[0.833vw] p-3 2xl:rounded-[0.556vw] rounded-lg text-left transition-colors hover:bg-[#F3F3F3]"
|
||||
)}
|
||||
onClick={() => {
|
||||
setSelectedOption(option);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
>
|
||||
<CheckIcon />
|
||||
</div>
|
||||
<span className="text-s">{option}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={clsx(
|
||||
"2xl:size-[1.111vw] size-4 shrink-0 text-[#7B60F3]",
|
||||
option !== selectedOption && "opacity-0"
|
||||
)}
|
||||
>
|
||||
<CheckIcon />
|
||||
</div>
|
||||
<span className="text-s">{option}</span>
|
||||
</button>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user