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:
6
.dockerignore
Normal file
6
.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
||||
.git
|
||||
**/node_modules
|
||||
**/dist
|
||||
**/*.log
|
||||
**/.env
|
||||
frontend
|
||||
11
DESIGN.md
11
DESIGN.md
@@ -214,6 +214,15 @@ 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、名称
|
||||
- 第二行:喜欢的环境、特长
|
||||
@@ -501,6 +510,8 @@ API 暴露边界:
|
||||
已验证用户编辑 API:
|
||||
|
||||
- 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 的更新、删除。
|
||||
- `POST /api/life-posts`
|
||||
- `PUT /api/life-posts/:id`
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
FROM node:22-alpine
|
||||
|
||||
WORKDIR /app
|
||||
COPY package.json ./
|
||||
COPY backend/package.json ./
|
||||
RUN corepack enable && pnpm install
|
||||
COPY . .
|
||||
COPY backend/. .
|
||||
COPY data ./data
|
||||
EXPOSE 3001
|
||||
CMD ["pnpm", "run", "start"]
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { parseIdList, parseMatchMode, sqlForRelationFilter } from './filter.ts';
|
||||
import { pool, query, queryOne } from './db.ts';
|
||||
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';
|
||||
|
||||
type QueryValue = string | string[] | undefined;
|
||||
@@ -83,6 +86,34 @@ type PokemonPayload = {
|
||||
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 = {
|
||||
name: string;
|
||||
translations: TranslationInput;
|
||||
@@ -257,6 +288,7 @@ const localePattern = /^[a-z]{2}(-[A-Z]{2})?$/;
|
||||
const defaultLifePostLimit = 20;
|
||||
const maxLifePostLimit = 50;
|
||||
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 }> = [
|
||||
{ key: 'hp', label: 'HP' },
|
||||
{ key: 'attack', label: 'Attack' },
|
||||
@@ -292,6 +324,8 @@ const discussionEntityDefinitions: Record<DiscussionEntityType, DiscussionEntity
|
||||
habitats: { table: 'habitats' }
|
||||
};
|
||||
|
||||
let pokemonCsvDataCache: Promise<PokemonCsvData> | null = null;
|
||||
|
||||
function asString(value: QueryValue): string | undefined {
|
||||
return Array.isArray(value) ? value[0] : value;
|
||||
}
|
||||
@@ -656,7 +690,7 @@ function requireLanguageCode(value: unknown): string {
|
||||
}
|
||||
|
||||
export async function listLanguages(includeDisabled = false) {
|
||||
return query(
|
||||
return query<LanguagePayload>(
|
||||
`
|
||||
SELECT code, name, enabled, is_default AS "isDefault", sort_order AS "sortOrder"
|
||||
FROM languages
|
||||
@@ -786,6 +820,393 @@ export async function reorderLanguages(payload: Record<string, unknown>) {
|
||||
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 {
|
||||
const cleanValue = value?.trim() ?? '';
|
||||
return cleanValue === '' ? 'None' : cleanValue;
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
deleteLifePostReaction,
|
||||
deletePokemon,
|
||||
deleteRecipe,
|
||||
fetchPokemonData,
|
||||
getHabitat,
|
||||
getItem,
|
||||
getOptions,
|
||||
@@ -42,6 +43,7 @@ import {
|
||||
listLanguages,
|
||||
listLifePosts,
|
||||
listPokemon,
|
||||
listPokemonFetchOptions,
|
||||
listRecipes,
|
||||
reorderConfig,
|
||||
reorderDailyChecklistItems,
|
||||
@@ -342,6 +344,13 @@ app.get('/api/pokemon', async (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) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const pokemon = await getPokemon(Number(id), requestLocale(request));
|
||||
@@ -360,6 +369,11 @@ app.post('/api/pokemon', async (request, reply) => {
|
||||
: 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) => {
|
||||
const user = await requireVerifiedUser(request, reply);
|
||||
if (!user) {
|
||||
|
||||
1026
data/localized_pokemon_genus.csv
Normal file
1026
data/localized_pokemon_genus.csv
Normal file
File diff suppressed because it is too large
Load Diff
1026
data/localized_pokemon_name.csv
Normal file
1026
data/localized_pokemon_name.csv
Normal file
File diff suppressed because it is too large
Load Diff
22
data/localized_type_name.csv
Normal file
22
data/localized_type_name.csv
Normal 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","ダーク","暗"
|
||||
|
1351
data/pokemon_data.csv
Normal file
1351
data/pokemon_data.csv
Normal file
File diff suppressed because it is too large
Load Diff
@@ -17,7 +17,8 @@ services:
|
||||
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
context: .
|
||||
dockerfile: backend/Dockerfile
|
||||
environment:
|
||||
DATABASE_URL: postgres://pokopia:pokopia@postgres:5432/pokopia
|
||||
BACKEND_PORT: 3001
|
||||
|
||||
@@ -103,6 +103,17 @@ const messages = {
|
||||
editTabAdvance: 'Advance',
|
||||
newTitle: 'New Pokemon',
|
||||
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',
|
||||
loadingDetail: 'Loading Pokemon detail',
|
||||
loadingEdit: 'Loading Pokemon editor',
|
||||
@@ -564,6 +575,17 @@ const messages = {
|
||||
editTabAdvance: '进阶',
|
||||
newTitle: '新增 Pokemon',
|
||||
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 列表',
|
||||
loadingDetail: '正在加载 Pokemon 详情',
|
||||
loadingEdit: '正在加载 Pokemon 编辑内容',
|
||||
|
||||
@@ -290,6 +290,24 @@ export interface PokemonPayload {
|
||||
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 {
|
||||
name: string;
|
||||
translations?: TranslationMap;
|
||||
@@ -416,9 +434,10 @@ async function getErrorMessage(response: Response): Promise<string> {
|
||||
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}`, {
|
||||
headers: requestHeaders()
|
||||
headers: requestHeaders(),
|
||||
signal
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -550,6 +569,9 @@ export const api = {
|
||||
pokemon: (params: Record<string, string | number | undefined>) =>
|
||||
getJson<Pokemon[]>(`/api/pokemon${buildQuery(params)}`),
|
||||
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),
|
||||
updatePokemon: (id: string | number, payload: PokemonPayload) =>
|
||||
sendJson<PokemonDetail>(`/api/pokemon/${id}`, 'PUT', payload),
|
||||
|
||||
@@ -732,7 +732,84 @@ button:disabled,
|
||||
.pokemon-edit-form {
|
||||
height: clamp(420px, calc(100dvh - 188px), 640px);
|
||||
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 {
|
||||
@@ -3131,6 +3208,18 @@ button:disabled,
|
||||
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 {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
@@ -3713,6 +3802,7 @@ button:disabled,
|
||||
.toolbar,
|
||||
.entity-grid,
|
||||
.grid,
|
||||
.pokemon-fetch-panel,
|
||||
.pokemon-edit-grid,
|
||||
.coming-soon-preview {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
@@ -180,6 +180,10 @@ function formatImperialHeight(inches: number): string {
|
||||
return `${feet}'${remainingInches}"`;
|
||||
}
|
||||
|
||||
function pokemonTypeIconSrc(typeId: number): string | null {
|
||||
return typeId >= 1 && typeId <= 19 ? `/types/small/${typeId}.png` : null;
|
||||
}
|
||||
|
||||
async function loadPokemonDetail() {
|
||||
const nextPokemon = await api.pokemonDetail(String(route.params.id));
|
||||
pokemon.value = nextPokemon;
|
||||
@@ -319,7 +323,10 @@ watch(
|
||||
|
||||
<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">{{ 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>
|
||||
<p v-else class="meta-line">{{ t('common.none') }}</p>
|
||||
</section>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
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 { useRoute, useRouter } from 'vue-router';
|
||||
import Modal from '../components/Modal.vue';
|
||||
@@ -10,13 +10,15 @@ import StatusMessage from '../components/StatusMessage.vue';
|
||||
import Tabs from '../components/Tabs.vue';
|
||||
import TagsSelect from '../components/TagsSelect.vue';
|
||||
import TranslationFields from '../components/TranslationFields.vue';
|
||||
import { iconCancel, iconSave } from '../icons';
|
||||
import { iconCancel, iconSave, iconSearch } from '../icons';
|
||||
import {
|
||||
api,
|
||||
type ConfigType,
|
||||
type Language,
|
||||
type NamedEntity,
|
||||
type Options,
|
||||
type PokemonFetchOption,
|
||||
type PokemonFetchResult,
|
||||
type PokemonPayload,
|
||||
type PokemonStats,
|
||||
type TranslationMap
|
||||
@@ -35,11 +37,17 @@ const itemOptions = ref<NamedEntity[]>([]);
|
||||
const languages = ref<Language[]>([]);
|
||||
const loading = ref(true);
|
||||
const busy = ref(false);
|
||||
const fetchBusy = ref(false);
|
||||
const fetchOptionsLoading = ref(false);
|
||||
const fetchOptionsOpen = ref(false);
|
||||
const message = ref('');
|
||||
const fetchIdentifier = ref('');
|
||||
const fetchOptions = ref<PokemonFetchOption[]>([]);
|
||||
const creatingSelect = ref('');
|
||||
const activeEditTab = ref('basic');
|
||||
const heightUnit = ref<'imperial' | 'metric'>('imperial');
|
||||
const weightUnit = ref<'imperial' | 'metric'>('imperial');
|
||||
let fetchOptionsController: AbortController | null = null;
|
||||
|
||||
function defaultPokemonStats(): PokemonStats {
|
||||
return {
|
||||
@@ -174,6 +182,46 @@ function pokemonIdForSave() {
|
||||
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() {
|
||||
const id = pokemonIdForSave();
|
||||
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) {
|
||||
const cleanName = name.trim();
|
||||
if (!cleanName) return;
|
||||
@@ -262,6 +399,10 @@ async function createMultiOption(selectKey: string, type: ConfigType, name: stri
|
||||
}
|
||||
|
||||
async function savePokemon() {
|
||||
if (fetchBusy.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasRequiredBasicFields()) {
|
||||
await showBasicFieldValidation();
|
||||
return;
|
||||
@@ -301,7 +442,10 @@ onMounted(() => {
|
||||
void loadEditor();
|
||||
});
|
||||
|
||||
onBeforeUnmount(cancelFetchOptionsRequest);
|
||||
|
||||
watch(() => pokemonForm.value.skillIds.slice(), syncSkillItemDrops);
|
||||
watch(fetchIdentifier, refreshFetchOptions);
|
||||
</script>
|
||||
|
||||
<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">
|
||||
<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')">
|
||||
<div class="pokemon-edit-grid">
|
||||
<div class="field">
|
||||
@@ -476,10 +661,8 @@ watch(() => pokemonForm.value.skillIds.slice(), syncSkillItemDrops);
|
||||
v-model="pokemonForm.typeIds"
|
||||
:options="options.pokemonTypes"
|
||||
:max="2"
|
||||
allow-create
|
||||
:creating="creatingSelect === 'pokemon-types'"
|
||||
:placeholder="t('pages.pokemon.searchTypes')"
|
||||
@create="createMultiOption('pokemon-types', 'pokemon-types', $event, pokemonForm.typeIds, 2)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -498,7 +681,7 @@ watch(() => pokemonForm.value.skillIds.slice(), syncSkillItemDrops);
|
||||
</section>
|
||||
|
||||
<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" />
|
||||
{{ busy ? t('common.saving') : t('common.save') }}
|
||||
</button>
|
||||
|
||||
@@ -44,6 +44,10 @@ async function loadPokemon() {
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
function pokemonTypeIconSrc(typeId: number): string | null {
|
||||
return typeId >= 1 && typeId <= 19 ? `/types/small/${typeId}.png` : null;
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
options.value = await api.options();
|
||||
await loadPokemon();
|
||||
@@ -145,7 +149,12 @@ watch(query, loadPokemon);
|
||||
:to="`/pokemon/${item.id}`"
|
||||
>
|
||||
<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.favorite_things" />
|
||||
</EntityCard>
|
||||
|
||||
Reference in New Issue
Block a user