feat(wiki): add community image upload for wiki entities
Support uploading images for Pokemon, Items, and Habitats Track upload history in new entity_image_uploads table Update entity cards to display uploaded images and usage ribbons
This commit is contained in:
43
DESIGN.md
43
DESIGN.md
@@ -148,6 +148,34 @@
|
|||||||
- 详情页展示最后编辑者、最后编辑时间和编辑历史面板。
|
- 详情页展示最后编辑者、最后编辑时间和编辑历史面板。
|
||||||
- 编辑历史中的用户信息只展示必要署名,不暴露邮箱、token、hash 或内部元数据。
|
- 编辑历史中的用户信息只展示必要署名,不暴露邮箱、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、物品、材料单、栖息地详情页支持讨论。
|
- Pokemon、物品、材料单、栖息地详情页支持讨论。
|
||||||
@@ -260,6 +288,7 @@ Pokemon 编辑表单使用标签页组织字段:
|
|||||||
- 图片选择不直接创建或更新 Pokemon;用户仍需通过 Save 保存,保存时沿用现有编辑审计。
|
- 图片选择不直接创建或更新 Pokemon;用户仍需通过 Save 保存,保存时沿用现有编辑审计。
|
||||||
- 图片选择界面使用 Pokédex 风格:上方显示当前选择的大图,大图下方显示版本、状态和描述,再下方以缩略图网格展示同一 Pokemon 的不同风格 / 版本 / 状态。
|
- 图片选择界面使用 Pokédex 风格:上方显示当前选择的大图,大图下方显示版本、状态和描述,再下方以缩略图网格展示同一 Pokemon 的不同风格 / 版本 / 状态。
|
||||||
- Pokemon 保存显示图片的相对路径、风格、版本、状态和描述;API 对外返回可直接展示的图片 URL,但不暴露内部校验状态。
|
- Pokemon 保存显示图片的相对路径、风格、版本、状态和描述;API 对外返回可直接展示的图片 URL,但不暴露内部校验状态。
|
||||||
|
- Pokemon 也支持社区上传图片;上传图片使用通用 Wiki 图片上传历史,当前显示图片可在静态候选和上传图片之间切换。
|
||||||
- 基础标签页:
|
- 基础标签页:
|
||||||
- 第一行:ID、名称
|
- 第一行:ID、名称
|
||||||
- 第二行:喜欢的环境、特长
|
- 第二行:喜欢的环境、特长
|
||||||
@@ -321,6 +350,7 @@ Pokemon 详情页展示:
|
|||||||
- 可改花纹
|
- 可改花纹
|
||||||
- 无材料单:`no_recipe`
|
- 无材料单:`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
|
- 可出现的 Pokemon
|
||||||
|
- 图片:通过通用 Wiki 图片上传维护当前图片和历史上传记录
|
||||||
- 翻译
|
- 翻译
|
||||||
- 排序
|
- 排序
|
||||||
|
|
||||||
@@ -407,10 +445,12 @@ Pokemon 出现配置:
|
|||||||
栖息地列表功能:
|
栖息地列表功能:
|
||||||
|
|
||||||
- 按自定义排序展示
|
- 按自定义排序展示
|
||||||
- 展示配方摘要和可能出现的 Pokemon 摘要
|
- 栖息地列表卡片使用与 Pokemon 列表一致的居中图鉴式布局,只展示栖息地图片和名称;不展示配方摘要、可能出现的 Pokemon 摘要或编辑元信息。
|
||||||
|
- 已配置图片时,栖息地卡片展示图片缩略图;未配置图片时保留默认栖息地标记。
|
||||||
|
|
||||||
栖息地详情页展示:
|
栖息地详情页展示:
|
||||||
|
|
||||||
|
- 图片和图片上传历史
|
||||||
- 配方列表
|
- 配方列表
|
||||||
- 可能出现的 Pokemon 列表
|
- 可能出现的 Pokemon 列表
|
||||||
- 出现时间
|
- 出现时间
|
||||||
@@ -559,6 +599,7 @@ API 暴露边界:
|
|||||||
- `GET /api/pokemon/fetch-options`:按搜索词返回 Pokemon CSV data 搜索结果;需要已验证用户;只返回 `id`、`identifier`、`name`。
|
- `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/fetch`:按 data identifier 或 Pokemon ID 查询 CSV 资料并填充 Pokemon 编辑表单;需要已验证用户;不直接保存 Pokemon。
|
||||||
- `POST /api/pokemon/image-options`:按 data identifier 或 Pokemon ID 查询 pokesprite 可用图片候选;需要已验证用户;只返回 `id`、`identifier` 和图片候选列表。
|
- `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 的更新、删除。
|
- Life Post 的创建,以及作者本人对 Life Post 的更新、删除。
|
||||||
- `POST /api/life-posts`
|
- `POST /api/life-posts`
|
||||||
- `PUT /api/life-posts/:id`
|
- `PUT /api/life-posts/:id`
|
||||||
|
|||||||
@@ -330,11 +330,13 @@ CREATE TABLE IF NOT EXISTS items (
|
|||||||
dual_dyeable boolean NOT NULL DEFAULT false,
|
dual_dyeable boolean NOT NULL DEFAULT false,
|
||||||
pattern_editable boolean NOT NULL DEFAULT false,
|
pattern_editable boolean NOT NULL DEFAULT false,
|
||||||
no_recipe 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)
|
sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0)
|
||||||
);
|
);
|
||||||
|
|
||||||
ALTER TABLE items ALTER COLUMN usage_id DROP NOT NULL;
|
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 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;
|
ALTER TABLE items DROP COLUMN IF EXISTS no_habitat;
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS recipes (
|
CREATE TABLE IF NOT EXISTS recipes (
|
||||||
@@ -420,6 +422,7 @@ CREATE TABLE IF NOT EXISTS maps (
|
|||||||
CREATE TABLE IF NOT EXISTS habitats (
|
CREATE TABLE IF NOT EXISTS habitats (
|
||||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
name text NOT NULL UNIQUE,
|
name text NOT NULL UNIQUE,
|
||||||
|
image_path text NOT NULL DEFAULT '',
|
||||||
sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0)
|
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 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 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 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 (
|
WITH ordered AS (
|
||||||
SELECT id, (row_number() OVER (ORDER BY created_at, id) * 10)::integer AS next_sort_order
|
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
|
CREATE INDEX IF NOT EXISTS wiki_edit_logs_user_id_idx
|
||||||
ON wiki_edit_logs(user_id);
|
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 (
|
CREATE TABLE IF NOT EXISTS entity_discussion_comments (
|
||||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
entity_type text NOT NULL CHECK (entity_type IN ('pokemon', 'items', 'recipes', 'habitats')),
|
entity_type text NOT NULL CHECK (entity_type IN ('pokemon', 'items', 'recipes', 'habitats')),
|
||||||
|
|||||||
@@ -14,6 +14,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cors": "latest",
|
"@fastify/cors": "latest",
|
||||||
|
"@fastify/multipart": "^10.0.0",
|
||||||
|
"@fastify/static": "^9.1.3",
|
||||||
"fastify": "latest",
|
"fastify": "latest",
|
||||||
"pg": "latest"
|
"pg": "latest"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
import { parseIdList, parseMatchMode, sqlForRelationFilter } from './filter.ts';
|
import { parseIdList, parseMatchMode, sqlForRelationFilter } from './filter.ts';
|
||||||
import { pool, query, queryOne } from './db.ts';
|
import { pool, query, queryOne } from './db.ts';
|
||||||
|
import {
|
||||||
|
isUploadImagePath,
|
||||||
|
linkEntityImageUpload,
|
||||||
|
listEntityImageUploads,
|
||||||
|
uploadImageUrl,
|
||||||
|
uploadPublicBaseUrl
|
||||||
|
} from './uploads.ts';
|
||||||
import { Buffer } from 'node:buffer';
|
import { Buffer } from 'node:buffer';
|
||||||
import { readFile } from 'node:fs/promises';
|
import { readFile } from 'node:fs/promises';
|
||||||
import { dirname, resolve } from 'node:path';
|
import { dirname, resolve } from 'node:path';
|
||||||
@@ -77,6 +84,12 @@ type PokemonImage = {
|
|||||||
version: string;
|
version: string;
|
||||||
variant: string;
|
variant: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
source?: 'sprite' | 'upload';
|
||||||
|
};
|
||||||
|
|
||||||
|
type EntityImageValue = {
|
||||||
|
path: string;
|
||||||
|
url: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type PokemonImageCandidate = Omit<PokemonImage, 'url'>;
|
type PokemonImageCandidate = Omit<PokemonImage, 'url'>;
|
||||||
@@ -143,6 +156,7 @@ type ItemPayload = {
|
|||||||
noRecipe: boolean;
|
noRecipe: boolean;
|
||||||
acquisitionMethodIds: number[];
|
acquisitionMethodIds: number[];
|
||||||
tagIds: number[];
|
tagIds: number[];
|
||||||
|
imagePath: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type RecipePayload = {
|
type RecipePayload = {
|
||||||
@@ -236,6 +250,7 @@ type LifePostsPage = {
|
|||||||
type HabitatPayload = {
|
type HabitatPayload = {
|
||||||
name: string;
|
name: string;
|
||||||
translations: TranslationInput;
|
translations: TranslationInput;
|
||||||
|
imagePath: string;
|
||||||
recipeItems: IdQuantity[];
|
recipeItems: IdQuantity[];
|
||||||
pokemonAppearances: Array<{
|
pokemonAppearances: Array<{
|
||||||
pokemonId: number;
|
pokemonId: number;
|
||||||
@@ -282,6 +297,7 @@ type PokemonChangeSource = {
|
|||||||
};
|
};
|
||||||
type ItemChangeSource = {
|
type ItemChangeSource = {
|
||||||
name: string;
|
name: string;
|
||||||
|
image: EntityImageValue | null;
|
||||||
category: { name: string };
|
category: { name: string };
|
||||||
usage: { name: string } | null;
|
usage: { name: string } | null;
|
||||||
customization: { dyeable: boolean; dualDyeable: boolean; patternEditable: boolean };
|
customization: { dyeable: boolean; dualDyeable: boolean; patternEditable: boolean };
|
||||||
@@ -291,6 +307,7 @@ type ItemChangeSource = {
|
|||||||
};
|
};
|
||||||
type HabitatChangeSource = {
|
type HabitatChangeSource = {
|
||||||
name: string;
|
name: string;
|
||||||
|
image: EntityImageValue | null;
|
||||||
recipe: Array<{ name: string; quantity: number }>;
|
recipe: Array<{ name: string; quantity: number }>;
|
||||||
pokemon: Array<{ name: string; time_of_day: string; weather: string; rarity: number; map: { name: string } }>;
|
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("'", "''")}'`;
|
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(
|
function localizedField(
|
||||||
entityType: EntityType,
|
entityType: EntityType,
|
||||||
entityIdExpression: string,
|
entityIdExpression: string,
|
||||||
@@ -559,6 +595,17 @@ function cleanOptionalText(value: unknown): string {
|
|||||||
return typeof value === 'string' ? value.trim() : '';
|
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[] {
|
function cleanIds(value: unknown): number[] {
|
||||||
if (!Array.isArray(value)) {
|
if (!Array.isArray(value)) {
|
||||||
return [];
|
return [];
|
||||||
@@ -1039,7 +1086,7 @@ function pokemonSpriteUrl(path: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function pokemonImageWithUrl(candidate: PokemonImageCandidate): PokemonImage {
|
function pokemonImageWithUrl(candidate: PokemonImageCandidate): PokemonImage {
|
||||||
return { ...candidate, url: pokemonSpriteUrl(candidate.path) };
|
return { ...candidate, url: pokemonSpriteUrl(candidate.path), source: 'sprite' };
|
||||||
}
|
}
|
||||||
|
|
||||||
function pokemonImageCandidates(id: number): PokemonImageCandidate[] {
|
function pokemonImageCandidates(id: number): PokemonImageCandidate[] {
|
||||||
@@ -1223,7 +1270,10 @@ function pokemonImageCandidates(id: number): PokemonImageCandidate[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function pokemonImageLabel(image: PokemonImage | null | undefined): string {
|
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 {
|
function pokemonImageCandidateForPath(id: number, path: string): PokemonImage | null {
|
||||||
@@ -1238,6 +1288,21 @@ function cleanPokemonImage(value: unknown, pokemonId: number): PokemonImage | nu
|
|||||||
return null;
|
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);
|
const image = pokemonImageCandidateForPath(pokemonId, path);
|
||||||
if (!image) {
|
if (!image) {
|
||||||
throw validationError('Pokemon image path is invalid');
|
throw validationError('Pokemon image path is invalid');
|
||||||
@@ -1653,6 +1718,7 @@ async function itemEditChanges(
|
|||||||
const tagNames = await entityNameMap(client, 'favorite_things', after.tagIds);
|
const tagNames = await entityNameMap(client, 'favorite_things', after.tagIds);
|
||||||
|
|
||||||
pushChange(changes, 'Name', before.name, after.name);
|
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, 'Category', before.category.name, categoryNames.get(after.categoryId));
|
||||||
pushChange(changes, 'Usage', before.usage?.name, after.usageId ? usageNames.get(after.usageId) : null);
|
pushChange(changes, 'Usage', before.usage?.name, after.usageId ? usageNames.get(after.usageId) : null);
|
||||||
pushChange(changes, 'Dyeable', boolValue(before.customization.dyeable), boolValue(after.dyeable));
|
pushChange(changes, 'Dyeable', boolValue(before.customization.dyeable), boolValue(after.dyeable));
|
||||||
@@ -1684,6 +1750,7 @@ async function habitatEditChanges(
|
|||||||
.join(' / ');
|
.join(' / ');
|
||||||
|
|
||||||
pushChange(changes, 'Name', before.name, after.name);
|
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, 'Recipe', quantityListValue(before.recipe), await quantityPayloadValue(client, after.recipeItems));
|
||||||
pushChange(changes, 'Possible Pokemon', appearanceListValue(before.pokemon), afterAppearances);
|
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",
|
round((p.height_inches * 0.0254)::numeric, 2)::double precision AS "heightMeters",
|
||||||
p.weight_pounds AS "weightPounds",
|
p.weight_pounds AS "weightPounds",
|
||||||
round((p.weight_pounds * 0.45359237)::numeric, 2)::double precision AS "weightKg",
|
round((p.weight_pounds * 0.45359237)::numeric, 2)::double precision AS "weightKg",
|
||||||
CASE WHEN p.image_path <> '' THEN json_build_object(
|
CASE
|
||||||
|
WHEN p.image_path LIKE '/sprites/%' THEN json_build_object(
|
||||||
'path', p.image_path,
|
'path', p.image_path,
|
||||||
'url', '${pokemonSpriteBaseUrl}' || p.image_path,
|
'url', '${pokemonSpriteBaseUrl}' || p.image_path,
|
||||||
'style', p.image_style,
|
'style', p.image_style,
|
||||||
'version', p.image_version,
|
'version', p.image_version,
|
||||||
'variant', p.image_variant,
|
'variant', p.image_variant,
|
||||||
'description', p.image_description
|
'description', p.image_description,
|
||||||
) ELSE NULL END AS image,
|
'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(
|
json_build_object(
|
||||||
'hp', p.hp,
|
'hp', p.hp,
|
||||||
'attack', p.attack,
|
'attack', p.attack,
|
||||||
@@ -2951,7 +3031,7 @@ export async function getPokemon(id: number, locale = defaultLocale) {
|
|||||||
const relatedSkillName = localizedName('skills', 'related_skill', locale);
|
const relatedSkillName = localizedName('skills', 'related_skill', locale);
|
||||||
const relatedFavoriteThingName = localizedName('favorite-things', 'related_favorite_thing', locale);
|
const relatedFavoriteThingName = localizedName('favorite-things', 'related_favorite_thing', locale);
|
||||||
|
|
||||||
const [habitats, itemDrops, favoriteThingItems, relatedPokemon, editHistory] = await Promise.all([
|
const [habitats, itemDrops, favoriteThingItems, relatedPokemon, editHistory, imageHistory] = await Promise.all([
|
||||||
query(
|
query(
|
||||||
`
|
`
|
||||||
SELECT
|
SELECT
|
||||||
@@ -3068,7 +3148,8 @@ export async function getPokemon(id: number, locale = defaultLocale) {
|
|||||||
`,
|
`,
|
||||||
[id]
|
[id]
|
||||||
),
|
),
|
||||||
getEditHistory('pokemon', id)
|
getEditHistory('pokemon', id),
|
||||||
|
listEntityImageUploads('pokemon', id)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const dropsBySkill = itemDrops.reduce((itemsBySkill, item) => {
|
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<string, unknown>): PokemonPayload {
|
function cleanPokemonPayload(payload: Record<string, unknown>): PokemonPayload {
|
||||||
@@ -3245,6 +3326,7 @@ export async function createPokemon(payload: Record<string, unknown>, userId: nu
|
|||||||
userId
|
userId
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
await linkEntityImageUpload(client, 'pokemon', cleanPayload.id, cleanPayload.image?.path, cleanPayload.name);
|
||||||
await replacePokemonRelations(client, cleanPayload.id, cleanPayload);
|
await replacePokemonRelations(client, cleanPayload.id, cleanPayload);
|
||||||
await replaceEntityTranslations(client, 'pokemon', cleanPayload.id, cleanPayload.translations, ['name', 'details', 'genus']);
|
await replaceEntityTranslations(client, 'pokemon', cleanPayload.id, cleanPayload.translations, ['name', 'details', 'genus']);
|
||||||
await recordEditLog(client, 'pokemon', cleanPayload.id, 'create', userId);
|
await recordEditLog(client, 'pokemon', cleanPayload.id, 'create', userId);
|
||||||
@@ -3308,6 +3390,7 @@ export async function updatePokemon(id: number, payload: Record<string, unknown>
|
|||||||
if (result.rowCount === 0) {
|
if (result.rowCount === 0) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
await linkEntityImageUpload(client, 'pokemon', id, cleanPayload.image?.path, cleanPayload.name);
|
||||||
await replacePokemonRelations(client, id, cleanPayload);
|
await replacePokemonRelations(client, id, cleanPayload);
|
||||||
await replaceEntityTranslations(client, 'pokemon', id, cleanPayload.translations, ['name', 'details', 'genus']);
|
await replaceEntityTranslations(client, 'pokemon', id, cleanPayload.translations, ['name', 'details', 'genus']);
|
||||||
const changes = before ? await pokemonEditChanges(client, before as unknown as PokemonChangeSource, cleanPayload) : [];
|
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",
|
h.name AS "baseName",
|
||||||
${translationsSelect('habitats', 'h.id')} AS translations,
|
${translationsSelect('habitats', 'h.id')} AS translations,
|
||||||
${auditSelect('h', 'habitat_created_user', 'habitat_updated_user')},
|
${auditSelect('h', 'habitat_created_user', 'habitat_updated_user')},
|
||||||
|
${uploadedImageJson('h.image_path')} AS image,
|
||||||
COALESCE((
|
COALESCE((
|
||||||
SELECT json_agg(json_build_object('id', i.id, 'name', ${itemName}, 'quantity', hri.quantity) ORDER BY ${orderByEntity('i')})
|
SELECT json_agg(json_build_object('id', i.id, 'name', ${itemName}, 'quantity', hri.quantity) ORDER BY ${orderByEntity('i')})
|
||||||
FROM habitat_recipe_items hri
|
FROM habitat_recipe_items hri
|
||||||
@@ -3378,6 +3462,7 @@ export async function getHabitat(id: number, locale = defaultLocale) {
|
|||||||
h.name AS "baseName",
|
h.name AS "baseName",
|
||||||
${translationsSelect('habitats', 'h.id')} AS translations,
|
${translationsSelect('habitats', 'h.id')} AS translations,
|
||||||
${auditSelect('h', 'habitat_created_user', 'habitat_updated_user')},
|
${auditSelect('h', 'habitat_created_user', 'habitat_updated_user')},
|
||||||
|
${uploadedImageJson('h.image_path')} AS image,
|
||||||
COALESCE((
|
COALESCE((
|
||||||
SELECT json_agg(json_build_object('id', i.id, 'name', ${itemName}, 'quantity', hri.quantity) ORDER BY ${orderByEntity('i')})
|
SELECT json_agg(json_build_object('id', i.id, 'name', ${itemName}, 'quantity', hri.quantity) ORDER BY ${orderByEntity('i')})
|
||||||
FROM habitat_recipe_items hri
|
FROM habitat_recipe_items hri
|
||||||
@@ -3395,7 +3480,7 @@ export async function getHabitat(id: number, locale = defaultLocale) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [pokemon, editHistory] = await Promise.all([
|
const [pokemon, editHistory, imageHistory] = await Promise.all([
|
||||||
query(
|
query(
|
||||||
`
|
`
|
||||||
SELECT
|
SELECT
|
||||||
@@ -3413,10 +3498,11 @@ export async function getHabitat(id: number, locale = defaultLocale) {
|
|||||||
`,
|
`,
|
||||||
[id]
|
[id]
|
||||||
),
|
),
|
||||||
getEditHistory('habitats', id)
|
getEditHistory('habitats', id),
|
||||||
|
listEntityImageUploads('habitats', id)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return { ...habitat, pokemon, editHistory };
|
return { ...habitat, pokemon, editHistory, imageHistory };
|
||||||
}
|
}
|
||||||
|
|
||||||
function cleanHabitatPayload(payload: Record<string, unknown>): HabitatPayload {
|
function cleanHabitatPayload(payload: Record<string, unknown>): HabitatPayload {
|
||||||
@@ -3453,6 +3539,7 @@ function cleanHabitatPayload(payload: Record<string, unknown>): HabitatPayload {
|
|||||||
return {
|
return {
|
||||||
name: cleanName(payload.name, 'Habitat name is required'),
|
name: cleanName(payload.name, 'Habitat name is required'),
|
||||||
translations: cleanTranslations(payload.translations, ['name']),
|
translations: cleanTranslations(payload.translations, ['name']),
|
||||||
|
imagePath: cleanUploadImagePath(payload.imagePath, 'habitats'),
|
||||||
recipeItems: cleanQuantities(payload.recipeItems),
|
recipeItems: cleanQuantities(payload.recipeItems),
|
||||||
pokemonAppearances: [...pokemonAppearances.values()]
|
pokemonAppearances: [...pokemonAppearances.values()]
|
||||||
};
|
};
|
||||||
@@ -3488,13 +3575,14 @@ export async function createHabitat(payload: Record<string, unknown>, userId: nu
|
|||||||
const sortOrder = await nextSortOrder(client, 'habitats');
|
const sortOrder = await nextSortOrder(client, 'habitats');
|
||||||
const result = await client.query<{ id: number }>(
|
const result = await client.query<{ id: number }>(
|
||||||
`
|
`
|
||||||
INSERT INTO habitats (name, sort_order, created_by_user_id, updated_by_user_id)
|
INSERT INTO habitats (name, image_path, sort_order, created_by_user_id, updated_by_user_id)
|
||||||
VALUES ($1, $2, $3, $3)
|
VALUES ($1, $2, $3, $4, $4)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
`,
|
`,
|
||||||
[cleanPayload.name, sortOrder, userId]
|
[cleanPayload.name, cleanPayload.imagePath, sortOrder, userId]
|
||||||
);
|
);
|
||||||
const habitatId = result.rows[0].id;
|
const habitatId = result.rows[0].id;
|
||||||
|
await linkEntityImageUpload(client, 'habitats', habitatId, cleanPayload.imagePath, cleanPayload.name);
|
||||||
await replaceHabitatRelations(client, habitatId, cleanPayload);
|
await replaceHabitatRelations(client, habitatId, cleanPayload);
|
||||||
await replaceEntityTranslations(client, 'habitats', habitatId, cleanPayload.translations, ['name']);
|
await replaceEntityTranslations(client, 'habitats', habitatId, cleanPayload.translations, ['name']);
|
||||||
await recordEditLog(client, 'habitats', habitatId, 'create', userId);
|
await recordEditLog(client, 'habitats', habitatId, 'create', userId);
|
||||||
@@ -3509,12 +3597,13 @@ export async function updateHabitat(id: number, payload: Record<string, unknown>
|
|||||||
|
|
||||||
const updated = await withTransaction(async (client) => {
|
const updated = await withTransaction(async (client) => {
|
||||||
const result = await client.query(
|
const result = await client.query(
|
||||||
'UPDATE habitats SET name = $1, updated_by_user_id = $2, updated_at = now() WHERE id = $3',
|
'UPDATE habitats SET name = $1, image_path = $2, updated_by_user_id = $3, updated_at = now() WHERE id = $4',
|
||||||
[cleanPayload.name, userId, id]
|
[cleanPayload.name, cleanPayload.imagePath, userId, id]
|
||||||
);
|
);
|
||||||
if (result.rowCount === 0) {
|
if (result.rowCount === 0) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
await linkEntityImageUpload(client, 'habitats', id, cleanPayload.imagePath, cleanPayload.name);
|
||||||
await replaceHabitatRelations(client, id, cleanPayload);
|
await replaceHabitatRelations(client, id, cleanPayload);
|
||||||
await replaceEntityTranslations(client, 'habitats', id, cleanPayload.translations, ['name']);
|
await replaceEntityTranslations(client, 'habitats', id, cleanPayload.translations, ['name']);
|
||||||
const changes = before ? await habitatEditChanges(client, before as unknown as HabitatChangeSource, cleanPayload) : [];
|
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",
|
i.name AS "baseName",
|
||||||
${translationsSelect('items', 'i.id')} AS translations,
|
${translationsSelect('items', 'i.id')} AS translations,
|
||||||
${auditSelect('i', 'item_created_user', 'item_updated_user')},
|
${auditSelect('i', 'item_created_user', 'item_updated_user')},
|
||||||
|
${uploadedImageJson('i.image_path')} AS image,
|
||||||
json_build_object('id', c.id, 'name', ${categoryName}) AS category,
|
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,
|
CASE WHEN u.id IS NULL THEN NULL ELSE json_build_object('id', u.id, 'name', ${usageName}) END AS usage,
|
||||||
json_build_object(
|
json_build_object(
|
||||||
@@ -3649,7 +3739,7 @@ export async function getItem(id: number, locale = defaultLocale) {
|
|||||||
const pokemonName = localizedName('pokemon', 'p', locale);
|
const pokemonName = localizedName('pokemon', 'p', locale);
|
||||||
const skillName = localizedName('skills', 's', 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(
|
query(
|
||||||
`
|
`
|
||||||
SELECT am.id, ${acquisitionMethodName} AS name
|
SELECT am.id, ${acquisitionMethodName} AS name
|
||||||
@@ -3737,10 +3827,11 @@ export async function getItem(id: number, locale = defaultLocale) {
|
|||||||
`,
|
`,
|
||||||
[id]
|
[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<string, unknown>): ItemPayload {
|
function cleanItemPayload(payload: Record<string, unknown>): ItemPayload {
|
||||||
@@ -3758,7 +3849,8 @@ function cleanItemPayload(payload: Record<string, unknown>): ItemPayload {
|
|||||||
patternEditable: Boolean(payload.patternEditable),
|
patternEditable: Boolean(payload.patternEditable),
|
||||||
noRecipe: Boolean(payload.noRecipe),
|
noRecipe: Boolean(payload.noRecipe),
|
||||||
acquisitionMethodIds: cleanIds(payload.acquisitionMethodIds),
|
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<string, unknown>, userId: numbe
|
|||||||
dual_dyeable,
|
dual_dyeable,
|
||||||
pattern_editable,
|
pattern_editable,
|
||||||
no_recipe,
|
no_recipe,
|
||||||
|
image_path,
|
||||||
sort_order,
|
sort_order,
|
||||||
created_by_user_id,
|
created_by_user_id,
|
||||||
updated_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
|
RETURNING id
|
||||||
`,
|
`,
|
||||||
[
|
[
|
||||||
@@ -3822,11 +3915,13 @@ export async function createItem(payload: Record<string, unknown>, userId: numbe
|
|||||||
cleanPayload.dualDyeable,
|
cleanPayload.dualDyeable,
|
||||||
cleanPayload.patternEditable,
|
cleanPayload.patternEditable,
|
||||||
cleanPayload.noRecipe,
|
cleanPayload.noRecipe,
|
||||||
|
cleanPayload.imagePath,
|
||||||
sortOrder,
|
sortOrder,
|
||||||
userId
|
userId
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
const itemId = result.rows[0].id;
|
const itemId = result.rows[0].id;
|
||||||
|
await linkEntityImageUpload(client, 'items', itemId, cleanPayload.imagePath, cleanPayload.name);
|
||||||
await replaceItemRelations(client, itemId, cleanPayload);
|
await replaceItemRelations(client, itemId, cleanPayload);
|
||||||
await replaceEntityTranslations(client, 'items', itemId, cleanPayload.translations, ['name']);
|
await replaceEntityTranslations(client, 'items', itemId, cleanPayload.translations, ['name']);
|
||||||
await recordEditLog(client, 'items', itemId, 'create', userId);
|
await recordEditLog(client, 'items', itemId, 'create', userId);
|
||||||
@@ -3851,9 +3946,10 @@ export async function updateItem(id: number, payload: Record<string, unknown>, u
|
|||||||
dual_dyeable = $5,
|
dual_dyeable = $5,
|
||||||
pattern_editable = $6,
|
pattern_editable = $6,
|
||||||
no_recipe = $7,
|
no_recipe = $7,
|
||||||
updated_by_user_id = $8,
|
image_path = $8,
|
||||||
|
updated_by_user_id = $9,
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
WHERE id = $9
|
WHERE id = $10
|
||||||
`,
|
`,
|
||||||
[
|
[
|
||||||
cleanPayload.name,
|
cleanPayload.name,
|
||||||
@@ -3863,6 +3959,7 @@ export async function updateItem(id: number, payload: Record<string, unknown>, u
|
|||||||
cleanPayload.dualDyeable,
|
cleanPayload.dualDyeable,
|
||||||
cleanPayload.patternEditable,
|
cleanPayload.patternEditable,
|
||||||
cleanPayload.noRecipe,
|
cleanPayload.noRecipe,
|
||||||
|
cleanPayload.imagePath,
|
||||||
userId,
|
userId,
|
||||||
id
|
id
|
||||||
]
|
]
|
||||||
@@ -3870,6 +3967,7 @@ export async function updateItem(id: number, payload: Record<string, unknown>, u
|
|||||||
if (result.rowCount === 0) {
|
if (result.rowCount === 0) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
await linkEntityImageUpload(client, 'items', id, cleanPayload.imagePath, cleanPayload.name);
|
||||||
await replaceItemRelations(client, id, cleanPayload);
|
await replaceItemRelations(client, id, cleanPayload);
|
||||||
await replaceEntityTranslations(client, 'items', id, cleanPayload.translations, ['name']);
|
await replaceEntityTranslations(client, 'items', id, cleanPayload.translations, ['name']);
|
||||||
const changes = before ? await itemEditChanges(client, before as unknown as ItemChangeSource, cleanPayload) : [];
|
const changes = before ? await itemEditChanges(client, before as unknown as ItemChangeSource, cleanPayload) : [];
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import cors from '@fastify/cors';
|
import cors from '@fastify/cors';
|
||||||
|
import multipart, { type MultipartFile } from '@fastify/multipart';
|
||||||
|
import fastifyStatic from '@fastify/static';
|
||||||
import Fastify from 'fastify';
|
import Fastify from 'fastify';
|
||||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||||
|
import { mkdir } from 'node:fs/promises';
|
||||||
import {
|
import {
|
||||||
getUserBySessionToken,
|
getUserBySessionToken,
|
||||||
loginUser,
|
loginUser,
|
||||||
@@ -81,6 +84,12 @@ import {
|
|||||||
systemMessage,
|
systemMessage,
|
||||||
updateSystemWordingValue
|
updateSystemWordingValue
|
||||||
} from './systemWordingQueries.ts';
|
} from './systemWordingQueries.ts';
|
||||||
|
import {
|
||||||
|
imageUploadMaxBytes,
|
||||||
|
isUploadEntityType,
|
||||||
|
saveEntityImageUpload,
|
||||||
|
uploadRoot
|
||||||
|
} from './uploads.ts';
|
||||||
|
|
||||||
const app = Fastify({
|
const app = Fastify({
|
||||||
logger: true
|
logger: true
|
||||||
@@ -92,6 +101,19 @@ await app.register(cors, {
|
|||||||
origin: process.env.FRONTEND_ORIGIN ?? true
|
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) => {
|
app.setErrorHandler(async (error, _request, reply) => {
|
||||||
const pgError = error as Error & { code?: string; constraint?: string; detail?: string; statusCode?: number };
|
const pgError = error as Error & { code?: string; constraint?: string; detail?: string; statusCode?: number };
|
||||||
const locale = requestLocale(_request);
|
const locale = requestLocale(_request);
|
||||||
@@ -137,6 +159,12 @@ function serverMessage(
|
|||||||
return systemMessage(locale, `server.errors.${key}`);
|
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) {
|
async function notFound(reply: FastifyReply, request: FastifyRequest) {
|
||||||
return reply.code(404).send({ message: await serverMessage(requestLocale(request), 'notFound') });
|
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<string, unknown>) : undefined;
|
return user ? fetchPokemonImageOptions(request.body as Record<string, unknown>) : 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) => {
|
app.put('/api/pokemon/:id', async (request, reply) => {
|
||||||
const user = await requireVerifiedUser(request, reply);
|
const user = await requireVerifiedUser(request, reply);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
|
|||||||
286
backend/src/uploads.ts
Normal file
286
backend/src/uploads.ts
Normal file
@@ -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<UploadEntityType>(['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<string, unknown> | 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<boolean> {
|
||||||
|
try {
|
||||||
|
await stat(filePath);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uniqueRelativePath(entityType: UploadEntityType, entityName: string, extension: string): Promise<string> {
|
||||||
|
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<EntityImageUpload> {
|
||||||
|
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<string, unknown>, '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<string, unknown>, 'entityId'));
|
||||||
|
const relativePath = await uniqueRelativePath(entityType, entityName, extension);
|
||||||
|
const absolutePath = path.join(uploadRoot, relativePath);
|
||||||
|
await writeFile(absolutePath, buffer, { flag: 'wx' });
|
||||||
|
|
||||||
|
const row = await queryOne<UploadRow>(
|
||||||
|
`
|
||||||
|
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<EntityImageUpload[]> {
|
||||||
|
const rows = await query<UploadRow>(
|
||||||
|
`
|
||||||
|
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<void> {
|
||||||
|
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]
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -24,10 +24,14 @@ services:
|
|||||||
BACKEND_PORT: 3001
|
BACKEND_PORT: 3001
|
||||||
FRONTEND_ORIGIN: http://localhost:3000
|
FRONTEND_ORIGIN: http://localhost:3000
|
||||||
APP_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:-}
|
RESEND_API_KEY: ${RESEND_API_KEY:-}
|
||||||
EMAIL_FROM: "${EMAIL_FROM:-Pokopia Wiki <onboarding@resend.dev>}"
|
EMAIL_FROM: "${EMAIL_FROM:-Pokopia Wiki <onboarding@resend.dev>}"
|
||||||
ports:
|
ports:
|
||||||
- "3001:3001"
|
- "3001:3001"
|
||||||
|
volumes:
|
||||||
|
- backend_uploads:/app/uploads
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -45,3 +49,4 @@ services:
|
|||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres18_data:
|
postgres18_data:
|
||||||
|
backend_uploads:
|
||||||
|
|||||||
@@ -10,11 +10,13 @@ defineProps<{
|
|||||||
icon?: AppIcon;
|
icon?: AppIcon;
|
||||||
marker?: string;
|
marker?: string;
|
||||||
image?: { src: string; alt: string };
|
image?: { src: string; alt: string };
|
||||||
|
ribbon?: string;
|
||||||
}>();
|
}>();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<RouterLink v-if="to" class="entity-card entity-card--link" :to="to">
|
<RouterLink v-if="to" class="entity-card entity-card--link" :to="to">
|
||||||
|
<span v-if="ribbon" class="entity-card__ribbon">{{ ribbon }}</span>
|
||||||
<span class="entity-card__mark" :class="{ 'entity-card__mark--image': image }">
|
<span class="entity-card__mark" :class="{ 'entity-card__mark--image': image }">
|
||||||
<img v-if="image" class="entity-card__image" :src="image.src" :alt="image.alt" loading="lazy" />
|
<img v-if="image" class="entity-card__image" :src="image.src" :alt="image.alt" loading="lazy" />
|
||||||
<Icon v-else-if="icon" :icon="icon" class="entity-card__icon" aria-hidden="true" />
|
<Icon v-else-if="icon" :icon="icon" class="entity-card__icon" aria-hidden="true" />
|
||||||
@@ -23,12 +25,14 @@ defineProps<{
|
|||||||
</span>
|
</span>
|
||||||
<div class="entity-card__content">
|
<div class="entity-card__content">
|
||||||
<span class="entity-card__title">{{ title }}</span>
|
<span class="entity-card__title">{{ title }}</span>
|
||||||
|
<slot name="after-title"></slot>
|
||||||
<span v-if="subtitle" class="entity-card__subtitle">{{ subtitle }}</span>
|
<span v-if="subtitle" class="entity-card__subtitle">{{ subtitle }}</span>
|
||||||
<slot></slot>
|
<slot></slot>
|
||||||
</div>
|
</div>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
|
||||||
<article v-else class="entity-card">
|
<article v-else class="entity-card">
|
||||||
|
<span v-if="ribbon" class="entity-card__ribbon">{{ ribbon }}</span>
|
||||||
<span class="entity-card__mark" :class="{ 'entity-card__mark--image': image }">
|
<span class="entity-card__mark" :class="{ 'entity-card__mark--image': image }">
|
||||||
<img v-if="image" class="entity-card__image" :src="image.src" :alt="image.alt" loading="lazy" />
|
<img v-if="image" class="entity-card__image" :src="image.src" :alt="image.alt" loading="lazy" />
|
||||||
<Icon v-else-if="icon" :icon="icon" class="entity-card__icon" aria-hidden="true" />
|
<Icon v-else-if="icon" :icon="icon" class="entity-card__icon" aria-hidden="true" />
|
||||||
@@ -37,6 +41,7 @@ defineProps<{
|
|||||||
</span>
|
</span>
|
||||||
<div class="entity-card__content">
|
<div class="entity-card__content">
|
||||||
<span class="entity-card__title">{{ title }}</span>
|
<span class="entity-card__title">{{ title }}</span>
|
||||||
|
<slot name="after-title"></slot>
|
||||||
<span v-if="subtitle" class="entity-card__subtitle">{{ subtitle }}</span>
|
<span v-if="subtitle" class="entity-card__subtitle">{{ subtitle }}</span>
|
||||||
<slot></slot>
|
<slot></slot>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
167
frontend/src/components/ImageUploadField.vue
Normal file
167
frontend/src/components/ImageUploadField.vue
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Icon } from '@iconify/vue';
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { iconCancel, iconImage, iconUpload } from '../icons';
|
||||||
|
import { api, type EntityImage, type EntityImageUpload, type ImageUploadEntityType } from '../services/api';
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
modelValue: string;
|
||||||
|
entityType: ImageUploadEntityType;
|
||||||
|
entityId?: string | number | null;
|
||||||
|
entityName: string;
|
||||||
|
label?: string;
|
||||||
|
currentImage?: EntityImage | null;
|
||||||
|
history?: EntityImageUpload[];
|
||||||
|
disabled?: boolean;
|
||||||
|
showPreview?: boolean;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
label: '',
|
||||||
|
currentImage: null,
|
||||||
|
history: () => [],
|
||||||
|
disabled: false,
|
||||||
|
showPreview: true
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: string];
|
||||||
|
uploaded: [image: EntityImageUpload];
|
||||||
|
selected: [image: EntityImage];
|
||||||
|
error: [message: string];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const fileInput = ref<HTMLInputElement | null>(null);
|
||||||
|
const uploadBusy = ref(false);
|
||||||
|
const localUploads = ref<EntityImageUpload[]>([]);
|
||||||
|
|
||||||
|
const imageLabel = computed(() => props.label || t('media.image'));
|
||||||
|
const uploadDisabled = computed(() => props.disabled || uploadBusy.value || props.entityName.trim() === '');
|
||||||
|
const imageOptions = computed<EntityImage[]>(() => {
|
||||||
|
const images = [
|
||||||
|
...localUploads.value,
|
||||||
|
...(props.history ?? []),
|
||||||
|
...(props.currentImage ? [props.currentImage] : [])
|
||||||
|
];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
return images.filter((image) => {
|
||||||
|
if (!image.path || seen.has(image.path)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
seen.add(image.path);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const selectedImage = computed(() => {
|
||||||
|
if (!props.modelValue) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return imageOptions.value.find((image) => image.path === props.modelValue) ?? props.currentImage ?? null;
|
||||||
|
});
|
||||||
|
|
||||||
|
function imageName(image: EntityImage): string {
|
||||||
|
const parts = image.path.split('/');
|
||||||
|
return parts.at(-1) ?? t('media.image');
|
||||||
|
}
|
||||||
|
|
||||||
|
function openFilePicker() {
|
||||||
|
if (!uploadDisabled.value) {
|
||||||
|
fileInput.value?.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectImage(image: EntityImage) {
|
||||||
|
emit('update:modelValue', image.path);
|
||||||
|
emit('selected', image);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearImage() {
|
||||||
|
emit('update:modelValue', '');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadImage(event: Event) {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
const file = input.files?.[0];
|
||||||
|
if (!file) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadBusy.value = true;
|
||||||
|
try {
|
||||||
|
const uploaded = await api.uploadImage(props.entityType, {
|
||||||
|
file,
|
||||||
|
entityName: props.entityName,
|
||||||
|
entityId: props.entityId
|
||||||
|
});
|
||||||
|
localUploads.value = [uploaded, ...localUploads.value];
|
||||||
|
emit('uploaded', uploaded);
|
||||||
|
selectImage(uploaded);
|
||||||
|
} catch (error) {
|
||||||
|
emit('error', error instanceof Error && error.message ? error.message : t('media.uploadFailed'));
|
||||||
|
} finally {
|
||||||
|
uploadBusy.value = false;
|
||||||
|
input.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="image-upload-field field">
|
||||||
|
<div class="image-upload-field__header">
|
||||||
|
<span class="field-label">{{ imageLabel }}</span>
|
||||||
|
<div class="image-upload-field__actions">
|
||||||
|
<input
|
||||||
|
ref="fileInput"
|
||||||
|
class="image-upload-field__input"
|
||||||
|
type="file"
|
||||||
|
accept="image/png,image/jpeg,image/webp,image/gif"
|
||||||
|
:disabled="uploadDisabled"
|
||||||
|
@change="uploadImage"
|
||||||
|
/>
|
||||||
|
<button type="button" class="ui-button ui-button--blue ui-button--small" :disabled="uploadDisabled" @click="openFilePicker">
|
||||||
|
<Icon :icon="iconUpload" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ uploadBusy ? t('media.uploading') : t('media.uploadImage') }}
|
||||||
|
</button>
|
||||||
|
<button v-if="modelValue" type="button" class="plain-button ui-button--small" :disabled="disabled || uploadBusy" @click="clearImage">
|
||||||
|
<Icon :icon="iconCancel" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ t('media.clearImage') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="showPreview && selectedImage" class="pokemon-image-preview image-upload-field__preview" :aria-label="t('media.selectedImage')">
|
||||||
|
<div class="pokemon-image-preview__screen">
|
||||||
|
<img :src="selectedImage.url" :alt="t('media.imageAlt', { name: entityName })" />
|
||||||
|
</div>
|
||||||
|
<div class="pokemon-image-preview__caption">
|
||||||
|
<strong>{{ t('media.selectedImage') }}</strong>
|
||||||
|
<span>{{ imageName(selectedImage) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p v-else-if="showPreview" class="meta-line">{{ t('media.imageEmpty') }}</p>
|
||||||
|
|
||||||
|
<div v-if="imageOptions.length" class="pokemon-image-thumbnails image-upload-field__history" :aria-label="t('media.imageHistory')">
|
||||||
|
<button
|
||||||
|
v-for="image in imageOptions"
|
||||||
|
:key="image.path"
|
||||||
|
type="button"
|
||||||
|
class="pokemon-image-thumbnail"
|
||||||
|
:class="{ active: image.path === modelValue }"
|
||||||
|
:aria-pressed="image.path === modelValue"
|
||||||
|
:disabled="disabled || uploadBusy"
|
||||||
|
@click="selectImage(image)"
|
||||||
|
>
|
||||||
|
<img :src="image.url" :alt="t('media.imageAlt', { name: entityName })" loading="lazy" />
|
||||||
|
<span>{{ imageName(image) }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p v-else class="meta-line image-upload-field__empty">
|
||||||
|
<Icon :icon="iconImage" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ t('media.imageHistoryEmpty') }}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -18,6 +18,7 @@ export const iconEdit: AppIcon = 'mdi:pencil-outline';
|
|||||||
export const iconError: AppIcon = 'mdi:close-circle-outline';
|
export const iconError: AppIcon = 'mdi:close-circle-outline';
|
||||||
export const iconEvent: AppIcon = 'mdi:calendar-star';
|
export const iconEvent: AppIcon = 'mdi:calendar-star';
|
||||||
export const iconHabitat: AppIcon = 'mdi:pine-tree';
|
export const iconHabitat: AppIcon = 'mdi:pine-tree';
|
||||||
|
export const iconImage: AppIcon = 'mdi:image-outline';
|
||||||
export const iconInfo: AppIcon = 'mdi:information-outline';
|
export const iconInfo: AppIcon = 'mdi:information-outline';
|
||||||
export const iconItem: AppIcon = 'mdi:bag-personal-outline';
|
export const iconItem: AppIcon = 'mdi:bag-personal-outline';
|
||||||
export const iconKey: AppIcon = 'mdi:key-outline';
|
export const iconKey: AppIcon = 'mdi:key-outline';
|
||||||
@@ -41,4 +42,5 @@ export const iconSave: AppIcon = 'mdi:content-save-outline';
|
|||||||
export const iconSearch: AppIcon = 'mdi:magnify';
|
export const iconSearch: AppIcon = 'mdi:magnify';
|
||||||
export const iconSuccess: AppIcon = 'mdi:check-circle-outline';
|
export const iconSuccess: AppIcon = 'mdi:check-circle-outline';
|
||||||
export const iconTranslate: AppIcon = 'mdi:translate';
|
export const iconTranslate: AppIcon = 'mdi:translate';
|
||||||
|
export const iconUpload: AppIcon = 'mdi:upload-outline';
|
||||||
export const iconWarning: AppIcon = 'mdi:alert-outline';
|
export const iconWarning: AppIcon = 'mdi:alert-outline';
|
||||||
|
|||||||
@@ -50,18 +50,36 @@ export interface PokemonStats {
|
|||||||
speed: number;
|
speed: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PokemonImage {
|
export interface UserSummary {
|
||||||
|
id: number;
|
||||||
|
displayName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EntityImage {
|
||||||
path: string;
|
path: string;
|
||||||
url: string;
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EntityImageUpload extends EntityImage {
|
||||||
|
id: number;
|
||||||
|
entityType: ImageUploadEntityType;
|
||||||
|
entityId: number | null;
|
||||||
|
entityName: string;
|
||||||
|
originalFilename: string;
|
||||||
|
mimeType: string;
|
||||||
|
byteSize: number;
|
||||||
|
uploadedAt: string;
|
||||||
|
uploadedBy: UserSummary | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ImageUploadEntityType = 'pokemon' | 'items' | 'habitats';
|
||||||
|
|
||||||
|
export interface PokemonImage extends EntityImage {
|
||||||
style: string;
|
style: string;
|
||||||
version: string;
|
version: string;
|
||||||
variant: string;
|
variant: string;
|
||||||
description: string;
|
description: string;
|
||||||
}
|
source?: 'sprite' | 'upload';
|
||||||
|
|
||||||
export interface UserSummary {
|
|
||||||
id: number;
|
|
||||||
displayName: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EditInfo {
|
export interface EditInfo {
|
||||||
@@ -120,6 +138,7 @@ export interface PokemonDetail extends Pokemon {
|
|||||||
favoriteThingItems: Array<NamedEntity & { category: NamedEntity; tags: NamedEntity[] }>;
|
favoriteThingItems: Array<NamedEntity & { category: NamedEntity; tags: NamedEntity[] }>;
|
||||||
relatedPokemon: RelatedPokemon[];
|
relatedPokemon: RelatedPokemon[];
|
||||||
editHistory: EditHistoryEntry[];
|
editHistory: EditHistoryEntry[];
|
||||||
|
imageHistory: EntityImageUpload[];
|
||||||
habitats: Array<{
|
habitats: Array<{
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -135,12 +154,14 @@ export interface Habitat extends EditInfo {
|
|||||||
name: string;
|
name: string;
|
||||||
baseName?: string;
|
baseName?: string;
|
||||||
translations?: TranslationMap;
|
translations?: TranslationMap;
|
||||||
|
image: EntityImage | null;
|
||||||
recipe: Array<NamedEntity & { quantity: number }>;
|
recipe: Array<NamedEntity & { quantity: number }>;
|
||||||
pokemon?: NamedEntity[];
|
pokemon?: NamedEntity[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HabitatDetail extends Habitat {
|
export interface HabitatDetail extends Habitat {
|
||||||
editHistory: EditHistoryEntry[];
|
editHistory: EditHistoryEntry[];
|
||||||
|
imageHistory: EntityImageUpload[];
|
||||||
pokemon: Array<NamedEntity & {
|
pokemon: Array<NamedEntity & {
|
||||||
time_of_day: string;
|
time_of_day: string;
|
||||||
weather: string;
|
weather: string;
|
||||||
@@ -170,6 +191,7 @@ export interface Item extends EditInfo {
|
|||||||
name: string;
|
name: string;
|
||||||
baseName?: string;
|
baseName?: string;
|
||||||
translations?: TranslationMap;
|
translations?: TranslationMap;
|
||||||
|
image: EntityImage | null;
|
||||||
category: NamedEntity;
|
category: NamedEntity;
|
||||||
usage: NamedEntity | null;
|
usage: NamedEntity | null;
|
||||||
customization: {
|
customization: {
|
||||||
@@ -188,6 +210,7 @@ export interface ItemDetail extends Item {
|
|||||||
relatedRecipes: RecipeUsage[];
|
relatedRecipes: RecipeUsage[];
|
||||||
relatedHabitats: HabitatUsage[];
|
relatedHabitats: HabitatUsage[];
|
||||||
editHistory: EditHistoryEntry[];
|
editHistory: EditHistoryEntry[];
|
||||||
|
imageHistory: EntityImageUpload[];
|
||||||
droppedByPokemon: Array<{
|
droppedByPokemon: Array<{
|
||||||
pokemon: NamedEntity;
|
pokemon: NamedEntity;
|
||||||
skill: NamedEntity;
|
skill: NamedEntity;
|
||||||
@@ -356,6 +379,7 @@ export interface ItemPayload {
|
|||||||
noRecipe: boolean;
|
noRecipe: boolean;
|
||||||
acquisitionMethodIds: number[];
|
acquisitionMethodIds: number[];
|
||||||
tagIds: number[];
|
tagIds: number[];
|
||||||
|
imagePath: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RecipePayload {
|
export interface RecipePayload {
|
||||||
@@ -367,6 +391,7 @@ export interface RecipePayload {
|
|||||||
export interface HabitatPayload {
|
export interface HabitatPayload {
|
||||||
name: string;
|
name: string;
|
||||||
translations?: TranslationMap;
|
translations?: TranslationMap;
|
||||||
|
imagePath: string;
|
||||||
recipeItems: Array<{ itemId: number; quantity: number }>;
|
recipeItems: Array<{ itemId: number; quantity: number }>;
|
||||||
pokemonAppearances: Array<{
|
pokemonAppearances: Array<{
|
||||||
pokemonId: number;
|
pokemonId: number;
|
||||||
@@ -518,6 +543,20 @@ async function sendJson<T>(path: string, method: 'PATCH' | 'POST' | 'PUT', body:
|
|||||||
return response.json() as Promise<T>;
|
return response.json() as Promise<T>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function sendFormData<T>(path: string, body: FormData): Promise<T> {
|
||||||
|
const response = await fetch(`${apiBaseUrl}${path}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: requestHeaders(),
|
||||||
|
body
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await getErrorMessage(response));
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
async function postEmpty(path: string): Promise<void> {
|
async function postEmpty(path: string): Promise<void> {
|
||||||
const response = await fetch(`${apiBaseUrl}${path}`, {
|
const response = await fetch(`${apiBaseUrl}${path}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -614,6 +653,18 @@ export const api = {
|
|||||||
payload: EntityDiscussionCommentPayload
|
payload: EntityDiscussionCommentPayload
|
||||||
) => sendJson<EntityDiscussionComment>(`/api/discussions/${entityType}/${entityId}/comments/${commentId}/replies`, 'POST', payload),
|
) => sendJson<EntityDiscussionComment>(`/api/discussions/${entityType}/${entityId}/comments/${commentId}/replies`, 'POST', payload),
|
||||||
deleteEntityDiscussionComment: (id: string | number) => deleteJson(`/api/discussions/comments/${id}`),
|
deleteEntityDiscussionComment: (id: string | number) => deleteJson(`/api/discussions/comments/${id}`),
|
||||||
|
uploadImage: (
|
||||||
|
entityType: ImageUploadEntityType,
|
||||||
|
payload: { file: File; entityName: string; entityId?: string | number | null }
|
||||||
|
) => {
|
||||||
|
const body = new FormData();
|
||||||
|
body.set('entityName', payload.entityName);
|
||||||
|
if (payload.entityId) {
|
||||||
|
body.set('entityId', String(payload.entityId));
|
||||||
|
}
|
||||||
|
body.set('file', payload.file);
|
||||||
|
return sendFormData<EntityImageUpload>(`/api/uploads/${entityType}`, body);
|
||||||
|
},
|
||||||
createDailyChecklistItem: (payload: DailyChecklistPayload) =>
|
createDailyChecklistItem: (payload: DailyChecklistPayload) =>
|
||||||
sendJson<DailyChecklistItem>('/api/admin/daily-checklist', 'POST', payload),
|
sendJson<DailyChecklistItem>('/api/admin/daily-checklist', 'POST', payload),
|
||||||
updateDailyChecklistItem: (id: string | number, payload: DailyChecklistPayload) =>
|
updateDailyChecklistItem: (id: string | number, payload: DailyChecklistPayload) =>
|
||||||
|
|||||||
@@ -960,6 +960,49 @@ button:disabled,
|
|||||||
justify-self: start;
|
justify-self: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.image-upload-field {
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-upload-field__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-upload-field__actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-upload-field__input {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0 0 0 0);
|
||||||
|
clip-path: inset(50%);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-upload-field__preview .pokemon-image-preview__screen {
|
||||||
|
min-height: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-upload-field__preview .pokemon-image-preview__screen img {
|
||||||
|
max-height: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-upload-field__empty {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
.pokemon-edit-panel {
|
.pokemon-edit-panel {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -1414,6 +1457,7 @@ button:disabled,
|
|||||||
}
|
}
|
||||||
|
|
||||||
.entity-card {
|
.entity-card {
|
||||||
|
position: relative;
|
||||||
min-height: 164px;
|
min-height: 164px;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: auto minmax(0, 1fr);
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
@@ -1424,6 +1468,7 @@ button:disabled,
|
|||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
box-shadow: var(--shadow-control);
|
box-shadow: var(--shadow-control);
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.entity-card--link {
|
.entity-card--link {
|
||||||
@@ -1471,6 +1516,29 @@ button:disabled,
|
|||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.entity-card__ribbon {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 1;
|
||||||
|
top: 14px;
|
||||||
|
left: -38px;
|
||||||
|
width: 132px;
|
||||||
|
min-height: 26px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 4px 10px;
|
||||||
|
transform: rotate(-35deg);
|
||||||
|
border: 2px solid var(--line-strong);
|
||||||
|
background: var(--pokemon-blue);
|
||||||
|
color: #ffffff;
|
||||||
|
box-shadow: 0 2px 0 var(--line-strong);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 950;
|
||||||
|
line-height: 1;
|
||||||
|
pointer-events: none;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
.entity-card__content {
|
.entity-card__content {
|
||||||
display: grid;
|
display: grid;
|
||||||
align-content: start;
|
align-content: start;
|
||||||
@@ -1493,6 +1561,16 @@ button:disabled,
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.catalog-card-grid .entity-card {
|
||||||
|
min-height: 224px;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
justify-items: center;
|
||||||
|
align-content: start;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 18px 16px 16px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
.pokemon-list-grid .entity-card {
|
.pokemon-list-grid .entity-card {
|
||||||
min-height: 168px;
|
min-height: 168px;
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
@@ -1502,24 +1580,50 @@ button:disabled,
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pokemon-list-grid .entity-card__mark {
|
.pokemon-list-grid .entity-card__mark,
|
||||||
|
.catalog-card-grid .entity-card__mark {
|
||||||
width: 92px;
|
width: 92px;
|
||||||
height: 92px;
|
height: 92px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pokemon-list-grid .pokeball-mark {
|
.pokemon-list-grid .pokeball-mark,
|
||||||
|
.catalog-card-grid .pokeball-mark {
|
||||||
--ball-size: 64px !important;
|
--ball-size: 64px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.catalog-card-grid .entity-card__content {
|
||||||
|
justify-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
.pokemon-list-grid .entity-card__content {
|
.pokemon-list-grid .entity-card__content {
|
||||||
justify-items: center;
|
justify-items: center;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pokemon-list-grid .entity-card__title {
|
.pokemon-list-grid .entity-card__title,
|
||||||
|
.catalog-card-grid .entity-card__title {
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.catalog-card-grid .entity-card__subtitle {
|
||||||
|
min-height: 20px;
|
||||||
|
font-weight: 850;
|
||||||
|
}
|
||||||
|
|
||||||
|
.catalog-card-action {
|
||||||
|
min-height: 36px;
|
||||||
|
max-width: 100%;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.catalog-card-action--hidden {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
.edit-meta {
|
.edit-meta {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
@@ -3379,6 +3483,59 @@ button:disabled,
|
|||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.entity-detail-image {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-detail-image__frame {
|
||||||
|
min-height: 220px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border: 2px solid var(--line-strong);
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
background:
|
||||||
|
linear-gradient(90deg, rgba(42, 117, 187, 0.08) 1px, transparent 1px) 0 0 / 18px 18px,
|
||||||
|
linear-gradient(rgba(42, 117, 187, 0.08) 1px, transparent 1px) 0 0 / 18px 18px,
|
||||||
|
var(--surface-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-detail-image__frame img {
|
||||||
|
width: min(100%, 360px);
|
||||||
|
max-height: 240px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-history-list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(92px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-history-list__item {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
justify-items: center;
|
||||||
|
padding: 8px;
|
||||||
|
border: 2px solid var(--line);
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-history-list__item img {
|
||||||
|
width: 74px;
|
||||||
|
height: 64px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-history-list__item span {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.76rem;
|
||||||
|
font-weight: 850;
|
||||||
|
text-align: center;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
.pokemon-image-detail {
|
.pokemon-image-detail {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(220px, 420px) minmax(0, 1fr);
|
grid-template-columns: minmax(220px, 420px) minmax(0, 1fr);
|
||||||
|
|||||||
@@ -108,6 +108,10 @@ const pokemonRows = computed<PokemonRow[]>(() => {
|
|||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function imageFileName(path: string): string {
|
||||||
|
return path.split('/').at(-1) ?? t('media.image');
|
||||||
|
}
|
||||||
|
|
||||||
async function loadHabitatDetail() {
|
async function loadHabitatDetail() {
|
||||||
habitat.value = await api.habitatDetail(String(route.params.id));
|
habitat.value = await api.habitatDetail(String(route.params.id));
|
||||||
}
|
}
|
||||||
@@ -200,6 +204,21 @@ watch(
|
|||||||
<Tabs id="habitat-detail-tabs" v-model="detailTab" :tabs="detailTabs" :label="t('common.details')" />
|
<Tabs id="habitat-detail-tabs" v-model="detailTab" :tabs="detailTabs" :label="t('common.details')" />
|
||||||
|
|
||||||
<div v-if="detailTab === 'details'" class="habitat-detail-stack">
|
<div v-if="detailTab === 'details'" class="habitat-detail-stack">
|
||||||
|
<DetailSection v-if="habitat.image || habitat.imageHistory.length" :title="t('media.image')">
|
||||||
|
<div class="entity-detail-image">
|
||||||
|
<div v-if="habitat.image" class="entity-detail-image__frame">
|
||||||
|
<img :src="habitat.image.url" :alt="t('media.imageAlt', { name: habitat.name })" />
|
||||||
|
</div>
|
||||||
|
<p v-else class="meta-line">{{ t('media.imageEmpty') }}</p>
|
||||||
|
<div v-if="habitat.imageHistory.length" class="image-history-list" :aria-label="t('media.imageHistory')">
|
||||||
|
<div v-for="image in habitat.imageHistory" :key="image.path" class="image-history-list__item">
|
||||||
|
<img :src="image.url" :alt="t('media.imageAlt', { name: habitat.name })" loading="lazy" />
|
||||||
|
<span>{{ imageFileName(image.path) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DetailSection>
|
||||||
|
|
||||||
<DetailSection :title="t('pages.habitats.recipeList')">
|
<DetailSection :title="t('pages.habitats.recipeList')">
|
||||||
<EntityChips :items="habitat.recipe" />
|
<EntityChips :items="habitat.recipe" />
|
||||||
</DetailSection>
|
</DetailSection>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Icon } from '@iconify/vue';
|
|||||||
import { computed, onMounted, ref } from 'vue';
|
import { computed, onMounted, ref } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
import ImageUploadField from '../components/ImageUploadField.vue';
|
||||||
import Modal from '../components/Modal.vue';
|
import Modal from '../components/Modal.vue';
|
||||||
import Skeleton from '../components/Skeleton.vue';
|
import Skeleton from '../components/Skeleton.vue';
|
||||||
import StatusMessage from '../components/StatusMessage.vue';
|
import StatusMessage from '../components/StatusMessage.vue';
|
||||||
@@ -13,6 +14,8 @@ import { iconAdd, iconCancel, iconDelete, iconPokemon, iconSave } from '../icons
|
|||||||
import {
|
import {
|
||||||
api,
|
api,
|
||||||
type ConfigType,
|
type ConfigType,
|
||||||
|
type EntityImage,
|
||||||
|
type EntityImageUpload,
|
||||||
type HabitatDetail,
|
type HabitatDetail,
|
||||||
type HabitatPayload,
|
type HabitatPayload,
|
||||||
type Item,
|
type Item,
|
||||||
@@ -37,6 +40,8 @@ const options = ref<Options | null>(null);
|
|||||||
const itemRows = ref<Item[]>([]);
|
const itemRows = ref<Item[]>([]);
|
||||||
const pokemonRows = ref<Pokemon[]>([]);
|
const pokemonRows = ref<Pokemon[]>([]);
|
||||||
const languages = ref<Language[]>([]);
|
const languages = ref<Language[]>([]);
|
||||||
|
const currentImage = ref<EntityImage | null>(null);
|
||||||
|
const imageHistory = ref<EntityImageUpload[]>([]);
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
const busy = ref(false);
|
const busy = ref(false);
|
||||||
const message = ref('');
|
const message = ref('');
|
||||||
@@ -44,6 +49,7 @@ const creatingSelect = ref('');
|
|||||||
const habitatForm = ref({
|
const habitatForm = ref({
|
||||||
name: '',
|
name: '',
|
||||||
translations: {} as TranslationMap,
|
translations: {} as TranslationMap,
|
||||||
|
imagePath: '',
|
||||||
recipeItems: [] as Array<{ itemId: string; quantity: number }>,
|
recipeItems: [] as Array<{ itemId: string; quantity: number }>,
|
||||||
pokemonAppearances: [] as HabitatAppearanceForm[]
|
pokemonAppearances: [] as HabitatAppearanceForm[]
|
||||||
});
|
});
|
||||||
@@ -73,6 +79,7 @@ const pageTitle = computed(() =>
|
|||||||
: t('pages.habitats.newTitle')
|
: t('pages.habitats.newTitle')
|
||||||
);
|
);
|
||||||
const cancelTo = computed(() => (isEditing.value ? `/habitats/${routeId.value}` : '/habitats'));
|
const cancelTo = computed(() => (isEditing.value ? `/habitats/${routeId.value}` : '/habitats'));
|
||||||
|
const imageEntityName = computed(() => habitatNameForSave().trim());
|
||||||
|
|
||||||
function toIds(values: string[]): number[] {
|
function toIds(values: string[]): number[] {
|
||||||
return values.map(Number).filter((item) => Number.isInteger(item) && item > 0);
|
return values.map(Number).filter((item) => Number.isInteger(item) && item > 0);
|
||||||
@@ -159,9 +166,12 @@ async function loadEditor() {
|
|||||||
habitatForm.value = {
|
habitatForm.value = {
|
||||||
name: habitat.baseName ?? habitat.name,
|
name: habitat.baseName ?? habitat.name,
|
||||||
translations: habitat.translations ?? {},
|
translations: habitat.translations ?? {},
|
||||||
|
imagePath: habitat.image?.path ?? '',
|
||||||
recipeItems: habitat.recipe.map((recipeItem) => ({ itemId: String(recipeItem.id), quantity: recipeItem.quantity })),
|
recipeItems: habitat.recipe.map((recipeItem) => ({ itemId: String(recipeItem.id), quantity: recipeItem.quantity })),
|
||||||
pokemonAppearances: groupPokemonAppearances(habitat)
|
pokemonAppearances: groupPokemonAppearances(habitat)
|
||||||
};
|
};
|
||||||
|
currentImage.value = habitat.image;
|
||||||
|
imageHistory.value = habitat.imageHistory;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.value = errorText(error, t('errors.loadFailed'));
|
message.value = errorText(error, t('errors.loadFailed'));
|
||||||
@@ -202,6 +212,7 @@ async function saveHabitat() {
|
|||||||
const payload: HabitatPayload = {
|
const payload: HabitatPayload = {
|
||||||
name: habitatNameForSave(),
|
name: habitatNameForSave(),
|
||||||
translations: habitatForm.value.translations,
|
translations: habitatForm.value.translations,
|
||||||
|
imagePath: habitatForm.value.imagePath,
|
||||||
recipeItems: toQuantityRows(habitatForm.value.recipeItems),
|
recipeItems: toQuantityRows(habitatForm.value.recipeItems),
|
||||||
pokemonAppearances: habitatForm.value.pokemonAppearances
|
pokemonAppearances: habitatForm.value.pokemonAppearances
|
||||||
.map((item) => ({
|
.map((item) => ({
|
||||||
@@ -222,6 +233,15 @@ async function saveHabitat() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(() => {
|
onMounted(() => {
|
||||||
void loadEditor();
|
void loadEditor();
|
||||||
});
|
});
|
||||||
@@ -242,6 +262,20 @@ onMounted(() => {
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ImageUploadField
|
||||||
|
v-model="habitatForm.imagePath"
|
||||||
|
entity-type="habitats"
|
||||||
|
:entity-id="isEditing ? routeId : null"
|
||||||
|
:entity-name="imageEntityName"
|
||||||
|
:label="t('media.image')"
|
||||||
|
:current-image="currentImage"
|
||||||
|
:history="imageHistory"
|
||||||
|
:disabled="busy"
|
||||||
|
@selected="handleImageSelected"
|
||||||
|
@uploaded="handleImageUploaded"
|
||||||
|
@error="message = $event"
|
||||||
|
/>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>{{ t('pages.habitats.recipe') }}</label>
|
<label>{{ t('pages.habitats.recipe') }}</label>
|
||||||
<div v-for="(row, index) in habitatForm.recipeItems" :key="index" class="inline-row">
|
<div v-for="(row, index) in habitatForm.recipeItems" :key="index" class="inline-row">
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ import { Icon } from '@iconify/vue';
|
|||||||
import { computed, onMounted, ref } from 'vue';
|
import { computed, onMounted, ref } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
import EditMeta from '../components/EditMeta.vue';
|
|
||||||
import EntityChips from '../components/EntityChips.vue';
|
|
||||||
import EntityCard from '../components/EntityCard.vue';
|
import EntityCard from '../components/EntityCard.vue';
|
||||||
import PageHeader from '../components/PageHeader.vue';
|
import PageHeader from '../components/PageHeader.vue';
|
||||||
import Skeleton from '../components/Skeleton.vue';
|
import Skeleton from '../components/Skeleton.vue';
|
||||||
@@ -19,6 +17,10 @@ const loading = ref(true);
|
|||||||
const skeletonCardCount = 6;
|
const skeletonCardCount = 6;
|
||||||
const showEditor = computed(() => route.name === 'habitat-new');
|
const showEditor = computed(() => route.name === 'habitat-new');
|
||||||
|
|
||||||
|
function habitatCardImage(item: Habitat) {
|
||||||
|
return item.image ? { src: item.image.url, alt: t('media.imageAlt', { name: item.name }) } : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
habitats.value = await api.habitats();
|
habitats.value = await api.habitats();
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
@@ -37,27 +39,23 @@ onMounted(async () => {
|
|||||||
</template>
|
</template>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
<div v-if="loading" class="entity-grid" aria-busy="true" :aria-label="t('pages.habitats.loadingList')">
|
<div v-if="loading" class="entity-grid pokemon-list-grid" aria-busy="true" :aria-label="t('pages.habitats.loadingList')">
|
||||||
<article v-for="index in skeletonCardCount" :key="index" class="entity-card entity-card--skeleton">
|
<article v-for="index in skeletonCardCount" :key="index" class="entity-card entity-card--skeleton">
|
||||||
<Skeleton variant="box" width="42px" height="42px" class="skeleton-entity-mark" />
|
<Skeleton variant="box" width="92px" height="92px" class="skeleton-entity-mark" />
|
||||||
<div class="entity-card__content">
|
<div class="entity-card__content">
|
||||||
<Skeleton width="68%" height="24px" />
|
<Skeleton width="128px" height="24px" />
|
||||||
<Skeleton width="66%" />
|
|
||||||
<div class="skeleton-chip-row">
|
|
||||||
<Skeleton v-for="chipIndex in 3" :key="`recipe-${chipIndex}`" width="70px" class="skeleton-chip" />
|
|
||||||
</div>
|
|
||||||
<div class="skeleton-chip-row">
|
|
||||||
<Skeleton v-for="chipIndex in 2" :key="`pokemon-${chipIndex}`" width="82px" class="skeleton-chip" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="entity-grid">
|
<div v-else class="entity-grid pokemon-list-grid">
|
||||||
<EntityCard v-for="item in habitats" :key="item.id" :title="item.name" :to="`/habitats/${item.id}`" :icon="iconHabitat">
|
<EntityCard
|
||||||
<EditMeta :entity="item" />
|
v-for="item in habitats"
|
||||||
<EntityChips :items="item.recipe" />
|
:key="item.id"
|
||||||
<EntityChips :items="item.pokemon ?? []" />
|
:title="item.name"
|
||||||
</EntityCard>
|
:to="`/habitats/${item.id}`"
|
||||||
|
:icon="iconHabitat"
|
||||||
|
:image="habitatCardImage(item)"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<HabitatEdit v-if="showEditor" />
|
<HabitatEdit v-if="showEditor" />
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ const customization = computed(() => {
|
|||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function imageFileName(path: string): string {
|
||||||
|
return path.split('/').at(-1) ?? t('media.image');
|
||||||
|
}
|
||||||
|
|
||||||
async function loadItemDetail() {
|
async function loadItemDetail() {
|
||||||
item.value = await api.itemDetail(String(route.params.id));
|
item.value = await api.itemDetail(String(route.params.id));
|
||||||
}
|
}
|
||||||
@@ -136,6 +140,21 @@ watch(
|
|||||||
<Tabs id="item-detail-tabs" v-model="detailTab" :tabs="detailTabs" :label="t('common.details')" />
|
<Tabs id="item-detail-tabs" v-model="detailTab" :tabs="detailTabs" :label="t('common.details')" />
|
||||||
|
|
||||||
<div v-if="detailTab === 'details'" class="detail-grid">
|
<div v-if="detailTab === 'details'" class="detail-grid">
|
||||||
|
<DetailSection v-if="item.image || item.imageHistory.length" :title="t('media.image')">
|
||||||
|
<div class="entity-detail-image">
|
||||||
|
<div v-if="item.image" class="entity-detail-image__frame">
|
||||||
|
<img :src="item.image.url" :alt="t('media.imageAlt', { name: item.name })" />
|
||||||
|
</div>
|
||||||
|
<p v-else class="meta-line">{{ t('media.imageEmpty') }}</p>
|
||||||
|
<div v-if="item.imageHistory.length" class="image-history-list" :aria-label="t('media.imageHistory')">
|
||||||
|
<div v-for="image in item.imageHistory" :key="image.path" class="image-history-list__item">
|
||||||
|
<img :src="image.url" :alt="t('media.imageAlt', { name: item.name })" loading="lazy" />
|
||||||
|
<span>{{ imageFileName(image.path) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DetailSection>
|
||||||
|
|
||||||
<DetailSection :title="t('pages.items.acquisitionMethods')">
|
<DetailSection :title="t('pages.items.acquisitionMethods')">
|
||||||
<EntityChips :items="item.acquisitionMethods" />
|
<EntityChips :items="item.acquisitionMethods" />
|
||||||
</DetailSection>
|
</DetailSection>
|
||||||
|
|||||||
@@ -3,19 +3,22 @@ import { Icon } from '@iconify/vue';
|
|||||||
import { computed, onMounted, ref } from 'vue';
|
import { computed, onMounted, ref } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
import ImageUploadField from '../components/ImageUploadField.vue';
|
||||||
import Modal from '../components/Modal.vue';
|
import Modal from '../components/Modal.vue';
|
||||||
import Skeleton from '../components/Skeleton.vue';
|
import Skeleton from '../components/Skeleton.vue';
|
||||||
import StatusMessage from '../components/StatusMessage.vue';
|
import StatusMessage from '../components/StatusMessage.vue';
|
||||||
import TagsSelect from '../components/TagsSelect.vue';
|
import TagsSelect from '../components/TagsSelect.vue';
|
||||||
import TranslationFields from '../components/TranslationFields.vue';
|
import TranslationFields from '../components/TranslationFields.vue';
|
||||||
import { iconCancel, iconSave } from '../icons';
|
import { iconCancel, iconSave } 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 route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { locale, t } = useI18n();
|
const { locale, t } = useI18n();
|
||||||
const options = ref<Options | null>(null);
|
const options = ref<Options | null>(null);
|
||||||
const languages = ref<Language[]>([]);
|
const languages = ref<Language[]>([]);
|
||||||
|
const currentImage = ref<EntityImage | null>(null);
|
||||||
|
const imageHistory = ref<EntityImageUpload[]>([]);
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
const busy = ref(false);
|
const busy = ref(false);
|
||||||
const message = ref('');
|
const message = ref('');
|
||||||
@@ -30,7 +33,8 @@ const itemForm = ref({
|
|||||||
patternEditable: false,
|
patternEditable: false,
|
||||||
noRecipe: false,
|
noRecipe: false,
|
||||||
acquisitionMethodIds: [] as string[],
|
acquisitionMethodIds: [] as string[],
|
||||||
tagIds: [] as string[]
|
tagIds: [] as string[],
|
||||||
|
imagePath: ''
|
||||||
});
|
});
|
||||||
|
|
||||||
const routeId = computed(() => (typeof route.params.id === 'string' ? route.params.id : ''));
|
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 cancelTo = computed(() => (isEditing.value ? `/items/${routeId.value}` : '/items'));
|
||||||
const hasRecipe = ref(false);
|
const hasRecipe = ref(false);
|
||||||
|
const imageEntityName = computed(() => itemNameForSave().trim());
|
||||||
|
|
||||||
function toIds(values: string[]): number[] {
|
function toIds(values: string[]): number[] {
|
||||||
return values.map(Number).filter((item) => Number.isInteger(item) && item > 0);
|
return values.map(Number).filter((item) => Number.isInteger(item) && item > 0);
|
||||||
@@ -88,8 +93,11 @@ async function loadEditor() {
|
|||||||
patternEditable: item.customization.patternEditable,
|
patternEditable: item.customization.patternEditable,
|
||||||
noRecipe: item.noRecipe,
|
noRecipe: item.noRecipe,
|
||||||
acquisitionMethodIds: item.acquisitionMethods.map((method) => String(method.id)),
|
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;
|
hasRecipe.value = item.recipe !== null;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -151,7 +159,8 @@ async function saveItem() {
|
|||||||
patternEditable: itemForm.value.patternEditable,
|
patternEditable: itemForm.value.patternEditable,
|
||||||
noRecipe: itemForm.value.noRecipe,
|
noRecipe: itemForm.value.noRecipe,
|
||||||
acquisitionMethodIds: toIds(itemForm.value.acquisitionMethodIds),
|
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);
|
const saved = isEditing.value ? await api.updateItem(routeId.value, payload) : await api.createItem(payload);
|
||||||
await router.push(`/items/${saved.id}`);
|
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(() => {
|
onMounted(() => {
|
||||||
void loadEditor();
|
void loadEditor();
|
||||||
});
|
});
|
||||||
@@ -182,6 +200,20 @@ onMounted(() => {
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ImageUploadField
|
||||||
|
v-model="itemForm.imagePath"
|
||||||
|
entity-type="items"
|
||||||
|
:entity-id="isEditing ? routeId : null"
|
||||||
|
:entity-name="imageEntityName"
|
||||||
|
:label="t('media.image')"
|
||||||
|
:current-image="currentImage"
|
||||||
|
:history="imageHistory"
|
||||||
|
:disabled="busy"
|
||||||
|
@selected="handleImageSelected"
|
||||||
|
@uploaded="handleImageUploaded"
|
||||||
|
@error="message = $event"
|
||||||
|
/>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="item-category">{{ t('pages.items.category') }}</label>
|
<label for="item-category">{{ t('pages.items.category') }}</label>
|
||||||
<TagsSelect
|
<TagsSelect
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ import { Icon } from '@iconify/vue';
|
|||||||
import { computed, onMounted, ref, watch } from 'vue';
|
import { computed, onMounted, ref, watch } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
import EditMeta from '../components/EditMeta.vue';
|
|
||||||
import EntityChips from '../components/EntityChips.vue';
|
|
||||||
import EntityCard from '../components/EntityCard.vue';
|
import EntityCard from '../components/EntityCard.vue';
|
||||||
import FilterPanel from '../components/FilterPanel.vue';
|
import FilterPanel from '../components/FilterPanel.vue';
|
||||||
import PageHeader from '../components/PageHeader.vue';
|
import PageHeader from '../components/PageHeader.vue';
|
||||||
@@ -42,6 +40,10 @@ const itemQuery = computed(() => ({
|
|||||||
}));
|
}));
|
||||||
const showEditor = computed(() => route.name === 'item-new');
|
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() {
|
async function loadItems() {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
items.value = await api.items(itemQuery.value);
|
items.value = await api.items(itemQuery.value);
|
||||||
@@ -112,36 +114,26 @@ watch(itemQuery, loadItems);
|
|||||||
</div>
|
</div>
|
||||||
</FilterPanel>
|
</FilterPanel>
|
||||||
|
|
||||||
<div v-if="loading" class="entity-grid" aria-busy="true" :aria-label="t('pages.items.loadingList')">
|
<div v-if="loading" class="entity-grid catalog-card-grid" aria-busy="true" :aria-label="t('pages.items.loadingList')">
|
||||||
<article v-for="index in skeletonCardCount" :key="`item-skeleton-${index}`" class="entity-card entity-card--skeleton">
|
<article v-for="index in skeletonCardCount" :key="`item-skeleton-${index}`" class="entity-card entity-card--skeleton">
|
||||||
<Skeleton variant="box" width="42px" height="42px" class="skeleton-entity-mark" />
|
<Skeleton variant="box" width="92px" height="92px" class="skeleton-entity-mark" />
|
||||||
<div class="entity-card__content">
|
<div class="entity-card__content">
|
||||||
<Skeleton width="72%" height="24px" />
|
<Skeleton width="128px" height="24px" />
|
||||||
<Skeleton width="52%" />
|
<Skeleton width="92px" />
|
||||||
<Skeleton width="64%" />
|
|
||||||
<div class="skeleton-chip-row">
|
|
||||||
<Skeleton
|
|
||||||
v-for="chipIndex in 3"
|
|
||||||
:key="chipIndex"
|
|
||||||
:width="chipIndex === 1 ? '74px' : '58px'"
|
|
||||||
class="skeleton-chip"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="entity-grid">
|
<div v-else class="entity-grid catalog-card-grid">
|
||||||
<EntityCard
|
<EntityCard
|
||||||
v-for="item in items"
|
v-for="item in items"
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
:title="item.name"
|
:title="item.name"
|
||||||
:subtitle="item.usage ? `${item.category.name} · ${item.usage.name}` : item.category.name"
|
:subtitle="item.category.name"
|
||||||
:to="`/items/${item.id}`"
|
:to="`/items/${item.id}`"
|
||||||
:icon="iconItem"
|
:icon="iconItem"
|
||||||
>
|
:image="itemCardImage(item)"
|
||||||
<EditMeta :entity="item" />
|
:ribbon="item.usage?.name"
|
||||||
<EntityChips :items="item.tags" />
|
/>
|
||||||
</EntityCard>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ItemEdit v-if="showEditor" />
|
<ItemEdit v-if="showEditor" />
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { api, type PokemonDetail } from '../services/api';
|
|||||||
import PokemonEdit from './PokemonEdit.vue';
|
import PokemonEdit from './PokemonEdit.vue';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const { t } = useI18n();
|
const { locale, t } = useI18n();
|
||||||
const pokemon = ref<PokemonDetail | null>(null);
|
const pokemon = ref<PokemonDetail | null>(null);
|
||||||
const itemCategoryTab = ref('');
|
const itemCategoryTab = ref('');
|
||||||
const relatedHabitatTab = ref('');
|
const relatedHabitatTab = ref('');
|
||||||
@@ -187,11 +187,30 @@ function pokemonTypeIconSrc(typeId: number): string | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function pokemonImageAlt() {
|
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() {
|
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() {
|
function openImageModal() {
|
||||||
@@ -502,8 +521,15 @@ watch(
|
|||||||
</div>
|
</div>
|
||||||
<div class="pokemon-image-detail__caption">
|
<div class="pokemon-image-detail__caption">
|
||||||
<strong>{{ pokemonImageLabel() }}</strong>
|
<strong>{{ pokemonImageLabel() }}</strong>
|
||||||
<span>{{ pokemon.image.style }}</span>
|
<span v-if="pokemon.image.style">{{ pokemon.image.style }}</span>
|
||||||
<p>{{ pokemon.image.description }}</p>
|
<p v-if="pokemon.image.description">{{ pokemon.image.description }}</p>
|
||||||
|
<div v-if="pokemon.imageHistory.length" class="image-history-list" :aria-label="t('media.imageHistory')">
|
||||||
|
<div v-for="image in pokemon.imageHistory" :key="image.path" class="image-history-list__item">
|
||||||
|
<img :src="image.url" :alt="t('media.imageAlt', { name: pokemon.name })" loading="lazy" />
|
||||||
|
<span>{{ imageFileName(image.path) }}</span>
|
||||||
|
<span>{{ formatDateTime(image.uploadedAt) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Icon } from '@iconify/vue';
|
|||||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
import ImageUploadField from '../components/ImageUploadField.vue';
|
||||||
import Modal from '../components/Modal.vue';
|
import Modal from '../components/Modal.vue';
|
||||||
import PokemonStatsFields from '../components/PokemonStatsFields.vue';
|
import PokemonStatsFields from '../components/PokemonStatsFields.vue';
|
||||||
import Skeleton from '../components/Skeleton.vue';
|
import Skeleton from '../components/Skeleton.vue';
|
||||||
@@ -14,6 +15,8 @@ import { iconCancel, iconSave, iconSearch } from '../icons';
|
|||||||
import {
|
import {
|
||||||
api,
|
api,
|
||||||
type ConfigType,
|
type ConfigType,
|
||||||
|
type EntityImage,
|
||||||
|
type EntityImageUpload,
|
||||||
type Language,
|
type Language,
|
||||||
type NamedEntity,
|
type NamedEntity,
|
||||||
type Options,
|
type Options,
|
||||||
@@ -47,6 +50,7 @@ const fetchIdentifier = ref('');
|
|||||||
const fetchOptions = ref<PokemonFetchOption[]>([]);
|
const fetchOptions = ref<PokemonFetchOption[]>([]);
|
||||||
const imageOptions = ref<PokemonImage[]>([]);
|
const imageOptions = ref<PokemonImage[]>([]);
|
||||||
const currentPokemonImage = ref<PokemonImage | null>(null);
|
const currentPokemonImage = ref<PokemonImage | null>(null);
|
||||||
|
const imageHistory = ref<EntityImageUpload[]>([]);
|
||||||
const creatingSelect = ref('');
|
const creatingSelect = ref('');
|
||||||
const activeEditTab = ref('basic');
|
const activeEditTab = ref('basic');
|
||||||
const heightUnit = ref<'imperial' | 'metric'>('imperial');
|
const heightUnit = ref<'imperial' | 'metric'>('imperial');
|
||||||
@@ -102,6 +106,7 @@ const heightInchesValue = computed(() => totalHeightInchesValue.value - heightFe
|
|||||||
const heightMetersValue = computed(() => roundMeasurement(pokemonForm.value.heightInches * 0.0254, 2));
|
const heightMetersValue = computed(() => roundMeasurement(pokemonForm.value.heightInches * 0.0254, 2));
|
||||||
const weightPoundsValue = computed(() => roundMeasurement(pokemonForm.value.weightPounds, 1));
|
const weightPoundsValue = computed(() => roundMeasurement(pokemonForm.value.weightPounds, 1));
|
||||||
const weightKgValue = computed(() => roundMeasurement(pokemonForm.value.weightPounds * 0.45359237, 2));
|
const weightKgValue = computed(() => roundMeasurement(pokemonForm.value.weightPounds * 0.45359237, 2));
|
||||||
|
const imageEntityName = computed(() => pokemonNameForSave().trim());
|
||||||
const selectedPokemonImage = computed(() => {
|
const selectedPokemonImage = computed(() => {
|
||||||
const imagePath = pokemonForm.value.imagePath;
|
const imagePath = pokemonForm.value.imagePath;
|
||||||
if (!imagePath) {
|
if (!imagePath) {
|
||||||
@@ -118,6 +123,7 @@ const displayedImageOptions = computed(() => {
|
|||||||
|
|
||||||
return [selectedImage, ...imageOptions.value];
|
return [selectedImage, ...imageOptions.value];
|
||||||
});
|
});
|
||||||
|
const selectedUploadImage = computed(() => (selectedPokemonImage.value?.source === 'upload' ? selectedPokemonImage.value : null));
|
||||||
|
|
||||||
function toIds(values: string[]): number[] {
|
function toIds(values: string[]): number[] {
|
||||||
return values.map(Number).filter((item) => Number.isInteger(item) && item > 0);
|
return values.map(Number).filter((item) => Number.isInteger(item) && item > 0);
|
||||||
@@ -287,6 +293,7 @@ async function loadEditor() {
|
|||||||
};
|
};
|
||||||
currentPokemonImage.value = pokemon.image;
|
currentPokemonImage.value = pokemon.image;
|
||||||
imageOptions.value = pokemon.image ? [pokemon.image] : [];
|
imageOptions.value = pokemon.image ? [pokemon.image] : [];
|
||||||
|
imageHistory.value = pokemon.imageHistory;
|
||||||
syncSkillItemDrops();
|
syncSkillItemDrops();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -394,12 +401,15 @@ function fetchPokemonFromInput() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function pokemonImageLabel(image: PokemonImage) {
|
function pokemonImageLabel(image: PokemonImage) {
|
||||||
|
if (image.source === 'upload') {
|
||||||
|
return t('media.uploadedImage');
|
||||||
|
}
|
||||||
return `${image.version} - ${image.variant}`;
|
return `${image.version} - ${image.variant}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function pokemonImageAlt(image: PokemonImage) {
|
function pokemonImageAlt(image: PokemonImage) {
|
||||||
const name = pokemonForm.value.name.trim() || (pokemonForm.value.id.trim() ? `#${pokemonForm.value.id.trim()}` : t('pages.pokemon.title'));
|
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) {
|
function selectPokemonImage(image: PokemonImage) {
|
||||||
@@ -412,6 +422,27 @@ function clearPokemonImage() {
|
|||||||
currentPokemonImage.value = null;
|
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() {
|
async function fetchPokemonImages() {
|
||||||
const identifier = fetchIdentifier.value.trim() || pokemonForm.value.id.trim();
|
const identifier = fetchIdentifier.value.trim() || pokemonForm.value.id.trim();
|
||||||
if (!identifier) {
|
if (!identifier) {
|
||||||
@@ -716,6 +747,21 @@ watch(fetchIdentifier, refreshFetchOptions);
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p v-else class="meta-line">{{ t('pages.pokemon.imageEmpty') }}</p>
|
<p v-else class="meta-line">{{ t('pages.pokemon.imageEmpty') }}</p>
|
||||||
|
|
||||||
|
<ImageUploadField
|
||||||
|
v-model="pokemonForm.imagePath"
|
||||||
|
entity-type="pokemon"
|
||||||
|
:entity-id="isEditing ? routeId : null"
|
||||||
|
:entity-name="imageEntityName"
|
||||||
|
:label="t('media.imageHistory')"
|
||||||
|
:current-image="selectedUploadImage"
|
||||||
|
:history="imageHistory"
|
||||||
|
:disabled="busy || imageBusy"
|
||||||
|
:show-preview="false"
|
||||||
|
@selected="handleUploadImageSelected"
|
||||||
|
@uploaded="handleUploadImageUploaded"
|
||||||
|
@error="message = $event"
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section v-else class="pokemon-edit-panel" role="tabpanel" :aria-label="t('pages.pokemon.editTabAdvance')">
|
<section v-else class="pokemon-edit-panel" role="tabpanel" :aria-label="t('pages.pokemon.editTabAdvance')">
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { Icon } from '@iconify/vue';
|
|||||||
import { computed, onMounted, ref, watch } from 'vue';
|
import { computed, onMounted, ref, watch } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
import EditMeta from '../components/EditMeta.vue';
|
|
||||||
import EntityCard from '../components/EntityCard.vue';
|
import EntityCard from '../components/EntityCard.vue';
|
||||||
import FilterPanel from '../components/FilterPanel.vue';
|
import FilterPanel from '../components/FilterPanel.vue';
|
||||||
import PageHeader from '../components/PageHeader.vue';
|
import PageHeader from '../components/PageHeader.vue';
|
||||||
@@ -46,8 +45,8 @@ function recipeTarget(item: Item) {
|
|||||||
return item.recipe ? `/recipes/${item.recipe.id}` : undefined;
|
return item.recipe ? `/recipes/${item.recipe.id}` : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function itemSubtitle(item: Item) {
|
function recipeCardImage(item: Item) {
|
||||||
return item.usage ? `${item.category.name} · ${item.usage.name}` : item.category.name;
|
return item.image ? { src: item.image.url, alt: t('media.imageAlt', { name: item.name }) } : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createRecipeTarget(item: Item) {
|
function createRecipeTarget(item: Item) {
|
||||||
@@ -132,31 +131,55 @@ watch(itemQuery, loadItems);
|
|||||||
</div>
|
</div>
|
||||||
</FilterPanel>
|
</FilterPanel>
|
||||||
|
|
||||||
<div v-if="loading" class="entity-grid" aria-busy="true" :aria-label="t('pages.recipes.loadingList')">
|
<div v-if="loading" class="entity-grid catalog-card-grid" aria-busy="true" :aria-label="t('pages.recipes.loadingList')">
|
||||||
<article v-for="index in skeletonCardCount" :key="`recipe-skeleton-${index}`" class="entity-card entity-card--skeleton">
|
<article v-for="index in skeletonCardCount" :key="`recipe-skeleton-${index}`" class="entity-card entity-card--skeleton">
|
||||||
<Skeleton variant="box" width="42px" height="42px" class="skeleton-entity-mark" />
|
<Skeleton variant="box" width="92px" height="92px" class="skeleton-entity-mark" />
|
||||||
<div class="entity-card__content">
|
<div class="entity-card__content">
|
||||||
<Skeleton width="72%" height="24px" />
|
<Skeleton width="128px" height="24px" />
|
||||||
<Skeleton width="52%" />
|
<Skeleton variant="box" width="132px" height="36px" />
|
||||||
<Skeleton width="64%" />
|
<Skeleton width="92px" />
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="entity-grid">
|
<div v-else class="entity-grid catalog-card-grid">
|
||||||
<EntityCard
|
<EntityCard
|
||||||
v-for="item in items"
|
v-for="item in items"
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
:title="item.name"
|
:title="item.name"
|
||||||
:subtitle="itemSubtitle(item)"
|
:subtitle="item.category.name"
|
||||||
:to="recipeTarget(item)"
|
:to="recipeTarget(item)"
|
||||||
:icon="itemIcon(item)"
|
:icon="itemIcon(item)"
|
||||||
|
:image="recipeCardImage(item)"
|
||||||
|
:ribbon="item.usage?.name"
|
||||||
|
>
|
||||||
|
<template #after-title>
|
||||||
|
<span
|
||||||
|
v-if="item.recipe"
|
||||||
|
class="ui-button ui-button--primary ui-button--small catalog-card-action catalog-card-action--hidden"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<Icon :icon="iconAdd" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ t('pages.items.createRecipe') }}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
v-else-if="item.noRecipe"
|
||||||
|
class="ui-button ui-button--primary ui-button--small catalog-card-action"
|
||||||
|
type="button"
|
||||||
|
disabled
|
||||||
|
>
|
||||||
|
<Icon :icon="iconAdd" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ t('pages.items.createRecipe') }}
|
||||||
|
</button>
|
||||||
|
<RouterLink
|
||||||
|
v-else
|
||||||
|
class="ui-button ui-button--primary ui-button--small catalog-card-action"
|
||||||
|
:to="createRecipeTarget(item)"
|
||||||
>
|
>
|
||||||
<EditMeta v-if="item.recipe" :entity="item.recipe" />
|
|
||||||
<RouterLink v-else-if="!item.noRecipe" class="ui-button ui-button--primary ui-button--small" :to="createRecipeTarget(item)">
|
|
||||||
<Icon :icon="iconAdd" class="ui-icon" aria-hidden="true" />
|
<Icon :icon="iconAdd" class="ui-icon" aria-hidden="true" />
|
||||||
{{ t('pages.items.createRecipe') }}
|
{{ t('pages.items.createRecipe') }}
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
</template>
|
||||||
</EntityCard>
|
</EntityCard>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
172
pnpm-lock.yaml
generated
172
pnpm-lock.yaml
generated
@@ -13,6 +13,12 @@ importers:
|
|||||||
'@fastify/cors':
|
'@fastify/cors':
|
||||||
specifier: latest
|
specifier: latest
|
||||||
version: 11.2.0
|
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:
|
fastify:
|
||||||
specifier: latest
|
specifier: latest
|
||||||
version: 5.8.5
|
version: 5.8.5
|
||||||
@@ -258,12 +264,21 @@ packages:
|
|||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
|
'@fastify/accept-negotiator@2.0.1':
|
||||||
|
resolution: {integrity: sha512-/c/TW2bO/v9JeEgoD/g1G5GxGeCF1Hafdf79WPmUlgYiBXummY0oX3VVq4yFkKKVBKDNlaDUYoab7g38RpPqCQ==}
|
||||||
|
|
||||||
'@fastify/ajv-compiler@4.0.5':
|
'@fastify/ajv-compiler@4.0.5':
|
||||||
resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==}
|
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':
|
'@fastify/cors@11.2.0':
|
||||||
resolution: {integrity: sha512-LbLHBuSAdGdSFZYTLVA3+Ch2t+sA6nq3Ejc6XLAKiQ6ViS2qFnvicpj0htsx03FyYeLs04HfRNBsz/a8SvbcUw==}
|
resolution: {integrity: sha512-LbLHBuSAdGdSFZYTLVA3+Ch2t+sA6nq3Ejc6XLAKiQ6ViS2qFnvicpj0htsx03FyYeLs04HfRNBsz/a8SvbcUw==}
|
||||||
|
|
||||||
|
'@fastify/deepmerge@3.2.1':
|
||||||
|
resolution: {integrity: sha512-N5Oqvltoa2r9z1tbx4xjky0oRR60v+T47Ic4J1ukoVQcptLOrIdRnCSdTGmOmajZuHVKlTnfcmrjyqsGEW1ztA==}
|
||||||
|
|
||||||
'@fastify/error@4.2.0':
|
'@fastify/error@4.2.0':
|
||||||
resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==}
|
resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==}
|
||||||
|
|
||||||
@@ -276,9 +291,18 @@ packages:
|
|||||||
'@fastify/merge-json-schemas@0.2.1':
|
'@fastify/merge-json-schemas@0.2.1':
|
||||||
resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==}
|
resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==}
|
||||||
|
|
||||||
|
'@fastify/multipart@10.0.0':
|
||||||
|
resolution: {integrity: sha512-pUx3Z1QStY7E7kwvDTIvB6P+rF5lzP+iqPgZyJyG3yBJVPvQaZxzDHYbQD89rbY0ciXrMOyGi8ezHDVexLvJDA==}
|
||||||
|
|
||||||
'@fastify/proxy-addr@5.1.0':
|
'@fastify/proxy-addr@5.1.0':
|
||||||
resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==}
|
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':
|
'@iconify/types@2.0.0':
|
||||||
resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==}
|
resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==}
|
||||||
|
|
||||||
@@ -319,6 +343,10 @@ packages:
|
|||||||
'@jridgewell/trace-mapping@0.3.31':
|
'@jridgewell/trace-mapping@0.3.31':
|
||||||
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
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':
|
'@napi-rs/wasm-runtime@1.1.4':
|
||||||
resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==}
|
resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -603,9 +631,17 @@ packages:
|
|||||||
avvio@9.2.0:
|
avvio@9.2.0:
|
||||||
resolution: {integrity: sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ==}
|
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:
|
birpc@2.9.0:
|
||||||
resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==}
|
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:
|
chai@6.2.2:
|
||||||
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
|
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -620,6 +656,10 @@ packages:
|
|||||||
confbox@0.2.4:
|
confbox@0.2.4:
|
||||||
resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==}
|
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:
|
convert-source-map@2.0.0:
|
||||||
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
|
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
|
||||||
|
|
||||||
@@ -630,6 +670,10 @@ packages:
|
|||||||
csstype@3.2.3:
|
csstype@3.2.3:
|
||||||
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
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:
|
dequal@2.0.3:
|
||||||
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
|
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@@ -650,6 +694,9 @@ packages:
|
|||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
escape-html@1.0.3:
|
||||||
|
resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==}
|
||||||
|
|
||||||
estree-walker@2.0.2:
|
estree-walker@2.0.2:
|
||||||
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
|
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
|
||||||
|
|
||||||
@@ -708,9 +755,20 @@ packages:
|
|||||||
get-tsconfig@4.14.0:
|
get-tsconfig@4.14.0:
|
||||||
resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==}
|
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:
|
hookable@5.5.3:
|
||||||
resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==}
|
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:
|
ipaddr.js@2.3.0:
|
||||||
resolution: {integrity: sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==}
|
resolution: {integrity: sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
@@ -812,6 +870,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==}
|
resolution: {integrity: sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==}
|
||||||
engines: {node: '>=14'}
|
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:
|
magic-string-ast@1.0.3:
|
||||||
resolution: {integrity: sha512-CvkkH1i81zl7mmb94DsRiFeG9V2fR2JeuK8yDgS8oiZSFa++wWLEgZ5ufEOyLHbvSbD1gTRKv9NdX69Rnvr9JA==}
|
resolution: {integrity: sha512-CvkkH1i81zl7mmb94DsRiFeG9V2fR2JeuK8yDgS8oiZSFa++wWLEgZ5ufEOyLHbvSbD1gTRKv9NdX69Rnvr9JA==}
|
||||||
engines: {node: '>=20.19.0'}
|
engines: {node: '>=20.19.0'}
|
||||||
@@ -819,6 +881,19 @@ packages:
|
|||||||
magic-string@0.30.21:
|
magic-string@0.30.21:
|
||||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
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:
|
mlly@1.8.2:
|
||||||
resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==}
|
resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==}
|
||||||
|
|
||||||
@@ -840,6 +915,10 @@ packages:
|
|||||||
path-browserify@1.0.1:
|
path-browserify@1.0.1:
|
||||||
resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==}
|
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:
|
pathe@2.0.3:
|
||||||
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
|
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
|
||||||
|
|
||||||
@@ -988,6 +1067,9 @@ packages:
|
|||||||
set-cookie-parser@2.7.2:
|
set-cookie-parser@2.7.2:
|
||||||
resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==}
|
resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==}
|
||||||
|
|
||||||
|
setprototypeof@1.2.0:
|
||||||
|
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
|
||||||
|
|
||||||
siginfo@2.0.0:
|
siginfo@2.0.0:
|
||||||
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
|
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
|
||||||
|
|
||||||
@@ -1005,6 +1087,10 @@ packages:
|
|||||||
stackback@0.0.2:
|
stackback@0.0.2:
|
||||||
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
|
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:
|
std-env@4.1.0:
|
||||||
resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==}
|
resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==}
|
||||||
|
|
||||||
@@ -1031,6 +1117,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==}
|
resolution: {integrity: sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
toidentifier@1.0.1:
|
||||||
|
resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
|
||||||
|
engines: {node: '>=0.6'}
|
||||||
|
|
||||||
tslib@2.8.1:
|
tslib@2.8.1:
|
||||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||||
|
|
||||||
@@ -1314,17 +1404,23 @@ snapshots:
|
|||||||
'@esbuild/win32-x64@0.27.7':
|
'@esbuild/win32-x64@0.27.7':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@fastify/accept-negotiator@2.0.1': {}
|
||||||
|
|
||||||
'@fastify/ajv-compiler@4.0.5':
|
'@fastify/ajv-compiler@4.0.5':
|
||||||
dependencies:
|
dependencies:
|
||||||
ajv: 8.20.0
|
ajv: 8.20.0
|
||||||
ajv-formats: 3.0.1(ajv@8.20.0)
|
ajv-formats: 3.0.1(ajv@8.20.0)
|
||||||
fast-uri: 3.1.0
|
fast-uri: 3.1.0
|
||||||
|
|
||||||
|
'@fastify/busboy@3.2.0': {}
|
||||||
|
|
||||||
'@fastify/cors@11.2.0':
|
'@fastify/cors@11.2.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
fastify-plugin: 5.1.0
|
fastify-plugin: 5.1.0
|
||||||
toad-cache: 3.7.0
|
toad-cache: 3.7.0
|
||||||
|
|
||||||
|
'@fastify/deepmerge@3.2.1': {}
|
||||||
|
|
||||||
'@fastify/error@4.2.0': {}
|
'@fastify/error@4.2.0': {}
|
||||||
|
|
||||||
'@fastify/fast-json-stringify-compiler@5.0.3':
|
'@fastify/fast-json-stringify-compiler@5.0.3':
|
||||||
@@ -1337,11 +1433,36 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
dequal: 2.0.3
|
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':
|
'@fastify/proxy-addr@5.1.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@fastify/forwarded': 3.0.1
|
'@fastify/forwarded': 3.0.1
|
||||||
ipaddr.js: 2.3.0
|
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/types@2.0.0': {}
|
||||||
|
|
||||||
'@iconify/vue@5.0.0(vue@3.5.33(typescript@6.0.3))':
|
'@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/resolve-uri': 3.1.2
|
||||||
'@jridgewell/sourcemap-codec': 1.5.5
|
'@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)':
|
'@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@emnapi/core': 1.10.0
|
'@emnapi/core': 1.10.0
|
||||||
@@ -1665,8 +1788,14 @@ snapshots:
|
|||||||
'@fastify/error': 4.2.0
|
'@fastify/error': 4.2.0
|
||||||
fastq: 1.20.1
|
fastq: 1.20.1
|
||||||
|
|
||||||
|
balanced-match@4.0.4: {}
|
||||||
|
|
||||||
birpc@2.9.0: {}
|
birpc@2.9.0: {}
|
||||||
|
|
||||||
|
brace-expansion@5.0.5:
|
||||||
|
dependencies:
|
||||||
|
balanced-match: 4.0.4
|
||||||
|
|
||||||
chai@6.2.2: {}
|
chai@6.2.2: {}
|
||||||
|
|
||||||
chokidar@5.0.0:
|
chokidar@5.0.0:
|
||||||
@@ -1677,12 +1806,16 @@ snapshots:
|
|||||||
|
|
||||||
confbox@0.2.4: {}
|
confbox@0.2.4: {}
|
||||||
|
|
||||||
|
content-disposition@1.1.0: {}
|
||||||
|
|
||||||
convert-source-map@2.0.0: {}
|
convert-source-map@2.0.0: {}
|
||||||
|
|
||||||
cookie@1.1.1: {}
|
cookie@1.1.1: {}
|
||||||
|
|
||||||
csstype@3.2.3: {}
|
csstype@3.2.3: {}
|
||||||
|
|
||||||
|
depd@2.0.0: {}
|
||||||
|
|
||||||
dequal@2.0.3: {}
|
dequal@2.0.3: {}
|
||||||
|
|
||||||
detect-libc@2.1.2: {}
|
detect-libc@2.1.2: {}
|
||||||
@@ -1720,6 +1853,8 @@ snapshots:
|
|||||||
'@esbuild/win32-ia32': 0.27.7
|
'@esbuild/win32-ia32': 0.27.7
|
||||||
'@esbuild/win32-x64': 0.27.7
|
'@esbuild/win32-x64': 0.27.7
|
||||||
|
|
||||||
|
escape-html@1.0.3: {}
|
||||||
|
|
||||||
estree-walker@2.0.2: {}
|
estree-walker@2.0.2: {}
|
||||||
|
|
||||||
estree-walker@3.0.3:
|
estree-walker@3.0.3:
|
||||||
@@ -1790,8 +1925,24 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
resolve-pkg-maps: 1.0.0
|
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: {}
|
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: {}
|
ipaddr.js@2.3.0: {}
|
||||||
|
|
||||||
jsesc@3.1.0: {}
|
jsesc@3.1.0: {}
|
||||||
@@ -1865,6 +2016,8 @@ snapshots:
|
|||||||
pkg-types: 2.3.1
|
pkg-types: 2.3.1
|
||||||
quansync: 0.2.11
|
quansync: 0.2.11
|
||||||
|
|
||||||
|
lru-cache@11.3.5: {}
|
||||||
|
|
||||||
magic-string-ast@1.0.3:
|
magic-string-ast@1.0.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
magic-string: 0.30.21
|
magic-string: 0.30.21
|
||||||
@@ -1873,6 +2026,14 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/sourcemap-codec': 1.5.5
|
'@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:
|
mlly@1.8.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
acorn: 8.16.0
|
acorn: 8.16.0
|
||||||
@@ -1890,6 +2051,11 @@ snapshots:
|
|||||||
|
|
||||||
path-browserify@1.0.1: {}
|
path-browserify@1.0.1: {}
|
||||||
|
|
||||||
|
path-scurry@2.0.2:
|
||||||
|
dependencies:
|
||||||
|
lru-cache: 11.3.5
|
||||||
|
minipass: 7.1.3
|
||||||
|
|
||||||
pathe@2.0.3: {}
|
pathe@2.0.3: {}
|
||||||
|
|
||||||
perfect-debounce@2.1.0: {}
|
perfect-debounce@2.1.0: {}
|
||||||
@@ -2038,6 +2204,8 @@ snapshots:
|
|||||||
|
|
||||||
set-cookie-parser@2.7.2: {}
|
set-cookie-parser@2.7.2: {}
|
||||||
|
|
||||||
|
setprototypeof@1.2.0: {}
|
||||||
|
|
||||||
siginfo@2.0.0: {}
|
siginfo@2.0.0: {}
|
||||||
|
|
||||||
sonic-boom@4.2.1:
|
sonic-boom@4.2.1:
|
||||||
@@ -2050,6 +2218,8 @@ snapshots:
|
|||||||
|
|
||||||
stackback@0.0.2: {}
|
stackback@0.0.2: {}
|
||||||
|
|
||||||
|
statuses@2.0.2: {}
|
||||||
|
|
||||||
std-env@4.1.0: {}
|
std-env@4.1.0: {}
|
||||||
|
|
||||||
thread-stream@4.0.0:
|
thread-stream@4.0.0:
|
||||||
@@ -2069,6 +2239,8 @@ snapshots:
|
|||||||
|
|
||||||
toad-cache@3.7.0: {}
|
toad-cache@3.7.0: {}
|
||||||
|
|
||||||
|
toidentifier@1.0.1: {}
|
||||||
|
|
||||||
tslib@2.8.1:
|
tslib@2.8.1:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
|||||||
@@ -506,6 +506,21 @@ export const systemWordingMessages = {
|
|||||||
delete: 'Delete',
|
delete: 'Delete',
|
||||||
empty: 'No edit history'
|
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: {
|
discussion: {
|
||||||
title: 'Discussion',
|
title: 'Discussion',
|
||||||
count: '{count} comments',
|
count: '{count} comments',
|
||||||
@@ -575,6 +590,12 @@ export const systemWordingMessages = {
|
|||||||
pokemonTypeDataUnavailable: 'Pokemon type data is unavailable',
|
pokemonTypeDataUnavailable: 'Pokemon type data is unavailable',
|
||||||
pokemonDataNotFound: 'Pokemon data was not found',
|
pokemonDataNotFound: 'Pokemon data was not found',
|
||||||
pokemonImagePathInvalid: 'Pokemon image path is invalid',
|
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',
|
taskRequired: 'Please enter a task',
|
||||||
selectTask: 'Please select a task',
|
selectTask: 'Please select a task',
|
||||||
taskDoesNotExist: 'Task does not exist',
|
taskDoesNotExist: 'Task does not exist',
|
||||||
@@ -1132,6 +1153,21 @@ export const systemWordingMessages = {
|
|||||||
delete: '删除',
|
delete: '删除',
|
||||||
empty: '暂无编辑历史'
|
empty: '暂无编辑历史'
|
||||||
},
|
},
|
||||||
|
media: {
|
||||||
|
image: '图片',
|
||||||
|
imageAlt: '{name}图片',
|
||||||
|
uploadImage: '上传图片',
|
||||||
|
uploading: '上传中',
|
||||||
|
uploadedImage: '上传图片',
|
||||||
|
selectedImage: '已选择图片',
|
||||||
|
imageHistory: '图片历史',
|
||||||
|
imageOptions: '图片选项',
|
||||||
|
clearImage: '清除图片',
|
||||||
|
imageEmpty: '尚未选择图片',
|
||||||
|
imageHistoryEmpty: '暂无上传图片',
|
||||||
|
uploadFailed: '图片上传失败',
|
||||||
|
selectImage: '选择图片'
|
||||||
|
},
|
||||||
discussion: {
|
discussion: {
|
||||||
title: '讨论',
|
title: '讨论',
|
||||||
count: '{count} 条评论',
|
count: '{count} 条评论',
|
||||||
@@ -1201,6 +1237,12 @@ export const systemWordingMessages = {
|
|||||||
pokemonTypeDataUnavailable: 'Pokemon 属性数据不可用',
|
pokemonTypeDataUnavailable: 'Pokemon 属性数据不可用',
|
||||||
pokemonDataNotFound: '未找到 Pokemon 数据',
|
pokemonDataNotFound: '未找到 Pokemon 数据',
|
||||||
pokemonImagePathInvalid: 'Pokemon 图片路径不合法',
|
pokemonImagePathInvalid: 'Pokemon 图片路径不合法',
|
||||||
|
imagePathInvalid: '图片路径不合法',
|
||||||
|
imageUploadRequired: '请选择图片',
|
||||||
|
imageUploadTypeInvalid: '不支持这种图片类型',
|
||||||
|
imageUploadContentInvalid: '图片文件不合法',
|
||||||
|
imageUploadEntityNameRequired: '请先输入名称再上传图片',
|
||||||
|
imageUploadFailed: '图片上传失败',
|
||||||
taskRequired: '请输入任务',
|
taskRequired: '请输入任务',
|
||||||
selectTask: '请选择任务',
|
selectTask: '请选择任务',
|
||||||
taskDoesNotExist: '任务不存在',
|
taskDoesNotExist: '任务不存在',
|
||||||
|
|||||||
Reference in New Issue
Block a user