- 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>
516 lines
19 KiB
TypeScript
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'] });
|
|
},
|
|
});
|
|
}
|