diff --git a/src/lib/api/mutations/crew.mutations.ts b/src/lib/api/mutations/crew.mutations.ts new file mode 100644 index 00000000..82ad25e1 --- /dev/null +++ b/src/lib/api/mutations/crew.mutations.ts @@ -0,0 +1,258 @@ +/** + * Crew Mutation Configurations + * + * Provides mutation configurations for crew CRUD operations + * with cache invalidation using TanStack Query v6. + * + * @module api/mutations/crew + */ + +import { useQueryClient, createMutation } from '@tanstack/svelte-query' +import { crewAdapter } from '$lib/api/adapters/crew.adapter' +import { crewKeys } from '$lib/api/queries/crew.queries' +import type { + CreateCrewInput, + UpdateCrewInput, + CreatePhantomPlayerInput, + UpdatePhantomPlayerInput, + UpdateMembershipInput +} from '$lib/types/api/crew' + +// ==================== Crew Mutations ==================== + +/** + * Create crew mutation + */ +export function useCreateCrew() { + const queryClient = useQueryClient() + + return createMutation(() => ({ + mutationFn: (input: CreateCrewInput) => crewAdapter.create(input), + onSuccess: (crew) => { + queryClient.setQueryData(crewKeys.myCrew(), crew) + queryClient.invalidateQueries({ queryKey: crewKeys.invitations.pending() }) + } + })) +} + +/** + * Update crew mutation + */ +export function useUpdateCrew() { + const queryClient = useQueryClient() + + return createMutation(() => ({ + mutationFn: (input: UpdateCrewInput) => crewAdapter.update(input), + onSuccess: (crew) => { + queryClient.setQueryData(crewKeys.myCrew(), crew) + } + })) +} + +/** + * Leave crew mutation + */ +export function useLeaveCrew() { + const queryClient = useQueryClient() + + return createMutation(() => ({ + mutationFn: () => crewAdapter.leave(), + onSuccess: () => { + queryClient.removeQueries({ queryKey: crewKeys.myCrew() }) + queryClient.removeQueries({ queryKey: crewKeys.membersAll() }) + } + })) +} + +/** + * Transfer captain mutation + */ +export function useTransferCaptain() { + const queryClient = useQueryClient() + + return createMutation(() => ({ + mutationFn: ({ crewId, userId }: { crewId: string; userId: string }) => + crewAdapter.transferCaptain(crewId, userId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: crewKeys.myCrew() }) + queryClient.invalidateQueries({ queryKey: crewKeys.membersAll() }) + } + })) +} + +// ==================== Membership Mutations ==================== + +/** + * Update membership (promote/demote or update joined_at) mutation + */ +export function useUpdateMembership() { + const queryClient = useQueryClient() + + return createMutation(() => ({ + mutationFn: ({ + crewId, + membershipId, + input + }: { + crewId: string + membershipId: string + input: UpdateMembershipInput + }) => crewAdapter.updateMembership(crewId, membershipId, input), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: crewKeys.membersAll() }) + } + })) +} + +/** + * Remove member mutation + */ +export function useRemoveMember() { + const queryClient = useQueryClient() + + return createMutation(() => ({ + mutationFn: ({ crewId, membershipId }: { crewId: string; membershipId: string }) => + crewAdapter.removeMember(crewId, membershipId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: crewKeys.membersAll() }) + queryClient.invalidateQueries({ queryKey: crewKeys.myCrew() }) + } + })) +} + +// ==================== Invitation Mutations ==================== + +/** + * Send invitation mutation + */ +export function useSendInvitation() { + const queryClient = useQueryClient() + + return createMutation(() => ({ + mutationFn: ({ crewId, userId }: { crewId: string; userId: string }) => + crewAdapter.sendInvitation(crewId, userId), + onSuccess: (_invitation, { crewId }) => { + queryClient.invalidateQueries({ queryKey: crewKeys.crewInvitations(crewId) }) + } + })) +} + +/** + * Accept invitation mutation + */ +export function useAcceptInvitation() { + const queryClient = useQueryClient() + + return createMutation(() => ({ + mutationFn: (invitationId: string) => crewAdapter.acceptInvitation(invitationId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: crewKeys.myCrew() }) + queryClient.invalidateQueries({ queryKey: crewKeys.invitations.pending() }) + } + })) +} + +/** + * Reject invitation mutation + */ +export function useRejectInvitation() { + const queryClient = useQueryClient() + + return createMutation(() => ({ + mutationFn: (invitationId: string) => crewAdapter.rejectInvitation(invitationId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: crewKeys.invitations.pending() }) + } + })) +} + +// ==================== Phantom Player Mutations ==================== + +/** + * Create phantom player mutation + */ +export function useCreatePhantom() { + const queryClient = useQueryClient() + + return createMutation(() => ({ + mutationFn: ({ crewId, input }: { crewId: string; input: CreatePhantomPlayerInput }) => + crewAdapter.createPhantom(crewId, input), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: crewKeys.membersAll() }) + } + })) +} + +/** + * Update phantom player mutation + */ +export function useUpdatePhantom() { + const queryClient = useQueryClient() + + return createMutation(() => ({ + mutationFn: ({ + crewId, + phantomId, + input + }: { + crewId: string + phantomId: string + input: UpdatePhantomPlayerInput + }) => crewAdapter.updatePhantom(crewId, phantomId, input), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: crewKeys.membersAll() }) + } + })) +} + +/** + * Delete phantom player mutation + */ +export function useDeletePhantom() { + const queryClient = useQueryClient() + + return createMutation(() => ({ + mutationFn: ({ crewId, phantomId }: { crewId: string; phantomId: string }) => + crewAdapter.deletePhantom(crewId, phantomId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: crewKeys.membersAll() }) + } + })) +} + +/** + * Assign phantom player mutation + */ +export function useAssignPhantom() { + const queryClient = useQueryClient() + + return createMutation(() => ({ + mutationFn: ({ + crewId, + phantomId, + userId + }: { + crewId: string + phantomId: string + userId: string + }) => crewAdapter.assignPhantom(crewId, phantomId, userId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: crewKeys.membersAll() }) + } + })) +} + +/** + * Confirm phantom claim mutation + */ +export function useConfirmPhantomClaim() { + const queryClient = useQueryClient() + + return createMutation(() => ({ + mutationFn: ({ crewId, phantomId }: { crewId: string; phantomId: string }) => + crewAdapter.confirmPhantomClaim(crewId, phantomId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: crewKeys.membersAll() }) + } + })) +} diff --git a/src/lib/api/mutations/gw.mutations.ts b/src/lib/api/mutations/gw.mutations.ts new file mode 100644 index 00000000..bd5ad1e1 --- /dev/null +++ b/src/lib/api/mutations/gw.mutations.ts @@ -0,0 +1,213 @@ +/** + * GW (Guild War) Mutation Configurations + * + * Provides mutation configurations for GW operations + * with cache invalidation using TanStack Query v6. + * + * @module api/mutations/gw + */ + +import { useQueryClient, createMutation } from '@tanstack/svelte-query' +import { gwAdapter } from '$lib/api/adapters/gw.adapter' +import { gwKeys } from '$lib/api/queries/gw.queries' +import type { + CreateGwEventInput, + UpdateGwEventInput, + UpdateParticipationRankingInput, + CreateCrewScoreInput, + UpdateCrewScoreInput, + CreateIndividualScoreInput, + BatchIndividualScoresInput +} from '$lib/types/api/gw' + +// ==================== Event Mutations (Admin) ==================== + +/** + * Create GW event mutation (admin only) + */ +export function useCreateGwEvent() { + const queryClient = useQueryClient() + + return createMutation(() => ({ + mutationFn: (input: CreateGwEventInput) => gwAdapter.createEvent(input), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: gwKeys.events() }) + } + })) +} + +/** + * Update GW event mutation (admin only) + */ +export function useUpdateGwEvent() { + const queryClient = useQueryClient() + + return createMutation(() => ({ + mutationFn: ({ eventId, input }: { eventId: string; input: UpdateGwEventInput }) => + gwAdapter.updateEvent(eventId, input), + onSuccess: (_data, { eventId }) => { + queryClient.invalidateQueries({ queryKey: gwKeys.events() }) + queryClient.invalidateQueries({ queryKey: gwKeys.event(eventId) }) + } + })) +} + +// ==================== Participation Mutations ==================== + +/** + * Join GW event mutation + */ +export function useJoinGwEvent() { + const queryClient = useQueryClient() + + return createMutation(() => ({ + mutationFn: (eventId: string) => gwAdapter.joinEvent(eventId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: gwKeys.participationsAll() }) + } + })) +} + +/** + * Update participation ranking mutation + */ +export function useUpdateParticipationRanking() { + const queryClient = useQueryClient() + + return createMutation(() => ({ + mutationFn: ({ + participationId, + input + }: { + participationId: string + input: UpdateParticipationRankingInput + }) => gwAdapter.updateParticipationRanking(participationId, input), + onSuccess: (_data, { participationId }) => { + queryClient.invalidateQueries({ queryKey: gwKeys.participationsAll() }) + queryClient.invalidateQueries({ queryKey: gwKeys.participation(participationId) }) + } + })) +} + +// ==================== Crew Score Mutations ==================== + +/** + * Add crew score mutation + */ +export function useAddCrewScore() { + const queryClient = useQueryClient() + + return createMutation(() => ({ + mutationFn: ({ + participationId, + input + }: { + participationId: string + input: CreateCrewScoreInput + }) => gwAdapter.addCrewScore(participationId, input), + onSuccess: (_data, { participationId }) => { + queryClient.invalidateQueries({ queryKey: gwKeys.participation(participationId) }) + } + })) +} + +/** + * Update crew score mutation + */ +export function useUpdateCrewScore() { + const queryClient = useQueryClient() + + return createMutation(() => ({ + mutationFn: ({ + participationId, + scoreId, + input + }: { + participationId: string + scoreId: string + input: UpdateCrewScoreInput + }) => gwAdapter.updateCrewScore(participationId, scoreId, input), + onSuccess: (_data, { participationId }) => { + queryClient.invalidateQueries({ queryKey: gwKeys.participation(participationId) }) + } + })) +} + +// ==================== Individual Score Mutations ==================== + +/** + * Add individual score mutation + */ +export function useAddIndividualScore() { + const queryClient = useQueryClient() + + return createMutation(() => ({ + mutationFn: ({ + participationId, + input + }: { + participationId: string + input: CreateIndividualScoreInput + }) => gwAdapter.addIndividualScore(participationId, input), + onSuccess: (_data, { participationId }) => { + queryClient.invalidateQueries({ queryKey: gwKeys.participation(participationId) }) + } + })) +} + +/** + * Batch add individual scores mutation + */ +export function useBatchAddIndividualScores() { + const queryClient = useQueryClient() + + return createMutation(() => ({ + mutationFn: ({ + participationId, + input + }: { + participationId: string + input: BatchIndividualScoresInput + }) => gwAdapter.batchAddIndividualScores(participationId, input), + onSuccess: (_data, { participationId }) => { + queryClient.invalidateQueries({ queryKey: gwKeys.participation(participationId) }) + } + })) +} + +/** + * Update individual score mutation + */ +export function useUpdateIndividualScore() { + const queryClient = useQueryClient() + + return createMutation(() => ({ + mutationFn: ({ + participationId, + scoreId, + input + }: { + participationId: string + scoreId: string + input: Partial + }) => gwAdapter.updateIndividualScore(participationId, scoreId, input), + onSuccess: (_data, { participationId }) => { + queryClient.invalidateQueries({ queryKey: gwKeys.participation(participationId) }) + } + })) +} + +/** + * Delete individual score mutation + */ +export function useDeleteIndividualScore() { + const queryClient = useQueryClient() + + return createMutation(() => ({ + mutationFn: ({ participationId, scoreId }: { participationId: string; scoreId: string }) => + gwAdapter.deleteIndividualScore(participationId, scoreId), + onSuccess: (_data, { participationId }) => { + queryClient.invalidateQueries({ queryKey: gwKeys.participation(participationId) }) + } + })) +} diff --git a/src/lib/api/queries/crew.queries.ts b/src/lib/api/queries/crew.queries.ts new file mode 100644 index 00000000..2fc574c1 --- /dev/null +++ b/src/lib/api/queries/crew.queries.ts @@ -0,0 +1,117 @@ +/** + * Crew Query Options Factory + * + * Provides type-safe, reusable query configurations for crew operations + * using TanStack Query v6 patterns. + * + * @module api/queries/crew + */ + +import { queryOptions } from '@tanstack/svelte-query' +import { crewAdapter } from '$lib/api/adapters/crew.adapter' +import type { MemberFilter } from '$lib/types/api/crew' + +/** + * Crew query options factory + * + * @example + * ```typescript + * import { createQuery } from '@tanstack/svelte-query' + * import { crewQueries } from '$lib/api/queries/crew.queries' + * + * // Current user's crew + * const crew = createQuery(() => crewQueries.myCrew()) + * + * // Crew members + * const members = createQuery(() => crewQueries.members('active')) + * + * // Pending invitations + * const invitations = createQuery(() => crewQueries.pendingInvitations()) + * ``` + */ +export const crewQueries = { + /** + * Current user's crew query options + */ + myCrew: () => + queryOptions({ + queryKey: ['crew', 'my'] as const, + queryFn: () => crewAdapter.getMyCrew(), + staleTime: 1000 * 60 * 5, // 5 minutes + gcTime: 1000 * 60 * 30, // 30 minutes + retry: (failureCount, error) => { + // Don't retry on 404 (no crew) + if (error && 'status' in error && error.status === 404) { + return false + } + return failureCount < 3 + } + }), + + /** + * Crew members query options + * + * @param filter - 'active' (default), 'retired', 'phantom', 'all' + */ + members: (filter: MemberFilter = 'active') => + queryOptions({ + queryKey: ['crew', 'members', filter] as const, + queryFn: () => crewAdapter.getMembers(filter), + staleTime: 1000 * 60 * 2, // 2 minutes + gcTime: 1000 * 60 * 15 // 15 minutes + }), + + /** + * Crew's sent invitations query options + * + * @param crewId - Crew ID + */ + crewInvitations: (crewId: string) => + queryOptions({ + queryKey: ['crew', crewId, 'invitations'] as const, + queryFn: () => crewAdapter.getCrewInvitations(crewId), + enabled: !!crewId, + staleTime: 1000 * 60 * 2, // 2 minutes + gcTime: 1000 * 60 * 15 // 15 minutes + }), + + /** + * Current user's pending invitations query options + */ + pendingInvitations: () => + queryOptions({ + queryKey: ['invitations', 'pending'] as const, + queryFn: () => crewAdapter.getPendingInvitations(), + staleTime: 1000 * 60 * 2, // 2 minutes + gcTime: 1000 * 60 * 15 // 15 minutes + }) +} + +/** + * Query key helpers for cache invalidation + * + * @example + * ```typescript + * import { useQueryClient } from '@tanstack/svelte-query' + * import { crewKeys } from '$lib/api/queries/crew.queries' + * + * const queryClient = useQueryClient() + * + * // Invalidate current crew + * queryClient.invalidateQueries({ queryKey: crewKeys.myCrew() }) + * + * // Invalidate all member queries + * queryClient.invalidateQueries({ queryKey: crewKeys.membersAll() }) + * ``` + */ +export const crewKeys = { + all: ['crew'] as const, + myCrew: () => [...crewKeys.all, 'my'] as const, + membersAll: () => [...crewKeys.all, 'members'] as const, + members: (filter: MemberFilter) => [...crewKeys.all, 'members', filter] as const, + crewInvitations: (crewId: string) => [...crewKeys.all, crewId, 'invitations'] as const, + invitations: { + all: ['invitations'] as const, + pending: () => ['invitations', 'pending'] as const + } +} diff --git a/src/lib/api/queries/gw.queries.ts b/src/lib/api/queries/gw.queries.ts new file mode 100644 index 00000000..a96fb714 --- /dev/null +++ b/src/lib/api/queries/gw.queries.ts @@ -0,0 +1,109 @@ +/** + * GW (Guild War / Unite and Fight) Query Options Factory + * + * Provides type-safe, reusable query configurations for GW operations + * using TanStack Query v6 patterns. + * + * @module api/queries/gw + */ + +import { queryOptions } from '@tanstack/svelte-query' +import { gwAdapter } from '$lib/api/adapters/gw.adapter' + +/** + * GW query options factory + * + * @example + * ```typescript + * import { createQuery } from '@tanstack/svelte-query' + * import { gwQueries } from '$lib/api/queries/gw.queries' + * + * // All GW events + * const events = createQuery(() => gwQueries.events()) + * + * // Single event + * const event = createQuery(() => gwQueries.event(eventId)) + * + * // Crew's participations + * const participations = createQuery(() => gwQueries.participations()) + * + * // Single participation with scores + * const participation = createQuery(() => gwQueries.participation(participationId)) + * ``` + */ +export const gwQueries = { + /** + * All GW events query options + */ + events: () => + queryOptions({ + queryKey: ['gw', 'events'] as const, + queryFn: () => gwAdapter.getEvents(), + staleTime: 1000 * 60 * 10, // 10 minutes - events don't change often + gcTime: 1000 * 60 * 60 // 1 hour + }), + + /** + * Single GW event query options + * + * @param eventId - Event ID + */ + event: (eventId: string) => + queryOptions({ + queryKey: ['gw', 'events', eventId] as const, + queryFn: () => gwAdapter.getEvent(eventId), + enabled: !!eventId, + staleTime: 1000 * 60 * 10, // 10 minutes + gcTime: 1000 * 60 * 60 // 1 hour + }), + + /** + * Crew's GW participations query options + */ + participations: () => + queryOptions({ + queryKey: ['gw', 'participations'] as const, + queryFn: () => gwAdapter.getParticipations(), + staleTime: 1000 * 60 * 5, // 5 minutes + gcTime: 1000 * 60 * 30 // 30 minutes + }), + + /** + * Single participation with scores query options + * + * @param participationId - Participation ID + */ + participation: (participationId: string) => + queryOptions({ + queryKey: ['gw', 'participations', participationId] as const, + queryFn: () => gwAdapter.getParticipation(participationId), + enabled: !!participationId, + staleTime: 1000 * 60 * 2, // 2 minutes - scores change during event + gcTime: 1000 * 60 * 15 // 15 minutes + }) +} + +/** + * Query key helpers for cache invalidation + * + * @example + * ```typescript + * import { useQueryClient } from '@tanstack/svelte-query' + * import { gwKeys } from '$lib/api/queries/gw.queries' + * + * const queryClient = useQueryClient() + * + * // Invalidate all events + * queryClient.invalidateQueries({ queryKey: gwKeys.events() }) + * + * // Invalidate a specific participation + * queryClient.invalidateQueries({ queryKey: gwKeys.participation(participationId) }) + * ``` + */ +export const gwKeys = { + all: ['gw'] as const, + events: () => [...gwKeys.all, 'events'] as const, + event: (eventId: string) => [...gwKeys.all, 'events', eventId] as const, + participationsAll: () => [...gwKeys.all, 'participations'] as const, + participation: (participationId: string) => [...gwKeys.all, 'participations', participationId] as const +} diff --git a/src/lib/components/crew/CrewHeader.svelte b/src/lib/components/crew/CrewHeader.svelte new file mode 100644 index 00000000..82f3fc00 --- /dev/null +++ b/src/lib/components/crew/CrewHeader.svelte @@ -0,0 +1,90 @@ + + + + +
+
+
+ {title} + {#if subtitle} + [{subtitle}] + {/if} +
+ {#if description} +

{description}

+ {/if} + {#if belowTitle} + {@render belowTitle()} + {/if} +
+ {#if actions} +
+ {@render actions()} +
+ {/if} +
+ + diff --git a/src/lib/components/ui/ModalBody.svelte b/src/lib/components/ui/ModalBody.svelte new file mode 100644 index 00000000..7f2d87c2 --- /dev/null +++ b/src/lib/components/ui/ModalBody.svelte @@ -0,0 +1,25 @@ + + + + + + + diff --git a/src/lib/components/ui/ModalFooter.svelte b/src/lib/components/ui/ModalFooter.svelte new file mode 100644 index 00000000..267f80af --- /dev/null +++ b/src/lib/components/ui/ModalFooter.svelte @@ -0,0 +1,27 @@ + + + + + + + diff --git a/src/lib/components/ui/ModalHeader.svelte b/src/lib/components/ui/ModalHeader.svelte new file mode 100644 index 00000000..86cbab5e --- /dev/null +++ b/src/lib/components/ui/ModalHeader.svelte @@ -0,0 +1,54 @@ + + + + + + + diff --git a/src/lib/stores/crew.store.svelte.ts b/src/lib/stores/crew.store.svelte.ts new file mode 100644 index 00000000..22d8f338 --- /dev/null +++ b/src/lib/stores/crew.store.svelte.ts @@ -0,0 +1,148 @@ +/** + * Crew Store + * + * Manages the current user's crew state using Svelte 5 runes. + * This store is populated after auth and provides convenient + * accessors for crew-related permissions. + */ + +import type { Crew, CrewMembership, CrewInvitation } from '$lib/types/api/crew' + +class CrewStore { + // Core state + crew = $state(null) + membership = $state(null) + pendingInvitations = $state([]) + isLoading = $state(false) + error = $state(null) + + // Computed properties + get isInCrew(): boolean { + return this.crew !== null + } + + get isCaptain(): boolean { + return this.membership?.role === 'captain' + } + + get isViceCaptain(): boolean { + return this.membership?.role === 'vice_captain' + } + + get isOfficer(): boolean { + return this.isCaptain || this.isViceCaptain + } + + get isMember(): boolean { + return this.membership?.role === 'member' + } + + get canManageMembers(): boolean { + return this.isOfficer + } + + get canEditCrew(): boolean { + return this.isOfficer + } + + get canTransferCaptain(): boolean { + return this.isCaptain + } + + get canLeaveCrew(): boolean { + return this.isInCrew && !this.isCaptain + } + + get canManagePhantoms(): boolean { + return this.isOfficer + } + + get canRecordOthersScores(): boolean { + return this.isOfficer + } + + get hasPendingInvitations(): boolean { + return this.pendingInvitations.length > 0 + } + + get pendingInvitationCount(): number { + return this.pendingInvitations.length + } + + // Actions + setCrew(crew: Crew | null, membership: CrewMembership | null = null) { + this.crew = crew + this.membership = membership + this.error = null + } + + setMembership(membership: CrewMembership | null) { + this.membership = membership + } + + setPendingInvitations(invitations: CrewInvitation[]) { + this.pendingInvitations = invitations + } + + addPendingInvitation(invitation: CrewInvitation) { + this.pendingInvitations = [...this.pendingInvitations, invitation] + } + + removePendingInvitation(invitationId: string) { + this.pendingInvitations = this.pendingInvitations.filter((inv) => inv.id !== invitationId) + } + + setLoading(loading: boolean) { + this.isLoading = loading + } + + setError(error: string | null) { + this.error = error + } + + clear() { + this.crew = null + this.membership = null + this.pendingInvitations = [] + this.isLoading = false + this.error = null + } + + /** + * Check if current user can perform an action on a specific member + */ + canActOnMember(memberRole: 'member' | 'vice_captain' | 'captain'): boolean { + if (!this.isOfficer) return false + + // Captain can act on anyone except themselves + if (this.isCaptain) return true + + // Vice captain can only act on regular members + if (this.isViceCaptain && memberRole === 'member') return true + + return false + } + + /** + * Check if current user can promote a member to a specific role + */ + canPromoteTo(targetRole: 'vice_captain' | 'captain'): boolean { + // Only captain can promote to vice_captain + if (targetRole === 'vice_captain') return this.isCaptain + + // No one can promote to captain (must transfer) + return false + } + + /** + * Check if current user can demote a member + */ + canDemote(memberRole: 'vice_captain' | 'captain'): boolean { + // Only captain can demote vice captains + if (memberRole === 'vice_captain') return this.isCaptain + + return false + } +} + +export const crewStore = new CrewStore()