feat: add custom sorting for all major entities
Add sort_order column to pokemon, items, recipes, habitats, and configs Implement drag-and-drop reordering in the admin interface Update API endpoints and database queries to respect the new sort order
This commit is contained in:
@@ -65,6 +65,10 @@ Pokemon 可配置:
|
||||
- 配方(物品,数量)
|
||||
- 可出现的宝可梦(可多选)
|
||||
|
||||
列表顺序:
|
||||
- 全局配置项、Pokemon、物品、材料单、地图、栖息地均可自定义排序
|
||||
- 初始排序按创建时间旧到新
|
||||
|
||||
出现契机
|
||||
- 时间:早晨 / 中午 / 傍晚 / 晚上
|
||||
- 天气:晴天 / 阴天 / 雨天
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
CREATE TABLE IF NOT EXISTS environments (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
name text NOT NULL UNIQUE
|
||||
name text NOT NULL UNIQUE,
|
||||
sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS languages (
|
||||
@@ -100,7 +101,8 @@ CREATE INDEX IF NOT EXISTS daily_checklist_items_sort_order_idx
|
||||
CREATE TABLE IF NOT EXISTS skills (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
name text NOT NULL UNIQUE,
|
||||
has_item_drop boolean NOT NULL DEFAULT false
|
||||
has_item_drop boolean NOT NULL DEFAULT false,
|
||||
sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0)
|
||||
);
|
||||
|
||||
ALTER TABLE skills DROP COLUMN IF EXISTS subcategory;
|
||||
@@ -109,13 +111,15 @@ CREATE UNIQUE INDEX IF NOT EXISTS skills_name_key ON skills(name);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS favorite_things (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
name text NOT NULL UNIQUE
|
||||
name text NOT NULL UNIQUE,
|
||||
sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS pokemon (
|
||||
id integer PRIMARY KEY,
|
||||
name text NOT NULL UNIQUE,
|
||||
environment_id integer NOT NULL REFERENCES environments(id)
|
||||
environment_id integer NOT NULL REFERENCES environments(id),
|
||||
sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS pokemon_skills (
|
||||
@@ -132,17 +136,20 @@ CREATE TABLE IF NOT EXISTS pokemon_favorite_things (
|
||||
|
||||
CREATE TABLE IF NOT EXISTS item_categories (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
name text NOT NULL UNIQUE
|
||||
name text NOT NULL UNIQUE,
|
||||
sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS item_usages (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
name text NOT NULL UNIQUE
|
||||
name text NOT NULL UNIQUE,
|
||||
sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS acquisition_methods (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
name text NOT NULL UNIQUE
|
||||
name text NOT NULL UNIQUE,
|
||||
sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS items (
|
||||
@@ -153,7 +160,8 @@ CREATE TABLE IF NOT EXISTS items (
|
||||
dyeable boolean NOT NULL DEFAULT false,
|
||||
dual_dyeable 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,
|
||||
sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0)
|
||||
);
|
||||
|
||||
ALTER TABLE items ALTER COLUMN usage_id DROP NOT NULL;
|
||||
@@ -162,7 +170,8 @@ ALTER TABLE items DROP COLUMN IF EXISTS no_habitat;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS recipes (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
item_id integer NOT NULL UNIQUE REFERENCES items(id)
|
||||
item_id integer NOT NULL UNIQUE REFERENCES items(id),
|
||||
sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0)
|
||||
);
|
||||
|
||||
ALTER TABLE recipes ADD COLUMN IF NOT EXISTS item_id integer REFERENCES items(id);
|
||||
@@ -235,12 +244,14 @@ CREATE TABLE IF NOT EXISTS recipe_materials (
|
||||
|
||||
CREATE TABLE IF NOT EXISTS maps (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
name text NOT NULL UNIQUE
|
||||
name text NOT NULL UNIQUE,
|
||||
sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS habitats (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
name text NOT NULL UNIQUE
|
||||
name text NOT NULL UNIQUE,
|
||||
sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS habitat_recipe_items (
|
||||
@@ -264,56 +275,189 @@ ALTER TABLE environments ADD COLUMN IF NOT EXISTS created_by_user_id integer REF
|
||||
ALTER TABLE environments ADD COLUMN IF NOT EXISTS updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
|
||||
ALTER TABLE environments ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now();
|
||||
ALTER TABLE environments ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now();
|
||||
ALTER TABLE environments ADD COLUMN IF NOT EXISTS sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0);
|
||||
|
||||
ALTER TABLE skills ADD COLUMN IF NOT EXISTS created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
|
||||
ALTER TABLE skills ADD COLUMN IF NOT EXISTS updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
|
||||
ALTER TABLE skills ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now();
|
||||
ALTER TABLE skills ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now();
|
||||
ALTER TABLE skills ADD COLUMN IF NOT EXISTS sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0);
|
||||
|
||||
ALTER TABLE favorite_things ADD COLUMN IF NOT EXISTS created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
|
||||
ALTER TABLE favorite_things ADD COLUMN IF NOT EXISTS updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
|
||||
ALTER TABLE favorite_things ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now();
|
||||
ALTER TABLE favorite_things ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now();
|
||||
ALTER TABLE favorite_things ADD COLUMN IF NOT EXISTS sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0);
|
||||
|
||||
ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
|
||||
ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
|
||||
ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now();
|
||||
ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now();
|
||||
ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0);
|
||||
|
||||
ALTER TABLE item_categories ADD COLUMN IF NOT EXISTS created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
|
||||
ALTER TABLE item_categories ADD COLUMN IF NOT EXISTS updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
|
||||
ALTER TABLE item_categories ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now();
|
||||
ALTER TABLE item_categories ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now();
|
||||
ALTER TABLE item_categories ADD COLUMN IF NOT EXISTS sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0);
|
||||
|
||||
ALTER TABLE item_usages ADD COLUMN IF NOT EXISTS created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
|
||||
ALTER TABLE item_usages ADD COLUMN IF NOT EXISTS updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
|
||||
ALTER TABLE item_usages ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now();
|
||||
ALTER TABLE item_usages ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now();
|
||||
ALTER TABLE item_usages ADD COLUMN IF NOT EXISTS sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0);
|
||||
|
||||
ALTER TABLE acquisition_methods ADD COLUMN IF NOT EXISTS created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
|
||||
ALTER TABLE acquisition_methods ADD COLUMN IF NOT EXISTS updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
|
||||
ALTER TABLE acquisition_methods ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now();
|
||||
ALTER TABLE acquisition_methods ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now();
|
||||
ALTER TABLE acquisition_methods ADD COLUMN IF NOT EXISTS sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0);
|
||||
|
||||
ALTER TABLE items ADD COLUMN IF NOT EXISTS created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
|
||||
ALTER TABLE items ADD COLUMN IF NOT EXISTS updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
|
||||
ALTER TABLE items ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now();
|
||||
ALTER TABLE items ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now();
|
||||
ALTER TABLE items ADD COLUMN IF NOT EXISTS sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0);
|
||||
|
||||
ALTER TABLE recipes ADD COLUMN IF NOT EXISTS created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
|
||||
ALTER TABLE recipes ADD COLUMN IF NOT EXISTS updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
|
||||
ALTER TABLE recipes ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now();
|
||||
ALTER TABLE recipes ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now();
|
||||
ALTER TABLE recipes ADD COLUMN IF NOT EXISTS sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0);
|
||||
|
||||
ALTER TABLE maps ADD COLUMN IF NOT EXISTS created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
|
||||
ALTER TABLE maps ADD COLUMN IF NOT EXISTS updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
|
||||
ALTER TABLE maps ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now();
|
||||
ALTER TABLE maps ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now();
|
||||
ALTER TABLE maps ADD COLUMN IF NOT EXISTS sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0);
|
||||
|
||||
ALTER TABLE habitats ADD COLUMN IF NOT EXISTS created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
|
||||
ALTER TABLE habitats ADD COLUMN IF NOT EXISTS updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
|
||||
ALTER TABLE habitats ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now();
|
||||
ALTER TABLE habitats ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now();
|
||||
ALTER TABLE habitats ADD COLUMN IF NOT EXISTS sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0);
|
||||
|
||||
WITH ordered AS (
|
||||
SELECT id, (row_number() OVER (ORDER BY created_at, id) * 10)::integer AS next_sort_order
|
||||
FROM environments
|
||||
WHERE sort_order = 0
|
||||
)
|
||||
UPDATE environments target
|
||||
SET sort_order = ordered.next_sort_order
|
||||
FROM ordered
|
||||
WHERE target.id = ordered.id;
|
||||
|
||||
WITH ordered AS (
|
||||
SELECT id, (row_number() OVER (ORDER BY created_at, id) * 10)::integer AS next_sort_order
|
||||
FROM skills
|
||||
WHERE sort_order = 0
|
||||
)
|
||||
UPDATE skills target
|
||||
SET sort_order = ordered.next_sort_order
|
||||
FROM ordered
|
||||
WHERE target.id = ordered.id;
|
||||
|
||||
WITH ordered AS (
|
||||
SELECT id, (row_number() OVER (ORDER BY created_at, id) * 10)::integer AS next_sort_order
|
||||
FROM favorite_things
|
||||
WHERE sort_order = 0
|
||||
)
|
||||
UPDATE favorite_things target
|
||||
SET sort_order = ordered.next_sort_order
|
||||
FROM ordered
|
||||
WHERE target.id = ordered.id;
|
||||
|
||||
WITH ordered AS (
|
||||
SELECT id, (row_number() OVER (ORDER BY created_at, id) * 10)::integer AS next_sort_order
|
||||
FROM pokemon
|
||||
WHERE sort_order = 0
|
||||
)
|
||||
UPDATE pokemon target
|
||||
SET sort_order = ordered.next_sort_order
|
||||
FROM ordered
|
||||
WHERE target.id = ordered.id;
|
||||
|
||||
WITH ordered AS (
|
||||
SELECT id, (row_number() OVER (ORDER BY created_at, id) * 10)::integer AS next_sort_order
|
||||
FROM item_categories
|
||||
WHERE sort_order = 0
|
||||
)
|
||||
UPDATE item_categories target
|
||||
SET sort_order = ordered.next_sort_order
|
||||
FROM ordered
|
||||
WHERE target.id = ordered.id;
|
||||
|
||||
WITH ordered AS (
|
||||
SELECT id, (row_number() OVER (ORDER BY created_at, id) * 10)::integer AS next_sort_order
|
||||
FROM item_usages
|
||||
WHERE sort_order = 0
|
||||
)
|
||||
UPDATE item_usages target
|
||||
SET sort_order = ordered.next_sort_order
|
||||
FROM ordered
|
||||
WHERE target.id = ordered.id;
|
||||
|
||||
WITH ordered AS (
|
||||
SELECT id, (row_number() OVER (ORDER BY created_at, id) * 10)::integer AS next_sort_order
|
||||
FROM acquisition_methods
|
||||
WHERE sort_order = 0
|
||||
)
|
||||
UPDATE acquisition_methods target
|
||||
SET sort_order = ordered.next_sort_order
|
||||
FROM ordered
|
||||
WHERE target.id = ordered.id;
|
||||
|
||||
WITH ordered AS (
|
||||
SELECT id, (row_number() OVER (ORDER BY created_at, id) * 10)::integer AS next_sort_order
|
||||
FROM items
|
||||
WHERE sort_order = 0
|
||||
)
|
||||
UPDATE items target
|
||||
SET sort_order = ordered.next_sort_order
|
||||
FROM ordered
|
||||
WHERE target.id = ordered.id;
|
||||
|
||||
WITH ordered AS (
|
||||
SELECT id, (row_number() OVER (ORDER BY created_at, id) * 10)::integer AS next_sort_order
|
||||
FROM recipes
|
||||
WHERE sort_order = 0
|
||||
)
|
||||
UPDATE recipes target
|
||||
SET sort_order = ordered.next_sort_order
|
||||
FROM ordered
|
||||
WHERE target.id = ordered.id;
|
||||
|
||||
WITH ordered AS (
|
||||
SELECT id, (row_number() OVER (ORDER BY created_at, id) * 10)::integer AS next_sort_order
|
||||
FROM maps
|
||||
WHERE sort_order = 0
|
||||
)
|
||||
UPDATE maps target
|
||||
SET sort_order = ordered.next_sort_order
|
||||
FROM ordered
|
||||
WHERE target.id = ordered.id;
|
||||
|
||||
WITH ordered AS (
|
||||
SELECT id, (row_number() OVER (ORDER BY created_at, id) * 10)::integer AS next_sort_order
|
||||
FROM habitats
|
||||
WHERE sort_order = 0
|
||||
)
|
||||
UPDATE habitats target
|
||||
SET sort_order = ordered.next_sort_order
|
||||
FROM ordered
|
||||
WHERE target.id = ordered.id;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS environments_sort_order_idx ON environments(sort_order, id);
|
||||
CREATE INDEX IF NOT EXISTS skills_sort_order_idx ON skills(sort_order, id);
|
||||
CREATE INDEX IF NOT EXISTS favorite_things_sort_order_idx ON favorite_things(sort_order, id);
|
||||
CREATE INDEX IF NOT EXISTS pokemon_sort_order_idx ON pokemon(sort_order, id);
|
||||
CREATE INDEX IF NOT EXISTS item_categories_sort_order_idx ON item_categories(sort_order, id);
|
||||
CREATE INDEX IF NOT EXISTS item_usages_sort_order_idx ON item_usages(sort_order, id);
|
||||
CREATE INDEX IF NOT EXISTS acquisition_methods_sort_order_idx ON acquisition_methods(sort_order, id);
|
||||
CREATE INDEX IF NOT EXISTS items_sort_order_idx ON items(sort_order, id);
|
||||
CREATE INDEX IF NOT EXISTS recipes_sort_order_idx ON recipes(sort_order, id);
|
||||
CREATE INDEX IF NOT EXISTS maps_sort_order_idx ON maps(sort_order, id);
|
||||
CREATE INDEX IF NOT EXISTS habitats_sort_order_idx ON habitats(sort_order, id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS wiki_edit_logs (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
|
||||
@@ -37,6 +37,11 @@ type ConfigDefinition = {
|
||||
entityType: EntityType;
|
||||
hasItemDrop?: boolean;
|
||||
};
|
||||
type SortableContentType = 'pokemon' | 'items' | 'recipes' | 'habitats';
|
||||
type SortableContentDefinition = {
|
||||
table: string;
|
||||
entityType: SortableContentType;
|
||||
};
|
||||
|
||||
type IdQuantity = {
|
||||
itemId: number;
|
||||
@@ -157,6 +162,13 @@ const configDefinitions: Record<ConfigType, ConfigDefinition> = {
|
||||
maps: { table: 'maps', entityType: 'maps' }
|
||||
};
|
||||
|
||||
const sortableContentDefinitions: Record<SortableContentType, SortableContentDefinition> = {
|
||||
pokemon: { table: 'pokemon', entityType: 'pokemon' },
|
||||
items: { table: 'items', entityType: 'items' },
|
||||
recipes: { table: 'recipes', entityType: 'recipes' },
|
||||
habitats: { table: 'habitats', entityType: 'habitats' }
|
||||
};
|
||||
|
||||
function asString(value: QueryValue): string | undefined {
|
||||
return Array.isArray(value) ? value[0] : value;
|
||||
}
|
||||
@@ -302,12 +314,12 @@ function optionSelect(
|
||||
locale: string
|
||||
): Promise<Array<{ id: number; name: string }>> {
|
||||
const name = localizedName(entityType, 'o', locale);
|
||||
return query(`SELECT o.id, ${name} AS name FROM ${tableName} o ORDER BY ${name}`);
|
||||
return query(`SELECT o.id, ${name} AS name FROM ${tableName} o ORDER BY ${orderByEntity('o')}`);
|
||||
}
|
||||
|
||||
function skillOptions(locale: string): Promise<Array<{ id: number; name: string; hasItemDrop: boolean }>> {
|
||||
const name = localizedName('skills', 's', locale);
|
||||
return query(`SELECT s.id, ${name} AS name, s.has_item_drop AS "hasItemDrop" FROM skills s ORDER BY ${name}`);
|
||||
return query(`SELECT s.id, ${name} AS name, s.has_item_drop AS "hasItemDrop" FROM skills s ORDER BY ${orderByEntity('s')}`);
|
||||
}
|
||||
|
||||
function auditSelect(entityAlias: string, createdAlias = 'created_user', updatedAlias = 'updated_user'): string {
|
||||
@@ -332,8 +344,8 @@ function auditJoins(entityAlias: string, createdAlias = 'created_user', updatedA
|
||||
`;
|
||||
}
|
||||
|
||||
function configOrder(definition: ConfigDefinition, locale: string): string {
|
||||
return localizedName(definition.entityType, 'c', locale);
|
||||
function configOrder(): string {
|
||||
return orderByEntity('c');
|
||||
}
|
||||
|
||||
function configSelect(definition: ConfigDefinition, locale: string): string {
|
||||
@@ -397,6 +409,10 @@ function cleanOptions(value: unknown, allowedValues: string[]): string[] {
|
||||
return [...new Set(values.map((item) => String(item ?? '')).filter((item) => allowedValues.includes(item)))];
|
||||
}
|
||||
|
||||
function orderByEntity(entityAlias: string): string {
|
||||
return `${entityAlias}.sort_order, ${entityAlias}.id`;
|
||||
}
|
||||
|
||||
async function withTransaction<T>(callback: (client: DbClient) => Promise<T>): Promise<T> {
|
||||
const client = await pool.connect();
|
||||
|
||||
@@ -413,6 +429,42 @@ async function withTransaction<T>(callback: (client: DbClient) => Promise<T>): P
|
||||
}
|
||||
}
|
||||
|
||||
async function nextSortOrder(client: DbClient, tableName: string): Promise<number> {
|
||||
const result = await client.query<{ sortOrder: number }>(
|
||||
`SELECT COALESCE(MAX(sort_order), 0) + 10 AS "sortOrder" FROM ${tableName}`
|
||||
);
|
||||
return result.rows[0]?.sortOrder ?? 10;
|
||||
}
|
||||
|
||||
async function reorderTableRows(
|
||||
client: DbClient,
|
||||
tableName: string,
|
||||
entityType: string,
|
||||
ids: number[],
|
||||
userId: number
|
||||
): Promise<void> {
|
||||
const existing = await client.query<{ id: number }>(
|
||||
`SELECT id FROM ${tableName} WHERE id = ANY($1::integer[])`,
|
||||
[ids]
|
||||
);
|
||||
|
||||
if (existing.rowCount !== ids.length) {
|
||||
throw validationError('Record does not exist');
|
||||
}
|
||||
|
||||
for (const [index, id] of ids.entries()) {
|
||||
await client.query(
|
||||
`
|
||||
UPDATE ${tableName}
|
||||
SET sort_order = $1, updated_by_user_id = $2, updated_at = now()
|
||||
WHERE id = $3
|
||||
`,
|
||||
[(index + 1) * 10, userId, id]
|
||||
);
|
||||
await recordEditLog(client, entityType, id, 'update', userId);
|
||||
}
|
||||
}
|
||||
|
||||
async function recordEditLog(
|
||||
client: DbClient,
|
||||
entityType: string,
|
||||
@@ -813,13 +865,13 @@ function pokemonProjection(locale: string): string {
|
||||
${auditSelect('p', 'pokemon_created_user', 'pokemon_updated_user')},
|
||||
json_build_object('id', e.id, 'name', ${environmentName}) AS environment,
|
||||
COALESCE((
|
||||
SELECT json_agg(json_build_object('id', s.id, 'name', ${skillName}, 'hasItemDrop', s.has_item_drop) ORDER BY ${skillName})
|
||||
SELECT json_agg(json_build_object('id', s.id, 'name', ${skillName}, 'hasItemDrop', s.has_item_drop) ORDER BY ${orderByEntity('s')})
|
||||
FROM pokemon_skills ps
|
||||
JOIN skills s ON s.id = ps.skill_id
|
||||
WHERE ps.pokemon_id = p.id
|
||||
), '[]'::json) AS skills,
|
||||
COALESCE((
|
||||
SELECT json_agg(json_build_object('id', ft.id, 'name', ${favoriteThingName}) ORDER BY ${favoriteThingName})
|
||||
SELECT json_agg(json_build_object('id', ft.id, 'name', ${favoriteThingName}) ORDER BY ${orderByEntity('ft')})
|
||||
FROM pokemon_favorite_things pft
|
||||
JOIN favorite_things ft ON ft.id = pft.favorite_thing_id
|
||||
WHERE pft.pokemon_id = p.id
|
||||
@@ -1004,7 +1056,7 @@ export async function listConfig(type: ConfigType, locale = defaultLocale) {
|
||||
SELECT ${configSelect(definition, locale)}, ${auditSelect('c')}
|
||||
FROM ${definition.table} c
|
||||
${auditJoins('c')}
|
||||
ORDER BY ${configOrder(definition, locale)}
|
||||
ORDER BY ${configOrder()}
|
||||
`
|
||||
);
|
||||
}
|
||||
@@ -1029,22 +1081,23 @@ export async function createConfig(type: ConfigType, payload: Record<string, unk
|
||||
const hasItemDrop = definition.hasItemDrop ? Boolean(payload.hasItemDrop) : false;
|
||||
|
||||
const id = await withTransaction(async (client) => {
|
||||
const sortOrder = await nextSortOrder(client, definition.table);
|
||||
const result = definition.hasItemDrop
|
||||
? await client.query<{ id: number }>(
|
||||
`
|
||||
INSERT INTO ${definition.table} (name, has_item_drop, created_by_user_id, updated_by_user_id)
|
||||
VALUES ($1, $2, $3, $3)
|
||||
INSERT INTO ${definition.table} (name, has_item_drop, sort_order, created_by_user_id, updated_by_user_id)
|
||||
VALUES ($1, $2, $3, $4, $4)
|
||||
RETURNING id
|
||||
`,
|
||||
[name, hasItemDrop, userId]
|
||||
[name, hasItemDrop, sortOrder, userId]
|
||||
)
|
||||
: await client.query<{ id: number }>(
|
||||
`
|
||||
INSERT INTO ${definition.table} (name, created_by_user_id, updated_by_user_id)
|
||||
VALUES ($1, $2, $2)
|
||||
INSERT INTO ${definition.table} (name, sort_order, created_by_user_id, updated_by_user_id)
|
||||
VALUES ($1, $2, $3, $3)
|
||||
RETURNING id
|
||||
`,
|
||||
[name, userId]
|
||||
[name, sortOrder, userId]
|
||||
);
|
||||
|
||||
const createdId = result.rows[0].id;
|
||||
@@ -1056,6 +1109,20 @@ export async function createConfig(type: ConfigType, payload: Record<string, unk
|
||||
return getConfigById(type, id, locale);
|
||||
}
|
||||
|
||||
export async function reorderConfig(type: ConfigType, payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
|
||||
const definition = configDefinitions[type];
|
||||
const ids = cleanIds(payload.ids);
|
||||
if (ids.length === 0) {
|
||||
throw validationError('Please select a record');
|
||||
}
|
||||
|
||||
await withTransaction(async (client) => {
|
||||
await reorderTableRows(client, definition.table, type, ids, userId);
|
||||
});
|
||||
|
||||
return listConfig(type, locale);
|
||||
}
|
||||
|
||||
export async function updateConfig(
|
||||
type: ConfigType,
|
||||
id: number,
|
||||
@@ -1117,6 +1184,38 @@ export async function deleteConfig(type: ConfigType, id: number, userId: number)
|
||||
});
|
||||
}
|
||||
|
||||
async function reorderContent(type: SortableContentType, payload: Record<string, unknown>, userId: number): Promise<void> {
|
||||
const definition = sortableContentDefinitions[type];
|
||||
const ids = cleanIds(payload.ids);
|
||||
if (ids.length === 0) {
|
||||
throw validationError('Please select a record');
|
||||
}
|
||||
|
||||
await withTransaction(async (client) => {
|
||||
await reorderTableRows(client, definition.table, definition.entityType, ids, userId);
|
||||
});
|
||||
}
|
||||
|
||||
export async function reorderPokemon(payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
|
||||
await reorderContent('pokemon', payload, userId);
|
||||
return listPokemon({}, locale);
|
||||
}
|
||||
|
||||
export async function reorderItems(payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
|
||||
await reorderContent('items', payload, userId);
|
||||
return listItems({}, locale);
|
||||
}
|
||||
|
||||
export async function reorderRecipes(payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
|
||||
await reorderContent('recipes', payload, userId);
|
||||
return listRecipes({}, locale);
|
||||
}
|
||||
|
||||
export async function reorderHabitats(payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
|
||||
await reorderContent('habitats', payload, userId);
|
||||
return listHabitats(locale);
|
||||
}
|
||||
|
||||
export async function listPokemon(paramsQuery: QueryParams, locale = defaultLocale) {
|
||||
const params: unknown[] = [];
|
||||
const conditions: string[] = [];
|
||||
@@ -1162,7 +1261,7 @@ export async function listPokemon(paramsQuery: QueryParams, locale = defaultLoca
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
return query(`${pokemonProjection(locale)} ${whereClause} ORDER BY p.id`, params);
|
||||
return query(`${pokemonProjection(locale)} ${whereClause} ORDER BY ${orderByEntity('p')}`, params);
|
||||
}
|
||||
|
||||
export async function getPokemon(id: number, locale = defaultLocale) {
|
||||
@@ -1191,7 +1290,7 @@ export async function getPokemon(id: number, locale = defaultLocale) {
|
||||
JOIN habitats h ON h.id = hp.habitat_id
|
||||
JOIN maps m ON m.id = hp.map_id
|
||||
WHERE hp.pokemon_id = $1
|
||||
ORDER BY ${habitatName}, hp.rarity, ${mapName}
|
||||
ORDER BY ${orderByEntity('h')}, hp.rarity, ${orderByEntity('m')}
|
||||
`,
|
||||
[id]
|
||||
),
|
||||
@@ -1203,7 +1302,7 @@ export async function getPokemon(id: number, locale = defaultLocale) {
|
||||
JOIN items i ON i.id = psid.item_id
|
||||
WHERE psid.pokemon_id = $1
|
||||
AND s.has_item_drop = true
|
||||
ORDER BY psid.skill_id, ${itemName}
|
||||
ORDER BY ${orderByEntity('s')}, ${orderByEntity('i')}
|
||||
`,
|
||||
[id]
|
||||
),
|
||||
@@ -1213,15 +1312,15 @@ export async function getPokemon(id: number, locale = defaultLocale) {
|
||||
i.id,
|
||||
${itemName} AS name,
|
||||
json_build_object('id', c.id, 'name', ${categoryName}) AS category,
|
||||
json_agg(json_build_object('id', ft.id, 'name', ${tagName}) ORDER BY ${tagName}) AS tags
|
||||
json_agg(json_build_object('id', ft.id, 'name', ${tagName}) ORDER BY ${orderByEntity('ft')}) AS tags
|
||||
FROM pokemon_favorite_things pft
|
||||
JOIN item_favorite_things ift ON ift.favorite_thing_id = pft.favorite_thing_id
|
||||
JOIN favorite_things ft ON ft.id = pft.favorite_thing_id
|
||||
JOIN items i ON i.id = ift.item_id
|
||||
JOIN item_categories c ON c.id = i.category_id
|
||||
WHERE pft.pokemon_id = $1
|
||||
GROUP BY i.id, i.name, c.id, c.name
|
||||
ORDER BY ${categoryName}, ${itemName}
|
||||
GROUP BY i.id, i.name, i.sort_order, c.id, c.name, c.sort_order
|
||||
ORDER BY ${orderByEntity('c')}, ${orderByEntity('i')}
|
||||
`,
|
||||
[id]
|
||||
),
|
||||
@@ -1325,12 +1424,13 @@ export async function createPokemon(payload: Record<string, unknown>, userId: nu
|
||||
const cleanPayload = cleanPokemonPayload(payload);
|
||||
|
||||
const id = await withTransaction(async (client) => {
|
||||
const sortOrder = await nextSortOrder(client, 'pokemon');
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO pokemon (id, name, environment_id, created_by_user_id, updated_by_user_id)
|
||||
VALUES ($1, $2, $3, $4, $4)
|
||||
INSERT INTO pokemon (id, name, environment_id, sort_order, created_by_user_id, updated_by_user_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $5)
|
||||
`,
|
||||
[cleanPayload.id, cleanPayload.name, cleanPayload.environmentId, userId]
|
||||
[cleanPayload.id, cleanPayload.name, cleanPayload.environmentId, sortOrder, userId]
|
||||
);
|
||||
await replacePokemonRelations(client, cleanPayload.id, cleanPayload);
|
||||
await replaceEntityTranslations(client, 'pokemon', cleanPayload.id, cleanPayload.translations, ['name']);
|
||||
@@ -1390,20 +1490,23 @@ export async function listHabitats(locale = defaultLocale) {
|
||||
${translationsSelect('habitats', 'h.id')} AS translations,
|
||||
${auditSelect('h', 'habitat_created_user', 'habitat_updated_user')},
|
||||
COALESCE((
|
||||
SELECT json_agg(json_build_object('id', i.id, 'name', ${itemName}, 'quantity', hri.quantity) ORDER BY ${itemName})
|
||||
SELECT json_agg(json_build_object('id', i.id, 'name', ${itemName}, 'quantity', hri.quantity) ORDER BY ${orderByEntity('i')})
|
||||
FROM habitat_recipe_items hri
|
||||
JOIN items i ON i.id = hri.item_id
|
||||
WHERE hri.habitat_id = h.id
|
||||
), '[]'::json) AS recipe,
|
||||
COALESCE((
|
||||
SELECT json_agg(DISTINCT jsonb_build_object('id', p.id, 'name', ${pokemonName}))
|
||||
FROM habitat_pokemon hp
|
||||
JOIN pokemon p ON p.id = hp.pokemon_id
|
||||
WHERE hp.habitat_id = h.id
|
||||
SELECT json_agg(json_build_object('id', pokemon_rows.id, 'name', pokemon_rows.name) ORDER BY pokemon_rows.sort_order, pokemon_rows.id)
|
||||
FROM (
|
||||
SELECT DISTINCT p.id, ${pokemonName} AS name, p.sort_order
|
||||
FROM habitat_pokemon hp
|
||||
JOIN pokemon p ON p.id = hp.pokemon_id
|
||||
WHERE hp.habitat_id = h.id
|
||||
) pokemon_rows
|
||||
), '[]'::json) AS pokemon
|
||||
FROM habitats h
|
||||
${auditJoins('h', 'habitat_created_user', 'habitat_updated_user')}
|
||||
ORDER BY ${habitatName}
|
||||
ORDER BY ${orderByEntity('h')}
|
||||
`);
|
||||
}
|
||||
|
||||
@@ -1421,7 +1524,7 @@ export async function getHabitat(id: number, locale = defaultLocale) {
|
||||
${translationsSelect('habitats', 'h.id')} AS translations,
|
||||
${auditSelect('h', 'habitat_created_user', 'habitat_updated_user')},
|
||||
COALESCE((
|
||||
SELECT json_agg(json_build_object('id', i.id, 'name', ${itemName}, 'quantity', hri.quantity) ORDER BY ${itemName})
|
||||
SELECT json_agg(json_build_object('id', i.id, 'name', ${itemName}, 'quantity', hri.quantity) ORDER BY ${orderByEntity('i')})
|
||||
FROM habitat_recipe_items hri
|
||||
JOIN items i ON i.id = hri.item_id
|
||||
WHERE hri.habitat_id = h.id
|
||||
@@ -1451,7 +1554,7 @@ export async function getHabitat(id: number, locale = defaultLocale) {
|
||||
JOIN pokemon p ON p.id = hp.pokemon_id
|
||||
JOIN maps m ON m.id = hp.map_id
|
||||
WHERE hp.habitat_id = $1
|
||||
ORDER BY hp.rarity, p.id, ${mapName}
|
||||
ORDER BY hp.rarity, ${orderByEntity('p')}, ${orderByEntity('m')}
|
||||
`,
|
||||
[id]
|
||||
),
|
||||
@@ -1527,13 +1630,14 @@ export async function createHabitat(payload: Record<string, unknown>, userId: nu
|
||||
const cleanPayload = cleanHabitatPayload(payload);
|
||||
|
||||
const id = await withTransaction(async (client) => {
|
||||
const sortOrder = await nextSortOrder(client, 'habitats');
|
||||
const result = await client.query<{ id: number }>(
|
||||
`
|
||||
INSERT INTO habitats (name, created_by_user_id, updated_by_user_id)
|
||||
VALUES ($1, $2, $2)
|
||||
INSERT INTO habitats (name, sort_order, created_by_user_id, updated_by_user_id)
|
||||
VALUES ($1, $2, $3, $3)
|
||||
RETURNING id
|
||||
`,
|
||||
[cleanPayload.name, userId]
|
||||
[cleanPayload.name, sortOrder, userId]
|
||||
);
|
||||
const habitatId = result.rows[0].id;
|
||||
await replaceHabitatRelations(client, habitatId, cleanPayload);
|
||||
@@ -1599,7 +1703,7 @@ function itemProjection(locale: string): string {
|
||||
) AS customization,
|
||||
i.no_recipe AS "noRecipe",
|
||||
COALESCE((
|
||||
SELECT json_agg(json_build_object('id', t.id, 'name', ${tagName}) ORDER BY ${tagName})
|
||||
SELECT json_agg(json_build_object('id', t.id, 'name', ${tagName}) ORDER BY ${orderByEntity('t')})
|
||||
FROM item_favorite_things ift
|
||||
JOIN favorite_things t ON t.id = ift.favorite_thing_id
|
||||
WHERE ift.item_id = i.id
|
||||
@@ -1637,6 +1741,7 @@ export async function listItems(paramsQuery: QueryParams, locale = defaultLocale
|
||||
const usageId = Number(asString(paramsQuery.usageId));
|
||||
const tagIds = parseIdList(asString(paramsQuery.tagIds));
|
||||
const search = asString(paramsQuery.search)?.trim();
|
||||
const recipeOrder = asString(paramsQuery.recipeOrder) === '1';
|
||||
|
||||
if (search) {
|
||||
params.push(`%${search}%`);
|
||||
@@ -1667,7 +1772,10 @@ export async function listItems(paramsQuery: QueryParams, locale = defaultLocale
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
return query(`${itemProjection(locale)} ${whereClause} ORDER BY ${localizedName('item-categories', 'c', locale)}, ${localizedName('items', 'i', locale)}`, params);
|
||||
const orderClause = recipeOrder
|
||||
? `ORDER BY CASE WHEN item_recipe.id IS NULL THEN 1 ELSE 0 END, item_recipe.sort_order, item_recipe.id, ${orderByEntity('i')}`
|
||||
: `ORDER BY ${orderByEntity('i')}`;
|
||||
return query(`${itemProjection(locale)} ${whereClause} ${orderClause}`, params);
|
||||
}
|
||||
|
||||
export async function getItem(id: number, locale = defaultLocale) {
|
||||
@@ -1691,7 +1799,7 @@ export async function getItem(id: number, locale = defaultLocale) {
|
||||
FROM item_acquisition_methods iam
|
||||
JOIN acquisition_methods am ON am.id = iam.acquisition_method_id
|
||||
WHERE iam.item_id = $1
|
||||
ORDER BY ${acquisitionMethodName}
|
||||
ORDER BY ${orderByEntity('am')}
|
||||
`,
|
||||
[id]
|
||||
),
|
||||
@@ -1702,13 +1810,13 @@ export async function getItem(id: number, locale = defaultLocale) {
|
||||
${resultItemName} AS name,
|
||||
${auditSelect('r', 'recipe_created_user', 'recipe_updated_user')},
|
||||
COALESCE((
|
||||
SELECT json_agg(json_build_object('id', am.id, 'name', ${acquisitionMethodName}) ORDER BY ${acquisitionMethodName})
|
||||
SELECT json_agg(json_build_object('id', am.id, 'name', ${acquisitionMethodName}) ORDER BY ${orderByEntity('am')})
|
||||
FROM recipe_acquisition_methods ram
|
||||
JOIN acquisition_methods am ON am.id = ram.acquisition_method_id
|
||||
WHERE ram.recipe_id = r.id
|
||||
), '[]'::json) AS acquisition_methods,
|
||||
COALESCE((
|
||||
SELECT json_agg(json_build_object('id', mi.id, 'name', ${materialItemName}, 'quantity', rm.quantity) ORDER BY ${materialItemName})
|
||||
SELECT json_agg(json_build_object('id', mi.id, 'name', ${materialItemName}, 'quantity', rm.quantity) ORDER BY ${orderByEntity('mi')})
|
||||
FROM recipe_materials rm
|
||||
JOIN items mi ON mi.id = rm.item_id
|
||||
WHERE rm.recipe_id = r.id
|
||||
@@ -1727,7 +1835,7 @@ export async function getItem(id: number, locale = defaultLocale) {
|
||||
r.id,
|
||||
${resultItemName} AS name,
|
||||
COALESCE((
|
||||
SELECT json_agg(json_build_object('id', mi.id, 'name', ${materialItemName}, 'quantity', recipe_material.quantity) ORDER BY ${materialItemName})
|
||||
SELECT json_agg(json_build_object('id', mi.id, 'name', ${materialItemName}, 'quantity', recipe_material.quantity) ORDER BY ${orderByEntity('mi')})
|
||||
FROM recipe_materials recipe_material
|
||||
JOIN items mi ON mi.id = recipe_material.item_id
|
||||
WHERE recipe_material.recipe_id = r.id
|
||||
@@ -1736,7 +1844,7 @@ export async function getItem(id: number, locale = defaultLocale) {
|
||||
JOIN recipes r ON r.id = used_material.recipe_id
|
||||
JOIN items result_item ON result_item.id = r.item_id
|
||||
WHERE used_material.item_id = $1
|
||||
ORDER BY ${resultItemName}
|
||||
ORDER BY ${orderByEntity('r')}
|
||||
`,
|
||||
[id]
|
||||
),
|
||||
@@ -1746,7 +1854,7 @@ export async function getItem(id: number, locale = defaultLocale) {
|
||||
h.id,
|
||||
${habitatName} AS name,
|
||||
COALESCE((
|
||||
SELECT json_agg(json_build_object('id', recipe_item.id, 'name', ${recipeItemName}, 'quantity', recipe_item_row.quantity) ORDER BY ${recipeItemName})
|
||||
SELECT json_agg(json_build_object('id', recipe_item.id, 'name', ${recipeItemName}, 'quantity', recipe_item_row.quantity) ORDER BY ${orderByEntity('recipe_item')})
|
||||
FROM habitat_recipe_items recipe_item_row
|
||||
JOIN items recipe_item ON recipe_item.id = recipe_item_row.item_id
|
||||
WHERE recipe_item_row.habitat_id = h.id
|
||||
@@ -1754,7 +1862,7 @@ export async function getItem(id: number, locale = defaultLocale) {
|
||||
FROM habitat_recipe_items used_item
|
||||
JOIN habitats h ON h.id = used_item.habitat_id
|
||||
WHERE used_item.item_id = $1
|
||||
ORDER BY ${habitatName}
|
||||
ORDER BY ${orderByEntity('h')}
|
||||
`,
|
||||
[id]
|
||||
),
|
||||
@@ -1768,7 +1876,7 @@ export async function getItem(id: number, locale = defaultLocale) {
|
||||
JOIN skills s ON s.id = psid.skill_id
|
||||
WHERE psid.item_id = $1
|
||||
AND s.has_item_drop = true
|
||||
ORDER BY p.id, ${skillName}
|
||||
ORDER BY ${orderByEntity('p')}, ${orderByEntity('s')}
|
||||
`,
|
||||
[id]
|
||||
),
|
||||
@@ -1831,6 +1939,7 @@ export async function createItem(payload: Record<string, unknown>, userId: numbe
|
||||
const cleanPayload = cleanItemPayload(payload);
|
||||
|
||||
const id = await withTransaction(async (client) => {
|
||||
const sortOrder = await nextSortOrder(client, 'items');
|
||||
const result = await client.query<{ id: number }>(
|
||||
`
|
||||
INSERT INTO items (
|
||||
@@ -1841,10 +1950,11 @@ export async function createItem(payload: Record<string, unknown>, userId: numbe
|
||||
dual_dyeable,
|
||||
pattern_editable,
|
||||
no_recipe,
|
||||
sort_order,
|
||||
created_by_user_id,
|
||||
updated_by_user_id
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $8)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $9)
|
||||
RETURNING id
|
||||
`,
|
||||
[
|
||||
@@ -1855,6 +1965,7 @@ export async function createItem(payload: Record<string, unknown>, userId: numbe
|
||||
cleanPayload.dualDyeable,
|
||||
cleanPayload.patternEditable,
|
||||
cleanPayload.noRecipe,
|
||||
sortOrder,
|
||||
userId
|
||||
]
|
||||
);
|
||||
@@ -1943,7 +2054,7 @@ export async function listRecipes(paramsQuery: QueryParams = {}, locale = defaul
|
||||
${resultItemName} AS name,
|
||||
${auditSelect('r', 'recipe_created_user', 'recipe_updated_user')},
|
||||
COALESCE((
|
||||
SELECT json_agg(json_build_object('id', i.id, 'name', ${materialItemName}, 'quantity', rm.quantity) ORDER BY ${materialItemName})
|
||||
SELECT json_agg(json_build_object('id', i.id, 'name', ${materialItemName}, 'quantity', rm.quantity) ORDER BY ${orderByEntity('i')})
|
||||
FROM recipe_materials rm
|
||||
JOIN items i ON i.id = rm.item_id
|
||||
WHERE rm.recipe_id = r.id
|
||||
@@ -1952,7 +2063,7 @@ export async function listRecipes(paramsQuery: QueryParams = {}, locale = defaul
|
||||
JOIN items result_item ON result_item.id = r.item_id
|
||||
${auditJoins('r', 'recipe_created_user', 'recipe_updated_user')}
|
||||
${whereClause}
|
||||
ORDER BY ${resultItemName}
|
||||
ORDER BY ${orderByEntity('r')}
|
||||
`, params);
|
||||
}
|
||||
|
||||
@@ -1968,13 +2079,13 @@ export async function getRecipe(id: number, locale = defaultLocale) {
|
||||
${resultItemName} AS name,
|
||||
${auditSelect('r', 'recipe_created_user', 'recipe_updated_user')},
|
||||
COALESCE((
|
||||
SELECT json_agg(json_build_object('id', am.id, 'name', ${acquisitionMethodName}) ORDER BY ${acquisitionMethodName})
|
||||
SELECT json_agg(json_build_object('id', am.id, 'name', ${acquisitionMethodName}) ORDER BY ${orderByEntity('am')})
|
||||
FROM recipe_acquisition_methods ram
|
||||
JOIN acquisition_methods am ON am.id = ram.acquisition_method_id
|
||||
WHERE ram.recipe_id = r.id
|
||||
), '[]'::json) AS acquisition_methods,
|
||||
COALESCE((
|
||||
SELECT json_agg(json_build_object('id', i.id, 'name', ${materialItemName}, 'quantity', rm.quantity) ORDER BY ${materialItemName})
|
||||
SELECT json_agg(json_build_object('id', i.id, 'name', ${materialItemName}, 'quantity', rm.quantity) ORDER BY ${orderByEntity('i')})
|
||||
FROM recipe_materials rm
|
||||
JOIN items i ON i.id = rm.item_id
|
||||
WHERE rm.recipe_id = r.id
|
||||
@@ -2040,13 +2151,14 @@ export async function createRecipe(payload: Record<string, unknown>, userId: num
|
||||
|
||||
const id = await withTransaction(async (client) => {
|
||||
await ensureItemCanHaveRecipe(client, cleanPayload.itemId);
|
||||
const sortOrder = await nextSortOrder(client, 'recipes');
|
||||
const result = await client.query<{ id: number }>(
|
||||
`
|
||||
INSERT INTO recipes (item_id, created_by_user_id, updated_by_user_id)
|
||||
VALUES ($1, $2, $2)
|
||||
INSERT INTO recipes (item_id, sort_order, created_by_user_id, updated_by_user_id)
|
||||
VALUES ($1, $2, $3, $3)
|
||||
RETURNING id
|
||||
`,
|
||||
[cleanPayload.itemId, userId]
|
||||
[cleanPayload.itemId, sortOrder, userId]
|
||||
);
|
||||
const recipeId = result.rows[0].id;
|
||||
await replaceRecipeRelations(client, recipeId, cleanPayload);
|
||||
|
||||
@@ -32,8 +32,13 @@ import {
|
||||
listLanguages,
|
||||
listPokemon,
|
||||
listRecipes,
|
||||
reorderConfig,
|
||||
reorderDailyChecklistItems,
|
||||
reorderHabitats,
|
||||
reorderItems,
|
||||
reorderLanguages,
|
||||
reorderPokemon,
|
||||
reorderRecipes,
|
||||
updateConfig,
|
||||
updateDailyChecklistItem,
|
||||
updateHabitat,
|
||||
@@ -391,6 +396,26 @@ app.delete('/api/admin/daily-checklist/:id', async (request, reply) => {
|
||||
return deleted ? reply.code(204).send() : reply.code(404).send({ message: 'Not found' });
|
||||
});
|
||||
|
||||
app.put('/api/admin/pokemon/order', async (request, reply) => {
|
||||
const user = await requireVerifiedUser(request, reply);
|
||||
return user ? reorderPokemon(request.body as Record<string, unknown>, user.id, requestLocale(request)) : undefined;
|
||||
});
|
||||
|
||||
app.put('/api/admin/items/order', async (request, reply) => {
|
||||
const user = await requireVerifiedUser(request, reply);
|
||||
return user ? reorderItems(request.body as Record<string, unknown>, user.id, requestLocale(request)) : undefined;
|
||||
});
|
||||
|
||||
app.put('/api/admin/recipes/order', async (request, reply) => {
|
||||
const user = await requireVerifiedUser(request, reply);
|
||||
return user ? reorderRecipes(request.body as Record<string, unknown>, user.id, requestLocale(request)) : undefined;
|
||||
});
|
||||
|
||||
app.put('/api/admin/habitats/order', async (request, reply) => {
|
||||
const user = await requireVerifiedUser(request, reply);
|
||||
return user ? reorderHabitats(request.body as Record<string, unknown>, user.id, requestLocale(request)) : undefined;
|
||||
});
|
||||
|
||||
app.get('/api/admin/languages', async (request, reply) => {
|
||||
const user = await requireVerifiedUser(request, reply);
|
||||
return user ? listLanguages(true) : undefined;
|
||||
@@ -451,6 +476,18 @@ app.post('/api/admin/config/:type', async (request, reply) => {
|
||||
.send(await createConfig(type, request.body as Record<string, unknown>, user.id, requestLocale(request)));
|
||||
});
|
||||
|
||||
app.put('/api/admin/config/:type/order', async (request, reply) => {
|
||||
const user = await requireVerifiedUser(request, reply);
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
const { type } = request.params as { type: string };
|
||||
if (!isConfigType(type)) {
|
||||
return reply.code(404).send({ message: 'Not found' });
|
||||
}
|
||||
return reorderConfig(type, request.body as Record<string, unknown>, user.id, requestLocale(request));
|
||||
});
|
||||
|
||||
app.put('/api/admin/config/:type/:id', async (request, reply) => {
|
||||
const user = await requireVerifiedUser(request, reply);
|
||||
if (!user) {
|
||||
|
||||
@@ -383,6 +383,8 @@ export const api = {
|
||||
config: (type: ConfigType) => getJson<Array<Skill | NamedEntity>>(`/api/admin/config/${type}`),
|
||||
createConfig: (type: ConfigType, payload: { name: string; translations?: TranslationMap; hasItemDrop?: boolean }) =>
|
||||
sendJson<Skill | NamedEntity>(`/api/admin/config/${type}`, 'POST', payload),
|
||||
reorderConfig: (type: ConfigType, ids: number[]) =>
|
||||
sendJson<Array<Skill | NamedEntity>>(`/api/admin/config/${type}/order`, 'PUT', { ids }),
|
||||
updateConfig: (type: ConfigType, id: number, payload: { name: string; translations?: TranslationMap; hasItemDrop?: boolean }) =>
|
||||
sendJson<Skill | NamedEntity>(`/api/admin/config/${type}/${id}`, 'PUT', payload),
|
||||
deleteConfig: (type: ConfigType, id: number) => deleteJson(`/api/admin/config/${type}/${id}`),
|
||||
@@ -393,23 +395,27 @@ export const api = {
|
||||
updatePokemon: (id: string | number, payload: PokemonPayload) =>
|
||||
sendJson<PokemonDetail>(`/api/pokemon/${id}`, 'PUT', payload),
|
||||
deletePokemon: (id: string | number) => deleteJson(`/api/pokemon/${id}`),
|
||||
reorderPokemon: (ids: number[]) => sendJson<Pokemon[]>('/api/admin/pokemon/order', 'PUT', { ids }),
|
||||
habitats: () => getJson<Habitat[]>('/api/habitats'),
|
||||
habitatDetail: (id: string | number) => getJson<HabitatDetail>(`/api/habitats/${id}`),
|
||||
createHabitat: (payload: HabitatPayload) => sendJson<HabitatDetail>('/api/habitats', 'POST', payload),
|
||||
updateHabitat: (id: string | number, payload: HabitatPayload) =>
|
||||
sendJson<HabitatDetail>(`/api/habitats/${id}`, 'PUT', payload),
|
||||
deleteHabitat: (id: string | number) => deleteJson(`/api/habitats/${id}`),
|
||||
reorderHabitats: (ids: number[]) => sendJson<Habitat[]>('/api/admin/habitats/order', 'PUT', { ids }),
|
||||
items: (params: Record<string, string | number | undefined>) =>
|
||||
getJson<Item[]>(`/api/items${buildQuery(params)}`),
|
||||
itemDetail: (id: string | number) => getJson<ItemDetail>(`/api/items/${id}`),
|
||||
createItem: (payload: ItemPayload) => sendJson<ItemDetail>('/api/items', 'POST', payload),
|
||||
updateItem: (id: string | number, payload: ItemPayload) => sendJson<ItemDetail>(`/api/items/${id}`, 'PUT', payload),
|
||||
deleteItem: (id: string | number) => deleteJson(`/api/items/${id}`),
|
||||
reorderItems: (ids: number[]) => sendJson<Item[]>('/api/admin/items/order', 'PUT', { ids }),
|
||||
recipes: (params: Record<string, string | number | undefined> = {}) =>
|
||||
getJson<Recipe[]>(`/api/recipes${buildQuery(params)}`),
|
||||
recipeDetail: (id: string | number) => getJson<RecipeDetail>(`/api/recipes/${id}`),
|
||||
createRecipe: (payload: RecipePayload) => sendJson<RecipeDetail>('/api/recipes', 'POST', payload),
|
||||
updateRecipe: (id: string | number, payload: RecipePayload) =>
|
||||
sendJson<RecipeDetail>(`/api/recipes/${id}`, 'PUT', payload),
|
||||
deleteRecipe: (id: string | number) => deleteJson(`/api/recipes/${id}`)
|
||||
deleteRecipe: (id: string | number) => deleteJson(`/api/recipes/${id}`),
|
||||
reorderRecipes: (ids: number[]) => sendJson<Recipe[]>('/api/admin/recipes/order', 'PUT', { ids })
|
||||
};
|
||||
|
||||
@@ -106,6 +106,16 @@ const checklistKey = (item: DailyChecklistItem) => item.id;
|
||||
const checklistLabel = (item: DailyChecklistItem) => item.title;
|
||||
const languageKey = (item: Language) => item.code;
|
||||
const languageLabel = (item: Language) => item.name;
|
||||
const configKey = (item: EditableConfig) => item.id;
|
||||
const configLabel = (item: EditableConfig) => item.name;
|
||||
const pokemonKey = (item: Pokemon) => item.id;
|
||||
const pokemonLabel = (item: Pokemon) => `#${item.id} ${item.name}`;
|
||||
const itemKey = (item: Item) => item.id;
|
||||
const itemLabel = (item: Item) => item.name;
|
||||
const recipeKey = (item: Recipe) => item.id;
|
||||
const recipeLabel = (item: Recipe) => item.name;
|
||||
const habitatKey = (item: Habitat) => item.id;
|
||||
const habitatLabel = (item: Habitat) => item.name;
|
||||
|
||||
function dragSortLabel(name: string) {
|
||||
return t('pages.admin.dragSort', { name });
|
||||
@@ -203,6 +213,26 @@ function previewLanguageOrder(rows: Language[]) {
|
||||
languageRows.value = rows;
|
||||
}
|
||||
|
||||
function previewConfigOrder(rows: EditableConfig[]) {
|
||||
configRows.value = rows;
|
||||
}
|
||||
|
||||
function previewPokemonOrder(rows: Pokemon[]) {
|
||||
pokemonRows.value = rows;
|
||||
}
|
||||
|
||||
function previewItemOrder(rows: Item[]) {
|
||||
itemRows.value = rows;
|
||||
}
|
||||
|
||||
function previewRecipeOrder(rows: Recipe[]) {
|
||||
recipeRows.value = rows;
|
||||
}
|
||||
|
||||
function previewHabitatOrder(rows: Habitat[]) {
|
||||
habitatRows.value = rows;
|
||||
}
|
||||
|
||||
async function persistChecklistOrder(nextRows: DailyChecklistItem[], fallbackRows: DailyChecklistItem[]) {
|
||||
checklistRows.value = nextRows;
|
||||
await run(async () => {
|
||||
@@ -228,6 +258,66 @@ async function persistLanguageOrder(nextRows: Language[], fallbackRows: Language
|
||||
});
|
||||
}
|
||||
|
||||
async function persistConfigOrder(nextRows: EditableConfig[], fallbackRows: EditableConfig[]) {
|
||||
configRows.value = nextRows;
|
||||
await run(async () => {
|
||||
try {
|
||||
configRows.value = (await api.reorderConfig(activeConfigType.value, nextRows.map((item) => item.id))) as EditableConfig[];
|
||||
} catch (error) {
|
||||
configRows.value = fallbackRows;
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function persistPokemonOrder(nextRows: Pokemon[], fallbackRows: Pokemon[]) {
|
||||
pokemonRows.value = nextRows;
|
||||
await run(async () => {
|
||||
try {
|
||||
pokemonRows.value = await api.reorderPokemon(nextRows.map((item) => item.id));
|
||||
} catch (error) {
|
||||
pokemonRows.value = fallbackRows;
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function persistItemOrder(nextRows: Item[], fallbackRows: Item[]) {
|
||||
itemRows.value = nextRows;
|
||||
await run(async () => {
|
||||
try {
|
||||
itemRows.value = await api.reorderItems(nextRows.map((item) => item.id));
|
||||
} catch (error) {
|
||||
itemRows.value = fallbackRows;
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function persistRecipeOrder(nextRows: Recipe[], fallbackRows: Recipe[]) {
|
||||
recipeRows.value = nextRows;
|
||||
await run(async () => {
|
||||
try {
|
||||
recipeRows.value = await api.reorderRecipes(nextRows.map((item) => item.id));
|
||||
} catch (error) {
|
||||
recipeRows.value = fallbackRows;
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function persistHabitatOrder(nextRows: Habitat[], fallbackRows: Habitat[]) {
|
||||
habitatRows.value = nextRows;
|
||||
await run(async () => {
|
||||
try {
|
||||
habitatRows.value = await api.reorderHabitats(nextRows.map((item) => item.id));
|
||||
} catch (error) {
|
||||
habitatRows.value = fallbackRows;
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function saveConfig() {
|
||||
await run(async () => {
|
||||
const payload = {
|
||||
@@ -516,15 +606,28 @@ onMounted(() => {
|
||||
</form>
|
||||
|
||||
<h3 class="section-subtitle">{{ selectedConfig.label }}</h3>
|
||||
<ul v-if="configRows.length" class="row-list">
|
||||
<li v-for="item in configRows" :key="item.id">
|
||||
<span>{{ item.name }}<span v-if="item.hasItemDrop" class="config-flag">{{ t('pages.admin.hasItemDrop') }}</span></span>
|
||||
<span class="row-actions">
|
||||
<button type="button" @click="editConfig(item)">{{ t('common.edit') }}</button>
|
||||
<button type="button" @click="removeConfig(item.id)">{{ t('common.delete') }}</button>
|
||||
<ReorderableList
|
||||
v-if="configRows.length"
|
||||
:items="configRows"
|
||||
:item-key="configKey"
|
||||
:item-label="configLabel"
|
||||
:disabled="busy"
|
||||
:handle-label="dragSortLabel"
|
||||
:handle-title="t('pages.admin.dragSortTitle')"
|
||||
@preview="previewConfigOrder"
|
||||
@cancel="previewConfigOrder"
|
||||
@reorder="persistConfigOrder"
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<span class="reorderable-row-title">
|
||||
{{ item.name }}<span v-if="item.hasItemDrop" class="config-flag">{{ t('pages.admin.hasItemDrop') }}</span>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
<span class="row-actions">
|
||||
<button type="button" :disabled="busy" @click="editConfig(item)">{{ t('common.edit') }}</button>
|
||||
<button type="button" :disabled="busy" @click="removeConfig(item.id)">{{ t('common.delete') }}</button>
|
||||
</span>
|
||||
</template>
|
||||
</ReorderableList>
|
||||
<p v-else class="meta-line">{{ t('common.noRecords') }}</p>
|
||||
</section>
|
||||
|
||||
@@ -582,53 +685,97 @@ onMounted(() => {
|
||||
|
||||
<section v-else-if="canEdit && activeTab === 'pokemon'" class="detail-section">
|
||||
<h2>{{ t('pages.admin.pokemonList') }}</h2>
|
||||
<ul v-if="pokemonRows.length" class="row-list">
|
||||
<li v-for="item in pokemonRows" :key="item.id">
|
||||
<ReorderableList
|
||||
v-if="pokemonRows.length"
|
||||
:items="pokemonRows"
|
||||
:item-key="pokemonKey"
|
||||
:item-label="pokemonLabel"
|
||||
:disabled="busy"
|
||||
:handle-label="dragSortLabel"
|
||||
:handle-title="t('pages.admin.dragSortTitle')"
|
||||
@preview="previewPokemonOrder"
|
||||
@cancel="previewPokemonOrder"
|
||||
@reorder="persistPokemonOrder"
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<RouterLink :to="`/pokemon/${item.id}`">#{{ item.id }} {{ item.name }}</RouterLink>
|
||||
<span class="row-actions">
|
||||
<button type="button" @click="removePokemon(item.id)">{{ t('common.delete') }}</button>
|
||||
<button type="button" :disabled="busy" @click="removePokemon(item.id)">{{ t('common.delete') }}</button>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
</ReorderableList>
|
||||
<p v-else class="meta-line">{{ t('common.noRecords') }}</p>
|
||||
</section>
|
||||
|
||||
<section v-else-if="canEdit && activeTab === 'items'" class="detail-section">
|
||||
<h2>{{ t('pages.admin.itemList') }}</h2>
|
||||
<ul v-if="itemRows.length" class="row-list">
|
||||
<li v-for="item in itemRows" :key="item.id">
|
||||
<ReorderableList
|
||||
v-if="itemRows.length"
|
||||
:items="itemRows"
|
||||
:item-key="itemKey"
|
||||
:item-label="itemLabel"
|
||||
:disabled="busy"
|
||||
:handle-label="dragSortLabel"
|
||||
:handle-title="t('pages.admin.dragSortTitle')"
|
||||
@preview="previewItemOrder"
|
||||
@cancel="previewItemOrder"
|
||||
@reorder="persistItemOrder"
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<RouterLink :to="`/items/${item.id}`">{{ item.name }}</RouterLink>
|
||||
<span class="row-actions">
|
||||
<button type="button" @click="removeItem(item.id)">{{ t('common.delete') }}</button>
|
||||
<button type="button" :disabled="busy" @click="removeItem(item.id)">{{ t('common.delete') }}</button>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
</ReorderableList>
|
||||
<p v-else class="meta-line">{{ t('common.noRecords') }}</p>
|
||||
</section>
|
||||
|
||||
<section v-else-if="canEdit && activeTab === 'recipes'" class="detail-section">
|
||||
<h2>{{ t('pages.admin.recipeList') }}</h2>
|
||||
<ul v-if="recipeRows.length" class="row-list">
|
||||
<li v-for="item in recipeRows" :key="item.id">
|
||||
<ReorderableList
|
||||
v-if="recipeRows.length"
|
||||
:items="recipeRows"
|
||||
:item-key="recipeKey"
|
||||
:item-label="recipeLabel"
|
||||
:disabled="busy"
|
||||
:handle-label="dragSortLabel"
|
||||
:handle-title="t('pages.admin.dragSortTitle')"
|
||||
@preview="previewRecipeOrder"
|
||||
@cancel="previewRecipeOrder"
|
||||
@reorder="persistRecipeOrder"
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<RouterLink :to="`/recipes/${item.id}`">{{ item.name }}</RouterLink>
|
||||
<span class="row-actions">
|
||||
<button type="button" @click="removeRecipe(item.id)">{{ t('common.delete') }}</button>
|
||||
<button type="button" :disabled="busy" @click="removeRecipe(item.id)">{{ t('common.delete') }}</button>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
</ReorderableList>
|
||||
<p v-else class="meta-line">{{ t('common.noRecords') }}</p>
|
||||
</section>
|
||||
|
||||
<section v-else-if="canEdit && activeTab === 'habitats'" class="detail-section">
|
||||
<h2>{{ t('pages.admin.habitatList') }}</h2>
|
||||
<ul v-if="habitatRows.length" class="row-list">
|
||||
<li v-for="item in habitatRows" :key="item.id">
|
||||
<ReorderableList
|
||||
v-if="habitatRows.length"
|
||||
:items="habitatRows"
|
||||
:item-key="habitatKey"
|
||||
:item-label="habitatLabel"
|
||||
:disabled="busy"
|
||||
:handle-label="dragSortLabel"
|
||||
:handle-title="t('pages.admin.dragSortTitle')"
|
||||
@preview="previewHabitatOrder"
|
||||
@cancel="previewHabitatOrder"
|
||||
@reorder="persistHabitatOrder"
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<RouterLink :to="`/habitats/${item.id}`">{{ item.name }}</RouterLink>
|
||||
<span class="row-actions">
|
||||
<button type="button" @click="removeHabitat(item.id)">{{ t('common.delete') }}</button>
|
||||
<button type="button" :disabled="busy" @click="removeHabitat(item.id)">{{ t('common.delete') }}</button>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
</ReorderableList>
|
||||
<p v-else class="meta-line">{{ t('common.noRecords') }}</p>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
@@ -92,7 +92,7 @@ const pokemonRows = computed<PokemonRow[]>(() => {
|
||||
timeOfDays: sortByOrder(row.timeOfDays, timeOfDays),
|
||||
weathers: sortByOrder(row.weathers, weathers),
|
||||
rarity: row.rarity,
|
||||
maps: [...row.maps].sort((a, b) => a.localeCompare(b))
|
||||
maps: [...row.maps]
|
||||
}));
|
||||
});
|
||||
|
||||
|
||||
@@ -94,7 +94,7 @@ const habitatRows = computed<HabitatRow[]>(() => {
|
||||
timeOfDays: sortByOrder(row.timeOfDays, timeOfDays),
|
||||
weathers: sortByOrder(row.weathers, weathers),
|
||||
rarity: row.rarity,
|
||||
maps: [...row.maps].sort((a, b) => a.localeCompare(b))
|
||||
maps: [...row.maps]
|
||||
}));
|
||||
});
|
||||
const skillDropRows = computed(() => pokemon.value?.skills.filter((skill) => skill.itemDrop) ?? []);
|
||||
@@ -105,9 +105,7 @@ const itemCategoryTabs = computed<TabOption[]>(() => {
|
||||
categories.set(String(item.category.id), item.category.name);
|
||||
});
|
||||
|
||||
const tabs = [...categories.entries()]
|
||||
.sort(([, nameA], [, nameB]) => nameA.localeCompare(nameB))
|
||||
.map(([value, label]) => ({ value, label }));
|
||||
const tabs = [...categories.entries()].map(([value, label]) => ({ value, label }));
|
||||
|
||||
return tabs.length > 1 ? [{ value: '', label: t('common.all') }, ...tabs] : [];
|
||||
});
|
||||
|
||||
@@ -32,7 +32,8 @@ const itemQuery = computed(() => ({
|
||||
search: search.value,
|
||||
categoryId: categoryId.value,
|
||||
usageId: usageId.value,
|
||||
tagIds: tagIds.value.join(',')
|
||||
tagIds: tagIds.value.join(','),
|
||||
recipeOrder: 1
|
||||
}));
|
||||
|
||||
function recipeTarget(item: Item) {
|
||||
|
||||
Reference in New Issue
Block a user