diff --git a/DESIGN.md b/DESIGN.md index 1294820..d698bfa 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -65,6 +65,10 @@ Pokemon 可配置: - 配方(物品,数量) - 可出现的宝可梦(可多选) +列表顺序: +- 全局配置项、Pokemon、物品、材料单、地图、栖息地均可自定义排序 +- 初始排序按创建时间旧到新 + 出现契机 - 时间:早晨 / 中午 / 傍晚 / 晚上 - 天气:晴天 / 阴天 / 雨天 diff --git a/backend/db/schema.sql b/backend/db/schema.sql index 8ef8e1f..cb22a4e 100644 --- a/backend/db/schema.sql +++ b/backend/db/schema.sql @@ -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, diff --git a/backend/src/queries.ts b/backend/src/queries.ts index 32f60cb..675196f 100644 --- a/backend/src/queries.ts +++ b/backend/src/queries.ts @@ -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 = { maps: { table: 'maps', entityType: 'maps' } }; +const sortableContentDefinitions: Record = { + 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> { 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> { 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(callback: (client: DbClient) => Promise): Promise { const client = await pool.connect(); @@ -413,6 +429,42 @@ async function withTransaction(callback: (client: DbClient) => Promise): P } } +async function nextSortOrder(client: DbClient, tableName: string): Promise { + 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 { + 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 { + 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, 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, userId: number): Promise { + 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, userId: number, locale = defaultLocale) { + await reorderContent('pokemon', payload, userId); + return listPokemon({}, locale); +} + +export async function reorderItems(payload: Record, userId: number, locale = defaultLocale) { + await reorderContent('items', payload, userId); + return listItems({}, locale); +} + +export async function reorderRecipes(payload: Record, userId: number, locale = defaultLocale) { + await reorderContent('recipes', payload, userId); + return listRecipes({}, locale); +} + +export async function reorderHabitats(payload: Record, 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, 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, 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, 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, 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, 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, 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); diff --git a/backend/src/server.ts b/backend/src/server.ts index 10bb5f5..61e2678 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -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, 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, 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, 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, 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, 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, user.id, requestLocale(request)); +}); + app.put('/api/admin/config/:type/:id', async (request, reply) => { const user = await requireVerifiedUser(request, reply); if (!user) { diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 02344f7..9d99d27 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -383,6 +383,8 @@ export const api = { config: (type: ConfigType) => getJson>(`/api/admin/config/${type}`), createConfig: (type: ConfigType, payload: { name: string; translations?: TranslationMap; hasItemDrop?: boolean }) => sendJson(`/api/admin/config/${type}`, 'POST', payload), + reorderConfig: (type: ConfigType, ids: number[]) => + sendJson>(`/api/admin/config/${type}/order`, 'PUT', { ids }), updateConfig: (type: ConfigType, id: number, payload: { name: string; translations?: TranslationMap; hasItemDrop?: boolean }) => sendJson(`/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(`/api/pokemon/${id}`, 'PUT', payload), deletePokemon: (id: string | number) => deleteJson(`/api/pokemon/${id}`), + reorderPokemon: (ids: number[]) => sendJson('/api/admin/pokemon/order', 'PUT', { ids }), habitats: () => getJson('/api/habitats'), habitatDetail: (id: string | number) => getJson(`/api/habitats/${id}`), createHabitat: (payload: HabitatPayload) => sendJson('/api/habitats', 'POST', payload), updateHabitat: (id: string | number, payload: HabitatPayload) => sendJson(`/api/habitats/${id}`, 'PUT', payload), deleteHabitat: (id: string | number) => deleteJson(`/api/habitats/${id}`), + reorderHabitats: (ids: number[]) => sendJson('/api/admin/habitats/order', 'PUT', { ids }), items: (params: Record) => getJson(`/api/items${buildQuery(params)}`), itemDetail: (id: string | number) => getJson(`/api/items/${id}`), createItem: (payload: ItemPayload) => sendJson('/api/items', 'POST', payload), updateItem: (id: string | number, payload: ItemPayload) => sendJson(`/api/items/${id}`, 'PUT', payload), deleteItem: (id: string | number) => deleteJson(`/api/items/${id}`), + reorderItems: (ids: number[]) => sendJson('/api/admin/items/order', 'PUT', { ids }), recipes: (params: Record = {}) => getJson(`/api/recipes${buildQuery(params)}`), recipeDetail: (id: string | number) => getJson(`/api/recipes/${id}`), createRecipe: (payload: RecipePayload) => sendJson('/api/recipes', 'POST', payload), updateRecipe: (id: string | number, payload: RecipePayload) => sendJson(`/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('/api/admin/recipes/order', 'PUT', { ids }) }; diff --git a/frontend/src/views/AdminView.vue b/frontend/src/views/AdminView.vue index 26affed..80c6b51 100644 --- a/frontend/src/views/AdminView.vue +++ b/frontend/src/views/AdminView.vue @@ -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(() => {

{{ selectedConfig.label }}

-
    -
  • - {{ item.name }}{{ t('pages.admin.hasItemDrop') }} - - - + + +

    {{ t('common.noRecords') }}

    @@ -582,53 +685,97 @@ onMounted(() => {

    {{ t('pages.admin.pokemonList') }}

    -
      -
    • + + +

      {{ t('common.noRecords') }}

    {{ t('pages.admin.itemList') }}

    -
      -
    • + + +

      {{ t('common.noRecords') }}

    {{ t('pages.admin.recipeList') }}

    -
      -
    • + + +

      {{ t('common.noRecords') }}

    {{ t('pages.admin.habitatList') }}

    -
      -
    • + + +

      {{ t('common.noRecords') }}

    diff --git a/frontend/src/views/HabitatDetail.vue b/frontend/src/views/HabitatDetail.vue index fec0204..427544e 100644 --- a/frontend/src/views/HabitatDetail.vue +++ b/frontend/src/views/HabitatDetail.vue @@ -92,7 +92,7 @@ const pokemonRows = computed(() => { 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] })); }); diff --git a/frontend/src/views/PokemonDetail.vue b/frontend/src/views/PokemonDetail.vue index ccdc17a..611ce30 100644 --- a/frontend/src/views/PokemonDetail.vue +++ b/frontend/src/views/PokemonDetail.vue @@ -94,7 +94,7 @@ const habitatRows = computed(() => { 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(() => { 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] : []; }); diff --git a/frontend/src/views/RecipeList.vue b/frontend/src/views/RecipeList.vue index 58c4dcc..2793aa6 100644 --- a/frontend/src/views/RecipeList.vue +++ b/frontend/src/views/RecipeList.vue @@ -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) {