V1.14.1: choose where a new encounter goes

- A new encounter, battle or quest, goes to the bottom of the
  campaign's encounter list. It used to go to the top.
- A menu beside the name when adding one says where it goes instead:
  at the bottom, at the top, or straight after any encounter on the
  list. Top or bottom is remembered per browser.
- All packages at version 1.14.1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-10 11:50:41 +02:00
co-authored by Claude Opus 5.5
parent e9f8169286
commit 61221f262c
9 changed files with 108 additions and 20 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "1.14.0",
"version": "1.14.1",
"type": "module",
"scripts": {
"dev": "vite",
+2 -1
View File
@@ -735,7 +735,8 @@ export function useCampaignEncounters(campaignId: string | undefined) {
export function useCreateEncounter(campaignId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (data: { name: string; mode?: EncounterMode }) =>
// Where it goes in the campaign's order: the top, the bottom (the default), or straight after another.
mutationFn: async (data: { name: string; mode?: EncounterMode; position?: 'TOP' | 'BOTTOM'; afterEncounterId?: string }) =>
(await apiClient.post<{ encounter: Encounter }>(`/campaigns/${campaignId}/encounters`, data)).data.encounter,
onSuccess: () => qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'encounters'] }),
});
+37
View File
@@ -62,6 +62,15 @@ function storedEncounterSort(): EncounterSort {
return stored && stored in ENCOUNTER_SORT_LABELS ? (stored as EncounterSort) : 'custom';
}
// Where a new encounter goes in the campaign's order: the bottom, the top, or
// straight after another one ("after:<id>"). Top or bottom is remembered.
const ENCOUNTER_PLACE_KEY = 'dnd_encounter_place';
const AFTER = 'after:';
function storedEncounterPlace(): string {
return localStorage.getItem(ENCOUNTER_PLACE_KEY) === 'TOP' ? 'TOP' : 'BOTTOM';
}
// `encounters` arrives in campaign order, and the sort is stable, so ties
// (same status, same type) stay in that order.
function sortEncounters(encounters: Encounter[], sort: EncounterSort): Encounter[] {
@@ -161,6 +170,7 @@ export function CampaignHomePage() {
const [newCharName, setNewCharName] = useState('');
const [newEncounterName, setNewEncounterName] = useState('');
const [newEncounterMode, setNewEncounterMode] = useState<EncounterMode>('BATTLE');
const [newEncounterPlace, setNewEncounterPlace] = useState(storedEncounterPlace);
const [encounterSort, setEncounterSort] = useState<EncounterSort>(storedEncounterSort);
const [showArchivedEncounters, setShowArchivedEncounters] = useState(false);
const [noteBody, setNoteBody] = useState('');
@@ -173,6 +183,8 @@ export function CampaignHomePage() {
const [deleteConfirmName, setDeleteConfirmName] = useState('');
const liveEncounters = encounters?.filter((e) => !e.archivedAt) || [];
// An encounter picked to go after may have been archived or deleted since.
const encounterPlace = newEncounterPlace.startsWith(AFTER) && !liveEncounters.some((e) => AFTER + e.id === newEncounterPlace) ? 'BOTTOM' : newEncounterPlace;
const archivedEncounters = encounters?.filter((e) => e.archivedAt) || [];
const shownEncounters = sortEncounters(liveEncounters, encounterSort);
const encountersPage = usePagination(shownEncounters, 10);
@@ -208,6 +220,9 @@ export function CampaignHomePage() {
const encounter = await createEncounter.mutateAsync({
name: newEncounterName || (newEncounterMode === 'QUEST' ? 'New Quest' : 'New Encounter'),
mode: newEncounterMode,
...(encounterPlace.startsWith(AFTER)
? { afterEncounterId: encounterPlace.slice(AFTER.length) }
: { position: encounterPlace === 'TOP' ? ('TOP' as const) : ('BOTTOM' as const) }),
});
navigate(`/encounters/${encounter.id}`);
} catch (err) {
@@ -501,6 +516,28 @@ export function CampaignHomePage() {
<option value="BATTLE">Battle</option>
<option value="QUEST">NPC quest</option>
</select>
{liveEncounters.length > 0 && (
<select
aria-label="Where the new encounter goes in the list"
title="Where it goes in the campaign's order. ▲ and ▼ move it afterwards."
value={encounterPlace}
onChange={(e) => {
setNewEncounterPlace(e.target.value);
if (!e.target.value.startsWith(AFTER)) localStorage.setItem(ENCOUNTER_PLACE_KEY, e.target.value);
}}
style={{ maxWidth: 220 }}
>
<option value="BOTTOM">At the bottom of the list</option>
<option value="TOP">At the top of the list</option>
<optgroup label="Straight after…">
{liveEncounters.map((e) => (
<option key={e.id} value={AFTER + e.id}>
After {e.name}
</option>
))}
</optgroup>
</select>
)}
<button type="submit" className="btn btn-primary" disabled={createEncounter.isPending}>
{newEncounterMode === 'QUEST' ? '📜 Start Quest' : '⚔ Start Encounter'}
</button>