feat(pokemon): add opposite relationships and redesign detail view

Add description and opposite relationships to environments and favorite things
Move pokedex reference data (stats, dimensions, types) to a separate tab
Highlight core mechanics (skills, habitat, favorite things) in detail view
Update related pokemon scoring to account for opposite relationships
This commit is contained in:
2026-05-07 15:57:38 +08:00
parent a781bc559b
commit 953b90eba1
8 changed files with 489 additions and 96 deletions

View File

@@ -93,6 +93,8 @@ type AdminGroup = 'content' | 'configuration' | 'localization' | 'access';
type AdminNavItem = { key: AdminTab; label: string; permission: string | string[] };
type AdminNavGroup = { key: AdminGroup; label: string; items: AdminNavItem[] };
type EditableConfig = (NamedEntity | Skill | GameVersion) & {
description?: string;
opposite?: NamedEntity | null;
hasItemDrop?: boolean;
hasTrading?: boolean;
changeLog?: string;
@@ -205,12 +207,14 @@ const configTypes = computed<
supportsItemDrop?: boolean;
supportsTrading?: boolean;
supportsChangeLog?: boolean;
supportsDescription?: boolean;
supportsOpposite?: boolean;
}>
>(() => [
{ key: 'pokemon-types', label: t('config.pokemonTypes') },
{ key: 'skills', label: t('config.skills'), supportsItemDrop: true, supportsTrading: true },
{ key: 'environments', label: t('config.environments') },
{ key: 'favorite-things', label: t('config.favoriteThings') },
{ key: 'environments', label: t('config.environments'), supportsDescription: true, supportsOpposite: true },
{ key: 'favorite-things', label: t('config.favoriteThings'), supportsOpposite: true },
{ key: 'acquisition-methods', label: t('config.acquisitionMethods') },
{ key: 'maps', label: t('config.maps') },
{ key: 'game-versions', label: t('config.gameVersions'), supportsChangeLog: true },
@@ -246,6 +250,8 @@ const message = ref('');
const configForm = ref({
id: 0,
name: '',
description: '',
oppositeId: '',
translations: {} as TranslationMap,
hasItemDrop: false,
hasTrading: false,
@@ -346,6 +352,12 @@ const configNameInput = computed({
}
});
const configNamePlaceholder = computed(() => (isConfigDefaultLocale.value ? '' : configForm.value.name));
const configOppositeOptions = computed(() => [
{ id: '', name: t('common.none') },
...configRows.value
.filter((item) => item.id !== configForm.value.id)
.map((item) => ({ id: item.id, name: item.name }))
]);
const activeConfigTab = computed({
get: () => activeConfigType.value,
set: (value: string) => {
@@ -610,7 +622,7 @@ async function loadLanguages() {
}
function resetConfigForm() {
configForm.value = { id: 0, name: '', translations: {}, hasItemDrop: false, hasTrading: false, changeLog: '' };
configForm.value = { id: 0, name: '', description: '', oppositeId: '', translations: {}, hasItemDrop: false, hasTrading: false, changeLog: '' };
}
function resetChecklistForm() {
@@ -718,6 +730,8 @@ function editConfig(item: EditableConfig) {
configForm.value = {
id: item.id,
name: item.baseName ?? item.name,
description: item.description ?? '',
oppositeId: item.opposite ? String(item.opposite.id) : '',
translations: item.translations ?? {},
hasItemDrop: item.hasItemDrop === true,
hasTrading: item.hasTrading === true,
@@ -1103,6 +1117,8 @@ async function saveConfig() {
const payload = {
name: configBaseNameForSave(),
translations: configForm.value.translations,
description: selectedConfig.value.supportsDescription ? configForm.value.description : undefined,
oppositeId: selectedConfig.value.supportsOpposite && configForm.value.oppositeId ? Number(configForm.value.oppositeId) : null,
hasItemDrop: selectedConfig.value.supportsItemDrop ? configForm.value.hasItemDrop : undefined,
hasTrading: selectedConfig.value.supportsTrading ? configForm.value.hasTrading : undefined,
changeLog: selectedConfig.value.supportsChangeLog ? configForm.value.changeLog : undefined
@@ -2158,9 +2174,11 @@ onMounted(() => {
<template #default="{ item }">
<span class="reorderable-row-title">
{{ item.name }}
<span v-if="item.opposite" class="config-flag">{{ t('pages.admin.opposite') }}: {{ item.opposite.name }}</span>
<span v-if="item.hasItemDrop" class="config-flag">{{ t('pages.admin.hasItemDrop') }}</span>
<span v-if="item.hasTrading" class="config-flag">{{ t('pages.admin.hasTrading') }}</span>
</span>
<span v-if="item.description" class="meta-line">{{ item.description }}</span>
<span class="row-actions">
<button v-if="can('admin.config.update')" type="button" :disabled="busy" @click="editConfig(item)">
<Icon :icon="iconEdit" class="ui-icon" aria-hidden="true" />
@@ -2989,6 +3007,20 @@ onMounted(() => {
<label for="config-name">{{ t('common.name') }}</label>
<input id="config-name" v-model="configNameInput" :placeholder="configNamePlaceholder" :required="configNameRequired" />
</div>
<div v-if="selectedConfig.supportsDescription" class="field">
<label for="config-description">{{ t('pages.admin.description') }}</label>
<textarea id="config-description" v-model="configForm.description"></textarea>
</div>
<div v-if="selectedConfig.supportsOpposite" class="field">
<label for="config-opposite">{{ t('pages.admin.opposite') }}</label>
<TagsSelect
id="config-opposite"
v-model="configForm.oppositeId"
:multiple="false"
:options="configOppositeOptions"
:placeholder="t('pages.admin.opposite')"
/>
</div>
<div v-if="selectedConfig.supportsItemDrop" class="check-row">
<label>
<input v-model="configForm.hasItemDrop" type="checkbox" />

View File

@@ -276,6 +276,7 @@ const listPath = computed(() => (pokemon.value?.isEventItem ? '/event-pokemon' :
const detailKicker = computed(() => t(pokemon.value?.isEventItem ? 'pages.eventPokemon.detailKicker' : 'pages.pokemon.detailKicker'));
const detailTabs = computed<TabOption[]>(() => [
{ value: 'details', label: t('common.details') },
{ value: 'reference', label: t('pages.pokemon.referenceTab') },
{ value: 'discussion', label: t('discussion.title') },
{ value: 'history', label: t('history.editHistory') }
]);
@@ -322,6 +323,12 @@ const relatedPokemonRows = computed(() => {
return rows.slice(0, relatedPokemonLimit);
}
if (pokemon.value && selectedTab === habitatTabValue(pokemon.value.environment.id)) {
return rows
.filter((item) => habitatTabValue(item.environment.id) === selectedTab || item.environment.isOpposite)
.slice(0, relatedPokemonLimit);
}
return rows.filter((item) => habitatTabValue(item.environment.id) === selectedTab).slice(0, relatedPokemonLimit);
});
const typeSlotClass = computed(() => ({
@@ -740,7 +747,7 @@ watch(initialPokemon, applyInitialPokemon, { immediate: true });
</div>
</section>
<section v-else class="page-stack">
<PageHeader :title="`#${pokemon.displayId} ${pokemon.name}`" :subtitle="t('pages.pokemon.environmentPrefix', { name: pokemon.environment.name })">
<PageHeader :title="`#${pokemon.displayId} ${pokemon.name}`" :subtitle="pokemon.genus || t('pages.pokemon.detailSubtitle')">
<template #kicker>{{ detailKicker }}</template>
<template #actions>
<RouterLink v-if="canUpdatePokemon" class="ui-button ui-button--primary ui-button--small" :to="`/pokemon/${pokemon.id}/edit`">
@@ -758,66 +765,49 @@ watch(initialPokemon, applyInitialPokemon, { immediate: true });
<Tabs id="pokemon-detail-tabs" v-model="detailTab" :tabs="detailTabs" :label="t('common.details')" />
<div v-if="detailTab === 'details'" class="detail-grid detail-grid--stack">
<div class="pokemon-profile-grid pokemon-profile-grid--with-image">
<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 pokemon-type-chip">
<img v-if="pokemonTypeIconSrc(type.id)" class="pokemon-type-chip__icon" :src="pokemonTypeIconSrc(type.id) ?? undefined" alt="" aria-hidden="true" />
<span>{{ type.name }}</span>
</span>
</div>
<p v-else class="meta-line">{{ t('common.none') }}</p>
</section>
</div>
<div class="pokemon-description-grid">
<button v-if="pokemon.image" type="button" class="pokemon-description-image" :aria-label="pokemonImageLabel()" @click="openImageModal">
<img :src="pokemon.image.url" :alt="pokemonImageAlt()" />
</button>
<div v-else class="pokemon-description-image pokemon-description-image--placeholder" role="img" :aria-label="t('pages.pokemon.imageEmpty')">
<PokeBallMark size="82px" />
</div>
<div class="pokemon-profile-side pokemon-profile-side--with-image">
<DetailSection class="pokemon-profile-stats" :title="t('pages.pokemon.statsTitle')">
<PokemonStatsPanel :stats="pokemon.stats" />
</DetailSection>
<button v-if="pokemon.image" type="button" class="pokemon-profile-image" :aria-label="pokemonImageLabel()" @click="openImageModal">
<img :src="pokemon.image.url" :alt="pokemonImageAlt()" />
</button>
<div v-else class="pokemon-profile-image pokemon-profile-image--placeholder" role="img" :aria-label="t('pages.pokemon.imageEmpty')">
<PokeBallMark size="64px" />
</div>
</div>
<section class="detail-section pokemon-description-card" :aria-label="t('pages.pokemon.details')">
<h2 class="section-subtitle">{{ t('pages.pokemon.description') }}</h2>
<p v-if="pokemon.details.trim()" class="detail-text">{{ pokemon.details }}</p>
<p v-else class="meta-line">{{ t('common.none') }}</p>
</section>
</div>
<DetailSection :title="t('pages.pokemon.skills')">
<EntityChips :items="pokemon.skills" />
</DetailSection>
<div class="pokemon-core-grid" :aria-label="t('pages.pokemon.coreFactors')">
<DetailSection class="pokemon-core-card" :title="t('pages.pokemon.skills')">
<p class="meta-line pokemon-core-note">{{ t('pages.pokemon.skillsCoreNote') }}</p>
<EntityChips :items="pokemon.skills" />
</DetailSection>
<DetailSection class="pokemon-core-card" :title="t('pages.pokemon.environment')">
<p class="meta-line pokemon-core-note">{{ t('pages.pokemon.environmentCoreNote') }}</p>
<div class="pokemon-core-value">
<span class="chip">{{ pokemon.environment.name }}</span>
<span v-if="pokemon.environment.opposite" class="chip chip--danger">
{{ t('pages.pokemon.opposite') }}: {{ pokemon.environment.opposite.name }}
</span>
</div>
<p v-if="pokemon.environment.description" class="detail-text">{{ pokemon.environment.description }}</p>
</DetailSection>
<DetailSection class="pokemon-core-card" :title="t('pages.pokemon.favoriteThings')">
<p class="meta-line pokemon-core-note">{{ t('pages.pokemon.favoriteThingsCoreNote') }}</p>
<div v-if="pokemon.favorite_things.length" class="pokemon-favourite-list">
<span v-for="thing in pokemon.favorite_things" :key="thing.id" class="chip pokemon-favourite-chip">
<span>{{ thing.name }}</span>
<span v-if="thing.opposite" class="pokemon-chip-note">{{ t('pages.pokemon.opposite') }}: {{ thing.opposite.name }}</span>
</span>
</div>
<p v-else class="meta-line">{{ t('common.none') }}</p>
</DetailSection>
</div>
<DetailSection v-if="hasItemDropSkill" :title="t('pages.pokemon.skillDrops')">
<ul class="row-list skill-drop-summary">
@@ -864,10 +854,6 @@ watch(initialPokemon, applyInitialPokemon, { immediate: true });
</div>
</DetailSection>
<DetailSection :title="t('pages.pokemon.favoriteThings')">
<EntityChips :items="pokemon.favorite_things" />
</DetailSection>
<div class="pokemon-related-grid">
<DetailSection :title="t('pages.pokemon.relatedPokemon')">
<template v-if="pokemon.relatedPokemon.length">
@@ -896,9 +882,13 @@ watch(initialPokemon, applyInitialPokemon, { immediate: true });
/>
<span
class="chip related-pokemon-row__environment"
:class="{ 'related-pokemon-row__environment--match': related.environment.id === pokemon.environment.id }"
:class="{
'related-pokemon-row__environment--match': related.environment.matches || related.environment.id === pokemon.environment.id,
'related-pokemon-row__environment--opposite': related.environment.isOpposite
}"
>
{{ related.environment.name }}
<span>{{ related.environment.name }}</span>
<span v-if="related.environment.isOpposite" class="related-relation-label">{{ t('pages.pokemon.opposite') }}</span>
</span>
</div>
</div>
@@ -910,9 +900,10 @@ watch(initialPokemon, applyInitialPokemon, { immediate: true });
v-for="thing in related.favorite_things"
:key="thing.id"
class="chip related-favourite-chip"
:class="{ 'related-favourite-chip--match': thing.matches }"
:class="{ 'related-favourite-chip--match': thing.matches, 'related-favourite-chip--opposite': thing.isOpposite }"
>
{{ thing.name }}
<span>{{ thing.name }}</span>
<span v-if="thing.isOpposite" class="related-relation-label">{{ t('pages.pokemon.opposite') }}</span>
</span>
</div>
</div>
@@ -982,6 +973,51 @@ watch(initialPokemon, applyInitialPokemon, { immediate: true });
</DetailSection>
</div>
<div v-else-if="detailTab === 'reference'" class="detail-grid detail-grid--stack">
<DetailSection :title="t('pages.pokemon.referenceData')">
<p class="meta-line">{{ t('pages.pokemon.pokedexReferenceNote') }}</p>
</DetailSection>
<div class="pokemon-reference-grid">
<section class="detail-section pokemon-profile-card" :aria-label="t('pages.pokemon.measurements')">
<h2 class="section-subtitle">{{ t('pages.pokemon.measurements') }}</h2>
<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')">
<h2 class="section-subtitle">{{ t('pages.pokemon.types') }}</h2>
<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 pokemon-type-chip">
<img v-if="pokemonTypeIconSrc(type.id)" class="pokemon-type-chip__icon" :src="pokemonTypeIconSrc(type.id) ?? undefined" alt="" aria-hidden="true" />
<span>{{ type.name }}</span>
</span>
</div>
<p v-else class="meta-line">{{ t('common.none') }}</p>
</section>
<DetailSection class="pokemon-profile-stats" :title="t('pages.pokemon.statsTitle')">
<PokemonStatsPanel :stats="pokemon.stats" />
</DetailSection>
</div>
</div>
<div v-else-if="detailTab === 'discussion'" class="detail-tab-panel">
<EntityDiscussionPanel entity-type="pokemon" :entity-id="pokemon.id" />
</div>