Add dyeability integer field to support up to triple dyeable items Update frontend forms to use a radio group for dyeability selection
526 lines
18 KiB
Vue
526 lines
18 KiB
Vue
<script setup lang="ts">
|
|
import { Icon } from '@iconify/vue';
|
|
import { computed, onMounted, ref } from 'vue';
|
|
import { useI18n } from 'vue-i18n';
|
|
import { useRoute, useRouter } from 'vue-router';
|
|
import ImageUploadField from '../components/ImageUploadField.vue';
|
|
import Modal from '../components/Modal.vue';
|
|
import Skeleton from '../components/Skeleton.vue';
|
|
import StatusMessage from '../components/StatusMessage.vue';
|
|
import TagsSelect from '../components/TagsSelect.vue';
|
|
import TranslationFields from '../components/TranslationFields.vue';
|
|
import { iconCancel, iconSave } from '../icons';
|
|
import {
|
|
api,
|
|
type AuthUser,
|
|
type ConfigType,
|
|
type EntityImage,
|
|
type EntityImageUpload,
|
|
type ItemPayload,
|
|
type Language,
|
|
type Options,
|
|
type TranslationMap
|
|
} from '../services/api';
|
|
|
|
const route = useRoute();
|
|
const router = useRouter();
|
|
const { locale, t } = useI18n();
|
|
const options = ref<Options | null>(null);
|
|
const languages = ref<Language[]>([]);
|
|
const currentUser = ref<AuthUser | null>(null);
|
|
const currentImage = ref<EntityImage | null>(null);
|
|
const imageHistory = ref<EntityImageUpload[]>([]);
|
|
const loading = ref(true);
|
|
const busy = ref(false);
|
|
const message = ref('');
|
|
const creatingSelect = ref('');
|
|
|
|
type Dyeability = 0 | 1 | 2 | 3;
|
|
|
|
const itemForm = ref({
|
|
name: '',
|
|
details: '',
|
|
basePrice: '',
|
|
ancientArtifactCategoryId: '',
|
|
translations: {} as TranslationMap,
|
|
categoryId: '',
|
|
usageId: '',
|
|
dyeability: 0 as Dyeability,
|
|
patternEditable: false,
|
|
noRecipe: false,
|
|
isEventItem: false,
|
|
acquisitionMethodIds: [] as string[],
|
|
tagIds: [] as string[],
|
|
imagePath: ''
|
|
});
|
|
|
|
type ItemCreateDefaults = {
|
|
categoryId: string;
|
|
usageId: string;
|
|
dyeability: Dyeability;
|
|
patternEditable: boolean;
|
|
noRecipe: boolean;
|
|
acquisitionMethodIds: string[];
|
|
};
|
|
|
|
const itemCreateDefaultsStorageKey = 'pokopia_item_create_defaults';
|
|
|
|
const routeId = computed(() => (typeof route.params.id === 'string' ? route.params.id : ''));
|
|
const isEditing = computed(() => routeId.value !== '');
|
|
const isEventCreate = computed(() => route.name === 'event-item-new');
|
|
const isAncientArtifactRoute = computed(() => route.name === 'ancient-artifact-new' || route.name === 'ancient-artifact-edit');
|
|
const isAncientArtifactCreate = computed(() => route.name === 'ancient-artifact-new');
|
|
const insertBeforeItemId = computed(() => queryItemId(route.query.insertBeforeItemId));
|
|
const insertAfterItemId = computed(() => queryItemId(route.query.insertAfterItemId));
|
|
const pageTitle = computed(() =>
|
|
isEditing.value
|
|
? isAncientArtifactRoute.value
|
|
? t('pages.ancientArtifacts.editTitle', { name: itemForm.value.name || t('pages.ancientArtifacts.fallbackName') })
|
|
: t('pages.items.editTitle', { name: itemForm.value.name || t('pages.items.fallbackName') })
|
|
: isAncientArtifactCreate.value
|
|
? t('pages.ancientArtifacts.newTitle')
|
|
: isEventCreate.value
|
|
? t('pages.eventItems.newTitle')
|
|
: t('pages.items.newTitle')
|
|
);
|
|
const pageSubtitle = computed(() => (isAncientArtifactRoute.value ? t('pages.ancientArtifacts.editSubtitle') : t('pages.items.editSubtitle')));
|
|
const cancelTo = computed(() =>
|
|
isEditing.value
|
|
? isAncientArtifactRoute.value
|
|
? `/ancient-artifacts/${routeId.value}`
|
|
: `/items/${routeId.value}`
|
|
: isAncientArtifactCreate.value
|
|
? '/ancient-artifacts'
|
|
: isEventCreate.value
|
|
? '/event-items'
|
|
: '/items'
|
|
);
|
|
const hasRecipe = ref(false);
|
|
const imageEntityName = computed(() => itemNameForSave().trim());
|
|
const ancientArtifactOptions = computed(() => [
|
|
{ value: '', label: t('common.no') },
|
|
...(options.value?.ancientArtifactCategories.map((item) => ({ value: String(item.id), label: item.name })) ?? [])
|
|
]);
|
|
const dyeabilityOptions = computed<Array<{ value: Dyeability; label: string }>>(() => [
|
|
{ value: 0, label: t('pages.items.notDyeable') },
|
|
{ value: 1, label: t('pages.items.dyeable') },
|
|
{ value: 2, label: t('pages.items.dualDyeable') },
|
|
{ value: 3, label: t('pages.items.tripleDyeable') }
|
|
]);
|
|
const canCreateConfig = computed(() => currentUser.value?.permissions.includes('admin.config.create') === true);
|
|
const canUploadImage = computed(() => currentUser.value?.permissions.includes('items.upload') === true);
|
|
|
|
function toIds(values: string[]): number[] {
|
|
return values.map(Number).filter((item) => Number.isInteger(item) && item > 0);
|
|
}
|
|
|
|
function queryItemId(value: unknown): number | null {
|
|
const rawValue = Array.isArray(value) ? value[0] : value;
|
|
const id = Number(rawValue);
|
|
return Number.isInteger(id) && id > 0 ? id : null;
|
|
}
|
|
|
|
function errorText(error: unknown, fallback: string) {
|
|
return error instanceof Error && error.message ? error.message : fallback;
|
|
}
|
|
|
|
function defaultDyeability(value: { dyeability?: unknown; dualDyeable?: unknown; dyeable?: unknown }): Dyeability {
|
|
const dyeability = Number(value.dyeability);
|
|
if (Number.isInteger(dyeability) && dyeability >= 0 && dyeability <= 3) {
|
|
return dyeability as Dyeability;
|
|
}
|
|
if (value.dualDyeable === true) {
|
|
return 2;
|
|
}
|
|
if (value.dyeable === true) {
|
|
return 1;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
function readItemCreateDefaults(): ItemCreateDefaults {
|
|
if (typeof sessionStorage === 'undefined') {
|
|
return {
|
|
categoryId: '',
|
|
usageId: '',
|
|
dyeability: 0,
|
|
patternEditable: false,
|
|
noRecipe: false,
|
|
acquisitionMethodIds: []
|
|
};
|
|
}
|
|
|
|
try {
|
|
const rawValue = sessionStorage.getItem(itemCreateDefaultsStorageKey);
|
|
if (!rawValue) {
|
|
return {
|
|
categoryId: '',
|
|
usageId: '',
|
|
dyeability: 0,
|
|
patternEditable: false,
|
|
noRecipe: false,
|
|
acquisitionMethodIds: []
|
|
};
|
|
}
|
|
|
|
const parsedValue = JSON.parse(rawValue) as Partial<ItemCreateDefaults>;
|
|
return {
|
|
categoryId: typeof parsedValue.categoryId === 'string' ? parsedValue.categoryId : '',
|
|
usageId: typeof parsedValue.usageId === 'string' ? parsedValue.usageId : '',
|
|
dyeability: defaultDyeability(parsedValue),
|
|
patternEditable: parsedValue.patternEditable === true,
|
|
noRecipe: parsedValue.noRecipe === true,
|
|
acquisitionMethodIds: Array.isArray(parsedValue.acquisitionMethodIds)
|
|
? parsedValue.acquisitionMethodIds.filter((item) => typeof item === 'string')
|
|
: []
|
|
};
|
|
} catch {
|
|
return {
|
|
categoryId: '',
|
|
usageId: '',
|
|
dyeability: 0,
|
|
patternEditable: false,
|
|
noRecipe: false,
|
|
acquisitionMethodIds: []
|
|
};
|
|
}
|
|
}
|
|
|
|
function applyItemCreateDefaults(isEventItem: boolean) {
|
|
const loadedOptions = options.value;
|
|
if (!loadedOptions) {
|
|
itemForm.value.isEventItem = isEventItem;
|
|
return;
|
|
}
|
|
|
|
const defaults = readItemCreateDefaults();
|
|
const categoryIds = new Set(loadedOptions.itemCategories.map((item) => String(item.id)));
|
|
const usageIds = new Set(loadedOptions.itemUsages.map((item) => String(item.id)));
|
|
const methodIds = new Set(loadedOptions.acquisitionMethods.map((item) => String(item.id)));
|
|
itemForm.value = {
|
|
...itemForm.value,
|
|
categoryId: categoryIds.has(defaults.categoryId) ? defaults.categoryId : '',
|
|
usageId: usageIds.has(defaults.usageId) ? defaults.usageId : '',
|
|
ancientArtifactCategoryId: isAncientArtifactCreate.value ? String(loadedOptions.ancientArtifactCategories[0]?.id ?? '') : '',
|
|
dyeability: defaults.dyeability,
|
|
patternEditable: defaults.patternEditable,
|
|
noRecipe: defaults.noRecipe,
|
|
isEventItem,
|
|
acquisitionMethodIds: defaults.acquisitionMethodIds.filter((item) => methodIds.has(item))
|
|
};
|
|
}
|
|
|
|
function closeEditor() {
|
|
void router.push(cancelTo.value);
|
|
}
|
|
|
|
function itemNameForSave() {
|
|
const baseName = itemForm.value.name.trim();
|
|
if (baseName !== '') {
|
|
return itemForm.value.name;
|
|
}
|
|
|
|
return itemForm.value.translations[String(locale.value || '')]?.name ?? '';
|
|
}
|
|
|
|
async function loadOptions() {
|
|
const [loadedOptions, loadedLanguages] = await Promise.all([api.options(), api.languages()]);
|
|
options.value = loadedOptions;
|
|
languages.value = loadedLanguages;
|
|
}
|
|
|
|
async function loadCurrentUser() {
|
|
try {
|
|
currentUser.value = (await api.me()).user;
|
|
} catch {
|
|
currentUser.value = null;
|
|
}
|
|
}
|
|
|
|
async function loadEditor() {
|
|
loading.value = true;
|
|
message.value = '';
|
|
|
|
try {
|
|
await Promise.all([loadCurrentUser(), loadOptions()]);
|
|
if (isEditing.value) {
|
|
const item = await api.itemDetail(routeId.value);
|
|
itemForm.value = {
|
|
name: item.baseName ?? item.name,
|
|
details: item.baseDetails ?? item.details,
|
|
basePrice: item.basePrice === null || item.basePrice === undefined ? '' : String(item.basePrice),
|
|
ancientArtifactCategoryId: item.ancientArtifactCategory ? String(item.ancientArtifactCategory.id) : '',
|
|
translations: item.translations ?? {},
|
|
categoryId: String(item.category.id),
|
|
usageId: item.usage ? String(item.usage.id) : '',
|
|
dyeability: defaultDyeability(item.customization),
|
|
patternEditable: item.customization.patternEditable,
|
|
noRecipe: item.noRecipe,
|
|
isEventItem: item.isEventItem,
|
|
acquisitionMethodIds: item.acquisitionMethods.map((method) => String(method.id)),
|
|
tagIds: item.tags.map((tag) => String(tag.id)),
|
|
imagePath: item.image?.path ?? ''
|
|
};
|
|
currentImage.value = item.image;
|
|
imageHistory.value = item.imageHistory;
|
|
hasRecipe.value = item.recipe !== null;
|
|
} else {
|
|
applyItemCreateDefaults(isEventCreate.value);
|
|
}
|
|
} catch (error) {
|
|
message.value = errorText(error, t('errors.loadFailed'));
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
}
|
|
|
|
async function createMultiOption(selectKey: string, type: ConfigType, name: string, values: string[]) {
|
|
const cleanName = name.trim();
|
|
if (!cleanName || !canCreateConfig.value) return;
|
|
|
|
creatingSelect.value = selectKey;
|
|
message.value = '';
|
|
try {
|
|
const created = await api.createConfig(type, { name: cleanName });
|
|
await loadOptions();
|
|
const value = String(created.id);
|
|
if (!values.includes(value)) {
|
|
values.push(value);
|
|
}
|
|
} catch (error) {
|
|
message.value = errorText(error, t('errors.addFailed'));
|
|
} finally {
|
|
creatingSelect.value = '';
|
|
}
|
|
}
|
|
|
|
async function saveItem() {
|
|
busy.value = true;
|
|
message.value = '';
|
|
|
|
try {
|
|
const payload: ItemPayload = {
|
|
name: itemNameForSave(),
|
|
details: itemForm.value.details,
|
|
basePrice: itemForm.value.basePrice.trim() === '' ? null : Number(itemForm.value.basePrice),
|
|
ancientArtifactCategoryId:
|
|
itemForm.value.ancientArtifactCategoryId.trim() === '' ? null : Number(itemForm.value.ancientArtifactCategoryId),
|
|
translations: itemForm.value.translations,
|
|
categoryId: Number(itemForm.value.categoryId),
|
|
usageId: itemForm.value.usageId ? Number(itemForm.value.usageId) : null,
|
|
dyeability: itemForm.value.dyeability,
|
|
patternEditable: itemForm.value.patternEditable,
|
|
noRecipe: itemForm.value.noRecipe,
|
|
isEventItem: itemForm.value.isEventItem,
|
|
acquisitionMethodIds: toIds(itemForm.value.acquisitionMethodIds),
|
|
tagIds: toIds(itemForm.value.tagIds),
|
|
imagePath: itemForm.value.imagePath
|
|
};
|
|
if (!isEditing.value && insertBeforeItemId.value !== null) {
|
|
payload.insertBeforeItemId = insertBeforeItemId.value;
|
|
}
|
|
if (!isEditing.value && insertAfterItemId.value !== null) {
|
|
payload.insertAfterItemId = insertAfterItemId.value;
|
|
}
|
|
const saved = isEditing.value ? await api.updateItem(routeId.value, payload) : await api.createItem(payload);
|
|
await router.push(isAncientArtifactRoute.value ? `/ancient-artifacts/${saved.id}` : `/items/${saved.id}`);
|
|
} catch (error) {
|
|
message.value = errorText(error, t('errors.saveFailed'));
|
|
} finally {
|
|
busy.value = false;
|
|
}
|
|
}
|
|
|
|
function handleImageSelected(image: EntityImage) {
|
|
currentImage.value = image;
|
|
}
|
|
|
|
function handleImageUploaded(image: EntityImageUpload) {
|
|
currentImage.value = image;
|
|
imageHistory.value = [image, ...imageHistory.value.filter((item) => item.path !== image.path)];
|
|
}
|
|
|
|
onMounted(() => {
|
|
void loadEditor();
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<Modal :title="pageTitle" :subtitle="pageSubtitle" :close-label="t('common.close')" size="wide" @close="closeEditor">
|
|
<StatusMessage v-if="message" variant="danger">{{ message }}</StatusMessage>
|
|
|
|
<form v-if="!loading && options" id="item-edit-form" class="modal-edit-form" @submit.prevent="saveItem">
|
|
<div class="item-edit-row item-edit-row--name-price">
|
|
<TranslationFields
|
|
id-prefix="item-name"
|
|
v-model:base-value="itemForm.name"
|
|
v-model:translations="itemForm.translations"
|
|
field="name"
|
|
:label="t('common.name')"
|
|
:languages="languages"
|
|
required
|
|
/>
|
|
|
|
<div class="field">
|
|
<label for="item-base-price">{{ t('pages.items.basePrice') }}</label>
|
|
<input id="item-base-price" v-model="itemForm.basePrice" type="number" min="0" step="1" inputmode="numeric" />
|
|
</div>
|
|
</div>
|
|
|
|
<TranslationFields
|
|
id-prefix="item-details"
|
|
v-model:base-value="itemForm.details"
|
|
v-model:translations="itemForm.translations"
|
|
field="details"
|
|
:label="t('pages.items.description')"
|
|
:languages="languages"
|
|
multiline
|
|
:rows="4"
|
|
/>
|
|
|
|
<ImageUploadField
|
|
v-model="itemForm.imagePath"
|
|
entity-type="items"
|
|
:entity-id="isEditing ? routeId : null"
|
|
:entity-name="imageEntityName"
|
|
:label="t('media.image')"
|
|
:current-image="currentImage"
|
|
:history="imageHistory"
|
|
:disabled="busy"
|
|
:allow-upload="canUploadImage"
|
|
@selected="handleImageSelected"
|
|
@uploaded="handleImageUploaded"
|
|
@error="message = $event"
|
|
/>
|
|
|
|
<div class="item-edit-row item-edit-row--category-usage">
|
|
<div class="field">
|
|
<label for="item-category">{{ t('pages.items.category') }}</label>
|
|
<TagsSelect
|
|
id="item-category"
|
|
v-model="itemForm.categoryId"
|
|
:options="options.itemCategories"
|
|
:multiple="false"
|
|
:placeholder="t('common.select')"
|
|
:search-placeholder="t('pages.items.searchCategory')"
|
|
/>
|
|
</div>
|
|
|
|
<div class="field">
|
|
<label for="item-usage">{{ t('pages.items.usage') }}</label>
|
|
<TagsSelect
|
|
id="item-usage"
|
|
v-model="itemForm.usageId"
|
|
:options="options.itemUsages"
|
|
:multiple="false"
|
|
clearable
|
|
:placeholder="t('common.none')"
|
|
:search-placeholder="t('pages.items.searchUsage')"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="field">
|
|
<fieldset class="radio-group">
|
|
<legend>{{ t('pages.items.ancientArtifact') }}</legend>
|
|
<div class="radio-group__options">
|
|
<label v-for="option in ancientArtifactOptions" :key="option.value" class="radio-group__option">
|
|
<input v-model="itemForm.ancientArtifactCategoryId" type="radio" name="item-ancient-artifact" :value="option.value" />
|
|
<span>{{ option.label }}</span>
|
|
</label>
|
|
</div>
|
|
</fieldset>
|
|
</div>
|
|
|
|
<div class="field">
|
|
<fieldset class="radio-group">
|
|
<legend>{{ t('pages.items.dyeability') }}</legend>
|
|
<div class="radio-group__options">
|
|
<label v-for="option in dyeabilityOptions" :key="option.value" class="radio-group__option">
|
|
<input v-model="itemForm.dyeability" type="radio" name="item-dyeability" :value="option.value" />
|
|
<span>{{ option.label }}</span>
|
|
</label>
|
|
</div>
|
|
</fieldset>
|
|
</div>
|
|
|
|
<div class="check-row">
|
|
<label><input v-model="itemForm.patternEditable" type="checkbox" /> {{ t('pages.items.patternEditable') }}</label>
|
|
<label><input v-model="itemForm.noRecipe" type="checkbox" :disabled="hasRecipe" /> {{ t('pages.items.noRecipe') }}</label>
|
|
<label><input v-model="itemForm.isEventItem" type="checkbox" :disabled="isEventCreate" /> {{ t('pages.items.eventItem') }}</label>
|
|
</div>
|
|
|
|
<div class="field">
|
|
<label for="item-methods">{{ t('pages.items.acquisitionMethods') }}</label>
|
|
<TagsSelect
|
|
id="item-methods"
|
|
v-model="itemForm.acquisitionMethodIds"
|
|
:options="options.acquisitionMethods"
|
|
:allow-create="canCreateConfig"
|
|
:creating="creatingSelect === 'item-methods'"
|
|
:placeholder="t('pages.items.searchMethods')"
|
|
@create="createMultiOption('item-methods', 'acquisition-methods', $event, itemForm.acquisitionMethodIds)"
|
|
/>
|
|
</div>
|
|
|
|
<div class="field">
|
|
<label for="item-tags">{{ t('pages.items.tags') }}</label>
|
|
<TagsSelect
|
|
id="item-tags"
|
|
v-model="itemForm.tagIds"
|
|
:options="options.itemTags"
|
|
:allow-create="canCreateConfig"
|
|
:creating="creatingSelect === 'item-tags'"
|
|
:placeholder="t('pages.items.searchTags')"
|
|
@create="createMultiOption('item-tags', 'favorite-things', $event, itemForm.tagIds)"
|
|
/>
|
|
</div>
|
|
</form>
|
|
|
|
<section v-else class="modal-edit-form skeleton-detail-section" aria-busy="true" :aria-label="t('pages.items.loadingEdit')">
|
|
<div v-for="index in 7" :key="index" class="field">
|
|
<Skeleton :width="index === 1 ? '52px' : '88px'" />
|
|
<Skeleton variant="box" height="44px" />
|
|
</div>
|
|
</section>
|
|
|
|
<template v-if="!loading && options" #footer>
|
|
<button type="submit" form="item-edit-form" class="link-button" :disabled="busy">
|
|
<Icon :icon="iconSave" class="ui-icon" aria-hidden="true" />
|
|
{{ busy ? t('common.saving') : t('common.save') }}
|
|
</button>
|
|
<button type="button" class="plain-button" :disabled="busy" @click="closeEditor">
|
|
<Icon :icon="iconCancel" class="ui-icon" aria-hidden="true" />
|
|
{{ t('common.cancel') }}
|
|
</button>
|
|
</template>
|
|
</Modal>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.item-edit-row {
|
|
display: grid;
|
|
gap: 12px;
|
|
align-items: start;
|
|
}
|
|
|
|
.item-edit-row--name-price {
|
|
grid-template-columns: minmax(0, 1fr) minmax(180px, 240px);
|
|
}
|
|
|
|
.item-edit-row--category-usage {
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
}
|
|
|
|
.item-edit-row > * {
|
|
min-width: 0;
|
|
}
|
|
|
|
@media (max-width: 720px) {
|
|
.item-edit-row--name-price,
|
|
.item-edit-row--category-usage {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
}
|
|
</style>
|