hensei-web/src/lib/components/party/Party.svelte

1393 lines
36 KiB
Svelte

<script lang="ts">
import { onMount, getContext, setContext, onDestroy } from 'svelte'
import { goto } from '$app/navigation'
import { page } from '$app/state'
import type { Party, GridCharacter, GridWeapon, GridSummon } from '$lib/types/api/party'
import { partyStore } from '$lib/stores/partyStore.svelte'
// TanStack Query mutations - Grid
import {
useCreateGridWeapon,
useCreateGridCharacter,
useCreateGridSummon,
useDeleteGridWeapon,
useDeleteGridCharacter,
useDeleteGridSummon,
useUpdateGridWeapon,
useUpdateGridCharacter,
useUpdateGridSummon,
useUpdateWeaponUncap,
useUpdateCharacterUncap,
useUpdateSummonUncap,
useSwapWeapons,
useSwapCharacters,
useSwapSummons
} from '$lib/api/mutations/grid.mutations'
// TanStack Query mutations - Party
import {
useUpdateParty,
useDeleteParty,
useRemixParty,
useFavoriteParty,
useUnfavoriteParty
} from '$lib/api/mutations/party.mutations'
// Utilities
import { getLocalId } from '$lib/utils/localId'
import { getEditKey, storeEditKey, computeEditability } from '$lib/utils/editKeys'
import { createDragDropContext, type DragOperation } from '$lib/composables/drag-drop.svelte'
import WeaponGrid from '$lib/components/grids/WeaponGrid.svelte'
import SummonGrid from '$lib/components/grids/SummonGrid.svelte'
import CharacterGrid from '$lib/components/grids/CharacterGrid.svelte'
import { openSearchSidebar } from '$lib/features/search/openSearchSidebar.svelte'
import PartySegmentedControl from '$lib/components/party/PartySegmentedControl.svelte'
import type { SearchResult } from '$lib/api/adapters'
import { GridType } from '$lib/types/enums'
import Dialog from '$lib/components/ui/Dialog.svelte'
import Button from '$lib/components/ui/Button.svelte'
import Icon from '$lib/components/Icon.svelte'
import DescriptionRenderer from '$lib/components/DescriptionRenderer.svelte'
import { openDescriptionSidebar } from '$lib/features/description/openDescriptionSidebar.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 { extractErrorMessage } from '$lib/utils/errors'
import { transformSkillsToArray } from '$lib/utils/jobSkills'
import { findNextEmptySlot, SLOT_NOT_FOUND } from '$lib/utils/gridHelpers'
import ConflictDialog from '$lib/components/dialogs/ConflictDialog.svelte'
import type { ConflictData } from '$lib/types/api/conflict'
import { isConflictResponse, createConflictData } from '$lib/types/api/conflict'
interface Props {
party?: Party
canEdit?: boolean
authUserId?: string
initialTab?: GridType
}
let { party: initial, canEdit: canEditServer = false, authUserId, initialTab }: Props = $props()
// Per-route local state using Svelte 5 runes
const defaultParty: Party = {
id: 'new',
shortcode: 'new',
name: '',
description: '',
weapons: [],
summons: [],
characters: []
}
// Derive party directly from prop - single source of truth from TanStack Query cache
let party = $derived(
initial?.id && initial?.id !== 'new' && Array.isArray(initial?.weapons) ? initial : defaultParty
)
// Sync party to global store for components outside the party context (like DetailsSidebar)
$effect(() => {
partyStore.setParty(party)
})
// Clear store on unmount
onDestroy(() => {
partyStore.clear()
})
// Map URL segment to GridType
const tabMap: Record<string, GridType> = {
weapons: GridType.Weapon,
summons: GridType.Summon,
characters: GridType.Character
}
// Derive activeTab from URL params (single source of truth)
// This ensures back/forward navigation works correctly
// Falls back to initialTab prop for SSR hydration
let activeTab = $derived.by(() => {
const urlTab = page.params.tab as string | undefined
if (urlTab) {
return tabMap[urlTab] ?? GridType.Weapon
}
// Use initialTab for SSR or when no tab in URL
return initialTab ?? GridType.Weapon
})
let loading = $state(false)
let error = $state<string | null>(null)
let selectedSlot = $state<number>(0)
let editDialogOpen = $state(false)
let editingTitle = $state('')
let conflictDialogOpen = $state(false)
let conflictData = $state<ConflictData | null>(null)
// TanStack Query mutations - Grid
const createWeapon = useCreateGridWeapon()
const createCharacter = useCreateGridCharacter()
const createSummon = useCreateGridSummon()
const deleteWeapon = useDeleteGridWeapon()
const deleteCharacter = useDeleteGridCharacter()
const deleteSummon = useDeleteGridSummon()
const updateWeapon = useUpdateGridWeapon()
const updateCharacter = useUpdateGridCharacter()
const updateSummon = useUpdateGridSummon()
const updateWeaponUncap = useUpdateWeaponUncap()
const updateCharacterUncap = useUpdateCharacterUncap()
const updateSummonUncap = useUpdateSummonUncap()
const swapWeapons = useSwapWeapons()
const swapCharacters = useSwapCharacters()
const swapSummons = useSwapSummons()
// TanStack Query mutations - Party
const updatePartyMutation = useUpdateParty()
const deletePartyMutation = useDeleteParty()
const remixPartyMutation = useRemixParty()
const favoritePartyMutation = useFavoriteParty()
const unfavoritePartyMutation = useUnfavoriteParty()
// Create drag-drop context
const dragContext = createDragDropContext({
onLocalUpdate: async (operation) => {
console.log('📝 Drag operation:', operation)
await handleDragOperation(operation)
},
onValidate: (source, target) => {
// Type must match
if (source.type !== target.type) return false
// Characters: Sequential filling
if (source.type === 'character' && target.container === 'main-characters') {
// For now, allow any position (we'll handle sequential filling in the operation)
return true
}
// Weapons: Mainhand not draggable
if (target.type === 'weapon' && target.position === -1) return false
// Summons: Main/Friend not draggable
if (target.type === 'summon' && (target.position === -1 || target.position === 6))
return false
return true
}
})
// Handle drag operations
async function handleDragOperation(operation: DragOperation) {
if (!canEdit()) return
const { source, target } = operation
try {
loading = true
if (operation.type === 'swap') {
// Handle swapping items between positions
await handleSwap(source, target)
} else if (operation.type === 'move') {
// Handle moving to empty position
await handleMove(source, target)
}
// Party will be updated via cache invalidation from mutations
} catch (err: any) {
error = err.message || 'Failed to update party'
console.error('Drag operation failed:', err)
} finally {
loading = false
dragContext.clearQueue()
}
}
async function handleSwap(source: any, target: any): Promise<void> {
if (!party.id || party.id === 'new') {
throw new Error('Cannot swap items in unsaved party')
}
// Use appropriate swap mutation based on item type
const swapParams = {
partyId: party.id,
partyShortcode: party.shortcode,
sourceId: source.itemId,
targetId: target.itemId
}
if (source.type === 'weapon') {
await swapWeapons.mutateAsync(swapParams)
} else if (source.type === 'character') {
await swapCharacters.mutateAsync(swapParams)
} else if (source.type === 'summon') {
await swapSummons.mutateAsync(swapParams)
}
}
async function handleMove(source: any, target: any): Promise<void> {
if (!party.id || party.id === 'new') {
throw new Error('Cannot move items in unsaved party')
}
// Move is swap with empty target - use update mutation to change position
const updateParams = {
id: source.itemId,
partyShortcode: party.shortcode,
updates: { position: target.position }
}
if (source.type === 'weapon') {
await updateWeapon.mutateAsync(updateParams)
} else if (source.type === 'character') {
await updateCharacter.mutateAsync(updateParams)
} else if (source.type === 'summon') {
await updateSummon.mutateAsync(updateParams)
}
}
// Localized name helper: accepts either an object with { name: { en, ja } }
// or a direct { en, ja } map, or a plain string.
function displayName(input: any): string {
if (!input) return '—'
const maybe = input.name ?? input
if (typeof maybe === 'string') return maybe
if (maybe && typeof maybe === 'object') {
return maybe.en || maybe.ja || '—'
}
return '—'
}
// Client-side editability state
let localId = $state<string>('')
let editKey = $state<string | undefined>(undefined)
// Derived editability (combines server and client state)
let canEdit = $derived(() => {
if (canEditServer) return true
// Re-compute on client with localStorage values
const result = computeEditability(party, authUserId, localId, editKey)
return result.canEdit
})
// Derived elements for character image logic
const mainWeapon = $derived(
(party?.weapons ?? []).find((w) => w?.mainhand || w?.position === -1)
)
const mainWeaponElement = $derived(mainWeapon?.element ?? mainWeapon?.weapon?.element)
const partyElement = $derived((party as any)?.element)
function handleTabChange(tab: GridType) {
// Update URL (adds to browser history)
// activeTab is derived from URL params, so it will update automatically
// Always use explicit tab path (e.g., /weapons not just /) to ensure navigation triggers
const basePath = `/teams/${party.shortcode}`
const newPath = `${basePath}/${tab}s`
goto(newPath, { noScroll: true, keepFocus: true })
// Update selectedSlot to the first valid empty slot for this tab
const nextEmpty = findNextEmptySlot(party, tab)
if (nextEmpty !== SLOT_NOT_FOUND) {
selectedSlot = nextEmpty
} else {
// Fallback: all grid types start at 0
selectedSlot = 0
}
}
// Edit dialog functions
function openEditDialog() {
if (!canEdit()) return
editingTitle = party.name || ''
editDialogOpen = true
}
async function savePartyTitle() {
if (!canEdit()) return
try {
loading = true
error = null
// Update party title via API
await updatePartyDetails({ name: editingTitle })
editDialogOpen = false
} catch (err: any) {
error = err.message || 'Failed to update party title'
} finally {
loading = false
}
}
// Party operations
async function updatePartyDetails(updates: Partial<Party>) {
if (!canEdit()) return
loading = true
error = null
try {
// Use TanStack Query mutation to update party
await updatePartyMutation.mutateAsync({ shortcode: party.shortcode, ...updates })
// Party will be updated via cache invalidation
} catch (err: any) {
error = err.message || 'Failed to update party'
} finally {
loading = false
}
}
async function toggleFavorite() {
if (!authUserId) return // Must be logged in to favorite
loading = true
error = null
try {
if (party.favorited) {
await unfavoritePartyMutation.mutateAsync(party.shortcode)
} else {
await favoritePartyMutation.mutateAsync(party.shortcode)
}
// Party will be updated via cache invalidation
} catch (err: any) {
error = err.message || 'Failed to update favorite status'
} finally {
loading = false
}
}
async function remixParty() {
loading = true
error = null
try {
const newParty = await remixPartyMutation.mutateAsync(party.shortcode)
// Navigate to new party
window.location.href = `/teams/${newParty.shortcode}`
} catch (err: any) {
error = err.message || 'Failed to remix party'
} finally {
loading = false
}
}
let deleteDialogOpen = $state(false)
let deleting = $state(false)
function openDescriptionPanel() {
openDescriptionSidebar({
title: party.name || '(untitled party)',
description: party.description,
canEdit: canEdit(),
onEdit: openEditDialog
})
}
async function deleteParty() {
// Only allow deletion if user owns the party
if (party.user?.id !== authUserId) return
try {
deleting = true
error = null
// Delete the party using mutation (API expects UUID, cache uses shortcode)
await deletePartyMutation.mutateAsync({ id: party.id, shortcode: party.shortcode })
// Navigate to user's own profile page after deletion
if (party.user?.username) {
window.location.href = `/${party.user.username}`
} else {
// Fallback to /me for logged-in users
window.location.href = '/me'
}
} catch (err: any) {
error = err.message || 'Failed to delete party'
deleteDialogOpen = false
} finally {
deleting = false
}
}
// Handle job selection
async function handleSelectJob() {
if (!canEdit()) return
openJobSelectionSidebar({
currentJobId: party.job?.id,
onSelectJob: async (job) => {
loading = true
error = null
try {
// Update job via API (use shortcode for party identification)
await partyAdapter.updateJob(party.shortcode, job.id)
// Party will be updated via cache invalidation
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to update job'
console.error('Failed to update job:', e)
} finally {
loading = false
}
}
})
}
// Handle job skill selection
async function handleSelectJobSkill(slot: number) {
if (!canEdit()) return
openJobSkillSelectionSidebar({
job: party.job,
currentSkills: party.jobSkills,
targetSlot: slot,
onSelectSkill: async (skill) => {
loading = true
error = null
try {
// Update skills with the new skill in the slot
const updatedSkills = { ...party.jobSkills }
updatedSkills[String(slot) as keyof typeof updatedSkills] = skill
console.log('[Party] Current jobSkills:', party.jobSkills)
console.log('[Party] Updated jobSkills object:', updatedSkills)
console.log('[Party] Slot being updated:', slot)
console.log('[Party] New skill:', skill)
// Convert skills object to array format expected by API
const skillsArray = transformSkillsToArray(updatedSkills)
console.log('[Party] Skills array to send:', skillsArray)
await partyAdapter.updateJobSkills(party.shortcode, skillsArray)
// Party will be updated via cache invalidation
} catch (e: any) {
error = extractErrorMessage(e, 'Failed to update skill')
console.error('Failed to update skill:', e)
} finally {
loading = false
}
},
onRemoveSkill: async () => {
loading = true
error = null
try {
// Remove skill from slot
const updatedSkills = { ...party.jobSkills }
delete updatedSkills[String(slot) as keyof typeof updatedSkills]
console.log('[Party] Removing skill from slot:', slot)
console.log('[Party] Current jobSkills:', party.jobSkills)
console.log('[Party] Updated jobSkills after removal:', updatedSkills)
// Convert skills object to array format expected by API
const skillsArray = transformSkillsToArray(updatedSkills)
console.log('[Party] Skills array to send after removal:', skillsArray)
await partyAdapter.updateJobSkills(party.shortcode, skillsArray)
// Party will be updated via cache invalidation
} catch (e: any) {
error = extractErrorMessage(e, 'Failed to remove skill')
console.error('Failed to remove skill:', e)
} finally {
loading = false
}
}
})
}
// Handle removing a skill directly
async function handleRemoveJobSkill(slot: number) {
if (!canEdit()) return
loading = true
error = null
try {
// Remove skill from slot
const updatedSkills = { ...party.jobSkills }
delete updatedSkills[String(slot) as keyof typeof updatedSkills]
// Convert skills object to array format expected by API
const skillsArray = transformSkillsToArray(updatedSkills)
await partyAdapter.updateJobSkills(party.shortcode, skillsArray)
// Party will be updated via cache invalidation
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to remove skill'
console.error('Failed to remove skill:', e)
} finally {
loading = false
}
}
// Handle adding items from the search sidebar
async function handleAddItems(items: SearchResult[]) {
if (items.length === 0 || !canEdit()) return
const item = items[0]
if (!item) return
loading = true
error = null
try {
// Determine which slot to use
let targetSlot = selectedSlot
// Call appropriate create mutation based on current tab
// Use granblueId (camelCase) as that's what the SearchResult type uses
const itemId = item.granblueId
let result: unknown
if (activeTab === GridType.Weapon) {
result = await createWeapon.mutateAsync({
partyId: party.id,
weaponId: itemId,
position: targetSlot,
mainhand: targetSlot === -1
})
// Check if the result is a conflict response
if (isConflictResponse(result)) {
conflictData = createConflictData(result, 'weapon')
conflictDialogOpen = true
return
}
} else if (activeTab === GridType.Summon) {
await createSummon.mutateAsync({
partyId: party.id,
summonId: itemId,
position: targetSlot,
main: targetSlot === -1,
friend: targetSlot === 6
})
} else if (activeTab === GridType.Character) {
result = await createCharacter.mutateAsync({
partyId: party.id,
characterId: itemId,
position: targetSlot
})
// Check if the result is a conflict response
if (isConflictResponse(result)) {
conflictData = createConflictData(result, 'character')
conflictDialogOpen = true
return
}
}
// Party will be updated via cache invalidation from the mutation
// Find next empty slot for continuous adding
const nextEmptySlot = findNextEmptySlot(party, activeTab)
if (nextEmptySlot !== SLOT_NOT_FOUND) {
selectedSlot = nextEmptySlot
}
// Note: Sidebar stays open for continuous adding
} catch (err: any) {
error = err.message || 'Failed to add item'
} finally {
loading = false
}
}
// Client-side initialization
onMount(() => {
// Get or create local ID
localId = getLocalId()
// Get edit key for this party if it exists
editKey = getEditKey(party.shortcode) ?? undefined
// No longer need to verify party data integrity after hydration
// since $state.raw prevents the hydration mismatch
})
// Grid service wrapper using TanStack Query mutations
const clientGridService = {
async removeWeapon(partyId: string, gridWeaponId: string, _editKey?: string) {
try {
await deleteWeapon.mutateAsync({
id: gridWeaponId,
partyId,
partyShortcode: party.shortcode
})
// Party will be updated via cache invalidation
} catch (err) {
console.error('Failed to remove weapon:', err)
throw err
}
},
async removeSummon(partyId: string, gridSummonId: string, _editKey?: string) {
try {
await deleteSummon.mutateAsync({
id: gridSummonId,
partyId,
partyShortcode: party.shortcode
})
} catch (err) {
console.error('Failed to remove summon:', err)
throw err
}
},
async removeCharacter(partyId: string, gridCharacterId: string, _editKey?: string) {
try {
await deleteCharacter.mutateAsync({
id: gridCharacterId,
partyId,
partyShortcode: party.shortcode
})
} catch (err) {
console.error('Failed to remove character:', err)
throw err
}
},
async updateWeapon(partyId: string, gridWeaponId: string, updates: any, _editKey?: string) {
try {
await updateWeapon.mutateAsync({
id: gridWeaponId,
partyShortcode: party.shortcode,
updates
})
} catch (err) {
console.error('Failed to update weapon:', err)
throw err
}
},
async updateSummon(partyId: string, gridSummonId: string, updates: any, _editKey?: string) {
try {
await updateSummon.mutateAsync({
id: gridSummonId,
partyShortcode: party.shortcode,
updates
})
} catch (err) {
console.error('Failed to update summon:', err)
throw err
}
},
async updateCharacter(
partyId: string,
gridCharacterId: string,
updates: any,
_editKey?: string
) {
try {
await updateCharacter.mutateAsync({
id: gridCharacterId,
partyShortcode: party.shortcode,
updates
})
} catch (err) {
console.error('Failed to update character:', err)
throw err
}
},
async updateCharacterUncap(
gridCharacterId: string,
uncapLevel?: number,
transcendenceStep?: number,
_editKey?: string
) {
try {
await updateCharacterUncap.mutateAsync({
id: gridCharacterId,
partyShortcode: party.shortcode,
uncapLevel,
transcendenceStep
})
} catch (err) {
console.error('Failed to update character uncap:', err)
throw err
}
},
async updateWeaponUncap(
gridWeaponId: string,
uncapLevel?: number,
transcendenceStep?: number,
_editKey?: string
) {
try {
await updateWeaponUncap.mutateAsync({
id: gridWeaponId,
partyShortcode: party.shortcode,
uncapLevel,
transcendenceStep
})
} catch (err) {
console.error('Failed to update weapon uncap:', err)
throw err
}
},
async updateSummonUncap(
gridSummonId: string,
uncapLevel?: number,
transcendenceStep?: number,
_editKey?: string
) {
try {
await updateSummonUncap.mutateAsync({
id: gridSummonId,
partyShortcode: party.shortcode,
uncapLevel,
transcendenceStep
})
} catch (err) {
console.error('Failed to update summon uncap:', err)
throw err
}
}
}
// Provide services to child components via context
setContext('party', {
getParty: () => party,
canEdit: () => canEdit(),
getEditKey: () => editKey,
getSelectedSlot: () => selectedSlot,
getActiveTab: () => activeTab,
services: {
gridService: clientGridService // Uses TanStack Query mutations
},
openPicker: (opts: {
type: 'weapon' | 'summon' | 'character'
position: number
item?: any
}) => {
if (!canEdit()) return
selectedSlot = opts.position
activeTab =
opts.type === 'weapon'
? GridType.Weapon
: opts.type === 'summon'
? GridType.Summon
: GridType.Character
// Open the search sidebar with the appropriate type
openSearchSidebar({
type: opts.type,
onAddItems: handleAddItems,
canAddMore: true
})
}
})
// Provide drag-drop context to child components
setContext('drag-drop', dragContext)
</script>
<div class="page-wrap">
<div class="track">
<section class="party-container">
<header class="party-header">
<div class="party-info">
<h1>{party.name || '(untitled party)'}</h1>
{#if party.user}
{@const avatarFile = party.user.avatar?.picture || ''}
{@const ensurePng = (name: string) => (/\.png$/i.test(name) ? name : `${name}.png`)}
{@const to2x = (name: string) =>
/\.png$/i.test(name) ? name.replace(/\.png$/i, '@2x.png') : `${name}@2x.png`}
{@const avatarSrc = avatarFile ? `/profile/${ensurePng(avatarFile)}` : ''}
{@const avatarSrcSet = avatarFile
? `${avatarSrc} 1x, /profile/${to2x(avatarFile)} 2x`
: ''}
<div class="creator">
<a href="/{party.user.username}" class="creator-link">
<div class="avatar-wrapper {party.user.avatar?.element || ''}">
{#if party.user.avatar?.picture}
<img
class="avatar"
alt={`Avatar of ${party.user.username}`}
src={avatarSrc}
srcset={avatarSrcSet}
width="32"
height="32"
/>
{:else}
<div class="avatar-placeholder" aria-hidden="true"></div>
{/if}
</div>
<span class="username">{party.user.username}</span>
</a>
</div>
{/if}
</div>
<div class="party-actions">
<DropdownMenu.Root>
<DropdownMenu.Trigger class="party-actions-trigger" aria-label="Open actions menu">
<Icon name="ellipsis" size={16} />
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content class="dropdown-content" sideOffset={6} align="end">
{#if canEdit()}
<DropdownItem>
<button onclick={openEditDialog} disabled={loading}>Edit</button>
</DropdownItem>
{/if}
{#if authUserId}
<DropdownItem>
<button onclick={toggleFavorite} disabled={loading}>
{party.favorited ? 'Remove from favorites' : 'Add to favorites'}
</button>
</DropdownItem>
{/if}
<DropdownItem>
<button onclick={remixParty} disabled={loading}>Remix</button>
</DropdownItem>
{#if party.user?.id === authUserId}
<DropdownMenu.Separator class="dropdown-separator" />
<DropdownItem>
<button onclick={() => (deleteDialogOpen = true)} disabled={loading}>
Delete
</button>
</DropdownItem>
{/if}
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
</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}
<PartySegmentedControl
selectedTab={activeTab}
onTabChange={handleTabChange}
{party}
class="party-tabs"
/>
{#if error}
<div class="error-message" role="alert">
{error}
</div>
{/if}
<div class="party-content">
{#if activeTab === GridType.Weapon}
<WeaponGrid
weapons={party.weapons}
raidExtra={(party as any)?.raid?.group?.extra}
showGuidebooks={(party as any)?.raid?.group?.guidebooks}
guidebooks={(party as any)?.guidebooks}
/>
{:else if activeTab === GridType.Summon}
<SummonGrid summons={party.summons} />
{:else}
<div class="character-tab-content">
<JobSection
job={party.job}
jobSkills={party.jobSkills}
accessory={party.accessory}
canEdit={canEdit()}
gender={Gender.Gran}
element={mainWeaponElement}
onSelectJob={handleSelectJob}
onSelectSkill={handleSelectJobSkill}
onRemoveSkill={handleRemoveJobSkill}
onSelectAccessory={() => {
// TODO: Open accessory selection sidebar
console.log('Open accessory selection sidebar')
}}
/>
<CharacterGrid
characters={party.characters}
{mainWeaponElement}
{partyElement}
/>
</div>
{/if}
</div>
</section>
</div>
</div>
<!-- Edit Dialog -->
<Dialog bind:open={editDialogOpen} title="Edit Party Details">
{#snippet children()}
<div class="edit-form">
<label for="party-title">Party Title</label>
<input
id="party-title"
type="text"
bind:value={editingTitle}
placeholder="Enter party title..."
disabled={loading}
/>
</div>
{/snippet}
{#snippet footer()}
<button class="btn-secondary" onclick={() => (editDialogOpen = false)} disabled={loading}>
Cancel
</button>
<button class="btn-primary" onclick={savePartyTitle} disabled={loading || !editingTitle.trim()}>
{loading ? 'Saving...' : 'Save'}
</button>
{/snippet}
</Dialog>
<!-- Delete Confirmation Dialog -->
<Dialog bind:open={deleteDialogOpen} title="Delete Party">
{#snippet children()}
<div class="delete-confirmation">
<p>Are you sure you want to delete this party?</p>
<p><strong>{party.name || 'Unnamed Party'}</strong></p>
<p class="warning">⚠️ This action cannot be undone.</p>
</div>
{/snippet}
{#snippet footer()}
<button class="btn-secondary" onclick={() => (deleteDialogOpen = false)} disabled={deleting}>
Cancel
</button>
<button class="btn-danger" onclick={deleteParty} disabled={deleting}>
{deleting ? 'Deleting...' : 'Delete Party'}
</button>
{/snippet}
</Dialog>
<!-- Conflict Resolution Dialog -->
<ConflictDialog
bind:open={conflictDialogOpen}
conflict={conflictData}
partyId={party.id}
partyShortcode={party.shortcode}
onResolve={() => {
conflictData = null
// Find next empty slot after conflict resolution
const nextEmptySlot = findNextEmptySlot(party, activeTab)
if (nextEmptySlot !== SLOT_NOT_FOUND) {
selectedSlot = nextEmptySlot
}
}}
onCancel={() => {
conflictData = null
}}
/>
<style lang="scss">
@use '$src/themes/typography' as *;
@use '$src/themes/colors' as *;
@use '$src/themes/spacing' as *;
@use '$src/themes/effects' as *;
@use '$src/themes/layout' as *;
.page-wrap {
position: relative;
--panel-w: 380px;
overflow-x: auto;
}
.track {
display: flex;
gap: 0;
align-items: flex-start;
}
.party-container {
width: 1200px;
margin: 0 auto;
padding: $unit-half;
gap: $unit-2x;
display: flex;
flex-direction: column;
}
.party-header {
display: flex;
justify-content: space-between;
align-items: start;
vertical-align: middle;
align-items: center;
padding: $unit-2x 0;
}
.party-info {
flex-grow: 1;
h1 {
margin: 0 0 $unit-fourth 0;
font-size: $font-xlarge;
font-weight: $bold;
line-height: 1.2;
}
}
.creator {
margin-top: $unit-half;
&-link {
display: inline-flex;
align-items: center;
gap: $unit-three-quarter;
text-decoration: none;
color: var(--text-tertiary);
@include smooth-transition($duration-standard, color);
&:hover {
color: var(--text-tertiary-hover);
.avatar-wrapper {
transform: scale(1.05);
}
}
}
}
.avatar-wrapper {
width: $unit-4x;
height: $unit-4x;
border-radius: 50%;
overflow: hidden;
background: var(--card-bg);
display: flex;
align-items: center;
justify-content: center;
@include smooth-transition($duration-zoom, transform);
&.wind {
background: var(--wind-bg);
}
&.fire {
background: var(--fire-bg);
}
&.water {
background: var(--water-bg);
}
&.earth {
background: var(--earth-bg);
}
&.light {
background: var(--light-bg);
}
&.dark {
background: var(--dark-bg);
}
.avatar {
width: $unit-4x + $unit-half;
height: $unit-4x + $unit-half;
border-radius: 50%;
object-fit: cover;
}
.avatar-placeholder {
width: $unit-4x + $unit-half;
height: $unit-4x + $unit-half;
border-radius: 50%;
background: var(--placeholder-bg);
}
}
.username {
font-size: $font-regular;
font-weight: $medium;
}
.party-actions {
display: flex;
gap: $unit-half;
}
// Style the dropdown trigger button
:global(.party-actions-trigger) {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
border-radius: 50%;
background-color: transparent;
color: var(--text-secondary);
border: none;
cursor: pointer;
transition: background-color 0.2s ease, color 0.2s ease;
outline: none;
&:hover {
background-color: var(--button-subtle-bg-hover);
color: var(--text-primary);
}
&:focus-visible {
box-shadow: 0 0 0 2px var(--accent-blue-focus);
}
&:active {
background-color: var(--button-subtle-bg-active);
}
}
// 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()
border: 1px solid rgba(209, 58, 58, 0.3);
border-radius: $unit-half;
color: $error;
margin-bottom: $unit;
font-size: $font-small;
}
.party-content {
min-height: 400px;
}
.character-tab-content {
display: flex;
flex-direction: column;
gap: $unit-2x;
}
// Edit form styles
.edit-form {
display: flex;
flex-direction: column;
gap: $unit-half;
label {
font-weight: $medium;
font-size: $font-small;
color: var(--text-secondary);
}
input {
padding: $unit-three-quarter;
border: 1px solid var(--button-bg);
border-radius: $unit-three-quarter;
font-size: $font-regular;
background: var(--input-bg);
@include smooth-transition($duration-quick, border-color, background);
&:hover {
background: var(--input-bg-hover);
}
&:focus {
outline: none;
border-color: var(--accent-blue);
box-shadow: 0 0 0 2px rgba(39, 93, 197, 0.1); // Using raw value since CSS variables don't work in rgba()
}
&:disabled {
background: var(--button-bg);
opacity: 0.7;
cursor: not-allowed;
}
}
}
// Dialog buttons (shared styles)
.btn-primary,
.btn-secondary,
.btn-danger {
padding: $unit-three-quarter $unit-2x;
border-radius: $unit-three-quarter;
font-weight: $medium;
cursor: pointer;
@include smooth-transition($duration-standard, all);
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.btn-primary {
background: var(--accent-blue);
color: white;
border: none;
&:hover:not(:disabled) {
background: var(--accent-blue-focus);
}
}
.btn-secondary {
background: var(--card-bg);
color: var(--text-primary);
border: 1px solid var(--button-bg);
&:hover:not(:disabled) {
background: var(--button-bg-hover);
border-color: var(--button-bg-hover);
}
}
.btn-danger {
background: $error;
color: white;
border: none;
&:hover:not(:disabled) {
background: darken($error, 10%);
}
}
// Delete confirmation styles
.delete-confirmation {
display: flex;
flex-direction: column;
gap: $unit;
text-align: center;
padding: $unit 0;
p {
margin: 0;
}
strong {
color: var(--text-primary);
font-size: $font-medium;
}
.warning {
color: $error;
font-size: $font-small;
margin-top: $unit-half;
}
}
</style>