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:
2026-05-03 09:51:45 +08:00
parent 784cbdacd1
commit 4d05618530
10 changed files with 713 additions and 240 deletions

View File

@@ -319,19 +319,19 @@ Pokemon 列表功能:
Pokemon 详情页展示:
- 基本信息
- 已配置图片时,详情主内容在六维 Stats 右侧展示正方形居中的 Pokédex 风格图片;页面内不直接展示图片版本、状态或描述,用户可通过图片 Modal 查看详情;未配置图片时不显示图片区
- 详情主内容在六维 Stats 右侧始终保留正方形图片区;已配置图片时展示居中的 Pokédex 风格图片,未配置图片时展示默认 Poké Ball 占位符;页面内不直接展示图片版本、状态或描述,用户可通过图片 Modal 查看详情。
- 主内容顶部按以下布局展示:
- 左上Genus & Details无区块标题如有 Genus先展示 Genus再以分割线连接 Details 内容
- 左下Height / Weight 与 Types 按 2:1 比例并排Height / Weight 无区块标题,在 Dimension 区内左右并排展示并以中间分割线隔开每组按英制、分割线、公制、标签上下排列Types 不显示 Type 1 / Type 2 文案,上下布局并居中展示
- 右侧:六维 Stats已配置图片时图片展示在 Stats 右侧
- 右侧:六维 Stats图片或默认占位符展示在 Stats 右侧
- 六维使用 ProgressBar 展示,最大值按 150 计算。
- 特长
- 特长掉落物品
- 特长掉落物品:展示掉落物品图标;未配置图标时显示默认物品标记占位符
- 喜欢的环境
- 喜欢的东西
- 相关 Pokemon与关联喜欢的东西的物品在桌面端左右并排展示按相同喜欢的环境优先其次按共同喜欢的东西数量从多到少排序支持按喜欢的环境筛选默认筛选当前 Pokemon 的喜欢的环境,也可切换到其他喜欢的环境或全部;每个筛选视图最多展示 6 个;每项第一行左侧展示名称,右侧展示特长和喜欢的环境,第二行展示喜欢的东西,并高亮共同喜欢的东西
- 关联喜欢的东西的物品:与相关 Pokemon 在桌面端左右并排展示
- 出现的栖息地
- 相关 Pokemon与关联喜欢的东西的物品在桌面端左右并排展示按相同喜欢的环境优先其次按共同喜欢的东西数量从多到少排序支持按喜欢的环境筛选默认筛选当前 Pokemon 的喜欢的环境,也可切换到其他喜欢的环境或全部;每个筛选视图最多展示 6 个;每项左侧展示 Pokemon 图片或默认 Poké Ball 占位符,原列表项信息布局保持不变,第一行左侧展示名称,右侧展示特长和喜欢的环境,第二行展示喜欢的东西,并高亮共同喜欢的东西
- 关联喜欢的东西的物品:与相关 Pokemon 在桌面端左右并排展示;每项展示物品图标,未配置图标时显示默认物品标记占位符
- 出现的栖息地:每行左侧展示栖息地图片,未配置图片时显示默认栖息地标记占位符
- 最后编辑信息
- 讨论
- 编辑历史:通过详情页 Tabs 展示
@@ -368,16 +368,17 @@ Pokemon 详情页展示:
物品详情页展示:
- 基本信息
- 图标图片和图片上传历史
- 当前图标图片;未配置图标时展示默认物品标记占位符
- 顶部按图标 / 占位符与核心信息概览并排展示,移动端改为单列;顶部概览卡片不显示 `Image` / `Details` 通用区块标题,也不展示图片历史缩略图
- 分类
- 用途
- 入手方式
- 客制化
- 标签
- 关联材料单
- 作为材料出现的材料单
- 相关栖息地
- 相关 Pokemon 掉落
- 关联材料单:展示结果物品图标和材料物品图标;未配置图标时显示默认物品标记占位符
- 作为材料出现的材料单:展示结果物品图标和材料物品图标;未配置图标时显示默认物品标记占位符
- 相关栖息地:展示栖息地图片或默认栖息地标记占位符,并展示配方材料物品图标
- 相关 Pokemon 掉落:展示 Pokemon 图片;未配置图片时显示默认 Poké Ball 占位符
- 最后编辑信息
- 讨论
- 编辑历史
@@ -409,9 +410,10 @@ Pokemon 详情页展示:
材料单详情页展示:
- 结果物品
- 结果物品图片或默认材料单标记占位符;顶部概览卡片不显示 `Image` / `Details` 通用区块标题
- 结果物品名称、分类和用途;`GET /api/recipes/:id``item` 字段返回展示所需的 `id``name``image``category``usage`
- 入手方式
- 需要材料列表
- 需要材料列表:展示材料物品图标;未配置图标时显示默认物品标记占位符
- 最后编辑信息
- 讨论
- 编辑历史
@@ -450,9 +452,10 @@ Pokemon 出现配置:
栖息地详情页展示:
- 图片和图片上传历史
- 配方列表
- 可能出现的 Pokemon 列表
- 当前图片;未配置图片时展示默认栖息地标记占位符
- 顶部按图片 / 占位符与核心信息概览并排展示,移动端改为单列;顶部概览卡片不显示 `Image` / `Details` 通用区块标题,也不展示图片历史缩略图
- 配方列表:展示材料物品图标;未配置图标时显示默认物品标记占位符
- 可能出现的 Pokemon 列表:展示 Pokemon 图片;未配置图片时显示默认 Poké Ball 占位符
- 出现时间
- 出现天气
- 稀有度

