From 3666b8db86c0114a32c593147c3e9a2756127b70 Mon Sep 17 00:00:00 2001 From: Justin Edmund Date: Thu, 25 Sep 2025 01:08:25 -0700 Subject: [PATCH] add perpetuity ring toggle and fix grid api response handling --- docs/details-sidebar-segmented-control.md | 414 ++++++++++++ docs/transcendence-popover-fix.md | 234 +++++++ src/assets/icons/perpetuity/empty.svg | 49 ++ src/assets/icons/perpetuity/filled.svg | 53 ++ src/lib/api/adapters/grid.adapter.ts | 107 +-- src/lib/api/adapters/party.adapter.ts | 62 +- .../components/sidebar/DetailsSidebar.svelte | 4 +- src/lib/components/units/CharacterUnit.svelte | 624 +++++++++++------- src/lib/services/grid.service.ts | 18 +- src/lib/types/api/party.ts | 2 - src/lib/utils/modificationDetector.ts | 5 +- 11 files changed, 1175 insertions(+), 397 deletions(-) create mode 100644 docs/details-sidebar-segmented-control.md create mode 100644 docs/transcendence-popover-fix.md create mode 100644 src/assets/icons/perpetuity/empty.svg create mode 100644 src/assets/icons/perpetuity/filled.svg diff --git a/docs/details-sidebar-segmented-control.md b/docs/details-sidebar-segmented-control.md new file mode 100644 index 00000000..4670a768 --- /dev/null +++ b/docs/details-sidebar-segmented-control.md @@ -0,0 +1,414 @@ +# DetailsSidebar Segmented Control Implementation + +## Overview + +Add a segmented control to the DetailsSidebar component that allows users to switch between viewing the canonical (base) item data and the user's customized version with their modifications. + +## User Requirements + +The sidebar should display two distinct views: + +1. **Canonical Data View** - Shows the base item statistics and properties as they exist in the game database +2. **User Version View** - Shows the user's specific customizations and modifications to the item + +## Data Structure Analysis + +### Current Grid Item Structure + +Each grid item (GridCharacter, GridWeapon, GridSummon) contains: +- The base object data (`character`, `weapon`, or `summon`) +- User-specific modifications stored at the grid item level +- Instance-specific properties like position, uncap level, etc. + +### User Version Data by Type + +#### Weapons (GridWeapon) +- `uncapLevel` - Current uncap level (0-6) +- `transcendenceStep` - Transcendence stage (0-5) +- `awakening` - Object containing: + - `type` - Awakening type with name and slug + - `level` - Awakening level +- `weaponKeys` - Array of weapon keys: + - Opus pendulums (series 2) + - Draconic telumas (series 3, 34) + - Ultima gauph keys (series 17) + - Revans emblems (series 22) +- `ax` - Array of AX skills containing: + - `modifier` - Skill ID + - `strength` - Skill strength value +- `element` - Instance element for null-element weapons + +#### Characters (GridCharacter) +- `uncapLevel` - Current uncap level (0-5 or 0-6) +- `transcendenceStep` - Transcendence stage (0-5) +- `awakening` - Awakening type and level +- `rings` - Array of over mastery rings: + - `modifier` - Ring stat type + - `strength` - Ring stat value +- `earring` - Aetherial mastery object: + - `modifier` - Earring stat type + - `strength` - Earring stat value +- `aetherial_mastery` - Alternative property name for earring +- `perpetuity` - Boolean for permanent mastery status + +#### Summons (GridSummon) +- `uncapLevel` - Current uncap level (0-5) +- `transcendenceStep` - Transcendence stage (0-5) +- `quick_summon` - Boolean for quick summon status +- `friend` - Boolean for friend summon + +## Component Architecture + +### Reusable Components to Create + +#### 1. `DetailsSidebarSegmentedControl.svelte` +A specialized segmented control for the details sidebar that can be reused across different detail views. + +```svelte + +``` + +#### 2. `ModificationSection.svelte` +Generic wrapper for modification sections with consistent styling. + +```svelte + + +{#if visible} +
+

{title}

