add job system
This commit is contained in:
parent
9537c57485
commit
4161a615ba
14 changed files with 3583 additions and 1 deletions
220
src/lib/api/adapters/job.adapter.ts
Normal file
220
src/lib/api/adapters/job.adapter.ts
Normal file
|
|
@ -0,0 +1,220 @@
|
||||||
|
/**
|
||||||
|
* Job Adapter
|
||||||
|
*
|
||||||
|
* Handles all job-related API operations including fetching jobs, skills, and accessories.
|
||||||
|
* Provides a clean interface for job management with automatic
|
||||||
|
* request handling, caching, and error management.
|
||||||
|
*
|
||||||
|
* @module adapters/job
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { BaseAdapter } from './base.adapter'
|
||||||
|
import type { AdapterOptions } from './types'
|
||||||
|
import { DEFAULT_ADAPTER_CONFIG } from './config'
|
||||||
|
import type { Job, JobSkill, JobAccessory } from '$lib/types/api/entities'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parameters for searching job skills
|
||||||
|
*/
|
||||||
|
export interface SearchJobSkillsParams {
|
||||||
|
query?: string
|
||||||
|
jobId: string // Required for API
|
||||||
|
page?: number
|
||||||
|
per?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Job skill search response
|
||||||
|
*/
|
||||||
|
export interface JobSkillSearchResponse {
|
||||||
|
results: JobSkill[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
totalPages: number
|
||||||
|
meta?: {
|
||||||
|
count: number
|
||||||
|
page: number
|
||||||
|
perPage: number
|
||||||
|
totalPages: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Job adapter for managing jobs and their related data
|
||||||
|
*/
|
||||||
|
export class JobAdapter extends BaseAdapter {
|
||||||
|
constructor(options?: AdapterOptions) {
|
||||||
|
super(options)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets all available jobs
|
||||||
|
* Jobs are returned as a flat array from the API
|
||||||
|
*/
|
||||||
|
async getAll(): Promise<Job[]> {
|
||||||
|
const response = await this.request<Job[]>('/jobs', {
|
||||||
|
method: 'GET',
|
||||||
|
cacheTTL: 300000 // Cache for 5 minutes - jobs don't change often
|
||||||
|
})
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets a single job by ID
|
||||||
|
*/
|
||||||
|
async getById(id: string): Promise<Job> {
|
||||||
|
const response = await this.request<{ job: Job }>(`/jobs/${id}`, {
|
||||||
|
method: 'GET',
|
||||||
|
cacheTTL: 300000 // Cache for 5 minutes
|
||||||
|
})
|
||||||
|
return response.job
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets all skills for a specific job
|
||||||
|
* Returns skills categorized by type (main, sub, emp, base)
|
||||||
|
*/
|
||||||
|
async getSkills(jobId: string): Promise<JobSkill[]> {
|
||||||
|
const response = await this.request<{ skills: JobSkill[] }>(`/jobs/${jobId}/skills`, {
|
||||||
|
method: 'GET',
|
||||||
|
cacheTTL: 300000 // Cache for 5 minutes
|
||||||
|
})
|
||||||
|
return response.skills
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets all accessories available for a specific job
|
||||||
|
* Only returns data if the job supports accessories
|
||||||
|
*/
|
||||||
|
async getAccessories(jobId: string): Promise<JobAccessory[]> {
|
||||||
|
const response = await this.request<{ accessories: JobAccessory[] }>(
|
||||||
|
`/jobs/${jobId}/accessories`,
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
cacheTTL: 300000 // Cache for 5 minutes
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return response.accessories
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Searches for job skills based on query and filters
|
||||||
|
* Used for the skill selection interface with pagination
|
||||||
|
*/
|
||||||
|
async searchSkills(params: SearchJobSkillsParams): Promise<JobSkillSearchResponse> {
|
||||||
|
const response = await this.request<{
|
||||||
|
results: JobSkill[]
|
||||||
|
meta?: {
|
||||||
|
count?: number
|
||||||
|
total_pages?: number
|
||||||
|
per_page?: number
|
||||||
|
}
|
||||||
|
}>('/search/job_skills', {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
search: {
|
||||||
|
query: params.query || '',
|
||||||
|
job: params.jobId
|
||||||
|
},
|
||||||
|
page: params.page || 1,
|
||||||
|
per: params.per || 50
|
||||||
|
},
|
||||||
|
cacheTTL: 60000 // Cache for 1 minute
|
||||||
|
})
|
||||||
|
|
||||||
|
// Transform the response to match the expected format
|
||||||
|
return {
|
||||||
|
results: response.results,
|
||||||
|
page: params.page || 1,
|
||||||
|
total: response.meta?.count || 0,
|
||||||
|
totalPages: response.meta?.total_pages || 1,
|
||||||
|
meta: response.meta
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets all available job skills (not filtered by job)
|
||||||
|
* Useful for browsing all skills
|
||||||
|
*/
|
||||||
|
async getAllSkills(): Promise<JobSkill[]> {
|
||||||
|
const response = await this.request<{ skills: JobSkill[] }>('/jobs/skills', {
|
||||||
|
method: 'GET',
|
||||||
|
cacheTTL: 300000 // Cache for 5 minutes
|
||||||
|
})
|
||||||
|
return response.skills
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the job for a party
|
||||||
|
* @param partyId The party's ID (UUID)
|
||||||
|
* @param jobId The job ID to set
|
||||||
|
*/
|
||||||
|
async updatePartyJob(partyId: string, jobId: string): Promise<void> {
|
||||||
|
await this.request(`/parties/${partyId}/jobs`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: {
|
||||||
|
job_id: jobId
|
||||||
|
}
|
||||||
|
})
|
||||||
|
// Clear party cache to reflect the change
|
||||||
|
this.clearCache(`/parties/${partyId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates job skills for a party
|
||||||
|
* @param partyId The party's ID (UUID)
|
||||||
|
* @param skills Array of skill assignments with slot positions
|
||||||
|
*/
|
||||||
|
async updatePartyJobSkills(
|
||||||
|
partyId: string,
|
||||||
|
skills: Array<{ id: string; slot: number }>
|
||||||
|
): Promise<void> {
|
||||||
|
// Convert skills array to Rails expected format
|
||||||
|
const party: Record<string, string | null> = {}
|
||||||
|
|
||||||
|
// Initialize all slots with null
|
||||||
|
for (let i = 1; i <= 4; i++) {
|
||||||
|
party[`skill${i}_id`] = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the provided skills
|
||||||
|
skills.forEach(skill => {
|
||||||
|
// Rails expects skill1_id, skill2_id, skill3_id, skill4_id
|
||||||
|
party[`skill${skill.slot + 1}_id`] = skill.id
|
||||||
|
})
|
||||||
|
|
||||||
|
await this.request(`/parties/${partyId}/job_skills`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: { party }
|
||||||
|
})
|
||||||
|
// Clear party cache to reflect the change
|
||||||
|
this.clearCache(`/parties/${partyId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes a job skill from a party
|
||||||
|
* @param partyId The party's ID (UUID)
|
||||||
|
* @param slot The skill slot to clear (0-3)
|
||||||
|
*/
|
||||||
|
async removePartyJobSkill(partyId: string, slot: number): Promise<void> {
|
||||||
|
await this.request(`/parties/${partyId}/job_skills`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
body: { slot }
|
||||||
|
})
|
||||||
|
// Clear party cache to reflect the change
|
||||||
|
this.clearCache(`/parties/${partyId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears the cache for job-related data
|
||||||
|
*/
|
||||||
|
clearJobCache() {
|
||||||
|
this.clearCache('/jobs')
|
||||||
|
this.clearCache('/search/job_skills')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default job adapter instance
|
||||||
|
*/
|
||||||
|
export const jobAdapter = new JobAdapter(DEFAULT_ADAPTER_CONFIG)
|
||||||
370
src/lib/api/adapters/resources/job.resource.svelte.ts
Normal file
370
src/lib/api/adapters/resources/job.resource.svelte.ts
Normal file
|
|
@ -0,0 +1,370 @@
|
||||||
|
/**
|
||||||
|
* Reactive Job Resource using Svelte 5 Runes and Runed
|
||||||
|
*
|
||||||
|
* Provides reactive state management for job-related operations with
|
||||||
|
* automatic loading states, error handling, and caching.
|
||||||
|
*
|
||||||
|
* @module adapters/resources/job
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { JobAdapter, jobAdapter } from '../job.adapter'
|
||||||
|
import type { Job, JobSkill, JobAccessory } from '$lib/types/api/entities'
|
||||||
|
import type { AdapterError, AdapterOptions } from '../types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Job resource configuration options
|
||||||
|
*/
|
||||||
|
export interface JobResourceOptions {
|
||||||
|
/** Job adapter instance to use */
|
||||||
|
adapter?: JobAdapter
|
||||||
|
/** Cache duration in milliseconds */
|
||||||
|
cacheDuration?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* State for job data
|
||||||
|
*/
|
||||||
|
interface JobState<T> {
|
||||||
|
data?: T
|
||||||
|
loading: boolean
|
||||||
|
error?: AdapterError
|
||||||
|
lastFetch?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a reactive job resource for job data management
|
||||||
|
* This is a Svelte 5 universal reactive state (works in both components and modules)
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```svelte
|
||||||
|
* <script>
|
||||||
|
* import { createJobResource } from '$lib/api/adapters/resources/job.resource.svelte'
|
||||||
|
*
|
||||||
|
* const jobResource = createJobResource()
|
||||||
|
*
|
||||||
|
* // Fetch all jobs
|
||||||
|
* $effect(() => {
|
||||||
|
* jobResource.fetchJobs()
|
||||||
|
* })
|
||||||
|
* </script>
|
||||||
|
*
|
||||||
|
* {#if jobResource.jobs.loading}
|
||||||
|
* <p>Loading jobs...</p>
|
||||||
|
* {:else if jobResource.jobs.error}
|
||||||
|
* <p>Error: {jobResource.jobs.error.message}</p>
|
||||||
|
* {:else if jobResource.jobs.data}
|
||||||
|
* {#each jobResource.jobs.data as job}
|
||||||
|
* <div>{job.name.en}</div>
|
||||||
|
* {/each}
|
||||||
|
* {/if}
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export class JobResource {
|
||||||
|
// Private adapter instance
|
||||||
|
private adapter: JobAdapter
|
||||||
|
private cacheDuration: number
|
||||||
|
|
||||||
|
// Reactive state for job data
|
||||||
|
jobs = $state<JobState<Job[]>>({ loading: false })
|
||||||
|
currentJob = $state<JobState<Job>>({ loading: false })
|
||||||
|
jobSkills = $state<JobState<JobSkill[]>>({ loading: false })
|
||||||
|
jobAccessories = $state<JobState<JobAccessory[]>>({ loading: false })
|
||||||
|
allSkills = $state<JobState<JobSkill[]>>({ loading: false })
|
||||||
|
|
||||||
|
// Track active requests
|
||||||
|
private activeRequests = new Map<string, AbortController>()
|
||||||
|
|
||||||
|
constructor(options: JobResourceOptions = {}) {
|
||||||
|
this.adapter = options.adapter || jobAdapter
|
||||||
|
this.cacheDuration = options.cacheDuration || 5 * 60 * 1000 // 5 minutes default
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if cached data is still valid
|
||||||
|
*/
|
||||||
|
private isCacheValid(state: JobState<any>): boolean {
|
||||||
|
if (!state.data || !state.lastFetch) return false
|
||||||
|
return Date.now() - state.lastFetch < this.cacheDuration
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch all jobs
|
||||||
|
*/
|
||||||
|
async fetchJobs(force = false): Promise<Job[]> {
|
||||||
|
// Return cached data if valid and not forced
|
||||||
|
if (!force && this.isCacheValid(this.jobs) && this.jobs.data) {
|
||||||
|
return this.jobs.data
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel any existing request
|
||||||
|
this.cancelRequest('jobs')
|
||||||
|
|
||||||
|
// Create new abort controller
|
||||||
|
const controller = new AbortController()
|
||||||
|
this.activeRequests.set('jobs', controller)
|
||||||
|
|
||||||
|
// Update loading state
|
||||||
|
this.jobs = { ...this.jobs, loading: true, error: undefined }
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await this.adapter.getAll()
|
||||||
|
this.jobs = { data, loading: false, lastFetch: Date.now() }
|
||||||
|
return data
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.code !== 'CANCELLED') {
|
||||||
|
this.jobs = {
|
||||||
|
...this.jobs,
|
||||||
|
loading: false,
|
||||||
|
error: error as AdapterError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
this.activeRequests.delete('jobs')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a single job by ID
|
||||||
|
*/
|
||||||
|
async fetchJob(id: string, force = false): Promise<Job> {
|
||||||
|
// Check if this job is already loaded
|
||||||
|
if (!force && this.currentJob.data?.id === id && this.isCacheValid(this.currentJob)) {
|
||||||
|
return this.currentJob.data
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel any existing request
|
||||||
|
this.cancelRequest('currentJob')
|
||||||
|
|
||||||
|
// Create new abort controller
|
||||||
|
const controller = new AbortController()
|
||||||
|
this.activeRequests.set('currentJob', controller)
|
||||||
|
|
||||||
|
// Update loading state
|
||||||
|
this.currentJob = { ...this.currentJob, loading: true, error: undefined }
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await this.adapter.getById(id)
|
||||||
|
this.currentJob = { data, loading: false, lastFetch: Date.now() }
|
||||||
|
return data
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.code !== 'CANCELLED') {
|
||||||
|
this.currentJob = {
|
||||||
|
...this.currentJob,
|
||||||
|
loading: false,
|
||||||
|
error: error as AdapterError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
this.activeRequests.delete('currentJob')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch skills for a specific job
|
||||||
|
*/
|
||||||
|
async fetchJobSkills(jobId: string, force = false): Promise<JobSkill[]> {
|
||||||
|
// Cancel any existing request
|
||||||
|
this.cancelRequest('jobSkills')
|
||||||
|
|
||||||
|
// Create new abort controller
|
||||||
|
const controller = new AbortController()
|
||||||
|
this.activeRequests.set('jobSkills', controller)
|
||||||
|
|
||||||
|
// Update loading state
|
||||||
|
this.jobSkills = { ...this.jobSkills, loading: true, error: undefined }
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await this.adapter.getSkills(jobId)
|
||||||
|
this.jobSkills = { data, loading: false, lastFetch: Date.now() }
|
||||||
|
return data
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.code !== 'CANCELLED') {
|
||||||
|
this.jobSkills = {
|
||||||
|
...this.jobSkills,
|
||||||
|
loading: false,
|
||||||
|
error: error as AdapterError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
this.activeRequests.delete('jobSkills')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch accessories for a specific job
|
||||||
|
*/
|
||||||
|
async fetchJobAccessories(jobId: string, force = false): Promise<JobAccessory[]> {
|
||||||
|
// Cancel any existing request
|
||||||
|
this.cancelRequest('jobAccessories')
|
||||||
|
|
||||||
|
// Create new abort controller
|
||||||
|
const controller = new AbortController()
|
||||||
|
this.activeRequests.set('jobAccessories', controller)
|
||||||
|
|
||||||
|
// Update loading state
|
||||||
|
this.jobAccessories = { ...this.jobAccessories, loading: true, error: undefined }
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await this.adapter.getAccessories(jobId)
|
||||||
|
this.jobAccessories = { data, loading: false, lastFetch: Date.now() }
|
||||||
|
return data
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.code !== 'CANCELLED') {
|
||||||
|
this.jobAccessories = {
|
||||||
|
...this.jobAccessories,
|
||||||
|
loading: false,
|
||||||
|
error: error as AdapterError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
this.activeRequests.delete('jobAccessories')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch all available job skills
|
||||||
|
*/
|
||||||
|
async fetchAllSkills(force = false): Promise<JobSkill[]> {
|
||||||
|
// Return cached data if valid and not forced
|
||||||
|
if (!force && this.isCacheValid(this.allSkills) && this.allSkills.data) {
|
||||||
|
return this.allSkills.data
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel any existing request
|
||||||
|
this.cancelRequest('allSkills')
|
||||||
|
|
||||||
|
// Create new abort controller
|
||||||
|
const controller = new AbortController()
|
||||||
|
this.activeRequests.set('allSkills', controller)
|
||||||
|
|
||||||
|
// Update loading state
|
||||||
|
this.allSkills = { ...this.allSkills, loading: true, error: undefined }
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await this.adapter.getAllSkills()
|
||||||
|
this.allSkills = { data, loading: false, lastFetch: Date.now() }
|
||||||
|
return data
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.code !== 'CANCELLED') {
|
||||||
|
this.allSkills = {
|
||||||
|
...this.allSkills,
|
||||||
|
loading: false,
|
||||||
|
error: error as AdapterError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
this.activeRequests.delete('allSkills')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update party job
|
||||||
|
*/
|
||||||
|
async updatePartyJob(partyId: string, jobId: string): Promise<void> {
|
||||||
|
await this.adapter.updatePartyJob(partyId, jobId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update party job skills
|
||||||
|
*/
|
||||||
|
async updatePartyJobSkills(
|
||||||
|
partyId: string,
|
||||||
|
skills: Array<{ id: string; slot: number }>
|
||||||
|
): Promise<void> {
|
||||||
|
await this.adapter.updatePartyJobSkills(partyId, skills)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove party job skill
|
||||||
|
*/
|
||||||
|
async removePartyJobSkill(partyId: string, slot: number): Promise<void> {
|
||||||
|
await this.adapter.removePartyJobSkill(partyId, slot)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel an active request
|
||||||
|
*/
|
||||||
|
private cancelRequest(key: string) {
|
||||||
|
const controller = this.activeRequests.get(key)
|
||||||
|
if (controller) {
|
||||||
|
controller.abort()
|
||||||
|
this.activeRequests.delete(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel all active requests
|
||||||
|
*/
|
||||||
|
cancelAll() {
|
||||||
|
this.activeRequests.forEach(controller => controller.abort())
|
||||||
|
this.activeRequests.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear cached data
|
||||||
|
*/
|
||||||
|
clearCache() {
|
||||||
|
this.jobs = { loading: false }
|
||||||
|
this.currentJob = { loading: false }
|
||||||
|
this.jobSkills = { loading: false }
|
||||||
|
this.jobAccessories = { loading: false }
|
||||||
|
this.allSkills = { loading: false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new job resource instance
|
||||||
|
*/
|
||||||
|
export function createJobResource(options?: JobResourceOptions): JobResource {
|
||||||
|
return new JobResource(options)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to group jobs by tier (row)
|
||||||
|
*/
|
||||||
|
export function groupJobsByTier(jobs: Job[]): Record<string, Job[]> {
|
||||||
|
const tiers: Record<string, Job[]> = {
|
||||||
|
'1': [],
|
||||||
|
'2': [],
|
||||||
|
'3': [],
|
||||||
|
'4': [],
|
||||||
|
'5': [],
|
||||||
|
'ex': [],
|
||||||
|
'ex2': []
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const job of jobs) {
|
||||||
|
const tier = job.row.toString().toLowerCase()
|
||||||
|
if (tier in tiers) {
|
||||||
|
tiers[tier].push(job)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort jobs within each tier by order
|
||||||
|
for (const tier in tiers) {
|
||||||
|
tiers[tier].sort((a, b) => a.order - b.order)
|
||||||
|
}
|
||||||
|
|
||||||
|
return tiers
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to get tier display name
|
||||||
|
*/
|
||||||
|
export function getTierDisplayName(tier: string): string {
|
||||||
|
const tierNames: Record<string, string> = {
|
||||||
|
'1': 'Class I',
|
||||||
|
'2': 'Class II',
|
||||||
|
'3': 'Class III',
|
||||||
|
'4': 'Class IV',
|
||||||
|
'5': 'Class V',
|
||||||
|
'ex': 'Extra',
|
||||||
|
'ex2': 'Extra II'
|
||||||
|
}
|
||||||
|
return tierNames[tier] || tier
|
||||||
|
}
|
||||||
150
src/lib/components/job/JobItem.svelte
Normal file
150
src/lib/components/job/JobItem.svelte
Normal file
|
|
@ -0,0 +1,150 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { Job } from '$lib/types/api/entities'
|
||||||
|
import { getJobIconUrl, formatJobProficiency } from '$lib/utils/jobUtils'
|
||||||
|
import ProficiencyLabel from '../labels/ProficiencyLabel.svelte'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
job: Job
|
||||||
|
selected?: boolean
|
||||||
|
onClick?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
let { job, selected = false, onClick }: Props = $props()
|
||||||
|
|
||||||
|
const proficiencies = $derived(formatJobProficiency(job.proficiency))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="job-item"
|
||||||
|
class:selected
|
||||||
|
on:click={onClick}
|
||||||
|
aria-pressed={selected}
|
||||||
|
aria-label="{job.name.en} - {selected ? 'Currently selected' : 'Click to select'}"
|
||||||
|
>
|
||||||
|
<img src={getJobIconUrl(job.granblueId)} alt={job.name.en} class="job-icon" loading="lazy" />
|
||||||
|
|
||||||
|
<div class="job-info">
|
||||||
|
<span class="job-name">{job.name.en}</span>
|
||||||
|
|
||||||
|
<div class="job-details">
|
||||||
|
{#if job.ultimateMastery}
|
||||||
|
<span class="badge ultimate">UM</span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if proficiencies.length > 0}
|
||||||
|
<div class="proficiencies">
|
||||||
|
{#each job.proficiency as prof}
|
||||||
|
{#if prof > 0}
|
||||||
|
<ProficiencyLabel proficiency={prof} size="small" />
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@use '$src/themes/spacing' as spacing;
|
||||||
|
@use '$src/themes/typography' as typography;
|
||||||
|
@use '$src/themes/layout' as layout;
|
||||||
|
|
||||||
|
.job-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: spacing.$unit;
|
||||||
|
padding: spacing.$unit-2x spacing.$unit;
|
||||||
|
background: var(--card-bg);
|
||||||
|
border-radius: layout.$card-corner;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
text-align: left;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--button-contained-bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.selected {
|
||||||
|
background: var(--primary-10);
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 3px;
|
||||||
|
background: var(--primary-50);
|
||||||
|
border-radius: layout.$item-corner 0 0 layout.$item-corner;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
.job-icon {
|
||||||
|
// Display at native size (job icons are typically 48x48px)
|
||||||
|
width: auto;
|
||||||
|
height: 24px;
|
||||||
|
max-width: 48px;
|
||||||
|
max-height: 48px;
|
||||||
|
border-radius: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-info {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
.job-name {
|
||||||
|
font-size: typography.$font-regular;
|
||||||
|
font-weight: typography.$medium;
|
||||||
|
color: var(--text-primary);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-details {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: spacing.$unit-half;
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: typography.$font-small;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
&.master {
|
||||||
|
background: var(--badge-master-bg, #ffd700);
|
||||||
|
color: var(--badge-master-text, #000);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.ultimate {
|
||||||
|
background: var(--badge-ultimate-bg, #9b59b6);
|
||||||
|
color: var(--badge-ultimate-text, #fff);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.proficiencies {
|
||||||
|
display: flex;
|
||||||
|
gap: spacing.$unit-half;
|
||||||
|
align-items: center;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
200
src/lib/components/job/JobPortrait.svelte
Normal file
200
src/lib/components/job/JobPortrait.svelte
Normal file
|
|
@ -0,0 +1,200 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { Job } from '$lib/types/api/entities'
|
||||||
|
import { getJobFullImageUrl, getJobIconUrl, Gender } from '$lib/utils/jobUtils'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
job?: Job
|
||||||
|
gender?: Gender
|
||||||
|
element?: number
|
||||||
|
showPlaceholder?: boolean
|
||||||
|
size?: 'small' | 'medium' | 'large'
|
||||||
|
clickable?: boolean
|
||||||
|
onclick?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
job,
|
||||||
|
gender = Gender.Gran,
|
||||||
|
element,
|
||||||
|
showPlaceholder = true,
|
||||||
|
size = 'medium',
|
||||||
|
clickable = false,
|
||||||
|
onclick
|
||||||
|
}: Props = $props()
|
||||||
|
|
||||||
|
// Use full image URL for JobSection component
|
||||||
|
const portraitUrl = $derived(getJobFullImageUrl(job, gender))
|
||||||
|
const iconUrl = $derived(job ? getJobIconUrl(job.granblueId) : '')
|
||||||
|
|
||||||
|
// Get element class for protagonist styling
|
||||||
|
const elementClass = $derived(
|
||||||
|
element !== undefined
|
||||||
|
? ['null', 'wind', 'fire', 'water', 'earth', 'light', 'dark'][element] || ''
|
||||||
|
: ''
|
||||||
|
)
|
||||||
|
|
||||||
|
function handleClick() {
|
||||||
|
if (clickable && onclick) {
|
||||||
|
onclick()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(event: KeyboardEvent) {
|
||||||
|
if (clickable && onclick && (event.key === 'Enter' || event.key === ' ')) {
|
||||||
|
event.preventDefault()
|
||||||
|
onclick()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="job-portrait {size} {elementClass}"
|
||||||
|
class:empty={!job && showPlaceholder}
|
||||||
|
class:clickable
|
||||||
|
role={clickable ? 'button' : undefined}
|
||||||
|
tabindex={clickable ? 0 : undefined}
|
||||||
|
on:click={handleClick}
|
||||||
|
on:keydown={handleKeydown}
|
||||||
|
>
|
||||||
|
{#if job}
|
||||||
|
<img src={portraitUrl} alt={job.name.en} class="portrait" loading="lazy" decoding="async" />
|
||||||
|
{#if size !== 'small'}
|
||||||
|
<img src={iconUrl} alt="{job.name.en} icon" class="icon" loading="lazy" decoding="async" />
|
||||||
|
{/if}
|
||||||
|
{:else if showPlaceholder}
|
||||||
|
<div class="placeholder">
|
||||||
|
<span>No Job</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@use '$src/themes/spacing' as *;
|
||||||
|
@use '$src/themes/layout' as *;
|
||||||
|
|
||||||
|
.job-portrait {
|
||||||
|
position: relative;
|
||||||
|
border-radius: $item-corner;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--placeholder-bg);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
&.clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.small {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
|
||||||
|
.portrait {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.medium {
|
||||||
|
width: 120px;
|
||||||
|
height: 140px;
|
||||||
|
|
||||||
|
.portrait {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 4px;
|
||||||
|
right: 4px;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.large {
|
||||||
|
width: 180px;
|
||||||
|
height: 210px;
|
||||||
|
|
||||||
|
.portrait {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 8px;
|
||||||
|
right: 8px;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.empty {
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
background: var(--placeholder-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Element-based borders for protagonist
|
||||||
|
&.wind {
|
||||||
|
border: 2px solid var(--wind-bg);
|
||||||
|
background: var(--wind-portrait-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.fire {
|
||||||
|
border: 2px solid var(--fire-bg);
|
||||||
|
background: var(--fire-portrait-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.water {
|
||||||
|
border: 2px solid var(--water-bg);
|
||||||
|
background: var(--water-portrait-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.earth {
|
||||||
|
border: 2px solid var(--earth-bg);
|
||||||
|
background: var(--earth-portrait-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.light {
|
||||||
|
border: 2px solid var(--light-bg);
|
||||||
|
background: var(--light-portrait-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.dark {
|
||||||
|
border: 2px solid var(--dark-bg);
|
||||||
|
background: var(--dark-portrait-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
418
src/lib/components/job/JobSection.svelte
Normal file
418
src/lib/components/job/JobSection.svelte
Normal file
|
|
@ -0,0 +1,418 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { Job, JobAccessory } from '$lib/types/api/entities'
|
||||||
|
import type { JobSkillList } from '$lib/types/api/party'
|
||||||
|
import JobSkillSlot from './JobSkillSlot.svelte'
|
||||||
|
import {
|
||||||
|
getJobSkillSlotCount,
|
||||||
|
getJobIconUrl,
|
||||||
|
getJobFullImageUrl,
|
||||||
|
Gender,
|
||||||
|
isSkillSlotAvailable,
|
||||||
|
isSkillSlotLocked
|
||||||
|
} from '$lib/utils/jobUtils'
|
||||||
|
import Icon from '$lib/components/Icon.svelte'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
job?: Job
|
||||||
|
jobSkills?: JobSkillList
|
||||||
|
accessory?: JobAccessory
|
||||||
|
canEdit?: boolean
|
||||||
|
gender?: Gender
|
||||||
|
element?: number
|
||||||
|
onSelectJob?: () => void
|
||||||
|
onSelectSkill?: (slot: number) => void
|
||||||
|
onRemoveSkill?: (slot: number) => void
|
||||||
|
onSelectAccessory?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
job,
|
||||||
|
jobSkills = {},
|
||||||
|
accessory,
|
||||||
|
canEdit = false,
|
||||||
|
gender = Gender.Gran,
|
||||||
|
element,
|
||||||
|
onSelectJob,
|
||||||
|
onSelectSkill,
|
||||||
|
onRemoveSkill,
|
||||||
|
onSelectAccessory
|
||||||
|
}: Props = $props()
|
||||||
|
|
||||||
|
const slotCount = $derived(getJobSkillSlotCount(job))
|
||||||
|
const jobIconUrl = $derived(job ? getJobIconUrl(job.granblueId) : '')
|
||||||
|
const jobImageUrl = $derived(job ? getJobFullImageUrl(job, gender) : '')
|
||||||
|
|
||||||
|
function handleSelectSkill(slot: number) {
|
||||||
|
if (onSelectSkill) {
|
||||||
|
onSelectSkill(slot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRemoveSkill(slot: number) {
|
||||||
|
if (onRemoveSkill) {
|
||||||
|
onRemoveSkill(slot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="job-section">
|
||||||
|
<div class="job-image-container">
|
||||||
|
{#if job}
|
||||||
|
<img class="job-portrait" src={jobImageUrl} alt={job.name.en} />
|
||||||
|
<div class="overlay"></div>
|
||||||
|
{:else}
|
||||||
|
<div class="empty-portrait">
|
||||||
|
{#if canEdit}
|
||||||
|
<button class="select-job-button" on:click={onSelectJob}>
|
||||||
|
<Icon name="plus" size={24} />
|
||||||
|
<span>Select Job</span>
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<span>No Job Selected</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if canEdit && job}
|
||||||
|
<button class="change-job-button" on:click={onSelectJob} aria-label="Change job">
|
||||||
|
<Icon name="arrow-left" size={16} />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right: Job details and skills -->
|
||||||
|
<div class="job-details">
|
||||||
|
{#if job}
|
||||||
|
<div class="job-header">
|
||||||
|
{#if canEdit}
|
||||||
|
<button class="job-name clickable" on:click={onSelectJob}>
|
||||||
|
<img src={jobIconUrl} alt="{job.name.en} icon" class="job-icon" />
|
||||||
|
<h3>{job.name.en}</h3>
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<div class="job-name">
|
||||||
|
<img src={jobIconUrl} alt="{job.name.en} icon" class="job-icon" />
|
||||||
|
<h3>{job.name.en}</h3>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if job.masterLevel || job.ultimateMastery}
|
||||||
|
<div class="job-badges">
|
||||||
|
{#if job.masterLevel}
|
||||||
|
<span class="badge master">Master Lv.{job.masterLevel}</span>
|
||||||
|
{/if}
|
||||||
|
{#if job.ultimateMastery}
|
||||||
|
<span class="badge ultimate">Ultimate</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="job-skills">
|
||||||
|
{#each Array(4) as _, slot}
|
||||||
|
{#if isSkillSlotAvailable(job, slot)}
|
||||||
|
<JobSkillSlot
|
||||||
|
skill={jobSkills[slot as keyof JobSkillList]}
|
||||||
|
{slot}
|
||||||
|
locked={isSkillSlotLocked(slot, job, jobSkills)}
|
||||||
|
editable={canEdit}
|
||||||
|
available={true}
|
||||||
|
onclick={() => handleSelectSkill(slot)}
|
||||||
|
onRemove={() => handleRemoveSkill(slot)}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if job.accessory}
|
||||||
|
<div class="job-accessory">
|
||||||
|
<div
|
||||||
|
class="accessory-slot"
|
||||||
|
class:empty={!accessory}
|
||||||
|
class:editable={canEdit}
|
||||||
|
role={canEdit ? 'button' : undefined}
|
||||||
|
tabindex={canEdit ? 0 : undefined}
|
||||||
|
on:click={() => canEdit && onSelectAccessory?.()}
|
||||||
|
on:keydown={(e) => {
|
||||||
|
if (canEdit && onSelectAccessory && (e.key === 'Enter' || e.key === ' ')) {
|
||||||
|
e.preventDefault()
|
||||||
|
onSelectAccessory()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{#if accessory}
|
||||||
|
<img
|
||||||
|
src="/images/accessory-square/{accessory.granblueId}.jpg"
|
||||||
|
alt={accessory.name.en}
|
||||||
|
class="accessory-icon"
|
||||||
|
/>
|
||||||
|
<span class="accessory-name">{accessory.name.en}</span>
|
||||||
|
{:else}
|
||||||
|
<Icon name="plus" size={16} />
|
||||||
|
<span>Select Accessory</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<div class="no-job-message">
|
||||||
|
<p>Select a job to view skills and details</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@use '$src/themes/spacing' as spacing;
|
||||||
|
@use '$src/themes/typography' as typography;
|
||||||
|
@use '$src/themes/layout' as layout;
|
||||||
|
|
||||||
|
.job-section {
|
||||||
|
display: flex;
|
||||||
|
gap: spacing.$unit-3x;
|
||||||
|
padding: spacing.$unit-2x;
|
||||||
|
background: var(--card-bg);
|
||||||
|
border-radius: layout.$card-corner;
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
@media (max-width: 800px) {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: spacing.$unit-2x;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-image-container {
|
||||||
|
position: relative;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 447px;
|
||||||
|
max-width: 100%;
|
||||||
|
height: 252px;
|
||||||
|
aspect-ratio: 7/4;
|
||||||
|
background: url('/images/background_a.jpg');
|
||||||
|
background-size: 500px 281px;
|
||||||
|
background-position: center;
|
||||||
|
border-radius: layout.$item-corner;
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.2);
|
||||||
|
overflow: hidden;
|
||||||
|
isolation: isolate;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
@media (max-width: 800px) {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
aspect-ratio: 16/9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-portrait {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
object-fit: contain;
|
||||||
|
z-index: 2;
|
||||||
|
filter: drop-shadow(4px 4px 8px rgba(0, 0, 0, 0.48));
|
||||||
|
transform: translateY(74px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
backdrop-filter: blur(5px) saturate(100%) brightness(80%);
|
||||||
|
z-index: 1;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-portrait {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: typography.$font-regular;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-job-button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: spacing.$unit-half;
|
||||||
|
padding: spacing.$unit spacing.$unit-2x;
|
||||||
|
background: var(--button-primary-bg);
|
||||||
|
color: var(--button-primary-text);
|
||||||
|
border: none;
|
||||||
|
border-radius: layout.$card-corner;
|
||||||
|
font-size: typography.$font-regular;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--button-primary-bg-hover);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.change-job-button {
|
||||||
|
position: absolute;
|
||||||
|
top: spacing.$unit;
|
||||||
|
right: spacing.$unit;
|
||||||
|
z-index: 3;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: layout.$card-corner;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-details {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: spacing.$unit;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
.job-header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: spacing.$unit;
|
||||||
|
|
||||||
|
.job-name {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: spacing.$unit;
|
||||||
|
padding: spacing.$unit;
|
||||||
|
border-radius: layout.$card-corner;
|
||||||
|
width: 100%;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
font-family: inherit;
|
||||||
|
text-align: left;
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
|
||||||
|
&.clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
background: var(--button-contained-bg);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--button-contained-bg-hover);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-icon {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: layout.$item-corner;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: typography.$font-regular;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-badges {
|
||||||
|
display: flex;
|
||||||
|
gap: spacing.$unit-half;
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: layout.$item-corner;
|
||||||
|
font-size: typography.$font-small;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
|
||||||
|
&.master {
|
||||||
|
background: var(--badge-master-bg, #ffd700);
|
||||||
|
color: var(--badge-master-text, #000);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.ultimate {
|
||||||
|
background: var(--badge-ultimate-bg, #9b59b6);
|
||||||
|
color: var(--badge-ultimate-text, #fff);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-skills {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-accessory {
|
||||||
|
.accessory-slot {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: spacing.$unit;
|
||||||
|
padding: spacing.$unit;
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: layout.$item-corner;
|
||||||
|
background: var(--card-bg);
|
||||||
|
min-height: 48px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&.empty {
|
||||||
|
border-style: dashed;
|
||||||
|
background: var(--placeholder-bg);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.editable {
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--button-contained-bg-hover);
|
||||||
|
border-color: var(--border-medium);
|
||||||
|
|
||||||
|
&.empty {
|
||||||
|
border-style: solid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.accessory-icon {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: layout.$item-corner;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accessory-name {
|
||||||
|
font-size: typography.$font-regular;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-job-message {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: typography.$font-regular;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
181
src/lib/components/job/JobSkillItem.svelte
Normal file
181
src/lib/components/job/JobSkillItem.svelte
Normal file
|
|
@ -0,0 +1,181 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { JobSkill } from '$lib/types/api/entities'
|
||||||
|
import { getSkillCategoryName } from '$lib/utils/jobUtils'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
skill: JobSkill
|
||||||
|
onClick?: () => void
|
||||||
|
disabled?: boolean
|
||||||
|
variant?: 'default' | 'current'
|
||||||
|
onRemove?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
let { skill, onClick, disabled = false, variant = 'default', onRemove }: Props = $props()
|
||||||
|
|
||||||
|
function getSkillIcon(skill: JobSkill): string {
|
||||||
|
if (skill.slug) {
|
||||||
|
return `/images/job-skills/${skill.slug}.png`
|
||||||
|
}
|
||||||
|
// Fallback if no slug
|
||||||
|
return '/images/job-skills/default.png'
|
||||||
|
}
|
||||||
|
|
||||||
|
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'
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="skill-item {getSkillColorClass(skill)}"
|
||||||
|
class:current={variant === 'current'}
|
||||||
|
on:click={onClick}
|
||||||
|
{disabled}
|
||||||
|
aria-label="{skill.name.en} - {getSkillCategoryName(skill)} skill"
|
||||||
|
>
|
||||||
|
<img src={getSkillIcon(skill)} alt={skill.name.en} class="skill-icon" loading="lazy" />
|
||||||
|
|
||||||
|
<div class="skill-info">
|
||||||
|
<span class="skill-name">{skill.name.en}</span>
|
||||||
|
<span class="skill-category">
|
||||||
|
{getSkillCategoryName(skill)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if variant === 'current' && onRemove}
|
||||||
|
<button class="remove-button" on:click|stopPropagation={onRemove} aria-label="Remove skill">
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@use '$src/themes/spacing' as *;
|
||||||
|
@use '$src/themes/layout' as *;
|
||||||
|
|
||||||
|
.skill-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $unit;
|
||||||
|
padding: $unit;
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: $item-corner;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
text-align: left;
|
||||||
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: var(--button-contained-bg-hover);
|
||||||
|
border-color: var(--border-medium);
|
||||||
|
transform: translateX(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active:not(:disabled) {
|
||||||
|
transform: translateX(1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Category color indicators
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 3px;
|
||||||
|
border-radius: $item-corner 0 0 $item-corner;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.skill-main::before {
|
||||||
|
background: var(--skill-main, #ff6b6b);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.skill-sub::before {
|
||||||
|
background: var(--skill-sub, #4ecdc4);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.skill-emp::before {
|
||||||
|
background: var(--skill-emp, #45b7d1);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.skill-base::before {
|
||||||
|
background: var(--skill-base, #96ceb4);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Current skill variant
|
||||||
|
&.current {
|
||||||
|
background: var(--primary-10);
|
||||||
|
border-color: var(--primary-50);
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: var(--primary-20);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-icon {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-info {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
.skill-name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-category {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-button {
|
||||||
|
padding: $unit-half $unit;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border-medium);
|
||||||
|
border-radius: $item-corner;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--button-ghost-bg-hover);
|
||||||
|
border-color: var(--error-border);
|
||||||
|
color: var(--error-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
251
src/lib/components/job/JobSkillSlot.svelte
Normal file
251
src/lib/components/job/JobSkillSlot.svelte
Normal file
|
|
@ -0,0 +1,251 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { JobSkill } from '$lib/types/api/entities'
|
||||||
|
import { getSkillCategoryColor } from '$lib/utils/jobUtils'
|
||||||
|
import Icon from '$lib/components/Icon.svelte'
|
||||||
|
import Tooltip from '$lib/components/ui/Tooltip.svelte'
|
||||||
|
import Button from '$lib/components/ui/Button.svelte'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
skill?: JobSkill
|
||||||
|
slot: number
|
||||||
|
locked?: boolean
|
||||||
|
editable?: boolean
|
||||||
|
available?: boolean
|
||||||
|
onclick?: () => void
|
||||||
|
onRemove?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
skill,
|
||||||
|
slot,
|
||||||
|
locked = false,
|
||||||
|
editable = false,
|
||||||
|
available = true,
|
||||||
|
onclick,
|
||||||
|
onRemove
|
||||||
|
}: Props = $props()
|
||||||
|
|
||||||
|
const categoryColor = $derived(skill ? getSkillCategoryColor(skill) : '')
|
||||||
|
const skillIconUrl = $derived(skill?.slug ? `/images/job-skills/${skill.slug}.png` : '')
|
||||||
|
|
||||||
|
const isEditable = $derived(editable && !locked && available)
|
||||||
|
const isUnavailable = $derived(!available)
|
||||||
|
const isFilled = $derived(Boolean(skill))
|
||||||
|
const allowsRemove = $derived(isFilled && isEditable && skill)
|
||||||
|
|
||||||
|
function handleClick() {
|
||||||
|
if (isEditable && onclick) {
|
||||||
|
onclick()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRemove(event: Event) {
|
||||||
|
event.stopPropagation()
|
||||||
|
if (onRemove) {
|
||||||
|
onRemove()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if isEditable}
|
||||||
|
<div class="slot-row">
|
||||||
|
<button
|
||||||
|
class="skill-slot editable"
|
||||||
|
class:empty={!isFilled}
|
||||||
|
style:--category-color={categoryColor}
|
||||||
|
on:click={handleClick}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{@render SlotBody({ locked: false })}
|
||||||
|
</button>
|
||||||
|
{#if allowsRemove}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
icon="close"
|
||||||
|
on:click={handleRemove}
|
||||||
|
aria-label="Remove skill"
|
||||||
|
type="button"
|
||||||
|
class="remove-button"
|
||||||
|
iconOnly
|
||||||
|
></Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
class="skill-slot"
|
||||||
|
class:empty={!isFilled}
|
||||||
|
class:locked
|
||||||
|
class:unavailable={isUnavailable}
|
||||||
|
style:--category-color={categoryColor}
|
||||||
|
>
|
||||||
|
{@render SlotBody({ locked })}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#snippet SlotBody({ locked })}
|
||||||
|
{#if isFilled}
|
||||||
|
{@render SkillContent({ skill: skill!, skillIconUrl, locked })}
|
||||||
|
{:else if !isUnavailable}
|
||||||
|
{@render EmptyState({ slot })}
|
||||||
|
{:else}
|
||||||
|
{@render UnavailableState()}
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet SkillContent({ skill, skillIconUrl, locked })}
|
||||||
|
<div class="skill-content">
|
||||||
|
{#if skillIconUrl}
|
||||||
|
<img src={skillIconUrl} alt={skill.name.en} class="skill-icon" loading="lazy" />
|
||||||
|
{/if}
|
||||||
|
<div class="skill-info">
|
||||||
|
<span class="skill-name">{skill.name.en}</span>
|
||||||
|
</div>
|
||||||
|
{#if locked}
|
||||||
|
<Tooltip content="Main skill (locked)">
|
||||||
|
{#snippet children()}
|
||||||
|
<Icon name="lock" size={16} class="lock-icon" />
|
||||||
|
{/snippet}
|
||||||
|
</Tooltip>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet EmptyState({ slot })}
|
||||||
|
<div class="empty-content">
|
||||||
|
<Icon name="plus" size={20} />
|
||||||
|
<span>Slot {slot + 1}</span>
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet UnavailableState()}
|
||||||
|
<div class="unavailable-content">
|
||||||
|
<span>—</span>
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@use '$src/themes/spacing' as *;
|
||||||
|
@use '$src/themes/layout' as *;
|
||||||
|
|
||||||
|
.slot-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $unit-half;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-slot {
|
||||||
|
position: relative;
|
||||||
|
border: none;
|
||||||
|
border-radius: $card-corner;
|
||||||
|
background: var(--button-bound-bg);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
font: inherit;
|
||||||
|
padding: 0;
|
||||||
|
|
||||||
|
&.editable {
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--button-contained-bg-hover);
|
||||||
|
border-color: var(--border-medium);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.empty {
|
||||||
|
border-style: dashed;
|
||||||
|
background: var(--placeholder-bg);
|
||||||
|
|
||||||
|
&.editable:hover {
|
||||||
|
background: var(--button-contained-bg-hover);
|
||||||
|
border-style: solid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.locked {
|
||||||
|
background: var(--card-bg-locked);
|
||||||
|
cursor: default;
|
||||||
|
padding-right: calc($unit - 2px);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.unavailable {
|
||||||
|
background: var(--card-bg-disabled);
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: $unit;
|
||||||
|
gap: $unit;
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
.skill-icon {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: $unit-half;
|
||||||
|
flex-shrink: 0;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
|
||||||
|
.skill-name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-category {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: $unit;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
height: 60px;
|
||||||
|
|
||||||
|
span {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.unavailable-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: $unit;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
height: 60px;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.lock-icon.icon) {
|
||||||
|
color: var(--text-tertiary) !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
118
src/lib/components/job/JobTierSelector.svelte
Normal file
118
src/lib/components/job/JobTierSelector.svelte
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { ComponentProps } from 'svelte'
|
||||||
|
|
||||||
|
interface Tier {
|
||||||
|
value: string
|
||||||
|
label: string
|
||||||
|
shortLabel: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
tiers: Tier[]
|
||||||
|
selectedTiers: Set<string>
|
||||||
|
onToggleTier: (value: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
let { tiers, selectedTiers, onToggleTier }: Props = $props()
|
||||||
|
|
||||||
|
// Split tiers into two rows
|
||||||
|
const firstRowTiers = $derived(tiers.filter(t => ['1', '2', '3', '4', '5'].includes(t.value)))
|
||||||
|
const secondRowTiers = $derived(tiers.filter(t => ['ex', 'ex2', 'o1'].includes(t.value)))
|
||||||
|
|
||||||
|
function handleTierClick(value: string) {
|
||||||
|
onToggleTier(value)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="tier-selector">
|
||||||
|
<div class="tier-row">
|
||||||
|
{#each firstRowTiers as tier (tier.value)}
|
||||||
|
<button
|
||||||
|
class="tier-button"
|
||||||
|
class:selected={selectedTiers.has(tier.value)}
|
||||||
|
on:click={() => handleTierClick(tier.value)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{tier.shortLabel}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<div class="tier-row">
|
||||||
|
{#each secondRowTiers as tier (tier.value)}
|
||||||
|
<button
|
||||||
|
class="tier-button"
|
||||||
|
class:selected={selectedTiers.has(tier.value)}
|
||||||
|
on:click={() => handleTierClick(tier.value)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{tier.shortLabel}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@use '$src/themes/spacing' as spacing;
|
||||||
|
@use '$src/themes/layout' as layout;
|
||||||
|
@use '$src/themes/typography' as typography;
|
||||||
|
@use '$src/themes/effects' as effects;
|
||||||
|
|
||||||
|
.tier-selector {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: spacing.$unit-half;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-row {
|
||||||
|
display: flex;
|
||||||
|
gap: spacing.$unit-half;
|
||||||
|
|
||||||
|
&:first-child {
|
||||||
|
// First row with 5 items
|
||||||
|
.tier-button {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
// Second row with 3 items
|
||||||
|
.tier-button {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-button {
|
||||||
|
padding: spacing.$unit spacing.$unit-2x;
|
||||||
|
background: var(--button-secondary-bg);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: layout.$card-corner;
|
||||||
|
font-size: typography.$font-small;
|
||||||
|
font-weight: typography.$medium;
|
||||||
|
color: var(--button-secondary-text);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all effects.$duration-quick ease;
|
||||||
|
user-select: none;
|
||||||
|
font-family: var(--font-family);
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
&:hover:not(.selected) {
|
||||||
|
background: var(--button-secondary-bg-hover);
|
||||||
|
color: var(--button-secondary-text-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.selected {
|
||||||
|
background: var(--button-primary-bg);
|
||||||
|
color: var(--button-primary-text);
|
||||||
|
border-color: var(--button-primary-border);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--button-primary-bg-hover);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: translateY(1px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -19,6 +19,10 @@
|
||||||
import { openDescriptionSidebar } from '$lib/features/description/openDescriptionSidebar.svelte.ts'
|
import { openDescriptionSidebar } from '$lib/features/description/openDescriptionSidebar.svelte.ts'
|
||||||
import { DropdownMenu } from 'bits-ui'
|
import { DropdownMenu } from 'bits-ui'
|
||||||
import DropdownItem from '$lib/components/ui/dropdown/DropdownItem.svelte'
|
import DropdownItem from '$lib/components/ui/dropdown/DropdownItem.svelte'
|
||||||
|
import JobSection from '$lib/components/job/JobSection.svelte'
|
||||||
|
import { Gender } from '$lib/utils/jobUtils'
|
||||||
|
import { openJobSelectionSidebar, openJobSkillSelectionSidebar } from '$lib/features/job/openJobSidebar.svelte'
|
||||||
|
import { partyAdapter } from '$lib/api/adapters/party.adapter'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
party?: Party
|
party?: Party
|
||||||
|
|
@ -354,6 +358,197 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle job selection
|
||||||
|
async function handleSelectJob() {
|
||||||
|
if (!canEdit()) return
|
||||||
|
|
||||||
|
openJobSelectionSidebar({
|
||||||
|
currentJobId: party.job?.id,
|
||||||
|
onSelectJob: async (job) => {
|
||||||
|
loading = true
|
||||||
|
error = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Update job via API
|
||||||
|
const updated = await partyAdapter.updateJob(party.id, job.id)
|
||||||
|
party = updated
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : 'Failed to update job'
|
||||||
|
console.error('Failed to update job:', e)
|
||||||
|
} finally {
|
||||||
|
loading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle job skill selection
|
||||||
|
async function handleSelectJobSkill(slot: number) {
|
||||||
|
if (!canEdit()) return
|
||||||
|
|
||||||
|
openJobSkillSelectionSidebar({
|
||||||
|
job: party.job,
|
||||||
|
currentSkills: party.jobSkills,
|
||||||
|
targetSlot: slot,
|
||||||
|
onSelectSkill: async (skill) => {
|
||||||
|
loading = true
|
||||||
|
error = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Update skills with the new skill in the slot
|
||||||
|
const updatedSkills = { ...party.jobSkills }
|
||||||
|
updatedSkills[slot as keyof typeof updatedSkills] = skill
|
||||||
|
|
||||||
|
console.log('[Party] Current jobSkills:', party.jobSkills)
|
||||||
|
console.log('[Party] Updated jobSkills object:', updatedSkills)
|
||||||
|
console.log('[Party] Slot being updated:', slot)
|
||||||
|
console.log('[Party] New skill:', skill)
|
||||||
|
|
||||||
|
// Convert skills object to array format expected by API
|
||||||
|
const skillsArray = Object.entries(updatedSkills)
|
||||||
|
.filter(([_, skill]) => skill !== null && skill !== undefined)
|
||||||
|
.map(([slotKey, skill]) => ({
|
||||||
|
id: skill!.id,
|
||||||
|
slot: parseInt(slotKey)
|
||||||
|
}))
|
||||||
|
|
||||||
|
console.log('[Party] Skills array to send:', skillsArray)
|
||||||
|
|
||||||
|
const updated = await partyAdapter.updateJobSkills(
|
||||||
|
party.id,
|
||||||
|
skillsArray
|
||||||
|
)
|
||||||
|
party = updated
|
||||||
|
} catch (e: any) {
|
||||||
|
// Extract detailed error message from nested structure
|
||||||
|
let errorDetails = e?.details
|
||||||
|
|
||||||
|
// Navigate through nested details structure
|
||||||
|
while (errorDetails?.details) {
|
||||||
|
errorDetails = errorDetails.details
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errorDetails?.errors) {
|
||||||
|
if (errorDetails.errors.message) {
|
||||||
|
// Simple message format
|
||||||
|
error = errorDetails.errors.message
|
||||||
|
} else {
|
||||||
|
// Field-based errors
|
||||||
|
const errorMessages = Object.entries(errorDetails.errors)
|
||||||
|
.map(([field, messages]) => {
|
||||||
|
if (Array.isArray(messages)) {
|
||||||
|
return messages.join(', ')
|
||||||
|
}
|
||||||
|
return String(messages)
|
||||||
|
})
|
||||||
|
.join('; ')
|
||||||
|
error = errorMessages || e.message || 'Failed to update skill'
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
error = e?.message || 'Failed to update skill'
|
||||||
|
}
|
||||||
|
console.error('Failed to update skill:', e)
|
||||||
|
} finally {
|
||||||
|
loading = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onRemoveSkill: async () => {
|
||||||
|
loading = true
|
||||||
|
error = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Remove skill from slot
|
||||||
|
const updatedSkills = { ...party.jobSkills }
|
||||||
|
delete updatedSkills[slot as keyof typeof updatedSkills]
|
||||||
|
|
||||||
|
console.log('[Party] Removing skill from slot:', slot)
|
||||||
|
console.log('[Party] Current jobSkills:', party.jobSkills)
|
||||||
|
console.log('[Party] Updated jobSkills after removal:', updatedSkills)
|
||||||
|
|
||||||
|
// Convert skills object to array format expected by API
|
||||||
|
const skillsArray = Object.entries(updatedSkills)
|
||||||
|
.filter(([_, skill]) => skill !== null && skill !== undefined)
|
||||||
|
.map(([slotKey, skill]) => ({
|
||||||
|
id: skill!.id,
|
||||||
|
slot: parseInt(slotKey)
|
||||||
|
}))
|
||||||
|
|
||||||
|
console.log('[Party] Skills array to send after removal:', skillsArray)
|
||||||
|
|
||||||
|
const updated = await partyAdapter.updateJobSkills(
|
||||||
|
party.id,
|
||||||
|
skillsArray
|
||||||
|
)
|
||||||
|
party = updated
|
||||||
|
} catch (e: any) {
|
||||||
|
// Extract detailed error message from nested structure
|
||||||
|
let errorDetails = e?.details
|
||||||
|
|
||||||
|
// Navigate through nested details structure
|
||||||
|
while (errorDetails?.details) {
|
||||||
|
errorDetails = errorDetails.details
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errorDetails?.errors) {
|
||||||
|
if (errorDetails.errors.message) {
|
||||||
|
// Simple message format
|
||||||
|
error = errorDetails.errors.message
|
||||||
|
} else {
|
||||||
|
// Field-based errors
|
||||||
|
const errorMessages = Object.entries(errorDetails.errors)
|
||||||
|
.map(([field, messages]) => {
|
||||||
|
if (Array.isArray(messages)) {
|
||||||
|
return messages.join(', ')
|
||||||
|
}
|
||||||
|
return String(messages)
|
||||||
|
})
|
||||||
|
.join('; ')
|
||||||
|
error = errorMessages || e.message || 'Failed to remove skill'
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
error = e?.message || 'Failed to remove skill'
|
||||||
|
}
|
||||||
|
console.error('Failed to remove skill:', e)
|
||||||
|
} finally {
|
||||||
|
loading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle removing a skill directly
|
||||||
|
async function handleRemoveJobSkill(slot: number) {
|
||||||
|
if (!canEdit()) return
|
||||||
|
|
||||||
|
loading = true
|
||||||
|
error = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Remove skill from slot
|
||||||
|
const updatedSkills = { ...party.jobSkills }
|
||||||
|
delete updatedSkills[slot as keyof typeof updatedSkills]
|
||||||
|
|
||||||
|
// Convert skills object to array format expected by API
|
||||||
|
const skillsArray = Object.entries(updatedSkills)
|
||||||
|
.filter(([_, skill]) => skill !== null && skill !== undefined)
|
||||||
|
.map(([slotKey, skill]) => ({
|
||||||
|
id: skill!.id,
|
||||||
|
slot: parseInt(slotKey)
|
||||||
|
}))
|
||||||
|
|
||||||
|
const updated = await partyAdapter.updateJobSkills(
|
||||||
|
party.id,
|
||||||
|
skillsArray
|
||||||
|
)
|
||||||
|
party = updated
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : 'Failed to remove skill'
|
||||||
|
console.error('Failed to remove skill:', e)
|
||||||
|
} finally {
|
||||||
|
loading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Handle adding items from the search sidebar
|
// Handle adding items from the search sidebar
|
||||||
async function handleAddItems(items: SearchResult[]) {
|
async function handleAddItems(items: SearchResult[]) {
|
||||||
if (items.length === 0 || !canEdit()) return
|
if (items.length === 0 || !canEdit()) return
|
||||||
|
|
@ -863,7 +1058,29 @@
|
||||||
{:else if activeTab === GridType.Summon}
|
{:else if activeTab === GridType.Summon}
|
||||||
<SummonGrid summons={party.summons} />
|
<SummonGrid summons={party.summons} />
|
||||||
{:else}
|
{:else}
|
||||||
<CharacterGrid characters={party.characters} {mainWeaponElement} {partyElement} />
|
<div class="character-tab-content">
|
||||||
|
<JobSection
|
||||||
|
job={party.job}
|
||||||
|
jobSkills={party.jobSkills}
|
||||||
|
accessory={party.accessory}
|
||||||
|
canEdit={canEdit()}
|
||||||
|
gender={Gender.Gran}
|
||||||
|
element={mainWeaponElement}
|
||||||
|
onSelectJob={handleSelectJob}
|
||||||
|
onSelectSkill={handleSelectJobSkill}
|
||||||
|
onRemoveSkill={handleRemoveJobSkill}
|
||||||
|
onSelectAccessory={() => {
|
||||||
|
// TODO: Open accessory selection sidebar
|
||||||
|
console.log('Open accessory selection sidebar')
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<CharacterGrid
|
||||||
|
characters={party.characters}
|
||||||
|
{mainWeaponElement}
|
||||||
|
{partyElement}
|
||||||
|
job={party.job}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -1181,6 +1398,12 @@
|
||||||
min-height: 400px;
|
min-height: 400px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.character-tab-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $unit-2x;
|
||||||
|
}
|
||||||
|
|
||||||
// Edit form styles
|
// Edit form styles
|
||||||
.edit-form {
|
.edit-form {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
|
||||||
282
src/lib/components/sidebar/JobSelectionSidebar.svelte
Normal file
282
src/lib/components/sidebar/JobSelectionSidebar.svelte
Normal file
|
|
@ -0,0 +1,282 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte'
|
||||||
|
import type { Job } from '$lib/types/api/entities'
|
||||||
|
import { createJobResource } from '$lib/api/adapters/resources/job.resource.svelte'
|
||||||
|
import { getJobTierName, getJobTierOrder } from '$lib/utils/jobUtils'
|
||||||
|
import JobItem from '../job/JobItem.svelte'
|
||||||
|
import JobTierSelector from '../job/JobTierSelector.svelte'
|
||||||
|
import Input from '../ui/Input.svelte'
|
||||||
|
import Button from '../ui/Button.svelte'
|
||||||
|
import Icon from '../Icon.svelte'
|
||||||
|
import * as m from '$lib/paraglide/messages'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
currentJobId?: string
|
||||||
|
onSelectJob?: (job: Job) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
let { currentJobId, onSelectJob }: Props = $props()
|
||||||
|
|
||||||
|
// Create job resource
|
||||||
|
const jobResource = createJobResource()
|
||||||
|
|
||||||
|
// State
|
||||||
|
let searchQuery = $state('')
|
||||||
|
let selectedTiers = $state<Set<string>>(new Set(['4', '5', 'ex2', 'o1'])) // Default to IV, V, EXII, OI
|
||||||
|
let loading = $state(false)
|
||||||
|
let error = $state<string | undefined>()
|
||||||
|
|
||||||
|
// Available tiers with short labels for display
|
||||||
|
const tiers = [
|
||||||
|
{ value: '1', label: 'Class I', shortLabel: 'I' },
|
||||||
|
{ value: '2', label: 'Class II', shortLabel: 'II' },
|
||||||
|
{ value: '3', label: 'Class III', shortLabel: 'III' },
|
||||||
|
{ value: '4', label: 'Class IV', shortLabel: 'IV' },
|
||||||
|
{ value: '5', label: 'Class V', shortLabel: 'V' },
|
||||||
|
{ value: 'ex', label: 'Extra', shortLabel: 'EXI' },
|
||||||
|
{ value: 'ex2', label: 'Extra II', shortLabel: 'EXII' },
|
||||||
|
{ value: 'o1', label: 'Origin I', shortLabel: 'OI' }
|
||||||
|
]
|
||||||
|
|
||||||
|
function toggleTier(value: string) {
|
||||||
|
const newSet = new Set(selectedTiers)
|
||||||
|
if (newSet.has(value)) {
|
||||||
|
newSet.delete(value)
|
||||||
|
} else {
|
||||||
|
newSet.add(value)
|
||||||
|
}
|
||||||
|
selectedTiers = newSet
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch jobs on mount
|
||||||
|
onMount(() => {
|
||||||
|
loadJobs()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadJobs() {
|
||||||
|
loading = true
|
||||||
|
error = undefined
|
||||||
|
|
||||||
|
try {
|
||||||
|
await jobResource.fetchJobs()
|
||||||
|
} catch (e: any) {
|
||||||
|
error = e.message || 'Failed to load jobs'
|
||||||
|
console.error('Error loading jobs:', e)
|
||||||
|
} finally {
|
||||||
|
loading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter jobs based on search and filters
|
||||||
|
const filteredJobs = $derived(
|
||||||
|
(() => {
|
||||||
|
let jobs = jobResource.jobs.data || []
|
||||||
|
|
||||||
|
// Filter by search query
|
||||||
|
if (searchQuery) {
|
||||||
|
const query = searchQuery.toLowerCase()
|
||||||
|
jobs = jobs.filter(
|
||||||
|
(job) =>
|
||||||
|
job.name.en.toLowerCase().includes(query) || job.name.ja?.toLowerCase().includes(query)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by selected tiers
|
||||||
|
if (selectedTiers.size > 0) {
|
||||||
|
jobs = jobs.filter((job) => {
|
||||||
|
const jobTier = job.row.toString().toLowerCase()
|
||||||
|
return selectedTiers.has(jobTier)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by tier and then by order field (create a copy to avoid mutating state)
|
||||||
|
jobs = [...jobs].sort((a, b) => {
|
||||||
|
const tierDiff = getJobTierOrder(a.row) - getJobTierOrder(b.row)
|
||||||
|
if (tierDiff !== 0) return tierDiff
|
||||||
|
// Use the order field for sorting within the same tier
|
||||||
|
return a.order - b.order
|
||||||
|
})
|
||||||
|
|
||||||
|
// Group by tier
|
||||||
|
const grouped: Record<string, Job[]> = {}
|
||||||
|
for (const job of jobs) {
|
||||||
|
const tierName = getJobTierName(job.row)
|
||||||
|
if (!grouped[tierName]) {
|
||||||
|
grouped[tierName] = []
|
||||||
|
}
|
||||||
|
grouped[tierName].push(job)
|
||||||
|
}
|
||||||
|
|
||||||
|
return grouped
|
||||||
|
})()
|
||||||
|
)
|
||||||
|
|
||||||
|
function handleSelectJob(job: Job) {
|
||||||
|
onSelectJob?.(job)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isJobSelected(job: Job): boolean {
|
||||||
|
return job.id === currentJobId
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="job-selection-content">
|
||||||
|
<div class="search-section">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder={m.job_selection_search_placeholder()}
|
||||||
|
bind:value={searchQuery}
|
||||||
|
leftIcon="search"
|
||||||
|
fullWidth={true}
|
||||||
|
contained={true}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<JobTierSelector {tiers} {selectedTiers} onToggleTier={toggleTier} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="jobs-container">
|
||||||
|
{#if loading}
|
||||||
|
<div class="loading-state">
|
||||||
|
<Icon name="loader-2" size={32} />
|
||||||
|
<p>Loading jobs...</p>
|
||||||
|
</div>
|
||||||
|
{:else if error}
|
||||||
|
<div class="error-state">
|
||||||
|
<Icon name="alert-circle" size={32} />
|
||||||
|
<p>{error}</p>
|
||||||
|
<Button size="small" on:click={loadJobs}>Retry</Button>
|
||||||
|
</div>
|
||||||
|
{:else if Object.keys(filteredJobs).length === 0}
|
||||||
|
<div class="empty-state">
|
||||||
|
<Icon name="briefcase" size={32} />
|
||||||
|
<p>No jobs found</p>
|
||||||
|
{#if searchQuery || selectedTiers.size > 0}
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
on:click={() => {
|
||||||
|
searchQuery = ''
|
||||||
|
selectedTiers = new Set(['4', '5', 'ex2', 'o1'])
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear filters
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="jobs-grid">
|
||||||
|
{#each Object.entries(filteredJobs) as [tierName, jobs]}
|
||||||
|
<div class="tier-group">
|
||||||
|
<div class="tier-header">
|
||||||
|
<h4>{tierName}</h4>
|
||||||
|
</div>
|
||||||
|
<div class="jobs-list">
|
||||||
|
{#each jobs as job (job.id)}
|
||||||
|
<JobItem {job} selected={isJobSelected(job)} onClick={() => handleSelectJob(job)} />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@use '$src/themes/layout' as layout;
|
||||||
|
@use '$src/themes/spacing' as spacing;
|
||||||
|
@use '$src/themes/typography' as typography;
|
||||||
|
|
||||||
|
.job-selection-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-section {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: spacing.$unit;
|
||||||
|
padding: 0 spacing.$unit-2x spacing.$unit-2x;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jobs-container {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: spacing.$unit-2x 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-state,
|
||||||
|
.error-state,
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: spacing.$unit;
|
||||||
|
padding: spacing.$unit-4x;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
|
||||||
|
:global(svg) {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: typography.$font-regular;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-state :global(svg) {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.jobs-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: spacing.$unit-3x;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-group {
|
||||||
|
.tier-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: spacing.$unit;
|
||||||
|
padding: 0 spacing.$unit-2x spacing.$unit-half;
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: typography.$font-small;
|
||||||
|
font-weight: typography.$medium;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-count {
|
||||||
|
padding: spacing.$unit-half spacing.$unit-2x;
|
||||||
|
background: var(--badge-bg);
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: typography.$font-small;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.jobs-list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
|
padding: 0 spacing.$unit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
410
src/lib/components/sidebar/JobSkillSelectionSidebar.svelte
Normal file
410
src/lib/components/sidebar/JobSkillSelectionSidebar.svelte
Normal file
|
|
@ -0,0 +1,410 @@
|
||||||
|
<svelte:options runes={true} />
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import type { Job, JobSkill } from '$lib/types/api/entities'
|
||||||
|
import type { JobSkillList } from '$lib/types/api/party'
|
||||||
|
import { jobAdapter } from '$lib/api/adapters/job.adapter'
|
||||||
|
import { createInfiniteScrollResource } from '$lib/api/adapters/resources/infiniteScroll.resource.svelte'
|
||||||
|
import JobSkillItem from '../job/JobSkillItem.svelte'
|
||||||
|
import Button from '../ui/Button.svelte'
|
||||||
|
import Input from '../ui/Input.svelte'
|
||||||
|
import Icon from '../Icon.svelte'
|
||||||
|
import * as m from '$lib/paraglide/messages'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
job?: Job
|
||||||
|
currentSkills?: JobSkillList
|
||||||
|
targetSlot: number
|
||||||
|
onSelectSkill?: (skill: JobSkill) => void
|
||||||
|
onRemoveSkill?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
job,
|
||||||
|
currentSkills = {},
|
||||||
|
targetSlot = 0,
|
||||||
|
onSelectSkill,
|
||||||
|
onRemoveSkill
|
||||||
|
}: Props = $props()
|
||||||
|
|
||||||
|
// State
|
||||||
|
let searchQuery = $state('')
|
||||||
|
let error = $state<string | undefined>()
|
||||||
|
let sentinelEl = $state<HTMLElement>()
|
||||||
|
let skillsResource = $state<ReturnType<typeof createInfiniteScrollResource<JobSkill>> | null>(null)
|
||||||
|
let lastSearchQuery = ''
|
||||||
|
let lastJobId: string | undefined
|
||||||
|
|
||||||
|
// Check if slot is locked
|
||||||
|
const slotLocked = $derived(targetSlot === 0)
|
||||||
|
const currentSkill = $derived(currentSkills[targetSlot as keyof JobSkillList])
|
||||||
|
|
||||||
|
const canSearch = $derived(Boolean(job) && !slotLocked)
|
||||||
|
|
||||||
|
// Manage resource creation and search updates
|
||||||
|
let debounceTimer: ReturnType<typeof setTimeout> | undefined
|
||||||
|
|
||||||
|
function updateSearch() {
|
||||||
|
const jobId = job?.id
|
||||||
|
const locked = slotLocked
|
||||||
|
|
||||||
|
// Clean up if no job or locked
|
||||||
|
if (!jobId || locked) {
|
||||||
|
if (skillsResource) {
|
||||||
|
skillsResource.destroy()
|
||||||
|
skillsResource = null
|
||||||
|
}
|
||||||
|
lastJobId = undefined
|
||||||
|
lastSearchQuery = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new resource if job changed
|
||||||
|
if (jobId !== lastJobId) {
|
||||||
|
if (skillsResource) {
|
||||||
|
skillsResource.destroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
const resource = createInfiniteScrollResource<JobSkill>({
|
||||||
|
fetcher: async (page) => {
|
||||||
|
const response = await jobAdapter.searchSkills({
|
||||||
|
query: lastSearchQuery,
|
||||||
|
jobId,
|
||||||
|
page
|
||||||
|
})
|
||||||
|
return response
|
||||||
|
},
|
||||||
|
threshold: 200,
|
||||||
|
debounceMs: 200
|
||||||
|
})
|
||||||
|
|
||||||
|
skillsResource = resource
|
||||||
|
lastJobId = jobId
|
||||||
|
lastSearchQuery = searchQuery
|
||||||
|
resource.load()
|
||||||
|
}
|
||||||
|
// Reload if search query changed
|
||||||
|
else if (searchQuery !== lastSearchQuery && skillsResource) {
|
||||||
|
lastSearchQuery = searchQuery
|
||||||
|
skillsResource.reset()
|
||||||
|
skillsResource.load()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watch for job changes
|
||||||
|
$effect(() => {
|
||||||
|
job // Track job
|
||||||
|
updateSearch()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Watch for search query changes with debounce
|
||||||
|
$effect(() => {
|
||||||
|
const query = searchQuery // Track searchQuery
|
||||||
|
|
||||||
|
if (debounceTimer) {
|
||||||
|
clearTimeout(debounceTimer)
|
||||||
|
}
|
||||||
|
|
||||||
|
debounceTimer = setTimeout(() => {
|
||||||
|
if (query !== lastSearchQuery) {
|
||||||
|
updateSearch()
|
||||||
|
}
|
||||||
|
}, 300)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (debounceTimer) {
|
||||||
|
clearTimeout(debounceTimer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Bind sentinel when ready
|
||||||
|
$effect(() => {
|
||||||
|
const sentinel = sentinelEl
|
||||||
|
const resource = skillsResource
|
||||||
|
|
||||||
|
if (sentinel && resource) {
|
||||||
|
resource.bindSentinel(sentinel)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleSelectSkill(skill: JobSkill) {
|
||||||
|
// Clear any previous errors
|
||||||
|
error = undefined
|
||||||
|
|
||||||
|
if (slotLocked) {
|
||||||
|
error = 'This slot cannot be changed'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if skill is already equipped in a different slot
|
||||||
|
const alreadyEquipped = Object.entries(currentSkills).some(([slotKey, s]) => {
|
||||||
|
// Skip checking the current slot we're updating
|
||||||
|
if (parseInt(slotKey) === targetSlot) return false
|
||||||
|
return s?.id === skill.id
|
||||||
|
})
|
||||||
|
|
||||||
|
if (alreadyEquipped) {
|
||||||
|
error = 'This skill is already equipped in another slot'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
onSelectSkill?.(skill)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRemoveSkill() {
|
||||||
|
if (!slotLocked) {
|
||||||
|
error = undefined
|
||||||
|
onRemoveSkill?.()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="skill-selection-content">
|
||||||
|
{#if slotLocked && currentSkill}
|
||||||
|
<div class="locked-notice">
|
||||||
|
<Icon name="arrow-left" size={16} />
|
||||||
|
<p>This slot cannot be changed</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if currentSkill && !slotLocked}
|
||||||
|
<div class="current-skill">
|
||||||
|
<h4>Current Skill</h4>
|
||||||
|
<JobSkillItem
|
||||||
|
skill={currentSkill}
|
||||||
|
variant="current"
|
||||||
|
onRemove={handleRemoveSkill}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<div class="error-banner">
|
||||||
|
<Icon name="alert-circle" size={16} />
|
||||||
|
<p>{error}</p>
|
||||||
|
<button class="close-error" on:click={() => error = undefined}>
|
||||||
|
<Icon name="x" size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="search-section">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder={m.skill_selection_search_placeholder()}
|
||||||
|
bind:value={searchQuery}
|
||||||
|
leftIcon="search"
|
||||||
|
disabled={!canSearch}
|
||||||
|
fullWidth={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="skills-container">
|
||||||
|
{#if !job}
|
||||||
|
<div class="empty-state">
|
||||||
|
<Icon name="briefcase" size={32} />
|
||||||
|
<p>Select a job first</p>
|
||||||
|
</div>
|
||||||
|
{:else if slotLocked}
|
||||||
|
<div class="empty-state">
|
||||||
|
<Icon name="arrow-left" size={32} />
|
||||||
|
<p>This slot cannot be changed</p>
|
||||||
|
</div>
|
||||||
|
{:else if skillsResource?.loading}
|
||||||
|
<div class="loading-state">
|
||||||
|
<Icon name="loader-2" size={32} />
|
||||||
|
<p>Loading skills...</p>
|
||||||
|
</div>
|
||||||
|
{:else if skillsResource?.error}
|
||||||
|
<div class="error-state">
|
||||||
|
<Icon name="alert-circle" size={32} />
|
||||||
|
<p>{skillsResource.error.message || 'Failed to load skills'}</p>
|
||||||
|
<Button size="small" on:click={() => skillsResource?.retry()}>Retry</Button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="skills-list">
|
||||||
|
{#each skillsResource?.items || [] as skill (skill.id)}
|
||||||
|
<JobSkillItem
|
||||||
|
{skill}
|
||||||
|
onClick={() => handleSelectSkill(skill)}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
{#if skillsResource?.isEmpty}
|
||||||
|
<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}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if skillsResource?.hasMore && !skillsResource?.loadingMore}
|
||||||
|
<div class="load-more-sentinel" bind:this={sentinelEl}></div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if skillsResource?.loadingMore}
|
||||||
|
<div class="loading-more">
|
||||||
|
<Icon name="loader-2" size={20} />
|
||||||
|
<span>Loading more skills...</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@use '$src/themes/spacing' as *;
|
||||||
|
@use '$src/themes/layout' as *;
|
||||||
|
|
||||||
|
.skill-selection-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.locked-notice {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $unit;
|
||||||
|
padding: $unit-2x 0;
|
||||||
|
background: var(--warning-bg);
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
|
||||||
|
:global(svg) {
|
||||||
|
color: var(--warning-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--warning-text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-skill {
|
||||||
|
padding: $unit-2x 0;
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
margin: 0 0 $unit 0;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $unit;
|
||||||
|
padding: $unit;
|
||||||
|
background: var(--error-bg);
|
||||||
|
border-bottom: 1px solid var(--error-border);
|
||||||
|
|
||||||
|
:global(svg) {
|
||||||
|
color: var(--error-text);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
flex: 1;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--error-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-error {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 4px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--error-text);
|
||||||
|
opacity: 0.7;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-section {
|
||||||
|
padding: $unit-2x 0;
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skills-container {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: $unit-2x 0;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-state,
|
||||||
|
.error-state,
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: $unit;
|
||||||
|
padding: $unit-4x;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
|
||||||
|
:global(svg) {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-state :global(svg) {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.skills-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $unit-half;
|
||||||
|
}
|
||||||
|
|
||||||
|
.load-more-sentinel {
|
||||||
|
height: 1px;
|
||||||
|
margin-top: $unit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-more {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: $unit;
|
||||||
|
padding: $unit-2x;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 14px;
|
||||||
|
|
||||||
|
:global(svg) {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
429
src/lib/data/overMastery.ts
Normal file
429
src/lib/data/overMastery.ts
Normal file
|
|
@ -0,0 +1,429 @@
|
||||||
|
const overMasteryPrimary: ItemSkill[] = [
|
||||||
|
{
|
||||||
|
name: {
|
||||||
|
en: 'ATK',
|
||||||
|
ja: '攻撃',
|
||||||
|
},
|
||||||
|
id: 1,
|
||||||
|
granblue_id: '',
|
||||||
|
slug: 'atk',
|
||||||
|
minValue: 300,
|
||||||
|
maxValue: 3000,
|
||||||
|
suffix: '',
|
||||||
|
fractional: false,
|
||||||
|
values: [300, 600, 900, 1200, 1500, 1800, 2100, 2400, 2700, 3000],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: {
|
||||||
|
en: 'HP',
|
||||||
|
ja: 'HP',
|
||||||
|
},
|
||||||
|
id: 2,
|
||||||
|
granblue_id: '',
|
||||||
|
slug: 'hp',
|
||||||
|
minValue: 150,
|
||||||
|
maxValue: 1500,
|
||||||
|
suffix: '',
|
||||||
|
fractional: false,
|
||||||
|
values: [150, 300, 450, 600, 750, 900, 1050, 1200, 1350, 1500],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const overMasterySecondary: ItemSkill[] = [
|
||||||
|
{
|
||||||
|
name: {
|
||||||
|
en: 'Debuff Success',
|
||||||
|
ja: '弱体成功率',
|
||||||
|
},
|
||||||
|
id: 3,
|
||||||
|
granblue_id: '',
|
||||||
|
slug: 'debuff-success',
|
||||||
|
minValue: 6,
|
||||||
|
maxValue: 15,
|
||||||
|
suffix: '%',
|
||||||
|
fractional: false,
|
||||||
|
values: [6, 7, 8, 9, 10, 11, 12, 13, 14, 15],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: {
|
||||||
|
en: 'Skill DMG Cap',
|
||||||
|
ja: 'アビダメ上限',
|
||||||
|
},
|
||||||
|
id: 4,
|
||||||
|
granblue_id: '',
|
||||||
|
slug: 'skill-cap',
|
||||||
|
minValue: 6,
|
||||||
|
maxValue: 15,
|
||||||
|
suffix: '%',
|
||||||
|
fractional: false,
|
||||||
|
values: [6, 7, 8, 9, 10, 11, 12, 13, 14, 15],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: {
|
||||||
|
en: 'C.A. DMG',
|
||||||
|
ja: '奥義ダメージ',
|
||||||
|
},
|
||||||
|
id: 5,
|
||||||
|
granblue_id: '',
|
||||||
|
slug: 'ca-dmg',
|
||||||
|
minValue: 10,
|
||||||
|
maxValue: 30,
|
||||||
|
suffix: '%',
|
||||||
|
fractional: false,
|
||||||
|
values: [10, 12, 14, 16, 18, 20, 22, 24, 27, 30],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: {
|
||||||
|
en: 'C.A. DMG Cap',
|
||||||
|
ja: '奥義ダメージ上限',
|
||||||
|
},
|
||||||
|
id: 6,
|
||||||
|
granblue_id: '',
|
||||||
|
slug: 'ca-cap',
|
||||||
|
minValue: 6,
|
||||||
|
maxValue: 15,
|
||||||
|
suffix: '%',
|
||||||
|
fractional: false,
|
||||||
|
values: [6, 7, 8, 9, 10, 11, 12, 13, 14, 15],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: {
|
||||||
|
en: 'Stamina',
|
||||||
|
ja: '渾身',
|
||||||
|
},
|
||||||
|
id: 7,
|
||||||
|
granblue_id: '',
|
||||||
|
slug: 'stamina',
|
||||||
|
minValue: 1,
|
||||||
|
maxValue: 10,
|
||||||
|
suffix: '',
|
||||||
|
fractional: false,
|
||||||
|
values: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: {
|
||||||
|
en: 'Enmity',
|
||||||
|
ja: '背水',
|
||||||
|
},
|
||||||
|
id: 8,
|
||||||
|
granblue_id: '',
|
||||||
|
slug: 'enmity',
|
||||||
|
minValue: 1,
|
||||||
|
maxValue: 10,
|
||||||
|
suffix: '',
|
||||||
|
fractional: false,
|
||||||
|
values: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: {
|
||||||
|
en: 'Critical Hit',
|
||||||
|
ja: 'クリティカル確率',
|
||||||
|
},
|
||||||
|
id: 9,
|
||||||
|
granblue_id: '',
|
||||||
|
slug: 'crit',
|
||||||
|
minValue: 10,
|
||||||
|
maxValue: 30,
|
||||||
|
suffix: '%',
|
||||||
|
fractional: false,
|
||||||
|
values: [10, 12, 14, 16, 18, 20, 22, 24, 27, 30],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const overMasteryTertiary: ItemSkill[] = [
|
||||||
|
{
|
||||||
|
name: {
|
||||||
|
en: 'Double Attack',
|
||||||
|
ja: 'ダブルアタック確率',
|
||||||
|
},
|
||||||
|
id: 10,
|
||||||
|
granblue_id: '',
|
||||||
|
slug: 'da',
|
||||||
|
minValue: 6,
|
||||||
|
maxValue: 15,
|
||||||
|
suffix: '%',
|
||||||
|
fractional: false,
|
||||||
|
values: [6, 7, 8, 9, 10, 11, 12, 13, 14, 15],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: {
|
||||||
|
en: 'Triple Attack',
|
||||||
|
ja: 'トリプルアタック確率',
|
||||||
|
},
|
||||||
|
id: 11,
|
||||||
|
granblue_id: '',
|
||||||
|
slug: 'ta',
|
||||||
|
minValue: 1,
|
||||||
|
maxValue: 10,
|
||||||
|
suffix: '%',
|
||||||
|
fractional: false,
|
||||||
|
values: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: {
|
||||||
|
en: 'DEF',
|
||||||
|
ja: '防御',
|
||||||
|
},
|
||||||
|
id: 12,
|
||||||
|
granblue_id: '',
|
||||||
|
slug: 'def',
|
||||||
|
minValue: 6,
|
||||||
|
maxValue: 20,
|
||||||
|
suffix: '%',
|
||||||
|
fractional: false,
|
||||||
|
values: [6, 7, 8, 9, 10, 12, 14, 16, 18, 20],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: {
|
||||||
|
en: 'Healing',
|
||||||
|
ja: '回復性能',
|
||||||
|
},
|
||||||
|
id: 13,
|
||||||
|
granblue_id: '',
|
||||||
|
slug: 'heal',
|
||||||
|
minValue: 3,
|
||||||
|
maxValue: 30,
|
||||||
|
suffix: '%',
|
||||||
|
fractional: false,
|
||||||
|
values: [3, 6, 9, 12, 15, 18, 21, 24, 27, 30],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: {
|
||||||
|
en: 'Debuff Resistance',
|
||||||
|
ja: '弱体耐性',
|
||||||
|
},
|
||||||
|
id: 14,
|
||||||
|
granblue_id: '',
|
||||||
|
slug: 'debuff-resist',
|
||||||
|
minValue: 6,
|
||||||
|
maxValue: 15,
|
||||||
|
suffix: '%',
|
||||||
|
fractional: false,
|
||||||
|
values: [6, 7, 8, 9, 10, 11, 12, 13, 14, 15],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: {
|
||||||
|
en: 'Dodge',
|
||||||
|
ja: '回避',
|
||||||
|
},
|
||||||
|
id: 15,
|
||||||
|
granblue_id: '',
|
||||||
|
slug: 'dodge',
|
||||||
|
minValue: 1,
|
||||||
|
maxValue: 10,
|
||||||
|
suffix: '%',
|
||||||
|
fractional: false,
|
||||||
|
values: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const overMastery = {
|
||||||
|
a: overMasteryPrimary,
|
||||||
|
b: overMasterySecondary,
|
||||||
|
c: overMasteryTertiary,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const aetherialMastery: ItemSkill[] = [
|
||||||
|
{
|
||||||
|
name: {
|
||||||
|
en: 'Double Attack',
|
||||||
|
ja: 'ダブルアタック確率',
|
||||||
|
},
|
||||||
|
id: 1,
|
||||||
|
granblue_id: '',
|
||||||
|
slug: 'da',
|
||||||
|
minValue: 10,
|
||||||
|
maxValue: 17,
|
||||||
|
suffix: '%',
|
||||||
|
fractional: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: {
|
||||||
|
en: 'Triple Attack',
|
||||||
|
ja: 'トリプルアタック確率',
|
||||||
|
},
|
||||||
|
id: 2,
|
||||||
|
granblue_id: '',
|
||||||
|
slug: 'ta',
|
||||||
|
minValue: 5,
|
||||||
|
maxValue: 12,
|
||||||
|
suffix: '%',
|
||||||
|
fractional: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: {
|
||||||
|
en: '{Element} ATK Up',
|
||||||
|
ja: '{属性}攻撃',
|
||||||
|
},
|
||||||
|
id: 3,
|
||||||
|
granblue_id: '',
|
||||||
|
slug: 'element-atk',
|
||||||
|
minValue: 15,
|
||||||
|
maxValue: 22,
|
||||||
|
suffix: '%',
|
||||||
|
fractional: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: {
|
||||||
|
en: '{Element} Resistance',
|
||||||
|
ja: '{属性}軽減',
|
||||||
|
},
|
||||||
|
id: 4,
|
||||||
|
granblue_id: '',
|
||||||
|
slug: 'element-resist',
|
||||||
|
minValue: 5,
|
||||||
|
maxValue: 12,
|
||||||
|
suffix: '%',
|
||||||
|
fractional: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: {
|
||||||
|
en: 'Stamina',
|
||||||
|
ja: '渾身',
|
||||||
|
},
|
||||||
|
id: 5,
|
||||||
|
granblue_id: '',
|
||||||
|
slug: 'stamina',
|
||||||
|
minValue: 5,
|
||||||
|
maxValue: 12,
|
||||||
|
suffix: '',
|
||||||
|
fractional: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: {
|
||||||
|
en: 'Enmity',
|
||||||
|
ja: '背水',
|
||||||
|
},
|
||||||
|
id: 6,
|
||||||
|
granblue_id: '',
|
||||||
|
slug: 'enmity',
|
||||||
|
minValue: 5,
|
||||||
|
maxValue: 12,
|
||||||
|
suffix: '',
|
||||||
|
fractional: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: {
|
||||||
|
en: 'Supplemental DMG',
|
||||||
|
ja: '与ダメ上昇',
|
||||||
|
},
|
||||||
|
id: 7,
|
||||||
|
granblue_id: '',
|
||||||
|
slug: 'supplemental',
|
||||||
|
minValue: 5,
|
||||||
|
maxValue: 12,
|
||||||
|
suffix: '',
|
||||||
|
fractional: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: {
|
||||||
|
en: 'Critical Hit',
|
||||||
|
ja: 'クリティカル',
|
||||||
|
},
|
||||||
|
id: 8,
|
||||||
|
granblue_id: '',
|
||||||
|
slug: 'crit',
|
||||||
|
minValue: 18,
|
||||||
|
maxValue: 35,
|
||||||
|
suffix: '%',
|
||||||
|
fractional: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: {
|
||||||
|
en: 'Counters on Dodge',
|
||||||
|
ja: 'カウンター(回避)',
|
||||||
|
},
|
||||||
|
id: 9,
|
||||||
|
granblue_id: '',
|
||||||
|
slug: 'counter-dodge',
|
||||||
|
minValue: 5,
|
||||||
|
maxValue: 12,
|
||||||
|
suffix: '%',
|
||||||
|
fractional: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: {
|
||||||
|
en: 'Counters on DMG',
|
||||||
|
ja: 'カウンター(被ダメ)',
|
||||||
|
},
|
||||||
|
id: 10,
|
||||||
|
granblue_id: '',
|
||||||
|
slug: 'counter-dmg',
|
||||||
|
minValue: 10,
|
||||||
|
maxValue: 17,
|
||||||
|
suffix: '%',
|
||||||
|
fractional: false,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const permanentMastery: ItemSkill[] = [
|
||||||
|
{
|
||||||
|
name: {
|
||||||
|
en: 'Extended Mastery Star Cap',
|
||||||
|
ja: 'LB強化回数上限',
|
||||||
|
},
|
||||||
|
id: 1,
|
||||||
|
granblue_id: '',
|
||||||
|
slug: 'star-cap',
|
||||||
|
minValue: 10,
|
||||||
|
maxValue: 10,
|
||||||
|
suffix: '',
|
||||||
|
fractional: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: {
|
||||||
|
en: 'ATK',
|
||||||
|
ja: '攻撃',
|
||||||
|
},
|
||||||
|
id: 2,
|
||||||
|
granblue_id: '',
|
||||||
|
slug: 'atk',
|
||||||
|
minValue: 10,
|
||||||
|
maxValue: 10,
|
||||||
|
suffix: '%',
|
||||||
|
fractional: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: {
|
||||||
|
en: 'HP',
|
||||||
|
ja: 'HP',
|
||||||
|
},
|
||||||
|
id: 3,
|
||||||
|
granblue_id: '',
|
||||||
|
slug: 'hp',
|
||||||
|
minValue: 10,
|
||||||
|
maxValue: 10,
|
||||||
|
suffix: '',
|
||||||
|
fractional: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: {
|
||||||
|
en: 'DMG Cap',
|
||||||
|
ja: 'ダメージ上限',
|
||||||
|
},
|
||||||
|
id: 4,
|
||||||
|
granblue_id: '',
|
||||||
|
slug: 'dmg-cap',
|
||||||
|
minValue: 5,
|
||||||
|
maxValue: 5,
|
||||||
|
suffix: '%',
|
||||||
|
fractional: false,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
// Type for ItemSkill
|
||||||
|
export interface ItemSkill {
|
||||||
|
name: {
|
||||||
|
en: string
|
||||||
|
ja: string
|
||||||
|
}
|
||||||
|
id: number
|
||||||
|
granblue_id: string
|
||||||
|
slug: string
|
||||||
|
minValue: number
|
||||||
|
maxValue: number
|
||||||
|
suffix: string
|
||||||
|
fractional: boolean
|
||||||
|
values?: number[]
|
||||||
|
}
|
||||||
|
|
||||||
62
src/lib/features/job/openJobSidebar.svelte.ts
Normal file
62
src/lib/features/job/openJobSidebar.svelte.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
import { sidebar } from '$lib/stores/sidebar.svelte'
|
||||||
|
import JobSelectionSidebar from '$lib/components/sidebar/JobSelectionSidebar.svelte'
|
||||||
|
import JobSkillSelectionSidebar from '$lib/components/sidebar/JobSkillSelectionSidebar.svelte'
|
||||||
|
import type { Job, JobSkill } from '$lib/types/api/entities'
|
||||||
|
import type { JobSkillList } from '$lib/types/api/party'
|
||||||
|
|
||||||
|
interface JobSelectionOptions {
|
||||||
|
currentJobId?: string
|
||||||
|
onSelectJob?: (job: Job) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface JobSkillSelectionOptions {
|
||||||
|
job?: Job
|
||||||
|
currentSkills?: JobSkillList
|
||||||
|
targetSlot: number
|
||||||
|
onSelectSkill?: (skill: JobSkill) => void
|
||||||
|
onRemoveSkill?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openJobSelectionSidebar(options: JobSelectionOptions) {
|
||||||
|
const { currentJobId, onSelectJob } = options
|
||||||
|
|
||||||
|
sidebar.openWithComponent(
|
||||||
|
'Select Job',
|
||||||
|
JobSelectionSidebar,
|
||||||
|
{
|
||||||
|
currentJobId,
|
||||||
|
onSelectJob: (job: Job) => {
|
||||||
|
onSelectJob?.(job)
|
||||||
|
sidebar.close()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
false // scrollable = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openJobSkillSelectionSidebar(options: JobSkillSelectionOptions) {
|
||||||
|
const { job, currentSkills, targetSlot, onSelectSkill, onRemoveSkill } = options
|
||||||
|
|
||||||
|
sidebar.openWithComponent(
|
||||||
|
`Select Skill - Slot ${targetSlot + 1}`,
|
||||||
|
JobSkillSelectionSidebar,
|
||||||
|
{
|
||||||
|
job,
|
||||||
|
currentSkills,
|
||||||
|
targetSlot,
|
||||||
|
onSelectSkill: (skill: JobSkill) => {
|
||||||
|
onSelectSkill?.(skill)
|
||||||
|
sidebar.close()
|
||||||
|
},
|
||||||
|
onRemoveSkill: () => {
|
||||||
|
onRemoveSkill?.()
|
||||||
|
sidebar.close()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
false // scrollable = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closeJobSidebar() {
|
||||||
|
sidebar.close()
|
||||||
|
}
|
||||||
268
src/lib/utils/jobUtils.ts
Normal file
268
src/lib/utils/jobUtils.ts
Normal file
|
|
@ -0,0 +1,268 @@
|
||||||
|
/**
|
||||||
|
* Job Utility Functions
|
||||||
|
*
|
||||||
|
* Helper functions for job-related operations including
|
||||||
|
* image URL generation, tier naming, and validation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Job, JobSkill } from '$lib/types/api/entities'
|
||||||
|
import type { JobSkillList } from '$lib/types/api/party'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gender options for job portraits
|
||||||
|
*/
|
||||||
|
export enum Gender {
|
||||||
|
Gran = 0, // Male protagonist (a)
|
||||||
|
Djeeta = 1 // Female protagonist (b)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate job portrait URL for protagonist slot (CharacterRep/CharacterUnit)
|
||||||
|
* These are smaller portrait images stored in /static/images/job-portraits/
|
||||||
|
*/
|
||||||
|
export function getJobPortraitUrl(job: Job | undefined, gender: Gender = Gender.Gran): string {
|
||||||
|
if (!job) {
|
||||||
|
return '/images/placeholders/placeholder-weapon-grid.png'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert job name to slug format (lowercase, spaces to hyphens)
|
||||||
|
const slug = job.name.en.toLowerCase().replace(/\s+/g, '-')
|
||||||
|
const genderSuffix = gender === Gender.Djeeta ? 'b' : 'a'
|
||||||
|
|
||||||
|
return `/images/job-portraits/${slug}_${genderSuffix}.png`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate full job image URL for JobSection component
|
||||||
|
* These are full job images stored in /static/images/jobs/
|
||||||
|
*/
|
||||||
|
export function getJobFullImageUrl(job: Job | undefined, gender: Gender = Gender.Gran): string {
|
||||||
|
if (!job) {
|
||||||
|
return '/images/placeholders/placeholder-weapon-grid.png'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert job name to slug format (lowercase, spaces to hyphens)
|
||||||
|
const slug = job.name.en.toLowerCase().replace(/\s+/g, '-')
|
||||||
|
const genderSuffix = gender === Gender.Djeeta ? 'b' : 'a'
|
||||||
|
|
||||||
|
return `/images/jobs/${slug}_${genderSuffix}.png`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate job icon URL
|
||||||
|
* Job icons are small square icons representing the job
|
||||||
|
* Images are stored locally in /static/images/job-icons/
|
||||||
|
*/
|
||||||
|
export function getJobIconUrl(granblueId: string | undefined): string {
|
||||||
|
if (!granblueId) {
|
||||||
|
return '/images/placeholders/placeholder-weapon-grid.png'
|
||||||
|
}
|
||||||
|
|
||||||
|
return `/images/job-icons/${granblueId}.png`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get job tier display name
|
||||||
|
* Converts internal row codes to user-friendly names
|
||||||
|
*/
|
||||||
|
export function getJobTierName(row: string | number): string {
|
||||||
|
const tierNames: Record<string, string> = {
|
||||||
|
'1': 'Class I',
|
||||||
|
'2': 'Class II',
|
||||||
|
'3': 'Class III',
|
||||||
|
'4': 'Class IV',
|
||||||
|
'5': 'Class V',
|
||||||
|
ex: 'Extra',
|
||||||
|
ex2: 'Extra II'
|
||||||
|
}
|
||||||
|
|
||||||
|
const rowStr = row.toString().toLowerCase()
|
||||||
|
return tierNames[rowStr] || `Class ${row}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get job tier order for sorting
|
||||||
|
* Returns a numeric value for sorting tiers
|
||||||
|
*/
|
||||||
|
export function getJobTierOrder(row: string | number): number {
|
||||||
|
const tierOrder: Record<string, number> = {
|
||||||
|
'1': 1,
|
||||||
|
'2': 2,
|
||||||
|
'3': 3,
|
||||||
|
'4': 4,
|
||||||
|
'5': 5,
|
||||||
|
ex: 6,
|
||||||
|
ex2: 7
|
||||||
|
}
|
||||||
|
|
||||||
|
const rowStr = row.toString().toLowerCase()
|
||||||
|
return tierOrder[rowStr] || 99
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a job supports accessories
|
||||||
|
*/
|
||||||
|
export function jobSupportsAccessories(job: Job | undefined): boolean {
|
||||||
|
return job?.accessory === true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the number of skill slots for a job
|
||||||
|
* Row 1 jobs have 3 slots, all others have 4
|
||||||
|
*/
|
||||||
|
export function getJobSkillSlotCount(job: Job | undefined): number {
|
||||||
|
if (!job) return 0
|
||||||
|
return job.row === 1 || job.row === '1' ? 3 : 4
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a skill slot is available for a job
|
||||||
|
*/
|
||||||
|
export function isSkillSlotAvailable(job: Job | undefined, slot: number): boolean {
|
||||||
|
if (!job) return false
|
||||||
|
const slotCount = getJobSkillSlotCount(job)
|
||||||
|
return slot >= 0 && slot < slotCount
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a skill slot is locked (cannot be changed)
|
||||||
|
* Slot 0 is locked when it contains a main skill
|
||||||
|
*/
|
||||||
|
export function isSkillSlotLocked(
|
||||||
|
slot: number,
|
||||||
|
job: Job | undefined,
|
||||||
|
jobSkills: JobSkillList | undefined
|
||||||
|
): boolean {
|
||||||
|
// Slot 0 is locked if it contains a main skill
|
||||||
|
return slot === 0 && jobSkills?.['0']?.main === true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get skill category display name
|
||||||
|
*/
|
||||||
|
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'
|
||||||
|
return 'Unknown'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get skill category color
|
||||||
|
* Returns CSS color variable name
|
||||||
|
*/
|
||||||
|
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)'
|
||||||
|
return 'var(--skill-default)'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format job proficiency for display
|
||||||
|
* Converts proficiency numbers to weapon type names
|
||||||
|
*/
|
||||||
|
export function formatJobProficiency(proficiency: [number, number]): string[] {
|
||||||
|
const weaponTypes: Record<number, string> = {
|
||||||
|
1: 'Sword',
|
||||||
|
2: 'Dagger',
|
||||||
|
3: 'Spear',
|
||||||
|
4: 'Axe',
|
||||||
|
5: 'Staff',
|
||||||
|
6: 'Gun',
|
||||||
|
7: 'Melee',
|
||||||
|
8: 'Bow',
|
||||||
|
9: 'Harp',
|
||||||
|
10: 'Katana'
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: string[] = []
|
||||||
|
if (proficiency[0] && weaponTypes[proficiency[0]]) {
|
||||||
|
result.push(weaponTypes[proficiency[0]])
|
||||||
|
}
|
||||||
|
if (proficiency[1] && weaponTypes[proficiency[1]]) {
|
||||||
|
result.push(weaponTypes[proficiency[1]])
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a job is an advanced job (Row IV, V, or Extra II)
|
||||||
|
*/
|
||||||
|
export function isAdvancedJob(job: Job): boolean {
|
||||||
|
const row = job.row.toString().toLowerCase()
|
||||||
|
return row === '4' || row === '5' || row === 'ex2'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count skills by type in current skill list
|
||||||
|
*/
|
||||||
|
export function countSkillsByType(skills: JobSkillList): {
|
||||||
|
main: number
|
||||||
|
sub: number
|
||||||
|
emp: number
|
||||||
|
base: number
|
||||||
|
} {
|
||||||
|
const counts = { main: 0, sub: 0, emp: 0, base: 0 }
|
||||||
|
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
const skill = skills[i as keyof 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++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return counts
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate if a skill configuration is valid for a job
|
||||||
|
*/
|
||||||
|
export function validateSkillConfiguration(
|
||||||
|
job: Job,
|
||||||
|
skills: JobSkillList
|
||||||
|
): { valid: boolean; errors: string[] } {
|
||||||
|
const errors: string[] = []
|
||||||
|
const counts = countSkillsByType(skills)
|
||||||
|
|
||||||
|
// Check for advanced job constraints
|
||||||
|
if (isAdvancedJob(job)) {
|
||||||
|
if (counts.sub > 2) {
|
||||||
|
errors.push('Maximum 2 subskills allowed for advanced jobs')
|
||||||
|
}
|
||||||
|
if (counts.emp > 2) {
|
||||||
|
errors.push('Maximum 2 EMP skills allowed for advanced jobs')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for Row 1 constraint
|
||||||
|
if ((job.row === 1 || job.row === '1') && skills[3]) {
|
||||||
|
errors.push('Row I jobs only support 3 skill slots')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for duplicate skills
|
||||||
|
const skillIds = new Set<string>()
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
const skill = skills[i as keyof JobSkillList]
|
||||||
|
if (skill) {
|
||||||
|
if (skillIds.has(skill.id)) {
|
||||||
|
errors.push(`Duplicate skill: ${skill.name.en}`)
|
||||||
|
}
|
||||||
|
skillIds.add(skill.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: errors.length === 0,
|
||||||
|
errors
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue