092_user_interface / src /components /roomlist /SpaceRoomList.jsx
anotherath's picture
feat(ui): space icons, chat improvements, StudyBot mentions
880ab03
import { useState, useEffect } from "react";
import { useSelector, useDispatch } from "react-redux";
import { FiSearch, FiPlus, FiSliders } from "react-icons/fi";
import { createRoom } from "../../store/slices/spaceSlice";
import { setActiveRoom } from "../../store/slices/appSlice";
import { getSpaceIconComponent } from "../../constants/spaceIcons";
function RoomItem({
room,
isActive,
onClick,
lastMessage,
lastMessageTime,
unreadCount,
}) {
const [isHovered, setIsHovered] = useState(false);
return (
<div
className="flex items-center px-3 py-2.5 rounded-md cursor-pointer transition-colors gap-2.5 mb-0.5"
style={{
background: isActive
? "var(--primary-active)"
: isHovered
? "var(--hover-primary)"
: "transparent",
borderRadius: "8px",
}}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
onClick={onClick}
>
{/* Room info */}
<div className="flex-1 min-w-0 overflow-hidden">
<div className="flex items-center justify-between min-w-0">
<div className="flex items-center gap-1.5 min-w-0">
<div
className="text-sm font-semibold truncate"
style={{ color: "var(--text-primary)" }}
>
{room.name}
</div>
{room.is_private && (
<span
className="text-[10px] px-1 py-0.5 rounded flex-shrink-0"
style={{
background: "var(--primary-active)",
color: "var(--primary)",
}}
>
Private
</span>
)}
</div>
{/* Last message time */}
{lastMessageTime && (
<span
className="text-[10px] flex-shrink-0 ml-1"
style={{ color: "var(--text-muted)" }}
>
{lastMessageTime}
</span>
)}
</div>
{/* Last message */}
<div
className="text-xs mt-0.5 truncate"
style={{
color:
unreadCount > 0 ? "var(--text-primary)" : "var(--text-secondary)",
fontWeight: unreadCount > 0 ? 500 : 400,
}}
>
{lastMessage || "Bắt đầu trò chuyện"}
</div>
</div>
{/* Unread badge */}
{unreadCount > 0 && (
<span
className="px-1.5 py-0.5 rounded-full text-[10px] font-bold text-white flex-shrink-0"
style={{ background: "#ef4444", lineHeight: 1 }}
>
{unreadCount > 99 ? "99+" : unreadCount}
</span>
)}
</div>
);
}
function LoadingDots() {
return (
<div className="flex items-center justify-center gap-1 py-4">
<span
className="w-2 h-2 rounded-full animate-bounce"
style={{ background: "var(--text-muted)", animationDelay: "0ms" }}
/>
<span
className="w-2 h-2 rounded-full animate-bounce"
style={{ background: "var(--text-muted)", animationDelay: "150ms" }}
/>
<span
className="w-2 h-2 rounded-full animate-bounce"
style={{ background: "var(--text-muted)", animationDelay: "300ms" }}
/>
</div>
);
}
function EmptyState({ isDark, onCreateRoomClick }) {
return (
<div className="flex flex-col items-center justify-center py-10 px-4 text-center">
<div
className="w-14 h-14 rounded-lg flex items-center justify-center mb-3"
style={{
background: isDark ? "rgba(255,255,255,0.05)" : "rgba(0,0,0,0.03)",
}}
>
<FiPlus size={24} style={{ color: "var(--text-muted)" }} />
</div>
<div
className="text-sm font-medium mb-1"
style={{ color: "var(--text-primary)" }}
>
Chưa có room nào
</div>
<div className="text-xs mb-4" style={{ color: "var(--text-muted)" }}>
Tạo room đầu tiên để bắt đầu thảo luận
</div>
<button
onClick={onCreateRoomClick}
className="px-4 py-2 rounded-md text-xs font-medium transition-colors cursor-pointer"
style={{
background: "var(--primary)",
color: "#fff",
}}
>
Tạo room mới
</button>
</div>
);
}
function CreateRoomModal({ isOpen, onClose, onCreate, isDark }) {
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [isPrivate, setIsPrivate] = useState(false);
if (!isOpen) return null;
const handleSubmit = (e) => {
e.preventDefault();
if (name.trim()) {
onCreate({
name: name.trim(),
description: description.trim(),
isPrivate,
});
setName("");
setDescription("");
setIsPrivate(false);
onClose();
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div
className="w-full max-w-md rounded-lg p-6 shadow-xl"
style={{
background: "var(--bg-surface-secondary)",
border: "1px solid var(--border-primary)",
}}
>
<h2
className="text-lg font-semibold mb-4"
style={{ color: "var(--text-primary)" }}
>
Tạo Room mới
</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label
className="block text-sm font-medium mb-1"
style={{ color: "var(--text-secondary)" }}
>
Tên room
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="VD: Thảo luận, Tài liệu..."
className="w-full px-3 py-2 rounded-md text-sm border outline-none"
style={{
background: "var(--input-bg)",
borderColor: "var(--input-border)",
color: "var(--input-text)",
}}
autoFocus
/>
</div>
<div>
<label
className="block text-sm font-medium mb-1"
style={{ color: "var(--text-secondary)" }}
>
Mô tả (tùy chọn)
</label>
<input
type="text"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Mô tả ngắn về room..."
className="w-full px-3 py-2 rounded-md text-sm border outline-none"
style={{
background: "var(--input-bg)",
borderColor: "var(--input-border)",
color: "var(--input-text)",
}}
/>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="isPrivate"
checked={isPrivate}
onChange={(e) => setIsPrivate(e.target.checked)}
className="w-4 h-4 cursor-pointer"
style={{ accentColor: "var(--primary)" }}
/>
<label
htmlFor="isPrivate"
className="text-sm cursor-pointer"
style={{ color: "var(--text-secondary)" }}
>
Room riêng tư
</label>
</div>
<div className="flex justify-end gap-3 pt-2">
<button
type="button"
onClick={onClose}
className="px-4 py-2 rounded-md text-sm font-medium"
style={{ color: "var(--text-secondary)" }}
>
Hủy
</button>
<button
type="submit"
disabled={!name.trim()}
className="px-4 py-2 rounded-md text-sm font-medium disabled:opacity-50"
style={{
background: "var(--primary)",
color: isDark ? "var(--bg-surface)" : "#fff",
}}
>
Tạo
</button>
</div>
</form>
</div>
</div>
);
}
function SpaceRoomList({
activeSpace,
activeRoom,
setActiveRoom,
searchQuery,
setSearchQuery,
onCreateRoomClick,
}) {
const { isDark } = useSelector((state) => state.theme);
const dispatch = useDispatch();
const [isSearching, setIsSearching] = useState(false);
const {
spaces,
roomsMap,
roomsLoading,
fetchedRooms,
roomUnreadCounts,
} = useSelector((state) => state.space);
const currentUser = useSelector((state) => state.auth.user);
const spaceRooms = roomsMap[activeSpace] || [];
const isFetched = fetchedRooms[activeSpace];
// Helper to format last message from API
const getRoomLastMessage = (room) => {
const lastMsg = room.last_message;
if (!lastMsg) return null;
const isOwn =
lastMsg.sender_id &&
currentUser?.id &&
String(lastMsg.sender_id) === String(currentUser.id);
const senderName = isOwn
? "Bạn"
: lastMsg.sender_display_name || lastMsg.username || "Unknown";
return {
content: lastMsg.content,
senderName,
};
};
// Rooms are already fetched globally in App.jsx
// This component only reads from Redux store, no need to fetch here
const filteredRooms = spaceRooms.filter((room) =>
room.name.toLowerCase().includes(searchQuery.toLowerCase()),
);
const handleSearchChange = (e) => {
setSearchQuery(e.target.value);
if (e.target.value.trim()) {
setIsSearching(true);
setTimeout(() => setIsSearching(false), 300);
} else {
setIsSearching(false);
}
};
const handleCreateRoom = (roomData) => {
if (activeSpace) {
dispatch(createRoom({ spaceId: activeSpace, data: roomData }));
}
};
const currentSpace = spaces.find((s) => s.id === activeSpace);
return (
<div
className="w-60 min-w-60 flex flex-col h-screen border-r"
style={{
background: "var(--bg-surface-secondary)",
borderColor: "var(--border-primary)",
}}
>
{/* Header */}
<div
className="p-4 border-b"
style={{ borderColor: "var(--border-primary)" }}
>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2 min-w-0">
<div
className="text-base font-semibold truncate"
style={{ color: "var(--text-primary)" }}
>
{currentSpace?.name || "Space"}
</div>
</div>
<button
onClick={() => console.log("Space settings clicked")}
className="p-1.5 rounded hover:opacity-70 transition-opacity cursor-pointer"
style={{ color: "var(--text-muted)" }}
title="Cài đặt space"
>
<FiSliders size={14} />
</button>
</div>
<div className="relative">
<FiSearch
size={16}
className="absolute left-3 top-1/2 -translate-y-1/2"
style={{ color: "var(--text-muted)" }}
/>
<input
type="text"
className="w-full pl-9 pr-3 py-2 border rounded-md text-sm outline-none transition-colors"
style={{
background: "var(--input-bg)",
borderColor: "var(--input-border)",
color: "var(--input-text)",
}}
placeholder="Tìm kiếm room..."
value={searchQuery}
onChange={handleSearchChange}
/>
</div>
</div>
{/* Room list */}
<div className="flex-1 overflow-y-auto p-2">
{/* Header with create button — always visible */}
<div className="flex items-center justify-between px-3 py-2">
<div
className="text-xs font-medium uppercase tracking-wider"
style={{ color: "var(--text-muted)" }}
>
Rooms
</div>
<button
onClick={(e) => {
e.stopPropagation();
if (onCreateRoomClick) onCreateRoomClick();
}}
className="p-1 rounded hover:opacity-70 transition-opacity cursor-pointer"
style={{ color: "var(--text-muted)" }}
title="Tạo room mới"
>
<FiPlus size={14} />
</button>
</div>
{(roomsLoading || isSearching) && <LoadingDots />}
{!roomsLoading && !isSearching && filteredRooms.length === 0 && (
<EmptyState isDark={isDark} onCreateRoomClick={onCreateRoomClick} />
)}
{!isSearching && filteredRooms.length > 0 && (
<div>
{filteredRooms.map((room) => {
const lastMsg = getRoomLastMessage(room);
return (
<RoomItem
key={room.id}
room={room}
isActive={activeRoom === room.id}
onClick={() => setActiveRoom(room.id)}
lastMessage={
lastMsg ? `${lastMsg.senderName}: ${lastMsg.content}` : null
}
unreadCount={roomUnreadCounts[room.id] || 0}
/>
);
})}
</div>
)}
</div>
</div>
);
}
export default SpaceRoomList;