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 { DropdownMenu } from 'bits-ui'
|
||||
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 {
|
||||
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
|
||||
async function handleAddItems(items: SearchResult[]) {
|
||||
if (items.length === 0 || !canEdit()) return
|
||||
|
|
@ -863,7 +1058,29 @@
|
|||
{:else if activeTab === GridType.Summon}
|
||||
<SummonGrid summons={party.summons} />
|
||||
{: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}
|
||||
</div>
|
||||
</section>
|
||||
|
|
@ -1181,6 +1398,12 @@
|
|||
min-height: 400px;
|
||||
}
|
||||
|
||||
.character-tab-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $unit-2x;
|
||||
}
|
||||
|
||||
// Edit form styles
|
||||
.edit-form {
|
||||
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