- RoomRow: ⋮ button (hover) for pin/unpin context menu - MessageBubble: right-click context menu with emoji reactions + edit/delete - Typing indicator: debounced setTyping, animated TypingDots, polling getTyping every 2s - Online presence: heartbeat setPresence every 30s, green dot on avatars/header - Read receipts: ✓/✓✓ for own messages in direct rooms via otherUserLastRead - Migration 075: chat_presence + chat_typing tables - Context menus clamped to viewport bounds Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
902 lines
45 KiB
TypeScript
902 lines
45 KiB
TypeScript
import { useState, useEffect, useRef, useCallback } from 'react'
|
||
import {
|
||
MessageSquare, X, ChevronLeft, Send, Users, Loader2,
|
||
Search, Bell, Settings, PenSquare, BellOff, Volume2, VolumeX, Paperclip, Pin,
|
||
} from 'lucide-react'
|
||
import { api, type ChatRoom, type ChatMessage, type ChatSearchResult, type ChatReaction } from '../../lib/api'
|
||
import type { User } from '../../types'
|
||
import { useAuth } from '../../contexts/AuthContext'
|
||
import { cn } from '../../lib/utils'
|
||
|
||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||
|
||
function avatarColor(name: string) {
|
||
const colors = ['#4F46E5','#059669','#2563EB','#7C3AED','#DC2626','#D97706','#DB2777','#0891B2']
|
||
let h = 0
|
||
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) % colors.length
|
||
return colors[h]
|
||
}
|
||
function initials(name: string) {
|
||
return name.split(' ').map(p => p[0]).join('').toUpperCase().slice(0, 2)
|
||
}
|
||
function fmtTime(iso: string) {
|
||
const d = new Date(iso), now = new Date()
|
||
if (now.toDateString() === d.toDateString())
|
||
return d.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })
|
||
return d.toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' })
|
||
}
|
||
|
||
function Avatar({ name, size = 28, online = false }: { name: string; size?: number; online?: boolean }) {
|
||
return (
|
||
<div className="relative shrink-0" style={{ width: size, height: size }}>
|
||
<div style={{ width: size, height: size, background: avatarColor(name), fontSize: size * 0.38 }}
|
||
className="rounded-full flex items-center justify-center text-white font-semibold">
|
||
{initials(name)}
|
||
</div>
|
||
{online && (
|
||
<span className="absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full bg-green-500 border-2 border-white dark:border-slate-800" />
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
function SystemAvatar({ size = 28 }: { size?: number }) {
|
||
return (
|
||
<div style={{ width: size, height: size }}
|
||
className="rounded-full flex items-center justify-center shrink-0 bg-amber-100 dark:bg-amber-900/30 text-amber-600 dark:text-amber-400">
|
||
<Bell size={size * 0.5} />
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── Sound ──────────────────────────────────────────────────────────────────
|
||
|
||
function playNotifSound() {
|
||
try {
|
||
const AudioCtx = window.AudioContext ?? (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext
|
||
const ctx = new AudioCtx()
|
||
const osc = ctx.createOscillator(), gain = ctx.createGain()
|
||
osc.connect(gain); gain.connect(ctx.destination)
|
||
osc.frequency.value = 880
|
||
gain.gain.setValueAtTime(0.18, ctx.currentTime)
|
||
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.25)
|
||
osc.start(ctx.currentTime); osc.stop(ctx.currentTime + 0.25)
|
||
setTimeout(() => ctx.close(), 600)
|
||
} catch { /* ignore */ }
|
||
}
|
||
|
||
// ── Persist ────────────────────────────────────────────────────────────────
|
||
|
||
interface ChatSettings { notifVisible: boolean; soundEnabled: boolean }
|
||
const SETTINGS_KEY = 'hotelsync-chat-settings'
|
||
const PINS_KEY = 'hotelsync-chat-pins'
|
||
|
||
function loadSettings(): ChatSettings {
|
||
try {
|
||
const s = localStorage.getItem(SETTINGS_KEY)
|
||
if (s) return { notifVisible: true, soundEnabled: false, ...JSON.parse(s) as Partial<ChatSettings> }
|
||
} catch { /**/ }
|
||
return { notifVisible: true, soundEnabled: false }
|
||
}
|
||
function saveSettings(s: ChatSettings) { localStorage.setItem(SETTINGS_KEY, JSON.stringify(s)) }
|
||
function loadPins(): string[] {
|
||
try { return JSON.parse(localStorage.getItem(PINS_KEY) ?? '[]') as string[] } catch { return [] }
|
||
}
|
||
function savePins(ids: string[]) { localStorage.setItem(PINS_KEY, JSON.stringify(ids)) }
|
||
|
||
// ── Context menu ───────────────────────────────────────────────────────────
|
||
|
||
type CtxMenu =
|
||
| { type: 'message'; x: number; y: number; msg: ChatMessage }
|
||
| { type: 'room'; x: number; y: number; room: ChatRoom }
|
||
|
||
const EMOJIS = ['👍','❤️','😂','😮','😢','🔥','👏','✅']
|
||
|
||
// ── Main ───────────────────────────────────────────────────────────────────
|
||
|
||
type View = 'rooms' | 'messages' | 'search' | 'settings'
|
||
|
||
export function ChatWidget() {
|
||
const { user } = useAuth()
|
||
const slug = user?.hotelSlug ?? ''
|
||
|
||
const [open, setOpen] = useState(false)
|
||
const [view, setView] = useState<View>('rooms')
|
||
const [rooms, setRooms] = useState<ChatRoom[]>([])
|
||
const [activeRoom, setActiveRoom] = useState<ChatRoom | null>(null)
|
||
const [messages, setMessages] = useState<ChatMessage[]>([])
|
||
const [text, setText] = useState('')
|
||
const [loadingRooms, setLoadingRooms] = useState(false)
|
||
const [loadingMsgs, setLoadingMsgs] = useState(false)
|
||
const [sending, setSending] = useState(false)
|
||
const [attachment, setAttachment] = useState<File | null>(null)
|
||
const [attachPreview, setAttachPreview] = useState<string | null>(null)
|
||
|
||
// Edit
|
||
const [editingMsgId, setEditingMsgId] = useState<string | null>(null)
|
||
const [editText, setEditText] = useState('')
|
||
|
||
// Context menu
|
||
const [ctxMenu, setCtxMenu] = useState<CtxMenu | null>(null)
|
||
|
||
// Typing & presence
|
||
const [typingNames, setTypingNames] = useState<string[]>([])
|
||
const [onlineIds, setOnlineIds] = useState<string[]>([])
|
||
|
||
// Search
|
||
const [searchQuery, setSearchQuery] = useState('')
|
||
const [searchUsers, setSearchUsers] = useState<User[]>([])
|
||
const [searchResults, setSearchResults] = useState<ChatSearchResult[]>([])
|
||
const [searchTab, setSearchTab] = useState<'people' | 'messages'>('people')
|
||
const [loadingSearch, setLoadingSearch] = useState(false)
|
||
const [allUsers, setAllUsers] = useState<User[]>([])
|
||
|
||
// Settings + pins
|
||
const [settings, setSettings] = useState<ChatSettings>(loadSettings)
|
||
const [pinnedIds, setPinnedIds] = useState<string[]>(loadPins)
|
||
|
||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||
const searchInputRef = useRef<HTMLInputElement>(null)
|
||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||
const prevUnreadRef = useRef<number>(0)
|
||
const prevMsgCountRef = useRef<number>(0)
|
||
const typingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||
const isTypingRef = useRef(false)
|
||
|
||
const totalUnread = rooms.reduce((s, r) => s + (Number(r.unreadCount) || 0), 0)
|
||
|
||
const visibleRooms = [...rooms]
|
||
.filter(r => !(r.type === 'notifications' && !settings.notifVisible))
|
||
.sort((a, b) => (pinnedIds.includes(a.id) ? 0 : 1) - (pinnedIds.includes(b.id) ? 0 : 1))
|
||
|
||
// ── Effects ───────────────────────────────────────────────────────────────
|
||
|
||
const loadRooms = useCallback(async () => {
|
||
if (!slug) return
|
||
try {
|
||
const data = await api.chat.listRooms(slug)
|
||
setRooms(data)
|
||
if (settings.soundEnabled && !open) {
|
||
const n = data.reduce((s, r) => s + (Number(r.unreadCount) || 0), 0)
|
||
if (n > prevUnreadRef.current) playNotifSound()
|
||
prevUnreadRef.current = n
|
||
}
|
||
} catch { /**/ }
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [slug, settings.soundEnabled, open])
|
||
|
||
// Poll rooms
|
||
useEffect(() => {
|
||
if (!open || !slug) return
|
||
setLoadingRooms(true)
|
||
loadRooms().finally(() => setLoadingRooms(false))
|
||
pollRef.current = setInterval(loadRooms, 5000)
|
||
return () => { if (pollRef.current) clearInterval(pollRef.current) }
|
||
}, [open, slug, loadRooms])
|
||
|
||
// Presence heartbeat
|
||
useEffect(() => {
|
||
if (!open || !slug) return
|
||
api.chat.setPresence(slug).catch(() => {/**/})
|
||
api.chat.getPresence(slug).then(setOnlineIds).catch(() => {/**/})
|
||
const t = setInterval(async () => {
|
||
api.chat.setPresence(slug).catch(() => {/**/})
|
||
api.chat.getPresence(slug).then(setOnlineIds).catch(() => {/**/})
|
||
}, 30_000)
|
||
return () => clearInterval(t)
|
||
}, [open, slug])
|
||
|
||
// Auto-scroll
|
||
useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) }, [messages])
|
||
|
||
// Poll messages + typing
|
||
useEffect(() => {
|
||
if (view !== 'messages' || !activeRoom) return
|
||
const interval = setInterval(async () => {
|
||
try {
|
||
const [msgs, typing] = await Promise.all([
|
||
api.chat.getMessages(slug, activeRoom.id),
|
||
api.chat.getTyping(slug, activeRoom.id),
|
||
])
|
||
if (settings.soundEnabled && msgs.length > prevMsgCountRef.current && prevMsgCountRef.current > 0) {
|
||
const last = msgs[msgs.length - 1]
|
||
if (last.senderId !== user?.id) playNotifSound()
|
||
}
|
||
prevMsgCountRef.current = msgs.length
|
||
setMessages(msgs)
|
||
setTypingNames(typing)
|
||
} catch { /**/ }
|
||
}, 2000)
|
||
return () => clearInterval(interval)
|
||
}, [view, activeRoom, slug, settings.soundEnabled, user?.id])
|
||
|
||
// Clear typing names when leaving room
|
||
useEffect(() => { if (view !== 'messages') setTypingNames([]) }, [view])
|
||
|
||
// Search users
|
||
useEffect(() => {
|
||
if (view !== 'search' || !slug) return
|
||
api.users.list(slug).then(setAllUsers).catch(() => {/**/})
|
||
}, [view, slug])
|
||
|
||
// Search effect
|
||
useEffect(() => {
|
||
if (view !== 'search') return
|
||
const q = searchQuery.trim()
|
||
if (!q) { setSearchUsers([]); setSearchResults([]); return }
|
||
const ql = q.toLowerCase()
|
||
setSearchUsers(allUsers.filter(u => u.id !== user?.id && (u.name.toLowerCase().includes(ql) || u.email.toLowerCase().includes(ql))))
|
||
const timer = setTimeout(async () => {
|
||
if (searchTab !== 'messages') return
|
||
setLoadingSearch(true)
|
||
try { setSearchResults(await api.chat.search(slug, q)) } catch { /**/ }
|
||
finally { setLoadingSearch(false) }
|
||
}, 400)
|
||
return () => clearTimeout(timer)
|
||
}, [searchQuery, view, allUsers, user?.id, slug, searchTab])
|
||
|
||
useEffect(() => {
|
||
if (view === 'search') setTimeout(() => searchInputRef.current?.focus(), 50)
|
||
}, [view])
|
||
|
||
// Close ctx menu on outside click
|
||
useEffect(() => {
|
||
if (!ctxMenu) return
|
||
const h = () => setCtxMenu(null)
|
||
window.addEventListener('click', h)
|
||
return () => window.removeEventListener('click', h)
|
||
}, [ctxMenu])
|
||
|
||
// ── Typing helpers ────────────────────────────────────────────────────────
|
||
|
||
const sendTyping = useCallback((typing: boolean) => {
|
||
if (!activeRoom || !slug) return
|
||
if (typing === isTypingRef.current) return
|
||
isTypingRef.current = typing
|
||
api.chat.setTyping(slug, activeRoom.id, typing).catch(() => {/**/})
|
||
}, [activeRoom, slug])
|
||
|
||
const handleTextChange = (val: string) => {
|
||
setText(val)
|
||
if (!val.trim()) { sendTyping(false); return }
|
||
sendTyping(true)
|
||
if (typingTimerRef.current) clearTimeout(typingTimerRef.current)
|
||
typingTimerRef.current = setTimeout(() => sendTyping(false), 4000)
|
||
}
|
||
|
||
// ── Actions ───────────────────────────────────────────────────────────────
|
||
|
||
const openRoom = async (room: ChatRoom) => {
|
||
setActiveRoom(room); setView('messages'); setLoadingMsgs(true)
|
||
prevMsgCountRef.current = 0; isTypingRef.current = false
|
||
try {
|
||
const msgs = await api.chat.getMessages(slug, room.id)
|
||
prevMsgCountRef.current = msgs.length
|
||
setMessages(msgs)
|
||
await api.chat.markRead(slug, room.id)
|
||
setRooms(prev => prev.map(r => r.id === room.id ? { ...r, unreadCount: 0 } : r))
|
||
} catch { /**/ }
|
||
finally { setLoadingMsgs(false) }
|
||
}
|
||
|
||
const openDirect = async (targetUser: User) => {
|
||
try {
|
||
const { roomId } = await api.chat.openDirect(slug, targetUser.id)
|
||
await loadRooms()
|
||
await openRoom({ id: roomId, type: 'direct', name: null, unreadCount: 0, lastMessage: null, lastMessageAt: null, lastSender: null, otherUserName: targetUser.name, otherUserId: targetUser.id, otherUserLastRead: null })
|
||
setSearchQuery('')
|
||
} catch { /**/ }
|
||
}
|
||
|
||
const openSearchResult = async (result: ChatSearchResult) => {
|
||
const room = rooms.find(r => r.id === result.roomId)
|
||
if (room) { setSearchQuery(''); setView('rooms'); await openRoom(room) }
|
||
}
|
||
|
||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const file = e.target.files?.[0]; if (!file) return
|
||
setAttachment(file)
|
||
const reader = new FileReader()
|
||
reader.onload = ev => setAttachPreview(ev.target?.result as string)
|
||
reader.readAsDataURL(file); e.target.value = ''
|
||
}
|
||
|
||
const sendMessage = async () => {
|
||
if ((!text.trim() && !attachment) || !activeRoom || sending) return
|
||
const t = text.trim(), file = attachment
|
||
setText(''); setAttachment(null); setAttachPreview(null); setSending(true)
|
||
sendTyping(false)
|
||
try {
|
||
let url: string | undefined
|
||
if (file) { url = (await api.chat.uploadImage(file)).url }
|
||
const msg = await api.chat.sendMessage(slug, activeRoom.id, t, url)
|
||
setMessages(prev => { prevMsgCountRef.current = prev.length + 1; return [...prev, msg] })
|
||
} catch {
|
||
setText(t)
|
||
if (file) { setAttachment(file); setAttachPreview(attachPreview) }
|
||
} finally { setSending(false) }
|
||
}
|
||
|
||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); if (text.trim() || attachment) void sendMessage() }
|
||
}
|
||
|
||
const startEdit = (msg: ChatMessage) => { setEditingMsgId(msg.id); setEditText(msg.text); setCtxMenu(null) }
|
||
const saveEdit = async (msg: ChatMessage) => {
|
||
if (!editText.trim() || !activeRoom) return
|
||
try {
|
||
const updated = await api.chat.editMessage(slug, activeRoom.id, msg.id, editText)
|
||
setMessages(prev => prev.map(m => m.id === msg.id ? updated : m))
|
||
} catch { /**/ }
|
||
setEditingMsgId(null)
|
||
}
|
||
|
||
const deleteMsg = async (msg: ChatMessage) => {
|
||
if (!activeRoom) return; setCtxMenu(null)
|
||
try {
|
||
await api.chat.deleteMessage(slug, activeRoom.id, msg.id)
|
||
setMessages(prev => prev.map(m => m.id === msg.id ? { ...m, deletedAt: new Date().toISOString(), text: '', attachmentUrl: null } : m))
|
||
} catch { /**/ }
|
||
}
|
||
|
||
const toggleReaction = async (msg: ChatMessage, emoji: string) => {
|
||
if (!activeRoom) return; setCtxMenu(null)
|
||
try {
|
||
const updated = await api.chat.toggleReaction(slug, activeRoom.id, msg.id, emoji)
|
||
setMessages(prev => prev.map(m => m.id === msg.id ? updated : m))
|
||
} catch { /**/ }
|
||
}
|
||
|
||
const togglePin = (roomId: string) => {
|
||
setCtxMenu(null)
|
||
const next = pinnedIds.includes(roomId) ? pinnedIds.filter(id => id !== roomId) : [...pinnedIds, roomId]
|
||
setPinnedIds(next); savePins(next)
|
||
}
|
||
|
||
const updateSettings = (patch: Partial<ChatSettings>) => {
|
||
const next = { ...settings, ...patch }; setSettings(next); saveSettings(next)
|
||
}
|
||
|
||
const goBack = () => {
|
||
if (view === 'messages') {
|
||
sendTyping(false)
|
||
setView('rooms'); setActiveRoom(null); setEditingMsgId(null); setTypingNames([])
|
||
}
|
||
else if (view === 'search') { setView('rooms'); setSearchQuery('') }
|
||
else if (view === 'settings') setView('rooms')
|
||
}
|
||
|
||
const roomDisplayName = (room: ChatRoom) =>
|
||
room.type === 'general' ? 'Общий чат' : room.type === 'notifications' ? 'Уведомления' : room.otherUserName ?? 'Чат'
|
||
|
||
const isOtherOnline = (room: ChatRoom) =>
|
||
room.type === 'direct' && room.otherUserId ? onlineIds.includes(room.otherUserId) : false
|
||
|
||
// Read receipt: is message read by other side?
|
||
const isRead = (msg: ChatMessage) => {
|
||
if (!activeRoom || activeRoom.type !== 'direct' || !activeRoom.otherUserLastRead) return false
|
||
return new Date(msg.createdAt) <= new Date(activeRoom.otherUserLastRead)
|
||
}
|
||
|
||
if (!slug) return null
|
||
|
||
// ── Render ────────────────────────────────────────────────────────────────
|
||
|
||
return (
|
||
<>
|
||
{/* Float button */}
|
||
<button onClick={() => setOpen(v => !v)}
|
||
className={cn('fixed bottom-6 right-6 z-50 w-14 h-14 rounded-full shadow-lg bg-brand-600 hover:bg-brand-700 text-white transition-all flex items-center justify-center', open && 'scale-90')}>
|
||
{open ? <X size={22} /> : <MessageSquare size={22} />}
|
||
{!open && totalUnread > 0 && (
|
||
<span className="absolute -top-1 -right-1 w-5 h-5 rounded-full bg-red-500 text-white text-[10px] font-bold flex items-center justify-center">
|
||
{totalUnread > 9 ? '9+' : totalUnread}
|
||
</span>
|
||
)}
|
||
</button>
|
||
|
||
{/* Panel */}
|
||
{open && (
|
||
<div className="fixed bottom-24 right-6 z-50 w-80 bg-white dark:bg-slate-800 rounded-2xl shadow-2xl border border-slate-200 dark:border-slate-700 flex flex-col overflow-hidden" style={{ height: 520 }}>
|
||
|
||
{/* Header */}
|
||
<div className="flex items-center gap-2 px-3 py-3 border-b border-slate-200 dark:border-slate-700 bg-brand-600 text-white rounded-t-2xl shrink-0">
|
||
{view !== 'rooms' && (
|
||
<button onClick={goBack} className="p-1 hover:bg-white/20 rounded-lg transition-colors shrink-0">
|
||
<ChevronLeft size={16} />
|
||
</button>
|
||
)}
|
||
<div className="flex-1 min-w-0">
|
||
<div className="flex items-center gap-2">
|
||
<p className="font-semibold text-sm truncate">
|
||
{view === 'rooms' && 'Чат сотрудников'}
|
||
{view === 'messages' && (activeRoom ? roomDisplayName(activeRoom) : 'Чат')}
|
||
{view === 'search' && 'Поиск'}
|
||
{view === 'settings' && 'Настройки чата'}
|
||
</p>
|
||
{/* Online dot in messages header */}
|
||
{view === 'messages' && activeRoom && isOtherOnline(activeRoom) && (
|
||
<span className="w-2 h-2 rounded-full bg-green-400 shrink-0" />
|
||
)}
|
||
</div>
|
||
{view === 'rooms' && visibleRooms.length > 0 && <p className="text-xs text-white/70">{visibleRooms.length} чатов</p>}
|
||
{view === 'messages' && activeRoom?.type === 'direct' && isOtherOnline(activeRoom) && (
|
||
<p className="text-xs text-green-300">в сети</p>
|
||
)}
|
||
{view === 'messages' && activeRoom?.type === 'notifications' && (
|
||
<p className="text-xs text-white/70">Только чтение</p>
|
||
)}
|
||
</div>
|
||
{view === 'rooms' && (
|
||
<div className="flex items-center gap-0.5">
|
||
<button onClick={() => setView('search')} className="p-1.5 hover:bg-white/20 rounded-lg transition-colors"><Search size={15} /></button>
|
||
<button onClick={() => setView('search')} className="p-1.5 hover:bg-white/20 rounded-lg transition-colors"><PenSquare size={15} /></button>
|
||
<button onClick={() => setView('settings')} className="p-1.5 hover:bg-white/20 rounded-lg transition-colors"><Settings size={15} /></button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* ── Rooms ── */}
|
||
{view === 'rooms' && (
|
||
<div className="flex-1 overflow-y-auto">
|
||
{loadingRooms && visibleRooms.length === 0 ? (
|
||
<>
|
||
<RoomSkeleton icon={<Users size={16} className="text-brand-400" />} bg="bg-brand-100 dark:bg-brand-900/30" label="Общий чат" />
|
||
{settings.notifVisible && <RoomSkeleton icon={<Bell size={16} className="text-amber-500" />} bg="bg-amber-100 dark:bg-amber-900/30" label="Уведомления" />}
|
||
</>
|
||
) : (
|
||
visibleRooms.map(room => (
|
||
<RoomRow
|
||
key={room.id}
|
||
room={room}
|
||
isPinned={pinnedIds.includes(room.id)}
|
||
isOnline={isOtherOnline(room)}
|
||
onClick={() => openRoom(room)}
|
||
onMenuClick={e => { e.stopPropagation(); setCtxMenu({ type: 'room', x: e.clientX, y: e.clientY, room }) }}
|
||
/>
|
||
))
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* ── Search ── */}
|
||
{view === 'search' && (
|
||
<div className="flex-1 flex flex-col overflow-hidden">
|
||
<div className="px-3 pt-3 pb-2 shrink-0">
|
||
<div className="flex items-center gap-2 bg-slate-100 dark:bg-slate-700 rounded-xl px-3 py-2">
|
||
<Search size={14} className="text-slate-400 shrink-0" />
|
||
<input ref={searchInputRef} value={searchQuery} onChange={e => setSearchQuery(e.target.value)}
|
||
placeholder="Люди или сообщения..."
|
||
className="flex-1 bg-transparent text-sm text-slate-800 dark:text-slate-200 placeholder-slate-400 outline-none" />
|
||
{searchQuery && <button onClick={() => setSearchQuery('')} className="text-slate-400 hover:text-slate-600"><X size={13} /></button>}
|
||
</div>
|
||
</div>
|
||
<div className="flex px-3 gap-1 shrink-0 mb-1">
|
||
{(['people', 'messages'] as const).map(tab => (
|
||
<button key={tab} onClick={() => setSearchTab(tab)}
|
||
className={cn('flex-1 py-1.5 text-xs font-medium rounded-lg transition-colors', searchTab === tab ? 'bg-brand-600 text-white' : 'text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-700')}>
|
||
{tab === 'people' ? 'Люди' : 'Сообщения'}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<div className="flex-1 overflow-y-auto">
|
||
{searchTab === 'people' && (
|
||
!searchQuery
|
||
? allUsers.length === 0
|
||
? <div className="text-center py-6 text-xs text-slate-400">Загрузка...</div>
|
||
: allUsers.filter(u => u.id !== user?.id).map(u => (
|
||
<UserRow key={u.id} user={u} online={onlineIds.includes(u.id)} onClick={() => openDirect(u)} />
|
||
))
|
||
: searchUsers.length === 0
|
||
? <div className="text-center py-6 text-xs text-slate-400">Никого не найдено</div>
|
||
: searchUsers.map(u => <UserRow key={u.id} user={u} online={onlineIds.includes(u.id)} onClick={() => openDirect(u)} />)
|
||
)}
|
||
{searchTab === 'messages' && (
|
||
!searchQuery
|
||
? <div className="text-center py-6 text-xs text-slate-400">Введите запрос для поиска</div>
|
||
: loadingSearch
|
||
? <div className="flex items-center justify-center py-6"><Loader2 size={18} className="animate-spin text-slate-400" /></div>
|
||
: searchResults.length === 0
|
||
? <div className="text-center py-6 text-xs text-slate-400">Ничего не найдено</div>
|
||
: searchResults.map(r => (
|
||
<button key={r.id} onClick={() => openSearchResult(r)}
|
||
className="w-full text-left px-4 py-2.5 hover:bg-slate-50 dark:hover:bg-slate-700/50 transition-colors border-b border-slate-100 dark:border-slate-700/50">
|
||
<p className="text-xs text-slate-500 mb-0.5">
|
||
{r.roomType === 'general' ? 'Общий чат' : r.roomType === 'notifications' ? 'Уведомления' : r.otherUserName ?? 'Чат'}
|
||
{' · '}{r.senderName.split(' ')[0]}
|
||
</p>
|
||
<p className="text-sm text-slate-800 dark:text-slate-200 truncate">{r.text}</p>
|
||
<p className="text-[10px] text-slate-400 mt-0.5">{fmtTime(r.createdAt)}</p>
|
||
</button>
|
||
))
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* ── Settings ── */}
|
||
{view === 'settings' && (
|
||
<div className="flex-1 overflow-y-auto py-4 space-y-4">
|
||
<SettingsSection title="Канал уведомлений">
|
||
<ToggleRow icon={settings.notifVisible ? <Bell size={15} /> : <BellOff size={15} />}
|
||
label="Показывать канал уведомлений" checked={settings.notifVisible}
|
||
onChange={v => updateSettings({ notifVisible: v })} />
|
||
</SettingsSection>
|
||
<SettingsSection title="Звук">
|
||
<ToggleRow icon={settings.soundEnabled ? <Volume2 size={15} /> : <VolumeX size={15} />}
|
||
label="Звуковое уведомление" checked={settings.soundEnabled}
|
||
onChange={v => updateSettings({ soundEnabled: v })} />
|
||
</SettingsSection>
|
||
<SettingsSection title="Подсказка">
|
||
<p className="text-xs text-slate-500 leading-relaxed">
|
||
Правый клик на сообщении — реакции, редактирование, удаление.<br />
|
||
Кнопка ⋮ на чате — закрепить / открепить сверху.
|
||
</p>
|
||
</SettingsSection>
|
||
</div>
|
||
)}
|
||
|
||
{/* ── Messages ── */}
|
||
{view === 'messages' && (
|
||
<>
|
||
<div className="flex-1 overflow-y-auto px-3 py-3 space-y-2">
|
||
{loadingMsgs ? (
|
||
<div className="flex items-center justify-center h-32"><Loader2 size={20} className="animate-spin text-slate-400" /></div>
|
||
) : messages.length === 0 ? (
|
||
<div className="text-center py-10 text-sm text-slate-400">
|
||
{activeRoom?.type === 'notifications' ? 'Уведомлений пока нет' : 'Нет сообщений. Напишите первым!'}
|
||
</div>
|
||
) : (
|
||
messages.map(msg => (
|
||
<MessageBubble
|
||
key={msg.id}
|
||
msg={msg}
|
||
isOwn={!msg.isSystem && msg.senderId === user?.id}
|
||
isEditing={editingMsgId === msg.id}
|
||
editText={editText}
|
||
isRead={isRead(msg)}
|
||
onContextMenu={e => {
|
||
if (msg.deletedAt || msg.isSystem || activeRoom?.type === 'notifications') return
|
||
e.preventDefault()
|
||
setCtxMenu({ type: 'message', x: e.clientX, y: e.clientY, msg })
|
||
}}
|
||
onEditTextChange={setEditText}
|
||
onSaveEdit={() => saveEdit(msg)}
|
||
onCancelEdit={() => setEditingMsgId(null)}
|
||
onReaction={emoji => toggleReaction(msg, emoji)}
|
||
/>
|
||
))
|
||
)}
|
||
|
||
{/* Typing indicator */}
|
||
{typingNames.length > 0 && (
|
||
<div className="flex items-center gap-2 px-1">
|
||
<TypingDots />
|
||
<span className="text-xs text-slate-400">
|
||
{typingNames.length === 1
|
||
? `${typingNames[0].split(' ')[0]} печатает...`
|
||
: `${typingNames.map(n => n.split(' ')[0]).join(', ')} печатают...`}
|
||
</span>
|
||
</div>
|
||
)}
|
||
|
||
<div ref={messagesEndRef} />
|
||
</div>
|
||
|
||
{activeRoom?.type !== 'notifications' && (
|
||
<div className="px-3 py-3 border-t border-slate-200 dark:border-slate-700 shrink-0">
|
||
{attachPreview && (
|
||
<div className="relative inline-block mb-2">
|
||
<img src={attachPreview} alt="превью" className="h-16 rounded-lg object-cover border border-slate-200 dark:border-slate-600" />
|
||
<button onClick={() => { setAttachment(null); setAttachPreview(null) }}
|
||
className="absolute -top-1.5 -right-1.5 w-5 h-5 rounded-full bg-slate-700 text-white flex items-center justify-center hover:bg-red-500 transition-colors">
|
||
<X size={10} />
|
||
</button>
|
||
</div>
|
||
)}
|
||
<div className="flex items-end gap-1.5">
|
||
<input ref={fileInputRef} type="file" accept="image/jpeg,image/png,image/webp,image/gif" className="hidden" onChange={handleFileSelect} />
|
||
<button onClick={() => fileInputRef.current?.click()}
|
||
className="p-2 rounded-xl text-slate-400 hover:text-brand-600 hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors shrink-0">
|
||
<Paperclip size={16} />
|
||
</button>
|
||
<textarea value={text} onChange={e => handleTextChange(e.target.value)} onKeyDown={handleKeyDown}
|
||
placeholder="Сообщение..." rows={1}
|
||
className="flex-1 resize-none rounded-xl px-3 py-2 text-sm bg-slate-100 dark:bg-slate-700 text-slate-900 dark:text-slate-100 placeholder-slate-400 outline-none max-h-24 overflow-y-auto" />
|
||
<button onClick={() => void sendMessage()} disabled={(!text.trim() && !attachment) || sending}
|
||
className="p-2.5 rounded-xl bg-brand-600 hover:bg-brand-700 disabled:opacity-40 text-white transition-colors shrink-0">
|
||
{sending ? <Loader2 size={16} className="animate-spin" /> : <Send size={16} />}
|
||
</button>
|
||
</div>
|
||
<p className="text-[10px] text-slate-400 mt-1.5">Enter — отправить · Shift+Enter — перенос</p>
|
||
</div>
|
||
)}
|
||
|
||
{activeRoom?.type === 'notifications' && (
|
||
<div className="px-4 py-3 border-t border-slate-200 dark:border-slate-700 bg-amber-50 dark:bg-amber-900/10 shrink-0">
|
||
<p className="text-xs text-amber-700 dark:text-amber-400 text-center flex items-center justify-center gap-1.5">
|
||
<Bell size={12} /> Канал только для чтения
|
||
</p>
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Context menu */}
|
||
{ctxMenu && (
|
||
<ContextMenu menu={ctxMenu} currentUserId={user?.id ?? ''} pinnedIds={pinnedIds}
|
||
onReaction={emoji => ctxMenu.type === 'message' && toggleReaction(ctxMenu.msg, emoji)}
|
||
onEdit={() => ctxMenu.type === 'message' && startEdit(ctxMenu.msg)}
|
||
onDelete={() => ctxMenu.type === 'message' && deleteMsg(ctxMenu.msg)}
|
||
onTogglePin={() => ctxMenu.type === 'room' && togglePin(ctxMenu.room.id)}
|
||
onClose={() => setCtxMenu(null)}
|
||
/>
|
||
)}
|
||
</>
|
||
)
|
||
}
|
||
|
||
// ── RoomRow ────────────────────────────────────────────────────────────────
|
||
|
||
function RoomRow({ room, isPinned, isOnline, onClick, onMenuClick }: {
|
||
room: ChatRoom; isPinned: boolean; isOnline: boolean
|
||
onClick: () => void; onMenuClick: (e: React.MouseEvent) => void
|
||
}) {
|
||
const [hovered, setHovered] = useState(false)
|
||
return (
|
||
<div
|
||
className="group relative flex items-center gap-3 px-4 py-3 hover:bg-slate-50 dark:hover:bg-slate-700/50 transition-colors border-b border-slate-100 dark:border-slate-700/50 cursor-pointer"
|
||
onClick={onClick}
|
||
onMouseEnter={() => setHovered(true)}
|
||
onMouseLeave={() => setHovered(false)}
|
||
>
|
||
{/* Avatar */}
|
||
<div className="relative shrink-0">
|
||
{room.type === 'general' ? (
|
||
<div className="w-9 h-9 rounded-full bg-brand-100 dark:bg-brand-900/30 flex items-center justify-center">
|
||
<Users size={16} className="text-brand-600 dark:text-brand-400" />
|
||
</div>
|
||
) : room.type === 'notifications' ? (
|
||
<div className="w-9 h-9 rounded-full bg-amber-100 dark:bg-amber-900/30 flex items-center justify-center">
|
||
<Bell size={16} className="text-amber-600 dark:text-amber-400" />
|
||
</div>
|
||
) : (
|
||
<Avatar name={room.otherUserName ?? '?'} size={36} online={isOnline} />
|
||
)}
|
||
{Number(room.unreadCount) > 0 && (
|
||
<span className="absolute -top-1 -right-1 w-4 h-4 rounded-full bg-red-500 text-white text-[9px] font-bold flex items-center justify-center">
|
||
{room.unreadCount}
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
{/* Text */}
|
||
<div className="flex-1 min-w-0">
|
||
<div className="flex items-center gap-1">
|
||
{isPinned && <Pin size={10} className="text-brand-500 shrink-0" />}
|
||
<p className={cn('text-sm font-medium truncate flex-1', Number(room.unreadCount) > 0 ? 'text-slate-900 dark:text-slate-100' : 'text-slate-700 dark:text-slate-300')}>
|
||
{room.type === 'general' ? 'Общий чат' : room.type === 'notifications' ? 'Уведомления' : room.otherUserName}
|
||
</p>
|
||
{!hovered && room.lastMessageAt && (
|
||
<span className="text-[10px] text-slate-400 shrink-0">{fmtTime(room.lastMessageAt)}</span>
|
||
)}
|
||
</div>
|
||
{room.lastMessage && (
|
||
<p className={cn('text-xs truncate mt-0.5', Number(room.unreadCount) > 0 ? 'text-slate-600 dark:text-slate-300 font-medium' : 'text-slate-400 dark:text-slate-500')}>
|
||
{room.lastSender && room.type === 'general' ? `${room.lastSender.split(' ')[0]}: ` : ''}{room.lastMessage}
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
{/* ⋮ button — appears on hover */}
|
||
{hovered && (
|
||
<button
|
||
onClick={onMenuClick}
|
||
className="shrink-0 w-7 h-7 flex items-center justify-center rounded-lg hover:bg-slate-200 dark:hover:bg-slate-600 text-slate-500 transition-colors text-base leading-none"
|
||
title="Действия"
|
||
>
|
||
⋮
|
||
</button>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── MessageBubble ──────────────────────────────────────────────────────────
|
||
|
||
function MessageBubble({ msg, isOwn, isEditing, editText, isRead, onContextMenu, onEditTextChange, onSaveEdit, onCancelEdit, onReaction }: {
|
||
msg: ChatMessage; isOwn: boolean; isEditing: boolean; editText: string; isRead: boolean
|
||
onContextMenu: (e: React.MouseEvent) => void
|
||
onEditTextChange: (v: string) => void
|
||
onSaveEdit: () => void; onCancelEdit: () => void
|
||
onReaction: (emoji: string) => void
|
||
}) {
|
||
const isDeleted = !!msg.deletedAt, isSystem = msg.isSystem
|
||
return (
|
||
<div className={cn('flex gap-2', isOwn && 'flex-row-reverse')} onContextMenu={onContextMenu}>
|
||
{!isOwn && (isSystem ? <SystemAvatar size={24} /> : <Avatar name={msg.senderName} size={24} />)}
|
||
<div className={cn('max-w-[80%]', isOwn && 'items-end flex flex-col')}>
|
||
{!isOwn && !isDeleted && (
|
||
<p className="text-[10px] text-slate-400 mb-0.5 ml-1">
|
||
{isSystem ? msg.systemName ?? 'Система' : msg.senderName.split(' ')[0]}
|
||
</p>
|
||
)}
|
||
|
||
{isDeleted ? (
|
||
<p className="px-3 py-2 text-sm italic text-slate-400 dark:text-slate-500 bg-slate-100 dark:bg-slate-700/50 rounded-2xl">
|
||
Сообщение удалено
|
||
</p>
|
||
) : isEditing ? (
|
||
<div className="w-48">
|
||
<textarea value={editText} onChange={e => onEditTextChange(e.target.value)} autoFocus rows={2}
|
||
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); onSaveEdit() } if (e.key === 'Escape') onCancelEdit() }}
|
||
className="w-full resize-none rounded-xl px-3 py-2 text-sm bg-slate-100 dark:bg-slate-700 text-slate-900 dark:text-slate-100 outline-none border border-brand-400" />
|
||
<div className="flex gap-1.5 mt-1">
|
||
<button onClick={onSaveEdit} className="text-[11px] px-2 py-0.5 rounded bg-brand-600 text-white hover:bg-brand-700">Сохранить</button>
|
||
<button onClick={onCancelEdit} className="text-[11px] px-2 py-0.5 rounded bg-slate-200 dark:bg-slate-600 text-slate-700 dark:text-slate-300">Отмена</button>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className={cn('rounded-2xl text-sm overflow-hidden',
|
||
isSystem ? 'bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-tl-sm'
|
||
: isOwn ? 'bg-brand-600 rounded-tr-sm' : 'bg-slate-100 dark:bg-slate-700 rounded-tl-sm')}>
|
||
{msg.attachmentUrl && (
|
||
<a href={msg.attachmentUrl} target="_blank" rel="noreferrer">
|
||
<img src={msg.attachmentUrl} alt="вложение" className="max-w-full rounded-t-2xl block" style={{ maxHeight: 180, objectFit: 'cover', width: '100%' }} />
|
||
</a>
|
||
)}
|
||
{msg.text && (
|
||
<p className={cn('px-3 py-2', isSystem ? 'text-amber-900 dark:text-amber-200' : isOwn ? 'text-white' : 'text-slate-800 dark:text-slate-200')}>
|
||
{msg.text}
|
||
</p>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Meta: time + edited + read receipt */}
|
||
{!isDeleted && (
|
||
<div className={cn('flex items-center gap-1 mt-0.5 mx-1', isOwn && 'flex-row-reverse')}>
|
||
<p className="text-[10px] text-slate-400">{fmtTime(msg.createdAt)}</p>
|
||
{msg.editedAt && <p className="text-[10px] text-slate-400">· изм.</p>}
|
||
{isOwn && (
|
||
<span className={cn('text-[11px] leading-none', isRead ? 'text-brand-400' : 'text-slate-300 dark:text-slate-600')}>
|
||
{isRead ? '✓✓' : '✓'}
|
||
</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Reactions */}
|
||
{!isDeleted && msg.reactions?.length > 0 && (
|
||
<div className={cn('flex flex-wrap gap-1 mt-1', isOwn && 'justify-end')}>
|
||
{(msg.reactions as ChatReaction[]).map(r => (
|
||
<button key={r.emoji} onClick={() => onReaction(r.emoji)}
|
||
className={cn('flex items-center gap-0.5 px-1.5 py-0.5 rounded-full text-xs transition-colors',
|
||
r.hasOwn
|
||
? 'bg-brand-100 dark:bg-brand-900/40 border border-brand-400 text-brand-700 dark:text-brand-300'
|
||
: 'bg-slate-100 dark:bg-slate-700 border border-slate-200 dark:border-slate-600 text-slate-700 dark:text-slate-300 hover:bg-slate-200')}>
|
||
{r.emoji} <span className="text-[10px] font-medium">{r.count}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── ContextMenu ────────────────────────────────────────────────────────────
|
||
|
||
function ContextMenu({ menu, currentUserId, pinnedIds, onReaction, onEdit, onDelete, onTogglePin, onClose }: {
|
||
menu: CtxMenu; currentUserId: string; pinnedIds: string[]
|
||
onReaction: (emoji: string) => void; onEdit: () => void; onDelete: () => void
|
||
onTogglePin: () => void; onClose: () => void
|
||
}) {
|
||
const menuW = 182, menuH = menu.type === 'message' ? 160 : 56
|
||
const x = Math.min(menu.x, window.innerWidth - menuW - 8)
|
||
const y = Math.min(menu.y, window.innerHeight - menuH - 8)
|
||
return (
|
||
<div style={{ position: 'fixed', top: y, left: x, zIndex: 9999, minWidth: menuW }}
|
||
className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-600 rounded-xl shadow-xl py-1"
|
||
onClick={e => e.stopPropagation()}>
|
||
{menu.type === 'message' && (
|
||
<>
|
||
<div className="flex justify-around px-2 py-1.5 border-b border-slate-100 dark:border-slate-700">
|
||
{EMOJIS.map(e => (
|
||
<button key={e} onClick={() => { onReaction(e); onClose() }}
|
||
className="text-base hover:scale-125 transition-transform leading-none p-0.5">{e}</button>
|
||
))}
|
||
</div>
|
||
{menu.msg.senderId === currentUserId && (
|
||
<button onClick={() => { onEdit(); onClose() }}
|
||
className="w-full text-left px-3 py-2 text-sm text-slate-700 dark:text-slate-200 hover:bg-slate-50 dark:hover:bg-slate-700/50 flex items-center gap-2">
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
||
Редактировать
|
||
</button>
|
||
)}
|
||
{menu.msg.senderId === currentUserId && (
|
||
<button onClick={() => { onDelete(); onClose() }}
|
||
className="w-full text-left px-3 py-2 text-sm text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 flex items-center gap-2">
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4h6v2"/></svg>
|
||
Удалить
|
||
</button>
|
||
)}
|
||
</>
|
||
)}
|
||
{menu.type === 'room' && (
|
||
<button onClick={() => { onTogglePin(); onClose() }}
|
||
className="w-full text-left px-3 py-2 text-sm text-slate-700 dark:text-slate-200 hover:bg-slate-50 dark:hover:bg-slate-700/50 flex items-center gap-2">
|
||
<Pin size={14} />
|
||
{pinnedIds.includes(menu.room.id) ? 'Открепить' : 'Закрепить сверху'}
|
||
</button>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── Misc sub-components ───────────────────────────────────────────────────
|
||
|
||
function TypingDots() {
|
||
return (
|
||
<div className="flex gap-0.5 items-center">
|
||
{[0, 1, 2].map(i => (
|
||
<span key={i} className="w-1.5 h-1.5 rounded-full bg-slate-400 animate-bounce" style={{ animationDelay: `${i * 0.15}s` }} />
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function UserRow({ user, online, onClick }: { user: User; online: boolean; onClick: () => void }) {
|
||
const roleLabels: Record<string, string> = {
|
||
hotel_admin: 'Администратор', manager: 'Менеджер', housekeeper: 'Горничная',
|
||
receptionist: 'Ресепшн', accountant: 'Бухгалтер', security: 'Охрана', technician: 'Техник',
|
||
}
|
||
return (
|
||
<button onClick={onClick} className="w-full flex items-center gap-3 px-4 py-2.5 hover:bg-slate-50 dark:hover:bg-slate-700/50 transition-colors border-b border-slate-100 dark:border-slate-700/50 text-left">
|
||
<Avatar name={user.name} size={32} online={online} />
|
||
<div className="flex-1 min-w-0">
|
||
<p className="text-sm font-medium text-slate-800 dark:text-slate-200 truncate">{user.name}</p>
|
||
<p className="text-xs text-slate-400 truncate">{roleLabels[user.role] ?? user.role}</p>
|
||
</div>
|
||
</button>
|
||
)
|
||
}
|
||
|
||
function SettingsSection({ title, children }: { title: string; children: React.ReactNode }) {
|
||
return (
|
||
<div className="px-4">
|
||
<p className="text-xs font-semibold text-slate-400 uppercase tracking-wide mb-2">{title}</p>
|
||
<div className="space-y-1">{children}</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function ToggleRow({ icon, label, checked, onChange }: {
|
||
icon: React.ReactNode; label: string; checked: boolean; onChange: (v: boolean) => void
|
||
}) {
|
||
return (
|
||
<div className="flex items-center gap-3 py-2">
|
||
<span className="text-slate-500 shrink-0">{icon}</span>
|
||
<span className="flex-1 text-sm text-slate-700 dark:text-slate-300 leading-tight">{label}</span>
|
||
<button onClick={() => onChange(!checked)}
|
||
className={cn('relative w-10 h-5 rounded-full transition-colors shrink-0', checked ? 'bg-brand-600' : 'bg-slate-300 dark:bg-slate-600')}>
|
||
<span className={cn('absolute top-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform', checked ? 'translate-x-5' : 'translate-x-0.5')} />
|
||
</button>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function RoomSkeleton({ icon, bg, label }: { icon: React.ReactNode; bg: string; label: string }) {
|
||
return (
|
||
<div className="flex items-center gap-3 px-4 py-3 border-b border-slate-100 dark:border-slate-700/50 animate-pulse">
|
||
<div className={cn('w-9 h-9 rounded-full flex items-center justify-center shrink-0', bg)}>{icon}</div>
|
||
<div className="flex-1 min-w-0">
|
||
<p className="text-sm font-medium text-slate-600 dark:text-slate-300">{label}</p>
|
||
<div className="h-3 w-24 bg-slate-200 dark:bg-slate-600 rounded mt-1" />
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|