256 lines
8.4 KiB
TypeScript
256 lines
8.4 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect, useRef, useCallback } from "react";
|
|
import { X, Settings } from "lucide-react";
|
|
import { useMessengerContext } from "@/contexts/MessengerContext";
|
|
import { useRooms, useUnreadCount } from "@/hooks/useMessenger";
|
|
import { RoomList } from "./RoomList";
|
|
import { ChatPanel } from "./ChatPanel";
|
|
import { MessengerSettings } from "./MessengerSettings";
|
|
import { ScreenCapture } from "./ScreenCapture";
|
|
import type { MessageInputHandle } from "./MessageInput";
|
|
|
|
const MIN_W = 400, MIN_H = 320;
|
|
const MAX_W = 1000, MAX_H = 800;
|
|
const INIT_W = 720, INIT_H = 500;
|
|
|
|
type ResizeDir = "n" | "s" | "e" | "w" | "ne" | "nw" | "se" | "sw";
|
|
|
|
const CURSOR: Record<ResizeDir, string> = {
|
|
n: "ns-resize", s: "ns-resize",
|
|
e: "ew-resize", w: "ew-resize",
|
|
ne: "nesw-resize", sw: "nesw-resize",
|
|
nw: "nwse-resize", se: "nwse-resize",
|
|
};
|
|
|
|
export function MessengerModal() {
|
|
const { isOpen, closeMessenger, selectedRoomId, setUnreadCount } = useMessengerContext();
|
|
const { data: rooms = [] } = useRooms();
|
|
const { data: serverUnread } = useUnreadCount();
|
|
const [showSettings, setShowSettings] = useState(false);
|
|
const [capturing, setCapturing] = useState(false);
|
|
const capturedImgRef = useRef<HTMLImageElement | null>(null);
|
|
const messageInputRef = useRef<MessageInputHandle>(null);
|
|
|
|
useEffect(() => {
|
|
const count = (serverUnread as any)?.unread_count ?? serverUnread ?? 0;
|
|
setUnreadCount(Number(count));
|
|
}, [serverUnread, setUnreadCount]);
|
|
|
|
const selectedRoom = rooms.find((r) => r.id === selectedRoomId) || null;
|
|
|
|
const handleCaptureClick = useCallback(async () => {
|
|
try {
|
|
const { domToPng } = await import("modern-screenshot");
|
|
const scale = Math.max(window.devicePixelRatio || 1, 2);
|
|
const dataUrl = await domToPng(document.body, {
|
|
width: window.innerWidth,
|
|
height: window.innerHeight,
|
|
scale,
|
|
});
|
|
const img = new Image();
|
|
img.src = dataUrl;
|
|
await new Promise((res) => { img.onload = res; });
|
|
capturedImgRef.current = img;
|
|
setCapturing(true);
|
|
} catch {
|
|
// fail silently
|
|
}
|
|
}, []);
|
|
|
|
// Position & size state
|
|
const [pos, setPos] = useState({ x: 0, y: 0 });
|
|
const [size, setSize] = useState({ w: INIT_W, h: INIT_H });
|
|
const initialized = useRef(false);
|
|
|
|
// Initialize position to bottom-right on first open
|
|
useEffect(() => {
|
|
if (isOpen && !initialized.current) {
|
|
setPos({
|
|
x: window.innerWidth - INIT_W - 24,
|
|
y: window.innerHeight - INIT_H - 24,
|
|
});
|
|
initialized.current = true;
|
|
}
|
|
}, [isOpen]);
|
|
|
|
// Clamp position when window resizes
|
|
useEffect(() => {
|
|
const onResize = () => {
|
|
setPos((p) => ({
|
|
x: Math.min(p.x, window.innerWidth - size.w),
|
|
y: Math.min(p.y, window.innerHeight - size.h),
|
|
}));
|
|
};
|
|
window.addEventListener("resize", onResize);
|
|
return () => window.removeEventListener("resize", onResize);
|
|
}, [size]);
|
|
|
|
// --- Drag to move ---
|
|
const dragRef = useRef<{ startX: number; startY: number; origX: number; origY: number } | null>(null);
|
|
|
|
const onHeaderMouseDown = useCallback((e: React.MouseEvent) => {
|
|
if ((e.target as HTMLElement).closest("button")) return;
|
|
e.preventDefault();
|
|
dragRef.current = { startX: e.clientX, startY: e.clientY, origX: pos.x, origY: pos.y };
|
|
|
|
const onMove = (ev: MouseEvent) => {
|
|
if (!dragRef.current) return;
|
|
const dx = ev.clientX - dragRef.current.startX;
|
|
const dy = ev.clientY - dragRef.current.startY;
|
|
setPos({
|
|
x: Math.max(0, Math.min(dragRef.current.origX + dx, window.innerWidth - size.w)),
|
|
y: Math.max(0, Math.min(dragRef.current.origY + dy, window.innerHeight - size.h)),
|
|
});
|
|
};
|
|
const onUp = () => {
|
|
dragRef.current = null;
|
|
document.removeEventListener("mousemove", onMove);
|
|
document.removeEventListener("mouseup", onUp);
|
|
};
|
|
document.addEventListener("mousemove", onMove);
|
|
document.addEventListener("mouseup", onUp);
|
|
}, [pos, size]);
|
|
|
|
// --- Resize ---
|
|
const resizeRef = useRef<{
|
|
dir: ResizeDir;
|
|
startX: number; startY: number;
|
|
origX: number; origY: number;
|
|
origW: number; origH: number;
|
|
} | null>(null);
|
|
|
|
const onResizeMouseDown = useCallback((e: React.MouseEvent, dir: ResizeDir) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
resizeRef.current = {
|
|
dir,
|
|
startX: e.clientX, startY: e.clientY,
|
|
origX: pos.x, origY: pos.y,
|
|
origW: size.w, origH: size.h,
|
|
};
|
|
|
|
const onMove = (ev: MouseEvent) => {
|
|
const r = resizeRef.current;
|
|
if (!r) return;
|
|
const dx = ev.clientX - r.startX;
|
|
const dy = ev.clientY - r.startY;
|
|
|
|
let newX = r.origX, newY = r.origY, newW = r.origW, newH = r.origH;
|
|
|
|
if (r.dir.includes("e")) newW = Math.min(MAX_W, Math.max(MIN_W, r.origW + dx));
|
|
if (r.dir.includes("s")) newH = Math.min(MAX_H, Math.max(MIN_H, r.origH + dy));
|
|
if (r.dir.includes("w")) {
|
|
const w = Math.min(MAX_W, Math.max(MIN_W, r.origW - dx));
|
|
newX = r.origX + (r.origW - w);
|
|
newW = w;
|
|
}
|
|
if (r.dir.includes("n")) {
|
|
const h = Math.min(MAX_H, Math.max(MIN_H, r.origH - dy));
|
|
newY = r.origY + (r.origH - h);
|
|
newH = h;
|
|
}
|
|
|
|
// Clamp to viewport
|
|
newX = Math.max(0, Math.min(newX, window.innerWidth - newW));
|
|
newY = Math.max(0, Math.min(newY, window.innerHeight - newH));
|
|
|
|
setPos({ x: newX, y: newY });
|
|
setSize({ w: newW, h: newH });
|
|
};
|
|
|
|
const onUp = () => {
|
|
resizeRef.current = null;
|
|
document.removeEventListener("mousemove", onMove);
|
|
document.removeEventListener("mouseup", onUp);
|
|
};
|
|
document.addEventListener("mousemove", onMove);
|
|
document.addEventListener("mouseup", onUp);
|
|
}, [pos, size]);
|
|
|
|
const handle = (dir: ResizeDir, className: string) => (
|
|
<div
|
|
className={`absolute z-20 ${className}`}
|
|
style={{ cursor: CURSOR[dir] }}
|
|
onMouseDown={(e) => onResizeMouseDown(e, dir)}
|
|
/>
|
|
);
|
|
|
|
return (
|
|
<>
|
|
{capturing && capturedImgRef.current && (
|
|
<ScreenCapture
|
|
capturedImg={capturedImgRef.current}
|
|
onCapture={(file) => {
|
|
setCapturing(false);
|
|
messageInputRef.current?.addFiles([file]);
|
|
}}
|
|
onCancel={() => setCapturing(false)}
|
|
/>
|
|
)}
|
|
<div
|
|
className="fixed z-[9999] flex flex-col bg-background border rounded-lg shadow-2xl overflow-hidden"
|
|
style={{
|
|
left: pos.x,
|
|
top: pos.y,
|
|
width: size.w,
|
|
height: size.h,
|
|
display: isOpen && !capturing ? "flex" : "none",
|
|
}}
|
|
>
|
|
{/* Resize handles */}
|
|
{handle("n", "top-0 left-2 right-2 h-1.5")}
|
|
{handle("s", "bottom-0 left-2 right-2 h-1.5")}
|
|
{handle("e", "right-0 top-2 bottom-2 w-1.5")}
|
|
{handle("w", "left-0 top-2 bottom-2 w-1.5")}
|
|
{handle("ne", "top-0 right-0 w-3 h-3")}
|
|
{handle("nw", "top-0 left-0 w-3 h-3")}
|
|
{handle("se", "bottom-0 right-0 w-3 h-3")}
|
|
{handle("sw", "bottom-0 left-0 w-3 h-3")}
|
|
|
|
{/* Header */}
|
|
<div
|
|
className="flex items-center justify-between px-4 py-2 border-b bg-muted/30 select-none cursor-move"
|
|
onMouseDown={onHeaderMouseDown}
|
|
>
|
|
<h2 className="text-sm font-semibold">메신저</h2>
|
|
<div className="flex items-center gap-1">
|
|
<button
|
|
onClick={() => setShowSettings((p) => !p)}
|
|
className="p-1 hover:bg-muted rounded"
|
|
aria-label="설정"
|
|
>
|
|
<Settings className="h-4 w-4 text-muted-foreground" />
|
|
</button>
|
|
<button
|
|
onClick={closeMessenger}
|
|
className="p-1 hover:bg-muted rounded"
|
|
aria-label="닫기"
|
|
>
|
|
<X className="h-4 w-4 text-muted-foreground" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Body */}
|
|
<div className="flex flex-1 min-h-0 relative">
|
|
<RoomList />
|
|
<ChatPanel room={selectedRoom} messageInputRef={messageInputRef} onCaptureClick={handleCaptureClick} />
|
|
|
|
{showSettings && (
|
|
<div className="absolute inset-0 bg-background z-10 flex flex-col">
|
|
<div className="flex items-center justify-between px-4 py-2 border-b">
|
|
<span className="text-sm font-semibold">설정</span>
|
|
<button onClick={() => setShowSettings(false)} className="p-1 hover:bg-muted rounded">
|
|
<X className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
<MessengerSettings />
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|