308 lines
11 KiB
TypeScript
308 lines
11 KiB
TypeScript
'use client';
|
||
|
||
import { useState } from 'react';
|
||
|
||
import type { ChartConfig } from '@/lib/types';
|
||
|
||
import { useApp } from '@/lib/context';
|
||
import { resolveKey } from '@/lib/localization';
|
||
|
||
type DraftChart = Omit<ChartConfig, 'id'>;
|
||
|
||
interface Props {
|
||
initial?: ChartConfig;
|
||
onSave: (draft: DraftChart) => void;
|
||
onClose: () => void;
|
||
}
|
||
|
||
const inputCls =
|
||
'w-full bg-gray-800 border border-gray-600 rounded px-3 py-1.5 text-white text-sm focus:outline-none focus:border-indigo-500';
|
||
|
||
/**
|
||
* Normalizes a comma-separated list of user tokens (localized names or raw keys)
|
||
* to raw item_keys. Unknown tokens are kept as-is.
|
||
*/
|
||
function normalizeList(raw: string, reverseMap: Map<string, string>): string[] | null {
|
||
const arr = raw
|
||
.split(',')
|
||
.map((x) => x.trim())
|
||
.filter(Boolean);
|
||
if (arr.length === 0) return null;
|
||
return arr.map((t) => resolveKey(t, reverseMap));
|
||
}
|
||
|
||
export default function ChartEditor({ initial, onSave, onClose }: Props) {
|
||
const { reverseMap } = useApp();
|
||
|
||
const [title, setTitle] = useState(initial?.title ?? '');
|
||
const [chartType, setChartType] = useState<ChartConfig['chart_type']>(
|
||
initial?.chart_type ?? 'signals',
|
||
);
|
||
const [vizType, setVizType] = useState<ChartConfig['viz_type']>(initial?.viz_type ?? 'line');
|
||
const [signalType, setSignalType] = useState<ChartConfig['signal_type']>(
|
||
initial?.signal_type ?? 'both',
|
||
);
|
||
const [combinators, setCombinators] = useState((initial?.filter_combinators ?? []).join(', '));
|
||
const [whitelist, setWhitelist] = useState((initial?.filter_items ?? []).join(', '));
|
||
const [blacklist, setBlacklist] = useState((initial?.filter_items_exclude ?? []).join(', '));
|
||
const [useRegex, setUseRegex] = useState(initial?.filter_items_regex ?? false);
|
||
const [orderBy, setOrderBy] = useState<ChartConfig['order_by']>(initial?.order_by ?? 'value_asc');
|
||
const [seriesLimit, setSeriesLimit] = useState(initial?.series_limit ?? 20);
|
||
const [yMin, setYMin] = useState(initial?.y_min?.toString() ?? '');
|
||
const [yMax, setYMax] = useState(initial?.y_max?.toString() ?? '');
|
||
const [yScale, setYScale] = useState<ChartConfig['y_scale']>(initial?.y_scale ?? 'linear');
|
||
const [width, setWidth] = useState(initial?.width ?? 2);
|
||
const [height, setHeight] = useState(initial?.height ?? 4);
|
||
|
||
function splitCombinators(): string[] | null {
|
||
const arr = combinators
|
||
.split(',')
|
||
.map((x) => x.trim())
|
||
.filter(Boolean);
|
||
return arr.length > 0 ? arr : null;
|
||
}
|
||
|
||
function handleSave() {
|
||
if (!title.trim()) return;
|
||
|
||
// Regex mode: store pattern exactly as typed — server expands via matchKeys at query time.
|
||
// Non-regex mode: resolve each comma-token (localized name or raw key) to raw key.
|
||
const filter_items = useRegex
|
||
? whitelist.trim()
|
||
? [whitelist.trim()]
|
||
: null
|
||
: normalizeList(whitelist, reverseMap);
|
||
const filter_items_exclude = useRegex
|
||
? blacklist.trim()
|
||
? [blacklist.trim()]
|
||
: null
|
||
: normalizeList(blacklist, reverseMap);
|
||
|
||
onSave({
|
||
title: title.trim(),
|
||
chart_type: chartType,
|
||
viz_type: chartType === 'divider' ? 'line' : vizType,
|
||
signal_type: signalType,
|
||
pos_x: initial?.pos_x ?? 0,
|
||
pos_y: initial?.pos_y ?? 0,
|
||
width,
|
||
height,
|
||
filter_combinators: chartType === 'divider' ? null : splitCombinators(),
|
||
filter_items: chartType === 'divider' ? null : filter_items,
|
||
filter_items_exclude: chartType === 'divider' ? null : filter_items_exclude,
|
||
filter_items_regex: useRegex,
|
||
order_by: orderBy,
|
||
series_limit: seriesLimit,
|
||
y_min: yMin !== '' ? parseFloat(yMin) : null,
|
||
y_max: yMax !== '' ? parseFloat(yMax) : null,
|
||
y_scale: yScale,
|
||
});
|
||
}
|
||
|
||
const isSignals = chartType === 'signals';
|
||
const isDivider = chartType === 'divider';
|
||
|
||
return (
|
||
<div
|
||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60"
|
||
onClick={onClose}
|
||
>
|
||
<div
|
||
className="bg-gray-900 border border-gray-700 rounded-lg p-6 w-full max-w-md shadow-xl overflow-y-auto max-h-[90vh]"
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<h2 className="text-lg font-semibold text-white mb-4">
|
||
{initial ? 'Edit Chart' : 'New Chart'}
|
||
</h2>
|
||
|
||
<label className="block text-sm text-gray-400 mb-1">Title</label>
|
||
<input
|
||
value={title}
|
||
onChange={(e) => setTitle(e.target.value)}
|
||
className={`${inputCls} mb-3`}
|
||
/>
|
||
|
||
<label className="block text-sm text-gray-400 mb-1">Chart Type</label>
|
||
<select
|
||
value={chartType}
|
||
onChange={(e) => setChartType(e.target.value as ChartConfig['chart_type'])}
|
||
className={`${inputCls} mb-3`}
|
||
>
|
||
<option value="signals">Signals</option>
|
||
<option value="ups">UPS / Game Tick Rate</option>
|
||
<option value="divider">Divider / Section Label</option>
|
||
</select>
|
||
|
||
{!isDivider && (
|
||
<>
|
||
<label className="block text-sm text-gray-400 mb-1">Visualization</label>
|
||
<select
|
||
value={vizType}
|
||
onChange={(e) => setVizType(e.target.value as ChartConfig['viz_type'])}
|
||
className={`${inputCls} mb-3`}
|
||
>
|
||
<option value="line">Line Chart</option>
|
||
<option value="table">Table</option>
|
||
</select>
|
||
</>
|
||
)}
|
||
|
||
{isSignals && (
|
||
<>
|
||
<label className="block text-sm text-gray-400 mb-1">Signal</label>
|
||
<select
|
||
value={signalType}
|
||
onChange={(e) => setSignalType(e.target.value as ChartConfig['signal_type'])}
|
||
className={`${inputCls} mb-3`}
|
||
>
|
||
<option value="both">Both (green + red)</option>
|
||
<option value="green">Green only</option>
|
||
<option value="red">Red only</option>
|
||
</select>
|
||
|
||
<label className="block text-sm text-gray-400 mb-1">Sort series by</label>
|
||
<select
|
||
value={orderBy}
|
||
onChange={(e) => setOrderBy(e.target.value as ChartConfig['order_by'])}
|
||
className={`${inputCls} mb-3`}
|
||
>
|
||
<option value="value_asc">Latest lowest values</option>
|
||
<option value="value_desc">Latest highest values</option>
|
||
<option value="delta_asc">Biggest decrease (last 10 min)</option>
|
||
<option value="delta_desc">Biggest increase (last 10 min)</option>
|
||
</select>
|
||
|
||
<label className="block text-sm text-gray-400 mb-1">Max series (lines)</label>
|
||
<input
|
||
type="number"
|
||
min={1}
|
||
max={200}
|
||
value={seriesLimit}
|
||
onChange={(e) => setSeriesLimit(Number(e.target.value))}
|
||
className={`${inputCls} mb-3`}
|
||
/>
|
||
|
||
<label className="block text-sm text-gray-400 mb-1">
|
||
Combinators (comma-separated, empty = all)
|
||
</label>
|
||
<input
|
||
value={combinators}
|
||
onChange={(e) => setCombinators(e.target.value)}
|
||
placeholder="nauvis, nauvis-orbit"
|
||
className={`${inputCls} mb-3`}
|
||
/>
|
||
|
||
<div className="flex items-center gap-2 mb-2">
|
||
<label className="text-sm text-gray-400 flex-1">Item filters</label>
|
||
<label className="flex items-center gap-1.5 text-xs text-gray-400 cursor-pointer">
|
||
<input
|
||
type="checkbox"
|
||
checked={useRegex}
|
||
onChange={(e) => setUseRegex(e.target.checked)}
|
||
className="accent-indigo-500"
|
||
/>
|
||
Use regex
|
||
</label>
|
||
</div>
|
||
<input
|
||
value={whitelist}
|
||
onChange={(e) => setWhitelist(e.target.value)}
|
||
placeholder={useRegex ? 'wissen.*|Iron Plate' : 'Iron Plate, copper-plate'}
|
||
className={`${inputCls} mb-1`}
|
||
/>
|
||
<p className="text-xs text-gray-500 mb-2">
|
||
Whitelist — localized names or item keys accepted (empty = all)
|
||
</p>
|
||
<input
|
||
value={blacklist}
|
||
onChange={(e) => setBlacklist(e.target.value)}
|
||
placeholder={useRegex ? 'Holz|stone' : 'Wood, stone'}
|
||
className={`${inputCls} mb-1`}
|
||
/>
|
||
<p className="text-xs text-gray-500 mb-3">
|
||
Blacklist — localized names or item keys accepted
|
||
</p>
|
||
</>
|
||
)}
|
||
|
||
{!isDivider && (
|
||
<>
|
||
<div className="flex gap-3 mb-3">
|
||
<div className="flex-1">
|
||
<label className="block text-sm text-gray-400 mb-1">Y Min (empty = auto)</label>
|
||
<input
|
||
type="number"
|
||
value={yMin}
|
||
onChange={(e) => setYMin(e.target.value)}
|
||
placeholder="auto"
|
||
className={inputCls}
|
||
/>
|
||
</div>
|
||
<div className="flex-1">
|
||
<label className="block text-sm text-gray-400 mb-1">Y Max (empty = auto)</label>
|
||
<input
|
||
type="number"
|
||
value={yMax}
|
||
onChange={(e) => setYMax(e.target.value)}
|
||
placeholder="auto"
|
||
className={inputCls}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<label className="block text-sm text-gray-400 mb-1">Y Scale</label>
|
||
<select
|
||
value={yScale}
|
||
onChange={(e) => setYScale(e.target.value as ChartConfig['y_scale'])}
|
||
className={`${inputCls} mb-3`}
|
||
>
|
||
<option value="linear">Linear</option>
|
||
<option value="log">Symmetric Log (arcsinh)</option>
|
||
</select>
|
||
</>
|
||
)}
|
||
|
||
<div className="flex gap-3 mb-4">
|
||
<div className="flex-1">
|
||
<label className="block text-sm text-gray-400 mb-1">Width (1–6 cols)</label>
|
||
<input
|
||
type="number"
|
||
min={1}
|
||
max={6}
|
||
value={width}
|
||
onChange={(e) => setWidth(Number(e.target.value))}
|
||
className={inputCls}
|
||
/>
|
||
</div>
|
||
<div className="flex-1">
|
||
<label className="block text-sm text-gray-400 mb-1">Height (rows)</label>
|
||
<input
|
||
type="number"
|
||
min={2}
|
||
max={20}
|
||
value={height}
|
||
onChange={(e) => setHeight(Number(e.target.value))}
|
||
className={inputCls}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex justify-end gap-2">
|
||
<button
|
||
onClick={onClose}
|
||
className="px-4 py-1.5 text-sm text-gray-400 hover:text-white rounded hover:bg-gray-700"
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
onClick={handleSave}
|
||
className="px-4 py-1.5 text-sm bg-indigo-600 hover:bg-indigo-500 text-white rounded"
|
||
>
|
||
Save
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|