add bulk phantom creation, delete confirmation, default to all filter

- bulk create phantoms with individual join dates
- confirm before deleting phantoms
- reorder filters with All first and as default
This commit is contained in:
Justin Edmund 2025-12-13 23:12:40 -08:00
parent 7dcb100412
commit 4745baca1c
4 changed files with 350 additions and 110 deletions

View file

@ -205,6 +205,26 @@ export class CrewAdapter extends BaseAdapter {
return response.phantom_player
}
/**
* Create multiple phantom players at once (officers only)
*/
async bulkCreatePhantoms(
crewId: string,
phantoms: CreatePhantomPlayerInput[],
options?: RequestOptions
): Promise<PhantomPlayer[]> {
const response = await this.request<{ phantom_players: PhantomPlayer[] }>(
`/crews/${crewId}/phantom_players/bulk_create`,
{
...options,
method: 'POST',
body: JSON.stringify({ phantom_players: phantoms })
}
)
this.clearCache('/crew/members')
return response.phantom_players
}
/**
* Update a phantom player
*/

View file

@ -183,6 +183,21 @@ export function useCreatePhantom() {
}))
}
/**
* Bulk create phantom players mutation
*/
export function useBulkCreatePhantoms() {
const queryClient = useQueryClient()
return createMutation(() => ({
mutationFn: ({ crewId, phantoms }: { crewId: string; phantoms: CreatePhantomPlayerInput[] }) =>
crewAdapter.bulkCreatePhantoms(crewId, phantoms),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: crewKeys.membersAll() })
}
}))
}
/**
* Update phantom player mutation
*/

View file

@ -0,0 +1,263 @@
<svelte:options runes={true} />
<script lang="ts">
import { untrack } from 'svelte'
import { useBulkCreatePhantoms } from '$lib/api/mutations/crew.mutations'
import Dialog from '$lib/components/ui/Dialog.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 Button from '$lib/components/ui/Button.svelte'
import Icon from '$lib/components/Icon.svelte'
import type { CreatePhantomPlayerInput } from '$lib/types/api/crew'
interface Props {
open: boolean
crewId: string
}
let { open = $bindable(false), crewId }: Props = $props()
const bulkCreateMutation = useBulkCreatePhantoms()
// State for phantom rows
interface PhantomRow {
id: number
name: string
granblueId: string
joinedAt: string
}
// Use a regular variable for ID counter (doesn't need to be reactive)
let nextId = 1
let rows = $state<PhantomRow[]>([createEmptyRow()])
function createEmptyRow(): PhantomRow {
return {
id: nextId++,
name: '',
granblueId: '',
joinedAt: ''
}
}
function addRow() {
rows = [...rows, createEmptyRow()]
}
function removeRow(id: number) {
if (rows.length <= 1) return
rows = rows.filter((row) => row.id !== id)
}
function resetState() {
nextId = 1
rows = [createEmptyRow()]
}
// Check if we have at least one valid phantom (name is required)
const validPhantoms = $derived(rows.filter((row) => row.name.trim()))
const canSubmit = $derived(validPhantoms.length > 0 && !bulkCreateMutation.isPending)
async function handleSubmit() {
if (!canSubmit) return
const phantoms: CreatePhantomPlayerInput[] = validPhantoms.map((row) => ({
name: row.name.trim(),
granblueId: row.granblueId.trim() || undefined,
joinedAt: row.joinedAt || undefined
}))
try {
await bulkCreateMutation.mutateAsync({ crewId, phantoms })
resetState()
open = false
} catch (error) {
console.error('Failed to create phantoms:', error)
}
}
function handleCancel() {
resetState()
open = false
}
// Reset state when modal opens
$effect(() => {
if (open) {
untrack(() => resetState())
}
})
</script>
<Dialog bind:open size="medium">
{#snippet children()}
<ModalHeader
title="Add Phantom Players"
description="Phantom players allow you to track the scores of members without accounts"
/>
<ModalBody>
<div class="phantom-rows">
<div class="row-header">
<span class="col-name">Name</span>
<span class="col-id">Granblue ID</span>
<span class="col-date">Join Date</span>
<span class="col-action"></span>
</div>
{#each rows as row (row.id)}
<div class="phantom-row">
<input
type="text"
class="input name-input"
placeholder="Player name"
bind:value={row.name}
/>
<input
type="text"
class="input id-input"
placeholder="Optional"
bind:value={row.granblueId}
/>
<input type="date" class="input date-input" bind:value={row.joinedAt} />
<button
type="button"
class="remove-btn"
onclick={() => removeRow(row.id)}
disabled={rows.length <= 1}
aria-label="Remove row"
>
<Icon name="close" size={16} />
</button>
</div>
{/each}
</div>
<Button variant="ghost" size="small" leftIcon="plus" onclick={addRow}>
Add Another
</Button>
{#if bulkCreateMutation.isError}
<p class="error-message">
Failed to create phantom players. Please check the inputs and try again.
</p>
{/if}
</ModalBody>
<ModalFooter
onCancel={handleCancel}
cancelDisabled={bulkCreateMutation.isPending}
primaryAction={{
label: bulkCreateMutation.isPending
? 'Creating...'
: `Create ${validPhantoms.length} Phantom${validPhantoms.length !== 1 ? 's' : ''}`,
onclick: handleSubmit,
disabled: !canSubmit
}}
/>
{/snippet}
</Dialog>
<style lang="scss">
@use '$src/themes/colors' as colors;
@use '$src/themes/spacing' as spacing;
@use '$src/themes/typography' as typography;
@use '$src/themes/layout' as layout;
.phantom-rows {
display: flex;
flex-direction: column;
gap: spacing.$unit;
margin-bottom: spacing.$unit-2x;
}
.row-header {
display: grid;
grid-template-columns: 1fr 140px 180px 32px;
gap: spacing.$unit;
padding: 0 spacing.$unit-half;
font-size: typography.$font-small;
font-weight: typography.$medium;
color: var(--text-secondary);
}
.phantom-row {
display: grid;
grid-template-columns: 1fr 140px 180px 32px;
gap: spacing.$unit;
align-items: center;
}
.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);
}
&::placeholder {
color: var(--text-tertiary);
}
}
.date-input {
padding: spacing.$unit-half spacing.$unit;
}
.remove-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
border: none;
border-radius: layout.$item-corner-small;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.15s;
&:hover:not(:disabled) {
background: rgba(0, 0, 0, 0.06);
color: var(--text-primary);
}
&:disabled {
opacity: 0.3;
cursor: not-allowed;
}
}
.error-message {
color: colors.$error;
font-size: typography.$font-small;
margin: spacing.$unit 0 0;
}
.col-name {
text-align: left;
}
.col-id,
.col-date {
text-align: left;
}
.col-action {
width: 32px;
}
</style>

