Allow users to search and fetch Pokemon data from local CSV files Auto-populate basic fields, stats, types, and translations Add type icons to Pokemon detail and list views
695 lines
26 KiB
Vue
695 lines
26 KiB
Vue
<script setup lang="ts">
|
|
import { Icon } from '@iconify/vue';
|
|
import { computed, nextTick, onBeforeUnmount, 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 Tabs from '../components/Tabs.vue';
|
|
import TagsSelect from '../components/TagsSelect.vue';
|
|
import TranslationFields from '../components/TranslationFields.vue';
|
|
import { iconCancel, iconSave, iconSearch } from '../icons';
|
|
import {
|
|
api,
|
|
type ConfigType,
|
|
type Language,
|
|
type NamedEntity,
|
|
type Options,
|
|
type PokemonFetchOption,
|
|
type PokemonFetchResult,
|
|
type PokemonPayload,
|
|
type PokemonStats,
|
|
type TranslationMap
|
|
} from '../services/api';
|
|
|
|
type SkillItemDropForm = {
|
|
skillId: string;
|
|
itemId: string;
|
|
};
|
|
|
|
const route = useRoute();
|
|
const router = useRouter();
|
|
const { locale, t } = useI18n();
|
|
const options = ref<Options | null>(null);
|
|
const itemOptions = ref<NamedEntity[]>([]);
|
|
const languages = ref<Language[]>([]);
|
|
const loading = ref(true);
|
|
const busy = ref(false);
|
|
const fetchBusy = ref(false);
|
|
const fetchOptionsLoading = ref(false);
|
|
const fetchOptionsOpen = ref(false);
|
|
const message = ref('');
|
|
const fetchIdentifier = ref('');
|
|
const fetchOptions = ref<PokemonFetchOption[]>([]);
|
|
const creatingSelect = ref('');
|
|
const activeEditTab = ref('basic');
|
|
const heightUnit = ref<'imperial' | 'metric'>('imperial');
|
|
const weightUnit = ref<'imperial' | 'metric'>('imperial');
|
|
let fetchOptionsController: AbortController | null = null;
|
|
|
|
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[],
|
|
skillItemDrops: [] as SkillItemDropForm[]
|
|
});
|
|
|
|
const routeId = computed(() => (typeof route.params.id === 'string' ? route.params.id : ''));
|
|
const isEditing = computed(() => routeId.value !== '');
|
|
const pageTitle = computed(() =>
|
|
isEditing.value
|
|
? t('pages.pokemon.editTitle', { id: pokemonForm.value.id || routeId.value, name: pokemonForm.value.name })
|
|
: t('pages.pokemon.newTitle')
|
|
);
|
|
const cancelTo = computed(() => (isEditing.value ? `/pokemon/${routeId.value}` : '/pokemon'));
|
|
const selectedSkillDropRows = computed(() =>
|
|
pokemonForm.value.skillItemDrops.filter((row) => pokemonForm.value.skillIds.includes(row.skillId) && skillSupportsItemDrop(row.skillId))
|
|
);
|
|
const editTabs = computed(() => [
|
|
{ value: 'basic', label: t('pages.pokemon.editTabBasic') },
|
|
{ value: 'advance', label: t('pages.pokemon.editTabAdvance') }
|
|
]);
|
|
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;
|
|
}
|
|
|
|
async function loadOptions() {
|
|
const [loadedOptions, loadedItems, loadedLanguages] = await Promise.all([api.options(), api.items({}), api.languages()]);
|
|
options.value = loadedOptions;
|
|
itemOptions.value = loadedItems.map((item) => ({ id: item.id, name: item.name }));
|
|
languages.value = loadedLanguages;
|
|
}
|
|
|
|
function syncSkillItemDrops() {
|
|
const selectedSkillIds = new Set(pokemonForm.value.skillIds);
|
|
const rows = pokemonForm.value.skillItemDrops.filter((row) => selectedSkillIds.has(row.skillId) && skillSupportsItemDrop(row.skillId));
|
|
|
|
pokemonForm.value.skillIds.forEach((skillId) => {
|
|
if (skillSupportsItemDrop(skillId) && !rows.some((row) => row.skillId === skillId)) {
|
|
rows.push({ skillId, itemId: '' });
|
|
}
|
|
});
|
|
|
|
pokemonForm.value.skillItemDrops = rows;
|
|
}
|
|
|
|
function skillName(skillId: string) {
|
|
return options.value?.skills.find((skill) => String(skill.id) === skillId)?.name ?? '';
|
|
}
|
|
|
|
function skillSupportsItemDrop(skillId: string) {
|
|
return options.value?.skills.some((skill) => String(skill.id) === skillId && skill.hasItemDrop) === true;
|
|
}
|
|
|
|
function skillDropLabel(skillId: string) {
|
|
const name = skillName(skillId);
|
|
return name ? t('pages.pokemon.skillDrop', { name }) : t('pages.pokemon.dropItem');
|
|
}
|
|
|
|
function pokemonNameForSave() {
|
|
const baseName = pokemonForm.value.name.trim();
|
|
if (baseName !== '') {
|
|
return pokemonForm.value.name;
|
|
}
|
|
|
|
return pokemonForm.value.translations[String(locale.value || '')]?.name ?? '';
|
|
}
|
|
|
|
function pokemonIdForSave() {
|
|
return Number(isEditing.value ? routeId.value : pokemonForm.value.id);
|
|
}
|
|
|
|
function mergeFetchedTranslations(fetchedTranslations: TranslationMap | undefined): TranslationMap {
|
|
const nextTranslations = Object.entries(pokemonForm.value.translations).reduce<TranslationMap>((translations, [code, fields]) => {
|
|
translations[code] = { ...fields };
|
|
return translations;
|
|
}, {});
|
|
|
|
Object.entries(fetchedTranslations ?? {}).forEach(([code, fields]) => {
|
|
const nextFields = { ...(nextTranslations[code] ?? {}) };
|
|
if (typeof fields.name === 'string') {
|
|
nextFields.name = fields.name;
|
|
}
|
|
if (typeof fields.genus === 'string') {
|
|
nextFields.genus = fields.genus;
|
|
}
|
|
nextTranslations[code] = nextFields;
|
|
});
|
|
|
|
return nextTranslations;
|
|
}
|
|
|
|
function applyFetchedPokemon(fetchedPokemon: PokemonFetchResult): boolean {
|
|
if (isEditing.value && fetchedPokemon.id !== pokemonIdForSave()) {
|
|
message.value = t('pages.pokemon.fetchIdMismatch', { id: fetchedPokemon.id });
|
|
return false;
|
|
}
|
|
|
|
pokemonForm.value = {
|
|
...pokemonForm.value,
|
|
id: isEditing.value ? pokemonForm.value.id : String(fetchedPokemon.id),
|
|
name: fetchedPokemon.name,
|
|
genus: fetchedPokemon.genus,
|
|
heightInches: fetchedPokemon.heightInches,
|
|
weightPounds: fetchedPokemon.weightPounds,
|
|
translations: mergeFetchedTranslations(fetchedPokemon.translations),
|
|
typeIds: fetchedPokemon.typeIds.map(String),
|
|
stats: fetchedPokemon.stats
|
|
};
|
|
return true;
|
|
}
|
|
|
|
function hasRequiredBasicFields() {
|
|
const id = pokemonIdForSave();
|
|
return Number.isInteger(id) && id > 0 && pokemonNameForSave().trim() !== '';
|
|
}
|
|
|
|
async function showBasicFieldValidation() {
|
|
activeEditTab.value = 'basic';
|
|
await nextTick();
|
|
document.querySelector<HTMLFormElement>('#pokemon-edit-form')?.reportValidity();
|
|
}
|
|
|
|
function closeEditor() {
|
|
void router.push(cancelTo.value);
|
|
}
|
|
|
|
async function loadEditor() {
|
|
loading.value = true;
|
|
message.value = '';
|
|
|
|
try {
|
|
await loadOptions();
|
|
if (isEditing.value) {
|
|
const pokemon = await api.pokemonDetail(routeId.value);
|
|
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)),
|
|
skillItemDrops: pokemon.skills.map((skill) => ({
|
|
skillId: String(skill.id),
|
|
itemId: skill.itemDrop ? String(skill.itemDrop.id) : ''
|
|
}))
|
|
};
|
|
syncSkillItemDrops();
|
|
}
|
|
} catch (error) {
|
|
message.value = errorText(error, t('errors.loadFailed'));
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
}
|
|
|
|
function cancelFetchOptionsRequest() {
|
|
fetchOptionsController?.abort();
|
|
fetchOptionsController = null;
|
|
fetchOptionsLoading.value = false;
|
|
}
|
|
|
|
function fetchOptionLabel(option: PokemonFetchOption) {
|
|
return `#${option.id} ${option.name}`;
|
|
}
|
|
|
|
async function loadFetchOptions() {
|
|
cancelFetchOptionsRequest();
|
|
const controller = new AbortController();
|
|
fetchOptionsController = controller;
|
|
fetchOptionsLoading.value = true;
|
|
|
|
try {
|
|
const rows = await api.pokemonFetchOptions(fetchIdentifier.value, controller.signal);
|
|
if (fetchOptionsController === controller) {
|
|
fetchOptions.value = rows;
|
|
}
|
|
} catch (error) {
|
|
if (error instanceof DOMException && error.name === 'AbortError') {
|
|
return;
|
|
}
|
|
if (fetchOptionsController === controller) {
|
|
fetchOptions.value = [];
|
|
message.value = errorText(error, t('pages.pokemon.fetchSearchFailed'));
|
|
}
|
|
} finally {
|
|
if (fetchOptionsController === controller) {
|
|
fetchOptionsLoading.value = false;
|
|
fetchOptionsController = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
function refreshFetchOptions() {
|
|
if (!fetchOptionsOpen.value) {
|
|
return;
|
|
}
|
|
|
|
void loadFetchOptions();
|
|
}
|
|
|
|
function openFetchOptions() {
|
|
fetchOptionsOpen.value = true;
|
|
refreshFetchOptions();
|
|
}
|
|
|
|
function closeFetchOptions() {
|
|
fetchOptionsOpen.value = false;
|
|
cancelFetchOptionsRequest();
|
|
}
|
|
|
|
async function selectFetchOption(option: PokemonFetchOption) {
|
|
fetchIdentifier.value = option.identifier;
|
|
closeFetchOptions();
|
|
await fetchPokemonByIdentifier(option.identifier);
|
|
}
|
|
|
|
async function fetchPokemonByIdentifier(identifierValue?: string) {
|
|
const identifier = (identifierValue ?? fetchIdentifier.value).trim() || pokemonForm.value.id.trim();
|
|
if (!identifier) {
|
|
message.value = t('pages.pokemon.fetchIdentifierRequired');
|
|
return;
|
|
}
|
|
|
|
fetchBusy.value = true;
|
|
message.value = '';
|
|
|
|
try {
|
|
const fetchedPokemon = await api.fetchPokemonData(identifier);
|
|
await loadOptions();
|
|
if (applyFetchedPokemon(fetchedPokemon)) {
|
|
fetchIdentifier.value = fetchedPokemon.identifier;
|
|
closeFetchOptions();
|
|
}
|
|
} catch (error) {
|
|
message.value = errorText(error, t('pages.pokemon.fetchFailed'));
|
|
} finally {
|
|
fetchBusy.value = false;
|
|
}
|
|
}
|
|
|
|
function fetchPokemonFromInput() {
|
|
void fetchPokemonByIdentifier();
|
|
}
|
|
|
|
async function createSingleOption(selectKey: string, type: ConfigType, name: string, assign: (value: string) => void) {
|
|
const cleanName = name.trim();
|
|
if (!cleanName) return;
|
|
|
|
creatingSelect.value = selectKey;
|
|
message.value = '';
|
|
try {
|
|
const created = await api.createConfig(type, { name: cleanName });
|
|
await loadOptions();
|
|
assign(String(created.id));
|
|
} catch (error) {
|
|
message.value = errorText(error, t('errors.addFailed'));
|
|
} finally {
|
|
creatingSelect.value = '';
|
|
}
|
|
}
|
|
|
|
async function createMultiOption(selectKey: string, type: ConfigType, name: string, values: string[], max = 0) {
|
|
const cleanName = name.trim();
|
|
if (!cleanName || (max > 0 && values.length >= max)) return;
|
|
|
|
creatingSelect.value = selectKey;
|
|
message.value = '';
|
|
try {
|
|
const created = await api.createConfig(type, { name: cleanName });
|
|
await loadOptions();
|
|
const value = String(created.id);
|
|
if (!values.includes(value)) {
|
|
values.push(value);
|
|
}
|
|
} catch (error) {
|
|
message.value = errorText(error, t('errors.addFailed'));
|
|
} finally {
|
|
creatingSelect.value = '';
|
|
}
|
|
}
|
|
|
|
async function savePokemon() {
|
|
if (fetchBusy.value) {
|
|
return;
|
|
}
|
|
|
|
if (!hasRequiredBasicFields()) {
|
|
await showBasicFieldValidation();
|
|
return;
|
|
}
|
|
|
|
busy.value = true;
|
|
message.value = '';
|
|
|
|
try {
|
|
const payload: PokemonPayload = {
|
|
id: pokemonIdForSave(),
|
|
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)),
|
|
skillItemDrops: selectedSkillDropRows.value
|
|
.map((row) => ({ skillId: Number(row.skillId), itemId: Number(row.itemId) }))
|
|
.filter((row) => Number.isInteger(row.skillId) && row.skillId > 0 && Number.isInteger(row.itemId) && row.itemId > 0)
|
|
};
|
|
const saved = isEditing.value ? await api.updatePokemon(routeId.value, payload) : await api.createPokemon(payload);
|
|
await router.push(`/pokemon/${saved.id}`);
|
|
} catch (error) {
|
|
message.value = errorText(error, t('errors.saveFailed'));
|
|
} finally {
|
|
busy.value = false;
|
|
}
|
|
}
|
|
|
|
onMounted(() => {
|
|
void loadEditor();
|
|
});
|
|
|
|
onBeforeUnmount(cancelFetchOptionsRequest);
|
|
|
|
watch(() => pokemonForm.value.skillIds.slice(), syncSkillItemDrops);
|
|
watch(fetchIdentifier, refreshFetchOptions);
|
|
</script>
|
|
|
|
<template>
|
|
<Modal :title="pageTitle" :subtitle="t('pages.pokemon.editSubtitle')" :close-label="t('common.close')" size="wide" @close="closeEditor">
|
|
<StatusMessage v-if="message" variant="danger">{{ message }}</StatusMessage>
|
|
|
|
<form v-if="!loading && options" id="pokemon-edit-form" class="modal-edit-form modal-edit-form--tabbed pokemon-edit-form" @submit.prevent="savePokemon">
|
|
<Tabs id="pokemon-edit-tabs" v-model="activeEditTab" :tabs="editTabs" :label="t('pages.pokemon.editSections')" />
|
|
|
|
<div class="pokemon-fetch-panel" :aria-label="t('pages.pokemon.fetchData')">
|
|
<div class="field pokemon-fetch-panel__input">
|
|
<label for="pokemon-fetch-identifier">{{ t('pages.pokemon.fetchIdentifier') }}</label>
|
|
<input
|
|
id="pokemon-fetch-identifier"
|
|
v-model="fetchIdentifier"
|
|
type="search"
|
|
:placeholder="t('pages.pokemon.fetchIdentifierPlaceholder')"
|
|
autocomplete="off"
|
|
role="combobox"
|
|
:aria-expanded="fetchOptionsOpen"
|
|
aria-controls="pokemon-fetch-results"
|
|
@focus="openFetchOptions"
|
|
@keydown.escape.stop="closeFetchOptions"
|
|
@keydown.enter.prevent="fetchPokemonFromInput"
|
|
/>
|
|
<div v-if="fetchOptionsOpen" id="pokemon-fetch-results" class="pokemon-fetch-results" role="listbox" :aria-label="t('pages.pokemon.fetchResults')">
|
|
<p v-if="fetchOptionsLoading" class="pokemon-fetch-results__status">{{ t('pages.pokemon.fetchSearching') }}</p>
|
|
<template v-else-if="fetchOptions.length">
|
|
<button
|
|
v-for="option in fetchOptions"
|
|
:key="option.id"
|
|
type="button"
|
|
class="pokemon-fetch-option"
|
|
role="option"
|
|
@mousedown.prevent
|
|
@click="selectFetchOption(option)"
|
|
>
|
|
<span class="pokemon-fetch-option__name">{{ fetchOptionLabel(option) }}</span>
|
|
<span class="pokemon-fetch-option__identifier">{{ option.identifier }}</span>
|
|
</button>
|
|
</template>
|
|
<p v-else class="pokemon-fetch-results__status">{{ t('pages.pokemon.fetchNoMatches') }}</p>
|
|
</div>
|
|
</div>
|
|
<button type="button" class="ui-button ui-button--blue ui-button--small pokemon-fetch-panel__button" :disabled="busy || fetchBusy" @click="fetchPokemonFromInput">
|
|
<Icon :icon="iconSearch" class="ui-icon" aria-hidden="true" />
|
|
{{ fetchBusy ? t('pages.pokemon.fetchingData') : t('pages.pokemon.fetchData') }}
|
|
</button>
|
|
</div>
|
|
|
|
<section v-if="activeEditTab === 'basic'" class="pokemon-edit-panel" role="tabpanel" :aria-label="t('pages.pokemon.editTabBasic')">
|
|
<div class="pokemon-edit-grid">
|
|
<div class="field">
|
|
<label for="pokemon-id">ID</label>
|
|
<input id="pokemon-id" v-model="pokemonForm.id" :disabled="isEditing" min="1" required type="number" />
|
|
</div>
|
|
|
|
<TranslationFields
|
|
id-prefix="pokemon-name"
|
|
v-model:base-value="pokemonForm.name"
|
|
v-model:translations="pokemonForm.translations"
|
|
field="name"
|
|
:label="t('common.name')"
|
|
:languages="languages"
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<div class="pokemon-edit-grid">
|
|
<div class="field">
|
|
<label for="pokemon-environment">{{ t('pages.pokemon.environment') }}</label>
|
|
<TagsSelect
|
|
id="pokemon-environment"
|
|
v-model="pokemonForm.environmentId"
|
|
:options="options.environments"
|
|
:multiple="false"
|
|
allow-create
|
|
:creating="creatingSelect === 'pokemon-environment'"
|
|
:placeholder="t('common.select')"
|
|
:search-placeholder="t('pages.pokemon.searchEnvironment')"
|
|
@create="createSingleOption('pokemon-environment', 'environments', $event, (value) => (pokemonForm.environmentId = value))"
|
|
/>
|
|
</div>
|
|
|
|
<div class="field">
|
|
<label for="pokemon-skills">{{ t('pages.pokemon.skills') }}</label>
|
|
<TagsSelect
|
|
id="pokemon-skills"
|
|
v-model="pokemonForm.skillIds"
|
|
:options="options.skills"
|
|
:max="2"
|
|
allow-create
|
|
:creating="creatingSelect === 'pokemon-skills'"
|
|
:placeholder="t('pages.pokemon.searchSkills')"
|
|
@create="createMultiOption('pokemon-skills', 'skills', $event, pokemonForm.skillIds, 2)"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="field">
|
|
<label for="pokemon-things">{{ t('pages.pokemon.favoriteThings') }}</label>
|
|
<TagsSelect
|
|
id="pokemon-things"
|
|
v-model="pokemonForm.favoriteThingIds"
|
|
:options="options.favoriteThings"
|
|
:max="6"
|
|
allow-create
|
|
:creating="creatingSelect === 'pokemon-things'"
|
|
:placeholder="t('pages.pokemon.searchFavoriteThings')"
|
|
@create="createMultiOption('pokemon-things', 'favorite-things', $event, pokemonForm.favoriteThingIds, 6)"
|
|
/>
|
|
</div>
|
|
|
|
<div v-if="selectedSkillDropRows.length" class="field">
|
|
<span class="field-label">{{ t('pages.pokemon.skillDrops') }}</span>
|
|
<div class="skill-drop-list">
|
|
<div v-for="row in selectedSkillDropRows" :key="row.skillId" class="skill-drop-row">
|
|
<label :for="`pokemon-skill-drops-${row.skillId}`">{{ skillDropLabel(row.skillId) }}</label>
|
|
<TagsSelect
|
|
:id="`pokemon-skill-drops-${row.skillId}`"
|
|
v-model="row.itemId"
|
|
:options="itemOptions"
|
|
:multiple="false"
|
|
:placeholder="t('pages.pokemon.dropItem')"
|
|
:search-placeholder="t('pages.pokemon.searchItems')"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section v-else class="pokemon-edit-panel" role="tabpanel" :aria-label="t('pages.pokemon.editTabAdvance')">
|
|
<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-measurements-label" class="field-label">{{ t('pages.pokemon.measurements') }}</span>
|
|
<div class="pokemon-measurement-row" aria-labelledby="pokemon-measurements-label">
|
|
<div class="pokemon-measurement-control">
|
|
<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="pokemon-measurement-control">
|
|
<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>
|
|
</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"
|
|
:creating="creatingSelect === 'pokemon-types'"
|
|
:placeholder="t('pages.pokemon.searchTypes')"
|
|
/>
|
|
</div>
|
|
|
|
<div class="field">
|
|
<span class="field-label">{{ t('pages.pokemon.statsTitle') }}</span>
|
|
<PokemonStatsFields id-prefix="pokemon-stats" v-model="pokemonForm.stats" />
|
|
</div>
|
|
</section>
|
|
</form>
|
|
|
|
<section v-else class="modal-edit-form skeleton-detail-section" aria-busy="true" :aria-label="t('pages.pokemon.loadingEdit')">
|
|
<div v-for="index in 5" :key="index" class="field">
|
|
<Skeleton :width="index === 1 ? '52px' : '88px'" />
|
|
<Skeleton variant="box" height="44px" />
|
|
</div>
|
|
</section>
|
|
|
|
<template v-if="!loading && options" #footer>
|
|
<button type="submit" form="pokemon-edit-form" class="link-button" :disabled="busy || fetchBusy">
|
|
<Icon :icon="iconSave" class="ui-icon" aria-hidden="true" />
|
|
{{ busy ? t('common.saving') : t('common.save') }}
|
|
</button>
|
|
<button type="button" class="plain-button" :disabled="busy" @click="closeEditor">
|
|
<Icon :icon="iconCancel" class="ui-icon" aria-hidden="true" />
|
|
{{ t('common.cancel') }}
|
|
</button>
|
|
</template>
|
|
</Modal>
|
|
</template>
|