add crew UI components, store, queries, and mutations
This commit is contained in:
parent
32af6a7788
commit
aee0690b2d
9 changed files with 1041 additions and 0 deletions
258
src/lib/api/mutations/crew.mutations.ts
Normal file
258
src/lib/api/mutations/crew.mutations.ts
Normal file
|
|
@ -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() })
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
213
src/lib/api/mutations/gw.mutations.ts
Normal file
213
src/lib/api/mutations/gw.mutations.ts
Normal file
|
|
@ -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<CreateIndividualScoreInput>
|
||||||
|
}) => 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) })
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
117
src/lib/api/queries/crew.queries.ts
Normal file
117
src/lib/api/queries/crew.queries.ts
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
109
src/lib/api/queries/gw.queries.ts
Normal file
109
src/lib/api/queries/gw.queries.ts
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
90
src/lib/components/crew/CrewHeader.svelte
Normal file
90
src/lib/components/crew/CrewHeader.svelte
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
<svelte:options runes={true} />
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from 'svelte'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Main title text */
|
||||||
|
title: string
|
||||||
|
/** Optional subtitle (e.g., gamertag) */
|
||||||
|
subtitle?: string | undefined
|
||||||
|
/** Optional description text */
|
||||||
|
description?: string | undefined
|
||||||
|
/** Content rendered below title (e.g., filter tabs) */
|
||||||
|
belowTitle?: Snippet | undefined
|
||||||
|
/** Action buttons on the right */
|
||||||
|
actions?: Snippet | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const { title, subtitle, description, belowTitle, actions }: Props = $props()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="section-header">
|
||||||
|
<div class="header-info">
|
||||||
|
<div class="title-row">
|
||||||
|
<span class="header-title">{title}</span>
|
||||||
|
{#if subtitle}
|
||||||
|
<span class="header-subtitle">[{subtitle}]</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if description}
|
||||||
|
<p class="header-description">{description}</p>
|
||||||
|
{/if}
|
||||||
|
{#if belowTitle}
|
||||||
|
{@render belowTitle()}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if actions}
|
||||||
|
<div class="header-actions">
|
||||||
|
{@render actions()}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@use '$src/themes/spacing' as spacing;
|
||||||
|
@use '$src/themes/typography' as typography;
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: spacing.$unit-2x;
|
||||||
|
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: spacing.$unit-half;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: spacing.$unit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-title {
|
||||||
|
font-size: typography.$font-regular;
|
||||||
|
font-weight: typography.$medium;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-subtitle {
|
||||||
|
font-size: typography.$font-small;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-description {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: typography.$font-small;
|
||||||
|
line-height: 1.5;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: spacing.$unit;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
25
src/lib/components/ui/ModalBody.svelte
Normal file
25
src/lib/components/ui/ModalBody.svelte
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
<svelte:options runes={true} />
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from 'svelte'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: Snippet
|
||||||
|
}
|
||||||
|
|
||||||
|
let { children }: Props = $props()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="modal-body">
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@use '$src/themes/spacing' as spacing;
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
padding: spacing.$unit-2x;
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
27
src/lib/components/ui/ModalFooter.svelte
Normal file
27
src/lib/components/ui/ModalFooter.svelte
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
<svelte:options runes={true} />
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from 'svelte'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: Snippet
|
||||||
|
}
|
||||||
|
|
||||||
|
let { children }: Props = $props()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="modal-footer">
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@use '$src/themes/spacing' as spacing;
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
padding: spacing.$unit-2x;
|
||||||
|
padding-top: 0;
|
||||||
|
display: flex;
|
||||||
|
gap: spacing.$unit-2x;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
54
src/lib/components/ui/ModalHeader.svelte
Normal file
54
src/lib/components/ui/ModalHeader.svelte
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
<svelte:options runes={true} />
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from 'svelte'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
children?: Snippet
|
||||||
|
}
|
||||||
|
|
||||||
|
let { title, description, children }: Props = $props()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="modal-header">
|
||||||
|
<div class="header-text">
|
||||||
|
<h2 class="title">{title}</h2>
|
||||||
|
{#if description}
|
||||||
|
<p class="description">{description}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if children}
|
||||||
|
{@render children()}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@use '$src/themes/spacing' as spacing;
|
||||||
|
@use '$src/themes/typography' as typography;
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
padding: spacing.$unit-2x;
|
||||||
|
padding-bottom: spacing.$unit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: spacing.$unit-half;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: typography.$font-large;
|
||||||
|
font-weight: typography.$bold;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
font-size: typography.$font-small;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
148
src/lib/stores/crew.store.svelte.ts
Normal file
148
src/lib/stores/crew.store.svelte.ts
Normal file
|
|
@ -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<Crew | null>(null)
|
||||||
|
membership = $state<CrewMembership | null>(null)
|
||||||
|
pendingInvitations = $state<CrewInvitation[]>([])
|
||||||
|
isLoading = $state(false)
|
||||||
|
error = $state<string | null>(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()
|
||||||
Loading…
Reference in a new issue