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