hensei-web/src/lib/components/job/JobSection.svelte

439 lines
9.9 KiB
Svelte

<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 { getAccessoryImage, getBasePath } from '$lib/utils/images'
import Icon from '$lib/components/Icon.svelte'
interface Props {
job?: Job | undefined
jobSkills?: JobSkillList | undefined
accessory?: JobAccessory | undefined
canEdit?: boolean | undefined
gender?: Gender | undefined
element?: number | undefined
onSelectJob?: (() => void) | undefined
onSelectSkill?: ((slot: number) => void) | undefined
onRemoveSkill?: ((slot: number) => void) | undefined
onSelectAccessory?: (() => void) | undefined
}
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) : '')
const jobBackgroundUrl = `${getBasePath()}/background_a.jpg`
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" style:background-image="url({jobBackgroundUrl})">
{#if job}
<img class="job-portrait" src={jobImageUrl} alt={job.name.en} />
<div class="overlay"></div>
{:else if canEdit}
<div class="empty-portrait">
<button class="select-job-button" on:click={onSelectJob}>
<Icon name="plus" size={24} />
<span>Select Job</span>
</button>
</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}>
<div class="job-name-row">
<img src={jobIconUrl} alt="{job.name.en} icon" class="job-icon" />
<h3>{job.name.en}</h3>
</div>
{#if job.masterLevel || job.ultimateMastery}
<div class="job-badges">
{#if job.masterLevel}
<span class="badge master">ML</span>
{/if}
{#if job.ultimateMastery}
<span class="badge ultimate">UM</span>
{/if}
</div>
{/if}
</button>
{:else}
<div class="job-name">
<div class="job-name-row">
<img src={jobIconUrl} alt="{job.name.en} icon" class="job-icon" />
<h3>{job.name.en}</h3>
</div>
{#if job.masterLevel || job.ultimateMastery}
<div class="job-badges">
{#if job.masterLevel}
<span class="badge master">ML</span>
{/if}
{#if job.ultimateMastery}
<span class="badge ultimate">UM</span>
{/if}
</div>
{/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={getAccessoryImage(accessory.granblueId)}
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" class:readonly={!canEdit}>
<p>{canEdit ? 'Select a job to view skills and details' : 'No job selected'}</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-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;
justify-content: space-between;
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-name-row {
display: flex;
align-items: center;
gap: spacing.$unit-half;
}
.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;
&.readonly {
color: var(--text-secondary);
}
}
}
</style>