diff --git a/DESIGN.md b/DESIGN.md index 9223b6e..9dfd5ac 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -465,10 +465,13 @@ ### 喜欢的环境 - 名称 +- Description:可为空,用于解释该 Ideal Habitat 对 Pokemon 栖息地选择的意义 +- Opposite:可为空,关联另一个喜欢的环境作为反义关系 ### 喜欢的东西 / 标签 - 名称 +- Opposite:可为空,关联另一个喜欢的东西 / 标签作为反义关系 - 同时用于: - Pokemon 喜欢的东西 - 物品标签 @@ -588,19 +591,17 @@ Pokemon 列表功能: Pokemon 详情页展示: - 基本信息 -- 详情主内容在六维 Stats 右侧始终保留正方形图片区;已配置图片时展示居中的 Pokédex 风格图片,未配置图片时展示默认 Poké Ball 占位符;页面内不直接展示图片版本、状态或描述,用户可通过图片 Modal 查看详情。 -- 主内容顶部按以下布局展示: - - 左上:Genus & Details;无区块标题;如有 Genus,先展示 Genus,再以分割线连接 Details 内容 - - 左下:Height / Weight 与 Types 按 2:1 比例并排;Height / Weight 无区块标题,在 Dimension 区内左右并排展示并以中间分割线隔开,每组按英制、分割线、公制、标签上下排列;Types 不显示 Type 1 / Type 2 文案,上下布局并居中展示 - - 右侧:六维 Stats;图片或默认占位符展示在 Stats 右侧 -- 六维使用 ProgressBar 展示,最大值按 150 计算。 -- 特长 +- 标题区不展示 Ideal Habitat;Ideal Habitat 属于正文核心资料。 +- 详情主内容顶部改为左侧 Pokemon 图片、右侧 Pokemon Description;已配置图片时展示居中的 Pokédex 风格图片,未配置图片时展示默认 Poké Ball 占位符;页面内不直接展示图片版本、状态或描述,用户可通过图片 Modal 查看详情。 +- 详情页需要突出 Pokopia 机制核心要素: + - Skills:影响栖息地选择、物品掉落和 Trading 行为 + - Ideal Habitat:影响栖息地选择和相关 Pokemon 对比;正文中展示名称、Description 和可选 Opposite + - Favourite Things:影响物品掉落、隐藏标签判断和 Trading 价格证据;正文中展示名称和可选 Opposite - 特长掉落物品:当该 Pokemon 拥有支持掉落物的已选特长时默认展示;已配置时展示掉落物品图标,未配置时展示空状态 - Trading:当该 Pokemon 拥有支持 Trading 的已选特长时默认展示;分 Likes 与 Neutral 两组展示物品,Likes 表示交易价格 1.5x,Neutral 表示无加成,未配置观察时展示空状态;详情页双列列表高度受限,内容较多时每列内部独立滚动,避免 Section 被大量物品无限拉长 - Trading 可在详情页通过 Manage Trading Modal 维护;Modal 左侧顶部为同一行搜索框和分类下拉筛选,下面提供 Likes / Neutral 默认加入目标切换;搜索结果优先展示名称精确匹配、名称开头匹配和单词匹配的物品,再展示名称包含、分类或用途包含的物品;搜索框支持上下键切换当前结果、左右键切换默认加入组、Enter 将当前结果加入默认目标组;从左侧选择物品会加入当前默认目标组,右侧按 Likes / Neutral 分组展示已选物品,并可切换分组或移除;列表区域高度稳定,过滤结果减少时 Modal 不应抖动 -- 喜欢的环境 -- 喜欢的东西 -- 相关 Pokemon:与关联喜欢的东西的物品在桌面端左右并排展示;按相同喜欢的环境优先,其次按共同喜欢的东西数量从多到少排序;支持按喜欢的环境筛选,默认筛选当前 Pokemon 的喜欢的环境,也可切换到其他喜欢的环境或全部;每个筛选视图最多展示 6 个;每项左侧展示 Pokemon 图片或默认 Poké Ball 占位符,原列表项信息布局保持不变,第一行左侧展示名称,右侧展示特长和喜欢的环境,第二行展示喜欢的东西,并高亮共同喜欢的东西 +- 参考资料 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 在桌面端左右并排展示;每项展示物品图标,未配置图标时显示默认物品标记占位符 - 出现的栖息地:每行左侧展示栖息地图片,未配置图片时显示默认栖息地标记占位符 - 最后编辑信息 diff --git a/backend/db/schema.sql b/backend/db/schema.sql index 5f85cd2..67fb1aa 100644 --- a/backend/db/schema.sql +++ b/backend/db/schema.sql @@ -109,11 +109,14 @@ CREATE INDEX IF NOT EXISTS user_follows_followed_created_idx CREATE TABLE IF NOT EXISTS environments ( id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, 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), created_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(), - 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 ( @@ -1071,11 +1074,13 @@ CREATE TABLE IF NOT EXISTS skills ( CREATE TABLE IF NOT EXISTS favorite_things ( id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, 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), created_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(), - 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 ( @@ -1526,6 +1531,50 @@ ALTER TABLE skills ALTER TABLE items 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 SET dyeability = CASE WHEN dual_dyeable THEN 2 diff --git a/backend/src/queries.ts b/backend/src/queries.ts index 27a4b5b..fd2be93 100644 --- a/backend/src/queries.ts +++ b/backend/src/queries.ts @@ -157,6 +157,8 @@ type ConfigDefinition = { hasItemDrop?: boolean; hasTrading?: boolean; hasChangeLog?: boolean; + hasDescription?: boolean; + oppositeColumn?: string; }; type SortableContentType = 'items' | 'ancient-artifacts' | 'recipes' | 'habitats'; type SortableContentDefinition = { @@ -656,6 +658,8 @@ type DailyChecklistChangeSource = { } & TranslationChangeSource; type ConfigChangeSource = { name: string; + description?: string; + opposite?: { name: string } | null; hasItemDrop?: boolean; hasTrading?: boolean; changeLog?: string; @@ -722,8 +726,8 @@ const ancientArtifactCategoryOptions = [ const configDefinitions: Record = { 'pokemon-types': { table: 'pokemon_types', entityType: 'pokemon-types' }, skills: { table: 'skills', entityType: 'skills', hasItemDrop: true, hasTrading: true }, - environments: { table: 'environments', entityType: 'environments' }, - 'favorite-things': { table: 'favorite_things', entityType: 'favorite-things' }, + environments: { table: 'environments', entityType: 'environments', hasDescription: true, oppositeColumn: 'opposite_environment_id' }, + 'favorite-things': { table: 'favorite_things', entityType: 'favorite-things', oppositeColumn: 'opposite_favorite_thing_id' }, 'acquisition-methods': { table: 'acquisition_methods', entityType: 'acquisition-methods' }, maps: { table: 'maps', entityType: 'maps' }, 'game-versions': { table: 'game_versions', entityType: 'game-versions', hasChangeLog: true }, @@ -1108,8 +1112,22 @@ function configOrder(): string { function configSelect(definition: ConfigDefinition, locale: string): string { const name = localizedName(definition.entityType, 'c', locale); + const oppositeName = localizedName(definition.entityType, 'opposite_config', locale); const translations = translationsSelect(definition.entityType, 'c.id'); 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) { columns.push(`c.has_item_drop AS "hasItemDrop"`); } @@ -1122,6 +1140,14 @@ function configSelect(definition: ConfigDefinition, locale: string): string { 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 { const error = new Error(message) as ValidationError; error.statusCode = 400; @@ -2070,7 +2096,7 @@ async function ensurePokemonTypeCatalog( const changes = configEditChanges( { table: 'pokemon_types', entityType: 'pokemon-types' }, existing.rows[0], - { name, translations, hasItemDrop: false, hasTrading: false, changeLog: '' } + { name, description: '', translations, oppositeName: '', hasItemDrop: false, hasTrading: false, changeLog: '' } ); if (changes.length) { await client.query( @@ -2636,7 +2662,9 @@ function configEditChanges( before: ConfigChangeSource, after: { name: string; + description: string; translations: TranslationInput; + oppositeName: string; hasItemDrop: boolean; hasTrading: boolean; changeLog: string; @@ -2645,6 +2673,12 @@ function configEditChanges( const changes: EditChange[] = []; pushChange(changes, 'Name', before.name, after.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) { 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 typeName = localizedName('pokemon-types', 'pt', locale); const environmentName = localizedName('environments', 'e', locale); + const environmentOppositeName = localizedName('environments', 'environment_opposite', locale); const skillName = localizedName('skills', 's', locale); const favoriteThingName = localizedName('favorite-things', 'ft', locale); + const favoriteThingOppositeName = localizedName('favorite-things', 'opposite_ft', locale); return ` SELECT @@ -2715,7 +2751,16 @@ function pokemonProjection(locale: string): string { ) AS stats, ${translationsSelect('pokemon', 'p.id')} AS translations, ${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(( SELECT json_agg(json_build_object('id', pt.id, 'name', ${typeName}) ORDER BY ppt.slot_order) FROM pokemon_pokemon_types ppt @@ -2729,13 +2774,26 @@ function pokemonProjection(locale: string): string { WHERE ps.pokemon_id = p.id ), '[]'::json) AS skills, 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 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 ), '[]'::json) AS favorite_things FROM pokemon p 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')} `; } @@ -5363,6 +5421,7 @@ export async function listConfig(type: ConfigType, locale = defaultLocale) { ` SELECT ${configSelect(definition, locale)}, ${auditSelect('c')} FROM ${definition.table} c + ${configRelationJoins(definition)} ${auditJoins('c')} ORDER BY ${configOrder()} ` @@ -5375,6 +5434,7 @@ async function getConfigById(type: ConfigType, id: number, locale = defaultLocal ` SELECT ${configSelect(definition, locale)}, ${auditSelect('c')} FROM ${definition.table} c + ${configRelationJoins(definition)} ${auditJoins('c')} WHERE c.id = $1 `, @@ -5386,6 +5446,8 @@ export async function createConfig(type: ConfigType, payload: Record { const assignments = ['name = $1']; 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) { values.push(hasItemDrop); assignments.push(`has_item_drop = $${values.length}`); @@ -5513,8 +5597,17 @@ export async function updateConfig( } await replaceEntityTranslations(client, definition.entityType, id, translations, ['name']); + const oppositeNames = definition.oppositeColumn && oppositeId ? await entityNameMap(client, definition.table, [oppositeId]) : new Map(); 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); return true; @@ -5637,6 +5730,7 @@ export async function getPokemon(id: number, locale = defaultLocale) { const relatedEnvironmentName = localizedName('environments', 'related_environment', locale); const relatedSkillName = localizedName('skills', 'related_skill', 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 [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 ), current_favourites AS ( - SELECT pft.favorite_thing_id + SELECT + pft.favorite_thing_id, + ft.opposite_favorite_thing_id FROM pokemon_favorite_things pft + JOIN favorite_things ft ON ft.id = pft.favorite_thing_id WHERE pft.pokemon_id = $1 ), scored_pokemon AS ( SELECT related_pokemon.id, (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 + JOIN environments current_environment ON current_environment.id = current_pokemon.environment_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 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 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 - 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 related_pokemon.id, @@ -5743,7 +5869,12 @@ export async function getPokemon(id: number, locale = defaultLocale) { ${relatedPokemonName} AS name, related_pokemon.is_event_item AS "isEventItem", ${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(( SELECT json_agg( json_build_object( @@ -5763,22 +5894,40 @@ export async function getPokemon(id: number, locale = defaultLocale) { json_build_object( 'id', related_favorite_thing.id, '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 ( SELECT 1 FROM current_favourites 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')} ) FROM pokemon_favorite_things related_pokemon_favourite 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 ), '[]'::json) AS favorite_things FROM scored_pokemon JOIN pokemon related_pokemon ON related_pokemon.id = scored_pokemon.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] ), diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 5e80a76..0411338 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -71,6 +71,8 @@ export interface NamedEntity { id: number; name: string; baseName?: string; + description?: string; + opposite?: NamedEntity | null; translations?: TranslationMap; } @@ -234,9 +236,9 @@ export interface RelatedPokemon { name: string; isEventItem: boolean; image?: PokemonImage | null; - environment: NamedEntity; + environment: NamedEntity & { matches?: boolean; isOpposite?: boolean }; skills: Skill[]; - favorite_things: Array; + favorite_things: Array; } export interface PokemonDetail extends Pokemon { @@ -1581,7 +1583,7 @@ export const api = { config: (type: ConfigType) => getJson>(`/api/admin/config/${type}`), createConfig: ( 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(`/api/admin/config/${type}`, 'POST', payload), reorderConfig: (type: ConfigType, ids: number[]) => @@ -1589,7 +1591,7 @@ export const api = { updateConfig: ( type: ConfigType, 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(`/api/admin/config/${type}/${id}`, 'PUT', payload), deleteConfig: (type: ConfigType, id: number) => deleteJson(`/api/admin/config/${type}/${id}`), diff --git a/frontend/src/styles/main.css b/frontend/src/styles/main.css index c64d82f..384c919 100644 --- a/frontend/src/styles/main.css +++ b/frontend/src/styles/main.css @@ -5756,6 +5756,105 @@ button:disabled, 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 { display: grid; grid-template-columns: minmax(0, 1fr) minmax(280px, 360px); @@ -7976,8 +8075,11 @@ button:disabled, .entity-profile-grid, .home-hero, .pokemon-image-detail, + .pokemon-description-grid, .pokemon-profile-grid, .pokemon-profile-row, + .pokemon-core-grid, + .pokemon-reference-grid, .pokemon-related-grid, .profile-layout, .profile-layout--loading, diff --git a/frontend/src/views/AdminView.vue b/frontend/src/views/AdminView.vue index 1427d40..8f37a63 100644 --- a/frontend/src/views/AdminView.vue +++ b/frontend/src/views/AdminView.vue @@ -93,6 +93,8 @@ type AdminGroup = 'content' | 'configuration' | 'localization' | 'access'; type AdminNavItem = { key: AdminTab; label: string; permission: string | string[] }; type AdminNavGroup = { key: AdminGroup; label: string; items: AdminNavItem[] }; type EditableConfig = (NamedEntity | Skill | GameVersion) & { + description?: string; + opposite?: NamedEntity | null; hasItemDrop?: boolean; hasTrading?: boolean; changeLog?: string; @@ -205,12 +207,14 @@ const configTypes = computed< supportsItemDrop?: boolean; supportsTrading?: boolean; supportsChangeLog?: boolean; + supportsDescription?: boolean; + supportsOpposite?: boolean; }> >(() => [ { key: 'pokemon-types', label: t('config.pokemonTypes') }, { key: 'skills', label: t('config.skills'), supportsItemDrop: true, supportsTrading: true }, - { key: 'environments', label: t('config.environments') }, - { key: 'favorite-things', label: t('config.favoriteThings') }, + { key: 'environments', label: t('config.environments'), supportsDescription: true, supportsOpposite: true }, + { key: 'favorite-things', label: t('config.favoriteThings'), supportsOpposite: true }, { key: 'acquisition-methods', label: t('config.acquisitionMethods') }, { key: 'maps', label: t('config.maps') }, { key: 'game-versions', label: t('config.gameVersions'), supportsChangeLog: true }, @@ -246,6 +250,8 @@ const message = ref(''); const configForm = ref({ id: 0, name: '', + description: '', + oppositeId: '', translations: {} as TranslationMap, hasItemDrop: false, hasTrading: false, @@ -346,6 +352,12 @@ const configNameInput = computed({ } }); 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({ get: () => activeConfigType.value, set: (value: string) => { @@ -610,7 +622,7 @@ async function loadLanguages() { } 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() { @@ -718,6 +730,8 @@ function editConfig(item: EditableConfig) { configForm.value = { id: item.id, name: item.baseName ?? item.name, + description: item.description ?? '', + oppositeId: item.opposite ? String(item.opposite.id) : '', translations: item.translations ?? {}, hasItemDrop: item.hasItemDrop === true, hasTrading: item.hasTrading === true, @@ -1103,6 +1117,8 @@ async function saveConfig() { const payload = { name: configBaseNameForSave(), 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, hasTrading: selectedConfig.value.supportsTrading ? configForm.value.hasTrading : undefined, changeLog: selectedConfig.value.supportsChangeLog ? configForm.value.changeLog : undefined @@ -2158,9 +2174,11 @@ onMounted(() => {