feat(pokemon): add CSV data fetch to populate edit form

Allow users to search and fetch Pokemon data from local CSV files
Auto-populate basic fields, stats, types, and translations
Add type icons to Pokemon detail and list views
This commit is contained in:
2026-05-02 11:02:02 +08:00
parent b0d18a845d
commit e8e20539c9
16 changed files with 4226 additions and 14 deletions

6
.dockerignore Normal file
View File

@@ -0,0 +1,6 @@
.git
**/node_modules
**/dist
**/*.log
**/.env
frontend

View File

@@ -214,6 +214,15 @@ Pokemon 可配置:
Pokemon 编辑表单使用标签页组织字段: Pokemon 编辑表单使用标签页组织字段:
- 编辑表单提供 Fetch data 功能:
- 已验证用户可输入 data identifier 或 Pokemon ID从仓库 `data/` CSV 查询基础资料并填入当前表单。
- Fetch 输入框提供 data 列表搜索,搜索范围包含 Pokemon ID、identifier、当前语言名称和默认语言名称结果只展示 `#ID`、名称和 identifier。
- Fetch 搜索不使用防抖或节流;前端在每次新搜索时取消上一条搜索请求,并且只渲染最新请求结果。
- Fetch 只填入 CSV 可提供的字段ID、名称、Genus、Height、Weight、Types、六维和名称/Genus 翻译;不填入 Details、喜欢的环境、特长、特长掉落物品或喜欢的东西。
- Fetch 不直接创建或更新 Pokemon用户仍需通过 Save 保存,保存时沿用现有编辑审计。
- Fetch 根据 `languages.code` 自动匹配 CSV 语言列:`en``ja``ko``fr``de``es``it` 使用同名列;`zh-CN` / `zh-SG` 等简体语言使用 `zh_hans``zh-TW` / `zh-HK` / `zh-MO` 使用 `zh_hant`
- Fetch 会自动确保 canonical Pokemon Types 存在于 `pokemon_types`Type ID 与 `data/localized_type_name.csv``frontend/public/types` 图标文件保持一致;用户不需要为 Fetch 手工创建 Type 配置。
- Type 展示使用 `frontend/public/types/small/{typeId}.png` 图标并保留文字名称。
- 基础标签页: - 基础标签页:
- 第一行ID、名称 - 第一行ID、名称
- 第二行:喜欢的环境、特长 - 第二行:喜欢的环境、特长
@@ -501,6 +510,8 @@ API 暴露边界:
已验证用户编辑 API 已验证用户编辑 API
- Pokemon、栖息地、物品、材料单的创建、更新、删除。 - Pokemon、栖息地、物品、材料单的创建、更新、删除。
- `GET /api/pokemon/fetch-options`:按搜索词返回 Pokemon CSV data 搜索结果;需要已验证用户;只返回 `id``identifier``name`
- `POST /api/pokemon/fetch`:按 data identifier 或 Pokemon ID 查询 CSV 资料并填充 Pokemon 编辑表单;需要已验证用户;不直接保存 Pokemon。
- Life Post 的创建,以及作者本人对 Life Post 的更新、删除。 - Life Post 的创建,以及作者本人对 Life Post 的更新、删除。
- `POST /api/life-posts` - `POST /api/life-posts`
- `PUT /api/life-posts/:id` - `PUT /api/life-posts/:id`

View File

@@ -1,8 +1,9 @@
FROM node:22-alpine FROM node:22-alpine
WORKDIR /app WORKDIR /app
COPY package.json ./ COPY backend/package.json ./
RUN corepack enable && pnpm install RUN corepack enable && pnpm install
COPY . . COPY backend/. .
COPY data ./data
EXPOSE 3001 EXPOSE 3001
CMD ["pnpm", "run", "start"] CMD ["pnpm", "run", "start"]

View File

