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