|
|
|
@@ -1085,6 +1085,17 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
const participants = encounter.participants || [];
|
|
|
|
|
const [editingField, setEditingField] = useState(null);
|
|
|
|
|
const [editingId, setEditingId] = useState(null);
|
|
|
|
|
const blurTimeoutRef = useRef(null);
|
|
|
|
|
const handleFieldFocus = (id, label) => {
|
|
|
|
|
if (blurTimeoutRef.current) { clearTimeout(blurTimeoutRef.current); blurTimeoutRef.current = null; }
|
|
|
|
|
setEditingId(id);
|
|
|
|
|
setEditingField(label);
|
|
|
|
|
};
|
|
|
|
|
const handleFieldBlur = () => {
|
|
|
|
|
blurTimeoutRef.current = setTimeout(() => { setEditingField(null); setEditingId(null); }, 150);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (participantType === 'character' && selectedCharacterId) {
|
|
|
|
@@ -1283,6 +1294,53 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleInlineCurrentHp = async (participantId, value) => {
|
|
|
|
|
let n = parseInt(value, 10);
|
|
|
|
|
if (isNaN(n)) return;
|
|
|
|
|
const p = participants.find(pp => pp.id === participantId);
|
|
|
|
|
if (!p || n === p.currentHp) return;
|
|
|
|
|
try {
|
|
|
|
|
const isGeneric = (encounter.ruleset === 'generic');
|
|
|
|
|
let patch;
|
|
|
|
|
if (!isGeneric && n < 0) n = 0;
|
|
|
|
|
if (isGeneric) {
|
|
|
|
|
patch = { currentHp: n, status: n > 0 ? 'conscious' : 'down' };
|
|
|
|
|
} else {
|
|
|
|
|
patch = { currentHp: n, deathSaveSuccesses: 0, deathSaveFailures: 0 };
|
|
|
|
|
if (n > 0) { patch.status = 'conscious'; }
|
|
|
|
|
else if (p.type === 'monster') { patch.status = 'dead'; patch.isActive = false; }
|
|
|
|
|
else { patch.status = 'dying'; }
|
|
|
|
|
}
|
|
|
|
|
await updateParticipant(encounter, participantId, patch, buildCtx(encounterPath));
|
|
|
|
|
} catch (err) {
|
|
|
|
|
showToast('Failed to update HP.');
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleInlineMaxHp = async (participantId, value) => {
|
|
|
|
|
const n = parseInt(value, 10);
|
|
|
|
|
if (isNaN(n)) return;
|
|
|
|
|
const p = participants.find(pp => pp.id === participantId);
|
|
|
|
|
if (!p || n === p.maxHp || n <= 0) return;
|
|
|
|
|
try {
|
|
|
|
|
await updateParticipant(encounter, participantId, { maxHp: n }, buildCtx(encounterPath));
|
|
|
|
|
} catch (err) {
|
|
|
|
|
showToast('Failed to update Max HP.');
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleInlineAc = async (participantId, value) => {
|
|
|
|
|
const n = parseInt(value, 10);
|
|
|
|
|
if (isNaN(n)) return;
|
|
|
|
|
const p = participants.find(pp => pp.id === participantId);
|
|
|
|
|
if (!p || n === p.ac) return;
|
|
|
|
|
try {
|
|
|
|
|
await updateParticipant(encounter, participantId, { ac: n }, buildCtx(encounterPath));
|
|
|
|
|
} catch (err) {
|
|
|
|
|
showToast('Failed to update AC.');
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleDeathSaveChange = async (participantId, outcome) => {
|
|
|
|
|
if (!db) return;
|
|
|
|
|
try {
|
|
|
|
@@ -1602,6 +1660,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
|
|
|
|
|
|
|
|
|
{participants.length === 0 && <p className="text-sm text-stone-400">No participants added yet.</p>}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
<ul className="space-y-2">
|
|
|
|
|
{sortedParticipants.map((p) => {
|
|
|
|
|
const isCurrentTurn = encounter.isStarted && p.id === encounter.currentTurnParticipantId;
|
|
|
|
@@ -1628,8 +1687,15 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
|
|
|
|
onDragOver={isDraggable ? handleDragOver : undefined}
|
|
|
|
|
onDrop={isDraggable ? (e) => handleDrop(e, p.id) : undefined}
|
|
|
|
|
onDragEnd={() => setDraggedItemId(null)}
|
|
|
|
|
className={`p-3 rounded-md flex flex-col sm:flex-row justify-between items-start sm:items-center gap-2 transition-all duration-300 ${bgColor} ${isCurrentTurn && !encounter.isPaused ? 'ring-2 ring-green-300 shadow-lg' : ''} ${!p.isActive ? 'opacity-35 grayscale' : (isDead ? 'opacity-90 brightness-75 saturate-125' : '')} ${isDraggable ? 'cursor-grab' : ''} ${draggedItemId === p.id ? 'opacity-50 ring-2 ring-yellow-400' : ''}`}
|
|
|
|
|
className={`relative p-3 rounded-md flex flex-col sm:flex-row justify-between items-start sm:items-center gap-2 transition-all duration-300 ${bgColor} ${isCurrentTurn && !encounter.isPaused ? 'ring-2 ring-green-300 shadow-lg' : ''} ${!p.isActive ? 'opacity-35 grayscale' : (isDead ? 'opacity-90 brightness-75 saturate-125' : '')} ${isDraggable ? 'cursor-grab' : ''} ${draggedItemId === p.id ? 'opacity-50 ring-2 ring-yellow-400' : ''} ${editingId === p.id ? 'ring-2 ring-amber-400' : ''}`}
|
|
|
|
|
>
|
|
|
|
|
{editingId === p.id && (
|
|
|
|
|
<span className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center">
|
|
|
|
|
<span className="px-4 py-1 bg-amber-600 rounded-full text-white text-sm font-bold shadow-lg">
|
|
|
|
|
✎ Editing {editingField}
|
|
|
|
|
</span>
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
<div className="flex-1 flex items-start sm:items-center">
|
|
|
|
|
{isDraggable && (
|
|
|
|
|
<ChevronsUpDown
|
|
|
|
@@ -1645,7 +1711,16 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
|
|
|
|
{p.ac != null && (
|
|
|
|
|
<span className="ml-2 inline-flex items-baseline gap-1 px-2 py-0.5 bg-sky-900 border border-sky-500 rounded">
|
|
|
|
|
<span className="text-[10px] text-sky-300 font-semibold tracking-wide">AC</span>
|
|
|
|
|
<span className="text-sky-100 text-lg font-bold leading-none">{p.ac}</span>
|
|
|
|
|
<input
|
|
|
|
|
type="number"
|
|
|
|
|
defaultValue={p.ac}
|
|
|
|
|
key={`ac-${p.ac}`}
|
|
|
|
|
onFocus={(e) => { e.target.select(); handleFieldFocus(p.id, 'AC'); }}
|
|
|
|
|
onBlur={(e) => { if (e.target.value !== String(p.ac)) handleInlineAc(p.id, e.target.value); handleFieldBlur(); }}
|
|
|
|
|
onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur(); if (e.key === 'Escape') e.target.value = p.ac; }}
|
|
|
|
|
className="w-6 bg-transparent text-sky-100 text-lg font-bold leading-none text-center focus:outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
|
|
|
|
aria-label={`AC for ${p.name}`}
|
|
|
|
|
/>
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
{isCurrentTurn && !encounter.isPaused && (
|
|
|
|
@@ -1665,19 +1740,42 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
|
|
|
|
type="number"
|
|
|
|
|
id={`init-${p.id}`}
|
|
|
|
|
defaultValue={p.initiative}
|
|
|
|
|
key={p.initiative}
|
|
|
|
|
key={`init-${p.initiative}`}
|
|
|
|
|
min="0"
|
|
|
|
|
max="99"
|
|
|
|
|
disabled={encounter.isStarted && !encounter.isPaused}
|
|
|
|
|
onChange={(e) => { if (e.target.value.length > 2) e.target.value = e.target.value.slice(0, 2); }}
|
|
|
|
|
onFocus={(e) => e.target.select()}
|
|
|
|
|
onBlur={(e) => { if (e.target.value !== String(p.initiative)) handleInlineInitiative(p.id, e.target.value); }}
|
|
|
|
|
onFocus={(e) => { e.target.select(); handleFieldFocus(p.id, 'Initiative'); }}
|
|
|
|
|
onBlur={(e) => { if (e.target.value !== String(p.initiative)) handleInlineInitiative(p.id, e.target.value); handleFieldBlur(); }}
|
|
|
|
|
onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur(); }}
|
|
|
|
|
className="w-8 px-1 py-0.5 bg-stone-800 border border-stone-700 rounded-md shadow-sm text-white text-sm text-center focus:outline-none focus:ring-1 focus:ring-amber-600 focus:border-amber-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
|
|
|
className="w-6 bg-transparent text-white text-sm text-center border-b border-transparent hover:border-stone-500 focus:border-amber-500 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
|
|
|
|
aria-label={`Initiative for ${p.name}`}
|
|
|
|
|
/>
|
|
|
|
|
</span>
|
|
|
|
|
<span>HP: {p.currentHp}/{p.maxHp}</span>
|
|
|
|
|
<span className="inline-flex items-baseline">
|
|
|
|
|
<input
|
|
|
|
|
type="number"
|
|
|
|
|
id={`hp-${p.id}`}
|
|
|
|
|
defaultValue={p.currentHp}
|
|
|
|
|
key={`hp-${p.currentHp}`}
|
|
|
|
|
onFocus={(e) => { e.target.select(); handleFieldFocus(p.id, 'HP'); }}
|
|
|
|
|
onBlur={(e) => { if (e.target.value !== String(p.currentHp)) handleInlineCurrentHp(p.id, e.target.value); handleFieldBlur(); }}
|
|
|
|
|
onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur(); if (e.key === 'Escape') e.target.value = p.currentHp; }}
|
|
|
|
|
className="w-8 bg-transparent text-white text-sm text-center border-b border-transparent hover:border-stone-500 focus:border-red-500 focus:outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
|
|
|
|
aria-label={`Current HP for ${p.name}`}
|
|
|
|
|
/>
|
|
|
|
|
<span className="text-stone-500 text-sm">/</span>
|
|
|
|
|
<input
|
|
|
|
|
type="number"
|
|
|
|
|
defaultValue={p.maxHp}
|
|
|
|
|
key={`maxhp-${p.maxHp}`}
|
|
|
|
|
onFocus={(e) => { e.target.select(); handleFieldFocus(p.id, 'Max HP'); }}
|
|
|
|
|
onBlur={(e) => { if (e.target.value !== String(p.maxHp)) handleInlineMaxHp(p.id, e.target.value); handleFieldBlur(); }}
|
|
|
|
|
onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur(); if (e.key === 'Escape') e.target.value = p.maxHp; }}
|
|
|
|
|
className="w-8 bg-transparent text-stone-400 text-sm text-center border-b border-transparent hover:border-stone-500 focus:border-red-500 focus:outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
|
|
|
|
aria-label={`Max HP for ${p.name}`}
|
|
|
|
|
/>
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{participantStatus === 'dead' && (
|
|
|
|
|