@@ -1,6 +1,9 @@
import { parseIdList, parseMatchMode, sqlForRelationFilter } from './filter.ts'; import { parseIdList, parseMatchMode, sqlForRelationFilter } from './filter.ts';
import { pool, query, queryOne } from './db.ts'; import { pool, query, queryOne } from './db.ts';
import { Buffer } from 'node:buffer'; import { Buffer } from 'node:buffer';
import { readFile } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import type { PoolClient } from 'pg'; import type { PoolClient } from 'pg';
type QueryValue = string | string[] | undefined; type QueryValue = string | string[] | undefined;
@@ -83,6 +86,34 @@ type PokemonPayload = {
skillItemDrops: SkillItemDrop[]; skillItemDrops: SkillItemDrop[];
}; };
type PokemonFetchResult = {
id: number;
identifier: string;
name: string;
genus: string;
heightInches: number;
weightPounds: number;
translations: TranslationInput;
typeIds: number[];
stats: PokemonStats;
};
type PokemonFetchOption = {
id: number;
identifier: string;
name: string;
};
type CsvRow = Record<string, string>;
type PokemonCsvData = {
pokemonRows: CsvRow[];
pokemonByLookup: Map<string, CsvRow>;
namesByPokemonId: Map<number, CsvRow>;
genusByPokemonId: Map<number, CsvRow>;
typesById: Map<number, CsvRow>;
canonicalTypeRows: CsvRow[];
};
type ItemPayload = { type ItemPayload = {
name: string; name: string;
translations: TranslationInput; translations: TranslationInput;
@@ -257,6 +288,7 @@ const localePattern = /^[a-z]{2}(-[A-Z]{2})?$/;
const defaultLifePostLimit = 20; const defaultLifePostLimit = 20;
const maxLifePostLimit = 50; const maxLifePostLimit = 50;
const lifeReactionTypes = ['like', 'helpful', 'fun', 'thanks'] as const; const lifeReactionTypes = ['like', 'helpful', 'fun', 'thanks'] as const;
const pokemonTypeIconIds = new Set(Array.from({ length: 19 }, (_value, index) => index + 1));
const pokemonStatLabels: Array<{ key: keyof PokemonStats; label: string }> = [ const pokemonStatLabels: Array<{ key: keyof PokemonStats; label: string }> = [
{ key: 'hp', label: 'HP' }, { key: 'hp', label: 'HP' },
{ key: 'attack', label: 'Attack' }, { key: 'attack', label: 'Attack' },
@@ -292,6 +324,8 @@ const discussionEntityDefinitions: Record<DiscussionEntityType, DiscussionEntity
habitats: { table: 'habitats' } habitats: { table: 'habitats' }
}; };
let pokemonCsvDataCache: Promise<PokemonCsvData> | null = null;
function asString(value: QueryValue): string | undefined { function asString(value: QueryValue): string | undefined {
return Array.isArray(value) ? value[0] : value; return Array.isArray(value) ? value[0] : value;
} }
@@ -656,7 +690,7 @@ function requireLanguageCode(value: unknown): string {
} }
export async function listLanguages(includeDisabled = false) { export async function listLanguages(includeDisabled = false) {
return query( return query<LanguagePayload>(
` `
SELECT code, name, enabled, is_default AS "isDefault", sort_order AS "sortOrder" SELECT code, name, enabled, is_default AS "isDefault", sort_order AS "sortOrder"
FROM languages FROM languages
@@ -786,6 +820,393 @@ export async function reorderLanguages(payload: Record<string, unknown>) {
return listLanguages(true); return listLanguages(true);
} }
function parseCsv(content: string, fileName: string): CsvRow[] {
const rows: string[][] = [];
let row: string[] = [];
let cell = '';
let inQuotes = false;
for (let index = 0; index < content.length; index += 1) {
const char = content[index];
if (inQuotes) {
if (char === '"' && content[index + 1] === '"') {
cell += '"';
index += 1;
} else if (char === '"') {
inQuotes = false;
} else {
cell += char;
}
continue;
}
if (char === '"') {
inQuotes = true;
} else if (char === ',') {
row.push(cell);
cell = '';
} else if (char === '\n') {
row.push(cell);
if (row.some((value) => value !== '')) {
rows.push(row);
}
row = [];
cell = '';
} else if (char !== '\r') {
cell += char;
}
}
if (cell !== '' || row.length > 0) {
row.push(cell);
if (row.some((value) => value !== '')) {
rows.push(row);
}
}
const headers = rows[0]?.map((header) => header.replace(/^\uFEFF/, ''));
if (!headers?.length) {
throw validationError(`${fileName} is empty`);
}
return rows.slice(1).map((values) =>
headers.reduce<CsvRow>((record, header, index) => {
record[header] = values[index] ?? '';
return record;
}, {})
);
}
async function readPokemonDataFile(fileName: string): Promise<string> {
const sourceDir = dirname(fileURLToPath(import.meta.url));
const directories = [
process.env.POKOPIA_DATA_DIR ? resolve(process.env.POKOPIA_DATA_DIR) : '',
resolve(process.cwd(), 'data'),
resolve(process.cwd(), '..', 'data'),
resolve(sourceDir, '..', 'data'),
resolve(sourceDir, '..', '..', 'data')
].filter(Boolean);
const uniqueDirectories = [...new Set(directories)];
for (const directory of uniqueDirectories) {
try {
return await readFile(resolve(directory, fileName), 'utf8');
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
throw error;
}
}
}
throw validationError(`Pokemon data file ${fileName} is unavailable`);
}
function csvInteger(row: CsvRow, fieldName: string): number {
const value = Number(row[fieldName]);
return Number.isInteger(value) ? value : 0;
}
function csvNumber(row: CsvRow, fieldName: string): number {
const value = Number(row[fieldName]);
return Number.isFinite(value) ? value : 0;
}
function csvText(row: CsvRow, fieldName: string): string {
return row[fieldName]?.trim() ?? '';
}
function indexRowsByNumber(rows: CsvRow[], fieldName: string): Map<number, CsvRow> {
return rows.reduce((index, row) => {
const id = csvInteger(row, fieldName);
if (id > 0) {
index.set(id, row);
}
return index;
}, new Map<number, CsvRow>());
}
async function loadPokemonCsvData(): Promise<PokemonCsvData> {
if (!pokemonCsvDataCache) {
pokemonCsvDataCache = (async () => {
const [pokemonContent, namesContent, genusContent, typesContent] = await Promise.all([
readPokemonDataFile('pokemon_data.csv'),
readPokemonDataFile('localized_pokemon_name.csv'),
readPokemonDataFile('localized_pokemon_genus.csv'),
readPokemonDataFile('localized_type_name.csv')
]);
const pokemonRows = parseCsv(pokemonContent, 'pokemon_data.csv');
const typeRows = parseCsv(typesContent, 'localized_type_name.csv');
const pokemonByLookup = new Map<string, CsvRow>();
for (const row of pokemonRows) {
const id = csvInteger(row, 'id');
const identifier = csvText(row, 'identifier').toLowerCase();
if (id > 0) {
pokemonByLookup.set(String(id), row);
}
if (identifier) {
pokemonByLookup.set(identifier, row);
}
}
return {
pokemonRows,
pokemonByLookup,
namesByPokemonId: indexRowsByNumber(parseCsv(namesContent, 'localized_pokemon_name.csv'), 'pokemon_species_id'),
genusByPokemonId: indexRowsByNumber(parseCsv(genusContent, 'localized_pokemon_genus.csv'), 'pokemon_species_id'),
typesById: indexRowsByNumber(typeRows, 'type_id'),
canonicalTypeRows: typeRows.filter((row) => pokemonTypeIconIds.has(csvInteger(row, 'type_id')))
};
})();
}
return pokemonCsvDataCache;
}
function pokemonDataLookupKey(value: unknown): string {
const rawValue = typeof value === 'number' ? String(value) : typeof value === 'string' ? value.trim() : '';
if (rawValue === '') {
throw validationError('Pokemon identifier is required');
}
const numericValue = Number(rawValue);
if (Number.isInteger(numericValue) && numericValue > 0) {
return String(numericValue);
}
return rawValue.toLowerCase();
}
function languageCsvColumn(code: string): string | null {
const [language, region = ''] = code.split('-');
const languageKey = language.toLowerCase();
const regionKey = region.toUpperCase();
const directColumns: Record<string, string> = {
de: 'de',
en: 'en',
es: 'es',
fr: 'fr',
it: 'it',
ja: 'ja',
ko: 'ko'
};
if (languageKey === 'zh') {
return ['HK', 'MO', 'TW'].includes(regionKey) ? 'zh_hant' : 'zh_hans';
}
return directColumns[languageKey] ?? null;
}
function localizedCsvText(row: CsvRow, code: string): string {
const column = languageCsvColumn(code);
return column ? csvText(row, column) : '';
}
function defaultLanguage(languages: LanguagePayload[]): LanguagePayload | undefined {
return languages.find((language) => language.isDefault) ?? languages.find((language) => language.code === defaultLocale) ?? languages[0];
}
function defaultCsvText(row: CsvRow, languages: LanguagePayload[], fallback: string): string {
const defaultCode = defaultLanguage(languages)?.code ?? defaultLocale;
return localizedCsvText(row, defaultCode) || localizedCsvText(row, defaultLocale) || fallback;
}
function assignTranslation(translations: TranslationInput, locale: string, fieldName: TranslationField, value: string): void {
if (!value) {
return;
}
translations[locale] = {
...(translations[locale] ?? {}),
[fieldName]: value
};
}
function localizedCsvTranslations(
rows: Array<{ row: CsvRow; fieldName: TranslationField }>,
languages: LanguagePayload[]
): TranslationInput {
const translations: TranslationInput = {};
const defaultCode = defaultLanguage(languages)?.code ?? defaultLocale;
for (const language of languages) {
if (language.code === defaultCode) {
continue;
}
for (const { row, fieldName } of rows) {
assignTranslation(translations, language.code, fieldName, localizedCsvText(row, language.code));
}
}
return cleanTranslations(translations, rows.map((row) => row.fieldName));
}
function fetchedPokemonStats(row: CsvRow): PokemonStats {
return {
hp: csvInteger(row, 'hp'),
attack: csvInteger(row, 'atk'),
defense: csvInteger(row, 'def'),
specialAttack: csvInteger(row, 'sp_atk'),
specialDefense: csvInteger(row, 'sp_def'),
speed: csvInteger(row, 'spd')
};
}
function fetchedPokemonTypeIds(row: CsvRow, data: PokemonCsvData): number[] {
const typeIds = [csvInteger(row, 'type_1_id'), csvInteger(row, 'type_2_id')].filter((typeId) => typeId > 0);
if (typeIds.length === 0 || typeIds.some((typeId) => !data.typesById.has(typeId) || !pokemonTypeIconIds.has(typeId))) {
throw validationError('Pokemon type data is unavailable');
}
return typeIds;
}
async function ensurePokemonTypeCatalog(
client: DbClient,
data: PokemonCsvData,
languages: LanguagePayload[],
userId: number
): Promise<void> {
for (const row of data.canonicalTypeRows) {
const typeId = csvInteger(row, 'type_id');
const name = defaultCsvText(row, languages, csvText(row, 'identifier'));
const translations = localizedCsvTranslations([{ row, fieldName: 'name' }], languages);
const existing = await client.query<{ name: string }>('SELECT name FROM pokemon_types WHERE id = $1', [typeId]);
if (existing.rowCount === 0) {
await client.query(
`
INSERT INTO pokemon_types (
id,
name,
sort_order,
created_by_user_id,
updated_by_user_id
)
VALUES ($1, $2, $3, $4, $4)
`,
[typeId, name, typeId * 10, userId]
);
await recordEditLog(client, 'pokemon-types', typeId, 'create', userId);
} else if (existing.rows[0].name !== name) {
await client.query(
`
UPDATE pokemon_types
SET name = $1,
updated_by_user_id = $2,
updated_at = now()
WHERE id = $3
`,
[name, userId, typeId]
);
await recordEditLog(client, 'pokemon-types', typeId, 'update', userId, [
{ label: 'Name', before: existing.rows[0].name, after: name }
]);
}
await replaceEntityTranslations(client, 'pokemon-types', typeId, translations, ['name']);
}
await client.query(
`
SELECT setval(
pg_get_serial_sequence('pokemon_types', 'id'),
GREATEST((SELECT COALESCE(MAX(id), 1) FROM pokemon_types), 1),
true
)
`
);
}
export async function fetchPokemonData(payload: Record<string, unknown>, userId: number): Promise<PokemonFetchResult> {
const lookupKey = pokemonDataLookupKey(payload.identifier);
const [data, languages] = await Promise.all([loadPokemonCsvData(), listLanguages()]);
const pokemonRow = data.pokemonByLookup.get(lookupKey);
if (!pokemonRow) {
throw validationError('Pokemon data was not found');
}
const id = csvInteger(pokemonRow, 'id');
const nameRow = data.namesByPokemonId.get(id) ?? pokemonRow;
const genusRow = data.genusByPokemonId.get(id) ?? pokemonRow;
const identifier = csvText(pokemonRow, 'identifier');
const typeIds = fetchedPokemonTypeIds(pokemonRow, data);
await withTransaction((client) => ensurePokemonTypeCatalog(client, data, languages, userId));
return {
id,
identifier,
name: defaultCsvText(nameRow, languages, identifier),
genus: defaultCsvText(genusRow, languages, ''),
heightInches: Math.round(csvNumber(pokemonRow, 'height_m') * 39.37007874015748 * 100) / 100,
weightPounds: Math.round(csvNumber(pokemonRow, 'weight_kg') * 2.2046226218 * 10) / 10,
translations: localizedCsvTranslations(
[
{ row: nameRow, fieldName: 'name' },
{ row: genusRow, fieldName: 'genus' }
],
languages
),
typeIds,
stats: fetchedPokemonStats(pokemonRow)
};
}
function pokemonFetchOption(row: CsvRow, data: PokemonCsvData, languages: LanguagePayload[], locale: string): PokemonFetchOption {
const id = csvInteger(row, 'id');
const identifier = csvText(row, 'identifier');
const nameRow = data.namesByPokemonId.get(id) ?? row;
return {
id,
identifier,
name: localizedCsvText(nameRow, cleanLocale(locale)) || defaultCsvText(nameRow, languages, identifier)
};
}
function pokemonFetchOptionMatches(
row: CsvRow,
data: PokemonCsvData,
languages: LanguagePayload[],
locale: string,
search: string
): boolean {
if (!search) {
return true;
}
const id = csvInteger(row, 'id');
const identifier = csvText(row, 'identifier');
const nameRow = data.namesByPokemonId.get(id) ?? row;
const defaultCode = defaultLanguage(languages)?.code ?? defaultLocale;
const searchFields = [
String(id),
identifier,
localizedCsvText(nameRow, cleanLocale(locale)),
localizedCsvText(nameRow, defaultCode),
localizedCsvText(nameRow, defaultLocale)
];
const keyword = search.toLowerCase();
return searchFields.some((field) => field.toLowerCase().includes(keyword));
}
export async function listPokemonFetchOptions(paramsQuery: QueryParams, locale = defaultLocale): Promise<PokemonFetchOption[]> {
const search = asString(paramsQuery.search)?.trim() ?? '';
const [data, languages] = await Promise.all([loadPokemonCsvData(), listLanguages()]);
return data.pokemonRows
.filter((row) => csvInteger(row, 'id') > 0 && pokemonFetchOptionMatches(row, data, languages, locale, search))
.slice(0, 20)
.map((row) => pokemonFetchOption(row, data, languages, locale));
}
function displayValue(value: string | null | undefined): string { function displayValue(value: string | null | undefined): string {
const cleanValue = value?.trim() ?? ''; const cleanValue = value?.trim() ?? '';
return cleanValue === '' ? 'None' : cleanValue; return cleanValue === '' ? 'None' : cleanValue;

View File

@@ -28,6 +28,7 @@ import {
deleteLifePostReaction, deleteLifePostReaction,
deletePokemon, deletePokemon,
deleteRecipe, deleteRecipe,
fetchPokemonData,
getHabitat, getHabitat,
getItem, getItem,
getOptions, getOptions,
@@ -42,6 +43,7 @@ import {
listLanguages, listLanguages,
listLifePosts, listLifePosts,
listPokemon, listPokemon,
listPokemonFetchOptions,
listRecipes, listRecipes,
reorderConfig, reorderConfig,
reorderDailyChecklistItems, reorderDailyChecklistItems,
@@ -342,6 +344,13 @@ app.get('/api/pokemon', async (request) =>
listPokemon(request.query as Record<string, string | string[] | undefined>, requestLocale(request)) listPokemon(request.query as Record<string, string | string[] | undefined>, requestLocale(request))
); );
app.get('/api/pokemon/fetch-options', async (request, reply) => {
const user = await requireVerifiedUser(request, reply);
return user
? listPokemonFetchOptions(request.query as Record<string, string | string[] | undefined>, requestLocale(request))
: undefined;
});
app.get('/api/pokemon/:id', async (request, reply) => { app.get('/api/pokemon/:id', async (request, reply) => {
const { id } = request.params as { id: string }; const { id } = request.params as { id: string };
const pokemon = await getPokemon(Number(id), requestLocale(request)); const pokemon = await getPokemon(Number(id), requestLocale(request));
@@ -360,6 +369,11 @@ app.post('/api/pokemon', async (request, reply) => {
: undefined; : undefined;
}); });
app.post('/api/pokemon/fetch', async (request, reply) => {
const user = await requireVerifiedUser(request, reply);
return user ? fetchPokemonData(request.body as Record<string, unknown>, user.id) : undefined;
});
app.put('/api/pokemon/:id', async (request, reply) => { app.put('/api/pokemon/:id', async (request, reply) => {
const user = await requireVerifiedUser(request, reply); const user = await requireVerifiedUser(request, reply);
if (!user) { if (!user) {

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,22 @@
"type_id","identifier","ja_hrkt","ja_roma","ko","zh_hant","fr","de","es","it","en","ja","zh_hans"
"1","normal","ノーマル",,"노말","一般","Normal","Normal","Normal","Normale","Normal","ノーマル","一般"
"2","fighting","かくとう",,"격투","格鬥","Combat","Kampf","Lucha","Lotta","Fighting","かくとう","格斗"
"3","flying","ひこう",,"비행","飛行","Vol","Flug","Volador","Volante","Flying","ひこう","飞行"
"4","poison","どく",,"","","Poison","Gift","Veneno","Veleno","Poison","どく",""
"5","ground","じめん",,"","地面","Sol","Boden","Tierra","Terra","Ground","じめん","地面"
"6","rock","いわ",,"바위","岩石","Roche","Gestein","Roca","Roccia","Rock","いわ","岩石"
"7","bug","むし",,"벌레","","Insecte","Käfer","Bicho","Coleottero","Bug","むし",""
"8","ghost","ゴースト",,"고스트","幽靈","Spectre","Geist","Fantasma","Spettro","Ghost","ゴースト","幽灵"
"9","steel","はがね",,"강철","","Acier","Stahl","Acero","Acciaio","Steel","はがね",""
"10","fire","ほのお",,"불꽃","","Feu","Feuer","Fuego","Fuoco","Fire","ほのお",""
"11","water","みず",,"","","Eau","Wasser","Agua","Acqua","Water","みず",""
"12","grass","くさ",,"","","Plante","Pflanze","Planta","Erba","Grass","くさ",""
"13","electric","でんき",,"전기","","Électrik","Elektro","Eléctrico","Elettro","Electric","でんき",""
"14","psychic","エスパー",,"에스퍼","超能力","Psy","Psycho","Psíquico","Psico","Psychic","エスパー","超能力"
"15","ice","こおり",,"얼음","","Glace","Eis","Hielo","Ghiaccio","Ice","こおり",""
"16","dragon","ドラゴン",,"드래곤","","Dragon","Drache","Dragón","Drago","Dragon","ドラゴン",""
"17","dark","あく",,"","","Ténèbres","Unlicht","Siniestro","Buio","Dark","あく",""
"18","fairy","フェアリー",,"페어리","妖精","Fée","Fee","Hada","Folletto","Fairy","フェアリー","妖精"
"19","stellar","ステラ"," Stella"," 스텔라","星晶","Stellaire","Stellar","Astral","Astrale","Stellar","ステラ","星晶"
"10001","unknown","",,"???","???","???","???","???","???","???","","???"
"10002","shadow","ダーク",,"다크","","Obscur","Crypto",,"Ombra","Shadow","ダーク",""
1 type_id identifier ja_hrkt ja_roma ko zh_hant fr de es it en ja zh_hans
2 1 normal ノーマル 노말 一般 Normal Normal Normal Normale Normal ノーマル 一般
3 2 fighting かくとう 격투 格鬥 Combat Kampf Lucha Lotta Fighting かくとう 格斗
4 3 flying ひこう 비행 飛行 Vol Flug Volador Volante Flying ひこう 飞行
5 4 poison どく Poison Gift Veneno Veleno Poison どく
6 5 ground じめん 地面 Sol Boden Tierra Terra Ground じめん 地面
7 6 rock いわ 바위 岩石 Roche Gestein Roca Roccia Rock いわ 岩石
8 7 bug むし 벌레 Insecte Käfer Bicho Coleottero Bug むし
9 8 ghost ゴースト 고스트 幽靈 Spectre Geist Fantasma Spettro Ghost ゴースト 幽灵
10 9 steel はがね 강철 Acier Stahl Acero Acciaio Steel はがね
11 10 fire ほのお 불꽃 Feu Feuer Fuego Fuoco Fire ほのお
12 11 water みず Eau Wasser Agua Acqua Water みず
13 12 grass くさ Plante Pflanze Planta Erba Grass くさ
14 13 electric でんき 전기 Électrik Elektro Eléctrico Elettro Electric でんき
15 14 psychic エスパー 에스퍼 超能力 Psy Psycho Psíquico Psico Psychic エスパー 超能力
16 15 ice こおり 얼음 Glace Eis Hielo Ghiaccio Ice こおり
17 16 dragon ドラゴン 드래곤 Dragon Drache Dragón Drago Dragon ドラゴン
18 17 dark あく Ténèbres Unlicht Siniestro Buio Dark あく
19 18 fairy フェアリー 페어리 妖精 Fée Fee Hada Folletto Fairy フェアリー 妖精
20 19 stellar ステラ Stella 스텔라 星晶 Stellaire Stellar Astral Astrale Stellar ステラ 星晶
21 10001 unknown ??? ??? ??? ??? ??? ??? ??? ??? ??? ???
22 10002 shadow ダーク 다크 Obscur Crypto Ombra Shadow ダーク

1351
data/pokemon_data.csv Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -17,7 +17,8 @@ services:
backend: backend:
build: build:
context: ./backend context: .
dockerfile: backend/Dockerfile
environment: environment:
DATABASE_URL: postgres://pokopia:pokopia@postgres:5432/pokopia DATABASE_URL: postgres://pokopia:pokopia@postgres:5432/pokopia
BACKEND_PORT: 3001 BACKEND_PORT: 3001

View File

@@ -103,6 +103,17 @@ const messages = {
editTabAdvance: 'Advance', editTabAdvance: 'Advance',
newTitle: 'New Pokemon', newTitle: 'New Pokemon',
editTitle: 'Edit #{id} {name}', editTitle: 'Edit #{id} {name}',
fetchData: 'Fetch data',
fetchingData: 'Fetching',
fetchIdentifier: 'Data identifier',
fetchIdentifierPlaceholder: 'bulbasaur or 1',
fetchIdentifierRequired: 'Enter a Pokemon identifier',
fetchFailed: 'Pokemon data fetch failed',
fetchIdMismatch: 'Fetched Pokemon ID #{id} does not match this editor.',
fetchResults: 'Pokemon data results',
fetchSearching: 'Searching data',
fetchNoMatches: 'No matching Pokemon data',
fetchSearchFailed: 'Pokemon data search failed',
loadingList: 'Loading Pokemon list', loadingList: 'Loading Pokemon list',
loadingDetail: 'Loading Pokemon detail', loadingDetail: 'Loading Pokemon detail',
loadingEdit: 'Loading Pokemon editor', loadingEdit: 'Loading Pokemon editor',
@@ -564,6 +575,17 @@ const messages = {
editTabAdvance: '进阶', editTabAdvance: '进阶',
newTitle: '新增 Pokemon', newTitle: '新增 Pokemon',
editTitle: '编辑 #{id} {name}', editTitle: '编辑 #{id} {name}',
fetchData: '获取数据',
fetchingData: '正在获取',
fetchIdentifier: '数据标识',
fetchIdentifierPlaceholder: 'bulbasaur 或 1',
fetchIdentifierRequired: '请输入 Pokemon 数据标识',
fetchFailed: 'Pokemon 数据获取失败',
fetchIdMismatch: '获取到的 Pokemon ID #{id} 与当前编辑内容不一致。',
fetchResults: 'Pokemon 数据结果',
fetchSearching: '正在搜索数据',
fetchNoMatches: '没有匹配的 Pokemon 数据',
fetchSearchFailed: 'Pokemon 数据搜索失败',
loadingList: '正在加载 Pokemon 列表', loadingList: '正在加载 Pokemon 列表',
loadingDetail: '正在加载 Pokemon 详情', loadingDetail: '正在加载 Pokemon 详情',
loadingEdit: '正在加载 Pokemon 编辑内容', loadingEdit: '正在加载 Pokemon 编辑内容',

View File

@@ -290,6 +290,24 @@ export interface PokemonPayload {
skillItemDrops: Array<{ skillId: number; itemId: number }>; skillItemDrops: Array<{ skillId: number; itemId: number }>;
} }
export interface PokemonFetchResult {
id: number;
identifier: string;
name: string;
genus: string;
heightInches: number;
weightPounds: number;
translations?: TranslationMap;
typeIds: number[];
stats: PokemonStats;
}
export interface PokemonFetchOption {
id: number;
identifier: string;
name: string;
}
export interface ItemPayload { export interface ItemPayload {
name: string; name: string;
translations?: TranslationMap; translations?: TranslationMap;
@@ -416,9 +434,10 @@ async function getErrorMessage(response: Response): Promise<string> {
return `Request failed (${response.status})`; return `Request failed (${response.status})`;
} }
async function getJson<T>(path: string): Promise<T> { async function getJson<T>(path: string, signal?: AbortSignal): Promise<T> {
const response = await fetch(`${apiBaseUrl}${path}`, { const response = await fetch(`${apiBaseUrl}${path}`, {
headers: requestHeaders() headers: requestHeaders(),
signal
}); });
if (!response.ok) { if (!response.ok) {
@@ -550,6 +569,9 @@ export const api = {
pokemon: (params: Record<string, string | number | undefined>) => pokemon: (params: Record<string, string | number | undefined>) =>
getJson<Pokemon[]>(`/api/pokemon${buildQuery(params)}`), getJson<Pokemon[]>(`/api/pokemon${buildQuery(params)}`),
pokemonDetail: (id: string | number) => getJson<PokemonDetail>(`/api/pokemon/${id}`), pokemonDetail: (id: string | number) => getJson<PokemonDetail>(`/api/pokemon/${id}`),
pokemonFetchOptions: (search: string, signal?: AbortSignal) =>
getJson<PokemonFetchOption[]>(`/api/pokemon/fetch-options${buildQuery({ search: search.trim() })}`, signal),
fetchPokemonData: (identifier: string) => sendJson<PokemonFetchResult>('/api/pokemon/fetch', 'POST', { identifier }),
createPokemon: (payload: PokemonPayload) => sendJson<PokemonDetail>('/api/pokemon', 'POST', payload), createPokemon: (payload: PokemonPayload) => sendJson<PokemonDetail>('/api/pokemon', 'POST', payload),
updatePokemon: (id: string | number, payload: PokemonPayload) => updatePokemon: (id: string | number, payload: PokemonPayload) =>
sendJson<PokemonDetail>(`/api/pokemon/${id}`, 'PUT', payload), sendJson<PokemonDetail>(`/api/pokemon/${id}`, 'PUT', payload),

View File

@@ -732,7 +732,84 @@ button:disabled,
.pokemon-edit-form { .pokemon-edit-form {
height: clamp(420px, calc(100dvh - 188px), 640px); height: clamp(420px, calc(100dvh - 188px), 640px);
min-height: 0; min-height: 0;
grid-template-rows: auto minmax(0, 1fr); grid-template-rows: auto auto minmax(0, 1fr);
}
.pokemon-fetch-panel {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
align-items: end;
padding: 10px;
border: 1px solid var(--line);
border-radius: var(--radius-card);
background: var(--surface-soft);
}
.pokemon-fetch-panel__input {
position: relative;
min-width: 0;
}
.pokemon-fetch-panel__button {
min-width: 118px;
justify-content: center;
}
.pokemon-fetch-results {
position: absolute;
top: calc(100% + 6px);
right: 0;
left: 0;
z-index: 35;
max-height: 260px;
overflow-y: auto;
display: grid;
gap: 4px;
padding: 6px;
border: 2px solid var(--line-strong);
border-radius: var(--radius-control);
background: var(--surface);
box-shadow: var(--shadow-raised);
}
.pokemon-fetch-option {
width: 100%;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
align-items: center;
padding: 8px 10px;
border-radius: var(--radius-small);
background: transparent;
color: var(--ink);
text-align: left;
cursor: pointer;
}
.pokemon-fetch-option:hover,
.pokemon-fetch-option:focus-visible {
background: color-mix(in srgb, var(--pokemon-blue) 10%, var(--surface));
}
.pokemon-fetch-option__name {
min-width: 0;
font-weight: 900;
overflow-wrap: anywhere;
}
.pokemon-fetch-option__identifier {
color: var(--muted);
font-family: var(--font-mono);
font-size: 0.78rem;
}
.pokemon-fetch-results__status {
margin: 0;
padding: 10px;
color: var(--muted);
font-size: 0.88rem;
font-weight: 800;
} }
.pokemon-edit-panel { .pokemon-edit-panel {
@@ -3131,6 +3208,18 @@ button:disabled,
min-width: 0; min-width: 0;
} }
.pokemon-type-chip {
gap: 7px;
min-height: 32px;
padding: 5px 10px 5px 7px;
}
.pokemon-type-chip__icon {
width: 22px;
height: 22px;
object-fit: contain;
}
.progress { .progress {
display: grid; display: grid;
gap: 6px; gap: 6px;
@@ -3713,6 +3802,7 @@ button:disabled,
.toolbar, .toolbar,
.entity-grid, .entity-grid,
.grid, .grid,
.pokemon-fetch-panel,
.pokemon-edit-grid, .pokemon-edit-grid,
.coming-soon-preview { .coming-soon-preview {
grid-template-columns: 1fr; grid-template-columns: 1fr;

View File

@@ -180,6 +180,10 @@ function formatImperialHeight(inches: number): string {
return `${feet}'${remainingInches}"`; return `${feet}'${remainingInches}"`;
} }
function pokemonTypeIconSrc(typeId: number): string | null {
return typeId >= 1 && typeId <= 19 ? `/types/small/${typeId}.png` : null;
}
async function loadPokemonDetail() { async function loadPokemonDetail() {
const nextPokemon = await api.pokemonDetail(String(route.params.id)); const nextPokemon = await api.pokemonDetail(String(route.params.id));
pokemon.value = nextPokemon; pokemon.value = nextPokemon;
@@ -319,7 +323,10 @@ watch(
<section class="detail-section pokemon-profile-card pokemon-types-card" :aria-label="t('pages.pokemon.types')"> <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"> <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">{{ type.name }}</span> <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> </div>
<p v-else class="meta-line">{{ t('common.none') }}</p> <p v-else class="meta-line">{{ t('common.none') }}</p>
</section> </section>

View File

@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { Icon } from '@iconify/vue'; import { Icon } from '@iconify/vue';
import { computed, nextTick, onMounted, ref, watch } from 'vue'; import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import Modal from '../components/Modal.vue'; import Modal from '../components/Modal.vue';
@@ -10,13 +10,15 @@ import StatusMessage from '../components/StatusMessage.vue';
import Tabs from '../components/Tabs.vue'; import Tabs from '../components/Tabs.vue';
import TagsSelect from '../components/TagsSelect.vue'; import TagsSelect from '../components/TagsSelect.vue';
import TranslationFields from '../components/TranslationFields.vue'; import TranslationFields from '../components/TranslationFields.vue';
import { iconCancel, iconSave } from '../icons'; import { iconCancel, iconSave, iconSearch } from '../icons';
import { import {
api, api,
type ConfigType, type ConfigType,
type Language, type Language,
type NamedEntity, type NamedEntity,
type Options, type Options,
type PokemonFetchOption,
type PokemonFetchResult,
type PokemonPayload, type PokemonPayload,
type PokemonStats, type PokemonStats,
type TranslationMap type TranslationMap
@@ -35,11 +37,17 @@ const itemOptions = ref<NamedEntity[]>([]);
const languages = ref<Language[]>([]); const languages = ref<Language[]>([]);
const loading = ref(true); const loading = ref(true);
const busy = ref(false); const busy = ref(false);
const fetchBusy = ref(false);
const fetchOptionsLoading = ref(false);
const fetchOptionsOpen = ref(false);
const message = ref(''); const message = ref('');
const fetchIdentifier = ref('');
const fetchOptions = ref<PokemonFetchOption[]>([]);
const creatingSelect = ref(''); const creatingSelect = ref('');
const activeEditTab = ref('basic'); const activeEditTab = ref('basic');
const heightUnit = ref<'imperial' | 'metric'>('imperial'); const heightUnit = ref<'imperial' | 'metric'>('imperial');
const weightUnit = ref<'imperial' | 'metric'>('imperial'); const weightUnit = ref<'imperial' | 'metric'>('imperial');
let fetchOptionsController: AbortController | null = null;
function defaultPokemonStats(): PokemonStats { function defaultPokemonStats(): PokemonStats {
return { return {
@@ -174,6 +182,46 @@ function pokemonIdForSave() {
return Number(isEditing.value ? routeId.value : pokemonForm.value.id); return Number(isEditing.value ? routeId.value : pokemonForm.value.id);
} }
function mergeFetchedTranslations(fetchedTranslations: TranslationMap | undefined): TranslationMap {
const nextTranslations = Object.entries(pokemonForm.value.translations).reduce<TranslationMap>((translations, [code, fields]) => {
translations[code] = { ...fields };
return translations;
}, {});
Object.entries(fetchedTranslations ?? {}).forEach(([code, fields]) => {
const nextFields = { ...(nextTranslations[code] ?? {}) };
if (typeof fields.name === 'string') {
nextFields.name = fields.name;
}
if (typeof fields.genus === 'string') {
nextFields.genus = fields.genus;
}
nextTranslations[code] = nextFields;
});
return nextTranslations;
}
function applyFetchedPokemon(fetchedPokemon: PokemonFetchResult): boolean {
if (isEditing.value && fetchedPokemon.id !== pokemonIdForSave()) {
message.value = t('pages.pokemon.fetchIdMismatch', { id: fetchedPokemon.id });
return false;
}
pokemonForm.value = {
...pokemonForm.value,
id: isEditing.value ? pokemonForm.value.id : String(fetchedPokemon.id),
name: fetchedPokemon.name,
genus: fetchedPokemon.genus,
heightInches: fetchedPokemon.heightInches,
weightPounds: fetchedPokemon.weightPounds,
translations: mergeFetchedTranslations(fetchedPokemon.translations),
typeIds: fetchedPokemon.typeIds.map(String),
stats: fetchedPokemon.stats
};
return true;
}
function hasRequiredBasicFields() { function hasRequiredBasicFields() {
const id = pokemonIdForSave(); const id = pokemonIdForSave();
return Number.isInteger(id) && id > 0 && pokemonNameForSave().trim() !== ''; return Number.isInteger(id) && id > 0 && pokemonNameForSave().trim() !== '';
@@ -224,6 +272,95 @@ async function loadEditor() {
} }
} }
function cancelFetchOptionsRequest() {
fetchOptionsController?.abort();
fetchOptionsController = null;
fetchOptionsLoading.value = false;
}
function fetchOptionLabel(option: PokemonFetchOption) {
return `#${option.id} ${option.name}`;
}
async function loadFetchOptions() {
cancelFetchOptionsRequest();
const controller = new AbortController();
fetchOptionsController = controller;
fetchOptionsLoading.value = true;
try {
const rows = await api.pokemonFetchOptions(fetchIdentifier.value, controller.signal);
if (fetchOptionsController === controller) {
fetchOptions.value = rows;
}
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
return;
}
if (fetchOptionsController === controller) {
fetchOptions.value = [];
message.value = errorText(error, t('pages.pokemon.fetchSearchFailed'));
}
} finally {
if (fetchOptionsController === controller) {
fetchOptionsLoading.value = false;
fetchOptionsController = null;
}
}
}
function refreshFetchOptions() {
if (!fetchOptionsOpen.value) {
return;
}
void loadFetchOptions();
}
function openFetchOptions() {
fetchOptionsOpen.value = true;
refreshFetchOptions();
}
function closeFetchOptions() {
fetchOptionsOpen.value = false;
cancelFetchOptionsRequest();
}
async function selectFetchOption(option: PokemonFetchOption) {
fetchIdentifier.value = option.identifier;
closeFetchOptions();
await fetchPokemonByIdentifier(option.identifier);
}
async function fetchPokemonByIdentifier(identifierValue?: string) {
const identifier = (identifierValue ?? fetchIdentifier.value).trim() || pokemonForm.value.id.trim();
if (!identifier) {
message.value = t('pages.pokemon.fetchIdentifierRequired');
return;
}
fetchBusy.value = true;
message.value = '';
try {
const fetchedPokemon = await api.fetchPokemonData(identifier);
await loadOptions();
if (applyFetchedPokemon(fetchedPokemon)) {
fetchIdentifier.value = fetchedPokemon.identifier;
closeFetchOptions();
}
} catch (error) {
message.value = errorText(error, t('pages.pokemon.fetchFailed'));
} finally {
fetchBusy.value = false;
}
}
function fetchPokemonFromInput() {
void fetchPokemonByIdentifier();
}
async function createSingleOption(selectKey: string, type: ConfigType, name: string, assign: (value: string) => void) { async function createSingleOption(selectKey: string, type: ConfigType, name: string, assign: (value: string) => void) {
const cleanName = name.trim(); const cleanName = name.trim();
if (!cleanName) return; if (!cleanName) return;
@@ -262,6 +399,10 @@ async function createMultiOption(selectKey: string, type: ConfigType, name: stri
} }
async function savePokemon() { async function savePokemon() {
if (fetchBusy.value) {
return;
}
if (!hasRequiredBasicFields()) { if (!hasRequiredBasicFields()) {
await showBasicFieldValidation(); await showBasicFieldValidation();
return; return;
@@ -301,7 +442,10 @@ onMounted(() => {
void loadEditor(); void loadEditor();
}); });
onBeforeUnmount(cancelFetchOptionsRequest);
watch(() => pokemonForm.value.skillIds.slice(), syncSkillItemDrops); watch(() => pokemonForm.value.skillIds.slice(), syncSkillItemDrops);
watch(fetchIdentifier, refreshFetchOptions);
</script> </script>
<template> <template>
@@ -311,6 +455,47 @@ watch(() => pokemonForm.value.skillIds.slice(), syncSkillItemDrops);
<form v-if="!loading && options" id="pokemon-edit-form" class="modal-edit-form modal-edit-form--tabbed pokemon-edit-form" @submit.prevent="savePokemon"> <form v-if="!loading && options" id="pokemon-edit-form" class="modal-edit-form modal-edit-form--tabbed pokemon-edit-form" @submit.prevent="savePokemon">
<Tabs id="pokemon-edit-tabs" v-model="activeEditTab" :tabs="editTabs" :label="t('pages.pokemon.editSections')" /> <Tabs id="pokemon-edit-tabs" v-model="activeEditTab" :tabs="editTabs" :label="t('pages.pokemon.editSections')" />
<div class="pokemon-fetch-panel" :aria-label="t('pages.pokemon.fetchData')">
<div class="field pokemon-fetch-panel__input">
<label for="pokemon-fetch-identifier">{{ t('pages.pokemon.fetchIdentifier') }}</label>
<input
id="pokemon-fetch-identifier"
v-model="fetchIdentifier"
type="search"
:placeholder="t('pages.pokemon.fetchIdentifierPlaceholder')"
autocomplete="off"
role="combobox"
:aria-expanded="fetchOptionsOpen"
aria-controls="pokemon-fetch-results"
@focus="openFetchOptions"
@keydown.escape.stop="closeFetchOptions"
@keydown.enter.prevent="fetchPokemonFromInput"
/>
<div v-if="fetchOptionsOpen" id="pokemon-fetch-results" class="pokemon-fetch-results" role="listbox" :aria-label="t('pages.pokemon.fetchResults')">
<p v-if="fetchOptionsLoading" class="pokemon-fetch-results__status">{{ t('pages.pokemon.fetchSearching') }}</p>
<template v-else-if="fetchOptions.length">
<button
v-for="option in fetchOptions"
:key="option.id"
type="button"
class="pokemon-fetch-option"
role="option"
@mousedown.prevent
@click="selectFetchOption(option)"
>
<span class="pokemon-fetch-option__name">{{ fetchOptionLabel(option) }}</span>
<span class="pokemon-fetch-option__identifier">{{ option.identifier }}</span>
</button>
</template>
<p v-else class="pokemon-fetch-results__status">{{ t('pages.pokemon.fetchNoMatches') }}</p>
</div>
</div>
<button type="button" class="ui-button ui-button--blue ui-button--small pokemon-fetch-panel__button" :disabled="busy || fetchBusy" @click="fetchPokemonFromInput">
<Icon :icon="iconSearch" class="ui-icon" aria-hidden="true" />
{{ fetchBusy ? t('pages.pokemon.fetchingData') : t('pages.pokemon.fetchData') }}
</button>
</div>
<section v-if="activeEditTab === 'basic'" class="pokemon-edit-panel" role="tabpanel" :aria-label="t('pages.pokemon.editTabBasic')"> <section v-if="activeEditTab === 'basic'" class="pokemon-edit-panel" role="tabpanel" :aria-label="t('pages.pokemon.editTabBasic')">
<div class="pokemon-edit-grid"> <div class="pokemon-edit-grid">
<div class="field"> <div class="field">
@@ -476,10 +661,8 @@ watch(() => pokemonForm.value.skillIds.slice(), syncSkillItemDrops);
v-model="pokemonForm.typeIds" v-model="pokemonForm.typeIds"
:options="options.pokemonTypes" :options="options.pokemonTypes"
:max="2" :max="2"
allow-create
:creating="creatingSelect === 'pokemon-types'" :creating="creatingSelect === 'pokemon-types'"
:placeholder="t('pages.pokemon.searchTypes')" :placeholder="t('pages.pokemon.searchTypes')"
@create="createMultiOption('pokemon-types', 'pokemon-types', $event, pokemonForm.typeIds, 2)"
/> />
</div> </div>
@@ -498,7 +681,7 @@ watch(() => pokemonForm.value.skillIds.slice(), syncSkillItemDrops);
</section> </section>
<template v-if="!loading && options" #footer> <template v-if="!loading && options" #footer>
<button type="submit" form="pokemon-edit-form" class="link-button" :disabled="busy"> <button type="submit" form="pokemon-edit-form" class="link-button" :disabled="busy || fetchBusy">
<Icon :icon="iconSave" class="ui-icon" aria-hidden="true" /> <Icon :icon="iconSave" class="ui-icon" aria-hidden="true" />
{{ busy ? t('common.saving') : t('common.save') }} {{ busy ? t('common.saving') : t('common.save') }}
</button> </button>

View File

@@ -44,6 +44,10 @@ async function loadPokemon() {
loading.value = false; loading.value = false;
} }
function pokemonTypeIconSrc(typeId: number): string | null {
return typeId >= 1 && typeId <= 19 ? `/types/small/${typeId}.png` : null;
}
onMounted(async () => { onMounted(async () => {
options.value = await api.options(); options.value = await api.options();
await loadPokemon(); await loadPokemon();
@@ -145,7 +149,12 @@ watch(query, loadPokemon);
:to="`/pokemon/${item.id}`" :to="`/pokemon/${item.id}`"
> >
<EditMeta :entity="item" /> <EditMeta :entity="item" />
<EntityChips v-if="item.types.length" :items="item.types" /> <div v-if="item.types.length" class="chips">
<span v-for="type in item.types" :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>
<EntityChips :items="item.skills" /> <EntityChips :items="item.skills" />
<EntityChips :items="item.favorite_things" /> <EntityChips :items="item.favorite_things" />
</EntityCard> </EntityCard>