add perpetuity ring toggle and fix grid api response handling

This commit is contained in:
Justin Edmund 2025-09-25 01:08:25 -07:00
parent a10659b347
commit 3666b8db86
11 changed files with 1175 additions and 397 deletions

View file

@ -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
<script lang="ts">
interface Props {
hasModifications: boolean
selectedView: 'canonical' | 'user'
onViewChange: (view: 'canonical' | 'user') => void
}
</script>
```
#### 2. `ModificationSection.svelte`
Generic wrapper for modification sections with consistent styling.
```svelte
<script lang="ts">
interface Props {
title: string
visible?: boolean
children: Snippet
}
</script>
{#if visible}
<div class="modification-section">
<h3>{title}</h3>
<div class="modification-content">
{@render children()}
</div>
</div>
{/if}
```
#### 3. `AwakeningDisplay.svelte`
Reusable awakening display component for both weapons and characters.
```svelte
<script lang="ts">
import { getAwakeningImage } from '$lib/utils/modifiers'
interface Props {
awakening?: { type: Awakening; level: number }
size?: 'small' | 'medium' | 'large'
showLevel?: boolean
}
</script>
```
#### 4. `WeaponKeysList.svelte`
Component for displaying weapon keys with proper icons and formatting.
```svelte
<script lang="ts">
import { getWeaponKeyImages } from '$lib/utils/modifiers'
interface Props {
weaponKeys?: WeaponKey[]
weaponData: { element?: number; proficiency?: number; series?: number; name?: LocalizedString }
layout?: 'list' | 'grid'
}
</script>
```
#### 5. `StatModifierItem.svelte`
Generic component for displaying stat modifications (rings, earrings, etc.).
```svelte
<script lang="ts">
interface Props {
label: string
value: string | number
suffix?: string
icon?: string
variant?: 'default' | 'enhanced' | 'max'
}
</script>
<div class="stat-modifier" class:variant>
{#if icon}<img src={icon} alt="" />{/if}
<span class="label">{label}</span>
<span class="value">{value}{suffix}</span>
</div>
```
#### 6. `UncapStatusDisplay.svelte`
Dedicated component for showing current uncap/transcendence status.
```svelte
<script lang="ts">
interface Props {
type: 'character' | 'weapon' | 'summon'
uncapLevel?: number
transcendenceStep?: number
maxUncap: number
showIndicator?: boolean
}
</script>
```
### 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
<!-- DetailsSidebar.svelte -->
<DetailsSidebarSegmentedControl {hasModifications} bind:selectedView />
{#if selectedView === 'canonical'}
<!-- Existing canonical view -->
{:else}
<!-- User version composed of reusable components -->
<UncapStatusDisplay {type} {uncapLevel} {transcendenceStep} />
<ModificationSection title="Awakening" visible={item.awakening}>
<AwakeningDisplay awakening={item.awakening} size="medium" showLevel />
</ModificationSection>
{#if type === 'weapon'}
<ModificationSection title={getWeaponKeyTitle(item.weapon?.series)} visible={item.weaponKeys?.length}>
<WeaponKeysList {weaponKeys} weaponData={item.weapon} />
</ModificationSection>
{/if}
<!-- etc... -->
{/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

View file

@ -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<PopoverPosition | null>(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}
<Portal>
<div
class="popover"
class:above={popoverPosition.placement === 'above'}
style="top: {popoverPosition.top}px; left: {popoverPosition.left}px"
bind:this={popoverElement}
>
<div class="fragments">
<!-- existing fragment content -->
</div>
<div class="level">
<!-- existing level display -->
</div>
</div>
</Portal>
{/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/`

View file

@ -0,0 +1,49 @@
<svg viewBox="0 0 45 46" fill="none" xmlns="http://www.w3.org/2000/svg">
<g filter="url(#filter0_d_1460_2996)">
<rect x="4" y="2" width="37" height="38" rx="2" fill="white"/>
<rect x="5" y="3" width="35" height="36" rx="1" fill="url(#paint0_linear_1460_2996)"/>
<g filter="url(#filter1_i_1460_2996)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M24.4885 18.3682C27.4659 19.17 29.6429 21.7269 29.6429 24.7572C29.6429 28.4299 26.445 31.4072 22.5001 31.4072C18.5552 31.4072 15.3572 28.4299 15.3572 24.7572C15.3572 21.7288 17.5316 19.1732 20.5061 18.3697L18.6119 16.002C15.0204 17.4432 12.5 20.8206 12.5 24.7572C12.5 30.0039 16.9772 34.2572 22.5 34.2572C28.0228 34.2572 32.5 30.0039 32.5 24.7572C32.5 20.8187 29.9772 17.4401 26.3831 16L24.4885 18.3682Z" fill="#999999"/>
</g>
<g filter="url(#filter2_i_1460_2996)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M21.7158 18.0239L16.9691 12.0905C16.6896 11.7412 16.6762 11.2487 16.9363 10.8846L18.6976 8.41876C18.8853 8.15597 19.1884 8 19.5113 8H25.4821C25.805 8 26.1081 8.15597 26.2958 8.41876L28.0571 10.8846C28.3172 11.2487 28.3038 11.7412 28.0243 12.0905L23.2776 18.0239C22.8772 18.5243 22.1162 18.5243 21.7158 18.0239ZM23 9.25V15.7873C23 16.0236 23.2976 16.128 23.4452 15.9435L26.8751 11.6562C26.9481 11.5649 26.9481 11.4351 26.8751 11.3438L25.0751 9.09383C25.0276 9.03452 24.9558 9 24.8798 9H23.25C23.1119 9 23 9.11193 23 9.25Z" fill="#999999"/>
</g>
<rect x="5.5" y="3.5" width="34" height="35" rx="0.5" stroke="black" stroke-opacity="0.09"/>
<rect x="3.5" y="1.5" width="38" height="39" rx="2.5" stroke="black" stroke-opacity="0.1"/>
</g>
<defs>
<filter id="filter0_d_1460_2996" x="0" y="0" width="45" height="46" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="2"/>
<feGaussianBlur stdDeviation="1.5"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.13 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_1460_2996"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_1460_2996" result="shape"/>
</filter>
<filter id="filter1_i_1460_2996" x="12.5" y="16" width="20" height="19.2572" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="1"/>
<feGaussianBlur stdDeviation="1"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.12 0"/>
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_1460_2996"/>
</filter>
<filter id="filter2_i_1460_2996" x="16.75" y="8" width="11.4934" height="11.3992" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="1"/>
<feGaussianBlur stdDeviation="1"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.12 0"/>
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_1460_2996"/>
</filter>
<linearGradient id="paint0_linear_1460_2996" x1="22.5" y1="3" x2="22.5" y2="39" gradientUnits="userSpaceOnUse">
<stop offset="0.0618126" stop-color="#C1C1C1"/>
<stop offset="0.756271" stop-color="#B0B0B0"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.8 KiB

View file

@ -0,0 +1,53 @@
<svg viewBox="0 0 45 46" fill="none" xmlns="http://www.w3.org/2000/svg">
<g filter="url(#filter0_d_1460_2995)">
<rect x="4" y="2" width="37" height="38" rx="2" fill="white"/>
<rect x="5" y="3" width="35" height="36" rx="1" fill="url(#paint0_linear_1460_2995)"/>
<g filter="url(#filter1_d_1460_2995)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M24.4885 18.3682C27.4659 19.17 29.6429 21.7269 29.6429 24.7572C29.6429 28.4299 26.445 31.4072 22.5001 31.4072C18.5552 31.4072 15.3572 28.4299 15.3572 24.7572C15.3572 21.7288 17.5316 19.1732 20.5061 18.3697L18.6119 16.002C15.0204 17.4432 12.5 20.8206 12.5 24.7572C12.5 30.0039 16.9772 34.2572 22.5 34.2572C28.0228 34.2572 32.5 30.0039 32.5 24.7572C32.5 20.8187 29.9772 17.4401 26.3831 16L24.4885 18.3682Z" fill="white"/>
</g>
<g filter="url(#filter2_d_1460_2995)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M21.7158 18.0239L16.9691 12.0905C16.6896 11.7412 16.6762 11.2487 16.9363 10.8846L18.6976 8.41876C18.8853 8.15597 19.1884 8 19.5113 8H25.4821C25.805 8 26.1081 8.15597 26.2958 8.41876L28.0571 10.8846C28.3172 11.2487 28.3038 11.7412 28.0243 12.0905L23.2776 18.0239C22.8772 18.5243 22.1162 18.5243 21.7158 18.0239ZM23 9.25V15.7873C23 16.0236 23.2976 16.128 23.4452 15.9435L26.8751 11.6562C26.9481 11.5649 26.9481 11.4351 26.8751 11.3438L25.0751 9.09383C25.0276 9.03452 24.9558 9 24.8798 9H23.25C23.1119 9 23 9.11193 23 9.25Z" fill="white"/>
</g>
<rect x="5.5" y="3.5" width="34" height="35" rx="0.5" stroke="url(#paint1_linear_1460_2995)" stroke-opacity="0.5"/>
<rect x="3.5" y="1.5" width="38" height="39" rx="2.5" stroke="black" stroke-opacity="0.1"/>
</g>
<defs>
<filter id="filter0_d_1460_2995" x="0" y="0" width="45" height="46" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="2"/>
<feGaussianBlur stdDeviation="1.5"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.13 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_1460_2995"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_1460_2995" result="shape"/>
</filter>
<filter id="filter1_d_1460_2995" x="10.5" y="15" width="24" height="22.2572" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="1"/>
<feGaussianBlur stdDeviation="1"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.22 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_1460_2995"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_1460_2995" result="shape"/>
</filter>
<filter id="filter2_d_1460_2995" x="14.75" y="7" width="15.4934" height="14.3992" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="1"/>
<feGaussianBlur stdDeviation="1"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.22 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_1460_2995"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_1460_2995" result="shape"/>
</filter>
<linearGradient id="paint0_linear_1460_2995" x1="22.5" y1="3" x2="22.5" y2="39" gradientUnits="userSpaceOnUse">
<stop offset="0.0618126" stop-color="#6CD6FF"/>
<stop offset="0.756271" stop-color="#ACB3FE"/>
</linearGradient>
<linearGradient id="paint1_linear_1460_2995" x1="22.5" y1="3" x2="22.5" y2="39" gradientUnits="userSpaceOnUse">
<stop stop-color="#8DEFFE"/>
<stop offset="1" stop-color="#E8EDFF"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 4 KiB

View file

@ -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<string, any>
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<string, string>): Promise<GridWeapon> {
return this.request<GridWeapon>('/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<GridWeapon>, headers?: Record<string, string>): Promise<GridWeapon> {
return this.request<GridWeapon>(`/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<string, string>): Promise<GridWeapon> {
return this.request<GridWeapon>('/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<string, string>): Promise<GridWeapon> {
return this.request<GridWeapon>('/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<string, string>): Promise<GridWeapon> {
const { id, position, container, partyId } = params
return this.request<GridWeapon>(`/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<string, string>): Promise<GridCharacter> {
return this.request<GridCharacter>('/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<GridCharacter>, headers?: Record<string, string>): Promise<GridCharacter> {
return this.request<GridCharacter>(`/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<string, string>): Promise<GridCharacter> {
return this.request<GridCharacter>('/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<string, string>): Promise<GridCharacter> {
return this.request<GridCharacter>('/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<string, string>): Promise<GridCharacter> {
const { id, position, container, partyId } = params
return this.request<GridCharacter>(`/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<string, string>): Promise<GridSummon> {
return this.request<GridSummon>('/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<GridSummon>, headers?: Record<string, string>): Promise<GridSummon> {
return this.request<GridSummon>(`/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<string, string>): Promise<GridSummon> {
return this.request<GridSummon>('/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<string, string>): Promise<GridSummon> {
const { id, position, container, partyId } = params
return this.request<GridSummon>(`/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
}
/**

View file

@ -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<string, string>
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<string, string>
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<string, any>
awakenings?: Array<{
id: string
level: number
}>
character: {
id: string
granblueId: string
name: Record<string, string>
element: number
rarity: number
}
}
// GridCharacter type is imported from types/api/party
/**
* Parameters for creating a new party

View file

@ -345,7 +345,7 @@
{#if modificationStatus.hasRings}
<ModificationSection title="Over Mastery Rings" visible={true}>
{#each (char.rings || char.over_mastery || []) as ring}
{#each (char.over_mastery || []) as ring}
<StatModifierItem
label={formatRingStat(ring.modifier, ring.strength).split('+')[0].trim()}
value={`+${ring.strength}`}
@ -356,7 +356,7 @@
{/if}
{#if modificationStatus.hasEarring}
{@const earring = char.earring || char.aetherial_mastery}
{@const earring = char.aetherial_mastery}
{#if earring}
<ModificationSection title="Aetherial Mastery" visible={true}>
<StatModifierItem

View file

@ -1,280 +1,406 @@
<script lang="ts">
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<PartyCtx>('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<PartyCtx>('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)
}
}
</script>
<div class="unit" class:empty={!item}>
{#if item}
<ContextMenu>
{#snippet children()}
{#key item?.id ?? position}
<div
class="frame character cell"
class:editable={ctx?.canEdit()}
onclick={() => viewDetails()}
>
<img
class="image"
class:placeholder={!item?.character?.granblueId}
alt={displayName(item?.character)}
src={imageUrl}
/>
{#if ctx?.canEdit() && item?.id}
<div class="actions">
<button class="remove" title="Remove" onclick={(e) => { e.stopPropagation(); remove() }}>×</button>
</div>
{/if}
</div>
{/key}
{/snippet}
{#if item}
<ContextMenu>
{#snippet children()}
{#key item?.id ?? position}
<div
class="frame character cell"
class:editable={ctx?.canEdit()}
onclick={() => viewDetails()}
>
{#if ctx?.canEdit()}
<button
class="perpetuity"
class:active={item.perpetuity}
onclick={togglePerpetuity}
title={item.perpetuity ? 'Remove Perpetuity Ring' : 'Add Perpetuity Ring'}
>
<img class="perpetuity-icon filled" src={perpetuityFilled} alt="Perpetuity Ring" />
<img
class="perpetuity-icon empty"
src={perpetuityEmpty}
alt="Add Perpetuity Ring"
/>
</button>
{:else if item.perpetuity}
<img
class="perpetuity static"
src={perpetuityFilled}
alt="Perpetuity Ring"
title="Perpetuity Ring"
/>
{/if}
<img
class="image"
class:placeholder={!item?.character?.granblueId}
alt={displayName(item?.character)}
src={imageUrl}
/>
</div>
{/key}
{/snippet}
{#snippet menu()}
<ContextMenuBase.Item class="context-menu-item" onclick={viewDetails}>
View Details
</ContextMenuBase.Item>
{#if ctx?.canEdit()}
<ContextMenuBase.Item class="context-menu-item" onclick={replace}>
Replace
</ContextMenuBase.Item>
<ContextMenuBase.Separator class="context-menu-separator" />
<ContextMenuBase.Item class="context-menu-item danger" onclick={remove}>
Remove
</ContextMenuBase.Item>
{/if}
{/snippet}
</ContextMenu>
{:else}
{#key `empty-${position}`}
<div
class="frame character cell"
class:editable={ctx?.canEdit()}
onclick={() => ctx?.canEdit() && ctx?.openPicker && ctx.openPicker({ type: 'character', position, item })}
>
<img
class="image placeholder"
alt=""
src="/images/placeholders/placeholder-weapon-grid.png"
/>
{#if ctx?.canEdit()}
<span class="icon">
<Icon name="plus" size={24} />
</span>
{/if}
</div>
{/key}
{/if}
{#if item}
<UncapIndicator
type="character"
uncapLevel={item.uncapLevel}
transcendenceStage={item.transcendenceStep}
special={item.character?.special}
flb={item.character?.uncap?.flb}
ulb={item.character?.uncap?.ulb}
transcendence={!item.character?.special && item.character?.uncap?.ulb}
editable={ctx?.canEdit()}
updateUncap={async (level) => {
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}
<div class="name">{item ? displayName(item?.character) : ''}</div>
{#snippet menu()}
<ContextMenuBase.Item class="context-menu-item" onclick={viewDetails}>
View Details
</ContextMenuBase.Item>
{#if ctx?.canEdit()}
<ContextMenuBase.Item class="context-menu-item" onclick={replace}>
Replace
</ContextMenuBase.Item>
<ContextMenuBase.Separator class="context-menu-separator" />
<ContextMenuBase.Item class="context-menu-item danger" onclick={remove}>
Remove
</ContextMenuBase.Item>
{/if}
{/snippet}
</ContextMenu>
{:else}
{#key `empty-${position}`}
<div
class="frame character cell"
class:editable={ctx?.canEdit()}
onclick={() =>
ctx?.canEdit() &&
ctx?.openPicker &&
ctx.openPicker({ type: 'character', position, item })}
>
<img
class="image placeholder"
alt=""
src="/images/placeholders/placeholder-weapon-grid.png"
/>
{#if ctx?.canEdit()}
<span class="icon">
<Icon name="plus" size={24} />
</span>
{/if}
</div>
{/key}
{/if}
{#if item}
<UncapIndicator
type="character"
uncapLevel={item.uncapLevel}
transcendenceStage={item.transcendenceStep}
special={item.character?.special}
flb={item.character?.uncap?.flb}
ulb={item.character?.uncap?.ulb}
transcendence={!item.character?.special && item.character?.uncap?.ulb}
editable={ctx?.canEdit()}
updateUncap={async (level) => {
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}
<div class="name">{item ? displayName(item?.character) : ''}</div>
</div>
<style lang="scss">
@use '$src/themes/colors' as *;
@use '$src/themes/typography' as *;
@use '$src/themes/spacing' as *;
@use '$src/themes/rep' as rep;
@use '$src/themes/colors' as *;
@use '$src/themes/spacing' as spacing;
@use '$src/themes/typography' as *;
@use '$src/themes/spacing' as *;
@use '$src/themes/rep' as rep;
.unit {
position: relative;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: $unit;
.unit {
position: relative;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: $unit;
&.empty .name {
display: none;
}
}
&.empty .name {
display: none;
}
}
.frame {
position: relative;
width: 100%;
overflow: hidden;
border-radius: 8px;
background: var(--card-bg, #f5f5f5);
border: 1px solid transparent;
transition: all 0.2s ease-in-out;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
.frame {
position: relative;
width: 100%;
overflow: visible;
border-radius: 8px;
background: var(--card-bg, #f5f5f5);
border: 1px solid transparent;
transition: all 0.2s ease-in-out;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
&:hover {
opacity: 0.95;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
}
&:hover {
opacity: 0.95;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
}
.frame.character.cell {
@include rep.aspect(rep.$char-cell-w, rep.$char-cell-h);
}
.frame.character.cell {
@include rep.aspect(rep.$char-cell-w, rep.$char-cell-h);
}
.image {
position: relative;
width: 100%;
height: 100%;
object-fit: cover;
display: block;
z-index: 2;
.image {
position: relative;
width: 100%;
height: 100%;
object-fit: cover;
display: block;
z-index: 2;
border-radius: 8px;
&.placeholder {
opacity: 0;
}
}
&.placeholder {
opacity: 0;
}
}
.icon {
position: absolute;
z-index: 1;
color: var(--icon-secondary, #999);
transition: color 0.2s ease-in-out;
}
.icon {
position: absolute;
z-index: 1;
color: var(--icon-secondary, #999);
transition: color 0.2s ease-in-out;
}
.frame.editable:hover .icon {
color: var(--icon-secondary-hover, #666);
}
.frame.editable:hover .icon {
color: var(--icon-secondary-hover, #666);
}
.name {
font-size: $font-small;
text-align: center;
color: $grey-50;
}
.name {
font-size: $font-small;
text-align: center;
color: $grey-50;
}
.actions {
position: absolute;
top: 6px;
right: 6px;
display: flex;
gap: 6px;
z-index: 3;
}
.perpetuity {
position: absolute;
z-index: 20;
top: calc(spacing.$unit * -1);
right: spacing.$unit-3x;
width: spacing.$unit-4x;
height: spacing.$unit-4x;
padding: 0;
border: none;
background: transparent;
cursor: pointer;
transition: transform 0.2s ease;
.remove {
background: rgba(0,0,0,.6);
color: white;
border: none;
border-radius: 12px;
width: 24px;
height: 24px;
line-height: 24px;
cursor: pointer;
}
&:hover {
transform: scale(1.1);
}
&.static {
cursor: default;
&:hover {
transform: none;
}
}
}
.perpetuity-icon {
width: 100%;
height: 100%;
display: block;
&.filled {
display: none;
}
&.empty {
display: block;
}
}
.perpetuity.active {
.perpetuity-icon.filled {
display: block;
}
.perpetuity-icon.empty {
display: none;
}
&:hover {
.perpetuity-icon.filled {
display: none;
}
.perpetuity-icon.empty {
display: block;
}
}
}
.perpetuity:not(.active):hover {
.perpetuity-icon.filled {
display: block;
}
.perpetuity-icon.empty {
display: none;
}
}
</style>

View file

@ -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<Party | null> {
await gridAdapter.updateCharacter(gridCharacterId, {
): Promise<GridCharacter | null> {
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(

View file

@ -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 }>
}

View file

@ -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