crew members page improvements
- edit join date dialog - add phantom with join date - dropdown menus for member actions - disable scout when roster full (30) - invalidate gw queries on join date change
This commit is contained in:
parent
f4d04a7073
commit
32af6a7788
3 changed files with 1302 additions and 0 deletions
277
src/lib/api/adapters/crew.adapter.ts
Normal file
277
src/lib/api/adapters/crew.adapter.ts
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
import { BaseAdapter } from './base.adapter'
|
||||
import { DEFAULT_ADAPTER_CONFIG } from './config'
|
||||
import type { RequestOptions } from './types'
|
||||
import type {
|
||||
Crew,
|
||||
CrewMembership,
|
||||
CrewMembersResponse,
|
||||
CrewInvitation,
|
||||
PhantomPlayer,
|
||||
CreateCrewInput,
|
||||
UpdateCrewInput,
|
||||
CreatePhantomPlayerInput,
|
||||
UpdatePhantomPlayerInput,
|
||||
UpdateMembershipInput,
|
||||
MemberFilter
|
||||
} from '$lib/types/api/crew'
|
||||
|
||||
/**
|
||||
* Adapter for crew-related API operations
|
||||
*/
|
||||
export class CrewAdapter extends BaseAdapter {
|
||||
// ==================== Crew Operations ====================
|
||||
|
||||
/**
|
||||
* Get current user's crew
|
||||
*/
|
||||
async getMyCrew(options?: RequestOptions): Promise<Crew> {
|
||||
const response = await this.request<{ crew: Crew }>('/crew', options)
|
||||
return response.crew
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new crew (user becomes captain)
|
||||
*/
|
||||
async create(input: CreateCrewInput, options?: RequestOptions): Promise<Crew> {
|
||||
const response = await this.request<{ crew: Crew }>('/crews', {
|
||||
...options,
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ crew: input })
|
||||
})
|
||||
return response.crew
|
||||
}
|
||||
|
||||
/**
|
||||
* Update current user's crew (officers only)
|
||||
*/
|
||||
async update(input: UpdateCrewInput, options?: RequestOptions): Promise<Crew> {
|
||||
const response = await this.request<{ crew: Crew }>('/crew', {
|
||||
...options,
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ crew: input })
|
||||
})
|
||||
this.clearCache('/crew')
|
||||
return response.crew
|
||||
}
|
||||
|
||||
/**
|
||||
* Get crew members with optional filter
|
||||
* @param filter - 'active' (default), 'retired', 'phantom', 'all'
|
||||
*/
|
||||
async getMembers(filter: MemberFilter = 'active', options?: RequestOptions): Promise<CrewMembersResponse> {
|
||||
const params = filter !== 'active' ? { filter } : undefined
|
||||
return this.request<CrewMembersResponse>('/crew/members', { ...options, params })
|
||||
}
|
||||
|
||||
/**
|
||||
* Leave current crew (not available for captain)
|
||||
*/
|
||||
async leave(options?: RequestOptions): Promise<void> {
|
||||
await this.request<void>('/crew/leave', {
|
||||
...options,
|
||||
method: 'POST'
|
||||
})
|
||||
this.clearCache('/crew')
|
||||
this.clearCache('/crew/members')
|
||||
}
|
||||
|
||||
/**
|
||||
* Transfer captain role to another member (captain only)
|
||||
*/
|
||||
async transferCaptain(crewId: string, userId: string, options?: RequestOptions): Promise<Crew> {
|
||||
const response = await this.request<{ crew: Crew }>(`/crews/${crewId}/transfer_captain`, {
|
||||
...options,
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ user_id: userId })
|
||||
})
|
||||
this.clearCache('/crew')
|
||||
this.clearCache('/crew/members')
|
||||
return response.crew
|
||||
}
|
||||
|
||||
// ==================== Membership Operations ====================
|
||||
|
||||
/**
|
||||
* Update a member's role or joined_at (officers for joined_at, captain for role)
|
||||
*/
|
||||
async updateMembership(
|
||||
crewId: string,
|
||||
membershipId: string,
|
||||
input: UpdateMembershipInput,
|
||||
options?: RequestOptions
|
||||
): Promise<CrewMembership> {
|
||||
const response = await this.request<{ membership: CrewMembership }>(
|
||||
`/crews/${crewId}/memberships/${membershipId}`,
|
||||
{
|
||||
...options,
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ membership: input })
|
||||
}
|
||||
)
|
||||
this.clearCache('/crew/members')
|
||||
return response.membership
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a member from crew (officers only)
|
||||
*/
|
||||
async removeMember(crewId: string, membershipId: string, options?: RequestOptions): Promise<void> {
|
||||
await this.request<void>(`/crews/${crewId}/memberships/${membershipId}`, {
|
||||
...options,
|
||||
method: 'DELETE'
|
||||
})
|
||||
this.clearCache('/crew/members')
|
||||
}
|
||||
|
||||
// ==================== Invitation Operations ====================
|
||||
|
||||
/**
|
||||
* Send invitation to a user (officers only)
|
||||
*/
|
||||
async sendInvitation(crewId: string, userId: string, options?: RequestOptions): Promise<CrewInvitation> {
|
||||
const response = await this.request<{ invitation: CrewInvitation }>(`/crews/${crewId}/invitations`, {
|
||||
...options,
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ user_id: userId })
|
||||
})
|
||||
return response.invitation
|
||||
}
|
||||
|
||||
/**
|
||||
* Get crew's sent invitations
|
||||
*/
|
||||
async getCrewInvitations(crewId: string, options?: RequestOptions): Promise<CrewInvitation[]> {
|
||||
const response = await this.request<{ invitations: CrewInvitation[] }>(
|
||||
`/crews/${crewId}/invitations`,
|
||||
options
|
||||
)
|
||||
return response.invitations
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current user's pending invitations
|
||||
*/
|
||||
async getPendingInvitations(options?: RequestOptions): Promise<CrewInvitation[]> {
|
||||
const response = await this.request<{ invitations: CrewInvitation[] }>('/invitations/pending', options)
|
||||
return response.invitations
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept an invitation
|
||||
*/
|
||||
async acceptInvitation(invitationId: string, options?: RequestOptions): Promise<CrewMembership> {
|
||||
const response = await this.request<{ membership: CrewMembership }>(
|
||||
`/invitations/${invitationId}/accept`,
|
||||
{
|
||||
...options,
|
||||
method: 'POST'
|
||||
}
|
||||
)
|
||||
this.clearCache('/crew')
|
||||
this.clearCache('/invitations/pending')
|
||||
return response.membership
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject an invitation
|
||||
*/
|
||||
async rejectInvitation(invitationId: string, options?: RequestOptions): Promise<void> {
|
||||
await this.request<void>(`/invitations/${invitationId}/reject`, {
|
||||
...options,
|
||||
method: 'POST'
|
||||
})
|
||||
this.clearCache('/invitations/pending')
|
||||
}
|
||||
|
||||
// ==================== Phantom Player Operations ====================
|
||||
|
||||
/**
|
||||
* Create a phantom player (officers only)
|
||||
*/
|
||||
async createPhantom(
|
||||
crewId: string,
|
||||
input: CreatePhantomPlayerInput,
|
||||
options?: RequestOptions
|
||||
): Promise<PhantomPlayer> {
|
||||
const response = await this.request<{ phantom_player: PhantomPlayer }>(
|
||||
`/crews/${crewId}/phantom_players`,
|
||||
{
|
||||
...options,
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ phantom_player: input })
|
||||
}
|
||||
)
|
||||
this.clearCache('/crew/members')
|
||||
return response.phantom_player
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a phantom player
|
||||
*/
|
||||
async updatePhantom(
|
||||
crewId: string,
|
||||
phantomId: string,
|
||||
input: UpdatePhantomPlayerInput,
|
||||
options?: RequestOptions
|
||||
): Promise<PhantomPlayer> {
|
||||
const response = await this.request<{ phantom_player: PhantomPlayer }>(
|
||||
`/crews/${crewId}/phantom_players/${phantomId}`,
|
||||
{
|
||||
...options,
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ phantom_player: input })
|
||||
}
|
||||
)
|
||||
this.clearCache('/crew/members')
|
||||
return response.phantom_player
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a phantom player
|
||||
*/
|
||||
async deletePhantom(crewId: string, phantomId: string, options?: RequestOptions): Promise<void> {
|
||||
await this.request<void>(`/crews/${crewId}/phantom_players/${phantomId}`, {
|
||||
...options,
|
||||
method: 'DELETE'
|
||||
})
|
||||
this.clearCache('/crew/members')
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign a phantom player to a user (officers only)
|
||||
*/
|
||||
async assignPhantom(
|
||||
crewId: string,
|
||||
phantomId: string,
|
||||
userId: string,
|
||||
options?: RequestOptions
|
||||
): Promise<PhantomPlayer> {
|
||||
const response = await this.request<{ phantom_player: PhantomPlayer }>(
|
||||
`/crews/${crewId}/phantom_players/${phantomId}/assign`,
|
||||
{
|
||||
...options,
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ user_id: userId })
|
||||
}
|
||||
)
|
||||
this.clearCache('/crew/members')
|
||||
return response.phantom_player
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm claim of a phantom player (by the assigned user)
|
||||
*/
|
||||
async confirmPhantomClaim(crewId: string, phantomId: string, options?: RequestOptions): Promise<PhantomPlayer> {
|
||||
const response = await this.request<{ phantom_player: PhantomPlayer }>(
|
||||
`/crews/${crewId}/phantom_players/${phantomId}/confirm_claim`,
|
||||
{
|
||||
...options,
|
||||
method: 'POST'
|
||||
}
|
||||
)
|
||||
this.clearCache('/crew/members')
|
||||
return response.phantom_player
|
||||
}
|
||||
}
|
||||
|
||||
export const crewAdapter = new CrewAdapter(DEFAULT_ADAPTER_CONFIG)
|
||||
122
src/lib/types/api/crew.ts
Normal file
122
src/lib/types/api/crew.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
// Crew and membership types based on Rails blueprints
|
||||
// These define the crew management structure
|
||||
|
||||
import type { User } from './entities'
|
||||
|
||||
// Crew roles
|
||||
export type CrewRole = 'member' | 'vice_captain' | 'captain'
|
||||
|
||||
// Invitation status
|
||||
export type InvitationStatus = 'pending' | 'accepted' | 'rejected' | 'expired'
|
||||
|
||||
// Member filter options for GET /crew/members
|
||||
export type MemberFilter = 'active' | 'retired' | 'phantom' | 'all'
|
||||
|
||||
// Crew from CrewBlueprint
|
||||
export interface Crew {
|
||||
id: string
|
||||
name: string
|
||||
gamertag: string | null
|
||||
granblueCrewId: string | null
|
||||
description: string | null
|
||||
createdAt: string
|
||||
// From :full view
|
||||
memberCount?: number
|
||||
captain?: User
|
||||
viceCaptains?: User[]
|
||||
// From :with_membership view (current user's membership)
|
||||
currentMembership?: CrewMembership
|
||||
}
|
||||
|
||||
// Minimal crew for references
|
||||
export interface CrewMinimal {
|
||||
id: string
|
||||
name: string
|
||||
gamertag: string | null
|
||||
}
|
||||
|
||||
// CrewMembership from CrewMembershipBlueprint
|
||||
export interface CrewMembership {
|
||||
id: string
|
||||
role: CrewRole
|
||||
retired: boolean
|
||||
retiredAt: string | null
|
||||
joinedAt: string | null
|
||||
createdAt: string
|
||||
// From :with_user view
|
||||
user?: User
|
||||
// From :with_crew view
|
||||
crew?: CrewMinimal
|
||||
}
|
||||
|
||||
// PhantomPlayer from PhantomPlayerBlueprint
|
||||
export interface PhantomPlayer {
|
||||
id: string
|
||||
name: string
|
||||
granblueId: string | null
|
||||
notes: string | null
|
||||
claimed: boolean
|
||||
claimConfirmed: boolean
|
||||
retired: boolean
|
||||
retiredAt: string | null
|
||||
joinedAt: string | null
|
||||
// From :with_claimed_by view
|
||||
claimedBy?: User
|
||||
// From :with_scores view
|
||||
totalScore?: number
|
||||
scoreCount?: number
|
||||
}
|
||||
|
||||
// CrewInvitation from CrewInvitationBlueprint
|
||||
export interface CrewInvitation {
|
||||
id: string
|
||||
status: InvitationStatus
|
||||
expiresAt: string
|
||||
createdAt: string
|
||||
// From :with_crew view
|
||||
crew?: CrewMinimal
|
||||
// From :with_user view
|
||||
user?: User
|
||||
invitedBy?: User
|
||||
}
|
||||
|
||||
// Response type for GET /crew/members
|
||||
export interface CrewMembersResponse {
|
||||
members: CrewMembership[]
|
||||
phantoms: PhantomPlayer[]
|
||||
}
|
||||
|
||||
// Input types for mutations
|
||||
|
||||
export interface CreateCrewInput {
|
||||
name: string
|
||||
gamertag?: string
|
||||
granblueCrewId?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export interface UpdateCrewInput {
|
||||
name?: string
|
||||
gamertag?: string
|
||||
granblueCrewId?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export interface CreatePhantomPlayerInput {
|
||||
name: string
|
||||
granblueId?: string
|
||||
notes?: string
|
||||
joinedAt?: string
|
||||
}
|
||||
|
||||
export interface UpdatePhantomPlayerInput {
|
||||
name?: string
|
||||
granblueId?: string
|
||||
notes?: string
|
||||
joinedAt?: string
|
||||
}
|
||||
|
||||
export interface UpdateMembershipInput {
|
||||
role?: CrewRole
|
||||
joinedAt?: string
|
||||
}
|
||||
903
src/routes/(app)/crew/members/+page.svelte
Normal file
903
src/routes/(app)/crew/members/+page.svelte
Normal file
|
|
@ -0,0 +1,903 @@
|
|||
<svelte:options runes={true} />
|
||||
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores'
|
||||
import { goto } from '$app/navigation'
|
||||
import { createQuery, useQueryClient } from '@tanstack/svelte-query'
|
||||
import { crewQueries } from '$lib/api/queries/crew.queries'
|
||||
import {
|
||||
useRemoveMember,
|
||||
useUpdateMembership,
|
||||
useCreatePhantom,
|
||||
useDeletePhantom
|
||||
} from '$lib/api/mutations/crew.mutations'
|
||||
import { crewAdapter } from '$lib/api/adapters/crew.adapter'
|
||||
import { crewStore } from '$lib/stores/crew.store.svelte'
|
||||
import Button from '$lib/components/ui/Button.svelte'
|
||||
import Dialog from '$lib/components/ui/Dialog.svelte'
|
||||
import DropdownMenu from '$lib/components/ui/DropdownMenu.svelte'
|
||||
import ModalHeader from '$lib/components/ui/ModalHeader.svelte'
|
||||
import ModalBody from '$lib/components/ui/ModalBody.svelte'
|
||||
import ModalFooter from '$lib/components/ui/ModalFooter.svelte'
|
||||
import Input from '$lib/components/ui/Input.svelte'
|
||||
import CrewHeader from '$lib/components/crew/CrewHeader.svelte'
|
||||
import { DropdownMenu as DropdownMenuBase } from 'bits-ui'
|
||||
import type { MemberFilter, CrewMembership, PhantomPlayer } from '$lib/types/api/crew'
|
||||
import type { PageData } from './$types'
|
||||
|
||||
interface Props {
|
||||
data: PageData
|
||||
}
|
||||
|
||||
let { data }: Props = $props()
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
// Get filter from URL
|
||||
const filter = $derived<MemberFilter>(
|
||||
($page.url.searchParams.get('filter') as MemberFilter) || 'active'
|
||||
)
|
||||
|
||||
// Query for members based on filter
|
||||
const membersQuery = createQuery(() => crewQueries.members(filter))
|
||||
|
||||
// Query for active members (needed for roster cap check regardless of current filter)
|
||||
const activeQuery = createQuery(() => ({
|
||||
...crewQueries.members('active'),
|
||||
enabled: filter !== 'active' // Only fetch separately if not already viewing active
|
||||
}))
|
||||
|
||||
// Calculate total active roster size (members + phantoms)
|
||||
const activeRosterSize = $derived.by(() => {
|
||||
// Use active filter data if viewing active, otherwise use dedicated query
|
||||
const activeMembers =
|
||||
filter === 'active'
|
||||
? (membersQuery.data?.members ?? [])
|
||||
: (activeQuery.data?.members ?? [])
|
||||
const activePhantoms =
|
||||
filter === 'active'
|
||||
? (membersQuery.data?.phantoms ?? [])
|
||||
: (activeQuery.data?.phantoms ?? [])
|
||||
|
||||
const activeMemberCount = activeMembers.filter((m) => !m.retired).length
|
||||
const activePhantomCount = activePhantoms.filter((p) => !p.retired).length
|
||||
return activeMemberCount + activePhantomCount
|
||||
})
|
||||
|
||||
const isRosterFull = $derived(activeRosterSize >= 30)
|
||||
|
||||
// Mutations
|
||||
const removeMemberMutation = useRemoveMember()
|
||||
const updateMembershipMutation = useUpdateMembership()
|
||||
const createPhantomMutation = useCreatePhantom()
|
||||
const deletePhantomMutation = useDeletePhantom()
|
||||
|
||||
// Filter options
|
||||
const filterOptions: { value: MemberFilter; label: string }[] = [
|
||||
{ value: 'active', label: 'Active' },
|
||||
{ value: 'phantom', label: 'Phantoms' },
|
||||
{ value: 'retired', label: 'Retired' },
|
||||
{ value: 'all', label: 'All' }
|
||||
]
|
||||
|
||||
// Change filter
|
||||
function handleFilterChange(newFilter: MemberFilter) {
|
||||
const url = new URL($page.url)
|
||||
if (newFilter === 'active') {
|
||||
url.searchParams.delete('filter')
|
||||
} else {
|
||||
url.searchParams.set('filter', newFilter)
|
||||
}
|
||||
goto(url.toString(), { replaceState: true })
|
||||
}
|
||||
|
||||
// Dialog state for member actions
|
||||
let confirmDialogOpen = $state(false)
|
||||
let confirmAction = $state<'remove' | 'promote' | 'demote' | null>(null)
|
||||
let selectedMember = $state<CrewMembership | null>(null)
|
||||
|
||||
// Dialog state for editing join date
|
||||
let editJoinDateDialogOpen = $state(false)
|
||||
let editingMember = $state<CrewMembership | null>(null)
|
||||
let editingPhantom = $state<PhantomPlayer | null>(null)
|
||||
let editJoinDate = $state('')
|
||||
|
||||
// Dialog state for phantom creation
|
||||
let phantomDialogOpen = $state(false)
|
||||
let phantomName = $state('')
|
||||
let phantomGranblueId = $state('')
|
||||
let phantomNotes = $state('')
|
||||
let phantomJoinedAt = $state('')
|
||||
|
||||
// Role display helpers
|
||||
function getRoleLabel(role: string): string {
|
||||
switch (role) {
|
||||
case 'captain':
|
||||
return 'Captain'
|
||||
case 'vice_captain':
|
||||
return 'Vice Captain'
|
||||
default:
|
||||
return 'Member'
|
||||
}
|
||||
}
|
||||
|
||||
function getRoleClass(role: string): string {
|
||||
switch (role) {
|
||||
case 'captain':
|
||||
return 'captain'
|
||||
case 'vice_captain':
|
||||
return 'officer'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
// Member actions
|
||||
function openRemoveDialog(member: CrewMembership) {
|
||||
selectedMember = member
|
||||
confirmAction = 'remove'
|
||||
confirmDialogOpen = true
|
||||
}
|
||||
|
||||
function openPromoteDialog(member: CrewMembership) {
|
||||
selectedMember = member
|
||||
confirmAction = 'promote'
|
||||
confirmDialogOpen = true
|
||||
}
|
||||
|
||||
function openDemoteDialog(member: CrewMembership) {
|
||||
selectedMember = member
|
||||
confirmAction = 'demote'
|
||||
confirmDialogOpen = true
|
||||
}
|
||||
|
||||
async function handleConfirmAction() {
|
||||
if (!selectedMember || !crewStore.crew) return
|
||||
|
||||
try {
|
||||
if (confirmAction === 'remove') {
|
||||
await removeMemberMutation.mutateAsync({
|
||||
crewId: crewStore.crew.id,
|
||||
membershipId: selectedMember.id
|
||||
})
|
||||
} else if (confirmAction === 'promote') {
|
||||
await updateMembershipMutation.mutateAsync({
|
||||
crewId: crewStore.crew.id,
|
||||
membershipId: selectedMember.id,
|
||||
input: { role: 'vice_captain' }
|
||||
})
|
||||
} else if (confirmAction === 'demote') {
|
||||
await updateMembershipMutation.mutateAsync({
|
||||
crewId: crewStore.crew.id,
|
||||
membershipId: selectedMember.id,
|
||||
input: { role: 'member' }
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Action failed:', error)
|
||||
}
|
||||
|
||||
confirmDialogOpen = false
|
||||
selectedMember = null
|
||||
confirmAction = null
|
||||
}
|
||||
|
||||
// Join date editing
|
||||
function openEditJoinDateDialog(member: CrewMembership) {
|
||||
editingMember = member
|
||||
editingPhantom = null
|
||||
// Format date for input
|
||||
editJoinDate = member.joinedAt ? member.joinedAt.split('T')[0] : ''
|
||||
editJoinDateDialogOpen = true
|
||||
}
|
||||
|
||||
function openEditPhantomJoinDateDialog(phantom: PhantomPlayer) {
|
||||
editingPhantom = phantom
|
||||
editingMember = null
|
||||
editJoinDate = phantom.joinedAt ? phantom.joinedAt.split('T')[0] : ''
|
||||
editJoinDateDialogOpen = true
|
||||
}
|
||||
|
||||
async function handleSaveJoinDate() {
|
||||
if (!crewStore.crew) return
|
||||
|
||||
try {
|
||||
if (editingMember) {
|
||||
await updateMembershipMutation.mutateAsync({
|
||||
crewId: crewStore.crew.id,
|
||||
membershipId: editingMember.id,
|
||||
input: { joinedAt: editJoinDate }
|
||||
})
|
||||
} else if (editingPhantom) {
|
||||
// Call the phantom update directly through the adapter
|
||||
await crewAdapter.updatePhantom(crewStore.crew.id, editingPhantom.id, {
|
||||
joinedAt: editJoinDate
|
||||
})
|
||||
// Invalidate members query
|
||||
membersQuery.refetch()
|
||||
}
|
||||
// Invalidate GW event queries since membersDuringEvent depends on join dates
|
||||
queryClient.invalidateQueries({ queryKey: ['crew', 'gw'] })
|
||||
} catch (error) {
|
||||
console.error('Failed to update join date:', error)
|
||||
}
|
||||
|
||||
editJoinDateDialogOpen = false
|
||||
editingMember = null
|
||||
editingPhantom = null
|
||||
editJoinDate = ''
|
||||
}
|
||||
|
||||
// Phantom actions
|
||||
function openPhantomDialog() {
|
||||
phantomName = ''
|
||||
phantomGranblueId = ''
|
||||
phantomNotes = ''
|
||||
phantomJoinedAt = ''
|
||||
phantomDialogOpen = true
|
||||
}
|
||||
|
||||
async function handleCreatePhantom() {
|
||||
if (!phantomName.trim() || !crewStore.crew) return
|
||||
|
||||
try {
|
||||
await createPhantomMutation.mutateAsync({
|
||||
crewId: crewStore.crew.id,
|
||||
input: {
|
||||
name: phantomName.trim(),
|
||||
granblueId: phantomGranblueId.trim() || undefined,
|
||||
notes: phantomNotes.trim() || undefined,
|
||||
joinedAt: phantomJoinedAt || undefined
|
||||
}
|
||||
})
|
||||
phantomDialogOpen = false
|
||||
} catch (error) {
|
||||
console.error('Failed to create phantom:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeletePhantom(phantom: PhantomPlayer) {
|
||||
if (!crewStore.crew) return
|
||||
|
||||
try {
|
||||
await deletePhantomMutation.mutateAsync({
|
||||
crewId: crewStore.crew.id,
|
||||
phantomId: phantom.id
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to delete phantom:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Format date
|
||||
function formatDate(dateString: string): string {
|
||||
return new Date(dateString).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Crew Members | Hensei</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="page">
|
||||
<div class="card">
|
||||
<CrewHeader title="Members">
|
||||
{#snippet belowTitle()}
|
||||
<div class="filter-tabs">
|
||||
{#each filterOptions as option}
|
||||
<button
|
||||
class="filter-tab"
|
||||
class:active={filter === option.value}
|
||||
onclick={() => handleFilterChange(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/snippet}
|
||||
{#snippet actions()}
|
||||
{#if crewStore.isOfficer}
|
||||
<Button variant="secondary" size="small" disabled={isRosterFull}>Scout</Button>
|
||||
<DropdownMenu>
|
||||
{#snippet trigger({ props })}
|
||||
<Button variant="secondary" size="small" iconOnly icon="ellipsis" {...props} />
|
||||
{/snippet}
|
||||
{#snippet menu()}
|
||||
<DropdownMenuBase.Item class="dropdown-menu-item" onclick={openPhantomDialog}>
|
||||
Add Phantom
|
||||
</DropdownMenuBase.Item>
|
||||
{/snippet}
|
||||
</DropdownMenu>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</CrewHeader>
|
||||
|
||||
{#if membersQuery.isLoading}
|
||||
<div class="loading-state">
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
{:else if membersQuery.isError}
|
||||
<div class="error-state">
|
||||
<p>Failed to load members</p>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Regular members -->
|
||||
{#if membersQuery.data?.members && membersQuery.data.members.length > 0}
|
||||
<ul class="member-list">
|
||||
{#each membersQuery.data.members as member}
|
||||
<li class="member-item" class:retired={member.retired}>
|
||||
<div class="member-info">
|
||||
<span class="username">{member.user?.username ?? 'Unknown'}</span>
|
||||
<span class="role-badge {getRoleClass(member.role)}">
|
||||
{getRoleLabel(member.role)}
|
||||
</span>
|
||||
{#if member.joinedAt}
|
||||
<span class="joined-date">Joined {formatDate(member.joinedAt)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if crewStore.isOfficer}
|
||||
<DropdownMenu>
|
||||
{#snippet trigger({ props })}
|
||||
<Button variant="secondary" size="small" iconOnly icon="ellipsis" {...props} />
|
||||
{/snippet}
|
||||
{#snippet menu()}
|
||||
<DropdownMenuBase.Item
|
||||
class="dropdown-menu-item"
|
||||
onclick={() => openEditJoinDateDialog(member)}
|
||||
>
|
||||
Edit
|
||||
</DropdownMenuBase.Item>
|
||||
{#if crewStore.canActOnMember(member.role) && !member.retired && member.id !== crewStore.membership?.id}
|
||||
{#if member.role === 'member' && crewStore.canPromoteTo('vice_captain')}
|
||||
<DropdownMenuBase.Item
|
||||
class="dropdown-menu-item"
|
||||
onclick={() => openPromoteDialog(member)}
|
||||
>
|
||||
Promote
|
||||
</DropdownMenuBase.Item>
|
||||
{/if}
|
||||
{#if member.role === 'vice_captain' && crewStore.canDemote('vice_captain')}
|
||||
<DropdownMenuBase.Item
|
||||
class="dropdown-menu-item"
|
||||
onclick={() => openDemoteDialog(member)}
|
||||
>
|
||||
Demote
|
||||
</DropdownMenuBase.Item>
|
||||
{/if}
|
||||
<DropdownMenuBase.Item
|
||||
class="dropdown-menu-item danger"
|
||||
onclick={() => openRemoveDialog(member)}
|
||||
>
|
||||
Remove
|
||||
</DropdownMenuBase.Item>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</DropdownMenu>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else if filter !== 'phantom'}
|
||||
<p class="empty-state">No members found</p>
|
||||
{/if}
|
||||
|
||||
<!-- Phantom players -->
|
||||
{#if membersQuery.data?.phantoms && membersQuery.data.phantoms.length > 0}
|
||||
{#if filter === 'all' && membersQuery.data.members.length > 0}
|
||||
<div class="section-divider">
|
||||
<span>Phantom Players</span>
|
||||
</div>
|
||||
{/if}
|
||||
<ul class="member-list">
|
||||
{#each membersQuery.data.phantoms as phantom}
|
||||
<li class="member-item" class:retired={phantom.retired}>
|
||||
<div class="member-info">
|
||||
<div class="phantom-details">
|
||||
<span class="username">{phantom.name}</span>
|
||||
{#if phantom.granblueId}
|
||||
<span class="granblue-id">ID: {phantom.granblueId}</span>
|
||||
{/if}
|
||||
{#if phantom.joinedAt}
|
||||
<span class="joined-date">Joined {formatDate(phantom.joinedAt)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if phantom.claimConfirmed && phantom.claimedBy}
|
||||
<span class="status-badge claimed">
|
||||
Claimed by {phantom.claimedBy.username}
|
||||
</span>
|
||||
{:else if phantom.claimedBy}
|
||||
<span class="status-badge pending">
|
||||
Pending: {phantom.claimedBy.username}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="status-badge unclaimed">Unclaimed</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if crewStore.isOfficer}
|
||||
<DropdownMenu>
|
||||
{#snippet trigger({ props })}
|
||||
<Button variant="secondary" size="small" iconOnly icon="ellipsis" {...props} />
|
||||
{/snippet}
|
||||
{#snippet menu()}
|
||||
<DropdownMenuBase.Item
|
||||
class="dropdown-menu-item"
|
||||
onclick={() => openEditPhantomJoinDateDialog(phantom)}
|
||||
>
|
||||
Edit
|
||||
</DropdownMenuBase.Item>
|
||||
<DropdownMenuBase.Item
|
||||
class="dropdown-menu-item danger"
|
||||
onclick={() => handleDeletePhantom(phantom)}
|
||||
>
|
||||
Delete
|
||||
</DropdownMenuBase.Item>
|
||||
{/snippet}
|
||||
</DropdownMenu>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else if filter === 'phantom'}
|
||||
<div class="empty-state">
|
||||
<p>No phantom players.</p>
|
||||
{#if crewStore.isOfficer}
|
||||
<Button variant="secondary" size="small" onclick={openPhantomDialog}>
|
||||
Add Phantom
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Confirm Action Dialog -->
|
||||
<Dialog bind:open={confirmDialogOpen}>
|
||||
{#snippet children()}
|
||||
<ModalHeader
|
||||
title={confirmAction === 'remove'
|
||||
? 'Remove Member'
|
||||
: confirmAction === 'promote'
|
||||
? 'Promote to Vice Captain'
|
||||
: 'Demote to Member'}
|
||||
/>
|
||||
|
||||
<ModalBody>
|
||||
<p class="confirm-message">
|
||||
{#if confirmAction === 'remove'}
|
||||
Are you sure you want to remove {selectedMember?.user?.username ?? 'this member'} from the
|
||||
crew?
|
||||
{:else if confirmAction === 'promote'}
|
||||
Promote {selectedMember?.user?.username ?? 'this member'} to Vice Captain?
|
||||
{:else if confirmAction === 'demote'}
|
||||
Demote {selectedMember?.user?.username ?? 'this member'} from Vice Captain to Member?
|
||||
{/if}
|
||||
</p>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
{#snippet children()}
|
||||
<Button variant="ghost" onclick={() => (confirmDialogOpen = false)}>Cancel</Button>
|
||||
<Button
|
||||
variant={confirmAction === 'remove' ? 'danger' : 'primary'}
|
||||
onclick={handleConfirmAction}
|
||||
>
|
||||
{#if confirmAction === 'remove'}
|
||||
Remove
|
||||
{:else if confirmAction === 'promote'}
|
||||
Promote
|
||||
{:else}
|
||||
Demote
|
||||
{/if}
|
||||
</Button>
|
||||
{/snippet}
|
||||
</ModalFooter>
|
||||
{/snippet}
|
||||
</Dialog>
|
||||
|
||||
<!-- Create Phantom Dialog -->
|
||||
<Dialog bind:open={phantomDialogOpen}>
|
||||
{#snippet children()}
|
||||
<ModalHeader
|
||||
title="Add Phantom Player"
|
||||
description="Phantom players allow you to track the scores of members without accounts"
|
||||
/>
|
||||
|
||||
<ModalBody>
|
||||
<div class="modal-form">
|
||||
<div class="form-fields">
|
||||
<Input
|
||||
label="Name"
|
||||
bind:value={phantomName}
|
||||
placeholder="Player's name or nickname"
|
||||
fullWidth
|
||||
contained
|
||||
required
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Granblue ID"
|
||||
bind:value={phantomGranblueId}
|
||||
placeholder="In-game player ID (optional)"
|
||||
fullWidth
|
||||
contained
|
||||
/>
|
||||
|
||||
<div class="form-field">
|
||||
<label for="phantomJoinedAt">Join Date <span class="optional">(optional)</span></label>
|
||||
<input
|
||||
id="phantomJoinedAt"
|
||||
type="date"
|
||||
bind:value={phantomJoinedAt}
|
||||
class="date-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
<label for="phantomNotes">Notes <span class="optional">(optional)</span></label>
|
||||
<textarea
|
||||
id="phantomNotes"
|
||||
bind:value={phantomNotes}
|
||||
placeholder="Optional notes about this player"
|
||||
rows="2"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
{#snippet children()}
|
||||
<Button
|
||||
variant="ghost"
|
||||
onclick={() => (phantomDialogOpen = false)}
|
||||
disabled={createPhantomMutation.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onclick={handleCreatePhantom}
|
||||
disabled={!phantomName.trim() || createPhantomMutation.isPending}
|
||||
>
|
||||
{createPhantomMutation.isPending ? 'Creating...' : 'Create'}
|
||||
</Button>
|
||||
{/snippet}
|
||||
</ModalFooter>
|
||||
{/snippet}
|
||||
</Dialog>
|
||||
|
||||
<!-- Edit Join Date Dialog -->
|
||||
<Dialog bind:open={editJoinDateDialogOpen}>
|
||||
{#snippet children()}
|
||||
<ModalHeader title="Edit player" />
|
||||
|
||||
<ModalBody>
|
||||
<div class="modal-form">
|
||||
<div class="form-fields">
|
||||
<div class="form-field">
|
||||
<label for="joinDate">Join date</label>
|
||||
<input id="joinDate" type="date" bind:value={editJoinDate} class="date-input" />
|
||||
</div>
|
||||
<p class="help-text">
|
||||
This date is used to determine which events a member was active for when adding
|
||||
historical GW scores.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
{#snippet children()}
|
||||
<Button variant="ghost" onclick={() => (editJoinDateDialogOpen = false)}>Cancel</Button>
|
||||
<Button variant="primary" onclick={handleSaveJoinDate} disabled={!editJoinDate}>
|
||||
Save
|
||||
</Button>
|
||||
{/snippet}
|
||||
</ModalFooter>
|
||||
{/snippet}
|
||||
</Dialog>
|
||||
|
||||
<style lang="scss">
|
||||
@use '$src/themes/colors' as colors;
|
||||
@use '$src/themes/effects' as effects;
|
||||
@use '$src/themes/spacing' as spacing;
|
||||
@use '$src/themes/typography' as typography;
|
||||
@use '$src/themes/layout' as layout;
|
||||
|
||||
.page {
|
||||
padding: spacing.$unit-2x 0;
|
||||
margin: 0 auto;
|
||||
max-width: var(--main-max-width);
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--card-bg);
|
||||
border: 0.5px solid rgba(0, 0, 0, 0.18);
|
||||
border-radius: layout.$page-corner;
|
||||
box-shadow: effects.$page-elevation;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.filter-tabs {
|
||||
display: flex;
|
||||
gap: spacing.$unit-half;
|
||||
}
|
||||
|
||||
.filter-tab {
|
||||
padding: 4px spacing.$unit;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: layout.$item-corner-small;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: typography.$font-small;
|
||||
transition:
|
||||
color 0.15s,
|
||||
background-color 0.15s;
|
||||
|
||||
&:hover {
|
||||
color: var(--text-primary);
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: var(--text-primary);
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
font-weight: typography.$medium;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-state,
|
||||
.error-state {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: spacing.$unit-4x;
|
||||
color: var(--text-secondary);
|
||||
font-size: typography.$font-small;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: spacing.$unit-2x;
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
padding: spacing.$unit-4x;
|
||||
font-size: typography.$font-small;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.member-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: spacing.$unit;
|
||||
}
|
||||
|
||||
.member-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: spacing.$unit spacing.$unit-2x;
|
||||
border-radius: layout.$item-corner;
|
||||
transition: background-color 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
&.retired {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
.member-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: spacing.$unit;
|
||||
}
|
||||
|
||||
.phantom-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.username {
|
||||
font-size: typography.$font-small;
|
||||
font-weight: typography.$medium;
|
||||
}
|
||||
|
||||
.granblue-id {
|
||||
font-size: typography.$font-small;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.role-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: layout.$item-corner-small;
|
||||
font-size: typography.$font-small;
|
||||
font-weight: typography.$medium;
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
|
||||
&.captain {
|
||||
background: var(--color-gold-light, #fef3c7);
|
||||
color: var(--color-gold-dark, #92400e);
|
||||
}
|
||||
|
||||
&.officer {
|
||||
background: var(--color-blue-light, #dbeafe);
|
||||
color: var(--color-blue-dark, #1e40af);
|
||||
}
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: layout.$item-corner-small;
|
||||
font-size: typography.$font-small;
|
||||
|
||||
&.unclaimed {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
&.pending {
|
||||
background: var(--color-yellow-light, #fef9c3);
|
||||
color: var(--color-yellow-dark, #854d0e);
|
||||
}
|
||||
|
||||
&.claimed {
|
||||
background: var(--color-green-light, #dcfce7);
|
||||
color: var(--color-green-dark, #166534);
|
||||
}
|
||||
}
|
||||
|
||||
.section-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: spacing.$unit spacing.$unit-2x;
|
||||
background: rgba(0, 0, 0, 0.02);
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||
|
||||
span {
|
||||
font-size: typography.$font-small;
|
||||
font-weight: typography.$medium;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: spacing.$unit-half;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 4px 8px;
|
||||
border: none;
|
||||
border-radius: layout.$item-corner-small;
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
color: var(--text-secondary);
|
||||
font-size: typography.$font-small;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.08);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
&.danger {
|
||||
color: colors.$error;
|
||||
|
||||
&:hover {
|
||||
background: rgba(colors.$error, 0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Confirm dialog styles
|
||||
.confirm-message {
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
// Modal form styles
|
||||
.modal-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: spacing.$unit-3x;
|
||||
}
|
||||
|
||||
.form-fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: spacing.$unit-3x;
|
||||
}
|
||||
|
||||
.form-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: spacing.$unit-half;
|
||||
|
||||
label {
|
||||
font-size: typography.$font-small;
|
||||
font-weight: typography.$medium;
|
||||
color: var(--text-primary);
|
||||
|
||||
.optional {
|
||||
font-weight: typography.$normal;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
textarea {
|
||||
padding: spacing.$unit-2x;
|
||||
border: none;
|
||||
border-radius: layout.$input-corner;
|
||||
font-size: typography.$font-regular;
|
||||
font-family: inherit;
|
||||
background: var(--input-bound-bg);
|
||||
color: var(--text-primary);
|
||||
resize: vertical;
|
||||
min-height: 60px;
|
||||
|
||||
&:hover {
|
||||
background: var(--input-bound-bg-hover);
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:global(fieldset) {
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.joined-date {
|
||||
font-size: typography.$font-small;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.date-input {
|
||||
padding: spacing.$unit spacing.$unit-2x;
|
||||
border: none;
|
||||
border-radius: layout.$input-corner;
|
||||
font-size: typography.$font-regular;
|
||||
font-family: inherit;
|
||||
background: var(--input-bound-bg);
|
||||
color: var(--text-primary);
|
||||
width: 100%;
|
||||
|
||||
&:hover {
|
||||
background: var(--input-bound-bg-hover);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
background: var(--input-bound-bg-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.help-text {
|
||||
font-size: typography.$font-small;
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
</style>
|
||||
Loading…
Reference in a new issue