feat(players): app Players library and circular NPC tokens on scenes
Add userData players/teams, scene npcTokens with hex-inscribed sizing, session scale synced to presentation, and Playwright e2e coverage. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,665 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createPortal, flushSync } from 'react-dom';
|
||||
|
||||
import { ipcChannels } from '../../shared/ipc/contracts';
|
||||
import type { AppPlayer, AppPlayerTeam, PlayerId, PlayerTeamId } from '../../shared/types';
|
||||
import {
|
||||
DEFAULT_PLAYER_IMAGE_OFFSET,
|
||||
DEFAULT_PLAYER_IMAGE_SCALE,
|
||||
DEFAULT_PLAYER_RING_COLOR,
|
||||
} from '../../shared/types/appPlayers';
|
||||
import { getDndApi } from '../shared/dndApi';
|
||||
import { PlayerTokenView } from '../shared/playerToken/PlayerTokenView';
|
||||
import { useAppPlayers } from '../shared/playerToken/useAppPlayers';
|
||||
import { usePlayerImageUrl } from '../shared/playerToken/usePlayerImageUrl';
|
||||
import { Button, Input } from '../shared/ui/controls';
|
||||
|
||||
import styles from './EditorApp.module.css';
|
||||
import {
|
||||
filterMaterialImagePaths,
|
||||
getDroppedFileEntries,
|
||||
pickFirstMaterialImagePath,
|
||||
useFileDropZone,
|
||||
} from './fileDrop';
|
||||
import { useEditorI18n } from './i18n/EditorI18nContext';
|
||||
import playerStyles from './PlayersModals.module.css';
|
||||
|
||||
const PLAYER_DND_MIME = 'application/x-dnd-player-id';
|
||||
|
||||
function PlayerRow({
|
||||
player,
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
player: AppPlayer;
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const url = usePlayerImageUrl(player.id);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`player-row-${player.id}`}
|
||||
className={[playerStyles.playerRow, selected ? playerStyles.playerRowSelected : '']
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.setData(PLAYER_DND_MIME, player.id);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
}}
|
||||
onClick={onSelect}
|
||||
>
|
||||
<PlayerTokenView
|
||||
name={player.name}
|
||||
imageUrl={url}
|
||||
ringColor={player.ringColor}
|
||||
imageOffset={player.imageOffset}
|
||||
imageScale={player.imageScale}
|
||||
sizePx={48}
|
||||
hideName
|
||||
/>
|
||||
<span>{player.name}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function TeamSection({
|
||||
team,
|
||||
players,
|
||||
selectedId,
|
||||
onSelect,
|
||||
onAssign,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: {
|
||||
team: AppPlayerTeam | null;
|
||||
players: AppPlayer[];
|
||||
selectedId: PlayerId | null;
|
||||
onSelect: (id: PlayerId) => void;
|
||||
onAssign: (id: PlayerId, teamId: PlayerTeamId | null) => void;
|
||||
onEdit?: () => void;
|
||||
onDelete?: () => void;
|
||||
}) {
|
||||
const { t } = useEditorI18n();
|
||||
return (
|
||||
<section
|
||||
className={playerStyles.team}
|
||||
onDragOver={(e) => {
|
||||
if (e.dataTransfer.types.includes(PLAYER_DND_MIME)) e.preventDefault();
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
const id = e.dataTransfer.getData(PLAYER_DND_MIME);
|
||||
if (id) onAssign(id as PlayerId, team?.id ?? null);
|
||||
}}
|
||||
>
|
||||
<div className={playerStyles.teamHeader}>
|
||||
{team ? <span className={playerStyles.teamDot} style={{ background: team.color }} /> : null}
|
||||
<span className={playerStyles.teamName}>{team?.name ?? t('players.ungrouped')}</span>
|
||||
{team ? (
|
||||
<details className={playerStyles.teamMenu}>
|
||||
<summary aria-label={t('players.teamMenu')}>⋮</summary>
|
||||
<div className={playerStyles.teamMenuPopup}>
|
||||
<button type="button" onClick={onEdit}>
|
||||
{t('common.edit')}
|
||||
</button>
|
||||
<button type="button" onClick={onDelete}>
|
||||
{t('common.delete')}
|
||||
</button>
|
||||
</div>
|
||||
</details>
|
||||
) : null}
|
||||
</div>
|
||||
<div className={playerStyles.teamBody}>
|
||||
{players.map((player) => (
|
||||
<PlayerRow
|
||||
key={player.id}
|
||||
player={player}
|
||||
selected={player.id === selectedId}
|
||||
onSelect={() => onSelect(player.id)}
|
||||
/>
|
||||
))}
|
||||
{players.length === 0 ? <div className={playerStyles.dropTarget}>{t('players.dropHere')}</div> : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function PlayersManagerModal({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
const { t } = useEditorI18n();
|
||||
const api = getDndApi();
|
||||
const { players, teams } = useAppPlayers();
|
||||
const [query, setQuery] = useState('');
|
||||
const [selectedId, setSelectedId] = useState<PlayerId | null>(null);
|
||||
const [name, setName] = useState('');
|
||||
const [ringColor, setRingColor] = useState(DEFAULT_PLAYER_RING_COLOR);
|
||||
const [imageOffset, setImageOffset] = useState(DEFAULT_PLAYER_IMAGE_OFFSET);
|
||||
const [imageScale, setImageScale] = useState(DEFAULT_PLAYER_IMAGE_SCALE);
|
||||
const [filePath, setFilePath] = useState<string | null>(null);
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [progress, setProgress] = useState({ percent: 0, detail: '' });
|
||||
/** Electron не поддерживает window.prompt — форма команды в сайдбаре. */
|
||||
const [teamForm, setTeamForm] = useState<{
|
||||
id: PlayerTeamId | null;
|
||||
name: string;
|
||||
color: string;
|
||||
} | null>(null);
|
||||
const [pendingDeletePlayer, setPendingDeletePlayer] = useState<AppPlayer | null>(null);
|
||||
const [pendingDeleteTeam, setPendingDeleteTeam] = useState<AppPlayerTeam | null>(null);
|
||||
const appearanceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const selected = players.find((p) => p.id === selectedId) ?? null;
|
||||
const selectedUrl = usePlayerImageUrl(selected?.id);
|
||||
|
||||
const resetModalState = useCallback(() => {
|
||||
if (appearanceTimerRef.current) {
|
||||
clearTimeout(appearanceTimerRef.current);
|
||||
appearanceTimerRef.current = null;
|
||||
}
|
||||
setQuery('');
|
||||
setSelectedId(null);
|
||||
setName('');
|
||||
setRingColor(DEFAULT_PLAYER_RING_COLOR);
|
||||
setImageOffset({ ...DEFAULT_PLAYER_IMAGE_OFFSET });
|
||||
setImageScale(DEFAULT_PLAYER_IMAGE_SCALE);
|
||||
setFilePath(null);
|
||||
setPreviewUrl((prev) => {
|
||||
if (prev?.startsWith('blob:')) URL.revokeObjectURL(prev);
|
||||
return null;
|
||||
});
|
||||
setAdding(false);
|
||||
setSaving(false);
|
||||
setProgress({ percent: 0, detail: '' });
|
||||
setTeamForm(null);
|
||||
setPendingDeletePlayer(null);
|
||||
setPendingDeleteTeam(null);
|
||||
}, []);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
if (saving) return;
|
||||
resetModalState();
|
||||
onClose();
|
||||
}, [onClose, resetModalState, saving]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setSelectedId((current) =>
|
||||
current && players.some((p) => p.id === current) ? current : (players[0]?.id ?? null),
|
||||
);
|
||||
}, [open, players]);
|
||||
|
||||
// Синхронизировать форму только при смене выбранного игрока — не при каждом
|
||||
// players.stateChanged (иначе сбрасывается несохранённый zoom/pan).
|
||||
useEffect(() => {
|
||||
if (adding) return;
|
||||
if (!selectedId) return;
|
||||
const p = players.find((item) => item.id === selectedId);
|
||||
if (!p) return;
|
||||
setName(p.name);
|
||||
setRingColor(p.ringColor);
|
||||
setImageOffset(p.imageOffset);
|
||||
setImageScale(p.imageScale ?? DEFAULT_PLAYER_IMAGE_SCALE);
|
||||
setFilePath(null);
|
||||
setPreviewUrl(null);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- только selectedId / adding
|
||||
}, [adding, selectedId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
return api.on(ipcChannels.players.upsertProgress, (event) => {
|
||||
setProgress({
|
||||
percent: Math.max(0, Math.min(100, Math.round(event.percent))),
|
||||
detail: event.detail?.trim() ?? t('players.savingWait'),
|
||||
});
|
||||
});
|
||||
}, [api, open, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Escape' || saving) return;
|
||||
if (pendingDeletePlayer) {
|
||||
setPendingDeletePlayer(null);
|
||||
return;
|
||||
}
|
||||
if (pendingDeleteTeam) {
|
||||
setPendingDeleteTeam(null);
|
||||
return;
|
||||
}
|
||||
if (teamForm) {
|
||||
setTeamForm(null);
|
||||
return;
|
||||
}
|
||||
handleClose();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [handleClose, open, pendingDeletePlayer, pendingDeleteTeam, saving, teamForm]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
return q ? players.filter((p) => p.name.toLowerCase().includes(q)) : players;
|
||||
}, [players, query]);
|
||||
|
||||
const beginAdd = () => {
|
||||
setAdding(true);
|
||||
setSelectedId(null);
|
||||
setName('');
|
||||
setRingColor(DEFAULT_PLAYER_RING_COLOR);
|
||||
setImageOffset({ ...DEFAULT_PLAYER_IMAGE_OFFSET });
|
||||
setImageScale(DEFAULT_PLAYER_IMAGE_SCALE);
|
||||
setFilePath(null);
|
||||
setPreviewUrl(null);
|
||||
};
|
||||
|
||||
const setPreviewFromPathAndUrl = (path: string, url: string) => {
|
||||
setFilePath(path);
|
||||
setPreviewUrl((prev) => {
|
||||
if (prev?.startsWith('blob:')) URL.revokeObjectURL(prev);
|
||||
return url || null;
|
||||
});
|
||||
};
|
||||
|
||||
const pickImage = async () => {
|
||||
const result = await api.invoke(ipcChannels.players.pickImage, {});
|
||||
if (result.canceled) return;
|
||||
setPreviewFromPathAndUrl(result.filePath, result.previewDataUrl);
|
||||
};
|
||||
|
||||
const applyDroppedImage = (path: string, previewFromFile: string) => {
|
||||
if (!adding && !selected) beginAdd();
|
||||
setPreviewFromPathAndUrl(path, previewFromFile);
|
||||
};
|
||||
|
||||
const drop = useFileDropZone({
|
||||
disabled: saving,
|
||||
filterPaths: filterMaterialImagePaths,
|
||||
onDropPaths: (paths) => {
|
||||
const picked = pickFirstMaterialImagePath(paths);
|
||||
if (!picked) return;
|
||||
applyDroppedImage(picked, '');
|
||||
},
|
||||
});
|
||||
|
||||
const appearanceLatestRef = useRef({
|
||||
selected,
|
||||
adding,
|
||||
saving,
|
||||
ringColor,
|
||||
imageOffset,
|
||||
imageScale,
|
||||
});
|
||||
appearanceLatestRef.current = { selected, adding, saving, ringColor, imageOffset, imageScale };
|
||||
|
||||
const persistAppearance = (patch: {
|
||||
ringColor?: string;
|
||||
imageOffset?: typeof imageOffset;
|
||||
imageScale?: number;
|
||||
}) => {
|
||||
const snap = appearanceLatestRef.current;
|
||||
if (!snap.selected || snap.adding || snap.saving) return;
|
||||
const payload = {
|
||||
id: snap.selected.id,
|
||||
teamId: snap.selected.teamId,
|
||||
name: snap.selected.name,
|
||||
ringColor: patch.ringColor ?? snap.ringColor,
|
||||
imageOffset: patch.imageOffset ?? snap.imageOffset,
|
||||
imageScale: patch.imageScale ?? snap.imageScale,
|
||||
};
|
||||
if (appearanceTimerRef.current) clearTimeout(appearanceTimerRef.current);
|
||||
appearanceTimerRef.current = setTimeout(() => {
|
||||
appearanceTimerRef.current = null;
|
||||
void api.invoke(ipcChannels.players.upsert, payload);
|
||||
}, 180);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (appearanceTimerRef.current) clearTimeout(appearanceTimerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const save = async () => {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed || (selected === null && filePath === null)) return;
|
||||
if (appearanceTimerRef.current) {
|
||||
clearTimeout(appearanceTimerRef.current);
|
||||
appearanceTimerRef.current = null;
|
||||
}
|
||||
flushSync(() => {
|
||||
setSaving(true);
|
||||
setProgress({ percent: 0, detail: t('players.savingWait') });
|
||||
});
|
||||
try {
|
||||
const result = await api.invoke(ipcChannels.players.upsert, {
|
||||
...(selected ? { id: selected.id, teamId: selected.teamId } : {}),
|
||||
name: trimmed,
|
||||
...(filePath ? { filePath } : {}),
|
||||
ringColor,
|
||||
imageOffset,
|
||||
imageScale,
|
||||
});
|
||||
setAdding(false);
|
||||
setSelectedId(result.player.id);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!open) return null;
|
||||
const activeUrl = previewUrl ?? selectedUrl;
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<div className={styles.modalBackdrop} aria-hidden />
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
data-testid="players-modal"
|
||||
className={[styles.modalDialog, playerStyles.dialog].join(' ')}
|
||||
>
|
||||
<div className={styles.modalHeader}>
|
||||
<div className={styles.modalTitle}>{t('players.managerTitle')}</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
onClick={handleClose}
|
||||
className={styles.modalClose}
|
||||
disabled={saving}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className={playerStyles.body}>
|
||||
<aside className={playerStyles.sidebar}>
|
||||
<Input value={query} onChange={setQuery} placeholder={t('players.search')} />
|
||||
<div className={playerStyles.sidebarActions}>
|
||||
<Button variant="primary" data-testid="players-add" onClick={beginAdd}>
|
||||
{t('players.add')}
|
||||
</Button>
|
||||
<Button
|
||||
data-testid="players-add-team"
|
||||
onClick={() =>
|
||||
setTeamForm({
|
||||
id: null,
|
||||
name: '',
|
||||
color: DEFAULT_PLAYER_RING_COLOR,
|
||||
})
|
||||
}
|
||||
>
|
||||
{t('players.addTeam')}
|
||||
</Button>
|
||||
</div>
|
||||
{teamForm ? (
|
||||
<div className={playerStyles.teamForm} data-testid="players-team-form">
|
||||
<label className={playerStyles.teamFormField}>
|
||||
<span>{t('players.teamName')}</span>
|
||||
<Input
|
||||
value={teamForm.name}
|
||||
onChange={(v) => setTeamForm((prev) => (prev ? { ...prev, name: v } : prev))}
|
||||
placeholder={t('players.teamName')}
|
||||
autoFocus
|
||||
/>
|
||||
</label>
|
||||
<label className={playerStyles.teamFormField}>
|
||||
<span>{t('players.teamColor')}</span>
|
||||
<input
|
||||
type="color"
|
||||
value={teamForm.color}
|
||||
onChange={(e) =>
|
||||
setTeamForm((prev) =>
|
||||
prev ? { ...prev, color: e.currentTarget.value } : prev,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<div className={playerStyles.teamFormActions}>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={!teamForm.name.trim()}
|
||||
onClick={() => {
|
||||
const trimmed = teamForm.name.trim();
|
||||
if (!trimmed) return;
|
||||
void api
|
||||
.invoke(ipcChannels.players.upsertTeam, {
|
||||
...(teamForm.id ? { id: teamForm.id } : {}),
|
||||
name: trimmed,
|
||||
color: teamForm.color,
|
||||
})
|
||||
.then(() => setTeamForm(null));
|
||||
}}
|
||||
>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
<Button onClick={() => setTeamForm(null)}>{t('common.cancel')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className={playerStyles.list}>
|
||||
{teams.map((team) => (
|
||||
<TeamSection
|
||||
key={team.id}
|
||||
team={team}
|
||||
players={filtered.filter((p) => p.teamId === team.id)}
|
||||
selectedId={selectedId}
|
||||
onSelect={(id) => {
|
||||
setAdding(false);
|
||||
setSelectedId(id);
|
||||
}}
|
||||
onAssign={(playerId, teamId) =>
|
||||
void api.invoke(ipcChannels.players.assignTeam, { playerId, teamId })
|
||||
}
|
||||
onEdit={() =>
|
||||
setTeamForm({
|
||||
id: team.id,
|
||||
name: team.name,
|
||||
color: team.color,
|
||||
})
|
||||
}
|
||||
onDelete={() => setPendingDeleteTeam(team)}
|
||||
/>
|
||||
))}
|
||||
<TeamSection
|
||||
team={null}
|
||||
players={filtered.filter((p) => p.teamId === null)}
|
||||
selectedId={selectedId}
|
||||
onSelect={(id) => {
|
||||
setAdding(false);
|
||||
setSelectedId(id);
|
||||
}}
|
||||
onAssign={(playerId, teamId) =>
|
||||
void api.invoke(ipcChannels.players.assignTeam, { playerId, teamId })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
<main
|
||||
className={[playerStyles.editor, drop.dragOver ? playerStyles.editorDropOver : '']
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
onDragEnter={drop.onDragEnter}
|
||||
onDragLeave={drop.onDragLeave}
|
||||
onDragOver={drop.onDragOver}
|
||||
onDrop={(e) => {
|
||||
drop.onDrop(e);
|
||||
const entries = getDroppedFileEntries(e);
|
||||
const files = e.dataTransfer?.files;
|
||||
for (let i = 0; i < entries.length; i += 1) {
|
||||
const entry = entries[i]!;
|
||||
if (!pickFirstMaterialImagePath([entry.path])) continue;
|
||||
const file = files?.[i];
|
||||
applyDroppedImage(entry.path, file ? URL.createObjectURL(file) : '');
|
||||
return;
|
||||
}
|
||||
}}
|
||||
>
|
||||
{drop.dragOver ? <div className={styles.dropHintOverlay}>{t('players.dropHint')}</div> : null}
|
||||
{adding || selected ? (
|
||||
<>
|
||||
<PlayerTokenView
|
||||
data-testid="player-token-preview"
|
||||
name={name}
|
||||
imageUrl={activeUrl}
|
||||
ringColor={ringColor}
|
||||
imageOffset={imageOffset}
|
||||
imageScale={imageScale}
|
||||
panEnabled
|
||||
onImageOffsetChange={(next) => {
|
||||
setImageOffset(next);
|
||||
persistAppearance({ imageOffset: next });
|
||||
}}
|
||||
onImageScaleChange={(next) => {
|
||||
setImageScale(next);
|
||||
persistAppearance({ imageScale: next });
|
||||
}}
|
||||
sizePx={220}
|
||||
/>
|
||||
<label className={playerStyles.field}>
|
||||
<span>{t('players.name')}</span>
|
||||
<Input value={name} onChange={setName} placeholder={t('players.namePlaceholder')} />
|
||||
</label>
|
||||
<label className={playerStyles.field}>
|
||||
<span>{t('players.ringColor')}</span>
|
||||
<input
|
||||
type="color"
|
||||
value={ringColor}
|
||||
onChange={(e) => {
|
||||
const next = e.currentTarget.value;
|
||||
setRingColor(next);
|
||||
persistAppearance({ ringColor: next });
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<div className={playerStyles.actions}>
|
||||
<Button data-testid="players-choose-image" onClick={() => void pickImage()}>
|
||||
{t('players.chooseImage')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
data-testid="players-save"
|
||||
disabled={!name.trim() || (!selected && !filePath) || saving}
|
||||
onClick={() => void save()}
|
||||
>
|
||||
{saving ? t('common.saving') : t('common.save')}
|
||||
</Button>
|
||||
{selected ? (
|
||||
<Button onClick={() => setPendingDeletePlayer(selected)}>{t('common.delete')}</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className={styles.muted}>{t('players.selectPrompt')}</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
{pendingDeletePlayer
|
||||
? createPortal(
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
className={styles.modalBackdrop}
|
||||
onClick={() => setPendingDeletePlayer(null)}
|
||||
/>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
data-testid="players-delete-confirm"
|
||||
className={styles.modalDialog}
|
||||
>
|
||||
<div className={styles.modalHeader}>
|
||||
<div className={styles.modalTitle}>{t('players.deleteTitle')}</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
className={styles.modalClose}
|
||||
onClick={() => setPendingDeletePlayer(null)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.muted}>
|
||||
{t('players.deleteConfirm', { name: pendingDeletePlayer.name })}
|
||||
</div>
|
||||
<div className={styles.modalFooter}>
|
||||
<Button onClick={() => setPendingDeletePlayer(null)}>{t('common.cancel')}</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
const id = pendingDeletePlayer.id;
|
||||
setPendingDeletePlayer(null);
|
||||
void api.invoke(ipcChannels.players.delete, { id });
|
||||
}}
|
||||
>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
{pendingDeleteTeam
|
||||
? createPortal(
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
className={styles.modalBackdrop}
|
||||
onClick={() => setPendingDeleteTeam(null)}
|
||||
/>
|
||||
<div role="dialog" aria-modal="true" className={styles.modalDialog}>
|
||||
<div className={styles.modalHeader}>
|
||||
<div className={styles.modalTitle}>{t('players.deleteTeamTitle')}</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
className={styles.modalClose}
|
||||
onClick={() => setPendingDeleteTeam(null)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.muted}>
|
||||
{t('players.deleteTeamConfirm', { name: pendingDeleteTeam.name })}
|
||||
</div>
|
||||
<div className={styles.modalFooter}>
|
||||
<Button onClick={() => setPendingDeleteTeam(null)}>{t('common.cancel')}</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
const id = pendingDeleteTeam.id;
|
||||
setPendingDeleteTeam(null);
|
||||
void api.invoke(ipcChannels.players.deleteTeam, { id });
|
||||
}}
|
||||
>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
{saving ? (
|
||||
<div className={styles.progressOverlay} role="dialog" aria-busy data-testid="players-save-progress">
|
||||
<div className={styles.progressModal}>
|
||||
<div className={styles.progressTitle}>{t('players.savingTitle')}</div>
|
||||
<div className={styles.previewSpinner} aria-hidden />
|
||||
<div className={styles.progressBar}>
|
||||
<div className={styles.progressFill} style={{ width: `${String(progress.percent)}%` }} />
|
||||
</div>
|
||||
<div className={styles.progressMeta}>
|
||||
<div>{progress.detail}</div>
|
||||
<div>{progress.percent}%</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user