add party edit sidebar with raid selection

This commit is contained in:
Justin Edmund 2025-12-21 02:56:59 -08:00
parent b5cc2b7b9f
commit cf29c00c8d
4 changed files with 598 additions and 132 deletions

View file

@ -54,12 +54,17 @@
import Icon from '$lib/components/Icon.svelte'
import DescriptionRenderer from '$lib/components/DescriptionRenderer.svelte'
import { openDescriptionSidebar } from '$lib/features/description/openDescriptionSidebar.svelte'
import {
openPartyEditSidebar,
type PartyEditValues
} from '$lib/features/party/openPartyEditSidebar.svelte'
import PartyInfoGrid from '$lib/components/party/info/PartyInfoGrid.svelte'
import { DropdownMenu } from 'bits-ui'
import DropdownItem from '$lib/components/ui/dropdown/DropdownItem.svelte'
import JobSection from '$lib/components/job/JobSection.svelte'
import { Gender } from '$lib/utils/jobUtils'
import { openJobSelectionSidebar, openJobSkillSelectionSidebar } from '$lib/features/job/openJobSidebar.svelte'
import { partyAdapter } from '$lib/api/adapters/party.adapter'
import { partyAdapter, type UpdatePartyParams } from '$lib/api/adapters/party.adapter'
import { extractErrorMessage } from '$lib/utils/errors'
import { transformSkillsToArray } from '$lib/utils/jobSkills'
import { findNextEmptySlot, SLOT_NOT_FOUND } from '$lib/utils/gridHelpers'
@ -269,6 +274,16 @@
return result.canEdit
})
// Element mapping for theming (used for party element which is numeric)
const ELEMENT_MAP: Record<number, 'wind' | 'fire' | 'water' | 'earth' | 'dark' | 'light'> = {
1: 'wind',
2: 'fire',
3: 'water',
4: 'earth',
5: 'dark',
6: 'light'
}
// Derived elements for character image logic
const mainWeapon = $derived(
(party?.weapons ?? []).find((w) => w?.mainhand || w?.position === -1)
@ -276,6 +291,10 @@
const mainWeaponElement = $derived(mainWeapon?.element ?? mainWeapon?.weapon?.element)
const partyElement = $derived((party as any)?.element)
// User's element preference (string) - used for UI theming
type ElementType = 'wind' | 'fire' | 'water' | 'earth' | 'dark' | 'light'
const userElement = $derived(party.user?.avatar?.element as ElementType | undefined)
// Check if any items in the party are linked to collection (for sync menu option)
const hasCollectionLinks = $derived.by(() => {
const hasLinkedWeapons = (party?.weapons ?? []).some((w) => w?.collectionWeaponId)
@ -333,7 +352,7 @@
}
// Party operations
async function updatePartyDetails(updates: Partial<Party>) {
async function updatePartyDetails(updates: Omit<UpdatePartyParams, 'id' | 'shortcode'>) {
if (!canEdit()) return
loading = true
@ -341,7 +360,8 @@
try {
// Use TanStack Query mutation to update party
await updatePartyMutation.mutateAsync({ shortcode: party.shortcode, ...updates })
// Note: API expects UUID (id), shortcode is for cache invalidation
await updatePartyMutation.mutateAsync({ id: party.id, shortcode: party.shortcode, ...updates })
// Party will be updated via cache invalidation
} catch (err: any) {
error = err.message || 'Failed to update party'
@ -417,6 +437,45 @@
})
}
function openSettingsPanel() {
if (!canEdit()) return
const initialValues: PartyEditValues = {
name: party.name ?? '',
fullAuto: party.fullAuto ?? false,
autoGuard: party.autoGuard ?? false,
autoSummon: party.autoSummon ?? false,
chargeAttack: party.chargeAttack ?? true,
clearTime: party.clearTime ?? null,
buttonCount: party.buttonCount ?? null,
chainCount: party.chainCount ?? null,
summonCount: party.summonCount ?? null,
videoUrl: party.videoUrl ?? null,
raid: party.raid ?? null,
raidId: party.raid?.id ?? null
}
openPartyEditSidebar({
initialValues,
element: userElement,
onSave: async (values) => {
await updatePartyDetails({
name: values.name,
fullAuto: values.fullAuto,
autoGuard: values.autoGuard,
autoSummon: values.autoSummon,
chargeAttack: values.chargeAttack,
clearTime: values.clearTime,
buttonCount: values.buttonCount,
chainCount: values.chainCount,
summonCount: values.summonCount,
videoUrl: values.videoUrl,
raidId: values.raidId
})
}
})
}
async function deleteParty() {
// Only allow deletion if user owns the party
if (party.user?.id !== authUserId) return
@ -888,7 +947,7 @@
<DropdownMenu.Content class="dropdown-content" sideOffset={6} align="end">
{#if canEdit()}
<DropdownItem>
<button onclick={openEditDialog} disabled={loading}>Edit</button>
<button onclick={openSettingsPanel} disabled={loading}>Edit</button>
</DropdownItem>
{#if hasCollectionLinks}
<DropdownItem>
@ -926,41 +985,12 @@
</div>
</header>
{#if party.description || party.raid}
<div class="cards">
{#if party.description}
<div
class="description-card clickable"
onclick={openDescriptionPanel}
role="button"
tabindex="0"
onkeydown={(e) => e.key === 'Enter' && openDescriptionPanel()}
aria-label="View full description"
>
<h2 class="card-label">Description</h2>
<div class="card-content">
<DescriptionRenderer content={party.description} truncate={true} maxLines={4} />
</div>
</div>
{/if}
{#if party.raid}
<div class="raid-card">
<h2 class="card-label">Raid</h2>
<div class="raid-content">
<span class="raid-name">
{typeof party.raid.name === 'string'
? party.raid.name
: party.raid.name?.en || party.raid.name?.ja || 'Unknown Raid'}
</span>
{#if party.raid.group}
<span class="raid-difficulty">Difficulty: {party.raid.group.difficulty}</span>
{/if}
</div>
</div>
{/if}
</div>
{/if}
<PartyInfoGrid
{party}
canEdit={canEdit()}
onOpenDescription={openDescriptionPanel}
onOpenEdit={openSettingsPanel}
/>
<PartySegmentedControl
selectedTab={activeTab}
@ -1230,99 +1260,6 @@
}
}
// Cards container
.cards {
display: flex;
gap: $unit-2x;
// Individual card styles
.description-card,
.raid-card {
flex: 1;
min-width: 0; // Allow flexbox to shrink items
background: var(--card-bg);
border: 0.5px solid var(--button-bg);
border-radius: $card-corner;
padding: $unit-2x;
// box-shadow: $card-elevation;
text-align: left;
.card-label {
margin: 0 0 $unit 0;
font-size: $font-small;
font-weight: $bold;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-secondary);
}
.card-text {
margin: 0;
color: var(--text-primary);
font-size: $font-regular;
line-height: 1.5;
// Text truncation after 3 lines
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
}
.card-content {
margin: 0;
color: var(--text-primary);
}
.card-hint {
display: none;
margin-top: $unit;
font-size: $font-small;
color: var(--accent-blue);
font-weight: $medium;
}
&.clickable {
cursor: pointer;
@include smooth-transition($duration-quick, box-shadow);
&:hover {
box-shadow: $card-elevation-hover;
}
}
}
// Specific styling for raid card
.raid-card {
flex: 0 0 auto;
min-width: 250px;
.raid-content {
display: flex;
flex-direction: column;
gap: $unit-half;
}
.raid-name {
font-weight: $bold;
color: var(--text-primary);
font-size: $font-regular;
}
.raid-difficulty {
color: var(--text-secondary);
font-size: $font-small;
}
}
// Description card takes up more space
.description-card {
flex: 2;
max-width: 600px;
}
}
.error-message {
padding: $unit-three-quarter;
background: rgba(209, 58, 58, 0.1); // Using raw value since CSS variables don't work in rgba()

View file

@ -0,0 +1,153 @@
<svelte:options runes={true} />
<script lang="ts">
/**
* EditRaidPane - Raid selection pane for the sidebar
*
* Allows users to browse and select a raid from organized groups.
* Features section tabs (Raids/Events/Solo), search, and sort toggle.
*/
import type { RaidFull } from '$lib/types/api/raid'
import type { Raid } from '$lib/types/api/entities'
import { createQuery } from '@tanstack/svelte-query'
import { raidQueries } from '$lib/api/queries/raid.queries'
import { RaidSection } from '$lib/utils/raidSection'
import RaidSectionTabs from '$lib/components/raid/RaidSectionTabs.svelte'
import RaidGroupList from '$lib/components/raid/RaidGroupList.svelte'
import Button from '$lib/components/ui/Button.svelte'
import Input from '$lib/components/ui/Input.svelte'
interface Props {
/** Currently selected raid */
currentRaid?: Raid | null
/** Callback when a raid is selected */
onSelect: (raid: RaidFull | null) => void
}
let { currentRaid, onSelect }: Props = $props()
// State
let searchQuery = $state('')
let selectedSection = $state(RaidSection.Raid)
let sortAscending = $state(false)
// Fetch all raid groups
const groupsQuery = createQuery(() => raidQueries.groups())
// Filter groups by section and sort
const filteredGroups = $derived(() => {
if (!groupsQuery.data) return []
// Filter by section
let groups = groupsQuery.data.filter((group) => {
const groupSection =
typeof group.section === 'string' ? parseInt(group.section, 10) : group.section
return groupSection === selectedSection
})
// Sort by difficulty
groups = [...groups].sort((a, b) => {
const diff = a.difficulty - b.difficulty
return sortAscending ? diff : -diff
})
return groups
})
function handleRaidSelect(raid: RaidFull) {
// Toggle: clicking selected raid unselects it
if (raid.id === currentRaid?.id) {
onSelect(null)
} else {
onSelect(raid)
}
}
function toggleSort() {
sortAscending = !sortAscending
}
const isLoading = $derived(groupsQuery.isLoading)
</script>
<div class="edit-raid-pane">
<!-- Search Input -->
<div class="search-section">
<Input
leftIcon="search"
placeholder="Search raids..."
bind:value={searchQuery}
contained
clearable
fullWidth
/>
</div>
<!-- Controls: Tabs + Sort -->
<div class="controls-section">
<RaidSectionTabs bind:value={selectedSection} />
<Button
variant="ghost"
iconOnly
icon={sortAscending ? 'arrow-up' : 'arrow-down'}
onclick={toggleSort}
title="Toggle sort order"
/>
</div>
<!-- Raid List -->
<div class="raid-list-container">
{#if isLoading}
<div class="loading-state">
<span>Loading raids...</span>
</div>
{:else}
<RaidGroupList
groups={filteredGroups()}
selectedRaidId={currentRaid?.id}
onSelect={handleRaidSelect}
{searchQuery}
/>
{/if}
</div>
</div>
<style lang="scss">
@use '$src/themes/spacing' as *;
@use '$src/themes/typography' as *;
@use '$src/themes/layout' as *;
@use '$src/themes/effects' as *;
@use '$src/themes/colors' as *;
.edit-raid-pane {
display: flex;
flex-direction: column;
height: 100%;
}
.search-section {
padding: $unit-2x $unit-2x $unit $unit-2x;
}
.controls-section {
display: flex;
align-items: center;
gap: $unit;
padding: $unit $unit-2x $unit-2x $unit-2x;
border-bottom: 1px solid var(--border-secondary);
}
.raid-list-container {
flex: 1;
overflow-y: auto;
}
.loading-state {
display: flex;
align-items: center;
justify-content: center;
padding: $unit-4x;
color: var(--text-tertiary);
font-style: italic;
}
</style>

View file

@ -0,0 +1,342 @@
<script lang="ts">
/**
* PartyEditSidebar - Edit party metadata (settings, performance, video)
*
* This sidebar is opened via openPartyEditSidebar() and provides
* editing for battle settings, clear time, metrics, video URL, and raid.
*/
import DetailsSection from './details/DetailsSection.svelte'
import DetailRow from './details/DetailRow.svelte'
import Input from '$lib/components/ui/Input.svelte'
import BattleSettingsSection from '$lib/components/party/edit/BattleSettingsSection.svelte'
import ClearTimeInput from '$lib/components/party/edit/ClearTimeInput.svelte'
import YouTubeUrlInput from '$lib/components/party/edit/YouTubeUrlInput.svelte'
import MetricField from '$lib/components/party/edit/MetricField.svelte'
import EditRaidPane from '$lib/components/sidebar/EditRaidPane.svelte'
import { sidebar } from '$lib/stores/sidebar.svelte'
import { usePaneStack } from '$lib/stores/paneStack.svelte'
import { untrack } from 'svelte'
import type { Raid } from '$lib/types/api/entities'
import type { RaidFull } from '$lib/types/api/raid'
import Icon from '$lib/components/Icon.svelte'
export interface PartyEditValues {
name: string
fullAuto: boolean
autoGuard: boolean
autoSummon: boolean
chargeAttack: boolean
clearTime: number | null
buttonCount: number | null
chainCount: number | null
summonCount: number | null
videoUrl: string | null
raid: Raid | null
raidId: string | null
}
type ElementType = 'wind' | 'fire' | 'water' | 'earth' | 'dark' | 'light'
interface Props {
/** Current party values */
initialValues: PartyEditValues
/** Party element for switch theming */
element?: ElementType
/** Callback when save is requested */
onSave?: (values: PartyEditValues) => void
}
let { initialValues, element, onSave }: Props = $props()
// Get the pane stack for pushing EditRaidPane
const paneStack = usePaneStack()
// Local state - initialized from initialValues
let name = $state(initialValues.name)
let fullAuto = $state(initialValues.fullAuto)
let autoGuard = $state(initialValues.autoGuard)
let autoSummon = $state(initialValues.autoSummon)
let chargeAttack = $state(initialValues.chargeAttack)
let clearTime = $state(initialValues.clearTime)
let buttonCount = $state(initialValues.buttonCount)
let chainCount = $state(initialValues.chainCount)
let summonCount = $state(initialValues.summonCount)
let videoUrl = $state(initialValues.videoUrl)
let raid = $state<Raid | null>(initialValues.raid)
let raidId = $state<string | null>(initialValues.raidId)
// Check if any values have changed
const hasChanges = $derived(
name !== initialValues.name ||
fullAuto !== initialValues.fullAuto ||
autoGuard !== initialValues.autoGuard ||
autoSummon !== initialValues.autoSummon ||
chargeAttack !== initialValues.chargeAttack ||
clearTime !== initialValues.clearTime ||
buttonCount !== initialValues.buttonCount ||
chainCount !== initialValues.chainCount ||
summonCount !== initialValues.summonCount ||
videoUrl !== initialValues.videoUrl ||
raidId !== initialValues.raidId
)
// Expose save function for sidebar action button
export function save() {
const values: PartyEditValues = {
name,
fullAuto,
autoGuard,
autoSummon,
chargeAttack,
clearTime,
buttonCount,
chainCount,
summonCount,
videoUrl,
raid,
raidId
}
onSave?.(values)
sidebar.close()
}
// Update sidebar action button state based on changes
$effect(() => {
// Read hasChanges to track it
const changed = hasChanges
// Use untrack to prevent tracking sidebar mutations
untrack(() => {
if (changed) {
sidebar.setAction(save, 'Save', element)
} else {
sidebar.setAction(undefined, 'Save', element)
}
})
})
function handleSettingsChange(
field: 'fullAuto' | 'autoGuard' | 'autoSummon' | 'chargeAttack',
value: boolean
) {
if (field === 'fullAuto') fullAuto = value
else if (field === 'autoGuard') autoGuard = value
else if (field === 'autoSummon') autoSummon = value
else chargeAttack = value
}
function getRaidName(r: Raid | null): string {
if (!r) return ''
if (typeof r.name === 'string') return r.name
return r.name?.en || r.name?.ja || 'Unknown Raid'
}
function openRaidPane() {
paneStack.push({
id: 'edit-raid',
title: 'Select Raid',
component: EditRaidPane,
props: {
currentRaid: raid,
onSelect: handleRaidSelected
},
scrollable: true
})
}
function getRaidElementClass(r: Raid | null): string {
if (!r) return ''
const elementMap: Record<number, string> = {
1: 'wind',
2: 'fire',
3: 'water',
4: 'earth',
5: 'dark',
6: 'light'
}
return elementMap[r.element] ?? ''
}
function handleRaidSelected(selectedRaid: RaidFull | null) {
if (selectedRaid) {
// Convert RaidFull to Raid (they have compatible structures)
raid = {
id: selectedRaid.id,
slug: selectedRaid.slug,
name: selectedRaid.name,
level: selectedRaid.level,
element: selectedRaid.element,
group: selectedRaid.group
? {
id: selectedRaid.group.id,
name: selectedRaid.group.name,
section: String(selectedRaid.group.section),
order: selectedRaid.group.order,
difficulty: selectedRaid.group.difficulty,
hl: selectedRaid.group.hl,
extra: selectedRaid.group.extra,
guidebooks: selectedRaid.group.guidebooks
}
: undefined
}
raidId = selectedRaid.id
} else {
raid = null
raidId = null
}
paneStack.pop()
}
</script>
<div class="party-edit-sidebar">
<div class="top-fields">
<Input
label="Title"
bind:value={name}
placeholder="Enter party title..."
contained
fullWidth
/>
<YouTubeUrlInput label="Video" bind:value={videoUrl} contained />
</div>
<DetailsSection title="Battle">
<DetailRow label="Raid" noHover compact>
{#snippet children()}
<button
type="button"
class="raid-select-button {getRaidElementClass(raid)}"
onclick={openRaidPane}
>
{#if raid}
<span class="raid-name">{getRaidName(raid)}</span>
{:else}
<span class="placeholder">Select raid...</span>
{/if}
<Icon name="chevron-right" size={16} class="chevron-icon" />
</button>
{/snippet}
</DetailRow>
</DetailsSection>
<BattleSettingsSection
bind:fullAuto
bind:autoGuard
bind:autoSummon
bind:chargeAttack
{element}
onchange={handleSettingsChange}
/>
<DetailsSection title="Performance">
<DetailRow label="Clear Time" noHover compact>
{#snippet children()}
<ClearTimeInput bind:value={clearTime} contained />
{/snippet}
</DetailRow>
<DetailRow label="Button Count" noHover compact>
{#snippet children()}
<MetricField bind:value={buttonCount} label="B" contained />
{/snippet}
</DetailRow>
<DetailRow label="Chain Count" noHover compact>
{#snippet children()}
<MetricField bind:value={chainCount} label="C" contained />
{/snippet}
</DetailRow>
<DetailRow label="Summon Count" noHover compact>
{#snippet children()}
<MetricField bind:value={summonCount} label="S" contained />
{/snippet}
</DetailRow>
</DetailsSection>
</div>
<style lang="scss">
@use '$src/themes/spacing' as *;
@use '$src/themes/typography' as *;
@use '$src/themes/layout' as *;
@use '$src/themes/effects' as *;
@use '$src/themes/colors' as *;
.party-edit-sidebar {
display: flex;
flex-direction: column;
gap: $unit-3x;
padding-bottom: $unit-2x;
}
.top-fields {
display: flex;
flex-direction: column;
gap: $unit-2x;
padding: 0 $unit-2x;
}
.raid-select-button {
display: flex;
align-items: center;
gap: $unit;
padding: 0;
background: none;
border: none;
cursor: pointer;
text-align: left;
}
.raid-name {
font-size: $font-regular;
font-weight: $medium;
color: var(--text-secondary);
}
.placeholder {
font-size: $font-regular;
font-weight: $medium;
color: var(--text-secondary);
}
.chevron-icon {
color: var(--text-secondary);
flex-shrink: 0;
}
// Element-specific colors when raid is selected
.raid-select-button {
&.wind {
.raid-name,
.chevron-icon {
color: $wind-text-30;
}
}
&.fire {
.raid-name,
.chevron-icon {
color: $fire-text-30;
}
}
&.water {
.raid-name,
.chevron-icon {
color: $water-text-30;
}
}
&.earth {
.raid-name,
.chevron-icon {
color: $earth-text-30;
}
}
&.dark {
.raid-name,
.chevron-icon {
color: $dark-text-30;
}
}
&.light {
.raid-name,
.chevron-icon {
color: $light-text-30;
}
}
}
</style>

View file

@ -0,0 +1,34 @@
import { sidebar } from '$lib/stores/sidebar.svelte'
import PartyEditSidebar, {
type PartyEditValues
} from '$lib/components/sidebar/PartyEditSidebar.svelte'
type ElementType = 'wind' | 'fire' | 'water' | 'earth' | 'dark' | 'light'
interface PartyEditSidebarOptions {
/** Current party values for editing */
initialValues: PartyEditValues
/** Party element for switch theming */
element?: ElementType
/** Callback when user saves changes */
onSave: (values: PartyEditValues) => void
}
/**
* Opens the party edit sidebar for editing battle settings, performance metrics, and video URL.
*/
export function openPartyEditSidebar(options: PartyEditSidebarOptions) {
const { initialValues, element, onSave } = options
sidebar.openWithComponent('Edit Party Settings', PartyEditSidebar, {
initialValues,
element,
onSave
})
}
export function closePartyEditSidebar() {
sidebar.close()
}
export type { PartyEditValues }