feat(acquisition): add category grouping for acquisition methods
Add category field to acquisition_methods table with 'General' default Group acquisition methods by category in item and recipe detail views Enable category management in admin configuration
This commit is contained in:
@@ -100,6 +100,7 @@ export interface NamedEntity {
|
||||
id: number;
|
||||
name: string;
|
||||
baseName?: string;
|
||||
category?: string;
|
||||
description?: string;
|
||||
opposite?: NamedEntity | null;
|
||||
translations?: TranslationMap;
|
||||
@@ -1626,7 +1627,7 @@ export const api = {
|
||||
config: (type: ConfigType) => getJson<Array<Skill | GameVersion | NamedEntity>>(`/api/admin/config/${type}`),
|
||||
createConfig: (
|
||||
type: ConfigType,
|
||||
payload: { name: string; translations?: TranslationMap; description?: string; oppositeId?: number | null; hasItemDrop?: boolean; hasTrading?: boolean; changeLog?: string }
|
||||
payload: { name: string; translations?: TranslationMap; category?: string; description?: string; oppositeId?: number | null; hasItemDrop?: boolean; hasTrading?: boolean; changeLog?: string }
|
||||
) =>
|
||||
sendJson<Skill | GameVersion | NamedEntity>(`/api/admin/config/${type}`, 'POST', payload),
|
||||
reorderConfig: (type: ConfigType, ids: number[]) =>
|
||||
@@ -1634,7 +1635,7 @@ export const api = {
|
||||
updateConfig: (
|
||||
type: ConfigType,
|
||||
id: number,
|
||||
payload: { name: string; translations?: TranslationMap; description?: string; oppositeId?: number | null; hasItemDrop?: boolean; hasTrading?: boolean; changeLog?: string }
|
||||
payload: { name: string; translations?: TranslationMap; category?: string; description?: string; oppositeId?: number | null; hasItemDrop?: boolean; hasTrading?: boolean; changeLog?: string }
|
||||
) =>
|
||||
sendJson<Skill | GameVersion | NamedEntity>(`/api/admin/config/${type}/${id}`, 'PUT', payload),
|
||||
deleteConfig: (type: ConfigType, id: number) => deleteJson(`/api/admin/config/${type}/${id}`),
|
||||
|
||||
@@ -5738,6 +5738,50 @@ button:disabled,
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.entity-method-table {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line);
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
border-radius: var(--radius-card);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.entity-method-table th,
|
||||
.entity-method-table td {
|
||||
padding: 10px 12px;
|
||||
border-top: 1px solid var(--line);
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.entity-method-table tr:first-child th,
|
||||
.entity-method-table tr:first-child td {
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.entity-method-table th {
|
||||
width: 32%;
|
||||
background: var(--surface-soft);
|
||||
color: var(--muted);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 900;
|
||||
line-height: 1.25;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.entity-method-table td {
|
||||
color: var(--ink);
|
||||
font-weight: 850;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.entity-method-table td span {
|
||||
display: block;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.entity-profile-title-link {
|
||||
justify-self: center;
|
||||
color: var(--pokemon-blue-deep);
|
||||
|
||||
@@ -96,6 +96,7 @@ type AdminGroup = 'content' | 'configuration' | 'localization' | 'access';
|
||||
type AdminNavItem = { key: AdminTab; label: string; permission: string | string[] };
|
||||
type AdminNavGroup = { key: AdminGroup; label: string; items: AdminNavItem[] };
|
||||
type EditableConfig = (NamedEntity | Skill | GameVersion) & {
|
||||
category?: string;
|
||||
description?: string;
|
||||
opposite?: NamedEntity | null;
|
||||
hasItemDrop?: boolean;
|
||||
@@ -210,6 +211,7 @@ const configTypes = computed<
|
||||
supportsItemDrop?: boolean;
|
||||
supportsTrading?: boolean;
|
||||
supportsChangeLog?: boolean;
|
||||
supportsCategory?: boolean;
|
||||
supportsDescription?: boolean;
|
||||
supportsOpposite?: boolean;
|
||||
}>
|
||||
@@ -218,7 +220,7 @@ const configTypes = computed<
|
||||
{ key: 'skills', label: t('config.skills'), supportsItemDrop: true, supportsTrading: true },
|
||||
{ key: 'environments', label: t('config.environments'), supportsDescription: true, supportsOpposite: true },
|
||||
{ key: 'favorite-things', label: t('config.favoriteThings'), supportsOpposite: true },
|
||||
{ key: 'acquisition-methods', label: t('config.acquisitionMethods') },
|
||||
{ key: 'acquisition-methods', label: t('config.acquisitionMethods'), supportsCategory: true },
|
||||
{ key: 'maps', label: t('config.maps') },
|
||||
{ key: 'game-versions', label: t('config.gameVersions'), supportsChangeLog: true },
|
||||
{ key: 'dish-flavors', label: t('config.dishFlavors') }
|
||||
@@ -254,6 +256,7 @@ const message = ref('');
|
||||
const configForm = ref({
|
||||
id: 0,
|
||||
name: '',
|
||||
category: 'General',
|
||||
description: '',
|
||||
oppositeId: '',
|
||||
translations: {} as TranslationMap,
|
||||
@@ -632,7 +635,7 @@ async function loadModuleSettings() {
|
||||
}
|
||||
|
||||
function resetConfigForm() {
|
||||
configForm.value = { id: 0, name: '', description: '', oppositeId: '', translations: {}, hasItemDrop: false, hasTrading: false, changeLog: '' };
|
||||
configForm.value = { id: 0, name: '', category: 'General', description: '', oppositeId: '', translations: {}, hasItemDrop: false, hasTrading: false, changeLog: '' };
|
||||
}
|
||||
|
||||
function resetChecklistForm() {
|
||||
@@ -740,6 +743,7 @@ function editConfig(item: EditableConfig) {
|
||||
configForm.value = {
|
||||
id: item.id,
|
||||
name: item.baseName ?? item.name,
|
||||
category: item.category ?? 'General',
|
||||
description: item.description ?? '',
|
||||
oppositeId: item.opposite ? String(item.opposite.id) : '',
|
||||
translations: item.translations ?? {},
|
||||
@@ -1143,6 +1147,7 @@ async function saveConfig() {
|
||||
const payload = {
|
||||
name: configBaseNameForSave(),
|
||||
translations: configForm.value.translations,
|
||||
category: selectedConfig.value.supportsCategory ? configForm.value.category : undefined,
|
||||
description: selectedConfig.value.supportsDescription ? configForm.value.description : undefined,
|
||||
oppositeId: selectedConfig.value.supportsOpposite && configForm.value.oppositeId ? Number(configForm.value.oppositeId) : null,
|
||||
hasItemDrop: selectedConfig.value.supportsItemDrop ? configForm.value.hasItemDrop : undefined,
|
||||
@@ -2231,6 +2236,7 @@ onMounted(() => {
|
||||
<template #default="{ item }">
|
||||
<span class="reorderable-row-title">
|
||||
{{ item.name }}
|
||||
<span v-if="selectedConfig.supportsCategory" class="config-flag">{{ item.category ?? 'General' }}</span>
|
||||
<span v-if="item.opposite" class="config-flag">{{ t('pages.admin.opposite') }}: {{ item.opposite.name }}</span>
|
||||
<span v-if="item.hasItemDrop" class="config-flag">{{ t('pages.admin.hasItemDrop') }}</span>
|
||||
<span v-if="tradingModuleEnabled && item.hasTrading" class="config-flag">{{ t('pages.admin.hasTrading') }}</span>
|
||||
@@ -3064,6 +3070,10 @@ onMounted(() => {
|
||||
<label for="config-name">{{ t('common.name') }}</label>
|
||||
<input id="config-name" v-model="configNameInput" :placeholder="configNamePlaceholder" :required="configNameRequired" />
|
||||
</div>
|
||||
<div v-if="selectedConfig.supportsCategory" class="field">
|
||||
<label for="config-category">{{ t('pages.admin.category') }}</label>
|
||||
<input id="config-category" v-model="configForm.category" />
|
||||
</div>
|
||||
<div v-if="selectedConfig.supportsDescription" class="field">
|
||||
<label for="config-description">{{ t('pages.admin.description') }}</label>
|
||||
<textarea id="config-description" v-model="configForm.description"></textarea>
|
||||
|
||||
@@ -13,7 +13,7 @@ import Skeleton from '../components/Skeleton.vue';
|
||||
import Tabs, { type TabOption } from '../components/Tabs.vue';
|
||||
import { iconAdd, iconBack, iconEdit, iconHabitat, iconItem } from '../icons';
|
||||
import { applySeo, resolvedSeoHead, resolveSeo } from '../seo';
|
||||
import { api, type AuthUser, type ItemDetail, type ModuleSettings } from '../services/api';
|
||||
import { api, type AuthUser, type ItemDetail, type ModuleSettings, type NamedEntity } from '../services/api';
|
||||
import ItemEdit from './ItemEdit.vue';
|
||||
|
||||
const route = useRoute();
|
||||
@@ -72,6 +72,7 @@ const possibleTagEvidenceSections = computed(() => [
|
||||
{ key: 'likes', title: t('pages.pokemon.tradingLikes'), rows: item.value?.possibleTags?.evidence.likes ?? [] },
|
||||
{ key: 'neutral', title: t('pages.pokemon.tradingNeutral'), rows: item.value?.possibleTags?.evidence.neutral ?? [] }
|
||||
]);
|
||||
const acquisitionMethodGroups = computed(() => groupAcquisitionMethods(item.value?.acquisitionMethods ?? []));
|
||||
|
||||
const { data: moduleSettings } = useAsyncData<ModuleSettings>(
|
||||
'module-settings',
|
||||
@@ -185,6 +186,17 @@ function activeItemRouteId(): string | null {
|
||||
: null;
|
||||
}
|
||||
|
||||
function groupAcquisitionMethods(methods: NamedEntity[]) {
|
||||
const groups = new Map<string, NamedEntity[]>();
|
||||
|
||||
for (const method of methods) {
|
||||
const category = method.category?.trim() || t('common.none');
|
||||
groups.set(category, [...(groups.get(category) ?? []), method]);
|
||||
}
|
||||
|
||||
return [...groups.entries()].map(([category, values]) => ({ category, values }));
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
currentUser.value = (await api.me()).user;
|
||||
@@ -341,7 +353,16 @@ watch(initialItem, applyInitialItem, { immediate: true });
|
||||
</div>
|
||||
<div class="entity-profile-group">
|
||||
<h3 class="section-subtitle">{{ t('pages.items.acquisitionMethods') }}</h3>
|
||||
<EntityChips v-if="item.acquisitionMethods.length" :items="item.acquisitionMethods" />
|
||||
<table v-if="acquisitionMethodGroups.length" class="entity-method-table">
|
||||
<tbody>
|
||||
<tr v-for="group in acquisitionMethodGroups" :key="group.category">
|
||||
<th scope="row">{{ group.category }}</th>
|
||||
<td>
|
||||
<span v-for="method in group.values" :key="method.id">{{ method.name }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p v-else class="meta-line">{{ t('common.none') }}</p>
|
||||
</div>
|
||||
<div class="entity-profile-group">
|
||||
|
||||
@@ -12,7 +12,7 @@ import Skeleton from '../components/Skeleton.vue';
|
||||
import Tabs, { type TabOption } from '../components/Tabs.vue';
|
||||
import { iconBack, iconEdit, iconRecipe } from '../icons';
|
||||
import { applySeo, resolvedSeoHead, resolveSeo } from '../seo';
|
||||
import { api, type AuthUser, type RecipeDetail } from '../services/api';
|
||||
import { api, type AuthUser, type NamedEntity, type RecipeDetail } from '../services/api';
|
||||
import RecipeEdit from './RecipeEdit.vue';
|
||||
|
||||
const route = useRoute();
|
||||
@@ -41,6 +41,7 @@ const recipeSubtitle = computed(() => {
|
||||
|
||||
return categoryName ?? t('pages.recipes.detailSubtitle');
|
||||
});
|
||||
const acquisitionMethodGroups = computed(() => groupAcquisitionMethods(recipe.value?.acquisition_methods ?? []));
|
||||
|
||||
const { data: initialRecipe } = useAsyncData<RecipeDetail | null>(
|
||||
`recipe-detail:${String(route.params.id)}:${locale.value}`,
|
||||
@@ -75,6 +76,17 @@ function applyInitialRecipe(value: RecipeDetail | null | undefined) {
|
||||
initialRecipeLoaded.value = true;
|
||||
}
|
||||
|
||||
function groupAcquisitionMethods(methods: NamedEntity[]) {
|
||||
const groups = new Map<string, NamedEntity[]>();
|
||||
|
||||
for (const method of methods) {
|
||||
const category = method.category?.trim() || t('common.none');
|
||||
groups.set(category, [...(groups.get(category) ?? []), method]);
|
||||
}
|
||||
|
||||
return [...groups.entries()].map(([category, values]) => ({ category, values }));
|
||||
}
|
||||
|
||||
async function loadRecipeDetail() {
|
||||
try {
|
||||
const nextRecipe = await api.recipeDetail(String(route.params.id));
|
||||
@@ -214,7 +226,16 @@ watch(initialRecipe, applyInitialRecipe, { immediate: true });
|
||||
<div class="entity-profile-groups">
|
||||
<div class="entity-profile-group">
|
||||
<h3 class="section-subtitle">{{ t('pages.items.acquisitionMethods') }}</h3>
|
||||
<EntityChips v-if="recipe.acquisition_methods.length" :items="recipe.acquisition_methods" />
|
||||
<table v-if="acquisitionMethodGroups.length" class="entity-method-table">
|
||||
<tbody>
|
||||
<tr v-for="group in acquisitionMethodGroups" :key="group.category">
|
||||
<th scope="row">{{ group.category }}</th>
|
||||
<td>
|
||||
<span v-for="method in group.values" :key="method.id">{{ method.name }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p v-else class="meta-line">{{ t('common.none') }}</p>
|
||||
</div>
|
||||
<div class="entity-profile-group">
|
||||
|
||||
Reference in New Issue
Block a user