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:
Justin Edmund 2025-12-04 03:03:04 -08:00
parent f4d04a7073
commit 32af6a7788
3 changed files with 1302 additions and 0 deletions

View 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
View 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
}

View 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>