add job system

This commit is contained in:
Justin Edmund 2025-09-29 23:45:50 -07:00
parent 9537c57485
commit 4161a615ba
14 changed files with 3583 additions and 1 deletions

View 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)

View 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
}

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View file

@ -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;

View 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>

View 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
View 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[]
}

View 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
View 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
}
}