weapon series detail/edit pages (#449)
## Summary - add detail and edit pages for weapon series at `/database/series/weapons/[slug]` - fix series table navigation to use slug instead of uuid - add `?view=series` URL param for bookmarkable series view - remove redundant `/database/series` page - rename augment type `none` → `no_augment` for consistency ## Test plan - [ ] navigate to /database/weapons, toggle to series view - [ ] verify URL updates to ?view=series - [ ] click a series row, should go to detail page - [ ] back button returns to series view - [ ] edit page saves correctly
This commit is contained in:
parent
839365a5a1
commit
1b00889e81
15 changed files with 496 additions and 261 deletions
|
|
@ -29,7 +29,7 @@ describe('EntityAdapter', () => {
|
|||
name: { en: 'Dark Opus', ja: 'ダークオーパス' },
|
||||
hasWeaponKeys: true,
|
||||
hasAwakening: true,
|
||||
augmentType: 'none',
|
||||
augmentType: 'no_augment',
|
||||
extra: false,
|
||||
elementChangeable: false
|
||||
},
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ describe('GridAdapter', () => {
|
|||
name: { en: 'Dark Opus', ja: 'ダークオーパス' },
|
||||
hasWeaponKeys: true,
|
||||
hasAwakening: true,
|
||||
augmentType: 'none',
|
||||
augmentType: 'no_augment',
|
||||
extra: false,
|
||||
elementChangeable: false
|
||||
},
|
||||
|
|
|
|||
|
|
@ -114,7 +114,7 @@
|
|||
const keySlotCount = $derived(seriesSlug ? (WEAPON_KEY_SLOTS[seriesSlug] ?? 2) : 0)
|
||||
|
||||
// Augment type from series determines AX skills vs befoulment
|
||||
const augmentType = $derived(series?.augmentType ?? 'none')
|
||||
const augmentType = $derived(series?.augmentType ?? 'no_augment')
|
||||
const hasAxSkills = $derived(augmentType === 'ax')
|
||||
const hasBefoulment = $derived(augmentType === 'befoulment')
|
||||
const hasAwakening = $derived((weaponData?.maxAwakeningLevel ?? 0) > 0)
|
||||
|
|
|
|||
|
|
@ -82,7 +82,7 @@
|
|||
const keySlotCount = $derived(seriesSlug ? (WEAPON_KEY_SLOTS[seriesSlug] ?? 2) : 0)
|
||||
|
||||
// Augment type from series determines AX skills vs befoulment
|
||||
const augmentType = $derived(series?.augmentType ?? 'none')
|
||||
const augmentType = $derived(series?.augmentType ?? 'no_augment')
|
||||
const hasAxSkills = $derived(augmentType === 'ax')
|
||||
const hasBefoulment = $derived(augmentType === 'befoulment')
|
||||
const hasAwakening = $derived((weaponData?.maxAwakeningLevel ?? 0) > 0)
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ export interface WeaponSeriesRef {
|
|||
name: { en: string; ja: string }
|
||||
hasWeaponKeys: boolean
|
||||
hasAwakening: boolean
|
||||
/** Type of augment this series supports: "ax", "befoulment", or "none" */
|
||||
/** Type of augment this series supports: "ax", "befoulment", or "no_augment" */
|
||||
augmentType: AugmentType
|
||||
extra: boolean
|
||||
elementChangeable: boolean
|
||||
|
|
@ -40,7 +40,7 @@ export interface WeaponSeries {
|
|||
elementChangeable: boolean
|
||||
hasWeaponKeys: boolean
|
||||
hasAwakening: boolean
|
||||
/** Type of augment this series supports: "ax", "befoulment", or "none" */
|
||||
/** Type of augment this series supports: "ax", "befoulment", or "no_augment" */
|
||||
augmentType: AugmentType
|
||||
// Only included in :full view (show endpoint)
|
||||
weaponCount?: number
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
* Augment type enum for weapon series.
|
||||
* Determines whether a weapon series supports AX skills, befoulments, or neither.
|
||||
*/
|
||||
export type AugmentType = 'none' | 'ax' | 'befoulment'
|
||||
export type AugmentType = 'no_augment' | 'ax' | 'befoulment'
|
||||
|
||||
/**
|
||||
* WeaponStatModifier from the API.
|
||||
|
|
|
|||
42
src/lib/utils/augmentType.ts
Normal file
42
src/lib/utils/augmentType.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import type { AugmentType } from '$lib/types/api/weaponStatModifier'
|
||||
|
||||
interface AugmentTypeData {
|
||||
en: string
|
||||
ja: string
|
||||
}
|
||||
|
||||
export const AUGMENT_TYPES: Record<AugmentType, AugmentTypeData> = {
|
||||
no_augment: { en: 'None', ja: 'なし' },
|
||||
ax: { en: 'AX Skills', ja: 'AXスキル' },
|
||||
befoulment: { en: 'Befoulment', ja: '禍スキル' }
|
||||
}
|
||||
|
||||
export const AUGMENT_TYPE_LABELS: Record<AugmentType, string> = {
|
||||
no_augment: 'None',
|
||||
ax: 'AX Skills',
|
||||
befoulment: 'Befoulment'
|
||||
}
|
||||
|
||||
export function getAugmentTypeLabel(type?: AugmentType): string {
|
||||
if (!type) return '—'
|
||||
return AUGMENT_TYPE_LABELS[type] || '—'
|
||||
}
|
||||
|
||||
export function getAugmentTypeName(type?: AugmentType, locale: 'en' | 'ja' = 'en'): string {
|
||||
if (!type) return '—'
|
||||
const data = AUGMENT_TYPES[type]
|
||||
if (!data) return '—'
|
||||
return data[locale]
|
||||
}
|
||||
|
||||
export function getAugmentTypeOptions(): Array<{ value: AugmentType; label: string }> {
|
||||
return Object.entries(AUGMENT_TYPE_LABELS).map(([value, label]) => ({
|
||||
value: value as AugmentType,
|
||||
label
|
||||
}))
|
||||
}
|
||||
|
||||
export function getAugmentTypeClass(type?: AugmentType): string {
|
||||
if (!type || type === 'no_augment') return ''
|
||||
return `augment-${type}`
|
||||
}
|
||||
|
|
@ -116,8 +116,8 @@ export function canWeaponBeModified(gridWeapon: GridWeapon | undefined): boolean
|
|||
const hasWeaponKeys = seriesHasWeaponKeys(weapon.series)
|
||||
|
||||
// AX skills or Befoulment - check augmentType from series
|
||||
const augmentType = weapon.series?.augmentType ?? 'none'
|
||||
const hasAugments = augmentType !== 'none'
|
||||
const augmentType = weapon.series?.augmentType ?? 'no_augment'
|
||||
const hasAugments = augmentType !== 'no_augment'
|
||||
|
||||
// Awakening (maxAwakeningLevel > 0 means it can have awakening)
|
||||
const hasAwakening = (weapon.maxAwakeningLevel ?? 0) > 0
|
||||
|
|
|
|||
|
|
@ -1,241 +0,0 @@
|
|||
<svelte:options runes={true} />
|
||||
|
||||
<script lang="ts">
|
||||
import PageMeta from '$lib/components/PageMeta.svelte'
|
||||
import * as m from '$lib/paraglide/messages'
|
||||
import { createQuery } from '@tanstack/svelte-query'
|
||||
import { entityQueries } from '$lib/api/queries/entity.queries'
|
||||
import SegmentedControl from '$lib/components/ui/segmented-control/SegmentedControl.svelte'
|
||||
import Segment from '$lib/components/ui/segmented-control/Segment.svelte'
|
||||
|
||||
type SeriesType = 'weapons' | 'characters' | 'summons'
|
||||
|
||||
let activeType = $state<SeriesType>('weapons')
|
||||
|
||||
// Fetch all series lists
|
||||
const weaponSeriesQuery = createQuery(() => entityQueries.weaponSeriesList())
|
||||
const characterSeriesQuery = createQuery(() => entityQueries.characterSeriesList())
|
||||
const summonSeriesQuery = createQuery(() => entityQueries.summonSeriesList())
|
||||
|
||||
// Get active query based on selected type
|
||||
const activeQuery = $derived.by(() => {
|
||||
switch (activeType) {
|
||||
case 'weapons':
|
||||
return weaponSeriesQuery
|
||||
case 'characters':
|
||||
return characterSeriesQuery
|
||||
case 'summons':
|
||||
return summonSeriesQuery
|
||||
}
|
||||
})
|
||||
|
||||
// Get sorted data
|
||||
const sortedData = $derived.by(() => {
|
||||
if (!activeQuery.data) return []
|
||||
return [...activeQuery.data].sort((a, b) => a.order - b.order)
|
||||
})
|
||||
|
||||
// Check if the current type has flags (only weapons)
|
||||
const hasFlags = $derived(activeType === 'weapons')
|
||||
</script>
|
||||
|
||||
<PageMeta title={m.page_title_db_series()} description={m.page_desc_home()} />
|
||||
|
||||
<div class="database-page">
|
||||
<div class="grid-container">
|
||||
<nav class="series-nav" aria-label="Series type">
|
||||
<SegmentedControl bind:value={activeType} variant="blended" size="small">
|
||||
<Segment value="weapons">Weapons</Segment>
|
||||
<Segment value="characters">Characters</Segment>
|
||||
<Segment value="summons">Summons</Segment>
|
||||
</SegmentedControl>
|
||||
</nav>
|
||||
|
||||
{#if activeQuery.isPending}
|
||||
<div class="loading">Loading {activeType} series...</div>
|
||||
{:else if activeQuery.error}
|
||||
<div class="error">Failed to load {activeType} series</div>
|
||||
{:else if sortedData.length > 0}
|
||||
<div class="series-table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="order">Order</th>
|
||||
<th class="name">Name (EN)</th>
|
||||
<th class="name-ja">Name (JA)</th>
|
||||
<th class="slug">Slug</th>
|
||||
{#if hasFlags}
|
||||
<th class="flags">Flags</th>
|
||||
{/if}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each sortedData as series (series.id)}
|
||||
<tr>
|
||||
<td class="order">{series.order}</td>
|
||||
<td class="name">{series.name.en}</td>
|
||||
<td class="name-ja">{series.name.ja}</td>
|
||||
<td class="slug"><code>{series.slug}</code></td>
|
||||
{#if hasFlags && 'extra' in series}
|
||||
<td class="flags">
|
||||
{#if series.extra}<span class="flag extra">Extra</span>{/if}
|
||||
{#if series.elementChangeable}<span class="flag element">Element</span>{/if}
|
||||
{#if series.hasWeaponKeys}<span class="flag keys">Keys</span>{/if}
|
||||
{#if series.hasAwakening}<span class="flag awaken">Awaken</span>{/if}
|
||||
{#if series.hasAxSkills}<span class="flag ax">AX</span>{/if}
|
||||
</td>
|
||||
{/if}
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="empty">No {activeType} series found</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
@use '$src/themes/colors' as colors;
|
||||
@use '$src/themes/effects' as effects;
|
||||
@use '$src/themes/layout' as layout;
|
||||
@use '$src/themes/spacing' as spacing;
|
||||
@use '$src/themes/typography' as typography;
|
||||
|
||||
.database-page {
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.grid-container {
|
||||
background: var(--card-bg);
|
||||
border: 0.5px solid rgba(0, 0, 0, 0.18);
|
||||
border-radius: layout.$page-corner;
|
||||
box-shadow: effects.$page-elevation;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.series-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: spacing.$unit-2x;
|
||||
padding: spacing.$unit-2x;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.loading,
|
||||
.error,
|
||||
.empty {
|
||||
padding: spacing.$unit-4x;
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--text-error, #ef4444);
|
||||
}
|
||||
|
||||
.series-table {
|
||||
overflow-x: auto;
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: spacing.$unit-2x spacing.$unit-3x;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border-secondary, #e5e5e5);
|
||||
}
|
||||
|
||||
th {
|
||||
background: var(--table-header-bg, #f9f9f9);
|
||||
font-weight: typography.$medium;
|
||||
font-size: typography.$font-small;
|
||||
color: colors.$grey-40;
|
||||
}
|
||||
|
||||
td {
|
||||
font-size: typography.$font-regular;
|
||||
}
|
||||
|
||||
.order {
|
||||
width: 80px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.name {
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.name-ja {
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.slug code {
|
||||
font-family: 'SF Mono', Monaco, monospace;
|
||||
font-size: typography.$font-small;
|
||||
background: var(--code-bg, #f0f0f0);
|
||||
padding: spacing.$unit-fourth spacing.$unit-half;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.flags {
|
||||
.flag {
|
||||
display: inline-block;
|
||||
font-size: typography.$font-tiny;
|
||||
padding: spacing.$unit-fourth spacing.$unit-half;
|
||||
margin-right: spacing.$unit-half;
|
||||
border-radius: 4px;
|
||||
font-weight: typography.$medium;
|
||||
|
||||
&.extra {
|
||||
background: #f3e8ff;
|
||||
color: #6b21a8;
|
||||
}
|
||||
|
||||
&.element {
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
#fecaca,
|
||||
#fef08a,
|
||||
#bbf7d0,
|
||||
#bfdbfe,
|
||||
#e9d5ff,
|
||||
#fbcfe8
|
||||
);
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
&.keys {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
&.awaken {
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
&.ax {
|
||||
background: #ffe4e6;
|
||||
color: #9f1239;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tbody tr:nth-child(odd) {
|
||||
background: var(--table-row-alt, #fafafa);
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background: var(--table-row-hover, #f0f0f0);
|
||||
}
|
||||
|
||||
tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
import type { PageServerLoad } from './$types'
|
||||
import { entityAdapter } from '$lib/api/adapters/entity.adapter'
|
||||
import { error } from '@sveltejs/kit'
|
||||
|
||||
export const load: PageServerLoad = async ({ params, parent }) => {
|
||||
const parentData = await parent()
|
||||
|
||||
try {
|
||||
const series = await entityAdapter.getWeaponSeries(params.slug)
|
||||
|
||||
if (!series) {
|
||||
throw error(404, 'Weapon series not found')
|
||||
}
|
||||
|
||||
return {
|
||||
series,
|
||||
role: parentData.role
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load weapon series:', err)
|
||||
|
||||
if (err instanceof Error && 'status' in err && err.status === 404) {
|
||||
throw error(404, 'Weapon series not found')
|
||||
}
|
||||
|
||||
throw error(500, 'Failed to load weapon series')
|
||||
}
|
||||
}
|
||||
110
src/routes/(app)/database/series/weapons/[slug]/+page.svelte
Normal file
110
src/routes/(app)/database/series/weapons/[slug]/+page.svelte
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
<svelte:options runes={true} />
|
||||
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation'
|
||||
import { createQuery } from '@tanstack/svelte-query'
|
||||
import { entityQueries } from '$lib/api/queries/entity.queries'
|
||||
import { withInitialData } from '$lib/query/ssr'
|
||||
import PageMeta from '$lib/components/PageMeta.svelte'
|
||||
import * as m from '$lib/paraglide/messages'
|
||||
import DatabasePageHeader from '$lib/components/database/DatabasePageHeader.svelte'
|
||||
import DetailsContainer from '$lib/components/ui/DetailsContainer.svelte'
|
||||
import DetailItem from '$lib/components/ui/DetailItem.svelte'
|
||||
import Button from '$lib/components/ui/Button.svelte'
|
||||
import { getAugmentTypeLabel } from '$lib/utils/augmentType'
|
||||
import type { PageData } from './$types'
|
||||
|
||||
let { data }: { data: PageData } = $props()
|
||||
|
||||
const seriesQuery = createQuery(() => ({
|
||||
...entityQueries.weaponSeries(data.series?.slug ?? ''),
|
||||
...withInitialData(data.series)
|
||||
}))
|
||||
|
||||
const series = $derived(seriesQuery.data)
|
||||
const userRole = $derived(data.role || 0)
|
||||
const canEdit = $derived(userRole >= 7)
|
||||
const editUrl = $derived(series?.slug ? `/database/series/weapons/${series.slug}/edit` : undefined)
|
||||
|
||||
const pageTitle = $derived(series?.name?.en ? `${series.name.en} Series` : 'Weapon Series')
|
||||
</script>
|
||||
|
||||
<PageMeta title={pageTitle} description={m.page_desc_home()} />
|
||||
|
||||
<div class="page">
|
||||
<DatabasePageHeader title="Weapon Series" backHref="/database/weapons?view=series">
|
||||
{#snippet rightAction()}
|
||||
{#if canEdit && editUrl}
|
||||
<Button variant="ghost" size="small" href={editUrl}>Edit</Button>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</DatabasePageHeader>
|
||||
|
||||
{#if series}
|
||||
<div class="content">
|
||||
<DetailsContainer title="Basic Info">
|
||||
<DetailItem label="Name (EN)" value={series.name.en} />
|
||||
<DetailItem label="Name (JA)" value={series.name.ja} />
|
||||
<DetailItem label="Slug" value={series.slug} />
|
||||
<DetailItem label="Order" value={series.order} />
|
||||
</DetailsContainer>
|
||||
|
||||
<DetailsContainer title="Flags">
|
||||
<DetailItem label="Extra Grid" value={series.extra ? 'Yes' : 'No'} />
|
||||
<DetailItem label="Element Changeable" value={series.elementChangeable ? 'Yes' : 'No'} />
|
||||
<DetailItem label="Has Weapon Keys" value={series.hasWeaponKeys ? 'Yes' : 'No'} />
|
||||
<DetailItem label="Has Awakening" value={series.hasAwakening ? 'Yes' : 'No'} />
|
||||
<DetailItem label="Augment Type" value={getAugmentTypeLabel(series.augmentType)} />
|
||||
</DetailsContainer>
|
||||
|
||||
{#if series.weaponCount !== undefined}
|
||||
<DetailsContainer title="Statistics">
|
||||
<DetailItem label="Weapon Count" value={series.weaponCount} />
|
||||
</DetailsContainer>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="not-found">
|
||||
<h2>Series Not Found</h2>
|
||||
<p>The weapon series you're looking for could not be found.</p>
|
||||
<button onclick={() => goto('/database/weapons?view=series')}>Back to Series</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
@use '$src/themes/colors' as colors;
|
||||
@use '$src/themes/layout' as layout;
|
||||
@use '$src/themes/spacing' as spacing;
|
||||
@use '$src/themes/typography' as typography;
|
||||
|
||||
.page {
|
||||
background: white;
|
||||
border-radius: layout.$page-corner;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.not-found {
|
||||
text-align: center;
|
||||
padding: spacing.$unit * 4;
|
||||
|
||||
button {
|
||||
background: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: spacing.$unit-half spacing.$unit;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
margin-top: spacing.$unit;
|
||||
|
||||
&:hover {
|
||||
background: #0056b3;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
import type { PageServerLoad } from './$types'
|
||||
import { entityAdapter } from '$lib/api/adapters/entity.adapter'
|
||||
import { error, redirect } from '@sveltejs/kit'
|
||||
|
||||
export const load: PageServerLoad = async ({ params, parent }) => {
|
||||
const parentData = await parent()
|
||||
|
||||
// Role check - must be editor level (>= 7) to edit
|
||||
if (!parentData.role || parentData.role < 7) {
|
||||
throw redirect(303, `/database/series/weapons/${params.slug}`)
|
||||
}
|
||||
|
||||
try {
|
||||
const series = await entityAdapter.getWeaponSeries(params.slug)
|
||||
|
||||
if (!series) {
|
||||
throw error(404, 'Weapon series not found')
|
||||
}
|
||||
|
||||
return {
|
||||
series,
|
||||
role: parentData.role
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load weapon series:', err)
|
||||
|
||||
if (err instanceof Error && 'status' in err && err.status === 404) {
|
||||
throw error(404, 'Weapon series not found')
|
||||
}
|
||||
|
||||
throw error(500, 'Failed to load weapon series')
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,252 @@
|
|||
<svelte:options runes={true} />
|
||||
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation'
|
||||
import { createQuery, useQueryClient } from '@tanstack/svelte-query'
|
||||
import { entityQueries } from '$lib/api/queries/entity.queries'
|
||||
import { entityAdapter } from '$lib/api/adapters/entity.adapter'
|
||||
import { withInitialData } from '$lib/query/ssr'
|
||||
import PageMeta from '$lib/components/PageMeta.svelte'
|
||||
import * as m from '$lib/paraglide/messages'
|
||||
import DatabasePageHeader from '$lib/components/database/DatabasePageHeader.svelte'
|
||||
import DetailsContainer from '$lib/components/ui/DetailsContainer.svelte'
|
||||
import DetailItem from '$lib/components/ui/DetailItem.svelte'
|
||||
import Button from '$lib/components/ui/Button.svelte'
|
||||
import { getAugmentTypeOptions } from '$lib/utils/augmentType'
|
||||
import type { AugmentType } from '$lib/types/api/weaponStatModifier'
|
||||
import type { PageData } from './$types'
|
||||
|
||||
let { data }: { data: PageData } = $props()
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const seriesQuery = createQuery(() => ({
|
||||
...entityQueries.weaponSeries(data.series?.slug ?? ''),
|
||||
...withInitialData(data.series)
|
||||
}))
|
||||
|
||||
const series = $derived(seriesQuery.data)
|
||||
const pageTitle = $derived(series?.name?.en ? `Edit ${series.name.en}` : 'Edit Weapon Series')
|
||||
|
||||
// Save state
|
||||
let isSaving = $state(false)
|
||||
let saveError = $state<string | null>(null)
|
||||
|
||||
// Editable fields
|
||||
let editData = $state({
|
||||
nameEn: '',
|
||||
nameJa: '',
|
||||
slug: '',
|
||||
order: 0,
|
||||
extra: false,
|
||||
elementChangeable: false,
|
||||
hasWeaponKeys: false,
|
||||
hasAwakening: false,
|
||||
augmentType: 'no_augment' as AugmentType
|
||||
})
|
||||
|
||||
// Populate edit data when series loads
|
||||
$effect(() => {
|
||||
if (series) {
|
||||
editData = {
|
||||
nameEn: series.name.en || '',
|
||||
nameJa: series.name.ja || '',
|
||||
slug: series.slug || '',
|
||||
order: series.order || 0,
|
||||
extra: series.extra || false,
|
||||
elementChangeable: series.elementChangeable || false,
|
||||
hasWeaponKeys: series.hasWeaponKeys || false,
|
||||
hasAwakening: series.hasAwakening || false,
|
||||
augmentType: series.augmentType || 'no_augment'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Augment type options for dropdown
|
||||
const augmentTypeOptions = getAugmentTypeOptions().map((opt) => ({
|
||||
value: opt.value,
|
||||
label: opt.label
|
||||
}))
|
||||
|
||||
async function saveChanges() {
|
||||
if (!series?.id) return
|
||||
|
||||
isSaving = true
|
||||
saveError = null
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
name_en: editData.nameEn,
|
||||
name_jp: editData.nameJa,
|
||||
slug: editData.slug,
|
||||
order: editData.order,
|
||||
extra: editData.extra,
|
||||
element_changeable: editData.elementChangeable,
|
||||
has_weapon_keys: editData.hasWeaponKeys,
|
||||
has_awakening: editData.hasAwakening,
|
||||
augment_type: editData.augmentType
|
||||
}
|
||||
|
||||
await entityAdapter.updateWeaponSeries(series.id, payload)
|
||||
|
||||
// Invalidate cache
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ['weaponSeries'],
|
||||
refetchType: 'all'
|
||||
})
|
||||
|
||||
// Navigate back to detail page (use new slug if changed)
|
||||
goto(`/database/series/weapons/${editData.slug}`)
|
||||
} catch (error) {
|
||||
saveError = 'Failed to save changes. Please try again.'
|
||||
console.error('Save error:', error)
|
||||
} finally {
|
||||
isSaving = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<PageMeta title={pageTitle} description={m.page_desc_home()} />
|
||||
|
||||
<div class="page">
|
||||
<DatabasePageHeader title="Edit Weapon Series" backHref={`/database/series/weapons/${series?.slug}`}>
|
||||
{#snippet rightAction()}
|
||||
<Button variant="ghost" size="small" onclick={saveChanges} disabled={isSaving}>
|
||||
{isSaving ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
{/snippet}
|
||||
</DatabasePageHeader>
|
||||
|
||||
{#if series}
|
||||
<div class="content">
|
||||
{#if saveError}
|
||||
<div class="error-banner">{saveError}</div>
|
||||
{/if}
|
||||
|
||||
<DetailsContainer title="Basic Info">
|
||||
<DetailItem
|
||||
label="Name (EN)"
|
||||
bind:value={editData.nameEn}
|
||||
editable={true}
|
||||
type="text"
|
||||
placeholder="English name"
|
||||
width="320px"
|
||||
/>
|
||||
<DetailItem
|
||||
label="Name (JA)"
|
||||
bind:value={editData.nameJa}
|
||||
editable={true}
|
||||
type="text"
|
||||
placeholder="Japanese name"
|
||||
width="320px"
|
||||
/>
|
||||
<DetailItem
|
||||
label="Slug"
|
||||
bind:value={editData.slug}
|
||||
editable={true}
|
||||
type="text"
|
||||
placeholder="url-friendly-slug"
|
||||
width="240px"
|
||||
/>
|
||||
<DetailItem
|
||||
label="Order"
|
||||
bind:value={editData.order}
|
||||
editable={true}
|
||||
type="number"
|
||||
placeholder="0"
|
||||
/>
|
||||
</DetailsContainer>
|
||||
|
||||
<DetailsContainer title="Flags">
|
||||
<DetailItem
|
||||
label="Extra Grid"
|
||||
sublabel="Weapon can be placed in Extra grid slots"
|
||||
bind:value={editData.extra}
|
||||
editable={true}
|
||||
type="checkbox"
|
||||
/>
|
||||
<DetailItem
|
||||
label="Element Changeable"
|
||||
sublabel="Weapon element can be changed by player"
|
||||
bind:value={editData.elementChangeable}
|
||||
editable={true}
|
||||
type="checkbox"
|
||||
/>
|
||||
<DetailItem
|
||||
label="Has Weapon Keys"
|
||||
sublabel="Weapon supports Pendulum/Teluma keys"
|
||||
bind:value={editData.hasWeaponKeys}
|
||||
editable={true}
|
||||
type="checkbox"
|
||||
/>
|
||||
<DetailItem
|
||||
label="Has Awakening"
|
||||
sublabel="Weapon can be awakened"
|
||||
bind:value={editData.hasAwakening}
|
||||
editable={true}
|
||||
type="checkbox"
|
||||
/>
|
||||
<DetailItem
|
||||
label="Augment Type"
|
||||
sublabel="Type of stat augments this series supports"
|
||||
bind:value={editData.augmentType}
|
||||
editable={true}
|
||||
type="select"
|
||||
options={augmentTypeOptions}
|
||||
/>
|
||||
</DetailsContainer>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="not-found">
|
||||
<h2>Series Not Found</h2>
|
||||
<p>The weapon series you're looking for could not be found.</p>
|
||||
<button onclick={() => goto('/database/weapons?view=series')}>Back to Series</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
@use '$src/themes/colors' as colors;
|
||||
@use '$src/themes/layout' as layout;
|
||||
@use '$src/themes/spacing' as spacing;
|
||||
@use '$src/themes/typography' as typography;
|
||||
|
||||
.page {
|
||||
background: white;
|
||||
border-radius: layout.$page-corner;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.error-banner {
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
color: #dc2626;
|
||||
padding: spacing.$unit-2x;
|
||||
margin: spacing.$unit-2x;
|
||||
border-radius: layout.$item-corner;
|
||||
}
|
||||
|
||||
.not-found {
|
||||
text-align: center;
|
||||
padding: spacing.$unit * 4;
|
||||
|
||||
button {
|
||||
background: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: spacing.$unit-half spacing.$unit;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
margin-top: spacing.$unit;
|
||||
|
||||
&:hover {
|
||||
background: #0056b3;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -4,6 +4,7 @@
|
|||
import PageMeta from '$lib/components/PageMeta.svelte'
|
||||
import * as m from '$lib/paraglide/messages'
|
||||
import { goto } from '$app/navigation'
|
||||
import { page } from '$app/stores'
|
||||
import { createQuery } from '@tanstack/svelte-query'
|
||||
import { entityQueries } from '$lib/api/queries/entity.queries'
|
||||
import DatabaseGridWithProvider from '$lib/components/database/DatabaseGridWithProvider.svelte'
|
||||
|
|
@ -17,8 +18,19 @@
|
|||
import LastUpdatedCell from '$lib/components/database/cells/LastUpdatedCell.svelte'
|
||||
import { getRarityLabel } from '$lib/utils/rarity'
|
||||
|
||||
// View mode state
|
||||
let viewMode = $state<'weapons' | 'series'>('weapons')
|
||||
// View mode state - read initial value from URL
|
||||
const initialView = $page.url.searchParams.get('view')
|
||||
let viewMode = $state<'weapons' | 'series'>(initialView === 'series' ? 'series' : 'weapons')
|
||||
|
||||
// Sync viewMode changes to URL
|
||||
$effect(() => {
|
||||
const currentView = $page.url.searchParams.get('view')
|
||||
if (viewMode === 'series' && currentView !== 'series') {
|
||||
goto('?view=series', { replaceState: true, noScroll: true })
|
||||
} else if (viewMode === 'weapons' && currentView === 'series') {
|
||||
goto('/database/weapons', { replaceState: true, noScroll: true })
|
||||
}
|
||||
})
|
||||
|
||||
// Query for weapon series
|
||||
const weaponSeriesQuery = createQuery(() => entityQueries.weaponSeriesList())
|
||||
|
|
@ -30,8 +42,8 @@
|
|||
})
|
||||
|
||||
// Navigate to series detail
|
||||
function handleSeriesClick(seriesId: string) {
|
||||
goto(`/database/series/${seriesId}`)
|
||||
function handleSeriesClick(slug: string) {
|
||||
goto(`/database/series/weapons/${slug}`)
|
||||
}
|
||||
|
||||
// Column configuration for weapons
|
||||
|
|
@ -130,7 +142,7 @@
|
|||
</thead>
|
||||
<tbody>
|
||||
{#each sortedSeries as series (series.id)}
|
||||
<tr onclick={() => handleSeriesClick(series.id)} class="clickable">
|
||||
<tr onclick={() => handleSeriesClick(series.slug)} class="clickable">
|
||||
<td class="col-order">{series.order}</td>
|
||||
<td class="col-name">
|
||||
<span class="series-name">{series.name.en}</span>
|
||||
|
|
@ -188,8 +200,7 @@
|
|||
.controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: spacing.$unit;
|
||||
border-bottom: 1px solid #e5e5e5;
|
||||
padding: spacing.$unit-2x;
|
||||
gap: spacing.$unit;
|
||||
}
|
||||
|
||||
|
|
@ -216,7 +227,7 @@
|
|||
|
||||
th,
|
||||
td {
|
||||
padding: spacing.$unit spacing.$unit-2x;
|
||||
padding: spacing.$unit-2x spacing.$unit-2x;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #e5e5e5;
|
||||
}
|
||||
|
|
@ -262,7 +273,7 @@
|
|||
}
|
||||
|
||||
.series-name {
|
||||
font-weight: typography.$bold;
|
||||
font-weight: typography.$normal;
|
||||
}
|
||||
|
||||
.no-flags {
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ const mockOpusSeries = {
|
|||
name: { en: 'Opus', ja: 'オプス' },
|
||||
hasWeaponKeys: true,
|
||||
hasAwakening: true,
|
||||
augmentType: 'none' as const,
|
||||
augmentType: 'no_augment' as const,
|
||||
extra: false,
|
||||
elementChangeable: false
|
||||
};
|
||||
|
|
@ -30,7 +30,7 @@ const mockDraconicSeries = {
|
|||
name: { en: 'Draconic', ja: 'ドラゴニック' },
|
||||
hasWeaponKeys: true,
|
||||
hasAwakening: false,
|
||||
augmentType: 'none' as const,
|
||||
augmentType: 'no_augment' as const,
|
||||
extra: false,
|
||||
elementChangeable: false
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue