124 lines
7.9 KiB
TypeScript
124 lines
7.9 KiB
TypeScript
import { createPortal } from 'react-dom';
|
|
import { Radio, X, Volume2, VolumeX, Play, Megaphone, ListChecks, Trash2, CheckSquare, Square, Volume1 } from 'lucide-react';
|
|
import { cn } from '@/lib/utils';
|
|
import { useRadio, speak, beep } from '@/core/radio';
|
|
|
|
const tm = (iso: string) => new Date(iso).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
|
const DOT: Record<string, string> = { critical: 'bg-crit', warning: 'bg-warn', info: 'bg-info', ok: 'bg-ok' };
|
|
|
|
// Мини-окно «Рация»: включение/отключение оповещения + эфир. Открывается кнопкой в шапке.
|
|
export function RadioMiniWindow({ open, onClose }: { open: boolean; onClose: () => void }) {
|
|
const { on, ether, toggle, board, readList, toggleTask, removeBoard, enqueueRead, removeRead, clearRead, readAll } = useRadio();
|
|
if (!open) return null;
|
|
|
|
const openTasks = board.filter((b) => b.kind === 'task' && !b.done).length;
|
|
|
|
const test = () => {
|
|
beep(on ? 880 : 440, 0.14, 0.25);
|
|
if (on) speak('Проверка связи. Рация в эфире.');
|
|
};
|
|
|
|
return createPortal(
|
|
<div className="fixed bottom-4 right-4 z-[3500] w-[300px] overflow-hidden rounded-2xl border border-border bg-surface shadow-card">
|
|
<div className="flex items-center justify-between border-b border-border px-3 py-2.5">
|
|
<div className="flex items-center gap-2">
|
|
<span className={cn('grid h-7 w-7 place-items-center rounded-lg', on ? 'bg-accent/20 text-accent' : 'bg-muted text-fg-muted')}>
|
|
<Radio className="h-4 w-4" />
|
|
</span>
|
|
<div>
|
|
<div className="text-sm font-semibold leading-tight">Рация</div>
|
|
<div className="text-[10px] text-fg-muted">{on ? 'оповещение включено' : 'оповещение выключено'}</div>
|
|
</div>
|
|
</div>
|
|
<button onClick={onClose} className="grid h-7 w-7 place-items-center rounded-lg text-fg-muted hover:bg-muted hover:text-fg"><X className="h-4 w-4" /></button>
|
|
</div>
|
|
|
|
{/* Вкл/выкл оповещение */}
|
|
<div className="flex items-center gap-2 px-3 py-3">
|
|
<button
|
|
onClick={toggle}
|
|
className={cn('flex flex-1 items-center justify-center gap-2 rounded-xl px-3 py-2.5 text-sm font-semibold transition-colors', on ? 'bg-accent text-[#06210f] hover:opacity-90' : 'border border-border text-fg hover:bg-muted')}
|
|
>
|
|
{on ? <Volume2 className="h-4 w-4" /> : <VolumeX className="h-4 w-4" />}
|
|
{on ? 'Выключить оповещение' : 'Включить оповещение'}
|
|
</button>
|
|
<button onClick={test} title="Проверка связи" className="grid h-10 w-10 shrink-0 place-items-center rounded-xl border border-border text-fg-muted hover:bg-muted hover:text-fg">
|
|
<Play className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Эфир */}
|
|
<div className="border-t border-border">
|
|
<div className="px-3 py-1.5 text-[10px] uppercase tracking-wide text-fg-muted">Эфир · последние события</div>
|
|
<div className="max-h-48 space-y-0.5 overflow-y-auto px-2 pb-2">
|
|
{ether.length === 0 && <div className="px-2 py-4 text-center text-xs text-fg-muted">Пока тихо. События появятся здесь.</div>}
|
|
{ether.map((e, i) => (
|
|
<div key={i} className="flex items-start gap-2 rounded-lg px-2 py-1.5 hover:bg-muted/50">
|
|
<span className={cn('mt-1 h-2 w-2 shrink-0 rounded-full', DOT[e.severity] ?? 'bg-fg-muted')} />
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center gap-1.5 text-[11px]">
|
|
<span className="truncate font-medium text-fg">{e.callsign}</span>
|
|
<span className="tnum shrink-0 text-fg-muted">{tm(e.ts)}</span>
|
|
</div>
|
|
<div className="truncate text-[11px] text-fg-muted">{e.text}</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Доска объявлений */}
|
|
<div className="border-t border-border">
|
|
<div className="flex items-center justify-between px-3 py-1.5">
|
|
<span className="flex items-center gap-1.5 text-[10px] uppercase tracking-wide text-fg-muted"><Megaphone className="h-3 w-3" /> Доска объявлений</span>
|
|
{openTasks > 0 && <span className="rounded-full bg-warn/20 px-1.5 text-[10px] font-semibold text-warn">{openTasks} задач</span>}
|
|
</div>
|
|
<div className="max-h-52 space-y-1 overflow-y-auto px-2 pb-2">
|
|
{board.map((b) => (
|
|
<div key={b.id} className={cn('group flex items-start gap-2 rounded-lg px-2 py-1.5', b.urgent && !b.done ? 'sm-blink bg-crit/10' : 'hover:bg-muted/50')}>
|
|
{b.kind === 'task' ? (
|
|
<button onClick={() => toggleTask(b.id)} title={b.done ? 'Открыть задачу' : 'Закрыть задачу'} className={cn('mt-0.5 shrink-0', b.done ? 'text-ok' : 'text-fg-muted hover:text-fg')}>
|
|
{b.done ? <CheckSquare className="h-4 w-4" /> : <Square className="h-4 w-4" />}
|
|
</button>
|
|
) : (
|
|
<span className={cn('mt-1 h-2 w-2 shrink-0 rounded-full', b.urgent ? 'bg-crit' : 'bg-info')} />
|
|
)}
|
|
<div className="min-w-0 flex-1">
|
|
<div className={cn('text-[11px] leading-snug', b.done ? 'text-fg-muted line-through' : 'text-fg')}>{b.text}</div>
|
|
</div>
|
|
<div className="flex shrink-0 items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
|
<button onClick={() => enqueueRead(b.text)} title="В список чтения (озвучить)" className="text-fg-muted hover:text-accent"><Volume1 className="h-3.5 w-3.5" /></button>
|
|
<button onClick={() => removeBoard(b.id)} title="Удалить" className="text-fg-muted hover:text-crit"><Trash2 className="h-3.5 w-3.5" /></button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Список чтения — голосовые уведомления */}
|
|
<div className="border-t border-border">
|
|
<div className="flex items-center justify-between px-3 py-1.5">
|
|
<span className="flex items-center gap-1.5 text-[10px] uppercase tracking-wide text-fg-muted"><ListChecks className="h-3 w-3" /> Список чтения · {readList.length}</span>
|
|
<div className="flex items-center gap-1">
|
|
<button onClick={readAll} disabled={readList.length === 0} className="flex items-center gap-1 rounded-md bg-accent/15 px-2 py-0.5 text-[10px] font-semibold text-accent hover:bg-accent/25 disabled:opacity-40">
|
|
<Volume2 className="h-3 w-3" /> Читать
|
|
</button>
|
|
<button onClick={clearRead} disabled={readList.length === 0} title="Очистить" className="text-fg-muted hover:text-crit disabled:opacity-40"><Trash2 className="h-3.5 w-3.5" /></button>
|
|
</div>
|
|
</div>
|
|
<div className="max-h-36 space-y-0.5 overflow-y-auto px-2 pb-2">
|
|
{readList.length === 0 && <div className="px-2 py-3 text-center text-[11px] text-fg-muted">Список пуст. Добавьте объявление кнопкой 🔈.</div>}
|
|
{readList.map((t, i) => (
|
|
<div key={i} className="flex items-start gap-2 rounded-lg px-2 py-1 hover:bg-muted/50">
|
|
<button onClick={() => speak(t)} title="Прочитать" className="mt-0.5 shrink-0 text-fg-muted hover:text-accent"><Play className="h-3.5 w-3.5" /></button>
|
|
<div className="min-w-0 flex-1 text-[11px] leading-snug text-fg-muted">{t}</div>
|
|
<button onClick={() => removeRead(i)} title="Убрать" className="shrink-0 text-fg-muted hover:text-crit"><X className="h-3.5 w-3.5" /></button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>,
|
|
document.body,
|
|
);
|
|
}
|