import { useState, type FormEvent } from 'react';
import { AlertTriangle, Crosshair, DatabaseZap, Pause, Play, RotateCcw, Search } from 'lucide-react';
import { useApiResource, useServer } from '../context/server-context';
import { useI18n } from '../lib/i18n';
import type { RollbackPreview } from '../types';
import { Badge, Button, FeatureUnavailable, Field, Input, PageHeader, Panel, ResourceState, Select, TableShell, Textarea, Toast, formatDate } from '../components/ui';
interface WorldFilter {
actor?: string;
since?: string;
radius?: number;
action?: string;
dimension?: string;
x?: number;
y?: number;
z?: number;
}
function validNumber(value: string): number | undefined {
if (!value.trim()) return undefined;
const number = Number(value);
return Number.isFinite(number) ? number : undefined;
}
export function RollbackPage() {
const { t, locale } = useI18n();
const { api, features, operations } = useServer();
const enabled = features?.worldTrackingEnabled === true && features?.rollbackEnabled === true;
const [actor, setActor] = useState('');
const [since, setSince] = useState('');
const [radius, setRadius] = useState('');
const [action, setAction] = useState('');
const [dimension, setDimension] = useState('minecraft:overworld');
const [x, setX] = useState('');
const [y, setY] = useState('');
const [z, setZ] = useState('');
const [filter, setFilter] = useState<WorldFilter>({});
const [preview, setPreview] = useState<RollbackPreview>();
const [previewing, setPreviewing] = useState(false);
const [reason, setReason] = useState('');
const [busy, setBusy] = useState(false);
const [operationId, setOperationId] = useState('');
const [toast, setToast] = useState<{ message: string; tone: 'good' | 'danger' }>();
const resource = useApiResource(() => api.worldChanges(undefined, filter), [JSON.stringify(filter)], ['world-change'], enabled);
const collectFilter = (): WorldFilter => {
const parsedDate = since ? new Date(since) : undefined;
return {
actor: actor.trim() || undefined,
since: parsedDate && !Number.isNaN(parsedDate.valueOf()) ? parsedDate.toISOString() : undefined,
radius: validNumber(radius),
action: action || undefined,
dimension: dimension.trim() || undefined,
x: validNumber(x), y: validNumber(y), z: validNumber(z),
};
};
const search = (event: FormEvent) => {
event.preventDefault();
setFilter(collectFilter());
setPreview(undefined);
};
const createPreview = async () => {
const nextFilter = collectFilter();
setFilter(nextFilter);
setPreviewing(true);
try {
setPreview(await api.previewRollback({
from: nextFilter.since,
actorIds: nextFilter.actor ? [nextFilter.actor] : [],
dimensionIds: nextFilter.dimension ? [nextFilter.dimension] : [],
kinds: nextFilter.action ? [nextFilter.action] : [],
centerX: nextFilter.x,
centerZ: nextFilter.z,
radius: nextFilter.radius,
}));
} catch (error) {
setToast({ message: error instanceof Error ? error.message : String(error), tone: 'danger' });
} finally { setPreviewing(false); }
};
const execute = async () => {
if (!preview || !reason.trim()) return;
setBusy(true);
try {
const operation = await api.startRollback(preview.id, preview.confirmationToken, reason.trim());
setOperationId(operation.id);
setToast({ message: `${t('operation.queued')} · ${operation.id}`, tone: 'good' });
setPreview(undefined); setReason('');
} catch (error) {
setToast({ message: error instanceof Error ? error.message : String(error), tone: 'danger' });
} finally { setBusy(false); }
};
const currentOperation = operations.find((item) => item.id === operationId);
const control = async (action: 'pause' | 'resume' | 'cancel' | 'redo') => {
if (!operationId) return; setBusy(true);
try {
if (action === 'pause') await api.pauseRollback(operationId);
else if (action === 'resume') await api.resumeRollback(operationId);
else if (action === 'cancel') await api.cancelRollback(operationId);
else { setPreview(await api.previewRedo(operationId)); setReason(''); }
} catch (error) { setToast({ message: error instanceof Error ? error.message : String(error), tone: 'danger' }); }
finally { setBusy(false); }
};
const disabled = !enabled;
return <div className="page"><PageHeader title={t('rollback.title')} description={t('rollback.description')} />
{disabled && <FeatureUnavailable>{t('rollback.featureOff')}</FeatureUnavailable>}
<div className="rollback-layout">
<Panel title={<><Crosshair size={18} />{t('rollback.filter')}</>}>
<form className="form-stack" onSubmit={search}>
<div className="form-grid"><Field label={t('rollback.actor')}><Input value={actor} onChange={(event) => setActor(event.target.value)} /></Field><Field label={t('rollback.since')}><Input type="datetime-local" value={since} onChange={(event) => setSince(event.target.value)} /></Field></div>
<div className="form-grid"><Field label={t('rollback.dimension')}><Input value={dimension} onChange={(event) => setDimension(event.target.value)} spellCheck={false} /></Field><Field label={t('rollback.radius')}><Input type="number" min="1" max="10000" value={radius} onChange={(event) => setRadius(event.target.value)} /></Field></div>
<Field label={t('rollback.center')}><div className="coordinate-grid"><Input type="number" aria-label="X" placeholder="X" value={x} onChange={(event) => setX(event.target.value)} /><Input type="number" aria-label="Y" placeholder="Y" value={y} onChange={(event) => setY(event.target.value)} /><Input type="number" aria-label="Z" placeholder="Z" value={z} onChange={(event) => setZ(event.target.value)} /></div></Field>
<Field label={t('rollback.action')}><Select value={action} onChange={(event) => setAction(event.target.value)}><option value="">—</option><option value="BLOCK_PLACE">BLOCK_PLACE</option><option value="BLOCK_BREAK">BLOCK_BREAK</option><option value="CONTAINER_CHANGE">CONTAINER_CHANGE</option><option value="EXPLOSION">EXPLOSION</option><option value="ENTITY_SPAWN">ENTITY_SPAWN</option><option value="ENTITY_REMOVE">ENTITY_REMOVE</option><option value="ITEM_PICKUP">ITEM_PICKUP</option></Select></Field>
<div className="button-row"><Button type="submit" variant="secondary" disabled={disabled}><Search size={16} />{t('common.search')}</Button><Button type="button" onClick={() => void createPreview()} disabled={disabled || previewing}><DatabaseZap size={16} />{t('rollback.preview')}</Button></div>
</form>
{preview && <div className="rollback-preview"><div className="rollback-preview__title"><div><span className="page-header__eyebrow">{preview.id}</span><h3>{t('rollback.summary')}</h3></div><Badge tone={preview.gaps || preview.conflicts ? 'warn' : 'good'}>#{preview.watermark}</Badge></div><div className="preview-metrics"><div><strong>{preview.matched}</strong><span>{t('rollback.matched')}</span></div><div><strong>{preview.applicable}</strong><span>{t('rollback.applicable')}</span></div><div><strong>{preview.conflicts}</strong><span>{t('rollback.conflicts')}</span></div><div><strong>{preview.unloadedChunks}</strong><span>{t('rollback.unloaded')}</span></div><div><strong>{preview.gaps}</strong><span>{t('rollback.gaps')}</span></div><div><strong>{preview.incompatible}</strong><span>{t('rollback.incompatible')}</span></div></div>{(preview.gaps > 0 || preview.conflicts > 0) && <div className="confirm-box"><AlertTriangle /><p>{preview.gaps} {t('rollback.gaps')} · {preview.conflicts} {t('rollback.conflicts')}</p></div>}<Field label={t('common.reason')} hint={t('common.reasonRequired')}><Textarea value={reason} onChange={(event) => setReason(event.target.value)} required /></Field><Button variant="danger" disabled={busy || !reason.trim() || preview.applicable === 0} onClick={() => void execute()}><Play size={16} />{t('rollback.execute')}</Button></div>}
{currentOperation && <div className="confirm-box"><RotateCcw /><div><strong>{currentOperation.kind} · {currentOperation.state}</strong><p>{currentOperation.message}</p><div className="button-row">{currentOperation.state === 'RUNNING' && <Button variant="secondary" disabled={busy} onClick={() => void control('pause')}><Pause size={16} />{t('rollback.pause')}</Button>}{currentOperation.state === 'PAUSED' && <Button disabled={busy} onClick={() => void control('resume')}><Play size={16} />{t('rollback.resume')}</Button>}{(currentOperation.state === 'RUNNING' || currentOperation.state === 'PAUSED') && <Button variant="danger" disabled={busy} onClick={() => void control('cancel')}>{t('rollback.cancel')}</Button>}{currentOperation.state === 'SUCCEEDED' && <Button variant="secondary" disabled={busy} onClick={() => void control('redo')}><RotateCcw size={16} />{t('rollback.redo')}</Button>}</div></div></div>}
</Panel>
<Panel title={<><RotateCcw size={18} />{t('rollback.changes')}</>}>
<ResourceState loading={resource.loading} error={resource.error} empty={resource.data?.items.length === 0} onRetry={resource.reload}><TableShell><thead><tr><th>{t('audit.time')}</th><th>{t('audit.actor')}</th><th>{t('audit.action')}</th><th>{t('rollback.resource')}</th><th>{t('rollback.position')}</th></tr></thead><tbody>{resource.data?.items.map((change) => <tr key={change.id}><td>{formatDate(change.at, locale)}</td><td>{change.actor ?? '—'}</td><td><Badge tone="info">{change.action}</Badge></td><td><code>{change.resourceId ?? '—'}</code></td><td><span className="location-cell">{change.dimension}<br />{change.x}, {change.y}, {change.z}</span></td></tr>)}</tbody></TableShell></ResourceState>
</Panel>
</div>{toast && <Toast {...toast} onClose={() => setToast(undefined)} />}</div>;
}
import { useState, type FormEvent } from 'react';
import { AlertTriangle, Crosshair, DatabaseZap, Pause, Play, RotateCcw, Search } from 'lucide-react';
import { useApiResource, useServer } from '../context/server-context';
import { useI18n } from '../lib/i18n';
import type { RollbackPreview } from '../types';
import { Badge, Button, FeatureUnavailable, Field, Input, PageHeader, Panel, ResourceState, Select, TableShell, Textarea, Toast, formatDate } from '../components/ui';
interface WorldFilter {
actor?: string;
since?: string;
radius?: number;
action?: string;
dimension?: string;
x?: number;
y?: number;
z?: number;
}
function validNumber(value: string): number | undefined {
if (!value.trim()) return undefined;
const number = Number(value);
return Number.isFinite(number) ? number : undefined;
}
export function RollbackPage() {
const { t, locale } = useI18n();
const { api, features, operations } = useServer();
const enabled = features?.worldTrackingEnabled === true && features?.rollbackEnabled === true;
const [actor, setActor] = useState('');
const [since, setSince] = useState('');
const [radius, setRadius] = useState('');
const [action, setAction] = useState('');
const [dimension, setDimension] = useState('minecraft:overworld');
const [x, setX] = useState('');
const [y, setY] = useState('');
const [z, setZ] = useState('');
const [filter, setFilter] = useState<WorldFilter>({});
const [preview, setPreview] = useState<RollbackPreview>();
const [previewing, setPreviewing] = useState(false);
const [reason, setReason] = useState('');
const [busy, setBusy] = useState(false);
const [operationId, setOperationId] = useState('');
const [toast, setToast] = useState<{ message: string; tone: 'good' | 'danger' }>();
const resource = useApiResource(() => api.worldChanges(undefined, filter), [JSON.stringify(filter)], ['world-change'], enabled);
const collectFilter = (): WorldFilter => {
const parsedDate = since ? new Date(since) : undefined;
return {
actor: actor.trim() || undefined,
since: parsedDate && !Number.isNaN(parsedDate.valueOf()) ? parsedDate.toISOString() : undefined,
radius: validNumber(radius),
action: action || undefined,
dimension: dimension.trim() || undefined,
x: validNumber(x), y: validNumber(y), z: validNumber(z),
};
};
const search = (event: FormEvent) => {
event.preventDefault();
setFilter(collectFilter());
setPreview(undefined);
};
const createPreview = async () => {
const nextFilter = collectFilter();
setFilter(nextFilter);
setPreviewing(true);
try {
setPreview(await api.previewRollback({
from: nextFilter.since,
actorIds: nextFilter.actor ? [nextFilter.actor] : [],
dimensionIds: nextFilter.dimension ? [nextFilter.dimension] : [],
kinds: nextFilter.action ? [nextFilter.action] : [],
centerX: nextFilter.x,
centerZ: nextFilter.z,
radius: nextFilter.radius,
}));
} catch (error) {
setToast({ message: error instanceof Error ? error.message : String(error), tone: 'danger' });
} finally { setPreviewing(false); }
};
const execute = async () => {
if (!preview || !reason.trim()) return;
setBusy(true);
try {
const operation = await api.startRollback(preview.id, preview.confirmationToken, reason.trim());
setOperationId(operation.id);
setToast({ message: `${t('operation.queued')} · ${operation.id}`, tone: 'good' });
setPreview(undefined); setReason('');
} catch (error) {
setToast({ message: error instanceof Error ? error.message : String(error), tone: 'danger' });
} finally { setBusy(false); }
};
const currentOperation = operations.find((item) => item.id === operationId);
const control = async (action: 'pause' | 'resume' | 'cancel' | 'redo') => {
if (!operationId) return; setBusy(true);
try {
if (action === 'pause') await api.pauseRollback(operationId);
else if (action === 'resume') await api.resumeRollback(operationId);
else if (action === 'cancel') await api.cancelRollback(operationId);
else { setPreview(await api.previewRedo(operationId)); setReason(''); }
} catch (error) { setToast({ message: error instanceof Error ? error.message : String(error), tone: 'danger' }); }
finally { setBusy(false); }
};
const disabled = !enabled;
return <div className="page"><PageHeader title={t('rollback.title')} description={t('rollback.description')} />
{disabled && <FeatureUnavailable>{t('rollback.featureOff')}</FeatureUnavailable>}
<div className="rollback-layout">
<Panel title={<><Crosshair size={18} />{t('rollback.filter')}</>}>
<form className="form-stack" onSubmit={search}>
<div className="form-grid"><Field label={t('rollback.actor')}><Input value={actor} onChange={(event) => setActor(event.target.value)} /></Field><Field label={t('rollback.since')}><Input type="datetime-local" value={since} onChange={(event) => setSince(event.target.value)} /></Field></div>
<div className="form-grid"><Field label={t('rollback.dimension')}><Input value={dimension} onChange={(event) => setDimension(event.target.value)} spellCheck={false} /></Field><Field label={t('rollback.radius')}><Input type="number" min="1" max="10000" value={radius} onChange={(event) => setRadius(event.target.value)} /></Field></div>
<Field label={t('rollback.center')}><div className="coordinate-grid"><Input type="number" aria-label="X" placeholder="X" value={x} onChange={(event) => setX(event.target.value)} /><Input type="number" aria-label="Y" placeholder="Y" value={y} onChange={(event) => setY(event.target.value)} /><Input type="number" aria-label="Z" placeholder="Z" value={z} onChange={(event) => setZ(event.target.value)} /></div></Field>
<Field label={t('rollback.action')}><Select value={action} onChange={(event) => setAction(event.target.value)}><option value="">—</option><option value="BLOCK_PLACE">BLOCK_PLACE</option><option value="BLOCK_BREAK">BLOCK_BREAK</option><option value="CONTAINER_CHANGE">CONTAINER_CHANGE</option><option value="EXPLOSION">EXPLOSION</option><option value="ENTITY_SPAWN">ENTITY_SPAWN</option><option value="ENTITY_REMOVE">ENTITY_REMOVE</option><option value="ITEM_PICKUP">ITEM_PICKUP</option></Select></Field>
<div className="button-row"><Button type="submit" variant="secondary" disabled={disabled}><Search size={16} />{t('common.search')}</Button><Button type="button" onClick={() => void createPreview()} disabled={disabled || previewing}><DatabaseZap size={16} />{t('rollback.preview')}</Button></div>
</form>
{preview && <div className="rollback-preview"><div className="rollback-preview__title"><div><span className="page-header__eyebrow">{preview.id}</span><h3>{t('rollback.summary')}</h3></div><Badge tone={preview.gaps || preview.conflicts ? 'warn' : 'good'}>#{preview.watermark}</Badge></div><div className="preview-metrics"><div><strong>{preview.matched}</strong><span>{t('rollback.matched')}</span></div><div><strong>{preview.applicable}</strong><span>{t('rollback.applicable')}</span></div><div><strong>{preview.conflicts}</strong><span>{t('rollback.conflicts')}</span></div><div><strong>{preview.unloadedChunks}</strong><span>{t('rollback.unloaded')}</span></div><div><strong>{preview.gaps}</strong><span>{t('rollback.gaps')}</span></div><div><strong>{preview.incompatible}</strong><span>{t('rollback.incompatible')}</span></div></div>{(preview.gaps > 0 || preview.conflicts > 0) && <div className="confirm-box"><AlertTriangle /><p>{preview.gaps} {t('rollback.gaps')} · {preview.conflicts} {t('rollback.conflicts')}</p></div>}<Field label={t('common.reason')} hint={t('common.reasonRequired')}><Textarea value={reason} onChange={(event) => setReason(event.target.value)} required /></Field><Button variant="danger" disabled={busy || !reason.trim() || preview.applicable === 0} onClick={() => void execute()}><Play size={16} />{t('rollback.execute')}</Button></div>}
{currentOperation && <div className="confirm-box"><RotateCcw /><div><strong>{currentOperation.kind} · {currentOperation.state}</strong><p>{currentOperation.message}</p><div className="button-row">{currentOperation.state === 'RUNNING' && <Button variant="secondary" disabled={busy} onClick={() => void control('pause')}><Pause size={16} />{t('rollback.pause')}</Button>}{currentOperation.state === 'PAUSED' && <Button disabled={busy} onClick={() => void control('resume')}><Play size={16} />{t('rollback.resume')}</Button>}{(currentOperation.state === 'RUNNING' || currentOperation.state === 'PAUSED') && <Button variant="danger" disabled={busy} onClick={() => void control('cancel')}>{t('rollback.cancel')}</Button>}{currentOperation.state === 'SUCCEEDED' && <Button variant="secondary" disabled={busy} onClick={() => void control('redo')}><RotateCcw size={16} />{t('rollback.redo')}</Button>}</div></div></div>}
</Panel>
<Panel title={<><RotateCcw size={18} />{t('rollback.changes')}</>}>
<ResourceState loading={resource.loading} error={resource.error} empty={resource.data?.items.length === 0} onRetry={resource.reload}><TableShell><thead><tr><th>{t('audit.time')}</th><th>{t('audit.actor')}</th><th>{t('audit.action')}</th><th>{t('rollback.resource')}</th><th>{t('rollback.position')}</th></tr></thead><tbody>{resource.data?.items.map((change) => <tr key={change.id}><td>{formatDate(change.at, locale)}</td><td>{change.actor ?? '—'}</td><td><Badge tone="info">{change.action}</Badge></td><td><code>{change.resourceId ?? '—'}</code></td><td><span className="location-cell">{change.dimension}<br />{change.x}, {change.y}, {change.z}</span></td></tr>)}</tbody></TableShell></ResourceState>
</Panel>
</div>{toast && <Toast {...toast} onClose={() => setToast(undefined)} />}</div>;
}