feat(pokemon): add opposite relationships and redesign detail view

Add description and opposite relationships to environments and favorite things
Move pokedex reference data (stats, dimensions, types) to a separate tab
Highlight core mechanics (skills, habitat, favorite things) in detail view
Update related pokemon scoring to account for opposite relationships
This commit is contained in:
2026-05-07 15:57:38 +08:00
parent a781bc559b
commit 953b90eba1
8 changed files with 489 additions and 96 deletions

View File

@@ -465,10 +465,13 @@
### 喜欢的环境 ### 喜欢的环境
- 名称 - 名称
- Description可为空用于解释该 Ideal Habitat 对 Pokemon 栖息地选择的意义
- Opposite可为空关联另一个喜欢的环境作为反义关系
### 喜欢的东西 / 标签 ### 喜欢的东西 / 标签
- 名称 - 名称
- Opposite可为空关联另一个喜欢的东西 / 标签作为反义关系
- 同时用于: - 同时用于:
- Pokemon 喜欢的东西 - Pokemon 喜欢的东西
- 物品标签 - 物品标签
@@ -588,19 +591,17 @@ Pokemon 列表功能:
Pokemon 详情页展示: Pokemon 详情页展示:
- 基本信息 - 基本信息
- 详情主内容在六维 Stats 右侧始终保留正方形图片区;已配置图片时展示居中的 Pokédex 风格图片,未配置图片时展示默认 Poké Ball 占位符;页面内不直接展示图片版本、状态或描述,用户可通过图片 Modal 查看详情 - 标题区不展示 Ideal HabitatIdeal Habitat 属于正文核心资料
- 主内容顶部按以下布局展示: - 详情主内容顶部改为左侧 Pokemon 图片、右侧 Pokemon Description已配置图片时展示居中的 Pokédex 风格图片,未配置图片时展示默认 Poké Ball 占位符;页面内不直接展示图片版本、状态或描述,用户可通过图片 Modal 查看详情。
- 左上Genus & Details无区块标题如有 Genus先展示 Genus再以分割线连接 Details 内容 - 详情页需要突出 Pokopia 机制核心要素:
- 左下Height / Weight 与 Types 按 2:1 比例并排Height / Weight 无区块标题,在 Dimension 区内左右并排展示并以中间分割线隔开每组按英制、分割线、公制、标签上下排列Types 不显示 Type 1 / Type 2 文案,上下布局并居中展示 - Skills影响栖息地选择、物品掉落和 Trading 行为
- 右侧:六维 Stats图片或默认占位符展示在 Stats 右侧 - Ideal Habitat影响栖息地选择和相关 Pokemon 对比正文中展示名称、Description 和可选 Opposite
- 六维使用 ProgressBar 展示,最大值按 150 计算。 - Favourite Things影响物品掉落、隐藏标签判断和 Trading 价格证据;正文中展示名称和可选 Opposite
- 特长
- 特长掉落物品:当该 Pokemon 拥有支持掉落物的已选特长时默认展示;已配置时展示掉落物品图标,未配置时展示空状态 - 特长掉落物品:当该 Pokemon 拥有支持掉落物的已选特长时默认展示;已配置时展示掉落物品图标,未配置时展示空状态
- Trading当该 Pokemon 拥有支持 Trading 的已选特长时默认展示;分 Likes 与 Neutral 两组展示物品Likes 表示交易价格 1.5xNeutral 表示无加成,未配置观察时展示空状态;详情页双列列表高度受限,内容较多时每列内部独立滚动,避免 Section 被大量物品无限拉长 - Trading当该 Pokemon 拥有支持 Trading 的已选特长时默认展示;分 Likes 与 Neutral 两组展示物品Likes 表示交易价格 1.5xNeutral 表示无加成,未配置观察时展示空状态;详情页双列列表高度受限,内容较多时每列内部独立滚动,避免 Section 被大量物品无限拉长
- Trading 可在详情页通过 Manage Trading Modal 维护Modal 左侧顶部为同一行搜索框和分类下拉筛选,下面提供 Likes / Neutral 默认加入目标切换搜索结果优先展示名称精确匹配、名称开头匹配和单词匹配的物品再展示名称包含、分类或用途包含的物品搜索框支持上下键切换当前结果、左右键切换默认加入组、Enter 将当前结果加入默认目标组;从左侧选择物品会加入当前默认目标组,右侧按 Likes / Neutral 分组展示已选物品,并可切换分组或移除;列表区域高度稳定,过滤结果减少时 Modal 不应抖动 - Trading 可在详情页通过 Manage Trading Modal 维护Modal 左侧顶部为同一行搜索框和分类下拉筛选,下面提供 Likes / Neutral 默认加入目标切换搜索结果优先展示名称精确匹配、名称开头匹配和单词匹配的物品再展示名称包含、分类或用途包含的物品搜索框支持上下键切换当前结果、左右键切换默认加入组、Enter 将当前结果加入默认目标组;从左侧选择物品会加入当前默认目标组,右侧按 Likes / Neutral 分组展示已选物品,并可切换分组或移除;列表区域高度稳定,过滤结果减少时 Modal 不应抖动
- 喜欢的环境 - 参考资料 TabHeight / Weight、Types 和六维 Stats 移到独立 Tab该 Tab 必须注明这些数据只是参考 Pokédex 的展示设计,不属于 Pokopia 机制;六维使用 ProgressBar 展示,最大值按 150 计算
- 喜欢的东西 - 相关 Pokemon与关联喜欢的东西的物品在桌面端左右并排展示按相同喜欢的环境优先其次按共同喜欢的东西数量从多到少排序支持按喜欢的环境筛选默认筛选当前 Pokemon 的喜欢的环境,也可切换到其他喜欢的环境或全部;每个筛选视图最多展示 6 个;每项左侧展示 Pokemon 图片或默认 Poké Ball 占位符,原列表项信息布局保持不变,第一行左侧展示名称,右侧展示特长和喜欢的环境,第二行展示喜欢的东西,并高亮共同喜欢的东西;当相关 Pokemon 的 Ideal Habitat 或 Favourite Things 与当前 Pokemon 配置的 Opposite 反义关系命中时,使用红色标记并展示 Opposite 文案,不能只依赖颜色表达
- 相关 Pokemon与关联喜欢的东西的物品在桌面端左右并排展示按相同喜欢的环境优先其次按共同喜欢的东西数量从多到少排序支持按喜欢的环境筛选默认筛选当前 Pokemon 的喜欢的环境,也可切换到其他喜欢的环境或全部;每个筛选视图最多展示 6 个;每项左侧展示 Pokemon 图片或默认 Poké Ball 占位符,原列表项信息布局保持不变,第一行左侧展示名称,右侧展示特长和喜欢的环境,第二行展示喜欢的东西,并高亮共同喜欢的东西
- 关联喜欢的东西的物品:与相关 Pokemon 在桌面端左右并排展示;每项展示物品图标,未配置图标时显示默认物品标记占位符 - 关联喜欢的东西的物品:与相关 Pokemon 在桌面端左右并排展示;每项展示物品图标,未配置图标时显示默认物品标记占位符
- 出现的栖息地:每行左侧展示栖息地图片,未配置图片时显示默认栖息地标记占位符 - 出现的栖息地:每行左侧展示栖息地图片,未配置图片时显示默认栖息地标记占位符
- 最后编辑信息 - 最后编辑信息

View File