View File

@@ -386,6 +386,32 @@ function uploadedImageJson(pathExpression: string): string {
`;
}
function pokemonImageJson(alias: string): string {
return `
CASE
WHEN ${alias}.image_path LIKE '/sprites/%' THEN json_build_object(
'path', ${alias}.image_path,
'url', ${sqlLiteral(pokemonSpriteBaseUrl)} || ${alias}.image_path,
'style', ${alias}.image_style,
'version', ${alias}.image_version,
'variant', ${alias}.image_variant,
'description', ${alias}.image_description,
'source', 'sprite'
)
WHEN ${alias}.image_path <> '' THEN json_build_object(
'path', ${alias}.image_path,
'url', ${sqlLiteral(uploadPublicBaseUrl)} || ${alias}.image_path,
'style', 'Upload',
'version', 'Community upload',
'variant', ${alias}.name,
'description', '',
'source', 'upload'
)
ELSE NULL
END
`;
}
function imagePathLabel(path: string | null | undefined): string {
const cleanPath = path?.trim() ?? '';
if (cleanPath === '') {
@@ -1816,27 +1842,7 @@ function pokemonProjection(locale: string): string {
round((p.height_inches * 0.0254)::numeric, 2)::double precision AS "heightMeters",
p.weight_pounds AS "weightPounds",
round((p.weight_pounds * 0.45359237)::numeric, 2)::double precision AS "weightKg",
CASE
WHEN p.image_path LIKE '/sprites/%' THEN json_build_object(
'path', p.image_path,
'url', '${pokemonSpriteBaseUrl}' || p.image_path,
'style', p.image_style,
'version', p.image_version,
'variant', p.image_variant,
'description', p.image_description,
'source', 'sprite'
)
WHEN p.image_path <> '' THEN json_build_object(
'path', p.image_path,
'url', ${sqlLiteral(uploadPublicBaseUrl)} || p.image_path,
'style', 'Upload',
'version', 'Community upload',
'variant', p.name,
'description', '',
'source', 'upload'
)
ELSE NULL
END AS image,
${pokemonImageJson('p')} AS image,
json_build_object(
'hp', p.hp,
'attack', p.attack,
@@ -3037,6 +3043,7 @@ export async function getPokemon(id: number, locale = defaultLocale) {
SELECT
h.id,
${habitatName} AS name,
${uploadedImageJson('h.image_path')} AS image,
hp.time_of_day,
hp.weather,
hp.rarity,
@@ -3049,9 +3056,9 @@ export async function getPokemon(id: number, locale = defaultLocale) {
`,
[id]
),
query<{ skillId: number; id: number; name: string }>(
query<{ skillId: number; id: number; name: string; image: EntityImageValue | null }>(
`
SELECT psid.skill_id AS "skillId", i.id, ${itemName} AS name
SELECT psid.skill_id AS "skillId", i.id, ${itemName} AS name, ${uploadedImageJson('i.image_path')} AS image
FROM pokemon_skill_item_drops psid
JOIN skills s ON s.id = psid.skill_id
JOIN items i ON i.id = psid.item_id
@@ -3066,6 +3073,7 @@ export async function getPokemon(id: number, locale = defaultLocale) {
SELECT
i.id,
${itemName} AS name,
${uploadedImageJson('i.image_path')} AS image,
json_build_object('id', c.id, 'name', ${categoryName}) AS category,
json_agg(json_build_object('id', ft.id, 'name', ${tagName}) ORDER BY ${orderByEntity('ft')}) AS tags
FROM pokemon_favorite_things pft
@@ -3074,7 +3082,7 @@ export async function getPokemon(id: number, locale = defaultLocale) {
JOIN items i ON i.id = ift.item_id
JOIN item_categories c ON c.id = i.category_id
WHERE pft.pokemon_id = $1
GROUP BY i.id, i.name, i.sort_order, c.id, c.name, c.sort_order
GROUP BY i.id, i.name, i.image_path, i.sort_order, c.id, c.name, c.sort_order
ORDER BY ${orderByEntity('c')}, ${orderByEntity('i')}
`,
[id]
@@ -3110,6 +3118,7 @@ export async function getPokemon(id: number, locale = defaultLocale) {
SELECT
related_pokemon.id,
${relatedPokemonName} AS name,
${pokemonImageJson('related_pokemon')} AS image,
json_build_object('id', related_environment.id, 'name', ${relatedEnvironmentName}) AS environment,
COALESCE((
SELECT json_agg(
@@ -3153,9 +3162,9 @@ export async function getPokemon(id: number, locale = defaultLocale) {
]);
const dropsBySkill = itemDrops.reduce((itemsBySkill, item) => {
itemsBySkill.set(item.skillId, { id: item.id, name: item.name });
itemsBySkill.set(item.skillId, { id: item.id, name: item.name, image: item.image });
return itemsBySkill;
}, new Map<number, { id: number; name: string }>());
}, new Map<number, { id: number; name: string; image: EntityImageValue | null }>());
const skills = Array.isArray(pokemon.skills)
? pokemon.skills.map((skill: { id: number; name: string }) => ({
@@ -3464,7 +3473,15 @@ export async function getHabitat(id: number, locale = defaultLocale) {
${auditSelect('h', 'habitat_created_user', 'habitat_updated_user')},
${uploadedImageJson('h.image_path')} AS image,
COALESCE((
SELECT json_agg(json_build_object('id', i.id, 'name', ${itemName}, 'quantity', hri.quantity) ORDER BY ${orderByEntity('i')})
SELECT json_agg(
json_build_object(
'id', i.id,
'name', ${itemName},
'image', ${uploadedImageJson('i.image_path')},
'quantity', hri.quantity
)
ORDER BY ${orderByEntity('i')}
)
FROM habitat_recipe_items hri
JOIN items i ON i.id = hri.item_id
WHERE hri.habitat_id = h.id
@@ -3486,6 +3503,7 @@ export async function getHabitat(id: number, locale = defaultLocale) {
SELECT
p.id,
${pokemonName} AS name,
${pokemonImageJson('p')} AS image,
hp.time_of_day,
hp.weather,
hp.rarity,
@@ -3733,6 +3751,8 @@ export async function getItem(id: number, locale = defaultLocale) {
const acquisitionMethodName = localizedName('acquisition-methods', 'am', locale);
const resultItemName = localizedName('items', 'result_item', locale);
const resultItemCategoryName = localizedName('item-categories', 'result_category', locale);
const resultItemUsageName = localizedName('item-usages', 'result_usage', locale);
const materialItemName = localizedName('items', 'mi', locale);
const habitatName = localizedName('habitats', 'h', locale);
const recipeItemName = localizedName('items', 'recipe_item', locale);
@@ -3763,14 +3783,33 @@ export async function getItem(id: number, locale = defaultLocale) {
WHERE ram.recipe_id = r.id
), '[]'::json) AS acquisition_methods,
COALESCE((
SELECT json_agg(json_build_object('id', mi.id, 'name', ${materialItemName}, 'quantity', rm.quantity) ORDER BY ${orderByEntity('mi')})
SELECT json_agg(
json_build_object(
'id', mi.id,
'name', ${materialItemName},
'image', ${uploadedImageJson('mi.image_path')},
'quantity', rm.quantity
)
ORDER BY ${orderByEntity('mi')}
)
FROM recipe_materials rm
JOIN items mi ON mi.id = rm.item_id
WHERE rm.recipe_id = r.id
), '[]'::json) AS materials,
json_build_object('id', result_item.id, 'name', ${resultItemName}) AS item
json_build_object(
'id', result_item.id,
'name', ${resultItemName},
'image', ${uploadedImageJson('result_item.image_path')},
'category', json_build_object('id', result_category.id, 'name', ${resultItemCategoryName}),
'usage', CASE
WHEN result_usage.id IS NULL THEN NULL
ELSE json_build_object('id', result_usage.id, 'name', ${resultItemUsageName})
END
) AS item
FROM recipes r
JOIN items result_item ON result_item.id = r.item_id
JOIN item_categories result_category ON result_category.id = result_item.category_id
LEFT JOIN item_usages result_usage ON result_usage.id = result_item.usage_id
${auditJoins('r', 'recipe_created_user', 'recipe_updated_user')}
WHERE r.item_id = $1
`,
@@ -3781,8 +3820,17 @@ export async function getItem(id: number, locale = defaultLocale) {
SELECT
r.id,
${resultItemName} AS name,
${uploadedImageJson('result_item.image_path')} AS image,
COALESCE((
SELECT json_agg(json_build_object('id', mi.id, 'name', ${materialItemName}, 'quantity', recipe_material.quantity) ORDER BY ${orderByEntity('mi')})
SELECT json_agg(
json_build_object(
'id', mi.id,
'name', ${materialItemName},
'image', ${uploadedImageJson('mi.image_path')},
'quantity', recipe_material.quantity
)
ORDER BY ${orderByEntity('mi')}
)
FROM recipe_materials recipe_material
JOIN items mi ON mi.id = recipe_material.item_id
WHERE recipe_material.recipe_id = r.id
@@ -3800,8 +3848,17 @@ export async function getItem(id: number, locale = defaultLocale) {
SELECT
h.id,
${habitatName} AS name,
${uploadedImageJson('h.image_path')} AS image,
COALESCE((
SELECT json_agg(json_build_object('id', recipe_item.id, 'name', ${recipeItemName}, 'quantity', recipe_item_row.quantity) ORDER BY ${orderByEntity('recipe_item')})
SELECT json_agg(
json_build_object(
'id', recipe_item.id,
'name', ${recipeItemName},
'image', ${uploadedImageJson('recipe_item.image_path')},
'quantity', recipe_item_row.quantity
)
ORDER BY ${orderByEntity('recipe_item')}
)
FROM habitat_recipe_items recipe_item_row
JOIN items recipe_item ON recipe_item.id = recipe_item_row.item_id
WHERE recipe_item_row.habitat_id = h.id
@@ -3816,7 +3873,7 @@ export async function getItem(id: number, locale = defaultLocale) {
query(
`
SELECT
json_build_object('id', p.id, 'name', ${pokemonName}) AS pokemon,
json_build_object('id', p.id, 'name', ${pokemonName}, 'image', ${pokemonImageJson('p')}) AS pokemon,
json_build_object('id', s.id, 'name', ${skillName}) AS skill
FROM pokemon_skill_item_drops psid
JOIN pokemon p ON p.id = psid.pokemon_id
@@ -4025,6 +4082,8 @@ export async function listRecipes(paramsQuery: QueryParams = {}, locale = defaul
export async function getRecipe(id: number, locale = defaultLocale) {
const resultItemName = localizedName('items', 'result_item', locale);
const resultItemCategoryName = localizedName('item-categories', 'result_category', locale);
const resultItemUsageName = localizedName('item-usages', 'result_usage', locale);
const acquisitionMethodName = localizedName('acquisition-methods', 'am', locale);
const materialItemName = localizedName('items', 'i', locale);
@@ -4041,14 +4100,33 @@ export async function getRecipe(id: number, locale = defaultLocale) {
WHERE ram.recipe_id = r.id
), '[]'::json) AS acquisition_methods,
COALESCE((
SELECT json_agg(json_build_object('id', i.id, 'name', ${materialItemName}, 'quantity', rm.quantity) ORDER BY ${orderByEntity('i')})
SELECT json_agg(
json_build_object(
'id', i.id,
'name', ${materialItemName},
'image', ${uploadedImageJson('i.image_path')},
'quantity', rm.quantity
)
ORDER BY ${orderByEntity('i')}
)
FROM recipe_materials rm
JOIN items i ON i.id = rm.item_id
WHERE rm.recipe_id = r.id
), '[]'::json) AS materials,
json_build_object('id', result_item.id, 'name', ${resultItemName}) AS item
json_build_object(
'id', result_item.id,
'name', ${resultItemName},
'image', ${uploadedImageJson('result_item.image_path')},
'category', json_build_object('id', result_category.id, 'name', ${resultItemCategoryName}),
'usage', CASE
WHEN result_usage.id IS NULL THEN NULL
ELSE json_build_object('id', result_usage.id, 'name', ${resultItemUsageName})
END
) AS item
FROM recipes r
JOIN items result_item ON result_item.id = r.item_id
JOIN item_categories result_category ON result_category.id = result_item.category_id
LEFT JOIN item_usages result_usage ON result_usage.id = result_item.usage_id
${auditJoins('r', 'recipe_created_user', 'recipe_updated_user')}
WHERE r.id = $1
`,

View File

@@ -1,14 +1,29 @@
<script setup lang="ts">
import type { NamedEntity } from '../services/api';
import { Icon } from '@iconify/vue';
import { iconItem } from '../icons';
import type { EntityImage, NamedEntity, PokemonImage } from '../services/api';
type ChipItem = NamedEntity & {
image?: EntityImage | PokemonImage | null;
quantity?: number;
};
defineProps<{
items: Array<NamedEntity & { quantity?: number }>;
items: ChipItem[];
}>();
function hasImageSlot(item: ChipItem) {
return Object.prototype.hasOwnProperty.call(item, 'image');
}
</script>
<template>
<div class="chips">
<span v-for="item in items" :key="`${item.id}-${item.name}`" class="chip">
<span v-for="item in items" :key="`${item.id}-${item.name}`" class="chip" :class="{ 'chip--with-media': hasImageSlot(item) }">
<span v-if="hasImageSlot(item)" class="chip__media" aria-hidden="true">
<img v-if="item.image" :src="item.image.url" alt="" loading="lazy" />
<Icon v-else :icon="iconItem" class="chip__icon" aria-hidden="true" />
</span>
{{ item.name }}<span v-if="item.quantity"> × {{ item.quantity }}</span>
</span>
</div>

View File

@@ -128,20 +128,22 @@ export interface Pokemon extends EditInfo {
export interface RelatedPokemon {
id: number;
name: string;
image?: PokemonImage | null;
environment: NamedEntity;
skills: Skill[];
favorite_things: Array<NamedEntity & { matches: boolean }>;
}
export interface PokemonDetail extends Pokemon {
skills: Array<Skill & { itemDrop: NamedEntity | null }>;
favoriteThingItems: Array<NamedEntity & { category: NamedEntity; tags: NamedEntity[] }>;
skills: Array<Skill & { itemDrop: (NamedEntity & { image?: EntityImage | null }) | null }>;
favoriteThingItems: Array<NamedEntity & { image?: EntityImage | null; category: NamedEntity; tags: NamedEntity[] }>;
relatedPokemon: RelatedPokemon[];
editHistory: EditHistoryEntry[];
imageHistory: EntityImageUpload[];
habitats: Array<{
id: number;
name: string;
image?: EntityImage | null;
time_of_day: string;
weather: string;
rarity: number;
@@ -155,7 +157,7 @@ export interface Habitat extends EditInfo {
baseName?: string;
translations?: TranslationMap;
image: EntityImage | null;
recipe: Array<NamedEntity & { quantity: number }>;
recipe: Array<NamedEntity & { image?: EntityImage | null; quantity: number }>;
pokemon?: NamedEntity[];
}
@@ -163,6 +165,7 @@ export interface HabitatDetail extends Habitat {
editHistory: EditHistoryEntry[];
imageHistory: EntityImageUpload[];
pokemon: Array<NamedEntity & {
image?: PokemonImage | null;
time_of_day: string;
weather: string;
rarity: number;
@@ -177,13 +180,21 @@ export interface RecipeSummary extends EditInfo {
export interface RecipeUsage {
id: number;
name: string;
materials: Array<NamedEntity & { quantity: number }>;
image?: EntityImage | null;
materials: Array<NamedEntity & { image?: EntityImage | null; quantity: number }>;
}
export interface HabitatUsage {
id: number;
name: string;
recipe: Array<NamedEntity & { quantity: number }>;
image?: EntityImage | null;
recipe: Array<NamedEntity & { image?: EntityImage | null; quantity: number }>;
}
export interface RecipeResultItem extends NamedEntity {
image?: EntityImage | null;
category?: NamedEntity;
usage?: NamedEntity | null;
}
export interface Item extends EditInfo {
@@ -212,7 +223,7 @@ export interface ItemDetail extends Item {
editHistory: EditHistoryEntry[];
imageHistory: EntityImageUpload[];
droppedByPokemon: Array<{
pokemon: NamedEntity;
pokemon: NamedEntity & { image?: PokemonImage | null };
skill: NamedEntity;
}>;
}
@@ -220,7 +231,7 @@ export interface ItemDetail extends Item {
export interface Recipe extends EditInfo {
id: number;
name: string;
materials: Array<NamedEntity & { quantity: number }>;
materials: Array<NamedEntity & { image?: EntityImage | null; quantity: number }>;
}
export interface DailyChecklistItem {
@@ -274,7 +285,7 @@ export interface LifeComment {
export interface RecipeDetail extends Recipe {
acquisition_methods: NamedEntity[];
editHistory: EditHistoryEntry[];
item: NamedEntity;
item: RecipeResultItem;
}
export interface Options {

View File

@@ -2847,6 +2847,36 @@ button:disabled,
font-weight: 800;
}
.chip--with-media {
gap: 6px;
padding-left: 4px;
}
.chip__media {
width: 22px;
height: 22px;
flex: 0 0 auto;
display: grid;
place-items: center;
overflow: hidden;
border: 1px solid rgba(31, 42, 59, 0.22);
border-radius: var(--radius-small);
background: #ffffff;
color: var(--pokemon-blue-deep);
}
.chip__media img {
width: 100%;
height: 100%;
padding: 2px;
object-fit: contain;
}
.chip__icon {
width: 15px;
height: 15px;
}
.detail-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -3414,6 +3444,14 @@ button:disabled,
display: block;
}
.related-pokemon-list-item {
display: grid;
grid-template-columns: 42px minmax(0, 1fr);
gap: 10px;
align-items: start;
min-width: 0;
}
.related-pokemon-row {
display: grid;
gap: 8px;
@@ -3477,6 +3515,72 @@ button:disabled,
min-width: 0;
}
.related-entity-link {
min-width: 0;
max-width: 100%;
display: inline-flex;
align-items: center;
gap: 8px;
}
.related-entity-link > span:last-child {
min-width: 0;
overflow-wrap: anywhere;
}
.related-entity-link--compact {
line-height: 1.2;
}
.related-entity-media {
width: 38px;
height: 38px;
flex: 0 0 auto;
display: grid;
place-items: center;
overflow: hidden;
border: 2px solid var(--line-strong);
border-radius: var(--radius-control);
background:
linear-gradient(135deg, rgba(255, 203, 5, 0.22), rgba(42, 117, 187, 0.12)),
#ffffff;
color: #172036;
}
.related-entity-media img {
width: 100%;
height: 100%;
padding: 3px;
object-fit: contain;
}
.related-entity-media--inline {
width: 28px;
height: 28px;
border-width: 1px;
border-color: rgba(31, 42, 59, 0.22);
border-radius: var(--radius-small);
}
.related-entity-media--pokemon {
border-radius: 50%;
}
.related-entity-media--appearance {
width: 44px;
height: 44px;
}
.related-entity-media__icon {
width: 18px;
height: 18px;
}
.related-entity-media--appearance .related-entity-media__icon {
width: 22px;
height: 22px;
}
.detail-text {
margin: 0;
color: var(--ink-soft);
@@ -3489,9 +3593,13 @@ button:disabled,
}
.entity-detail-image__frame {
min-height: 220px;
width: 100%;
min-height: 0;
aspect-ratio: 1 / 1;
display: grid;
place-items: center;
padding: 16px;
overflow: hidden;
border: 2px solid var(--line-strong);
border-radius: var(--radius-card);
background:
@@ -3500,42 +3608,123 @@ button:disabled,
var(--surface-soft);
}
.detail-section a.entity-detail-image__frame {
color: inherit;
font-weight: inherit;
}
.entity-detail-image__frame--link {
transition:
transform 0.16s ease,
border-color 0.16s ease,
box-shadow 0.16s ease;
}
.entity-detail-image__frame--link:hover {
transform: translateY(-2px);
border-color: var(--pokemon-blue);
box-shadow: 0 5px 0 var(--line-strong);
}
.entity-detail-image__frame--placeholder {
background:
linear-gradient(135deg, rgba(255, 203, 5, 0.26), rgba(42, 117, 187, 0.14)),
var(--surface-soft);
}
.entity-detail-image__frame img {
width: min(100%, 360px);
max-height: 240px;
width: 100%;
height: 100%;
object-fit: contain;
}
.image-history-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(92px, 1fr));
gap: 10px;
.entity-detail-image__mark.entity-card__mark {
width: 92px;
height: 92px;
}
.image-history-list__item {
.entity-detail-image__mark .entity-card__icon {
width: 42px;
height: 42px;
}
.entity-profile-grid {
display: grid;
gap: 6px;
justify-items: center;
padding: 8px;
border: 2px solid var(--line);
grid-template-columns: minmax(220px, 320px) minmax(0, 1fr);
gap: 16px;
align-items: stretch;
}
.entity-profile-main {
display: grid;
gap: 16px;
min-width: 0;
}
.entity-profile-media-section,
.entity-profile-overview {
align-content: start;
min-width: 0;
}
.entity-profile-groups,
.entity-profile-group {
display: grid;
gap: 12px;
min-width: 0;
}
.entity-profile-facts {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(148px, 1fr));
gap: 1px;
margin: 0;
overflow: hidden;
border: 1px solid var(--line);
border-radius: var(--radius-card);
background: var(--surface);
background: var(--line);
}
.image-history-list__item img {
width: 74px;
height: 64px;
object-fit: contain;
.entity-profile-facts div {
min-width: 0;
display: grid;
gap: 4px;
align-content: start;
padding: 12px 14px;
background: var(--surface-soft);
}
.image-history-list__item span {
.entity-profile-facts dt {
color: var(--muted);
font-size: 0.76rem;
font-size: 0.78rem;
font-weight: 850;
line-height: 1.2;
}
.entity-profile-facts dd {
margin: 0;
color: var(--ink);
font-weight: 950;
line-height: 1.2;
overflow-wrap: anywhere;
font-variant-numeric: tabular-nums;
}
.entity-profile-title-link {
justify-self: center;
color: var(--pokemon-blue-deep);
font-family: var(--font-display);
font-size: 1.1rem;
font-weight: 950;
line-height: 1.15;
text-align: center;
overflow-wrap: anywhere;
}
.detail-section .entity-profile-title-link {
font-weight: 950;
}
.pokemon-image-detail {
display: grid;
grid-template-columns: minmax(220px, 420px) minmax(0, 1fr);
@@ -3649,11 +3838,15 @@ button:disabled,
cursor: pointer;
}
.pokemon-profile-image:hover,
.pokemon-profile-image:focus-visible {
.pokemon-profile-image:not(.pokemon-profile-image--placeholder):hover,
.pokemon-profile-image:not(.pokemon-profile-image--placeholder):focus-visible {
border-color: var(--pokemon-blue);
}
.pokemon-profile-image--placeholder {
cursor: default;
}
.pokemon-profile-image img {
width: 100%;
height: 100%;
@@ -3835,6 +4028,14 @@ button:disabled,
justify-content: stretch;
}
.appearance-list--with-media li {
grid-template-columns: 48px clamp(132px, 20%, 220px) minmax(0, 1fr);
}
.appearance-list--with-media .appearance-name {
align-self: center;
}
.appearance-name {
min-width: 0;
overflow: hidden;
@@ -4435,6 +4636,7 @@ button:disabled,
}
.detail-grid,
.entity-profile-grid,
.pokemon-image-detail,
.pokemon-profile-grid,
.pokemon-profile-row,
@@ -4513,6 +4715,15 @@ button:disabled,
grid-template-columns: 1fr;
}
.entity-profile-facts {
grid-template-columns: 1fr;
}
.entity-detail-image__mark.entity-card__mark {
width: 84px;
height: 84px;
}
.pokemon-fetch-panel__actions {
justify-content: flex-start;
}
@@ -4633,6 +4844,14 @@ button:disabled,
grid-template-columns: 1fr;
}
.appearance-list--with-media li {
grid-template-columns: 44px minmax(0, 1fr);
}
.appearance-list--with-media .appearance-summary {
grid-column: 2;
}
.related-pokemon-row {
grid-template-columns: 1fr;
}

View File

@@ -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 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 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 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>

View File

@@ -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,40 +143,69 @@ 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 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 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 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>
</div>
</DetailSection>
</section>
<DetailSection :title="t('pages.items.acquisitionMethods')">
<EntityChips :items="item.acquisitionMethods" />
</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>{{ 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>
<DetailSection :title="t('pages.items.customization')">
<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>
</DetailSection>
<DetailSection :title="t('pages.items.tags')">
<EntityChips :items="item.tags" />
</DetailSection>
</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>
</div>
<div class="detail-grid">
<DetailSection :title="t('pages.items.recipeInfo')">
<template v-if="item.recipe">
<RouterLink :to="`/recipes/${item.recipe.id}`">{{ item.recipe.name }}</RouterLink>
<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>
@@ -188,7 +221,13 @@ watch(
<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>
<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>
@@ -198,7 +237,13 @@ watch(
<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>
<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>
@@ -208,13 +253,20 @@ watch(
<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>
<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">
<EntityDiscussionPanel entity-type="items" :entity-id="item.id" />

View File

@@ -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,6 +414,11 @@ 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-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>
@@ -442,6 +450,7 @@ watch(
</span>
</div>
</div>
</div>
</li>
</ul>
<p v-else class="meta-line">{{ t('common.none') }}</p>
@@ -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>

View File

@@ -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">

View File

@@ -212,6 +212,7 @@ export const systemWordingMessages = {
habitats: {
title: 'Habitats',
subtitle: 'View recipes and Pokemon that may appear.',
detailKicker: 'Habitat Detail',
detailSubtitle: 'Habitat detail',
editSubtitle: 'Maintain habitat recipes and possible Pokemon appearances.',
newTitle: 'New habitat',
@@ -859,6 +860,7 @@ export const systemWordingMessages = {
habitats: {
title: '栖息地',
subtitle: '查看配方和可能出现的宝可梦。',
detailKicker: 'Habitat Detail',
detailSubtitle: '栖息地详情',
editSubtitle: '维护栖息地配方和可能出现的 Pokemon。',
newTitle: '新增栖息地',