add AddArtifactSidebar for collection artifacts
flow: proficiency -> element (color dots) -> artifact dropdown (filtered by proficiency) -> level/nickname -> skills config using pane stack for iOS-style navigation
This commit is contained in:
parent
ed32b7e924
commit
df045ecd2b
3 changed files with 434 additions and 2 deletions
391
src/lib/components/sidebar/AddArtifactSidebar.svelte
Normal file
391
src/lib/components/sidebar/AddArtifactSidebar.svelte
Normal file
|
|
@ -0,0 +1,391 @@
|
||||||
|
<svelte:options runes={true} />
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* AddArtifactSidebar - Root pane for adding a new artifact to collection
|
||||||
|
*
|
||||||
|
* Flow:
|
||||||
|
* 1. Select proficiency (weapon type)
|
||||||
|
* 2. Select element (with colored dots)
|
||||||
|
* 3. Select artifact (filtered by proficiency - ~3 options)
|
||||||
|
* 4. Configure level, nickname
|
||||||
|
* 5. Configure skills (for standard artifacts only, uses pane stack)
|
||||||
|
*/
|
||||||
|
import type { Artifact, ArtifactSkill, ArtifactSkillInstance, CollectionArtifactInput } 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 { useCreateCollectionArtifact } from '$lib/api/mutations/artifact.mutations'
|
||||||
|
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 Input from '$lib/components/ui/Input.svelte'
|
||||||
|
import ArtifactSkillRow from '$lib/components/artifact/ArtifactSkillRow.svelte'
|
||||||
|
import ArtifactModifierList from '$lib/components/artifact/ArtifactModifierList.svelte'
|
||||||
|
import Button from '$lib/components/ui/Button.svelte'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Callback when artifact is created successfully */
|
||||||
|
onSuccess?: () => void
|
||||||
|
/** Callback to close the sidebar */
|
||||||
|
onClose?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const { onSuccess, onClose }: Props = $props()
|
||||||
|
|
||||||
|
// Get the pane stack from context for pushing modifier selection panes
|
||||||
|
const paneStack = usePaneStack()
|
||||||
|
|
||||||
|
// Mutation for creating artifact
|
||||||
|
const createMutation = useCreateCollectionArtifact()
|
||||||
|
|
||||||
|
// Query all artifacts for dropdown
|
||||||
|
const artifactsQuery = createQuery(() => artifactQueries.all())
|
||||||
|
|
||||||
|
// Query all skills for skill rows
|
||||||
|
const skillsQuery = createQuery(() => artifactQueries.skills())
|
||||||
|
|
||||||
|
// Local state for form - ordered by user flow
|
||||||
|
let proficiency = $state<number | undefined>(undefined)
|
||||||
|
let element = $state<number | undefined>(undefined)
|
||||||
|
let selectedArtifactId = $state<string | undefined>(undefined)
|
||||||
|
let level = $state<number>(1)
|
||||||
|
let nickname = $state<string>('')
|
||||||
|
let skills = $state<(ArtifactSkillInstance | null)[]>([null, null, null, null])
|
||||||
|
|
||||||
|
// Element colors for dots (CSS colors that work in both light/dark themes)
|
||||||
|
const ELEMENT_COLORS: Record<number, string> = {
|
||||||
|
1: '#1dc688', // Wind - green
|
||||||
|
2: '#ec5c5c', // Fire - red
|
||||||
|
3: '#5cb7ec', // Water - blue
|
||||||
|
4: '#ec985c', // Earth - orange/brown
|
||||||
|
5: '#c65cec', // Dark - purple
|
||||||
|
6: '#c59c0c' // Light - gold/yellow
|
||||||
|
}
|
||||||
|
|
||||||
|
// Proficiency options - matches database enum values
|
||||||
|
const proficiencyOptions = [
|
||||||
|
{ value: 1, label: 'Sabre' },
|
||||||
|
{ value: 2, label: 'Dagger' },
|
||||||
|
{ value: 3, label: 'Axe' },
|
||||||
|
{ value: 4, label: 'Spear' },
|
||||||
|
{ value: 5, label: 'Bow' },
|
||||||
|
{ value: 6, label: 'Staff' },
|
||||||
|
{ value: 7, label: 'Melee' },
|
||||||
|
{ value: 8, label: 'Harp' },
|
||||||
|
{ value: 9, label: 'Gun' },
|
||||||
|
{ value: 10, label: 'Katana' }
|
||||||
|
]
|
||||||
|
|
||||||
|
// Element options with colored dots
|
||||||
|
const elementOptions = [
|
||||||
|
{ value: 1, label: 'Wind', color: ELEMENT_COLORS[1] },
|
||||||
|
{ value: 2, label: 'Fire', color: ELEMENT_COLORS[2] },
|
||||||
|
{ value: 3, label: 'Water', color: ELEMENT_COLORS[3] },
|
||||||
|
{ value: 4, label: 'Earth', color: ELEMENT_COLORS[4] },
|
||||||
|
{ value: 5, label: 'Dark', color: ELEMENT_COLORS[5] },
|
||||||
|
{ value: 6, label: 'Light', color: ELEMENT_COLORS[6] }
|
||||||
|
]
|
||||||
|
|
||||||
|
// Filter artifacts by selected proficiency
|
||||||
|
// Standard artifacts have a fixed proficiency, quirk artifacts match any proficiency
|
||||||
|
const filteredArtifacts = $derived.by(() => {
|
||||||
|
if (!artifactsQuery.data || proficiency === undefined) return []
|
||||||
|
return artifactsQuery.data.filter(a => {
|
||||||
|
// Quirk artifacts have null proficiency - they work with any proficiency
|
||||||
|
if (a.proficiency === null) return true
|
||||||
|
// Standard artifacts match their fixed proficiency
|
||||||
|
return a.proficiency === proficiency
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Build artifact options for dropdown (filtered by proficiency)
|
||||||
|
const artifactOptions = $derived.by(() => {
|
||||||
|
return filteredArtifacts.map(a => ({
|
||||||
|
value: a.id,
|
||||||
|
label: typeof a.name === 'string' ? a.name : (a.name.en || a.name.ja || '—')
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
// Selected artifact data
|
||||||
|
const selectedArtifact = $derived(
|
||||||
|
artifactsQuery.data?.find(a => a.id === selectedArtifactId)
|
||||||
|
)
|
||||||
|
const isQuirk = $derived(selectedArtifact ? isQuirkArtifact(selectedArtifact) : false)
|
||||||
|
|
||||||
|
// 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' }
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
|
||||||
|
// Pop back to the root 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle proficiency change - reset artifact selection
|
||||||
|
function handleProficiencyChange(newProficiency: number | undefined) {
|
||||||
|
proficiency = newProficiency
|
||||||
|
// Reset artifact selection when proficiency changes
|
||||||
|
selectedArtifactId = undefined
|
||||||
|
skills = [null, null, null, null]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle artifact selection change
|
||||||
|
function handleArtifactChange(newArtifactId: string | undefined) {
|
||||||
|
selectedArtifactId = newArtifactId
|
||||||
|
// Reset skills when artifact changes
|
||||||
|
skills = [null, null, null, null]
|
||||||
|
// Reset level for quirk
|
||||||
|
const artifact = artifactsQuery.data?.find(a => a.id === newArtifactId)
|
||||||
|
if (artifact && isQuirkArtifact(artifact)) {
|
||||||
|
level = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate form
|
||||||
|
const isValid = $derived(
|
||||||
|
proficiency !== undefined &&
|
||||||
|
element !== undefined &&
|
||||||
|
selectedArtifactId !== undefined
|
||||||
|
)
|
||||||
|
|
||||||
|
// Handle save
|
||||||
|
async function handleSave() {
|
||||||
|
if (!selectedArtifactId || !isValid || element === undefined) return
|
||||||
|
|
||||||
|
const input: CollectionArtifactInput = {
|
||||||
|
artifactId: selectedArtifactId,
|
||||||
|
element,
|
||||||
|
level,
|
||||||
|
nickname: nickname.trim() || undefined,
|
||||||
|
// For quirk artifacts, send the user-selected proficiency
|
||||||
|
proficiency: isQuirk ? proficiency : undefined,
|
||||||
|
skill1: skills[0] ?? undefined,
|
||||||
|
skill2: skills[1] ?? undefined,
|
||||||
|
skill3: skills[2] ?? undefined,
|
||||||
|
skill4: skills[3] ?? undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
createMutation.mutate(input, {
|
||||||
|
onSuccess: () => {
|
||||||
|
onSuccess?.()
|
||||||
|
onClose?.()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="add-artifact-sidebar">
|
||||||
|
<div class="form-sections">
|
||||||
|
<DetailsSection title="Base Properties">
|
||||||
|
<DetailRow label="Proficiency" noHover>
|
||||||
|
<Select
|
||||||
|
options={proficiencyOptions}
|
||||||
|
value={proficiency}
|
||||||
|
onValueChange={handleProficiencyChange}
|
||||||
|
size="small"
|
||||||
|
contained
|
||||||
|
placeholder="Select proficiency"
|
||||||
|
/>
|
||||||
|
</DetailRow>
|
||||||
|
|
||||||
|
<DetailRow label="Element" noHover>
|
||||||
|
<Select
|
||||||
|
options={elementOptions}
|
||||||
|
value={element}
|
||||||
|
onValueChange={(v) => (element = v)}
|
||||||
|
size="small"
|
||||||
|
contained
|
||||||
|
placeholder="Select element"
|
||||||
|
/>
|
||||||
|
</DetailRow>
|
||||||
|
</DetailsSection>
|
||||||
|
|
||||||
|
{#if proficiency !== undefined}
|
||||||
|
<DetailsSection title="Artifact">
|
||||||
|
<div class="artifact-select">
|
||||||
|
{#if artifactsQuery.isPending}
|
||||||
|
<p class="loading">Loading artifacts...</p>
|
||||||
|
{:else if artifactsQuery.isError}
|
||||||
|
<p class="error">Failed to load artifacts</p>
|
||||||
|
{:else if artifactOptions.length === 0}
|
||||||
|
<p class="empty">No artifacts available for this proficiency</p>
|
||||||
|
{:else}
|
||||||
|
<Select
|
||||||
|
options={artifactOptions}
|
||||||
|
value={selectedArtifactId}
|
||||||
|
onValueChange={handleArtifactChange}
|
||||||
|
placeholder="Select artifact..."
|
||||||
|
size="medium"
|
||||||
|
fullWidth
|
||||||
|
contained
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</DetailsSection>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if selectedArtifact}
|
||||||
|
<DetailsSection title="Configuration">
|
||||||
|
<DetailRow label="Level" noHover>
|
||||||
|
<Select
|
||||||
|
options={levelOptions}
|
||||||
|
value={level}
|
||||||
|
onValueChange={(v) => v !== undefined && (level = v)}
|
||||||
|
size="small"
|
||||||
|
contained
|
||||||
|
/>
|
||||||
|
</DetailRow>
|
||||||
|
|
||||||
|
<DetailRow label="Nickname" noHover>
|
||||||
|
<Input
|
||||||
|
bind:value={nickname}
|
||||||
|
placeholder="Optional nickname"
|
||||||
|
maxLength={50}
|
||||||
|
/>
|
||||||
|
</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)}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</DetailsSection>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-footer">
|
||||||
|
<Button variant="secondary" onclick={onClose}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onclick={handleSave}
|
||||||
|
disabled={!isValid || createMutation.isPending}
|
||||||
|
>
|
||||||
|
{createMutation.isPending ? 'Adding...' : 'Add to Collection'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</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;
|
||||||
|
|
||||||
|
.add-artifact-sidebar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-sections {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: spacing.$unit-3x;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-bottom: spacing.$unit-4x;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artifact-select {
|
||||||
|
padding: spacing.$unit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading,
|
||||||
|
.error,
|
||||||
|
.empty {
|
||||||
|
padding: spacing.$unit-2x;
|
||||||
|
text-align: center;
|
||||||
|
color: colors.$grey-50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: colors.$error;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skills-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: spacing.$unit;
|
||||||
|
padding: 0 spacing.$unit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-footer {
|
||||||
|
display: flex;
|
||||||
|
gap: spacing.$unit-2x;
|
||||||
|
padding: spacing.$unit-2x;
|
||||||
|
border-top: 1px solid var(--border-secondary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
:global(button) {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
23
src/lib/features/collection/openAddArtifactSidebar.ts
Normal file
23
src/lib/features/collection/openAddArtifactSidebar.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { sidebar } from '$lib/stores/sidebar.svelte'
|
||||||
|
import AddArtifactSidebar from '$lib/components/sidebar/AddArtifactSidebar.svelte'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the Add Artifact sidebar for adding a new artifact to the collection.
|
||||||
|
*
|
||||||
|
* Uses the pane stack pattern - the root pane allows artifact selection and
|
||||||
|
* configuration, with skill modifier selection pushing additional panes.
|
||||||
|
*/
|
||||||
|
export function openAddArtifactSidebar(options?: { onSuccess?: () => void }) {
|
||||||
|
const handleClose = () => {
|
||||||
|
sidebar.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
sidebar.openWithComponent('Add Artifact', AddArtifactSidebar, {
|
||||||
|
onSuccess: options?.onSuccess,
|
||||||
|
onClose: handleClose
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closeAddArtifactSidebar() {
|
||||||
|
sidebar.close()
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
import Button from '$lib/components/ui/Button.svelte'
|
import Button from '$lib/components/ui/Button.svelte'
|
||||||
import Icon from '$lib/components/Icon.svelte'
|
import Icon from '$lib/components/Icon.svelte'
|
||||||
import AddToCollectionModal from '$lib/components/collection/AddToCollectionModal.svelte'
|
import AddToCollectionModal from '$lib/components/collection/AddToCollectionModal.svelte'
|
||||||
|
import { openAddArtifactSidebar } from '$lib/features/collection/openAddArtifactSidebar'
|
||||||
|
|
||||||
let { data, children }: { data: LayoutData; children: any } = $props()
|
let { data, children }: { data: LayoutData; children: any } = $props()
|
||||||
|
|
||||||
|
|
@ -26,13 +27,16 @@
|
||||||
const modalEntityType = $derived.by((): 'character' | 'weapon' | 'summon' | undefined => {
|
const modalEntityType = $derived.by((): 'character' | 'weapon' | 'summon' | undefined => {
|
||||||
if (activeEntityType === 'weapons') return 'weapon'
|
if (activeEntityType === 'weapons') return 'weapon'
|
||||||
if (activeEntityType === 'summons') return 'summon'
|
if (activeEntityType === 'summons') return 'summon'
|
||||||
if (activeEntityType === 'artifacts') return undefined // Artifacts use different flow
|
if (activeEntityType === 'artifacts') return undefined // Artifacts use sidebar instead
|
||||||
return 'character'
|
return 'character'
|
||||||
})
|
})
|
||||||
|
|
||||||
// Whether the current entity type supports the add modal
|
// Whether the current entity type supports the add modal (all except artifacts)
|
||||||
const supportsAddModal = $derived(activeEntityType !== 'artifacts')
|
const supportsAddModal = $derived(activeEntityType !== 'artifacts')
|
||||||
|
|
||||||
|
// Whether to show the add button for artifacts (uses sidebar instead of modal)
|
||||||
|
const isArtifacts = $derived(activeEntityType === 'artifacts')
|
||||||
|
|
||||||
// Dynamic button text
|
// Dynamic button text
|
||||||
const addButtonText = $derived(`Add ${activeEntityType}`)
|
const addButtonText = $derived(`Add ${activeEntityType}`)
|
||||||
|
|
||||||
|
|
@ -41,6 +45,10 @@
|
||||||
function handleTabChange(value: string) {
|
function handleTabChange(value: string) {
|
||||||
goto(`/${username}/collection/${value}`)
|
goto(`/${username}/collection/${value}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleAddArtifact() {
|
||||||
|
openAddArtifactSidebar()
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
|
|
@ -80,6 +88,16 @@
|
||||||
>
|
>
|
||||||
{addButtonText}
|
{addButtonText}
|
||||||
</Button>
|
</Button>
|
||||||
|
{:else if data.isOwner && isArtifacts}
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="small"
|
||||||
|
onclick={handleAddArtifact}
|
||||||
|
icon="plus"
|
||||||
|
iconPosition="left"
|
||||||
|
>
|
||||||
|
Add artifact
|
||||||
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue