Files
the-sunken-reach/frontend/src/api/queries.ts
T
joshuaandClaude Opus 5.5 94067f1268 V1.0.1: Aldenfell campaign seed, content library, party overview
- Seed now resets the database and seeds the Aldenfell campaign from the
  campaign book: admin DM user, default weapons, locations, bestiary,
  pre-built encounters, and DM/shared notes
- Default weapon/spell/monster library, character profile fields,
  locations and dice mode (new migration)
- Party overview page, pagination, info tips, build preview calc
- Remove stale demo-login hint; notes preserve line breaks

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 10:24:48 +02:00

516 lines
19 KiB
TypeScript

import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { apiClient } from './client';
import type {
Campaign,
CampaignLibrary,
CampaignMemberSummary,
CharacterDetail,
CharacterSummary,
DiceMode,
Encounter,
EncounterParticipant,
InventoryItem,
Location,
Monster,
Note,
QuickStats,
Spell,
Weapon,
} from './types';
// --------------------------------------------------------------------------
// Campaigns
// --------------------------------------------------------------------------
export function useCampaigns() {
return useQuery({
queryKey: ['campaigns'],
queryFn: async () => (await apiClient.get<{ campaigns: Campaign[] }>('/campaigns')).data.campaigns,
});
}
export function useCampaign(campaignId: string | undefined) {
return useQuery({
queryKey: ['campaign', campaignId],
enabled: Boolean(campaignId),
queryFn: async () =>
(
await apiClient.get<{ campaign: Campaign; members: CampaignMemberSummary[] }>(`/campaigns/${campaignId}`)
).data,
});
}
export function useCreateCampaign() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (data: {
name: string;
description?: string;
startingGold?: number;
startingItemCount?: number;
startingSpellCount?: number;
skipMotivationRoll?: boolean;
}) => (await apiClient.post<{ campaign: Campaign }>('/campaigns', data)).data.campaign,
onSuccess: () => qc.invalidateQueries({ queryKey: ['campaigns'] }),
});
}
export function useUpdateCampaign(campaignId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (data: Partial<Campaign>) =>
(await apiClient.patch<{ campaign: Campaign }>(`/campaigns/${campaignId}`, data)).data.campaign,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['campaign', campaignId] });
qc.invalidateQueries({ queryKey: ['campaigns'] });
},
});
}
export function useJoinCampaign() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (inviteCode: string) =>
(await apiClient.post<{ campaign: Campaign }>(`/campaigns/join/${inviteCode}`)).data.campaign,
onSuccess: () => qc.invalidateQueries({ queryKey: ['campaigns'] }),
});
}
export function useRegenerateInvite(campaignId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async () =>
(await apiClient.post<{ inviteCode: string }>(`/campaigns/${campaignId}/invite`, { regenerate: true })).data
.inviteCode,
onSuccess: () => qc.invalidateQueries({ queryKey: ['campaign', campaignId] }),
});
}
// --------------------------------------------------------------------------
// Characters
// --------------------------------------------------------------------------
export function useCampaignCharacters(campaignId: string | undefined) {
return useQuery({
queryKey: ['campaign', campaignId, 'characters'],
enabled: Boolean(campaignId),
queryFn: async () =>
(await apiClient.get<{ characters: CharacterSummary[] }>(`/campaigns/${campaignId}/characters`)).data
.characters,
});
}
export function useCreateCharacter(campaignId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (data: { name: string; userId?: string }) =>
(await apiClient.post<{ character: CharacterSummary }>(`/campaigns/${campaignId}/characters`, data)).data
.character,
onSuccess: () => qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'characters'] }),
});
}
export function useCharacter(characterId: string | undefined) {
return useQuery({
queryKey: ['character', characterId],
enabled: Boolean(characterId),
refetchInterval: false,
queryFn: async () => (await apiClient.get<{ character: CharacterDetail }>(`/characters/${characterId}`)).data
.character,
});
}
export function usePatchCharacter(characterId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (data: Partial<CharacterDetail>) =>
(await apiClient.patch<{ character: CharacterDetail }>(`/characters/${characterId}`, data)).data.character,
onSuccess: (character) => {
qc.setQueryData(['character', characterId], character);
qc.invalidateQueries({ queryKey: ['campaign', character.campaignId, 'characters'] });
},
});
}
export function useDeleteCharacter(campaignId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (characterId: string) => apiClient.delete(`/characters/${characterId}`),
onSuccess: () => qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'characters'] }),
});
}
export function useQuickStats(characterId: string | undefined, enabled: boolean) {
return useQuery({
queryKey: ['character', characterId, 'quick-stats'],
enabled: Boolean(characterId) && enabled,
queryFn: async () =>
(await apiClient.get<{ quickStats: QuickStats }>(`/characters/${characterId}/quick-stats`)).data.quickStats,
});
}
// --------------------------------------------------------------------------
// Character build flow
// --------------------------------------------------------------------------
function invalidateCharacter(qc: ReturnType<typeof useQueryClient>, characterId: string) {
qc.invalidateQueries({ queryKey: ['character', characterId] });
}
export function usePatchDiceMode(characterId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (diceMode: DiceMode) =>
(await apiClient.patch<{ diceMode: DiceMode }>(`/characters/${characterId}/build/dice-mode`, { diceMode })).data
.diceMode,
onSuccess: () => invalidateCharacter(qc, characterId),
});
}
export function useBuildRoll(characterId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (body?: { value?: number; abilitySets?: number[][] }) =>
(await apiClient.post<{ buildStage: string; buildState: Record<string, unknown> }>(
`/characters/${characterId}/build/roll`,
body
)).data,
onSuccess: () => invalidateCharacter(qc, characterId),
});
}
export function useBuildChoose(characterId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (body: Record<string, unknown>) =>
(await apiClient.post<{ buildStage: string; buildState: Record<string, unknown> }>(
`/characters/${characterId}/build/choose`,
body
)).data,
onSuccess: () => invalidateCharacter(qc, characterId),
});
}
export function useBuildFinalize(characterId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async () =>
(await apiClient.post<{ character: CharacterDetail }>(`/characters/${characterId}/build/finalize`)).data
.character,
onSuccess: (character) => {
qc.setQueryData(['character', characterId], character);
qc.invalidateQueries({ queryKey: ['campaign', character.campaignId, 'characters'] });
},
});
}
// --------------------------------------------------------------------------
// Weapons / inventory / spells on a character
// --------------------------------------------------------------------------
export function useAssignWeapon(characterId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (data: { weaponId: string; quantity?: number }) =>
(await apiClient.post(`/characters/${characterId}/weapons`, data)).data,
onSuccess: () => invalidateCharacter(qc, characterId),
});
}
export function usePatchCharacterWeapon(characterId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ weaponId, ...data }: { weaponId: string; equipped?: boolean; quantity?: number }) =>
(await apiClient.patch(`/characters/${characterId}/weapons/${weaponId}`, data)).data,
onSuccess: () => invalidateCharacter(qc, characterId),
});
}
export function useAddInventoryItem(characterId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (data: { name: string; description?: string; quantity?: number }) =>
(await apiClient.post<{ item: InventoryItem }>(`/characters/${characterId}/inventory`, data)).data.item,
onSuccess: () => invalidateCharacter(qc, characterId),
});
}
export function usePatchInventoryItem(characterId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ itemId, ...data }: { itemId: string; quantity?: number; equipped?: boolean }) =>
(await apiClient.patch(`/characters/${characterId}/inventory/${itemId}`, data)).data,
onSuccess: () => invalidateCharacter(qc, characterId),
});
}
export function useDeleteInventoryItem(characterId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (itemId: string) => apiClient.delete(`/characters/${characterId}/inventory/${itemId}`),
onSuccess: () => invalidateCharacter(qc, characterId),
});
}
export function useAssignSpell(characterId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (data: { spellId: string; prepared?: boolean }) =>
(await apiClient.post(`/characters/${characterId}/spells`, data)).data,
onSuccess: () => invalidateCharacter(qc, characterId),
});
}
export function usePatchCharacterSpell(characterId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ spellId, prepared }: { spellId: string; prepared: boolean }) =>
(await apiClient.patch(`/characters/${characterId}/spells/${spellId}`, { prepared })).data,
onSuccess: () => invalidateCharacter(qc, characterId),
});
}
// --------------------------------------------------------------------------
// Campaign weapons / spells / monsters pools
// --------------------------------------------------------------------------
export function useCampaignWeapons(campaignId: string | undefined) {
return useQuery({
queryKey: ['campaign', campaignId, 'weapons'],
enabled: Boolean(campaignId),
queryFn: async () => (await apiClient.get<{ weapons: Weapon[] }>(`/campaigns/${campaignId}/weapons`)).data.weapons,
});
}
export function useCreateWeapon(campaignId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (data: Omit<Weapon, 'id' | 'campaignId'>) =>
(await apiClient.post<{ weapon: Weapon }>(`/campaigns/${campaignId}/weapons`, data)).data.weapon,
onSuccess: () => qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'weapons'] }),
});
}
export function useCampaignSpells(campaignId: string | undefined) {
return useQuery({
queryKey: ['campaign', campaignId, 'spells'],
enabled: Boolean(campaignId),
queryFn: async () => (await apiClient.get<{ spells: Spell[] }>(`/campaigns/${campaignId}/spells`)).data.spells,
});
}
export function useCreateSpell(campaignId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (data: Omit<Spell, 'id' | 'campaignId'>) =>
(await apiClient.post<{ spell: Spell }>(`/campaigns/${campaignId}/spells`, data)).data.spell,
onSuccess: () => qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'spells'] }),
});
}
export function useCampaignMonsters(campaignId: string | undefined) {
return useQuery({
queryKey: ['campaign', campaignId, 'monsters'],
enabled: Boolean(campaignId),
queryFn: async () =>
(await apiClient.get<{ monsters: Monster[] }>(`/campaigns/${campaignId}/monsters`)).data.monsters,
});
}
export function useCreateMonster(campaignId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (data: Omit<Monster, 'id' | 'campaignId'>) =>
(await apiClient.post<{ monster: Monster }>(`/campaigns/${campaignId}/monsters`, data)).data.monster,
onSuccess: () => qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'monsters'] }),
});
}
export function useDeleteMonster(campaignId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (monsterId: string) => apiClient.delete(`/campaigns/${campaignId}/monsters/${monsterId}`),
onSuccess: () => qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'monsters'] }),
});
}
// --------------------------------------------------------------------------
// Default content library — search/browse, "add to campaign"
// --------------------------------------------------------------------------
export function useCampaignLibrary(campaignId: string | undefined) {
return useQuery({
queryKey: ['campaign', campaignId, 'library'],
enabled: Boolean(campaignId),
staleTime: Infinity, // static reference data — never changes under a campaign
queryFn: async () => (await apiClient.get<CampaignLibrary>(`/campaigns/${campaignId}/library`)).data,
});
}
export function useAddWeaponFromLibrary(campaignId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (key: string) =>
(await apiClient.post<{ weapon: Weapon }>(`/campaigns/${campaignId}/weapons/from-library`, { key })).data
.weapon,
onSuccess: () => qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'weapons'] }),
});
}
export function useAddSpellFromLibrary(campaignId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (key: string) =>
(await apiClient.post<{ spell: Spell }>(`/campaigns/${campaignId}/spells/from-library`, { key })).data.spell,
onSuccess: () => qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'spells'] }),
});
}
export function useAddMonsterFromLibrary(campaignId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (key: string) =>
(await apiClient.post<{ monster: Monster }>(`/campaigns/${campaignId}/monsters/from-library`, { key })).data
.monster,
onSuccess: () => qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'monsters'] }),
});
}
// --------------------------------------------------------------------------
// Locations
// --------------------------------------------------------------------------
export function useCampaignLocations(campaignId: string | undefined) {
return useQuery({
queryKey: ['campaign', campaignId, 'locations'],
enabled: Boolean(campaignId),
queryFn: async () =>
(await apiClient.get<{ locations: Location[] }>(`/campaigns/${campaignId}/locations`)).data.locations,
});
}
export function useCreateLocation(campaignId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (data: { name: string; description?: string | null }) =>
(await apiClient.post<{ location: Location }>(`/campaigns/${campaignId}/locations`, data)).data.location,
onSuccess: () => qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'locations'] }),
});
}
export function useDeleteLocation(campaignId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (locationId: string) => apiClient.delete(`/campaigns/${campaignId}/locations/${locationId}`),
onSuccess: () => qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'locations'] }),
});
}
// --------------------------------------------------------------------------
// Encounters
// --------------------------------------------------------------------------
export function useCampaignEncounters(campaignId: string | undefined) {
return useQuery({
queryKey: ['campaign', campaignId, 'encounters'],
enabled: Boolean(campaignId),
queryFn: async () =>
(await apiClient.get<{ encounters: Encounter[] }>(`/campaigns/${campaignId}/encounters`)).data.encounters,
});
}
export function useCreateEncounter(campaignId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (name: string) =>
(await apiClient.post<{ encounter: Encounter }>(`/campaigns/${campaignId}/encounters`, { name })).data
.encounter,
onSuccess: () => qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'encounters'] }),
});
}
export function useEncounter(encounterId: string | undefined) {
return useQuery({
queryKey: ['encounter', encounterId],
enabled: Boolean(encounterId),
queryFn: async () => (await apiClient.get<{ encounter: Encounter }>(`/encounters/${encounterId}`)).data.encounter,
});
}
export function useAddParticipant(encounterId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (data: { characterId?: string; monsterId?: string; displayName?: string; initiative?: number }) =>
(await apiClient.post(`/encounters/${encounterId}/participants`, data)).data,
onSuccess: () => qc.invalidateQueries({ queryKey: ['encounter', encounterId] }),
});
}
export function usePatchParticipant(encounterId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ participantId, ...data }: Partial<EncounterParticipant> & { participantId: string }) =>
(await apiClient.patch(`/encounters/${encounterId}/participants/${participantId}`, data)).data,
onSuccess: () => qc.invalidateQueries({ queryKey: ['encounter', encounterId] }),
});
}
export function useRemoveParticipant(encounterId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (participantId: string) =>
apiClient.delete(`/encounters/${encounterId}/participants/${participantId}`),
onSuccess: () => qc.invalidateQueries({ queryKey: ['encounter', encounterId] }),
});
}
export function useAdvanceRound(encounterId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async () => (await apiClient.post(`/encounters/${encounterId}/advance-round`)).data,
onSuccess: () => qc.invalidateQueries({ queryKey: ['encounter', encounterId] }),
});
}
export function usePatchEncounter(encounterId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (data: { status?: EncounterStatusInput; name?: string }) =>
(await apiClient.patch(`/encounters/${encounterId}`, data)).data,
onSuccess: () => qc.invalidateQueries({ queryKey: ['encounter', encounterId] }),
});
}
type EncounterStatusInput = 'PLANNING' | 'ACTIVE' | 'COMPLETE';
// --------------------------------------------------------------------------
// Notes
// --------------------------------------------------------------------------
export function useCampaignNotes(campaignId: string | undefined) {
return useQuery({
queryKey: ['campaign', campaignId, 'notes'],
enabled: Boolean(campaignId),
queryFn: async () => (await apiClient.get<{ notes: Note[] }>(`/campaigns/${campaignId}/notes`)).data.notes,
});
}
export function useCharacterNotes(characterId: string | undefined) {
return useQuery({
queryKey: ['character', characterId, 'notes'],
enabled: Boolean(characterId),
queryFn: async () => (await apiClient.get<{ notes: Note[] }>(`/characters/${characterId}/notes`)).data.notes,
});
}
export function useCreateNote() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (data: { campaignId?: string; characterId?: string; body: string; visibility?: 'DM_ONLY' | 'SHARED' }) =>
(await apiClient.post<{ note: Note }>('/notes', data)).data.note,
onSuccess: (note) => {
if (note.campaignId) qc.invalidateQueries({ queryKey: ['campaign', note.campaignId, 'notes'] });
if (note.characterId) qc.invalidateQueries({ queryKey: ['character', note.characterId, 'notes'] });
},
});
}