Merge branch 'main' of http://192.168.1.163:3000/inmake/stream.graff.tech-new
This commit is contained in:
+2
-1
@@ -1 +1,2 @@
|
||||
VITE_API_URL=http://localhost:3000
|
||||
# VITE_API_URL=http://192.168.1.23:3000
|
||||
VITE_API_URL=http://192.168.1.224:3000
|
||||
@@ -0,0 +1,44 @@
|
||||
<svg width="243" height="88" viewBox="0 0 243 88" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect y="28.5" width="3" height="3" rx="1.5" fill="#7B60F3"/>
|
||||
<rect x="6" y="24" width="3" height="12" rx="1.5" fill="#7B60F3"/>
|
||||
<rect x="12" y="22" width="3" height="16" rx="1.5" fill="#7B60F3"/>
|
||||
<rect x="18" y="24" width="3" height="12" rx="1.5" fill="#7B60F3"/>
|
||||
<rect x="24" y="20" width="3" height="20" rx="1.5" fill="#7B60F3"/>
|
||||
<rect x="30" y="8" width="3" height="44" rx="1.5" fill="#7B60F3"/>
|
||||
<rect x="36" y="16" width="3" height="28" rx="1.5" fill="#7B60F3"/>
|
||||
<rect x="42" y="24" width="3" height="12" rx="1.5" fill="#7B60F3"/>
|
||||
<rect x="48" y="24" width="3" height="12" rx="1.5" fill="#7B60F3"/>
|
||||
<rect x="54" y="22" width="3" height="16" rx="1.5" fill="#7B60F3"/>
|
||||
<rect x="60" y="20" width="3" height="20" rx="1.5" fill="#7B60F3"/>
|
||||
<rect x="66" y="13" width="3" height="34" rx="1.5" fill="#D6D6D6"/>
|
||||
<rect x="72" width="3" height="60" rx="1.5" fill="#D6D6D6"/>
|
||||
<rect x="78" y="16" width="3" height="28" rx="1.5" fill="#D6D6D6"/>
|
||||
<rect x="84" y="24" width="3" height="12" rx="1.5" fill="#D6D6D6"/>
|
||||
<rect x="90" y="26" width="3" height="8" rx="1.5" fill="#D6D6D6"/>
|
||||
<rect x="96" y="22" width="3" height="16" rx="1.5" fill="#D6D6D6"/>
|
||||
<rect x="102" y="24" width="3" height="12" rx="1.5" fill="#D6D6D6"/>
|
||||
<rect x="108" y="20" width="3" height="20" rx="1.5" fill="#D6D6D6"/>
|
||||
<rect x="114" y="10" width="3" height="40" rx="1.5" fill="#D6D6D6"/>
|
||||
<rect x="120" y="16" width="3" height="28" rx="1.5" fill="#D6D6D6"/>
|
||||
<rect x="126" y="24" width="3" height="12" rx="1.5" fill="#D6D6D6"/>
|
||||
<rect x="132" y="24" width="3" height="12" rx="1.5" fill="#D6D6D6"/>
|
||||
<rect x="138" y="22" width="3" height="16" rx="1.5" fill="#D6D6D6"/>
|
||||
<rect x="144" y="24" width="3" height="12" rx="1.5" fill="#D6D6D6"/>
|
||||
<rect x="150" y="20" width="3" height="20" rx="1.5" fill="#D6D6D6"/>
|
||||
<rect x="156" y="21" width="3" height="18" rx="1.5" fill="#D6D6D6"/>
|
||||
<rect x="162" y="12" width="3" height="36" rx="1.5" fill="#D6D6D6"/>
|
||||
<rect x="168" y="18" width="3" height="24" rx="1.5" fill="#D6D6D6"/>
|
||||
<rect x="174" y="22" width="3" height="16" rx="1.5" fill="#D6D6D6"/>
|
||||
<rect x="180" y="28.5" width="3" height="3" rx="1.5" fill="#D6D6D6"/>
|
||||
<rect x="186" y="26" width="3" height="8" rx="1.5" fill="#D6D6D6"/>
|
||||
<rect x="192" y="24" width="3" height="12" rx="1.5" fill="#D6D6D6"/>
|
||||
<rect x="198" y="20" width="3" height="20" rx="1.5" fill="#D6D6D6"/>
|
||||
<rect x="204" y="8" width="3" height="44" rx="1.5" fill="#D6D6D6"/>
|
||||
<rect x="210" y="16" width="3" height="28" rx="1.5" fill="#D6D6D6"/>
|
||||
<rect x="216" y="22" width="3" height="16" rx="1.5" fill="#D6D6D6"/>
|
||||
<rect x="222" y="23.5" width="3" height="13" rx="1.5" fill="#D6D6D6"/>
|
||||
<rect x="228" y="25" width="3" height="10" rx="1.5" fill="#D6D6D6"/>
|
||||
<rect x="234" y="27" width="3" height="6" rx="1.5" fill="#D6D6D6"/>
|
||||
<rect x="240" y="28.5" width="3" height="3" rx="1.5" fill="#D6D6D6"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M116.67 78.706C116.897 77.8581 117.343 77.0849 117.964 76.4641C118.584 75.8433 119.357 75.3969 120.205 75.1696C121.053 74.9423 121.946 74.9422 122.794 75.1693C123.642 75.3964 124.415 75.8427 125.035 76.4634L126.304 77.732H124.182C124.049 77.732 123.922 77.7847 123.828 77.8785C123.735 77.9722 123.682 78.0994 123.682 78.232C123.682 78.3646 123.735 78.4918 123.828 78.5856C123.922 78.6794 124.049 78.732 124.182 78.732H127.51C127.643 78.732 127.77 78.6794 127.864 78.5856C127.957 78.4918 128.01 78.3646 128.01 78.232V74.904C128.01 74.7714 127.957 74.6442 127.864 74.5505C127.77 74.4567 127.643 74.404 127.51 74.404C127.377 74.404 127.25 74.4567 127.156 74.5505C127.063 74.6442 127.01 74.7714 127.01 74.904V77.024L125.743 75.7574C124.999 75.0123 124.071 74.4765 123.054 74.2037C122.036 73.9309 120.965 73.9308 119.947 74.2034C118.93 74.4759 118.002 75.0116 117.257 75.7565C116.512 76.5013 115.977 77.4292 115.704 78.4467C115.687 78.5102 115.683 78.5764 115.691 78.6415C115.7 78.7066 115.721 78.7695 115.754 78.8263C115.787 78.8832 115.831 78.9331 115.883 78.9731C115.935 79.0131 115.995 79.0424 116.058 79.0594C116.122 79.0763 116.188 79.0807 116.253 79.0721C116.318 79.0635 116.381 79.0421 116.438 79.0093C116.495 78.9764 116.544 78.9326 116.584 78.8805C116.624 78.8283 116.654 78.7688 116.671 78.7054L116.67 78.706ZM126.942 80.9407C126.879 80.9237 126.812 80.9193 126.747 80.9278C126.682 80.9363 126.619 80.9576 126.563 80.9904C126.506 81.0233 126.456 81.067 126.416 81.119C126.376 81.1711 126.346 81.2306 126.329 81.294C126.102 82.1419 125.656 82.9151 125.036 83.5359C124.415 84.1567 123.642 84.6032 122.794 84.8305C121.946 85.0578 121.054 85.0579 120.206 84.8308C119.358 84.6037 118.585 84.1573 117.964 83.5367L116.696 82.268H118.818C118.951 82.268 119.078 82.2154 119.172 82.1216C119.265 82.0278 119.318 81.9006 119.318 81.768C119.318 81.6354 119.265 81.5082 119.172 81.4145C119.078 81.3207 118.951 81.268 118.818 81.268H115.489C115.357 81.268 115.23 81.3207 115.136 81.4145C115.042 81.5082 114.989 81.6354 114.989 81.768V85.096C114.989 85.2286 115.042 85.3558 115.136 85.4496C115.23 85.5434 115.357 85.596 115.489 85.596C115.622 85.596 115.749 85.5434 115.843 85.4496C115.937 85.3558 115.989 85.2286 115.989 85.096V82.976L117.256 84.2427C118.001 84.9877 118.929 85.5235 119.946 85.7963C120.964 86.069 122.035 86.0691 123.052 85.7965C124.07 85.5238 124.998 84.9881 125.743 84.2431C126.487 83.4982 127.023 82.5703 127.295 81.5527C127.33 81.4247 127.312 81.2883 127.245 81.1736C127.179 81.0588 127.07 80.9751 126.942 80.9407Z" fill="#CCCCCC"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.4 KiB |
@@ -20,7 +20,7 @@ function ActionsSidebarWrapper({
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className={clsx(
|
||||
"flex 2xl:flex 2xl:gap-[0.556vw] 2xl:flex-col gap-2 max-2xl:p-2 max-2xl:rounded-[32px] absolute 2xl:top-1/2 2xl:-translate-y-1/2 2xl:right-[1.111vw] max-2xl:left-1/2 max-2xl:-translate-x-1/2 max-2xl:bottom-2 max-2xl:landscape:bg-[#00000026] max-2xl:landscape:backdrop-blur",
|
||||
"flex 2xl:flex 2xl:gap-[0.556vw] 2xl:flex-col gap-2 max-2xl:p-2 max-2xl:rounded-[32px] absolute 2xl:top-1/2 2xl:-translate-y-1/2 2xl:right-[1.111vw] max-2xl:left-1/2 max-2xl:-translate-x-1/2 max-2xl:bottom-2 max-2xl:bg-[#00000026] max-2xl:backdrop-blur",
|
||||
className
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -15,9 +15,16 @@ 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="2xl:p-[1.389vw] p-5">{children}</div>
|
||||
<div className={clsx("2xl:p-[1.389vw] p-5", !title && "!pt-0")}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,10 +10,12 @@ import type { AllSettings } from "@epicgames-ps/lib-pixelstreamingfrontend-ue5.7
|
||||
|
||||
export interface PixelStreamingWrapperProps {
|
||||
initialSettings?: Partial<AllSettings>;
|
||||
onVideoInitialized?: () => void;
|
||||
}
|
||||
|
||||
export const PixelStreamingWrapper = ({
|
||||
initialSettings,
|
||||
onVideoInitialized,
|
||||
}: PixelStreamingWrapperProps) => {
|
||||
// A reference to parent div element that the Pixel Streaming library attaches into:
|
||||
const videoParent = useRef<HTMLDivElement>(null);
|
||||
@@ -38,6 +40,10 @@ export const PixelStreamingWrapper = ({
|
||||
setClickToPlayVisible(true);
|
||||
});
|
||||
|
||||
streaming.addEventListener("videoInitialized", () => {
|
||||
onVideoInitialized?.();
|
||||
});
|
||||
|
||||
// Save the library instance into component state so that it can be accessed later:
|
||||
setPixelStreaming(streaming);
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -44,11 +44,11 @@ function PopupWrapper({
|
||||
setPosition({
|
||||
x: Math.min(
|
||||
Math.max(0, position.x + x - mouseDownPosition.x),
|
||||
window.innerWidth - wrapperRef.current.clientWidth
|
||||
innerWidth - wrapperRef.current.clientWidth
|
||||
),
|
||||
y: Math.min(
|
||||
Math.max(0, position.y + y - mouseDownPosition.y),
|
||||
window.innerHeight - wrapperRef.current.clientHeight
|
||||
innerHeight - wrapperRef.current.clientHeight
|
||||
),
|
||||
});
|
||||
setMouseDownPosition({ x, y });
|
||||
@@ -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}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import UserCamera from "./ui/UserCamera";
|
||||
import UserDevicesControls from "./ui/UserDevicesControls";
|
||||
import clsx from "clsx";
|
||||
|
||||
export default function SessionUsersPanel() {
|
||||
const users = [
|
||||
@@ -40,9 +43,132 @@ export default function SessionUsersPanel() {
|
||||
console.log(`Can control user ${id}`);
|
||||
}
|
||||
|
||||
const [isTop, setIsTop] = useState(false);
|
||||
const [isLeft, setIsLeft] = useState(false);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 });
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const dragOffset = useRef({ x: 0, y: 0 });
|
||||
const dragStartPos = useRef({ x: 0, y: 0 });
|
||||
const isDragStarted = useRef(false);
|
||||
const DRAG_THRESHOLD = 15;
|
||||
|
||||
const handleMove = (e: MouseEvent | TouchEvent) => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
if (!isDragStarted.current) {
|
||||
const distance = Math.hypot(
|
||||
("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) {
|
||||
isDragStarted.current = true;
|
||||
setIsDragging(true);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (isDragStarted.current) {
|
||||
setDragPosition({
|
||||
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,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const center = {
|
||||
x: rect.left + rect.width / 2,
|
||||
y: rect.top + rect.height / 2,
|
||||
};
|
||||
const shouldBeTop = center.y < window.innerHeight / 2;
|
||||
const shouldBeLeft = center.x < window.innerWidth / 2;
|
||||
|
||||
setIsDragging(!isDragStarted.current);
|
||||
setIsTop(shouldBeTop);
|
||||
setIsLeft(shouldBeLeft);
|
||||
isDragStarted.current = false;
|
||||
|
||||
window.removeEventListener("mousemove", handleMove);
|
||||
window.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
|
||||
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: "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", handleMove);
|
||||
window.addEventListener("touchmove", handleMove);
|
||||
window.addEventListener("mouseup", handleMouseUp);
|
||||
window.addEventListener("touchend", handleMouseUp);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", handleMove);
|
||||
window.removeEventListener("mouseup", handleMouseUp);
|
||||
window.removeEventListener("touchmove", handleMove);
|
||||
window.removeEventListener("touchend", handleMouseUp);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const getStyle = (): React.CSSProperties => {
|
||||
if (isDragStarted.current && isDragging) {
|
||||
return {
|
||||
left: `${dragPosition.x}px`,
|
||||
top: `${dragPosition.y}px`,
|
||||
transition: "none",
|
||||
};
|
||||
}
|
||||
return {
|
||||
left: isLeft ? "1.111vw" : "calc(100vw - 1.111vw)",
|
||||
top: isTop ? "1.111vw" : "calc(100vh - 1.111vw)",
|
||||
transform: `translate(${isLeft ? "0" : "-100%"}, ${
|
||||
isTop ? "0" : "-100%"
|
||||
})`,
|
||||
transition: "all 0.5s cubic-bezier(.63,.08,.37,.89)",
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex gap-4 items-end">
|
||||
<div className="flex gap-4 w-max items-end">
|
||||
<div
|
||||
ref={containerRef}
|
||||
onMouseDown={handleMouseDown}
|
||||
onTouchStart={handleMouseDown}
|
||||
className="flex gap-4 active:cursor-grabbing cursor-grab absolute"
|
||||
style={getStyle()}
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
"flex gap-4 w-max",
|
||||
isLeft ? "flex-row-reverse" : "flex-row",
|
||||
isTop ? "items-start" : "items-end"
|
||||
)}
|
||||
>
|
||||
{users.map((user) => (
|
||||
<UserCamera
|
||||
key={user.id}
|
||||
@@ -52,8 +178,8 @@ export default function SessionUsersPanel() {
|
||||
{...user}
|
||||
/>
|
||||
))}
|
||||
<UserDevicesControls />
|
||||
</div>
|
||||
<UserDevicesControls />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ export default function Warning({
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"size-[1.111vw] border-[1px] border-white rounded-full flex items-center justify-center ",
|
||||
"2xl:size-[1.111vw] size-[4.444vw] border-[1px] border-white rounded-full flex items-center justify-center ",
|
||||
type === "caution" && "bg-[#F9A530]",
|
||||
type === "critical" && "bg-[#FF4517]",
|
||||
className
|
||||
|
||||
@@ -13,6 +13,7 @@ import useModalStore from "../../store/modalStore";
|
||||
import SoundCheckModal from "./SoundCheckModal";
|
||||
import VoiceCheckModal from "./VoiceCheckModal";
|
||||
import LoaderIcon from "../icons/LoaderIcon";
|
||||
import { isMediaDevicesSupported } from "../../lib/mediaDevices";
|
||||
|
||||
interface MediaDevice {
|
||||
deviceId: string;
|
||||
@@ -34,6 +35,7 @@ function SettingsModal() {
|
||||
const [selectedCamera, setSelectedCamera] = useState<string>("");
|
||||
|
||||
const [mediaType, setMediaType] = useState<"sound" | "video">("sound");
|
||||
const [mediaApiUnavailable, setMediaApiUnavailable] = useState(false);
|
||||
|
||||
const [participantsVideosHidden, setParticipantsVideosHidden] =
|
||||
useState(false);
|
||||
@@ -66,6 +68,17 @@ function SettingsModal() {
|
||||
setIsLoadingMicrophones(true);
|
||||
setIsLoadingSpeakers(true);
|
||||
|
||||
// Проверяем доступность API
|
||||
if (!isMediaDevicesSupported()) {
|
||||
console.error(
|
||||
"navigator.mediaDevices недоступен. Возможно, требуется HTTPS или поддержка браузера."
|
||||
);
|
||||
setMediaApiUnavailable(true);
|
||||
setIsLoadingMicrophones(false);
|
||||
setIsLoadingSpeakers(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Запрашиваем разрешения на аудио
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
@@ -150,6 +163,15 @@ function SettingsModal() {
|
||||
async function loadVideoDevices() {
|
||||
setIsLoadingCameras(true);
|
||||
|
||||
// Проверяем доступность API
|
||||
if (!isMediaDevicesSupported()) {
|
||||
console.error(
|
||||
"navigator.mediaDevices недоступен. Возможно, требуется HTTPS или поддержка браузера."
|
||||
);
|
||||
setIsLoadingCameras(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Запрашиваем разрешения на видео
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
@@ -214,6 +236,15 @@ function SettingsModal() {
|
||||
|
||||
// Запуск видео
|
||||
async function startVideoTest() {
|
||||
// Проверяем доступность API
|
||||
if (!isMediaDevicesSupported()) {
|
||||
console.error(
|
||||
"navigator.mediaDevices недоступен. Возможно, требуется HTTPS или поддержка браузера."
|
||||
);
|
||||
setIsVideoTestingError(true);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsVideoTestingLoading(true);
|
||||
setIsVideoTestingError(false);
|
||||
@@ -257,14 +288,20 @@ function SettingsModal() {
|
||||
}
|
||||
};
|
||||
|
||||
navigator.mediaDevices.addEventListener("devicechange", handleDeviceChange);
|
||||
|
||||
return () => {
|
||||
navigator.mediaDevices.removeEventListener(
|
||||
// Добавляем слушатель только если API доступен
|
||||
if (isMediaDevicesSupported()) {
|
||||
navigator.mediaDevices.addEventListener(
|
||||
"devicechange",
|
||||
handleDeviceChange
|
||||
);
|
||||
};
|
||||
|
||||
return () => {
|
||||
navigator.mediaDevices.removeEventListener(
|
||||
"devicechange",
|
||||
handleDeviceChange
|
||||
);
|
||||
};
|
||||
}
|
||||
}, [mediaType]);
|
||||
|
||||
// Загружаем видео устройства и запускаем видео при переключении на вкладку "Видео"
|
||||
@@ -296,7 +333,7 @@ function SettingsModal() {
|
||||
const openMicrophoneCheck = () => {
|
||||
setModal(
|
||||
<VoiceCheckModal
|
||||
selectedMicrophone={selectedMicrophone}
|
||||
initialMicrophone={selectedMicrophone}
|
||||
microphones={microphones}
|
||||
microphoneVolume={microphoneVolume}
|
||||
onSelectMicrophone={setSelectedMicrophone}
|
||||
@@ -308,10 +345,10 @@ function SettingsModal() {
|
||||
const openSpeakerCheck = () => {
|
||||
setModal(
|
||||
<SoundCheckModal
|
||||
selectedSpeaker={selectedSpeaker}
|
||||
initialSpeaker={selectedSpeaker}
|
||||
speakers={speakers}
|
||||
onSelectSpeaker={setSelectedSpeaker}
|
||||
speakerVolume={speakerVolume}
|
||||
onSelectSpeaker={setSelectedSpeaker}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -358,6 +395,17 @@ function SettingsModal() {
|
||||
<p className="font-medium">Видео</p>
|
||||
</Button>
|
||||
</div>
|
||||
{mediaApiUnavailable && (
|
||||
<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="title-s font-medium text-[#FF4517]">
|
||||
MediaDevices API недоступен
|
||||
</p>
|
||||
<p className="text-s text-[#FF4517]">
|
||||
Для работы с медиа-устройствами требуется безопасное соединение
|
||||
(HTTPS) или localhost. Проверьте настройки сервера и браузера.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{mediaType === "sound" && (
|
||||
<div className="2xl:space-y-[1.667vw] space-y-6">
|
||||
<div className="2xl:space-y-[0.833vw] space-y-3">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useRef, useEffect } from "react";
|
||||
import { useRef, useEffect, useState } from "react";
|
||||
import ModalWrapper from "../ModalWrapper";
|
||||
import Button from "../ui/Button";
|
||||
import RestartIcon from "../icons/RestartIcon";
|
||||
@@ -7,24 +7,81 @@ 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);
|
||||
|
||||
// Обработчик выбора динамика
|
||||
const handleSelectSpeaker = (label: string) => {
|
||||
setSelectedSpeaker(label);
|
||||
onSelectSpeaker(label);
|
||||
};
|
||||
|
||||
// Высоты баров эквалайзера (40 баров с несколькими волнами как на макете)
|
||||
const barCount = 40;
|
||||
const barHeights = [
|
||||
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,
|
||||
];
|
||||
|
||||
// Обновление прогресса воспроизведения
|
||||
useEffect(() => {
|
||||
if (!isPlaying) return;
|
||||
|
||||
const updateProgress = () => {
|
||||
const elapsed = Date.now() - playStartTimeRef.current;
|
||||
const progress = Math.min(elapsed / 3000, 1); // 3 секунды
|
||||
setPlayProgress(progress);
|
||||
|
||||
if (progress < 1) {
|
||||
animationFrameRef.current = requestAnimationFrame(updateProgress);
|
||||
}
|
||||
};
|
||||
|
||||
updateProgress();
|
||||
|
||||
return () => {
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
}
|
||||
};
|
||||
}, [isPlaying]);
|
||||
|
||||
// Очистка AudioContext при размонтировании
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
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;
|
||||
@@ -32,64 +89,161 @@ 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 + 1
|
||||
audioContext.currentTime + duration
|
||||
);
|
||||
|
||||
// Играем звук 1 секунду
|
||||
// Запускаем анимацию прогресса
|
||||
playStartTimeRef.current = Date.now();
|
||||
setIsPlaying(true);
|
||||
setPlayProgress(0);
|
||||
|
||||
// Играем звук 3 секунды
|
||||
oscillator.start(audioContext.currentTime);
|
||||
oscillator.stop(audioContext.currentTime + 1);
|
||||
oscillator.stop(audioContext.currentTime + duration);
|
||||
|
||||
// Останавливаем анимацию после окончания звука
|
||||
setTimeout(() => {
|
||||
setIsPlaying(false);
|
||||
setPlayProgress(0);
|
||||
}, duration * 1000);
|
||||
};
|
||||
|
||||
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 2xl:gap-[1.667vw] gap-6">
|
||||
<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>
|
||||
|
||||
<div className="flex flex-col items-center 2xl:gap-[0.833vw] gap-3">
|
||||
<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) => (
|
||||
{barHeights.map((height, index) => {
|
||||
// Определяем, заполнен ли бар синим (прогресс слева направо)
|
||||
const barProgress = (index + 1) / barCount;
|
||||
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] bg-[#D6D6D6] 2xl:rounded-[0.556vw] rounded-lg"
|
||||
style={{ height: `${(height / 1440) * innerWidth}px` }}
|
||||
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 || isInWave ? "#7B60F3" : "#D6D6D6",
|
||||
}}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={playTestSound}
|
||||
className="2xl:size-[2.778vw] size-10 rounded-full bg-[#7B60F3] hover:bg-[#9184F6] active:bg-[#B3AAF9] flex items-center justify-center text-white transition-colors"
|
||||
disabled={isPlaying}
|
||||
className="2xl:size-[2.778vw] size-10 rounded-full bg-[#7B60F3] hover:bg-[#9184F6] active:bg-[#B3AAF9] disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center text-white transition-colors"
|
||||
>
|
||||
<div className="2xl:size-[1.111vw] size-4">
|
||||
<RestartIcon />
|
||||
|
||||
@@ -3,33 +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 [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 [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);
|
||||
@@ -37,34 +43,21 @@ 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);
|
||||
|
||||
function detectAudioLevel() {
|
||||
if (!analyserRef.current) return;
|
||||
|
||||
const dataArray = new Uint8Array(analyserRef.current.frequencyBinCount);
|
||||
analyserRef.current.getByteFrequencyData(dataArray);
|
||||
|
||||
const average = dataArray.reduce((a, b) => a + b) / dataArray.length;
|
||||
setAudioLevel(average);
|
||||
|
||||
// Обновляем максимальный уровень звука
|
||||
setMaxAudioLevel((prev) => Math.max(prev, average));
|
||||
|
||||
// Если звук обнаружен и статус ещё не success, устанавливаем success
|
||||
if (average > 5) {
|
||||
if (statusRef.current !== "success") {
|
||||
statusRef.current = "success";
|
||||
setStatus("success");
|
||||
}
|
||||
setSoundDetected(true);
|
||||
async function startMicrophoneMonitoring() {
|
||||
// Проверяем доступность API
|
||||
if (!isMediaDevicesSupported()) {
|
||||
console.error(
|
||||
"navigator.mediaDevices недоступен. Возможно, требуется HTTPS или поддержка браузера."
|
||||
);
|
||||
setIsActive(false);
|
||||
return;
|
||||
}
|
||||
|
||||
animationFrameRef.current = requestAnimationFrame(detectAudioLevel);
|
||||
}
|
||||
|
||||
async function startMicrophoneTest() {
|
||||
try {
|
||||
const selectedMic = microphones.find(
|
||||
(mic) => mic.label === selectedMicrophone
|
||||
@@ -83,7 +76,10 @@ function VoiceCheckModal({
|
||||
audioContextRef.current = audioContext;
|
||||
|
||||
const analyser = audioContext.createAnalyser();
|
||||
analyser.fftSize = 256;
|
||||
analyser.fftSize = 2048;
|
||||
analyser.smoothingTimeConstant = 0.85;
|
||||
analyser.minDecibels = -90;
|
||||
analyser.maxDecibels = -10;
|
||||
analyserRef.current = analyser;
|
||||
|
||||
const gainNode = audioContext.createGain();
|
||||
@@ -98,41 +94,22 @@ function VoiceCheckModal({
|
||||
gainNode.connect(analyser);
|
||||
analyser.connect(audioContext.destination);
|
||||
|
||||
// Сбрасываем статус при новом тесте
|
||||
statusRef.current = "default";
|
||||
setStatus("default");
|
||||
setSoundDetected(false);
|
||||
setAudioLevel(0);
|
||||
setMaxAudioLevel(0);
|
||||
setIsTestRunning(true);
|
||||
// Сбрасываем состояние обнаружения
|
||||
soundDetectedRef.current = false;
|
||||
setDetectionStatus("waiting");
|
||||
setIsActive(true);
|
||||
|
||||
// Запускаем проверку звука
|
||||
detectAudioLevel();
|
||||
|
||||
// Останавливаем проверку через 3 секунды и устанавливаем результат
|
||||
testTimeoutRef.current = setTimeout(() => {
|
||||
// Останавливаем анимацию
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
animationFrameRef.current = null;
|
||||
// Через 3 секунды проверяем, был ли обнаружен звук
|
||||
detectionTimeoutRef.current = setTimeout(() => {
|
||||
if (soundDetectedRef.current) {
|
||||
setDetectionStatus("detected");
|
||||
} else {
|
||||
setDetectionStatus("not_detected");
|
||||
}
|
||||
|
||||
// Устанавливаем финальный статус
|
||||
if (statusRef.current === "default") {
|
||||
statusRef.current = "error";
|
||||
setStatus("error");
|
||||
}
|
||||
// Если статус уже success, он остаётся success
|
||||
|
||||
setIsTestRunning(false);
|
||||
|
||||
// Очищаем ресурсы после завершения теста
|
||||
cleanupAudioResources();
|
||||
}, 3000);
|
||||
} catch (error) {
|
||||
console.error("Ошибка доступа к микрофону:", error);
|
||||
setStatus("error");
|
||||
setIsTestRunning(false);
|
||||
setIsActive(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,33 +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();
|
||||
|
||||
// НЕ сбрасываем audioLevel и maxAudioLevel - они сохраняются до нового теста
|
||||
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]);
|
||||
|
||||
// Обновляем громкость микрофона при изменении слайдера
|
||||
@@ -216,59 +188,145 @@ function VoiceCheckModal({
|
||||
}
|
||||
}, [microphoneVolume]);
|
||||
|
||||
// Генерируем высоты для баров на основе уровня звука
|
||||
function generateBarHeights() {
|
||||
const baseHeights = [
|
||||
3, 12, 16, 20, 44, 28, 34, 60, 8, 40, 18, 36, 24, 13, 10, 6,
|
||||
];
|
||||
// Используем maxAudioLevel для сохранения максимальной высоты
|
||||
const levelToUse = isTestRunning ? audioLevel : maxAudioLevel;
|
||||
const multiplier = Math.min(levelToUse / 5);
|
||||
return baseHeights.map((h) => Math.max(3, Math.min(60, h * multiplier)));
|
||||
}
|
||||
// Состояние для баров эквалайзера (40 баров для плавных волн)
|
||||
const barCount = 40;
|
||||
const [barHeights, setBarHeights] = useState<number[]>(
|
||||
new Array(barCount).fill(3)
|
||||
);
|
||||
|
||||
const barHeights = generateBarHeights();
|
||||
// Обновляем бары эквалайзера в режиме реального времени
|
||||
useEffect(() => {
|
||||
if (!isActive || !analyserRef.current) return;
|
||||
|
||||
console.log(barHeights);
|
||||
const updateBars = () => {
|
||||
if (!analyserRef.current) return;
|
||||
|
||||
const bufferLength = analyserRef.current.frequencyBinCount;
|
||||
const dataArray = new Uint8Array(bufferLength);
|
||||
analyserRef.current.getByteFrequencyData(dataArray);
|
||||
|
||||
// Сначала создаём меньше опорных точек для интерполяции
|
||||
const keyPoints = Math.floor(barCount / 2.5); // ~16 опорных точек
|
||||
const keyHeights: number[] = [];
|
||||
|
||||
for (let i = 0; i < keyPoints; i++) {
|
||||
// Логарифмическое распределение частот
|
||||
const startFreq = Math.pow(2, (i / keyPoints) * 10);
|
||||
const endFreq = Math.pow(2, ((i + 1) / keyPoints) * 10);
|
||||
|
||||
const startIndex = Math.floor((startFreq / 1024) * bufferLength);
|
||||
const endIndex = Math.floor((endFreq / 1024) * bufferLength);
|
||||
|
||||
let sum = 0;
|
||||
let count = 0;
|
||||
|
||||
for (let j = startIndex; j < endIndex && j < bufferLength; j++) {
|
||||
sum += dataArray[j];
|
||||
count++;
|
||||
}
|
||||
|
||||
const average = count > 0 ? sum / count : 0;
|
||||
// Нормализуем к диапазону 3-60 пикселей
|
||||
const normalizedHeight = Math.min(
|
||||
60,
|
||||
Math.max(3, (average / 255) * 60 * 2.5)
|
||||
);
|
||||
keyHeights.push(normalizedHeight);
|
||||
}
|
||||
|
||||
// Интерполируем между опорными точками для плавных волн
|
||||
const newBarHeights: number[] = [];
|
||||
for (let i = 0; i < barCount; i++) {
|
||||
const position = (i / barCount) * (keyPoints - 1);
|
||||
const index = Math.floor(position);
|
||||
const fraction = position - index;
|
||||
|
||||
if (index >= keyPoints - 1) {
|
||||
newBarHeights.push(keyHeights[keyPoints - 1]);
|
||||
} else {
|
||||
// Линейная интерполяция между двумя опорными точками
|
||||
const interpolated =
|
||||
keyHeights[index] * (1 - fraction) +
|
||||
keyHeights[index + 1] * fraction;
|
||||
newBarHeights.push(interpolated);
|
||||
}
|
||||
}
|
||||
|
||||
setBarHeights(newBarHeights);
|
||||
|
||||
// Определяем текущую громкость по максимальному бару
|
||||
const maxBar = Math.max(...newBarHeights);
|
||||
setCurrentVolume(maxBar);
|
||||
|
||||
// Отмечаем, что звук был обнаружен (для проверки через 3 секунды)
|
||||
if (maxBar > 10) {
|
||||
soundDetectedRef.current = true;
|
||||
}
|
||||
|
||||
animationFrameRef.current = requestAnimationFrame(updateBars);
|
||||
};
|
||||
|
||||
updateBars();
|
||||
|
||||
return () => {
|
||||
if (animationFrameRef.current)
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
};
|
||||
}, [isActive, barCount]);
|
||||
|
||||
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 2xl:gap-[1.667vw] gap-6">
|
||||
{/* Выбор микрофона */}
|
||||
<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>
|
||||
|
||||
{/* Визуализация уровня звука */}
|
||||
<div className="flex flex-col items-center 2xl:gap-[0.556vw] gap-2">
|
||||
<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={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 className="flex flex-col 2xl:gap-[0.833vw] gap-3">
|
||||
{/* Визуализация эквалайзера */}
|
||||
<div className="flex flex-col items-center 2xl:gap-[0.556vw] gap-2">
|
||||
<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={clsx(
|
||||
"2xl:w-[0.208vw] w-[3px] 2xl:rounded-[0.556vw] rounded-lg transition-all duration-150 ease-out",
|
||||
currentVolume > 10 ? "bg-[#7B60F3]" : "bg-[#D6D6D6]"
|
||||
)}
|
||||
style={{
|
||||
height: `${Math.max(3, height)}px`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Статус */}
|
||||
{isActive && detectionStatus === "waiting" && (
|
||||
<p className="caption-s text-[#7D7D7D] font-medium">
|
||||
Говорите...
|
||||
</p>
|
||||
)}
|
||||
{isActive && detectionStatus === "detected" && (
|
||||
<p className="caption-s text-[#29AF61] font-medium">Звук есть!</p>
|
||||
)}
|
||||
{isActive && detectionStatus === "not_detected" && (
|
||||
<p className="caption-s text-[#FF4517] font-medium">
|
||||
Звук не обнаружен
|
||||
</p>
|
||||
)}
|
||||
{!isActive && (
|
||||
<p className="caption-s text-[#FF4517] font-medium">
|
||||
Ошибка подключения
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{!isTestRunning && status === "success" && (
|
||||
<p className="caption-s text-[#29AF61] font-medium">Звук есть!</p>
|
||||
)}
|
||||
{!isTestRunning && status === "error" && (
|
||||
<p className="caption-s text-[#FF4517] font-medium">
|
||||
Звук не обнаружен
|
||||
</p>
|
||||
)}
|
||||
{isTestRunning && (
|
||||
<p className="caption-s text-[#7D7D7D] font-medium">Проверка...</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center 2xl:gap-[0.556vw] gap-2 text-center">
|
||||
@@ -278,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>
|
||||
);
|
||||
|
||||
@@ -32,8 +32,8 @@ export default function ChatPopup() {
|
||||
}
|
||||
|
||||
return (
|
||||
<PopupWrapper title="Чат" draggable className="max-h-[40] 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>
|
||||
@@ -51,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} />
|
||||
))}
|
||||
@@ -91,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"
|
||||
@@ -165,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}
|
||||
@@ -179,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>
|
||||
|
||||
@@ -5,31 +5,69 @@ import VideoOffFilledIcon from "../icons/VideoOffFilledIcon";
|
||||
import HandRaisedFilledIcon from "../icons/HandRaisedFilledIcon";
|
||||
import XMarkFilledIcon from "../icons/XMarkFilledIcon";
|
||||
import Avatar from "../ui/Avatar";
|
||||
import Button from "../ui/Button";
|
||||
import ShareFilledIcon from "../icons/ShareFilledIcon";
|
||||
import HandRaisedOffFilledIcon from "../icons/HandRaisedOffFilledIcon";
|
||||
import MicrophoneOffFilledIcon from "../icons/MicrophoneOffFilledIcon";
|
||||
import { Fragment, useRef } from "react";
|
||||
|
||||
export default function ParticipantsPopup() {
|
||||
const participants = [1, 2, 3];
|
||||
const participants = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
|
||||
|
||||
return (
|
||||
<PopupWrapper title="Участники" draggable className="h-max">
|
||||
<div className="flex flex-col w-[21.667vw] relative">
|
||||
<ul className="flex flex-col gap-[1.111vw]">
|
||||
<PopupWrapper
|
||||
title="Участники"
|
||||
draggable
|
||||
className="h-max 2xl:w-[21.667vw]"
|
||||
>
|
||||
<div className="flex flex-col gap-[1.667vw] relative">
|
||||
<ul className="flex flex-col gap-0 2xl:gap-[1.111vw] 2xl:max-h-auto max-h-[calc(100dvh-50vw)] overflow-y-auto">
|
||||
{participants.map((participant, index) => (
|
||||
<>
|
||||
<ParticipantItem key={participant} id={participant.toString()} />
|
||||
<Fragment key={index}>
|
||||
<ParticipantItem id={participant.toString()} />
|
||||
{index !== participants.length - 1 && (
|
||||
<div className="w-full h-[1px] bg-[#F6F6F6]" />
|
||||
)}
|
||||
</>
|
||||
</Fragment>
|
||||
))}
|
||||
</ul>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
if (navigator.share) {
|
||||
navigator.share({
|
||||
title: "Участники",
|
||||
text: "Присоединяйся к моей встрече",
|
||||
url: window.location.href,
|
||||
});
|
||||
} else {
|
||||
navigator.clipboard.writeText(window.location.href);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<p>Поделиться ссылкой</p>
|
||||
<div className="2xl:size-[1.111vw] size-4">
|
||||
<ShareFilledIcon />
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</PopupWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
function ParticipantItem({ id }: { id: string }) {
|
||||
const isMuted = true;
|
||||
const isNotControlling = true;
|
||||
const isMobile = window.innerWidth <= 360;
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between w-full h-[2.5vw]">
|
||||
<div className="flex items-center gap-[0.833vw]">
|
||||
<div
|
||||
ref={parentRef}
|
||||
onTouchEnd={(ev) => ev.stopPropagation()}
|
||||
className="flex items-center justify-between w-full relative py-[2.222vw] 2xl:p-0"
|
||||
>
|
||||
<div className="flex items-center 2xl:gap-[0.833vw] gap-[3.333vw]">
|
||||
<Avatar size="medium" status="caution" />
|
||||
<div className="flex flex-col gap-[0.278vw]">
|
||||
<span className="button-m">Иван Иванович {id}</span>
|
||||
@@ -37,7 +75,18 @@ function ParticipantItem({ id }: { id: string }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex 2xl:gap-[0.556vw] gap-[2.222vw] items-center">
|
||||
{isNotControlling && (
|
||||
<div className="2xl:size-[1.111vw] size-[4.444vw] text-[#FF4517]">
|
||||
<HandRaisedOffFilledIcon />
|
||||
</div>
|
||||
)}
|
||||
{isMuted && (
|
||||
<div className="2xl:size-[1.111vw] size-[4.444vw] text-[#FF4517]">
|
||||
<MicrophoneOffFilledIcon />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ActionsPopover
|
||||
options={[
|
||||
{
|
||||
@@ -62,6 +111,8 @@ function ParticipantItem({ id }: { id: string }) {
|
||||
onClick: () => {},
|
||||
},
|
||||
]}
|
||||
parentRef={isMobile ? parentRef : undefined}
|
||||
className={isMobile ? "left-0" : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -14,6 +14,7 @@ function QRCodePopup({ link }: QRCodePopupProps) {
|
||||
|
||||
return (
|
||||
<PopupWrapper
|
||||
className="w-[21.667vw]"
|
||||
draggable
|
||||
leftButton={
|
||||
<Button
|
||||
|
||||
@@ -9,6 +9,18 @@ import QRCodePopup from "./QRCodePopup";
|
||||
function SharePopup({ link }: { link: string }) {
|
||||
const { setPopup } = usePopupStore();
|
||||
|
||||
function handleShare() {
|
||||
if (navigator.share) {
|
||||
navigator.share({
|
||||
title: "Пригласить",
|
||||
text: "Присоединяйся к моей встрече",
|
||||
url: link,
|
||||
});
|
||||
} else {
|
||||
navigator.clipboard.writeText(link);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<PopupWrapper
|
||||
title="Пригласить"
|
||||
@@ -25,11 +37,16 @@ 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 variant="primary" size="large" className="w-full">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="large"
|
||||
className="w-full"
|
||||
onClick={handleShare}
|
||||
>
|
||||
Отправить
|
||||
<div className="2xl:size-[1.111vw] size-4">
|
||||
<ShareFilledIcon />
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import clsx from "clsx";
|
||||
import React from "react";
|
||||
import Button from "../ui/Button";
|
||||
import useToastsStore from "../../store/toastsStore";
|
||||
|
||||
export interface ToastLayoutProps {
|
||||
id: string;
|
||||
type: "notification" | "warning";
|
||||
title: string;
|
||||
message: string;
|
||||
onDeny: () => void;
|
||||
onAllow: () => void;
|
||||
image?: string;
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function ToastLayout({
|
||||
id,
|
||||
type,
|
||||
title,
|
||||
message,
|
||||
onDeny,
|
||||
onAllow,
|
||||
}: ToastLayoutProps) {
|
||||
const { removeToast } = useToastsStore();
|
||||
|
||||
function handleDeny() {
|
||||
onDeny();
|
||||
removeToast(id);
|
||||
}
|
||||
|
||||
function handleAllow() {
|
||||
onAllow();
|
||||
removeToast(id);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-[20.833vw] rounded-[1.944vw] p-[0.556vw] bg-white shadow-[0_4px_40px_0_rgba(15,16,17,0.1)]">
|
||||
<div className="flex flex-col gap-[0.556vw]">
|
||||
<div
|
||||
className={clsx(
|
||||
"button-m",
|
||||
type === "notification" ? "text-[#7D7D7D]" : "text-[#FF4517]"
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
<div className="text-m">{message}</div>
|
||||
|
||||
<div className="flex gap-[0.556vw]">
|
||||
<Button variant="critical" size="small" onClick={handleDeny}>
|
||||
Отклонить
|
||||
</Button>
|
||||
<Button variant="primary" size="small" onClick={handleAllow}>
|
||||
Разрешить
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import useToastsStore from "../../store/toastsStore";
|
||||
import ToastLayout from "./ToastLayout";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
|
||||
export default function ToastsContainer() {
|
||||
const { toasts } = useToastsStore();
|
||||
|
||||
return (
|
||||
<div className="fixed w-full flex flex-col-reverse justify-center items-center top-[1.111vw] left-1/2 -translate-x-1/2 z-50 gap-[0.278vw] ">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{toasts.map((toast) => (
|
||||
<motion.div
|
||||
key={toast.id}
|
||||
layout
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{
|
||||
duration: 0.5,
|
||||
type: "spring",
|
||||
damping: 20,
|
||||
stiffness: 300,
|
||||
}}
|
||||
>
|
||||
<ToastLayout {...toast} />
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useState, useRef, useEffect } from "react";
|
||||
import Button from "./Button";
|
||||
import MoreIcon from "../icons/MoreIcon";
|
||||
import PopoverWrapper from "./PopoverWrapper";
|
||||
import clsx from "clsx";
|
||||
|
||||
interface ActionsPopoverProps {
|
||||
options: {
|
||||
@@ -10,47 +11,79 @@ interface ActionsPopoverProps {
|
||||
icon?: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
}[];
|
||||
isOpened?: boolean;
|
||||
parentRef?: React.RefObject<HTMLDivElement | null>;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function ActionsPopover({ options }: ActionsPopoverProps) {
|
||||
const [isOpened, setIsOpened] = useState(false);
|
||||
|
||||
/**
|
||||
* @param parentRef - Если передан, то кнопка активации опций не будет отображаться, а сам Popover будет отображаться по клику на parentRef.
|
||||
*/
|
||||
export default function ActionsPopover({
|
||||
options,
|
||||
isOpened = false,
|
||||
parentRef,
|
||||
className,
|
||||
}: ActionsPopoverProps) {
|
||||
const [opened, setOpened] = useState(isOpened);
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let isHandling = false;
|
||||
|
||||
const handleClickOutside = (event: MouseEvent | TouchEvent) => {
|
||||
if (
|
||||
popoverRef.current &&
|
||||
!popoverRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setIsOpened(false);
|
||||
// Предотвращаем двойное срабатывание mousedown и touchstart
|
||||
if (isHandling) return;
|
||||
isHandling = true;
|
||||
setTimeout(() => {
|
||||
isHandling = false;
|
||||
}, 200);
|
||||
|
||||
const tgt = event.target as Node;
|
||||
const clickedInsideParentElement = parentRef?.current?.contains(tgt);
|
||||
const clickedInsidePopover = popoverRef.current?.contains(tgt);
|
||||
if (clickedInsideParentElement && !clickedInsidePopover) {
|
||||
//
|
||||
} else {
|
||||
// Если нет parentRef - закрытие только по клику вне popover и вне кнопки
|
||||
const clickedInsideButton = buttonRef.current?.contains(tgt);
|
||||
if (!clickedInsidePopover && !clickedInsideButton) {
|
||||
setOpened(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
document.addEventListener("touchstart", handleClickOutside);
|
||||
parentRef?.current?.addEventListener("touchend", () => setOpened(!opened));
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
document.removeEventListener("touchstart", handleClickOutside);
|
||||
parentRef?.current?.addEventListener("touchend", () =>
|
||||
setOpened(!opened)
|
||||
);
|
||||
};
|
||||
}, []);
|
||||
}, [parentRef]);
|
||||
|
||||
return (
|
||||
<div className="relative" ref={popoverRef}>
|
||||
<button
|
||||
ref={buttonRef}
|
||||
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="2xl:size-[1.111vw] size-4 2xl:rounded-[0.556vw] rounded-2xl">
|
||||
<MoreIcon />
|
||||
</div>
|
||||
</button>
|
||||
<div className={!parentRef ? "relative" : ""} ref={popoverRef}>
|
||||
{!parentRef && (
|
||||
<button
|
||||
ref={buttonRef}
|
||||
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={() => setOpened(!opened)}
|
||||
>
|
||||
<div className="2xl:size-[1.111vw] size-4 2xl:rounded-[0.556vw] rounded-2xl">
|
||||
<MoreIcon />
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<PopoverWrapper
|
||||
isOpened={isOpened}
|
||||
buttonRef={buttonRef}
|
||||
className="w-[17.222vw]"
|
||||
isOpened={opened}
|
||||
parentElRef={parentRef ? parentRef : buttonRef}
|
||||
className={clsx("2xl:w-[17.222vw] w-[53.333vw]", className)}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<Button
|
||||
@@ -60,7 +93,9 @@ export default function ActionsPopover({ options }: ActionsPopoverProps) {
|
||||
onClick={option.onClick}
|
||||
disabled={option.disabled}
|
||||
>
|
||||
<div className="size-[1.111vw] ">{option.icon}</div>
|
||||
<div className="2xl:size-[1.111vw] size-[4.444vw]">
|
||||
{option.icon}
|
||||
</div>
|
||||
{option.label}
|
||||
</Button>
|
||||
))}
|
||||
|
||||
@@ -15,18 +15,21 @@ export default function Avatar({ size, status, src, name }: AvatarProps) {
|
||||
<div
|
||||
className={clsx(
|
||||
"rounded-full text-white relative",
|
||||
size === "small" && "size-[2.222vw] button-s",
|
||||
size === "medium" && "size-[2.5vw] button-m",
|
||||
size === "large" && "size-[3.333vw] title-s"
|
||||
size === "small" && "2xl:size-[2.222vw] size-[8.889vw] button-s",
|
||||
size === "medium" && "2xl:size-[2.5vw] size-[10vw] button-m",
|
||||
size === "large" && "2xl:size-[3.333vw] size-[13.333vw] title-s"
|
||||
)}
|
||||
>
|
||||
{GetAvatarImage(src, name)}
|
||||
<div
|
||||
className={clsx(
|
||||
"absolute",
|
||||
size === "small" && "bottom-[1.389vw] left-[1.389vw]",
|
||||
size === "medium" && "bottom-[1.667vw] left-[1.667vw]",
|
||||
size === "large" && "bottom-[2.5vw] left-[2.5vw]"
|
||||
size === "small" &&
|
||||
"2xl:bottom-[1.389vw] bottom-[5.556vw] 2xl:left-[1.389vw] left-[5.556vw]",
|
||||
size === "medium" &&
|
||||
"2xl:bottom-[1.667vw] bottom-[6.667vw] 2xl:left-[1.667vw] left-[6.667vw]",
|
||||
size === "large" &&
|
||||
"2xl:bottom-[2.5vw] bottom-[10vw] 2xl:left-[2.5vw] left-[10vw]"
|
||||
)}
|
||||
>
|
||||
{status === "caution" && (
|
||||
|
||||
@@ -29,7 +29,7 @@ function Button({
|
||||
onClick?.(e);
|
||||
}}
|
||||
className={clsx(
|
||||
"transition-all select-none cursor-pointer disabled:!cursor-default flex outline-none gap-2 items-center justify-center font-medium disabled:bg-[#F6F6F6] disabled:!text-[#D6D6D6]",
|
||||
"transition-colors select-none cursor-pointer disabled:!cursor-default flex outline-none gap-2 items-center justify-center font-medium disabled:bg-[#F6F6F6] disabled:!text-[#D6D6D6]",
|
||||
isActive && "bg-[#F3F1FD] !text-[#7B60F3]",
|
||||
variant === "menu" &&
|
||||
"text-[#7D7D7D] hover:bg-[#F3F3F3] active:bg-[#F3F1FD] active:text-[#7B60F3]",
|
||||
|
||||
@@ -4,12 +4,10 @@ interface ControlButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
size: "small" | "large";
|
||||
icon: React.ReactNode;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
function ControlButton({
|
||||
size,
|
||||
enabled,
|
||||
icon,
|
||||
className,
|
||||
onClick,
|
||||
@@ -20,13 +18,10 @@ function ControlButton({
|
||||
onClick={onClick}
|
||||
{...props}
|
||||
className={clsx(
|
||||
"backdrop-blur-[10px] rounded-full transition-all cursor-pointer disabled:!cursor-default outline-none",
|
||||
size === "large" ? "2xl:p-[0.833vw] p-3" : "2xl:p-[0.417vw] p-[6px]",
|
||||
!enabled
|
||||
? "bg-[#FF4517] hover:bg-[#FF4517]/85"
|
||||
: size === "large"
|
||||
? "bg-[#FFFFFF]/15 hover:bg-[#FFFFFF]/25"
|
||||
: "bg-[#141414]/15 hover:bg-[#141414]/25",
|
||||
"backdrop-blur-[10px] rounded-full transition-colors cursor-pointer disabled:!cursor-default outline-none disabled:bg-[#FF4517] disabled:hover:bg-[#FF4517]/85",
|
||||
size === "large"
|
||||
? "2xl:p-[0.833vw] p-3 enabled:bg-[#FFFFFF]/15 enabled:hover:bg-[#FFFFFF]/25"
|
||||
: "2xl:p-[0.417vw] p-[6px] enabled:bg-[#141414]/15 enabled:hover:bg-[#141414]/25",
|
||||
className
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -13,6 +13,7 @@ import ChatPopup from "../popups/ChatPopup";
|
||||
import ParticipantsPopup from "../popups/ParticipantsPopup";
|
||||
import SharePopup from "../popups/SharePopup";
|
||||
import SettingsModal from "../modals/SettingsModal";
|
||||
import clsx from "clsx";
|
||||
|
||||
function ControlsPopover() {
|
||||
const [isOpened, setIsOpened] = useState(false);
|
||||
@@ -41,10 +42,10 @@ function ControlsPopover() {
|
||||
const { setModal } = useModalStore();
|
||||
|
||||
return (
|
||||
<div className="2xl:hidden order-3 relative ah-full">
|
||||
<div className="2xl:hidden order-3 relative">
|
||||
<FloatingActionButton
|
||||
ref={buttonRef}
|
||||
className="!bg-[#7B60F3]"
|
||||
className={clsx(isOpened && "!bg-[#7B60F3]")}
|
||||
onClick={() => setIsOpened(!isOpened)}
|
||||
>
|
||||
<div className="size-4 text-white">
|
||||
@@ -53,7 +54,7 @@ function ControlsPopover() {
|
||||
</FloatingActionButton>
|
||||
<PopoverWrapper
|
||||
isOpened={isOpened}
|
||||
buttonRef={buttonRef}
|
||||
parentElRef={buttonRef}
|
||||
className="w-[248px] !bottom-[72px] min-h-full !fixed left-1/2 -translate-x-1/2"
|
||||
>
|
||||
<Button
|
||||
|
||||
@@ -18,10 +18,10 @@ 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"
|
||||
className="text-ellipsis text-s hover:cursor-pointer overflow-hidden text-nowrap"
|
||||
onClick={handleCopy}
|
||||
>
|
||||
{link}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -4,14 +4,14 @@ import clsx from "clsx";
|
||||
|
||||
interface PopoverProps {
|
||||
isOpened: boolean;
|
||||
buttonRef: React.RefObject<HTMLButtonElement | null>;
|
||||
parentElRef: React.RefObject<HTMLButtonElement | HTMLDivElement | null>;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function PopoverWrapper({
|
||||
isOpened,
|
||||
buttonRef,
|
||||
parentElRef,
|
||||
children,
|
||||
className,
|
||||
}: PopoverProps) {
|
||||
@@ -19,13 +19,13 @@ function PopoverWrapper({
|
||||
const [menuHeight, setMenuHeight] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpened && buttonRef.current) {
|
||||
const buttonRect = buttonRef.current.getBoundingClientRect();
|
||||
if (isOpened && parentElRef.current) {
|
||||
const buttonRect = parentElRef.current.getBoundingClientRect();
|
||||
const spaceBelow = window.innerHeight - buttonRect.bottom;
|
||||
const spaceAbove = buttonRect.top;
|
||||
setOpenUpwards(spaceBelow < menuHeight && spaceAbove > menuHeight);
|
||||
}
|
||||
}, [buttonRef, isOpened, menuHeight]);
|
||||
}, [parentElRef, isOpened, menuHeight]);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
|
||||
@@ -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"
|
||||
)}
|
||||
>
|
||||
@@ -97,7 +109,9 @@ function Select({
|
||||
>
|
||||
<CheckIcon />
|
||||
</div>
|
||||
<span className="text-s">{option}</span>
|
||||
<span className="text-s line-clamp-1 text-ellipsis text-nowrap">
|
||||
{option}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</motion.div>
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import HandRaisedOffFilledIcon from "../icons/HandRaisedOffFilledIcon";
|
||||
import HandRaisedFilledIcon from "../icons/HandRaisedFilledIcon";
|
||||
import MicrophoneFilledIcon from "../icons/MicrophoneFilledIcon";
|
||||
@@ -23,7 +21,7 @@ interface UserCameraControlsProps {
|
||||
interface UserCameraProps extends UserCameraControlsProps {
|
||||
isAdmin?: boolean;
|
||||
name?: string;
|
||||
mediaStream?: string;
|
||||
mediaStream?: MediaStream | null;
|
||||
isSpeaking?: boolean;
|
||||
}
|
||||
|
||||
@@ -34,47 +32,41 @@ export default function UserCamera({
|
||||
onMute,
|
||||
onVideoOff,
|
||||
onCanControl,
|
||||
|
||||
isSpeaking = false,
|
||||
isAdmin = false,
|
||||
name = "Гость",
|
||||
mediaStream = "",
|
||||
mediaStream = null,
|
||||
}: UserCameraProps) {
|
||||
const [isHover, setIsHover] = useState(false);
|
||||
const ref = useRef<HTMLVideoElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (ref.current) {
|
||||
ref.current.srcObject = mediaStream;
|
||||
}
|
||||
}, [mediaStream]);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
onMouseEnter={() => setIsHover(true)}
|
||||
onMouseLeave={() => setIsHover(false)}
|
||||
animate={{
|
||||
width: isHover ? "10.833vw" : "6.944vw",
|
||||
border: isSpeaking
|
||||
? "0.139vw solid #7B60F3"
|
||||
: "0.139vw solid #FFFFFF4D",
|
||||
}}
|
||||
<div
|
||||
className={clsx(
|
||||
"aspect-square rounded-[1.667vw] bg-yellow-500 relative flex-shrink-0",
|
||||
isAdmin && "order-last"
|
||||
"aspect-square group 2xl:rounded-[1.667vw] rounded-2xl relative flex-shrink-0 transition-[width,box-shadow,background-color] duration-300 pointer-events-auto hover:w-[10.833vw] w-[6.944vw] shadow-[0_4px_40px_0_rgba(15,16,17,0.1),0_2px_2px_0_rgba(0,0,0,0.06)]",
|
||||
isAdmin && "order-3",
|
||||
isSpeaking
|
||||
? "ring-[0.139vw] ring-[#7B60F3]"
|
||||
: "ring-[0.069vw] ring-[#FFFFFF4D]",
|
||||
isVideoOff ? "bg-green-500" : "bg-yellow-500"
|
||||
)}
|
||||
>
|
||||
{isAdmin && <Admin className="absolute top-0 right-0" />}
|
||||
<AnimatePresence mode="wait">
|
||||
{isHover && (
|
||||
<motion.div
|
||||
key="name"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="absolute whitespace-nowrap text-white button-s top-[0.556vw] left-1/2 translate-x-[-50%] px-[0.556vw] py-[0.278vw] rounded-full bg-[#14141440] backdrop-blur-[4px]"
|
||||
>
|
||||
{name}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<div
|
||||
key="name"
|
||||
className="absolute whitespace-nowrap transition-opacity duration-300 group-hover:opacity-100 opacity-0 text-white button-s top-[0.556vw] left-1/2 translate-x-[-50%] px-[0.556vw] py-[0.278vw] rounded-full bg-[#14141440] backdrop-blur-[4px]"
|
||||
>
|
||||
{name}
|
||||
</div>
|
||||
|
||||
<video
|
||||
src={mediaStream}
|
||||
ref={ref}
|
||||
className="size-full object-cover"
|
||||
autoPlay
|
||||
muted={isMuted}
|
||||
@@ -85,12 +77,11 @@ export default function UserCamera({
|
||||
isMuted={isMuted}
|
||||
isVideoOff={isVideoOff}
|
||||
isControlDisabled={isControlDisabled}
|
||||
isHover={isHover}
|
||||
onMute={onMute}
|
||||
onVideoOff={onVideoOff}
|
||||
onCanControl={onCanControl}
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -98,63 +89,51 @@ function UserCameraControls({
|
||||
isMuted,
|
||||
isVideoOff,
|
||||
isControlDisabled,
|
||||
isHover,
|
||||
onMute,
|
||||
onVideoOff,
|
||||
onCanControl,
|
||||
}: UserCameraControlsProps & { isHover: boolean }) {
|
||||
}: UserCameraControlsProps) {
|
||||
return (
|
||||
<div className="absolute bottom-[0.278vw] left-1/2 translate-x-[-50%]">
|
||||
<AnimatePresence mode="wait">
|
||||
{isHover ? (
|
||||
<motion.div
|
||||
key="controls"
|
||||
className="flex gap-[0.278vw] mb-[0.278vw]"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<ControlButton
|
||||
icon={isMuted ? <MicrophoneOffIcon /> : <MicrophoneFilledIcon />}
|
||||
size={"small"}
|
||||
enabled={!isMuted}
|
||||
onClick={onMute}
|
||||
/>
|
||||
<ControlButton
|
||||
icon={isVideoOff ? <VideoOffFilledIcon /> : <VideoFilledIcon />}
|
||||
size={"small"}
|
||||
enabled={!isVideoOff}
|
||||
onClick={onVideoOff}
|
||||
/>
|
||||
<ControlButton
|
||||
icon={
|
||||
isControlDisabled ? (
|
||||
<HandRaisedOffFilledIcon />
|
||||
) : (
|
||||
<HandRaisedFilledIcon />
|
||||
)
|
||||
}
|
||||
size={"small"}
|
||||
enabled={!isControlDisabled}
|
||||
onClick={onCanControl}
|
||||
/>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="controls-muted"
|
||||
className="size-[1.667vw] bg-[#14141426] backdrop-blur-[4px] rounded-full flex items-center justify-center z-10"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: isMuted ? 1 : 0 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<div className="size-[0.972vw] text-white flex items-center justify-center z-20">
|
||||
<MicrophoneOffIcon />
|
||||
</div>
|
||||
</motion.div>
|
||||
<div className="absolute transition-[bottom] duration-300 2xl:bottom-[0.278vw] 2xl:group-hover:bottom-[0.556vw] group-hover:bottom-2 bottom-1 left-1/2 -translate-x-1/2">
|
||||
<div
|
||||
className={clsx(
|
||||
"2xl:size-[1.667vw] size-6 bg-[#14141426] backdrop-blur-[4px] transition-opacity duration-300 rounded-full flex items-center justify-center z-10a absolute left-1/2 -translate-x-1/2 2xl:bottom-0 [0.278vw] group-hover:opacity-0",
|
||||
isMuted ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
</AnimatePresence>
|
||||
>
|
||||
<div className="size-[0.972vw] text-white">
|
||||
<MicrophoneOffIcon />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="flex gap-[0.278vw] mb-[0.278vw] group-hover:opacity-100 opacity-0 transition-opacity duration-300"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<ControlButton
|
||||
icon={isMuted ? <MicrophoneOffIcon /> : <MicrophoneFilledIcon />}
|
||||
size={"small"}
|
||||
disabled={isMuted}
|
||||
onClick={onMute}
|
||||
/>
|
||||
<ControlButton
|
||||
icon={isVideoOff ? <VideoOffFilledIcon /> : <VideoFilledIcon />}
|
||||
size={"small"}
|
||||
disabled={isVideoOff}
|
||||
onClick={onVideoOff}
|
||||
/>
|
||||
<ControlButton
|
||||
icon={
|
||||
isControlDisabled ? (
|
||||
<HandRaisedOffFilledIcon />
|
||||
) : (
|
||||
<HandRaisedFilledIcon />
|
||||
)
|
||||
}
|
||||
size={"small"}
|
||||
disabled={isControlDisabled}
|
||||
onClick={onCanControl}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import SettingsModal from "../modals/SettingsModal";
|
||||
|
||||
export default function UserDevicesControls() {
|
||||
const { setModal } = useModalStore();
|
||||
|
||||
function ToggleAudioDevice() {
|
||||
console.log("Mute device");
|
||||
}
|
||||
@@ -22,29 +23,29 @@ export default function UserDevicesControls() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="hidden 2xl:flex aspect-square p-[0.556vw] flex-wrap justify-between items-center size-[6.944vw] rounded-[1.667vw] bg-[#00000040] shadow-[0_4px_40px_0_#0F10111A] backdrop-blur-[10px]">
|
||||
<div className="hidden order-4 2xl:grid grid-cols-2 gap-[0.278vw] aspect-square p-[0.556vw] flex-wrap justify-between items-center size-[6.944vw] rounded-[1.667vw] bg-[#00000040] shadow-[0_4px_40px_0_#0F10111A] backdrop-blur-[10px] pointer-events-auto">
|
||||
<ControlButton
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
size="large"
|
||||
icon={<MicrophoneFilledIcon />}
|
||||
onClick={ToggleAudioDevice}
|
||||
enabled={true}
|
||||
/>
|
||||
<ControlButton
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
size="large"
|
||||
icon={<VideoFilledIcon />}
|
||||
enabled={true}
|
||||
onClick={ToggleVideoDevice}
|
||||
/>
|
||||
<ControlButton
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
size="large"
|
||||
icon={<HandRaisedFilledIcon />}
|
||||
onClick={ToggleCanControl}
|
||||
enabled={true}
|
||||
/>
|
||||
<ControlButton
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
size="large"
|
||||
icon={<CogFilledIcon />}
|
||||
enabled={true}
|
||||
onClick={ToggleSettings}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Проверяет доступность navigator.mediaDevices API
|
||||
*
|
||||
* API может быть недоступен если:
|
||||
* - Браузер не поддерживает MediaDevices API
|
||||
* - Сайт открыт без HTTPS (кроме localhost)
|
||||
* - В браузере отключены медиа-возможности
|
||||
*
|
||||
* @returns true если API доступен, иначе false
|
||||
*/
|
||||
export function isMediaDevicesSupported(): boolean {
|
||||
return !!(
|
||||
navigator &&
|
||||
navigator.mediaDevices &&
|
||||
navigator.mediaDevices.getUserMedia
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Безопасная обертка для доступа к MediaDevices API
|
||||
*
|
||||
* @param callback - функция, которая использует navigator.mediaDevices
|
||||
* @param onError - опциональный обработчик ошибки
|
||||
* @returns результат callback или undefined при ошибке
|
||||
*/
|
||||
export async function safeMediaDeviceAccess<T>(
|
||||
callback: () => Promise<T>,
|
||||
onError?: (error: Error) => void
|
||||
): Promise<T | undefined> {
|
||||
if (!isMediaDevicesSupported()) {
|
||||
const error = new Error(
|
||||
"navigator.mediaDevices недоступен. Возможно, требуется HTTPS или поддержка браузера."
|
||||
);
|
||||
console.error(error.message);
|
||||
onError?.(error);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
return await callback();
|
||||
} catch (error) {
|
||||
console.error("Ошибка при работе с MediaDevices:", error);
|
||||
if (onError && error instanceof Error) {
|
||||
onError(error);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
+7
-4
@@ -2,16 +2,18 @@ import { createRoot } from "react-dom/client";
|
||||
import "./index.css";
|
||||
import HomePage from "./pages/HomePage";
|
||||
import { createBrowserRouter, RouterProvider } from "react-router";
|
||||
import SessionPage from "./pages/SessionPage";
|
||||
// import SessionPage from "./pages/SessionPage";
|
||||
import LoginPage from "./pages/LoginPage";
|
||||
import RegisterPage from "./pages/RegisterPage";
|
||||
// import TestPage from "./pages/TestPage";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { queryClient } from "./lib/queryClient";
|
||||
import ProtectedRoute from "./components/ProtectedRoute";
|
||||
import PublicRoute from "./components/PublicRoute";
|
||||
import ModalContainer from "./components/ModalContainer";
|
||||
import PopupContainer from "./components/PopupContainer";
|
||||
import ToastsContainer from "./components/toasts/ToastsContainer";
|
||||
// import NewSessionPage from "./pages/NewSessionPage";
|
||||
import TestPage from "./pages/TestPage";
|
||||
import NewSessionPage from "./pages/NewSessionPage";
|
||||
|
||||
const router = createBrowserRouter([
|
||||
@@ -41,11 +43,11 @@ const router = createBrowserRouter([
|
||||
},
|
||||
{
|
||||
path: "/test",
|
||||
element: <NewSessionPage />,
|
||||
element: <TestPage />,
|
||||
},
|
||||
{
|
||||
path: "/sessions/:id",
|
||||
element: <SessionPage />,
|
||||
element: <NewSessionPage />,
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -54,5 +56,6 @@ createRoot(document.getElementById("root")!).render(
|
||||
<RouterProvider router={router} />
|
||||
<ModalContainer />
|
||||
<PopupContainer />
|
||||
<ToastsContainer />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
||||
@@ -7,8 +7,13 @@ 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 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";
|
||||
import ParticipantsPopup from "../components/popups/ParticipantsPopup";
|
||||
import SessionUsersPanel from "../components/SessionUsersPanel";
|
||||
|
||||
function HomePage() {
|
||||
const { data: user } = useMe();
|
||||
@@ -23,6 +28,31 @@ function HomePage() {
|
||||
const { setPopup } = usePopupStore();
|
||||
const { setModal } = useModalStore();
|
||||
|
||||
// -------------------------------- Тосты --------------------------------
|
||||
// const { addToast } = useToastsStore();
|
||||
|
||||
// useEffect(() => {
|
||||
// addToast({
|
||||
// id: crypto.randomUUID(),
|
||||
// type: "warning",
|
||||
// title: "Тестовое предупреждение",
|
||||
// message: "Это тестовое предупреждение",
|
||||
// onDeny: () => {},
|
||||
// onAllow: () => {},
|
||||
// });
|
||||
// const timer = setTimeout(() => {
|
||||
// addToast({
|
||||
// id: crypto.randomUUID(),
|
||||
// type: "notification",
|
||||
// title: "Тестовое уведомление",
|
||||
// message: "Это тестовое уведомление",
|
||||
// onDeny: () => {},
|
||||
// onAllow: () => {},
|
||||
// });
|
||||
// }, 1000);
|
||||
// return () => clearTimeout(timer);
|
||||
// }, []);
|
||||
|
||||
return (
|
||||
<div className="py-8 min-h-screen bg-gray-50">
|
||||
<div className="px-4 mx-auto max-w-4xl">
|
||||
@@ -39,7 +69,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,6 +1,5 @@
|
||||
import ActionsSidebarWrapper from "../components/ActionsSidebarWrapper";
|
||||
import ChatFilledIcon from "../components/icons/ChatFilledIcon";
|
||||
import CogFilledIcon from "../components/icons/CogFilledIcon";
|
||||
import ExitFilledIcon from "../components/icons/ExitFilledIcon";
|
||||
import FullscreenExitIcon from "../components/icons/FullscreenExitIcon";
|
||||
import FullscreenIcon from "../components/icons/FullscreenIcon";
|
||||
@@ -14,13 +13,19 @@ import usePopupStore from "../store/popupStore";
|
||||
import ControlsPopover from "../components/ui/ControlsPopover";
|
||||
import ChatPopup from "../components/popups/ChatPopup";
|
||||
import SharePopup from "../components/popups/SharePopup";
|
||||
import SettingsModal from "../components/modals/SettingsModal";
|
||||
import useModalStore from "../store/modalStore";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api } from "../lib/api";
|
||||
import type { Session } from "../types/Session";
|
||||
import { PixelStreamingWrapper } from "../components/PixelStreamingWrapper";
|
||||
import WarningIcon from "../components/icons/WarningIcon";
|
||||
import Button from "../components/ui/Button";
|
||||
import LoaderIcon from "../components/icons/LoaderIcon";
|
||||
import SessionUsersPanel from "../components/SessionUsersPanel";
|
||||
|
||||
function NewSessionPage() {
|
||||
const { setPopup } = usePopupStore();
|
||||
const { setModal } = useModalStore();
|
||||
const { setPopup, setPosition } = usePopupStore();
|
||||
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
|
||||
@@ -39,12 +44,119 @@ function NewSessionPage() {
|
||||
);
|
||||
}, []);
|
||||
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const {
|
||||
data: sessionData,
|
||||
isLoading,
|
||||
error,
|
||||
// refetch,
|
||||
} = useQuery({
|
||||
queryKey: ["session", id],
|
||||
queryFn: async () => {
|
||||
const response = await api.get(`sessions/${id}`).json<{
|
||||
session: Session;
|
||||
}>();
|
||||
return response;
|
||||
},
|
||||
refetchInterval: (query) => {
|
||||
// Автоматически обновляем каждые 2 секунды, если сессия в процессе запуска
|
||||
const data = query.state.data;
|
||||
if (
|
||||
data?.session.status === "starting" ||
|
||||
data?.session.status === "ending"
|
||||
) {
|
||||
return 2000;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
});
|
||||
|
||||
const session = sessionData?.session;
|
||||
|
||||
// Перенаправление на тестовую страницу при завершении сессии
|
||||
useEffect(() => {
|
||||
if (session?.status === "ended") {
|
||||
const timer = setTimeout(() => {
|
||||
navigate("/test");
|
||||
}, 5000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [session?.status, navigate]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-screen bg-gray-50">
|
||||
<div className="flex flex-col gap-4 items-center">
|
||||
<div className="size-12 text-[#7B60F3] animate-spin">
|
||||
<LoaderIcon />
|
||||
</div>
|
||||
<p className="text-gray-600 text-m">
|
||||
Загрузка информации о сессии...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !session) {
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-screen bg-gray-50">
|
||||
<div className="p-8 w-full max-w-2xl bg-white rounded-lg shadow-md">
|
||||
<div className="flex gap-4 items-start">
|
||||
<div className="text-red-500 size-6">
|
||||
<WarningIcon />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h1 className="mb-2 text-red-900 title-l">Сессия не найдена</h1>
|
||||
<p className="mb-6 text-gray-600 text-m">
|
||||
{error instanceof Error
|
||||
? error.message
|
||||
: "Не удалось загрузить информацию о сессии"}
|
||||
</p>
|
||||
<Button variant="primary" onClick={() => navigate("/test")}>
|
||||
Вернуться назад
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative w-screen h-screen bg-[#DADADA] order-3 overflow-hidden">
|
||||
<div className="relative w-screen h-screen bg-black order-3 overflow-hidden flex justify-center_items-center">
|
||||
{session.status === "started" &&
|
||||
session.mode === "stream" &&
|
||||
session.server?.localIp &&
|
||||
session.playerPort && (
|
||||
<div className="aspect-video w-full h-full">
|
||||
<PixelStreamingWrapper
|
||||
initialSettings={{
|
||||
ss: `ws://${session.server.localIp}:${session.playerPort}`,
|
||||
AutoPlayVideo: true,
|
||||
AutoConnect: true,
|
||||
StartVideoMuted: true,
|
||||
HoveringMouse: true,
|
||||
WaitForStreamer: true,
|
||||
}}
|
||||
onVideoInitialized={() => {
|
||||
console.log("Video initialized");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<ActionsSidebarWrapper>
|
||||
<FloatingActionButton
|
||||
className="max-2xl:hidden"
|
||||
onClick={() => setPopup(<ChatPopup />)}
|
||||
onClick={() => {
|
||||
setPosition({
|
||||
x: ((1440 - 384) / 1440) * innerWidth,
|
||||
y: (200 / 1440) * innerWidth,
|
||||
});
|
||||
setPopup(<ChatPopup />);
|
||||
}}
|
||||
>
|
||||
<div className="size-[1.111vw] text-white">
|
||||
<ChatFilledIcon />
|
||||
@@ -52,7 +164,13 @@ function NewSessionPage() {
|
||||
</FloatingActionButton>
|
||||
<FloatingActionButton
|
||||
className="max-2xl:hidden"
|
||||
onClick={() => setPopup(<ParticipantsPopup />)}
|
||||
onClick={() => {
|
||||
setPosition({
|
||||
x: ((1440 - 384) / 1440) * innerWidth,
|
||||
y: (234 / 800) * innerHeight,
|
||||
});
|
||||
setPopup(<ParticipantsPopup />);
|
||||
}}
|
||||
>
|
||||
<div className="size-[1.111vw] text-white">
|
||||
<UsersFilledIcon />
|
||||
@@ -61,21 +179,15 @@ function NewSessionPage() {
|
||||
<FloatingActionButton
|
||||
className="max-2xl:hidden"
|
||||
onClick={() =>
|
||||
setPopup(<SharePopup link="https://estate.stream/ahdy12jdco1" />)
|
||||
setPopup(
|
||||
<SharePopup link={`${window.location.origin}/sessions/${id}`} />
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="size-[1.111vw] text-white">
|
||||
<ShareFilledIcon />
|
||||
</div>
|
||||
</FloatingActionButton>
|
||||
<FloatingActionButton
|
||||
className="max-2xl:hidden"
|
||||
onClick={() => setModal(<SettingsModal />)}
|
||||
>
|
||||
<div className="size-[1.111vw] text-white">
|
||||
<CogFilledIcon />
|
||||
</div>
|
||||
</FloatingActionButton>
|
||||
<FloatingActionButton className="2xl:hidden">
|
||||
<div className="size-4 text-white">
|
||||
<MicrophoneFilledIcon />
|
||||
@@ -101,6 +213,7 @@ function NewSessionPage() {
|
||||
</FloatingActionButton>
|
||||
<ControlsPopover />
|
||||
</ActionsSidebarWrapper>
|
||||
<SessionUsersPanel />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -164,7 +164,9 @@ function SessionPage() {
|
||||
HoveringMouse: true,
|
||||
WaitForStreamer: true,
|
||||
}}
|
||||
// onVideoInitialized={() => setIsVideoInitialized(true)}
|
||||
onVideoInitialized={() => {
|
||||
console.log("Video initialized");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { create } from "zustand";
|
||||
import type { ToastLayoutProps } from "../components/toasts/ToastLayout";
|
||||
|
||||
interface ToastState {
|
||||
toasts: ToastLayoutProps[];
|
||||
addToast: (toast: ToastLayoutProps) => void;
|
||||
removeToast: (id: ToastLayoutProps["id"]) => void;
|
||||
clearToasts: () => void;
|
||||
}
|
||||
|
||||
const useToastsStore = create<ToastState>((set) => ({
|
||||
toasts: [],
|
||||
addToast: (toast) =>
|
||||
set((state) => ({
|
||||
toasts: [...state.toasts, toast],
|
||||
})),
|
||||
removeToast: (id) =>
|
||||
set((state) => ({
|
||||
toasts: state.toasts.filter((toast) => toast.id !== id),
|
||||
})),
|
||||
clearToasts: () => set({ toasts: [] }),
|
||||
}));
|
||||
|
||||
export default useToastsStore;
|
||||
@@ -0,0 +1,39 @@
|
||||
export interface Session {
|
||||
id: string;
|
||||
appId: string;
|
||||
userId: string | null;
|
||||
mode: "stream" | "local";
|
||||
status: "starting" | "started" | "ending" | "ended";
|
||||
tier: "demo" | "prod" | null;
|
||||
serverId: string | null;
|
||||
appPid: number | null;
|
||||
cirrusPid: number | null;
|
||||
streamerPort: number | null;
|
||||
playerPort: number | null;
|
||||
sfuPort: number | null;
|
||||
startAt: string;
|
||||
endAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
app?: {
|
||||
id: string;
|
||||
name: string;
|
||||
title: string;
|
||||
gpuLimitMb: number | null;
|
||||
psVersion: number | null;
|
||||
};
|
||||
server?: {
|
||||
id: string;
|
||||
localIp: string;
|
||||
hostname: string;
|
||||
type: "stream" | "local";
|
||||
tier: "demo" | "prod" | null;
|
||||
location: "ru1" | "uae1" | null;
|
||||
} | null;
|
||||
user?: {
|
||||
id: string;
|
||||
email: string;
|
||||
role: string;
|
||||
displayName: string;
|
||||
} | null;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ export default {
|
||||
extend: {},
|
||||
screens: {
|
||||
"2xl": "1440px",
|
||||
sm: "640px",
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
|
||||
Reference in New Issue
Block a user