View file

@ -8,7 +8,6 @@
import {
useRemoveMember,
useUpdateMembership,
useCreatePhantom,
useDeletePhantom
} from '$lib/api/mutations/crew.mutations'
import { crewAdapter } from '$lib/api/adapters/crew.adapter'
@ -19,9 +18,9 @@
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 ScoutUserModal from '$lib/components/crew/ScoutUserModal.svelte'
import BulkPhantomModal from '$lib/components/crew/BulkPhantomModal.svelte'
import { DropdownMenu as DropdownMenuBase } from 'bits-ui'
import type { MemberFilter, CrewMembership, PhantomPlayer, CrewInvitation } from '$lib/types/api/crew'
import type { PageData } from './$types'
@ -36,7 +35,7 @@
// Get filter from URL
const filter = $derived<MemberFilter>(
($page.url.searchParams.get('filter') as MemberFilter) || 'active'
($page.url.searchParams.get('filter') as MemberFilter) || 'all'
)
// Query for members based on filter
@ -76,21 +75,20 @@
// Mutations
const removeMemberMutation = useRemoveMember()
const updateMembershipMutation = useUpdateMembership()
const createPhantomMutation = useCreatePhantom()
const deletePhantomMutation = useDeletePhantom()
// Filter options
const filterOptions: { value: MemberFilter; label: string }[] = [
{ value: 'all', label: 'All' },
{ value: 'active', label: 'Active' },
{ value: 'phantom', label: 'Phantoms' },
{ value: 'retired', label: 'Retired' },
{ value: 'all', label: 'All' }
{ value: 'retired', label: 'Retired' }
]
// Change filter
function handleFilterChange(newFilter: MemberFilter) {
const url = new URL($page.url)
if (newFilter === 'active') {
if (newFilter === 'all') {
url.searchParams.delete('filter')
} else {
url.searchParams.set('filter', newFilter)
@ -116,11 +114,11 @@
let invitationsSectionOpen = $state(true)
// Dialog state for phantom creation
let phantomDialogOpen = $state(false)
let phantomName = $state('')
let phantomGranblueId = $state('')
let phantomNotes = $state('')
let phantomJoinedAt = $state('')
let bulkPhantomDialogOpen = $state(false)
// Dialog state for phantom deletion confirmation
let deletePhantomDialogOpen = $state(false)
let phantomToDelete = $state<PhantomPlayer | null>(null)
// Role display helpers
function getRoleLabel(role: string): string {
@ -241,45 +239,25 @@
editJoinDate = ''
}
// Phantom actions
function openPhantomDialog() {
phantomName = ''
phantomGranblueId = ''
phantomNotes = ''
phantomJoinedAt = ''
phantomDialogOpen = true
function openDeletePhantomDialog(phantom: PhantomPlayer) {
phantomToDelete = phantom
deletePhantomDialogOpen = 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
async function handleConfirmDeletePhantom() {
if (!crewStore.crew || !phantomToDelete) return
try {
await deletePhantomMutation.mutateAsync({
crewId: crewStore.crew.id,
phantomId: phantom.id
phantomId: phantomToDelete.id
})
} catch (error) {
console.error('Failed to delete phantom:', error)
}
deletePhantomDialogOpen = false
phantomToDelete = null
}
// Format date
@ -337,8 +315,11 @@
<Button variant="secondary" size="small" iconOnly icon="ellipsis" {...props} />
{/snippet}
{#snippet menu()}
<DropdownMenuBase.Item class="dropdown-menu-item" onclick={openPhantomDialog}>
Add Phantom
<DropdownMenuBase.Item
class="dropdown-menu-item"
onclick={() => (bulkPhantomDialogOpen = true)}
>
Add phantoms...
</DropdownMenuBase.Item>
{/snippet}
</DropdownMenu>
@ -504,7 +485,7 @@
</DropdownMenuBase.Item>
<DropdownMenuBase.Item
class="dropdown-menu-item danger"
onclick={() => handleDeletePhantom(phantom)}
onclick={() => openDeletePhantomDialog(phantom)}
>
Delete
</DropdownMenuBase.Item>
@ -518,8 +499,8 @@
<div class="empty-state">
<p>No phantom players.</p>
{#if crewStore.isOfficer}
<Button variant="secondary" size="small" onclick={openPhantomDialog}>
Add Phantom
<Button variant="secondary" size="small" onclick={() => (bulkPhantomDialogOpen = true)}>
Add phantoms...
</Button>
{/if}
</div>
@ -563,69 +544,6 @@
{/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
onCancel={() => (phantomDialogOpen = false)}
cancelDisabled={createPhantomMutation.isPending}
primaryAction={{
label: createPhantomMutation.isPending ? 'Creating...' : 'Create',
onclick: handleCreatePhantom,
disabled: !phantomName.trim() || createPhantomMutation.isPending
}}
/>
{/snippet}
</Dialog>
<!-- Edit Join Date Dialog -->
<Dialog bind:open={editJoinDateDialogOpen}>
{#snippet children()}
@ -657,9 +575,33 @@
{/snippet}
</Dialog>
<!-- Delete Phantom Confirmation Dialog -->
<Dialog bind:open={deletePhantomDialogOpen}>
{#snippet children()}
<ModalHeader title="Delete Phantom Player" />
<ModalBody>
<p class="confirm-message">
Are you sure you want to delete {phantomToDelete?.name ?? 'this phantom player'}? This
action cannot be undone.
</p>
</ModalBody>
<ModalFooter
onCancel={() => (deletePhantomDialogOpen = false)}
primaryAction={{
label: 'Delete',
onclick: handleConfirmDeletePhantom,
destructive: true
}}
/>
{/snippet}
</Dialog>
<!-- Scout User Modal -->
{#if crewStore.crew?.id}
<ScoutUserModal bind:open={scoutModalOpen} crewId={crewStore.crew.id} />
<BulkPhantomModal bind:open={bulkPhantomDialogOpen} crewId={crewStore.crew.id} />
{/if}
<style lang="scss">
@ -868,7 +810,7 @@
// Confirm dialog styles
.confirm-message {
color: var(--text-secondary);
color: var(--text-primary);
line-height: 1.5;
margin: 0;
}