12 Commits
Author SHA1 Message Date
david raistrick 907484fc7f Character drag reorder via players array
Character list items draggable (ChevronsUpDown handle). Drop reorders
players array on campaign doc (single updateDoc). Array order = display
order.
2026-07-07 15:40:58 -04:00
david raistrick 9dbbb88730 Campaign drag reorder: fix sort in fetchDetails path
fetchDetails (db active path) sorted by createdAt, ignored order field.
Drag persisted but UI didn't re-sort. Now both paths sort by order
(fallback createdAt).
2026-07-07 15:37:04 -04:00
david raistrick 1078062b53 Campaign drag reorder via order field
Campaign cards draggable (ChevronsUpDown handle in header). Drop reorders,
batch updates order field on affected campaign docs. Sort by order (fallback
createdAt). New campaign gets order = max+1.
2026-07-07 15:24:12 -04:00
david raistrick 94126567e7 Encounter drag reorder via order field
Encounter cards draggable (ChevronsUpDown handle). Drop reorders, batch
updates order field on affected encounter docs. Sort by order (fallback
createdAt). New encounter gets order = max+1.
2026-07-07 15:18:08 -04:00
david raistrick f3ed971592 Merge remote-tracking branch 'upstream/main' into feat/generic-ruleset 2026-07-07 14:53:42 -04:00
david raistrick 95db6fdfdc Character section rollup: clickable title, count, persisted collapse
Match campaigns rollup pattern. Title button toggles collapse (chevron
left, like campaigns). Character count in header. Collapse state persisted
to localStorage key ttrpg.charactersCollapsed.
2026-07-07 14:51:45 -04:00
david raistrick a5b6d61e83 Generic ruleset UI + campaign/encounter tags + switch-during-combat fix
Campaign + encounter cards: ruleset tag (5e/GEN), create date visible.
CreateEncounterForm keyed by campaignId so default ruleset syncs on campaign switch.

Campaign switch during active combat:
- encounterStartedRef (unpaused) blocks switch + toast
- encounterActiveRef (started paused-or-not) gates display-follow effect
- manualSelectRef tracks user clicks; external display change clears it
  (BUG-12 follow still works for replay/other-DM)
- Prevents revert race when EncounterManager unmounts and refs go false

UI:
- campaign card: ruleset tag bottom-right, opposite delete
- encounter card: tag inline title, date left of participants count
- EncounterManager fetches campaignDoc for default ruleset inheritance

Tests green: app 100, shared 186, server 40.
2026-07-07 14:28:32 -04:00
robertandClaude Opus 4.8 76f5dc09ab Trigger Portainer stack webhooks after image push
Adds a main-only Redeploy stage that POSTs to two Portainer stack
webhooks (re-pull + redeploy) after a successful push. URLs are read
from Secret-text credentials so they stay out of the repo and logs.
Gated by the REDEPLOY_PORTAINER parameter.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 13:57:47 -04:00
david raistrick c2bb5cca29 Generic (non-5e) ruleset mode: negative HP, down status, mark dead/revive
New campaign/encounter ruleset toggle: 5e (default, unchanged) vs generic.

Generic mode:
- No death saves (deathSave/stabilize throw)
- Negative HP allowed (no clamp at 0)
- <=0 HP = status 'down' (no pips, no unconscious auto-condition)
- Monster death = dead + inactive (same as 5e)
- markDead button: DM sets dead manually (both modes)
- revive: dead->conscious (generic), dead->stable (5e)

5e mode: zero behavior change.

shared/turn.js:
- applyHpChangeGeneric: negative HP, down status, no death-save logic
- markDead: status dead, monster auto-inactive
- reviveParticipant: ruleset-aware (generic=conscious, 5e=stable)
- deathSave/stabilize: throw in generic
- expandUndo: mark_dead case added

UI (src/App.js):
- CreateCampaignForm + CreateEncounterForm: ruleset radio toggle
- handleCreateCampaign/handleCreateEncounter: store ruleset field
- EncounterManager: fetch campaignDoc for default ruleset inheritance
- DM participant: down/dead labels, Mark Dead button (generic), Revive both
- death-save pips/buttons gated 5e-only
- DisplayView: down label, generic status derivation