@@ -109,11 +109,14 @@ CREATE INDEX IF NOT EXISTS user_follows_followed_created_idx
CREATE TABLE IF NOT EXISTS environments ( CREATE TABLE IF NOT EXISTS environments (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
name text NOT NULL UNIQUE, name text NOT NULL UNIQUE,
description text NOT NULL DEFAULT '',
opposite_environment_id integer REFERENCES environments(id) ON DELETE SET NULL,
sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0), sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0),
created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL, created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL, updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now(), created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now() updated_at timestamptz NOT NULL DEFAULT now(),
CHECK (opposite_environment_id IS NULL OR opposite_environment_id <> id)
); );
CREATE TABLE IF NOT EXISTS roles ( CREATE TABLE IF NOT EXISTS roles (
@@ -1071,11 +1074,13 @@ CREATE TABLE IF NOT EXISTS skills (
CREATE TABLE IF NOT EXISTS favorite_things ( CREATE TABLE IF NOT EXISTS favorite_things (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
name text NOT NULL UNIQUE, name text NOT NULL UNIQUE,
opposite_favorite_thing_id integer REFERENCES favorite_things(id) ON DELETE SET NULL,
sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0), sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0),
created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL, created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL, updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now(), created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now() updated_at timestamptz NOT NULL DEFAULT now(),
CHECK (opposite_favorite_thing_id IS NULL OR opposite_favorite_thing_id <> id)
); );
CREATE TABLE IF NOT EXISTS pokemon_types ( CREATE TABLE IF NOT EXISTS pokemon_types (
@@ -1526,6 +1531,50 @@ ALTER TABLE skills
ALTER TABLE items ALTER TABLE items
ADD COLUMN IF NOT EXISTS dyeability integer NOT NULL DEFAULT 0 CHECK (dyeability IN (0, 1, 2, 3)); ADD COLUMN IF NOT EXISTS dyeability integer NOT NULL DEFAULT 0 CHECK (dyeability IN (0, 1, 2, 3));
ALTER TABLE environments
ADD COLUMN IF NOT EXISTS description text NOT NULL DEFAULT '';
ALTER TABLE environments
ADD COLUMN IF NOT EXISTS opposite_environment_id integer;
ALTER TABLE favorite_things
ADD COLUMN IF NOT EXISTS opposite_favorite_thing_id integer;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'environments_opposite_environment_id_fkey'
) THEN
ALTER TABLE environments
ADD CONSTRAINT environments_opposite_environment_id_fkey
FOREIGN KEY (opposite_environment_id) REFERENCES environments(id) ON DELETE SET NULL;
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'environments_opposite_environment_id_check'
) THEN
ALTER TABLE environments
ADD CONSTRAINT environments_opposite_environment_id_check
CHECK (opposite_environment_id IS NULL OR opposite_environment_id <> id);
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'favorite_things_opposite_favorite_thing_id_fkey'
) THEN
ALTER TABLE favorite_things
ADD CONSTRAINT favorite_things_opposite_favorite_thing_id_fkey
FOREIGN KEY (opposite_favorite_thing_id) REFERENCES favorite_things(id) ON DELETE SET NULL;
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'favorite_things_opposite_favorite_thing_id_check'
) THEN
ALTER TABLE favorite_things
ADD CONSTRAINT favorite_things_opposite_favorite_thing_id_check
CHECK (opposite_favorite_thing_id IS NULL OR opposite_favorite_thing_id <> id);
END IF;
END $$;
UPDATE items UPDATE items
SET dyeability = CASE SET dyeability = CASE
WHEN dual_dyeable THEN 2 WHEN dual_dyeable THEN 2

View File

