Files
vexplor_dev/frontend/components/messenger/NewRoomModal.tsx
syc0123 403e5cae40 [RAPID] 메신저 기능 구현 및 UI/UX 개선
- 사용자 온라인 상태 표시 (온라인/자리비움/오프라인) 디스코드 스타일
- 채팅방별 알림 ON/OFF 토글 (Bell 아이콘, localStorage 저장)
- 파일 업로드 실시간 소켓 브로드캐스트 및 한글 파일명 깨짐 수정
- FAB 읽지않음 배지 버그 수정 (메신저 닫혀있을 때 markAsRead 차단)
- 타이핑 도트 애니메이션, 날짜 오늘/어제 표시
- 입력창 bordered box, DM 편집 버튼 숨김
- 메신저 설정 버튼 제거, 새 대화 시작하기 Empty state CTA
- useMessengerSocket 소켓 중복 생성 방지 (MessengerModal로 이동)

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>

[RAPID-micro] 추적 파일 정리 및 메신저 소소한 변경

- .omc/state/ 파일 git 추적 제거 (.gitignore 이미 설정됨)
- db/checkpoints/ gitignore 추가
- globals.css: 메신저 메시지 시간 폰트 스타일 추가
- useMessenger.ts: fileMimeType 필드 및 API_BASE_URL import 추가

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 12:20:43 +09:00

175 lines
6.0 KiB
TypeScript

"use client";
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { useCompanyUsers, useCreateRoom } from "@/hooks/useMessenger";
import { useMessengerContext } from "@/contexts/MessengerContext";
import { UserAvatar } from "./UserAvatar";
import { Check } from "lucide-react";
import { cn } from "@/lib/utils";
import type { UserStatus } from "@/hooks/useMessengerSocket";
interface NewRoomModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
userStatuses: Map<string, UserStatus>;
}
export function NewRoomModal({ open, onOpenChange, userStatuses }: NewRoomModalProps) {
const [tab, setTab] = useState<"dm" | "group" | "channel">("dm");
const [search, setSearch] = useState("");
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [roomName, setRoomName] = useState("");
const [channelDesc, setChannelDesc] = useState("");
const { data: users = [] } = useCompanyUsers();
const createRoom = useCreateRoom();
const { selectRoom } = useMessengerContext();
const filtered = users.filter(
(u) =>
u.userName.toLowerCase().includes(search.toLowerCase()) ||
(u.deptName && u.deptName.toLowerCase().includes(search.toLowerCase()))
);
const toggleUser = (userId: string) => {
if (tab === "dm") {
setSelectedIds([userId]);
} else {
setSelectedIds((prev) =>
prev.includes(userId) ? prev.filter((id) => id !== userId) : [...prev, userId]
);
}
};
const handleCreate = async () => {
try {
const room = await createRoom.mutateAsync({
type: tab,
name: tab === "channel" ? roomName : tab === "group" ? roomName : undefined,
description: tab === "channel" ? channelDesc : undefined,
participantIds: selectedIds,
});
selectRoom(room.id);
onOpenChange(false);
reset();
} catch {
// handled by query
}
};
const reset = () => {
setSearch("");
setSelectedIds([]);
setRoomName("");
setChannelDesc("");
};
const canCreate =
selectedIds.length > 0 &&
(tab === "dm" || tab === "group" || (tab === "channel" && roomName.trim()));
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle> </DialogTitle>
</DialogHeader>
<Tabs value={tab} onValueChange={(v) => { setTab(v as typeof tab); reset(); }}>
<TabsList className="w-full">
<TabsTrigger value="dm" className="flex-1">DM</TabsTrigger>
<TabsTrigger value="group" className="flex-1"></TabsTrigger>
<TabsTrigger value="channel" className="flex-1"></TabsTrigger>
</TabsList>
{(tab === "group" || tab === "channel") && (
<div className="mt-3 space-y-2">
<Input
placeholder={tab === "group" ? "그룹 이름" : "채널 이름"}
value={roomName}
onChange={(e) => setRoomName(e.target.value)}
/>
{tab === "channel" && (
<Input
placeholder="채널 설명 (선택)"
value={channelDesc}
onChange={(e) => setChannelDesc(e.target.value)}
/>
)}
</div>
)}
<TabsContent value={tab} className="mt-3">
<Input
placeholder="사용자 검색..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="mb-2"
/>
{selectedIds.length > 0 && tab !== "dm" && (
<div className="flex flex-wrap gap-1 mb-2">
{selectedIds.map((id) => {
const u = users.find((x) => x.userId === id);
return u ? (
<span
key={id}
onClick={() => toggleUser(id)}
className="inline-flex items-center gap-1 rounded-full bg-primary/10 text-primary text-xs px-2 py-0.5 cursor-pointer hover:bg-primary/20"
>
{u.userName} &times;
</span>
) : null;
})}
</div>
)}
<ScrollArea className="h-60">
{filtered.map((u) => {
const selected = selectedIds.includes(u.userId);
return (
<button
key={u.userId}
onClick={() => toggleUser(u.userId)}
className={cn(
"w-full flex items-center gap-2 px-2 py-1.5 rounded hover:bg-muted text-left",
selected && "bg-accent"
)}
>
<UserAvatar photo={u.photo} name={u.userName} size="sm" status={userStatuses.get(u.userId) ?? "offline"} />
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{u.userName}</div>
{u.deptName && (
<div className="text-xs text-muted-foreground truncate">
{u.deptName} {u.positionName && `/ ${u.positionName}`}
</div>
)}
</div>
{selected && <Check className="h-4 w-4 text-primary shrink-0" />}
</button>
);
})}
</ScrollArea>
</TabsContent>
</Tabs>
<div className="flex justify-end mt-2">
<Button onClick={handleCreate} disabled={!canCreate || createRoom.isPending} size="sm">
{createRoom.isPending ? "생성 중..." : "대화 시작"}
</Button>
</div>
</DialogContent>
</Dialog>
);
}