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

View file

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

View file

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

View file

@ -112,8 +112,10 @@
{#snippet EmptyState({ slot })} {#snippet EmptyState({ slot })}
<div class="empty-content"> <div class="empty-content">
<Icon name="plus" size={20} /> <div class="placeholder-icon">
<span>Slot {slot + 1}</span> <Icon name="plus" size={16} />
</div>
<span class="placeholder-text">Select a skill</span>
</div> </div>
{/snippet} {/snippet}
@ -154,12 +156,19 @@
} }
&.empty { &.empty {
border-style: dashed; background: transparent;
background: var(--placeholder-bg);
&.editable:hover { &.editable:hover {
background: var(--button-contained-bg-hover); background: var(--button-bg-hover);
border-style: solid;
.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 { .empty-content {
display: flex; display: flex;
flex-direction: column; flex-direction: row;
align-items: center; align-items: center;
justify-content: center; gap: $unit;
gap: 4px;
padding: $unit; padding: $unit;
color: var(--text-tertiary); height: 100%;
height: 60px;
span { .placeholder-icon {
font-size: 12px; 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 JobSkillItem from '../job/JobSkillItem.svelte'
import Button from '../ui/Button.svelte' import Button from '../ui/Button.svelte'
import Input from '../ui/Input.svelte' import Input from '../ui/Input.svelte'
import Select from '../ui/Select.svelte'
import Icon from '../Icon.svelte' import Icon from '../Icon.svelte'
import * as m from '$lib/paraglide/messages' import * as m from '$lib/paraglide/messages'
@ -27,13 +28,28 @@
onRemoveSkill onRemoveSkill
}: Props = $props() }: 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 // State
let searchQuery = $state('') let searchQuery = $state('')
let skillCategory = $state(-1) // -1 = All
let error = $state<string | undefined>() let error = $state<string | undefined>()
let sentinelEl = $state<HTMLElement>() let sentinelEl = $state<HTMLElement>()
let skillsResource = $state<ReturnType<typeof createInfiniteScrollResource<JobSkill>> | null>(null) let skillsResource = $state<ReturnType<typeof createInfiniteScrollResource<JobSkill>> | null>(null)
let lastSearchQuery = '' let lastSearchQuery = ''
let lastJobId: string | undefined let lastJobId: string | undefined
let lastCategory = -1
// Check if slot is locked // Check if slot is locked
const slotLocked = $derived(targetSlot === 0) const slotLocked = $derived(targetSlot === 0)
@ -44,9 +60,10 @@
// Manage resource creation and search updates // Manage resource creation and search updates
let debounceTimer: ReturnType<typeof setTimeout> | undefined let debounceTimer: ReturnType<typeof setTimeout> | undefined
function updateSearch() { function updateSearch(forceRebuild = false) {
const jobId = job?.id const jobId = job?.id
const locked = slotLocked const locked = slotLocked
const category = skillCategory
// Clean up if no job or locked // Clean up if no job or locked
if (!jobId || locked) { if (!jobId || locked) {
@ -56,21 +73,29 @@
} }
lastJobId = undefined lastJobId = undefined
lastSearchQuery = '' lastSearchQuery = ''
lastCategory = -1
return return
} }
// Create new resource if job changed // Rebuild resource if job or category changed
if (jobId !== lastJobId) { const needsRebuild = forceRebuild || jobId !== lastJobId || category !== lastCategory
if (needsRebuild) {
if (skillsResource) { if (skillsResource) {
skillsResource.destroy() skillsResource.destroy()
} }
// Capture current values for the closure
const currentQuery = searchQuery
const currentCategory = category
const resource = createInfiniteScrollResource<JobSkill>({ const resource = createInfiniteScrollResource<JobSkill>({
fetcher: async (page) => { fetcher: async (page) => {
const response = await jobAdapter.searchSkills({ const response = await jobAdapter.searchSkills({
query: lastSearchQuery, query: currentQuery,
jobId, jobId,
page page,
...(currentCategory >= 0 ? { filters: { group: currentCategory } } : {})
}) })
return response return response
}, },
@ -81,19 +106,21 @@
skillsResource = resource skillsResource = resource
lastJobId = jobId lastJobId = jobId
lastSearchQuery = searchQuery lastSearchQuery = searchQuery
lastCategory = category
resource.load() resource.load()
} }
// Reload if search query changed // Reload if only search query changed
else if (searchQuery !== lastSearchQuery && skillsResource) { else if (searchQuery !== lastSearchQuery && skillsResource) {
lastSearchQuery = searchQuery lastSearchQuery = searchQuery
skillsResource.reset() // Need to rebuild to capture new query in closure
skillsResource.load() updateSearch(true)
} }
} }
// Watch for job changes // Watch for job and category changes
$effect(() => { $effect(() => {
job // Track job job // Track job
skillCategory // Track category
updateSearch() updateSearch()
}) })
@ -198,6 +225,16 @@
disabled={!canSearch} disabled={!canSearch}
fullWidth={true} 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>
<div class="skills-container"> <div class="skills-container">
@ -235,10 +272,19 @@
<div class="empty-state"> <div class="empty-state">
<Icon name="search-x" size={32} /> <Icon name="search-x" size={32} />
<p>No skills found</p> <p>No skills found</p>
{#if searchQuery} {#if searchQuery || skillCategory >= 0}
<Button size="small" variant="ghost" on:click={() => searchQuery = ''}> <div class="clear-filters">
Clear search {#if searchQuery}
</Button> <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} {/if}
</div> </div>
{/if} {/if}
@ -341,11 +387,19 @@
} }
.search-section { .search-section {
display: flex;
flex-direction: column;
gap: $unit;
padding: $unit-2x 0; padding: $unit-2x 0;
border-bottom: 1px solid var(--border-subtle); border-bottom: 1px solid var(--border-subtle);
flex-shrink: 0; flex-shrink: 0;
} }
.filter-row {
display: flex;
gap: $unit;
}
.skills-container { .skills-container {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
@ -372,6 +426,12 @@
margin: 0; margin: 0;
font-size: 14px; font-size: 14px;
} }
.clear-filters {
display: flex;
gap: $unit;
margin-top: $unit;
}
} }
.loading-state :global(svg) { .loading-state :global(svg) {

View file

@ -91,20 +91,24 @@ export interface Job {
row: number row: number
order: number order: number
proficiency: [number, number] proficiency: [number, number]
masterLevel?: number masterLevel?: boolean // Whether this job supports master level
ultimateMastery?: number ultimateMastery?: boolean // Whether this job supports ultimate mastery
accessory?: boolean accessory?: boolean
accessoryType?: number accessoryType?: number
} }
// JobSkill entity // JobSkill entity from JobSkillBlueprint
export interface JobSkill { export interface JobSkill {
id: string id: string
name: LocalizedName name: LocalizedName
slug: string slug: string
category: number color: number // Skill category (0-3 for colors, relates to skill type)
main: boolean main: boolean // Primary job skill
sub: boolean 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 // JobAccessory entity

View file

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