import { useEffect, useState, type FormEvent } from 'react';
import { HeartPulse, MapPin, RefreshCw, Search, Shield, UserRoundCog, X } from 'lucide-react';
import { useApiResource, useServer } from '../context/server-context';
import { ApiClient } from '../lib/api';
import { useI18n } from '../lib/i18n';
import { hasCapability, type Capability } from '../lib/permissions';
import type { InventorySlot, InventorySnapshot, OperationStatus, PlayerContainer, PlayerSummary } from '../types';
import { Badge, Button, Field, Input, PageHeader, Panel, ResourceState, Select, TableShell, Textarea, Toast } from '../components/ui';
import { ModalPortal } from '../components/modal-portal';
type PlayerAction = 'message' | 'kick' | 'heal' | 'feed' | 'clear_effects' | 'teleport' | 'recall' | 'gamemode' | 'whitelist' | 'ban';
type PlayerView = 'actions' | PlayerContainer;
const PLAYER_ACTIONS: ReadonlyArray<{ id: PlayerAction; label: Parameters<ReturnType<typeof useI18n>['t']>[0]; capability: Capability }> = [
{ id: 'message', label: 'players.message', capability: 'player_message' },
{ id: 'kick', label: 'players.kick', capability: 'moderation_write' },
{ id: 'heal', label: 'players.heal', capability: 'player_safe_write' },
{ id: 'feed', label: 'players.feed', capability: 'player_safe_write' },
{ id: 'clear_effects', label: 'players.clearEffects', capability: 'player_safe_write' },
{ id: 'teleport', label: 'players.teleport', capability: 'player_destructive_write' },
{ id: 'recall', label: 'players.recall', capability: 'player_destructive_write' },
{ id: 'gamemode', label: 'players.gameMode', capability: 'player_safe_write' },
{ id: 'whitelist', label: 'players.whitelist', capability: 'settings_write' },
{ id: 'ban', label: 'players.ban', capability: 'moderation_write' },
];
const ITEM_ID = /^[a-z0-9_.-]+:[a-z0-9_./-]+$/;
function location(player: PlayerSummary): string {
if (!player.location) return '—';
return `${player.location.dimension} · ${Math.round(player.location.x)}, ${Math.round(player.location.y)}, ${Math.round(player.location.z)}`;
}
function defaultDetail(action: PlayerAction): string {
if (action === 'gamemode') return 'survival';
if (action === 'whitelist') return 'add';
return '';
}
export function PlayersPage() {
const { t } = useI18n();
const { api, session } = useServer();
const [searchInput, setSearchInput] = useState('');
const [query, setQuery] = useState('');
const [selected, setSelected] = useState<PlayerSummary>();
const [view, setView] = useState<PlayerView>('actions');
const [action, setAction] = useState<PlayerAction>('message');
const [reason, setReason] = useState('');
const [detail, setDetail] = useState('');
const [serverPreview, setServerPreview] = useState<OperationStatus>();
const [busy, setBusy] = useState(false);
const [toast, setToast] = useState<{ message: string; tone: 'good' | 'danger' }>();
const resource = useApiResource(() => api.players(undefined, query), [query], ['player-changed', 'operation']);
const allowedActions = PLAYER_ACTIONS.filter((entry) => hasCapability(session?.actor, entry.capability));
const canEditInventory = hasCapability(session?.actor, 'inventory_write');
const canManage = allowedActions.length > 0 || canEditInventory;
const resetDialog = () => {
setSelected(undefined);
setView('actions');
setServerPreview(undefined);
setReason('');
setDetail('');
setAction('message');
};
const changeAction = (next: PlayerAction) => {
setAction(next);
setDetail(defaultDetail(next));
setServerPreview(undefined);
};
const submit = async (event: FormEvent) => {
event.preventDefault();
if (!selected || !reason.trim()) return;
setBusy(true);
try {
const operation = await api.playerAction(selected.uuid, action, {
reason: reason.trim(),
value: detail.trim() || undefined,
confirmedTargetUuid: selected.uuid,
previewToken: serverPreview?.previewToken,
});
if (operation.state === 'PREVIEW_REQUIRED' && operation.previewToken) {
setServerPreview(operation);
setToast(undefined);
return;
}
setToast({ message: `${t('operation.queued')} · ${operation.id}`, tone: 'good' });
resetDialog();
resource.reload();
} catch (error) {
setToast({ message: error instanceof Error ? error.message : String(error), tone: 'danger' });
} finally {
setBusy(false);
}
};
return <div className="page">
<PageHeader title={t('players.title')} description={t('players.description')} actions={
<form className="search-box" onSubmit={(event) => { event.preventDefault(); setQuery(searchInput.trim()); }}>
<Search size={16} /><Input aria-label={t('common.search')} value={searchInput} onChange={(event) => setSearchInput(event.target.value)} placeholder={t('common.search')} />
</form>
} />
<Panel>
<ResourceState loading={resource.loading} error={resource.error} empty={resource.data?.items.length === 0} onRetry={resource.reload}>
<TableShell>
<thead><tr><th>{t('players.player')}</th><th>{t('players.role')}</th><th>{t('players.location')}</th><th>{t('players.state')}</th><th><span className="sr-only">{t('players.actions')}</span></th></tr></thead>
<tbody>{resource.data?.items.map((player) => <tr key={player.uuid}>
<td><div className="player-cell"><span className="player-avatar">{player.name.slice(0, 2).toUpperCase()}</span><span><strong>{player.name}</strong><small>{player.uuid}</small></span></div></td>
<td><div className="badge-row"><Badge tone={player.opLevel ? 'info' : 'neutral'}><Shield size={12} />OP {player.opLevel ?? 0}</Badge>{player.roles?.slice(0, 1).map((role) => <Badge key={role}>{role}</Badge>)}</div></td>
<td><span className="location-cell"><MapPin size={14} />{location(player)}</span></td>
<td><Badge tone={player.online ? 'good' : 'neutral'}>{player.online ? t('players.online') : t('players.offline')}{player.pingMs !== undefined ? ` · ${player.pingMs}ms` : ''}</Badge></td>
<td>{canManage ? <Button variant="secondary" onClick={() => setSelected(player)}><UserRoundCog size={15} />{t('players.inspect')}</Button> : <span className="muted">{t('players.readOnly')}</span>}</td>
</tr>)}</tbody>
</TableShell>
</ResourceState>
</Panel>
{selected && <ModalPortal><div className="modal-backdrop" role="presentation" onMouseDown={(event) => { if (event.target === event.currentTarget) resetDialog(); }}>
<section className="modal modal--player" role="dialog" aria-modal="true" aria-labelledby="player-action-title">
<div className="modal__header"><div><span className="page-header__eyebrow">{selected.uuid}</span><h2 id="player-action-title">{t('players.actionTitle')} · {selected.name}</h2></div><button onClick={resetDialog} aria-label={t('common.close')}><X /></button></div>
<div className="player-manage-tabs" role="tablist">
{allowedActions.length > 0 && <button role="tab" aria-selected={view === 'actions'} className={view === 'actions' ? 'is-active' : ''} onClick={() => setView('actions')}>{t('players.actionsTab')}</button>}
{canEditInventory && <button role="tab" aria-selected={view === 'inventory'} className={view === 'inventory' ? 'is-active' : ''} onClick={() => setView('inventory')}>{t('players.inventory')}</button>}
{canEditInventory && <button role="tab" aria-selected={view === 'ender-chest'} className={view === 'ender-chest' ? 'is-active' : ''} onClick={() => setView('ender-chest')}>{t('players.enderChest')}</button>}
</div>
{view === 'actions' && allowedActions.length > 0 ? <form className="form-stack" onSubmit={submit}>
<Field label={t('players.action')}>
<Select value={action} onChange={(event) => changeAction(event.target.value as PlayerAction)}>
{allowedActions.map((entry) => <option key={entry.id} value={entry.id}>{t(entry.label)}</option>)}
</Select>
</Field>
{action === 'message' && <Field label={t('players.messageText')}><Textarea value={detail} onChange={(event) => { setDetail(event.target.value); setServerPreview(undefined); }} rows={3} required /></Field>}
{action === 'teleport' && <Field label={t('players.destination')}><Input value={detail} onChange={(event) => { setDetail(event.target.value); setServerPreview(undefined); }} required /></Field>}
{action === 'gamemode' && <Field label={t('players.gameMode')}><Select value={detail} onChange={(event) => { setDetail(event.target.value); setServerPreview(undefined); }}><option value="survival">survival</option><option value="creative">creative</option><option value="adventure">adventure</option><option value="spectator">spectator</option></Select></Field>}
{action === 'whitelist' && <Field label={t('players.whitelistMode')}><Select value={detail} onChange={(event) => { setDetail(event.target.value); setServerPreview(undefined); }}><option value="add">{t('players.whitelistAdd')}</option><option value="remove">{t('players.whitelistRemove')}</option></Select></Field>}
<Field label={t('common.reason')} hint={t('common.reasonRequired')}><Textarea value={reason} onChange={(event) => { setReason(event.target.value); setServerPreview(undefined); }} rows={3} required /></Field>
{serverPreview && <div className="confirm-box"><HeartPulse size={18} /><div><strong>{t('common.confirm')}</strong><p>{serverPreview.targets?.length ? serverPreview.targets.map((target) => <span key={target.uuid}>{target.name ?? '—'} · {target.uuid}<br /></span>) : <>{selected.name} · {selected.uuid}<br /></>}{serverPreview.expiresAt && <small>{formatPreviewExpiry(serverPreview.expiresAt)}</small>}</p></div></div>}
<div className="modal__actions"><Button type="button" variant="ghost" onClick={resetDialog}>{t('common.cancel')}</Button><Button type="submit" variant={serverPreview ? 'danger' : 'primary'} disabled={busy || !reason.trim()}>{serverPreview ? t('common.confirm') : t('players.submit')}</Button></div>
</form> : canEditInventory && view !== 'actions' ? <InventoryEditor api={api} player={selected} container={view} onToast={setToast} /> : <div className="empty-state"><p>{t('access.detail')}</p></div>}
</section>
</div></ModalPortal>}
{toast && <Toast {...toast} onClose={() => setToast(undefined)} />}
</div>;
}
function InventoryEditor({ api, player, container, onToast }: { api: ApiClient; player: PlayerSummary; container: PlayerContainer; onToast: (toast: { message: string; tone: 'good' | 'danger' } | undefined) => void }) {
const { t } = useI18n();
const [snapshot, setSnapshot] = useState<InventorySnapshot>();
const [selectedSlot, setSelectedSlot] = useState<InventorySlot>();
const [itemId, setItemId] = useState('minecraft:air');
const [count, setCount] = useState('0');
const [reason, setReason] = useState('');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [confirmed, setConfirmed] = useState(false);
const [error, setError] = useState<string>();
const load = async () => {
setLoading(true); setError(undefined); setSelectedSlot(undefined); setConfirmed(false);
try { setSnapshot(await api.playerInventory(player.uuid, container)); }
catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)); }
finally { setLoading(false); }
};
useEffect(() => {
let active = true;
setLoading(true); setError(undefined); setSelectedSlot(undefined); setConfirmed(false);
api.playerInventory(player.uuid, container).then((value) => { if (active) setSnapshot(value); }, (cause: unknown) => { if (active) setError(cause instanceof Error ? cause.message : String(cause)); }).finally(() => { if (active) setLoading(false); });
return () => { active = false; };
}, [api, player.uuid, container]);
const chooseSlot = (slot: InventorySlot) => {
setSelectedSlot(slot); setItemId(slot.itemId); setCount(String(slot.count)); setReason(''); setConfirmed(false); setError(undefined);
};
const parsedCount = Number(count);
const valid = ITEM_ID.test(itemId.trim()) && Number.isInteger(parsedCount) && parsedCount >= 0 && parsedCount <= 127;
const update = async (event: FormEvent) => {
event.preventDefault();
if (!snapshot || !selectedSlot || !valid || !reason.trim() || selectedSlot.opaqueDataPresent) return;
if (!confirmed) { setConfirmed(true); return; }
setSaving(true); setError(undefined);
const after: InventorySlot = { ...selectedSlot, itemId: itemId.trim(), count: parsedCount };
try {
const result = await api.updatePlayerInventory(player.uuid, container, snapshot.revision, after, reason.trim());
setSnapshot({ ...snapshot, revision: result.revision, slots: snapshot.slots.map((slot) => slot.slot === after.slot ? after : slot) });
setSelectedSlot(after); setConfirmed(false); setReason('');
onToast({ message: `${t('players.inventorySaved')} · r${result.revision}`, tone: 'good' });
} catch (cause) {
const message = cause instanceof Error ? cause.message : String(cause);
setError(message); setConfirmed(false); onToast({ message, tone: 'danger' });
} finally { setSaving(false); }
};
if (loading) return <div className="resource-state">{t('players.inventoryLoading')}</div>;
if (!snapshot || error && !selectedSlot) return <div className="resource-state resource-state--error"><div><strong>{t('common.error')}</strong><span>{error}</span></div><Button variant="secondary" onClick={() => void load()}>{t('common.refresh')}</Button></div>;
return <div className="inventory-editor">
<div className="inventory-toolbar"><div><strong>{container === 'inventory' ? t('players.inventory') : t('players.enderChest')}</strong><span>{t('players.inventoryRevision')}: <code>r{snapshot.revision}</code></span></div><Button variant="ghost" onClick={() => void load()}><RefreshCw size={14} />{t('common.refresh')}</Button></div>
<p className="panel-description">{t('players.inventoryHint')}</p>
{snapshot.slots.length === 0 ? <div className="empty-state"><p>{t('players.inventoryEmpty')}</p></div> : <div className="inventory-grid">{[...snapshot.slots].sort((left, right) => left.slot - right.slot).map((slot) => <button key={slot.slot} type="button" className={`inventory-slot${selectedSlot?.slot === slot.slot ? ' is-selected' : ''}${slot.opaqueDataPresent ? ' has-opaque' : ''}`} onClick={() => chooseSlot(slot)} aria-label={`${t('players.slot')} ${slot.slot}: ${slot.itemId} × ${slot.count}`} title={`${slot.itemId} × ${slot.count}`}><span>{slot.slot}</span><strong>{slot.itemId.replace(/^minecraft:/, '')}</strong><small>×{slot.count}</small></button>)}</div>}
{selectedSlot && <form className="inventory-slot-form form-stack" onSubmit={update}>
<div className="inventory-slot-heading"><strong>{t('players.editSlot')} #{selectedSlot.slot}</strong>{selectedSlot.opaqueDataPresent && <Badge tone="warn">opaque</Badge>}</div>
{selectedSlot.opaqueDataPresent && <div className="confirm-box confirm-box--danger"><Shield size={18} /><p>{t('players.opaqueProtected')}</p></div>}
<div className="form-grid">
<Field label={t('players.itemId')}><Input value={itemId} onChange={(event) => { setItemId(event.target.value); setConfirmed(false); }} required pattern="[a-z0-9_.-]+:[a-z0-9_./-]+" disabled={selectedSlot.opaqueDataPresent} /></Field>
<Field label={t('players.count')}><Input type="number" min={0} max={127} step={1} value={count} onChange={(event) => { setCount(event.target.value); setConfirmed(false); }} required disabled={selectedSlot.opaqueDataPresent} /></Field>
</div>
<Field label={t('common.reason')} hint={t('common.reasonRequired')}><Textarea value={reason} onChange={(event) => { setReason(event.target.value); setConfirmed(false); }} rows={2} required disabled={selectedSlot.opaqueDataPresent} /></Field>
{confirmed && <div className="confirm-box"><HeartPulse size={18} /><div><strong>{t('players.previewSlot')}</strong><p>{t('players.slotBefore')}: {selectedSlot.itemId} × {selectedSlot.count}<br />{t('players.slotAfter')}: {itemId.trim()} × {parsedCount}<br />r{snapshot.revision} CAS</p></div></div>}
{error && <div className="form-error" role="alert">{error}</div>}
<div className="modal__actions"><Button type="submit" variant={confirmed ? 'danger' : 'primary'} disabled={saving || !valid || !reason.trim() || selectedSlot.opaqueDataPresent}>{confirmed ? t('players.saveSlot') : t('players.previewSlot')}</Button></div>
</form>}
</div>;
}
function formatPreviewExpiry(value: string): string {
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleTimeString();
}
import { useEffect, useState, type FormEvent } from 'react';
import { HeartPulse, MapPin, RefreshCw, Search, Shield, UserRoundCog, X } from 'lucide-react';
import { useApiResource, useServer } from '../context/server-context';
import { ApiClient } from '../lib/api';
import { useI18n } from '../lib/i18n';
import { hasCapability, type Capability } from '../lib/permissions';
import type { InventorySlot, InventorySnapshot, OperationStatus, PlayerContainer, PlayerSummary } from '../types';
import { Badge, Button, Field, Input, PageHeader, Panel, ResourceState, Select, TableShell, Textarea, Toast } from '../components/ui';
import { ModalPortal } from '../components/modal-portal';
type PlayerAction = 'message' | 'kick' | 'heal' | 'feed' | 'clear_effects' | 'teleport' | 'recall' | 'gamemode' | 'whitelist' | 'ban';
type PlayerView = 'actions' | PlayerContainer;
const PLAYER_ACTIONS: ReadonlyArray<{ id: PlayerAction; label: Parameters<ReturnType<typeof useI18n>['t']>[0]; capability: Capability }> = [
{ id: 'message', label: 'players.message', capability: 'player_message' },
{ id: 'kick', label: 'players.kick', capability: 'moderation_write' },
{ id: 'heal', label: 'players.heal', capability: 'player_safe_write' },
{ id: 'feed', label: 'players.feed', capability: 'player_safe_write' },
{ id: 'clear_effects', label: 'players.clearEffects', capability: 'player_safe_write' },
{ id: 'teleport', label: 'players.teleport', capability: 'player_destructive_write' },
{ id: 'recall', label: 'players.recall', capability: 'player_destructive_write' },
{ id: 'gamemode', label: 'players.gameMode', capability: 'player_safe_write' },
{ id: 'whitelist', label: 'players.whitelist', capability: 'settings_write' },
{ id: 'ban', label: 'players.ban', capability: 'moderation_write' },
];
const ITEM_ID = /^[a-z0-9_.-]+:[a-z0-9_./-]+$/;
function location(player: PlayerSummary): string {
if (!player.location) return '—';
return `${player.location.dimension} · ${Math.round(player.location.x)}, ${Math.round(player.location.y)}, ${Math.round(player.location.z)}`;
}
function defaultDetail(action: PlayerAction): string {
if (action === 'gamemode') return 'survival';
if (action === 'whitelist') return 'add';
return '';
}
export function PlayersPage() {
const { t } = useI18n();
const { api, session } = useServer();
const [searchInput, setSearchInput] = useState('');
const [query, setQuery] = useState('');
const [selected, setSelected] = useState<PlayerSummary>();
const [view, setView] = useState<PlayerView>('actions');
const [action, setAction] = useState<PlayerAction>('message');
const [reason, setReason] = useState('');
const [detail, setDetail] = useState('');
const [serverPreview, setServerPreview] = useState<OperationStatus>();
const [busy, setBusy] = useState(false);
const [toast, setToast] = useState<{ message: string; tone: 'good' | 'danger' }>();
const resource = useApiResource(() => api.players(undefined, query), [query], ['player-changed', 'operation']);
const allowedActions = PLAYER_ACTIONS.filter((entry) => hasCapability(session?.actor, entry.capability));
const canEditInventory = hasCapability(session?.actor, 'inventory_write');
const canManage = allowedActions.length > 0 || canEditInventory;
const resetDialog = () => {
setSelected(undefined);
setView('actions');
setServerPreview(undefined);
setReason('');
setDetail('');
setAction('message');
};
const changeAction = (next: PlayerAction) => {
setAction(next);
setDetail(defaultDetail(next));
setServerPreview(undefined);
};
const submit = async (event: FormEvent) => {
event.preventDefault();
if (!selected || !reason.trim()) return;
setBusy(true);
try {
const operation = await api.playerAction(selected.uuid, action, {
reason: reason.trim(),
value: detail.trim() || undefined,
confirmedTargetUuid: selected.uuid,
previewToken: serverPreview?.previewToken,
});
if (operation.state === 'PREVIEW_REQUIRED' && operation.previewToken) {
setServerPreview(operation);
setToast(undefined);
return;
}
setToast({ message: `${t('operation.queued')} · ${operation.id}`, tone: 'good' });
resetDialog();
resource.reload();
} catch (error) {
setToast({ message: error instanceof Error ? error.message : String(error), tone: 'danger' });
} finally {
setBusy(false);
}
};
return <div className="page">
<PageHeader title={t('players.title')} description={t('players.description')} actions={
<form className="search-box" onSubmit={(event) => { event.preventDefault(); setQuery(searchInput.trim()); }}>
<Search size={16} /><Input aria-label={t('common.search')} value={searchInput} onChange={(event) => setSearchInput(event.target.value)} placeholder={t('common.search')} />
</form>
} />
<Panel>
<ResourceState loading={resource.loading} error={resource.error} empty={resource.data?.items.length === 0} onRetry={resource.reload}>
<TableShell>
<thead><tr><th>{t('players.player')}</th><th>{t('players.role')}</th><th>{t('players.location')}</th><th>{t('players.state')}</th><th><span className="sr-only">{t('players.actions')}</span></th></tr></thead>
<tbody>{resource.data?.items.map((player) => <tr key={player.uuid}>
<td><div className="player-cell"><span className="player-avatar">{player.name.slice(0, 2).toUpperCase()}</span><span><strong>{player.name}</strong><small>{player.uuid}</small></span></div></td>
<td><div className="badge-row"><Badge tone={player.opLevel ? 'info' : 'neutral'}><Shield size={12} />OP {player.opLevel ?? 0}</Badge>{player.roles?.slice(0, 1).map((role) => <Badge key={role}>{role}</Badge>)}</div></td>
<td><span className="location-cell"><MapPin size={14} />{location(player)}</span></td>
<td><Badge tone={player.online ? 'good' : 'neutral'}>{player.online ? t('players.online') : t('players.offline')}{player.pingMs !== undefined ? ` · ${player.pingMs}ms` : ''}</Badge></td>
<td>{canManage ? <Button variant="secondary" onClick={() => setSelected(player)}><UserRoundCog size={15} />{t('players.inspect')}</Button> : <span className="muted">{t('players.readOnly')}</span>}</td>
</tr>)}</tbody>
</TableShell>
</ResourceState>
</Panel>
{selected && <ModalPortal><div className="modal-backdrop" role="presentation" onMouseDown={(event) => { if (event.target === event.currentTarget) resetDialog(); }}>
<section className="modal modal--player" role="dialog" aria-modal="true" aria-labelledby="player-action-title">
<div className="modal__header"><div><span className="page-header__eyebrow">{selected.uuid}</span><h2 id="player-action-title">{t('players.actionTitle')} · {selected.name}</h2></div><button onClick={resetDialog} aria-label={t('common.close')}><X /></button></div>
<div className="player-manage-tabs" role="tablist">
{allowedActions.length > 0 && <button role="tab" aria-selected={view === 'actions'} className={view === 'actions' ? 'is-active' : ''} onClick={() => setView('actions')}>{t('players.actionsTab')}</button>}
{canEditInventory && <button role="tab" aria-selected={view === 'inventory'} className={view === 'inventory' ? 'is-active' : ''} onClick={() => setView('inventory')}>{t('players.inventory')}</button>}
{canEditInventory && <button role="tab" aria-selected={view === 'ender-chest'} className={view === 'ender-chest' ? 'is-active' : ''} onClick={() => setView('ender-chest')}>{t('players.enderChest')}</button>}
</div>
{view === 'actions' && allowedActions.length > 0 ? <form className="form-stack" onSubmit={submit}>
<Field label={t('players.action')}>
<Select value={action} onChange={(event) => changeAction(event.target.value as PlayerAction)}>
{allowedActions.map((entry) => <option key={entry.id} value={entry.id}>{t(entry.label)}</option>)}
</Select>
</Field>
{action === 'message' && <Field label={t('players.messageText')}><Textarea value={detail} onChange={(event) => { setDetail(event.target.value); setServerPreview(undefined); }} rows={3} required /></Field>}
{action === 'teleport' && <Field label={t('players.destination')}><Input value={detail} onChange={(event) => { setDetail(event.target.value); setServerPreview(undefined); }} required /></Field>}
{action === 'gamemode' && <Field label={t('players.gameMode')}><Select value={detail} onChange={(event) => { setDetail(event.target.value); setServerPreview(undefined); }}><option value="survival">survival</option><option value="creative">creative</option><option value="adventure">adventure</option><option value="spectator">spectator</option></Select></Field>}
{action === 'whitelist' && <Field label={t('players.whitelistMode')}><Select value={detail} onChange={(event) => { setDetail(event.target.value); setServerPreview(undefined); }}><option value="add">{t('players.whitelistAdd')}</option><option value="remove">{t('players.whitelistRemove')}</option></Select></Field>}
<Field label={t('common.reason')} hint={t('common.reasonRequired')}><Textarea value={reason} onChange={(event) => { setReason(event.target.value); setServerPreview(undefined); }} rows={3} required /></Field>
{serverPreview && <div className="confirm-box"><HeartPulse size={18} /><div><strong>{t('common.confirm')}</strong><p>{serverPreview.targets?.length ? serverPreview.targets.map((target) => <span key={target.uuid}>{target.name ?? '—'} · {target.uuid}<br /></span>) : <>{selected.name} · {selected.uuid}<br /></>}{serverPreview.expiresAt && <small>{formatPreviewExpiry(serverPreview.expiresAt)}</small>}</p></div></div>}
<div className="modal__actions"><Button type="button" variant="ghost" onClick={resetDialog}>{t('common.cancel')}</Button><Button type="submit" variant={serverPreview ? 'danger' : 'primary'} disabled={busy || !reason.trim()}>{serverPreview ? t('common.confirm') : t('players.submit')}</Button></div>
</form> : canEditInventory && view !== 'actions' ? <InventoryEditor api={api} player={selected} container={view} onToast={setToast} /> : <div className="empty-state"><p>{t('access.detail')}</p></div>}
</section>
</div></ModalPortal>}
{toast && <Toast {...toast} onClose={() => setToast(undefined)} />}
</div>;
}
function InventoryEditor({ api, player, container, onToast }: { api: ApiClient; player: PlayerSummary; container: PlayerContainer; onToast: (toast: { message: string; tone: 'good' | 'danger' } | undefined) => void }) {
const { t } = useI18n();
const [snapshot, setSnapshot] = useState<InventorySnapshot>();
const [selectedSlot, setSelectedSlot] = useState<InventorySlot>();
const [itemId, setItemId] = useState('minecraft:air');
const [count, setCount] = useState('0');
const [reason, setReason] = useState('');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [confirmed, setConfirmed] = useState(false);
const [error, setError] = useState<string>();
const load = async () => {
setLoading(true); setError(undefined); setSelectedSlot(undefined); setConfirmed(false);
try { setSnapshot(await api.playerInventory(player.uuid, container)); }
catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)); }
finally { setLoading(false); }
};
useEffect(() => {
let active = true;
setLoading(true); setError(undefined); setSelectedSlot(undefined); setConfirmed(false);
api.playerInventory(player.uuid, container).then((value) => { if (active) setSnapshot(value); }, (cause: unknown) => { if (active) setError(cause instanceof Error ? cause.message : String(cause)); }).finally(() => { if (active) setLoading(false); });
return () => { active = false; };
}, [api, player.uuid, container]);
const chooseSlot = (slot: InventorySlot) => {
setSelectedSlot(slot); setItemId(slot.itemId); setCount(String(slot.count)); setReason(''); setConfirmed(false); setError(undefined);
};
const parsedCount = Number(count);
const valid = ITEM_ID.test(itemId.trim()) && Number.isInteger(parsedCount) && parsedCount >= 0 && parsedCount <= 127;
const update = async (event: FormEvent) => {
event.preventDefault();
if (!snapshot || !selectedSlot || !valid || !reason.trim() || selectedSlot.opaqueDataPresent) return;
if (!confirmed) { setConfirmed(true); return; }
setSaving(true); setError(undefined);
const after: InventorySlot = { ...selectedSlot, itemId: itemId.trim(), count: parsedCount };
try {
const result = await api.updatePlayerInventory(player.uuid, container, snapshot.revision, after, reason.trim());
setSnapshot({ ...snapshot, revision: result.revision, slots: snapshot.slots.map((slot) => slot.slot === after.slot ? after : slot) });
setSelectedSlot(after); setConfirmed(false); setReason('');
onToast({ message: `${t('players.inventorySaved')} · r${result.revision}`, tone: 'good' });
} catch (cause) {
const message = cause instanceof Error ? cause.message : String(cause);
setError(message); setConfirmed(false); onToast({ message, tone: 'danger' });
} finally { setSaving(false); }
};
if (loading) return <div className="resource-state">{t('players.inventoryLoading')}</div>;
if (!snapshot || error && !selectedSlot) return <div className="resource-state resource-state--error"><div><strong>{t('common.error')}</strong><span>{error}</span></div><Button variant="secondary" onClick={() => void load()}>{t('common.refresh')}</Button></div>;
return <div className="inventory-editor">
<div className="inventory-toolbar"><div><strong>{container === 'inventory' ? t('players.inventory') : t('players.enderChest')}</strong><span>{t('players.inventoryRevision')}: <code>r{snapshot.revision}</code></span></div><Button variant="ghost" onClick={() => void load()}><RefreshCw size={14} />{t('common.refresh')}</Button></div>
<p className="panel-description">{t('players.inventoryHint')}</p>
{snapshot.slots.length === 0 ? <div className="empty-state"><p>{t('players.inventoryEmpty')}</p></div> : <div className="inventory-grid">{[...snapshot.slots].sort((left, right) => left.slot - right.slot).map((slot) => <button key={slot.slot} type="button" className={`inventory-slot${selectedSlot?.slot === slot.slot ? ' is-selected' : ''}${slot.opaqueDataPresent ? ' has-opaque' : ''}`} onClick={() => chooseSlot(slot)} aria-label={`${t('players.slot')} ${slot.slot}: ${slot.itemId} × ${slot.count}`} title={`${slot.itemId} × ${slot.count}`}><span>{slot.slot}</span><strong>{slot.itemId.replace(/^minecraft:/, '')}</strong><small>×{slot.count}</small></button>)}</div>}
{selectedSlot && <form className="inventory-slot-form form-stack" onSubmit={update}>
<div className="inventory-slot-heading"><strong>{t('players.editSlot')} #{selectedSlot.slot}</strong>{selectedSlot.opaqueDataPresent && <Badge tone="warn">opaque</Badge>}</div>
{selectedSlot.opaqueDataPresent && <div className="confirm-box confirm-box--danger"><Shield size={18} /><p>{t('players.opaqueProtected')}</p></div>}
<div className="form-grid">
<Field label={t('players.itemId')}><Input value={itemId} onChange={(event) => { setItemId(event.target.value); setConfirmed(false); }} required pattern="[a-z0-9_.-]+:[a-z0-9_./-]+" disabled={selectedSlot.opaqueDataPresent} /></Field>
<Field label={t('players.count')}><Input type="number" min={0} max={127} step={1} value={count} onChange={(event) => { setCount(event.target.value); setConfirmed(false); }} required disabled={selectedSlot.opaqueDataPresent} /></Field>
</div>
<Field label={t('common.reason')} hint={t('common.reasonRequired')}><Textarea value={reason} onChange={(event) => { setReason(event.target.value); setConfirmed(false); }} rows={2} required disabled={selectedSlot.opaqueDataPresent} /></Field>
{confirmed && <div className="confirm-box"><HeartPulse size={18} /><div><strong>{t('players.previewSlot')}</strong><p>{t('players.slotBefore')}: {selectedSlot.itemId} × {selectedSlot.count}<br />{t('players.slotAfter')}: {itemId.trim()} × {parsedCount}<br />r{snapshot.revision} CAS</p></div></div>}
{error && <div className="form-error" role="alert">{error}</div>}
<div className="modal__actions"><Button type="submit" variant={confirmed ? 'danger' : 'primary'} disabled={saving || !valid || !reason.trim() || selectedSlot.opaqueDataPresent}>{confirmed ? t('players.saveSlot') : t('players.previewSlot')}</Button></div>
</form>}
</div>;
}
function formatPreviewExpiry(value: string): string {
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleTimeString();
}