feat(pokemon): add types, stats, genus, dimensions, and details
Update schema and API to support expanded Pokemon profile fields Add UI for editing and displaying types, base stats, and dimensions Support translations for details and genus fields
This commit is contained in:
@@ -66,6 +66,7 @@ const tabs = computed<Array<{ key: AdminTab; label: string }>>(() => [
|
||||
]);
|
||||
|
||||
const configTypes = computed<Array<{ key: ConfigType; label: string; supportsItemDrop?: boolean }>>(() => [
|
||||
{ key: 'pokemon-types', label: t('config.pokemonTypes') },
|
||||
{ key: 'skills', label: t('config.skills'), supportsItemDrop: true },
|
||||
{ key: 'environments', label: t('config.environments') },
|
||||
{ key: 'favorite-things', label: t('config.favoriteThings') },
|
||||
|
||||
@@ -7,6 +7,7 @@ import DetailSection from '../components/DetailSection.vue';
|
||||
import EditHistoryPanel from '../components/EditHistoryPanel.vue';
|
||||
import EntityChips from '../components/EntityChips.vue';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import PokemonStatsPanel from '../components/PokemonStatsPanel.vue';
|
||||
import Skeleton from '../components/Skeleton.vue';
|
||||
import Tabs, { type TabOption } from '../components/Tabs.vue';
|
||||
import { iconBack, iconEdit } from '../icons';
|
||||
@@ -122,6 +123,24 @@ const favoriteThingItems = computed(() => {
|
||||
|
||||
return items.filter((item) => String(item.category.id) === itemCategoryTab.value);
|
||||
});
|
||||
const typeSlotClass = computed(() => ({
|
||||
'pokemon-type-slots--single': (pokemon.value?.types.length ?? 0) === 1
|
||||
}));
|
||||
|
||||
function formatMetricMeasure(value: number): string {
|
||||
return value.toFixed(2);
|
||||
}
|
||||
|
||||
function formatPoundsMeasure(value: number): string {
|
||||
return (Math.round(value * 10) / 10).toFixed(1);
|
||||
}
|
||||
|
||||
function formatImperialHeight(inches: number): string {
|
||||
const totalInches = Math.round(inches);
|
||||
const feet = Math.floor(totalInches / 12);
|
||||
const remainingInches = totalInches - feet * 12;
|
||||
return `${feet}'${remainingInches}"`;
|
||||
}
|
||||
|
||||
async function loadPokemonDetail() {
|
||||
pokemon.value = await api.pokemonDetail(String(route.params.id));
|
||||
@@ -223,6 +242,51 @@ watch(
|
||||
|
||||
<div class="detail-with-sidebar">
|
||||
<div class="detail-grid detail-grid--stack">
|
||||
<div class="pokemon-profile-grid">
|
||||
<div class="pokemon-profile-main">
|
||||
<section class="detail-section pokemon-profile-card" :aria-label="t('pages.pokemon.details')">
|
||||
<p v-if="pokemon.genus" class="pokemon-genus">{{ pokemon.genus }}</p>
|
||||
<div v-if="pokemon.genus && pokemon.details.trim()" class="pokemon-profile-divider"></div>
|
||||
<p v-if="pokemon.details.trim()" class="detail-text">{{ pokemon.details }}</p>
|
||||
<p v-if="!pokemon.genus && !pokemon.details.trim()" class="meta-line">{{ t('common.none') }}</p>
|
||||
</section>
|
||||
|
||||
<div class="pokemon-profile-row">
|
||||
<section class="detail-section pokemon-profile-card" :aria-label="t('pages.pokemon.measurements')">
|
||||
<div class="pokemon-measurement-display">
|
||||
<div class="pokemon-measurement-item" :title="`${formatImperialHeight(pokemon.heightInches)} / ${formatMetricMeasure(pokemon.heightMeters)} m`">
|
||||
<div class="pokemon-measurement-stack">
|
||||
<strong class="pokemon-measurement-value">{{ formatImperialHeight(pokemon.heightInches) }}</strong>
|
||||
<span class="pokemon-measurement-divider" aria-hidden="true"></span>
|
||||
<strong class="pokemon-measurement-value">{{ formatMetricMeasure(pokemon.heightMeters) }} m</strong>
|
||||
<span class="pokemon-measurement-label">{{ t('pages.pokemon.height') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pokemon-measurement-item" :title="`${formatPoundsMeasure(pokemon.weightPounds)} lbs / ${formatMetricMeasure(pokemon.weightKg)} kg`">
|
||||
<div class="pokemon-measurement-stack">
|
||||
<strong class="pokemon-measurement-value">{{ formatPoundsMeasure(pokemon.weightPounds) }} lbs</strong>
|
||||
<span class="pokemon-measurement-divider" aria-hidden="true"></span>
|
||||
<strong class="pokemon-measurement-value">{{ formatMetricMeasure(pokemon.weightKg) }} kg</strong>
|
||||
<span class="pokemon-measurement-label">{{ t('pages.pokemon.weight') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="detail-section pokemon-profile-card pokemon-types-card" :aria-label="t('pages.pokemon.types')">
|
||||
<div v-if="pokemon.types.length" class="pokemon-type-slots" :class="typeSlotClass">
|
||||
<span v-for="type in pokemon.types.slice(0, 2)" :key="type.id" class="chip">{{ type.name }}</span>
|
||||
</div>
|
||||
<p v-else class="meta-line">{{ t('common.none') }}</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DetailSection class="pokemon-profile-stats" :title="t('pages.pokemon.statsTitle')">
|
||||
<PokemonStatsPanel :stats="pokemon.stats" />
|
||||
</DetailSection>
|
||||
</div>
|
||||
|
||||
<DetailSection :title="t('pages.pokemon.skills')">
|
||||
<EntityChips :items="pokemon.skills" />
|
||||
</DetailSection>
|
||||
@@ -287,7 +351,9 @@ watch(
|
||||
</DetailSection>
|
||||
</div>
|
||||
|
||||
<EditHistoryPanel :entity="pokemon" :history="pokemon.editHistory" />
|
||||
<aside class="pokemon-detail-sidebar">
|
||||
<EditHistoryPanel :entity="pokemon" :history="pokemon.editHistory" />
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -4,12 +4,22 @@ import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import Modal from '../components/Modal.vue';
|
||||
import PokemonStatsFields from '../components/PokemonStatsFields.vue';
|
||||
import Skeleton from '../components/Skeleton.vue';
|
||||
import StatusMessage from '../components/StatusMessage.vue';
|
||||
import TagsSelect from '../components/TagsSelect.vue';
|
||||
import TranslationFields from '../components/TranslationFields.vue';
|
||||
import { iconCancel, iconSave } from '../icons';
|
||||
import { api, type ConfigType, type Language, type NamedEntity, type Options, type PokemonPayload, type TranslationMap } from '../services/api';
|
||||
import {
|
||||
api,
|
||||
type ConfigType,
|
||||
type Language,
|
||||
type NamedEntity,
|
||||
type Options,
|
||||
type PokemonPayload,
|
||||
type PokemonStats,
|
||||
type TranslationMap
|
||||
} from '../services/api';
|
||||
|
||||
type SkillItemDropForm = {
|
||||
skillId: string;
|
||||
@@ -26,10 +36,30 @@ const loading = ref(true);
|
||||
const busy = ref(false);
|
||||
const message = ref('');
|
||||
const creatingSelect = ref('');
|
||||
const heightUnit = ref<'imperial' | 'metric'>('imperial');
|
||||
const weightUnit = ref<'imperial' | 'metric'>('imperial');
|
||||
|
||||
function defaultPokemonStats(): PokemonStats {
|
||||
return {
|
||||
hp: 0,
|
||||
attack: 0,
|
||||
defense: 0,
|
||||
specialAttack: 0,
|
||||
specialDefense: 0,
|
||||
speed: 0
|
||||
};
|
||||
}
|
||||
|
||||
const pokemonForm = ref({
|
||||
id: '',
|
||||
name: '',
|
||||
genus: '',
|
||||
details: '',
|
||||
heightInches: 0,
|
||||
weightPounds: 0,
|
||||
translations: {} as TranslationMap,
|
||||
typeIds: [] as string[],
|
||||
stats: defaultPokemonStats(),
|
||||
environmentId: '',
|
||||
skillIds: [] as string[],
|
||||
favoriteThingIds: [] as string[],
|
||||
@@ -47,11 +77,47 @@ const cancelTo = computed(() => (isEditing.value ? `/pokemon/${routeId.value}` :
|
||||
const selectedSkillDropRows = computed(() =>
|
||||
pokemonForm.value.skillItemDrops.filter((row) => pokemonForm.value.skillIds.includes(row.skillId) && skillSupportsItemDrop(row.skillId))
|
||||
);
|
||||
const totalHeightInchesValue = computed(() => Math.round(pokemonForm.value.heightInches));
|
||||
const heightFeetValue = computed(() => Math.floor(totalHeightInchesValue.value / 12));
|
||||
const heightInchesValue = computed(() => totalHeightInchesValue.value - heightFeetValue.value * 12);
|
||||
const heightMetersValue = computed(() => roundMeasurement(pokemonForm.value.heightInches * 0.0254, 2));
|
||||
const weightPoundsValue = computed(() => roundMeasurement(pokemonForm.value.weightPounds, 1));
|
||||
const weightKgValue = computed(() => roundMeasurement(pokemonForm.value.weightPounds * 0.45359237, 2));
|
||||
|
||||
function toIds(values: string[]): number[] {
|
||||
return values.map(Number).filter((item) => Number.isInteger(item) && item > 0);
|
||||
}
|
||||
|
||||
function numericInputValue(event: Event): number {
|
||||
const value = event.target instanceof HTMLInputElement ? Number(event.target.value) : 0;
|
||||
return Number.isFinite(value) && value > 0 ? value : 0;
|
||||
}
|
||||
|
||||
function roundMeasurement(value: number, precision: number): number {
|
||||
const scale = 10 ** precision;
|
||||
return Math.round(value * scale) / scale;
|
||||
}
|
||||
|
||||
function updateHeightFeet(event: Event) {
|
||||
pokemonForm.value.heightInches = Math.round(numericInputValue(event) * 12 + heightInchesValue.value);
|
||||
}
|
||||
|
||||
function updateHeightInches(event: Event) {
|
||||
pokemonForm.value.heightInches = Math.round(heightFeetValue.value * 12 + numericInputValue(event));
|
||||
}
|
||||
|
||||
function updateHeightMeters(event: Event) {
|
||||
pokemonForm.value.heightInches = roundMeasurement(numericInputValue(event) / 0.0254, 2);
|
||||
}
|
||||
|
||||
function updateWeightPounds(event: Event) {
|
||||
pokemonForm.value.weightPounds = roundMeasurement(numericInputValue(event), 1);
|
||||
}
|
||||
|
||||
function updateWeightKg(event: Event) {
|
||||
pokemonForm.value.weightPounds = roundMeasurement(numericInputValue(event) / 0.45359237, 1);
|
||||
}
|
||||
|
||||
function errorText(error: unknown, fallback: string) {
|
||||
return error instanceof Error && error.message ? error.message : fallback;
|
||||
}
|
||||
@@ -113,7 +179,13 @@ async function loadEditor() {
|
||||
pokemonForm.value = {
|
||||
id: String(pokemon.id),
|
||||
name: pokemon.baseName ?? pokemon.name,
|
||||
genus: pokemon.baseGenus ?? pokemon.genus,
|
||||
details: pokemon.baseDetails ?? pokemon.details,
|
||||
heightInches: pokemon.heightInches,
|
||||
weightPounds: pokemon.weightPounds,
|
||||
translations: pokemon.translations ?? {},
|
||||
typeIds: pokemon.types.map((type) => String(type.id)),
|
||||
stats: pokemon.stats,
|
||||
environmentId: String(pokemon.environment.id),
|
||||
skillIds: pokemon.skills.map((skill) => String(skill.id)),
|
||||
favoriteThingIds: pokemon.favorite_things.map((thing) => String(thing.id)),
|
||||
@@ -176,7 +248,13 @@ async function savePokemon() {
|
||||
const payload: PokemonPayload = {
|
||||
id: Number(isEditing.value ? routeId.value : pokemonForm.value.id),
|
||||
name: pokemonNameForSave(),
|
||||
genus: pokemonForm.value.genus,
|
||||
details: pokemonForm.value.details,
|
||||
heightInches: pokemonForm.value.heightInches,
|
||||
weightPounds: pokemonForm.value.weightPounds,
|
||||
translations: pokemonForm.value.translations,
|
||||
typeIds: toIds(pokemonForm.value.typeIds.slice(0, 2)),
|
||||
stats: pokemonForm.value.stats,
|
||||
environmentId: Number(pokemonForm.value.environmentId),
|
||||
skillIds: toIds(pokemonForm.value.skillIds.slice(0, 2)),
|
||||
favoriteThingIds: toIds(pokemonForm.value.favoriteThingIds.slice(0, 6)),
|
||||
@@ -220,6 +298,96 @@ watch(() => pokemonForm.value.skillIds.slice(), syncSkillItemDrops);
|
||||
required
|
||||
/>
|
||||
|
||||
<TranslationFields
|
||||
id-prefix="pokemon-genus"
|
||||
v-model:base-value="pokemonForm.genus"
|
||||
v-model:translations="pokemonForm.translations"
|
||||
field="genus"
|
||||
:label="t('pages.pokemon.genus')"
|
||||
:languages="languages"
|
||||
/>
|
||||
|
||||
<TranslationFields
|
||||
id-prefix="pokemon-details"
|
||||
v-model:base-value="pokemonForm.details"
|
||||
v-model:translations="pokemonForm.translations"
|
||||
field="details"
|
||||
:label="t('pages.pokemon.details')"
|
||||
:languages="languages"
|
||||
multiline
|
||||
:rows="5"
|
||||
/>
|
||||
|
||||
<div class="field">
|
||||
<span id="pokemon-height-label" class="field-label">{{ t('pages.pokemon.height') }}</span>
|
||||
<div class="segmented" aria-labelledby="pokemon-height-label">
|
||||
<button :class="{ active: heightUnit === 'imperial' }" type="button" @click="heightUnit = 'imperial'">
|
||||
{{ t('pages.pokemon.heightImperial') }}
|
||||
</button>
|
||||
<button :class="{ active: heightUnit === 'metric' }" type="button" @click="heightUnit = 'metric'">
|
||||
{{ t('pages.pokemon.heightMetric') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="heightUnit === 'imperial'" class="pokemon-measurement-fields">
|
||||
<div class="field">
|
||||
<label for="pokemon-height-feet">{{ t('pages.pokemon.feet') }}</label>
|
||||
<input id="pokemon-height-feet" :value="heightFeetValue" min="0" step="1" type="number" inputmode="numeric" @input="updateHeightFeet" />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="pokemon-height-inches">{{ t('pages.pokemon.inches') }}</label>
|
||||
<input id="pokemon-height-inches" :value="heightInchesValue" min="0" step="1" type="number" inputmode="numeric" @input="updateHeightInches" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="field">
|
||||
<label for="pokemon-height-meters">{{ t('pages.pokemon.meters') }}</label>
|
||||
<input id="pokemon-height-meters" :value="heightMetersValue" min="0" step="0.01" type="number" inputmode="decimal" @input="updateHeightMeters" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<span id="pokemon-weight-label" class="field-label">{{ t('pages.pokemon.weight') }}</span>
|
||||
<div class="segmented" aria-labelledby="pokemon-weight-label">
|
||||
<button :class="{ active: weightUnit === 'imperial' }" type="button" @click="weightUnit = 'imperial'">
|
||||
{{ t('pages.pokemon.pounds') }}
|
||||
</button>
|
||||
<button :class="{ active: weightUnit === 'metric' }" type="button" @click="weightUnit = 'metric'">
|
||||
{{ t('pages.pokemon.kilograms') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="weightUnit === 'imperial'" class="field">
|
||||
<label for="pokemon-weight-pounds">{{ t('pages.pokemon.pounds') }}</label>
|
||||
<input id="pokemon-weight-pounds" :value="weightPoundsValue" min="0" step="0.1" type="number" inputmode="decimal" @input="updateWeightPounds" />
|
||||
</div>
|
||||
|
||||
<div v-else class="field">
|
||||
<label for="pokemon-weight-kg">{{ t('pages.pokemon.kilograms') }}</label>
|
||||
<input id="pokemon-weight-kg" :value="weightKgValue" min="0" step="0.01" type="number" inputmode="decimal" @input="updateWeightKg" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="pokemon-types">{{ t('pages.pokemon.types') }}</label>
|
||||
<TagsSelect
|
||||
id="pokemon-types"
|
||||
v-model="pokemonForm.typeIds"
|
||||
:options="options.pokemonTypes"
|
||||
:max="2"
|
||||
allow-create
|
||||
:creating="creatingSelect === 'pokemon-types'"
|
||||
:placeholder="t('pages.pokemon.searchTypes')"
|
||||
@create="createMultiOption('pokemon-types', 'pokemon-types', $event, pokemonForm.typeIds, 2)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<span class="field-label">{{ t('pages.pokemon.statsTitle') }}</span>
|
||||
<PokemonStatsFields id-prefix="pokemon-stats" v-model="pokemonForm.stats" />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="pokemon-environment">{{ t('pages.pokemon.environment') }}</label>
|
||||
<TagsSelect
|
||||
|
||||
@@ -145,6 +145,7 @@ watch(query, loadPokemon);
|
||||
:to="`/pokemon/${item.id}`"
|
||||
>
|
||||
<EditMeta :entity="item" />
|
||||
<EntityChips v-if="item.types.length" :items="item.types" />
|
||||
<EntityChips :items="item.skills" />
|
||||
<EntityChips :items="item.favorite_things" />
|
||||
</EntityCard>
|
||||
|
||||
Reference in New Issue
Block a user