94 lines
4.4 KiB
TypeScript
94 lines
4.4 KiB
TypeScript
import { useEffect, useRef, useState } from 'react';
|
|
import { MessageSquare, Send, Users, X } from 'lucide-react';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
export type ChatMsg = { id: number; operator: string; station: string; message: string; created_at: string };
|
|
export type ChatPresence = { operator: string; station: string; ago_secs: number };
|
|
|
|
function hhmm(iso: string): string {
|
|
const d = new Date(iso);
|
|
if (isNaN(d.getTime())) return '';
|
|
const p = (n: number) => String(n).padStart(2, '0');
|
|
return `${p(d.getUTCHours())}:${p(d.getUTCMinutes())}`;
|
|
}
|
|
|
|
// ChatPanel — presentational multi-op chat panel, docked in the aside row next
|
|
// to the rotor / WinKeyer / DVK panels. All data + state lives in App.tsx.
|
|
export function ChatPanel({ msgs, online, myCall, onSend, onClose }: {
|
|
msgs: ChatMsg[]; online: ChatPresence[]; myCall?: string;
|
|
onSend: (text: string) => void; onClose: () => void;
|
|
}) {
|
|
const [text, setText] = useState('');
|
|
const listRef = useRef<HTMLDivElement>(null);
|
|
const me = (myCall || '').toUpperCase();
|
|
useEffect(() => { const el = listRef.current; if (el) el.scrollTop = el.scrollHeight; }, [msgs]);
|
|
|
|
function send() {
|
|
const t = text.trim();
|
|
if (!t) return;
|
|
setText('');
|
|
onSend(t);
|
|
}
|
|
|
|
return (
|
|
<div className="h-full flex flex-col rounded-xl border border-border bg-card shadow-sm overflow-hidden">
|
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30 shrink-0">
|
|
<MessageSquare className="size-4 text-sky-600" />
|
|
<span className="text-xs font-bold uppercase tracking-wider text-foreground/80">Chat</span>
|
|
<span className="flex-1" />
|
|
{/* Online count — hover to see who's connected. */}
|
|
<div className="relative group">
|
|
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground cursor-default">
|
|
<Users className="size-3.5" />{online.length}
|
|
</span>
|
|
{online.length > 0 && (
|
|
<div className="hidden group-hover:block absolute right-0 top-5 z-20 min-w-[130px] rounded-md border border-border bg-popover shadow-lg p-1.5">
|
|
<div className="text-[9px] uppercase tracking-wider text-muted-foreground mb-1">Online</div>
|
|
{online.map((o) => (
|
|
<div key={o.operator} className="font-mono text-[11px] whitespace-nowrap">
|
|
{o.operator}{o.station && o.station.toUpperCase() !== o.operator.toUpperCase() ? <span className="text-muted-foreground"> · {o.station}</span> : null}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<button type="button" onClick={onClose} className="ml-1 text-muted-foreground hover:text-foreground" title="Close">
|
|
<X className="size-3.5" />
|
|
</button>
|
|
</div>
|
|
|
|
<div ref={listRef} className="flex-1 min-h-0 overflow-y-auto px-3 py-2 space-y-1.5 text-xs">
|
|
{msgs.length === 0 ? (
|
|
<div className="text-muted-foreground italic text-center py-6">No messages yet.</div>
|
|
) : msgs.map((m) => {
|
|
const mine = m.operator.toUpperCase() === me;
|
|
return (
|
|
<div key={m.id} className={cn('flex flex-col', mine && 'items-end')}>
|
|
<div className={cn('max-w-[85%] rounded-lg px-2 py-1', mine ? 'bg-sky-100 text-sky-900' : 'bg-muted')}>
|
|
{!mine && <span className="font-mono font-bold text-[10px] text-primary mr-1">{m.operator}</span>}
|
|
<span className="whitespace-pre-wrap break-words">{m.message}</span>
|
|
</div>
|
|
<span className="text-[9px] text-muted-foreground/70 px-1">{hhmm(m.created_at)}</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
<div className="flex items-center gap-1.5 p-2 border-t border-border/60 shrink-0">
|
|
<input
|
|
value={text}
|
|
onChange={(e) => setText(e.target.value)}
|
|
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); send(); } }}
|
|
placeholder="Message…"
|
|
maxLength={1000}
|
|
className="flex-1 h-8 rounded-md border border-border bg-background px-2 text-xs outline-none focus:border-primary"
|
|
/>
|
|
<button type="button" onClick={send} disabled={!text.trim()}
|
|
className="inline-flex items-center justify-center size-8 rounded-md bg-primary text-primary-foreground disabled:opacity-40">
|
|
<Send className="size-3.5" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|