feat: add images and profile grid layout to entity detail pages
Return image data for related entities across all backend detail queries Display images or default placeholders in detail headers, chips, and lists Standardize Item, Recipe, and Habitat detail views with a new profile grid
This commit is contained in:
@@ -8,9 +8,10 @@ import EditHistoryPanel from '../components/EditHistoryPanel.vue';
|
||||
import EntityDiscussionPanel from '../components/EntityDiscussionPanel.vue';
|
||||
import EntityChips from '../components/EntityChips.vue';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import PokeBallMark from '../components/PokeBallMark.vue';
|
||||
import Skeleton from '../components/Skeleton.vue';
|
||||
import Tabs, { type TabOption } from '../components/Tabs.vue';
|
||||
import { iconBack, iconEdit } from '../icons';
|
||||
import { iconBack, iconEdit, iconHabitat } from '../icons';
|
||||
import { api, type HabitatDetail } from '../services/api';
|
||||
import HabitatEdit from './HabitatEdit.vue';
|
||||
|
||||
@@ -30,6 +31,7 @@ const detailTabs = computed<TabOption[]>(() => [
|
||||
type PokemonRow = {
|
||||
id: number;
|
||||
name: string;
|
||||
image: HabitatDetail['pokemon'][number]['image'];
|
||||
timeOfDays: string[];
|
||||
weathers: string[];
|
||||
rarity: number;
|
||||
@@ -74,6 +76,7 @@ const pokemonRows = computed<PokemonRow[]>(() => {
|
||||
{
|
||||
id: number;
|
||||
name: string;
|
||||
image: HabitatDetail['pokemon'][number]['image'];
|
||||
timeOfDays: Set<string>;
|
||||
weathers: Set<string>;
|
||||
rarity: number;
|
||||
@@ -86,6 +89,7 @@ const pokemonRows = computed<PokemonRow[]>(() => {
|
||||
const row = rows.get(key) ?? {
|
||||
id: pokemon.id,
|
||||
name: pokemon.name,
|
||||
image: pokemon.image,
|
||||
timeOfDays: new Set<string>(),
|
||||
weathers: new Set<string>(),
|
||||
rarity: pokemon.rarity,
|
||||
@@ -101,6 +105,7 @@ const pokemonRows = computed<PokemonRow[]>(() => {
|
||||
return [...rows.values()].map((row) => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
image: row.image,
|
||||
timeOfDays: sortByOrder(row.timeOfDays, timeOfDays),
|
||||
weathers: sortByOrder(row.weathers, weathers),
|
||||
rarity: row.rarity,
|
||||
@@ -108,10 +113,6 @@ const pokemonRows = computed<PokemonRow[]>(() => {
|
||||
}));
|
||||
});
|
||||
|
||||
function imageFileName(path: string): string {
|
||||
return path.split('/').at(-1) ?? t('media.image');
|
||||
}
|
||||
|
||||
async function loadHabitatDetail() {
|
||||
habitat.value = await api.habitatDetail(String(route.params.id));
|
||||
}
|
||||
@@ -187,7 +188,7 @@ watch(
|
||||
</section>
|
||||
<section v-else class="page-stack">
|
||||
<PageHeader :title="habitat.name" :subtitle="t('pages.habitats.detailSubtitle')">
|
||||
<template #kicker>Habitat Detail</template>
|
||||
<template #kicker>{{ t('pages.habitats.detailKicker') }}</template>
|
||||
<template #actions>
|
||||
<RouterLink class="ui-button ui-button--primary ui-button--small" :to="`/habitats/${habitat.id}/edit`">
|
||||
<Icon :icon="iconEdit" class="ui-icon" aria-hidden="true" />
|
||||
@@ -203,29 +204,48 @@ watch(
|
||||
<div class="detail-tabs">
|
||||
<Tabs id="habitat-detail-tabs" v-model="detailTab" :tabs="detailTabs" :label="t('common.details')" />
|
||||
|
||||
<div v-if="detailTab === 'details'" class="habitat-detail-stack">
|
||||
<DetailSection v-if="habitat.image || habitat.imageHistory.length" :title="t('media.image')">
|
||||
<div class="entity-detail-image">
|
||||
<div v-if="habitat.image" class="entity-detail-image__frame">
|
||||
<img :src="habitat.image.url" :alt="t('media.imageAlt', { name: habitat.name })" />
|
||||
</div>
|
||||
<p v-else class="meta-line">{{ t('media.imageEmpty') }}</p>
|
||||
<div v-if="habitat.imageHistory.length" class="image-history-list" :aria-label="t('media.imageHistory')">
|
||||
<div v-for="image in habitat.imageHistory" :key="image.path" class="image-history-list__item">
|
||||
<img :src="image.url" :alt="t('media.imageAlt', { name: habitat.name })" loading="lazy" />
|
||||
<span>{{ imageFileName(image.path) }}</span>
|
||||
<div v-if="detailTab === 'details'" class="detail-grid detail-grid--stack">
|
||||
<div class="entity-profile-grid">
|
||||
<section class="detail-section entity-profile-media-section" :aria-label="t('media.image')">
|
||||
<div class="entity-detail-image">
|
||||
<div class="entity-detail-image__frame" :class="{ 'entity-detail-image__frame--placeholder': !habitat.image }">
|
||||
<img v-if="habitat.image" :src="habitat.image.url" :alt="t('media.imageAlt', { name: habitat.name })" />
|
||||
<span v-else class="entity-card__mark entity-detail-image__mark" role="img" :aria-label="t('media.imageEmpty')">
|
||||
<Icon :icon="iconHabitat" class="entity-card__icon" aria-hidden="true" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DetailSection>
|
||||
</section>
|
||||
|
||||
<DetailSection :title="t('pages.habitats.recipeList')">
|
||||
<EntityChips :items="habitat.recipe" />
|
||||
</DetailSection>
|
||||
<div class="entity-profile-main">
|
||||
<section class="detail-section entity-profile-overview" :aria-label="t('common.details')">
|
||||
<dl class="entity-profile-facts">
|
||||
<div>
|
||||
<dt>{{ t('pages.habitats.recipeList') }}</dt>
|
||||
<dd>{{ habitat.recipe.length }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ t('pages.habitats.possiblePokemon') }}</dt>
|
||||
<dd>{{ pokemonRows.length }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div class="entity-profile-group">
|
||||
<h3 class="section-subtitle">{{ t('pages.habitats.recipeList') }}</h3>
|
||||
<EntityChips v-if="habitat.recipe.length" :items="habitat.recipe" />
|
||||
<p v-else class="meta-line">{{ t('common.none') }}</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DetailSection :title="t('pages.habitats.possiblePokemon')">
|
||||
<ul class="row-list appearance-list">
|
||||
<ul v-if="pokemonRows.length" class="row-list appearance-list appearance-list--with-media">
|
||||
<li v-for="item in pokemonRows" :key="`${item.id}-${item.rarity}`">
|
||||
<span class="related-entity-media related-entity-media--appearance related-entity-media--pokemon" aria-hidden="true">
|
||||
<img v-if="item.image" :src="item.image.url" alt="" loading="lazy" />
|
||||
<PokeBallMark v-else size="24px" />
|
||||
</span>
|
||||
<RouterLink class="appearance-name" :to="`/pokemon/${item.id}`">{{ item.name }}</RouterLink>
|
||||
<dl class="appearance-summary">
|
||||
<div>
|
||||
@@ -247,6 +267,7 @@ watch(
|
||||
</dl>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else class="meta-line">{{ t('common.none') }}</p>
|
||||
</DetailSection>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -8,9 +8,10 @@ import EditHistoryPanel from '../components/EditHistoryPanel.vue';
|
||||
import EntityDiscussionPanel from '../components/EntityDiscussionPanel.vue';
|
||||
import EntityChips from '../components/EntityChips.vue';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import PokeBallMark from '../components/PokeBallMark.vue';
|
||||
import Skeleton from '../components/Skeleton.vue';
|
||||
import Tabs, { type TabOption } from '../components/Tabs.vue';
|
||||
import { iconAdd, iconBack, iconEdit } from '../icons';
|
||||
import { iconAdd, iconBack, iconEdit, iconHabitat, iconItem } from '../icons';
|
||||
import { api, type ItemDetail } from '../services/api';
|
||||
import ItemEdit from './ItemEdit.vue';
|
||||
|
||||
@@ -24,6 +25,13 @@ const detailTabs = computed<TabOption[]>(() => [
|
||||
{ value: 'discussion', label: t('discussion.title') },
|
||||
{ value: 'history', label: t('history.editHistory') }
|
||||
]);
|
||||
const itemSubtitle = computed(() => {
|
||||
if (!item.value) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return item.value.usage ? `${item.value.category.name} · ${item.value.usage.name}` : item.value.category.name;
|
||||
});
|
||||
|
||||
const customization = computed(() => {
|
||||
if (!item.value) {
|
||||
@@ -37,10 +45,6 @@ const customization = computed(() => {
|
||||
].filter(Boolean);
|
||||
});
|
||||
|
||||
function imageFileName(path: string): string {
|
||||
return path.split('/').at(-1) ?? t('media.image');
|
||||
}
|
||||
|
||||
async function loadItemDetail() {
|
||||
item.value = await api.itemDetail(String(route.params.id));
|
||||
}
|
||||
@@ -122,8 +126,8 @@ watch(
|
||||
</div>
|
||||
</section>
|
||||
<section v-else class="page-stack">
|
||||
<PageHeader :title="item.name" :subtitle="item.usage ? `${item.category.name} · ${item.usage.name}` : item.category.name">
|
||||
<template #kicker>Item Detail</template>
|
||||
<PageHeader :title="item.name" :subtitle="itemSubtitle">
|
||||
<template #kicker>{{ t('pages.items.detailKicker') }}</template>
|
||||
<template #actions>
|
||||
<RouterLink class="ui-button ui-button--primary ui-button--small" :to="`/items/${item.id}/edit`">
|
||||
<Icon :icon="iconEdit" class="ui-icon" aria-hidden="true" />
|
||||
@@ -139,81 +143,129 @@ watch(
|
||||
<div class="detail-tabs">
|
||||
<Tabs id="item-detail-tabs" v-model="detailTab" :tabs="detailTabs" :label="t('common.details')" />
|
||||
|
||||
<div v-if="detailTab === 'details'" class="detail-grid">
|
||||
<DetailSection v-if="item.image || item.imageHistory.length" :title="t('media.image')">
|
||||
<div class="entity-detail-image">
|
||||
<div v-if="item.image" class="entity-detail-image__frame">
|
||||
<img :src="item.image.url" :alt="t('media.imageAlt', { name: item.name })" />
|
||||
</div>
|
||||
<p v-else class="meta-line">{{ t('media.imageEmpty') }}</p>
|
||||
<div v-if="item.imageHistory.length" class="image-history-list" :aria-label="t('media.imageHistory')">
|
||||
<div v-for="image in item.imageHistory" :key="image.path" class="image-history-list__item">
|
||||
<img :src="image.url" :alt="t('media.imageAlt', { name: item.name })" loading="lazy" />
|
||||
<span>{{ imageFileName(image.path) }}</span>
|
||||
<div v-if="detailTab === 'details'" class="detail-grid detail-grid--stack">
|
||||
<div class="entity-profile-grid">
|
||||
<section class="detail-section entity-profile-media-section" :aria-label="t('media.image')">
|
||||
<div class="entity-detail-image">
|
||||
<div class="entity-detail-image__frame" :class="{ 'entity-detail-image__frame--placeholder': !item.image }">
|
||||
<img v-if="item.image" :src="item.image.url" :alt="t('media.imageAlt', { name: item.name })" />
|
||||
<span v-else class="entity-card__mark entity-detail-image__mark" role="img" :aria-label="t('media.imageEmpty')">
|
||||
<Icon :icon="iconItem" class="entity-card__icon" aria-hidden="true" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="entity-profile-main">
|
||||
<section class="detail-section entity-profile-overview" :aria-label="t('common.details')">
|
||||
<dl class="entity-profile-facts">
|
||||
<div>
|
||||
<dt>{{ t('pages.items.category') }}</dt>
|
||||
<dd>{{ item.category.name }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ t('pages.items.usage') }}</dt>
|
||||
<dd>{{ item.usage?.name ?? t('common.none') }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ t('pages.items.recipeInfo') }}</dt>
|
||||
<dd>{{ item.noRecipe ? t('pages.items.noRecipe') : item.recipe ? item.recipe.name : t('common.none') }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div class="entity-profile-groups">
|
||||
<div class="entity-profile-group">
|
||||
<h3 class="section-subtitle">{{ t('pages.items.acquisitionMethods') }}</h3>
|
||||
<EntityChips v-if="item.acquisitionMethods.length" :items="item.acquisitionMethods" />
|
||||
<p v-else class="meta-line">{{ t('common.none') }}</p>
|
||||
</div>
|
||||
<div class="entity-profile-group">
|
||||
<h3 class="section-subtitle">{{ t('pages.items.customization') }}</h3>
|
||||
<div v-if="customization.length" class="chips">
|
||||
<span v-for="entry in customization" :key="entry" class="chip">{{ entry }}</span>
|
||||
</div>
|
||||
<p v-else class="meta-line">{{ t('common.none') }}</p>
|
||||
</div>
|
||||
<div class="entity-profile-group">
|
||||
<h3 class="section-subtitle">{{ t('pages.items.tags') }}</h3>
|
||||
<EntityChips v-if="item.tags.length" :items="item.tags" />
|
||||
<p v-else class="meta-line">{{ t('common.none') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</DetailSection>
|
||||
</div>
|
||||
|
||||
<DetailSection :title="t('pages.items.acquisitionMethods')">
|
||||
<EntityChips :items="item.acquisitionMethods" />
|
||||
</DetailSection>
|
||||
<div class="detail-grid">
|
||||
<DetailSection :title="t('pages.items.recipeInfo')">
|
||||
<template v-if="item.recipe">
|
||||
<RouterLink class="related-entity-link related-entity-link--compact" :to="`/recipes/${item.recipe.id}`">
|
||||
<span class="related-entity-media related-entity-media--inline" aria-hidden="true">
|
||||
<img v-if="item.recipe.item.image" :src="item.recipe.item.image.url" alt="" loading="lazy" />
|
||||
<Icon v-else :icon="iconItem" class="related-entity-media__icon" aria-hidden="true" />
|
||||
</span>
|
||||
<span>{{ item.recipe.name }}</span>
|
||||
</RouterLink>
|
||||
<EntityChips :items="item.recipe.materials" />
|
||||
</template>
|
||||
<p v-else-if="item.noRecipe" class="meta-line">{{ t('pages.items.noRecipe') }}</p>
|
||||
<template v-else>
|
||||
<p class="meta-line">{{ t('common.none') }}</p>
|
||||
<RouterLink class="ui-button ui-button--primary ui-button--small" :to="`/recipes/new?itemId=${item.id}`">
|
||||
<Icon :icon="iconAdd" class="ui-icon" aria-hidden="true" />
|
||||
{{ t('pages.items.createRecipe') }}
|
||||
</RouterLink>
|
||||
</template>
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection :title="t('pages.items.customization')">
|
||||
<div v-if="customization.length" class="chips">
|
||||
<span v-for="entry in customization" :key="entry" class="chip">{{ entry }}</span>
|
||||
</div>
|
||||
<p v-else class="meta-line">{{ t('common.none') }}</p>
|
||||
</DetailSection>
|
||||
<DetailSection :title="t('pages.items.relatedRecipes')">
|
||||
<ul v-if="item.relatedRecipes.length" class="row-list">
|
||||
<li v-for="recipe in item.relatedRecipes" :key="recipe.id">
|
||||
<RouterLink class="related-entity-link related-entity-link--compact" :to="`/recipes/${recipe.id}`">
|
||||
<span class="related-entity-media related-entity-media--inline" aria-hidden="true">
|
||||
<img v-if="recipe.image" :src="recipe.image.url" alt="" loading="lazy" />
|
||||
<Icon v-else :icon="iconItem" class="related-entity-media__icon" aria-hidden="true" />
|
||||
</span>
|
||||
<span>{{ recipe.name }}</span>
|
||||
</RouterLink>
|
||||
<EntityChips :items="recipe.materials" />
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else class="meta-line">{{ t('common.none') }}</p>
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection :title="t('pages.items.tags')">
|
||||
<EntityChips :items="item.tags" />
|
||||
</DetailSection>
|
||||
<DetailSection :title="t('pages.items.relatedHabitats')">
|
||||
<ul v-if="item.relatedHabitats.length" class="row-list">
|
||||
<li v-for="habitat in item.relatedHabitats" :key="habitat.id">
|
||||
<RouterLink class="related-entity-link related-entity-link--compact" :to="`/habitats/${habitat.id}`">
|
||||
<span class="related-entity-media related-entity-media--inline" aria-hidden="true">
|
||||
<img v-if="habitat.image" :src="habitat.image.url" alt="" loading="lazy" />
|
||||
<Icon v-else :icon="iconHabitat" class="related-entity-media__icon" aria-hidden="true" />
|
||||
</span>
|
||||
<span>{{ habitat.name }}</span>
|
||||
</RouterLink>
|
||||
<EntityChips :items="habitat.recipe" />
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else class="meta-line">{{ t('common.none') }}</p>
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection :title="t('pages.items.recipeInfo')">
|
||||
<template v-if="item.recipe">
|
||||
<RouterLink :to="`/recipes/${item.recipe.id}`">{{ item.recipe.name }}</RouterLink>
|
||||
<EntityChips :items="item.recipe.materials" />
|
||||
</template>
|
||||
<p v-else-if="item.noRecipe" class="meta-line">{{ t('pages.items.noRecipe') }}</p>
|
||||
<template v-else>
|
||||
<p class="meta-line">{{ t('common.none') }}</p>
|
||||
<RouterLink class="ui-button ui-button--primary ui-button--small" :to="`/recipes/new?itemId=${item.id}`">
|
||||
<Icon :icon="iconAdd" class="ui-icon" aria-hidden="true" />
|
||||
{{ t('pages.items.createRecipe') }}
|
||||
</RouterLink>
|
||||
</template>
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection :title="t('pages.items.relatedRecipes')">
|
||||
<ul v-if="item.relatedRecipes.length" class="row-list">
|
||||
<li v-for="recipe in item.relatedRecipes" :key="recipe.id">
|
||||
<RouterLink :to="`/recipes/${recipe.id}`">{{ recipe.name }}</RouterLink>
|
||||
<EntityChips :items="recipe.materials" />
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else class="meta-line">{{ t('common.none') }}</p>
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection :title="t('pages.items.relatedHabitats')">
|
||||
<ul v-if="item.relatedHabitats.length" class="row-list">
|
||||
<li v-for="habitat in item.relatedHabitats" :key="habitat.id">
|
||||
<RouterLink :to="`/habitats/${habitat.id}`">{{ habitat.name }}</RouterLink>
|
||||
<EntityChips :items="habitat.recipe" />
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else class="meta-line">{{ t('common.none') }}</p>
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection :title="t('pages.items.pokemonDrops')">
|
||||
<ul v-if="item.droppedByPokemon.length" class="row-list">
|
||||
<li v-for="entry in item.droppedByPokemon" :key="`${entry.pokemon.id}-${entry.skill.id}`">
|
||||
<RouterLink :to="`/pokemon/${entry.pokemon.id}`">#{{ entry.pokemon.id }} {{ entry.pokemon.name }}</RouterLink>
|
||||
<span>{{ t('pages.pokemon.skillDrop', { name: entry.skill.name }) }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else class="meta-line">{{ t('common.none') }}</p>
|
||||
</DetailSection>
|
||||
<DetailSection :title="t('pages.items.pokemonDrops')">
|
||||
<ul v-if="item.droppedByPokemon.length" class="row-list">
|
||||
<li v-for="entry in item.droppedByPokemon" :key="`${entry.pokemon.id}-${entry.skill.id}`">
|
||||
<RouterLink class="related-entity-link related-entity-link--compact" :to="`/pokemon/${entry.pokemon.id}`">
|
||||
<span class="related-entity-media related-entity-media--inline related-entity-media--pokemon" aria-hidden="true">
|
||||
<img v-if="entry.pokemon.image" :src="entry.pokemon.image.url" alt="" loading="lazy" />
|
||||
<PokeBallMark v-else size="22px" />
|
||||
</span>
|
||||
<span>#{{ entry.pokemon.id }} {{ entry.pokemon.name }}</span>
|
||||
</RouterLink>
|
||||
<span>{{ t('pages.pokemon.skillDrop', { name: entry.skill.name }) }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else class="meta-line">{{ t('common.none') }}</p>
|
||||
</DetailSection>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="detailTab === 'discussion'" class="detail-tab-panel">
|
||||
|
||||
@@ -9,15 +9,16 @@ import EntityDiscussionPanel from '../components/EntityDiscussionPanel.vue';
|
||||
import EntityChips from '../components/EntityChips.vue';
|
||||
import Modal from '../components/Modal.vue';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import PokeBallMark from '../components/PokeBallMark.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';
|
||||
import { iconBack, iconEdit, iconHabitat, iconItem } from '../icons';
|
||||
import { api, type PokemonDetail } from '../services/api';
|
||||
import PokemonEdit from './PokemonEdit.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const { locale, t } = useI18n();
|
||||
const { t } = useI18n();
|
||||
const pokemon = ref<PokemonDetail | null>(null);
|
||||
const itemCategoryTab = ref('');
|
||||
const relatedHabitatTab = ref('');
|
||||
@@ -30,6 +31,7 @@ const relatedPokemonLimit = 6;
|
||||
type HabitatRow = {
|
||||
id: number;
|
||||
name: string;
|
||||
image: PokemonDetail['habitats'][number]['image'];
|
||||
timeOfDays: string[];
|
||||
weathers: string[];
|
||||
rarity: number;
|
||||
@@ -78,6 +80,7 @@ const habitatRows = computed<HabitatRow[]>(() => {
|
||||
{
|
||||
id: number;
|
||||
name: string;
|
||||
image: PokemonDetail['habitats'][number]['image'];
|
||||
timeOfDays: Set<string>;
|
||||
weathers: Set<string>;
|
||||
rarity: number;
|
||||
@@ -90,6 +93,7 @@ const habitatRows = computed<HabitatRow[]>(() => {
|
||||
const row = rows.get(key) ?? {
|
||||
id: habitat.id,
|
||||
name: habitat.name,
|
||||
image: habitat.image,
|
||||
timeOfDays: new Set<string>(),
|
||||
weathers: new Set<string>(),
|
||||
rarity: habitat.rarity,
|
||||
@@ -105,6 +109,7 @@ const habitatRows = computed<HabitatRow[]>(() => {
|
||||
return [...rows.values()].map((row) => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
image: row.image,
|
||||
timeOfDays: sortByOrder(row.timeOfDays, timeOfDays),
|
||||
weathers: sortByOrder(row.weathers, weathers),
|
||||
rarity: row.rarity,
|
||||
@@ -202,17 +207,6 @@ function pokemonImageLabel() {
|
||||
return pokemon.value.image.source === 'upload' ? t('media.uploadedImage') : `${pokemon.value.image.version} - ${pokemon.value.image.variant}`;
|
||||
}
|
||||
|
||||
function imageFileName(path: string): string {
|
||||
return path.split('/').at(-1) ?? t('media.image');
|
||||
}
|
||||
|
||||
function formatDateTime(value: string): string {
|
||||
return new Intl.DateTimeFormat(locale.value, {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short'
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
function openImageModal() {
|
||||
imageModalOpen.value = true;
|
||||
}
|
||||
@@ -328,7 +322,7 @@ watch(
|
||||
<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" :class="{ 'pokemon-profile-grid--with-image': pokemon.image }">
|
||||
<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>
|
||||
@@ -371,7 +365,7 @@ watch(
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pokemon-profile-side" :class="{ 'pokemon-profile-side--with-image': pokemon.image }">
|
||||
<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>
|
||||
@@ -379,6 +373,9 @@ watch(
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -390,7 +387,13 @@ watch(
|
||||
<ul class="row-list skill-drop-summary">
|
||||
<li v-for="skill in skillDropRows" :key="skill.id">
|
||||
<span>{{ t('pages.pokemon.skillDrop', { name: skill.name }) }}</span>
|
||||
<RouterLink v-if="skill.itemDrop" :to="`/items/${skill.itemDrop.id}`">{{ skill.itemDrop.name }}</RouterLink>
|
||||
<RouterLink v-if="skill.itemDrop" class="related-entity-link related-entity-link--compact" :to="`/items/${skill.itemDrop.id}`">
|
||||
<span class="related-entity-media related-entity-media--inline" aria-hidden="true">
|
||||
<img v-if="skill.itemDrop.image" :src="skill.itemDrop.image.url" alt="" loading="lazy" />
|
||||
<Icon v-else :icon="iconItem" class="related-entity-media__icon" aria-hidden="true" />
|
||||
</span>
|
||||
<span>{{ skill.itemDrop.name }}</span>
|
||||
</RouterLink>
|
||||
</li>
|
||||
</ul>
|
||||
</DetailSection>
|
||||
@@ -411,36 +414,42 @@ watch(
|
||||
/>
|
||||
<ul v-if="relatedPokemonRows.length" class="row-list related-pokemon-list">
|
||||
<li v-for="related in relatedPokemonRows" :key="related.id">
|
||||
<div class="related-pokemon-row">
|
||||
<div class="related-pokemon-row__summary">
|
||||
<RouterLink class="related-pokemon-row__name" :to="`/pokemon/${related.id}`">#{{ related.id }} {{ related.name }}</RouterLink>
|
||||
<div class="related-pokemon-row__traits">
|
||||
<EntityChips
|
||||
v-if="related.skills.length"
|
||||
class="related-pokemon-row__skills"
|
||||
:items="related.skills"
|
||||
/>
|
||||
<div class="related-pokemon-list-item">
|
||||
<span class="related-entity-media related-entity-media--pokemon" aria-hidden="true">
|
||||
<img v-if="related.image" :src="related.image.url" alt="" loading="lazy" />
|
||||
<PokeBallMark v-else size="24px" />
|
||||
</span>
|
||||
<div class="related-pokemon-row">
|
||||
<div class="related-pokemon-row__summary">
|
||||
<RouterLink class="related-pokemon-row__name" :to="`/pokemon/${related.id}`">#{{ related.id }} {{ related.name }}</RouterLink>
|
||||
<div class="related-pokemon-row__traits">
|
||||
<EntityChips
|
||||
v-if="related.skills.length"
|
||||
class="related-pokemon-row__skills"
|
||||
:items="related.skills"
|
||||
/>
|
||||
<span
|
||||
class="chip related-pokemon-row__environment"
|
||||
:class="{ 'related-pokemon-row__environment--match': related.environment.id === pokemon.environment.id }"
|
||||
>
|
||||
{{ related.environment.name }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="related.favorite_things.length"
|
||||
class="chips related-pokemon-row__favourites"
|
||||
>
|
||||
<span
|
||||
class="chip related-pokemon-row__environment"
|
||||
:class="{ 'related-pokemon-row__environment--match': related.environment.id === pokemon.environment.id }"
|
||||
v-for="thing in related.favorite_things"
|
||||
:key="thing.id"
|
||||
class="chip related-favourite-chip"
|
||||
:class="{ 'related-favourite-chip--match': thing.matches }"
|
||||
>
|
||||
{{ related.environment.name }}
|
||||
{{ thing.name }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="related.favorite_things.length"
|
||||
class="chips related-pokemon-row__favourites"
|
||||
>
|
||||
<span
|
||||
v-for="thing in related.favorite_things"
|
||||
:key="thing.id"
|
||||
class="chip related-favourite-chip"
|
||||
:class="{ 'related-favourite-chip--match': thing.matches }"
|
||||
>
|
||||
{{ thing.name }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -460,7 +469,13 @@ watch(
|
||||
/>
|
||||
<ul v-if="favoriteThingItems.length" class="row-list">
|
||||
<li v-for="item in favoriteThingItems" :key="item.id">
|
||||
<RouterLink :to="`/items/${item.id}`">{{ item.name }}</RouterLink>
|
||||
<RouterLink class="related-entity-link related-entity-link--compact" :to="`/items/${item.id}`">
|
||||
<span class="related-entity-media related-entity-media--inline" aria-hidden="true">
|
||||
<img v-if="item.image" :src="item.image.url" alt="" loading="lazy" />
|
||||
<Icon v-else :icon="iconItem" class="related-entity-media__icon" aria-hidden="true" />
|
||||
</span>
|
||||
<span>{{ item.name }}</span>
|
||||
</RouterLink>
|
||||
<EntityChips :items="item.tags" />
|
||||
</li>
|
||||
</ul>
|
||||
@@ -471,8 +486,12 @@ watch(
|
||||
</div>
|
||||
|
||||
<DetailSection :title="t('pages.pokemon.habitats')">
|
||||
<ul class="row-list appearance-list">
|
||||
<ul class="row-list appearance-list appearance-list--with-media">
|
||||
<li v-for="habitat in habitatRows" :key="`${habitat.id}-${habitat.rarity}`">
|
||||
<span class="related-entity-media related-entity-media--appearance" aria-hidden="true">
|
||||
<img v-if="habitat.image" :src="habitat.image.url" alt="" loading="lazy" />
|
||||
<Icon v-else :icon="iconHabitat" class="related-entity-media__icon" aria-hidden="true" />
|
||||
</span>
|
||||
<RouterLink class="appearance-name" :to="`/habitats/${habitat.id}`">{{ habitat.name }}</RouterLink>
|
||||
<dl class="appearance-summary">
|
||||
<div>
|
||||
@@ -523,13 +542,6 @@ watch(
|
||||
<strong>{{ pokemonImageLabel() }}</strong>
|
||||
<span v-if="pokemon.image.style">{{ pokemon.image.style }}</span>
|
||||
<p v-if="pokemon.image.description">{{ pokemon.image.description }}</p>
|
||||
<div v-if="pokemon.imageHistory.length" class="image-history-list" :aria-label="t('media.imageHistory')">
|
||||
<div v-for="image in pokemon.imageHistory" :key="image.path" class="image-history-list__item">
|
||||
<img :src="image.url" :alt="t('media.imageAlt', { name: pokemon.name })" loading="lazy" />
|
||||
<span>{{ imageFileName(image.path) }}</span>
|
||||
<span>{{ formatDateTime(image.uploadedAt) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -10,7 +10,7 @@ import EntityChips from '../components/EntityChips.vue';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import Skeleton from '../components/Skeleton.vue';
|
||||
import Tabs, { type TabOption } from '../components/Tabs.vue';
|
||||
import { iconBack, iconEdit } from '../icons';
|
||||
import { iconBack, iconEdit, iconRecipe } from '../icons';
|
||||
import { api, type RecipeDetail } from '../services/api';
|
||||
import RecipeEdit from './RecipeEdit.vue';
|
||||
|
||||
@@ -24,6 +24,20 @@ const detailTabs = computed<TabOption[]>(() => [
|
||||
{ value: 'discussion', label: t('discussion.title') },
|
||||
{ value: 'history', label: t('history.editHistory') }
|
||||
]);
|
||||
const recipeSubtitle = computed(() => {
|
||||
if (!recipe.value) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const categoryName = recipe.value.item.category?.name;
|
||||
const usageName = recipe.value.item.usage?.name;
|
||||
|
||||
if (categoryName && usageName) {
|
||||
return `${categoryName} · ${usageName}`;
|
||||
}
|
||||
|
||||
return categoryName ?? t('pages.recipes.detailSubtitle');
|
||||
});
|
||||
|
||||
async function loadRecipeDetail() {
|
||||
recipe.value = await api.recipeDetail(String(route.params.id));
|
||||
@@ -80,8 +94,8 @@ watch(
|
||||
</div>
|
||||
</section>
|
||||
<section v-else class="page-stack">
|
||||
<PageHeader :title="recipe.name" :subtitle="t('pages.recipes.detailSubtitle')">
|
||||
<template #kicker>Recipe Detail</template>
|
||||
<PageHeader :title="recipe.name" :subtitle="recipeSubtitle">
|
||||
<template #kicker>{{ t('pages.recipes.detailKicker') }}</template>
|
||||
<template #actions>
|
||||
<RouterLink class="ui-button ui-button--primary ui-button--small" :to="`/recipes/${recipe.id}/edit`">
|
||||
<Icon :icon="iconEdit" class="ui-icon" aria-hidden="true" />
|
||||
@@ -97,14 +111,60 @@ watch(
|
||||
<div class="detail-tabs">
|
||||
<Tabs id="recipe-detail-tabs" v-model="detailTab" :tabs="detailTabs" :label="t('common.details')" />
|
||||
|
||||
<div v-if="detailTab === 'details'" class="detail-grid">
|
||||
<DetailSection :title="t('pages.items.acquisitionMethods')">
|
||||
<EntityChips :items="recipe.acquisition_methods" />
|
||||
</DetailSection>
|
||||
<div v-if="detailTab === 'details'" class="detail-grid detail-grid--stack">
|
||||
<div class="entity-profile-grid">
|
||||
<section class="detail-section entity-profile-media-section" :aria-label="t('pages.recipes.item')">
|
||||
<div class="entity-detail-image">
|
||||
<RouterLink
|
||||
class="entity-detail-image__frame entity-detail-image__frame--link"
|
||||
:class="{ 'entity-detail-image__frame--placeholder': !recipe.item.image }"
|
||||
:to="`/items/${recipe.item.id}`"
|
||||
>
|
||||
<img v-if="recipe.item.image" :src="recipe.item.image.url" :alt="t('media.imageAlt', { name: recipe.item.name })" />
|
||||
<span v-else class="entity-card__mark entity-detail-image__mark" role="img" :aria-label="t('media.imageEmpty')">
|
||||
<Icon :icon="iconRecipe" class="entity-card__icon" aria-hidden="true" />
|
||||
</span>
|
||||
</RouterLink>
|
||||
<RouterLink class="entity-profile-title-link" :to="`/items/${recipe.item.id}`">{{ recipe.item.name }}</RouterLink>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<DetailSection :title="t('pages.recipes.materials')">
|
||||
<EntityChips :items="recipe.materials" />
|
||||
</DetailSection>
|
||||
<div class="entity-profile-main">
|
||||
<section class="detail-section entity-profile-overview" :aria-label="t('common.details')">
|
||||
<dl class="entity-profile-facts">
|
||||
<div>
|
||||
<dt>{{ t('pages.items.category') }}</dt>
|
||||
<dd>{{ recipe.item.category?.name ?? t('common.none') }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ t('pages.items.usage') }}</dt>
|
||||
<dd>{{ recipe.item.usage?.name ?? t('common.none') }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ t('pages.items.acquisitionMethods') }}</dt>
|
||||
<dd>{{ recipe.acquisition_methods.length }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ t('pages.recipes.materials') }}</dt>
|
||||
<dd>{{ recipe.materials.length }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div class="entity-profile-groups">
|
||||
<div class="entity-profile-group">
|
||||
<h3 class="section-subtitle">{{ t('pages.items.acquisitionMethods') }}</h3>
|
||||
<EntityChips v-if="recipe.acquisition_methods.length" :items="recipe.acquisition_methods" />
|
||||
<p v-else class="meta-line">{{ t('common.none') }}</p>
|
||||
</div>
|
||||
<div class="entity-profile-group">
|
||||
<h3 class="section-subtitle">{{ t('pages.recipes.materials') }}</h3>
|
||||
<EntityChips v-if="recipe.materials.length" :items="recipe.materials" />
|
||||
<p v-else class="meta-line">{{ t('common.none') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="detailTab === 'discussion'" class="detail-tab-panel">
|
||||
|
||||
Reference in New Issue
Block a user