Job accessories and DatabaseGridWithProvider for jobs (#453)
## Summary Add job accessories feature and migrate jobs list to DatabaseGridWithProvider pattern: - Jobs list now uses DatabaseGridWithProvider (consistent with weapons/characters/summons) - Segmented control to switch between jobs and accessories views - Full CRUD pages for job accessories (detail, edit, new) - Job creation page for editors - Navigation dropdown for jobs section ## Changes ### DatabaseGridWithProvider extension - Extended to support `jobs` resource - Added `searchJobs` method to search adapter - Job-specific filters (row, proficiency, master_level, etc.) - Collection filters hidden for jobs (no element/rarity/series) ### Job grid cells - `JobIconCell` - portrait thumbnail - `JobTierCell` - row tier badge - `JobProficienciesCell` - weapon proficiency icons - `JobFeaturesCell` - master/ultimate/accessory badges ### Job accessories - Detail page with job info - Edit/new pages for editors - Type utilities (Shield/Manatura) ### Navigation - Dropdown with "New job" and "New job accessory" options ## Dependencies Requires hensei-api#206 for backend endpoints. ## Test plan - [ ] Jobs list loads with DatabaseGridWithProvider - [ ] Segmented control switches between jobs/accessories - [ ] Job accessory detail/edit/new pages work - [ ] Job creation page works for editors - [ ] Navigation dropdown appears on job pages
This commit is contained in:
parent
90e5ded942
commit
0fbdd24491
26 changed files with 1951 additions and 220 deletions
|
|
@ -42,6 +42,19 @@ export interface JobSkillPayload {
|
||||||
action_id?: number
|
action_id?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Payload for creating/updating a job accessory
|
||||||
|
*/
|
||||||
|
export interface JobAccessoryPayload {
|
||||||
|
name_en: string
|
||||||
|
name_jp?: string
|
||||||
|
granblue_id: string
|
||||||
|
rarity?: number
|
||||||
|
release_date?: string
|
||||||
|
accessory_type: number // 1 = Shield, 2 = Manatura
|
||||||
|
job_id?: string
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Payload for updating a job entity
|
* Payload for updating a job entity
|
||||||
*/
|
*/
|
||||||
|
|
@ -123,15 +136,25 @@ export class JobAdapter extends BaseAdapter {
|
||||||
* Gets all accessories available for a specific job
|
* Gets all accessories available for a specific job
|
||||||
* Only returns data if the job supports accessories
|
* Only returns data if the job supports accessories
|
||||||
*/
|
*/
|
||||||
async getAccessories(jobId: string): Promise<JobAccessory[]> {
|
async getAccessoriesForJob(jobId: string): Promise<JobAccessory[]> {
|
||||||
const response = await this.request<{ accessories: JobAccessory[] }>(
|
return this.request<JobAccessory[]>(`/jobs/${jobId}/accessories`, {
|
||||||
`/jobs/${jobId}/accessories`,
|
method: 'GET',
|
||||||
{
|
cacheTTL: 300000 // Cache for 5 minutes
|
||||||
method: 'GET',
|
})
|
||||||
cacheTTL: 300000 // Cache for 5 minutes
|
}
|
||||||
}
|
|
||||||
)
|
/**
|
||||||
return response.accessories
|
* Creates a new job entity (database admin function)
|
||||||
|
* @param data The job data
|
||||||
|
*/
|
||||||
|
async createJob(data: JobUpdatePayload): Promise<Job> {
|
||||||
|
const response = await this.request<Job>('/jobs', {
|
||||||
|
method: 'POST',
|
||||||
|
body: data
|
||||||
|
})
|
||||||
|
// Clear jobs cache to reflect the new job
|
||||||
|
this.clearCache('/jobs')
|
||||||
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -327,12 +350,78 @@ export class JobAdapter extends BaseAdapter {
|
||||||
this.clearCache(`/parties/${partyId}`)
|
this.clearCache(`/parties/${partyId}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Job Accessory Methods (Database Management)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets all job accessories
|
||||||
|
* @param accessoryType Optional filter by type (1=Shield, 2=Manatura)
|
||||||
|
*/
|
||||||
|
async getAllAccessories(accessoryType?: number): Promise<JobAccessory[]> {
|
||||||
|
const params = accessoryType ? `?accessory_type=${accessoryType}` : ''
|
||||||
|
return this.request<JobAccessory[]>(`/job_accessories${params}`, {
|
||||||
|
method: 'GET',
|
||||||
|
cacheTTL: 300000 // Cache for 5 minutes
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets a single job accessory by ID or granblue_id
|
||||||
|
*/
|
||||||
|
async getAccessoryById(id: string): Promise<JobAccessory> {
|
||||||
|
return this.request<JobAccessory>(`/job_accessories/${id}`, {
|
||||||
|
method: 'GET',
|
||||||
|
cacheTTL: 300000 // Cache for 5 minutes
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new job accessory
|
||||||
|
* @param data The accessory data
|
||||||
|
*/
|
||||||
|
async createAccessory(data: JobAccessoryPayload): Promise<JobAccessory> {
|
||||||
|
const response = await this.request<JobAccessory>('/job_accessories', {
|
||||||
|
method: 'POST',
|
||||||
|
body: data
|
||||||
|
})
|
||||||
|
this.clearCache('/job_accessories')
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates an existing job accessory
|
||||||
|
* @param id The accessory's ID or granblue_id
|
||||||
|
* @param data The updated accessory data
|
||||||
|
*/
|
||||||
|
async updateAccessory(id: string, data: Partial<JobAccessoryPayload>): Promise<JobAccessory> {
|
||||||
|
const response = await this.request<JobAccessory>(`/job_accessories/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: data
|
||||||
|
})
|
||||||
|
this.clearCache('/job_accessories')
|
||||||
|
this.clearCache(`/job_accessories/${id}`)
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes a job accessory
|
||||||
|
* @param id The accessory's ID or granblue_id
|
||||||
|
*/
|
||||||
|
async deleteAccessory(id: string): Promise<void> {
|
||||||
|
await this.request(`/job_accessories/${id}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
})
|
||||||
|
this.clearCache('/job_accessories')
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clears the cache for job-related data
|
* Clears the cache for job-related data
|
||||||
*/
|
*/
|
||||||
clearJobCache() {
|
clearJobCache() {
|
||||||
this.clearCache('/jobs')
|
this.clearCache('/jobs')
|
||||||
this.clearCache('/search/job_skills')
|
this.clearCache('/search/job_skills')
|
||||||
|
this.clearCache('/job_accessories')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -377,6 +377,67 @@ export class SearchAdapter extends BaseAdapter {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Searches for jobs with specific filters
|
||||||
|
*
|
||||||
|
* @param params - Search parameters with job-specific filters
|
||||||
|
* @returns Promise resolving to job search results
|
||||||
|
*/
|
||||||
|
async searchJobs(params: SearchParams = {}): Promise<SearchResponse> {
|
||||||
|
const body: any = {
|
||||||
|
locale: params.locale || 'en',
|
||||||
|
page: params.page || 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.per) {
|
||||||
|
body.per = params.per
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.query) {
|
||||||
|
body.query = params.query
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.sortBy) {
|
||||||
|
body.sort = params.sortBy
|
||||||
|
}
|
||||||
|
if (params.sortOrder) {
|
||||||
|
body.order = params.sortOrder
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build job-specific filters
|
||||||
|
if (params.filters) {
|
||||||
|
const filters: any = {}
|
||||||
|
|
||||||
|
if (params.filters.row?.length) {
|
||||||
|
filters.row = params.filters.row
|
||||||
|
}
|
||||||
|
if (params.filters.proficiency?.length) {
|
||||||
|
filters.proficiency = params.filters.proficiency
|
||||||
|
}
|
||||||
|
if (params.filters.masterLevel !== undefined) {
|
||||||
|
filters.masterLevel = params.filters.masterLevel
|
||||||
|
}
|
||||||
|
if (params.filters.ultimateMastery !== undefined) {
|
||||||
|
filters.ultimateMastery = params.filters.ultimateMastery
|
||||||
|
}
|
||||||
|
if (params.filters.accessory !== undefined) {
|
||||||
|
filters.accessory = params.filters.accessory
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(filters).length > 0) {
|
||||||
|
body.filters = filters
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.request<SearchResponse>('/search/jobs', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { search: body },
|
||||||
|
credentials: 'omit',
|
||||||
|
cacheTTL: params.query ? 300000 : 0,
|
||||||
|
headers: params.per ? { 'X-Per-Page': String(params.per) } : undefined
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clears all cached search results
|
* Clears all cached search results
|
||||||
* Useful when entity data has been updated
|
* Useful when entity data has been updated
|
||||||
|
|
|
||||||
|
|
@ -191,6 +191,23 @@ export interface SearchFilters {
|
||||||
/** Filter special characters */
|
/** Filter special characters */
|
||||||
special?: boolean | undefined
|
special?: boolean | undefined
|
||||||
|
|
||||||
|
// Job-specific filters
|
||||||
|
|
||||||
|
/** Filter by job row (1-5, ex, ex2, o1) */
|
||||||
|
row?: string[] | undefined
|
||||||
|
|
||||||
|
/** Filter by proficiency (for jobs - matches either proficiency1 or proficiency2) */
|
||||||
|
proficiency?: number[] | undefined
|
||||||
|
|
||||||
|
/** Filter jobs with master level */
|
||||||
|
masterLevel?: boolean | undefined
|
||||||
|
|
||||||
|
/** Filter jobs with ultimate mastery */
|
||||||
|
ultimateMastery?: boolean | undefined
|
||||||
|
|
||||||
|
/** Filter jobs that support accessories */
|
||||||
|
accessory?: boolean | undefined
|
||||||
|
|
||||||
/** Custom filters for specific use cases */
|
/** Custom filters for specific use cases */
|
||||||
[key: string]: any
|
[key: string]: any
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -140,15 +140,15 @@ export const jobQueries = {
|
||||||
}),
|
}),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Job accessories query options
|
* Job accessories query options (for a specific job)
|
||||||
*
|
*
|
||||||
* @param jobId - Job ID to fetch accessories for
|
* @param jobId - Job ID to fetch accessories for
|
||||||
* @returns Query options for fetching job accessories
|
* @returns Query options for fetching job accessories
|
||||||
*/
|
*/
|
||||||
accessories: (jobId: string) =>
|
accessoriesForJob: (jobId: string) =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: ['jobs', jobId, 'accessories'] as const,
|
queryKey: ['jobs', jobId, 'accessories'] as const,
|
||||||
queryFn: () => jobAdapter.getAccessories(jobId),
|
queryFn: () => jobAdapter.getAccessoriesForJob(jobId),
|
||||||
enabled: !!jobId,
|
enabled: !!jobId,
|
||||||
staleTime: 1000 * 60 * 30, // 30 minutes
|
staleTime: 1000 * 60 * 30, // 30 minutes
|
||||||
gcTime: 1000 * 60 * 60 // 1 hour
|
gcTime: 1000 * 60 * 60 // 1 hour
|
||||||
|
|
@ -165,6 +165,39 @@ export const jobQueries = {
|
||||||
queryFn: () => jobAdapter.getAllSkills(),
|
queryFn: () => jobAdapter.getAllSkills(),
|
||||||
staleTime: 1000 * 60 * 30, // 30 minutes
|
staleTime: 1000 * 60 * 30, // 30 minutes
|
||||||
gcTime: 1000 * 60 * 60 // 1 hour
|
gcTime: 1000 * 60 * 60 // 1 hour
|
||||||
|
}),
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Job Accessory Database Queries
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All job accessories list query options
|
||||||
|
*
|
||||||
|
* @param accessoryType - Optional filter by type (1=Shield, 2=Manatura)
|
||||||
|
* @returns Query options for fetching all job accessories
|
||||||
|
*/
|
||||||
|
accessoriesList: (accessoryType?: number) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: ['jobAccessories', { accessoryType }] as const,
|
||||||
|
queryFn: () => jobAdapter.getAllAccessories(accessoryType),
|
||||||
|
staleTime: 1000 * 60 * 30, // 30 minutes
|
||||||
|
gcTime: 1000 * 60 * 60 // 1 hour
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single job accessory query options
|
||||||
|
*
|
||||||
|
* @param id - Accessory ID or granblue_id
|
||||||
|
* @returns Query options for fetching a single accessory
|
||||||
|
*/
|
||||||
|
accessoryById: (id: string) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: ['jobAccessories', id] as const,
|
||||||
|
queryFn: () => jobAdapter.getAccessoryById(id),
|
||||||
|
enabled: !!id,
|
||||||
|
staleTime: 1000 * 60 * 30, // 30 minutes
|
||||||
|
gcTime: 1000 * 60 * 60 // 1 hour
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -193,6 +226,15 @@ export const jobKeys = {
|
||||||
empSkills: (jobId: string) => [...jobKeys.all, jobId, 'emp_skills'] as const,
|
empSkills: (jobId: string) => [...jobKeys.all, jobId, 'emp_skills'] as const,
|
||||||
skillsSearch: (jobId: string, params?: Omit<SearchJobSkillsParams, 'jobId' | 'page'>) =>
|
skillsSearch: (jobId: string, params?: Omit<SearchJobSkillsParams, 'jobId' | 'page'>) =>
|
||||||
[...jobKeys.skills(jobId), 'search', params] as const,
|
[...jobKeys.skills(jobId), 'search', params] as const,
|
||||||
accessories: (jobId: string) => [...jobKeys.all, jobId, 'accessories'] as const,
|
accessoriesForJob: (jobId: string) => [...jobKeys.all, jobId, 'accessories'] as const,
|
||||||
allSkills: () => [...jobKeys.all, 'skills', 'all'] as const
|
allSkills: () => [...jobKeys.all, 'skills', 'all'] as const
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Job accessory query key helpers for cache invalidation
|
||||||
|
*/
|
||||||
|
export const jobAccessoryKeys = {
|
||||||
|
all: ['jobAccessories'] as const,
|
||||||
|
lists: (accessoryType?: number) => [...jobAccessoryKeys.all, { accessoryType }] as const,
|
||||||
|
detail: (id: string) => [...jobAccessoryKeys.all, id] as const
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,7 @@
|
||||||
if (path.startsWith(databaseCharactersHref)) return 'character'
|
if (path.startsWith(databaseCharactersHref)) return 'character'
|
||||||
if (path.startsWith(databaseWeaponsHref)) return 'weapon'
|
if (path.startsWith(databaseWeaponsHref)) return 'weapon'
|
||||||
if (path.startsWith(databaseSummonsHref)) return 'summon'
|
if (path.startsWith(databaseSummonsHref)) return 'summon'
|
||||||
|
if (path.startsWith(databaseJobsHref)) return 'job'
|
||||||
if (path.startsWith(databaseRaidsHref) || path.startsWith(databaseRaidGroupsHref)) return 'raid'
|
if (path.startsWith(databaseRaidsHref) || path.startsWith(databaseRaidGroupsHref)) return 'raid'
|
||||||
return null
|
return null
|
||||||
})
|
})
|
||||||
|
|
@ -92,9 +93,11 @@
|
||||||
? 'weapon'
|
? 'weapon'
|
||||||
: currentDatabaseEntity === 'summon'
|
: currentDatabaseEntity === 'summon'
|
||||||
? 'summon'
|
? 'summon'
|
||||||
: currentDatabaseEntity === 'raid'
|
: currentDatabaseEntity === 'job'
|
||||||
? 'raid'
|
? 'job'
|
||||||
: null
|
: currentDatabaseEntity === 'raid'
|
||||||
|
? 'raid'
|
||||||
|
: null
|
||||||
)
|
)
|
||||||
const databaseNewHref = $derived(
|
const databaseNewHref = $derived(
|
||||||
currentDatabaseEntity === 'character'
|
currentDatabaseEntity === 'character'
|
||||||
|
|
@ -373,6 +376,13 @@
|
||||||
<DropdownItem>
|
<DropdownItem>
|
||||||
<a href={localizeHref('/database/raid-groups/new')}>New raid group</a>
|
<a href={localizeHref('/database/raid-groups/new')}>New raid group</a>
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
|
{:else if currentDatabaseEntity === 'job'}
|
||||||
|
<DropdownItem>
|
||||||
|
<a href={localizeHref('/database/jobs/new')}>New job</a>
|
||||||
|
</DropdownItem>
|
||||||
|
<DropdownItem>
|
||||||
|
<a href={localizeHref('/database/job-accessories/new')}>New job accessory</a>
|
||||||
|
</DropdownItem>
|
||||||
{:else}
|
{:else}
|
||||||
{#if databaseNewHref}
|
{#if databaseNewHref}
|
||||||
<DropdownItem>
|
<DropdownItem>
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@
|
||||||
import type { Snippet } from 'svelte'
|
import type { Snippet } from 'svelte'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
resource: 'weapons' | 'characters' | 'summons'
|
resource: 'weapons' | 'characters' | 'summons' | 'jobs'
|
||||||
columns: IColumn[]
|
columns: IColumn[]
|
||||||
pageSize?: number
|
pageSize?: number
|
||||||
leftActions?: Snippet
|
leftActions?: Snippet
|
||||||
|
|
@ -45,9 +45,14 @@
|
||||||
|
|
||||||
// Derive entity type from resource
|
// Derive entity type from resource
|
||||||
const entityType = $derived(
|
const entityType = $derived(
|
||||||
resource === 'characters' ? 'character' : resource === 'summons' ? 'summon' : 'weapon'
|
resource === 'characters' ? 'character' :
|
||||||
|
resource === 'summons' ? 'summon' :
|
||||||
|
resource === 'jobs' ? 'job' : 'weapon'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Jobs don't use the standard CollectionFilters component
|
||||||
|
const supportsCollectionFilters = $derived(resource !== 'jobs')
|
||||||
|
|
||||||
// Fetch weapon series list for URL slug mapping (only for weapons)
|
// Fetch weapon series list for URL slug mapping (only for weapons)
|
||||||
const weaponSeriesQuery = createQuery(() =>
|
const weaponSeriesQuery = createQuery(() =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
|
|
@ -403,29 +408,31 @@
|
||||||
{@render headerActions()}
|
{@render headerActions()}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<Button
|
{#if supportsCollectionFilters}
|
||||||
variant="ghost"
|
<Button
|
||||||
size="small"
|
variant="ghost"
|
||||||
onclick={() => (showFilters = !showFilters)}
|
size="small"
|
||||||
class="filter-toggle {hasActiveFilters ? 'has-active' : ''}"
|
onclick={() => (showFilters = !showFilters)}
|
||||||
>
|
class="filter-toggle {hasActiveFilters ? 'has-active' : ''}"
|
||||||
Filters
|
>
|
||||||
{#if hasActiveFilters}
|
Filters
|
||||||
<span class="filter-count {selectedElement ?? ''}">
|
{#if hasActiveFilters}
|
||||||
{elementFilters.length +
|
<span class="filter-count {selectedElement ?? ''}">
|
||||||
rarityFilters.length +
|
{elementFilters.length +
|
||||||
seriesFilters.length +
|
rarityFilters.length +
|
||||||
proficiencyFilters.length +
|
seriesFilters.length +
|
||||||
seasonFilters.length}
|
proficiencyFilters.length +
|
||||||
</span>
|
seasonFilters.length}
|
||||||
{/if}
|
</span>
|
||||||
</Button>
|
{/if}
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<input type="text" placeholder="Search..." bind:value={searchTerm} />
|
<input type="text" placeholder="Search..." bind:value={searchTerm} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if showFilters}
|
{#if showFilters && supportsCollectionFilters}
|
||||||
<div class="filters-row">
|
<div class="filters-row">
|
||||||
<CollectionFilters
|
<CollectionFilters
|
||||||
entityType={resource === 'characters'
|
entityType={resource === 'characters'
|
||||||
|
|
|
||||||
55
src/lib/components/database/cells/JobFeaturesCell.svelte
Normal file
55
src/lib/components/database/cells/JobFeaturesCell.svelte
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
<svelte:options runes={true} />
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import type { Cell } from 'wx-svelte-grid'
|
||||||
|
|
||||||
|
const { row }: Cell = $props()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="features-cell">
|
||||||
|
{#if row.masterLevel}
|
||||||
|
<span class="badge master">Master</span>
|
||||||
|
{/if}
|
||||||
|
{#if row.ultimateMastery}
|
||||||
|
<span class="badge ultimate">Ultimate</span>
|
||||||
|
{/if}
|
||||||
|
{#if row.accessory}
|
||||||
|
<span class="badge accessory">Accessory</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@use '$src/themes/colors' as colors;
|
||||||
|
@use '$src/themes/typography' as typography;
|
||||||
|
|
||||||
|
.features-cell {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
29
src/lib/components/database/cells/JobIconCell.svelte
Normal file
29
src/lib/components/database/cells/JobIconCell.svelte
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
<svelte:options runes={true} />
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import type { Cell } from 'wx-svelte-grid'
|
||||||
|
import { getJobIconUrl } from '$lib/utils/jobUtils'
|
||||||
|
|
||||||
|
const { row }: Cell = $props()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="image-cell">
|
||||||
|
<img src={getJobIconUrl(row.granblueId)} alt={row.name?.en || ''} class="job-icon" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.image-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-icon {
|
||||||
|
width: auto;
|
||||||
|
height: 32px;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
<svelte:options runes={true} />
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import type { Cell } from 'wx-svelte-grid'
|
||||||
|
import ProficiencyLabel from '$lib/components/labels/ProficiencyLabel.svelte'
|
||||||
|
|
||||||
|
const { row }: Cell = $props()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="proficiencies-cell">
|
||||||
|
{#if row.proficiency?.[0]}
|
||||||
|
<ProficiencyLabel proficiency={row.proficiency[0]} size="small" />
|
||||||
|
{/if}
|
||||||
|
{#if row.proficiency?.[1]}
|
||||||
|
<ProficiencyLabel proficiency={row.proficiency[1]} size="small" />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.proficiencies-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
33
src/lib/components/database/cells/JobTierCell.svelte
Normal file
33
src/lib/components/database/cells/JobTierCell.svelte
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
<svelte:options runes={true} />
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import type { Cell } from 'wx-svelte-grid'
|
||||||
|
import { getJobTierName } from '$lib/utils/jobUtils'
|
||||||
|
|
||||||
|
const { row }: Cell = $props()
|
||||||
|
const tierName = $derived(getJobTierName(row.row))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="tier-cell">
|
||||||
|
<span class="tier-badge">{tierName}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@use '$src/themes/colors' as colors;
|
||||||
|
@use '$src/themes/typography' as typography;
|
||||||
|
|
||||||
|
.tier-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
|
background: colors.$grey-90;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: typography.$font-small;
|
||||||
|
color: colors.$grey-30;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -4,7 +4,7 @@ import type { SearchParams } from '$lib/api/adapters/search.adapter'
|
||||||
import type { SearchFilters } from '$lib/api/adapters/types'
|
import type { SearchFilters } from '$lib/api/adapters/types'
|
||||||
|
|
||||||
interface DatabaseProviderOptions {
|
interface DatabaseProviderOptions {
|
||||||
resource: 'weapons' | 'characters' | 'summons'
|
resource: 'weapons' | 'characters' | 'summons' | 'jobs'
|
||||||
pageSize?: number
|
pageSize?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -19,7 +19,7 @@ interface APIResponse {
|
||||||
}
|
}
|
||||||
|
|
||||||
export class DatabaseProvider extends RestDataProvider<any> {
|
export class DatabaseProvider extends RestDataProvider<any> {
|
||||||
private resource: 'weapons' | 'characters' | 'summons'
|
private resource: 'weapons' | 'characters' | 'summons' | 'jobs'
|
||||||
private pageSize: number
|
private pageSize: number
|
||||||
private currentPage: number = 1
|
private currentPage: number = 1
|
||||||
private totalCount: number = 0
|
private totalCount: number = 0
|
||||||
|
|
@ -72,6 +72,9 @@ export class DatabaseProvider extends RestDataProvider<any> {
|
||||||
case 'summons':
|
case 'summons':
|
||||||
result = await searchAdapter.searchSummons(searchParams)
|
result = await searchAdapter.searchSummons(searchParams)
|
||||||
break
|
break
|
||||||
|
case 'jobs':
|
||||||
|
result = await searchAdapter.searchJobs(searchParams)
|
||||||
|
break
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unknown resource type: ${this.resource}`)
|
throw new Error(`Unknown resource type: ${this.resource}`)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
13
src/lib/types/JobAccessory.d.ts
vendored
13
src/lib/types/JobAccessory.d.ts
vendored
|
|
@ -1,11 +1,2 @@
|
||||||
export interface JobAccessory {
|
// Re-export from entities for backwards compatibility
|
||||||
id: string
|
export type { JobAccessory } from './api/entities'
|
||||||
granblue_id: string
|
|
||||||
job: Job
|
|
||||||
name: {
|
|
||||||
[key: string]: string
|
|
||||||
en: string
|
|
||||||
ja: string
|
|
||||||
}
|
|
||||||
rarity: number
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -202,12 +202,15 @@ export interface JobSkill {
|
||||||
actionId?: number // Unique game ID
|
actionId?: number // Unique game ID
|
||||||
}
|
}
|
||||||
|
|
||||||
// JobAccessory entity
|
// JobAccessory entity from JobAccessoryBlueprint
|
||||||
export interface JobAccessory {
|
export interface JobAccessory {
|
||||||
id: string
|
id: string
|
||||||
name: LocalizedName
|
name: LocalizedName
|
||||||
slug: string
|
|
||||||
granblueId: string
|
granblueId: string
|
||||||
|
rarity: number
|
||||||
|
releaseDate?: string
|
||||||
|
accessoryType: number // 1 = Shield, 2 = Manatura
|
||||||
|
job?: Job // Associated job (optional, included when available)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Raid entity from RaidBlueprint
|
// Raid entity from RaidBlueprint
|
||||||
|
|
|
||||||
|
|
@ -181,7 +181,7 @@ function parseParamArray<T>(
|
||||||
*/
|
*/
|
||||||
export function parseFiltersFromUrl(
|
export function parseFiltersFromUrl(
|
||||||
searchParams: URLSearchParams,
|
searchParams: URLSearchParams,
|
||||||
entityType: 'character' | 'weapon' | 'summon',
|
entityType: 'character' | 'weapon' | 'summon' | 'job',
|
||||||
weaponSeriesList?: WeaponSeries[]
|
weaponSeriesList?: WeaponSeries[]
|
||||||
): ParsedFilters {
|
): ParsedFilters {
|
||||||
const element = parseParamArray(searchParams, 'element', PARAM_TO_ELEMENT)
|
const element = parseParamArray(searchParams, 'element', PARAM_TO_ELEMENT)
|
||||||
|
|
@ -252,7 +252,7 @@ export function buildUrlFromFilters(
|
||||||
filters: CollectionFilterState,
|
filters: CollectionFilterState,
|
||||||
searchQuery: string,
|
searchQuery: string,
|
||||||
page: number,
|
page: number,
|
||||||
entityType: 'character' | 'weapon' | 'summon',
|
entityType: 'character' | 'weapon' | 'summon' | 'job',
|
||||||
weaponSeriesList?: WeaponSeries[]
|
weaponSeriesList?: WeaponSeries[]
|
||||||
): URLSearchParams {
|
): URLSearchParams {
|
||||||
const params = new URLSearchParams()
|
const params = new URLSearchParams()
|
||||||
|
|
|
||||||
50
src/lib/utils/jobAccessoryUtils.ts
Normal file
50
src/lib/utils/jobAccessoryUtils.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
/**
|
||||||
|
* Job Accessory Utilities
|
||||||
|
*
|
||||||
|
* Helper functions for working with job accessories (Shields and Manatura).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accessory type constants
|
||||||
|
*/
|
||||||
|
export const ACCESSORY_TYPES = {
|
||||||
|
SHIELD: 1,
|
||||||
|
MANATURA: 2
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type AccessoryType = (typeof ACCESSORY_TYPES)[keyof typeof ACCESSORY_TYPES]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the display name for an accessory type
|
||||||
|
*/
|
||||||
|
export function getAccessoryTypeName(type: number): string {
|
||||||
|
switch (type) {
|
||||||
|
case ACCESSORY_TYPES.SHIELD:
|
||||||
|
return 'Shield'
|
||||||
|
case ACCESSORY_TYPES.MANATURA:
|
||||||
|
return 'Manatura'
|
||||||
|
default:
|
||||||
|
return 'Unknown'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets options for accessory type filter/select
|
||||||
|
*/
|
||||||
|
export function getAccessoryTypeOptions(): Array<{ value: number; label: string }> {
|
||||||
|
return [
|
||||||
|
{ value: ACCESSORY_TYPES.SHIELD, label: 'Shield' },
|
||||||
|
{ value: ACCESSORY_TYPES.MANATURA, label: 'Manatura' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the image URL for a job accessory
|
||||||
|
* @param granblueId The accessory's granblue_id
|
||||||
|
* @param accessoryType The type of accessory (1=Shield, 2=Manatura)
|
||||||
|
*/
|
||||||
|
export function getJobAccessoryImageUrl(granblueId: string, accessoryType: number): string {
|
||||||
|
// Different asset paths based on accessory type
|
||||||
|
const folder = accessoryType === ACCESSORY_TYPES.SHIELD ? 'shield' : 'manatura'
|
||||||
|
return `/images/job-accessories/${folder}/${granblueId}.png`
|
||||||
|
}
|
||||||
|
|
@ -290,3 +290,34 @@ export function validateSkillConfiguration(
|
||||||
errors
|
errors
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proficiency options for job forms
|
||||||
|
*/
|
||||||
|
export const PROFICIENCIES = [
|
||||||
|
{ 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' }
|
||||||
|
] as const
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Row options for job forms
|
||||||
|
*/
|
||||||
|
export const ROWS = [
|
||||||
|
{ 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: 'EX' },
|
||||||
|
{ value: 'ex2', label: 'EX II' },
|
||||||
|
{ value: 'o1', label: 'Origin I' }
|
||||||
|
] as const
|
||||||
|
|
|
||||||
|
|
@ -8,13 +8,13 @@ const STORAGE_KEY = 'database_list_url'
|
||||||
|
|
||||||
interface StoredListUrl {
|
interface StoredListUrl {
|
||||||
url: string
|
url: string
|
||||||
resource: 'characters' | 'weapons' | 'summons'
|
resource: 'characters' | 'weapons' | 'summons' | 'jobs'
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Store the current list URL before navigating to a detail page
|
* Store the current list URL before navigating to a detail page
|
||||||
*/
|
*/
|
||||||
export function storeListUrl(url: string, resource: 'characters' | 'weapons' | 'summons'): void {
|
export function storeListUrl(url: string, resource: 'characters' | 'weapons' | 'summons' | 'jobs'): void {
|
||||||
try {
|
try {
|
||||||
const data: StoredListUrl = { url, resource }
|
const data: StoredListUrl = { url, resource }
|
||||||
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(data))
|
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(data))
|
||||||
|
|
|
||||||
|
|
@ -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 accessory = await jobAdapter.getAccessoryById(params.granblueId)
|
||||||
|
|
||||||
|
if (!accessory) {
|
||||||
|
throw error(404, 'Job accessory not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
accessory,
|
||||||
|
role: parentData.role
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load job accessory:', err)
|
||||||
|
|
||||||
|
if (err instanceof Error && 'status' in err && err.status === 404) {
|
||||||
|
throw error(404, 'Job accessory not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error(500, 'Failed to load job accessory')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,222 @@
|
||||||
|
<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 } from '@tanstack/svelte-query'
|
||||||
|
import { jobQueries } from '$lib/api/queries/job.queries'
|
||||||
|
import { withInitialData } from '$lib/query/ssr'
|
||||||
|
|
||||||
|
// Components
|
||||||
|
import Button from '$lib/components/ui/Button.svelte'
|
||||||
|
import DetailsContainer from '$lib/components/ui/DetailsContainer.svelte'
|
||||||
|
import DetailItem from '$lib/components/ui/DetailItem.svelte'
|
||||||
|
|
||||||
|
// Utils
|
||||||
|
import { getAccessoryTypeName } from '$lib/utils/jobAccessoryUtils'
|
||||||
|
|
||||||
|
// Types
|
||||||
|
import type { PageData } from './$types'
|
||||||
|
|
||||||
|
let { data }: { data: PageData } = $props()
|
||||||
|
|
||||||
|
// Use TanStack Query with SSR initial data
|
||||||
|
const accessoryQuery = createQuery(() => ({
|
||||||
|
...jobQueries.accessoryById(data.accessory?.granblueId ?? ''),
|
||||||
|
...withInitialData(data.accessory)
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Get accessory from query
|
||||||
|
const accessory = $derived(accessoryQuery.data)
|
||||||
|
const userRole = $derived(data.role || 0)
|
||||||
|
const canEdit = $derived(userRole >= 7)
|
||||||
|
|
||||||
|
// Edit URL for navigation
|
||||||
|
const editUrl = $derived(accessory?.granblueId ? `/database/job-accessories/${accessory.granblueId}/edit` : undefined)
|
||||||
|
|
||||||
|
// Page title
|
||||||
|
const pageTitle = $derived(m.page_title_db_entity({ name: accessory?.name?.en ?? 'Job Accessory' }))
|
||||||
|
|
||||||
|
function handleBack() {
|
||||||
|
goto('/database/jobs?view=accessories')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<PageMeta title={pageTitle} description={m.page_desc_home()} />
|
||||||
|
|
||||||
|
<div class="page">
|
||||||
|
{#if accessory}
|
||||||
|
<div class="header">
|
||||||
|
<div class="header-content">
|
||||||
|
<button class="back-button" onclick={handleBack}>
|
||||||
|
← Back to Accessories
|
||||||
|
</button>
|
||||||
|
<h1 class="title">{accessory.name.en}</h1>
|
||||||
|
{#if accessory.name.ja}
|
||||||
|
<p class="subtitle">{accessory.name.ja}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if canEdit && editUrl}
|
||||||
|
<Button href={editUrl} variant="secondary" size="small">Edit</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="details">
|
||||||
|
<DetailsContainer title="Metadata">
|
||||||
|
<DetailItem label="English Name">
|
||||||
|
{accessory.name.en}
|
||||||
|
</DetailItem>
|
||||||
|
<DetailItem label="Japanese Name">
|
||||||
|
{accessory.name.ja ?? '—'}
|
||||||
|
</DetailItem>
|
||||||
|
<DetailItem label="Granblue ID">
|
||||||
|
<code>{accessory.granblueId}</code>
|
||||||
|
</DetailItem>
|
||||||
|
</DetailsContainer>
|
||||||
|
|
||||||
|
<DetailsContainer title="Classification">
|
||||||
|
<DetailItem label="Accessory Type">
|
||||||
|
<span class="type-badge {accessory.accessoryType === 1 ? 'shield' : 'manatura'}">
|
||||||
|
{getAccessoryTypeName(accessory.accessoryType)}
|
||||||
|
</span>
|
||||||
|
</DetailItem>
|
||||||
|
<DetailItem label="Rarity">
|
||||||
|
{accessory.rarity ?? '—'}
|
||||||
|
</DetailItem>
|
||||||
|
<DetailItem label="Release Date">
|
||||||
|
{accessory.releaseDate ?? '—'}
|
||||||
|
</DetailItem>
|
||||||
|
</DetailsContainer>
|
||||||
|
|
||||||
|
<DetailsContainer title="Associated Job">
|
||||||
|
<DetailItem label="Job">
|
||||||
|
{#if accessory.job}
|
||||||
|
<a href="/database/jobs/{accessory.job.granblueId}" class="job-link">
|
||||||
|
{accessory.job.name.en}
|
||||||
|
</a>
|
||||||
|
{:else}
|
||||||
|
—
|
||||||
|
{/if}
|
||||||
|
</DetailItem>
|
||||||
|
</DetailsContainer>
|
||||||
|
</section>
|
||||||
|
{:else if accessoryQuery.isLoading}
|
||||||
|
<div class="loading">Loading accessory...</div>
|
||||||
|
{:else}
|
||||||
|
<div class="error">Failed to load accessory</div>
|
||||||
|
{/if}
|
||||||
|
</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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: spacing.$unit-2x;
|
||||||
|
padding: spacing.$unit-2x;
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 0.5px solid rgba(0, 0, 0, 0.18);
|
||||||
|
border-radius: layout.$page-corner;
|
||||||
|
box-shadow: effects.$page-elevation;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: spacing.$unit-half;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: typography.$font-small;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
margin-bottom: spacing.$unit-half;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: typography.$font-xlarge;
|
||||||
|
font-weight: typography.$bold;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
font-size: typography.$font-medium;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: spacing.$unit-2x;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: typography.$font-small;
|
||||||
|
font-weight: typography.$medium;
|
||||||
|
|
||||||
|
&.shield {
|
||||||
|
background: #e0f2fe;
|
||||||
|
color: #0369a1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.manatura {
|
||||||
|
background: #fce7f3;
|
||||||
|
color: #be185d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-link {
|
||||||
|
color: colors.$blue;
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-size: typography.$font-small;
|
||||||
|
background: #f0f0f0;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading,
|
||||||
|
.error {
|
||||||
|
text-align: center;
|
||||||
|
padding: spacing.$unit * 4;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: colors.$red;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -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()
|
||||||
|
|
||||||
|
// Check if user has editor role
|
||||||
|
if (!parentData.role || parentData.role < 7) {
|
||||||
|
throw redirect(302, `/database/job-accessories/${params.granblueId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const accessory = await jobAdapter.getAccessoryById(params.granblueId)
|
||||||
|
|
||||||
|
if (!accessory) {
|
||||||
|
throw error(404, 'Job accessory not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
accessory,
|
||||||
|
role: parentData.role
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load job accessory:', err)
|
||||||
|
|
||||||
|
if (err instanceof Error && 'status' in err && err.status === 404) {
|
||||||
|
throw error(404, 'Job accessory not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error(500, 'Failed to load job accessory')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,283 @@
|
||||||
|
<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, jobAccessoryKeys } from '$lib/api/queries/job.queries'
|
||||||
|
import { jobAdapter } from '$lib/api/adapters/job.adapter'
|
||||||
|
import { withInitialData } from '$lib/query/ssr'
|
||||||
|
|
||||||
|
// Components
|
||||||
|
import Button from '$lib/components/ui/Button.svelte'
|
||||||
|
import DetailsContainer from '$lib/components/ui/DetailsContainer.svelte'
|
||||||
|
|
||||||
|
// Utils
|
||||||
|
import { ACCESSORY_TYPES } from '$lib/utils/jobAccessoryUtils'
|
||||||
|
|
||||||
|
// Types
|
||||||
|
import type { PageData } from './$types'
|
||||||
|
|
||||||
|
let { data }: { data: PageData } = $props()
|
||||||
|
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
// Use TanStack Query with SSR initial data
|
||||||
|
const accessoryQuery = createQuery(() => ({
|
||||||
|
...jobQueries.accessoryById(data.accessory?.granblueId ?? ''),
|
||||||
|
...withInitialData(data.accessory)
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Get accessory from query
|
||||||
|
const accessory = $derived(accessoryQuery.data)
|
||||||
|
|
||||||
|
// Save state
|
||||||
|
let isSaving = $state(false)
|
||||||
|
let saveError = $state<string | null>(null)
|
||||||
|
let saveSuccess = $state(false)
|
||||||
|
|
||||||
|
// Editable fields - initialized from accessory data
|
||||||
|
let editData = $state({
|
||||||
|
name_en: '',
|
||||||
|
name_jp: '',
|
||||||
|
granblue_id: '',
|
||||||
|
accessory_type: ACCESSORY_TYPES.SHIELD,
|
||||||
|
rarity: 0,
|
||||||
|
release_date: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
// Populate edit data when accessory loads
|
||||||
|
$effect(() => {
|
||||||
|
if (accessory) {
|
||||||
|
editData = {
|
||||||
|
name_en: accessory.name?.en || '',
|
||||||
|
name_jp: accessory.name?.ja || '',
|
||||||
|
granblue_id: accessory.granblueId || '',
|
||||||
|
accessory_type: accessory.accessoryType || ACCESSORY_TYPES.SHIELD,
|
||||||
|
rarity: accessory.rarity || 0,
|
||||||
|
release_date: accessory.releaseDate || ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function saveChanges() {
|
||||||
|
if (!accessory?.granblueId) return
|
||||||
|
|
||||||
|
isSaving = true
|
||||||
|
saveError = null
|
||||||
|
saveSuccess = false
|
||||||
|
|
||||||
|
try {
|
||||||
|
await jobAdapter.updateAccessory(accessory.granblueId, editData)
|
||||||
|
|
||||||
|
// Invalidate TanStack Query cache to refetch fresh data
|
||||||
|
await queryClient.invalidateQueries({ queryKey: jobAccessoryKeys.all })
|
||||||
|
|
||||||
|
saveSuccess = true
|
||||||
|
|
||||||
|
// Navigate back to detail page after a short delay
|
||||||
|
setTimeout(() => {
|
||||||
|
goto(`/database/job-accessories/${editData.granblue_id}`)
|
||||||
|
}, 500)
|
||||||
|
} catch (error) {
|
||||||
|
saveError = 'Failed to save changes. Please try again.'
|
||||||
|
console.error('Save error:', error)
|
||||||
|
} finally {
|
||||||
|
isSaving = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCancel() {
|
||||||
|
goto(`/database/job-accessories/${accessory?.granblueId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Page title
|
||||||
|
const pageTitle = $derived(m.page_title_db_edit({ name: accessory?.name?.en ?? 'Job Accessory' }))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<PageMeta title={pageTitle} description={m.page_desc_home()} />
|
||||||
|
|
||||||
|
<div class="page">
|
||||||
|
{#if accessory}
|
||||||
|
<div class="header">
|
||||||
|
<div class="header-content">
|
||||||
|
<h1 class="title">Edit: {accessory.name.en}</h1>
|
||||||
|
</div>
|
||||||
|
<div class="header-actions">
|
||||||
|
<Button variant="secondary" size="small" onclick={handleCancel} disabled={isSaving}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" size="small" onclick={saveChanges} disabled={isSaving}>
|
||||||
|
{isSaving ? 'Saving...' : 'Save'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if saveError}
|
||||||
|
<div class="error-message">{saveError}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if saveSuccess}
|
||||||
|
<div class="success-message">Changes saved successfully!</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<section class="details">
|
||||||
|
<DetailsContainer title="Names">
|
||||||
|
<div class="form-field">
|
||||||
|
<label for="name_en">English Name</label>
|
||||||
|
<input type="text" id="name_en" bind:value={editData.name_en} />
|
||||||
|
</div>
|
||||||
|
<div class="form-field">
|
||||||
|
<label for="name_jp">Japanese Name</label>
|
||||||
|
<input type="text" id="name_jp" bind:value={editData.name_jp} />
|
||||||
|
</div>
|
||||||
|
</DetailsContainer>
|
||||||
|
|
||||||
|
<DetailsContainer title="Identification">
|
||||||
|
<div class="form-field">
|
||||||
|
<label for="granblue_id">Granblue ID</label>
|
||||||
|
<input type="text" id="granblue_id" bind:value={editData.granblue_id} />
|
||||||
|
</div>
|
||||||
|
</DetailsContainer>
|
||||||
|
|
||||||
|
<DetailsContainer title="Classification">
|
||||||
|
<div class="form-field">
|
||||||
|
<label for="accessory_type">Accessory Type</label>
|
||||||
|
<select id="accessory_type" bind:value={editData.accessory_type}>
|
||||||
|
<option value={ACCESSORY_TYPES.SHIELD}>Shield</option>
|
||||||
|
<option value={ACCESSORY_TYPES.MANATURA}>Manatura</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-field">
|
||||||
|
<label for="rarity">Rarity</label>
|
||||||
|
<input type="number" id="rarity" bind:value={editData.rarity} min="0" max="4" />
|
||||||
|
</div>
|
||||||
|
<div class="form-field">
|
||||||
|
<label for="release_date">Release Date</label>
|
||||||
|
<input type="date" id="release_date" bind:value={editData.release_date} />
|
||||||
|
</div>
|
||||||
|
</DetailsContainer>
|
||||||
|
</section>
|
||||||
|
{:else if accessoryQuery.isLoading}
|
||||||
|
<div class="loading">Loading accessory...</div>
|
||||||
|
{:else}
|
||||||
|
<div class="error">Failed to load accessory</div>
|
||||||
|
{/if}
|
||||||
|
</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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: spacing.$unit-2x;
|
||||||
|
padding: spacing.$unit-2x;
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 0.5px solid rgba(0, 0, 0, 0.18);
|
||||||
|
border-radius: layout.$page-corner;
|
||||||
|
box-shadow: effects.$page-elevation;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: spacing.$unit-half;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: spacing.$unit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: typography.$font-xlarge;
|
||||||
|
font-weight: typography.$bold;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: spacing.$unit-2x;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: spacing.$unit-half;
|
||||||
|
padding: spacing.$unit 0;
|
||||||
|
|
||||||
|
label {
|
||||||
|
font-size: typography.$font-small;
|
||||||
|
font-weight: typography.$medium;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
select {
|
||||||
|
padding: spacing.$unit spacing.$unit-2x;
|
||||||
|
background: var(--input-bound-bg);
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
border-radius: layout.$item-corner;
|
||||||
|
font-size: typography.$font-medium;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--input-bound-bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: colors.$blue;
|
||||||
|
box-shadow: 0 0 0 2px rgba(colors.$blue, 0.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
padding: spacing.$unit-2x;
|
||||||
|
margin-bottom: spacing.$unit-2x;
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #991b1b;
|
||||||
|
border-radius: layout.$item-corner;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-message {
|
||||||
|
padding: spacing.$unit-2x;
|
||||||
|
margin-bottom: spacing.$unit-2x;
|
||||||
|
background: #dcfce7;
|
||||||
|
color: #166534;
|
||||||
|
border-radius: layout.$item-corner;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading,
|
||||||
|
.error {
|
||||||
|
text-align: center;
|
||||||
|
padding: spacing.$unit * 4;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: colors.$red;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
import type { PageServerLoad } from './$types'
|
||||||
|
import { redirect } from '@sveltejs/kit'
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ parent }) => {
|
||||||
|
// Get parent data to access role
|
||||||
|
const parentData = await parent()
|
||||||
|
|
||||||
|
// Check if user has editor role
|
||||||
|
if (!parentData.role || parentData.role < 7) {
|
||||||
|
throw redirect(302, '/database/jobs?view=accessories')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
role: parentData.role
|
||||||
|
}
|
||||||
|
}
|
||||||
253
src/routes/(app)/database/job-accessories/new/+page.svelte
Normal file
253
src/routes/(app)/database/job-accessories/new/+page.svelte
Normal file
|
|
@ -0,0 +1,253 @@
|
||||||
|
<svelte:options runes={true} />
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
// SvelteKit imports
|
||||||
|
import { goto } from '$app/navigation'
|
||||||
|
|
||||||
|
// Page metadata
|
||||||
|
import PageMeta from '$lib/components/PageMeta.svelte'
|
||||||
|
|
||||||
|
// TanStack Query
|
||||||
|
import { useQueryClient } from '@tanstack/svelte-query'
|
||||||
|
import { jobAccessoryKeys } from '$lib/api/queries/job.queries'
|
||||||
|
import { jobAdapter } from '$lib/api/adapters/job.adapter'
|
||||||
|
|
||||||
|
// Components
|
||||||
|
import Button from '$lib/components/ui/Button.svelte'
|
||||||
|
import DetailsContainer from '$lib/components/ui/DetailsContainer.svelte'
|
||||||
|
|
||||||
|
// Utils
|
||||||
|
import { ACCESSORY_TYPES } from '$lib/utils/jobAccessoryUtils'
|
||||||
|
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
// Save state
|
||||||
|
let isSaving = $state(false)
|
||||||
|
let saveError = $state<string | null>(null)
|
||||||
|
|
||||||
|
// Form data
|
||||||
|
let formData = $state({
|
||||||
|
name_en: '',
|
||||||
|
name_jp: '',
|
||||||
|
granblue_id: '',
|
||||||
|
accessory_type: ACCESSORY_TYPES.SHIELD,
|
||||||
|
rarity: 3,
|
||||||
|
release_date: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
const isValid = $derived(
|
||||||
|
formData.name_en.trim() !== '' &&
|
||||||
|
formData.granblue_id.trim() !== ''
|
||||||
|
)
|
||||||
|
|
||||||
|
async function handleCreate() {
|
||||||
|
if (!isValid) return
|
||||||
|
|
||||||
|
isSaving = true
|
||||||
|
saveError = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const accessory = await jobAdapter.createAccessory(formData)
|
||||||
|
|
||||||
|
// Invalidate cache
|
||||||
|
await queryClient.invalidateQueries({ queryKey: jobAccessoryKeys.all })
|
||||||
|
|
||||||
|
// Navigate to the new accessory
|
||||||
|
goto(`/database/job-accessories/${accessory.granblueId}`)
|
||||||
|
} catch (error) {
|
||||||
|
saveError = 'Failed to create accessory. Please try again.'
|
||||||
|
console.error('Create error:', error)
|
||||||
|
} finally {
|
||||||
|
isSaving = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCancel() {
|
||||||
|
goto('/database/jobs?view=accessories')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<PageMeta title="New Job Accessory" description="Create a new job accessory" />
|
||||||
|
|
||||||
|
<div class="page">
|
||||||
|
<div class="header">
|
||||||
|
<div class="header-content">
|
||||||
|
<button class="back-button" onclick={handleCancel}>
|
||||||
|
← Back to Accessories
|
||||||
|
</button>
|
||||||
|
<h1 class="title">New Job Accessory</h1>
|
||||||
|
</div>
|
||||||
|
<div class="header-actions">
|
||||||
|
<Button variant="secondary" size="small" onclick={handleCancel} disabled={isSaving}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" size="small" onclick={handleCreate} disabled={isSaving || !isValid}>
|
||||||
|
{isSaving ? 'Creating...' : 'Create'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if saveError}
|
||||||
|
<div class="error-message">{saveError}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<section class="details">
|
||||||
|
<DetailsContainer title="Names">
|
||||||
|
<div class="form-field">
|
||||||
|
<label for="name_en">English Name <span class="required">*</span></label>
|
||||||
|
<input type="text" id="name_en" bind:value={formData.name_en} placeholder="Enter English name" />
|
||||||
|
</div>
|
||||||
|
<div class="form-field">
|
||||||
|
<label for="name_jp">Japanese Name</label>
|
||||||
|
<input type="text" id="name_jp" bind:value={formData.name_jp} placeholder="Enter Japanese name" />
|
||||||
|
</div>
|
||||||
|
</DetailsContainer>
|
||||||
|
|
||||||
|
<DetailsContainer title="Identification">
|
||||||
|
<div class="form-field">
|
||||||
|
<label for="granblue_id">Granblue ID <span class="required">*</span></label>
|
||||||
|
<input type="text" id="granblue_id" bind:value={formData.granblue_id} placeholder="e.g., 1234567" />
|
||||||
|
<p class="hint">The unique game identifier for this accessory</p>
|
||||||
|
</div>
|
||||||
|
</DetailsContainer>
|
||||||
|
|
||||||
|
<DetailsContainer title="Classification">
|
||||||
|
<div class="form-field">
|
||||||
|
<label for="accessory_type">Accessory Type</label>
|
||||||
|
<select id="accessory_type" bind:value={formData.accessory_type}>
|
||||||
|
<option value={ACCESSORY_TYPES.SHIELD}>Shield</option>
|
||||||
|
<option value={ACCESSORY_TYPES.MANATURA}>Manatura</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-field">
|
||||||
|
<label for="rarity">Rarity</label>
|
||||||
|
<input type="number" id="rarity" bind:value={formData.rarity} min="0" max="4" />
|
||||||
|
</div>
|
||||||
|
<div class="form-field">
|
||||||
|
<label for="release_date">Release Date</label>
|
||||||
|
<input type="date" id="release_date" bind:value={formData.release_date} />
|
||||||
|
</div>
|
||||||
|
</DetailsContainer>
|
||||||
|
</section>
|
||||||
|
</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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: spacing.$unit-2x;
|
||||||
|
padding: spacing.$unit-2x;
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 0.5px solid rgba(0, 0, 0, 0.18);
|
||||||
|
border-radius: layout.$page-corner;
|
||||||
|
box-shadow: effects.$page-elevation;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: spacing.$unit-half;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: spacing.$unit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: typography.$font-small;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
margin-bottom: spacing.$unit-half;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: typography.$font-xlarge;
|
||||||
|
font-weight: typography.$bold;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: spacing.$unit-2x;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: spacing.$unit-half;
|
||||||
|
padding: spacing.$unit 0;
|
||||||
|
|
||||||
|
label {
|
||||||
|
font-size: typography.$font-small;
|
||||||
|
font-weight: typography.$medium;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
|
||||||
|
.required {
|
||||||
|
color: colors.$red;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
select {
|
||||||
|
padding: spacing.$unit spacing.$unit-2x;
|
||||||
|
background: var(--input-bound-bg);
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
border-radius: layout.$item-corner;
|
||||||
|
font-size: typography.$font-medium;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--input-bound-bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: colors.$blue;
|
||||||
|
box-shadow: 0 0 0 2px rgba(colors.$blue, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
font-size: typography.$font-tiny;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
padding: spacing.$unit-2x;
|
||||||
|
margin-bottom: spacing.$unit-2x;
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #991b1b;
|
||||||
|
border-radius: layout.$item-corner;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -2,143 +2,218 @@
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation'
|
import { goto } from '$app/navigation'
|
||||||
|
import { page } from '$app/stores'
|
||||||
import { createQuery } from '@tanstack/svelte-query'
|
import { createQuery } from '@tanstack/svelte-query'
|
||||||
import { jobQueries } from '$lib/api/queries/job.queries'
|
import { jobQueries } from '$lib/api/queries/job.queries'
|
||||||
import PageMeta from '$lib/components/PageMeta.svelte'
|
import PageMeta from '$lib/components/PageMeta.svelte'
|
||||||
import * as m from '$lib/paraglide/messages'
|
import * as m from '$lib/paraglide/messages'
|
||||||
import { getJobIconUrl, getJobTierName } from '$lib/utils/jobUtils'
|
import { getAccessoryTypeName, ACCESSORY_TYPES } from '$lib/utils/jobAccessoryUtils'
|
||||||
import ProficiencyLabel from '$lib/components/labels/ProficiencyLabel.svelte'
|
import SegmentedControl from '$lib/components/ui/segmented-control/SegmentedControl.svelte'
|
||||||
import type { Job } from '$lib/types/api/entities'
|
import Segment from '$lib/components/ui/segmented-control/Segment.svelte'
|
||||||
|
import DatabaseGridWithProvider from '$lib/components/database/DatabaseGridWithProvider.svelte'
|
||||||
|
import type { IColumn } from 'wx-svelte-grid'
|
||||||
|
import type { JobAccessory } from '$lib/types/api/entities'
|
||||||
|
|
||||||
// Fetch all jobs
|
// Job cell components
|
||||||
const jobsQuery = createQuery(() => jobQueries.list())
|
import JobIconCell from '$lib/components/database/cells/JobIconCell.svelte'
|
||||||
|
import JobTierCell from '$lib/components/database/cells/JobTierCell.svelte'
|
||||||
|
import JobProficienciesCell from '$lib/components/database/cells/JobProficienciesCell.svelte'
|
||||||
|
import JobFeaturesCell from '$lib/components/database/cells/JobFeaturesCell.svelte'
|
||||||
|
|
||||||
// Search state
|
// View mode state - read initial value from URL
|
||||||
let searchTerm = $state('')
|
const initialView = $page.url.searchParams.get('view')
|
||||||
|
let viewMode = $state<'jobs' | 'accessories'>(initialView === 'accessories' ? 'accessories' : 'jobs')
|
||||||
|
|
||||||
// Filter jobs based on search
|
// Accessory type filter (for accessories view)
|
||||||
const filteredJobs = $derived.by(() => {
|
let accessoryTypeFilter = $state<number | undefined>(undefined)
|
||||||
const jobs = jobsQuery.data ?? []
|
|
||||||
if (!searchTerm.trim()) return jobs
|
|
||||||
|
|
||||||
const term = searchTerm.toLowerCase()
|
// Sync viewMode changes to URL
|
||||||
return jobs.filter(
|
$effect(() => {
|
||||||
(job) =>
|
const currentView = $page.url.searchParams.get('view')
|
||||||
job.name.en.toLowerCase().includes(term) ||
|
if (viewMode === 'accessories' && currentView !== 'accessories') {
|
||||||
job.name.ja?.toLowerCase().includes(term) ||
|
goto('?view=accessories', { replaceState: true, noScroll: true })
|
||||||
job.granblueId.includes(term) ||
|
} else if (viewMode === 'jobs' && currentView === 'accessories') {
|
||||||
getJobTierName(job.row).toLowerCase().includes(term)
|
goto('/database/jobs', { replaceState: true, noScroll: true })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Define columns for jobs grid
|
||||||
|
const jobColumns: IColumn[] = [
|
||||||
|
{
|
||||||
|
id: 'granblueId',
|
||||||
|
header: '',
|
||||||
|
width: 60,
|
||||||
|
cell: JobIconCell
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'name',
|
||||||
|
header: 'Name',
|
||||||
|
flexgrow: 1,
|
||||||
|
sort: true,
|
||||||
|
template: (nameObj: any) => {
|
||||||
|
if (!nameObj) return '—'
|
||||||
|
if (typeof nameObj === 'string') return nameObj
|
||||||
|
return nameObj.en || nameObj.ja || '—'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'row',
|
||||||
|
header: 'Row',
|
||||||
|
width: 100,
|
||||||
|
sort: true,
|
||||||
|
cell: JobTierCell
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'proficiency',
|
||||||
|
header: 'Proficiencies',
|
||||||
|
width: 120,
|
||||||
|
cell: JobProficienciesCell
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'features',
|
||||||
|
header: 'Features',
|
||||||
|
width: 200,
|
||||||
|
cell: JobFeaturesCell
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
// Fetch all accessories (for accessories view)
|
||||||
|
const accessoriesQuery = createQuery(() => ({
|
||||||
|
...jobQueries.accessoriesList(accessoryTypeFilter),
|
||||||
|
enabled: viewMode === 'accessories'
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Search state for accessories
|
||||||
|
let accessorySearchTerm = $state('')
|
||||||
|
|
||||||
|
// Filter accessories based on search
|
||||||
|
const filteredAccessories = $derived.by(() => {
|
||||||
|
const accessories = accessoriesQuery.data ?? []
|
||||||
|
if (!accessorySearchTerm.trim()) return accessories
|
||||||
|
|
||||||
|
const term = accessorySearchTerm.toLowerCase()
|
||||||
|
return accessories.filter(
|
||||||
|
(acc) =>
|
||||||
|
acc.name.en.toLowerCase().includes(term) ||
|
||||||
|
acc.name.ja?.toLowerCase().includes(term) ||
|
||||||
|
acc.granblueId.includes(term)
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Row order mapping for sorting
|
// Sort accessories by type then granblue_id
|
||||||
const rowOrder: Record<string, number> = {
|
const sortedAccessories = $derived.by(() => {
|
||||||
'1': 1,
|
const accessories = [...filteredAccessories]
|
||||||
'2': 2,
|
accessories.sort((a, b) => {
|
||||||
'3': 3,
|
if (a.accessoryType !== b.accessoryType) {
|
||||||
'4': 4,
|
return a.accessoryType - b.accessoryType
|
||||||
'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
|
|
||||||
}
|
}
|
||||||
|
return a.granblueId.localeCompare(b.granblueId)
|
||||||
// Secondary sort: by order within the same row
|
|
||||||
return (a.order || 0) - (b.order || 0)
|
|
||||||
})
|
})
|
||||||
|
return accessories
|
||||||
return jobs
|
|
||||||
})
|
})
|
||||||
|
|
||||||
function handleRowClick(job: Job) {
|
function handleAccessoryRowClick(accessory: JobAccessory) {
|
||||||
goto(`/database/jobs/${job.granblueId}`)
|
goto(`/database/job-accessories/${accessory.granblueId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAccessoryTypeChange(event: Event) {
|
||||||
|
const select = event.target as HTMLSelectElement
|
||||||
|
accessoryTypeFilter = select.value ? Number(select.value) : undefined
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<PageMeta title={m.page_title_db_jobs()} description={m.page_desc_home()} />
|
<PageMeta title={m.page_title_db_jobs()} description={m.page_desc_home()} />
|
||||||
|
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<div class="grid-container">
|
{#if viewMode === 'jobs'}
|
||||||
<div class="controls">
|
<!-- Jobs View - Using DatabaseGridWithProvider -->
|
||||||
<input type="text" placeholder="Search jobs..." bind:value={searchTerm} class="search" />
|
<DatabaseGridWithProvider resource="jobs" columns={jobColumns} pageSize={20}>
|
||||||
</div>
|
{#snippet leftActions()}
|
||||||
|
<SegmentedControl bind:value={viewMode} size="xsmall" variant="background">
|
||||||
|
<Segment value="jobs">Jobs</Segment>
|
||||||
|
<Segment value="accessories">Accessories</Segment>
|
||||||
|
</SegmentedControl>
|
||||||
|
{/snippet}
|
||||||
|
</DatabaseGridWithProvider>
|
||||||
|
{:else}
|
||||||
|
<!-- Accessories View - Custom table -->
|
||||||
|
<div class="grid-container">
|
||||||
|
<div class="controls">
|
||||||
|
<div class="controls-left">
|
||||||
|
<SegmentedControl bind:value={viewMode} size="xsmall" variant="background">
|
||||||
|
<Segment value="jobs">Jobs</Segment>
|
||||||
|
<Segment value="accessories">Accessories</Segment>
|
||||||
|
</SegmentedControl>
|
||||||
|
|
||||||
{#if jobsQuery.isLoading}
|
<select class="filter-select" onchange={handleAccessoryTypeChange}>
|
||||||
<div class="loading">Loading jobs...</div>
|
<option value="">All types</option>
|
||||||
{:else if jobsQuery.isError}
|
<option value={ACCESSORY_TYPES.SHIELD}>Shield</option>
|
||||||
<div class="error">Failed to load jobs</div>
|
<option value={ACCESSORY_TYPES.MANATURA}>Manatura</option>
|
||||||
{:else}
|
</select>
|
||||||
<div class="table-wrapper">
|
</div>
|
||||||
<table class="data-table">
|
|
||||||
<thead>
|
<input type="text" placeholder="Search accessories..." bind:value={accessorySearchTerm} class="search" />
|
||||||
<tr>
|
</div>
|
||||||
<th class="col-image">Image</th>
|
|
||||||
<th class="col-name">Name</th>
|
{#if accessoriesQuery.isLoading}
|
||||||
<th class="col-row">Row</th>
|
<div class="loading">Loading accessories...</div>
|
||||||
<th class="col-proficiency">Proficiencies</th>
|
{:else if accessoriesQuery.isError}
|
||||||
<th class="col-features">Features</th>
|
<div class="error">Failed to load accessories</div>
|
||||||
</tr>
|
{:else if sortedAccessories.length === 0}
|
||||||
</thead>
|
<div class="empty">No accessories found</div>
|
||||||
<tbody>
|
{:else}
|
||||||
{#each sortedJobs as job (job.id)}
|
<div class="table-wrapper">
|
||||||
<tr onclick={() => handleRowClick(job)} class="clickable">
|
<table class="data-table">
|
||||||
<td class="col-image">
|
<thead>
|
||||||
<img src={getJobIconUrl(job.granblueId)} alt={job.name.en} class="job-icon" />
|
<tr>
|
||||||
</td>
|
<th class="col-name">Name</th>
|
||||||
<td class="col-name">
|
<th class="col-type">Type</th>
|
||||||
<div class="name-cell">
|
<th class="col-job">Job</th>
|
||||||
<span class="name-en">{job.name.en}</span>
|
<th class="col-rarity">Rarity</th>
|
||||||
</div>
|
<th class="col-id">Granblue ID</th>
|
||||||
</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>
|
</tr>
|
||||||
{/each}
|
</thead>
|
||||||
</tbody>
|
<tbody>
|
||||||
</table>
|
{#each sortedAccessories as accessory (accessory.id)}
|
||||||
</div>
|
<tr onclick={() => handleAccessoryRowClick(accessory)} class="clickable">
|
||||||
|
<td class="col-name">
|
||||||
|
<div class="name-cell">
|
||||||
|
<span class="name-en">{accessory.name.en}</span>
|
||||||
|
{#if accessory.name.ja}
|
||||||
|
<span class="name-ja">{accessory.name.ja}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="col-type">
|
||||||
|
<span class="type-badge {accessory.accessoryType === ACCESSORY_TYPES.SHIELD ? 'shield' : 'manatura'}">
|
||||||
|
{getAccessoryTypeName(accessory.accessoryType)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="col-job">
|
||||||
|
{#if accessory.job}
|
||||||
|
{accessory.job.name.en}
|
||||||
|
{:else}
|
||||||
|
—
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td class="col-rarity">
|
||||||
|
{accessory.rarity ?? '—'}
|
||||||
|
</td>
|
||||||
|
<td class="col-id">
|
||||||
|
<code>{accessory.granblueId}</code>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="footer">
|
<div class="footer">
|
||||||
Showing {sortedJobs.length} of {jobsQuery.data?.length ?? 0} jobs
|
Showing {sortedAccessories.length} of {accessoriesQuery.data?.length ?? 0} accessories
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
|
|
@ -165,17 +240,41 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: spacing.$unit;
|
padding: spacing.$unit-2x;
|
||||||
border-bottom: 1px solid #e5e5e5;
|
|
||||||
gap: spacing.$unit;
|
gap: spacing.$unit;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
.controls-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: spacing.$unit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-select {
|
||||||
|
padding: spacing.$unit spacing.$unit-2x;
|
||||||
|
background: var(--input-bound-bg);
|
||||||
|
border: none;
|
||||||
|
border-radius: layout.$item-corner;
|
||||||
|
font-size: typography.$font-small;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--input-bound-bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 2px colors.$blue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.search {
|
.search {
|
||||||
padding: spacing.$unit spacing.$unit-2x;
|
padding: spacing.$unit spacing.$unit-2x;
|
||||||
background: var(--input-bound-bg);
|
background: var(--input-bound-bg);
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: layout.$item-corner;
|
border-radius: layout.$item-corner;
|
||||||
font-size: typography.$font-medium;
|
font-size: typography.$font-small;
|
||||||
width: 100%;
|
width: 200px;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: var(--input-bound-bg-hover);
|
background: var(--input-bound-bg-hover);
|
||||||
|
|
@ -189,7 +288,8 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading,
|
.loading,
|
||||||
.error {
|
.error,
|
||||||
|
.empty {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: spacing.$unit * 4;
|
padding: spacing.$unit * 4;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
|
|
@ -232,80 +332,59 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.col-image {
|
|
||||||
width: 60px;
|
|
||||||
padding-left: spacing.$unit-2x !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.col-name {
|
.col-name {
|
||||||
min-width: 180px;
|
min-width: 180px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.col-row {
|
.col-type {
|
||||||
width: 100px;
|
width: 100px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.col-proficiency {
|
.col-job {
|
||||||
width: 100px;
|
min-width: 150px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.col-features {
|
.col-rarity {
|
||||||
width: 200px;
|
width: 80px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.job-icon {
|
.col-id {
|
||||||
width: auto;
|
width: 120px;
|
||||||
height: 28px;
|
|
||||||
border-radius: 4px;
|
code {
|
||||||
|
font-size: typography.$font-small;
|
||||||
|
background: #f0f0f0;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.name-cell {
|
.name-cell {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
|
|
||||||
|
.name-ja {
|
||||||
|
font-size: typography.$font-small;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.tier-badge {
|
.type-badge {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
background: colors.$grey-90;
|
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-size: typography.$font-small;
|
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;
|
font-weight: typography.$medium;
|
||||||
|
|
||||||
&.master {
|
&.shield {
|
||||||
background: colors.$yellow;
|
background: #e0f2fe;
|
||||||
color: white;
|
color: #0369a1;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.ultimate {
|
&.manatura {
|
||||||
background: colors.$dark-bg-00;
|
background: #fce7f3;
|
||||||
color: white;
|
color: #be185d;
|
||||||
}
|
|
||||||
|
|
||||||
&.accessory {
|
|
||||||
background: colors.$blue;
|
|
||||||
color: white;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
16
src/routes/(app)/database/jobs/new/+page.server.ts
Normal file
16
src/routes/(app)/database/jobs/new/+page.server.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import type { PageServerLoad } from './$types'
|
||||||
|
import { redirect } from '@sveltejs/kit'
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ parent }) => {
|
||||||
|
// Get parent data to access role
|
||||||
|
const parentData = await parent()
|
||||||
|
|
||||||
|
// Check if user has editor role
|
||||||
|
if (!parentData.role || parentData.role < 7) {
|
||||||
|
throw redirect(302, '/database/jobs')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
role: parentData.role
|
||||||
|
}
|
||||||
|
}
|
||||||
352
src/routes/(app)/database/jobs/new/+page.svelte
Normal file
352
src/routes/(app)/database/jobs/new/+page.svelte
Normal file
|
|
@ -0,0 +1,352 @@
|
||||||
|
<svelte:options runes={true} />
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
// SvelteKit imports
|
||||||
|
import { goto } from '$app/navigation'
|
||||||
|
|
||||||
|
// Page metadata
|
||||||
|
import PageMeta from '$lib/components/PageMeta.svelte'
|
||||||
|
|
||||||
|
// TanStack Query
|
||||||
|
import { useQueryClient, createQuery } from '@tanstack/svelte-query'
|
||||||
|
import { jobKeys, jobQueries } from '$lib/api/queries/job.queries'
|
||||||
|
import { jobAdapter, type JobUpdatePayload } from '$lib/api/adapters/job.adapter'
|
||||||
|
|
||||||
|
// Components
|
||||||
|
import Button from '$lib/components/ui/Button.svelte'
|
||||||
|
import DetailsContainer from '$lib/components/ui/DetailsContainer.svelte'
|
||||||
|
|
||||||
|
// Utils
|
||||||
|
import { PROFICIENCIES, ROWS } from '$lib/utils/jobUtils'
|
||||||
|
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
// Fetch all jobs for base job selection
|
||||||
|
const jobsQuery = createQuery(() => jobQueries.list())
|
||||||
|
|
||||||
|
// Save state
|
||||||
|
let isSaving = $state(false)
|
||||||
|
let saveError = $state<string | null>(null)
|
||||||
|
|
||||||
|
// Form data
|
||||||
|
let formData = $state<JobUpdatePayload>({
|
||||||
|
name_en: '',
|
||||||
|
name_jp: '',
|
||||||
|
granblue_id: '',
|
||||||
|
proficiency1: 0,
|
||||||
|
proficiency2: 0,
|
||||||
|
row: '1',
|
||||||
|
order: 0,
|
||||||
|
master_level: false,
|
||||||
|
ultimate_mastery: false,
|
||||||
|
accessory: false,
|
||||||
|
accessory_type: 0,
|
||||||
|
aux_weapon: false,
|
||||||
|
base_job_id: null
|
||||||
|
})
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
const isValid = $derived(
|
||||||
|
formData.name_en?.trim() !== '' &&
|
||||||
|
formData.granblue_id?.trim() !== ''
|
||||||
|
)
|
||||||
|
|
||||||
|
// Filter base jobs - only show row 4/5/ex2 jobs that could be base jobs
|
||||||
|
const baseJobOptions = $derived(
|
||||||
|
(jobsQuery.data ?? [])
|
||||||
|
.filter(job => ['4', '5', 'ex2'].includes(String(job.row || '')))
|
||||||
|
.sort((a, b) => (a.name?.en || '').localeCompare(b.name?.en || ''))
|
||||||
|
)
|
||||||
|
|
||||||
|
async function handleCreate() {
|
||||||
|
if (!isValid) return
|
||||||
|
|
||||||
|
isSaving = true
|
||||||
|
saveError = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const job = await jobAdapter.createJob(formData)
|
||||||
|
|
||||||
|
// Invalidate cache
|
||||||
|
await queryClient.invalidateQueries({ queryKey: jobKeys.all })
|
||||||
|
|
||||||
|
// Navigate to the new job
|
||||||
|
goto(`/database/jobs/${job.granblueId}`)
|
||||||
|
} catch (error) {
|
||||||
|
saveError = 'Failed to create job. Please try again.'
|
||||||
|
console.error('Create error:', error)
|
||||||
|
} finally {
|
||||||
|
isSaving = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCancel() {
|
||||||
|
goto('/database/jobs')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<PageMeta title="New Job" description="Create a new job" />
|
||||||
|
|
||||||
|
<div class="page">
|
||||||
|
<div class="header">
|
||||||
|
<div class="header-content">
|
||||||
|
<button class="back-button" onclick={handleCancel}>
|
||||||
|
← Back to Jobs
|
||||||
|
</button>
|
||||||
|
<h1 class="title">New Job</h1>
|
||||||
|
</div>
|
||||||
|
<div class="header-actions">
|
||||||
|
<Button variant="secondary" size="small" onclick={handleCancel} disabled={isSaving}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" size="small" onclick={handleCreate} disabled={isSaving || !isValid}>
|
||||||
|
{isSaving ? 'Creating...' : 'Create'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if saveError}
|
||||||
|
<div class="error-message">{saveError}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<section class="details">
|
||||||
|
<DetailsContainer title="Names">
|
||||||
|
<div class="form-field">
|
||||||
|
<label for="name_en">English Name <span class="required">*</span></label>
|
||||||
|
<input type="text" id="name_en" bind:value={formData.name_en} placeholder="Enter English name" />
|
||||||
|
</div>
|
||||||
|
<div class="form-field">
|
||||||
|
<label for="name_jp">Japanese Name</label>
|
||||||
|
<input type="text" id="name_jp" bind:value={formData.name_jp} placeholder="Enter Japanese name" />
|
||||||
|
</div>
|
||||||
|
</DetailsContainer>
|
||||||
|
|
||||||
|
<DetailsContainer title="Identification">
|
||||||
|
<div class="form-field">
|
||||||
|
<label for="granblue_id">Granblue ID <span class="required">*</span></label>
|
||||||
|
<input type="text" id="granblue_id" bind:value={formData.granblue_id} placeholder="e.g., 100001" />
|
||||||
|
<p class="hint">The unique game identifier for this job</p>
|
||||||
|
</div>
|
||||||
|
<div class="form-field">
|
||||||
|
<label for="order">Order</label>
|
||||||
|
<input type="number" id="order" bind:value={formData.order} min="0" />
|
||||||
|
<p class="hint">Display order within the job row</p>
|
||||||
|
</div>
|
||||||
|
</DetailsContainer>
|
||||||
|
|
||||||
|
<DetailsContainer title="Classification">
|
||||||
|
<div class="form-field">
|
||||||
|
<label for="row">Row</label>
|
||||||
|
<select id="row" bind:value={formData.row}>
|
||||||
|
{#each ROWS as row}
|
||||||
|
<option value={row.value}>{row.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-field">
|
||||||
|
<label for="proficiency1">Primary Proficiency</label>
|
||||||
|
<select id="proficiency1" bind:value={formData.proficiency1}>
|
||||||
|
{#each PROFICIENCIES as prof}
|
||||||
|
<option value={prof.value}>{prof.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-field">
|
||||||
|
<label for="proficiency2">Secondary Proficiency</label>
|
||||||
|
<select id="proficiency2" bind:value={formData.proficiency2}>
|
||||||
|
{#each PROFICIENCIES as prof}
|
||||||
|
<option value={prof.value}>{prof.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-field">
|
||||||
|
<label for="base_job_id">Base Job</label>
|
||||||
|
<select id="base_job_id" bind:value={formData.base_job_id}>
|
||||||
|
<option value={null}>None</option>
|
||||||
|
{#each baseJobOptions as job}
|
||||||
|
<option value={job.id}>{job.name?.en}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
<p class="hint">For EX2 jobs, select the Row IV/V job they are based on</p>
|
||||||
|
</div>
|
||||||
|
</DetailsContainer>
|
||||||
|
|
||||||
|
<DetailsContainer title="Features">
|
||||||
|
<div class="form-field checkbox-field">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" bind:checked={formData.master_level} />
|
||||||
|
Master Level
|
||||||
|
</label>
|
||||||
|
<p class="hint">Job has master levels</p>
|
||||||
|
</div>
|
||||||
|
<div class="form-field checkbox-field">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" bind:checked={formData.ultimate_mastery} />
|
||||||
|
Ultimate Mastery
|
||||||
|
</label>
|
||||||
|
<p class="hint">Job has ultimate mastery</p>
|
||||||
|
</div>
|
||||||
|
<div class="form-field checkbox-field">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" bind:checked={formData.accessory} />
|
||||||
|
Accessory
|
||||||
|
</label>
|
||||||
|
<p class="hint">Job supports accessories (shields/manatura)</p>
|
||||||
|
</div>
|
||||||
|
<div class="form-field checkbox-field">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" bind:checked={formData.aux_weapon} />
|
||||||
|
Auxiliary Weapon
|
||||||
|
</label>
|
||||||
|
<p class="hint">Job supports auxiliary weapon slot</p>
|
||||||
|
</div>
|
||||||
|
</DetailsContainer>
|
||||||
|
</section>
|
||||||
|
</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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: spacing.$unit-2x;
|
||||||
|
padding: spacing.$unit-2x;
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 0.5px solid rgba(0, 0, 0, 0.18);
|
||||||
|
border-radius: layout.$page-corner;
|
||||||
|
box-shadow: effects.$page-elevation;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: spacing.$unit-half;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: spacing.$unit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: typography.$font-small;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
margin-bottom: spacing.$unit-half;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: typography.$font-xlarge;
|
||||||
|
font-weight: typography.$bold;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: spacing.$unit-2x;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: spacing.$unit-half;
|
||||||
|
padding: spacing.$unit 0;
|
||||||
|
|
||||||
|
label {
|
||||||
|
font-size: typography.$font-small;
|
||||||
|
font-weight: typography.$medium;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
|
||||||
|
.required {
|
||||||
|
color: colors.$red;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"],
|
||||||
|
input[type="number"],
|
||||||
|
select {
|
||||||
|
padding: spacing.$unit spacing.$unit-2x;
|
||||||
|
background: var(--input-bound-bg);
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
border-radius: layout.$item-corner;
|
||||||
|
font-size: typography.$font-medium;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--input-bound-bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: colors.$blue;
|
||||||
|
box-shadow: 0 0 0 2px rgba(colors.$blue, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
font-size: typography.$font-tiny;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.checkbox-field {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: spacing.$unit;
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: spacing.$unit-half;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-primary);
|
||||||
|
|
||||||
|
input[type="checkbox"] {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
flex-basis: 100%;
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
padding: spacing.$unit-2x;
|
||||||
|
margin-bottom: spacing.$unit-2x;
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #991b1b;
|
||||||
|
border-radius: layout.$item-corner;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Loading…
Reference in a new issue