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:
2026-05-01 12:30:46 +08:00
parent 27100fbd22
commit 239a2ec3b5
9 changed files with 546 additions and 97 deletions

View File

@@ -65,6 +65,10 @@ Pokemon 可配置:
- 配方(物品,数量) - 配方(物品,数量)
- 可出现的宝可梦(可多选) - 可出现的宝可梦(可多选)
列表顺序:
- 全局配置项、Pokemon、物品、材料单、地图、栖息地均可自定义排序
- 初始排序按创建时间旧到新
出现契机 出现契机
- 时间:早晨 / 中午 / 傍晚 / 晚上 - 时间:早晨 / 中午 / 傍晚 / 晚上
- 天气:晴天 / 阴天 / 雨天 - 天气:晴天 / 阴天 / 雨天

View File

@@ -1,6 +1,7 @@
CREATE TABLE IF NOT EXISTS environments ( CREATE TABLE IF NOT EXISTS environments (
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,
sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0)
); );
CREATE TABLE IF NOT EXISTS languages ( 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 ( CREATE TABLE IF NOT EXISTS skills (
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,
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; 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 ( CREATE TABLE IF NOT EXISTS favorite_things (
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,
sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0)
); );
CREATE TABLE IF NOT EXISTS pokemon ( CREATE TABLE IF NOT EXISTS pokemon (
id integer PRIMARY KEY, id integer PRIMARY KEY,
name text NOT NULL UNIQUE, 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 ( 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 ( CREATE TABLE IF NOT EXISTS item_categories (
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,
sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0)
); );
CREATE TABLE IF NOT EXISTS item_usages ( CREATE TABLE IF NOT EXISTS item_usages (
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,
sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0)
); );
CREATE TABLE IF NOT EXISTS acquisition_methods ( CREATE TABLE IF NOT EXISTS acquisition_methods (
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,
sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0)
); );
CREATE TABLE IF NOT EXISTS items ( CREATE TABLE IF NOT EXISTS items (
@@ -153,7 +160,8 @@ CREATE TABLE IF NOT EXISTS items (
dyeable boolean NOT NULL DEFAULT false, dyeable boolean NOT NULL DEFAULT false,
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,
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;
@@ -162,7 +170,8 @@ ALTER TABLE items DROP COLUMN IF EXISTS no_habitat;
CREATE TABLE IF NOT EXISTS recipes ( CREATE TABLE IF NOT EXISTS recipes (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, 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); 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 ( CREATE TABLE IF NOT EXISTS maps (
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,
sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0)
); );
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,
sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0)
); );
CREATE TABLE IF NOT EXISTS habitat_recipe_items ( 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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);
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 ( CREATE TABLE IF NOT EXISTS wiki_edit_logs (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,

View File

@@ -37,6 +37,11 @@ type ConfigDefinition = {
entityType: EntityType; entityType: EntityType;
hasItemDrop?: boolean; hasItemDrop?: boolean;
}; };
type SortableContentType = 'pokemon' | 'items' | 'recipes' | 'habitats';
type SortableContentDefinition = {
table: string;
entityType: SortableContentType;
};
type IdQuantity = { type IdQuantity = {
itemId: number; itemId: number;
@@ -157,6 +162,13 @@ const configDefinitions: Record<ConfigType, ConfigDefinition> = {
maps: { table: 'maps', entityType: 'maps' } 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 { function asString(value: QueryValue): string | undefined {
return Array.isArray(value) ? value[0] : value; return Array.isArray(value) ? value[0] : value;
} }
@@ -302,12 +314,12 @@ function optionSelect(
locale: string locale: string
): Promise<Array<{ id: number; name: string }>> { ): Promise<Array<{ id: number; name: string }>> {
const name = localizedName(entityType, 'o', locale); 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 }>> { function skillOptions(locale: string): Promise<Array<{ id: number; name: string; hasItemDrop: boolean }>> {
const name = localizedName('skills', 's', locale); 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 { 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 { function configOrder(): string {
return localizedName(definition.entityType, 'c', locale); return orderByEntity('c');
} }
function configSelect(definition: ConfigDefinition, locale: string): string { 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)))]; 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> { async function withTransaction<T>(callback: (client: DbClient) => Promise<T>): Promise<T> {
const client = await pool.connect(); 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( async function recordEditLog(
client: DbClient, client: DbClient,
entityType: string, entityType: string,
@@ -813,13 +865,13 @@ function pokemonProjection(locale: string): string {
${auditSelect('p', 'pokemon_created_user', 'pokemon_updated_user')}, ${auditSelect('p', 'pokemon_created_user', 'pokemon_updated_user')},
json_build_object('id', e.id, 'name', ${environmentName}) AS environment, json_build_object('id', e.id, 'name', ${environmentName}) AS environment,
COALESCE(( 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 FROM pokemon_skills ps
JOIN skills s ON s.id = ps.skill_id JOIN skills s ON s.id = ps.skill_id
WHERE ps.pokemon_id = p.id WHERE ps.pokemon_id = p.id
), '[]'::json) AS skills, ), '[]'::json) AS skills,
COALESCE(( 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 FROM pokemon_favorite_things pft
JOIN favorite_things ft ON ft.id = pft.favorite_thing_id JOIN favorite_things ft ON ft.id = pft.favorite_thing_id
WHERE pft.pokemon_id = p.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')} SELECT ${configSelect(definition, locale)}, ${auditSelect('c')}
FROM ${definition.table} c FROM ${definition.table} c
${auditJoins('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 hasItemDrop = definition.hasItemDrop ? Boolean(payload.hasItemDrop) : false;
const id = await withTransaction(async (client) => { const id = await withTransaction(async (client) => {
const sortOrder = await nextSortOrder(client, definition.table);
const result = definition.hasItemDrop const result = definition.hasItemDrop
? await client.query<{ id: number }>( ? await client.query<{ id: number }>(
` `
INSERT INTO ${definition.table} (name, has_item_drop, created_by_user_id, updated_by_user_id) INSERT INTO ${definition.table} (name, has_item_drop, 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
`, `,
[name, hasItemDrop, userId] [name, hasItemDrop, sortOrder, userId]
) )
: await client.query<{ id: number }>( : await client.query<{ id: number }>(
` `
INSERT INTO ${definition.table} (name, created_by_user_id, updated_by_user_id) INSERT INTO ${definition.table} (name, sort_order, created_by_user_id, updated_by_user_id)
VALUES ($1, $2, $2) VALUES ($1, $2, $3, $3)
RETURNING id RETURNING id
`, `,
[name, userId] [name, sortOrder, userId]
); );
const createdId = result.rows[0].id; 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); 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( export async function updateConfig(
type: ConfigType, type: ConfigType,
id: number, 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) { export async function listPokemon(paramsQuery: QueryParams, locale = defaultLocale) {
const params: unknown[] = []; const params: unknown[] = [];
const conditions: string[] = []; const conditions: string[] = [];
@@ -1162,7 +1261,7 @@ export async function listPokemon(paramsQuery: QueryParams, locale = defaultLoca
} }
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; 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) { 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 habitats h ON h.id = hp.habitat_id
JOIN maps m ON m.id = hp.map_id JOIN maps m ON m.id = hp.map_id
WHERE hp.pokemon_id = $1 WHERE hp.pokemon_id = $1
ORDER BY ${habitatName}, hp.rarity, ${mapName} ORDER BY ${orderByEntity('h')}, hp.rarity, ${orderByEntity('m')}
`, `,
[id] [id]
), ),
@@ -1203,7 +1302,7 @@ export async function getPokemon(id: number, locale = defaultLocale) {
JOIN items i ON i.id = psid.item_id JOIN items i ON i.id = psid.item_id
WHERE psid.pokemon_id = $1 WHERE psid.pokemon_id = $1
AND s.has_item_drop = true AND s.has_item_drop = true
ORDER BY psid.skill_id, ${itemName} ORDER BY ${orderByEntity('s')}, ${orderByEntity('i')}
`, `,
[id] [id]
), ),
@@ -1213,15 +1312,15 @@ export async function getPokemon(id: number, locale = defaultLocale) {
i.id, i.id,
${itemName} AS name, ${itemName} AS name,
json_build_object('id', c.id, 'name', ${categoryName}) AS category, 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 FROM pokemon_favorite_things pft
JOIN item_favorite_things ift ON ift.favorite_thing_id = pft.favorite_thing_id 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 favorite_things ft ON ft.id = pft.favorite_thing_id
JOIN items i ON i.id = ift.item_id JOIN items i ON i.id = ift.item_id
JOIN item_categories c ON c.id = i.category_id JOIN item_categories c ON c.id = i.category_id
WHERE pft.pokemon_id = $1 WHERE pft.pokemon_id = $1
GROUP BY i.id, i.name, c.id, c.name GROUP BY i.id, i.name, i.sort_order, c.id, c.name, c.sort_order
ORDER BY ${categoryName}, ${itemName} ORDER BY ${orderByEntity('c')}, ${orderByEntity('i')}
`, `,
[id] [id]
), ),
@@ -1325,12 +1424,13 @@ export async function createPokemon(payload: Record<string, unknown>, userId: nu
const cleanPayload = cleanPokemonPayload(payload); const cleanPayload = cleanPokemonPayload(payload);
const id = await withTransaction(async (client) => { const id = await withTransaction(async (client) => {
const sortOrder = await nextSortOrder(client, 'pokemon');
await client.query( await client.query(
` `
INSERT INTO pokemon (id, name, environment_id, created_by_user_id, updated_by_user_id) INSERT INTO pokemon (id, name, environment_id, sort_order, created_by_user_id, updated_by_user_id)
VALUES ($1, $2, $3, $4, $4) 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 replacePokemonRelations(client, cleanPayload.id, cleanPayload);
await replaceEntityTranslations(client, 'pokemon', cleanPayload.id, cleanPayload.translations, ['name']); 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, ${translationsSelect('habitats', 'h.id')} AS translations,
${auditSelect('h', 'habitat_created_user', 'habitat_updated_user')}, ${auditSelect('h', 'habitat_created_user', 'habitat_updated_user')},
COALESCE(( 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 FROM habitat_recipe_items hri
JOIN items i ON i.id = hri.item_id JOIN items i ON i.id = hri.item_id
WHERE hri.habitat_id = h.id WHERE hri.habitat_id = h.id
), '[]'::json) AS recipe, ), '[]'::json) AS recipe,
COALESCE(( COALESCE((
SELECT json_agg(DISTINCT jsonb_build_object('id', p.id, 'name', ${pokemonName})) SELECT json_agg(json_build_object('id', pokemon_rows.id, 'name', pokemon_rows.name) ORDER BY pokemon_rows.sort_order, pokemon_rows.id)
FROM habitat_pokemon hp FROM (
JOIN pokemon p ON p.id = hp.pokemon_id SELECT DISTINCT p.id, ${pokemonName} AS name, p.sort_order
WHERE hp.habitat_id = h.id FROM habitat_pokemon hp
JOIN pokemon p ON p.id = hp.pokemon_id
WHERE hp.habitat_id = h.id
) pokemon_rows
), '[]'::json) AS pokemon ), '[]'::json) AS pokemon
FROM habitats h FROM habitats h
${auditJoins('h', 'habitat_created_user', 'habitat_updated_user')} ${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, ${translationsSelect('habitats', 'h.id')} AS translations,
${auditSelect('h', 'habitat_created_user', 'habitat_updated_user')}, ${auditSelect('h', 'habitat_created_user', 'habitat_updated_user')},
COALESCE(( 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 FROM habitat_recipe_items hri
JOIN items i ON i.id = hri.item_id JOIN items i ON i.id = hri.item_id
WHERE hri.habitat_id = h.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 pokemon p ON p.id = hp.pokemon_id
JOIN maps m ON m.id = hp.map_id JOIN maps m ON m.id = hp.map_id
WHERE hp.habitat_id = $1 WHERE hp.habitat_id = $1
ORDER BY hp.rarity, p.id, ${mapName} ORDER BY hp.rarity, ${orderByEntity('p')}, ${orderByEntity('m')}
`, `,
[id] [id]
), ),
@@ -1527,13 +1630,14 @@ export async function createHabitat(payload: Record<string, unknown>, userId: nu
const cleanPayload = cleanHabitatPayload(payload); const cleanPayload = cleanHabitatPayload(payload);
const id = await withTransaction(async (client) => { const id = await withTransaction(async (client) => {
const sortOrder = await nextSortOrder(client, 'habitats');
const result = await client.query<{ id: number }>( const result = await client.query<{ id: number }>(
` `
INSERT INTO habitats (name, created_by_user_id, updated_by_user_id) INSERT INTO habitats (name, sort_order, created_by_user_id, updated_by_user_id)
VALUES ($1, $2, $2) VALUES ($1, $2, $3, $3)
RETURNING id RETURNING id
`, `,
[cleanPayload.name, userId] [cleanPayload.name, sortOrder, userId]
); );
const habitatId = result.rows[0].id; const habitatId = result.rows[0].id;
await replaceHabitatRelations(client, habitatId, cleanPayload); await replaceHabitatRelations(client, habitatId, cleanPayload);
@@ -1599,7 +1703,7 @@ function itemProjection(locale: string): string {
) AS customization, ) AS customization,
i.no_recipe AS "noRecipe", i.no_recipe AS "noRecipe",
COALESCE(( 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 FROM item_favorite_things ift
JOIN favorite_things t ON t.id = ift.favorite_thing_id JOIN favorite_things t ON t.id = ift.favorite_thing_id
WHERE ift.item_id = i.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 usageId = Number(asString(paramsQuery.usageId));
const tagIds = parseIdList(asString(paramsQuery.tagIds)); const tagIds = parseIdList(asString(paramsQuery.tagIds));
const search = asString(paramsQuery.search)?.trim(); const search = asString(paramsQuery.search)?.trim();
const recipeOrder = asString(paramsQuery.recipeOrder) === '1';
if (search) { if (search) {
params.push(`%${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 ')}` : ''; 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) { 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 FROM item_acquisition_methods iam
JOIN acquisition_methods am ON am.id = iam.acquisition_method_id JOIN acquisition_methods am ON am.id = iam.acquisition_method_id
WHERE iam.item_id = $1 WHERE iam.item_id = $1
ORDER BY ${acquisitionMethodName} ORDER BY ${orderByEntity('am')}
`, `,
[id] [id]
), ),
@@ -1702,13 +1810,13 @@ export async function getItem(id: number, locale = defaultLocale) {
${resultItemName} AS name, ${resultItemName} AS name,
${auditSelect('r', 'recipe_created_user', 'recipe_updated_user')}, ${auditSelect('r', 'recipe_created_user', 'recipe_updated_user')},
COALESCE(( 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 FROM recipe_acquisition_methods ram
JOIN acquisition_methods am ON am.id = ram.acquisition_method_id JOIN acquisition_methods am ON am.id = ram.acquisition_method_id
WHERE ram.recipe_id = r.id WHERE ram.recipe_id = r.id
), '[]'::json) AS acquisition_methods, ), '[]'::json) AS acquisition_methods,
COALESCE(( 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 FROM recipe_materials rm
JOIN items mi ON mi.id = rm.item_id JOIN items mi ON mi.id = rm.item_id
WHERE rm.recipe_id = r.id WHERE rm.recipe_id = r.id
@@ -1727,7 +1835,7 @@ export async function getItem(id: number, locale = defaultLocale) {
r.id, r.id,
${resultItemName} AS name, ${resultItemName} AS name,
COALESCE(( 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 FROM recipe_materials recipe_material
JOIN items mi ON mi.id = recipe_material.item_id JOIN items mi ON mi.id = recipe_material.item_id
WHERE recipe_material.recipe_id = r.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 recipes r ON r.id = used_material.recipe_id
JOIN items result_item ON result_item.id = r.item_id JOIN items result_item ON result_item.id = r.item_id
WHERE used_material.item_id = $1 WHERE used_material.item_id = $1
ORDER BY ${resultItemName} ORDER BY ${orderByEntity('r')}
`, `,
[id] [id]
), ),
@@ -1746,7 +1854,7 @@ export async function getItem(id: number, locale = defaultLocale) {
h.id, h.id,
${habitatName} AS name, ${habitatName} AS name,
COALESCE(( 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 FROM habitat_recipe_items recipe_item_row
JOIN items recipe_item ON recipe_item.id = recipe_item_row.item_id JOIN items recipe_item ON recipe_item.id = recipe_item_row.item_id
WHERE recipe_item_row.habitat_id = h.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 FROM habitat_recipe_items used_item
JOIN habitats h ON h.id = used_item.habitat_id JOIN habitats h ON h.id = used_item.habitat_id
WHERE used_item.item_id = $1 WHERE used_item.item_id = $1
ORDER BY ${habitatName} ORDER BY ${orderByEntity('h')}
`, `,
[id] [id]
), ),
@@ -1768,7 +1876,7 @@ export async function getItem(id: number, locale = defaultLocale) {
JOIN skills s ON s.id = psid.skill_id JOIN skills s ON s.id = psid.skill_id
WHERE psid.item_id = $1 WHERE psid.item_id = $1
AND s.has_item_drop = true AND s.has_item_drop = true
ORDER BY p.id, ${skillName} ORDER BY ${orderByEntity('p')}, ${orderByEntity('s')}
`, `,
[id] [id]
), ),
@@ -1831,6 +1939,7 @@ export async function createItem(payload: Record<string, unknown>, userId: numbe
const cleanPayload = cleanItemPayload(payload); const cleanPayload = cleanItemPayload(payload);
const id = await withTransaction(async (client) => { const id = await withTransaction(async (client) => {
const sortOrder = await nextSortOrder(client, 'items');
const result = await client.query<{ id: number }>( const result = await client.query<{ id: number }>(
` `
INSERT INTO items ( INSERT INTO items (
@@ -1841,10 +1950,11 @@ export async function createItem(payload: Record<string, unknown>, userId: numbe
dual_dyeable, dual_dyeable,
pattern_editable, pattern_editable,
no_recipe, no_recipe,
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, $8) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $9)
RETURNING id RETURNING id
`, `,
[ [
@@ -1855,6 +1965,7 @@ export async function createItem(payload: Record<string, unknown>, userId: numbe
cleanPayload.dualDyeable, cleanPayload.dualDyeable,
cleanPayload.patternEditable, cleanPayload.patternEditable,
cleanPayload.noRecipe, cleanPayload.noRecipe,
sortOrder,
userId userId
] ]
); );
@@ -1943,7 +2054,7 @@ export async function listRecipes(paramsQuery: QueryParams = {}, locale = defaul
${resultItemName} AS name, ${resultItemName} AS name,
${auditSelect('r', 'recipe_created_user', 'recipe_updated_user')}, ${auditSelect('r', 'recipe_created_user', 'recipe_updated_user')},
COALESCE(( 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 FROM recipe_materials rm
JOIN items i ON i.id = rm.item_id JOIN items i ON i.id = rm.item_id
WHERE rm.recipe_id = r.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 JOIN items result_item ON result_item.id = r.item_id
${auditJoins('r', 'recipe_created_user', 'recipe_updated_user')} ${auditJoins('r', 'recipe_created_user', 'recipe_updated_user')}
${whereClause} ${whereClause}
ORDER BY ${resultItemName} ORDER BY ${orderByEntity('r')}
`, params); `, params);
} }
@@ -1968,13 +2079,13 @@ export async function getRecipe(id: number, locale = defaultLocale) {
${resultItemName} AS name, ${resultItemName} AS name,
${auditSelect('r', 'recipe_created_user', 'recipe_updated_user')}, ${auditSelect('r', 'recipe_created_user', 'recipe_updated_user')},
COALESCE(( 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 FROM recipe_acquisition_methods ram
JOIN acquisition_methods am ON am.id = ram.acquisition_method_id JOIN acquisition_methods am ON am.id = ram.acquisition_method_id
WHERE ram.recipe_id = r.id WHERE ram.recipe_id = r.id
), '[]'::json) AS acquisition_methods, ), '[]'::json) AS acquisition_methods,
COALESCE(( 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 FROM recipe_materials rm
JOIN items i ON i.id = rm.item_id JOIN items i ON i.id = rm.item_id
WHERE rm.recipe_id = r.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) => { const id = await withTransaction(async (client) => {
await ensureItemCanHaveRecipe(client, cleanPayload.itemId); await ensureItemCanHaveRecipe(client, cleanPayload.itemId);
const sortOrder = await nextSortOrder(client, 'recipes');
const result = await client.query<{ id: number }>( const result = await client.query<{ id: number }>(
` `
INSERT INTO recipes (item_id, created_by_user_id, updated_by_user_id) INSERT INTO recipes (item_id, sort_order, created_by_user_id, updated_by_user_id)
VALUES ($1, $2, $2) VALUES ($1, $2, $3, $3)
RETURNING id RETURNING id
`, `,
[cleanPayload.itemId, userId] [cleanPayload.itemId, sortOrder, userId]
); );
const recipeId = result.rows[0].id; const recipeId = result.rows[0].id;
await replaceRecipeRelations(client, recipeId, cleanPayload); await replaceRecipeRelations(client, recipeId, cleanPayload);

View File

@@ -32,8 +32,13 @@ import {
listLanguages, listLanguages,
listPokemon, listPokemon,
listRecipes, listRecipes,
reorderConfig,
reorderDailyChecklistItems, reorderDailyChecklistItems,
reorderHabitats,
reorderItems,
reorderLanguages, reorderLanguages,
reorderPokemon,
reorderRecipes,
updateConfig, updateConfig,
updateDailyChecklistItem, updateDailyChecklistItem,
updateHabitat, 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' }); 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) => { app.get('/api/admin/languages', async (request, reply) => {
const user = await requireVerifiedUser(request, reply); const user = await requireVerifiedUser(request, reply);
return user ? listLanguages(true) : undefined; 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))); .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) => { app.put('/api/admin/config/:type/:id', async (request, reply) => {
const user = await requireVerifiedUser(request, reply); const user = await requireVerifiedUser(request, reply);
if (!user) { if (!user) {

View File

@@ -383,6 +383,8 @@ export const api = {
config: (type: ConfigType) => getJson<Array<Skill | NamedEntity>>(`/api/admin/config/${type}`), config: (type: ConfigType) => getJson<Array<Skill | NamedEntity>>(`/api/admin/config/${type}`),
createConfig: (type: ConfigType, payload: { name: string; translations?: TranslationMap; hasItemDrop?: boolean }) => createConfig: (type: ConfigType, payload: { name: string; translations?: TranslationMap; hasItemDrop?: boolean }) =>
sendJson<Skill | NamedEntity>(`/api/admin/config/${type}`, 'POST', payload), 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 }) => updateConfig: (type: ConfigType, id: number, payload: { name: string; translations?: TranslationMap; hasItemDrop?: boolean }) =>
sendJson<Skill | NamedEntity>(`/api/admin/config/${type}/${id}`, 'PUT', payload), sendJson<Skill | NamedEntity>(`/api/admin/config/${type}/${id}`, 'PUT', payload),
deleteConfig: (type: ConfigType, id: number) => deleteJson(`/api/admin/config/${type}/${id}`), deleteConfig: (type: ConfigType, id: number) => deleteJson(`/api/admin/config/${type}/${id}`),
@@ -393,23 +395,27 @@ export const api = {
updatePokemon: (id: string | number, payload: PokemonPayload) => updatePokemon: (id: string | number, payload: PokemonPayload) =>
sendJson<PokemonDetail>(`/api/pokemon/${id}`, 'PUT', payload), sendJson<PokemonDetail>(`/api/pokemon/${id}`, 'PUT', payload),
deletePokemon: (id: string | number) => deleteJson(`/api/pokemon/${id}`), deletePokemon: (id: string | number) => deleteJson(`/api/pokemon/${id}`),
reorderPokemon: (ids: number[]) => sendJson<Pokemon[]>('/api/admin/pokemon/order', 'PUT', { ids }),
habitats: () => getJson<Habitat[]>('/api/habitats'), habitats: () => getJson<Habitat[]>('/api/habitats'),
habitatDetail: (id: string | number) => getJson<HabitatDetail>(`/api/habitats/${id}`), habitatDetail: (id: string | number) => getJson<HabitatDetail>(`/api/habitats/${id}`),
createHabitat: (payload: HabitatPayload) => sendJson<HabitatDetail>('/api/habitats', 'POST', payload), createHabitat: (payload: HabitatPayload) => sendJson<HabitatDetail>('/api/habitats', 'POST', payload),
updateHabitat: (id: string | number, payload: HabitatPayload) => updateHabitat: (id: string | number, payload: HabitatPayload) =>
sendJson<HabitatDetail>(`/api/habitats/${id}`, 'PUT', payload), sendJson<HabitatDetail>(`/api/habitats/${id}`, 'PUT', payload),
deleteHabitat: (id: string | number) => deleteJson(`/api/habitats/${id}`), 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>) => items: (params: Record<string, string | number | undefined>) =>
getJson<Item[]>(`/api/items${buildQuery(params)}`), getJson<Item[]>(`/api/items${buildQuery(params)}`),
itemDetail: (id: string | number) => getJson<ItemDetail>(`/api/items/${id}`), itemDetail: (id: string | number) => getJson<ItemDetail>(`/api/items/${id}`),
createItem: (payload: ItemPayload) => sendJson<ItemDetail>('/api/items', 'POST', payload), createItem: (payload: ItemPayload) => sendJson<ItemDetail>('/api/items', 'POST', payload),
updateItem: (id: string | number, payload: ItemPayload) => sendJson<ItemDetail>(`/api/items/${id}`, 'PUT', payload), updateItem: (id: string | number, payload: ItemPayload) => sendJson<ItemDetail>(`/api/items/${id}`, 'PUT', payload),
deleteItem: (id: string | number) => deleteJson(`/api/items/${id}`), 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> = {}) => recipes: (params: Record<string, string | number | undefined> = {}) =>
getJson<Recipe[]>(`/api/recipes${buildQuery(params)}`), getJson<Recipe[]>(`/api/recipes${buildQuery(params)}`),
recipeDetail: (id: string | number) => getJson<RecipeDetail>(`/api/recipes/${id}`), recipeDetail: (id: string | number) => getJson<RecipeDetail>(`/api/recipes/${id}`),
createRecipe: (payload: RecipePayload) => sendJson<RecipeDetail>('/api/recipes', 'POST', payload), createRecipe: (payload: RecipePayload) => sendJson<RecipeDetail>('/api/recipes', 'POST', payload),
updateRecipe: (id: string | number, payload: RecipePayload) => updateRecipe: (id: string | number, payload: RecipePayload) =>
sendJson<RecipeDetail>(`/api/recipes/${id}`, 'PUT', payload), 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 })
}; };

View File

@@ -106,6 +106,16 @@ const checklistKey = (item: DailyChecklistItem) => item.id;
const checklistLabel = (item: DailyChecklistItem) => item.title; const checklistLabel = (item: DailyChecklistItem) => item.title;
const languageKey = (item: Language) => item.code; const languageKey = (item: Language) => item.code;
const languageLabel = (item: Language) => item.name; 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) { function dragSortLabel(name: string) {
return t('pages.admin.dragSort', { name }); return t('pages.admin.dragSort', { name });
@@ -203,6 +213,26 @@ function previewLanguageOrder(rows: Language[]) {
languageRows.value = rows; 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[]) { async function persistChecklistOrder(nextRows: DailyChecklistItem[], fallbackRows: DailyChecklistItem[]) {
checklistRows.value = nextRows; checklistRows.value = nextRows;
await run(async () => { 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() { async function saveConfig() {
await run(async () => { await run(async () => {
const payload = { const payload = {
@@ -516,15 +606,28 @@ onMounted(() => {
</form> </form>
<h3 class="section-subtitle">{{ selectedConfig.label }}</h3> <h3 class="section-subtitle">{{ selectedConfig.label }}</h3>
<ul v-if="configRows.length" class="row-list"> <ReorderableList
<li v-for="item in configRows" :key="item.id"> v-if="configRows.length"
<span>{{ item.name }}<span v-if="item.hasItemDrop" class="config-flag">{{ t('pages.admin.hasItemDrop') }}</span></span> :items="configRows"
<span class="row-actions"> :item-key="configKey"
<button type="button" @click="editConfig(item)">{{ t('common.edit') }}</button> :item-label="configLabel"
<button type="button" @click="removeConfig(item.id)">{{ t('common.delete') }}</button> :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> </span>
</li> <span class="row-actions">
</ul> <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> <p v-else class="meta-line">{{ t('common.noRecords') }}</p>
</section> </section>
@@ -582,53 +685,97 @@ onMounted(() => {
<section v-else-if="canEdit && activeTab === 'pokemon'" class="detail-section"> <section v-else-if="canEdit && activeTab === 'pokemon'" class="detail-section">
<h2>{{ t('pages.admin.pokemonList') }}</h2> <h2>{{ t('pages.admin.pokemonList') }}</h2>
<ul v-if="pokemonRows.length" class="row-list"> <ReorderableList
<li v-for="item in pokemonRows" :key="item.id"> 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> <RouterLink :to="`/pokemon/${item.id}`">#{{ item.id }} {{ item.name }}</RouterLink>
<span class="row-actions"> <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> </span>
</li> </template>
</ul> </ReorderableList>
<p v-else class="meta-line">{{ t('common.noRecords') }}</p> <p v-else class="meta-line">{{ t('common.noRecords') }}</p>
</section> </section>
<section v-else-if="canEdit && activeTab === 'items'" class="detail-section"> <section v-else-if="canEdit && activeTab === 'items'" class="detail-section">
<h2>{{ t('pages.admin.itemList') }}</h2> <h2>{{ t('pages.admin.itemList') }}</h2>
<ul v-if="itemRows.length" class="row-list"> <ReorderableList
<li v-for="item in itemRows" :key="item.id"> 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> <RouterLink :to="`/items/${item.id}`">{{ item.name }}</RouterLink>
<span class="row-actions"> <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> </span>
</li> </template>
</ul> </ReorderableList>
<p v-else class="meta-line">{{ t('common.noRecords') }}</p> <p v-else class="meta-line">{{ t('common.noRecords') }}</p>
</section> </section>
<section v-else-if="canEdit && activeTab === 'recipes'" class="detail-section"> <section v-else-if="canEdit && activeTab === 'recipes'" class="detail-section">
<h2>{{ t('pages.admin.recipeList') }}</h2> <h2>{{ t('pages.admin.recipeList') }}</h2>
<ul v-if="recipeRows.length" class="row-list"> <ReorderableList
<li v-for="item in recipeRows" :key="item.id"> 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> <RouterLink :to="`/recipes/${item.id}`">{{ item.name }}</RouterLink>
<span class="row-actions"> <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> </span>
</li> </template>
</ul> </ReorderableList>
<p v-else class="meta-line">{{ t('common.noRecords') }}</p> <p v-else class="meta-line">{{ t('common.noRecords') }}</p>
</section> </section>
<section v-else-if="canEdit && activeTab === 'habitats'" class="detail-section"> <section v-else-if="canEdit && activeTab === 'habitats'" class="detail-section">
<h2>{{ t('pages.admin.habitatList') }}</h2> <h2>{{ t('pages.admin.habitatList') }}</h2>
<ul v-if="habitatRows.length" class="row-list"> <ReorderableList
<li v-for="item in habitatRows" :key="item.id"> 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> <RouterLink :to="`/habitats/${item.id}`">{{ item.name }}</RouterLink>
<span class="row-actions"> <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> </span>
</li> </template>
</ul> </ReorderableList>
<p v-else class="meta-line">{{ t('common.noRecords') }}</p> <p v-else class="meta-line">{{ t('common.noRecords') }}</p>
</section> </section>
</section> </section>

View File

@@ -92,7 +92,7 @@ const pokemonRows = computed<PokemonRow[]>(() => {
timeOfDays: sortByOrder(row.timeOfDays, timeOfDays), timeOfDays: sortByOrder(row.timeOfDays, timeOfDays),
weathers: sortByOrder(row.weathers, weathers), weathers: sortByOrder(row.weathers, weathers),
rarity: row.rarity, rarity: row.rarity,
maps: [...row.maps].sort((a, b) => a.localeCompare(b)) maps: [...row.maps]
})); }));
}); });

View File

@@ -94,7 +94,7 @@ const habitatRows = computed<HabitatRow[]>(() => {
timeOfDays: sortByOrder(row.timeOfDays, timeOfDays), timeOfDays: sortByOrder(row.timeOfDays, timeOfDays),
weathers: sortByOrder(row.weathers, weathers), weathers: sortByOrder(row.weathers, weathers),
rarity: row.rarity, 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) ?? []); 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); categories.set(String(item.category.id), item.category.name);
}); });
const tabs = [...categories.entries()] const tabs = [...categories.entries()].map(([value, label]) => ({ value, label }));
.sort(([, nameA], [, nameB]) => nameA.localeCompare(nameB))
.map(([value, label]) => ({ value, label }));
return tabs.length > 1 ? [{ value: '', label: t('common.all') }, ...tabs] : []; return tabs.length > 1 ? [{ value: '', label: t('common.all') }, ...tabs] : [];
}); });

View File

@@ -32,7 +32,8 @@ const itemQuery = computed(() => ({
search: search.value, search: search.value,
categoryId: categoryId.value, categoryId: categoryId.value,
usageId: usageId.value, usageId: usageId.value,
tagIds: tagIds.value.join(',') tagIds: tagIds.value.join(','),
recipeOrder: 1
})); }));
function recipeTarget(item: Item) { function recipeTarget(item: Item) {