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

@@ -71,6 +71,8 @@ export interface NamedEntity {
id: number;
name: string;
baseName?: string;
description?: string;
opposite?: NamedEntity | null;
translations?: TranslationMap;
}
@@ -234,9 +236,9 @@ export interface RelatedPokemon {
name: string;
isEventItem: boolean;
image?: PokemonImage | null;
environment: NamedEntity;
environment: NamedEntity & { matches?: boolean; isOpposite?: boolean };
skills: Skill[];
favorite_things: Array<NamedEntity & { matches: boolean }>;
favorite_things: Array<NamedEntity & { matches: boolean; isOpposite?: boolean }>;
}
export interface PokemonDetail extends Pokemon {
@@ -1581,7 +1583,7 @@ export const api = {
config: (type: ConfigType) => getJson<Array<Skill | GameVersion | NamedEntity>>(`/api/admin/config/${type}`),
createConfig: (
type: ConfigType,
payload: { name: string; translations?: TranslationMap; hasItemDrop?: boolean; hasTrading?: boolean; changeLog?: string }
payload: { name: string; translations?: TranslationMap; description?: string; oppositeId?: number | null; hasItemDrop?: boolean; hasTrading?: boolean; changeLog?: string }
) =>
sendJson<Skill | GameVersion | NamedEntity>(`/api/admin/config/${type}`, 'POST', payload),
reorderConfig: (type: ConfigType, ids: number[]) =>
@@ -1589,7 +1591,7 @@ export const api = {
updateConfig: (
type: ConfigType,
id: number,
payload: { name: string; translations?: TranslationMap; hasItemDrop?: boolean; hasTrading?: boolean; changeLog?: string }
payload: { name: string; translations?: TranslationMap; description?: string; oppositeId?: number | null; hasItemDrop?: boolean; hasTrading?: boolean; changeLog?: string }
) =>
sendJson<Skill | GameVersion | NamedEntity>(`/api/admin/config/${type}/${id}`, 'PUT', payload),
deleteConfig: (type: ConfigType, id: number) => deleteJson(`/api/admin/config/${type}/${id}`),

View File

@@ -5756,6 +5756,105 @@ button:disabled,
color: var(--ink-soft);
}
.pokemon-description-grid {
display: grid;
grid-template-columns: minmax(220px, 340px) minmax(0, 1fr);
gap: 16px;
align-items: stretch;
}
.pokemon-description-image {
width: 100%;
min-height: 260px;
aspect-ratio: 1 / 1;
display: grid;
place-items: center;
padding: 14px;
overflow: hidden;
border: 3px solid var(--line-strong);
border-radius: var(--radius-card);
background:
linear-gradient(90deg, rgba(42, 117, 187, 0.08) 1px, transparent 1px) 0 0 / 18px 18px,
linear-gradient(rgba(42, 117, 187, 0.08) 1px, transparent 1px) 0 0 / 18px 18px,
#eef9ff;
box-shadow: var(--shadow-soft);
cursor: pointer;
}
.pokemon-description-image:not(.pokemon-description-image--placeholder) {
padding: 0;
border: 0;
background: transparent;
box-shadow: none;
}
.pokemon-description-image img {
width: 100%;
height: 100%;
object-fit: contain;
}
.pokemon-description-image--placeholder {
cursor: default;
}
.pokemon-description-card {
align-content: start;
}
.pokemon-description-card .detail-text {
max-width: 78ch;
font-size: 1rem;
line-height: 1.7;
}
.pokemon-core-grid,
.pokemon-reference-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
align-items: stretch;
}
.pokemon-core-card {
align-content: start;
}
.pokemon-core-note {
margin: 0;
}
.pokemon-core-value,
.pokemon-favourite-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
min-width: 0;
}
.pokemon-favourite-chip {
align-items: flex-start;
flex-direction: column;
gap: 3px;
}
.pokemon-chip-note,
.related-relation-label {
color: inherit;
font-size: 0.72rem;
font-weight: 950;
line-height: 1;
text-transform: uppercase;
}
.chip--danger,
.related-pokemon-row__environment--opposite,
.related-favourite-chip--opposite {
border-color: color-mix(in srgb, var(--danger) 62%, var(--line));
background: color-mix(in srgb, var(--danger) 16%, var(--surface));
color: #8f1717;
}
.pokemon-profile-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(280px, 360px);
@@ -7976,8 +8075,11 @@ button:disabled,
.entity-profile-grid,
.home-hero,
.pokemon-image-detail,
.pokemon-description-grid,
.pokemon-profile-grid,
.pokemon-profile-row,
.pokemon-core-grid,
.pokemon-reference-grid,
.pokemon-related-grid,
.profile-layout,
.profile-layout--loading,

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>