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:
@@ -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}`),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user