All checks were successful
Code quality checks / biome (push) Successful in 22s
511 lines
12 KiB
JavaScript
511 lines
12 KiB
JavaScript
class MoveableChatFrame {
|
|
constructor() {
|
|
try {
|
|
this.initializeElements();
|
|
if (!this.frame) {
|
|
console.warn(
|
|
"MoveableChatFrame: Required elements not found, skipping initialization",
|
|
);
|
|
return;
|
|
}
|
|
|
|
this.isDragging = false;
|
|
this.isResizing = false;
|
|
this.isMinimized = false;
|
|
this.dragOffset = { x: 0, y: 0 };
|
|
this.resizeHandle = null;
|
|
this.chatLoaded = false;
|
|
|
|
this.initializeEventListeners();
|
|
this.loadSavedState();
|
|
} catch (error) {
|
|
console.error("MoveableChatFrame initialization failed:", error);
|
|
}
|
|
}
|
|
|
|
initializeElements() {
|
|
this.frame = document.getElementById("moveableChatFrame");
|
|
this.header = document.getElementById("chatFrameHeader");
|
|
this.toggleBtn = document.getElementById("toggleChatBtn");
|
|
this.minimizeBtn = document.getElementById("minimizeChatBtn");
|
|
this.closeBtn = document.getElementById("closeChatBtn");
|
|
|
|
if (!this.frame || !this.header || !this.toggleBtn) {
|
|
console.warn("MoveableChatFrame: Missing required DOM elements");
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
initializeEventListeners() {
|
|
if (
|
|
!this.toggleBtn ||
|
|
!this.minimizeBtn ||
|
|
!this.closeBtn ||
|
|
!this.header
|
|
) {
|
|
console.warn("MoveableChatFrame: Cannot bind events - missing elements");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
this.toggleBtn.addEventListener("click", (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
this.toggleChat();
|
|
});
|
|
|
|
this.minimizeBtn.addEventListener("click", (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
this.toggleMinimize();
|
|
});
|
|
|
|
this.closeBtn.addEventListener("click", (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
this.closeChat();
|
|
});
|
|
|
|
this.header.addEventListener("mousedown", (e) => {
|
|
if (e.target.closest(".chat-control-btn")) return;
|
|
e.preventDefault();
|
|
this.startDragging(e);
|
|
});
|
|
|
|
const resizeHandles = this.frame.querySelectorAll(".resize-handle");
|
|
for (const handle of resizeHandles) {
|
|
handle.addEventListener("mousedown", (e) => {
|
|
e.preventDefault();
|
|
this.startResizing(e, handle);
|
|
});
|
|
}
|
|
|
|
document.addEventListener(
|
|
"mousemove",
|
|
(e) => {
|
|
if (this.isDragging) {
|
|
this.drag(e);
|
|
} else if (this.isResizing) {
|
|
this.resize(e);
|
|
}
|
|
},
|
|
{ passive: true },
|
|
);
|
|
|
|
document.addEventListener(
|
|
"mouseup",
|
|
() => {
|
|
this.stopDragging();
|
|
this.stopResizing();
|
|
},
|
|
{ passive: true },
|
|
);
|
|
|
|
this.header.addEventListener(
|
|
"touchstart",
|
|
(e) => {
|
|
if (e.target.closest(".chat-control-btn")) return;
|
|
e.preventDefault();
|
|
this.startDragging(e.touches[0]);
|
|
},
|
|
{ passive: false },
|
|
);
|
|
|
|
document.addEventListener(
|
|
"touchmove",
|
|
(e) => {
|
|
if (this.isDragging) {
|
|
e.preventDefault();
|
|
this.drag(e.touches[0]);
|
|
}
|
|
},
|
|
{ passive: false },
|
|
);
|
|
|
|
document.addEventListener(
|
|
"touchend",
|
|
() => {
|
|
this.stopDragging();
|
|
},
|
|
{ passive: true },
|
|
);
|
|
|
|
window.addEventListener(
|
|
"resize",
|
|
() => {
|
|
this.constrainToViewport();
|
|
},
|
|
{ passive: true },
|
|
);
|
|
} catch (error) {
|
|
console.error("Error binding MoveableChatFrame events:", error);
|
|
}
|
|
}
|
|
|
|
toggleChat() {
|
|
if (!this.frame) return;
|
|
|
|
if (this.frame.style.display === "none") {
|
|
this.openChat();
|
|
} else {
|
|
this.closeChat();
|
|
}
|
|
}
|
|
|
|
openChat() {
|
|
if (!this.frame) return;
|
|
|
|
this.frame.style.display = "flex";
|
|
document.body.classList.add("chat-open");
|
|
|
|
if (!this.chatLoaded) {
|
|
this.loadChatContent();
|
|
}
|
|
|
|
this.saveState();
|
|
}
|
|
|
|
loadChatContent() {
|
|
const streamId = document.querySelector('meta[name="stream-id"]')?.content;
|
|
if (!streamId) {
|
|
this.showChatError("Stream ID not found");
|
|
return;
|
|
}
|
|
|
|
console.log("Loading chat for stream ID:", streamId);
|
|
this.showLoading();
|
|
|
|
const iframe = document.createElement("iframe");
|
|
iframe.className = "chat-frame-iframe";
|
|
iframe.src = `/chat/${encodeURIComponent(streamId)}`;
|
|
iframe.style.cssText = `
|
|
width: 100%;
|
|
height: 100%;
|
|
border: none;
|
|
border-radius: 0 0 12px 12px;
|
|
background: var(--bg-card);
|
|
`;
|
|
|
|
iframe.onload = () => {
|
|
this.chatLoaded = true;
|
|
this.hideLoading();
|
|
console.log("Chat iframe loaded successfully");
|
|
};
|
|
|
|
iframe.onerror = () => {
|
|
console.error("Failed to load chat iframe");
|
|
this.showChatError("Failed to load chat");
|
|
};
|
|
|
|
const loadTimeout = setTimeout(() => {
|
|
if (!this.chatLoaded) {
|
|
console.warn("Chat loading timeout");
|
|
this.showChatError("Chat loading timeout");
|
|
}
|
|
}, 10000);
|
|
|
|
iframe.addEventListener("load", () => {
|
|
clearTimeout(loadTimeout);
|
|
});
|
|
|
|
const content = this.frame.querySelector(".chat-frame-content");
|
|
if (content) {
|
|
content.innerHTML = "";
|
|
content.appendChild(iframe);
|
|
}
|
|
}
|
|
|
|
closeChat() {
|
|
if (!this.frame) return;
|
|
|
|
this.frame.style.display = "none";
|
|
document.body.classList.remove("chat-open");
|
|
|
|
const content = this.frame.querySelector(".chat-frame-content");
|
|
if (content) {
|
|
content.innerHTML = "";
|
|
}
|
|
|
|
this.chatLoaded = false;
|
|
this.saveState();
|
|
}
|
|
|
|
toggleMinimize() {
|
|
if (!this.frame || !this.minimizeBtn) return;
|
|
|
|
this.isMinimized = !this.isMinimized;
|
|
this.frame.classList.toggle("minimized", this.isMinimized);
|
|
|
|
const icon = this.minimizeBtn.querySelector("svg path");
|
|
if (icon) {
|
|
if (this.isMinimized) {
|
|
icon.setAttribute("d", "M18 15l-6-6-6 6");
|
|
this.minimizeBtn.title = "Maximize";
|
|
} else {
|
|
icon.setAttribute("d", "M6 9l6 6 6-6");
|
|
this.minimizeBtn.title = "Minimize";
|
|
}
|
|
}
|
|
|
|
this.saveState();
|
|
}
|
|
|
|
showLoading() {
|
|
if (!this.frame) return;
|
|
|
|
const loadingDiv = document.createElement("div");
|
|
loadingDiv.className = "chat-frame-loading";
|
|
loadingDiv.innerHTML = `
|
|
<div class="spinner"></div>
|
|
<span>Loading chat...</span>
|
|
`;
|
|
|
|
const content = this.frame.querySelector(".chat-frame-content");
|
|
if (content) {
|
|
content.innerHTML = "";
|
|
content.appendChild(loadingDiv);
|
|
}
|
|
}
|
|
|
|
hideLoading() {}
|
|
|
|
showChatError(message) {
|
|
if (!this.frame) return;
|
|
|
|
const errorDiv = document.createElement("div");
|
|
errorDiv.className = "chat-frame-error";
|
|
errorDiv.innerHTML = `
|
|
<div style="display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 2rem; color: var(--accent-red); text-align: center;">
|
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-bottom: 0.5rem;">
|
|
<circle cx="12" cy="12" r="10"></circle>
|
|
<line x1="15" y1="9" x2="9" y2="15"></line>
|
|
<line x1="9" y1="9" x2="15" y2="15"></line>
|
|
</svg>
|
|
<span style="font-size: 0.875rem;">${message}</span>
|
|
<button onclick="window.moveableChatFrame?.loadChatContent()"
|
|
style="margin-top: 0.5rem; padding: 0.25rem 0.5rem; background: var(--accent-blue); color: white; border: none; border-radius: 0.25rem; cursor: pointer; font-size: 0.75rem;">
|
|
Retry
|
|
</button>
|
|
</div>
|
|
`;
|
|
|
|
const content = this.frame.querySelector(".chat-frame-content");
|
|
if (content) {
|
|
content.innerHTML = "";
|
|
content.appendChild(errorDiv);
|
|
}
|
|
}
|
|
|
|
startDragging(event) {
|
|
if (!this.frame) return;
|
|
|
|
this.isDragging = true;
|
|
const rect = this.frame.getBoundingClientRect();
|
|
this.dragOffset.x = event.clientX - rect.left;
|
|
this.dragOffset.y = event.clientY - rect.top;
|
|
this.frame.style.transition = "none";
|
|
document.body.style.userSelect = "none";
|
|
}
|
|
|
|
drag(event) {
|
|
if (!this.isDragging || !this.frame) return;
|
|
|
|
const x = event.clientX - this.dragOffset.x;
|
|
const y = event.clientY - this.dragOffset.y;
|
|
|
|
const maxX = window.innerWidth - this.frame.offsetWidth;
|
|
const maxY = window.innerHeight - this.frame.offsetHeight;
|
|
|
|
const constrainedX = Math.max(0, Math.min(x, maxX));
|
|
const constrainedY = Math.max(0, Math.min(y, maxY));
|
|
|
|
this.frame.style.left = `${constrainedX}px`;
|
|
this.frame.style.top = `${constrainedY}px`;
|
|
this.frame.style.right = "auto";
|
|
this.frame.style.bottom = "auto";
|
|
}
|
|
|
|
stopDragging() {
|
|
if (this.isDragging && this.frame) {
|
|
this.isDragging = false;
|
|
this.frame.style.transition = "all 0.3s ease";
|
|
document.body.style.userSelect = "";
|
|
this.savePosition();
|
|
}
|
|
}
|
|
|
|
startResizing(event, handle) {
|
|
if (this.isMinimized || !this.frame) return;
|
|
|
|
this.isResizing = true;
|
|
this.resizeHandle = handle;
|
|
this.frame.style.transition = "none";
|
|
document.body.style.userSelect = "none";
|
|
event.preventDefault();
|
|
}
|
|
|
|
resize(event) {
|
|
if (!this.isResizing || !this.resizeHandle || !this.frame) return;
|
|
|
|
const rect = this.frame.getBoundingClientRect();
|
|
const handleClass = this.resizeHandle.className;
|
|
|
|
let newWidth = rect.width;
|
|
let newHeight = rect.height;
|
|
let newLeft = rect.left;
|
|
let newTop = rect.top;
|
|
|
|
if (handleClass.includes("resize-handle-e")) {
|
|
newWidth = event.clientX - rect.left;
|
|
}
|
|
if (handleClass.includes("resize-handle-w")) {
|
|
newWidth = rect.right - event.clientX;
|
|
newLeft = event.clientX;
|
|
}
|
|
if (handleClass.includes("resize-handle-s")) {
|
|
newHeight = event.clientY - rect.top;
|
|
}
|
|
if (handleClass.includes("resize-handle-n")) {
|
|
newHeight = rect.bottom - event.clientY;
|
|
newTop = event.clientY;
|
|
}
|
|
|
|
newWidth = Math.max(280, Math.min(newWidth, window.innerWidth));
|
|
newHeight = Math.max(200, Math.min(newHeight, window.innerHeight));
|
|
|
|
this.frame.style.width = `${newWidth}px`;
|
|
this.frame.style.height = `${newHeight}px`;
|
|
this.frame.style.left = `${newLeft}px`;
|
|
this.frame.style.top = `${newTop}px`;
|
|
this.frame.style.right = "auto";
|
|
this.frame.style.bottom = "auto";
|
|
}
|
|
|
|
stopResizing() {
|
|
if (this.isResizing && this.frame) {
|
|
this.isResizing = false;
|
|
this.resizeHandle = null;
|
|
this.frame.style.transition = "all 0.3s ease";
|
|
document.body.style.userSelect = "";
|
|
this.savePosition();
|
|
}
|
|
}
|
|
|
|
constrainToViewport() {
|
|
if (!this.frame || this.frame.style.display === "none") return;
|
|
|
|
const rect = this.frame.getBoundingClientRect();
|
|
const maxX = window.innerWidth - rect.width;
|
|
const maxY = window.innerHeight - rect.height;
|
|
|
|
const currentX = Number.parseInt(this.frame.style.left) || rect.left;
|
|
const currentY = Number.parseInt(this.frame.style.top) || rect.top;
|
|
|
|
const constrainedX = Math.max(0, Math.min(currentX, maxX));
|
|
const constrainedY = Math.max(0, Math.min(currentY, maxY));
|
|
|
|
this.frame.style.left = `${constrainedX}px`;
|
|
this.frame.style.top = `${constrainedY}px`;
|
|
this.frame.style.right = "auto";
|
|
this.frame.style.bottom = "auto";
|
|
}
|
|
|
|
savePosition() {
|
|
if (!this.frame) return;
|
|
|
|
try {
|
|
const rect = this.frame.getBoundingClientRect();
|
|
const position = {
|
|
left: rect.left,
|
|
top: rect.top,
|
|
width: rect.width,
|
|
height: rect.height,
|
|
};
|
|
localStorage.setItem("moveable-chat-position", JSON.stringify(position));
|
|
} catch (error) {
|
|
console.warn("Failed to save chat position:", error);
|
|
}
|
|
}
|
|
|
|
saveState() {
|
|
if (!this.frame) return;
|
|
|
|
try {
|
|
const state = {
|
|
visible: this.frame.style.display !== "none",
|
|
minimized: this.isMinimized,
|
|
};
|
|
localStorage.setItem("moveable-chat-state", JSON.stringify(state));
|
|
} catch (error) {
|
|
console.warn("Failed to save chat state:", error);
|
|
}
|
|
}
|
|
|
|
loadSavedState() {
|
|
if (!this.frame) return;
|
|
|
|
const savedPosition = localStorage.getItem("moveable-chat-position");
|
|
if (savedPosition) {
|
|
try {
|
|
const position = JSON.parse(savedPosition);
|
|
this.frame.style.left = `${position.left}px`;
|
|
this.frame.style.top = `${position.top}px`;
|
|
this.frame.style.width = `${position.width}px`;
|
|
this.frame.style.height = `${position.height}px`;
|
|
this.frame.style.right = "auto";
|
|
this.frame.style.bottom = "auto";
|
|
} catch (e) {
|
|
console.warn("Failed to load saved chat position:", e);
|
|
}
|
|
}
|
|
|
|
const savedState = localStorage.getItem("moveable-chat-state");
|
|
if (savedState) {
|
|
try {
|
|
const state = JSON.parse(savedState);
|
|
if (state.visible) {
|
|
this.openChat();
|
|
}
|
|
if (state.minimized) {
|
|
this.isMinimized = true;
|
|
this.toggleMinimize();
|
|
}
|
|
} catch (e) {
|
|
console.warn("Failed to load saved chat state:", e);
|
|
}
|
|
}
|
|
|
|
setTimeout(() => {
|
|
this.constrainToViewport();
|
|
}, 100);
|
|
}
|
|
}
|
|
|
|
function initializeMoveableChat() {
|
|
try {
|
|
const requiredElements = [
|
|
"moveableChatFrame",
|
|
"chatFrameHeader",
|
|
"toggleChatBtn",
|
|
];
|
|
|
|
const missingElements = requiredElements.filter(
|
|
(id) => !document.getElementById(id),
|
|
);
|
|
|
|
if (missingElements.length > 0) {
|
|
console.warn(
|
|
"MoveableChatFrame: Missing required elements:",
|
|
missingElements,
|
|
);
|
|
return;
|
|
}
|
|
|
|
window.moveableChatFrame = new MoveableChatFrame();
|
|
} catch (error) {
|
|
console.error("Failed to initialize MoveableChatFrame:", error);
|
|
}
|
|
}
|
|
|
|
setTimeout(initializeMoveableChat, 100);
|