add artifact skills database pages

- list page with skills grouped by slot
- edit page for all skill properties
This commit is contained in:
Justin Edmund 2025-12-18 23:14:53 -08:00
parent 1e1f4f9478
commit 63e7e3d273
2 changed files with 761 additions and 0 deletions

View file

@ -0,0 +1,345 @@
<svelte:options runes={true} />
<script lang="ts">
import { goto } from '$app/navigation'
import { createQuery } from '@tanstack/svelte-query'
import { artifactQueries } from '$lib/api/queries/artifact.queries'
import PageMeta from '$lib/components/PageMeta.svelte'
import type { ArtifactSkill } from '$lib/types/api/artifact'
// Fetch all skills
const skillsQuery = createQuery(() => artifactQueries.skills())
// Search state
let searchTerm = $state('')
// Filter skills based on search
const filteredSkills = $derived.by(() => {
const skills = skillsQuery.data ?? []
if (!searchTerm.trim()) return skills
const term = searchTerm.toLowerCase()
return skills.filter(
(skill) =>
skill.name.en.toLowerCase().includes(term) ||
skill.name.ja?.toLowerCase().includes(term) ||
skill.gameName?.en?.toLowerCase().includes(term) ||
skill.gameName?.ja?.toLowerCase().includes(term) ||
skill.modifier.toString().includes(term) ||
skill.skillGroup.toLowerCase().includes(term)
)
})
// Group skills by slot group
const groupedSkills = $derived.by(() => {
const skills = [...filteredSkills]
// Sort by modifier within each group
skills.sort((a, b) => a.modifier - b.modifier)
return {
group_i: skills.filter((s) => s.skillGroup === 'group_i'),
group_ii: skills.filter((s) => s.skillGroup === 'group_ii'),
group_iii: skills.filter((s) => s.skillGroup === 'group_iii')
}
})
const totalCount = $derived(
groupedSkills.group_i.length + groupedSkills.group_ii.length + groupedSkills.group_iii.length
)
function handleRowClick(skill: ArtifactSkill) {
goto(`/database/artifact-skills/${skill.id}`)
}
function getPolarityClass(polarity: string): string {
return polarity === 'positive' ? 'positive' : 'negative'
}
</script>
<PageMeta title="Artifact Skills" description="Database of artifact skills" />
<div class="page">
<div class="grid-container">
<div class="controls">
<input type="text" placeholder="Search skills..." bind:value={searchTerm} class="search" />
</div>
{#if skillsQuery.isLoading}
<div class="loading">Loading skills...</div>
{:else if skillsQuery.isError}
<div class="error">Failed to load skills</div>
{:else}
{#each [{ key: 'group_i', label: 'Group I — Slots 1 & 2', skills: groupedSkills.group_i }, { key: 'group_ii', label: 'Group II — Slot 3', skills: groupedSkills.group_ii }, { key: 'group_iii', label: 'Group III — Slot 4', skills: groupedSkills.group_iii }] as group (group.key)}
{#if group.skills.length > 0}
<div class="group-section">
<h3 class="group-header">{group.label}</h3>
<div class="table-wrapper">
<table class="data-table">
<thead>
<tr>
<th class="col-modifier">Mod</th>
<th class="col-name">Display Name</th>
<th class="col-game-name">Game Name</th>
<th class="col-polarity">Polarity</th>
<th class="col-values">Base Values</th>
</tr>
</thead>
<tbody>
{#each group.skills as skill (skill.id)}
<tr onclick={() => handleRowClick(skill)} class="clickable">
<td class="col-modifier">
<span class="modifier-badge">{skill.modifier}</span>
</td>
<td class="col-name">
<div class="name-cell">
<span class="name-en">{skill.name.en}</span>
{#if skill.name.ja}
<span class="name-jp">{skill.name.ja}</span>
{/if}
</div>
</td>
<td class="col-game-name">
<div class="name-cell">
{#if skill.gameName?.en || skill.gameName?.ja}
<span class="name-en">{skill.gameName?.en || '—'}</span>
{#if skill.gameName?.ja}
<span class="name-jp">{skill.gameName.ja}</span>
{/if}
{:else}
<span class="not-set">Not set</span>
{/if}
</div>
</td>
<td class="col-polarity">
<span class="polarity-badge {getPolarityClass(skill.polarity)}">
{skill.polarity}
</span>
</td>
<td class="col-values">
<span class="values">
{skill.baseValues.map((v) => v ?? '?').join(', ')}
</span>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
{/if}
{/each}
<div class="footer">
Showing {totalCount} of {skillsQuery.data?.length ?? 0} skills
</div>
{/if}
</div>
</div>
<style lang="scss">
@use '$src/themes/colors' as colors;
@use '$src/themes/effects' as effects;
@use '$src/themes/layout' as layout;
@use '$src/themes/spacing' as spacing;
@use '$src/themes/typography' as typography;
.page {
padding: spacing.$unit-2x 0;
margin: 0 auto;
}
.grid-container {
background: var(--card-bg);
border: 0.5px solid rgba(0, 0, 0, 0.18);
border-radius: layout.$page-corner;
box-shadow: effects.$page-elevation;
overflow: hidden;
}
.controls {
display: flex;
justify-content: space-between;
align-items: center;
padding: spacing.$unit;
border-bottom: 1px solid #e5e5e5;
gap: spacing.$unit;
.search {
padding: spacing.$unit spacing.$unit-2x;
background: var(--input-bound-bg);
border: none;
border-radius: layout.$item-corner;
font-size: typography.$font-medium;
width: 100%;
&:hover {
background: var(--input-bound-bg-hover);
}
&:focus {
outline: none;
box-shadow: 0 0 0 2px colors.$blue;
}
}
}
.loading,
.error {
text-align: center;
padding: spacing.$unit * 4;
color: colors.$grey-50;
}
.error {
color: colors.$red;
}
.table-wrapper {
overflow-x: auto;
}
.data-table {
width: 100%;
border-collapse: collapse;
th,
td {
padding: spacing.$unit-2x spacing.$unit;
text-align: left;
border-bottom: 1px solid #dee2e6;
}
th {
background: #f8f9fa;
font-weight: typography.$bold;
color: #495057;
white-space: nowrap;
}
tbody tr {
&.clickable {
cursor: pointer;
&:hover {
background: #f8f9fa;
}
}
}
}
.group-section {
&:not(:first-child) {
border-top: 1px solid rgba(0, 0, 0, 0.06);
}
}
.group-header {
display: flex;
align-items: center;
margin: 0;
padding: spacing.$unit spacing.$unit-2x;
background: #f8f9fa;
border-bottom: 1px solid #dee2e6;
font-size: typography.$font-small;
font-weight: typography.$medium;
color: colors.$grey-50;
}
.col-modifier {
width: 60px;
padding-left: spacing.$unit-2x !important;
}
.col-name {
min-width: 160px;
}
.col-game-name {
min-width: 160px;
}
.col-group {
width: 140px;
}
.col-polarity {
width: 100px;
}
.col-values {
width: 150px;
}
.modifier-badge {
display: inline-block;
padding: 2px 8px;
background: colors.$grey-90;
border-radius: 4px;
font-size: typography.$font-small;
font-weight: typography.$medium;
color: colors.$grey-30;
}
.name-cell {
display: flex;
flex-direction: column;
gap: 2px;
.name-en {
font-weight: typography.$medium;
}
.name-jp {
font-size: typography.$font-small;
color: colors.$grey-50;
}
.not-set {
color: colors.$grey-60;
font-style: italic;
}
}
.group-badge {
display: inline-block;
padding: 2px 8px;
background: colors.$blue;
color: white;
border-radius: 4px;
font-size: typography.$font-small;
}
.polarity-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: typography.$font-small;
font-weight: typography.$medium;
&.positive {
background: colors.$wind-bg-00;
color: white;
}
&.negative {
background: colors.$red;
color: white;
}
}
.values {
font-size: typography.$font-small;
color: colors.$grey-40;
font-family: monospace;
}
.footer {
padding: spacing.$unit;
text-align: center;
color: colors.$grey-50;
font-size: typography.$font-small;
background: #f8f9fa;
border-top: 1px solid #e5e5e5;
}
</style>

View file

@ -0,0 +1,416 @@
<svelte:options runes={true} />
<script lang="ts">
import { goto } from '$app/navigation'
import { page } from '$app/stores'
import { createQuery, useQueryClient } from '@tanstack/svelte-query'
import { artifactQueries, artifactKeys } from '$lib/api/queries/artifact.queries'
import { artifactAdapter } from '$lib/api/adapters/artifact.adapter'
import PageMeta from '$lib/components/PageMeta.svelte'
import Button from '$lib/components/ui/Button.svelte'
import DetailsContainer from '$lib/components/ui/DetailsContainer.svelte'
import DetailItem from '$lib/components/ui/DetailItem.svelte'
import Input from '$lib/components/ui/Input.svelte'
const queryClient = useQueryClient()
// Get skill ID from URL
const skillId = $derived($page.params.id)
// Fetch skill data
const skillQuery = createQuery(() => artifactQueries.skillById(skillId))
const skill = $derived(skillQuery.data)
// Save state
let isSaving = $state(false)
let saveError = $state<string | null>(null)
let saveSuccess = $state(false)
// Editable fields
let editData = $state({
nameEn: '',
nameJp: '',
gameNameEn: '',
gameNameJp: '',
skillGroup: 1,
modifier: 0,
polarity: 'positive',
baseValues: [] as (number | null)[],
growth: null as number | null,
suffixEn: '',
suffixJp: ''
})
// Populate edit data when skill loads
$effect(() => {
if (skill) {
editData = {
nameEn: skill.name?.en || '',
nameJp: skill.name?.ja || '',
gameNameEn: skill.gameName?.en || '',
gameNameJp: skill.gameName?.ja || '',
skillGroup: getSkillGroupNumber(skill.skillGroup),
modifier: skill.modifier || 0,
polarity: skill.polarity || 'positive',
baseValues: skill.baseValues || [],
growth: skill.growth ?? null,
suffixEn: skill.suffix?.en || '',
suffixJp: skill.suffix?.ja || ''
}
}
})
async function saveChanges() {
if (!skill?.id) return
isSaving = true
saveError = null
saveSuccess = false
try {
const payload = {
name_en: editData.nameEn,
name_jp: editData.nameJp,
game_name_en: editData.gameNameEn || null,
game_name_jp: editData.gameNameJp || null,
skill_group: editData.skillGroup,
modifier: editData.modifier,
polarity: editData.polarity,
base_values: editData.baseValues,
growth: editData.growth,
suffix_en: editData.suffixEn,
suffix_jp: editData.suffixJp
}
await artifactAdapter.updateSkill(skill.id, payload)
await queryClient.invalidateQueries({ queryKey: artifactKeys.skills })
saveSuccess = true
setTimeout(() => {
goto('/database/artifact-skills')
}, 500)
} catch (error) {
saveError = 'Failed to save changes. Please try again.'
console.error('Save error:', error)
} finally {
isSaving = false
}
}
function handleCancel() {
goto('/database/artifact-skills')
}
function getSkillGroupNumber(group: string): number {
switch (group) {
case 'group_i':
return 1
case 'group_ii':
return 2
case 'group_iii':
return 3
default:
return 1
}
}
const skillGroupOptions = [
{ value: 1, label: 'Group I (Slots 1 & 2)' },
{ value: 2, label: 'Group II (Slot 3)' },
{ value: 3, label: 'Group III (Slot 4)' }
]
const polarityOptions = [
{ value: 'positive', label: 'Positive' },
{ value: 'negative', label: 'Negative' }
]
const pageTitle = $derived(`Edit: ${skill?.name?.en ?? 'Artifact Skill'}`)
</script>
<PageMeta title={pageTitle} description="Edit artifact skill" />
<div class="page">
{#if skillQuery.isLoading}
<div class="loading">Loading skill...</div>
{:else if skillQuery.isError}
<div class="error">Failed to load skill</div>
{:else if skill}
<div class="content">
<header class="header">
<div class="left">
<div class="modifier-badge">{skill.modifier}</div>
<div class="info">
<h2>{skill.name.en}</h2>
<div class="meta">
<span class="skill-group">{skillGroupOptions.find(o => o.value === getSkillGroupNumber(skill.skillGroup))?.label}</span>
</div>
</div>
</div>
<div class="right">
<Button variant="secondary" size="medium" onclick={handleCancel} disabled={isSaving}>
Cancel
</Button>
<Button variant="primary" size="medium" onclick={saveChanges} disabled={isSaving}>
{isSaving ? 'Saving...' : 'Save'}
</Button>
</div>
</header>
{#if saveSuccess || saveError}
<div class="edit-controls">
{#if saveSuccess}
<span class="success-message">Changes saved successfully!</span>
{/if}
{#if saveError}
<span class="error-message">{saveError}</span>
{/if}
</div>
{/if}
<section class="details">
<DetailsContainer title="Display Names">
<DetailItem label="Name (EN)" bind:value={editData.nameEn} editable={true} type="text" />
<DetailItem label="Name (JP)" bind:value={editData.nameJp} editable={true} type="text" />
</DetailsContainer>
<DetailsContainer title="Game Names (for Import Matching)">
<DetailItem
label="Game Name (EN)"
sublabel="Used to match skills during artifact import"
bind:value={editData.gameNameEn}
editable={true}
type="text"
placeholder="Leave blank to use display name"
/>
<DetailItem
label="Game Name (JP)"
sublabel="Used to match skills during artifact import"
bind:value={editData.gameNameJp}
editable={true}
type="text"
placeholder="Leave blank to use display name"
/>
</DetailsContainer>
<DetailsContainer title="Skill Properties">
<DetailItem
label="Skill Group"
bind:value={editData.skillGroup}
editable={true}
type="select"
options={skillGroupOptions}
/>
<DetailItem
label="Modifier"
bind:value={editData.modifier}
editable={true}
type="number"
/>
<DetailItem
label="Polarity"
bind:value={editData.polarity}
editable={true}
type="select"
options={polarityOptions}
/>
<DetailItem
label="Growth"
bind:value={editData.growth}
editable={true}
type="number"
/>
</DetailsContainer>
<DetailsContainer title="Base Values (Quality 1-5)">
{#each [0, 1, 2, 3, 4] as index}
<DetailItem label="Quality {index + 1}" editable={true}>
<Input
type="number"
variant="number"
contained={true}
value={editData.baseValues[index] ?? ''}
oninput={(e) => {
const newValues = [...editData.baseValues]
const val = e.currentTarget.value
newValues[index] = val === '' ? null : parseFloat(val)
editData.baseValues = newValues
}}
placeholder="—"
/>
</DetailItem>
{/each}
</DetailsContainer>
<DetailsContainer title="Suffix">
<DetailItem
label="Suffix (EN)"
bind:value={editData.suffixEn}
editable={true}
type="text"
placeholder="%"
/>
<DetailItem
label="Suffix (JP)"
bind:value={editData.suffixJp}
editable={true}
type="text"
placeholder="%"
/>
</DetailsContainer>
</section>
</div>
{:else}
<div class="not-found">
<h2>Skill Not Found</h2>
<p>The skill you're looking for could not be found.</p>
<button onclick={() => goto('/database/artifact-skills')}>Back to Skills</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;
@use '$src/themes/effects' as effects;
.page {
background: white;
border-radius: layout.$card-corner;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.loading,
.error {
text-align: center;
padding: spacing.$unit * 4;
color: colors.$grey-50;
}
.error {
color: colors.$red;
}
.content {
background: white;
border-radius: layout.$card-corner;
overflow: visible;
position: relative;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: spacing.$unit * 2;
padding: spacing.$unit * 2;
background: white;
border-top-left-radius: layout.$card-corner;
border-top-right-radius: layout.$card-corner;
.left {
display: flex;
align-items: center;
gap: spacing.$unit-2x;
}
.right {
display: flex;
gap: spacing.$unit;
align-items: center;
}
.modifier-badge {
display: flex;
align-items: center;
justify-content: center;
min-width: 48px;
height: 48px;
padding: 0 spacing.$unit;
background: colors.$blue;
color: white;
border-radius: layout.$item-corner;
font-size: typography.$font-xlarge;
font-weight: typography.$bold;
}
.info {
flex: 1;
h2 {
font-size: typography.$font-xlarge;
font-weight: typography.$bold;
margin: 0 0 spacing.$unit-half 0;
color: colors.$grey-30;
}
.meta {
display: flex;
flex-direction: row;
gap: spacing.$unit;
align-items: center;
}
.skill-group {
font-size: typography.$font-small;
color: colors.$grey-50;
}
}
}
.edit-controls {
padding: spacing.$unit-2x;
border-bottom: 1px solid colors.$grey-80;
display: flex;
gap: spacing.$unit;
align-items: center;
.success-message {
color: colors.$grey-30;
font-size: typography.$font-small;
animation: fadeIn effects.$duration-opacity-fade ease-in;
}
.error-message {
color: colors.$error;
font-size: typography.$font-small;
animation: fadeIn effects.$duration-opacity-fade ease-in;
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.details {
display: flex;
flex-direction: column;
}
.not-found {
text-align: center;
padding: spacing.$unit * 4;
button {
background: colors.$blue;
color: white;
border: none;
padding: spacing.$unit spacing.$unit-2x;
border-radius: layout.$item-corner;
cursor: pointer;
margin-top: spacing.$unit;
&:hover {
filter: brightness(0.9);
}
}
}
</style>