Files
factorio-signal-exporter/web/components/AlertPanel.tsx

321 lines
10 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useState, useEffect, useRef } from 'react';
import type { AlertConfig } from '@/lib/types';
import { fetchAlerts, createAlert, updateAlert, deleteAlert } from '@/lib/api';
import { useApp } from '@/lib/context';
import { resolveName, resolveKey } from '@/lib/localization';
interface Props {
open: boolean;
onClose: () => void;
}
const inputCls =
'w-full bg-gray-800 border border-gray-600 rounded px-2 py-1 text-sm text-white focus:outline-none focus:border-indigo-500';
const selectCls =
'flex-1 bg-gray-800 border border-gray-600 rounded px-2 py-1 text-sm text-white focus:outline-none';
interface AlertFormState {
itemKey: string;
itemKeyIsRegex: boolean;
combinator: string;
signalType: 'green' | 'red';
condition: 'above' | 'below';
threshold: string;
}
function emptyForm(): AlertFormState {
return {
itemKey: '',
itemKeyIsRegex: false,
combinator: '',
signalType: 'green',
condition: 'below',
threshold: '0',
};
}
function alertToForm(a: AlertConfig): AlertFormState {
return {
itemKey: a.item_key,
itemKeyIsRegex: a.item_key_is_regex,
combinator: a.combinator ?? '',
signalType: a.signal_type,
condition: a.condition,
threshold: String(a.threshold),
};
}
function Tooltip({ text }: { text: string }) {
return (
<span className="relative group inline-flex items-center cursor-default">
<span className="text-gray-500 hover:text-gray-300 text-xs select-none"></span>
<span className="pointer-events-none absolute left-5 top-0 z-50 w-48 rounded bg-gray-700 px-2 py-1 text-xs text-gray-200 opacity-0 group-hover:opacity-100 transition-opacity shadow-lg">
{text}
</span>
</span>
);
}
function AlertForm({
value,
onChange,
onSubmit,
onCancel,
submitLabel,
}: {
value: AlertFormState;
onChange: (s: AlertFormState) => void;
onSubmit: () => void;
onCancel?: () => void;
submitLabel: string;
}) {
return (
<div className="space-y-2">
<input
value={value.itemKey}
onChange={(e) => onChange({ ...value, itemKey: e.target.value })}
placeholder={value.itemKeyIsRegex ? 'iron-.*|Iron Plate' : 'Iron Plate or item-key'}
className={inputCls}
/>
<label className="flex items-center gap-1.5 text-xs text-gray-400 cursor-pointer">
<input
type="checkbox"
checked={value.itemKeyIsRegex}
onChange={(e) => onChange({ ...value, itemKeyIsRegex: e.target.checked })}
className="accent-indigo-500"
/>
Item key is regex
</label>
<input
value={value.combinator}
onChange={(e) => onChange({ ...value, combinator: e.target.value })}
placeholder="combinator (empty = all)"
className={inputCls}
/>
<div className="flex gap-2">
<select
value={value.signalType}
onChange={(e) => onChange({ ...value, signalType: e.target.value as 'green' | 'red' })}
className={selectCls}
>
<option value="green">Green</option>
<option value="red">Red</option>
</select>
<select
value={value.condition}
onChange={(e) => onChange({ ...value, condition: e.target.value as 'above' | 'below' })}
className={selectCls}
>
<option value="below">Below</option>
<option value="above">Above</option>
</select>
</div>
<input
type="number"
value={value.threshold}
onChange={(e) => onChange({ ...value, threshold: e.target.value })}
placeholder="threshold"
className={inputCls}
/>
<div className="flex gap-2">
<button
aria-label={submitLabel}
onClick={onSubmit}
className="flex-1 bg-indigo-600 hover:bg-indigo-500 text-white text-sm rounded py-1.5"
>
{submitLabel}
</button>
{onCancel && (
<button
aria-label="Cancel"
onClick={onCancel}
className="px-3 py-1.5 text-sm text-gray-400 hover:text-white rounded hover:bg-gray-700"
>
Cancel
</button>
)}
</div>
</div>
);
}
export default function AlertPanel({ open, onClose }: Props) {
const { triggeredAlerts, refreshAlerts, localeMap, reverseMap } = useApp();
const [alerts, setAlerts] = useState<AlertConfig[]>([]);
const [newForm, setNewForm] = useState<AlertFormState>(emptyForm());
const [editingId, setEditingId] = useState<string | null>(null);
const [editForm, setEditForm] = useState<AlertFormState>(emptyForm());
const prevTriggeredCount = useRef(0);
useEffect(() => {
if (open) fetchAlerts().then(setAlerts);
}, [open]);
useEffect(() => {
if (triggeredAlerts.length > prevTriggeredCount.current) {
try {
const ctx = new AudioContext();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(ctx.destination);
osc.frequency.value = 880;
gain.gain.setValueAtTime(0.3, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.4);
osc.start();
osc.stop(ctx.currentTime + 0.4);
} catch {
/* AudioContext blocked */
}
}
prevTriggeredCount.current = triggeredAlerts.length;
}, [triggeredAlerts.length]);
/** Resolves item key input — skips resolution for regex mode. */
function normalizeItemKey(form: AlertFormState): string {
if (form.itemKeyIsRegex) return form.itemKey.trim();
return resolveKey(form.itemKey.trim(), reverseMap);
}
async function handleCreate() {
if (!newForm.itemKey.trim()) return;
const created = await createAlert({
item_key: normalizeItemKey(newForm),
item_key_is_regex: newForm.itemKeyIsRegex,
combinator: newForm.combinator.trim() || null,
signal_type: newForm.signalType,
condition: newForm.condition,
threshold: parseInt(newForm.threshold, 10),
});
setAlerts((a) => [created, ...a]);
setNewForm(emptyForm());
await refreshAlerts();
}
async function handleEdit(id: string) {
if (!editForm.itemKey.trim()) return;
const updated = await updateAlert(id, {
item_key: normalizeItemKey(editForm),
item_key_is_regex: editForm.itemKeyIsRegex,
combinator: editForm.combinator.trim() || null,
signal_type: editForm.signalType,
condition: editForm.condition,
threshold: parseInt(editForm.threshold, 10),
});
setAlerts((a) => a.map((x) => (x.id === id ? updated : x)));
setEditingId(null);
await refreshAlerts();
}
async function handleDelete(id: string) {
await deleteAlert(id);
setAlerts((a) => a.filter((x) => x.id !== id));
await refreshAlerts();
}
function startEdit(alert: AlertConfig) {
setEditingId(alert.id);
setEditForm(alertToForm(alert));
}
return (
<div
className={`fixed top-0 right-0 h-full w-80 bg-gray-900 border-l border-gray-700 shadow-xl z-40 transform transition-transform duration-200 ${open ? 'translate-x-0' : 'translate-x-full'}`}
>
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-700">
<span className="font-semibold text-white">Alerts</span>
<button
aria-label="Close alerts panel"
onClick={onClose}
className="text-gray-400 hover:text-white"
>
</button>
</div>
{triggeredAlerts.length > 0 && (
<div className="px-4 py-2 bg-red-900/40 border-b border-red-800">
<p className="text-red-300 text-xs font-semibold mb-1">
🔴 TRIGGERED ({triggeredAlerts.length})
</p>
{triggeredAlerts.map((a, i) => (
<div key={i} className="text-xs text-red-200 flex items-center gap-1 flex-wrap">
<span>{resolveName(a.matched_item_key, localeMap)}</span>
<span className="text-red-400">({a.combinator_match})</span>
<span>[{a.signal_type}]</span>
<span>
= {a.current_value} {a.condition} {a.threshold}
</span>
{a.item_key_is_regex && a.matched_item_key !== a.item_key && (
<Tooltip text={`Matched by regex: /${a.item_key}/`} />
)}
</div>
))}
</div>
)}
<div className="p-4 border-b border-gray-700 space-y-2">
<p className="text-xs text-gray-400 font-semibold uppercase">New Alert</p>
<AlertForm
value={newForm}
onChange={setNewForm}
onSubmit={handleCreate}
submitLabel="Add Alert"
/>
</div>
<div className="overflow-y-auto flex-1 p-4 space-y-2">
{alerts.length === 0 && <p className="text-gray-500 text-sm">No alerts configured.</p>}
{alerts.map((a) => (
<div key={a.id} className="bg-gray-800 rounded p-2 text-xs text-gray-300">
{editingId === a.id ? (
<AlertForm
value={editForm}
onChange={setEditForm}
onSubmit={() => handleEdit(a.id)}
onCancel={() => setEditingId(null)}
submitLabel="Save"
/>
) : (
<div className="flex justify-between items-start">
<div>
<span className="font-medium text-white">
{resolveName(a.item_key, localeMap)}
</span>
{a.item_key_is_regex && <Tooltip text={`Regex pattern: /${a.item_key}/`} />}
{a.combinator && <span className="text-gray-400"> @ {a.combinator}</span>}
<br />
<span className={a.signal_type === 'green' ? 'text-green-400' : 'text-red-400'}>
[{a.signal_type}]
</span>{' '}
{a.condition} {a.threshold}
</div>
<div className="flex gap-1 ml-2 shrink-0">
<button
aria-label="Edit alert"
onClick={() => startEdit(a)}
className="text-gray-500 hover:text-indigo-400"
>
</button>
<button
aria-label="Delete alert"
onClick={() => handleDelete(a.id)}
className="text-gray-500 hover:text-red-400"
>
</button>
</div>
</div>
)}
</div>
))}
</div>
</div>
);
}