chore: release v0.12
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
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" />
|
||||
<Users className="size-3.5 text-muted-foreground" />
|
||||
<span className="text-[11px] text-muted-foreground" title={online.map((o) => o.operator).join(', ')}>
|
||||
{online.length}
|
||||
</span>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user