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 { 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">
|
||||
|
||||
Reference in New Issue
Block a user