fix job skill types and slot styling

- update JobSkill type with emp/base boolean flags
- use new skill fields in jobUtils and components
- update job adapter with locale and filter params
- restyle empty skill slots with cleaner placeholder
- simplify ML badge to not show level number
This commit is contained in:
Justin Edmund 2025-11-28 11:04:16 -08:00
parent 0379cff81e
commit 5403aebe48
7 changed files with 138 additions and 52 deletions

View file

@ -21,6 +21,8 @@ export interface SearchJobSkillsParams {
jobId: string // Required for API
page?: number
per?: number
locale?: string
filters?: { group?: number }
}
/**
@ -114,12 +116,13 @@ export class JobAdapter extends BaseAdapter {
body: {
search: {
query: params.query || '',
job: params.jobId
},
page: params.page || 1,
per: params.per || 50
},
cacheTTL: 60000 // Cache for 1 minute
job: params.jobId,
page: params.page || 1,
locale: params.locale || 'en',
filters: params.filters || {}
}
}
// Note: No caching - filters change frequently and cache key bug causes stale data
})
// Transform the response to match the expected format

View file

@ -93,7 +93,7 @@
{#if job.masterLevel || job.ultimateMastery}
<div class="job-badges">
{#if job.masterLevel}
<span class="badge master">ML{job.masterLevel}</span>
<span class="badge master">ML</span>
{/if}
{#if job.ultimateMastery}
<span class="badge ultimate">UM</span>
@ -110,7 +110,7 @@
{#if job.masterLevel || job.ultimateMastery}
<div class="job-badges">
{#if job.masterLevel}
<span class="badge master">ML{job.masterLevel}</span>
<span class="badge master">ML</span>
{/if}
{#if job.ultimateMastery}
<span class="badge ultimate">UM</span>

View file

@ -23,9 +23,8 @@
function getSkillColorClass(skill: JobSkill): string {
if (skill.main) return 'skill-main'
if (skill.sub) return 'skill-sub'
// Use category for additional classification
if (skill.category === 2) return 'skill-emp'
if (skill.category === 1) return 'skill-base'
if (skill.emp) return 'skill-emp'
if (skill.base) return 'skill-base'
return ''
}
</script>

View file

@ -112,8 +112,10 @@
{#snippet EmptyState({ slot })}
<div class="empty-content">
<Icon name="plus" size={20} />
<span>Slot {slot + 1}</span>
<div class="placeholder-icon">
<Icon name="plus" size={16} />
</div>
<span class="placeholder-text">Select a skill</span>
</div>
{/snippet}
@ -154,12 +156,19 @@
}
&.empty {
border-style: dashed;
background: var(--placeholder-bg);
background: transparent;
&.editable:hover {
background: var(--button-contained-bg-hover);
border-style: solid;
background: var(--button-bg-hover);
.placeholder-icon {
border-color: var(--border-medium);
box-shadow: var(--hover-shadow);
}
.placeholder-text {
color: var(--text-tertiary-hover);
}
}
}
@ -222,16 +231,30 @@
.empty-content {
display: flex;
flex-direction: column;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 4px;
gap: $unit;
padding: $unit;
color: var(--text-tertiary);
height: 60px;
height: 100%;
span {
font-size: 12px;
.placeholder-icon {
width: 32px;
height: 32px;
background: var(--card-bg);
border: 1px solid transparent;
border-radius: $unit-half;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: var(--icon-secondary);
transition: all 0.15s ease;
}
.placeholder-text {
font-size: 14px;
color: var(--text-tertiary);
transition: color 0.15s ease;
}
}

View file

@ -8,6 +8,7 @@
import JobSkillItem from '../job/JobSkillItem.svelte'
import Button from '../ui/Button.svelte'
import Input from '../ui/Input.svelte'
import Select from '../ui/Select.svelte'
import Icon from '../Icon.svelte'
import * as m from '$lib/paraglide/messages'
@ -27,13 +28,28 @@
onRemoveSkill
}: Props = $props()
// Skill category filter options
// Values match Rails search_controller expectations:
// -1: All, 0-3: Color categories, 4: EMP, 5: Base
const skillCategoryOptions = [
{ value: -1, label: 'All Skills' },
{ value: 2, label: 'Damaging' },
{ value: 0, label: 'Buffing' },
{ value: 1, label: 'Debuffing' },
{ value: 3, label: 'Healing' },
{ value: 4, label: 'EMP' },
{ value: 5, label: 'Base' }
]
// State
let searchQuery = $state('')
let skillCategory = $state(-1) // -1 = All
let error = $state<string | undefined>()
let sentinelEl = $state<HTMLElement>()
let skillsResource = $state<ReturnType<typeof createInfiniteScrollResource<JobSkill>> | null>(null)
let lastSearchQuery = ''
let lastJobId: string | undefined
let lastCategory = -1
// Check if slot is locked
const slotLocked = $derived(targetSlot === 0)
@ -44,9 +60,10 @@
// Manage resource creation and search updates
let debounceTimer: ReturnType<typeof setTimeout> | undefined
function updateSearch() {
function updateSearch(forceRebuild = false) {
const jobId = job?.id
const locked = slotLocked
const category = skillCategory
// Clean up if no job or locked
if (!jobId || locked) {
@ -56,21 +73,29 @@
}
lastJobId = undefined
lastSearchQuery = ''
lastCategory = -1
return
}
// Create new resource if job changed
if (jobId !== lastJobId) {
// Rebuild resource if job or category changed
const needsRebuild = forceRebuild || jobId !== lastJobId || category !== lastCategory
if (needsRebuild) {
if (skillsResource) {
skillsResource.destroy()
}
// Capture current values for the closure
const currentQuery = searchQuery
const currentCategory = category
const resource = createInfiniteScrollResource<JobSkill>({
fetcher: async (page) => {
const response = await jobAdapter.searchSkills({
query: lastSearchQuery,
query: currentQuery,
jobId,
page
page,
...(currentCategory >= 0 ? { filters: { group: currentCategory } } : {})
})
return response
},
@ -81,19 +106,21 @@
skillsResource = resource
lastJobId = jobId
lastSearchQuery = searchQuery
lastCategory = category
resource.load()
}
// Reload if search query changed
// Reload if only search query changed
else if (searchQuery !== lastSearchQuery && skillsResource) {
lastSearchQuery = searchQuery
skillsResource.reset()
skillsResource.load()
// Need to rebuild to capture new query in closure
updateSearch(true)
}
}
// Watch for job changes
// Watch for job and category changes
$effect(() => {
job // Track job
skillCategory // Track category
updateSearch()
})
@ -198,6 +225,16 @@
disabled={!canSearch}
fullWidth={true}
/>
<div class="filter-row">
<Select
options={skillCategoryOptions}
bind:value={skillCategory}
placeholder="Filter by category"
disabled={!canSearch}
size="small"
fullWidth={true}
/>
</div>
</div>
<div class="skills-container">
@ -235,10 +272,19 @@
<div class="empty-state">
<Icon name="search-x" size={32} />
<p>No skills found</p>
{#if searchQuery}
<Button size="small" variant="ghost" on:click={() => searchQuery = ''}>
Clear search
</Button>
{#if searchQuery || skillCategory >= 0}
<div class="clear-filters">
{#if searchQuery}
<Button size="small" variant="ghost" on:click={() => searchQuery = ''}>
Clear search
</Button>
{/if}
{#if skillCategory >= 0}
<Button size="small" variant="ghost" on:click={() => skillCategory = -1}>
Clear filter
</Button>
{/if}
</div>
{/if}
</div>
{/if}
@ -341,11 +387,19 @@
}
.search-section {
display: flex;
flex-direction: column;
gap: $unit;
padding: $unit-2x 0;
border-bottom: 1px solid var(--border-subtle);
flex-shrink: 0;
}
.filter-row {
display: flex;
gap: $unit;
}
.skills-container {
flex: 1;
overflow-y: auto;
@ -372,6 +426,12 @@
margin: 0;
font-size: 14px;
}
.clear-filters {
display: flex;
gap: $unit;
margin-top: $unit;
}
}
.loading-state :global(svg) {

View file

@ -91,20 +91,24 @@ export interface Job {
row: number
order: number
proficiency: [number, number]
masterLevel?: number
ultimateMastery?: number
masterLevel?: boolean // Whether this job supports master level
ultimateMastery?: boolean // Whether this job supports ultimate mastery
accessory?: boolean
accessoryType?: number
}
// JobSkill entity
// JobSkill entity from JobSkillBlueprint
export interface JobSkill {
id: string
name: LocalizedName
slug: string
category: number
main: boolean
sub: boolean
color: number // Skill category (0-3 for colors, relates to skill type)
main: boolean // Primary job skill
sub: boolean // Sub-skill (transferable)
emp: boolean // EMP skill
base: boolean // Base skill (for advanced jobs)
order: number // Display order
job: Job // Associated job
}
// JobAccessory entity

View file

@ -154,9 +154,8 @@ export function isSkillSlotLocked(
export function getSkillCategoryName(skill: JobSkill): string {
if (skill.main) return 'Main'
if (skill.sub) return 'Subskill'
// Use category field for additional classification
if (skill.category === 2) return 'EMP'
if (skill.category === 1) return 'Base'
if (skill.emp) return 'EMP'
if (skill.base) return 'Base'
return 'Unknown'
}
@ -167,9 +166,8 @@ export function getSkillCategoryName(skill: JobSkill): string {
export function getSkillCategoryColor(skill: JobSkill): string {
if (skill.main) return 'var(--skill-main)'
if (skill.sub) return 'var(--skill-sub)'
// Use category field for additional classification
if (skill.category === 2) return 'var(--skill-emp)'
if (skill.category === 1) return 'var(--skill-base)'
if (skill.emp) return 'var(--skill-emp)'
if (skill.base) return 'var(--skill-base)'
return 'var(--skill-default)'
}
@ -226,9 +224,8 @@ export function countSkillsByType(skills: JobSkillList): {
if (skill) {
if (skill.main) counts.main++
else if (skill.sub) counts.sub++
// Use category field for additional classification
else if (skill.category === 2) counts.emp++
else if (skill.category === 1) counts.base++
else if (skill.emp) counts.emp++
else if (skill.base) counts.base++
}
}