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:
21
DESIGN.md
21
DESIGN.md
@@ -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 Habitat;Ideal 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.5x,Neutral 表示无加成,未配置观察时展示空状态;详情页双列列表高度受限,内容较多时每列内部独立滚动,避免 Section 被大量物品无限拉长
|
- Trading:当该 Pokemon 拥有支持 Trading 的已选特长时默认展示;分 Likes 与 Neutral 两组展示物品,Likes 表示交易价格 1.5x,Neutral 表示无加成,未配置观察时展示空状态;详情页双列列表高度受限,内容较多时每列内部独立滚动,避免 Section 被大量物品无限拉长
|
||||||
- Trading 可在详情页通过 Manage Trading Modal 维护;Modal 左侧顶部为同一行搜索框和分类下拉筛选,下面提供 Likes / Neutral 默认加入目标切换;搜索结果优先展示名称精确匹配、名称开头匹配和单词匹配的物品,再展示名称包含、分类或用途包含的物品;搜索框支持上下键切换当前结果、左右键切换默认加入组、Enter 将当前结果加入默认目标组;从左侧选择物品会加入当前默认目标组,右侧按 Likes / Neutral 分组展示已选物品,并可切换分组或移除;列表区域高度稳定,过滤结果减少时 Modal 不应抖动
|
- Trading 可在详情页通过 Manage Trading Modal 维护;Modal 左侧顶部为同一行搜索框和分类下拉筛选,下面提供 Likes / Neutral 默认加入目标切换;搜索结果优先展示名称精确匹配、名称开头匹配和单词匹配的物品,再展示名称包含、分类或用途包含的物品;搜索框支持上下键切换当前结果、左右键切换默认加入组、Enter 将当前结果加入默认目标组;从左侧选择物品会加入当前默认目标组,右侧按 Likes / Neutral 分组展示已选物品,并可切换分组或移除;列表区域高度稳定,过滤结果减少时 Modal 不应抖动
|
||||||
- 喜欢的环境
|
- 参考资料 Tab:Height / 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 在桌面端左右并排展示;每项展示物品图标,未配置图标时显示默认物品标记占位符
|
||||||
- 出现的栖息地:每行左侧展示栖息地图片,未配置图片时显示默认栖息地标记占位符
|
- 出现的栖息地:每行左侧展示栖息地图片,未配置图片时显示默认栖息地标记占位符
|
||||||
- 最后编辑信息
|
- 最后编辑信息
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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]
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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}`),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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: '拖曳排序',
|
||||||
|
|||||||
Reference in New Issue
Block a user