add job accessory database pages
- detail page with job info and accessory properties - edit page for editors - new page for creating accessories
This commit is contained in:
parent
29211709ef
commit
130d32c9ff
6 changed files with 837 additions and 0 deletions
|
|
@ -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>
|
||||||
Loading…
Reference in a new issue