rework ModalFooter to use action props

- onCancel callback with fixed "Nevermind" label
- optional primaryAction object (label, onclick, destructive, disabled)
- optional left snippet for custom content
This commit is contained in:
Justin Edmund 2025-12-13 20:02:25 -08:00
parent 7dae71965a
commit c54c959522
10 changed files with 333 additions and 223 deletions

View file

@ -8,11 +8,15 @@
import Select from './ui/Select.svelte'
import Switch from './ui/switch/Switch.svelte'
import Button from './ui/Button.svelte'
import Input from './ui/Input.svelte'
import { pictureData, type Picture } from '$lib/utils/pictureData'
import { users } from '$lib/api/resources/users'
import type { UserCookie } from '$lib/types/UserCookie'
import { setUserCookie } from '$lib/auth/cookies'
import { invalidateAll } from '$app/navigation'
import { createQuery } from '@tanstack/svelte-query'
import { crewQueries } from '$lib/api/queries/crew.queries'
import { userAdapter } from '$lib/api/adapters/user.adapter'
interface Props {
open: boolean
@ -25,7 +29,7 @@
let { open = $bindable(false), onOpenChange, username, userId, user, role }: Props = $props()
// Form state
// Form state - fields from cookie (can use immediately)
let picture = $state(user.picture)
let element = $state(user.element)
let gender = $state(user.gender)
@ -33,9 +37,48 @@
let theme = $state(user.theme)
let bahamut = $state(user.bahamut ?? false)
// Form state - fields from API (must wait for query to load)
// Initialize as empty - will be set by $effect when API data loads
let granblueId = $state('')
let showCrewGamertag = $state(false)
let apiDataLoaded = $state(false)
let saving = $state(false)
let error = $state<string | null>(null)
// Fetch current user data from API (to get actual show_gamertag value from database)
const currentUserQuery = createQuery(() => ({
queryKey: ['currentUser', 'settings'],
queryFn: () => userAdapter.getCurrentUser(),
enabled: open // Only fetch when modal is open
}))
// Fetch current user's crew (for showing gamertag toggle)
const myCrewQuery = createQuery(() => ({
...crewQueries.myCrew(),
enabled: open // Only fetch when modal is open
}))
const isInCrew = $derived(!!myCrewQuery.data)
const crewGamertag = $derived(myCrewQuery.data?.gamertag)
const isLoadingApiData = $derived(currentUserQuery.isLoading || currentUserQuery.isPending)
// Update form state when API data loads (to get actual values from database)
$effect(() => {
if (currentUserQuery.data) {
granblueId = currentUserQuery.data.granblueId ?? ''
showCrewGamertag = currentUserQuery.data.showCrewGamertag ?? false
apiDataLoaded = true
}
})
// Reset apiDataLoaded when modal closes so fresh data is fetched next time
$effect(() => {
if (!open) {
apiDataLoaded = false
}
})
// Get current locale from user settings
const locale = $derived(user.language as 'en' | 'ja')
@ -107,7 +150,9 @@
element,
gender,
language,
theme
theme,
granblueId: granblueId || undefined,
showCrewGamertag
}
// Call API to update user settings
@ -120,7 +165,9 @@
language: response.language,
gender: response.gender,
theme: response.theme,
bahamut
bahamut,
granblueId: response.granblueId,
showCrewGamertag: response.showCrewGamertag
}
// Save to cookie (we'll need to handle this server-side)
@ -137,7 +184,7 @@
body: JSON.stringify(updatedUser)
})
// If language or theme changed, we need a full page reload
// If language, theme, or bahamut mode changed, we need a full page reload
if (user.language !== language || user.theme !== theme || user.bahamut !== bahamut) {
await invalidateAll()
window.location.reload()
@ -203,6 +250,39 @@
contained
/>
<!-- Granblue ID -->
{#if isLoadingApiData}
<div class="loading-field">
<span class="loading-label">Granblue ID</span>
<span class="loading-text">Loading...</span>
</div>
{:else}
<Input
bind:value={granblueId}
label="Granblue ID"
placeholder="Enter your Granblue ID"
contained
fullWidth
/>
{/if}
<!-- Show Crew Gamertag (only if in a crew with a gamertag) -->
{#if isInCrew && crewGamertag}
<div class="inline-switch">
<label for="show-gamertag">
<span>Show crew tag on profile</span>
{#if isLoadingApiData}
<span class="loading-text">Loading...</span>
{:else}
<Switch bind:checked={showCrewGamertag} name="show-gamertag" element={element as 'wind' | 'fire' | 'water' | 'earth' | 'dark' | 'light' | undefined} />
{/if}
</label>
<p class="field-hint">Display "{crewGamertag}" next to your name</p>
</div>
{/if}
<hr class="separator" />
<!-- Gender Selection -->
<Select
bind:value={gender}
@ -233,12 +313,13 @@
contained
/>
<!-- Admin Mode (only for admins) -->
<!-- Bahamut Mode (only for admins) -->
{#if role === 9}
<hr class="separator" />
<div class="switch-field">
<label for="bahamut-mode">
<span>Admin Mode</span>
<Switch bind:checked={bahamut} name="bahamut-mode" />
<span>Bahamut Mode</span>
<Switch bind:checked={bahamut} name="bahamut-mode" element={element as 'wind' | 'fire' | 'water' | 'earth' | 'dark' | 'light' | undefined} />
</label>
</div>
{/if}
@ -246,14 +327,15 @@
</div>
</ModalBody>
<ModalFooter>
{#snippet children()}
<Button variant="ghost" onclick={handleClose} disabled={saving}>Cancel</Button>
<Button onclick={handleSave} variant="primary" disabled={saving}>
{saving ? 'Saving...' : 'Save Changes'}
</Button>
{/snippet}
</ModalFooter>
<ModalFooter
onCancel={handleClose}
cancelDisabled={saving}
primaryAction={{
label: saving ? 'Saving...' : 'Save Changes',
onclick: handleSave,
disabled: saving
}}
/>
{/snippet}
</Dialog>
@ -283,6 +365,53 @@
gap: spacing.$unit-3x;
}
.separator {
border: none;
border-top: 1px solid var(--border-color, rgba(0, 0, 0, 0.08));
margin: 0;
}
.inline-switch {
label {
display: flex;
align-items: center;
justify-content: space-between;
span {
font-size: typography.$font-regular;
color: var(--text-primary);
}
}
.field-hint {
margin: spacing.$unit-half 0 0;
font-size: typography.$font-small;
color: var(--text-secondary);
}
}
.loading-field {
display: flex;
flex-direction: column;
gap: spacing.$unit-half;
.loading-label {
font-size: typography.$font-small;
font-weight: typography.$medium;
color: var(--text-primary);
}
.loading-text {
font-size: typography.$font-regular;
color: var(--text-secondary);
}
}
.loading-text {
font-size: typography.$font-regular;
color: var(--text-secondary);
}
.picture-section {
display: flex;
gap: spacing.$unit-3x;

View file

@ -74,16 +74,21 @@
)
// Build filters for search (using SearchFilters type from search.queries)
// Filter seriesFilters to only numbers for characterSeries (strings are weapon UUIDs)
const numericSeriesFilters = $derived(
seriesFilters.filter((s): s is number => typeof s === 'number')
)
const searchFilters = $derived<SearchFilters>({
element: elementFilters.length > 0 ? elementFilters : undefined,
rarity: rarityFilters.length > 0 ? rarityFilters : undefined,
season: seasonFilters.length > 0 ? seasonFilters : undefined,
characterSeries: seriesFilters.length > 0 ? seriesFilters : undefined,
characterSeries: numericSeriesFilters.length > 0 ? numericSeriesFilters : undefined,
proficiency: proficiencyFilters.length > 0 ? proficiencyFilters : undefined
})
// Search query with infinite scroll - dynamic based on entity type
const searchResults = createInfiniteQuery(() => {
// Type assertion needed because queryKeys differ but data shape is the same
const getSearchOptions = () => {
const query = searchQuery
const filters = searchFilters
@ -102,7 +107,8 @@
enabled: open
}
}
})
}
const searchResults = createInfiniteQuery(getSearchOptions as () => ReturnType<typeof searchQueries.weapons>)
// Flatten results and deduplicate by ID
const allResults = $derived.by(() => {
@ -445,39 +451,25 @@
{/if}
</div>
</div>
<ModalFooter>
{#snippet children()}
<div class="modal-footer">
<div class="footer-left">
{#if selectedCount > 0}
<button
type="button"
class="selected-link"
class:active={showOnlySelected}
onclick={toggleShowSelected}
>
{selectedText}
</button>
{/if}
</div>
<div class="footer-right">
<Button variant="ghost" onclick={() => (open = false)}>
Cancel
</Button>
<Button
variant="primary"
disabled={selectedCount === 0 || currentMutation.isPending}
onclick={handleAdd}
>
{#if currentMutation.isPending}
<Icon name="loader-2" size={16} />
Adding...
{:else}
Add to Collection
{/if}
</Button>
</div>
</div>
<ModalFooter
onCancel={() => (open = false)}
primaryAction={{
label: currentMutation.isPending ? 'Adding...' : 'Add to Collection',
onclick: handleAdd,
disabled: selectedCount === 0 || currentMutation.isPending
}}
>
{#snippet left()}
{#if selectedCount > 0}
<button
type="button"
class="selected-link"
class:active={showOnlySelected}
onclick={toggleShowSelected}
>
{selectedText}
</button>
{/if}
{/snippet}
</ModalFooter>
{/snippet}
@ -588,22 +580,6 @@
}
}
.modal-footer {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
.footer-left {
flex: 1;
}
.footer-right {
display: flex;
gap: $unit;
}
.selected-link {
background: none;
border: none;

View file

@ -6,7 +6,6 @@
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'
interface Props {
open: boolean
@ -83,14 +82,15 @@
</ModalBody>
{#if !success}
<ModalFooter>
<Button variant="secondary" onclick={handleCancel} disabled={sendMutation.isPending}>
Cancel
</Button>
<Button variant="primary" onclick={handleSend} disabled={sendMutation.isPending}>
{sendMutation.isPending ? 'Sending...' : 'Send Invitation'}
</Button>
</ModalFooter>
<ModalFooter
onCancel={handleCancel}
cancelDisabled={sendMutation.isPending}
primaryAction={{
label: sendMutation.isPending ? 'Sending...' : 'Send Invitation',
onclick: handleSend,
disabled: sendMutation.isPending
}}
/>
{/if}
</Dialog>

View file

@ -165,20 +165,16 @@
</ModalBody>
{#if !inviteSuccess}
<ModalFooter>
<Button variant="secondary" onclick={handleCancel}>
Cancel
</Button>
{#if foundUser}
<Button
variant="primary"
onclick={handleInvite}
disabled={sendMutation.isPending}
>
{sendMutation.isPending ? 'Sending...' : 'Send Invitation'}
</Button>
{/if}
</ModalFooter>
<ModalFooter
onCancel={handleCancel}
primaryAction={foundUser
? {
label: sendMutation.isPending ? 'Sending...' : 'Send Invitation',
onclick: handleInvite,
disabled: sendMutation.isPending
}
: undefined}
/>
{/if}
</Dialog>

View file

@ -21,7 +21,6 @@
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 type { ConflictData } from '$lib/types/api/conflict'
import type { GridCharacter, GridWeapon } from '$lib/types/api/party'
import type { Character, Weapon } from '$lib/types/api/entities'
@ -233,15 +232,14 @@
{/if}
{/snippet}
</ModalBody>
<ModalFooter>
{#snippet children()}
<Button variant="ghost" onclick={handleCancel} disabled={isLoading}>
{m.conflict_cancel()}
</Button>
<Button variant="primary" onclick={handleResolve} disabled={isLoading}>
{m.conflict_confirm()}
</Button>
{/snippet}
</ModalFooter>
<ModalFooter
onCancel={handleCancel}
cancelDisabled={isLoading}
primaryAction={{
label: m.conflict_confirm(),
onclick: handleResolve,
disabled: isLoading
}}
/>
{/snippet}
</Dialog>

View file

@ -2,16 +2,43 @@
<script lang="ts">
import type { Snippet } from 'svelte'
import Button from './Button.svelte'
interface Props {
children: Snippet
interface PrimaryAction {
label: string
onclick: () => void
destructive?: boolean
disabled?: boolean
}
let { children }: Props = $props()
interface Props {
onCancel: () => void
cancelDisabled?: boolean
primaryAction?: PrimaryAction
left?: Snippet
}
let { onCancel, cancelDisabled = false, primaryAction, left }: Props = $props()
</script>
<div class="modal-footer">
{@render children()}
{#if left}
<div class="left">
{@render left()}
</div>
{/if}
<div class="actions">
<Button variant="ghost" onclick={onCancel} disabled={cancelDisabled}>Nevermind</Button>
{#if primaryAction}
<Button
variant={primaryAction.destructive ? 'destructive' : 'primary'}
onclick={primaryAction.onclick}
disabled={primaryAction.disabled}
>
{primaryAction.label}
</Button>
{/if}
</div>
</div>
<style lang="scss">
@ -22,6 +49,16 @@
padding-top: 0;
display: flex;
gap: spacing.$unit-2x;
justify-content: flex-end;
align-items: center;
}
.left {
flex: 1;
}
.actions {
display: flex;
gap: spacing.$unit;
margin-left: auto;
}
</style>

View file

@ -112,6 +112,7 @@
role: 'captain',
retired: false,
retiredAt: null,
joinedAt: new Date().toISOString(),
createdAt: new Date().toISOString()
})
@ -237,8 +238,8 @@
<div class="crew-dashboard">
<CrewHeader
title={crewStore.crew?.name ?? ''}
subtitle={crewStore.crew?.gamertag}
description={crewStore.crew?.description}
subtitle={crewStore.crew?.gamertag ?? undefined}
description={crewStore.crew?.description ?? undefined}
>
{#snippet actions()}
{#if crewStore.isOfficer}
@ -292,8 +293,8 @@
<span class="event-dates">
{formatDate(event.startDate)} {formatDate(event.endDate)}
</span>
<span class="event-status status-{event.status}"
>{formatEventStatus(event.status, event.startDate)}</span
<span class="event-status status-{event.status ?? 'unknown'}"
>{formatEventStatus(event.status ?? 'unknown', event.startDate)}</span
>
</li>
{/each}
@ -354,20 +355,15 @@
{/snippet}
</ModalBody>
<ModalFooter>
{#snippet children()}
<Button variant="ghost" onclick={handleCloseModal} disabled={createCrewMutation.isPending}>
Cancel
</Button>
<Button
onclick={handleCreateCrew}
variant="primary"
disabled={!canCreate || createCrewMutation.isPending}
>
{createCrewMutation.isPending ? 'Creating...' : 'Create Crew'}
</Button>
{/snippet}
</ModalFooter>
<ModalFooter
onCancel={handleCloseModal}
cancelDisabled={createCrewMutation.isPending}
primaryAction={{
label: createCrewMutation.isPending ? 'Creating...' : 'Create Crew',
onclick: handleCreateCrew,
disabled: !canCreate || createCrewMutation.isPending
}}
/>
{/snippet}
</Dialog>
@ -419,24 +415,15 @@
{/snippet}
</ModalBody>
<ModalFooter>
{#snippet children()}
<Button
variant="ghost"
onclick={handleCloseSettingsModal}
disabled={updateCrewMutation.isPending}
>
Cancel
</Button>
<Button
onclick={handleUpdateSettings}
variant="primary"
disabled={!canSaveSettings || updateCrewMutation.isPending}
>
{updateCrewMutation.isPending ? 'Saving...' : 'Save'}
</Button>
{/snippet}
</ModalFooter>
<ModalFooter
onCancel={handleCloseSettingsModal}
cancelDisabled={updateCrewMutation.isPending}
primaryAction={{
label: updateCrewMutation.isPending ? 'Saving...' : 'Save',
onclick: handleUpdateSettings,
disabled: !canSaveSettings || updateCrewMutation.isPending
}}
/>
{/snippet}
</Dialog>

View file

@ -32,7 +32,7 @@
// Query for event and participation data
const eventQuery = createQuery(() => ({
queryKey: ['crew', 'gw', 'event', eventNumber],
queryFn: () => gwAdapter.getEventWithParticipation(eventNumber),
queryFn: () => gwAdapter.getEventWithParticipation(eventNumber ?? ''),
enabled: !!eventNumber && crewStore.isInCrew
}))
@ -404,7 +404,7 @@
<!-- Add Score Modal -->
<Dialog bind:open={showScoreModal} onOpenChange={(open) => !open && closeScoreModal()}>
<ModalHeader title="Add Score" onClose={closeScoreModal} />
<ModalHeader title="Add Score" />
<ModalBody>
<div class="score-form">
<Select
@ -455,20 +455,17 @@
{/if}
</div>
</ModalBody>
<ModalFooter>
<Button variant="secondary" size="small" onclick={closeScoreModal}>Cancel</Button>
<Button
variant="primary"
size="small"
onclick={handleSubmitScore}
disabled={isSubmitting ||
<ModalFooter
onCancel={closeScoreModal}
primaryAction={{
label: isSubmitting ? 'Saving...' : 'Save',
onclick: handleSubmitScore,
disabled: isSubmitting ||
!selectedPlayerId ||
(!isCumulative && calculatedCumulativeScore === 0) ||
(isCumulative && !cumulativeScore)}
>
{isSubmitting ? 'Saving...' : 'Save'}
</Button>
</ModalFooter>
(isCumulative && !cumulativeScore)
}}
/>
</Dialog>
<style lang="scss">

View file

@ -552,23 +552,14 @@
</p>
</ModalBody>
<ModalFooter>
{#snippet children()}
<Button variant="ghost" onclick={() => (confirmDialogOpen = false)}>Cancel</Button>
<Button
variant={confirmAction === 'remove' ? 'destructive' : 'primary'}
onclick={handleConfirmAction}
>
{#if confirmAction === 'remove'}
Remove
{:else if confirmAction === 'promote'}
Promote
{:else}
Demote
{/if}
</Button>
{/snippet}
</ModalFooter>
<ModalFooter
onCancel={() => (confirmDialogOpen = false)}
primaryAction={{
label: confirmAction === 'remove' ? 'Remove' : confirmAction === 'promote' ? 'Promote' : 'Demote',
onclick: handleConfirmAction,
destructive: confirmAction === 'remove'
}}
/>
{/snippet}
</Dialog>
@ -623,24 +614,15 @@
</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>
<ModalFooter
onCancel={() => (phantomDialogOpen = false)}
cancelDisabled={createPhantomMutation.isPending}
primaryAction={{
label: createPhantomMutation.isPending ? 'Creating...' : 'Create',
onclick: handleCreatePhantom,
disabled: !phantomName.trim() || createPhantomMutation.isPending
}}
/>
{/snippet}
</Dialog>
@ -664,14 +646,14 @@
</div>
</ModalBody>
<ModalFooter>
{#snippet children()}
<Button variant="ghost" onclick={() => (editJoinDateDialogOpen = false)}>Cancel</Button>
<Button variant="primary" onclick={handleSaveJoinDate} disabled={!editJoinDate}>
Save
</Button>
{/snippet}
</ModalFooter>
<ModalFooter
onCancel={() => (editJoinDateDialogOpen = false)}
primaryAction={{
label: 'Save',
onclick: handleSaveJoinDate,
disabled: !editJoinDate
}}
/>
{/snippet}
</Dialog>

View file

@ -10,6 +10,9 @@
title: 'Components/UI/Dialog',
tags: ['autodocs']
})
// Dummy handlers for storybook
const noop = () => {}
</script>
<script>
@ -67,12 +70,13 @@
<p>Are you sure you want to proceed with this action?</p>
{/snippet}
</ModalBody>
<ModalFooter>
{#snippet children()}
<Button variant="secondary" onclick={() => (withFooterOpen = false)}>Cancel</Button>
<Button variant="primary" onclick={() => (withFooterOpen = false)}>Confirm</Button>
{/snippet}
</ModalFooter>
<ModalFooter
onCancel={() => (withFooterOpen = false)}
primaryAction={{
label: 'Confirm',
onclick: () => (withFooterOpen = false)
}}
/>
{/snippet}
</Dialog>
</div>
@ -112,12 +116,13 @@
</div>
{/snippet}
</ModalBody>
<ModalFooter>
{#snippet children()}
<Button variant="secondary" onclick={() => (longContentOpen = false)}>Decline</Button>
<Button variant="primary" onclick={() => (longContentOpen = false)}>Accept</Button>
{/snippet}
</ModalFooter>
<ModalFooter
onCancel={() => (longContentOpen = false)}
primaryAction={{
label: 'Accept',
onclick: () => (longContentOpen = false)
}}
/>
{/snippet}
</Dialog>
</div>
@ -160,12 +165,13 @@
</div>
{/snippet}
</ModalBody>
<ModalFooter>
{#snippet children()}
<Button variant="secondary" onclick={() => (formOpen = false)}>Cancel</Button>
<Button variant="primary" onclick={() => (formOpen = false)}>Save Changes</Button>
{/snippet}
</ModalFooter>
<ModalFooter
onCancel={() => (formOpen = false)}
primaryAction={{
label: 'Save Changes',
onclick: () => (formOpen = false)
}}
/>
{/snippet}
</Dialog>
</div>
@ -186,12 +192,14 @@
</p>
{/snippet}
</ModalBody>
<ModalFooter>
{#snippet children()}
<Button variant="secondary" onclick={() => (confirmOpen = false)}>Cancel</Button>
<Button variant="destructive" onclick={() => (confirmOpen = false)}>Delete</Button>
{/snippet}
</ModalFooter>
<ModalFooter
onCancel={() => (confirmOpen = false)}
primaryAction={{
label: 'Delete',
onclick: () => (confirmOpen = false),
destructive: true
}}
/>
{/snippet}
</Dialog>
</div>