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:
@@ -383,6 +383,8 @@ export const api = {
|
||||
config: (type: ConfigType) => getJson<Array<Skill | NamedEntity>>(`/api/admin/config/${type}`),
|
||||
createConfig: (type: ConfigType, payload: { name: string; translations?: TranslationMap; hasItemDrop?: boolean }) =>
|
||||
sendJson<Skill | NamedEntity>(`/api/admin/config/${type}`, 'POST', payload),
|
||||
reorderConfig: (type: ConfigType, ids: number[]) =>
|
||||
sendJson<Array<Skill | NamedEntity>>(`/api/admin/config/${type}/order`, 'PUT', { ids }),
|
||||
updateConfig: (type: ConfigType, id: number, payload: { name: string; translations?: TranslationMap; hasItemDrop?: boolean }) =>
|
||||
sendJson<Skill | NamedEntity>(`/api/admin/config/${type}/${id}`, 'PUT', payload),
|
||||
deleteConfig: (type: ConfigType, id: number) => deleteJson(`/api/admin/config/${type}/${id}`),
|
||||
@@ -393,23 +395,27 @@ export const api = {
|
||||
updatePokemon: (id: string | number, payload: PokemonPayload) =>
|
||||
sendJson<PokemonDetail>(`/api/pokemon/${id}`, 'PUT', payload),
|
||||
deletePokemon: (id: string | number) => deleteJson(`/api/pokemon/${id}`),
|
||||
reorderPokemon: (ids: number[]) => sendJson<Pokemon[]>('/api/admin/pokemon/order', 'PUT', { ids }),
|
||||
habitats: () => getJson<Habitat[]>('/api/habitats'),
|
||||
habitatDetail: (id: string | number) => getJson<HabitatDetail>(`/api/habitats/${id}`),
|
||||
createHabitat: (payload: HabitatPayload) => sendJson<HabitatDetail>('/api/habitats', 'POST', payload),
|
||||
updateHabitat: (id: string | number, payload: HabitatPayload) =>
|
||||
sendJson<HabitatDetail>(`/api/habitats/${id}`, 'PUT', payload),
|
||||
deleteHabitat: (id: string | number) => deleteJson(`/api/habitats/${id}`),
|
||||
reorderHabitats: (ids: number[]) => sendJson<Habitat[]>('/api/admin/habitats/order', 'PUT', { ids }),
|
||||
items: (params: Record<string, string | number | undefined>) =>
|
||||
getJson<Item[]>(`/api/items${buildQuery(params)}`),
|
||||
itemDetail: (id: string | number) => getJson<ItemDetail>(`/api/items/${id}`),
|
||||
createItem: (payload: ItemPayload) => sendJson<ItemDetail>('/api/items', 'POST', payload),
|
||||
updateItem: (id: string | number, payload: ItemPayload) => sendJson<ItemDetail>(`/api/items/${id}`, 'PUT', payload),
|
||||
deleteItem: (id: string | number) => deleteJson(`/api/items/${id}`),
|
||||
reorderItems: (ids: number[]) => sendJson<Item[]>('/api/admin/items/order', 'PUT', { ids }),
|
||||
recipes: (params: Record<string, string | number | undefined> = {}) =>
|
||||
getJson<Recipe[]>(`/api/recipes${buildQuery(params)}`),
|
||||
recipeDetail: (id: string | number) => getJson<RecipeDetail>(`/api/recipes/${id}`),
|
||||
createRecipe: (payload: RecipePayload) => sendJson<RecipeDetail>('/api/recipes', 'POST', payload),
|
||||
updateRecipe: (id: string | number, payload: RecipePayload) =>
|
||||
sendJson<RecipeDetail>(`/api/recipes/${id}`, 'PUT', payload),
|
||||
deleteRecipe: (id: string | number) => deleteJson(`/api/recipes/${id}`)
|
||||
deleteRecipe: (id: string | number) => deleteJson(`/api/recipes/${id}`),
|
||||
reorderRecipes: (ids: number[]) => sendJson<Recipe[]>('/api/admin/recipes/order', 'PUT', { ids })
|
||||
};
|
||||
|
||||
@@ -106,6 +106,16 @@ const checklistKey = (item: DailyChecklistItem) => item.id;
|
||||
const checklistLabel = (item: DailyChecklistItem) => item.title;
|
||||
const languageKey = (item: Language) => item.code;
|
||||
const languageLabel = (item: Language) => item.name;
|
||||
const configKey = (item: EditableConfig) => item.id;
|
||||
const configLabel = (item: EditableConfig) => item.name;
|
||||
const pokemonKey = (item: Pokemon) => item.id;
|
||||
const pokemonLabel = (item: Pokemon) => `#${item.id} ${item.name}`;
|
||||
const itemKey = (item: Item) => item.id;
|
||||
const itemLabel = (item: Item) => item.name;
|
||||
const recipeKey = (item: Recipe) => item.id;
|
||||
const recipeLabel = (item: Recipe) => item.name;
|
||||
const habitatKey = (item: Habitat) => item.id;
|
||||
const habitatLabel = (item: Habitat) => item.name;
|
||||
|
||||
function dragSortLabel(name: string) {
|
||||
return t('pages.admin.dragSort', { name });
|
||||
@@ -203,6 +213,26 @@ function previewLanguageOrder(rows: Language[]) {
|
||||
languageRows.value = rows;
|
||||
}
|
||||
|
||||
function previewConfigOrder(rows: EditableConfig[]) {
|
||||
configRows.value = rows;
|
||||
}
|
||||
|
||||
function previewPokemonOrder(rows: Pokemon[]) {
|
||||
pokemonRows.value = rows;
|
||||
}
|
||||
|
||||
function previewItemOrder(rows: Item[]) {
|
||||
itemRows.value = rows;
|
||||
}
|
||||
|
||||
function previewRecipeOrder(rows: Recipe[]) {
|
||||
recipeRows.value = rows;
|
||||
}
|
||||
|
||||
function previewHabitatOrder(rows: Habitat[]) {
|
||||
habitatRows.value = rows;
|
||||
}
|
||||
|
||||
async function persistChecklistOrder(nextRows: DailyChecklistItem[], fallbackRows: DailyChecklistItem[]) {
|
||||
checklistRows.value = nextRows;
|
||||
await run(async () => {
|
||||
@@ -228,6 +258,66 @@ async function persistLanguageOrder(nextRows: Language[], fallbackRows: Language
|
||||
});
|
||||
}
|
||||
|
||||
async function persistConfigOrder(nextRows: EditableConfig[], fallbackRows: EditableConfig[]) {
|
||||
configRows.value = nextRows;
|
||||
await run(async () => {
|
||||
try {
|
||||
configRows.value = (await api.reorderConfig(activeConfigType.value, nextRows.map((item) => item.id))) as EditableConfig[];
|
||||
} catch (error) {
|
||||
configRows.value = fallbackRows;
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function persistPokemonOrder(nextRows: Pokemon[], fallbackRows: Pokemon[]) {
|
||||
pokemonRows.value = nextRows;
|
||||
await run(async () => {
|
||||
try {
|
||||
pokemonRows.value = await api.reorderPokemon(nextRows.map((item) => item.id));
|
||||
} catch (error) {
|
||||
pokemonRows.value = fallbackRows;
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function persistItemOrder(nextRows: Item[], fallbackRows: Item[]) {
|
||||
itemRows.value = nextRows;
|
||||
await run(async () => {
|
||||
try {
|
||||
itemRows.value = await api.reorderItems(nextRows.map((item) => item.id));
|
||||
} catch (error) {
|
||||
itemRows.value = fallbackRows;
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function persistRecipeOrder(nextRows: Recipe[], fallbackRows: Recipe[]) {
|
||||
recipeRows.value = nextRows;
|
||||
await run(async () => {
|
||||
try {
|
||||
recipeRows.value = await api.reorderRecipes(nextRows.map((item) => item.id));
|
||||
} catch (error) {
|
||||
recipeRows.value = fallbackRows;
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function persistHabitatOrder(nextRows: Habitat[], fallbackRows: Habitat[]) {
|
||||
habitatRows.value = nextRows;
|
||||
await run(async () => {
|
||||
try {
|
||||
habitatRows.value = await api.reorderHabitats(nextRows.map((item) => item.id));
|
||||
} catch (error) {
|
||||
habitatRows.value = fallbackRows;
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function saveConfig() {
|
||||
await run(async () => {
|
||||
const payload = {
|
||||
@@ -516,15 +606,28 @@ onMounted(() => {
|
||||
</form>
|
||||
|
||||
<h3 class="section-subtitle">{{ selectedConfig.label }}</h3>
|
||||
<ul v-if="configRows.length" class="row-list">
|
||||
<li v-for="item in configRows" :key="item.id">
|
||||
<span>{{ item.name }}<span v-if="item.hasItemDrop" class="config-flag">{{ t('pages.admin.hasItemDrop') }}</span></span>
|
||||
<span class="row-actions">
|
||||
<button type="button" @click="editConfig(item)">{{ t('common.edit') }}</button>
|
||||
<button type="button" @click="removeConfig(item.id)">{{ t('common.delete') }}</button>
|
||||
<ReorderableList
|
||||
v-if="configRows.length"
|
||||
:items="configRows"
|
||||
:item-key="configKey"
|
||||
:item-label="configLabel"
|
||||
:disabled="busy"
|
||||
:handle-label="dragSortLabel"
|
||||
:handle-title="t('pages.admin.dragSortTitle')"
|
||||
@preview="previewConfigOrder"
|
||||
@cancel="previewConfigOrder"
|
||||
@reorder="persistConfigOrder"
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<span class="reorderable-row-title">
|
||||
{{ item.name }}<span v-if="item.hasItemDrop" class="config-flag">{{ t('pages.admin.hasItemDrop') }}</span>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
<span class="row-actions">
|
||||
<button type="button" :disabled="busy" @click="editConfig(item)">{{ t('common.edit') }}</button>
|
||||
<button type="button" :disabled="busy" @click="removeConfig(item.id)">{{ t('common.delete') }}</button>
|
||||
</span>
|
||||
</template>
|
||||
</ReorderableList>
|
||||
<p v-else class="meta-line">{{ t('common.noRecords') }}</p>
|
||||
</section>
|
||||
|
||||
@@ -582,53 +685,97 @@ onMounted(() => {
|
||||
|
||||
<section v-else-if="canEdit && activeTab === 'pokemon'" class="detail-section">
|
||||
<h2>{{ t('pages.admin.pokemonList') }}</h2>
|
||||
<ul v-if="pokemonRows.length" class="row-list">
|
||||
<li v-for="item in pokemonRows" :key="item.id">
|
||||
<ReorderableList
|
||||
v-if="pokemonRows.length"
|
||||
:items="pokemonRows"
|
||||
:item-key="pokemonKey"
|
||||
:item-label="pokemonLabel"
|
||||
:disabled="busy"
|
||||
:handle-label="dragSortLabel"
|
||||
:handle-title="t('pages.admin.dragSortTitle')"
|
||||
@preview="previewPokemonOrder"
|
||||
@cancel="previewPokemonOrder"
|
||||
@reorder="persistPokemonOrder"
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<RouterLink :to="`/pokemon/${item.id}`">#{{ item.id }} {{ item.name }}</RouterLink>
|
||||
<span class="row-actions">
|
||||
<button type="button" @click="removePokemon(item.id)">{{ t('common.delete') }}</button>
|
||||
<button type="button" :disabled="busy" @click="removePokemon(item.id)">{{ t('common.delete') }}</button>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
</ReorderableList>
|
||||
<p v-else class="meta-line">{{ t('common.noRecords') }}</p>
|
||||
</section>
|
||||
|
||||
<section v-else-if="canEdit && activeTab === 'items'" class="detail-section">
|
||||
<h2>{{ t('pages.admin.itemList') }}</h2>
|
||||
<ul v-if="itemRows.length" class="row-list">
|
||||
<li v-for="item in itemRows" :key="item.id">
|
||||
<ReorderableList
|
||||
v-if="itemRows.length"
|
||||
:items="itemRows"
|
||||
:item-key="itemKey"
|
||||
:item-label="itemLabel"
|
||||
:disabled="busy"
|
||||
:handle-label="dragSortLabel"
|
||||
:handle-title="t('pages.admin.dragSortTitle')"
|
||||
@preview="previewItemOrder"
|
||||
@cancel="previewItemOrder"
|
||||
@reorder="persistItemOrder"
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<RouterLink :to="`/items/${item.id}`">{{ item.name }}</RouterLink>
|
||||
<span class="row-actions">
|
||||
<button type="button" @click="removeItem(item.id)">{{ t('common.delete') }}</button>
|
||||
<button type="button" :disabled="busy" @click="removeItem(item.id)">{{ t('common.delete') }}</button>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
</ReorderableList>
|
||||
<p v-else class="meta-line">{{ t('common.noRecords') }}</p>
|
||||
</section>
|
||||
|
||||
<section v-else-if="canEdit && activeTab === 'recipes'" class="detail-section">
|
||||
<h2>{{ t('pages.admin.recipeList') }}</h2>
|
||||
<ul v-if="recipeRows.length" class="row-list">
|
||||
<li v-for="item in recipeRows" :key="item.id">
|
||||
<ReorderableList
|
||||
v-if="recipeRows.length"
|
||||
:items="recipeRows"
|
||||
:item-key="recipeKey"
|
||||
:item-label="recipeLabel"
|
||||
:disabled="busy"
|
||||
:handle-label="dragSortLabel"
|
||||
:handle-title="t('pages.admin.dragSortTitle')"
|
||||
@preview="previewRecipeOrder"
|
||||
@cancel="previewRecipeOrder"
|
||||
@reorder="persistRecipeOrder"
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<RouterLink :to="`/recipes/${item.id}`">{{ item.name }}</RouterLink>
|
||||
<span class="row-actions">
|
||||
<button type="button" @click="removeRecipe(item.id)">{{ t('common.delete') }}</button>
|
||||
<button type="button" :disabled="busy" @click="removeRecipe(item.id)">{{ t('common.delete') }}</button>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
</ReorderableList>
|
||||
<p v-else class="meta-line">{{ t('common.noRecords') }}</p>
|
||||
</section>
|
||||
|
||||
<section v-else-if="canEdit && activeTab === 'habitats'" class="detail-section">
|
||||
<h2>{{ t('pages.admin.habitatList') }}</h2>
|
||||
<ul v-if="habitatRows.length" class="row-list">
|
||||
<li v-for="item in habitatRows" :key="item.id">
|
||||
<ReorderableList
|
||||
v-if="habitatRows.length"
|
||||
:items="habitatRows"
|
||||
:item-key="habitatKey"
|
||||
:item-label="habitatLabel"
|
||||
:disabled="busy"
|
||||
:handle-label="dragSortLabel"
|
||||
:handle-title="t('pages.admin.dragSortTitle')"
|
||||
@preview="previewHabitatOrder"
|
||||
@cancel="previewHabitatOrder"
|
||||
@reorder="persistHabitatOrder"
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<RouterLink :to="`/habitats/${item.id}`">{{ item.name }}</RouterLink>
|
||||
<span class="row-actions">
|
||||
<button type="button" @click="removeHabitat(item.id)">{{ t('common.delete') }}</button>
|
||||
<button type="button" :disabled="busy" @click="removeHabitat(item.id)">{{ t('common.delete') }}</button>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
</ReorderableList>
|
||||
<p v-else class="meta-line">{{ t('common.noRecords') }}</p>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
@@ -92,7 +92,7 @@ const pokemonRows = computed<PokemonRow[]>(() => {
|
||||
timeOfDays: sortByOrder(row.timeOfDays, timeOfDays),
|
||||
weathers: sortByOrder(row.weathers, weathers),
|
||||
rarity: row.rarity,
|
||||
maps: [...row.maps].sort((a, b) => a.localeCompare(b))
|
||||
maps: [...row.maps]
|
||||
}));
|
||||
});
|
||||
|
||||
|
||||
@@ -94,7 +94,7 @@ const habitatRows = computed<HabitatRow[]>(() => {
|
||||
timeOfDays: sortByOrder(row.timeOfDays, timeOfDays),
|
||||
weathers: sortByOrder(row.weathers, weathers),
|
||||
rarity: row.rarity,
|
||||
maps: [...row.maps].sort((a, b) => a.localeCompare(b))
|
||||
maps: [...row.maps]
|
||||
}));
|
||||
});
|
||||
const skillDropRows = computed(() => pokemon.value?.skills.filter((skill) => skill.itemDrop) ?? []);
|
||||
@@ -105,9 +105,7 @@ const itemCategoryTabs = computed<TabOption[]>(() => {
|
||||
categories.set(String(item.category.id), item.category.name);
|
||||
});
|
||||
|
||||
const tabs = [...categories.entries()]
|
||||
.sort(([, nameA], [, nameB]) => nameA.localeCompare(nameB))
|
||||
.map(([value, label]) => ({ value, label }));
|
||||
const tabs = [...categories.entries()].map(([value, label]) => ({ value, label }));
|
||||
|
||||
return tabs.length > 1 ? [{ value: '', label: t('common.all') }, ...tabs] : [];
|
||||
});
|
||||
|
||||
@@ -32,7 +32,8 @@ const itemQuery = computed(() => ({
|
||||
search: search.value,
|
||||
categoryId: categoryId.value,
|
||||
usageId: usageId.value,
|
||||
tagIds: tagIds.value.join(',')
|
||||
tagIds: tagIds.value.join(','),
|
||||
recipeOrder: 1
|
||||
}));
|
||||
|
||||
function recipeTarget(item: Item) {
|
||||
|
||||
Reference in New Issue
Block a user