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_WEBRTC_URL=http://localhost:3001
# VITE_API_URL=https://stream.graff.estate/api
# VITE_WEBRTC_URL=https://stream.graff.estate
# VITE_API_URL=http://localhost:3000
# VITE_WEBRTC_URL=http://localhost:3001
VITE_API_URL=https://stream.graff.estate/api
VITE_WEBRTC_URL=https://stream.graff.estate
+85 -6
View File
@@ -152,6 +152,24 @@ export default function DraggableContainer({
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 => {
switch (corner) {
@@ -446,14 +464,75 @@ export default function DraggableContainer({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isDragging]);
// Конвертируем позицию из transform-based в absolute только после стабилизации размеров
// Это нужно для корректной работы внутренних поповеров
useEffect(() => {
if (!containerRef.current || enableSnapping) return;
const rect = containerRef.current.getBoundingClientRect();
setPosition({
top: rect.top,
left: rect.left,
if (!containerRef.current || enableSnapping || !position.transform) return;
const container = containerRef.current;
let timeoutId: NodeJS.Timeout;
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 стили на элемент-хэндл
useEffect(() => {
+1 -1
View File
@@ -20,7 +20,7 @@ function PopupWrapper({
return (
<div
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
)}
>
+1 -1
View File
@@ -208,7 +208,7 @@ function SessionUsersPanel({
isVideoOff={participant.isVideoOff || false}
isSpeaking={participant.isSpeaking}
isControlDisabled={true}
isAdmin={true}
isAdmin={false}
mediaStream={participant.stream}
hasLocalMediaPermission={hasLocalStream}
onMute={() => console.log(`Mute user ${participant.id}`)}
+2 -2
View File
@@ -5,11 +5,11 @@ export default function Admin({ className }: { className?: string }) {
return (
<div
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
)}
>
<div className="size-[0.694vw] text-white">
<div className="2xl:size-[0.694vw] size-[10px] text-white">
<CrownIcon />
</div>
</div>
+44 -19
View File
@@ -13,7 +13,9 @@ interface ChatPopupProps {
sessionId?: string;
}
export default function ChatPopup({ sessionId: sessionIdProp }: ChatPopupProps = {}) {
export default function ChatPopup({
sessionId: sessionIdProp,
}: ChatPopupProps = {}) {
const headerRef = useRef<HTMLDivElement>(null);
// Получаем sessionId из пропсов или из URL параметров
const { id: sessionIdFromParams } = useParams<{ id: string }>();
@@ -32,7 +34,10 @@ export default function ChatPopup({ sessionId: sessionIdProp }: ChatPopupProps =
// Обновляем userId в WebRTC сервисе при авторизации
useEffect(() => {
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);
}
}, [user?.id, updateUserId]);
@@ -42,10 +47,23 @@ export default function ChatPopup({ sessionId: sessionIdProp }: ChatPopupProps =
const myIdentifier = user?.id || currentUserId;
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
const { data: historyMessages = [], isLoading, error } = useChatHistory(sessionId);
const {
data: historyMessages = [],
isLoading,
error,
} = useChatHistory(sessionId);
console.log("[ChatPopup] Chat history state:", {
historyMessages: historyMessages.length,
@@ -67,9 +85,9 @@ export default function ChatPopup({ sessionId: sessionIdProp }: ChatPopupProps =
const newRealtimeMessages = sessionRealtimeMessages.filter(
(m) => !historyIds.has(m.id)
);
// Дедупликация на всякий случай - убираем дубликаты по ID
const allMessagesMap = new Map<string, typeof historyMessages[0]>();
const allMessagesMap = new Map<string, (typeof historyMessages)[0]>();
[...historyMessages, ...newRealtimeMessages].forEach((msg) => {
allMessagesMap.set(msg.id, msg);
});
@@ -85,7 +103,10 @@ export default function ChatPopup({ sessionId: sessionIdProp }: ChatPopupProps =
newRealtime: newRealtimeMessages.length,
total: allMessages.length,
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) {
@@ -103,10 +124,10 @@ export default function ChatPopup({ sessionId: sessionIdProp }: ChatPopupProps =
>
<PopupWrapper
title="Чат"
className="sm:overflow-hidden"
className="overflow-hidden"
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
messages={allMessages}
currentUserId={myIdentifier}
@@ -137,12 +158,15 @@ function MessageFeed({ messages, currentUserId, isLoading }: MessageFeedProps) {
const isInitialMount = useRef(true);
console.log("[MessageFeed] Rendering with currentUserId:", currentUserId);
console.log("[MessageFeed] Messages:", messages.map(m => ({
id: m.id,
senderId: m.senderId,
isFromMe: m.senderId === currentUserId,
content: m.content.substring(0, 20)
})));
console.log(
"[MessageFeed] Messages:",
messages.map((m) => ({
id: m.id,
senderId: m.senderId,
isFromMe: m.senderId === currentUserId,
content: m.content.substring(0, 20),
}))
);
// Скролл при первой загрузке сообщений
useEffect(() => {
@@ -313,13 +337,14 @@ function MessageInput({
e.preventDefault();
const textarea = textareaRef.current;
if (!textarea) return;
const start = textarea.selectionStart;
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);
// Устанавливаем курсор после вставленного переноса строки
setTimeout(() => {
textarea.selectionStart = textarea.selectionEnd = start + 1;
@@ -128,7 +128,10 @@ function ParticipantItem({
: undefined;
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">
<Avatar size="medium" status={status} />
<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}
parentElRef={buttonRef}
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) => (
<Button
+3 -3
View File
@@ -25,11 +25,11 @@ export default function Avatar({ size, status, src, name }: AvatarProps) {
className={clsx(
"absolute",
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" &&
"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" &&
"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" && (
+58 -22
View File
@@ -279,20 +279,20 @@ export default function UserCamera({
<div
className={clsx(
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-video 2xl:rounded-[1.667vw] rounded-2xl overflow-hidden group relative pointer-events-auto max-w-full 2xl:h-full object-cover",
? "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 group relative pointer-events-auto max-w-full 2xl:h-full object-cover",
isLocal && "order-last",
isVideoOff ? "bg-green-500" : "bg-yellow-500/10",
// isVideoOff ? "bg-green-500" : "bg-yellow-500/10",
className
)}
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 >= 1536 ? "0.139vw" : "2px"
} 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 ${
window.innerWidth >= 1536 ? "0.069vw" : "1px"
} rgba(255, 255, 255, 0.3)`,
// 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 >= 1536 ? "0.139vw" : "2px"
// } 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 ${
// window.innerWidth >= 1536 ? "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"
@@ -300,20 +300,29 @@ export default function UserCamera({
}}
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]">
{name}
</div>
{mode === "mini" && (
<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]">
{name}
</div>
)}
{/* Заглушка когда нет видео */}
{(!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 flex-col gap-2 items-center text-white/60">
<div className="2xl:size-[2.778vw] size-10">
<VideoOffFilledIcon />
</div>
<span className="text-xs">Нет видео</span>
// <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="2xl:size-[2.778vw] size-10">
// <VideoOffFilledIcon />
// </div>
// <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>
)}
@@ -321,13 +330,26 @@ export default function UserCamera({
<video
ref={ref}
className={clsx(
"object-cover size-full",
"object-cover size-full 2xl:rounded-[1.667vw] rounded-2xl",
isLocal && "scale-x-[-1]",
(!mediaStream || isVideoOff) && "hidden"
)}
autoPlay
muted={isLocal ? true : isAudioMuted}
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={() => {
if (!isLocal && ref.current) {
console.log(
@@ -394,8 +416,22 @@ export default function UserCamera({
</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
isMuted={isMuted}
isVideoOff={isVideoOff}
+12 -1
View File
@@ -28,9 +28,12 @@ import MicrophoneOffFilledIcon from "../components/icons/MicrophoneOffFilledIcon
import VideoFilledIcon from "../components/icons/VideoFilledIcon";
import clsx from "clsx";
import { useMe } from "../hooks/useAuth";
import QuitSessionModal from "../components/modals/QuitSessionModal";
import useModalStore from "../store/modalStore";
function SessionPage() {
const { setPopup } = usePopupStore();
const { setModal } = useModalStore();
// Загружаем данные пользователя сразу при входе на страницу сессии
// React Query закэширует результат для использования в других компонентах
@@ -104,6 +107,10 @@ function SessionPage() {
setMode(mode === "full" ? "mini" : "full");
}
function handleQuitSessionModalOpen() {
setModal(<QuitSessionModal onQuitSession={() => navigate("/test")} />);
}
// Не перенаправляем автоматически - пользователи могут продолжать общаться
// useEffect(() => {
// if (session?.status === "ended") {
@@ -278,7 +285,11 @@ function SessionPage() {
{isFullscreen ? <FullscreenExitIcon /> : <FullscreenIcon />}
</div>
</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">
<ExitFilledIcon />
</div>