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:
35
DESIGN.md
35
DESIGN.md
@@ -319,19 +319,19 @@ Pokemon 列表功能:
|
|||||||
Pokemon 详情页展示:
|
Pokemon 详情页展示:
|
||||||
|
|
||||||
- 基本信息
|
- 基本信息
|
||||||
- 已配置图片时,详情主内容在六维 Stats 右侧展示正方形居中的 Pokédex 风格图片;页面内不直接展示图片版本、状态或描述,用户可通过图片 Modal 查看详情;未配置图片时不显示图片区。
|
- 详情主内容在六维 Stats 右侧始终保留正方形图片区;已配置图片时展示居中的 Pokédex 风格图片,未配置图片时展示默认 Poké Ball 占位符;页面内不直接展示图片版本、状态或描述,用户可通过图片 Modal 查看详情。
|
||||||
- 主内容顶部按以下布局展示:
|
- 主内容顶部按以下布局展示:
|
||||||
- 左上:Genus & Details;无区块标题;如有 Genus,先展示 Genus,再以分割线连接 Details 内容
|
- 左上:Genus & Details;无区块标题;如有 Genus,先展示 Genus,再以分割线连接 Details 内容
|
||||||
- 左下:Height / Weight 与 Types 按 2:1 比例并排;Height / Weight 无区块标题,在 Dimension 区内左右并排展示并以中间分割线隔开,每组按英制、分割线、公制、标签上下排列;Types 不显示 Type 1 / Type 2 文案,上下布局并居中展示
|
- 左下:Height / Weight 与 Types 按 2:1 比例并排;Height / Weight 无区块标题,在 Dimension 区内左右并排展示并以中间分割线隔开,每组按英制、分割线、公制、标签上下排列;Types 不显示 Type 1 / Type 2 文案,上下布局并居中展示
|
||||||
- 右侧:六维 Stats;已配置图片时图片展示在 Stats 右侧
|
- 右侧:六维 Stats;图片或默认占位符展示在 Stats 右侧
|
||||||
- 六维使用 ProgressBar 展示,最大值按 150 计算。
|
- 六维使用 ProgressBar 展示,最大值按 150 计算。
|
||||||
- 特长
|
- 特长
|
||||||
- 特长掉落物品
|
- 特长掉落物品:展示掉落物品图标;未配置图标时显示默认物品标记占位符
|
||||||
- 喜欢的环境
|
- 喜欢的环境
|
||||||
- 喜欢的东西
|
- 喜欢的东西
|
||||||
- 相关 Pokemon:与关联喜欢的东西的物品在桌面端左右并排展示;按相同喜欢的环境优先,其次按共同喜欢的东西数量从多到少排序;支持按喜欢的环境筛选,默认筛选当前 Pokemon 的喜欢的环境,也可切换到其他喜欢的环境或全部;每个筛选视图最多展示 6 个;每项第一行左侧展示名称,右侧展示特长和喜欢的环境,第二行展示喜欢的东西,并高亮共同喜欢的东西
|
- 相关 Pokemon:与关联喜欢的东西的物品在桌面端左右并排展示;按相同喜欢的环境优先,其次按共同喜欢的东西数量从多到少排序;支持按喜欢的环境筛选,默认筛选当前 Pokemon 的喜欢的环境,也可切换到其他喜欢的环境或全部;每个筛选视图最多展示 6 个;每项左侧展示 Pokemon 图片或默认 Poké Ball 占位符,原列表项信息布局保持不变,第一行左侧展示名称,右侧展示特长和喜欢的环境,第二行展示喜欢的东西,并高亮共同喜欢的东西
|
||||||
- 关联喜欢的东西的物品:与相关 Pokemon 在桌面端左右并排展示
|
- 关联喜欢的东西的物品:与相关 Pokemon 在桌面端左右并排展示;每项展示物品图标,未配置图标时显示默认物品标记占位符
|
||||||
- 出现的栖息地
|
- 出现的栖息地:每行左侧展示栖息地图片,未配置图片时显示默认栖息地标记占位符
|
||||||
- 最后编辑信息
|
- 最后编辑信息
|
||||||
- 讨论
|
- 讨论
|
||||||
- 编辑历史:通过详情页 Tabs 展示
|
- 编辑历史:通过详情页 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 出现配置:
|
|||||||
|
|
||||||
栖息地详情页展示:
|
栖息地详情页展示:
|
||||||
|
|
||||||
- 图片和图片上传历史
|
- 当前图片;未配置图片时展示默认栖息地标记占位符
|
||||||
- 配方列表
|
- 顶部按图片 / 占位符与核心信息概览并排展示,移动端改为单列;顶部概览卡片不显示 `Image` / `Details` 通用区块标题,也不展示图片历史缩略图
|
||||||
- 可能出现的 Pokemon 列表
|
- 配方列表:展示材料物品图标;未配置图标时显示默认物品标记占位符
|
||||||
|
- 可能出现的 Pokemon 列表:展示 Pokemon 图片;未配置图片时显示默认 Poké Ball 占位符
|
||||||
- 出现时间
|
- 出现时间
|
||||||
- 出现天气
|
- 出现天气
|
||||||
- 稀有度
|
- 稀有度
|
||||||
|
|||||||
@@ -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 {
|
function imagePathLabel(path: string | null | undefined): string {
|
||||||
const cleanPath = path?.trim() ?? '';
|
const cleanPath = path?.trim() ?? '';
|
||||||
if (cleanPath === '') {
|
if (cleanPath === '') {
|
||||||
@@ -1816,27 +1842,7 @@ function pokemonProjection(locale: string): string {
|
|||||||
round((p.height_inches * 0.0254)::numeric, 2)::double precision AS "heightMeters",
|
round((p.height_inches * 0.0254)::numeric, 2)::double precision AS "heightMeters",
|
||||||
p.weight_pounds AS "weightPounds",
|
p.weight_pounds AS "weightPounds",
|
||||||
round((p.weight_pounds * 0.45359237)::numeric, 2)::double precision AS "weightKg",
|
round((p.weight_pounds * 0.45359237)::numeric, 2)::double precision AS "weightKg",
|
||||||
CASE
|
${pokemonImageJson('p')} AS image,
|
||||||
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,
|
|
||||||
json_build_object(
|
json_build_object(
|
||||||
'hp', p.hp,
|
'hp', p.hp,
|
||||||
'attack', p.attack,
|
'attack', p.attack,
|
||||||
@@ -3037,6 +3043,7 @@ export async function getPokemon(id: number, locale = defaultLocale) {
|
|||||||
SELECT
|
SELECT
|
||||||
h.id,
|
h.id,
|
||||||
${habitatName} AS name,
|
${habitatName} AS name,
|
||||||
|
${uploadedImageJson('h.image_path')} AS image,
|
||||||
hp.time_of_day,
|
hp.time_of_day,
|
||||||
hp.weather,
|
hp.weather,
|
||||||
hp.rarity,
|
hp.rarity,
|
||||||
@@ -3049,9 +3056,9 @@ export async function getPokemon(id: number, locale = defaultLocale) {
|
|||||||
`,
|
`,
|
||||||
[id]
|
[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
|
FROM pokemon_skill_item_drops psid
|
||||||
JOIN skills s ON s.id = psid.skill_id
|
JOIN skills s ON s.id = psid.skill_id
|
||||||
JOIN items i ON i.id = psid.item_id
|
JOIN items i ON i.id = psid.item_id
|
||||||
@@ -3066,6 +3073,7 @@ export async function getPokemon(id: number, locale = defaultLocale) {
|
|||||||
SELECT
|
SELECT
|
||||||
i.id,
|
i.id,
|
||||||
${itemName} AS name,
|
${itemName} AS name,
|
||||||
|
${uploadedImageJson('i.image_path')} AS image,
|
||||||
json_build_object('id', c.id, 'name', ${categoryName}) AS category,
|
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
|
json_agg(json_build_object('id', ft.id, 'name', ${tagName}) ORDER BY ${orderByEntity('ft')}) AS tags
|
||||||
FROM pokemon_favorite_things pft
|
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 items i ON i.id = ift.item_id
|
||||||
JOIN item_categories c ON c.id = i.category_id
|
JOIN item_categories c ON c.id = i.category_id
|
||||||
WHERE pft.pokemon_id = $1
|
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')}
|
ORDER BY ${orderByEntity('c')}, ${orderByEntity('i')}
|
||||||
`,
|
`,
|
||||||
[id]
|
[id]
|
||||||
@@ -3110,6 +3118,7 @@ export async function getPokemon(id: number, locale = defaultLocale) {
|
|||||||
SELECT
|
SELECT
|
||||||
related_pokemon.id,
|
related_pokemon.id,
|
||||||
${relatedPokemonName} AS name,
|
${relatedPokemonName} AS name,
|
||||||
|
${pokemonImageJson('related_pokemon')} AS image,
|
||||||
json_build_object('id', related_environment.id, 'name', ${relatedEnvironmentName}) AS environment,
|
json_build_object('id', related_environment.id, 'name', ${relatedEnvironmentName}) AS environment,
|
||||||
COALESCE((
|
COALESCE((
|
||||||
SELECT json_agg(
|
SELECT json_agg(
|
||||||
@@ -3153,9 +3162,9 @@ export async function getPokemon(id: number, locale = defaultLocale) {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const dropsBySkill = itemDrops.reduce((itemsBySkill, item) => {
|
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;
|
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)
|
const skills = Array.isArray(pokemon.skills)
|
||||||
? pokemon.skills.map((skill: { id: number; name: string }) => ({
|
? 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')},
|
${auditSelect('h', 'habitat_created_user', 'habitat_updated_user')},
|
||||||
${uploadedImageJson('h.image_path')} AS image,
|
${uploadedImageJson('h.image_path')} AS image,
|
||||||
COALESCE((
|
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
|
FROM habitat_recipe_items hri
|
||||||
JOIN items i ON i.id = hri.item_id
|
JOIN items i ON i.id = hri.item_id
|
||||||
WHERE hri.habitat_id = h.id
|
WHERE hri.habitat_id = h.id
|
||||||
@@ -3486,6 +3503,7 @@ export async function getHabitat(id: number, locale = defaultLocale) {
|
|||||||
SELECT
|
SELECT
|
||||||
p.id,
|
p.id,
|
||||||
${pokemonName} AS name,
|
${pokemonName} AS name,
|
||||||
|
${pokemonImageJson('p')} AS image,
|
||||||
hp.time_of_day,
|
hp.time_of_day,
|
||||||
hp.weather,
|
hp.weather,
|
||||||
hp.rarity,
|
hp.rarity,
|
||||||
@@ -3733,6 +3751,8 @@ export async function getItem(id: number, locale = defaultLocale) {
|
|||||||
|
|
||||||
const acquisitionMethodName = localizedName('acquisition-methods', 'am', locale);
|
const acquisitionMethodName = localizedName('acquisition-methods', 'am', locale);
|
||||||
const resultItemName = localizedName('items', 'result_item', 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 materialItemName = localizedName('items', 'mi', locale);
|
||||||
const habitatName = localizedName('habitats', 'h', locale);
|
const habitatName = localizedName('habitats', 'h', locale);
|
||||||
const recipeItemName = localizedName('items', 'recipe_item', 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
|
WHERE ram.recipe_id = r.id
|
||||||
), '[]'::json) AS acquisition_methods,
|
), '[]'::json) AS acquisition_methods,
|
||||||
COALESCE((
|
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
|
FROM recipe_materials rm
|
||||||
JOIN items mi ON mi.id = rm.item_id
|
JOIN items mi ON mi.id = rm.item_id
|
||||||
WHERE rm.recipe_id = r.id
|
WHERE rm.recipe_id = r.id
|
||||||
), '[]'::json) AS materials,
|
), '[]'::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
|
FROM recipes r
|
||||||
JOIN items result_item ON result_item.id = r.item_id
|
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')}
|
${auditJoins('r', 'recipe_created_user', 'recipe_updated_user')}
|
||||||
WHERE r.item_id = $1
|
WHERE r.item_id = $1
|
||||||
`,
|
`,
|
||||||
@@ -3781,8 +3820,17 @@ export async function getItem(id: number, locale = defaultLocale) {
|
|||||||
SELECT
|
SELECT
|
||||||
r.id,
|
r.id,
|
||||||
${resultItemName} AS name,
|
${resultItemName} AS name,
|
||||||
|
${uploadedImageJson('result_item.image_path')} AS image,
|
||||||
COALESCE((
|
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
|
FROM recipe_materials recipe_material
|
||||||
JOIN items mi ON mi.id = recipe_material.item_id
|
JOIN items mi ON mi.id = recipe_material.item_id
|
||||||
WHERE recipe_material.recipe_id = r.id
|
WHERE recipe_material.recipe_id = r.id
|
||||||
@@ -3800,8 +3848,17 @@ export async function getItem(id: number, locale = defaultLocale) {
|
|||||||
SELECT
|
SELECT
|
||||||
h.id,
|
h.id,
|
||||||
${habitatName} AS name,
|
${habitatName} AS name,
|
||||||
|
${uploadedImageJson('h.image_path')} AS image,
|
||||||
COALESCE((
|
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
|
FROM habitat_recipe_items recipe_item_row
|
||||||
JOIN items recipe_item ON recipe_item.id = recipe_item_row.item_id
|
JOIN items recipe_item ON recipe_item.id = recipe_item_row.item_id
|
||||||
WHERE recipe_item_row.habitat_id = h.id
|
WHERE recipe_item_row.habitat_id = h.id
|
||||||
@@ -3816,7 +3873,7 @@ export async function getItem(id: number, locale = defaultLocale) {
|
|||||||
query(
|
query(
|
||||||
`
|
`
|
||||||
SELECT
|
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
|
json_build_object('id', s.id, 'name', ${skillName}) AS skill
|
||||||
FROM pokemon_skill_item_drops psid
|
FROM pokemon_skill_item_drops psid
|
||||||
JOIN pokemon p ON p.id = psid.pokemon_id
|
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) {
|
export async function getRecipe(id: number, locale = defaultLocale) {
|
||||||
const resultItemName = localizedName('items', 'result_item', 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 acquisitionMethodName = localizedName('acquisition-methods', 'am', locale);
|
const acquisitionMethodName = localizedName('acquisition-methods', 'am', locale);
|
||||||
const materialItemName = localizedName('items', 'i', 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
|
WHERE ram.recipe_id = r.id
|
||||||
), '[]'::json) AS acquisition_methods,
|
), '[]'::json) AS acquisition_methods,
|
||||||
COALESCE((
|
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
|
FROM recipe_materials rm
|
||||||
JOIN items i ON i.id = rm.item_id
|
JOIN items i ON i.id = rm.item_id
|
||||||
WHERE rm.recipe_id = r.id
|
WHERE rm.recipe_id = r.id
|
||||||
), '[]'::json) AS materials,
|
), '[]'::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
|
FROM recipes r
|
||||||
JOIN items result_item ON result_item.id = r.item_id
|
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')}
|
${auditJoins('r', 'recipe_created_user', 'recipe_updated_user')}
|
||||||
WHERE r.id = $1
|
WHERE r.id = $1
|
||||||
`,
|
`,
|
||||||
|
|||||||
@@ -1,14 +1,29 @@
|
|||||||
<script setup lang="ts">
|
<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<{
|
defineProps<{
|
||||||
items: Array<NamedEntity & { quantity?: number }>;
|
items: ChipItem[];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
function hasImageSlot(item: ChipItem) {
|
||||||
|
return Object.prototype.hasOwnProperty.call(item, 'image');
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="chips">
|
<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>
|
{{ item.name }}<span v-if="item.quantity"> × {{ item.quantity }}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -128,20 +128,22 @@ export interface Pokemon extends EditInfo {
|
|||||||
export interface RelatedPokemon {
|
export interface RelatedPokemon {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
image?: PokemonImage | null;
|
||||||
environment: NamedEntity;
|
environment: NamedEntity;
|
||||||
skills: Skill[];
|
skills: Skill[];
|
||||||
favorite_things: Array<NamedEntity & { matches: boolean }>;
|
favorite_things: Array<NamedEntity & { matches: boolean }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PokemonDetail extends Pokemon {
|
export interface PokemonDetail extends Pokemon {
|
||||||
skills: Array<Skill & { itemDrop: NamedEntity | null }>;
|
skills: Array<Skill & { itemDrop: (NamedEntity & { image?: EntityImage | null }) | null }>;
|
||||||
favoriteThingItems: Array<NamedEntity & { category: NamedEntity; tags: NamedEntity[] }>;
|
favoriteThingItems: Array<NamedEntity & { image?: EntityImage | null; category: NamedEntity; tags: NamedEntity[] }>;
|
||||||
relatedPokemon: RelatedPokemon[];
|
relatedPokemon: RelatedPokemon[];
|
||||||
editHistory: EditHistoryEntry[];
|
editHistory: EditHistoryEntry[];
|
||||||
imageHistory: EntityImageUpload[];
|
imageHistory: EntityImageUpload[];
|
||||||
habitats: Array<{
|
habitats: Array<{
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
image?: EntityImage | null;
|
||||||
time_of_day: string;
|
time_of_day: string;
|
||||||
weather: string;
|
weather: string;
|
||||||
rarity: number;
|
rarity: number;
|
||||||
@@ -155,7 +157,7 @@ export interface Habitat extends EditInfo {
|
|||||||
baseName?: string;
|
baseName?: string;
|
||||||
translations?: TranslationMap;
|
translations?: TranslationMap;
|
||||||
image: EntityImage | null;
|
image: EntityImage | null;
|
||||||
recipe: Array<NamedEntity & { quantity: number }>;
|
recipe: Array<NamedEntity & { image?: EntityImage | null; quantity: number }>;
|
||||||
pokemon?: NamedEntity[];
|
pokemon?: NamedEntity[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,6 +165,7 @@ export interface HabitatDetail extends Habitat {
|
|||||||
editHistory: EditHistoryEntry[];
|
editHistory: EditHistoryEntry[];
|
||||||
imageHistory: EntityImageUpload[];
|
imageHistory: EntityImageUpload[];
|
||||||
pokemon: Array<NamedEntity & {
|
pokemon: Array<NamedEntity & {
|
||||||
|
image?: PokemonImage | null;
|
||||||
time_of_day: string;
|
time_of_day: string;
|
||||||
weather: string;
|
weather: string;
|
||||||
rarity: number;
|
rarity: number;
|
||||||
@@ -177,13 +180,21 @@ export interface RecipeSummary extends EditInfo {
|
|||||||
export interface RecipeUsage {
|
export interface RecipeUsage {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
materials: Array<NamedEntity & { quantity: number }>;
|
image?: EntityImage | null;
|
||||||
|
materials: Array<NamedEntity & { image?: EntityImage | null; quantity: number }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HabitatUsage {
|
export interface HabitatUsage {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
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 {
|
export interface Item extends EditInfo {
|
||||||
@@ -212,7 +223,7 @@ export interface ItemDetail extends Item {
|
|||||||
editHistory: EditHistoryEntry[];
|
editHistory: EditHistoryEntry[];
|
||||||
imageHistory: EntityImageUpload[];
|
imageHistory: EntityImageUpload[];
|
||||||
droppedByPokemon: Array<{
|
droppedByPokemon: Array<{
|
||||||
pokemon: NamedEntity;
|
pokemon: NamedEntity & { image?: PokemonImage | null };
|
||||||
skill: NamedEntity;
|
skill: NamedEntity;
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
@@ -220,7 +231,7 @@ export interface ItemDetail extends Item {
|
|||||||
export interface Recipe extends EditInfo {
|
export interface Recipe extends EditInfo {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
materials: Array<NamedEntity & { quantity: number }>;
|
materials: Array<NamedEntity & { image?: EntityImage | null; quantity: number }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DailyChecklistItem {
|
export interface DailyChecklistItem {
|
||||||
@@ -274,7 +285,7 @@ export interface LifeComment {
|
|||||||
export interface RecipeDetail extends Recipe {
|
export interface RecipeDetail extends Recipe {
|
||||||
acquisition_methods: NamedEntity[];
|
acquisition_methods: NamedEntity[];
|
||||||
editHistory: EditHistoryEntry[];
|
editHistory: EditHistoryEntry[];
|
||||||
item: NamedEntity;
|
item: RecipeResultItem;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Options {
|
export interface Options {
|
||||||
|
|||||||
@@ -2847,6 +2847,36 @@ button:disabled,
|
|||||||
font-weight: 800;
|
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 {
|
.detail-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
@@ -3414,6 +3444,14 @@ button:disabled,
|
|||||||
display: block;
|
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 {
|
.related-pokemon-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
@@ -3477,6 +3515,72 @@ button:disabled,
|
|||||||
min-width: 0;
|
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 {
|
.detail-text {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--ink-soft);
|
color: var(--ink-soft);
|
||||||
@@ -3489,9 +3593,13 @@ button:disabled,
|
|||||||
}
|
}
|
||||||
|
|
||||||
.entity-detail-image__frame {
|
.entity-detail-image__frame {
|
||||||
min-height: 220px;
|
width: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
|
padding: 16px;
|
||||||
|
overflow: hidden;
|
||||||
border: 2px solid var(--line-strong);
|
border: 2px solid var(--line-strong);
|
||||||
border-radius: var(--radius-card);
|
border-radius: var(--radius-card);
|
||||||
background:
|
background:
|
||||||
@@ -3500,42 +3608,123 @@ button:disabled,
|
|||||||
var(--surface-soft);
|
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 {
|
.entity-detail-image__frame img {
|
||||||
width: min(100%, 360px);
|
width: 100%;
|
||||||
max-height: 240px;
|
height: 100%;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
.image-history-list {
|
.entity-detail-image__mark.entity-card__mark {
|
||||||
display: grid;
|
width: 92px;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(92px, 1fr));
|
height: 92px;
|
||||||
gap: 10px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.image-history-list__item {
|
.entity-detail-image__mark .entity-card__icon {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-profile-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 6px;
|
grid-template-columns: minmax(220px, 320px) minmax(0, 1fr);
|
||||||
justify-items: center;
|
gap: 16px;
|
||||||
padding: 8px;
|
align-items: stretch;
|
||||||
border: 2px solid var(--line);
|
}
|
||||||
|
|
||||||
|
.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);
|
border-radius: var(--radius-card);
|
||||||
background: var(--surface);
|
background: var(--line);
|
||||||
}
|
}
|
||||||
|
|
||||||
.image-history-list__item img {
|
.entity-profile-facts div {
|
||||||
width: 74px;
|
min-width: 0;
|
||||||
height: 64px;
|
display: grid;
|
||||||
object-fit: contain;
|
gap: 4px;
|
||||||
|
align-content: start;
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: var(--surface-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.image-history-list__item span {
|
.entity-profile-facts dt {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 0.76rem;
|
font-size: 0.78rem;
|
||||||
font-weight: 850;
|
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;
|
text-align: center;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.detail-section .entity-profile-title-link {
|
||||||
|
font-weight: 950;
|
||||||
|
}
|
||||||
|
|
||||||
.pokemon-image-detail {
|
.pokemon-image-detail {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(220px, 420px) minmax(0, 1fr);
|
grid-template-columns: minmax(220px, 420px) minmax(0, 1fr);
|
||||||
@@ -3649,11 +3838,15 @@ button:disabled,
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pokemon-profile-image:hover,
|
.pokemon-profile-image:not(.pokemon-profile-image--placeholder):hover,
|
||||||
.pokemon-profile-image:focus-visible {
|
.pokemon-profile-image:not(.pokemon-profile-image--placeholder):focus-visible {
|
||||||
border-color: var(--pokemon-blue);
|
border-color: var(--pokemon-blue);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pokemon-profile-image--placeholder {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
.pokemon-profile-image img {
|
.pokemon-profile-image img {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -3835,6 +4028,14 @@ button:disabled,
|
|||||||
justify-content: stretch;
|
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 {
|
.appearance-name {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -4435,6 +4636,7 @@ button:disabled,
|
|||||||
}
|
}
|
||||||
|
|
||||||
.detail-grid,
|
.detail-grid,
|
||||||
|
.entity-profile-grid,
|
||||||
.pokemon-image-detail,
|
.pokemon-image-detail,
|
||||||
.pokemon-profile-grid,
|
.pokemon-profile-grid,
|
||||||
.pokemon-profile-row,
|
.pokemon-profile-row,
|
||||||
@@ -4513,6 +4715,15 @@ button:disabled,
|
|||||||
grid-template-columns: 1fr;
|
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 {
|
.pokemon-fetch-panel__actions {
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
}
|
}
|
||||||
@@ -4633,6 +4844,14 @@ button:disabled,
|
|||||||
grid-template-columns: 1fr;
|
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 {
|
.related-pokemon-row {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,9 +8,10 @@ import EditHistoryPanel from '../components/EditHistoryPanel.vue';
|
|||||||
import EntityDiscussionPanel from '../components/EntityDiscussionPanel.vue';
|
import EntityDiscussionPanel from '../components/EntityDiscussionPanel.vue';
|
||||||
import EntityChips from '../components/EntityChips.vue';
|
import EntityChips from '../components/EntityChips.vue';
|
||||||
import PageHeader from '../components/PageHeader.vue';
|
import PageHeader from '../components/PageHeader.vue';
|
||||||
|
import PokeBallMark from '../components/PokeBallMark.vue';
|
||||||
import Skeleton from '../components/Skeleton.vue';
|
import Skeleton from '../components/Skeleton.vue';
|
||||||
import Tabs, { type TabOption } from '../components/Tabs.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 { api, type HabitatDetail } from '../services/api';
|
||||||
import HabitatEdit from './HabitatEdit.vue';
|
import HabitatEdit from './HabitatEdit.vue';
|
||||||
|
|
||||||
@@ -30,6 +31,7 @@ const detailTabs = computed<TabOption[]>(() => [
|
|||||||
type PokemonRow = {
|
type PokemonRow = {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
image: HabitatDetail['pokemon'][number]['image'];
|
||||||
timeOfDays: string[];
|
timeOfDays: string[];
|
||||||
weathers: string[];
|
weathers: string[];
|
||||||
rarity: number;
|
rarity: number;
|
||||||
@@ -74,6 +76,7 @@ const pokemonRows = computed<PokemonRow[]>(() => {
|
|||||||
{
|
{
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
image: HabitatDetail['pokemon'][number]['image'];
|
||||||
timeOfDays: Set<string>;
|
timeOfDays: Set<string>;
|
||||||
weathers: Set<string>;
|
weathers: Set<string>;
|
||||||
rarity: number;
|
rarity: number;
|
||||||
@@ -86,6 +89,7 @@ const pokemonRows = computed<PokemonRow[]>(() => {
|
|||||||
const row = rows.get(key) ?? {
|
const row = rows.get(key) ?? {
|
||||||
id: pokemon.id,
|
id: pokemon.id,
|
||||||
name: pokemon.name,
|
name: pokemon.name,
|
||||||
|
image: pokemon.image,
|
||||||
timeOfDays: new Set<string>(),
|
timeOfDays: new Set<string>(),
|
||||||
weathers: new Set<string>(),
|
weathers: new Set<string>(),
|
||||||
rarity: pokemon.rarity,
|
rarity: pokemon.rarity,
|
||||||
@@ -101,6 +105,7 @@ const pokemonRows = computed<PokemonRow[]>(() => {
|
|||||||
return [...rows.values()].map((row) => ({
|
return [...rows.values()].map((row) => ({
|
||||||
id: row.id,
|
id: row.id,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
|
image: row.image,
|
||||||
timeOfDays: sortByOrder(row.timeOfDays, timeOfDays),
|
timeOfDays: sortByOrder(row.timeOfDays, timeOfDays),
|
||||||
weathers: sortByOrder(row.weathers, weathers),
|
weathers: sortByOrder(row.weathers, weathers),
|
||||||
rarity: row.rarity,
|
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() {
|
async function loadHabitatDetail() {
|
||||||
habitat.value = await api.habitatDetail(String(route.params.id));
|
habitat.value = await api.habitatDetail(String(route.params.id));
|
||||||
}
|
}
|
||||||
@@ -187,7 +188,7 @@ watch(
|
|||||||
</section>
|
</section>
|
||||||
<section v-else class="page-stack">
|
<section v-else class="page-stack">
|
||||||
<PageHeader :title="habitat.name" :subtitle="t('pages.habitats.detailSubtitle')">
|
<PageHeader :title="habitat.name" :subtitle="t('pages.habitats.detailSubtitle')">
|
||||||
<template #kicker>Habitat Detail</template>
|
<template #kicker>{{ t('pages.habitats.detailKicker') }}</template>
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<RouterLink class="ui-button ui-button--primary ui-button--small" :to="`/habitats/${habitat.id}/edit`">
|
<RouterLink class="ui-button ui-button--primary ui-button--small" :to="`/habitats/${habitat.id}/edit`">
|
||||||
<Icon :icon="iconEdit" class="ui-icon" aria-hidden="true" />
|
<Icon :icon="iconEdit" class="ui-icon" aria-hidden="true" />
|
||||||
@@ -203,29 +204,48 @@ watch(
|
|||||||
<div class="detail-tabs">
|
<div class="detail-tabs">
|
||||||
<Tabs id="habitat-detail-tabs" v-model="detailTab" :tabs="detailTabs" :label="t('common.details')" />
|
<Tabs id="habitat-detail-tabs" v-model="detailTab" :tabs="detailTabs" :label="t('common.details')" />
|
||||||
|
|
||||||
<div v-if="detailTab === 'details'" class="habitat-detail-stack">
|
<div v-if="detailTab === 'details'" class="detail-grid detail-grid--stack">
|
||||||
<DetailSection v-if="habitat.image || habitat.imageHistory.length" :title="t('media.image')">
|
<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">
|
||||||
<div v-if="habitat.image" class="entity-detail-image__frame">
|
<div class="entity-detail-image__frame" :class="{ 'entity-detail-image__frame--placeholder': !habitat.image }">
|
||||||
<img :src="habitat.image.url" :alt="t('media.imageAlt', { name: habitat.name })" />
|
<img v-if="habitat.image" :src="habitat.image.url" :alt="t('media.imageAlt', { name: habitat.name })" />
|
||||||
</div>
|
<span v-else class="entity-card__mark entity-detail-image__mark" role="img" :aria-label="t('media.imageEmpty')">
|
||||||
<p v-else class="meta-line">{{ t('media.imageEmpty') }}</p>
|
<Icon :icon="iconHabitat" class="entity-card__icon" aria-hidden="true" />
|
||||||
<div v-if="habitat.imageHistory.length" class="image-history-list" :aria-label="t('media.imageHistory')">
|
</span>
|
||||||
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
</DetailSection>
|
|
||||||
|
|
||||||
<DetailSection :title="t('pages.habitats.recipeList')">
|
<div class="entity-profile-main">
|
||||||
<EntityChips :items="habitat.recipe" />
|
<section class="detail-section entity-profile-overview" :aria-label="t('common.details')">
|
||||||
</DetailSection>
|
<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')">
|
<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}`">
|
<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>
|
<RouterLink class="appearance-name" :to="`/pokemon/${item.id}`">{{ item.name }}</RouterLink>
|
||||||
<dl class="appearance-summary">
|
<dl class="appearance-summary">
|
||||||
<div>
|
<div>
|
||||||
@@ -247,6 +267,7 @@ watch(
|
|||||||
</dl>
|
</dl>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
<p v-else class="meta-line">{{ t('common.none') }}</p>
|
||||||
</DetailSection>
|
</DetailSection>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -8,9 +8,10 @@ import EditHistoryPanel from '../components/EditHistoryPanel.vue';
|
|||||||
import EntityDiscussionPanel from '../components/EntityDiscussionPanel.vue';
|
import EntityDiscussionPanel from '../components/EntityDiscussionPanel.vue';
|
||||||
import EntityChips from '../components/EntityChips.vue';
|
import EntityChips from '../components/EntityChips.vue';
|
||||||
import PageHeader from '../components/PageHeader.vue';
|
import PageHeader from '../components/PageHeader.vue';
|
||||||
|
import PokeBallMark from '../components/PokeBallMark.vue';
|
||||||
import Skeleton from '../components/Skeleton.vue';
|
import Skeleton from '../components/Skeleton.vue';
|
||||||
import Tabs, { type TabOption } from '../components/Tabs.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 { api, type ItemDetail } from '../services/api';
|
||||||
import ItemEdit from './ItemEdit.vue';
|
import ItemEdit from './ItemEdit.vue';
|
||||||
|
|
||||||
@@ -24,6 +25,13 @@ const detailTabs = computed<TabOption[]>(() => [
|
|||||||
{ value: 'discussion', label: t('discussion.title') },
|
{ value: 'discussion', label: t('discussion.title') },
|
||||||
{ value: 'history', label: t('history.editHistory') }
|
{ 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(() => {
|
const customization = computed(() => {
|
||||||
if (!item.value) {
|
if (!item.value) {
|
||||||
@@ -37,10 +45,6 @@ const customization = computed(() => {
|
|||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
});
|
});
|
||||||
|
|
||||||
function imageFileName(path: string): string {
|
|
||||||
return path.split('/').at(-1) ?? t('media.image');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadItemDetail() {
|
async function loadItemDetail() {
|
||||||
item.value = await api.itemDetail(String(route.params.id));
|
item.value = await api.itemDetail(String(route.params.id));
|
||||||
}
|
}
|
||||||
@@ -122,8 +126,8 @@ watch(
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section v-else class="page-stack">
|
<section v-else class="page-stack">
|
||||||
<PageHeader :title="item.name" :subtitle="item.usage ? `${item.category.name} · ${item.usage.name}` : item.category.name">
|
<PageHeader :title="item.name" :subtitle="itemSubtitle">
|
||||||
<template #kicker>Item Detail</template>
|
<template #kicker>{{ t('pages.items.detailKicker') }}</template>
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<RouterLink class="ui-button ui-button--primary ui-button--small" :to="`/items/${item.id}/edit`">
|
<RouterLink class="ui-button ui-button--primary ui-button--small" :to="`/items/${item.id}/edit`">
|
||||||
<Icon :icon="iconEdit" class="ui-icon" aria-hidden="true" />
|
<Icon :icon="iconEdit" class="ui-icon" aria-hidden="true" />
|
||||||
@@ -139,40 +143,69 @@ watch(
|
|||||||
<div class="detail-tabs">
|
<div class="detail-tabs">
|
||||||
<Tabs id="item-detail-tabs" v-model="detailTab" :tabs="detailTabs" :label="t('common.details')" />
|
<Tabs id="item-detail-tabs" v-model="detailTab" :tabs="detailTabs" :label="t('common.details')" />
|
||||||
|
|
||||||
<div v-if="detailTab === 'details'" class="detail-grid">
|
<div v-if="detailTab === 'details'" class="detail-grid detail-grid--stack">
|
||||||
<DetailSection v-if="item.image || item.imageHistory.length" :title="t('media.image')">
|
<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">
|
||||||
<div v-if="item.image" class="entity-detail-image__frame">
|
<div class="entity-detail-image__frame" :class="{ 'entity-detail-image__frame--placeholder': !item.image }">
|
||||||
<img :src="item.image.url" :alt="t('media.imageAlt', { name: item.name })" />
|
<img v-if="item.image" :src="item.image.url" :alt="t('media.imageAlt', { name: item.name })" />
|
||||||
</div>
|
<span v-else class="entity-card__mark entity-detail-image__mark" role="img" :aria-label="t('media.imageEmpty')">
|
||||||
<p v-else class="meta-line">{{ t('media.imageEmpty') }}</p>
|
<Icon :icon="iconItem" class="entity-card__icon" aria-hidden="true" />
|
||||||
<div v-if="item.imageHistory.length" class="image-history-list" :aria-label="t('media.imageHistory')">
|
</span>
|
||||||
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
</DetailSection>
|
|
||||||
|
|
||||||
<DetailSection :title="t('pages.items.acquisitionMethods')">
|
<div class="entity-profile-main">
|
||||||
<EntityChips :items="item.acquisitionMethods" />
|
<section class="detail-section entity-profile-overview" :aria-label="t('common.details')">
|
||||||
</DetailSection>
|
<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">
|
<div v-if="customization.length" class="chips">
|
||||||
<span v-for="entry in customization" :key="entry" class="chip">{{ entry }}</span>
|
<span v-for="entry in customization" :key="entry" class="chip">{{ entry }}</span>
|
||||||
</div>
|
</div>
|
||||||
<p v-else class="meta-line">{{ t('common.none') }}</p>
|
<p v-else class="meta-line">{{ t('common.none') }}</p>
|
||||||
</DetailSection>
|
</div>
|
||||||
|
<div class="entity-profile-group">
|
||||||
<DetailSection :title="t('pages.items.tags')">
|
<h3 class="section-subtitle">{{ t('pages.items.tags') }}</h3>
|
||||||
<EntityChips :items="item.tags" />
|
<EntityChips v-if="item.tags.length" :items="item.tags" />
|
||||||
</DetailSection>
|
<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')">
|
<DetailSection :title="t('pages.items.recipeInfo')">
|
||||||
<template v-if="item.recipe">
|
<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" />
|
<EntityChips :items="item.recipe.materials" />
|
||||||
</template>
|
</template>
|
||||||
<p v-else-if="item.noRecipe" class="meta-line">{{ t('pages.items.noRecipe') }}</p>
|
<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')">
|
<DetailSection :title="t('pages.items.relatedRecipes')">
|
||||||
<ul v-if="item.relatedRecipes.length" class="row-list">
|
<ul v-if="item.relatedRecipes.length" class="row-list">
|
||||||
<li v-for="recipe in item.relatedRecipes" :key="recipe.id">
|
<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" />
|
<EntityChips :items="recipe.materials" />
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -198,7 +237,13 @@ watch(
|
|||||||
<DetailSection :title="t('pages.items.relatedHabitats')">
|
<DetailSection :title="t('pages.items.relatedHabitats')">
|
||||||
<ul v-if="item.relatedHabitats.length" class="row-list">
|
<ul v-if="item.relatedHabitats.length" class="row-list">
|
||||||
<li v-for="habitat in item.relatedHabitats" :key="habitat.id">
|
<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" />
|
<EntityChips :items="habitat.recipe" />
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -208,13 +253,20 @@ watch(
|
|||||||
<DetailSection :title="t('pages.items.pokemonDrops')">
|
<DetailSection :title="t('pages.items.pokemonDrops')">
|
||||||
<ul v-if="item.droppedByPokemon.length" class="row-list">
|
<ul v-if="item.droppedByPokemon.length" class="row-list">
|
||||||
<li v-for="entry in item.droppedByPokemon" :key="`${entry.pokemon.id}-${entry.skill.id}`">
|
<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>
|
<span>{{ t('pages.pokemon.skillDrop', { name: entry.skill.name }) }}</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p v-else class="meta-line">{{ t('common.none') }}</p>
|
<p v-else class="meta-line">{{ t('common.none') }}</p>
|
||||||
</DetailSection>
|
</DetailSection>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-else-if="detailTab === 'discussion'" class="detail-tab-panel">
|
<div v-else-if="detailTab === 'discussion'" class="detail-tab-panel">
|
||||||
<EntityDiscussionPanel entity-type="items" :entity-id="item.id" />
|
<EntityDiscussionPanel entity-type="items" :entity-id="item.id" />
|
||||||
|
|||||||
@@ -9,15 +9,16 @@ import EntityDiscussionPanel from '../components/EntityDiscussionPanel.vue';
|
|||||||
import EntityChips from '../components/EntityChips.vue';
|
import EntityChips from '../components/EntityChips.vue';
|
||||||
import Modal from '../components/Modal.vue';
|
import Modal from '../components/Modal.vue';
|
||||||
import PageHeader from '../components/PageHeader.vue';
|
import PageHeader from '../components/PageHeader.vue';
|
||||||
|
import PokeBallMark from '../components/PokeBallMark.vue';
|
||||||
import PokemonStatsPanel from '../components/PokemonStatsPanel.vue';
|
import PokemonStatsPanel from '../components/PokemonStatsPanel.vue';
|
||||||
import Skeleton from '../components/Skeleton.vue';
|
import Skeleton from '../components/Skeleton.vue';
|
||||||
import Tabs, { type TabOption } from '../components/Tabs.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 { api, type PokemonDetail } from '../services/api';
|
||||||
import PokemonEdit from './PokemonEdit.vue';
|
import PokemonEdit from './PokemonEdit.vue';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const { locale, t } = useI18n();
|
const { t } = useI18n();
|
||||||
const pokemon = ref<PokemonDetail | null>(null);
|
const pokemon = ref<PokemonDetail | null>(null);
|
||||||
const itemCategoryTab = ref('');
|
const itemCategoryTab = ref('');
|
||||||
const relatedHabitatTab = ref('');
|
const relatedHabitatTab = ref('');
|
||||||
@@ -30,6 +31,7 @@ const relatedPokemonLimit = 6;
|
|||||||
type HabitatRow = {
|
type HabitatRow = {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
image: PokemonDetail['habitats'][number]['image'];
|
||||||
timeOfDays: string[];
|
timeOfDays: string[];
|
||||||
weathers: string[];
|
weathers: string[];
|
||||||
rarity: number;
|
rarity: number;
|
||||||
@@ -78,6 +80,7 @@ const habitatRows = computed<HabitatRow[]>(() => {
|
|||||||
{
|
{
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
image: PokemonDetail['habitats'][number]['image'];
|
||||||
timeOfDays: Set<string>;
|
timeOfDays: Set<string>;
|
||||||
weathers: Set<string>;
|
weathers: Set<string>;
|
||||||
rarity: number;
|
rarity: number;
|
||||||
@@ -90,6 +93,7 @@ const habitatRows = computed<HabitatRow[]>(() => {
|
|||||||
const row = rows.get(key) ?? {
|
const row = rows.get(key) ?? {
|
||||||
id: habitat.id,
|
id: habitat.id,
|
||||||
name: habitat.name,
|
name: habitat.name,
|
||||||
|
image: habitat.image,
|
||||||
timeOfDays: new Set<string>(),
|
timeOfDays: new Set<string>(),
|
||||||
weathers: new Set<string>(),
|
weathers: new Set<string>(),
|
||||||
rarity: habitat.rarity,
|
rarity: habitat.rarity,
|
||||||
@@ -105,6 +109,7 @@ const habitatRows = computed<HabitatRow[]>(() => {
|
|||||||
return [...rows.values()].map((row) => ({
|
return [...rows.values()].map((row) => ({
|
||||||
id: row.id,
|
id: row.id,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
|
image: row.image,
|
||||||
timeOfDays: sortByOrder(row.timeOfDays, timeOfDays),
|
timeOfDays: sortByOrder(row.timeOfDays, timeOfDays),
|
||||||
weathers: sortByOrder(row.weathers, weathers),
|
weathers: sortByOrder(row.weathers, weathers),
|
||||||
rarity: row.rarity,
|
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}`;
|
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() {
|
function openImageModal() {
|
||||||
imageModalOpen.value = true;
|
imageModalOpen.value = true;
|
||||||
}
|
}
|
||||||
@@ -328,7 +322,7 @@ watch(
|
|||||||
<Tabs id="pokemon-detail-tabs" v-model="detailTab" :tabs="detailTabs" :label="t('common.details')" />
|
<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 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">
|
<div class="pokemon-profile-main">
|
||||||
<section class="detail-section pokemon-profile-card" :aria-label="t('pages.pokemon.details')">
|
<section class="detail-section pokemon-profile-card" :aria-label="t('pages.pokemon.details')">
|
||||||
<p v-if="pokemon.genus" class="pokemon-genus">{{ pokemon.genus }}</p>
|
<p v-if="pokemon.genus" class="pokemon-genus">{{ pokemon.genus }}</p>
|
||||||
@@ -371,7 +365,7 @@ watch(
|
|||||||
</div>
|
</div>
|
||||||
</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')">
|
<DetailSection class="pokemon-profile-stats" :title="t('pages.pokemon.statsTitle')">
|
||||||
<PokemonStatsPanel :stats="pokemon.stats" />
|
<PokemonStatsPanel :stats="pokemon.stats" />
|
||||||
</DetailSection>
|
</DetailSection>
|
||||||
@@ -379,6 +373,9 @@ watch(
|
|||||||
<button v-if="pokemon.image" type="button" class="pokemon-profile-image" :aria-label="pokemonImageLabel()" @click="openImageModal">
|
<button v-if="pokemon.image" type="button" class="pokemon-profile-image" :aria-label="pokemonImageLabel()" @click="openImageModal">
|
||||||
<img :src="pokemon.image.url" :alt="pokemonImageAlt()" />
|
<img :src="pokemon.image.url" :alt="pokemonImageAlt()" />
|
||||||
</button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -390,7 +387,13 @@ watch(
|
|||||||
<ul class="row-list skill-drop-summary">
|
<ul class="row-list skill-drop-summary">
|
||||||
<li v-for="skill in skillDropRows" :key="skill.id">
|
<li v-for="skill in skillDropRows" :key="skill.id">
|
||||||
<span>{{ t('pages.pokemon.skillDrop', { name: skill.name }) }}</span>
|
<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>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</DetailSection>
|
</DetailSection>
|
||||||
@@ -411,6 +414,11 @@ watch(
|
|||||||
/>
|
/>
|
||||||
<ul v-if="relatedPokemonRows.length" class="row-list related-pokemon-list">
|
<ul v-if="relatedPokemonRows.length" class="row-list related-pokemon-list">
|
||||||
<li v-for="related in relatedPokemonRows" :key="related.id">
|
<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">
|
||||||
<div class="related-pokemon-row__summary">
|
<div class="related-pokemon-row__summary">
|
||||||
<RouterLink class="related-pokemon-row__name" :to="`/pokemon/${related.id}`">#{{ related.id }} {{ related.name }}</RouterLink>
|
<RouterLink class="related-pokemon-row__name" :to="`/pokemon/${related.id}`">#{{ related.id }} {{ related.name }}</RouterLink>
|
||||||
@@ -442,6 +450,7 @@ watch(
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p v-else class="meta-line">{{ t('common.none') }}</p>
|
<p v-else class="meta-line">{{ t('common.none') }}</p>
|
||||||
@@ -460,7 +469,13 @@ watch(
|
|||||||
/>
|
/>
|
||||||
<ul v-if="favoriteThingItems.length" class="row-list">
|
<ul v-if="favoriteThingItems.length" class="row-list">
|
||||||
<li v-for="item in favoriteThingItems" :key="item.id">
|
<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" />
|
<EntityChips :items="item.tags" />
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -471,8 +486,12 @@ watch(
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DetailSection :title="t('pages.pokemon.habitats')">
|
<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}`">
|
<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>
|
<RouterLink class="appearance-name" :to="`/habitats/${habitat.id}`">{{ habitat.name }}</RouterLink>
|
||||||
<dl class="appearance-summary">
|
<dl class="appearance-summary">
|
||||||
<div>
|
<div>
|
||||||
@@ -523,13 +542,6 @@ watch(
|
|||||||
<strong>{{ pokemonImageLabel() }}</strong>
|
<strong>{{ pokemonImageLabel() }}</strong>
|
||||||
<span v-if="pokemon.image.style">{{ pokemon.image.style }}</span>
|
<span v-if="pokemon.image.style">{{ pokemon.image.style }}</span>
|
||||||
<p v-if="pokemon.image.description">{{ pokemon.image.description }}</p>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import EntityChips from '../components/EntityChips.vue';
|
|||||||
import PageHeader from '../components/PageHeader.vue';
|
import PageHeader from '../components/PageHeader.vue';
|
||||||
import Skeleton from '../components/Skeleton.vue';
|
import Skeleton from '../components/Skeleton.vue';
|
||||||
import Tabs, { type TabOption } from '../components/Tabs.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 { api, type RecipeDetail } from '../services/api';
|
||||||
import RecipeEdit from './RecipeEdit.vue';
|
import RecipeEdit from './RecipeEdit.vue';
|
||||||
|
|
||||||
@@ -24,6 +24,20 @@ const detailTabs = computed<TabOption[]>(() => [
|
|||||||
{ value: 'discussion', label: t('discussion.title') },
|
{ value: 'discussion', label: t('discussion.title') },
|
||||||
{ value: 'history', label: t('history.editHistory') }
|
{ 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() {
|
async function loadRecipeDetail() {
|
||||||
recipe.value = await api.recipeDetail(String(route.params.id));
|
recipe.value = await api.recipeDetail(String(route.params.id));
|
||||||
@@ -80,8 +94,8 @@ watch(
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section v-else class="page-stack">
|
<section v-else class="page-stack">
|
||||||
<PageHeader :title="recipe.name" :subtitle="t('pages.recipes.detailSubtitle')">
|
<PageHeader :title="recipe.name" :subtitle="recipeSubtitle">
|
||||||
<template #kicker>Recipe Detail</template>
|
<template #kicker>{{ t('pages.recipes.detailKicker') }}</template>
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<RouterLink class="ui-button ui-button--primary ui-button--small" :to="`/recipes/${recipe.id}/edit`">
|
<RouterLink class="ui-button ui-button--primary ui-button--small" :to="`/recipes/${recipe.id}/edit`">
|
||||||
<Icon :icon="iconEdit" class="ui-icon" aria-hidden="true" />
|
<Icon :icon="iconEdit" class="ui-icon" aria-hidden="true" />
|
||||||
@@ -97,14 +111,60 @@ watch(
|
|||||||
<div class="detail-tabs">
|
<div class="detail-tabs">
|
||||||
<Tabs id="recipe-detail-tabs" v-model="detailTab" :tabs="detailTabs" :label="t('common.details')" />
|
<Tabs id="recipe-detail-tabs" v-model="detailTab" :tabs="detailTabs" :label="t('common.details')" />
|
||||||
|
|
||||||
<div v-if="detailTab === 'details'" class="detail-grid">
|
<div v-if="detailTab === 'details'" class="detail-grid detail-grid--stack">
|
||||||
<DetailSection :title="t('pages.items.acquisitionMethods')">
|
<div class="entity-profile-grid">
|
||||||
<EntityChips :items="recipe.acquisition_methods" />
|
<section class="detail-section entity-profile-media-section" :aria-label="t('pages.recipes.item')">
|
||||||
</DetailSection>
|
<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')">
|
<div class="entity-profile-main">
|
||||||
<EntityChips :items="recipe.materials" />
|
<section class="detail-section entity-profile-overview" :aria-label="t('common.details')">
|
||||||
</DetailSection>
|
<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>
|
||||||
|
|
||||||
<div v-else-if="detailTab === 'discussion'" class="detail-tab-panel">
|
<div v-else-if="detailTab === 'discussion'" class="detail-tab-panel">
|
||||||
|
|||||||
@@ -212,6 +212,7 @@ export const systemWordingMessages = {
|
|||||||
habitats: {
|
habitats: {
|
||||||
title: 'Habitats',
|
title: 'Habitats',
|
||||||
subtitle: 'View recipes and Pokemon that may appear.',
|
subtitle: 'View recipes and Pokemon that may appear.',
|
||||||
|
detailKicker: 'Habitat Detail',
|
||||||
detailSubtitle: 'Habitat detail',
|
detailSubtitle: 'Habitat detail',
|
||||||
editSubtitle: 'Maintain habitat recipes and possible Pokemon appearances.',
|
editSubtitle: 'Maintain habitat recipes and possible Pokemon appearances.',
|
||||||
newTitle: 'New habitat',
|
newTitle: 'New habitat',
|
||||||
@@ -859,6 +860,7 @@ export const systemWordingMessages = {
|
|||||||
habitats: {
|
habitats: {
|
||||||
title: '栖息地',
|
title: '栖息地',
|
||||||
subtitle: '查看配方和可能出现的宝可梦。',
|
subtitle: '查看配方和可能出现的宝可梦。',
|
||||||
|
detailKicker: 'Habitat Detail',
|
||||||
detailSubtitle: '栖息地详情',
|
detailSubtitle: '栖息地详情',
|
||||||
editSubtitle: '维护栖息地配方和可能出现的 Pokemon。',
|
editSubtitle: '维护栖息地配方和可能出现的 Pokemon。',
|
||||||
newTitle: '新增栖息地',
|
newTitle: '新增栖息地',
|
||||||
|
|||||||
Reference in New Issue
Block a user