@@ -157,6 +157,8 @@ type ConfigDefinition = {
hasItemDrop?: boolean; hasItemDrop?: boolean;
hasTrading?: boolean; hasTrading?: boolean;
hasChangeLog?: boolean; hasChangeLog?: boolean;
hasDescription?: boolean;
oppositeColumn?: string;
}; };
type SortableContentType = 'items' | 'ancient-artifacts' | 'recipes' | 'habitats'; type SortableContentType = 'items' | 'ancient-artifacts' | 'recipes' | 'habitats';
type SortableContentDefinition = { type SortableContentDefinition = {
@@ -656,6 +658,8 @@ type DailyChecklistChangeSource = {
} & TranslationChangeSource; } & TranslationChangeSource;
type ConfigChangeSource = { type ConfigChangeSource = {
name: string; name: string;
description?: string;
opposite?: { name: string } | null;
hasItemDrop?: boolean; hasItemDrop?: boolean;
hasTrading?: boolean; hasTrading?: boolean;
changeLog?: string; changeLog?: string;
@@ -722,8 +726,8 @@ const ancientArtifactCategoryOptions = [
const configDefinitions: Record<ConfigType, ConfigDefinition> = { const configDefinitions: Record<ConfigType, ConfigDefinition> = {
'pokemon-types': { table: 'pokemon_types', entityType: 'pokemon-types' }, 'pokemon-types': { table: 'pokemon_types', entityType: 'pokemon-types' },
skills: { table: 'skills', entityType: 'skills', hasItemDrop: true, hasTrading: true }, skills: { table: 'skills', entityType: 'skills', hasItemDrop: true, hasTrading: true },
environments: { table: 'environments', entityType: 'environments' }, environments: { table: 'environments', entityType: 'environments', hasDescription: true, oppositeColumn: 'opposite_environment_id' },
'favorite-things': { table: 'favorite_things', entityType: 'favorite-things' }, 'favorite-things': { table: 'favorite_things', entityType: 'favorite-things', oppositeColumn: 'opposite_favorite_thing_id' },
'acquisition-methods': { table: 'acquisition_methods', entityType: 'acquisition-methods' }, 'acquisition-methods': { table: 'acquisition_methods', entityType: 'acquisition-methods' },
maps: { table: 'maps', entityType: 'maps' }, maps: { table: 'maps', entityType: 'maps' },
'game-versions': { table: 'game_versions', entityType: 'game-versions', hasChangeLog: true }, 'game-versions': { table: 'game_versions', entityType: 'game-versions', hasChangeLog: true },
@@ -1108,8 +1112,22 @@ function configOrder(): string {
function configSelect(definition: ConfigDefinition, locale: string): string { function configSelect(definition: ConfigDefinition, locale: string): string {
const name = localizedName(definition.entityType, 'c', locale); const name = localizedName(definition.entityType, 'c', locale);
const oppositeName = localizedName(definition.entityType, 'opposite_config', locale);
const translations = translationsSelect(definition.entityType, 'c.id'); const translations = translationsSelect(definition.entityType, 'c.id');
const columns = [`c.id`, `${name} AS name`, `c.name AS "baseName"`, `${translations} AS translations`]; const columns = [`c.id`, `${name} AS name`, `c.name AS "baseName"`, `${translations} AS translations`];
if (definition.hasDescription) {
columns.push(`c.description`);
}
if (definition.oppositeColumn) {
columns.push(
`
CASE
WHEN opposite_config.id IS NULL THEN NULL
ELSE json_build_object('id', opposite_config.id, 'name', ${oppositeName})
END AS opposite
`
);
}
if (definition.hasItemDrop) { if (definition.hasItemDrop) {
columns.push(`c.has_item_drop AS "hasItemDrop"`); columns.push(`c.has_item_drop AS "hasItemDrop"`);
} }
@@ -1122,6 +1140,14 @@ function configSelect(definition: ConfigDefinition, locale: string): string {
return columns.join(', '); return columns.join(', ');
} }
function configRelationJoins(definition: ConfigDefinition): string {
if (!definition.oppositeColumn) {
return '';
}
return `LEFT JOIN ${definition.table} opposite_config ON opposite_config.id = c.${definition.oppositeColumn}`;
}
function validationError(message: string): ValidationError { function validationError(message: string): ValidationError {
const error = new Error(message) as ValidationError; const error = new Error(message) as ValidationError;
error.statusCode = 400; error.statusCode = 400;
@@ -2070,7 +2096,7 @@ async function ensurePokemonTypeCatalog(
const changes = configEditChanges( const changes = configEditChanges(
{ table: 'pokemon_types', entityType: 'pokemon-types' }, { table: 'pokemon_types', entityType: 'pokemon-types' },
existing.rows[0], existing.rows[0],
{ name, translations, hasItemDrop: false, hasTrading: false, changeLog: '' } { name, description: '', translations, oppositeName: '', hasItemDrop: false, hasTrading: false, changeLog: '' }
); );
if (changes.length) { if (changes.length) {
await client.query( await client.query(
@@ -2636,7 +2662,9 @@ function configEditChanges(
before: ConfigChangeSource, before: ConfigChangeSource,
after: { after: {
name: string; name: string;
description: string;
translations: TranslationInput; translations: TranslationInput;
oppositeName: string;
hasItemDrop: boolean; hasItemDrop: boolean;
hasTrading: boolean; hasTrading: boolean;
changeLog: string; changeLog: string;
@@ -2645,6 +2673,12 @@ function configEditChanges(
const changes: EditChange[] = []; const changes: EditChange[] = [];
pushChange(changes, 'Name', before.name, after.name); pushChange(changes, 'Name', before.name, after.name);
pushTranslationChanges(changes, before.translations, after.translations, ['name']); pushTranslationChanges(changes, before.translations, after.translations, ['name']);
if (definition.hasDescription) {
pushChange(changes, 'Description', before.description, after.description);
}
if (definition.oppositeColumn) {
pushChange(changes, 'Opposite', before.opposite?.name ?? '', after.oppositeName);
}
if (definition.hasItemDrop) { if (definition.hasItemDrop) {
pushChange(changes, 'Has item drop', boolValue(Boolean(before.hasItemDrop)), boolValue(after.hasItemDrop)); pushChange(changes, 'Has item drop', boolValue(Boolean(before.hasItemDrop)), boolValue(after.hasItemDrop));
} }
@@ -2684,8 +2718,10 @@ function pokemonProjection(locale: string): string {
const pokemonDetails = localizedField('pokemon', 'p.id', 'p.details', 'details', locale); const pokemonDetails = localizedField('pokemon', 'p.id', 'p.details', 'details', locale);
const typeName = localizedName('pokemon-types', 'pt', locale); const typeName = localizedName('pokemon-types', 'pt', locale);
const environmentName = localizedName('environments', 'e', locale); const environmentName = localizedName('environments', 'e', locale);
const environmentOppositeName = localizedName('environments', 'environment_opposite', locale);
const skillName = localizedName('skills', 's', locale); const skillName = localizedName('skills', 's', locale);
const favoriteThingName = localizedName('favorite-things', 'ft', locale); const favoriteThingName = localizedName('favorite-things', 'ft', locale);
const favoriteThingOppositeName = localizedName('favorite-things', 'opposite_ft', locale);
return ` return `
SELECT SELECT
@@ -2715,7 +2751,16 @@ function pokemonProjection(locale: string): string {
) AS stats, ) AS stats,
${translationsSelect('pokemon', 'p.id')} AS translations, ${translationsSelect('pokemon', 'p.id')} AS translations,
${auditSelect('p', 'pokemon_created_user', 'pokemon_updated_user')}, ${auditSelect('p', 'pokemon_created_user', 'pokemon_updated_user')},
json_build_object('id', e.id, 'name', ${environmentName}) AS environment, json_build_object(
'id', e.id,
'name', ${environmentName},
'description', e.description,
'opposite',
CASE
WHEN environment_opposite.id IS NULL THEN NULL
ELSE json_build_object('id', environment_opposite.id, 'name', ${environmentOppositeName})
END
) AS environment,
COALESCE(( COALESCE((
SELECT json_agg(json_build_object('id', pt.id, 'name', ${typeName}) ORDER BY ppt.slot_order) SELECT json_agg(json_build_object('id', pt.id, 'name', ${typeName}) ORDER BY ppt.slot_order)
FROM pokemon_pokemon_types ppt FROM pokemon_pokemon_types ppt
@@ -2729,13 +2774,26 @@ function pokemonProjection(locale: string): string {
WHERE ps.pokemon_id = p.id WHERE ps.pokemon_id = p.id
), '[]'::json) AS skills, ), '[]'::json) AS skills,
COALESCE(( COALESCE((
SELECT json_agg(json_build_object('id', ft.id, 'name', ${favoriteThingName}) ORDER BY ${orderByEntity('ft')}) SELECT json_agg(
json_build_object(
'id', ft.id,
'name', ${favoriteThingName},
'opposite',
CASE
WHEN opposite_ft.id IS NULL THEN NULL
ELSE json_build_object('id', opposite_ft.id, 'name', ${favoriteThingOppositeName})
END
)
ORDER BY ${orderByEntity('ft')}
)
FROM pokemon_favorite_things pft FROM pokemon_favorite_things pft
JOIN favorite_things ft ON ft.id = pft.favorite_thing_id JOIN favorite_things ft ON ft.id = pft.favorite_thing_id
LEFT JOIN favorite_things opposite_ft ON opposite_ft.id = ft.opposite_favorite_thing_id
WHERE pft.pokemon_id = p.id WHERE pft.pokemon_id = p.id
), '[]'::json) AS favorite_things ), '[]'::json) AS favorite_things
FROM pokemon p FROM pokemon p
JOIN environments e ON e.id = p.environment_id JOIN environments e ON e.id = p.environment_id
LEFT JOIN environments environment_opposite ON environment_opposite.id = e.opposite_environment_id
${auditJoins('p', 'pokemon_created_user', 'pokemon_updated_user')} ${auditJoins('p', 'pokemon_created_user', 'pokemon_updated_user')}
`; `;
} }
@@ -5363,6 +5421,7 @@ export async function listConfig(type: ConfigType, locale = defaultLocale) {
` `
SELECT ${configSelect(definition, locale)}, ${auditSelect('c')} SELECT ${configSelect(definition, locale)}, ${auditSelect('c')}
FROM ${definition.table} c FROM ${definition.table} c
${configRelationJoins(definition)}
${auditJoins('c')} ${auditJoins('c')}
ORDER BY ${configOrder()} ORDER BY ${configOrder()}
` `
@@ -5375,6 +5434,7 @@ async function getConfigById(type: ConfigType, id: number, locale = defaultLocal
` `
SELECT ${configSelect(definition, locale)}, ${auditSelect('c')} SELECT ${configSelect(definition, locale)}, ${auditSelect('c')}
FROM ${definition.table} c FROM ${definition.table} c
${configRelationJoins(definition)}
${auditJoins('c')} ${auditJoins('c')}
WHERE c.id = $1 WHERE c.id = $1
`, `,
@@ -5386,6 +5446,8 @@ export async function createConfig(type: ConfigType, payload: Record<string, unk
const definition = configDefinitions[type]; const definition = configDefinitions[type];
const name = cleanName(payload.name); const name = cleanName(payload.name);
const translations = cleanTranslations(payload.translations, ['name']); const translations = cleanTranslations(payload.translations, ['name']);
const description = definition.hasDescription ? cleanOptionalText(payload.description) : '';
const oppositeId = definition.oppositeColumn ? cleanOptionalPositiveInteger(payload.oppositeId) : null;
const hasItemDrop = definition.hasItemDrop ? Boolean(payload.hasItemDrop) : false; const hasItemDrop = definition.hasItemDrop ? Boolean(payload.hasItemDrop) : false;
const hasTrading = definition.hasTrading ? Boolean(payload.hasTrading) : false; const hasTrading = definition.hasTrading ? Boolean(payload.hasTrading) : false;
const changeLog = definition.hasChangeLog ? cleanOptionalText(payload.changeLog) : ''; const changeLog = definition.hasChangeLog ? cleanOptionalText(payload.changeLog) : '';
@@ -5394,6 +5456,14 @@ export async function createConfig(type: ConfigType, payload: Record<string, unk
const sortOrder = await nextSortOrder(client, definition.table); const sortOrder = await nextSortOrder(client, definition.table);
const columns = ['name']; const columns = ['name'];
const values: unknown[] = [name]; const values: unknown[] = [name];
if (definition.hasDescription) {
columns.push('description');
values.push(description);
}
if (definition.oppositeColumn) {
columns.push(definition.oppositeColumn);
values.push(oppositeId);
}
if (definition.hasItemDrop) { if (definition.hasItemDrop) {
columns.push('has_item_drop'); columns.push('has_item_drop');
values.push(hasItemDrop); values.push(hasItemDrop);
@@ -5451,14 +5521,28 @@ export async function updateConfig(
const definition = configDefinitions[type]; const definition = configDefinitions[type];
const name = cleanName(payload.name); const name = cleanName(payload.name);
const translations = cleanTranslations(payload.translations, ['name']); const translations = cleanTranslations(payload.translations, ['name']);
const description = definition.hasDescription ? cleanOptionalText(payload.description) : '';
const oppositeId = definition.oppositeColumn ? cleanOptionalPositiveInteger(payload.oppositeId) : null;
const hasItemDrop = definition.hasItemDrop ? Boolean(payload.hasItemDrop) : false; const hasItemDrop = definition.hasItemDrop ? Boolean(payload.hasItemDrop) : false;
const hasTrading = definition.hasTrading ? Boolean(payload.hasTrading) : false; const hasTrading = definition.hasTrading ? Boolean(payload.hasTrading) : false;
const changeLog = definition.hasChangeLog ? cleanOptionalText(payload.changeLog) : ''; const changeLog = definition.hasChangeLog ? cleanOptionalText(payload.changeLog) : '';
const before = await getConfigById(type, id, defaultLocale); const before = await getConfigById(type, id, defaultLocale);
if (oppositeId === id) {
throw validationError('server.validation.invalidField');
}
const updated = await withTransaction(async (client) => { const updated = await withTransaction(async (client) => {
const assignments = ['name = $1']; const assignments = ['name = $1'];
const values: unknown[] = [name]; const values: unknown[] = [name];
if (definition.hasDescription) {
values.push(description);
assignments.push(`description = $${values.length}`);
}
if (definition.oppositeColumn) {
values.push(oppositeId);
assignments.push(`${definition.oppositeColumn} = $${values.length}`);
}
if (definition.hasItemDrop) { if (definition.hasItemDrop) {
values.push(hasItemDrop); values.push(hasItemDrop);
assignments.push(`has_item_drop = $${values.length}`); assignments.push(`has_item_drop = $${values.length}`);
@@ -5513,8 +5597,17 @@ export async function updateConfig(
} }
await replaceEntityTranslations(client, definition.entityType, id, translations, ['name']); await replaceEntityTranslations(client, definition.entityType, id, translations, ['name']);
const oppositeNames = definition.oppositeColumn && oppositeId ? await entityNameMap(client, definition.table, [oppositeId]) : new Map<number, string>();
const changes = before const changes = before
? configEditChanges(definition, before as ConfigChangeSource, { name, translations, hasItemDrop, hasTrading, changeLog }) ? configEditChanges(definition, before as ConfigChangeSource, {
name,
description,
translations,
oppositeName: oppositeId ? oppositeNames.get(oppositeId) ?? '' : '',
hasItemDrop,
hasTrading,
changeLog
})
: []; : [];
await recordEditLog(client, type, id, 'update', userId, changes); await recordEditLog(client, type, id, 'update', userId, changes);
return true; return true;
@@ -5637,6 +5730,7 @@ export async function getPokemon(id: number, locale = defaultLocale) {
const relatedEnvironmentName = localizedName('environments', 'related_environment', locale); const relatedEnvironmentName = localizedName('environments', 'related_environment', locale);
const relatedSkillName = localizedName('skills', 'related_skill', locale); const relatedSkillName = localizedName('skills', 'related_skill', locale);
const relatedFavoriteThingName = localizedName('favorite-things', 'related_favorite_thing', locale); const relatedFavoriteThingName = localizedName('favorite-things', 'related_favorite_thing', locale);
const relatedFavoriteThingOppositeName = localizedName('favorite-things', 'related_favorite_thing_opposite', locale);
const tradingItemName = localizedName('items', 'trading_item', locale); const tradingItemName = localizedName('items', 'trading_item', locale);
const [habitats, itemDrops, favoriteThingItems, tradingItems, relatedPokemon, editHistory, imageHistory] = await Promise.all([ const [habitats, itemDrops, favoriteThingItems, tradingItems, relatedPokemon, editHistory, imageHistory] = await Promise.all([
@@ -5718,24 +5812,56 @@ export async function getPokemon(id: number, locale = defaultLocale) {
WHERE p.id = $1 WHERE p.id = $1
), ),
current_favourites AS ( current_favourites AS (
SELECT pft.favorite_thing_id SELECT
pft.favorite_thing_id,
ft.opposite_favorite_thing_id
FROM pokemon_favorite_things pft FROM pokemon_favorite_things pft
JOIN favorite_things ft ON ft.id = pft.favorite_thing_id
WHERE pft.pokemon_id = $1 WHERE pft.pokemon_id = $1
), ),
scored_pokemon AS ( scored_pokemon AS (
SELECT SELECT
related_pokemon.id, related_pokemon.id,
(related_pokemon.environment_id = current_pokemon.environment_id) AS "environmentMatches", (related_pokemon.environment_id = current_pokemon.environment_id) AS "environmentMatches",
COUNT(current_favourites.favorite_thing_id)::integer AS "favoriteThingMatchCount" (
related_pokemon.environment_id = current_environment.opposite_environment_id
OR related_environment.opposite_environment_id = current_pokemon.environment_id
) AS "environmentIsOpposite",
COUNT(current_favourites.favorite_thing_id) FILTER (
WHERE current_favourites.favorite_thing_id = related_pokemon_favourite.favorite_thing_id
)::integer AS "favoriteThingMatchCount",
COUNT(current_favourites.favorite_thing_id) FILTER (
WHERE current_favourites.opposite_favorite_thing_id = related_pokemon_favourite.favorite_thing_id
OR related_favorite_thing.opposite_favorite_thing_id = current_favourites.favorite_thing_id
)::integer AS "favoriteThingOppositeCount"
FROM current_pokemon FROM current_pokemon
JOIN environments current_environment ON current_environment.id = current_pokemon.environment_id
JOIN pokemon related_pokemon ON related_pokemon.id <> current_pokemon.id JOIN pokemon related_pokemon ON related_pokemon.id <> current_pokemon.id
JOIN environments related_environment ON related_environment.id = related_pokemon.environment_id
LEFT JOIN pokemon_favorite_things related_pokemon_favourite LEFT JOIN pokemon_favorite_things related_pokemon_favourite
ON related_pokemon_favourite.pokemon_id = related_pokemon.id ON related_pokemon_favourite.pokemon_id = related_pokemon.id
LEFT JOIN favorite_things related_favorite_thing
ON related_favorite_thing.id = related_pokemon_favourite.favorite_thing_id
LEFT JOIN current_favourites LEFT JOIN current_favourites
ON current_favourites.favorite_thing_id = related_pokemon_favourite.favorite_thing_id ON current_favourites.favorite_thing_id = related_pokemon_favourite.favorite_thing_id
GROUP BY related_pokemon.id, related_pokemon.environment_id, current_pokemon.environment_id OR current_favourites.opposite_favorite_thing_id = related_pokemon_favourite.favorite_thing_id
OR related_favorite_thing.opposite_favorite_thing_id = current_favourites.favorite_thing_id
GROUP BY
related_pokemon.id,
related_pokemon.environment_id,
related_environment.opposite_environment_id,
current_pokemon.environment_id,
current_environment.opposite_environment_id
HAVING related_pokemon.environment_id = current_pokemon.environment_id HAVING related_pokemon.environment_id = current_pokemon.environment_id
OR COUNT(current_favourites.favorite_thing_id) > 0 OR related_pokemon.environment_id = current_environment.opposite_environment_id
OR related_environment.opposite_environment_id = current_pokemon.environment_id
OR COUNT(current_favourites.favorite_thing_id) FILTER (
WHERE current_favourites.favorite_thing_id = related_pokemon_favourite.favorite_thing_id
) > 0
OR COUNT(current_favourites.favorite_thing_id) FILTER (
WHERE current_favourites.opposite_favorite_thing_id = related_pokemon_favourite.favorite_thing_id
OR related_favorite_thing.opposite_favorite_thing_id = current_favourites.favorite_thing_id
) > 0
) )
SELECT SELECT
related_pokemon.id, related_pokemon.id,
@@ -5743,7 +5869,12 @@ export async function getPokemon(id: number, locale = defaultLocale) {
${relatedPokemonName} AS name, ${relatedPokemonName} AS name,
related_pokemon.is_event_item AS "isEventItem", related_pokemon.is_event_item AS "isEventItem",
${pokemonImageJson('related_pokemon')} AS image, ${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},
'matches', scored_pokemon."environmentMatches",
'isOpposite', scored_pokemon."environmentIsOpposite"
) AS environment,
COALESCE(( COALESCE((
SELECT json_agg( SELECT json_agg(
json_build_object( json_build_object(
@@ -5763,22 +5894,40 @@ export async function getPokemon(id: number, locale = defaultLocale) {
json_build_object( json_build_object(
'id', related_favorite_thing.id, 'id', related_favorite_thing.id,
'name', ${relatedFavoriteThingName}, 'name', ${relatedFavoriteThingName},
'opposite',
CASE
WHEN related_favorite_thing_opposite.id IS NULL THEN NULL
ELSE json_build_object('id', related_favorite_thing_opposite.id, 'name', ${relatedFavoriteThingOppositeName})
END,
'matches', EXISTS ( 'matches', EXISTS (
SELECT 1 SELECT 1
FROM current_favourites FROM current_favourites
WHERE current_favourites.favorite_thing_id = related_favorite_thing.id WHERE current_favourites.favorite_thing_id = related_favorite_thing.id
),
'isOpposite', EXISTS (
SELECT 1
FROM current_favourites
WHERE current_favourites.opposite_favorite_thing_id = related_favorite_thing.id
OR related_favorite_thing.opposite_favorite_thing_id = current_favourites.favorite_thing_id
) )
) )
ORDER BY ${orderByEntity('related_favorite_thing')} ORDER BY ${orderByEntity('related_favorite_thing')}
) )
FROM pokemon_favorite_things related_pokemon_favourite FROM pokemon_favorite_things related_pokemon_favourite
JOIN favorite_things related_favorite_thing ON related_favorite_thing.id = related_pokemon_favourite.favorite_thing_id JOIN favorite_things related_favorite_thing ON related_favorite_thing.id = related_pokemon_favourite.favorite_thing_id
LEFT JOIN favorite_things related_favorite_thing_opposite
ON related_favorite_thing_opposite.id = related_favorite_thing.opposite_favorite_thing_id
WHERE related_pokemon_favourite.pokemon_id = related_pokemon.id WHERE related_pokemon_favourite.pokemon_id = related_pokemon.id
), '[]'::json) AS favorite_things ), '[]'::json) AS favorite_things
FROM scored_pokemon FROM scored_pokemon
JOIN pokemon related_pokemon ON related_pokemon.id = scored_pokemon.id JOIN pokemon related_pokemon ON related_pokemon.id = scored_pokemon.id
JOIN environments related_environment ON related_environment.id = related_pokemon.environment_id JOIN environments related_environment ON related_environment.id = related_pokemon.environment_id
ORDER BY scored_pokemon."environmentMatches" DESC, scored_pokemon."favoriteThingMatchCount" DESC, related_pokemon.id ORDER BY
scored_pokemon."environmentMatches" DESC,
scored_pokemon."favoriteThingMatchCount" DESC,
scored_pokemon."environmentIsOpposite" DESC,
scored_pokemon."favoriteThingOppositeCount" DESC,
related_pokemon.id
`, `,
[id] [id]
), ),

View File

@@ -71,6 +71,8 @@ export interface NamedEntity {
id: number; id: number;
name: string; name: string;
baseName?: string; baseName?: string;
description?: string;
opposite?: NamedEntity | null;
translations?: TranslationMap; translations?: TranslationMap;
} }
@@ -234,9 +236,9 @@ export interface RelatedPokemon {
name: string; name: string;
isEventItem: boolean; isEventItem: boolean;
image?: PokemonImage | null; image?: PokemonImage | null;
environment: NamedEntity; environment: NamedEntity & { matches?: boolean; isOpposite?: boolean };
skills: Skill[]; skills: Skill[];
favorite_things: Array<NamedEntity & { matches: boolean }>; favorite_things: Array<NamedEntity & { matches: boolean; isOpposite?: boolean }>;
} }
export interface PokemonDetail extends Pokemon { export interface PokemonDetail extends Pokemon {
@@ -1581,7 +1583,7 @@ export const api = {
config: (type: ConfigType) => getJson<Array<Skill | GameVersion | NamedEntity>>(`/api/admin/config/${type}`), config: (type: ConfigType) => getJson<Array<Skill | GameVersion | NamedEntity>>(`/api/admin/config/${type}`),
createConfig: ( createConfig: (
type: ConfigType, type: ConfigType,
payload: { name: string; translations?: TranslationMap; hasItemDrop?: boolean; hasTrading?: boolean; changeLog?: string } payload: { name: string; translations?: TranslationMap; description?: string; oppositeId?: number | null; hasItemDrop?: boolean; hasTrading?: boolean; changeLog?: string }
) => ) =>
sendJson<Skill | GameVersion | NamedEntity>(`/api/admin/config/${type}`, 'POST', payload), sendJson<Skill | GameVersion | NamedEntity>(`/api/admin/config/${type}`, 'POST', payload),
reorderConfig: (type: ConfigType, ids: number[]) => reorderConfig: (type: ConfigType, ids: number[]) =>
@@ -1589,7 +1591,7 @@ export const api = {
updateConfig: ( updateConfig: (
type: ConfigType, type: ConfigType,
id: number, id: number,
payload: { name: string; translations?: TranslationMap; hasItemDrop?: boolean; hasTrading?: boolean; changeLog?: string } payload: { name: string; translations?: TranslationMap; description?: string; oppositeId?: number | null; hasItemDrop?: boolean; hasTrading?: boolean; changeLog?: string }
) => ) =>
sendJson<Skill | GameVersion | NamedEntity>(`/api/admin/config/${type}/${id}`, 'PUT', payload), sendJson<Skill | GameVersion | NamedEntity>(`/api/admin/config/${type}/${id}`, 'PUT', payload),
deleteConfig: (type: ConfigType, id: number) => deleteJson(`/api/admin/config/${type}/${id}`), deleteConfig: (type: ConfigType, id: number) => deleteJson(`/api/admin/config/${type}/${id}`),

View File

@@ -5756,6 +5756,105 @@ button:disabled,
color: var(--ink-soft); color: var(--ink-soft);
} }
.pokemon-description-grid {
display: grid;
grid-template-columns: minmax(220px, 340px) minmax(0, 1fr);
gap: 16px;
align-items: stretch;
}
.pokemon-description-image {
width: 100%;
min-height: 260px;
aspect-ratio: 1 / 1;
display: grid;
place-items: center;
padding: 14px;
overflow: hidden;
border: 3px solid var(--line-strong);
border-radius: var(--radius-card);
background:
linear-gradient(90deg, rgba(42, 117, 187, 0.08) 1px, transparent 1px) 0 0 / 18px 18px,
linear-gradient(rgba(42, 117, 187, 0.08) 1px, transparent 1px) 0 0 / 18px 18px,
#eef9ff;
box-shadow: var(--shadow-soft);
cursor: pointer;
}
.pokemon-description-image:not(.pokemon-description-image--placeholder) {
padding: 0;
border: 0;
background: transparent;
box-shadow: none;
}
.pokemon-description-image img {
width: 100%;
height: 100%;
object-fit: contain;
}
.pokemon-description-image--placeholder {
cursor: default;
}
.pokemon-description-card {
align-content: start;
}
.pokemon-description-card .detail-text {
max-width: 78ch;
font-size: 1rem;
line-height: 1.7;
}
.pokemon-core-grid,
.pokemon-reference-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
align-items: stretch;
}
.pokemon-core-card {
align-content: start;
}
.pokemon-core-note {
margin: 0;
}
.pokemon-core-value,
.pokemon-favourite-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
min-width: 0;
}
.pokemon-favourite-chip {
align-items: flex-start;
flex-direction: column;
gap: 3px;
}
.pokemon-chip-note,
.related-relation-label {
color: inherit;
font-size: 0.72rem;
font-weight: 950;
line-height: 1;
text-transform: uppercase;
}
.chip--danger,
.related-pokemon-row__environment--opposite,
.related-favourite-chip--opposite {
border-color: color-mix(in srgb, var(--danger) 62%, var(--line));
background: color-mix(in srgb, var(--danger) 16%, var(--surface));
color: #8f1717;
}
.pokemon-profile-grid { .pokemon-profile-grid {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) minmax(280px, 360px); grid-template-columns: minmax(0, 1fr) minmax(280px, 360px);
@@ -7976,8 +8075,11 @@ button:disabled,
.entity-profile-grid, .entity-profile-grid,
.home-hero, .home-hero,
.pokemon-image-detail, .pokemon-image-detail,
.pokemon-description-grid,
.pokemon-profile-grid, .pokemon-profile-grid,
.pokemon-profile-row, .pokemon-profile-row,
.pokemon-core-grid,
.pokemon-reference-grid,
.pokemon-related-grid, .pokemon-related-grid,
.profile-layout, .profile-layout,
.profile-layout--loading, .profile-layout--loading,

View File

@@ -93,6 +93,8 @@ type AdminGroup = 'content' | 'configuration' | 'localization' | 'access';
type AdminNavItem = { key: AdminTab; label: string; permission: string | string[] }; type AdminNavItem = { key: AdminTab; label: string; permission: string | string[] };
type AdminNavGroup = { key: AdminGroup; label: string; items: AdminNavItem[] }; type AdminNavGroup = { key: AdminGroup; label: string; items: AdminNavItem[] };
type EditableConfig = (NamedEntity | Skill | GameVersion) & { type EditableConfig = (NamedEntity | Skill | GameVersion) & {
description?: string;
opposite?: NamedEntity | null;
hasItemDrop?: boolean; hasItemDrop?: boolean;
hasTrading?: boolean; hasTrading?: boolean;
changeLog?: string; changeLog?: string;
@@ -205,12 +207,14 @@ const configTypes = computed<
supportsItemDrop?: boolean; supportsItemDrop?: boolean;
supportsTrading?: boolean; supportsTrading?: boolean;
supportsChangeLog?: boolean; supportsChangeLog?: boolean;
supportsDescription?: boolean;
supportsOpposite?: boolean;
}> }>
>(() => [ >(() => [
{ key: 'pokemon-types', label: t('config.pokemonTypes') }, { key: 'pokemon-types', label: t('config.pokemonTypes') },
{ key: 'skills', label: t('config.skills'), supportsItemDrop: true, supportsTrading: true }, { key: 'skills', label: t('config.skills'), supportsItemDrop: true, supportsTrading: true },
{ key: 'environments', label: t('config.environments') }, { key: 'environments', label: t('config.environments'), supportsDescription: true, supportsOpposite: true },
{ key: 'favorite-things', label: t('config.favoriteThings') }, { key: 'favorite-things', label: t('config.favoriteThings'), supportsOpposite: true },
{ key: 'acquisition-methods', label: t('config.acquisitionMethods') }, { key: 'acquisition-methods', label: t('config.acquisitionMethods') },
{ key: 'maps', label: t('config.maps') }, { key: 'maps', label: t('config.maps') },
{ key: 'game-versions', label: t('config.gameVersions'), supportsChangeLog: true }, { key: 'game-versions', label: t('config.gameVersions'), supportsChangeLog: true },
@@ -246,6 +250,8 @@ const message = ref('');
const configForm = ref({ const configForm = ref({
id: 0, id: 0,
name: '', name: '',
description: '',
oppositeId: '',
translations: {} as TranslationMap, translations: {} as TranslationMap,
hasItemDrop: false, hasItemDrop: false,
hasTrading: false, hasTrading: false,
@@ -346,6 +352,12 @@ const configNameInput = computed({
} }
}); });
const configNamePlaceholder = computed(() => (isConfigDefaultLocale.value ? '' : configForm.value.name)); const configNamePlaceholder = computed(() => (isConfigDefaultLocale.value ? '' : configForm.value.name));
const configOppositeOptions = computed(() => [
{ id: '', name: t('common.none') },
...configRows.value
.filter((item) => item.id !== configForm.value.id)
.map((item) => ({ id: item.id, name: item.name }))
]);
const activeConfigTab = computed({ const activeConfigTab = computed({
get: () => activeConfigType.value, get: () => activeConfigType.value,
set: (value: string) => { set: (value: string) => {
@@ -610,7 +622,7 @@ async function loadLanguages() {
} }
function resetConfigForm() { function resetConfigForm() {
configForm.value = { id: 0, name: '', translations: {}, hasItemDrop: false, hasTrading: false, changeLog: '' }; configForm.value = { id: 0, name: '', description: '', oppositeId: '', translations: {}, hasItemDrop: false, hasTrading: false, changeLog: '' };
} }
function resetChecklistForm() { function resetChecklistForm() {
@@ -718,6 +730,8 @@ function editConfig(item: EditableConfig) {
configForm.value = { configForm.value = {
id: item.id, id: item.id,
name: item.baseName ?? item.name, name: item.baseName ?? item.name,
description: item.description ?? '',
oppositeId: item.opposite ? String(item.opposite.id) : '',
translations: item.translations ?? {}, translations: item.translations ?? {},
hasItemDrop: item.hasItemDrop === true, hasItemDrop: item.hasItemDrop === true,
hasTrading: item.hasTrading === true, hasTrading: item.hasTrading === true,
@@ -1103,6 +1117,8 @@ async function saveConfig() {
const payload = { const payload = {
name: configBaseNameForSave(), name: configBaseNameForSave(),
translations: configForm.value.translations, translations: configForm.value.translations,
description: selectedConfig.value.supportsDescription ? configForm.value.description : undefined,
oppositeId: selectedConfig.value.supportsOpposite && configForm.value.oppositeId ? Number(configForm.value.oppositeId) : null,
hasItemDrop: selectedConfig.value.supportsItemDrop ? configForm.value.hasItemDrop : undefined, hasItemDrop: selectedConfig.value.supportsItemDrop ? configForm.value.hasItemDrop : undefined,
hasTrading: selectedConfig.value.supportsTrading ? configForm.value.hasTrading : undefined, hasTrading: selectedConfig.value.supportsTrading ? configForm.value.hasTrading : undefined,
changeLog: selectedConfig.value.supportsChangeLog ? configForm.value.changeLog : undefined changeLog: selectedConfig.value.supportsChangeLog ? configForm.value.changeLog : undefined
@@ -2158,9 +2174,11 @@ onMounted(() => {
<template #default="{ item }"> <template #default="{ item }">
<span class="reorderable-row-title"> <span class="reorderable-row-title">
{{ item.name }} {{ item.name }}
<span v-if="item.opposite" class="config-flag">{{ t('pages.admin.opposite') }}: {{ item.opposite.name }}</span>
<span v-if="item.hasItemDrop" class="config-flag">{{ t('pages.admin.hasItemDrop') }}</span> <span v-if="item.hasItemDrop" class="config-flag">{{ t('pages.admin.hasItemDrop') }}</span>
<span v-if="item.hasTrading" class="config-flag">{{ t('pages.admin.hasTrading') }}</span> <span v-if="item.hasTrading" class="config-flag">{{ t('pages.admin.hasTrading') }}</span>
</span> </span>
<span v-if="item.description" class="meta-line">{{ item.description }}</span>
<span class="row-actions"> <span class="row-actions">
<button v-if="can('admin.config.update')" type="button" :disabled="busy" @click="editConfig(item)"> <button v-if="can('admin.config.update')" type="button" :disabled="busy" @click="editConfig(item)">
<Icon :icon="iconEdit" class="ui-icon" aria-hidden="true" /> <Icon :icon="iconEdit" class="ui-icon" aria-hidden="true" />
@@ -2989,6 +3007,20 @@ onMounted(() => {
<label for="config-name">{{ t('common.name') }}</label> <label for="config-name">{{ t('common.name') }}</label>
<input id="config-name" v-model="configNameInput" :placeholder="configNamePlaceholder" :required="configNameRequired" /> <input id="config-name" v-model="configNameInput" :placeholder="configNamePlaceholder" :required="configNameRequired" />
</div> </div>
<div v-if="selectedConfig.supportsDescription" class="field">
<label for="config-description">{{ t('pages.admin.description') }}</label>
<textarea id="config-description" v-model="configForm.description"></textarea>
</div>
<div v-if="selectedConfig.supportsOpposite" class="field">
<label for="config-opposite">{{ t('pages.admin.opposite') }}</label>
<TagsSelect
id="config-opposite"
v-model="configForm.oppositeId"
:multiple="false"
:options="configOppositeOptions"
:placeholder="t('pages.admin.opposite')"
/>
</div>
<div v-if="selectedConfig.supportsItemDrop" class="check-row"> <div v-if="selectedConfig.supportsItemDrop" class="check-row">
<label> <label>
<input v-model="configForm.hasItemDrop" type="checkbox" /> <input v-model="configForm.hasItemDrop" type="checkbox" />

View File

@@ -276,6 +276,7 @@ const listPath = computed(() => (pokemon.value?.isEventItem ? '/event-pokemon' :
const detailKicker = computed(() => t(pokemon.value?.isEventItem ? 'pages.eventPokemon.detailKicker' : 'pages.pokemon.detailKicker')); const detailKicker = computed(() => t(pokemon.value?.isEventItem ? 'pages.eventPokemon.detailKicker' : 'pages.pokemon.detailKicker'));
const detailTabs = computed<TabOption[]>(() => [ const detailTabs = computed<TabOption[]>(() => [
{ value: 'details', label: t('common.details') }, { value: 'details', label: t('common.details') },
{ value: 'reference', label: t('pages.pokemon.referenceTab') },
{ value: 'discussion', label: t('discussion.title') }, { value: 'discussion', label: t('discussion.title') },
{ value: 'history', label: t('history.editHistory') } { value: 'history', label: t('history.editHistory') }
]); ]);
@@ -322,6 +323,12 @@ const relatedPokemonRows = computed(() => {
return rows.slice(0, relatedPokemonLimit); return rows.slice(0, relatedPokemonLimit);
} }
if (pokemon.value && selectedTab === habitatTabValue(pokemon.value.environment.id)) {
return rows
.filter((item) => habitatTabValue(item.environment.id) === selectedTab || item.environment.isOpposite)
.slice(0, relatedPokemonLimit);
}
return rows.filter((item) => habitatTabValue(item.environment.id) === selectedTab).slice(0, relatedPokemonLimit); return rows.filter((item) => habitatTabValue(item.environment.id) === selectedTab).slice(0, relatedPokemonLimit);
}); });
const typeSlotClass = computed(() => ({ const typeSlotClass = computed(() => ({
@@ -740,7 +747,7 @@ watch(initialPokemon, applyInitialPokemon, { immediate: true });
</div> </div>
</section> </section>
<section v-else class="page-stack"> <section v-else class="page-stack">
<PageHeader :title="`#${pokemon.displayId} ${pokemon.name}`" :subtitle="t('pages.pokemon.environmentPrefix', { name: pokemon.environment.name })"> <PageHeader :title="`#${pokemon.displayId} ${pokemon.name}`" :subtitle="pokemon.genus || t('pages.pokemon.detailSubtitle')">
<template #kicker>{{ detailKicker }}</template> <template #kicker>{{ detailKicker }}</template>
<template #actions> <template #actions>
<RouterLink v-if="canUpdatePokemon" class="ui-button ui-button--primary ui-button--small" :to="`/pokemon/${pokemon.id}/edit`"> <RouterLink v-if="canUpdatePokemon" class="ui-button ui-button--primary ui-button--small" :to="`/pokemon/${pokemon.id}/edit`">
@@ -758,67 +765,50 @@ watch(initialPokemon, applyInitialPokemon, { immediate: true });
<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 pokemon-profile-grid--with-image"> <div class="pokemon-description-grid">
<div class="pokemon-profile-main"> <button v-if="pokemon.image" type="button" class="pokemon-description-image" :aria-label="pokemonImageLabel()" @click="openImageModal">
<section class="detail-section pokemon-profile-card" :aria-label="t('pages.pokemon.details')"> <img :src="pokemon.image.url" :alt="pokemonImageAlt()" />
<p v-if="pokemon.genus" class="pokemon-genus">{{ pokemon.genus }}</p> </button>
<div v-if="pokemon.genus && pokemon.details.trim()" class="pokemon-profile-divider"></div> <div v-else class="pokemon-description-image pokemon-description-image--placeholder" role="img" :aria-label="t('pages.pokemon.imageEmpty')">
<PokeBallMark size="82px" />
</div>
<section class="detail-section pokemon-description-card" :aria-label="t('pages.pokemon.details')">
<h2 class="section-subtitle">{{ t('pages.pokemon.description') }}</h2>
<p v-if="pokemon.details.trim()" class="detail-text">{{ pokemon.details }}</p> <p v-if="pokemon.details.trim()" class="detail-text">{{ pokemon.details }}</p>
<p v-if="!pokemon.genus && !pokemon.details.trim()" class="meta-line">{{ t('common.none') }}</p>
</section>
<div class="pokemon-profile-row">
<section class="detail-section pokemon-profile-card" :aria-label="t('pages.pokemon.measurements')">
<div class="pokemon-measurement-display">
<div class="pokemon-measurement-item" :title="`${formatImperialHeight(pokemon.heightInches)} / ${formatMetricMeasure(pokemon.heightMeters)} m`">
<div class="pokemon-measurement-stack">
<strong class="pokemon-measurement-value">{{ formatImperialHeight(pokemon.heightInches) }}</strong>
<span class="pokemon-measurement-divider" aria-hidden="true"></span>
<strong class="pokemon-measurement-value">{{ formatMetricMeasure(pokemon.heightMeters) }} m</strong>
<span class="pokemon-measurement-label">{{ t('pages.pokemon.height') }}</span>
</div>
</div>
<div class="pokemon-measurement-item" :title="`${formatPoundsMeasure(pokemon.weightPounds)} lbs / ${formatMetricMeasure(pokemon.weightKg)} kg`">
<div class="pokemon-measurement-stack">
<strong class="pokemon-measurement-value">{{ formatPoundsMeasure(pokemon.weightPounds) }} lbs</strong>
<span class="pokemon-measurement-divider" aria-hidden="true"></span>
<strong class="pokemon-measurement-value">{{ formatMetricMeasure(pokemon.weightKg) }} kg</strong>
<span class="pokemon-measurement-label">{{ t('pages.pokemon.weight') }}</span>
</div>
</div>
</div>
</section>
<section class="detail-section pokemon-profile-card pokemon-types-card" :aria-label="t('pages.pokemon.types')">
<div v-if="pokemon.types.length" class="pokemon-type-slots" :class="typeSlotClass">
<span v-for="type in pokemon.types.slice(0, 2)" :key="type.id" class="chip pokemon-type-chip">
<img v-if="pokemonTypeIconSrc(type.id)" class="pokemon-type-chip__icon" :src="pokemonTypeIconSrc(type.id) ?? undefined" alt="" aria-hidden="true" />
<span>{{ type.name }}</span>
</span>
</div>
<p v-else class="meta-line">{{ t('common.none') }}</p> <p v-else class="meta-line">{{ t('common.none') }}</p>
</section> </section>
</div> </div>
</div>
<div class="pokemon-profile-side pokemon-profile-side--with-image"> <div class="pokemon-core-grid" :aria-label="t('pages.pokemon.coreFactors')">
<DetailSection class="pokemon-profile-stats" :title="t('pages.pokemon.statsTitle')"> <DetailSection class="pokemon-core-card" :title="t('pages.pokemon.skills')">
<PokemonStatsPanel :stats="pokemon.stats" /> <p class="meta-line pokemon-core-note">{{ t('pages.pokemon.skillsCoreNote') }}</p>
</DetailSection>
<button v-if="pokemon.image" type="button" class="pokemon-profile-image" :aria-label="pokemonImageLabel()" @click="openImageModal">
<img :src="pokemon.image.url" :alt="pokemonImageAlt()" />
</button>
<div v-else class="pokemon-profile-image pokemon-profile-image--placeholder" role="img" :aria-label="t('pages.pokemon.imageEmpty')">
<PokeBallMark size="64px" />
</div>
</div>
</div>
<DetailSection :title="t('pages.pokemon.skills')">
<EntityChips :items="pokemon.skills" /> <EntityChips :items="pokemon.skills" />
</DetailSection> </DetailSection>
<DetailSection class="pokemon-core-card" :title="t('pages.pokemon.environment')">
<p class="meta-line pokemon-core-note">{{ t('pages.pokemon.environmentCoreNote') }}</p>
<div class="pokemon-core-value">
<span class="chip">{{ pokemon.environment.name }}</span>
<span v-if="pokemon.environment.opposite" class="chip chip--danger">
{{ t('pages.pokemon.opposite') }}: {{ pokemon.environment.opposite.name }}
</span>
</div>
<p v-if="pokemon.environment.description" class="detail-text">{{ pokemon.environment.description }}</p>
</DetailSection>
<DetailSection class="pokemon-core-card" :title="t('pages.pokemon.favoriteThings')">
<p class="meta-line pokemon-core-note">{{ t('pages.pokemon.favoriteThingsCoreNote') }}</p>
<div v-if="pokemon.favorite_things.length" class="pokemon-favourite-list">
<span v-for="thing in pokemon.favorite_things" :key="thing.id" class="chip pokemon-favourite-chip">
<span>{{ thing.name }}</span>
<span v-if="thing.opposite" class="pokemon-chip-note">{{ t('pages.pokemon.opposite') }}: {{ thing.opposite.name }}</span>
</span>
</div>
<p v-else class="meta-line">{{ t('common.none') }}</p>
</DetailSection>
</div>
<DetailSection v-if="hasItemDropSkill" :title="t('pages.pokemon.skillDrops')"> <DetailSection v-if="hasItemDropSkill" :title="t('pages.pokemon.skillDrops')">
<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">
@@ -864,10 +854,6 @@ watch(initialPokemon, applyInitialPokemon, { immediate: true });
</div> </div>
</DetailSection> </DetailSection>
<DetailSection :title="t('pages.pokemon.favoriteThings')">
<EntityChips :items="pokemon.favorite_things" />
</DetailSection>
<div class="pokemon-related-grid"> <div class="pokemon-related-grid">
<DetailSection :title="t('pages.pokemon.relatedPokemon')"> <DetailSection :title="t('pages.pokemon.relatedPokemon')">
<template v-if="pokemon.relatedPokemon.length"> <template v-if="pokemon.relatedPokemon.length">
@@ -896,9 +882,13 @@ watch(initialPokemon, applyInitialPokemon, { immediate: true });
/> />
<span <span
class="chip related-pokemon-row__environment" class="chip related-pokemon-row__environment"
:class="{ 'related-pokemon-row__environment--match': related.environment.id === pokemon.environment.id }" :class="{
'related-pokemon-row__environment--match': related.environment.matches || related.environment.id === pokemon.environment.id,
'related-pokemon-row__environment--opposite': related.environment.isOpposite
}"
> >
{{ related.environment.name }} <span>{{ related.environment.name }}</span>
<span v-if="related.environment.isOpposite" class="related-relation-label">{{ t('pages.pokemon.opposite') }}</span>
</span> </span>
</div> </div>
</div> </div>
@@ -910,9 +900,10 @@ watch(initialPokemon, applyInitialPokemon, { immediate: true });
v-for="thing in related.favorite_things" v-for="thing in related.favorite_things"
:key="thing.id" :key="thing.id"
class="chip related-favourite-chip" class="chip related-favourite-chip"
:class="{ 'related-favourite-chip--match': thing.matches }" :class="{ 'related-favourite-chip--match': thing.matches, 'related-favourite-chip--opposite': thing.isOpposite }"
> >
{{ thing.name }} <span>{{ thing.name }}</span>
<span v-if="thing.isOpposite" class="related-relation-label">{{ t('pages.pokemon.opposite') }}</span>
</span> </span>
</div> </div>
</div> </div>
@@ -982,6 +973,51 @@ watch(initialPokemon, applyInitialPokemon, { immediate: true });
</DetailSection> </DetailSection>
</div> </div>
<div v-else-if="detailTab === 'reference'" class="detail-grid detail-grid--stack">
<DetailSection :title="t('pages.pokemon.referenceData')">
<p class="meta-line">{{ t('pages.pokemon.pokedexReferenceNote') }}</p>
</DetailSection>
<div class="pokemon-reference-grid">
<section class="detail-section pokemon-profile-card" :aria-label="t('pages.pokemon.measurements')">
<h2 class="section-subtitle">{{ t('pages.pokemon.measurements') }}</h2>
<div class="pokemon-measurement-display">
<div class="pokemon-measurement-item" :title="`${formatImperialHeight(pokemon.heightInches)} / ${formatMetricMeasure(pokemon.heightMeters)} m`">
<div class="pokemon-measurement-stack">
<strong class="pokemon-measurement-value">{{ formatImperialHeight(pokemon.heightInches) }}</strong>
<span class="pokemon-measurement-divider" aria-hidden="true"></span>
<strong class="pokemon-measurement-value">{{ formatMetricMeasure(pokemon.heightMeters) }} m</strong>
<span class="pokemon-measurement-label">{{ t('pages.pokemon.height') }}</span>
</div>
</div>
<div class="pokemon-measurement-item" :title="`${formatPoundsMeasure(pokemon.weightPounds)} lbs / ${formatMetricMeasure(pokemon.weightKg)} kg`">
<div class="pokemon-measurement-stack">
<strong class="pokemon-measurement-value">{{ formatPoundsMeasure(pokemon.weightPounds) }} lbs</strong>
<span class="pokemon-measurement-divider" aria-hidden="true"></span>
<strong class="pokemon-measurement-value">{{ formatMetricMeasure(pokemon.weightKg) }} kg</strong>
<span class="pokemon-measurement-label">{{ t('pages.pokemon.weight') }}</span>
</div>
</div>
</div>
</section>
<section class="detail-section pokemon-profile-card pokemon-types-card" :aria-label="t('pages.pokemon.types')">
<h2 class="section-subtitle">{{ t('pages.pokemon.types') }}</h2>
<div v-if="pokemon.types.length" class="pokemon-type-slots" :class="typeSlotClass">
<span v-for="type in pokemon.types.slice(0, 2)" :key="type.id" class="chip pokemon-type-chip">
<img v-if="pokemonTypeIconSrc(type.id)" class="pokemon-type-chip__icon" :src="pokemonTypeIconSrc(type.id) ?? undefined" alt="" aria-hidden="true" />
<span>{{ type.name }}</span>
</span>
</div>
<p v-else class="meta-line">{{ t('common.none') }}</p>
</section>
<DetailSection class="pokemon-profile-stats" :title="t('pages.pokemon.statsTitle')">
<PokemonStatsPanel :stats="pokemon.stats" />
</DetailSection>
</div>
</div>
<div v-else-if="detailTab === 'discussion'" class="detail-tab-panel"> <div v-else-if="detailTab === 'discussion'" class="detail-tab-panel">
<EntityDiscussionPanel entity-type="pokemon" :entity-id="pokemon.id" /> <EntityDiscussionPanel entity-type="pokemon" :entity-id="pokemon.id" />
</div> </div>

View File

@@ -552,6 +552,7 @@ export const systemWordingMessages = {
subtitle: 'Search Pokemon and filter by specialities, ideal habitat, and favourites.', subtitle: 'Search Pokemon and filter by specialities, ideal habitat, and favourites.',
listKicker: 'Pokédex', listKicker: 'Pokédex',
detailKicker: 'Pokédex Detail', detailKicker: 'Pokédex Detail',
detailSubtitle: 'Pokemon profile',
editKicker: 'Pokédex Edit', editKicker: 'Pokédex Edit',
editSubtitle: 'Maintain Pokemon profile, details, types, stats, specialities, and favourites.', editSubtitle: 'Maintain Pokemon profile, details, types, stats, specialities, and favourites.',
editSections: 'Pokemon edit sections', editSections: 'Pokemon edit sections',
@@ -588,6 +589,15 @@ export const systemWordingMessages = {
loadingEdit: 'Loading Pokemon editor', loadingEdit: 'Loading Pokemon editor',
environmentPrefix: 'Ideal Habitat: {name}', environmentPrefix: 'Ideal Habitat: {name}',
details: 'Details', details: 'Details',
description: 'Pokemon Description',
referenceTab: 'Pokédex reference',
referenceData: 'Pokédex reference data',
pokedexReferenceNote: 'Stats, height, weight, and types follow the Pokédex-style reference design only. They are not Pokopia mechanics.',
coreFactors: 'Core factors',
skillsCoreNote: 'Affects habitat selection, item drops, and Trading behavior.',
environmentCoreNote: 'Affects habitat selection and related Pokemon comparison.',
favoriteThingsCoreNote: 'Affects item drops, hidden item tags, and Trading price evidence.',
opposite: 'Opposite',
genus: 'Genus', genus: 'Genus',
height: 'Height', height: 'Height',
heightInput: 'Height (in)', heightInput: 'Height (in)',
@@ -1129,6 +1139,7 @@ export const systemWordingMessages = {
editConfig: 'Edit {name}', editConfig: 'Edit {name}',
hasItemDrop: 'Has item drop', hasItemDrop: 'Has item drop',
hasTrading: 'Has trading', hasTrading: 'Has trading',
opposite: 'Opposite',
changeLog: 'ChangeLog', changeLog: 'ChangeLog',
dragSort: 'Drag to reorder: {name}', dragSort: 'Drag to reorder: {name}',
dragSortTitle: 'Drag to reorder', dragSortTitle: 'Drag to reorder',
@@ -1966,6 +1977,7 @@ export const systemWordingMessages = {
subtitle: '搜索宝可梦,并按特长、环境、喜欢的东西筛选。', subtitle: '搜索宝可梦,并按特长、环境、喜欢的东西筛选。',
listKicker: 'Pokédex', listKicker: 'Pokédex',
detailKicker: 'Pokédex Detail', detailKicker: 'Pokédex Detail',
detailSubtitle: 'Pokemon 资料',
editKicker: 'Pokédex Edit', editKicker: 'Pokédex Edit',
editSubtitle: '维护 Pokemon 介绍、属性、六维、特长和喜欢的东西。', editSubtitle: '维护 Pokemon 介绍、属性、六维、特长和喜欢的东西。',
editSections: 'Pokemon 编辑分区', editSections: 'Pokemon 编辑分区',
@@ -2002,6 +2014,15 @@ export const systemWordingMessages = {
loadingEdit: '正在加载 Pokemon 编辑内容', loadingEdit: '正在加载 Pokemon 编辑内容',
environmentPrefix: '喜欢的环境:{name}', environmentPrefix: '喜欢的环境:{name}',
details: '介绍', details: '介绍',
description: 'Pokemon Description',
referenceTab: 'Pokédex 参考',
referenceData: 'Pokédex 参考数据',
pokedexReferenceNote: '六维、身高体重和属性仅参考 Pokédex 的展示设计,不属于 Pokopia 机制。',
coreFactors: '核心要素',
skillsCoreNote: '影响栖息地选择、物品掉落和 Trading 行为。',
environmentCoreNote: '影响栖息地选择和相关 Pokemon 对比。',
favoriteThingsCoreNote: '影响物品掉落、隐藏标签判断和 Trading 价格证据。',
opposite: '反义',
genus: '分类', genus: '分类',
height: '身高', height: '身高',
heightInput: '身高in', heightInput: '身高in',
@@ -2543,6 +2564,7 @@ export const systemWordingMessages = {
editConfig: '编辑{name}', editConfig: '编辑{name}',
hasItemDrop: '有掉落物', hasItemDrop: '有掉落物',
hasTrading: '有 Trading', hasTrading: '有 Trading',
opposite: '反义词',
changeLog: 'ChangeLog', changeLog: 'ChangeLog',
dragSort: '拖曳排序:{name}', dragSort: '拖曳排序:{name}',
dragSortTitle: '拖曳排序', dragSortTitle: '拖曳排序',