add artifact UI components for phase 4
- DisclosureRow: iOS-style disclosure row for navigation - ArtifactSkillRow: shows skill with modifiers and level/value controls - ArtifactModifierList: selectable list of skills by polarity - ArtifactGradeDisplay: shows letter grade, breakdown, recommendation - ArtifactEditPane: main edit pane combining base props, skills, grade
This commit is contained in:
parent
ab1243190b
commit
3a41adc4f2
5 changed files with 1082 additions and 0 deletions
305
src/lib/components/artifact/ArtifactEditPane.svelte
Normal file
305
src/lib/components/artifact/ArtifactEditPane.svelte
Normal file
|
|
@ -0,0 +1,305 @@
|
||||||
|
<svelte:options runes={true} />
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import type {
|
||||||
|
ArtifactInstance,
|
||||||
|
ArtifactSkillInstance,
|
||||||
|
ArtifactSkill,
|
||||||
|
ArtifactGrade
|
||||||
|
} from '$lib/types/api/artifact'
|
||||||
|
import { isQuirkArtifact, getSkillGroupForSlot } from '$lib/types/api/artifact'
|
||||||
|
import { createQuery } from '@tanstack/svelte-query'
|
||||||
|
import { artifactQueries } from '$lib/api/queries/artifact.queries'
|
||||||
|
import { usePaneStack, type PaneConfig } from '$lib/stores/paneStack.svelte'
|
||||||
|
import DetailsSection from '$lib/components/sidebar/details/DetailsSection.svelte'
|
||||||
|
import DetailRow from '$lib/components/sidebar/details/DetailRow.svelte'
|
||||||
|
import Select from '$lib/components/ui/Select.svelte'
|
||||||
|
import ArtifactSkillRow from './ArtifactSkillRow.svelte'
|
||||||
|
import ArtifactModifierList from './ArtifactModifierList.svelte'
|
||||||
|
import ArtifactGradeDisplay from './ArtifactGradeDisplay.svelte'
|
||||||
|
import { getElementIcon } from '$lib/utils/images'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** The artifact instance being edited */
|
||||||
|
artifact: ArtifactInstance
|
||||||
|
/** Handler when artifact is updated */
|
||||||
|
onUpdate?: (updates: Partial<ArtifactInstance>) => void
|
||||||
|
/** Whether editing is disabled (view-only mode) */
|
||||||
|
disabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const { artifact, onUpdate, disabled = false }: Props = $props()
|
||||||
|
|
||||||
|
// Get the pane stack from context for pushing modifier selection panes
|
||||||
|
const paneStack = usePaneStack()
|
||||||
|
|
||||||
|
// Local state for edits
|
||||||
|
let element = $state(artifact.element)
|
||||||
|
let level = $state(artifact.level)
|
||||||
|
let proficiency = $state(artifact.proficiency)
|
||||||
|
let skills = $state<(ArtifactSkillInstance | null)[]>([...artifact.skills])
|
||||||
|
|
||||||
|
// Derived values
|
||||||
|
const artifactData = $derived(artifact.artifact)
|
||||||
|
const isQuirk = $derived(isQuirkArtifact(artifactData))
|
||||||
|
const canChangeElement = $derived(true) // Artifacts can always change element
|
||||||
|
const canChangeProficiency = $derived(isQuirk) // Only quirk artifacts have variable proficiency
|
||||||
|
|
||||||
|
// Query all skills for skill rows
|
||||||
|
const skillsQuery = createQuery(() => artifactQueries.skills())
|
||||||
|
|
||||||
|
// Get skills available for each slot
|
||||||
|
function getSkillsForSlot(slot: number): ArtifactSkill[] {
|
||||||
|
if (!skillsQuery.data) return []
|
||||||
|
const group = getSkillGroupForSlot(slot)
|
||||||
|
return skillsQuery.data.filter((s) => s.skillGroup === group)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Element options
|
||||||
|
const elementOptions = [
|
||||||
|
{ value: 1, label: 'Wind', image: getElementIcon(1) },
|
||||||
|
{ value: 2, label: 'Fire', image: getElementIcon(2) },
|
||||||
|
{ value: 3, label: 'Water', image: getElementIcon(3) },
|
||||||
|
{ value: 4, label: 'Earth', image: getElementIcon(4) },
|
||||||
|
{ value: 5, label: 'Dark', image: getElementIcon(5) },
|
||||||
|
{ value: 6, label: 'Light', image: getElementIcon(6) }
|
||||||
|
]
|
||||||
|
|
||||||
|
// Level options (1-5 for standard, fixed at 1 for quirk)
|
||||||
|
const levelOptions = $derived(
|
||||||
|
isQuirk
|
||||||
|
? [{ value: 1, label: '1' }]
|
||||||
|
: [
|
||||||
|
{ value: 1, label: '1' },
|
||||||
|
{ value: 2, label: '2' },
|
||||||
|
{ value: 3, label: '3' },
|
||||||
|
{ value: 4, label: '4' },
|
||||||
|
{ value: 5, label: '5' }
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Proficiency options (1-10 for quirk artifacts)
|
||||||
|
const proficiencyOptions = [
|
||||||
|
{ value: 1, label: 'Sabre' },
|
||||||
|
{ value: 2, label: 'Dagger' },
|
||||||
|
{ value: 3, label: 'Spear' },
|
||||||
|
{ value: 4, label: 'Axe' },
|
||||||
|
{ value: 5, label: 'Staff' },
|
||||||
|
{ value: 6, label: 'Gun' },
|
||||||
|
{ value: 7, label: 'Melee' },
|
||||||
|
{ value: 8, label: 'Bow' },
|
||||||
|
{ value: 9, label: 'Harp' },
|
||||||
|
{ value: 10, label: 'Katana' }
|
||||||
|
]
|
||||||
|
|
||||||
|
// Get proficiency display name
|
||||||
|
function getProficiencyName(profValue: number | null | undefined): string {
|
||||||
|
if (!profValue) return '—'
|
||||||
|
const option = proficiencyOptions.find((o) => o.value === profValue)
|
||||||
|
return option?.label ?? '—'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push modifier selection pane for a specific slot
|
||||||
|
function handleSelectModifier(slot: number) {
|
||||||
|
const config: PaneConfig = {
|
||||||
|
id: `modifier-select-${slot}`,
|
||||||
|
title: `Select Skill ${slot}`,
|
||||||
|
component: ArtifactModifierList,
|
||||||
|
props: {
|
||||||
|
slot,
|
||||||
|
selectedModifier: skills[slot - 1]?.modifier,
|
||||||
|
onSelect: (skill: ArtifactSkill) => handleModifierSelected(slot, skill)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
paneStack.push(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle when a modifier is selected from the list
|
||||||
|
function handleModifierSelected(slot: number, skill: ArtifactSkill) {
|
||||||
|
const index = slot - 1
|
||||||
|
const newSkills = [...skills]
|
||||||
|
|
||||||
|
// Create new skill instance with first available strength and level 1
|
||||||
|
const firstStrength = skill.baseValues.find((v) => v !== null && v !== 0) ?? 0
|
||||||
|
newSkills[index] = {
|
||||||
|
modifier: skill.modifier,
|
||||||
|
strength: firstStrength,
|
||||||
|
level: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
skills = newSkills
|
||||||
|
notifyUpdate()
|
||||||
|
|
||||||
|
// Pop back to the edit pane
|
||||||
|
paneStack.pop()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle skill updates from skill row
|
||||||
|
function handleUpdateSkill(slot: number, update: Partial<ArtifactSkillInstance>) {
|
||||||
|
const index = slot - 1
|
||||||
|
const currentSkill = skills[index]
|
||||||
|
if (!currentSkill) return
|
||||||
|
|
||||||
|
const newSkills = [...skills]
|
||||||
|
newSkills[index] = { ...currentSkill, ...update }
|
||||||
|
skills = newSkills
|
||||||
|
notifyUpdate()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle element change
|
||||||
|
function handleElementChange(newElement: number | undefined) {
|
||||||
|
if (newElement === undefined) return
|
||||||
|
element = newElement
|
||||||
|
notifyUpdate()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle level change
|
||||||
|
function handleLevelChange(newLevel: number | undefined) {
|
||||||
|
if (newLevel === undefined) return
|
||||||
|
level = newLevel
|
||||||
|
notifyUpdate()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle proficiency change
|
||||||
|
function handleProficiencyChange(newProficiency: number | undefined) {
|
||||||
|
if (newProficiency === undefined) return
|
||||||
|
proficiency = newProficiency
|
||||||
|
notifyUpdate()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify parent of updates
|
||||||
|
function notifyUpdate() {
|
||||||
|
if (!onUpdate) return
|
||||||
|
|
||||||
|
const updates: Partial<ArtifactInstance> = {
|
||||||
|
element,
|
||||||
|
level,
|
||||||
|
skills: [...skills]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isQuirk && proficiency !== undefined) {
|
||||||
|
updates.proficiency = proficiency
|
||||||
|
}
|
||||||
|
|
||||||
|
onUpdate(updates)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Current grade (from artifact or could be recalculated)
|
||||||
|
const currentGrade: ArtifactGrade = $derived(artifact.grade)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="artifact-edit-pane">
|
||||||
|
<DetailsSection title="Base Properties">
|
||||||
|
<DetailRow label="Element" noHover>
|
||||||
|
{#if disabled}
|
||||||
|
<span class="element-display">
|
||||||
|
<img src={getElementIcon(element)} alt="" class="element-icon" />
|
||||||
|
{elementOptions.find((o) => o.value === element)?.label ?? '—'}
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
<Select
|
||||||
|
options={elementOptions}
|
||||||
|
value={element}
|
||||||
|
onValueChange={handleElementChange}
|
||||||
|
size="small"
|
||||||
|
contained
|
||||||
|
{disabled}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</DetailRow>
|
||||||
|
|
||||||
|
{#if canChangeProficiency}
|
||||||
|
<DetailRow label="Proficiency" noHover>
|
||||||
|
{#if disabled}
|
||||||
|
<span>{getProficiencyName(proficiency)}</span>
|
||||||
|
{:else}
|
||||||
|
<Select
|
||||||
|
options={proficiencyOptions}
|
||||||
|
value={proficiency}
|
||||||
|
onValueChange={handleProficiencyChange}
|
||||||
|
size="small"
|
||||||
|
contained
|
||||||
|
placeholder="Select proficiency"
|
||||||
|
{disabled}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</DetailRow>
|
||||||
|
{:else}
|
||||||
|
<DetailRow label="Proficiency" value={getProficiencyName(artifactData.proficiency)} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<DetailRow label="Level" noHover>
|
||||||
|
{#if disabled || isQuirk}
|
||||||
|
<span>{level}</span>
|
||||||
|
{:else}
|
||||||
|
<Select
|
||||||
|
options={levelOptions}
|
||||||
|
value={level}
|
||||||
|
onValueChange={handleLevelChange}
|
||||||
|
size="small"
|
||||||
|
contained
|
||||||
|
{disabled}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</DetailRow>
|
||||||
|
</DetailsSection>
|
||||||
|
|
||||||
|
{#if !isQuirk}
|
||||||
|
<DetailsSection title="Skills">
|
||||||
|
<div class="skills-list">
|
||||||
|
{#each [1, 2, 3, 4] as slot}
|
||||||
|
<ArtifactSkillRow
|
||||||
|
{slot}
|
||||||
|
skill={skills[slot - 1] ?? null}
|
||||||
|
availableSkills={getSkillsForSlot(slot)}
|
||||||
|
onSelectModifier={() => handleSelectModifier(slot)}
|
||||||
|
onUpdateSkill={(update) => handleUpdateSkill(slot, update)}
|
||||||
|
{disabled}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</DetailsSection>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<DetailsSection title="Grade">
|
||||||
|
<div class="grade-section">
|
||||||
|
<ArtifactGradeDisplay grade={currentGrade} />
|
||||||
|
</div>
|
||||||
|
</DetailsSection>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@use '$src/themes/colors' as colors;
|
||||||
|
@use '$src/themes/spacing' as spacing;
|
||||||
|
@use '$src/themes/typography' as typography;
|
||||||
|
@use '$src/themes/layout' as layout;
|
||||||
|
|
||||||
|
.artifact-edit-pane {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: spacing.$unit-3x;
|
||||||
|
padding-bottom: spacing.$unit-4x;
|
||||||
|
}
|
||||||
|
|
||||||
|
.element-display {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: spacing.$unit-half;
|
||||||
|
}
|
||||||
|
|
||||||
|
.element-icon {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skills-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: spacing.$unit;
|
||||||
|
padding: 0 spacing.$unit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grade-section {
|
||||||
|
padding: spacing.$unit;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
237
src/lib/components/artifact/ArtifactGradeDisplay.svelte
Normal file
237
src/lib/components/artifact/ArtifactGradeDisplay.svelte
Normal file
|
|
@ -0,0 +1,237 @@
|
||||||
|
<svelte:options runes={true} />
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import type { ArtifactGrade } from '$lib/types/api/artifact'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** The grade to display */
|
||||||
|
grade: ArtifactGrade
|
||||||
|
/** Whether to show the full breakdown (default: true) */
|
||||||
|
showBreakdown?: boolean
|
||||||
|
/** Whether to show the recommendation (default: true) */
|
||||||
|
showRecommendation?: boolean
|
||||||
|
/** Size variant */
|
||||||
|
size?: 'small' | 'medium' | 'large'
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
grade,
|
||||||
|
showBreakdown = true,
|
||||||
|
showRecommendation = true,
|
||||||
|
size = 'medium'
|
||||||
|
}: Props = $props()
|
||||||
|
|
||||||
|
// Get grade letter class for styling
|
||||||
|
const gradeClass = $derived(grade.letter?.toLowerCase() ?? 'none')
|
||||||
|
|
||||||
|
// Format score as percentage
|
||||||
|
const scoreDisplay = $derived(
|
||||||
|
grade.score !== null ? `${Math.round(grade.score)}%` : '—'
|
||||||
|
)
|
||||||
|
|
||||||
|
// Action display mapping
|
||||||
|
const actionLabels: Record<string, { label: string; class: string }> = {
|
||||||
|
keep: { label: 'Keep', class: 'action-keep' },
|
||||||
|
reroll: { label: 'Reroll', class: 'action-reroll' },
|
||||||
|
scrap: { label: 'Scrap', class: 'action-scrap' }
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="grade-display" class:size-small={size === 'small'} class:size-large={size === 'large'}>
|
||||||
|
{#if grade.letter}
|
||||||
|
<div class="grade-header">
|
||||||
|
<div class="grade-letter grade-{gradeClass}">
|
||||||
|
{grade.letter}
|
||||||
|
</div>
|
||||||
|
{#if grade.score !== null}
|
||||||
|
<div class="grade-score">{scoreDisplay}</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if showBreakdown && grade.breakdown}
|
||||||
|
<div class="breakdown">
|
||||||
|
<div class="breakdown-item">
|
||||||
|
<span class="breakdown-label">Selection</span>
|
||||||
|
<span class="breakdown-value">{grade.breakdown.skillSelection}</span>
|
||||||
|
</div>
|
||||||
|
<div class="breakdown-item">
|
||||||
|
<span class="breakdown-label">Strength</span>
|
||||||
|
<span class="breakdown-value">{grade.breakdown.baseStrength}</span>
|
||||||
|
</div>
|
||||||
|
<div class="breakdown-item">
|
||||||
|
<span class="breakdown-label">Synergy</span>
|
||||||
|
<span class="breakdown-value">{grade.breakdown.synergy}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if showRecommendation && grade.recommendation}
|
||||||
|
{@const action = actionLabels[grade.recommendation.action]}
|
||||||
|
<div class="recommendation">
|
||||||
|
<span class="action-badge {action?.class ?? ''}">{action?.label ?? grade.recommendation.action}</span>
|
||||||
|
{#if grade.recommendation.reason}
|
||||||
|
<p class="reason">{grade.recommendation.reason}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<div class="no-grade">
|
||||||
|
<span class="no-grade-text">{grade.note ?? 'No grade'}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@use '$src/themes/colors' as colors;
|
||||||
|
@use '$src/themes/spacing' as spacing;
|
||||||
|
@use '$src/themes/typography' as typography;
|
||||||
|
@use '$src/themes/layout' as layout;
|
||||||
|
|
||||||
|
.grade-display {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: spacing.$unit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grade-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: spacing.$unit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grade-letter {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: typography.$bold;
|
||||||
|
line-height: 1;
|
||||||
|
padding: spacing.$unit-half spacing.$unit;
|
||||||
|
border-radius: layout.$item-corner;
|
||||||
|
|
||||||
|
&.grade-s {
|
||||||
|
background: linear-gradient(135deg, #ffd700, #ffb347);
|
||||||
|
color: #6b4c00;
|
||||||
|
}
|
||||||
|
&.grade-a {
|
||||||
|
background: linear-gradient(135deg, #4ade80, #22c55e);
|
||||||
|
color: #14532d;
|
||||||
|
}
|
||||||
|
&.grade-b {
|
||||||
|
background: linear-gradient(135deg, #60a5fa, #3b82f6);
|
||||||
|
color: #1e3a5f;
|
||||||
|
}
|
||||||
|
&.grade-c {
|
||||||
|
background: colors.$grey-80;
|
||||||
|
color: colors.$grey-30;
|
||||||
|
}
|
||||||
|
&.grade-d {
|
||||||
|
background: colors.$grey-70;
|
||||||
|
color: colors.$grey-40;
|
||||||
|
}
|
||||||
|
&.grade-f {
|
||||||
|
background: linear-gradient(135deg, #f87171, #ef4444);
|
||||||
|
color: #7f1d1d;
|
||||||
|
}
|
||||||
|
&.grade-none {
|
||||||
|
background: colors.$grey-85;
|
||||||
|
color: colors.$grey-50;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.grade-score {
|
||||||
|
font-size: typography.$font-large;
|
||||||
|
font-weight: typography.$medium;
|
||||||
|
color: colors.$grey-40;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breakdown {
|
||||||
|
display: flex;
|
||||||
|
gap: spacing.$unit-2x;
|
||||||
|
padding: spacing.$unit;
|
||||||
|
background: colors.$grey-95;
|
||||||
|
border-radius: layout.$item-corner;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breakdown-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: spacing.$unit-fourth;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breakdown-label {
|
||||||
|
font-size: typography.$font-small;
|
||||||
|
color: colors.$grey-50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breakdown-value {
|
||||||
|
font-size: typography.$font-regular;
|
||||||
|
font-weight: typography.$medium;
|
||||||
|
color: colors.$grey-30;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recommendation {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: spacing.$unit-half;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-self: flex-start;
|
||||||
|
padding: spacing.$unit-fourth spacing.$unit-half;
|
||||||
|
border-radius: layout.$item-corner;
|
||||||
|
font-size: typography.$font-small;
|
||||||
|
font-weight: typography.$medium;
|
||||||
|
|
||||||
|
&.action-keep {
|
||||||
|
background: colors.$wind-bg-20;
|
||||||
|
color: colors.$wind-text-20;
|
||||||
|
}
|
||||||
|
&.action-reroll {
|
||||||
|
background: colors.$accent--yellow--100;
|
||||||
|
color: colors.$accent--yellow--10;
|
||||||
|
}
|
||||||
|
&.action-scrap {
|
||||||
|
background: colors.$error--bg--light;
|
||||||
|
color: colors.$error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.reason {
|
||||||
|
font-size: typography.$font-small;
|
||||||
|
color: colors.$grey-40;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-grade {
|
||||||
|
padding: spacing.$unit;
|
||||||
|
background: colors.$grey-95;
|
||||||
|
border-radius: layout.$item-corner;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-grade-text {
|
||||||
|
font-size: typography.$font-small;
|
||||||
|
color: colors.$grey-50;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Size variants
|
||||||
|
.size-small {
|
||||||
|
.grade-letter {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
padding: spacing.$unit-fourth spacing.$unit-half;
|
||||||
|
}
|
||||||
|
.grade-score {
|
||||||
|
font-size: typography.$font-regular;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.size-large {
|
||||||
|
.grade-letter {
|
||||||
|
font-size: 3rem;
|
||||||
|
padding: spacing.$unit spacing.$unit-2x;
|
||||||
|
}
|
||||||
|
.grade-score {
|
||||||
|
font-size: typography.$font-xlarge;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
178
src/lib/components/artifact/ArtifactModifierList.svelte
Normal file
178
src/lib/components/artifact/ArtifactModifierList.svelte
Normal file
|
|
@ -0,0 +1,178 @@
|
||||||
|
<svelte:options runes={true} />
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import type { ArtifactSkill } from '$lib/types/api/artifact'
|
||||||
|
import { createQuery } from '@tanstack/svelte-query'
|
||||||
|
import { artifactQueries } from '$lib/api/queries/artifact.queries'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Slot number (1-4) to get skills for */
|
||||||
|
slot: number
|
||||||
|
/** Currently selected modifier (for highlighting) */
|
||||||
|
selectedModifier?: number
|
||||||
|
/** Handler when a modifier is selected */
|
||||||
|
onSelect: (skill: ArtifactSkill) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const { slot, selectedModifier, onSelect }: Props = $props()
|
||||||
|
|
||||||
|
// Query skills for this slot
|
||||||
|
const skillsQuery = createQuery(() => artifactQueries.skillsForSlot(slot))
|
||||||
|
|
||||||
|
// Separate positive and negative skills
|
||||||
|
const positiveSkills = $derived(
|
||||||
|
skillsQuery.data?.filter((s) => s.polarity === 'positive') ?? []
|
||||||
|
)
|
||||||
|
const negativeSkills = $derived(
|
||||||
|
skillsQuery.data?.filter((s) => s.polarity === 'negative') ?? []
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="modifier-list">
|
||||||
|
{#if skillsQuery.isPending}
|
||||||
|
<div class="loading-state">Loading skills...</div>
|
||||||
|
{:else if skillsQuery.isError}
|
||||||
|
<div class="error-state">Failed to load skills</div>
|
||||||
|
{:else}
|
||||||
|
{#if positiveSkills.length > 0}
|
||||||
|
<div class="skill-group">
|
||||||
|
<h4 class="group-header positive">Positive Effects</h4>
|
||||||
|
<div class="skill-options">
|
||||||
|
{#each positiveSkills as skill (skill.id)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="modifier-option"
|
||||||
|
class:selected={selectedModifier === skill.modifier}
|
||||||
|
onclick={() => onSelect(skill)}
|
||||||
|
>
|
||||||
|
<span class="name">{skill.name.en}</span>
|
||||||
|
<span class="polarity positive">+</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if negativeSkills.length > 0}
|
||||||
|
<div class="skill-group">
|
||||||
|
<h4 class="group-header negative">Negative Effects</h4>
|
||||||
|
<div class="skill-options">
|
||||||
|
{#each negativeSkills as skill (skill.id)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="modifier-option"
|
||||||
|
class:selected={selectedModifier === skill.modifier}
|
||||||
|
onclick={() => onSelect(skill)}
|
||||||
|
>
|
||||||
|
<span class="name">{skill.name.en}</span>
|
||||||
|
<span class="polarity negative">−</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@use '$src/themes/colors' as colors;
|
||||||
|
@use '$src/themes/spacing' as spacing;
|
||||||
|
@use '$src/themes/typography' as typography;
|
||||||
|
@use '$src/themes/layout' as layout;
|
||||||
|
@use '$src/themes/effects' as effects;
|
||||||
|
|
||||||
|
.modifier-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: spacing.$unit-2x;
|
||||||
|
padding: spacing.$unit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-state,
|
||||||
|
.error-state {
|
||||||
|
padding: spacing.$unit-4x;
|
||||||
|
text-align: center;
|
||||||
|
color: colors.$grey-50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: spacing.$unit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-header {
|
||||||
|
font-size: typography.$font-small;
|
||||||
|
font-weight: typography.$medium;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 spacing.$unit-half;
|
||||||
|
|
||||||
|
&.positive {
|
||||||
|
color: colors.$wind-text-20;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.negative {
|
||||||
|
color: colors.$error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-options {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: spacing.$unit-half;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modifier-option {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
padding: spacing.$unit;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: layout.$item-corner;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
transition:
|
||||||
|
background-color effects.$duration-quick ease,
|
||||||
|
border-color effects.$duration-quick ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: colors.$grey-90;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
background: colors.$grey-85;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.selected {
|
||||||
|
background: colors.$water-bg-20;
|
||||||
|
border-color: colors.$blue;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
font-size: typography.$font-regular;
|
||||||
|
font-weight: typography.$normal;
|
||||||
|
color: colors.$grey-20;
|
||||||
|
}
|
||||||
|
|
||||||
|
.polarity {
|
||||||
|
font-size: typography.$font-small;
|
||||||
|
font-weight: typography.$bold;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
|
||||||
|
&.positive {
|
||||||
|
background: colors.$wind-bg-20;
|
||||||
|
color: colors.$wind-text-20;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.negative {
|
||||||
|
background: colors.$error--bg--light;
|
||||||
|
color: colors.$error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
223
src/lib/components/artifact/ArtifactSkillRow.svelte
Normal file
223
src/lib/components/artifact/ArtifactSkillRow.svelte
Normal file
|
|
@ -0,0 +1,223 @@
|
||||||
|
<svelte:options runes={true} />
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import DisclosureRow from '$lib/components/ui/DisclosureRow.svelte'
|
||||||
|
import Select from '$lib/components/ui/Select.svelte'
|
||||||
|
import type { ArtifactSkill, ArtifactSkillInstance } from '$lib/types/api/artifact'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Slot number (1-4) */
|
||||||
|
slot: number
|
||||||
|
/** Current skill configuration (null if not set) */
|
||||||
|
skill: ArtifactSkillInstance | null
|
||||||
|
/** Available skills for this slot (from query) */
|
||||||
|
availableSkills?: ArtifactSkill[]
|
||||||
|
/** Handler to open modifier selection pane */
|
||||||
|
onSelectModifier: () => void
|
||||||
|
/** Handler when skill is updated */
|
||||||
|
onUpdateSkill: (update: Partial<ArtifactSkillInstance>) => void
|
||||||
|
/** Whether editing is disabled */
|
||||||
|
disabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
slot,
|
||||||
|
skill,
|
||||||
|
availableSkills = [],
|
||||||
|
onSelectModifier,
|
||||||
|
onUpdateSkill,
|
||||||
|
disabled = false
|
||||||
|
}: Props = $props()
|
||||||
|
|
||||||
|
// Find the current skill definition
|
||||||
|
const currentSkillDef = $derived(
|
||||||
|
skill ? availableSkills.find((s) => s.modifier === skill.modifier) : null
|
||||||
|
)
|
||||||
|
|
||||||
|
// Get modifier name for display
|
||||||
|
const modifierName = $derived(currentSkillDef?.name?.en ?? 'Unknown')
|
||||||
|
|
||||||
|
// Build strength options from the skill's baseValues
|
||||||
|
const strengthOptions = $derived(() => {
|
||||||
|
if (!currentSkillDef?.baseValues) return []
|
||||||
|
return currentSkillDef.baseValues
|
||||||
|
.map((v, i) => ({
|
||||||
|
value: v ?? 0,
|
||||||
|
label: `${v}${currentSkillDef.suffix?.en ?? ''}`
|
||||||
|
}))
|
||||||
|
.filter((opt) => opt.value !== 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Build level options (1-5)
|
||||||
|
const levelOptions = [
|
||||||
|
{ value: 1, label: '1' },
|
||||||
|
{ value: 2, label: '2' },
|
||||||
|
{ value: 3, label: '3' },
|
||||||
|
{ value: 4, label: '4' },
|
||||||
|
{ value: 5, label: '5' }
|
||||||
|
]
|
||||||
|
|
||||||
|
function handleStrengthChange(newStrength: number) {
|
||||||
|
onUpdateSkill({ strength: newStrength })
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLevelChange(newLevel: number) {
|
||||||
|
onUpdateSkill({ level: newLevel })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="skill-row">
|
||||||
|
{#if !skill?.modifier}
|
||||||
|
<!-- Unset: Show disclosure to select modifier -->
|
||||||
|
<DisclosureRow
|
||||||
|
label="Skill {slot}"
|
||||||
|
sublabel="Tap to select"
|
||||||
|
onclick={onSelectModifier}
|
||||||
|
{disabled}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<!-- Set: Show modifier name + value/level controls -->
|
||||||
|
<div class="skill-row-set" class:disabled>
|
||||||
|
<div class="skill-header">
|
||||||
|
<div class="modifier-info">
|
||||||
|
<span class="modifier-name">{modifierName}</span>
|
||||||
|
{#if currentSkillDef?.polarity === 'negative'}
|
||||||
|
<span class="polarity negative">−</span>
|
||||||
|
{:else}
|
||||||
|
<span class="polarity positive">+</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="change-btn"
|
||||||
|
onclick={onSelectModifier}
|
||||||
|
{disabled}
|
||||||
|
>
|
||||||
|
Change
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="skill-controls">
|
||||||
|
<div class="control-group">
|
||||||
|
<label class="control-label">Value</label>
|
||||||
|
<Select
|
||||||
|
value={skill.strength}
|
||||||
|
options={strengthOptions()}
|
||||||
|
size="small"
|
||||||
|
contained
|
||||||
|
onValueChange={(v) => v !== undefined && handleStrengthChange(v)}
|
||||||
|
{disabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="control-group">
|
||||||
|
<label class="control-label">Level</label>
|
||||||
|
<Select
|
||||||
|
value={skill.level}
|
||||||
|
options={levelOptions}
|
||||||
|
size="small"
|
||||||
|
contained
|
||||||
|
onValueChange={(v) => v !== undefined && handleLevelChange(v)}
|
||||||
|
{disabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@use '$src/themes/colors' as colors;
|
||||||
|
@use '$src/themes/spacing' as spacing;
|
||||||
|
@use '$src/themes/typography' as typography;
|
||||||
|
@use '$src/themes/layout' as layout;
|
||||||
|
@use '$src/themes/effects' as effects;
|
||||||
|
|
||||||
|
.skill-row {
|
||||||
|
// Minimal container for skill row
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-row-set {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: spacing.$unit;
|
||||||
|
padding: spacing.$unit;
|
||||||
|
background: var(--input-bg);
|
||||||
|
border-radius: layout.$item-corner;
|
||||||
|
|
||||||
|
&.disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modifier-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: spacing.$unit-half;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modifier-name {
|
||||||
|
font-weight: typography.$medium;
|
||||||
|
color: colors.$grey-20;
|
||||||
|
}
|
||||||
|
|
||||||
|
.polarity {
|
||||||
|
font-size: typography.$font-small;
|
||||||
|
font-weight: typography.$bold;
|
||||||
|
padding: 2px 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
|
||||||
|
&.positive {
|
||||||
|
background: colors.$wind-bg-20;
|
||||||
|
color: colors.$wind-text-20;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.negative {
|
||||||
|
background: colors.$error--bg--light;
|
||||||
|
color: colors.$error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.change-btn {
|
||||||
|
font-size: typography.$font-small;
|
||||||
|
color: colors.$blue;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: spacing.$unit-fourth spacing.$unit-half;
|
||||||
|
border-radius: layout.$item-corner;
|
||||||
|
transition: background-color effects.$duration-quick ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: colors.$grey-90;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: spacing.$unit-2x;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: spacing.$unit-fourth;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-label {
|
||||||
|
font-size: typography.$font-small;
|
||||||
|
color: colors.$grey-50;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
139
src/lib/components/ui/DisclosureRow.svelte
Normal file
139
src/lib/components/ui/DisclosureRow.svelte
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
<svelte:options runes={true} />
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import Icon from './Icon.svelte'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Primary label text */
|
||||||
|
label: string
|
||||||
|
/** Optional secondary label/description */
|
||||||
|
sublabel?: string
|
||||||
|
/** Value displayed on the right side (before the chevron) */
|
||||||
|
value?: string
|
||||||
|
/** Click handler - typically pushes a new pane */
|
||||||
|
onclick?: () => void
|
||||||
|
/** Whether the row is disabled */
|
||||||
|
disabled?: boolean
|
||||||
|
/** Element color for styling (optional) */
|
||||||
|
element?: 'wind' | 'fire' | 'water' | 'earth' | 'dark' | 'light'
|
||||||
|
}
|
||||||
|
|
||||||
|
const { label, sublabel, value, onclick, disabled = false, element }: Props = $props()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="disclosure-row"
|
||||||
|
class:disabled
|
||||||
|
class:element-wind={element === 'wind'}
|
||||||
|
class:element-fire={element === 'fire'}
|
||||||
|
class:element-water={element === 'water'}
|
||||||
|
class:element-earth={element === 'earth'}
|
||||||
|
class:element-dark={element === 'dark'}
|
||||||
|
class:element-light={element === 'light'}
|
||||||
|
{onclick}
|
||||||
|
{disabled}
|
||||||
|
>
|
||||||
|
<div class="label-container">
|
||||||
|
<span class="label">{label}</span>
|
||||||
|
{#if sublabel}
|
||||||
|
<span class="sublabel">{sublabel}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="value-area">
|
||||||
|
{#if value}
|
||||||
|
<span class="value">{value}</span>
|
||||||
|
{/if}
|
||||||
|
<Icon name="chevron-right" size={16} />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@use '$src/themes/colors' as colors;
|
||||||
|
@use '$src/themes/layout' as layout;
|
||||||
|
@use '$src/themes/spacing' as spacing;
|
||||||
|
@use '$src/themes/typography' as typography;
|
||||||
|
@use '$src/themes/effects' as effects;
|
||||||
|
|
||||||
|
.disclosure-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
padding: spacing.$unit spacing.$unit;
|
||||||
|
margin: 0 calc(spacing.$unit * -1);
|
||||||
|
border-radius: layout.$item-corner;
|
||||||
|
font-size: typography.$font-regular;
|
||||||
|
min-height: calc(spacing.$unit * 5);
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
transition: background-color effects.$duration-quick ease;
|
||||||
|
|
||||||
|
&:hover:not(.disabled) {
|
||||||
|
background: colors.$grey-90;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active:not(.disabled) {
|
||||||
|
background: colors.$grey-85;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-right: spacing.$unit-2x;
|
||||||
|
gap: spacing.$unit-fourth;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-weight: typography.$medium;
|
||||||
|
color: colors.$grey-30;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sublabel {
|
||||||
|
font-size: typography.$font-small;
|
||||||
|
color: colors.$grey-60;
|
||||||
|
font-weight: typography.$normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.value-area {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: spacing.$unit-half;
|
||||||
|
color: colors.$grey-50;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.value {
|
||||||
|
font-size: typography.$font-regular;
|
||||||
|
color: colors.$grey-50;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Element-colored value
|
||||||
|
&.element-wind .value {
|
||||||
|
color: colors.$wind-text-20;
|
||||||
|
}
|
||||||
|
&.element-fire .value {
|
||||||
|
color: colors.$fire-text-20;
|
||||||
|
}
|
||||||
|
&.element-water .value {
|
||||||
|
color: colors.$water-text-20;
|
||||||
|
}
|
||||||
|
&.element-earth .value {
|
||||||
|
color: colors.$earth-text-20;
|
||||||
|
}
|
||||||
|
&.element-dark .value {
|
||||||
|
color: colors.$dark-text-20;
|
||||||
|
}
|
||||||
|
&.element-light .value {
|
||||||
|
color: colors.$light-text-20;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Loading…
Reference in a new issue