feat(ui): use modal dialogs for entity creation and editing
Introduce reusable Modal component for forms Update router to preserve scroll position when toggling modals Refactor admin and entity views to render editors as overlays
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import Modal from '../components/Modal.vue';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import ReorderableList from '../components/ReorderableList.vue';
|
||||
import Skeleton from '../components/Skeleton.vue';
|
||||
@@ -65,6 +66,9 @@ const configForm = ref({ id: 0, name: '', translations: {} as TranslationMap, ha
|
||||
const checklistForm = ref({ id: 0, title: '', translations: {} as TranslationMap });
|
||||
const languageForm = ref({ code: '', name: '', enabled: true, isDefault: false, sortOrder: 0 });
|
||||
const editingLanguageCode = ref('');
|
||||
const configModalOpen = ref(false);
|
||||
const checklistModalOpen = ref(false);
|
||||
const languageModalOpen = ref(false);
|
||||
|
||||
const selectedConfig = computed(() => configTypes.value.find((item) => item.key === activeConfigType.value) ?? configTypes.value[0]);
|
||||
const configTabs = computed<TabOption[]>(() => configTypes.value.map((item) => ({ value: item.key, label: item.label })));
|
||||
@@ -95,13 +99,18 @@ const activeConfigTab = computed({
|
||||
if (!nextConfig || nextConfig.key === activeConfigType.value) return;
|
||||
|
||||
activeConfigType.value = nextConfig.key;
|
||||
resetConfigForm();
|
||||
closeConfigModal();
|
||||
void run(loadConfig);
|
||||
}
|
||||
});
|
||||
const canEdit = computed(() => currentUser.value?.emailVerified === true);
|
||||
const showAdminSkeleton = computed(() => busy.value && !message.value && (!currentUser.value || contentLoading.value));
|
||||
const canSetLanguageDefault = computed(() => languageForm.value.code === 'en');
|
||||
const configModalTitle = computed(() =>
|
||||
configForm.value.id ? t('pages.admin.editConfig', { name: selectedConfig.value.label }) : t('pages.admin.newConfig', { name: selectedConfig.value.label })
|
||||
);
|
||||
const checklistModalTitle = computed(() => (checklistForm.value.id ? t('pages.checklist.editTask') : t('pages.checklist.newTask')));
|
||||
const languageModalTitle = computed(() => (editingLanguageCode.value ? t('pages.admin.editLanguage') : t('pages.admin.newLanguage')));
|
||||
const checklistKey = (item: DailyChecklistItem) => item.id;
|
||||
const checklistLabel = (item: DailyChecklistItem) => item.title;
|
||||
const languageKey = (item: Language) => item.code;
|
||||
@@ -159,12 +168,44 @@ function resetLanguageForm() {
|
||||
editingLanguageCode.value = '';
|
||||
}
|
||||
|
||||
function openNewConfig() {
|
||||
resetConfigForm();
|
||||
configModalOpen.value = true;
|
||||
}
|
||||
|
||||
function closeConfigModal() {
|
||||
configModalOpen.value = false;
|
||||
resetConfigForm();
|
||||
}
|
||||
|
||||
function editConfig(item: EditableConfig) {
|
||||
configForm.value = { id: item.id, name: item.baseName ?? item.name, translations: item.translations ?? {}, hasItemDrop: item.hasItemDrop === true };
|
||||
configModalOpen.value = true;
|
||||
}
|
||||
|
||||
function openNewChecklistItem() {
|
||||
resetChecklistForm();
|
||||
checklistModalOpen.value = true;
|
||||
}
|
||||
|
||||
function closeChecklistModal() {
|
||||
checklistModalOpen.value = false;
|
||||
resetChecklistForm();
|
||||
}
|
||||
|
||||
function editChecklistItem(item: DailyChecklistItem) {
|
||||
checklistForm.value = { id: item.id, title: item.title, translations: item.translations ?? {} };
|
||||
checklistModalOpen.value = true;
|
||||
}
|
||||
|
||||
function openNewLanguage() {
|
||||
resetLanguageForm();
|
||||
languageModalOpen.value = true;
|
||||
}
|
||||
|
||||
function closeLanguageModal() {
|
||||
languageModalOpen.value = false;
|
||||
resetLanguageForm();
|
||||
}
|
||||
|
||||
function editLanguage(item: Language) {
|
||||
@@ -176,6 +217,7 @@ function editLanguage(item: Language) {
|
||||
isDefault: item.isDefault,
|
||||
sortOrder: item.sortOrder
|
||||
};
|
||||
languageModalOpen.value = true;
|
||||
}
|
||||
|
||||
function updateConfigTranslation(localeCode: string, value: string) {
|
||||
@@ -332,8 +374,8 @@ async function saveConfig() {
|
||||
await api.createConfig(activeConfigType.value, payload);
|
||||
}
|
||||
|
||||
resetConfigForm();
|
||||
await loadConfig();
|
||||
closeConfigModal();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -359,7 +401,7 @@ async function saveChecklistItem() {
|
||||
}
|
||||
|
||||
await loadChecklist();
|
||||
resetChecklistForm();
|
||||
closeChecklistModal();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -376,7 +418,7 @@ async function saveLanguage() {
|
||||
languageRows.value = editingLanguageCode.value
|
||||
? await api.updateLanguage(editingLanguageCode.value, payload)
|
||||
: await api.createLanguage(payload);
|
||||
resetLanguageForm();
|
||||
closeLanguageModal();
|
||||
setCurrentLocale(getCurrentLocale());
|
||||
});
|
||||
}
|
||||
@@ -451,7 +493,7 @@ async function removeLanguage(code: string) {
|
||||
await run(async () => {
|
||||
await api.deleteLanguage(code);
|
||||
if (editingLanguageCode.value === code) {
|
||||
resetLanguageForm();
|
||||
closeLanguageModal();
|
||||
}
|
||||
await loadLanguages();
|
||||
setCurrentLocale(getCurrentLocale());
|
||||
@@ -462,7 +504,7 @@ async function removeConfig(id: number) {
|
||||
await run(async () => {
|
||||
await api.deleteConfig(activeConfigType.value, id);
|
||||
if (configForm.value.id === id) {
|
||||
resetConfigForm();
|
||||
closeConfigModal();
|
||||
}
|
||||
await loadConfig();
|
||||
});
|
||||
@@ -472,7 +514,7 @@ async function removeChecklistItem(id: number) {
|
||||
await run(async () => {
|
||||
await api.deleteDailyChecklistItem(id);
|
||||
if (checklistForm.value.id === id) {
|
||||
resetChecklistForm();
|
||||
closeChecklistModal();
|
||||
}
|
||||
await loadChecklist();
|
||||
});
|
||||
@@ -538,25 +580,12 @@ onMounted(() => {
|
||||
</section>
|
||||
|
||||
<section v-else-if="canEdit && activeTab === 'checklist'" class="detail-section">
|
||||
<h2>{{ t('pages.admin.checklist') }}</h2>
|
||||
|
||||
<form class="detail-section__body" @submit.prevent="saveChecklistItem">
|
||||
<h3 class="section-subtitle">{{ checklistForm.id ? t('pages.checklist.editTask') : t('pages.checklist.newTask') }}</h3>
|
||||
<TranslationFields
|
||||
id-prefix="checklist-title"
|
||||
v-model:base-value="checklistForm.title"
|
||||
v-model:translations="checklistForm.translations"
|
||||
field="title"
|
||||
:label="t('pages.checklist.task')"
|
||||
:languages="languageRows"
|
||||
required
|
||||
/>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="link-button" :disabled="busy">{{ busy ? t('common.saving') : t('common.save') }}</button>
|
||||
<button type="button" class="plain-button" :disabled="busy" @click="resetChecklistForm">{{ t('common.new') }}</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="detail-section__header">
|
||||
<h2>{{ t('pages.admin.checklist') }}</h2>
|
||||
<button type="button" class="ui-button ui-button--primary ui-button--small" :disabled="busy" @click="openNewChecklistItem">
|
||||
{{ t('common.new') }}
|
||||
</button>
|
||||
</div>
|
||||
<h3 class="section-subtitle">{{ t('pages.checklist.sectionTitle') }}</h3>
|
||||
<ReorderableList
|
||||
v-if="checklistRows.length"
|
||||
@@ -583,29 +612,13 @@ onMounted(() => {
|
||||
</section>
|
||||
|
||||
<section v-else-if="canEdit && activeTab === 'config'" class="detail-section">
|
||||
<h2>{{ t('pages.admin.config') }}</h2>
|
||||
<div class="detail-section__header">
|
||||
<h2>{{ t('pages.admin.config') }}</h2>
|
||||
<button type="button" class="ui-button ui-button--primary ui-button--small" :disabled="busy" @click="openNewConfig">
|
||||
{{ t('common.new') }}
|
||||
</button>
|
||||
</div>
|
||||
<Tabs id="admin-config-type" v-model="activeConfigTab" :tabs="configTabs" :label="t('pages.admin.configType')" />
|
||||
|
||||
<form class="detail-section__body" @submit.prevent="saveConfig">
|
||||
<h3 class="section-subtitle">
|
||||
{{ configForm.id ? t('pages.admin.editConfig', { name: selectedConfig.label }) : t('pages.admin.newConfig', { name: selectedConfig.label }) }}
|
||||
</h3>
|
||||
<div class="field">
|
||||
<label for="config-name">{{ t('common.name') }}</label>
|
||||
<input id="config-name" v-model="configNameInput" :required="configNameRequired" />
|
||||
</div>
|
||||
<div v-if="selectedConfig.supportsItemDrop" class="check-row">
|
||||
<label>
|
||||
<input v-model="configForm.hasItemDrop" type="checkbox" />
|
||||
{{ t('pages.admin.hasItemDrop') }}
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="link-button" :disabled="busy">{{ busy ? t('common.saving') : t('common.save') }}</button>
|
||||
<button type="button" class="plain-button" :disabled="busy" @click="resetConfigForm">{{ t('common.new') }}</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<h3 class="section-subtitle">{{ selectedConfig.label }}</h3>
|
||||
<ReorderableList
|
||||
v-if="configRows.length"
|
||||
@@ -634,31 +647,12 @@ onMounted(() => {
|
||||
</section>
|
||||
|
||||
<section v-else-if="canEdit && activeTab === 'languages'" class="detail-section">
|
||||
<h2>{{ t('pages.admin.languages') }}</h2>
|
||||
|
||||
<form class="detail-section__body" @submit.prevent="saveLanguage">
|
||||
<h3 class="section-subtitle">{{ editingLanguageCode ? t('pages.admin.editLanguage') : t('pages.admin.newLanguage') }}</h3>
|
||||
<div class="field">
|
||||
<label for="language-code">{{ t('pages.admin.languageCode') }}</label>
|
||||
<input id="language-code" v-model="languageForm.code" :disabled="Boolean(editingLanguageCode)" required />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="language-name">{{ t('pages.admin.languageName') }}</label>
|
||||
<input id="language-name" v-model="languageForm.name" required />
|
||||
</div>
|
||||
<div class="check-row">
|
||||
<label><input v-model="languageForm.enabled" type="checkbox" /> {{ t('pages.admin.enabled') }}</label>
|
||||
<label>
|
||||
<input v-model="languageForm.isDefault" type="checkbox" :disabled="!canSetLanguageDefault" />
|
||||
{{ t('pages.admin.defaultLanguage') }}
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="link-button" :disabled="busy">{{ busy ? t('common.saving') : t('common.save') }}</button>
|
||||
<button type="button" class="plain-button" :disabled="busy" @click="resetLanguageForm">{{ t('common.new') }}</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="detail-section__header">
|
||||
<h2>{{ t('pages.admin.languages') }}</h2>
|
||||
<button type="button" class="ui-button ui-button--primary ui-button--small" :disabled="busy" @click="openNewLanguage">
|
||||
{{ t('common.new') }}
|
||||
</button>
|
||||
</div>
|
||||
<ReorderableList
|
||||
v-if="languageRows.length"
|
||||
:items="languageRows"
|
||||
@@ -785,5 +779,75 @@ onMounted(() => {
|
||||
</ReorderableList>
|
||||
<p v-else class="meta-line">{{ t('common.noRecords') }}</p>
|
||||
</section>
|
||||
|
||||
<Modal v-if="checklistModalOpen" :title="checklistModalTitle" :close-label="t('common.close')" size="wide" @close="closeChecklistModal">
|
||||
<form id="admin-checklist-form" class="modal-edit-form" @submit.prevent="saveChecklistItem">
|
||||
<TranslationFields
|
||||
id-prefix="checklist-title"
|
||||
v-model:base-value="checklistForm.title"
|
||||
v-model:translations="checklistForm.translations"
|
||||
field="title"
|
||||
:label="t('pages.checklist.task')"
|
||||
:languages="languageRows"
|
||||
required
|
||||
/>
|
||||
</form>
|
||||
|
||||
<template #footer>
|
||||
<button type="submit" form="admin-checklist-form" class="link-button" :disabled="busy">
|
||||
{{ busy ? t('common.saving') : t('common.save') }}
|
||||
</button>
|
||||
<button type="button" class="plain-button" :disabled="busy" @click="closeChecklistModal">{{ t('common.cancel') }}</button>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<Modal v-if="configModalOpen" :title="configModalTitle" :close-label="t('common.close')" @close="closeConfigModal">
|
||||
<form id="admin-config-form" class="modal-edit-form" @submit.prevent="saveConfig">
|
||||
<div class="field">
|
||||
<label for="config-name">{{ t('common.name') }}</label>
|
||||
<input id="config-name" v-model="configNameInput" :required="configNameRequired" />
|
||||
</div>
|
||||
<div v-if="selectedConfig.supportsItemDrop" class="check-row">
|
||||
<label>
|
||||
<input v-model="configForm.hasItemDrop" type="checkbox" />
|
||||
{{ t('pages.admin.hasItemDrop') }}
|
||||
</label>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<template #footer>
|
||||
<button type="submit" form="admin-config-form" class="link-button" :disabled="busy">
|
||||
{{ busy ? t('common.saving') : t('common.save') }}
|
||||
</button>
|
||||
<button type="button" class="plain-button" :disabled="busy" @click="closeConfigModal">{{ t('common.cancel') }}</button>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<Modal v-if="languageModalOpen" :title="languageModalTitle" :close-label="t('common.close')" @close="closeLanguageModal">
|
||||
<form id="admin-language-form" class="modal-edit-form" @submit.prevent="saveLanguage">
|
||||
<div class="field">
|
||||
<label for="language-code">{{ t('pages.admin.languageCode') }}</label>
|
||||
<input id="language-code" v-model="languageForm.code" :disabled="Boolean(editingLanguageCode)" required />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="language-name">{{ t('pages.admin.languageName') }}</label>
|
||||
<input id="language-name" v-model="languageForm.name" required />
|
||||
</div>
|
||||
<div class="check-row">
|
||||
<label><input v-model="languageForm.enabled" type="checkbox" /> {{ t('pages.admin.enabled') }}</label>
|
||||
<label>
|
||||
<input v-model="languageForm.isDefault" type="checkbox" :disabled="!canSetLanguageDefault" />
|
||||
{{ t('pages.admin.defaultLanguage') }}
|
||||
</label>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<template #footer>
|
||||
<button type="submit" form="admin-language-form" class="link-button" :disabled="busy">
|
||||
{{ busy ? t('common.saving') : t('common.save') }}
|
||||
</button>
|
||||
<button type="button" class="plain-button" :disabled="busy" @click="closeLanguageModal">{{ t('common.cancel') }}</button>
|
||||
</template>
|
||||
</Modal>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute } from 'vue-router';
|
||||
import DetailSection from '../components/DetailSection.vue';
|
||||
@@ -8,12 +8,14 @@ import EntityChips from '../components/EntityChips.vue';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import Skeleton from '../components/Skeleton.vue';
|
||||
import { api, type HabitatDetail } from '../services/api';
|
||||
import HabitatEdit from './HabitatEdit.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const { t } = useI18n();
|
||||
const habitat = ref<HabitatDetail | null>(null);
|
||||
const timeOfDays = ['早晨', '中午', '傍晚', '晚上'];
|
||||
const weathers = ['晴天', '阴天', '雨天'];
|
||||
const showEditor = computed(() => route.name === 'habitat-edit');
|
||||
|
||||
type PokemonRow = {
|
||||
id: number;
|
||||
@@ -96,9 +98,30 @@ const pokemonRows = computed<PokemonRow[]>(() => {
|
||||
}));
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
async function loadHabitatDetail() {
|
||||
habitat.value = await api.habitatDetail(String(route.params.id));
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadHabitatDetail();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => route.name,
|
||||
(name, oldName) => {
|
||||
if (oldName === 'habitat-edit' && name === 'habitat-detail') {
|
||||
void loadHabitatDetail();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
() => route.params.id,
|
||||
() => {
|
||||
habitat.value = null;
|
||||
void loadHabitatDetail();
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -192,4 +215,6 @@ onMounted(async () => {
|
||||
<EditHistoryPanel :entity="habitat" :history="habitat.editHistory" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<HabitatEdit v-if="showEditor" />
|
||||
</template>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import Modal from '../components/Modal.vue';
|
||||
import Skeleton from '../components/Skeleton.vue';
|
||||
import StatusMessage from '../components/StatusMessage.vue';
|
||||
import SwitchGroup from '../components/SwitchGroup.vue';
|
||||
@@ -123,6 +123,10 @@ function groupPokemonAppearances(detail: HabitatDetail): HabitatAppearanceForm[]
|
||||
return [...rows.values()];
|
||||
}
|
||||
|
||||
function closeEditor() {
|
||||
void router.push(cancelTo.value);
|
||||
}
|
||||
|
||||
async function loadEditor() {
|
||||
loading.value = true;
|
||||
message.value = '';
|
||||
@@ -213,17 +217,10 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page-stack">
|
||||
<PageHeader :title="pageTitle" :subtitle="t('pages.habitats.editSubtitle')">
|
||||
<template #kicker>Habitat Edit</template>
|
||||
<template #actions>
|
||||
<RouterLink class="ui-button ui-button--blue ui-button--small" :to="cancelTo">{{ t('common.back') }}</RouterLink>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<Modal :title="pageTitle" :subtitle="t('pages.habitats.editSubtitle')" :close-label="t('common.close')" size="wide" @close="closeEditor">
|
||||
<StatusMessage v-if="message" variant="danger">{{ message }}</StatusMessage>
|
||||
|
||||
<form v-if="!loading && options" class="detail-section" @submit.prevent="saveHabitat">
|
||||
<form v-if="!loading && options" id="habitat-edit-form" class="modal-edit-form" @submit.prevent="saveHabitat">
|
||||
<TranslationFields
|
||||
id-prefix="habitat-name"
|
||||
v-model:base-value="habitatForm.name"
|
||||
@@ -294,18 +291,18 @@ onMounted(() => {
|
||||
</div>
|
||||
<button type="button" class="plain-button" @click="addPokemonAppearance">{{ t('pages.habitats.addPokemon') }}</button>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="link-button" :disabled="busy">{{ busy ? t('common.saving') : t('common.save') }}</button>
|
||||
<RouterLink class="plain-button" :to="cancelTo">{{ t('common.cancel') }}</RouterLink>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<section v-else class="detail-section skeleton-detail-section" aria-busy="true" :aria-label="t('pages.habitats.loadingEdit')">
|
||||
<section v-else class="modal-edit-form skeleton-detail-section" aria-busy="true" :aria-label="t('pages.habitats.loadingEdit')">
|
||||
<div v-for="index in 5" :key="index" class="field">
|
||||
<Skeleton :width="index === 1 ? '52px' : '112px'" />
|
||||
<Skeleton variant="box" height="44px" />
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<template v-if="!loading && options" #footer>
|
||||
<button type="submit" form="habitat-edit-form" class="link-button" :disabled="busy">{{ busy ? t('common.saving') : t('common.save') }}</button>
|
||||
<button type="button" class="plain-button" :disabled="busy" @click="closeEditor">{{ t('common.cancel') }}</button>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute } from 'vue-router';
|
||||
import EditMeta from '../components/EditMeta.vue';
|
||||
import EntityChips from '../components/EntityChips.vue';
|
||||
import EntityCard from '../components/EntityCard.vue';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import Skeleton from '../components/Skeleton.vue';
|
||||
import { api, type Habitat } from '../services/api';
|
||||
import HabitatEdit from './HabitatEdit.vue';
|
||||
|
||||
const habitats = ref<Habitat[]>([]);
|
||||
const route = useRoute();
|
||||
const { t } = useI18n();
|
||||
const loading = ref(true);
|
||||
const skeletonCardCount = 6;
|
||||
const showEditor = computed(() => route.name === 'habitat-new');
|
||||
|
||||
onMounted(async () => {
|
||||
habitats.value = await api.habitats();
|
||||
@@ -50,5 +54,7 @@ onMounted(async () => {
|
||||
<EntityChips :items="item.pokemon ?? []" />
|
||||
</EntityCard>
|
||||
</div>
|
||||
|
||||
<HabitatEdit v-if="showEditor" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute } from 'vue-router';
|
||||
import DetailSection from '../components/DetailSection.vue';
|
||||
@@ -8,10 +8,12 @@ import EntityChips from '../components/EntityChips.vue';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import Skeleton from '../components/Skeleton.vue';
|
||||
import { api, type ItemDetail } from '../services/api';
|
||||
import ItemEdit from './ItemEdit.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const { t } = useI18n();
|
||||
const item = ref<ItemDetail | null>(null);
|
||||
const showEditor = computed(() => route.name === 'item-edit');
|
||||
|
||||
const customization = computed(() => {
|
||||
if (!item.value) {
|
||||
@@ -25,9 +27,30 @@ const customization = computed(() => {
|
||||
].filter(Boolean);
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
async function loadItemDetail() {
|
||||
item.value = await api.itemDetail(String(route.params.id));
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadItemDetail();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => route.name,
|
||||
(name, oldName) => {
|
||||
if (oldName === 'item-edit' && name === 'item-detail') {
|
||||
void loadItemDetail();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
() => route.params.id,
|
||||
() => {
|
||||
item.value = null;
|
||||
void loadItemDetail();
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -157,4 +180,6 @@ onMounted(async () => {
|
||||
<EditHistoryPanel :entity="item" :history="item.editHistory" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<ItemEdit v-if="showEditor" />
|
||||
</template>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import PageHeader from '../components/PageHeader.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';
|
||||
@@ -49,6 +49,10 @@ function errorText(error: unknown, fallback: string) {
|
||||
return error instanceof Error && error.message ? error.message : fallback;
|
||||
}
|
||||
|
||||
function closeEditor() {
|
||||
void router.push(cancelTo.value);
|
||||
}
|
||||
|
||||
async function loadOptions() {
|
||||
const [loadedOptions, loadedLanguages] = await Promise.all([api.options(), api.languages()]);
|
||||
options.value = loadedOptions;
|
||||
@@ -153,17 +157,10 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page-stack">
|
||||
<PageHeader :title="pageTitle" :subtitle="t('pages.items.editSubtitle')">
|
||||
<template #kicker>Item Edit</template>
|
||||
<template #actions>
|
||||
<RouterLink class="ui-button ui-button--blue ui-button--small" :to="cancelTo">{{ t('common.back') }}</RouterLink>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<Modal :title="pageTitle" :subtitle="t('pages.items.editSubtitle')" :close-label="t('common.close')" size="wide" @close="closeEditor">
|
||||
<StatusMessage v-if="message" variant="danger">{{ message }}</StatusMessage>
|
||||
|
||||
<form v-if="!loading && options" class="detail-section" @submit.prevent="saveItem">
|
||||
<form v-if="!loading && options" id="item-edit-form" class="modal-edit-form" @submit.prevent="saveItem">
|
||||
<TranslationFields
|
||||
id-prefix="item-name"
|
||||
v-model:base-value="itemForm.name"
|
||||
@@ -236,18 +233,18 @@ onMounted(() => {
|
||||
@create="createMultiOption('item-tags', 'favorite-things', $event, itemForm.tagIds)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="link-button" :disabled="busy">{{ busy ? t('common.saving') : t('common.save') }}</button>
|
||||
<RouterLink class="plain-button" :to="cancelTo">{{ t('common.cancel') }}</RouterLink>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<section v-else class="detail-section skeleton-detail-section" aria-busy="true" :aria-label="t('pages.items.loadingEdit')">
|
||||
<section v-else class="modal-edit-form skeleton-detail-section" aria-busy="true" :aria-label="t('pages.items.loadingEdit')">
|
||||
<div v-for="index in 6" :key="index" class="field">
|
||||
<Skeleton :width="index === 1 ? '52px' : '88px'" />
|
||||
<Skeleton variant="box" height="44px" />
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<template v-if="!loading && options" #footer>
|
||||
<button type="submit" form="item-edit-form" class="link-button" :disabled="busy">{{ busy ? t('common.saving') : t('common.save') }}</button>
|
||||
<button type="button" class="plain-button" :disabled="busy" @click="closeEditor">{{ t('common.cancel') }}</button>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute } from 'vue-router';
|
||||
import EditMeta from '../components/EditMeta.vue';
|
||||
import EntityChips from '../components/EntityChips.vue';
|
||||
import EntityCard from '../components/EntityCard.vue';
|
||||
@@ -10,8 +11,10 @@ import Skeleton from '../components/Skeleton.vue';
|
||||
import Tabs, { type TabOption } from '../components/Tabs.vue';
|
||||
import TagsSelect from '../components/TagsSelect.vue';
|
||||
import { api, type Item, type Options } from '../services/api';
|
||||
import ItemEdit from './ItemEdit.vue';
|
||||
|
||||
const options = ref<Options | null>(null);
|
||||
const route = useRoute();
|
||||
const { t } = useI18n();
|
||||
const items = ref<Item[]>([]);
|
||||
const loading = ref(true);
|
||||
@@ -35,6 +38,7 @@ const itemQuery = computed(() => ({
|
||||
usageId: usageId.value,
|
||||
tagIds: tagIds.value.join(',')
|
||||
}));
|
||||
const showEditor = computed(() => route.name === 'item-new');
|
||||
|
||||
async function loadItems() {
|
||||
loading.value = true;
|
||||
@@ -134,5 +138,7 @@ watch(itemQuery, loadItems);
|
||||
<EntityChips :items="item.tags" />
|
||||
</EntityCard>
|
||||
</div>
|
||||
|
||||
<ItemEdit v-if="showEditor" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute } from 'vue-router';
|
||||
import DetailSection from '../components/DetailSection.vue';
|
||||
@@ -9,6 +9,7 @@ import PageHeader from '../components/PageHeader.vue';
|
||||
import Skeleton from '../components/Skeleton.vue';
|
||||
import Tabs, { type TabOption } from '../components/Tabs.vue';
|
||||
import { api, type PokemonDetail } from '../services/api';
|
||||
import PokemonEdit from './PokemonEdit.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const { t } = useI18n();
|
||||
@@ -98,6 +99,7 @@ const habitatRows = computed<HabitatRow[]>(() => {
|
||||
}));
|
||||
});
|
||||
const skillDropRows = computed(() => pokemon.value?.skills.filter((skill) => skill.itemDrop) ?? []);
|
||||
const showEditor = computed(() => route.name === 'pokemon-edit');
|
||||
const itemCategoryTabs = computed<TabOption[]>(() => {
|
||||
const categories = new Map<string, string>();
|
||||
|
||||
@@ -119,9 +121,30 @@ const favoriteThingItems = computed(() => {
|
||||
return items.filter((item) => String(item.category.id) === itemCategoryTab.value);
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
async function loadPokemonDetail() {
|
||||
pokemon.value = await api.pokemonDetail(String(route.params.id));
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadPokemonDetail();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => route.name,
|
||||
(name, oldName) => {
|
||||
if (oldName === 'pokemon-edit' && name === 'pokemon-detail') {
|
||||
void loadPokemonDetail();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
() => route.params.id,
|
||||
() => {
|
||||
pokemon.value = null;
|
||||
void loadPokemonDetail();
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -259,4 +282,6 @@ onMounted(async () => {
|
||||
<EditHistoryPanel :entity="pokemon" :history="pokemon.editHistory" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<PokemonEdit v-if="showEditor" />
|
||||
</template>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import PageHeader from '../components/PageHeader.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';
|
||||
@@ -87,6 +87,10 @@ function skillDropLabel(skillId: string) {
|
||||
return name ? t('pages.pokemon.skillDrop', { name }) : t('pages.pokemon.dropItem');
|
||||
}
|
||||
|
||||
function closeEditor() {
|
||||
void router.push(cancelTo.value);
|
||||
}
|
||||
|
||||
async function loadEditor() {
|
||||
loading.value = true;
|
||||
message.value = '';
|
||||
@@ -186,17 +190,10 @@ watch(() => pokemonForm.value.skillIds.slice(), syncSkillItemDrops);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page-stack">
|
||||
<PageHeader :title="pageTitle" :subtitle="t('pages.pokemon.editSubtitle')">
|
||||
<template #kicker>Pokédex Edit</template>
|
||||
<template #actions>
|
||||
<RouterLink class="ui-button ui-button--blue ui-button--small" :to="cancelTo">{{ t('common.back') }}</RouterLink>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<Modal :title="pageTitle" :subtitle="t('pages.pokemon.editSubtitle')" :close-label="t('common.close')" size="wide" @close="closeEditor">
|
||||
<StatusMessage v-if="message" variant="danger">{{ message }}</StatusMessage>
|
||||
|
||||
<form v-if="!loading && options" class="detail-section" @submit.prevent="savePokemon">
|
||||
<form v-if="!loading && options" id="pokemon-edit-form" class="modal-edit-form" @submit.prevent="savePokemon">
|
||||
<div class="field">
|
||||
<label for="pokemon-id">ID</label>
|
||||
<input id="pokemon-id" v-model="pokemonForm.id" :disabled="isEditing" min="1" required type="number" />
|
||||
@@ -271,18 +268,18 @@ watch(() => pokemonForm.value.skillIds.slice(), syncSkillItemDrops);
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="link-button" :disabled="busy">{{ busy ? t('common.saving') : t('common.save') }}</button>
|
||||
<RouterLink class="plain-button" :to="cancelTo">{{ t('common.cancel') }}</RouterLink>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<section v-else class="detail-section skeleton-detail-section" aria-busy="true" :aria-label="t('pages.pokemon.loadingEdit')">
|
||||
<section v-else class="modal-edit-form skeleton-detail-section" aria-busy="true" :aria-label="t('pages.pokemon.loadingEdit')">
|
||||
<div v-for="index in 5" :key="index" class="field">
|
||||
<Skeleton :width="index === 1 ? '52px' : '88px'" />
|
||||
<Skeleton variant="box" height="44px" />
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<template v-if="!loading && options" #footer>
|
||||
<button type="submit" form="pokemon-edit-form" class="link-button" :disabled="busy">{{ busy ? t('common.saving') : t('common.save') }}</button>
|
||||
<button type="button" class="plain-button" :disabled="busy" @click="closeEditor">{{ t('common.cancel') }}</button>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute } from 'vue-router';
|
||||
import EditMeta from '../components/EditMeta.vue';
|
||||
import EntityChips from '../components/EntityChips.vue';
|
||||
import EntityCard from '../components/EntityCard.vue';
|
||||
@@ -9,8 +10,10 @@ import PageHeader from '../components/PageHeader.vue';
|
||||
import Skeleton from '../components/Skeleton.vue';
|
||||
import TagsSelect from '../components/TagsSelect.vue';
|
||||
import { api, type Options, type Pokemon } from '../services/api';
|
||||
import PokemonEdit from './PokemonEdit.vue';
|
||||
|
||||
const options = ref<Options | null>(null);
|
||||
const route = useRoute();
|
||||
const { t } = useI18n();
|
||||
const pokemon = ref<Pokemon[]>([]);
|
||||
const loading = ref(true);
|
||||
@@ -31,6 +34,7 @@ const query = computed(() => ({
|
||||
favoriteThingIds: favoriteThingIds.value.join(','),
|
||||
favoriteThingMode: favoriteThingMode.value
|
||||
}));
|
||||
const showEditor = computed(() => route.name === 'pokemon-new');
|
||||
|
||||
async function loadPokemon() {
|
||||
loading.value = true;
|
||||
@@ -140,5 +144,7 @@ watch(query, loadPokemon);
|
||||
<EntityChips :items="item.favorite_things" />
|
||||
</EntityCard>
|
||||
</div>
|
||||
|
||||
<PokemonEdit v-if="showEditor" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute } from 'vue-router';
|
||||
import DetailSection from '../components/DetailSection.vue';
|
||||
@@ -8,14 +8,37 @@ import EntityChips from '../components/EntityChips.vue';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import Skeleton from '../components/Skeleton.vue';
|
||||
import { api, type RecipeDetail } from '../services/api';
|
||||
import RecipeEdit from './RecipeEdit.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const { t } = useI18n();
|
||||
const recipe = ref<RecipeDetail | null>(null);
|
||||
const showEditor = computed(() => route.name === 'recipe-edit');
|
||||
|
||||
async function loadRecipeDetail() {
|
||||
recipe.value = await api.recipeDetail(String(route.params.id));
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
recipe.value = await api.recipeDetail(String(route.params.id));
|
||||
await loadRecipeDetail();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => route.name,
|
||||
(name, oldName) => {
|
||||
if (oldName === 'recipe-edit' && name === 'recipe-detail') {
|
||||
void loadRecipeDetail();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
() => route.params.id,
|
||||
() => {
|
||||
recipe.value = null;
|
||||
void loadRecipeDetail();
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -68,4 +91,6 @@ onMounted(async () => {
|
||||
<EditHistoryPanel :entity="recipe" :history="recipe.editHistory" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<RecipeEdit v-if="showEditor" />
|
||||
</template>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import PageHeader from '../components/PageHeader.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';
|
||||
@@ -53,6 +53,10 @@ function errorText(error: unknown, fallback: string) {
|
||||
return error instanceof Error && error.message ? error.message : fallback;
|
||||
}
|
||||
|
||||
function closeEditor() {
|
||||
void router.push(cancelTo.value);
|
||||
}
|
||||
|
||||
function preselectedItemId() {
|
||||
const itemId = route.query.itemId;
|
||||
if (typeof itemId !== 'string') {
|
||||
@@ -141,17 +145,10 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page-stack">
|
||||
<PageHeader :title="pageTitle" :subtitle="t('pages.recipes.editSubtitle')">
|
||||
<template #kicker>Recipe Edit</template>
|
||||
<template #actions>
|
||||
<RouterLink class="ui-button ui-button--blue ui-button--small" :to="cancelTo">{{ t('common.back') }}</RouterLink>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<Modal :title="pageTitle" :subtitle="t('pages.recipes.editSubtitle')" :close-label="t('common.close')" size="wide" @close="closeEditor">
|
||||
<StatusMessage v-if="message" variant="danger">{{ message }}</StatusMessage>
|
||||
|
||||
<form v-if="!loading && options" class="detail-section" @submit.prevent="saveRecipe">
|
||||
<form v-if="!loading && options" id="recipe-edit-form" class="modal-edit-form" @submit.prevent="saveRecipe">
|
||||
<div class="field">
|
||||
<label for="recipe-item">{{ t('pages.recipes.item') }}</label>
|
||||
<TagsSelect
|
||||
@@ -193,18 +190,18 @@ onMounted(() => {
|
||||
</div>
|
||||
<button type="button" class="plain-button" @click="addRecipeMaterial">{{ t('pages.recipes.addMaterial') }}</button>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="link-button" :disabled="busy">{{ busy ? t('common.saving') : t('common.save') }}</button>
|
||||
<RouterLink class="plain-button" :to="cancelTo">{{ t('common.cancel') }}</RouterLink>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<section v-else class="detail-section skeleton-detail-section" aria-busy="true" :aria-label="t('pages.recipes.loadingEdit')">
|
||||
<section v-else class="modal-edit-form skeleton-detail-section" aria-busy="true" :aria-label="t('pages.recipes.loadingEdit')">
|
||||
<div v-for="index in 4" :key="index" class="field">
|
||||
<Skeleton :width="index === 1 ? '52px' : '88px'" />
|
||||
<Skeleton variant="box" height="44px" />
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<template v-if="!loading && options" #footer>
|
||||
<button type="submit" form="recipe-edit-form" class="link-button" :disabled="busy">{{ busy ? t('common.saving') : t('common.save') }}</button>
|
||||
<button type="button" class="plain-button" :disabled="busy" @click="closeEditor">{{ t('common.cancel') }}</button>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute } from 'vue-router';
|
||||
import EditMeta from '../components/EditMeta.vue';
|
||||
import EntityCard from '../components/EntityCard.vue';
|
||||
import FilterPanel from '../components/FilterPanel.vue';
|
||||
@@ -9,8 +10,10 @@ import Skeleton from '../components/Skeleton.vue';
|
||||
import Tabs, { type TabOption } from '../components/Tabs.vue';
|
||||
import TagsSelect from '../components/TagsSelect.vue';
|
||||
import { api, type Item, type Options } from '../services/api';
|
||||
import RecipeEdit from './RecipeEdit.vue';
|
||||
|
||||
const options = ref<Options | null>(null);
|
||||
const route = useRoute();
|
||||
const { t } = useI18n();
|
||||
const items = ref<Item[]>([]);
|
||||
const loading = ref(true);
|
||||
@@ -35,6 +38,7 @@ const itemQuery = computed(() => ({
|
||||
tagIds: tagIds.value.join(','),
|
||||
recipeOrder: 1
|
||||
}));
|
||||
const showEditor = computed(() => route.name === 'recipe-new');
|
||||
|
||||
function recipeTarget(item: Item) {
|
||||
return item.recipe ? `/recipes/${item.recipe.id}` : undefined;
|
||||
@@ -149,5 +153,7 @@ watch(itemQuery, loadItems);
|
||||
</RouterLink>
|
||||
</EntityCard>
|
||||
</div>
|
||||
|
||||
<RecipeEdit v-if="showEditor" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user