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