Update environment variables for production URLs, enhance DraggableContainer with padding parsing for responsive positioning, and improve PopupWrapper and SessionUsersPanel styling for better layout consistency. Refactor ChatPopup and UserCamera components for improved logging and conditional rendering based on user roles.

This commit is contained in:
2025-11-05 15:07:38 +05:00
parent d0c9138b22
commit ddeb7d8148
11 changed files with 215 additions and 61 deletions
+4 -4
View File
@@ -1,4 +1,4 @@
VITE_API_URL=http://localhost:3000 # VITE_API_URL=http://localhost:3000
VITE_WEBRTC_URL=http://localhost:3001 # VITE_WEBRTC_URL=http://localhost:3001
# VITE_API_URL=https://stream.graff.estate/api VITE_API_URL=https://stream.graff.estate/api
# VITE_WEBRTC_URL=https://stream.graff.estate VITE_WEBRTC_URL=https://stream.graff.estate
+85 -6
View File
@@ -152,6 +152,24 @@ export default function DraggableContainer({
initialPosition: { top: 0, left: 0 }, initialPosition: { top: 0, left: 0 },
}); });
// Функция для преобразования padding в пиксели
const parsePadding = (paddingValue: number | string): number => {
if (typeof paddingValue === "number") return paddingValue;
const value = parseFloat(paddingValue);
if (paddingValue.endsWith("vw")) {
return (value / 100) * window.innerWidth;
} else if (paddingValue.endsWith("vh")) {
return (value / 100) * window.innerHeight;
} else if (paddingValue.endsWith("%")) {
// Для процентов берем среднее между шириной и высотой
return (value / 100) * Math.min(window.innerWidth, window.innerHeight);
} else {
// По умолчанию считаем что это пиксели
return value;
}
};
// Функция для преобразования угла в позицию // Функция для преобразования угла в позицию
const getPositionFromCorner = (corner: Corner): Position => { const getPositionFromCorner = (corner: Corner): Position => {
switch (corner) { switch (corner) {
@@ -446,14 +464,75 @@ export default function DraggableContainer({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [isDragging]); }, [isDragging]);
// Конвертируем позицию из transform-based в absolute только после стабилизации размеров
// Это нужно для корректной работы внутренних поповеров
useEffect(() => { useEffect(() => {
if (!containerRef.current || enableSnapping) return; if (!containerRef.current || enableSnapping || !position.transform) return;
const rect = containerRef.current.getBoundingClientRect();
setPosition({ const container = containerRef.current;
top: rect.top, let timeoutId: NodeJS.Timeout;
left: rect.left, let lastHeight = 0;
// Используем ResizeObserver для отслеживания изменений размера
const resizeObserver = new ResizeObserver((entries) => {
const entry = entries[0];
const currentHeight = entry.contentRect.height;
// Если высота изменилась, перезапускаем таймер
if (currentHeight !== lastHeight) {
lastHeight = currentHeight;
clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
if (!containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
let { top, left } = rect;
// Проверяем, не выходит ли попап за границы экрана
const windowHeight = window.innerHeight;
const windowWidth = window.innerWidth;
// Корректируем вертикальную позицию если нужно
if (centerVertical) {
if (rect.bottom > windowHeight) {
// Попап выходит за нижнюю границу - прижимаем к низу с отступом
top = windowHeight - rect.height - parsePadding(padding);
} else if (rect.top < 0) {
// Попап выходит за верхнюю границу - прижимаем к верху с отступом
top = parsePadding(padding);
}
}
// Корректируем горизонтальную позицию если нужно
if (centerHorizontal) {
if (rect.right > windowWidth) {
left = windowWidth - rect.width - parsePadding(padding);
} else if (rect.left < 0) {
left = parsePadding(padding);
}
}
setPosition({
top,
left,
});
}, 100);
}
}); });
}, [enableSnapping]);
resizeObserver.observe(container);
return () => {
resizeObserver.disconnect();
clearTimeout(timeoutId);
};
}, [
enableSnapping,
position.transform,
centerVertical,
centerHorizontal,
padding,
]);
// Устанавливаем cursor стили на элемент-хэндл // Устанавливаем cursor стили на элемент-хэндл
useEffect(() => { useEffect(() => {
+1 -1
View File
@@ -20,7 +20,7 @@ function PopupWrapper({
return ( return (
<div <div
className={clsx( className={clsx(
"2xl:rounded-[2.222vw] 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]", "2xl:rounded-[2.222vw] bg-white shadow-[0_4px_40px_0_rgba(15,16,17,0.1)] 2xl:w-[21.667vw] sm:rounded-[32px] max-sm:w-screen sm:w-[312px] max-sm:rounded-t-[32px]",
className className
)} )}
> >
+1 -1
View File
@@ -208,7 +208,7 @@ function SessionUsersPanel({
isVideoOff={participant.isVideoOff || false} isVideoOff={participant.isVideoOff || false}
isSpeaking={participant.isSpeaking} isSpeaking={participant.isSpeaking}
isControlDisabled={true} isControlDisabled={true}
isAdmin={true} isAdmin={false}
mediaStream={participant.stream} mediaStream={participant.stream}
hasLocalMediaPermission={hasLocalStream} hasLocalMediaPermission={hasLocalStream}
onMute={() => console.log(`Mute user ${participant.id}`)} onMute={() => console.log(`Mute user ${participant.id}`)}
+2 -2
View File
@@ -5,11 +5,11 @@ export default function Admin({ className }: { className?: string }) {
return ( return (
<div <div
className={clsx( className={clsx(
"2xl:size-[0.972vw] size-[14px] 2xl:border-[0.069vw] border-[1px] border-white rounded-full flex items-center justify-center bg-[#141414]", "2xl:p-[0.208vw] p-[3px] 2xl:ring-[0.069vw] ring-[1px] ring-white rounded-full bg-[#141414]",
className className
)} )}
> >
<div className="size-[0.694vw] text-white"> <div className="2xl:size-[0.694vw] size-[10px] text-white">
<CrownIcon /> <CrownIcon />
</div> </div>
</div> </div>
+40 -15
View File
@@ -13,7 +13,9 @@ interface ChatPopupProps {
sessionId?: string; sessionId?: string;
} }
export default function ChatPopup({ sessionId: sessionIdProp }: ChatPopupProps = {}) { export default function ChatPopup({
sessionId: sessionIdProp,
}: ChatPopupProps = {}) {
const headerRef = useRef<HTMLDivElement>(null); const headerRef = useRef<HTMLDivElement>(null);
// Получаем sessionId из пропсов или из URL параметров // Получаем sessionId из пропсов или из URL параметров
const { id: sessionIdFromParams } = useParams<{ id: string }>(); const { id: sessionIdFromParams } = useParams<{ id: string }>();
@@ -32,7 +34,10 @@ export default function ChatPopup({ sessionId: sessionIdProp }: ChatPopupProps =
// Обновляем userId в WebRTC сервисе при авторизации // Обновляем userId в WebRTC сервисе при авторизации
useEffect(() => { useEffect(() => {
if (user?.id && updateUserId) { if (user?.id && updateUserId) {
console.log("[ChatPopup] User authenticated, updating WebRTC userId to:", user.id); console.log(
"[ChatPopup] User authenticated, updating WebRTC userId to:",
user.id
);
updateUserId(user.id); updateUserId(user.id);
} }
}, [user?.id, updateUserId]); }, [user?.id, updateUserId]);
@@ -42,10 +47,23 @@ export default function ChatPopup({ sessionId: sessionIdProp }: ChatPopupProps =
const myIdentifier = user?.id || currentUserId; const myIdentifier = user?.id || currentUserId;
console.log("[ChatPopup] Rendering with sessionId:", sessionId); console.log("[ChatPopup] Rendering with sessionId:", sessionId);
console.log("[ChatPopup] My identifier:", myIdentifier, "isAuthenticated:", !!user, "user.id:", user?.id, "currentUserId:", currentUserId); console.log(
"[ChatPopup] My identifier:",
myIdentifier,
"isAuthenticated:",
!!user,
"user.id:",
user?.id,
"currentUserId:",
currentUserId
);
// Загружаем историю через REST API // Загружаем историю через REST API
const { data: historyMessages = [], isLoading, error } = useChatHistory(sessionId); const {
data: historyMessages = [],
isLoading,
error,
} = useChatHistory(sessionId);
console.log("[ChatPopup] Chat history state:", { console.log("[ChatPopup] Chat history state:", {
historyMessages: historyMessages.length, historyMessages: historyMessages.length,
@@ -69,7 +87,7 @@ export default function ChatPopup({ sessionId: sessionIdProp }: ChatPopupProps =
); );
// Дедупликация на всякий случай - убираем дубликаты по ID // Дедупликация на всякий случай - убираем дубликаты по ID
const allMessagesMap = new Map<string, typeof historyMessages[0]>(); const allMessagesMap = new Map<string, (typeof historyMessages)[0]>();
[...historyMessages, ...newRealtimeMessages].forEach((msg) => { [...historyMessages, ...newRealtimeMessages].forEach((msg) => {
allMessagesMap.set(msg.id, msg); allMessagesMap.set(msg.id, msg);
}); });
@@ -85,7 +103,10 @@ export default function ChatPopup({ sessionId: sessionIdProp }: ChatPopupProps =
newRealtime: newRealtimeMessages.length, newRealtime: newRealtimeMessages.length,
total: allMessages.length, total: allMessages.length,
historyIds: Array.from(historyIds), historyIds: Array.from(historyIds),
realtimeIds: realtimeMessages.map(m => ({ id: m.id, sessionId: m.sessionId })), realtimeIds: realtimeMessages.map((m) => ({
id: m.id,
sessionId: m.sessionId,
})),
}); });
function onMessageSend(message: string) { function onMessageSend(message: string) {
@@ -103,10 +124,10 @@ export default function ChatPopup({ sessionId: sessionIdProp }: ChatPopupProps =
> >
<PopupWrapper <PopupWrapper
title="Чат" title="Чат"
className="sm:overflow-hidden" className="overflow-hidden"
headerRef={headerRef} headerRef={headerRef}
> >
<div className="flex flex-col 2xl:h-[19.444vw] max-sm:h-[87.5dvh] 2xl:-m-[1.389vw] -m-5"> <div className="flex flex-col 2xl:h-[19.444vw] max-2xl:h-[280px] 2xl:-m-[1.389vw] -m-5">
<MessageFeed <MessageFeed
messages={allMessages} messages={allMessages}
currentUserId={myIdentifier} currentUserId={myIdentifier}
@@ -137,12 +158,15 @@ function MessageFeed({ messages, currentUserId, isLoading }: MessageFeedProps) {
const isInitialMount = useRef(true); const isInitialMount = useRef(true);
console.log("[MessageFeed] Rendering with currentUserId:", currentUserId); console.log("[MessageFeed] Rendering with currentUserId:", currentUserId);
console.log("[MessageFeed] Messages:", messages.map(m => ({ console.log(
id: m.id, "[MessageFeed] Messages:",
senderId: m.senderId, messages.map((m) => ({
isFromMe: m.senderId === currentUserId, id: m.id,
content: m.content.substring(0, 20) senderId: m.senderId,
}))); isFromMe: m.senderId === currentUserId,
content: m.content.substring(0, 20),
}))
);
// Скролл при первой загрузке сообщений // Скролл при первой загрузке сообщений
useEffect(() => { useEffect(() => {
@@ -316,7 +340,8 @@ function MessageInput({
const start = textarea.selectionStart; const start = textarea.selectionStart;
const end = textarea.selectionEnd; const end = textarea.selectionEnd;
const newMessage = message.substring(0, start) + "\n" + message.substring(end); const newMessage =
message.substring(0, start) + "\n" + message.substring(end);
setMessage(newMessage); setMessage(newMessage);
@@ -128,7 +128,10 @@ function ParticipantItem({
: undefined; : undefined;
return ( return (
<div ref={parentRef} className="flex justify-between items-center w-full"> <div
ref={parentRef}
className="flex justify-between items-center w-full 2xl:gap-[0.556vw] gap-2"
>
<div className="flex items-center 2xl:gap-[0.833vw] gap-3"> <div className="flex items-center 2xl:gap-[0.833vw] gap-3">
<Avatar size="medium" status={status} /> <Avatar size="medium" status={status} />
<div className="flex flex-col 2xl:gap-[0.278vw] gap-1"> <div className="flex flex-col 2xl:gap-[0.278vw] gap-1">
+1 -1
View File
@@ -54,7 +54,7 @@ export default function ActionsPopover({
isOpened={opened} isOpened={opened}
parentElRef={buttonRef} parentElRef={buttonRef}
position="vertical" position="vertical"
className={clsx("2xl:w-[17.222vw] w-[53.333vw]", className)} className={clsx("2xl:w-[17.222vw] max-2xl:w-[192px]", className)}
> >
{options.map((option) => ( {options.map((option) => (
<Button <Button
+3 -3
View File
@@ -25,11 +25,11 @@ export default function Avatar({ size, status, src, name }: AvatarProps) {
className={clsx( className={clsx(
"absolute", "absolute",
size === "small" && size === "small" &&
"2xl:bottom-[1.389vw] bottom-[5.556vw] 2xl:left-[1.389vw] left-[5.556vw]", "2xl:bottom-[1.389vw] bottom-5 2xl:left-[1.389vw] left-5",
size === "medium" && size === "medium" &&
"2xl:bottom-[1.667vw] bottom-[6.667vw] 2xl:left-[1.667vw] left-[6.667vw]", "2xl:bottom-[1.667vw] bottom-6 2xl:left-[1.667vw] left-6",
size === "large" && size === "large" &&
"2xl:bottom-[2.5vw] bottom-[10vw] 2xl:left-[2.5vw] left-[10vw]" "2xl:bottom-[2.5vw] bottom-9 2xl:left-[2.5vw] left-9"
)} )}
> >
{status === "caution" && ( {status === "caution" && (
+58 -22
View File
@@ -279,20 +279,20 @@ export default function UserCamera({
<div <div
className={clsx( className={clsx(
mode === "mini" mode === "mini"
? "aspect-square h-fit group 2xl:rounded-[1.667vw] rounded-2xl relative flex-shrink-0 pointer-events-auto 2xl:hover:w-[10.833vw] 2xl:w-[6.944vw] sm:w-[15.625vw] w-[27.778vw] overflow-hidden" ? "aspect-square h-fit group relative flex-shrink-0 pointer-events-auto 2xl:hover:w-[10.833vw] 2xl:w-[6.944vw] sm:w-[15.625vw] w-[27.778vw]"
: "aspect-video 2xl:rounded-[1.667vw] rounded-2xl overflow-hidden group relative pointer-events-auto max-w-full 2xl:h-full object-cover", : "aspect-video group relative pointer-events-auto max-w-full 2xl:h-full object-cover",
isLocal && "order-last", isLocal && "order-last",
isVideoOff ? "bg-green-500" : "bg-yellow-500/10", // isVideoOff ? "bg-green-500" : "bg-yellow-500/10",
className className
)} )}
style={{ style={{
boxShadow: isSpeaking // boxShadow: isSpeaking
? `0 4px 40px 0 rgba(15,16,17,0.1), 0 2px 2px 0 rgba(0,0,0,0.06), 0 0 0 ${ // ? `0 4px 40px 0 rgba(15,16,17,0.1), 0 2px 2px 0 rgba(0,0,0,0.06), 0 0 0 ${
window.innerWidth >= 1536 ? "0.139vw" : "2px" // window.innerWidth >= 1536 ? "0.139vw" : "2px"
} rgba(34, 197, 94, ${ringOpacity})` // } rgba(34, 197, 94, ${ringOpacity})`
: `0 4px 40px 0 rgba(15,16,17,0.1), 0 2px 2px 0 rgba(0,0,0,0.06), 0 0 0 ${ // : `0 4px 40px 0 rgba(15,16,17,0.1), 0 2px 2px 0 rgba(0,0,0,0.06), 0 0 0 ${
window.innerWidth >= 1536 ? "0.069vw" : "1px" // window.innerWidth >= 1536 ? "0.069vw" : "1px"
} rgba(255, 255, 255, 0.3)`, // } rgba(255, 255, 255, 0.3)`,
transition: transition:
mode === "mini" mode === "mini"
? "box-shadow 0.1s ease-out, width 0.3s, background-color 0.3s" ? "box-shadow 0.1s ease-out, width 0.3s, background-color 0.3s"
@@ -300,20 +300,29 @@ export default function UserCamera({
}} }}
onClick={handleVideoClick} onClick={handleVideoClick}
> >
{isLocal && <Admin className="absolute top-0 right-0" />} {isAdmin && mode === "mini" && (
<Admin className="absolute top-0 right-0 z-10" />
)}
<div 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]"> {mode === "mini" && (
{name} <div 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]">
</div> {name}
</div>
)}
{/* Заглушка когда нет видео */} {/* Заглушка когда нет видео */}
{(!mediaStream || isVideoOff) && ( {(!mediaStream || isVideoOff) && (
<div className="flex absolute inset-0 justify-center items-center bg-gradient-to-br from-gray-700 to-gray-900"> // <div className="flex absolute inset-0 justify-center items-center bg-gradient-to-br from-gray-700 to-gray-900">
<div className="flex flex-col gap-2 items-center text-white/60"> // <div className="flex flex-col gap-2 items-center text-white/60">
<div className="2xl:size-[2.778vw] size-10"> // <div className="2xl:size-[2.778vw] size-10">
<VideoOffFilledIcon /> // <VideoOffFilledIcon />
</div> // </div>
<span className="text-xs">Нет видео</span> // <span className="text-xs">Нет видео</span>
// </div>
// </div>
<div className="2xl:rounded-[1.667vw] rounded-2xl absolute inset-0 bg-white/15 flex items-start justify-center">
<div className="rounded-full 2xl:w-1/3 aspect-square bg-[#7B60F3] mt-12 flex items-center justify-center text-6xl font-medium text-white">
ВД
</div> </div>
</div> </div>
)} )}
@@ -321,13 +330,26 @@ export default function UserCamera({
<video <video
ref={ref} ref={ref}
className={clsx( className={clsx(
"object-cover size-full", "object-cover size-full 2xl:rounded-[1.667vw] rounded-2xl",
isLocal && "scale-x-[-1]", isLocal && "scale-x-[-1]",
(!mediaStream || isVideoOff) && "hidden" (!mediaStream || isVideoOff) && "hidden"
)} )}
autoPlay autoPlay
muted={isLocal ? true : isAudioMuted} muted={isLocal ? true : isAudioMuted}
playsInline playsInline
style={{
boxShadow: isSpeaking
? `0 4px 40px 0 rgba(15,16,17,0.1), 0 2px 2px 0 rgba(0,0,0,0.06), 0 0 0 ${
window.innerWidth >= 1440 ? "0.139vw" : "2px"
} rgba(123, 96, 243, ${ringOpacity})`
: `0 4px 40px 0 rgba(15,16,17,0.1), 0 2px 2px 0 rgba(0,0,0,0.06), 0 0 0 ${
window.innerWidth >= 1440 ? "0.069vw" : "1px"
} rgba(255, 255, 255, 0.3)`,
transition:
mode === "mini"
? "box-shadow 0.1s ease-out, width 0.3s, background-color 0.3s"
: "box-shadow 0.1s ease-out",
}}
onLoadedData={() => { onLoadedData={() => {
if (!isLocal && ref.current) { if (!isLocal && ref.current) {
console.log( console.log(
@@ -394,8 +416,22 @@ export default function UserCamera({
</div> </div>
)} )}
{mode === "full" && (
<div className="2xl:px-[1.111vw] 2xl:py-[0.556vw] px-4 py-2 bg-[#141414]/25 backdrop-blur-[10px] 2xl:rounded-[1.111vw] rounded-2xl absolute 2xl:bottom-[1.111vw] bottom-4 left-1/2 -translate-x-1/2 z-10 flex 2xl:gap-[0.556vw] gap-2 items-center">
<p className="text-white button-m font-medium">{name}</p>
{isMuted && (
<div className="2xl:size-[1.111vw] size-4 text-white/50">
<MicrophoneOffFilledIcon />
</div>
)}
{isAdmin && (
<Admin className="2xl:size-[1.111vw] size-4 absolute 2xl:-top-[0.278vw] 2xl:-right-[0.278vw] -right-1 -top-1" />
)}
</div>
)}
{/* Элементы управления только для удаленных участников */} {/* Элементы управления только для удаленных участников */}
{!isLocal && ( {!isLocal && mode === "mini" && (
<UserCameraControls <UserCameraControls
isMuted={isMuted} isMuted={isMuted}
isVideoOff={isVideoOff} isVideoOff={isVideoOff}
+12 -1
View File
@@ -28,9 +28,12 @@ import MicrophoneOffFilledIcon from "../components/icons/MicrophoneOffFilledIcon
import VideoFilledIcon from "../components/icons/VideoFilledIcon"; import VideoFilledIcon from "../components/icons/VideoFilledIcon";
import clsx from "clsx"; import clsx from "clsx";
import { useMe } from "../hooks/useAuth"; import { useMe } from "../hooks/useAuth";
import QuitSessionModal from "../components/modals/QuitSessionModal";
import useModalStore from "../store/modalStore";
function SessionPage() { function SessionPage() {
const { setPopup } = usePopupStore(); const { setPopup } = usePopupStore();
const { setModal } = useModalStore();
// Загружаем данные пользователя сразу при входе на страницу сессии // Загружаем данные пользователя сразу при входе на страницу сессии
// React Query закэширует результат для использования в других компонентах // React Query закэширует результат для использования в других компонентах
@@ -104,6 +107,10 @@ function SessionPage() {
setMode(mode === "full" ? "mini" : "full"); setMode(mode === "full" ? "mini" : "full");
} }
function handleQuitSessionModalOpen() {
setModal(<QuitSessionModal onQuitSession={() => navigate("/test")} />);
}
// Не перенаправляем автоматически - пользователи могут продолжать общаться // Не перенаправляем автоматически - пользователи могут продолжать общаться
// useEffect(() => { // useEffect(() => {
// if (session?.status === "ended") { // if (session?.status === "ended") {
@@ -278,7 +285,11 @@ function SessionPage() {
{isFullscreen ? <FullscreenExitIcon /> : <FullscreenIcon />} {isFullscreen ? <FullscreenExitIcon /> : <FullscreenIcon />}
</div> </div>
</FloatingActionButton> </FloatingActionButton>
<FloatingActionButton variant="critical" className="max-2xl:order-1"> <FloatingActionButton
variant="critical"
className="max-2xl:order-1"
onClick={handleQuitSessionModalOpen}
>
<div className="2xl:size-[1.111vw] size-4 text-white"> <div className="2xl:size-[1.111vw] size-4 text-white">
<ExitFilledIcon /> <ExitFilledIcon />
</div> </div>