Первоначальная версия VidConf

This commit is contained in:
2026-07-23 01:04:01 +03:00
commit 896455381a
335 changed files with 61527 additions and 0 deletions

View File

@@ -0,0 +1,120 @@
import { useEffect, useRef, useState, type ChangeEvent, type KeyboardEvent } from 'react'
import { AlertCircle, Send, X } from 'lucide-react'
import type { ChatConnectionStatus, ChatMessageOut } from '@/hooks/useChat'
import { formatLocalTime } from '@/lib/localTime'
interface ChatPanelProps {
messages: ChatMessageOut[]
status: ChatConnectionStatus
statusMessage: string | null
onSend: (text: string) => void
onClose: () => void
}
/**
* Панель чата комнаты конференции (см. design/mockups/room.html, блок
* `.chat-panel`). Всегда в развёрнутом виде рендерится только пока сама
* панель открыта — сворачивание/разворачивание и счётчик непрочитанных
* управляются на уровне RoomPage (WS-соединение живёт независимо от того,
* открыта ли панель, — иначе при сворачивании терялась бы история).
*
* Textarea, а не `<input>` из макета — сознательное отступление ради
* Enter/Shift+Enter (перенос строки), стили сохранены визуально идентичными
* пилюле-полю из макета.
*/
export function ChatPanel({ messages, status, statusMessage, onSend, onClose }: ChatPanelProps) {
const [draft, setDraft] = useState('')
const listRef = useRef<HTMLDivElement>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null)
// Автоскролл к последнему сообщению — только если пользователь и так был
// внизу списка, чтобы не мешать чтению прокрученной вверх истории.
useEffect(() => {
const el = listRef.current
if (!el) return
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight
if (distanceFromBottom < 120) {
el.scrollTop = el.scrollHeight
}
}, [messages])
function handleSend() {
const text = draft.trim()
if (!text || status !== 'open') return
onSend(text)
setDraft('')
if (textareaRef.current) textareaRef.current.style.height = 'auto'
}
function handleKeyDown(event: KeyboardEvent<HTMLTextAreaElement>) {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault()
handleSend()
}
}
function handleDraftChange(event: ChangeEvent<HTMLTextAreaElement>) {
setDraft(event.target.value)
// Авторасширение textarea до 4 строк, дальше — внутренний скролл.
const el = event.target
el.style.height = 'auto'
el.style.height = `${Math.min(el.scrollHeight, 96)}px`
}
return (
<aside className="chat-panel">
<div className="chat-head">
<h2>Чат встречи</h2>
<button type="button" aria-label="Свернуть чат" onClick={onClose}>
<X className="lucide" aria-hidden="true" />
</button>
</div>
{statusMessage && (
<p className="chat-status-banner">
<AlertCircle className="lucide" aria-hidden="true" /> {statusMessage}
</p>
)}
<div className="chat-messages" ref={listRef}>
{messages.length === 0 && status === 'open' && !statusMessage && (
<p className="chat-empty">Сообщений пока нет начните обсуждение</p>
)}
{messages.map((message) => (
<div className="msg" key={message.id}>
<div className="msg-meta">
<span className="msg-author">
{message.author_name}
{message.is_guest && <span className="msg-guest-badge">гость</span>}
</span>
<span className="msg-time">{formatLocalTime(message.created_at)}</span>
</div>
<div className="msg-bubble">{message.text}</div>
</div>
))}
</div>
<form
className="chat-input-row"
onSubmit={(event) => {
event.preventDefault()
handleSend()
}}
>
<textarea
ref={textareaRef}
rows={1}
placeholder="Написать сообщение…"
value={draft}
onChange={handleDraftChange}
onKeyDown={handleKeyDown}
disabled={status !== 'open'}
maxLength={2000}
/>
<button type="submit" aria-label="Отправить" disabled={!draft.trim() || status !== 'open'}>
<Send className="lucide" aria-hidden="true" />
</button>
</form>
</aside>
)
}