diff --git a/DESIGN.md b/DESIGN.md index 5b32aa3..e56f1df 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -148,6 +148,34 @@ - 详情页展示最后编辑者、最后编辑时间和编辑历史面板。 - 编辑历史中的用户信息只展示必要署名,不暴露邮箱、token、hash 或内部元数据。 +## Wiki 图片上传 + +- 已验证用户可以为以下 Wiki 实体上传图片: + - Pokemon + - 物品图标 + - 栖息地 +- 上传图片只支持 `png`、`jpg/jpeg`、`webp`、`gif`。 +- 上传图片由服务端保存到受控上传目录,不接受任意外部 URL,也不信任客户端传入的最终文件路径。 +- 上传路径由服务端按实体类型、实体展示名称和时间戳生成,格式示例: + - `items/甜蜜蜜/20260501002000.png` + - `pokemon/Pikachu/20260501002000.png` + - `habitats/森林/20260501002000.png` +- 路径中的实体名称仅用于资源归档和可读性,实体关联仍以数据库 ID 为准。 +- 每次上传都会写入 `entity_image_uploads` 历史记录: + - `entity_type` + - `entity_id` + - `entity_name` + - `path` + - `original_filename` + - `mime_type` + - `byte_size` + - `created_by_user_id` + - `created_at` +- 实体表只保存当前显示图片的相对路径;历史上传记录不会因为切换当前图片而删除。 +- API 对外返回图片展示所需字段:`path`、`url`、上传时间和上传者必要署名;不返回服务器绝对文件路径或内部存储元数据。 +- 图片上传本身不直接改变实体内容;用户仍需保存实体编辑表单后,当前图片选择才成为实体行为并写入现有编辑审计。 +- Docker 运行时上传目录必须使用 volume 持久化,避免重新 build 后丢失用户上传图片。 + ## 实体讨论 - Pokemon、物品、材料单、栖息地详情页支持讨论。 @@ -260,6 +288,7 @@ Pokemon 编辑表单使用标签页组织字段: - 图片选择不直接创建或更新 Pokemon;用户仍需通过 Save 保存,保存时沿用现有编辑审计。 - 图片选择界面使用 Pokédex 风格:上方显示当前选择的大图,大图下方显示版本、状态和描述,再下方以缩略图网格展示同一 Pokemon 的不同风格 / 版本 / 状态。 - Pokemon 保存显示图片的相对路径、风格、版本、状态和描述;API 对外返回可直接展示的图片 URL,但不暴露内部校验状态。 + - Pokemon 也支持社区上传图片;上传图片使用通用 Wiki 图片上传历史,当前显示图片可在静态候选和上传图片之间切换。 - 基础标签页: - 第一行:ID、名称 - 第二行:喜欢的环境、特长 @@ -321,6 +350,7 @@ Pokemon 详情页展示: - 可改花纹 - 无材料单:`no_recipe` - 标签:使用喜欢的东西配置,可多选 +- 图标图片:通过通用 Wiki 图片上传维护当前图标和历史上传记录 - 翻译 - 排序 @@ -331,10 +361,14 @@ Pokemon 详情页展示: - 按用途筛选 - 按标签筛选 - 按自定义排序展示 +- 物品列表卡片使用与 Pokemon 列表一致的居中图鉴式布局,只展示物品图标、名称和分类;不展示标签、入手方式或编辑元信息。 +- 有用途的物品在卡片左上角以斜 Ribbon 展示用途名称。 +- 已配置图标时,物品卡片展示图标缩略图;未配置图标时保留默认物品标记。 物品详情页展示: - 基本信息 +- 图标图片和图片上传历史 - 分类 - 用途 - 入手方式 @@ -369,6 +403,9 @@ Pokemon 详情页展示: - 独立于物品列表展示 - 按结果物品分类展示 - 按自定义排序展示 +- 材料单列表卡片使用与 Pokemon 列表一致的居中图鉴式布局,按结果物品展示图标、名称和分类;不展示编辑元信息。 +- 有用途的结果物品在卡片左上角以斜 Ribbon 展示用途名称。 +- Create Recipe 按钮展示在结果物品名称下方;已有材料单的卡片保留同等按钮空间但不显示按钮;标记为无材料单的物品展示禁用按钮;可创建材料单的物品展示可点击按钮并进入创建流程。 材料单详情页展示: @@ -386,6 +423,7 @@ Pokemon 详情页展示: - 名称 - 配方:多项物品 + 数量 - 可出现的 Pokemon +- 图片:通过通用 Wiki 图片上传维护当前图片和历史上传记录 - 翻译 - 排序 @@ -407,10 +445,12 @@ Pokemon 出现配置: 栖息地列表功能: - 按自定义排序展示 -- 展示配方摘要和可能出现的 Pokemon 摘要 +- 栖息地列表卡片使用与 Pokemon 列表一致的居中图鉴式布局,只展示栖息地图片和名称;不展示配方摘要、可能出现的 Pokemon 摘要或编辑元信息。 +- 已配置图片时,栖息地卡片展示图片缩略图;未配置图片时保留默认栖息地标记。 栖息地详情页展示: +- 图片和图片上传历史 - 配方列表 - 可能出现的 Pokemon 列表 - 出现时间 @@ -559,6 +599,7 @@ API 暴露边界: - `GET /api/pokemon/fetch-options`:按搜索词返回 Pokemon CSV data 搜索结果;需要已验证用户;只返回 `id`、`identifier`、`name`。 - `POST /api/pokemon/fetch`:按 data identifier 或 Pokemon ID 查询 CSV 资料并填充 Pokemon 编辑表单;需要已验证用户;不直接保存 Pokemon。 - `POST /api/pokemon/image-options`:按 data identifier 或 Pokemon ID 查询 pokesprite 可用图片候选;需要已验证用户;只返回 `id`、`identifier` 和图片候选列表。 +- `POST /api/uploads/:entityType`:上传 Wiki 图片;需要已验证用户;`entityType` 支持 `pokemon`、`items`、`habitats`;返回图片历史记录项和可展示 URL。 - Life Post 的创建,以及作者本人对 Life Post 的更新、删除。 - `POST /api/life-posts` - `PUT /api/life-posts/:id` diff --git a/backend/db/schema.sql b/backend/db/schema.sql index 5ac6d35..c5f37cc 100644 --- a/backend/db/schema.sql +++ b/backend/db/schema.sql @@ -330,11 +330,13 @@ CREATE TABLE IF NOT EXISTS items ( dual_dyeable boolean NOT NULL DEFAULT false, pattern_editable boolean NOT NULL DEFAULT false, no_recipe boolean NOT NULL DEFAULT false, + image_path text NOT NULL DEFAULT '', sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0) ); ALTER TABLE items ALTER COLUMN usage_id DROP NOT NULL; ALTER TABLE items ADD COLUMN IF NOT EXISTS no_recipe boolean NOT NULL DEFAULT false; +ALTER TABLE items ADD COLUMN IF NOT EXISTS image_path text NOT NULL DEFAULT ''; ALTER TABLE items DROP COLUMN IF EXISTS no_habitat; CREATE TABLE IF NOT EXISTS recipes ( @@ -420,6 +422,7 @@ CREATE TABLE IF NOT EXISTS maps ( CREATE TABLE IF NOT EXISTS habitats ( id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, name text NOT NULL UNIQUE, + image_path text NOT NULL DEFAULT '', sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0) ); @@ -532,6 +535,7 @@ ALTER TABLE habitats ADD COLUMN IF NOT EXISTS updated_by_user_id integer REFEREN ALTER TABLE habitats ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now(); ALTER TABLE habitats ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now(); ALTER TABLE habitats ADD COLUMN IF NOT EXISTS sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0); +ALTER TABLE habitats ADD COLUMN IF NOT EXISTS image_path text NOT NULL DEFAULT ''; WITH ordered AS ( SELECT id, (row_number() OVER (ORDER BY created_at, id) * 10)::integer AS next_sort_order @@ -695,6 +699,37 @@ CREATE INDEX IF NOT EXISTS wiki_edit_logs_entity_idx CREATE INDEX IF NOT EXISTS wiki_edit_logs_user_id_idx ON wiki_edit_logs(user_id); +CREATE TABLE IF NOT EXISTS entity_image_uploads ( + id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + entity_type text NOT NULL CHECK (entity_type IN ('pokemon', 'items', 'habitats')), + entity_id integer, + entity_name text NOT NULL, + path text NOT NULL UNIQUE, + original_filename text NOT NULL DEFAULT '', + mime_type text NOT NULL CHECK (mime_type IN ('image/png', 'image/jpeg', 'image/webp', 'image/gif')), + byte_size integer NOT NULL CHECK (byte_size > 0), + created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL, + created_at timestamptz NOT NULL DEFAULT now(), + CHECK (length(entity_name) BETWEEN 1 AND 120), + CHECK (path !~ '(^/|\\.\\.)') +); + +ALTER TABLE entity_image_uploads DROP CONSTRAINT IF EXISTS entity_image_uploads_entity_type_check; +ALTER TABLE entity_image_uploads ADD CONSTRAINT entity_image_uploads_entity_type_check CHECK ( + entity_type IN ('pokemon', 'items', 'habitats') +); + +ALTER TABLE entity_image_uploads DROP CONSTRAINT IF EXISTS entity_image_uploads_mime_type_check; +ALTER TABLE entity_image_uploads ADD CONSTRAINT entity_image_uploads_mime_type_check CHECK ( + mime_type IN ('image/png', 'image/jpeg', 'image/webp', 'image/gif') +); + +CREATE INDEX IF NOT EXISTS entity_image_uploads_entity_idx + ON entity_image_uploads(entity_type, entity_id, created_at DESC, id DESC); + +CREATE INDEX IF NOT EXISTS entity_image_uploads_user_idx + ON entity_image_uploads(created_by_user_id); + CREATE TABLE IF NOT EXISTS entity_discussion_comments ( id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, entity_type text NOT NULL CHECK (entity_type IN ('pokemon', 'items', 'recipes', 'habitats')), diff --git a/backend/package.json b/backend/package.json index 8bdbc46..3af8ec8 100644 --- a/backend/package.json +++ b/backend/package.json @@ -14,6 +14,8 @@ }, "dependencies": { "@fastify/cors": "latest", + "@fastify/multipart": "^10.0.0", + "@fastify/static": "^9.1.3", "fastify": "latest", "pg": "latest" }, diff --git a/backend/src/queries.ts b/backend/src/queries.ts index f5bfd04..3c21863 100644 --- a/backend/src/queries.ts +++ b/backend/src/queries.ts @@ -1,5 +1,12 @@ import { parseIdList, parseMatchMode, sqlForRelationFilter } from './filter.ts'; import { pool, query, queryOne } from './db.ts'; +import { + isUploadImagePath, + linkEntityImageUpload, + listEntityImageUploads, + uploadImageUrl, + uploadPublicBaseUrl +} from './uploads.ts'; import { Buffer } from 'node:buffer'; import { readFile } from 'node:fs/promises'; import { dirname, resolve } from 'node:path'; @@ -77,6 +84,12 @@ type PokemonImage = { version: string; variant: string; description: string; + source?: 'sprite' | 'upload'; +}; + +type EntityImageValue = { + path: string; + url: string; }; type PokemonImageCandidate = Omit; @@ -143,6 +156,7 @@ type ItemPayload = { noRecipe: boolean; acquisitionMethodIds: number[]; tagIds: number[]; + imagePath: string; }; type RecipePayload = { @@ -236,6 +250,7 @@ type LifePostsPage = { type HabitatPayload = { name: string; translations: TranslationInput; + imagePath: string; recipeItems: IdQuantity[]; pokemonAppearances: Array<{ pokemonId: number; @@ -282,6 +297,7 @@ type PokemonChangeSource = { }; type ItemChangeSource = { name: string; + image: EntityImageValue | null; category: { name: string }; usage: { name: string } | null; customization: { dyeable: boolean; dualDyeable: boolean; patternEditable: boolean }; @@ -291,6 +307,7 @@ type ItemChangeSource = { }; type HabitatChangeSource = { name: string; + image: EntityImageValue | null; recipe: Array<{ name: string; quantity: number }>; pokemon: Array<{ name: string; time_of_day: string; weather: string; rarity: number; map: { name: string } }>; }; @@ -360,6 +377,25 @@ function sqlLiteral(value: string): string { return `'${value.replaceAll("'", "''")}'`; } +function uploadedImageJson(pathExpression: string): string { + return ` + CASE WHEN ${pathExpression} <> '' THEN json_build_object( + 'path', ${pathExpression}, + 'url', ${sqlLiteral(uploadPublicBaseUrl)} || ${pathExpression} + ) ELSE NULL END + `; +} + +function imagePathLabel(path: string | null | undefined): string { + const cleanPath = path?.trim() ?? ''; + if (cleanPath === '') { + return ''; + } + + const parts = cleanPath.split('/'); + return parts.length >= 3 ? `${parts[1]} / ${parts[2]}` : cleanPath; +} + function localizedField( entityType: EntityType, entityIdExpression: string, @@ -559,6 +595,17 @@ function cleanOptionalText(value: unknown): string { return typeof value === 'string' ? value.trim() : ''; } +function cleanUploadImagePath(value: unknown, entityType: 'items' | 'habitats'): string { + const imagePath = cleanOptionalText(value); + if (imagePath === '') { + return ''; + } + if (!isUploadImagePath(imagePath) || !imagePath.startsWith(`${entityType}/`)) { + throw validationError('server.validation.imagePathInvalid'); + } + return imagePath; +} + function cleanIds(value: unknown): number[] { if (!Array.isArray(value)) { return []; @@ -1039,7 +1086,7 @@ function pokemonSpriteUrl(path: string): string { } function pokemonImageWithUrl(candidate: PokemonImageCandidate): PokemonImage { - return { ...candidate, url: pokemonSpriteUrl(candidate.path) }; + return { ...candidate, url: pokemonSpriteUrl(candidate.path), source: 'sprite' }; } function pokemonImageCandidates(id: number): PokemonImageCandidate[] { @@ -1223,7 +1270,10 @@ function pokemonImageCandidates(id: number): PokemonImageCandidate[] { } function pokemonImageLabel(image: PokemonImage | null | undefined): string { - return image ? `${image.style} - ${image.version} - ${image.variant}` : ''; + if (!image) { + return ''; + } + return image.source === 'upload' || isUploadImagePath(image.path) ? imagePathLabel(image.path) : `${image.style} - ${image.version} - ${image.variant}`; } function pokemonImageCandidateForPath(id: number, path: string): PokemonImage | null { @@ -1238,6 +1288,21 @@ function cleanPokemonImage(value: unknown, pokemonId: number): PokemonImage | nu return null; } + if (isUploadImagePath(path)) { + if (!path.startsWith('pokemon/')) { + throw validationError('server.validation.imagePathInvalid'); + } + return { + path, + url: uploadImageUrl(path), + style: 'Upload', + version: 'Community upload', + variant: `#${pokemonId}`, + description: '', + source: 'upload' + }; + } + const image = pokemonImageCandidateForPath(pokemonId, path); if (!image) { throw validationError('Pokemon image path is invalid'); @@ -1653,6 +1718,7 @@ async function itemEditChanges( const tagNames = await entityNameMap(client, 'favorite_things', after.tagIds); pushChange(changes, 'Name', before.name, after.name); + pushChange(changes, 'Image', imagePathLabel(before.image?.path), imagePathLabel(after.imagePath)); pushChange(changes, 'Category', before.category.name, categoryNames.get(after.categoryId)); pushChange(changes, 'Usage', before.usage?.name, after.usageId ? usageNames.get(after.usageId) : null); pushChange(changes, 'Dyeable', boolValue(before.customization.dyeable), boolValue(after.dyeable)); @@ -1684,6 +1750,7 @@ async function habitatEditChanges( .join(' / '); pushChange(changes, 'Name', before.name, after.name); + pushChange(changes, 'Image', imagePathLabel(before.image?.path), imagePathLabel(after.imagePath)); pushChange(changes, 'Recipe', quantityListValue(before.recipe), await quantityPayloadValue(client, after.recipeItems)); pushChange(changes, 'Possible Pokemon', appearanceListValue(before.pokemon), afterAppearances); @@ -1749,14 +1816,27 @@ function pokemonProjection(locale: string): string { round((p.height_inches * 0.0254)::numeric, 2)::double precision AS "heightMeters", p.weight_pounds AS "weightPounds", round((p.weight_pounds * 0.45359237)::numeric, 2)::double precision AS "weightKg", - CASE WHEN p.image_path <> '' THEN json_build_object( - 'path', p.image_path, - 'url', '${pokemonSpriteBaseUrl}' || p.image_path, - 'style', p.image_style, - 'version', p.image_version, - 'variant', p.image_variant, - 'description', p.image_description - ) ELSE NULL END AS image, + CASE + WHEN p.image_path LIKE '/sprites/%' THEN json_build_object( + 'path', p.image_path, + 'url', '${pokemonSpriteBaseUrl}' || p.image_path, + 'style', p.image_style, + 'version', p.image_version, + 'variant', p.image_variant, + 'description', p.image_description, + 'source', 'sprite' + ) + WHEN p.image_path <> '' THEN json_build_object( + 'path', p.image_path, + 'url', ${sqlLiteral(uploadPublicBaseUrl)} || p.image_path, + 'style', 'Upload', + 'version', 'Community upload', + 'variant', p.name, + 'description', '', + 'source', 'upload' + ) + ELSE NULL + END AS image, json_build_object( 'hp', p.hp, 'attack', p.attack, @@ -2951,7 +3031,7 @@ export async function getPokemon(id: number, locale = defaultLocale) { const relatedSkillName = localizedName('skills', 'related_skill', locale); const relatedFavoriteThingName = localizedName('favorite-things', 'related_favorite_thing', locale); - const [habitats, itemDrops, favoriteThingItems, relatedPokemon, editHistory] = await Promise.all([ + const [habitats, itemDrops, favoriteThingItems, relatedPokemon, editHistory, imageHistory] = await Promise.all([ query( ` SELECT @@ -3068,7 +3148,8 @@ export async function getPokemon(id: number, locale = defaultLocale) { `, [id] ), - getEditHistory('pokemon', id) + getEditHistory('pokemon', id), + listEntityImageUploads('pokemon', id) ]); const dropsBySkill = itemDrops.reduce((itemsBySkill, item) => { @@ -3083,7 +3164,7 @@ export async function getPokemon(id: number, locale = defaultLocale) { })) : []; - return { ...pokemon, skills, habitats, favoriteThingItems, relatedPokemon, editHistory }; + return { ...pokemon, skills, habitats, favoriteThingItems, relatedPokemon, editHistory, imageHistory }; } function cleanPokemonPayload(payload: Record): PokemonPayload { @@ -3245,6 +3326,7 @@ export async function createPokemon(payload: Record, userId: nu userId ] ); + await linkEntityImageUpload(client, 'pokemon', cleanPayload.id, cleanPayload.image?.path, cleanPayload.name); await replacePokemonRelations(client, cleanPayload.id, cleanPayload); await replaceEntityTranslations(client, 'pokemon', cleanPayload.id, cleanPayload.translations, ['name', 'details', 'genus']); await recordEditLog(client, 'pokemon', cleanPayload.id, 'create', userId); @@ -3308,6 +3390,7 @@ export async function updatePokemon(id: number, payload: Record if (result.rowCount === 0) { return false; } + await linkEntityImageUpload(client, 'pokemon', id, cleanPayload.image?.path, cleanPayload.name); await replacePokemonRelations(client, id, cleanPayload); await replaceEntityTranslations(client, 'pokemon', id, cleanPayload.translations, ['name', 'details', 'genus']); const changes = before ? await pokemonEditChanges(client, before as unknown as PokemonChangeSource, cleanPayload) : []; @@ -3343,6 +3426,7 @@ export async function listHabitats(locale = defaultLocale) { h.name AS "baseName", ${translationsSelect('habitats', 'h.id')} AS translations, ${auditSelect('h', 'habitat_created_user', 'habitat_updated_user')}, + ${uploadedImageJson('h.image_path')} AS image, COALESCE(( SELECT json_agg(json_build_object('id', i.id, 'name', ${itemName}, 'quantity', hri.quantity) ORDER BY ${orderByEntity('i')}) FROM habitat_recipe_items hri @@ -3378,6 +3462,7 @@ export async function getHabitat(id: number, locale = defaultLocale) { h.name AS "baseName", ${translationsSelect('habitats', 'h.id')} AS translations, ${auditSelect('h', 'habitat_created_user', 'habitat_updated_user')}, + ${uploadedImageJson('h.image_path')} AS image, COALESCE(( SELECT json_agg(json_build_object('id', i.id, 'name', ${itemName}, 'quantity', hri.quantity) ORDER BY ${orderByEntity('i')}) FROM habitat_recipe_items hri @@ -3395,7 +3480,7 @@ export async function getHabitat(id: number, locale = defaultLocale) { return null; } - const [pokemon, editHistory] = await Promise.all([ + const [pokemon, editHistory, imageHistory] = await Promise.all([ query( ` SELECT @@ -3413,10 +3498,11 @@ export async function getHabitat(id: number, locale = defaultLocale) { `, [id] ), - getEditHistory('habitats', id) + getEditHistory('habitats', id), + listEntityImageUploads('habitats', id) ]); - return { ...habitat, pokemon, editHistory }; + return { ...habitat, pokemon, editHistory, imageHistory }; } function cleanHabitatPayload(payload: Record): HabitatPayload { @@ -3453,6 +3539,7 @@ function cleanHabitatPayload(payload: Record): HabitatPayload { return { name: cleanName(payload.name, 'Habitat name is required'), translations: cleanTranslations(payload.translations, ['name']), + imagePath: cleanUploadImagePath(payload.imagePath, 'habitats'), recipeItems: cleanQuantities(payload.recipeItems), pokemonAppearances: [...pokemonAppearances.values()] }; @@ -3488,13 +3575,14 @@ export async function createHabitat(payload: Record, userId: nu const sortOrder = await nextSortOrder(client, 'habitats'); const result = await client.query<{ id: number }>( ` - INSERT INTO habitats (name, sort_order, created_by_user_id, updated_by_user_id) - VALUES ($1, $2, $3, $3) + INSERT INTO habitats (name, image_path, sort_order, created_by_user_id, updated_by_user_id) + VALUES ($1, $2, $3, $4, $4) RETURNING id `, - [cleanPayload.name, sortOrder, userId] + [cleanPayload.name, cleanPayload.imagePath, sortOrder, userId] ); const habitatId = result.rows[0].id; + await linkEntityImageUpload(client, 'habitats', habitatId, cleanPayload.imagePath, cleanPayload.name); await replaceHabitatRelations(client, habitatId, cleanPayload); await replaceEntityTranslations(client, 'habitats', habitatId, cleanPayload.translations, ['name']); await recordEditLog(client, 'habitats', habitatId, 'create', userId); @@ -3509,12 +3597,13 @@ export async function updateHabitat(id: number, payload: Record const updated = await withTransaction(async (client) => { const result = await client.query( - 'UPDATE habitats SET name = $1, updated_by_user_id = $2, updated_at = now() WHERE id = $3', - [cleanPayload.name, userId, id] + 'UPDATE habitats SET name = $1, image_path = $2, updated_by_user_id = $3, updated_at = now() WHERE id = $4', + [cleanPayload.name, cleanPayload.imagePath, userId, id] ); if (result.rowCount === 0) { return false; } + await linkEntityImageUpload(client, 'habitats', id, cleanPayload.imagePath, cleanPayload.name); await replaceHabitatRelations(client, id, cleanPayload); await replaceEntityTranslations(client, 'habitats', id, cleanPayload.translations, ['name']); const changes = before ? await habitatEditChanges(client, before as unknown as HabitatChangeSource, cleanPayload) : []; @@ -3551,6 +3640,7 @@ function itemProjection(locale: string): string { i.name AS "baseName", ${translationsSelect('items', 'i.id')} AS translations, ${auditSelect('i', 'item_created_user', 'item_updated_user')}, + ${uploadedImageJson('i.image_path')} AS image, json_build_object('id', c.id, 'name', ${categoryName}) AS category, CASE WHEN u.id IS NULL THEN NULL ELSE json_build_object('id', u.id, 'name', ${usageName}) END AS usage, json_build_object( @@ -3649,7 +3739,7 @@ export async function getItem(id: number, locale = defaultLocale) { const pokemonName = localizedName('pokemon', 'p', locale); const skillName = localizedName('skills', 's', locale); - const [acquisitionMethods, recipe, relatedRecipes, relatedHabitats, droppedByPokemon, editHistory] = await Promise.all([ + const [acquisitionMethods, recipe, relatedRecipes, relatedHabitats, droppedByPokemon, editHistory, imageHistory] = await Promise.all([ query( ` SELECT am.id, ${acquisitionMethodName} AS name @@ -3737,10 +3827,11 @@ export async function getItem(id: number, locale = defaultLocale) { `, [id] ), - getEditHistory('items', id) + getEditHistory('items', id), + listEntityImageUploads('items', id) ]); - return { ...item, acquisitionMethods, recipe, relatedRecipes, relatedHabitats, droppedByPokemon, editHistory }; + return { ...item, acquisitionMethods, recipe, relatedRecipes, relatedHabitats, droppedByPokemon, editHistory, imageHistory }; } function cleanItemPayload(payload: Record): ItemPayload { @@ -3758,7 +3849,8 @@ function cleanItemPayload(payload: Record): ItemPayload { patternEditable: Boolean(payload.patternEditable), noRecipe: Boolean(payload.noRecipe), acquisitionMethodIds: cleanIds(payload.acquisitionMethodIds), - tagIds: cleanIds(payload.tagIds) + tagIds: cleanIds(payload.tagIds), + imagePath: cleanUploadImagePath(payload.imagePath, 'items') }; } @@ -3807,11 +3899,12 @@ export async function createItem(payload: Record, userId: numbe dual_dyeable, pattern_editable, no_recipe, + image_path, sort_order, created_by_user_id, updated_by_user_id ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $9) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $10) RETURNING id `, [ @@ -3822,11 +3915,13 @@ export async function createItem(payload: Record, userId: numbe cleanPayload.dualDyeable, cleanPayload.patternEditable, cleanPayload.noRecipe, + cleanPayload.imagePath, sortOrder, userId ] ); const itemId = result.rows[0].id; + await linkEntityImageUpload(client, 'items', itemId, cleanPayload.imagePath, cleanPayload.name); await replaceItemRelations(client, itemId, cleanPayload); await replaceEntityTranslations(client, 'items', itemId, cleanPayload.translations, ['name']); await recordEditLog(client, 'items', itemId, 'create', userId); @@ -3851,9 +3946,10 @@ export async function updateItem(id: number, payload: Record, u dual_dyeable = $5, pattern_editable = $6, no_recipe = $7, - updated_by_user_id = $8, + image_path = $8, + updated_by_user_id = $9, updated_at = now() - WHERE id = $9 + WHERE id = $10 `, [ cleanPayload.name, @@ -3863,6 +3959,7 @@ export async function updateItem(id: number, payload: Record, u cleanPayload.dualDyeable, cleanPayload.patternEditable, cleanPayload.noRecipe, + cleanPayload.imagePath, userId, id ] @@ -3870,6 +3967,7 @@ export async function updateItem(id: number, payload: Record, u if (result.rowCount === 0) { return false; } + await linkEntityImageUpload(client, 'items', id, cleanPayload.imagePath, cleanPayload.name); await replaceItemRelations(client, id, cleanPayload); await replaceEntityTranslations(client, 'items', id, cleanPayload.translations, ['name']); const changes = before ? await itemEditChanges(client, before as unknown as ItemChangeSource, cleanPayload) : []; diff --git a/backend/src/server.ts b/backend/src/server.ts index 50efd6e..954ecd2 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -1,6 +1,9 @@ import cors from '@fastify/cors'; +import multipart, { type MultipartFile } from '@fastify/multipart'; +import fastifyStatic from '@fastify/static'; import Fastify from 'fastify'; import type { FastifyReply, FastifyRequest } from 'fastify'; +import { mkdir } from 'node:fs/promises'; import { getUserBySessionToken, loginUser, @@ -81,6 +84,12 @@ import { systemMessage, updateSystemWordingValue } from './systemWordingQueries.ts'; +import { + imageUploadMaxBytes, + isUploadEntityType, + saveEntityImageUpload, + uploadRoot +} from './uploads.ts'; const app = Fastify({ logger: true @@ -92,6 +101,19 @@ await app.register(cors, { origin: process.env.FRONTEND_ORIGIN ?? true }); +await mkdir(uploadRoot, { recursive: true }); +await app.register(multipart, { + limits: { + fileSize: imageUploadMaxBytes, + files: 1 + } +}); +await app.register(fastifyStatic, { + root: uploadRoot, + prefix: '/uploads/', + decorateReply: false +}); + app.setErrorHandler(async (error, _request, reply) => { const pgError = error as Error & { code?: string; constraint?: string; detail?: string; statusCode?: number }; const locale = requestLocale(_request); @@ -137,6 +159,12 @@ function serverMessage( return systemMessage(locale, `server.errors.${key}`); } +function badRequest(message: string): Error & { statusCode: number } { + const error = new Error(message) as Error & { statusCode: number }; + error.statusCode = 400; + return error; +} + async function notFound(reply: FastifyReply, request: FastifyRequest) { return reply.code(404).send({ message: await serverMessage(requestLocale(request), 'notFound') }); } @@ -408,6 +436,31 @@ app.post('/api/pokemon/image-options', async (request, reply) => { return user ? fetchPokemonImageOptions(request.body as Record) : undefined; }); +app.post('/api/uploads/:entityType', async (request, reply) => { + const user = await requireVerifiedUser(request, reply); + if (!user) { + return; + } + + const { entityType } = request.params as { entityType: string }; + if (!isUploadEntityType(entityType)) { + return notFound(reply, request); + } + + let file: MultipartFile | undefined; + try { + file = await request.file(); + } catch (error) { + const multipartError = error as Error & { code?: string }; + if (multipartError.code === 'FST_REQ_FILE_TOO_LARGE') { + throw badRequest('server.validation.imageUploadContentInvalid'); + } + throw error; + } + + return reply.code(201).send(await saveEntityImageUpload(entityType, file, user)); +}); + app.put('/api/pokemon/:id', async (request, reply) => { const user = await requireVerifiedUser(request, reply); if (!user) { diff --git a/backend/src/uploads.ts b/backend/src/uploads.ts new file mode 100644 index 0000000..32b9590 --- /dev/null +++ b/backend/src/uploads.ts @@ -0,0 +1,286 @@ +import { mkdir, stat, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import type { MultipartFile } from '@fastify/multipart'; +import type { PoolClient } from 'pg'; +import type { AuthUser } from './auth.ts'; +import { query, queryOne } from './db.ts'; + +export type UploadEntityType = 'pokemon' | 'items' | 'habitats'; + +export type EntityImageUpload = { + id: number; + entityType: UploadEntityType; + entityId: number | null; + entityName: string; + path: string; + url: string; + originalFilename: string; + mimeType: string; + byteSize: number; + uploadedAt: Date; + uploadedBy: { id: number; displayName: string } | null; +}; + +type UploadRow = { + id: number; + entityType: UploadEntityType; + entityId: number | null; + entityName: string; + path: string; + originalFilename: string; + mimeType: string; + byteSize: number; + uploadedAt: Date; + uploadedBy: { id: number; displayName: string } | null; +}; + +type MultipartField = { + value?: unknown; +}; + +const uploadEntityTypes = new Set(['pokemon', 'items', 'habitats']); +const imageMimeTypes = new Map([ + ['image/png', '.png'], + ['image/jpeg', '.jpg'], + ['image/webp', '.webp'], + ['image/gif', '.gif'] +]); + +const backendPublicOrigin = process.env.BACKEND_PUBLIC_ORIGIN ?? `http://localhost:${process.env.BACKEND_PORT ?? 3001}`; +export const imageUploadMaxBytes = 3 * 1024 * 1024; +export const uploadRoot = path.resolve(process.env.UPLOAD_DIR ?? path.join(process.cwd(), 'uploads')); +export const uploadPublicBaseUrl = (process.env.UPLOAD_PUBLIC_BASE_URL ?? `${backendPublicOrigin}/uploads/`).replace(/\/?$/, '/'); + +export function isUploadEntityType(value: string): value is UploadEntityType { + return uploadEntityTypes.has(value as UploadEntityType); +} + +export function isUploadImagePath(value: string | null | undefined): boolean { + const cleanPath = value?.trim() ?? ''; + if (cleanPath === '' || cleanPath.startsWith('/') || cleanPath.includes('..')) { + return false; + } + + const [entityType] = cleanPath.split('/'); + return isUploadEntityType(entityType); +} + +export function uploadImageUrl(relativePath: string): string { + return `${uploadPublicBaseUrl}${relativePath.split('/').map(encodeURIComponent).join('/')}`; +} + +function validationError(message: string): Error & { statusCode: number } { + const error = new Error(message) as Error & { statusCode: number }; + error.statusCode = 400; + return error; +} + +function fieldValue(fields: Record | undefined, fieldName: string): string { + const field = fields?.[fieldName]; + if (field && typeof field === 'object' && 'value' in field) { + const value = (field as MultipartField).value; + return typeof value === 'string' ? value.trim() : ''; + } + return ''; +} + +function optionalPositiveInteger(value: string): number | null { + const numberValue = Number(value); + return Number.isInteger(numberValue) && numberValue > 0 ? numberValue : null; +} + +function safePathSegment(value: string): string { + const segment = value + .normalize('NFKC') + .trim() + .replace(/[\\/:*?"<>|#%?&\u0000-\u001F]+/g, '-') + .replace(/\s+/g, ' ') + .replace(/^\.+$/, '') + .slice(0, 80); + + return segment || 'record'; +} + +function timestampForPath(date = new Date()): string { + const pad = (value: number) => String(value).padStart(2, '0'); + return [ + date.getUTCFullYear(), + pad(date.getUTCMonth() + 1), + pad(date.getUTCDate()), + pad(date.getUTCHours()), + pad(date.getUTCMinutes()), + pad(date.getUTCSeconds()) + ].join(''); +} + +async function fileExists(filePath: string): Promise { + try { + await stat(filePath); + return true; + } catch { + return false; + } +} + +async function uniqueRelativePath(entityType: UploadEntityType, entityName: string, extension: string): Promise { + const entitySegment = safePathSegment(entityName); + const dir = path.join(uploadRoot, entityType, entitySegment); + const timestamp = timestampForPath(); + + await mkdir(dir, { recursive: true }); + + for (let index = 1; index <= 99; index += 1) { + const suffix = index === 1 ? '' : `-${index}`; + const fileName = `${timestamp}${suffix}${extension}`; + const candidate = path.join(dir, fileName); + if (!(await fileExists(candidate))) { + return `${entityType}/${entitySegment}/${fileName}`; + } + } + + throw validationError('server.validation.imageUploadFailed'); +} + +function hasValidImageSignature(mimeType: string, buffer: Buffer): boolean { + if (mimeType === 'image/png') { + return buffer.length > 8 && buffer[0] === 0x89 && buffer.subarray(1, 4).toString('ascii') === 'PNG'; + } + + if (mimeType === 'image/jpeg') { + return buffer.length > 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff; + } + + if (mimeType === 'image/webp') { + return buffer.length > 12 && buffer.subarray(0, 4).toString('ascii') === 'RIFF' && buffer.subarray(8, 12).toString('ascii') === 'WEBP'; + } + + if (mimeType === 'image/gif') { + const signature = buffer.subarray(0, 6).toString('ascii'); + return signature === 'GIF87a' || signature === 'GIF89a'; + } + + return false; +} + +function mapUploadRow(row: UploadRow): EntityImageUpload { + return { + ...row, + url: uploadImageUrl(row.path) + }; +} + +export async function saveEntityImageUpload( + entityType: UploadEntityType, + file: MultipartFile | undefined, + user: AuthUser +): Promise { + if (!file) { + throw validationError('server.validation.imageUploadRequired'); + } + + const extension = imageMimeTypes.get(file.mimetype); + if (!extension) { + throw validationError('server.validation.imageUploadTypeInvalid'); + } + + const entityName = fieldValue(file.fields as Record, 'entityName'); + if (entityName === '') { + throw validationError('server.validation.imageUploadEntityNameRequired'); + } + + const buffer = await file.toBuffer(); + if (buffer.length === 0 || buffer.length > imageUploadMaxBytes || !hasValidImageSignature(file.mimetype, buffer)) { + throw validationError('server.validation.imageUploadContentInvalid'); + } + + const entityId = optionalPositiveInteger(fieldValue(file.fields as Record, 'entityId')); + const relativePath = await uniqueRelativePath(entityType, entityName, extension); + const absolutePath = path.join(uploadRoot, relativePath); + await writeFile(absolutePath, buffer, { flag: 'wx' }); + + const row = await queryOne( + ` + INSERT INTO entity_image_uploads ( + entity_type, + entity_id, + entity_name, + path, + original_filename, + mime_type, + byte_size, + created_by_user_id + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING + id, + entity_type AS "entityType", + entity_id AS "entityId", + entity_name AS "entityName", + path, + original_filename AS "originalFilename", + mime_type AS "mimeType", + byte_size AS "byteSize", + created_at AS "uploadedAt", + json_build_object('id', $8::integer, 'displayName', $9::text) AS "uploadedBy" + `, + [entityType, entityId, entityName.trim(), relativePath, file.filename, file.mimetype, buffer.length, user.id, user.displayName] + ); + + if (!row) { + throw validationError('server.validation.imageUploadFailed'); + } + + return mapUploadRow(row); +} + +export async function listEntityImageUploads(entityType: UploadEntityType, entityId: number): Promise { + const rows = await query( + ` + SELECT + upload.id, + upload.entity_type AS "entityType", + upload.entity_id AS "entityId", + upload.entity_name AS "entityName", + upload.path, + upload.original_filename AS "originalFilename", + upload.mime_type AS "mimeType", + upload.byte_size AS "byteSize", + upload.created_at AS "uploadedAt", + CASE + WHEN u.id IS NULL THEN NULL + ELSE json_build_object('id', u.id, 'displayName', u.display_name) + END AS "uploadedBy" + FROM entity_image_uploads upload + LEFT JOIN users u ON u.id = upload.created_by_user_id + WHERE upload.entity_type = $1 + AND upload.entity_id = $2 + ORDER BY upload.created_at DESC, upload.id DESC + `, + [entityType, entityId] + ); + + return rows.map(mapUploadRow); +} + +export async function linkEntityImageUpload( + client: PoolClient, + entityType: UploadEntityType, + entityId: number, + imagePath: string | null | undefined, + entityName: string +): Promise { + if (!isUploadImagePath(imagePath)) { + return; + } + + await client.query( + ` + UPDATE entity_image_uploads + SET entity_id = $1, + entity_name = $2 + WHERE entity_type = $3 + AND path = $4 + `, + [entityId, entityName.trim(), entityType, imagePath] + ); +} diff --git a/docker-compose.yml b/docker-compose.yml index 42669d3..0c60757 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -24,10 +24,14 @@ services: BACKEND_PORT: 3001 FRONTEND_ORIGIN: http://localhost:3000 APP_ORIGIN: http://localhost:3000 + UPLOAD_DIR: /app/uploads + BACKEND_PUBLIC_ORIGIN: http://localhost:3001 RESEND_API_KEY: ${RESEND_API_KEY:-} EMAIL_FROM: "${EMAIL_FROM:-Pokopia Wiki }" ports: - "3001:3001" + volumes: + - backend_uploads:/app/uploads depends_on: postgres: condition: service_healthy @@ -45,3 +49,4 @@ services: volumes: postgres18_data: + backend_uploads: diff --git a/frontend/src/components/EntityCard.vue b/frontend/src/components/EntityCard.vue index 503ac77..325c287 100644 --- a/frontend/src/components/EntityCard.vue +++ b/frontend/src/components/EntityCard.vue @@ -10,11 +10,13 @@ defineProps<{ icon?: AppIcon; marker?: string; image?: { src: string; alt: string }; + ribbon?: string; }>(); -
+
- +
- - -
- -
-
- -
+
-
- - - - - +
+
diff --git a/frontend/src/views/ItemDetail.vue b/frontend/src/views/ItemDetail.vue index 7f60635..0b312d9 100644 --- a/frontend/src/views/ItemDetail.vue +++ b/frontend/src/views/ItemDetail.vue @@ -37,6 +37,10 @@ const customization = computed(() => { ].filter(Boolean); }); +function imageFileName(path: string): string { + return path.split('/').at(-1) ?? t('media.image'); +} + async function loadItemDetail() { item.value = await api.itemDetail(String(route.params.id)); } @@ -136,6 +140,21 @@ watch(
+ +
+
+ +
+

{{ t('media.imageEmpty') }}

+
+
+ + {{ imageFileName(image.path) }} +
+
+
+
+ diff --git a/frontend/src/views/ItemEdit.vue b/frontend/src/views/ItemEdit.vue index 0b1b9a4..9e61ba7 100644 --- a/frontend/src/views/ItemEdit.vue +++ b/frontend/src/views/ItemEdit.vue @@ -3,19 +3,22 @@ import { Icon } from '@iconify/vue'; import { computed, onMounted, ref } from 'vue'; import { useI18n } from 'vue-i18n'; import { useRoute, useRouter } from 'vue-router'; +import ImageUploadField from '../components/ImageUploadField.vue'; import Modal from '../components/Modal.vue'; import Skeleton from '../components/Skeleton.vue'; import StatusMessage from '../components/StatusMessage.vue'; import TagsSelect from '../components/TagsSelect.vue'; import TranslationFields from '../components/TranslationFields.vue'; import { iconCancel, iconSave } from '../icons'; -import { api, type ConfigType, type ItemPayload, type Language, type Options, type TranslationMap } from '../services/api'; +import { api, type ConfigType, type EntityImage, type EntityImageUpload, type ItemPayload, type Language, type Options, type TranslationMap } from '../services/api'; const route = useRoute(); const router = useRouter(); const { locale, t } = useI18n(); const options = ref(null); const languages = ref([]); +const currentImage = ref(null); +const imageHistory = ref([]); const loading = ref(true); const busy = ref(false); const message = ref(''); @@ -30,7 +33,8 @@ const itemForm = ref({ patternEditable: false, noRecipe: false, acquisitionMethodIds: [] as string[], - tagIds: [] as string[] + tagIds: [] as string[], + imagePath: '' }); const routeId = computed(() => (typeof route.params.id === 'string' ? route.params.id : '')); @@ -42,6 +46,7 @@ const pageTitle = computed(() => ); const cancelTo = computed(() => (isEditing.value ? `/items/${routeId.value}` : '/items')); const hasRecipe = ref(false); +const imageEntityName = computed(() => itemNameForSave().trim()); function toIds(values: string[]): number[] { return values.map(Number).filter((item) => Number.isInteger(item) && item > 0); @@ -88,8 +93,11 @@ async function loadEditor() { patternEditable: item.customization.patternEditable, noRecipe: item.noRecipe, acquisitionMethodIds: item.acquisitionMethods.map((method) => String(method.id)), - tagIds: item.tags.map((tag) => String(tag.id)) + tagIds: item.tags.map((tag) => String(tag.id)), + imagePath: item.image?.path ?? '' }; + currentImage.value = item.image; + imageHistory.value = item.imageHistory; hasRecipe.value = item.recipe !== null; } } catch (error) { @@ -151,7 +159,8 @@ async function saveItem() { patternEditable: itemForm.value.patternEditable, noRecipe: itemForm.value.noRecipe, acquisitionMethodIds: toIds(itemForm.value.acquisitionMethodIds), - tagIds: toIds(itemForm.value.tagIds) + tagIds: toIds(itemForm.value.tagIds), + imagePath: itemForm.value.imagePath }; const saved = isEditing.value ? await api.updateItem(routeId.value, payload) : await api.createItem(payload); await router.push(`/items/${saved.id}`); @@ -162,6 +171,15 @@ async function saveItem() { } } +function handleImageSelected(image: EntityImage) { + currentImage.value = image; +} + +function handleImageUploaded(image: EntityImageUpload) { + currentImage.value = image; + imageHistory.value = [image, ...imageHistory.value.filter((item) => item.path !== image.path)]; +} + onMounted(() => { void loadEditor(); }); @@ -182,6 +200,20 @@ onMounted(() => { required /> + +
({ })); const showEditor = computed(() => route.name === 'item-new'); +function itemCardImage(item: Item) { + return item.image ? { src: item.image.url, alt: t('media.imageAlt', { name: item.name }) } : undefined; +} + async function loadItems() { loading.value = true; items.value = await api.items(itemQuery.value); @@ -112,36 +114,26 @@ watch(itemQuery, loadItems);
-
+
- +
- - - -
- -
+ +
-
+
- - - + :image="itemCardImage(item)" + :ribbon="item.usage?.name" + />
diff --git a/frontend/src/views/PokemonDetail.vue b/frontend/src/views/PokemonDetail.vue index 0699fe4..c7ef00e 100644 --- a/frontend/src/views/PokemonDetail.vue +++ b/frontend/src/views/PokemonDetail.vue @@ -17,7 +17,7 @@ import { api, type PokemonDetail } from '../services/api'; import PokemonEdit from './PokemonEdit.vue'; const route = useRoute(); -const { t } = useI18n(); +const { locale, t } = useI18n(); const pokemon = ref(null); const itemCategoryTab = ref(''); const relatedHabitatTab = ref(''); @@ -187,11 +187,30 @@ function pokemonTypeIconSrc(typeId: number): string | null { } function pokemonImageAlt() { - return pokemon.value?.image ? t('pages.pokemon.imageAlt', { name: pokemon.value.name, variant: pokemon.value.image.variant }) : ''; + if (!pokemon.value?.image) { + return ''; + } + return pokemon.value.image.source === 'upload' + ? t('media.imageAlt', { name: pokemon.value.name }) + : t('pages.pokemon.imageAlt', { name: pokemon.value.name, variant: pokemon.value.image.variant }); } function pokemonImageLabel() { - return pokemon.value?.image ? `${pokemon.value.image.version} - ${pokemon.value.image.variant}` : ''; + if (!pokemon.value?.image) { + return ''; + } + return pokemon.value.image.source === 'upload' ? t('media.uploadedImage') : `${pokemon.value.image.version} - ${pokemon.value.image.variant}`; +} + +function imageFileName(path: string): string { + return path.split('/').at(-1) ?? t('media.image'); +} + +function formatDateTime(value: string): string { + return new Intl.DateTimeFormat(locale.value, { + dateStyle: 'medium', + timeStyle: 'short' + }).format(new Date(value)); } function openImageModal() { @@ -502,8 +521,15 @@ watch(
{{ pokemonImageLabel() }} - {{ pokemon.image.style }} -

{{ pokemon.image.description }}

+ {{ pokemon.image.style }} +

{{ pokemon.image.description }}

+
+
+ + {{ imageFileName(image.path) }} + {{ formatDateTime(image.uploadedAt) }} +
+
diff --git a/frontend/src/views/PokemonEdit.vue b/frontend/src/views/PokemonEdit.vue index fa3e2ed..a6333cc 100644 --- a/frontend/src/views/PokemonEdit.vue +++ b/frontend/src/views/PokemonEdit.vue @@ -3,6 +3,7 @@ import { Icon } from '@iconify/vue'; import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'; import { useI18n } from 'vue-i18n'; import { useRoute, useRouter } from 'vue-router'; +import ImageUploadField from '../components/ImageUploadField.vue'; import Modal from '../components/Modal.vue'; import PokemonStatsFields from '../components/PokemonStatsFields.vue'; import Skeleton from '../components/Skeleton.vue'; @@ -14,6 +15,8 @@ import { iconCancel, iconSave, iconSearch } from '../icons'; import { api, type ConfigType, + type EntityImage, + type EntityImageUpload, type Language, type NamedEntity, type Options, @@ -47,6 +50,7 @@ const fetchIdentifier = ref(''); const fetchOptions = ref([]); const imageOptions = ref([]); const currentPokemonImage = ref(null); +const imageHistory = ref([]); const creatingSelect = ref(''); const activeEditTab = ref('basic'); const heightUnit = ref<'imperial' | 'metric'>('imperial'); @@ -102,6 +106,7 @@ const heightInchesValue = computed(() => totalHeightInchesValue.value - heightFe const heightMetersValue = computed(() => roundMeasurement(pokemonForm.value.heightInches * 0.0254, 2)); const weightPoundsValue = computed(() => roundMeasurement(pokemonForm.value.weightPounds, 1)); const weightKgValue = computed(() => roundMeasurement(pokemonForm.value.weightPounds * 0.45359237, 2)); +const imageEntityName = computed(() => pokemonNameForSave().trim()); const selectedPokemonImage = computed(() => { const imagePath = pokemonForm.value.imagePath; if (!imagePath) { @@ -118,6 +123,7 @@ const displayedImageOptions = computed(() => { return [selectedImage, ...imageOptions.value]; }); +const selectedUploadImage = computed(() => (selectedPokemonImage.value?.source === 'upload' ? selectedPokemonImage.value : null)); function toIds(values: string[]): number[] { return values.map(Number).filter((item) => Number.isInteger(item) && item > 0); @@ -287,6 +293,7 @@ async function loadEditor() { }; currentPokemonImage.value = pokemon.image; imageOptions.value = pokemon.image ? [pokemon.image] : []; + imageHistory.value = pokemon.imageHistory; syncSkillItemDrops(); } } catch (error) { @@ -394,12 +401,15 @@ function fetchPokemonFromInput() { } function pokemonImageLabel(image: PokemonImage) { + if (image.source === 'upload') { + return t('media.uploadedImage'); + } return `${image.version} - ${image.variant}`; } function pokemonImageAlt(image: PokemonImage) { const name = pokemonForm.value.name.trim() || (pokemonForm.value.id.trim() ? `#${pokemonForm.value.id.trim()}` : t('pages.pokemon.title')); - return t('pages.pokemon.imageAlt', { name, variant: image.variant }); + return image.source === 'upload' ? t('media.imageAlt', { name }) : t('pages.pokemon.imageAlt', { name, variant: image.variant }); } function selectPokemonImage(image: PokemonImage) { @@ -412,6 +422,27 @@ function clearPokemonImage() { currentPokemonImage.value = null; } +function pokemonImageFromUpload(image: EntityImage): PokemonImage { + return { + path: image.path, + url: image.url, + style: t('media.uploadedImage'), + version: t('media.uploadedImage'), + variant: imageEntityName.value || (pokemonForm.value.id.trim() ? `#${pokemonForm.value.id.trim()}` : t('pages.pokemon.title')), + description: '', + source: 'upload' + }; +} + +function handleUploadImageSelected(image: EntityImage) { + selectPokemonImage(pokemonImageFromUpload(image)); +} + +function handleUploadImageUploaded(image: EntityImageUpload) { + imageHistory.value = [image, ...imageHistory.value.filter((item) => item.path !== image.path)]; + selectPokemonImage(pokemonImageFromUpload(image)); +} + async function fetchPokemonImages() { const identifier = fetchIdentifier.value.trim() || pokemonForm.value.id.trim(); if (!identifier) { @@ -716,6 +747,21 @@ watch(fetchIdentifier, refreshFetchOptions);

{{ t('pages.pokemon.imageEmpty') }}

+ +
diff --git a/frontend/src/views/RecipeList.vue b/frontend/src/views/RecipeList.vue index ccc5b32..a351ed0 100644 --- a/frontend/src/views/RecipeList.vue +++ b/frontend/src/views/RecipeList.vue @@ -3,7 +3,6 @@ import { Icon } from '@iconify/vue'; import { computed, onMounted, ref, watch } from 'vue'; import { useI18n } from 'vue-i18n'; import { useRoute } from 'vue-router'; -import EditMeta from '../components/EditMeta.vue'; import EntityCard from '../components/EntityCard.vue'; import FilterPanel from '../components/FilterPanel.vue'; import PageHeader from '../components/PageHeader.vue'; @@ -46,8 +45,8 @@ function recipeTarget(item: Item) { return item.recipe ? `/recipes/${item.recipe.id}` : undefined; } -function itemSubtitle(item: Item) { - return item.usage ? `${item.category.name} · ${item.usage.name}` : item.category.name; +function recipeCardImage(item: Item) { + return item.image ? { src: item.image.url, alt: t('media.imageAlt', { name: item.name }) } : undefined; } function createRecipeTarget(item: Item) { @@ -132,31 +131,55 @@ watch(itemQuery, loadItems);
-
+
- +
- - - + + +
-
+
- - - +
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 627a3d8..6c5674e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,6 +13,12 @@ importers: '@fastify/cors': specifier: latest version: 11.2.0 + '@fastify/multipart': + specifier: ^10.0.0 + version: 10.0.0 + '@fastify/static': + specifier: ^9.1.3 + version: 9.1.3 fastify: specifier: latest version: 5.8.5 @@ -258,12 +264,21 @@ packages: cpu: [x64] os: [win32] + '@fastify/accept-negotiator@2.0.1': + resolution: {integrity: sha512-/c/TW2bO/v9JeEgoD/g1G5GxGeCF1Hafdf79WPmUlgYiBXummY0oX3VVq4yFkKKVBKDNlaDUYoab7g38RpPqCQ==} + '@fastify/ajv-compiler@4.0.5': resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==} + '@fastify/busboy@3.2.0': + resolution: {integrity: sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==} + '@fastify/cors@11.2.0': resolution: {integrity: sha512-LbLHBuSAdGdSFZYTLVA3+Ch2t+sA6nq3Ejc6XLAKiQ6ViS2qFnvicpj0htsx03FyYeLs04HfRNBsz/a8SvbcUw==} + '@fastify/deepmerge@3.2.1': + resolution: {integrity: sha512-N5Oqvltoa2r9z1tbx4xjky0oRR60v+T47Ic4J1ukoVQcptLOrIdRnCSdTGmOmajZuHVKlTnfcmrjyqsGEW1ztA==} + '@fastify/error@4.2.0': resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==} @@ -276,9 +291,18 @@ packages: '@fastify/merge-json-schemas@0.2.1': resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==} + '@fastify/multipart@10.0.0': + resolution: {integrity: sha512-pUx3Z1QStY7E7kwvDTIvB6P+rF5lzP+iqPgZyJyG3yBJVPvQaZxzDHYbQD89rbY0ciXrMOyGi8ezHDVexLvJDA==} + '@fastify/proxy-addr@5.1.0': resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==} + '@fastify/send@4.1.0': + resolution: {integrity: sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw==} + + '@fastify/static@9.1.3': + resolution: {integrity: sha512-aXrYtsiryLhRxRNaxNqsn7FUISeb7rB9q4eHUPIot5aeQBLNahnz1m6thzm7JWC1poSGXS9XrX8DvuMivp2hkQ==} + '@iconify/types@2.0.0': resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} @@ -319,6 +343,10 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@lukeed/ms@2.0.2': + resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==} + engines: {node: '>=8'} + '@napi-rs/wasm-runtime@1.1.4': resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==} peerDependencies: @@ -603,9 +631,17 @@ packages: avvio@9.2.0: resolution: {integrity: sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ==} + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + birpc@2.9.0: resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==} + brace-expansion@5.0.5: + resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} + engines: {node: 18 || 20 || >=22} + chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} @@ -620,6 +656,10 @@ packages: confbox@0.2.4: resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} + content-disposition@1.1.0: + resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} + engines: {node: '>=18'} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} @@ -630,6 +670,10 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -650,6 +694,9 @@ packages: engines: {node: '>=18'} hasBin: true + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + estree-walker@2.0.2: resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} @@ -708,9 +755,20 @@ packages: get-tsconfig@4.14.0: resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} + glob@13.0.6: + resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} + engines: {node: 18 || 20 || >=22} + hookable@5.5.3: resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + ipaddr.js@2.3.0: resolution: {integrity: sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==} engines: {node: '>= 10'} @@ -812,6 +870,10 @@ packages: resolution: {integrity: sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==} engines: {node: '>=14'} + lru-cache@11.3.5: + resolution: {integrity: sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==} + engines: {node: 20 || >=22} + magic-string-ast@1.0.3: resolution: {integrity: sha512-CvkkH1i81zl7mmb94DsRiFeG9V2fR2JeuK8yDgS8oiZSFa++wWLEgZ5ufEOyLHbvSbD1gTRKv9NdX69Rnvr9JA==} engines: {node: '>=20.19.0'} @@ -819,6 +881,19 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + mime@3.0.0: + resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} + engines: {node: '>=10.0.0'} + hasBin: true + + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + mlly@1.8.2: resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} @@ -840,6 +915,10 @@ packages: path-browserify@1.0.1: resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -988,6 +1067,9 @@ packages: set-cookie-parser@2.7.2: resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -1005,6 +1087,10 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + std-env@4.1.0: resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} @@ -1031,6 +1117,10 @@ packages: resolution: {integrity: sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==} engines: {node: '>=12'} + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -1314,17 +1404,23 @@ snapshots: '@esbuild/win32-x64@0.27.7': optional: true + '@fastify/accept-negotiator@2.0.1': {} + '@fastify/ajv-compiler@4.0.5': dependencies: ajv: 8.20.0 ajv-formats: 3.0.1(ajv@8.20.0) fast-uri: 3.1.0 + '@fastify/busboy@3.2.0': {} + '@fastify/cors@11.2.0': dependencies: fastify-plugin: 5.1.0 toad-cache: 3.7.0 + '@fastify/deepmerge@3.2.1': {} + '@fastify/error@4.2.0': {} '@fastify/fast-json-stringify-compiler@5.0.3': @@ -1337,11 +1433,36 @@ snapshots: dependencies: dequal: 2.0.3 + '@fastify/multipart@10.0.0': + dependencies: + '@fastify/busboy': 3.2.0 + '@fastify/deepmerge': 3.2.1 + '@fastify/error': 4.2.0 + fastify-plugin: 5.1.0 + secure-json-parse: 4.1.0 + '@fastify/proxy-addr@5.1.0': dependencies: '@fastify/forwarded': 3.0.1 ipaddr.js: 2.3.0 + '@fastify/send@4.1.0': + dependencies: + '@lukeed/ms': 2.0.2 + escape-html: 1.0.3 + fast-decode-uri-component: 1.0.1 + http-errors: 2.0.1 + mime: 3.0.0 + + '@fastify/static@9.1.3': + dependencies: + '@fastify/accept-negotiator': 2.0.1 + '@fastify/send': 4.1.0 + content-disposition: 1.1.0 + fastify-plugin: 5.1.0 + fastq: 1.20.1 + glob: 13.0.6 + '@iconify/types@2.0.0': {} '@iconify/vue@5.0.0(vue@3.5.33(typescript@6.0.3))': @@ -1386,6 +1507,8 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@lukeed/ms@2.0.2': {} + '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: '@emnapi/core': 1.10.0 @@ -1665,8 +1788,14 @@ snapshots: '@fastify/error': 4.2.0 fastq: 1.20.1 + balanced-match@4.0.4: {} + birpc@2.9.0: {} + brace-expansion@5.0.5: + dependencies: + balanced-match: 4.0.4 + chai@6.2.2: {} chokidar@5.0.0: @@ -1677,12 +1806,16 @@ snapshots: confbox@0.2.4: {} + content-disposition@1.1.0: {} + convert-source-map@2.0.0: {} cookie@1.1.1: {} csstype@3.2.3: {} + depd@2.0.0: {} + dequal@2.0.3: {} detect-libc@2.1.2: {} @@ -1720,6 +1853,8 @@ snapshots: '@esbuild/win32-ia32': 0.27.7 '@esbuild/win32-x64': 0.27.7 + escape-html@1.0.3: {} + estree-walker@2.0.2: {} estree-walker@3.0.3: @@ -1790,8 +1925,24 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 + glob@13.0.6: + dependencies: + minimatch: 10.2.5 + minipass: 7.1.3 + path-scurry: 2.0.2 + hookable@5.5.3: {} + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + inherits@2.0.4: {} + ipaddr.js@2.3.0: {} jsesc@3.1.0: {} @@ -1865,6 +2016,8 @@ snapshots: pkg-types: 2.3.1 quansync: 0.2.11 + lru-cache@11.3.5: {} + magic-string-ast@1.0.3: dependencies: magic-string: 0.30.21 @@ -1873,6 +2026,14 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + mime@3.0.0: {} + + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.5 + + minipass@7.1.3: {} + mlly@1.8.2: dependencies: acorn: 8.16.0 @@ -1890,6 +2051,11 @@ snapshots: path-browserify@1.0.1: {} + path-scurry@2.0.2: + dependencies: + lru-cache: 11.3.5 + minipass: 7.1.3 + pathe@2.0.3: {} perfect-debounce@2.1.0: {} @@ -2038,6 +2204,8 @@ snapshots: set-cookie-parser@2.7.2: {} + setprototypeof@1.2.0: {} + siginfo@2.0.0: {} sonic-boom@4.2.1: @@ -2050,6 +2218,8 @@ snapshots: stackback@0.0.2: {} + statuses@2.0.2: {} + std-env@4.1.0: {} thread-stream@4.0.0: @@ -2069,6 +2239,8 @@ snapshots: toad-cache@3.7.0: {} + toidentifier@1.0.1: {} + tslib@2.8.1: optional: true diff --git a/system-wordings.ts b/system-wordings.ts index 2089d1d..3324597 100644 --- a/system-wordings.ts +++ b/system-wordings.ts @@ -506,6 +506,21 @@ export const systemWordingMessages = { delete: 'Delete', empty: 'No edit history' }, + media: { + image: 'Image', + imageAlt: '{name} image', + uploadImage: 'Upload image', + uploading: 'Uploading', + uploadedImage: 'Uploaded image', + selectedImage: 'Selected image', + imageHistory: 'Image history', + imageOptions: 'Image options', + clearImage: 'Clear image', + imageEmpty: 'No image selected', + imageHistoryEmpty: 'No uploaded images', + uploadFailed: 'Image upload failed', + selectImage: 'Select image' + }, discussion: { title: 'Discussion', count: '{count} comments', @@ -575,6 +590,12 @@ export const systemWordingMessages = { pokemonTypeDataUnavailable: 'Pokemon type data is unavailable', pokemonDataNotFound: 'Pokemon data was not found', pokemonImagePathInvalid: 'Pokemon image path is invalid', + imagePathInvalid: 'Image path is invalid', + imageUploadRequired: 'Please select an image', + imageUploadTypeInvalid: 'Image type is not supported', + imageUploadContentInvalid: 'Image file is invalid', + imageUploadEntityNameRequired: 'Please enter a name before uploading an image', + imageUploadFailed: 'Image upload failed', taskRequired: 'Please enter a task', selectTask: 'Please select a task', taskDoesNotExist: 'Task does not exist', @@ -1132,6 +1153,21 @@ export const systemWordingMessages = { delete: '删除', empty: '暂无编辑历史' }, + media: { + image: '图片', + imageAlt: '{name}图片', + uploadImage: '上传图片', + uploading: '上传中', + uploadedImage: '上传图片', + selectedImage: '已选择图片', + imageHistory: '图片历史', + imageOptions: '图片选项', + clearImage: '清除图片', + imageEmpty: '尚未选择图片', + imageHistoryEmpty: '暂无上传图片', + uploadFailed: '图片上传失败', + selectImage: '选择图片' + }, discussion: { title: '讨论', count: '{count} 条评论', @@ -1201,6 +1237,12 @@ export const systemWordingMessages = { pokemonTypeDataUnavailable: 'Pokemon 属性数据不可用', pokemonDataNotFound: '未找到 Pokemon 数据', pokemonImagePathInvalid: 'Pokemon 图片路径不合法', + imagePathInvalid: '图片路径不合法', + imageUploadRequired: '请选择图片', + imageUploadTypeInvalid: '不支持这种图片类型', + imageUploadContentInvalid: '图片文件不合法', + imageUploadEntityNameRequired: '请先输入名称再上传图片', + imageUploadFailed: '图片上传失败', taskRequired: '请输入任务', selectTask: '请选择任务', taskDoesNotExist: '任务不存在',