Tests: 15 generic cases (turn.generic.test.js). 186 shared total.
2026-07-07 13:36:16 -04:00
robertandClaude Opus 4.8 7955bed4a9 Add Jenkins CI and Portainer stack for image deployment
Jenkinsfile builds and pushes both the firebase (nginx/static) and
sqlite (caddy+node) images to thinkserver:5000, guarded to main only.
docker/portainer-stack.yml deploys the prebuilt sqlite image from the
registry behind the external TLS-terminating nginx.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 12:43:46 -04:00
robert 66f3cc1380 Adding Jenkinsfile to simplify my deployment. 2026-07-07 12:13:16 -04:00
robert 19de9fcf75 Merge pull request 'Player display animations, death visual, upstream merge' (#5) from single-source into main
Reviewed-on: #5
2026-07-07 11:21:23 -04:00
6 changed files with 742 additions and 46 deletions
Vendored
+172
View File
@@ -0,0 +1,172 @@
// Jenkinsfile — build + publish both images to the thinkserver:5000 registry.
//
// ttrpg-tracker-firebase : root Dockerfile, nginx serving the static SPA,
// Firebase storage (config baked in at build time).
// ttrpg-tracker-sqlite : docker/Dockerfile, Caddy + Node, server/SQLite
// storage (REACT_APP_STORAGE=server).
//
// Requirements on the Jenkins agent:
// * Docker CLI available, agent user in the docker group (or DooD socket).
// * thinkserver:5000 reachable and trusted by the Docker daemon. If it is a
// plain-HTTP registry, add it to /etc/docker/daemon.json on the AGENT host:
// { "insecure-registries": ["thinkserver:5000"] } (then restart docker)
//
// Jenkins credentials this pipeline expects:
// * ttrpg-firebase-env-local (Secret file) — the .env.local contents with
// REACT_APP_FIREBASE_* values. Baked into the firebase image at build time.
// * thinkserver-registry (Username/pw) — OPTIONAL. Only needed if the
// registry requires auth; the login stage is guarded below.
pipeline {
agent any
options {
timestamps()
disableConcurrentBuilds()
timeout(time: 30, unit: 'MINUTES')
}
parameters {
string(name: 'TRACKER_APP_ID',
defaultValue: 'ttrpg-initiative-tracker-default',
description: 'Firestore/app namespace baked into the SQLite image (REACT_APP_TRACKER_APP_ID).')
booleanParam(name: 'PUSH_LATEST', defaultValue: true,
description: 'Also tag and push :latest in addition to the commit-SHA tag.')
booleanParam(name: 'REGISTRY_AUTH', defaultValue: false,
description: 'Enable docker login using the thinkserver-registry credential.')
booleanParam(name: 'REDEPLOY_PORTAINER', defaultValue: true,
description: 'After pushing, POST to the Portainer stack webhooks to re-pull and redeploy.')
}
environment {
REGISTRY = 'thinkserver:5000'
FIREBASE_IMAGE = "${REGISTRY}/ttrpg-tracker-firebase"
SQLITE_IMAGE = "${REGISTRY}/ttrpg-tracker-sqlite"
DOCKER_BUILDKIT = '1'
}
stages {
stage('Checkout') {
steps {
checkout scm
script {
env.SHORT_SHA = sh(returnStdout: true, script: 'git rev-parse --short=8 HEAD').trim()
// Only build/push on main. BRANCH_NAME is set by Multibranch jobs; it
// is unset in a plain "Pipeline from SCM" job, which already only ever
// checks out main, so treat unset as main too.
env.IS_MAIN = (env.BRANCH_NAME == null || env.BRANCH_NAME == 'main').toString()
if (env.IS_MAIN != 'true') {
echo "Branch '${env.BRANCH_NAME}' is not main — skipping image build/push."
} else {
echo "Building tag ${env.SHORT_SHA} (build #${env.BUILD_NUMBER})"
}
}
}
}
stage('Registry login') {
when { allOf { expression { env.IS_MAIN == 'true' }; expression { return params.REGISTRY_AUTH } } }
steps {
withCredentials([usernamePassword(credentialsId: 'thinkserver-registry',
usernameVariable: 'REG_USER',
passwordVariable: 'REG_PASS')]) {
sh 'echo "$REG_PASS" | docker login "$REGISTRY" -u "$REG_USER" --password-stdin'
}
}
}
stage('Build firebase image') {
when { expression { env.IS_MAIN == 'true' } }
steps {
// Firebase config is compiled into the static bundle, so it must exist
// at build time. Inject .env.local from the Jenkins secret file, then
// the root Dockerfile's `COPY .env.local .env` picks it up.
withCredentials([file(credentialsId: 'ttrpg-firebase-env-local', variable: 'FIREBASE_ENV')]) {
sh '''
cp "$FIREBASE_ENV" .env.local
docker build \
-f Dockerfile \
-t "$FIREBASE_IMAGE:$SHORT_SHA" \
.
'''
}
}
}
stage('Build sqlite image') {
when { expression { env.IS_MAIN == 'true' } }
steps {
// Server/SQLite build. BuildKit is required (syntax directive + cache
// mount in docker/Dockerfile). Context is the repo root.
sh '''
docker build \
-f docker/Dockerfile \
--build-arg REACT_APP_TRACKER_APP_ID="$TRACKER_APP_ID" \
-t "$SQLITE_IMAGE:$SHORT_SHA" \
.
'''
}
}
stage('Tag latest') {
when { allOf { expression { env.IS_MAIN == 'true' }; expression { return params.PUSH_LATEST } } }
steps {
sh '''
docker tag "$FIREBASE_IMAGE:$SHORT_SHA" "$FIREBASE_IMAGE:latest"
docker tag "$SQLITE_IMAGE:$SHORT_SHA" "$SQLITE_IMAGE:latest"
'''
}
}
stage('Push') {
when { expression { env.IS_MAIN == 'true' } }
steps {
sh '''
docker push "$FIREBASE_IMAGE:$SHORT_SHA"
docker push "$SQLITE_IMAGE:$SHORT_SHA"
'''
script {
if (params.PUSH_LATEST) {
sh '''
docker push "$FIREBASE_IMAGE:latest"
docker push "$SQLITE_IMAGE:latest"
'''
}
}
}
}
stage('Redeploy (Portainer webhooks)') {
when { allOf { expression { env.IS_MAIN == 'true' }; expression { return params.REDEPLOY_PORTAINER } } }
steps {
// Webhook URLs are Secret-text credentials, so they never appear in the
// repo and Jenkins masks them in the console. They are bound to shell
// env vars and referenced inside a single-quoted sh block, so Groovy
// never interpolates the value into the pipeline log.
withCredentials([
string(credentialsId: 'portainer-webhook-firebase', variable: 'WEBHOOK_FIREBASE'),
string(credentialsId: 'portainer-webhook-sqlite', variable: 'WEBHOOK_SQLITE'),
]) {
sh '''
set +x
echo "Triggering Portainer redeploy (firebase stack)"
curl -fsS -X POST --retry 3 --retry-delay 5 "$WEBHOOK_FIREBASE"
echo "Triggering Portainer redeploy (sqlite stack)"
curl -fsS -X POST --retry 3 --retry-delay 5 "$WEBHOOK_SQLITE"
'''
}
}
}
}
post {
always {
// Never leave Firebase secrets in the workspace or log out cleanly.
sh 'rm -f .env.local || true'
sh 'docker logout "$REGISTRY" || true'
}
success {
echo "Pushed ${FIREBASE_IMAGE}:${SHORT_SHA} and ${SQLITE_IMAGE}:${SHORT_SHA}"
}
}
}
-13
View File
@@ -50,19 +50,6 @@ lots of updates
- `isActive = id => updatedParticipants.find(p => p.id === id && p.isActive)`
- Returns participant obj (truthy) not boolean. Works by accident, fragile.
### BUG: select campaign during active combat = screen flicker, no action
- In active encounter, click different campaign → flicker, no nav, no end.
- Either block (toast: end encounter first) or auto-end current + switch.
- Decide UX before fix.
### FEAT: generic/non-5e rules mode
- Campaign/encounter ruleset toggle: `5e` vs `generic`.
- Generic mode turns off death saves/state machine.
- Generic mode allows negative HP.
- 5e mode rejects negative damage/heal and clamps HP at 0.
### FEAT: clarify "Is NPC" in add-participant
- Ambiguous label. May expand work based on what NPC means here (ally? monster?
+27
View File
@@ -0,0 +1,27 @@
# docker/portainer-stack.yml — Portainer stack for the SQLite/server build.
# Deploys the prebuilt image from the local registry (built + pushed by Jenkins).
# Paste into Portainer's web editor (Stacks -> Add stack -> Web editor).
#
# TLS is terminated by the external nginx, which should reverse_proxy to
# http://<docker-host>:8080 (and pass the WebSocket upgrade headers for /ws).
services:
app:
image: thinkserver:5000/ttrpg-tracker-sqlite:latest
container_name: ttrpg-tracker
ports:
- "8080:80" # host:container — nginx upstream is :8080
volumes:
- app-data:/data # SQLite DB lives here; back this up
environment:
- DB_PATH=/data/tracker.sqlite
- PORT=4001 # node backend port (internal; caddy proxies to it)
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:80/"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
volumes:
app-data:
+185
View File
@@ -0,0 +1,185 @@
// Generic (non-5e) ruleset: no death saves, negative HP allowed, down status.
const shared = require('@ttrpg/shared');
const { mockCtx } = require('./_helpers');
const {
buildCharacterParticipant, buildMonsterParticipant,
startEncounter, applyHpChange, deathSave, markDead, reviveParticipant,
} = shared;
function char(id, hp = 100) {
const { participant } = buildCharacterParticipant({ id: `orig-${id}`, name: id, defaultMaxHp: hp, defaultInitMod: 2 });
participant.id = id;
return participant;
}
function mon(id, hp = 100) {
const { participant } = buildMonsterParticipant({ name: id, maxHp: hp, initMod: 2 });
participant.id = id;
return participant;
}
function enc(ps, ruleset = 'generic') {
return {
name: 't', participants: ps, isStarted: false, isPaused: false,
round: 0, currentTurnParticipantId: null, turnOrderIds: [],
ruleset,
};
}
const snap = (e) => JSON.parse(JSON.stringify(e));
describe('generic ruleset', () => {
test('damage past 0 = negative HP, status down (character)', async () => {
const { ctx } = mockCtx();
let e = enc([char('a', 10)]);
e = await startEncounter(e, ctx);
const newEnc = await applyHpChange(e, 'a', 'damage', 15, ctx);
const p = newEnc.participants.find(x => x.id === 'a');
expect(p.currentHp).toBe(-5);
expect(p.status).toBe('down');
});
test('damage exactly to 0 = status down', async () => {
const { ctx } = mockCtx();
let e = enc([char('a', 10)]);
e = await startEncounter(e, ctx);
const newEnc = await applyHpChange(e, 'a', 'damage', 10, ctx);
const p = newEnc.participants.find(x => x.id === 'a');
expect(p.currentHp).toBe(0);
expect(p.status).toBe('down');
});
test('heal from negative = conscious', async () => {
const { ctx } = mockCtx();
let e = enc([char('a', 10)]);
e = await startEncounter(e, ctx);
e = await applyHpChange(e, 'a', 'damage', 15, ctx); // -5
const newEnc = await applyHpChange(e, 'a', 'heal', 10, ctx);
const p = newEnc.participants.find(x => x.id === 'a');
expect(p.currentHp).toBe(5);
expect(p.status).toBe('conscious');
});
test('heal caps at maxHp', async () => {
const { ctx } = mockCtx();
let e = enc([char('a', 10)]);
e = await startEncounter(e, ctx);
e = await applyHpChange(e, 'a', 'damage', 5, ctx); // 5
const newEnc = await applyHpChange(e, 'a', 'heal', 20, ctx);
const p = newEnc.participants.find(x => x.id === 'a');
expect(p.currentHp).toBe(10);
expect(p.status).toBe('conscious');
});
test('monster death = auto-inactive (generic)', async () => {
const { ctx } = mockCtx();
let e = enc([mon('m', 10)]);
e = await startEncounter(e, ctx);
const newEnc = await applyHpChange(e, 'm', 'damage', 20, ctx);
const p = newEnc.participants.find(x => x.id.startsWith('m'));
expect(p.currentHp).toBe(-10);
expect(p.status).toBe('dead');
expect(p.isActive).toBe(false);
});
test('monster at exactly 0 = dead + inactive (generic)', async () => {
const { ctx } = mockCtx();
let e = enc([mon('m', 10)]);
e = await startEncounter(e, ctx);
const newEnc = await applyHpChange(e, 'm', 'damage', 10, ctx);
const p = newEnc.participants.find(x => x.id.startsWith('m'));
expect(p.currentHp).toBe(0);
expect(p.status).toBe('dead');
expect(p.isActive).toBe(false);
});
test('deathSave throws in generic mode', async () => {
const { ctx } = mockCtx();
let e = enc([char('a', 10)]);
e = await startEncounter(e, ctx);
e = await applyHpChange(e, 'a', 'damage', 10, ctx); // down
await expect(deathSave(e, 'a', 'fail', ctx)).rejects.toThrow();
});
test('revive in generic mode: dead→conscious (no stable)', async () => {
const { ctx } = mockCtx();
let e = enc([char('a', 10)]);
e = await startEncounter(e, ctx);
e = await applyHpChange(e, 'a', 'damage', 10, ctx); // down
e = await markDead(e, 'a', ctx); // dead
const newEnc = await reviveParticipant(e, 'a', ctx);
const p = newEnc.participants.find(x => x.id === 'a');
expect(p.status).toBe('conscious');
expect(p.currentHp).toBe(0); // HP unchanged from down
});
test('revive generic reactivates if inactive', async () => {
const { ctx } = mockCtx();
let e = enc([mon('m', 10)]);
e = await startEncounter(e, ctx);
e = await markDead(e, e.participants[0].id, ctx); // dead+inactive
const newEnc = await reviveParticipant(e, e.participants[0].id, ctx);
const p = newEnc.participants[0];
expect(p.status).toBe('conscious');
expect(p.isActive).toBe(true);
});
test('markDead sets status dead (character, stays active)', async () => {
const { ctx } = mockCtx();
let e = enc([char('a', 10)]);
e = await startEncounter(e, ctx);
e = await applyHpChange(e, 'a', 'damage', 10, ctx); // down
const newEnc = await markDead(e, 'a', ctx);
const p = newEnc.participants.find(x => x.id === 'a');
expect(p.status).toBe('dead');
expect(p.isActive).toBe(true);
});
test('markDead on monster = dead + inactive', async () => {
const { ctx } = mockCtx();
let e = enc([mon('m', 10)]);
e = await startEncounter(e, ctx);
e = await applyHpChange(e, 'm', 'damage', 5, ctx); // down (5hp)
const newEnc = await markDead(e, e.participants[0].id, ctx);
const p = newEnc.participants[0];
expect(p.status).toBe('dead');
expect(p.isActive).toBe(false);
});
test('markDead on conscious = dead', async () => {
const { ctx } = mockCtx();
let e = enc([char('a', 10)]);
e = await startEncounter(e, ctx);
const newEnc = await markDead(e, 'a', ctx);
const p = newEnc.participants.find(x => x.id === 'a');
expect(p.status).toBe('dead');
});
test('damage while down = more negative (no death saves)', async () => {
const { ctx } = mockCtx();
let e = enc([char('a', 10)]);
e = await startEncounter(e, ctx);
e = await applyHpChange(e, 'a', 'damage', 15, ctx); // -5 down
const newEnc = await applyHpChange(e, 'a', 'damage', 5, ctx);
const p = newEnc.participants.find(x => x.id === 'a');
expect(p.currentHp).toBe(-10);
expect(p.status).toBe('down');
});
test('no unconscious condition auto-added (generic)', async () => {
const { ctx } = mockCtx();
let e = enc([char('a', 10)]);
e = await startEncounter(e, ctx);
const newEnc = await applyHpChange(e, 'a', 'damage', 10, ctx);
const p = newEnc.participants.find(x => x.id === 'a');
expect(p.conditions).not.toContain('unconscious');
});
test('down→conscious clears dead? no, dead is separate', async () => {
// sanity: down heal to conscious, dead untouched
const { ctx } = mockCtx();
let e = enc([char('a', 10)]);
e = await startEncounter(e, ctx);
e = await applyHpChange(e, 'a', 'damage', 10, ctx); // down
const newEnc = await applyHpChange(e, 'a', 'heal', 10, ctx);
const p = newEnc.participants.find(x => x.id === 'a');
expect(p.status).toBe('conscious');
});
});
+115 -2
View File
@@ -207,6 +207,7 @@ function expandUndo(entry, currentEnc) {
case 'death_save':
case 'stabilize':
case 'revive':
case 'mark_dead':
case 'deactivate_dead_monster': {
// Full death-state restore: currentHp, status, death-save counters,
// isActive, conditions (unconscious added/removed by withDeathStatusConditions).
@@ -574,6 +575,7 @@ async function toggleParticipantActive(encounter, participantId, ctx) {
async function applyHpChange(encounter, participantId, changeType, amount, optionsOrCtx, maybeCtx) {
const ctx = maybeCtx || optionsOrCtx;
const options = maybeCtx ? (optionsOrCtx || {}) : {};
const ruleset = encounter.ruleset || '5e';
const participant = (encounter.participants || []).find(p => p.id === participantId);
if (!participant) throw new Error('Participant not found.');
if (isNaN(amount) || amount === 0) return encounter;
@@ -596,6 +598,12 @@ async function applyHpChange(encounter, participantId, changeType, amount, optio
let logDelta = { amount, from: participant.currentHp };
const status = oldValues.status;
// GENERIC ruleset: no death saves, negative HP allowed, down status at <=0.
// Monster death = dead + inactive. No unconscious auto-condition.
if (ruleset === 'generic') {
return applyHpChangeGeneric(encounter, participant, changeType, amount, oldValues, ctx);
}
if (changeType === 'damage') {
if (participant.currentHp === 0) {
if (status === 'dead') {
@@ -683,7 +691,76 @@ async function applyHpChange(encounter, participantId, changeType, amount, optio
return commit(encounter, patch, log, ctx);
}
// GENERIC ruleset HP change: no death saves, negative HP, down status at <=0.
// Monster death = dead + inactive. No unconscious auto-condition.
async function applyHpChangeGeneric(encounter, participant, changeType, amount, oldValues, ctx) {
const participantId = participant.id;
let updates, message, logDelta = { amount, from: participant.currentHp };
if (changeType === 'damage') {
if (oldValues.status === 'dead') {
if (participant.type === 'monster' && participant.isActive !== false) {
const updatedParticipants = (encounter.participants || []).map(p =>
p.id === participantId ? { ...p, isActive: false } : p
);
const newP = updatedParticipants.find(p => p.id === participantId);
const newValues = {
currentHp: newP.currentHp, status: newP.status,
deathSaveSuccesses: 0, deathSaveFailures: 0,
isActive: newP.isActive, conditions: [...(newP.conditions || [])],
};
return commit(encounter, { participants: updatedParticipants }, {
type: 'deactivate_dead_monster', participantId, participantName: participant.name,
message: `${participant.name} marked inactive (dead monster)`,
delta: { status: 'dead', isActive: false },
undo: { oldValues, newValues },
}, ctx);
}
return encounter;
}
const newHp = participant.currentHp - amount;
const isMonster = participant.type === 'monster';
if (newHp <= 0 && isMonster) {
updates = { currentHp: newHp, status: 'dead', isActive: false };
} else if (newHp <= 0) {
updates = { currentHp: newHp, status: 'down' };
} else {
updates = { currentHp: newHp, status: 'conscious' };
}
message = `${participant.name} took ${amount} damage (${participant.currentHp}${newHp} HP)`;
logDelta = { ...logDelta, to: newHp, status: updates.status };
} else if (changeType === 'heal') {
if (oldValues.status === 'dead') return encounter;
const newHp = Math.min(participant.maxHp, participant.currentHp + amount);
updates = { currentHp: newHp, status: 'conscious' };
message = `${participant.name} healed for ${amount} (${participant.currentHp}${newHp} HP)`;
logDelta = { ...logDelta, to: newHp, status: 'conscious' };
} else {
throw new Error(`Unknown HP change type: ${changeType}`);
}
const updatedParticipants = (encounter.participants || []).map(p =>
p.id === participantId ? { ...p, ...updates } : p
);
const newP = updatedParticipants.find(p => p.id === participantId);
const newValues = {
currentHp: newP.currentHp, status: newP.status,
deathSaveSuccesses: newP.deathSaveSuccesses || 0,
deathSaveFailures: newP.deathSaveFailures || 0,
isActive: newP.isActive, conditions: [...(newP.conditions || [])],
};
const undoAmount = changeType === 'damage' ? amount : -amount;
return commit(encounter, { participants: updatedParticipants }, {
type: changeType, participantId, participantName: participant.name,
message, delta: logDelta,
undo: { amount: undoAmount, oldValues, newValues },
}, ctx);
}
async function deathSave(encounter, participantId, outcome, ctx) {
if ((encounter.ruleset || '5e') === 'generic') {
throw new Error('Death saves not available in generic ruleset.');
}
const participant = (encounter.participants || []).find(p => p.id === participantId);
if (!participant) throw new Error('Participant not found.');
const statusBefore = participant.status || (participant.currentHp === 0 ? 'dying' : 'conscious');
@@ -764,6 +841,9 @@ async function toggleCondition(encounter, participantId, conditionId, ctx) {
}
async function stabilizeParticipant(encounter, participantId, ctx) {
if ((encounter.ruleset || '5e') === 'generic') {
throw new Error('Stabilize not available in generic ruleset.');
}
const participant = (encounter.participants || []).find(p => p.id === participantId);
if (!participant) throw new Error('Participant not found.');
const status = participant.status || (participant.currentHp === 0 ? 'dying' : 'conscious');
@@ -806,12 +886,17 @@ async function stabilizeParticipant(encounter, participantId, ctx) {
}
async function reviveParticipant(encounter, participantId, ctx) {
const ruleset = encounter.ruleset || '5e';
const participant = (encounter.participants || []).find(p => p.id === participantId);
if (!participant) throw new Error('Participant not found.');
const status = participant.status || (participant.currentHp === 0 ? 'dying' : 'conscious');
if (status !== 'dead') return encounter;
const updates = { currentHp: 0, status: 'stable', deathSaveSuccesses: 0, deathSaveFailures: 0, isActive: true };
// Generic: dead→conscious (no stable state). HP unchanged (may be negative).
// 5e: dead→stable at 0 HP, unconscious, clears death-save counters.
const updates = ruleset === 'generic'
? { status: 'conscious', ...(participant.type === 'monster' ? {} : {}), ...(participant.isActive === false ? { isActive: true } : {}) }
: { currentHp: 0, status: 'stable', deathSaveSuccesses: 0, deathSaveFailures: 0, isActive: true };
const updatedParticipants = (encounter.participants || []).map(p =>
p.id === participantId ? { ...p, ...withDeathStatusConditions(p, updates) } : p
);
@@ -845,6 +930,34 @@ async function reviveParticipant(encounter, participantId, ctx) {
return commit(encounter, patch, log, ctx);
}
// markDead: DM manually sets status dead. Both rulesets. Monster = auto-inactive.
async function markDead(encounter, participantId, ctx) {
const participant = (encounter.participants || []).find(p => p.id === participantId);
if (!participant) throw new Error('Participant not found.');
if (participant.status === 'dead') return encounter;
const oldValues = {
currentHp: participant.currentHp,
status: participant.status || 'conscious',
isActive: participant.isActive,
conditions: [...(participant.conditions || [])],
};
const updates = { status: 'dead', ...(participant.type === 'monster' ? { isActive: false } : {}) };
const updatedParticipants = (encounter.participants || []).map(p =>
p.id === participantId ? { ...p, ...withDeathStatusConditions(p, updates) } : p
);
const newP = updatedParticipants.find(p => p.id === participantId);
const newValues = {
currentHp: newP.currentHp, status: newP.status,
isActive: newP.isActive, conditions: [...(newP.conditions || [])],
};
return commit(encounter, { participants: updatedParticipants }, {
type: 'mark_dead', participantId, participantName: participant.name,
message: `${participant.name} marked dead`,
delta: { status: 'dead' },
undo: { oldValues, newValues },
}, ctx);
}
async function reorderParticipants(encounter, draggedId, targetId, ctx) {
const participants = [...(encounter.participants || [])];
const dragged = participants.find(p => p.id === draggedId);
@@ -919,7 +1032,7 @@ module.exports = {
makeParticipant, buildCharacterParticipant, buildMonsterParticipant,
startEncounter, nextTurn, togglePause,
addParticipant, addParticipants, updateParticipant, removeParticipant,
toggleParticipantActive, applyHpChange, deathSave, stabilizeParticipant, reviveParticipant, toggleCondition,
toggleParticipantActive, applyHpChange, deathSave, stabilizeParticipant, reviveParticipant, markDead, toggleCondition,
reorderParticipants, endEncounter,
activateDisplay, clearDisplay, toggleHidePlayerHp,
};
+242 -30
View File
@@ -5,7 +5,7 @@ import {
PlusCircle, Users, Swords, Trash2, Eye, Edit3, Save, XCircle, ChevronsUpDown,
UserCheck, UserX, HeartCrack, HeartPulse, Zap, EyeOff, ExternalLink, AlertTriangle,
Play as PlayIcon, Pause as PauseIcon, SkipForward as SkipForwardIcon,
StopCircle as StopCircleIcon, Users2, Dices, ChevronUp, ChevronDown, ScrollText,
StopCircle as StopCircleIcon, Users2, Dices, ChevronDown, ScrollText,
Maximize2, Minimize2, Moon, Coffee, Clock, ChevronRight, X,
Undo2, Redo2, Crosshair
} from 'lucide-react';
@@ -125,6 +125,7 @@ const {
deathSave: combatDeathSave,
stabilizeParticipant,
reviveParticipant,
markDead,
toggleCondition: combatToggleCondition,
reorderParticipants,
} = shared;
@@ -448,11 +449,12 @@ function ErrorDisplay({ message, critical = false }) {
function CreateCampaignForm({ onCreate, onCancel }) {
const [name, setName] = useState('');
const [backgroundUrl, setBackgroundUrl] = useState('');
const [ruleset, setRuleset] = useState('5e');
const handleSubmit = (e) => {
e.preventDefault();
if (name.trim()) {
onCreate(name, backgroundUrl);
onCreate(name, backgroundUrl, ruleset);
}
};
@@ -484,6 +486,19 @@ function CreateCampaignForm({ onCreate, onCancel }) {
className="mt-1 block w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white"
/>
</div>
<div>
<label className="block text-sm font-medium text-stone-300 mb-1">Ruleset</label>
<div className="flex gap-4">
<label className="flex items-center gap-2 text-sm text-stone-200">
<input type="radio" value="5e" checked={ruleset === '5e'} onChange={() => setRuleset('5e')} />
D&D 5e (death saves)
</label>
<label className="flex items-center gap-2 text-sm text-stone-200">
<input type="radio" value="generic" checked={ruleset === 'generic'} onChange={() => setRuleset('generic')} />
Generic (no death saves, negative HP)
</label>
</div>
</div>
<div className="flex justify-end space-x-3">
<button
type="button"
@@ -503,13 +518,14 @@ function CreateCampaignForm({ onCreate, onCancel }) {
);
}
function CreateEncounterForm({ onCreate, onCancel }) {
function CreateEncounterForm({ onCreate, onCancel, defaultRuleset = '5e' }) {
const [name, setName] = useState('');
const [ruleset, setRuleset] = useState(defaultRuleset);
const handleSubmit = (e) => {
e.preventDefault();
if (name.trim()) {
onCreate(name);
onCreate(name, ruleset);
}
};
@@ -528,6 +544,19 @@ function CreateEncounterForm({ onCreate, onCancel }) {
required
/>
</div>
<div>
<label className="block text-sm font-medium text-stone-300 mb-1">Ruleset</label>
<div className="flex gap-4">
<label className="flex items-center gap-2 text-sm text-stone-200">
<input type="radio" value="5e" checked={ruleset === '5e'} onChange={() => setRuleset('5e')} />
D&D 5e (death saves)
</label>
<label className="flex items-center gap-2 text-sm text-stone-200">
<input type="radio" value="generic" checked={ruleset === 'generic'} onChange={() => setRuleset('generic')} />
Generic (no death saves, negative HP)
</label>
</div>
</div>
<div className="flex justify-end space-x-3">
<button
type="button"
@@ -653,7 +682,30 @@ function CharacterManager({ campaignId, campaignCharacters }) {
const [editingCharacter, setEditingCharacter] = useState(null);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [itemToDelete, setItemToDelete] = useState(null);
const [isOpen, setIsOpen] = useState(true);
const [draggedCharId, setDraggedCharId] = useState(null);
const handleCharDragStart = (e, id) => { setDraggedCharId(id); e.dataTransfer.effectAllowed = 'move'; };
const handleCharDragOver = (e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; };
const handleCharDrop = async (e, targetId) => {
e.preventDefault();
if (!campaignId || !draggedCharId || draggedCharId === targetId) { setDraggedCharId(null); return; }
const reordered = [...campaignCharacters];
const fromIdx = reordered.findIndex(c => c.id === draggedCharId);
const toIdx = reordered.findIndex(c => c.id === targetId);
if (fromIdx === -1 || toIdx === -1) { setDraggedCharId(null); return; }
const [moved] = reordered.splice(fromIdx, 1);
reordered.splice(toIdx, 0, moved);
try { await storage.updateDoc(getPath.campaign(campaignId), { players: reordered }); } catch (err) {}
setDraggedCharId(null);
};
const [isOpen, setIsOpen] = useState(() => {
try { return localStorage.getItem('ttrpg.charactersCollapsed') !== 'true'; }
catch { return true; }
});
useEffect(() => {
try { localStorage.setItem('ttrpg.charactersCollapsed', String(!isOpen)); }
catch {}
}, [isOpen]);
const handleAddCharacter = async () => {
if (!db || !characterName.trim() || !campaignId) return;
@@ -740,15 +792,14 @@ function CharacterManager({ campaignId, campaignCharacters }) {
<>
<div className="p-4 bg-stone-900 rounded-lg shadow">
<div className="flex justify-between items-center mb-3">
<h3 className="text-xl font-semibold text-amber-300 font-cinzel tracking-wide flex items-center">
<Users size={24} className="mr-2" /> Campaign Characters
</h3>
<button
onClick={() => setIsOpen(!isOpen)}
className="p-1 text-stone-400 hover:text-stone-200"
aria-label={isOpen ? "Collapse" : "Expand"}
className="flex items-center text-xl font-semibold text-amber-300 font-cinzel tracking-wide hover:text-amber-200 transition-colors"
aria-expanded={isOpen}
>
{isOpen ? <ChevronUp size={20} /> : <ChevronDown size={20} />}
{isOpen ? <ChevronDown size={20} className="mr-1" /> : <ChevronRight size={20} className="mr-1" />}
<Users size={24} className="mr-2" /> Campaign Characters
<span className="text-sm font-normal text-stone-400 ml-1">({campaignCharacters.length})</span>
</button>
</div>
@@ -806,7 +857,17 @@ function CharacterManager({ campaignId, campaignCharacters }) {
<ul className="space-y-2">
{campaignCharacters.map(character => (
<li key={character.id} className="flex justify-between items-center p-3 bg-stone-800 rounded-md">
<li
key={character.id}
draggable
onDragStart={(e) => handleCharDragStart(e, character.id)}
onDragOver={handleCharDragOver}
onDrop={(e) => handleCharDrop(e, character.id)}
onDragEnd={() => setDraggedCharId(null)}
className={`flex justify-between items-center p-3 bg-stone-800 rounded-md cursor-grab ${draggedCharId === character.id ? 'opacity-50 ring-2 ring-yellow-400' : ''}`}
>
<div className="flex items-center flex-grow">
<ChevronsUpDown size={16} className="text-stone-400 flex-shrink-0 mr-2" title="Drag to reorder" />
{editingCharacter && editingCharacter.id === character.id ? (
<form
onSubmit={(e) => {
@@ -882,6 +943,7 @@ function CharacterManager({ campaignId, campaignCharacters }) {
</div>
</>
)}
</div>
</li>
))}
</ul>
@@ -1153,6 +1215,15 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
}
};
const handleMarkDead = async (participantId) => {
if (!db) return;
try {
await markDead(encounter, participantId, buildCtx(encounterPath));
} catch (err) {
showToast(err.message);
}
};
const handleCritDamage = async (participantId) => {
if (!db) return;
try {
@@ -1414,14 +1485,18 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
const isCurrentTurn = encounter.isStarted && p.id === encounter.currentTurnParticipantId;
const isDraggable = (!encounter.isStarted || encounter.isPaused) && tiedInitiatives.includes(Number(p.initiative));
const participantDisplayType = p.type === 'npc' ? 'NPC' : (p.type === 'monster' ? 'Monster' : 'Character');
const hasDeathSaves = p.type === 'character' || p.type === 'npc';
const isGeneric = (encounter.ruleset || '5e') === 'generic';
const hasDeathSaves = !isGeneric && (p.type === 'character' || p.type === 'npc');
let bgColor = p.type === 'character' ? 'bg-indigo-950' : 'bg-[#8e351c]';
if (isCurrentTurn && !encounter.isPaused) bgColor = 'bg-green-600';
const participantStatus = p.status || (p.currentHp === 0 ? (p.type === 'monster' ? 'dead' : 'dying') : 'conscious');
const isZeroHp = p.currentHp === 0;
const participantStatus = p.status || (isGeneric
? (p.currentHp <= 0 ? 'down' : 'conscious')
: (p.currentHp === 0 ? (p.type === 'monster' ? 'dead' : 'dying') : 'conscious'));
const isZeroHp = p.currentHp <= 0;
const isDead = participantStatus === 'dead';
const isDown = participantStatus === 'down';
return (
<li
@@ -1452,6 +1527,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
)}
{hasDeathSaves && participantStatus === 'dying' && <span className="ml-2 text-xs text-red-300 font-semibold animate-pulse">(Dying)</span>}
{hasDeathSaves && participantStatus === 'stable' && (p.conditions || []).includes('unconscious') && <span className="ml-2 text-xs text-emerald-300 font-semibold">(Unconscious)</span>}
{isDown && <span className="ml-2 text-xs text-red-300 font-semibold animate-pulse">(Down)</span>}
{isDead && <span className="ml-2 px-2 py-0.5 rounded bg-red-950 border border-red-500 text-red-200 text-xs font-bold">DEAD</span>}
</p>
<div className={`text-sm ${isCurrentTurn && !encounter.isPaused ? 'text-green-100' : 'text-stone-200'} flex items-center gap-2`}>
@@ -1477,11 +1553,17 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
</div>
{participantStatus === 'dead' && (
<button onClick={() => handleRevive(p.id)} className="mt-2 inline-flex items-center gap-1 self-start px-2.5 py-1 text-xs rounded bg-emerald-800 hover:bg-emerald-700 text-white" title="Revive to 0 HP, stable and unconscious">
<button onClick={() => handleRevive(p.id)} className="mt-2 inline-flex items-center gap-1 self-start px-2.5 py-1 text-xs rounded bg-emerald-800 hover:bg-emerald-700 text-white" title="Revive (clear dead status)">
<HeartPulse size={14} /> Revive
</button>
)}
{isGeneric && !isDead && (
<button onClick={() => handleMarkDead(p.id)} className="mt-2 inline-flex items-center gap-1 self-start px-2.5 py-1 text-xs rounded bg-red-900 hover:bg-red-800 text-white" title="Mark dead">
<HeartCrack size={14} /> Mark Dead
</button>
)}
{hasDeathSaves && participantStatus === 'stable' && (
<div className="mt-2 text-xs text-emerald-300/80 italic">Stable regains 1 HP after 1d4 hours</div>
)}
@@ -1944,18 +2026,20 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
// ENCOUNTER MANAGER COMPONENT
// ============================================================================
function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharacters }) {
function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharacters, encounterStartedRef, encounterActiveRef }) {
const { showToast } = useUIFeedback();
const { data: encountersData, isLoading: isLoadingEncounters } = useFirestoreCollection(
campaignId ? getPath.encounters(campaignId) : null
);
const { data: activeDisplayInfo } = useFirestoreDocument(getPath.activeDisplay());
const { data: campaignDoc } = useFirestoreDocument(campaignId ? getPath.campaign(campaignId) : null);
const [encounters, setEncounters] = useState([]);
const [selectedEncounterId, setSelectedEncounterId] = useState(null);
const [showCreateModal, setShowCreateModal] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [itemToDelete, setItemToDelete] = useState(null);
const [draggedEncounterId, setDraggedEncounterId] = useState(null);
const selectedEncounterIdRef = useRef(selectedEncounterId);
@@ -1963,6 +2047,45 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
if (encountersData) setEncounters(encountersData);
}, [encountersData]);
// Sort by order field (fallback createdAt). Drag reorder writes order.
const sortedEncounters = [...(encounters || [])].sort((a, b) => {
const ao = a.order ?? null, bo = b.order ?? null;
if (ao !== null && bo !== null) return ao - bo;
if (ao !== null) return -1;
if (bo !== null) return 1;
return (a.createdAt || '').localeCompare(b.createdAt || '');
});
const handleEncounterDragStart = (e, id) => {
setDraggedEncounterId(id);
e.dataTransfer.effectAllowed = 'move';
};
const handleEncounterDragOver = (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
};
const handleEncounterDrop = async (e, targetId) => {
e.preventDefault();
if (!db || !draggedEncounterId || draggedEncounterId === targetId) {
setDraggedEncounterId(null);
return;
}
const reordered = [...sortedEncounters];
const fromIdx = reordered.findIndex(x => x.id === draggedEncounterId);
const toIdx = reordered.findIndex(x => x.id === targetId);
if (fromIdx === -1 || toIdx === -1) { setDraggedEncounterId(null); return; }
const [moved] = reordered.splice(fromIdx, 1);
reordered.splice(toIdx, 0, moved);
// assign order = index, batch update
const ops = reordered.map((enc, i) => ({
type: 'update',
path: `${getPath.encounters(campaignId)}/${enc.id}`,
data: { order: i },
}));
try { await storage.batchWrite(ops); } catch (err) {}
setDraggedEncounterId(null);
};
useEffect(() => {
selectedEncounterIdRef.current = selectedEncounterId;
}, [selectedEncounterId]);
@@ -1992,7 +2115,7 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
}
}, [campaignId, initialActiveEncounterId, activeDisplayInfo, encounters]);
const handleCreateEncounter = async (name) => {
const handleCreateEncounter = async (name, ruleset = '5e') => {
if (!db || !name.trim() || !campaignId) return;
const newEncounterId = generateId();
@@ -2005,7 +2128,9 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
round: 0,
currentTurnParticipantId: null,
isStarted: false,
isPaused: false
isPaused: false,
ruleset: ruleset || '5e',
order: (encounters || []).reduce((max, e) => Math.max(max, e.order ?? -1), -1) + 1,
});
setShowCreateModal(false);
@@ -2079,6 +2204,15 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
const selectedEncounter = encounters?.find(e => e.id === selectedEncounterId);
useEffect(() => {
if (encounterStartedRef) {
encounterStartedRef.current = !!(selectedEncounter && selectedEncounter.isStarted && !selectedEncounter.isPaused);
}
if (encounterActiveRef) {
encounterActiveRef.current = !!(selectedEncounter && selectedEncounter.isStarted);
}
}, [selectedEncounter, encounterStartedRef, encounterActiveRef]);
if (isLoadingEncounters && campaignId) {
return <p className="text-center text-stone-300 mt-4">Loading encounters...</p>;
}
@@ -2103,7 +2237,7 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
)}
<div className="space-y-3">
{encounters?.map(encounter => {
{sortedEncounters.map(encounter => {
const isLive = activeDisplayInfo &&
activeDisplayInfo.activeCampaignId === campaignId &&
activeDisplayInfo.activeEncounterId === encounter.id;
@@ -2111,13 +2245,18 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
return (
<div
key={encounter.id}
className={`p-3 rounded-md shadow transition-all ${selectedEncounterId === encounter.id ? 'bg-amber-900 ring-2 ring-amber-500' : 'bg-stone-800 hover:bg-stone-700'} ${isLive ? 'ring-2 ring-green-500 shadow-md shadow-green-500/30' : ''}`}
draggable
onDragStart={(e) => handleEncounterDragStart(e, encounter.id)}
onDragOver={handleEncounterDragOver}
onDrop={(e) => handleEncounterDrop(e, encounter.id)}
onDragEnd={() => setDraggedEncounterId(null)}
className={`p-3 rounded-md shadow transition-all ${selectedEncounterId === encounter.id ? 'bg-amber-900 ring-2 ring-amber-500' : 'bg-stone-800 hover:bg-stone-700'} ${isLive ? 'ring-2 ring-green-500 shadow-md shadow-green-500/30' : ''} ${draggedEncounterId === encounter.id ? 'opacity-50 ring-2 ring-yellow-400' : ''} cursor-grab`}
>
<div className="flex justify-between items-center">
<div onClick={() => setSelectedEncounterId(encounter.id)} className="cursor-pointer flex-grow">
<h4 className="font-medium text-white">{encounter.name}</h4>
<h4 className="font-medium text-white">{encounter.name} <span className={`ml-1 px-1.5 py-0.5 rounded text-xs font-bold tracking-wide ${encounter.ruleset === 'generic' ? 'bg-purple-900/80 text-purple-200 border border-purple-500' : 'bg-amber-900/80 text-amber-200 border border-amber-500'}`}>{encounter.ruleset === 'generic' ? 'GEN' : '5e'}</span></h4>
<p className="text-xs text-stone-300">
Participants: {encounter.participants?.length || 0}
{encounter.createdAt && `${new Date(encounter.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false })} · `}Participants: {encounter.participants?.length || 0}
</p>
{isLive && (
<span className="text-xs text-green-400 font-semibold block mt-1">
@@ -2126,6 +2265,11 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
)}
</div>
<div className="flex items-center space-x-2">
<ChevronsUpDown
size={18}
className="text-stone-400 flex-shrink-0 cursor-grab"
title="Drag to reorder"
/>
<button
onClick={() => handleTogglePlayerDisplay(encounter.id)}
className={`p-1 rounded transition-colors ${isLive ? 'bg-red-500 hover:bg-red-600 text-white' : 'text-amber-400 hover:text-amber-300 bg-stone-700 hover:bg-stone-600'}`}
@@ -2153,8 +2297,10 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
{showCreateModal && (
<Modal onClose={() => setShowCreateModal(false)} title="Create New Encounter">
<CreateEncounterForm
key={campaignId}
onCreate={handleCreateEncounter}
onCancel={() => setShowCreateModal(false)}
defaultRuleset={campaignDoc?.ruleset || '5e'}
/>
</Modal>
)}
@@ -2211,7 +2357,21 @@ function AdminView({ userId }) {
const { data: initialActiveInfo } = useFirestoreDocument(getPath.activeDisplay());
const [campaignsWithDetails, setCampaignsWithDetails] = useState([]);
const [draggedCampaignId, setDraggedCampaignId] = useState(null);
const [selectedCampaignId, setSelectedCampaignId] = useState(null);
const encounterStartedRef = useRef(false);
const encounterActiveRef = useRef(false);
const manualSelectRef = useRef(false);
const prevDisplayCampaignRef = useRef(null);
// External display change (replay/other DM) = clear manual override, allow follow.
useEffect(() => {
const cur = initialActiveInfo?.activeCampaignId || null;
if (cur !== prevDisplayCampaignRef.current) {
manualSelectRef.current = false;
prevDisplayCampaignRef.current = cur;
}
}, [initialActiveInfo]);
const [showCreateModal, setShowCreateModal] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [itemToDelete, setItemToDelete] = useState(null);
@@ -2244,8 +2404,12 @@ function AdminView({ userId }) {
})
);
// newest first by createdAt (fallback to name for stable order).
// sort by order field (fallback createdAt)
detailedCampaigns.sort((a, b) => {
const ao = a.order ?? null, bo = b.order ?? null;
if (ao !== null && bo !== null) return ao - bo;
if (ao !== null) return -1;
if (bo !== null) return 1;
const at = a.createdAt || 0;
const bt = b.createdAt || 0;
if (at === bt) return (a.name || '').localeCompare(b.name || '');
@@ -2258,6 +2422,10 @@ function AdminView({ userId }) {
fetchDetails();
} else if (campaignsData) {
const sorted = [...campaignsData].sort((a, b) => {
const ao = a.order ?? null, bo = b.order ?? null;
if (ao !== null && bo !== null) return ao - bo;
if (ao !== null) return -1;
if (bo !== null) return 1;
const at = a.createdAt || 0;
const bt = b.createdAt || 0;
if (at === bt) return (a.name || '').localeCompare(b.name || '');
@@ -2271,10 +2439,15 @@ function AdminView({ userId }) {
}, [campaignsData]);
useEffect(() => {
// Skip follow only if user manually selected AND display hasn't changed.
// (external display change clears manualSelectRef via prevDisplay effect)
if (manualSelectRef.current && selectedCampaignId !== initialActiveInfo?.activeCampaignId) return;
if (
initialActiveInfo &&
initialActiveInfo.activeCampaignId &&
campaignsWithDetails.length > 0
campaignsWithDetails.length > 0 &&
!encounterStartedRef.current &&
!encounterActiveRef.current
) {
const campaignExists = campaignsWithDetails.some(c => c.id === initialActiveInfo.activeCampaignId);
if (campaignExists && selectedCampaignId !== initialActiveInfo.activeCampaignId) {
@@ -2283,7 +2456,7 @@ function AdminView({ userId }) {
}
}, [initialActiveInfo, campaignsWithDetails, selectedCampaignId]);
const handleCreateCampaign = async (name, backgroundUrl) => {
const handleCreateCampaign = async (name, backgroundUrl, ruleset = '5e') => {
if (!db || !name.trim()) return;
const newCampaignId = generateId();
@@ -2295,6 +2468,8 @@ function AdminView({ userId }) {
ownerId: userId,
createdAt: new Date().toISOString(),
players: [],
ruleset: ruleset || '5e',
order: (campaignsWithDetails || []).reduce((max, c) => Math.max(max, c.order ?? -1), -1) + 1,
});
setShowCreateModal(false);
@@ -2441,12 +2616,36 @@ function AdminView({ userId }) {
? { backgroundImage: `url(${campaign.playerDisplayBackgroundUrl})` }
: {};
const cardClasses = `h-40 flex flex-col justify-between rounded-lg shadow-md cursor-pointer transition-all relative overflow-hidden bg-cover bg-center ${selectedCampaignId === campaign.id ? 'ring-4 ring-amber-500' : ''} ${!campaign.playerDisplayBackgroundUrl ? 'bg-stone-800 hover:bg-stone-700' : 'hover:shadow-xl'}`;
const cardClasses = `h-40 flex flex-col justify-between rounded-lg shadow-md cursor-grab transition-all relative overflow-hidden bg-cover bg-center ${selectedCampaignId === campaign.id ? 'ring-4 ring-amber-500' : ''} ${!campaign.playerDisplayBackgroundUrl ? 'bg-stone-800 hover:bg-stone-700' : 'hover:shadow-xl'} ${draggedCampaignId === campaign.id ? 'opacity-50 ring-2 ring-yellow-400' : ''}`;
return (
<div
key={campaign.id}
onClick={() => setSelectedCampaignId(campaign.id)}
draggable
onDragStart={(e) => { setDraggedCampaignId(campaign.id); e.dataTransfer.effectAllowed = 'move'; }}
onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; }}
onDrop={async (e) => {
e.preventDefault();
if (!db || !draggedCampaignId || draggedCampaignId === campaign.id) { setDraggedCampaignId(null); return; }
const reordered = [...campaignsWithDetails];
const fromIdx = reordered.findIndex(c => c.id === draggedCampaignId);
const toIdx = reordered.findIndex(c => c.id === campaign.id);
if (fromIdx === -1 || toIdx === -1) { setDraggedCampaignId(null); return; }
const [moved] = reordered.splice(fromIdx, 1);
reordered.splice(toIdx, 0, moved);
const ops = reordered.map((c, i) => ({ type: 'update', path: getPath.campaign(c.id), data: { order: i } }));
try { await storage.batchWrite(ops); } catch (err) {}
setDraggedCampaignId(null);
}}
onDragEnd={() => setDraggedCampaignId(null)}
onClick={() => {
if (encounterStartedRef.current) {
showToast('End or pause active encounter before switching campaigns.');
return;
}
manualSelectRef.current = true;
setSelectedCampaignId(campaign.id);
}}
className={cardClasses}
style={cardStyle}
>
@@ -2454,7 +2653,10 @@ function AdminView({ userId }) {
className={`relative z-10 flex flex-col justify-between h-full ${campaign.playerDisplayBackgroundUrl ? 'bg-black bg-opacity-60 p-3' : 'p-4'}`}
>
<div>
<div className="flex items-center gap-1">
<ChevronsUpDown size={14} className="text-white/60 flex-shrink-0" title="Drag to reorder" />
<h3 className="text-xl font-semibold text-white">{campaign.name}</h3>
</div>
<div className="text-xs text-stone-100 mt-1 space-x-3">
<span className="inline-flex items-center">
<Users size={12} className="mr-1" /> {campaign.characters?.length || 0} Characters
@@ -2478,6 +2680,12 @@ function AdminView({ userId }) {
>
<Trash2 size={14} className="mr-1" /> Delete
</button>
<span
className={`absolute bottom-2 right-2 z-20 px-2 py-0.5 rounded text-xs font-bold tracking-wide ${campaign.ruleset === 'generic' ? 'bg-purple-900/80 text-purple-200 border border-purple-500' : 'bg-amber-900/80 text-amber-200 border border-amber-500'}`}
title={campaign.ruleset === 'generic' ? 'Generic ruleset' : 'D&D 5e ruleset'}
>
{campaign.ruleset === 'generic' ? 'GEN' : '5e'}
</span>
</div>
</div>
);
@@ -2514,6 +2722,8 @@ function AdminView({ userId }) {
: null
}
campaignCharacters={selectedCampaign.characters || []}
encounterStartedRef={encounterStartedRef}
encounterActiveRef={encounterActiveRef}
/>
</div>
)}
@@ -2847,8 +3057,9 @@ function DisplayView() {
<div className="space-y-4 max-w-3xl mx-auto">
{participantsToRender.map(p => {
const status = p.status || (p.currentHp === 0 ? 'dying' : 'conscious');
const isZeroHp = p.currentHp === 0;
const isGeneric = (activeEncounterData.ruleset || '5e') === 'generic';
const status = p.status || (isGeneric ? (p.currentHp <= 0 ? 'down' : 'conscious') : (p.currentHp === 0 ? 'dying' : 'conscious'));
const isZeroHp = p.currentHp <= 0;
let participantBgColor = p.type === 'monster' || p.type === 'npc'
? 'bg-[#8e351c]'
: 'bg-indigo-950';
@@ -2882,6 +3093,7 @@ function DisplayView() {
)}
{status === 'dying' && <span className="text-red-300 text-lg ml-2">(Dying)</span>}
{status === 'stable' && (p.conditions || []).includes('unconscious') && <span className="text-emerald-300 text-lg ml-2">(Unconscious)</span>}
{status === 'down' && <span className="text-red-300 text-lg ml-2 animate-pulse">(Down)</span>}
{status === 'dead' && <span className="text-red-300 text-lg ml-2">(Dead)</span>}
</h3>
<span