+
+ {@render children()} +
+
+{/if} +``` + +#### 3. `AwakeningDisplay.svelte` +Reusable awakening display component for both weapons and characters. + +```svelte + +``` + +#### 4. `WeaponKeysList.svelte` +Component for displaying weapon keys with proper icons and formatting. + +```svelte + +``` + +#### 5. `StatModifierItem.svelte` +Generic component for displaying stat modifications (rings, earrings, etc.). + +```svelte + + +
+ {#if icon}{/if} + {label} + {value}{suffix} +
+``` + +#### 6. `UncapStatusDisplay.svelte` +Dedicated component for showing current uncap/transcendence status. + +```svelte + +``` + +### Data Processing Utilities + +#### `modificationDetector.ts` +Utility to detect what modifications exist on a grid item. + +```typescript +export interface ModificationStatus { + hasModifications: boolean + hasAwakening: boolean + hasWeaponKeys: boolean + hasAxSkills: boolean + hasRings: boolean + hasEarring: boolean + hasPerpetuity: boolean + hasTranscendence: boolean +} + +export function detectModifications( + type: 'character' | 'weapon' | 'summon', + item: GridCharacter | GridWeapon | GridSummon +): ModificationStatus { + // Implementation +} +``` + +#### `modificationFormatters.ts` +Centralized formatters for modification display. + +```typescript +export function formatRingStat(modifier: number, strength: number): string +export function formatEarringStat(modifier: number, strength: number): string +export function formatAxSkill(ax: SimpleAxSkill): string +export function getWeaponKeyTitle(series?: number): string +``` + +### Component Composition Pattern + +The main DetailsSidebar will compose these smaller components: + +```svelte + + + +{#if selectedView === 'canonical'} + +{:else} + + + + + + + + {#if type === 'weapon'} + + + + {/if} + + +{/if} +``` + +## Styling Guidelines + +### IMPORTANT: Use Existing Theme System + +**DO NOT create new style variables or custom styles.** All necessary styling is already defined in the theme files: + +- `_colors.scss` - All color variables and element-specific colors +- `_typography.scss` - Font sizes, weights, and text styling +- `_spacing.scss` - Spacing units and gaps +- `_layout.scss` - Border radius, corners, and layout constants +- `_effects.scss` - Shadows, transitions, and visual effects +- `_mixins.scss` - Reusable style mixins +- `_rep.scss` - Representation/aspect ratio utilities + +### Component Styling Example + +```scss +// Import only what's needed from themes +@use '$src/themes/colors' as colors; +@use '$src/themes/typography' as typography; +@use '$src/themes/spacing' as spacing; +@use '$src/themes/layout' as layout; +@use '$src/themes/effects' as effects; + +.modification-section { + // Use existing spacing variables + margin-bottom: spacing.$unit-3x; + padding: spacing.$unit-2x; + + h3 { + // Use existing typography + font-size: typography.$font-regular; + font-weight: typography.$medium; + color: var(--text-secondary, colors.$grey-40); + margin-bottom: spacing.$unit-1-5x; + } +} + +.stat-modifier { + // Use existing layout patterns + display: flex; + justify-content: space-between; + padding: spacing.$unit; + background: colors.$grey-90; + border-radius: layout.$item-corner-small; + + .label { + font-size: typography.$font-small; + color: var(--text-secondary, colors.$grey-50); + } + + .value { + font-size: typography.$font-regular; + font-weight: typography.$medium; + color: var(--text-primary, colors.$grey-10); + } + + // Use existing effect patterns for enhanced state + &.enhanced { + background: colors.$grey-85; + box-shadow: effects.$hover-shadow; + } +} + +.awakening-display { + // Use consistent spacing + display: flex; + gap: spacing.$unit-2x; + align-items: center; + + img { + // Use standard sizing + width: spacing.$unit-6x; + height: spacing.$unit-6x; + border-radius: layout.$item-corner-small; + } +} +``` + +### Theme Variables Reference + +#### Colors +- Text: `var(--text-primary)`, `var(--text-secondary)`, `var(--text-tertiary)` +- Backgrounds: `var(--card-bg)`, `colors.$grey-90`, `colors.$grey-85` +- Element colors: `var(--wind-item-detail-bg)`, etc. +- State colors: `var(--color-success)`, `var(--color-error)` + +#### Typography +- Sizes: `$font-tiny`, `$font-small`, `$font-regular`, `$font-medium`, `$font-large` +- Weights: `$normal: 400`, `$medium: 500`, `$bold: 600` + +#### Spacing +- Base unit: `$unit: 8px` +- Multipliers: `$unit-half`, `$unit-2x`, `$unit-3x`, `$unit-4x`, etc. +- Fractions: `$unit-fourth`, `$unit-third` + +#### Layout +- Corners: `$item-corner`, `$item-corner-small`, `$modal-corner` +- Breakpoints: Use mixins from `_mixins.scss` + +#### Effects +- Shadows: `$hover-shadow`, `$focus-shadow` +- Transitions: `$duration-zoom`, `$duration-color-fade` +- Transforms: `$scale-wide`, `$scale-tall` + +## Benefits of Componentization + +### Maintainability +- Each component has a single responsibility +- Changes to display logic are isolated +- Easier to test individual components +- Consistent styling through shared theme system + +### Reusability +- `AwakeningDisplay` can be used in hovercards, modals, and sidebars +- `StatModifierItem` works for any stat modification +- `ModificationSection` provides consistent section layout + +### Type Safety +- Each component has clearly defined props +- TypeScript interfaces ensure correct data flow +- Compile-time checking prevents runtime errors + +### Performance +- Components can be memoized if needed +- Smaller components = smaller re-render boundaries +- Derived states prevent unnecessary recalculation + +## Testing Strategy + +### Unit Tests for Components +Each reusable component should have tests for: +- Rendering with different prop combinations +- Conditional visibility +- Event handling +- Edge cases (missing data, invalid values) + +### Integration Tests +Test the complete DetailsSidebar with: +- View switching +- Data flow between components +- Correct component composition + +### Visual Regression Tests +Use Storybook to document and test visual states: +- Different modification combinations +- Various item types +- Empty states +- Loading states + +## Implementation Checklist + +### Phase 1: Infrastructure +- [ ] Set up modificationDetector utility +- [ ] Set up modificationFormatters utility +- [ ] Create ModificationSection wrapper component + +### Phase 2: Display Components +- [ ] Create AwakeningDisplay component +- [ ] Create WeaponKeysList component +- [ ] Create StatModifierItem component +- [ ] Create UncapStatusDisplay component +- [ ] Create DetailsSidebarSegmentedControl + +### Phase 3: Integration +- [ ] Update DetailsSidebar to use new components +- [ ] Wire up view switching logic +- [ ] Implement canonical view with existing code +- [ ] Implement user version view with new components + +### Phase 4: Polish +- [ ] Add loading states +- [ ] Add empty states +- [ ] Optimize performance +- [ ] Add accessibility attributes +- [ ] Documentation and examples + +## Notes + +- Components should accept `class` prop for custom styling +- All components should handle missing/null data gracefully +- Consider using slots/snippets for maximum flexibility +- Keep components pure - no direct API calls +- Use consistent prop naming across components +- **Always use existing theme variables - never create custom styles** +- Import only the theme modules you need to minimize bundle size +- Use CSS custom properties (var()) for dynamic theming support \ No newline at end of file diff --git a/docs/transcendence-popover-fix.md b/docs/transcendence-popover-fix.md new file mode 100644 index 00000000..82cb370b --- /dev/null +++ b/docs/transcendence-popover-fix.md @@ -0,0 +1,234 @@ +# Transcendence Star Popover Fix - Implementation Plan + +## Problem Statement + +The TranscendenceStar component's popover interface has three critical issues: + +1. **Z-index layering issue**: The popover (z-index: 100) appears below weapon images and other UI elements +2. **Overflow clipping**: The parent container `.page-wrap` has `overflow-x: auto` which clips the popover +3. **Viewport positioning**: The popover can appear partially off-screen when the star is near the bottom of the viewport + +## Current Implementation Analysis + +### File Structure +- Component: `/src/lib/components/uncap/TranscendenceStar.svelte` +- Fragment: `/src/lib/components/uncap/TranscendenceFragment.svelte` + +### Current Approach +- Popover is rendered as a child div with `position: absolute` +- Uses local state `isPopoverOpen` to control visibility +- Z-index set to 100 (below tooltips at 1000) +- No viewport edge detection or smart positioning + +## Solution Architecture + +### 1. Portal-Based Rendering +Use bits-ui Portal component to render the popover outside the DOM hierarchy, avoiding overflow clipping. + +**Benefits:** +- Escapes any parent overflow constraints +- Maintains React-like portal behavior +- Already proven pattern in Dialog.svelte + +### 2. Z-index Hierarchy Management + +Current z-index levels in codebase: +- Tooltips: 1000 +- Navigation/Side panels: 50 +- Fragments: 32 +- Current popover: 100 + +**Solution:** Set popover z-index to 1001 (above tooltips) + +### 3. Smart Positioning System + +#### Position Calculation Algorithm + +```typescript +interface PopoverPosition { + top: number; + left: number; + placement: 'above' | 'below'; +} + +function calculatePopoverPosition( + starElement: HTMLElement, + popoverWidth: number = 80, + popoverHeight: number = 100 +): PopoverPosition { + const rect = starElement.getBoundingClientRect(); + const viewport = { + width: window.innerWidth, + height: window.innerHeight + }; + + // Calculate available space + const spaceBelow = viewport.height - rect.bottom; + const spaceAbove = rect.top; + const spaceRight = viewport.width - rect.right; + const spaceLeft = rect.left; + + // Determine vertical placement + const placement = spaceBelow < popoverHeight && spaceAbove > spaceBelow + ? 'above' + : 'below'; + + // Calculate position + let top = placement === 'below' + ? rect.bottom + 8 // 8px gap + : rect.top - popoverHeight - 8; + + // Center horizontally on star + let left = rect.left + (rect.width / 2) - (popoverWidth / 2); + + // Adjust horizontal position if too close to edges + if (left < 8) { + left = 8; // 8px from left edge + } else if (left + popoverWidth > viewport.width - 8) { + left = viewport.width - popoverWidth - 8; // 8px from right edge + } + + return { top, left, placement }; +} +``` + +### 4. Implementation Details + +#### State Management +```typescript +// New state variables +let popoverPosition = $state(null); +let popoverElement: HTMLDivElement; +``` + +#### Position Update Effect +```typescript +$effect(() => { + if (isPopoverOpen && starElement) { + const updatePosition = () => { + popoverPosition = calculatePopoverPosition(starElement); + }; + + // Initial position + updatePosition(); + + // Update on scroll/resize + window.addEventListener('scroll', updatePosition, true); + window.addEventListener('resize', updatePosition); + + return () => { + window.removeEventListener('scroll', updatePosition, true); + window.removeEventListener('resize', updatePosition); + }; + } +}); +``` + +#### Template Structure +```svelte +{#if interactive && isPopoverOpen && popoverPosition} + +
+
+ +
+
+ +
+
+
+{/if} +``` + +#### Style Updates +```scss +.popover { + position: fixed; + z-index: 1001; + + // Remove static positioning + // top: -10px; (remove) + // left: -10px; (remove) + + // Add placement variants + &.above { + // Arrow or visual indicator for above placement + } + + // Smooth appearance + animation: popover-appear 0.2s ease-out; +} + +@keyframes popover-appear { + from { + opacity: 0; + transform: translateY(-4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} +``` + +## Implementation Steps + +1. **Install/verify bits-ui Portal availability** + - Check if Portal is exported from bits-ui + - If not available, create custom portal implementation + +2. **Add positioning logic** + - Create calculatePopoverPosition function + - Add position state management + - Add scroll/resize listeners + +3. **Update template** + - Wrap popover in Portal component + - Apply dynamic positioning styles + - Add placement classes + +4. **Update styles** + - Change to position: fixed + - Increase z-index to 1001 + - Add animation for smooth appearance + - Handle above/below placement variants + +5. **Testing** + - Test near all viewport edges + - Test with scrolling + - Test with window resize + - Verify z-index layering + - Confirm no overflow clipping + +## Alternative Approaches Considered + +### Floating UI Library +- Pros: Robust positioning, automatic flipping, virtual element support +- Cons: Additional dependency, may be overkill for simple use case +- Decision: Start with custom implementation, can migrate if needed + +### Tooltip Component Reuse +- Pros: Consistent behavior with existing tooltips +- Cons: Tooltips likely simpler, may not support interactive content +- Decision: Custom implementation for specific transcendence needs + +## Success Criteria + +- [ ] Popover appears above all other UI elements +- [ ] No clipping by parent containers +- [ ] Smart positioning avoids viewport edges +- [ ] Smooth transitions and animations +- [ ] Click outside properly closes popover +- [ ] Position updates on scroll/resize +- [ ] Works on all screen sizes + +## References + +- Current implementation: `/src/lib/components/uncap/TranscendenceStar.svelte` +- Portal example: `/src/lib/components/ui/Dialog.svelte` +- Original Next.js version: `/hensei-web/components/uncap/TranscendencePopover/` \ No newline at end of file diff --git a/src/assets/icons/perpetuity/empty.svg b/src/assets/icons/perpetuity/empty.svg new file mode 100644 index 00000000..47ec18e1 --- /dev/null +++ b/src/assets/icons/perpetuity/empty.svg @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/icons/perpetuity/filled.svg b/src/assets/icons/perpetuity/filled.svg new file mode 100644 index 00000000..d8df6ed8 --- /dev/null +++ b/src/assets/icons/perpetuity/filled.svg @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/lib/api/adapters/grid.adapter.ts b/src/lib/api/adapters/grid.adapter.ts index 72d33bfb..2f044701 100644 --- a/src/lib/api/adapters/grid.adapter.ts +++ b/src/lib/api/adapters/grid.adapter.ts @@ -9,65 +9,10 @@ import { BaseAdapter } from './base.adapter' import type { AdapterOptions } from './types' +import type { GridWeapon, GridCharacter, GridSummon } from '$lib/types/api/party' import { DEFAULT_ADAPTER_CONFIG } from './config' -/** - * Common grid item structure - */ -interface BaseGridItem { - id: string - partyId: string - position: number - uncapLevel?: number - transcendenceStage?: number -} - -/** - * Grid weapon specific fields - */ -export interface GridWeapon extends BaseGridItem { - weaponId: string - mainhand?: boolean - element?: number - weaponKeys?: Array<{ - id: string - slot: number - }> - axModifier1?: string - axModifier2?: string - axStrength1?: number - axStrength2?: number - awakeningId?: string - awakeningLevel?: number -} - -/** - * Grid character specific fields - */ -export interface GridCharacter extends BaseGridItem { - characterId: string - perpetualModifiers?: Record - awakeningId?: string - awakeningLevel?: number - rings?: Array<{ - modifier: string - strength: number - }> - earring?: { - modifier: string - strength: number - } -} - -/** - * Grid summon specific fields - */ -export interface GridSummon extends BaseGridItem { - summonId: string - main?: boolean - friend?: boolean - quickSummon?: boolean -} +// GridWeapon, GridCharacter, and GridSummon types are imported from types/api/party /** * Parameters for creating grid items @@ -78,7 +23,7 @@ export interface CreateGridWeaponParams { position: number mainhand?: boolean uncapLevel?: number - transcendenceStage?: number + transcendenceStep?: number } export interface CreateGridCharacterParams { @@ -86,7 +31,7 @@ export interface CreateGridCharacterParams { characterId: string position: number uncapLevel?: number - transcendenceStage?: number + transcendenceStep?: number } export interface CreateGridSummonParams { @@ -97,7 +42,7 @@ export interface CreateGridSummonParams { friend?: boolean quickSummon?: boolean uncapLevel?: number - transcendenceStage?: number + transcendenceStep?: number } /** @@ -151,22 +96,24 @@ export class GridAdapter extends BaseAdapter { * Creates a new grid weapon instance */ async createWeapon(params: CreateGridWeaponParams, headers?: Record): Promise { - return this.request('/grid_weapons', { + const response = await this.request<{ gridWeapon: GridWeapon }>('/grid_weapons', { method: 'POST', body: { weapon: params }, headers }) + return response.gridWeapon } /** * Updates a grid weapon instance */ async updateWeapon(id: string, params: Partial, headers?: Record): Promise { - return this.request(`/grid_weapons/${id}`, { + const response = await this.request<{ gridWeapon: GridWeapon }>(`/grid_weapons/${id}`, { method: 'PUT', body: { weapon: params }, headers }) + return response.gridWeapon } /** @@ -192,7 +139,7 @@ export class GridAdapter extends BaseAdapter { * Updates weapon uncap level */ async updateWeaponUncap(params: UpdateUncapParams, headers?: Record): Promise { - return this.request('/grid_weapons/update_uncap', { + const response = await this.request<{ gridWeapon: GridWeapon }>('/grid_weapons/update_uncap', { method: 'POST', body: { weapon: { @@ -204,17 +151,19 @@ export class GridAdapter extends BaseAdapter { }, headers }) + return response.gridWeapon } /** * Resolves weapon conflicts */ async resolveWeaponConflict(params: ResolveConflictParams, headers?: Record): Promise { - return this.request('/grid_weapons/resolve', { + const response = await this.request<{ gridWeapon: GridWeapon }>('/grid_weapons/resolve', { method: 'POST', body: { resolve: params }, headers }) + return response.gridWeapon } /** @@ -222,11 +171,12 @@ export class GridAdapter extends BaseAdapter { */ async updateWeaponPosition(params: UpdatePositionParams, headers?: Record): Promise { const { id, position, container, partyId } = params - return this.request(`/parties/${partyId}/grid_weapons/${id}/position`, { + const response = await this.request<{ gridWeapon: GridWeapon }>(`/parties/${partyId}/grid_weapons/${id}/position`, { method: 'PUT', body: { position, container }, headers }) + return response.gridWeapon } /** @@ -250,22 +200,24 @@ export class GridAdapter extends BaseAdapter { * Creates a new grid character instance */ async createCharacter(params: CreateGridCharacterParams, headers?: Record): Promise { - return this.request('/grid_characters', { + const response = await this.request<{ gridCharacter: GridCharacter }>('/grid_characters', { method: 'POST', body: { character: params }, headers }) + return response.gridCharacter } /** * Updates a grid character instance */ async updateCharacter(id: string, params: Partial, headers?: Record): Promise { - return this.request(`/grid_characters/${id}`, { + const response = await this.request<{ gridCharacter: GridCharacter }>(`/grid_characters/${id}`, { method: 'PUT', body: { character: params }, headers }) + return response.gridCharacter } /** @@ -291,7 +243,7 @@ export class GridAdapter extends BaseAdapter { * Updates character uncap level */ async updateCharacterUncap(params: UpdateUncapParams, headers?: Record): Promise { - return this.request('/grid_characters/update_uncap', { + const response = await this.request<{ gridCharacter: GridCharacter }>('/grid_characters/update_uncap', { method: 'POST', body: { character: { @@ -303,17 +255,19 @@ export class GridAdapter extends BaseAdapter { }, headers }) + return response.gridCharacter } /** * Resolves character conflicts */ async resolveCharacterConflict(params: ResolveConflictParams, headers?: Record): Promise { - return this.request('/grid_characters/resolve', { + const response = await this.request<{ gridCharacter: GridCharacter }>('/grid_characters/resolve', { method: 'POST', body: { resolve: params }, headers }) + return response.gridCharacter } /** @@ -321,11 +275,12 @@ export class GridAdapter extends BaseAdapter { */ async updateCharacterPosition(params: UpdatePositionParams, headers?: Record): Promise { const { id, position, container, partyId } = params - return this.request(`/parties/${partyId}/grid_characters/${id}/position`, { + const response = await this.request<{ gridCharacter: GridCharacter }>(`/parties/${partyId}/grid_characters/${id}/position`, { method: 'PUT', body: { position, container }, headers }) + return response.gridCharacter } /** @@ -349,22 +304,24 @@ export class GridAdapter extends BaseAdapter { * Creates a new grid summon instance */ async createSummon(params: CreateGridSummonParams, headers?: Record): Promise { - return this.request('/grid_summons', { + const response = await this.request<{ gridSummon: GridSummon }>('/grid_summons', { method: 'POST', body: { summon: params }, headers }) + return response.gridSummon } /** * Updates a grid summon instance */ async updateSummon(id: string, params: Partial, headers?: Record): Promise { - return this.request(`/grid_summons/${id}`, { + const response = await this.request<{ gridSummon: GridSummon }>(`/grid_summons/${id}`, { method: 'PUT', body: { summon: params }, headers }) + return response.gridSummon } /** @@ -390,7 +347,7 @@ export class GridAdapter extends BaseAdapter { * Updates summon uncap level */ async updateSummonUncap(params: UpdateUncapParams, headers?: Record): Promise { - return this.request('/grid_summons/update_uncap', { + const response = await this.request<{ gridSummon: GridSummon }>('/grid_summons/update_uncap', { method: 'POST', body: { summon: { @@ -402,6 +359,7 @@ export class GridAdapter extends BaseAdapter { }, headers }) + return response.gridSummon } /** @@ -424,11 +382,12 @@ export class GridAdapter extends BaseAdapter { */ async updateSummonPosition(params: UpdatePositionParams, headers?: Record): Promise { const { id, position, container, partyId } = params - return this.request(`/parties/${partyId}/grid_summons/${id}/position`, { + const response = await this.request<{ gridSummon: GridSummon }>(`/parties/${partyId}/grid_summons/${id}/position`, { method: 'PUT', body: { position, container }, headers }) + return response.gridSummon } /** diff --git a/src/lib/api/adapters/party.adapter.ts b/src/lib/api/adapters/party.adapter.ts index b22f65f0..bc1e25cc 100644 --- a/src/lib/api/adapters/party.adapter.ts +++ b/src/lib/api/adapters/party.adapter.ts @@ -11,6 +11,7 @@ import { BaseAdapter } from './base.adapter' import type { RequestOptions, AdapterOptions, PaginatedResponse } from './types' import { DEFAULT_ADAPTER_CONFIG } from './config' +import type { Party, GridWeapon, GridCharacter, GridSummon } from '$lib/types/api/party' /** * Party data structure @@ -58,66 +59,11 @@ export interface Party { updatedAt: string } -/** - * Grid weapon structure - */ -export interface GridWeapon { - id: string - position: number - mainhand: boolean - uncapLevel: number - transcendenceStage: number - weaponKeys: Array<{ - id: string - slot: number - }> - weapon: { - id: string - granblueId: string - name: Record - element: number - rarity: number - } -} +// GridWeapon type is imported from types/api/party -/** - * Grid summon structure - */ -export interface GridSummon { - id: string - position: number - quickSummon: boolean - transcendenceStage: number - summon: { - id: string - granblueId: string - name: Record - element: number - rarity: number - } -} +// GridSummon type is imported from types/api/party -/** - * Grid character structure - */ -export interface GridCharacter { - id: string - position: number - uncapLevel: number - transcendenceStage: number - perpetualModifiers?: Record - awakenings?: Array<{ - id: string - level: number - }> - character: { - id: string - granblueId: string - name: Record - element: number - rarity: number - } -} +// GridCharacter type is imported from types/api/party /** * Parameters for creating a new party diff --git a/src/lib/components/sidebar/DetailsSidebar.svelte b/src/lib/components/sidebar/DetailsSidebar.svelte index f5e26086..70206c04 100644 --- a/src/lib/components/sidebar/DetailsSidebar.svelte +++ b/src/lib/components/sidebar/DetailsSidebar.svelte @@ -345,7 +345,7 @@ {#if modificationStatus.hasRings} - {#each (char.rings || char.over_mastery || []) as ring} + {#each (char.over_mastery || []) as ring} - import type { GridCharacter } from '$lib/types/api/party' - import type { Party } from '$lib/types/api/party' - import { getContext } from 'svelte' - import Icon from '$lib/components/Icon.svelte' - import ContextMenu from '$lib/components/ui/ContextMenu.svelte' - import { ContextMenu as ContextMenuBase } from 'bits-ui' - import UncapIndicator from '$lib/components/uncap/UncapIndicator.svelte' - import { getCharacterImageWithPose } from '$lib/utils/images' - import { openDetailsSidebar } from '$lib/features/details/openDetailsSidebar.svelte' + import type { GridCharacter } from '$lib/types/api/party' + import type { Party } from '$lib/types/api/party' + import { getContext } from 'svelte' + import Icon from '$lib/components/Icon.svelte' + import ContextMenu from '$lib/components/ui/ContextMenu.svelte' + import { ContextMenu as ContextMenuBase } from 'bits-ui' + import UncapIndicator from '$lib/components/uncap/UncapIndicator.svelte' + import { getCharacterImageWithPose } from '$lib/utils/images' + import { openDetailsSidebar } from '$lib/features/details/openDetailsSidebar.svelte' + import perpetuityFilled from '$src/assets/icons/perpetuity/filled.svg' + import perpetuityEmpty from '$src/assets/icons/perpetuity/empty.svg' - interface Props { - item?: GridCharacter - position: number - mainWeaponElement?: number | null - partyElement?: number | null - } + interface Props { + item?: GridCharacter + position: number + mainWeaponElement?: number | null + partyElement?: number | null + } - let { item, position, mainWeaponElement, partyElement }: Props = $props() + let { item, position, mainWeaponElement, partyElement }: Props = $props() - type PartyCtx = { - getParty: () => Party - updateParty: (p: Party) => void - canEdit: () => boolean - getEditKey: () => string | null - services: { gridService: any; partyService: any } - openPicker?: (opts: { type: 'character' | 'weapon' | 'summon'; position: number; item?: any }) => void - } - const ctx = getContext('party') + type PartyCtx = { + getParty: () => Party + updateParty: (p: Party) => void + canEdit: () => boolean + getEditKey: () => string | null + services: { gridService: any; partyService: any } + openPicker?: (opts: { + type: 'character' | 'weapon' | 'summon' + position: number + item?: any + }) => void + } + const ctx = getContext('party') - 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 '—' - } - // Use $derived to ensure consistent computation between server and client - let imageUrl = $derived.by(() => { - // If no item or no character with granblueId, return placeholder - if (!item || !item.character?.granblueId) { - return getCharacterImageWithPose(null, 'main', 0, 0) - } + 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 '—' + } + // Use $derived to ensure consistent computation between server and client + let imageUrl = $derived.by(() => { + // If no item or no character with granblueId, return placeholder + if (!item || !item.character?.granblueId) { + return getCharacterImageWithPose(null, 'main', 0, 0) + } - return getCharacterImageWithPose( - item.character.granblueId, - 'main', - item?.uncapLevel ?? 0, - item?.transcendenceStep ?? 0, - mainWeaponElement, - partyElement - ) - }) + return getCharacterImageWithPose( + item.character.granblueId, + 'main', + item?.uncapLevel ?? 0, + item?.transcendenceStep ?? 0, + mainWeaponElement, + partyElement + ) + }) - async function remove() { - if (!item?.id) return - try { - const party = ctx.getParty() - const editKey = ctx.getEditKey() - const updated = await ctx.services.gridService.removeCharacter(party.id, item.id as any, editKey || undefined) - if (updated) { - ctx.updateParty(updated) - } - } catch (err) { - console.error('Error removing character:', err) - } - } + async function remove() { + if (!item?.id) return + try { + const party = ctx.getParty() + const editKey = ctx.getEditKey() + const updated = await ctx.services.gridService.removeCharacter( + party.id, + item.id as any, + editKey || undefined + ) + if (updated) { + ctx.updateParty(updated) + } + } catch (err) { + console.error('Error removing character:', err) + } + } - function viewDetails() { - if (!item) return - openDetailsSidebar({ - type: 'character', - item - }) - } + function viewDetails() { + if (!item) return + openDetailsSidebar({ + type: 'character', + item + }) + } - function replace() { - if (ctx?.openPicker) { - ctx.openPicker({ type: 'character', position, item }) - } - } + function replace() { + if (ctx?.openPicker) { + ctx.openPicker({ type: 'character', position, item }) + } + } + async function togglePerpetuity(e: Event) { + e.stopPropagation() + if (!item?.id || !ctx?.canEdit()) return + try { + const party = ctx.getParty() + const editKey = ctx.getEditKey() + // Update the character on the server + const updatedCharacter = await ctx.services.gridService.updateCharacter( + party.id, + item.id, + { perpetuity: !item.perpetuity }, + editKey || undefined + ) + + if (updatedCharacter) { + // The API returns 'object' but we need 'character' + const fixedCharacter = { + ...updatedCharacter, + character: updatedCharacter.object, + object: undefined + } + + // Update the party locally with the new character data + const updatedParty = { + ...party, + characters: party.characters.map((char) => + char.id === fixedCharacter.id ? fixedCharacter : char + ) + } + ctx.updateParty(updatedParty) + } + } catch (err) { + console.error('Error toggling perpetuity:', err) + } + }
- {#if item} - - {#snippet children()} - {#key item?.id ?? position} -
viewDetails()} - > - {displayName(item?.character)} - {#if ctx?.canEdit() && item?.id} -
- -
- {/if} -
- {/key} - {/snippet} + {#if item} + + {#snippet children()} + {#key item?.id ?? position} +
viewDetails()} + > + {#if ctx?.canEdit()} + + {:else if item.perpetuity} + Perpetuity Ring + {/if} + {displayName(item?.character)} +
+ {/key} + {/snippet} - {#snippet menu()} - - View Details - - {#if ctx?.canEdit()} - - Replace - - - - Remove - - {/if} - {/snippet} -
- {:else} - {#key `empty-${position}`} -
ctx?.canEdit() && ctx?.openPicker && ctx.openPicker({ type: 'character', position, item })} - > - - {#if ctx?.canEdit()} - - - - {/if} -
- {/key} - {/if} - {#if item} - { - if (!item?.id || !ctx) return - try { - const editKey = ctx.getEditKey() - const updated = await ctx.services.gridService.updateCharacterUncap(item.id, level, undefined, editKey || undefined) - if (updated) { - ctx.updateParty(updated) - } - } catch (err) { - console.error('Failed to update character uncap:', err) - // TODO: Show user-friendly error notification - } - }} - updateTranscendence={async (stage) => { - if (!item?.id || !ctx) return - try { - const editKey = ctx.getEditKey() - // When setting transcendence > 0, also set uncap to max (6) - const maxUncap = stage > 0 ? 6 : undefined - const updated = await ctx.services.gridService.updateCharacterUncap(item.id, maxUncap, stage, editKey || undefined) - if (updated) { - ctx.updateParty(updated) - } - } catch (err) { - console.error('Failed to update character transcendence:', err) - // TODO: Show user-friendly error notification - } - }} - /> - {/if} -
{item ? displayName(item?.character) : ''}
+ {#snippet menu()} + + View Details + + {#if ctx?.canEdit()} + + Replace + + + + Remove + + {/if} + {/snippet} +
+ {:else} + {#key `empty-${position}`} +
+ ctx?.canEdit() && + ctx?.openPicker && + ctx.openPicker({ type: 'character', position, item })} + > + + {#if ctx?.canEdit()} + + + + {/if} +
+ {/key} + {/if} + {#if item} + { + if (!item?.id || !ctx) return + try { + const editKey = ctx.getEditKey() + const updated = await ctx.services.gridService.updateCharacterUncap( + item.id, + level, + undefined, + editKey || undefined + ) + if (updated) { + ctx.updateParty(updated) + } + } catch (err) { + console.error('Failed to update character uncap:', err) + // TODO: Show user-friendly error notification + } + }} + updateTranscendence={async (stage) => { + if (!item?.id || !ctx) return + try { + const editKey = ctx.getEditKey() + // When setting transcendence > 0, also set uncap to max (6) + const maxUncap = stage > 0 ? 6 : undefined + const updated = await ctx.services.gridService.updateCharacterUncap( + item.id, + maxUncap, + stage, + editKey || undefined + ) + if (updated) { + ctx.updateParty(updated) + } + } catch (err) { + console.error('Failed to update character transcendence:', err) + // TODO: Show user-friendly error notification + } + }} + /> + {/if} +
{item ? displayName(item?.character) : ''}
diff --git a/src/lib/services/grid.service.ts b/src/lib/services/grid.service.ts index 59813ead..1b26ec9b 100644 --- a/src/lib/services/grid.service.ts +++ b/src/lib/services/grid.service.ts @@ -43,7 +43,7 @@ export class GridService { position, mainhand: options?.mainhand, uncapLevel: getDefaultMaxUncapLevel('weapon'), - transcendenceStage: 0 + transcendenceStep: 0 }, this.buildHeaders(editKey)) console.log('[GridService] Created grid weapon:', gridWeapon) @@ -125,7 +125,7 @@ export class GridService { await gridAdapter.updateWeapon(gridWeaponId, { position: updates.position, uncapLevel: updates.uncapLevel, - transcendenceStage: updates.transcendenceStep, + transcendenceStep: updates.transcendenceStep, element: updates.element }, this.buildHeaders(editKey)) @@ -360,7 +360,7 @@ export class GridService { characterId, position, uncapLevel: getDefaultMaxUncapLevel('character'), - transcendenceStage: 0 + transcendenceStep: 0 }, this.buildHeaders(editKey)) console.log('[GridService] Created grid character:', gridCharacter) @@ -435,12 +435,12 @@ export class GridService { }, editKey?: string, options?: { shortcode?: string } - ): Promise { - await gridAdapter.updateCharacter(gridCharacterId, { + ): Promise { + const updated = await gridAdapter.updateCharacter(gridCharacterId, { position: updates.position, uncapLevel: updates.uncapLevel, - transcendenceStage: updates.transcendenceStep, - perpetualModifiers: updates.perpetuity ? {} : undefined + transcendenceStep: updates.transcendenceStep, + perpetuity: updates.perpetuity }, this.buildHeaders(editKey)) // Clear party cache if shortcode provided @@ -448,8 +448,8 @@ export class GridService { partyAdapter.clearPartyCache(options.shortcode) } - // Don't fetch - let caller handle refresh - return null + // Return the updated character + return updated } async moveCharacter( diff --git a/src/lib/types/api/party.ts b/src/lib/types/api/party.ts index 4983bef8..134cc445 100644 --- a/src/lib/types/api/party.ts +++ b/src/lib/types/api/party.ts @@ -44,8 +44,6 @@ export interface GridCharacter { transcendenceStep?: number character: Character // Named properly, not "object" awakening?: Awakening - rings?: Array<{ modifier: number; strength: number }> - earring?: { modifier: number; strength: number } aetherial_mastery?: { modifier: number; strength: number } over_mastery?: Array<{ modifier: number; strength: number }> } diff --git a/src/lib/utils/modificationDetector.ts b/src/lib/utils/modificationDetector.ts index ecd889fa..e9be37c8 100644 --- a/src/lib/utils/modificationDetector.ts +++ b/src/lib/utils/modificationDetector.ts @@ -40,9 +40,8 @@ export function detectModifications( const char = item as GridCharacter status.hasAwakening = !!char.awakening - status.hasRings = !!(char.rings && char.rings.length > 0) || - !!(char.over_mastery && char.over_mastery.length > 0) - status.hasEarring = !!char.earring || !!char.aetherial_mastery + status.hasRings = !!(char.over_mastery && char.over_mastery.length > 0) + status.hasEarring = !!char.aetherial_mastery status.hasPerpetuity = !!char.perpetuity status.hasTranscendence = !!(char.transcendenceStep && char.transcendenceStep > 0) status.hasUncapLevel = char.uncapLevel !== undefined && char.uncapLevel !== null