add jobs database pages (list, detail, edit)

This commit is contained in:
Justin Edmund 2025-12-15 14:21:40 -08:00
parent 0cf7982809
commit 3f87d51a55
10 changed files with 1385 additions and 0 deletions

View file

@ -0,0 +1,90 @@
<svelte:options runes={true} />
<script lang="ts">
import type { Job } from '$lib/types/api/entities'
import DetailsContainer from '$lib/components/ui/DetailsContainer.svelte'
import DetailItem from '$lib/components/ui/DetailItem.svelte'
import Checkbox from '$lib/components/ui/checkbox/Checkbox.svelte'
import Select from '$lib/components/ui/Select.svelte'
interface Props {
job: Job
editMode?: boolean
editData?: any
}
let { job, editMode = false, editData = $bindable() }: Props = $props()
// Accessory type options
const accessoryTypeOptions = [
{ value: 0, label: 'None' },
{ value: 1, label: 'Shield' },
{ value: 2, label: 'Manatura' }
]
function getAccessoryTypeName(type: number | undefined): string {
const option = accessoryTypeOptions.find((o) => o.value === type)
return option?.label || '—'
}
function formatBoolean(value: boolean | undefined): string {
return value ? 'Yes' : 'No'
}
</script>
<DetailsContainer title="Features">
{#if editMode}
<DetailItem label="Master Level" editable={true}>
<Checkbox bind:checked={editData.masterLevel} contained />
</DetailItem>
<DetailItem label="Ultimate Mastery" editable={true}>
<Checkbox bind:checked={editData.ultimateMastery} contained />
</DetailItem>
<DetailItem label="Has Accessory" editable={true}>
<Checkbox bind:checked={editData.accessory} contained />
</DetailItem>
{#if editData.accessory}
<DetailItem label="Accessory Type" editable={true}>
<Select
size="medium"
options={accessoryTypeOptions}
bind:value={editData.accessoryType}
contained
/>
</DetailItem>
{/if}
{:else}
<DetailItem label="Master Level">
<span class="boolean-indicator" class:yes={job.masterLevel}>
{formatBoolean(job.masterLevel)}
</span>
</DetailItem>
<DetailItem label="Ultimate Mastery">
<span class="boolean-indicator" class:yes={job.ultimateMastery}>
{formatBoolean(job.ultimateMastery)}
</span>
</DetailItem>
<DetailItem label="Has Accessory">
<span class="boolean-indicator" class:yes={job.accessory}>
{formatBoolean(job.accessory)}
</span>
</DetailItem>
{#if job.accessory}
<DetailItem label="Accessory Type" value={getAccessoryTypeName(job.accessoryType)} />
{/if}
{/if}
</DetailsContainer>
<style lang="scss">
@use '$src/themes/colors' as colors;
@use '$src/themes/typography' as typography;
.boolean-indicator {
font-size: typography.$font-regular;
color: colors.$grey-50;
&.yes {
color: colors.$wind-bg-00;
}
}
</style>

View file

@ -0,0 +1,77 @@
<svelte:options runes={true} />
<script lang="ts">
import type { Job } from '$lib/types/api/entities'
import DetailsContainer from '$lib/components/ui/DetailsContainer.svelte'
import DetailItem from '$lib/components/ui/DetailItem.svelte'
import CopyableText from '$lib/components/ui/CopyableText.svelte'
import Select from '$lib/components/ui/Select.svelte'
import { getJobTierName } from '$lib/utils/jobUtils'
interface Props {
job: Job
editMode?: boolean
editData?: any
}
let { job, editMode = false, editData = $bindable() }: Props = $props()
// Row options for the select dropdown
const rowOptions = [
{ value: '1', label: 'Class I' },
{ value: '2', label: 'Class II' },
{ value: '3', label: 'Class III' },
{ value: '4', label: 'Class IV' },
{ value: '5', label: 'Class V' },
{ value: 'ex', label: 'Extra' },
{ value: 'ex2', label: 'Extra II' }
]
</script>
<DetailsContainer title="Metadata">
{#if editMode}
<DetailItem
label="Name (EN)"
bind:value={editData.name}
editable={true}
type="text"
placeholder="English name"
/>
<DetailItem
label="Name (JP)"
bind:value={editData.nameJp}
editable={true}
type="text"
placeholder="日本語名"
/>
<DetailItem
label="Granblue ID"
bind:value={editData.granblueId}
editable={true}
type="text"
placeholder="Granblue ID"
/>
<DetailItem label="Row" editable={true}>
<Select size="medium" options={rowOptions} bind:value={editData.row} contained />
</DetailItem>
<DetailItem
label="Order"
bind:value={editData.order}
editable={true}
type="number"
placeholder="Display order"
/>
{:else}
<DetailItem label="Name (EN)" value={job.name?.en || '—'} />
<DetailItem label="Name (JP)" value={job.name?.ja || '—'} />
<DetailItem label="Granblue ID">
{#if job.granblueId}
<CopyableText value={job.granblueId} />
{:else}
{/if}
</DetailItem>
<DetailItem label="Row" value={getJobTierName(job.row)} />
<DetailItem label="Order" value={job.order?.toString() || '—'} />
{/if}
</DetailsContainer>

View file

@ -0,0 +1,77 @@
<svelte:options runes={true} />
<script lang="ts">
import type { Job } from '$lib/types/api/entities'
import DetailsContainer from '$lib/components/ui/DetailsContainer.svelte'
import DetailItem from '$lib/components/ui/DetailItem.svelte'
import Select from '$lib/components/ui/Select.svelte'
import { getProficiencyLabelImage } from '$lib/utils/images'
import { formatJobProficiency } from '$lib/utils/jobUtils'
interface Props {
job: Job
editMode?: boolean
editData?: any
}
let { job, editMode = false, editData = $bindable() }: Props = $props()
// Proficiency options for the select dropdowns
const proficiencyOptions = [
{ value: 0, label: 'None' },
{ value: 1, label: 'Sabre' },
{ value: 2, label: 'Dagger' },
{ value: 3, label: 'Axe' },
{ value: 4, label: 'Spear' },
{ value: 5, label: 'Bow' },
{ value: 6, label: 'Staff' },
{ value: 7, label: 'Melee' },
{ value: 8, label: 'Harp' },
{ value: 9, label: 'Gun' },
{ value: 10, label: 'Katana' }
]
// Get proficiency names for display
const proficiencyNames = $derived(formatJobProficiency(job.proficiency))
</script>
<DetailsContainer title="Proficiencies">
{#if editMode}
<DetailItem label="Proficiency 1" editable={true}>
<Select size="medium" options={proficiencyOptions} bind:value={editData.proficiency1} contained />
</DetailItem>
<DetailItem label="Proficiency 2" editable={true}>
<Select size="medium" options={proficiencyOptions} bind:value={editData.proficiency2} contained />
</DetailItem>
{:else}
<DetailItem label="Proficiency 1">
{#if proficiencyNames[0]}
<img
src={getProficiencyLabelImage(proficiencyNames[0])}
alt={proficiencyNames[0]}
class="proficiency-icon"
/>
{:else}
{/if}
</DetailItem>
<DetailItem label="Proficiency 2">
{#if proficiencyNames[1]}
<img
src={getProficiencyLabelImage(proficiencyNames[1])}
alt={proficiencyNames[1]}
class="proficiency-icon"
/>
{:else}
{/if}
</DetailItem>
{/if}
</DetailsContainer>
<style lang="scss">
.proficiency-icon {
height: 24px;
width: auto;
}
</style>

View file

@ -0,0 +1,163 @@
<svelte:options runes={true} />
<script lang="ts">
import type { Job } from '$lib/types/api/entities'
import {
getJobPortraitUrl,
getJobFullImageUrl,
getJobIconUrl,
getJobWideImageUrl,
Gender
} from '$lib/utils/jobUtils'
import SegmentedControl from '$lib/components/ui/segmented-control/SegmentedControl.svelte'
import Segment from '$lib/components/ui/segmented-control/Segment.svelte'
interface Props {
job: Job
}
let { job }: Props = $props()
// Gender toggle state - use string value for SegmentedControl
let selectedGenderValue = $state<string>('gran')
// Convert string value to Gender enum
const selectedGender = $derived(selectedGenderValue === 'djeeta' ? Gender.Djeeta : Gender.Gran)
// Compute image URLs based on selected gender
const images = $derived({
portrait: getJobPortraitUrl(job, selectedGender),
full: getJobFullImageUrl(job, selectedGender),
icon: getJobIconUrl(job.granblueId),
wide: getJobWideImageUrl(job, selectedGender)
})
function handleGenderChange(value: string) {
selectedGenderValue = value
}
</script>
<div class="images-tab">
<div class="gender-toggle">
<SegmentedControl value={selectedGenderValue} onValueChange={handleGenderChange}>
<Segment value="gran">Gran</Segment>
<Segment value="djeeta">Djeeta</Segment>
</SegmentedControl>
</div>
<div class="images-grid">
<div class="image-item">
<a href={images.portrait} target="_blank" rel="noopener noreferrer" class="image-container portrait">
<img src={images.portrait} alt="{job.name.en} Portrait" loading="lazy" />
</a>
<span class="image-label">Portrait</span>
</div>
<div class="image-item">
<a href={images.full} target="_blank" rel="noopener noreferrer" class="image-container full">
<img src={images.full} alt="{job.name.en} Full" loading="lazy" />
</a>
<span class="image-label">Full</span>
</div>
<div class="image-item">
<a href={images.icon} target="_blank" rel="noopener noreferrer" class="image-container icon">
<img src={images.icon} alt="{job.name.en} Icon" loading="lazy" />
</a>
<span class="image-label">Icon</span>
<span class="image-sublabel">(No gender variant)</span>
</div>
<div class="image-item wide-item">
<a href={images.wide} target="_blank" rel="noopener noreferrer" class="image-container wide">
<img src={images.wide} alt="{job.name.en} Wide" loading="lazy" />
</a>
<span class="image-label">Wide Banner</span>
</div>
</div>
</div>
<style lang="scss">
@use '$src/themes/colors' as colors;
@use '$src/themes/spacing' as spacing;
@use '$src/themes/typography' as typography;
@use '$src/themes/layout' as layout;
.images-tab {
padding: spacing.$unit-2x;
}
.gender-toggle {
margin-bottom: spacing.$unit-2x;
display: flex;
justify-content: center;
}
.images-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: spacing.$unit-2x;
}
.image-item {
display: flex;
flex-direction: column;
align-items: center;
gap: spacing.$unit;
}
.wide-item {
grid-column: span 2;
}
.image-container {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
background: colors.$grey-90;
border-radius: layout.$item-corner;
overflow: hidden;
transition: transform 0.2s ease;
&:hover {
transform: scale(1.02);
}
img {
display: block;
max-width: 100%;
max-height: 100%;
width: auto;
height: auto;
object-fit: contain;
}
}
.portrait,
.full {
aspect-ratio: 1;
}
.icon {
aspect-ratio: 1;
max-width: 100px;
}
.wide {
aspect-ratio: 2.5 / 1;
}
.image-label {
font-size: typography.$font-small;
color: colors.$grey-40;
text-align: center;
}
.image-sublabel {
font-size: typography.$font-tiny;
color: colors.$grey-60;
text-align: center;
}
</style>

View file

@ -0,0 +1,240 @@
<svelte:options runes={true} />
<script lang="ts">
import type { Job, JobSkill } from '$lib/types/api/entities'
import { createQuery } from '@tanstack/svelte-query'
import { jobQueries } from '$lib/api/queries/job.queries'
import { getJobSkillIcon } from '$lib/utils/images'
import { getSkillCategoryName, getSkillCategoryColor } from '$lib/utils/jobUtils'
interface Props {
job: Job
}
let { job }: Props = $props()
// Fetch skills for this job
const skillsQuery = createQuery(() => jobQueries.skillsByJob(job.granblueId))
// Group skills by type
const groupedSkills = $derived.by(() => {
const skills = skillsQuery.data ?? []
const groups: { main: JobSkill[]; sub: JobSkill[]; emp: JobSkill[]; base: JobSkill[] } = {
main: [],
sub: [],
emp: [],
base: []
}
for (const skill of skills) {
if (skill.main) groups.main.push(skill)
else if (skill.sub) groups.sub.push(skill)
else if (skill.emp) groups.emp.push(skill)
else if (skill.base) groups.base.push(skill)
}
// Sort each group by order
for (const key of Object.keys(groups) as Array<keyof typeof groups>) {
groups[key].sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
}
return groups
})
// Check if there are any skills in a group
function hasSkills(group: keyof typeof groupedSkills): boolean {
return groupedSkills[group].length > 0
}
</script>
<div class="skills-tab">
{#if skillsQuery.isLoading}
<div class="loading">Loading skills...</div>
{:else if skillsQuery.isError}
<div class="error">Failed to load skills</div>
{:else if !skillsQuery.data?.length}
<div class="empty">No skills found for this job</div>
{:else}
{#if hasSkills('main')}
<section class="skill-group">
<h3 class="group-title">Main Skills</h3>
<div class="skill-list">
{#each groupedSkills.main as skill}
<div class="skill-item">
<img src={getJobSkillIcon(skill.slug)} alt={skill.name.en} class="skill-icon" />
<div class="skill-info">
<span class="skill-name">{skill.name.en}</span>
{#if skill.name.ja}
<span class="skill-name-jp">{skill.name.ja}</span>
{/if}
</div>
<span class="skill-category" style="background: {getSkillCategoryColor(skill)}">
{getSkillCategoryName(skill)}
</span>
</div>
{/each}
</div>
</section>
{/if}
{#if hasSkills('sub')}
<section class="skill-group">
<h3 class="group-title">Subskills</h3>
<div class="skill-list">
{#each groupedSkills.sub as skill}
<div class="skill-item">
<img src={getJobSkillIcon(skill.slug)} alt={skill.name.en} class="skill-icon" />
<div class="skill-info">
<span class="skill-name">{skill.name.en}</span>
{#if skill.name.ja}
<span class="skill-name-jp">{skill.name.ja}</span>
{/if}
</div>
<span class="skill-category" style="background: {getSkillCategoryColor(skill)}">
{getSkillCategoryName(skill)}
</span>
</div>
{/each}
</div>
</section>
{/if}
{#if hasSkills('emp')}
<section class="skill-group">
<h3 class="group-title">EMP Skills</h3>
<div class="skill-list">
{#each groupedSkills.emp as skill}
<div class="skill-item">
<img src={getJobSkillIcon(skill.slug)} alt={skill.name.en} class="skill-icon" />
<div class="skill-info">
<span class="skill-name">{skill.name.en}</span>
{#if skill.name.ja}
<span class="skill-name-jp">{skill.name.ja}</span>
{/if}
</div>
<span class="skill-category" style="background: {getSkillCategoryColor(skill)}">
{getSkillCategoryName(skill)}
</span>
</div>
{/each}
</div>
</section>
{/if}
{#if hasSkills('base')}
<section class="skill-group">
<h3 class="group-title">Base Skills</h3>
<div class="skill-list">
{#each groupedSkills.base as skill}
<div class="skill-item">
<img src={getJobSkillIcon(skill.slug)} alt={skill.name.en} class="skill-icon" />
<div class="skill-info">
<span class="skill-name">{skill.name.en}</span>
{#if skill.name.ja}
<span class="skill-name-jp">{skill.name.ja}</span>
{/if}
</div>
<span class="skill-category" style="background: {getSkillCategoryColor(skill)}">
{getSkillCategoryName(skill)}
</span>
</div>
{/each}
</div>
</section>
{/if}
{/if}
</div>
<style lang="scss">
@use '$src/themes/colors' as colors;
@use '$src/themes/spacing' as spacing;
@use '$src/themes/typography' as typography;
@use '$src/themes/layout' as layout;
@use '$src/themes/effects' as effects;
.skills-tab {
display: flex;
flex-direction: column;
gap: spacing.$unit-2x;
}
.loading,
.error,
.empty {
text-align: center;
padding: spacing.$unit * 4;
color: colors.$grey-50;
}
.error {
color: colors.$red;
}
.skill-group {
background: white;
border-radius: layout.$card-corner;
box-shadow: effects.$page-elevation;
padding: spacing.$unit-2x;
}
.group-title {
font-size: typography.$font-large;
font-weight: typography.$bold;
margin: 0 0 spacing.$unit 0;
padding-bottom: spacing.$unit;
border-bottom: 1px solid colors.$grey-90;
}
.skill-list {
display: flex;
flex-direction: column;
gap: spacing.$unit;
}
.skill-item {
display: flex;
align-items: center;
gap: spacing.$unit;
padding: spacing.$unit;
background: colors.$grey-95;
border-radius: layout.$item-corner;
transition: background-color 0.15s ease;
&:hover {
background: colors.$grey-90;
}
}
.skill-icon {
width: 40px;
height: 40px;
border-radius: 4px;
object-fit: cover;
}
.skill-info {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
}
.skill-name {
font-size: typography.$font-medium;
font-weight: typography.$medium;
}
.skill-name-jp {
font-size: typography.$font-small;
color: colors.$grey-50;
}
.skill-category {
font-size: typography.$font-tiny;
font-weight: typography.$medium;
padding: 2px 8px;
border-radius: 12px;
color: white;
white-space: nowrap;
}
</style>

View file

@ -0,0 +1,320 @@
<svelte:options runes={true} />
<script lang="ts">
import { goto } from '$app/navigation'
import { createQuery } from '@tanstack/svelte-query'
import { jobQueries } from '$lib/api/queries/job.queries'
import PageMeta from '$lib/components/PageMeta.svelte'
import * as m from '$lib/paraglide/messages'
import { getJobIconUrl, getJobTierName } from '$lib/utils/jobUtils'
import ProficiencyLabel from '$lib/components/labels/ProficiencyLabel.svelte'
import type { Job } from '$lib/types/api/entities'
// Fetch all jobs
const jobsQuery = createQuery(() => jobQueries.list())
// Search state
let searchTerm = $state('')
// Filter jobs based on search
const filteredJobs = $derived.by(() => {
const jobs = jobsQuery.data ?? []
if (!searchTerm.trim()) return jobs
const term = searchTerm.toLowerCase()
return jobs.filter(
(job) =>
job.name.en.toLowerCase().includes(term) ||
job.name.ja?.toLowerCase().includes(term) ||
job.granblueId.includes(term) ||
getJobTierName(job.row).toLowerCase().includes(term)
)
})
// Row order mapping for sorting
const rowOrder: Record<string, number> = {
'1': 1,
'2': 2,
'3': 3,
'4': 4,
'5': 5,
ex: 6,
ex1: 6,
ex2: 7
}
// Sort jobs - always by row first, then by order within each row
const sortedJobs = $derived.by(() => {
const jobs = [...filteredJobs]
jobs.sort((a, b) => {
// Primary sort: by row
const rowA = rowOrder[a.row?.toString() || ''] || 99
const rowB = rowOrder[b.row?.toString() || ''] || 99
if (rowA !== rowB) {
return rowA - rowB
}
// Secondary sort: by order within the same row
return (a.order || 0) - (b.order || 0)
})
return jobs
})
function handleRowClick(job: Job) {
goto(`/database/jobs/${job.granblueId}`)
}
</script>
<PageMeta title={m.page_title_db_jobs()} description={m.page_desc_home()} />
<div class="page">
<div class="grid-container">
<div class="controls">
<input type="text" placeholder="Search jobs..." bind:value={searchTerm} class="search" />
</div>
{#if jobsQuery.isLoading}
<div class="loading">Loading jobs...</div>
{:else if jobsQuery.isError}
<div class="error">Failed to load jobs</div>
{:else}
<div class="table-wrapper">
<table class="data-table">
<thead>
<tr>
<th class="col-image">Image</th>
<th class="col-name">Name</th>
<th class="col-row">Row</th>
<th class="col-proficiency">Proficiencies</th>
<th class="col-features">Features</th>
</tr>
</thead>
<tbody>
{#each sortedJobs as job (job.id)}
<tr onclick={() => handleRowClick(job)} class="clickable">
<td class="col-image">
<img src={getJobIconUrl(job.granblueId)} alt={job.name.en} class="job-icon" />
</td>
<td class="col-name">
<div class="name-cell">
<span class="name-en">{job.name.en}</span>
</div>
</td>
<td class="col-row">
<span class="tier-badge">{getJobTierName(job.row)}</span>
</td>
<td class="col-proficiency">
<div class="proficiency-icons">
{#if job.proficiency?.[0]}
<ProficiencyLabel proficiency={job.proficiency[0]} size="small" />
{/if}
{#if job.proficiency?.[1]}
<ProficiencyLabel proficiency={job.proficiency[1]} size="small" />
{/if}
</div>
</td>
<td class="col-features">
<div class="features">
{#if job.masterLevel}
<span class="badge master">Master</span>
{/if}
{#if job.ultimateMastery}
<span class="badge ultimate">Ultimate</span>
{/if}
{#if job.accessory}
<span class="badge accessory">Accessory</span>
{/if}
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
<div class="footer">
Showing {sortedJobs.length} of {jobsQuery.data?.length ?? 0} jobs
</div>
{/if}
</div>
</div>
<style lang="scss">
@use '$src/themes/colors' as colors;
@use '$src/themes/effects' as effects;
@use '$src/themes/layout' as layout;
@use '$src/themes/spacing' as spacing;
@use '$src/themes/typography' as typography;
.page {
padding: spacing.$unit-2x 0;
margin: 0 auto;
}
.grid-container {
background: var(--card-bg);
border: 0.5px solid rgba(0, 0, 0, 0.18);
border-radius: layout.$page-corner;
box-shadow: effects.$page-elevation;
overflow: hidden;
}
.controls {
display: flex;
justify-content: space-between;
align-items: center;
padding: spacing.$unit;
border-bottom: 1px solid #e5e5e5;
gap: spacing.$unit;
.search {
padding: spacing.$unit spacing.$unit-2x;
background: var(--input-bound-bg);
border: none;
border-radius: layout.$item-corner;
font-size: typography.$font-medium;
width: 100%;
&:hover {
background: var(--input-bound-bg-hover);
}
&:focus {
outline: none;
box-shadow: 0 0 0 2px colors.$blue;
}
}
}
.loading,
.error {
text-align: center;
padding: spacing.$unit * 4;
color: colors.$grey-50;
}
.error {
color: colors.$red;
}
.table-wrapper {
overflow-x: auto;
}
.data-table {
width: 100%;
border-collapse: collapse;
th,
td {
padding: spacing.$unit-2x spacing.$unit;
text-align: left;
border-bottom: 1px solid #dee2e6;
}
th {
background: #f8f9fa;
font-weight: typography.$bold;
color: #495057;
white-space: nowrap;
}
tbody tr {
&.clickable {
cursor: pointer;
&:hover {
background: #f8f9fa;
}
}
}
}
.col-image {
width: 60px;
padding-left: spacing.$unit-2x !important;
}
.col-name {
min-width: 180px;
}
.col-row {
width: 100px;
}
.col-proficiency {
width: 100px;
}
.col-features {
width: 200px;
}
.job-icon {
width: auto;
height: 28px;
border-radius: 4px;
}
.name-cell {
display: flex;
flex-direction: column;
gap: 2px;
}
.tier-badge {
display: inline-block;
padding: 2px 8px;
background: colors.$grey-90;
border-radius: 4px;
font-size: typography.$font-small;
color: colors.$grey-30;
}
.proficiency-icons {
display: flex;
gap: 4px;
align-items: center;
}
.features {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.badge {
display: inline-block;
padding: 2px 6px;
border-radius: 4px;
font-size: typography.$font-tiny;
font-weight: typography.$medium;
&.master {
background: colors.$yellow;
color: white;
}
&.ultimate {
background: colors.$dark-bg-00;
color: white;
}
&.accessory {
background: colors.$blue;
color: white;
}
}
.footer {
padding: spacing.$unit;
text-align: center;
color: colors.$grey-50;
font-size: typography.$font-small;
background: #f8f9fa;
border-top: 1px solid #e5e5e5;
}
</style>

View file

@ -0,0 +1,29 @@
import type { PageServerLoad } from './$types'
import { jobAdapter } from '$lib/api/adapters/job.adapter'
import { error } from '@sveltejs/kit'
export const load: PageServerLoad = async ({ params, parent }) => {
try {
// Get parent data to access role
const parentData = await parent()
const job = await jobAdapter.getById(params.granblueId)
if (!job) {
throw error(404, 'Job not found')
}
return {
job,
role: parentData.role
}
} catch (err) {
console.error('Failed to load job:', err)
if (err instanceof Error && 'status' in err && err.status === 404) {
throw error(404, 'Job not found')
}
throw error(500, 'Failed to load job')
}
}

View file

@ -0,0 +1,154 @@
<svelte:options runes={true} />
<script lang="ts">
// SvelteKit imports
import { goto } from '$app/navigation'
import { page } from '$app/stores'
// Page metadata
import PageMeta from '$lib/components/PageMeta.svelte'
import * as m from '$lib/paraglide/messages'
// TanStack Query
import { createQuery } from '@tanstack/svelte-query'
import { jobQueries } from '$lib/api/queries/job.queries'
import { withInitialData } from '$lib/query/ssr'
// Components
import DetailScaffold, { type DetailTab } from '$lib/features/database/detail/DetailScaffold.svelte'
import SegmentedControl from '$lib/components/ui/segmented-control/SegmentedControl.svelte'
import Segment from '$lib/components/ui/segmented-control/Segment.svelte'
// Section Components
import JobMetadataSection from '$lib/features/database/jobs/sections/JobMetadataSection.svelte'
import JobProficiencySection from '$lib/features/database/jobs/sections/JobProficiencySection.svelte'
import JobFeaturesSection from '$lib/features/database/jobs/sections/JobFeaturesSection.svelte'
// Tab Components
import JobSkillsTab from '$lib/features/database/jobs/tabs/JobSkillsTab.svelte'
import JobImagesTab from '$lib/features/database/jobs/tabs/JobImagesTab.svelte'
// Utils
import { getJobIconUrl } from '$lib/utils/jobUtils'
// Types
import type { PageData } from './$types'
type JobTab = 'info' | 'skills' | 'images'
let { data }: { data: PageData } = $props()
// Tab state from URL
const currentTab = $derived(($page.url.searchParams.get('tab') as JobTab) || 'info')
function handleTabChange(tab: string) {
const url = new URL($page.url)
if (tab === 'info') {
url.searchParams.delete('tab')
} else {
url.searchParams.set('tab', tab)
}
goto(url.toString(), { replaceState: true })
}
// Use TanStack Query with SSR initial data
const jobQuery = createQuery(() => ({
...jobQueries.byId(data.job?.granblueId ?? ''),
...withInitialData(data.job)
}))
// Get job from query
const job = $derived(jobQuery.data)
const userRole = $derived(data.role || 0)
const canEdit = $derived(userRole >= 7)
// Edit URL for navigation
const editUrl = $derived(job?.granblueId ? `/database/jobs/${job.granblueId}/edit` : undefined)
// Page title
const pageTitle = $derived(m.page_title_db_entity({ name: job?.name?.en ?? 'Job' }))
</script>
<PageMeta title={pageTitle} description={m.page_desc_home()} />
<div class="page">
{#if job}
<DetailScaffold
type="job"
item={job}
image={getJobIconUrl(job.granblueId)}
showEdit={canEdit}
editUrl={canEdit ? editUrl : undefined}
currentTab={currentTab as DetailTab}
onTabChange={handleTabChange}
showTabs={false}
>
<div class="tabs-bar">
<SegmentedControl value={currentTab} onValueChange={handleTabChange} variant="background" size="small">
<Segment value="info">Info</Segment>
<Segment value="skills">Skills</Segment>
<Segment value="images">Images</Segment>
</SegmentedControl>
</div>
{#if currentTab === 'info'}
<section class="details">
<JobMetadataSection {job} />
<JobProficiencySection {job} />
<JobFeaturesSection {job} />
</section>
{:else if currentTab === 'skills'}
<JobSkillsTab {job} />
{:else if currentTab === 'images'}
<JobImagesTab {job} />
{/if}
</DetailScaffold>
{:else}
<div class="not-found">
<h2>Job Not Found</h2>
<p>The job you're looking for could not be found.</p>
<button onclick={() => goto('/database/jobs')}>Back to Jobs</button>
</div>
{/if}
</div>
<style lang="scss">
@use '$src/themes/colors' as colors;
@use '$src/themes/layout' as layout;
@use '$src/themes/spacing' as spacing;
.page {
background: white;
border-radius: layout.$card-corner;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.tabs-bar {
padding: spacing.$unit-2x;
border-bottom: 1px solid colors.$grey-90;
}
.details {
display: flex;
flex-direction: column;
}
.not-found {
text-align: center;
padding: spacing.$unit * 4;
button {
background: colors.$blue;
color: white;
border: none;
padding: spacing.$unit spacing.$unit-2x;
border-radius: layout.$item-corner;
cursor: pointer;
margin-top: spacing.$unit;
&:hover {
filter: brightness(0.9);
}
}
}
</style>

View file

@ -0,0 +1,34 @@
import type { PageServerLoad } from './$types'
import { jobAdapter } from '$lib/api/adapters/job.adapter'
import { error, redirect } from '@sveltejs/kit'
export const load: PageServerLoad = async ({ params, parent }) => {
// Get parent data to access role
const parentData = await parent()
// Role check - must be editor level (>= 7) to edit
if (!parentData.role || parentData.role < 7) {
throw redirect(303, `/database/jobs/${params.granblueId}`)
}
try {
const job = await jobAdapter.getById(params.granblueId)
if (!job) {
throw error(404, 'Job not found')
}
return {
job,
role: parentData.role
}
} catch (err) {
console.error('Failed to load job:', err)
if (err instanceof Error && 'status' in err && err.status === 404) {
throw error(404, 'Job not found')
}
throw error(500, 'Failed to load job')
}
}

View file

@ -0,0 +1,201 @@
<svelte:options runes={true} />
<script lang="ts">
// SvelteKit imports
import { goto } from '$app/navigation'
// Page metadata
import PageMeta from '$lib/components/PageMeta.svelte'
import * as m from '$lib/paraglide/messages'
// TanStack Query
import { createQuery, useQueryClient } from '@tanstack/svelte-query'
import { jobQueries, jobKeys } from '$lib/api/queries/job.queries'
import { jobAdapter } from '$lib/api/adapters/job.adapter'
import { withInitialData } from '$lib/query/ssr'
// Components
import DetailScaffold from '$lib/features/database/detail/DetailScaffold.svelte'
// Section Components
import JobMetadataSection from '$lib/features/database/jobs/sections/JobMetadataSection.svelte'
import JobProficiencySection from '$lib/features/database/jobs/sections/JobProficiencySection.svelte'
import JobFeaturesSection from '$lib/features/database/jobs/sections/JobFeaturesSection.svelte'
// Utils
import { getJobIconUrl } from '$lib/utils/jobUtils'
// Types
import type { PageData } from './$types'
let { data }: { data: PageData } = $props()
const queryClient = useQueryClient()
// Use TanStack Query with SSR initial data
const jobQuery = createQuery(() => ({
...jobQueries.byId(data.job?.granblueId ?? ''),
...withInitialData(data.job)
}))
// Get job from query
const job = $derived(jobQuery.data)
// Always in edit mode
const editMode = true
// Save state
let isSaving = $state(false)
let saveError = $state<string | null>(null)
let saveSuccess = $state(false)
// Editable fields - initialized from job data
let editData = $state({
name: '',
nameJp: '',
granblueId: '',
row: '1',
order: 0,
proficiency1: 0,
proficiency2: 0,
masterLevel: false,
ultimateMastery: false,
accessory: false,
accessoryType: 0
})
// Populate edit data when job loads
$effect(() => {
if (job) {
editData = {
name: job.name?.en || '',
nameJp: job.name?.ja || '',
granblueId: job.granblueId || '',
row: job.row?.toString() || '1',
order: job.order || 0,
proficiency1: job.proficiency?.[0] || 0,
proficiency2: job.proficiency?.[1] || 0,
masterLevel: job.masterLevel || false,
ultimateMastery: job.ultimateMastery || false,
accessory: job.accessory || false,
accessoryType: job.accessoryType || 0
}
}
})
async function saveChanges() {
if (!job?.granblueId) return
isSaving = true
saveError = null
saveSuccess = false
try {
// Prepare the data for API (flat snake_case format)
const payload = {
name_en: editData.name,
name_jp: editData.nameJp,
granblue_id: editData.granblueId,
row: editData.row,
order: editData.order,
proficiency1: editData.proficiency1,
proficiency2: editData.proficiency2,
master_level: editData.masterLevel,
ultimate_mastery: editData.ultimateMastery,
accessory: editData.accessory,
accessory_type: editData.accessoryType
}
await jobAdapter.updateJob(job.granblueId, payload)
// Invalidate TanStack Query cache to refetch fresh data
await queryClient.invalidateQueries({ queryKey: jobKeys.all })
saveSuccess = true
// Navigate back to detail page after a short delay
setTimeout(() => {
goto(`/database/jobs/${editData.granblueId}`)
}, 500)
} catch (error) {
saveError = 'Failed to save changes. Please try again.'
console.error('Save error:', error)
} finally {
isSaving = false
}
}
function handleCancel() {
goto(`/database/jobs/${job?.granblueId}`)
}
// Page title
const pageTitle = $derived(m.page_title_db_edit({ name: job?.name?.en ?? 'Job' }))
</script>
<PageMeta title={pageTitle} description={m.page_desc_home()} />
<div class="page">
{#if job}
<DetailScaffold
type="job"
item={job}
image={getJobIconUrl(job.granblueId)}
showEdit={true}
{editMode}
{isSaving}
{saveSuccess}
{saveError}
onSave={saveChanges}
onCancel={handleCancel}
>
<section class="details">
<JobMetadataSection {job} {editMode} bind:editData />
<JobProficiencySection {job} {editMode} bind:editData />
<JobFeaturesSection {job} {editMode} bind:editData />
</section>
</DetailScaffold>
{:else}
<div class="not-found">
<h2>Job Not Found</h2>
<p>The job you're looking for could not be found.</p>
<button onclick={() => goto('/database/jobs')}>Back to Jobs</button>
</div>
{/if}
</div>
<style lang="scss">
@use '$src/themes/colors' as colors;
@use '$src/themes/layout' as layout;
@use '$src/themes/spacing' as spacing;
.page {
background: white;
border-radius: layout.$card-corner;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.details {
display: flex;
flex-direction: column;
}
.not-found {
text-align: center;
padding: spacing.$unit * 4;
button {
background: colors.$blue;
color: white;
border: none;
padding: spacing.$unit spacing.$unit-2x;
border-radius: layout.$item-corner;
cursor: pointer;
margin-top: spacing.$unit;
&:hover {
filter: brightness(0.9);
}
}
}
</style>