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:
2026-05-01 13:44:34 +08:00
parent bd068ce2f6
commit 6812ddc428
18 changed files with 717 additions and 172 deletions

View File

@@ -0,0 +1,252 @@
<script setup lang="ts">
import { nextTick, onBeforeUnmount, onMounted, onUpdated, ref, watch } from 'vue';
const props = withDefaults(
defineProps<{
open?: boolean;
title: string;
subtitle?: string;
closeLabel: string;
size?: 'default' | 'wide';
closeOnBackdrop?: boolean;
closeOnEscape?: boolean;
}>(),
{
open: true,
size: 'default',
closeOnBackdrop: true,
closeOnEscape: true
}
);
const emit = defineEmits<{
close: [];
}>();
const titleId = `modal-title-${Math.random().toString(36).slice(2)}`;
const dialog = ref<HTMLElement | null>(null);
const modalBody = ref<HTMLElement | null>(null);
const closeButton = ref<HTMLButtonElement | null>(null);
const previousActiveElement = ref<HTMLElement | null>(null);
const focusedBodyControl = ref(false);
let bodyObserver: MutationObserver | null = null;
const focusableSelector = [
'a[href]',
'button:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])'
].join(',');
const bodyInputSelector = [
'[autofocus]',
'input:not([disabled]):not([readonly]):not([type="hidden"])',
'textarea:not([disabled]):not([readonly])',
'select:not([disabled])'
].join(',');
const bodyFallbackSelector = [
'.tags-select__trigger:not([disabled])',
'button:not([disabled])',
'[tabindex]:not([tabindex="-1"])'
].join(',');
function lockPage() {
document.body.classList.add('lock-scroll');
}
function unlockPage() {
document.body.classList.remove('lock-scroll');
}
function restoreFocus() {
const target = previousActiveElement.value;
if (target?.isConnected) {
target.focus();
}
}
function stopBodyObserver() {
bodyObserver?.disconnect();
bodyObserver = null;
}
function isVisible(element: HTMLElement) {
const style = window.getComputedStyle(element);
return style.display !== 'none' && style.visibility !== 'hidden' && element.getClientRects().length > 0;
}
function firstVisibleElement(root: HTMLElement, selector: string) {
return Array.from(root.querySelectorAll<HTMLElement>(selector)).find(isVisible);
}
function firstBodyControl() {
const body = modalBody.value;
if (!body) return undefined;
return firstVisibleElement(body, bodyInputSelector) ?? firstVisibleElement(body, bodyFallbackSelector);
}
function shouldMoveFocusIntoBody() {
const activeElement = document.activeElement;
return activeElement === closeButton.value || activeElement === dialog.value || activeElement === document.body;
}
function watchBodyForControls() {
stopBodyObserver();
if (!modalBody.value) return;
bodyObserver = new MutationObserver(() => {
if (!props.open || focusedBodyControl.value || !shouldMoveFocusIntoBody()) return;
focusFirstControl();
});
bodyObserver.observe(modalBody.value, { childList: true, subtree: true });
}
function focusFirstControl() {
void nextTick(() => {
const target = firstBodyControl();
if (target) {
target.focus();
focusedBodyControl.value = true;
stopBodyObserver();
return;
}
watchBodyForControls();
closeButton.value?.focus();
});
}
function handleOpen() {
previousActiveElement.value = document.activeElement instanceof HTMLElement ? document.activeElement : null;
focusedBodyControl.value = false;
lockPage();
focusFirstControl();
}
function handleClose() {
stopBodyObserver();
unlockPage();
restoreFocus();
}
function requestClose() {
emit('close');
}
function onBackdropClick(event: MouseEvent) {
if (props.closeOnBackdrop && event.target === event.currentTarget) {
requestClose();
}
}
function focusableElements() {
return Array.from(dialog.value?.querySelectorAll<HTMLElement>(focusableSelector) ?? []).filter(isVisible);
}
function keepFocusInside(event: KeyboardEvent) {
const elements = focusableElements();
if (!elements.length) {
event.preventDefault();
dialog.value?.focus();
return;
}
const first = elements[0];
const last = elements[elements.length - 1];
const current = document.activeElement;
if (event.shiftKey && current === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && current === last) {
event.preventDefault();
first.focus();
}
}
function onDocumentKeydown(event: KeyboardEvent) {
if (!props.open) return;
if (event.key === 'Escape' && props.closeOnEscape) {
event.preventDefault();
requestClose();
return;
}
if (event.key === 'Tab') {
keepFocusInside(event);
}
}
onMounted(() => {
document.addEventListener('keydown', onDocumentKeydown);
if (props.open) {
handleOpen();
}
});
onBeforeUnmount(() => {
document.removeEventListener('keydown', onDocumentKeydown);
stopBodyObserver();
if (props.open) {
handleClose();
}
});
onUpdated(() => {
if (!props.open || focusedBodyControl.value) return;
if (shouldMoveFocusIntoBody()) {
focusFirstControl();
}
});
watch(
() => props.open,
(open, wasOpen) => {
if (open && !wasOpen) {
handleOpen();
}
if (!open && wasOpen) {
handleClose();
}
}
);
</script>
<template>
<Teleport to="body">
<div v-if="open" class="modal-backdrop is-open" role="presentation" @click="onBackdropClick">
<section
ref="dialog"
class="modal"
:class="{ 'modal--wide': size === 'wide' }"
role="dialog"
aria-modal="true"
:aria-labelledby="titleId"
tabindex="-1"
>
<div class="modal-header">
<div class="modal-header__copy">
<h2 :id="titleId">{{ title }}</h2>
<p v-if="subtitle">{{ subtitle }}</p>
</div>
<button ref="closeButton" class="modal-close-button" type="button" :aria-label="closeLabel" @click="requestClose">
×
</button>
</div>
<div ref="modalBody" class="modal-body">
<slot></slot>
</div>
<div v-if="$slots.footer" class="modal-footer">
<slot name="footer"></slot>
</div>
</section>
</div>
</Teleport>
</template>

View File

@@ -211,7 +211,8 @@ function commitSearch() {
} }
function onRootKeydown(event: KeyboardEvent) { function onRootKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') { if (event.key === 'Escape' && isOpen.value) {
event.stopPropagation();
closeDropdown(); closeDropdown();
} }
} }

View File

@@ -13,6 +13,7 @@ const messages = {
back: 'Back', back: 'Back',
backToList: 'Back to list', backToList: 'Back to list',
cancel: 'Cancel', cancel: 'Cancel',
close: 'Close',
create: 'Create', create: 'Create',
delete: 'Delete', delete: 'Delete',
edit: 'Edit', edit: 'Edit',
@@ -266,6 +267,7 @@ const messages = {
back: '返回', back: '返回',
backToList: '返回列表', backToList: '返回列表',
cancel: '取消', cancel: '取消',
close: '关闭',
create: '创建', create: '创建',
delete: '删除', delete: '删除',
edit: '编辑', edit: '编辑',

View File

@@ -1,16 +1,12 @@
import { createRouter, createWebHistory } from 'vue-router'; import { createRouter, createWebHistory } from 'vue-router';
import PokemonList from '../views/PokemonList.vue'; import PokemonList from '../views/PokemonList.vue';
import PokemonDetail from '../views/PokemonDetail.vue'; import PokemonDetail from '../views/PokemonDetail.vue';
import PokemonEdit from '../views/PokemonEdit.vue';
import HabitatList from '../views/HabitatList.vue'; import HabitatList from '../views/HabitatList.vue';
import HabitatDetail from '../views/HabitatDetail.vue'; import HabitatDetail from '../views/HabitatDetail.vue';
import HabitatEdit from '../views/HabitatEdit.vue';
import ItemsList from '../views/ItemsList.vue'; import ItemsList from '../views/ItemsList.vue';
import ItemDetail from '../views/ItemDetail.vue'; import ItemDetail from '../views/ItemDetail.vue';
import ItemEdit from '../views/ItemEdit.vue';
import RecipeList from '../views/RecipeList.vue'; import RecipeList from '../views/RecipeList.vue';
import RecipeDetail from '../views/RecipeDetail.vue'; import RecipeDetail from '../views/RecipeDetail.vue';
import RecipeEdit from '../views/RecipeEdit.vue';
import DailyChecklistView from '../views/DailyChecklistView.vue'; import DailyChecklistView from '../views/DailyChecklistView.vue';
import AdminView from '../views/AdminView.vue'; import AdminView from '../views/AdminView.vue';
import LoginView from '../views/LoginView.vue'; import LoginView from '../views/LoginView.vue';
@@ -22,29 +18,33 @@ export const router = createRouter({
history: createWebHistory(), history: createWebHistory(),
routes: [ routes: [
{ path: '/', redirect: '/pokemon' }, { path: '/', redirect: '/pokemon' },
{ path: '/pokemon', component: PokemonList }, { path: '/pokemon', name: 'pokemon-list', component: PokemonList },
{ path: '/pokemon/new', component: PokemonEdit, meta: { requiresVerified: true } }, { path: '/pokemon/new', name: 'pokemon-new', component: PokemonList, meta: { requiresVerified: true, editorModal: true } },
{ path: '/pokemon/:id/edit', component: PokemonEdit, meta: { requiresVerified: true } }, { path: '/pokemon/:id/edit', name: 'pokemon-edit', component: PokemonDetail, meta: { requiresVerified: true, editorModal: true } },
{ path: '/pokemon/:id', component: PokemonDetail }, { path: '/pokemon/:id', name: 'pokemon-detail', component: PokemonDetail },
{ path: '/habitats', component: HabitatList }, { path: '/habitats', name: 'habitat-list', component: HabitatList },
{ path: '/habitats/new', component: HabitatEdit, meta: { requiresVerified: true } }, { path: '/habitats/new', name: 'habitat-new', component: HabitatList, meta: { requiresVerified: true, editorModal: true } },
{ path: '/habitats/:id/edit', component: HabitatEdit, meta: { requiresVerified: true } }, { path: '/habitats/:id/edit', name: 'habitat-edit', component: HabitatDetail, meta: { requiresVerified: true, editorModal: true } },
{ path: '/habitats/:id', component: HabitatDetail }, { path: '/habitats/:id', name: 'habitat-detail', component: HabitatDetail },
{ path: '/items', component: ItemsList }, { path: '/items', name: 'item-list', component: ItemsList },
{ path: '/items/new', component: ItemEdit, meta: { requiresVerified: true } }, { path: '/items/new', name: 'item-new', component: ItemsList, meta: { requiresVerified: true, editorModal: true } },
{ path: '/items/:id/edit', component: ItemEdit, meta: { requiresVerified: true } }, { path: '/items/:id/edit', name: 'item-edit', component: ItemDetail, meta: { requiresVerified: true, editorModal: true } },
{ path: '/items/:id', component: ItemDetail }, { path: '/items/:id', name: 'item-detail', component: ItemDetail },
{ path: '/recipes', component: RecipeList }, { path: '/recipes', name: 'recipe-list', component: RecipeList },
{ path: '/recipes/new', component: RecipeEdit, meta: { requiresVerified: true } }, { path: '/recipes/new', name: 'recipe-new', component: RecipeList, meta: { requiresVerified: true, editorModal: true } },
{ path: '/recipes/:id/edit', component: RecipeEdit, meta: { requiresVerified: true } }, { path: '/recipes/:id/edit', name: 'recipe-edit', component: RecipeDetail, meta: { requiresVerified: true, editorModal: true } },
{ path: '/recipes/:id', component: RecipeDetail }, { path: '/recipes/:id', name: 'recipe-detail', component: RecipeDetail },
{ path: '/checklist', component: DailyChecklistView }, { path: '/checklist', component: DailyChecklistView },
{ path: '/admin', component: AdminView, meta: { requiresVerified: true } }, { path: '/admin', component: AdminView, meta: { requiresVerified: true } },
{ path: '/login', component: LoginView }, { path: '/login', component: LoginView },
{ path: '/register', component: RegisterView }, { path: '/register', component: RegisterView },
{ path: '/verify-email', component: VerifyEmailView } { path: '/verify-email', component: VerifyEmailView }
], ],
scrollBehavior: () => ({ top: 0 }) scrollBehavior(to, from, savedPosition) {
if (savedPosition) return savedPosition;
if (to.meta.editorModal === true || from.meta.editorModal === true) return false;
return { top: 0 };
}
}); });
router.beforeEach(async (to) => { router.beforeEach(async (to) => {

View File

@@ -66,6 +66,10 @@ body {
linear-gradient(180deg, var(--bg) 0%, var(--bg-alt) 100%); linear-gradient(180deg, var(--bg) 0%, var(--bg-alt) 100%);
} }
body.lock-scroll {
overflow: hidden;
}
a { a {
color: inherit; color: inherit;
text-decoration: none; text-decoration: none;
@@ -545,6 +549,106 @@ button:disabled,
outline: none; outline: none;
} }
.modal-backdrop {
position: fixed;
inset: 0;
z-index: 65;
display: none;
place-items: center;
padding: 22px;
background: rgba(8, 13, 22, 0.56);
}
.modal-backdrop.is-open {
display: grid;
}
.modal {
width: min(var(--modal-width, 560px), 100%);
max-height: min(100%, calc(100vh - 44px));
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
border: 2px solid var(--line-strong);
border-radius: var(--radius-card);
background: var(--surface);
box-shadow: var(--shadow-raised);
overflow: hidden;
}
.modal--wide {
--modal-width: 980px;
}
.modal-header,
.modal-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 16px;
background: var(--surface-soft);
}
.modal-header {
border-bottom: 1px solid var(--line);
}
.modal-header__copy {
display: grid;
gap: 4px;
min-width: 0;
}
.modal-header h2 {
margin: 0;
color: var(--ink);
font-family: var(--font-display);
font-size: 22px;
font-weight: 950;
line-height: 1.15;
overflow-wrap: anywhere;
}
.modal-header p {
margin: 0;
color: var(--muted);
font-size: 14px;
}
.modal-close-button {
width: 38px;
min-width: 38px;
height: 38px;
display: inline-grid;
place-items: center;
border: 2px solid var(--line);
border-radius: var(--radius-control);
background: var(--surface);
color: var(--ink);
font-size: 24px;
font-weight: 900;
line-height: 1;
cursor: pointer;
}
.modal-body {
min-width: 0;
padding: 16px;
display: grid;
gap: 12px;
overflow: auto;
}
.modal-edit-form {
display: grid;
gap: 12px;
}
.modal-footer {
border-top: 1px solid var(--line);
justify-content: flex-end;
}
.tags-select { .tags-select {
position: relative; position: relative;
width: 100%; width: 100%;
@@ -2008,4 +2112,14 @@ button:disabled,
.appearance-row__main { .appearance-row__main {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.modal-footer {
align-items: stretch;
flex-direction: column-reverse;
}
.modal-footer .link-button,
.modal-footer .plain-button {
width: 100%;
}
} }

View File

@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import Modal from '../components/Modal.vue';
import PageHeader from '../components/PageHeader.vue'; import PageHeader from '../components/PageHeader.vue';
import ReorderableList from '../components/ReorderableList.vue'; import ReorderableList from '../components/ReorderableList.vue';
import Skeleton from '../components/Skeleton.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 checklistForm = ref({ id: 0, title: '', translations: {} as TranslationMap });
const languageForm = ref({ code: '', name: '', enabled: true, isDefault: false, sortOrder: 0 }); const languageForm = ref({ code: '', name: '', enabled: true, isDefault: false, sortOrder: 0 });
const editingLanguageCode = ref(''); 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 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 }))); 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; if (!nextConfig || nextConfig.key === activeConfigType.value) return;
activeConfigType.value = nextConfig.key; activeConfigType.value = nextConfig.key;
resetConfigForm(); closeConfigModal();
void run(loadConfig); void run(loadConfig);
} }
}); });
const canEdit = computed(() => currentUser.value?.emailVerified === true); const canEdit = computed(() => currentUser.value?.emailVerified === true);
const showAdminSkeleton = computed(() => busy.value && !message.value && (!currentUser.value || contentLoading.value)); const showAdminSkeleton = computed(() => busy.value && !message.value && (!currentUser.value || contentLoading.value));
const canSetLanguageDefault = computed(() => languageForm.value.code === 'en'); 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 checklistKey = (item: DailyChecklistItem) => item.id;
const checklistLabel = (item: DailyChecklistItem) => item.title; const checklistLabel = (item: DailyChecklistItem) => item.title;
const languageKey = (item: Language) => item.code; const languageKey = (item: Language) => item.code;
@@ -159,12 +168,44 @@ function resetLanguageForm() {
editingLanguageCode.value = ''; editingLanguageCode.value = '';
} }
function openNewConfig() {
resetConfigForm();
configModalOpen.value = true;
}
function closeConfigModal() {
configModalOpen.value = false;
resetConfigForm();
}
function editConfig(item: EditableConfig) { function editConfig(item: EditableConfig) {
configForm.value = { id: item.id, name: item.baseName ?? item.name, translations: item.translations ?? {}, hasItemDrop: item.hasItemDrop === true }; 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) { function editChecklistItem(item: DailyChecklistItem) {
checklistForm.value = { id: item.id, title: item.title, translations: item.translations ?? {} }; 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) { function editLanguage(item: Language) {
@@ -176,6 +217,7 @@ function editLanguage(item: Language) {
isDefault: item.isDefault, isDefault: item.isDefault,
sortOrder: item.sortOrder sortOrder: item.sortOrder
}; };
languageModalOpen.value = true;
} }
function updateConfigTranslation(localeCode: string, value: string) { function updateConfigTranslation(localeCode: string, value: string) {
@@ -332,8 +374,8 @@ async function saveConfig() {
await api.createConfig(activeConfigType.value, payload); await api.createConfig(activeConfigType.value, payload);
} }
resetConfigForm();
await loadConfig(); await loadConfig();
closeConfigModal();
}); });
} }
@@ -359,7 +401,7 @@ async function saveChecklistItem() {
} }
await loadChecklist(); await loadChecklist();
resetChecklistForm(); closeChecklistModal();
}); });
} }
@@ -376,7 +418,7 @@ async function saveLanguage() {
languageRows.value = editingLanguageCode.value languageRows.value = editingLanguageCode.value
? await api.updateLanguage(editingLanguageCode.value, payload) ? await api.updateLanguage(editingLanguageCode.value, payload)
: await api.createLanguage(payload); : await api.createLanguage(payload);
resetLanguageForm(); closeLanguageModal();
setCurrentLocale(getCurrentLocale()); setCurrentLocale(getCurrentLocale());
}); });
} }
@@ -451,7 +493,7 @@ async function removeLanguage(code: string) {
await run(async () => { await run(async () => {
await api.deleteLanguage(code); await api.deleteLanguage(code);
if (editingLanguageCode.value === code) { if (editingLanguageCode.value === code) {
resetLanguageForm(); closeLanguageModal();
} }
await loadLanguages(); await loadLanguages();
setCurrentLocale(getCurrentLocale()); setCurrentLocale(getCurrentLocale());
@@ -462,7 +504,7 @@ async function removeConfig(id: number) {
await run(async () => { await run(async () => {
await api.deleteConfig(activeConfigType.value, id); await api.deleteConfig(activeConfigType.value, id);
if (configForm.value.id === id) { if (configForm.value.id === id) {
resetConfigForm(); closeConfigModal();
} }
await loadConfig(); await loadConfig();
}); });
@@ -472,7 +514,7 @@ async function removeChecklistItem(id: number) {
await run(async () => { await run(async () => {
await api.deleteDailyChecklistItem(id); await api.deleteDailyChecklistItem(id);
if (checklistForm.value.id === id) { if (checklistForm.value.id === id) {
resetChecklistForm(); closeChecklistModal();
} }
await loadChecklist(); await loadChecklist();
}); });
@@ -538,25 +580,12 @@ onMounted(() => {
</section> </section>
<section v-else-if="canEdit && activeTab === 'checklist'" class="detail-section"> <section v-else-if="canEdit && activeTab === 'checklist'" class="detail-section">
<h2>{{ t('pages.admin.checklist') }}</h2> <div class="detail-section__header">
<h2>{{ t('pages.admin.checklist') }}</h2>
<form class="detail-section__body" @submit.prevent="saveChecklistItem"> <button type="button" class="ui-button ui-button--primary ui-button--small" :disabled="busy" @click="openNewChecklistItem">
<h3 class="section-subtitle">{{ checklistForm.id ? t('pages.checklist.editTask') : t('pages.checklist.newTask') }}</h3> {{ t('common.new') }}
<TranslationFields </button>
id-prefix="checklist-title" </div>
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>
<h3 class="section-subtitle">{{ t('pages.checklist.sectionTitle') }}</h3> <h3 class="section-subtitle">{{ t('pages.checklist.sectionTitle') }}</h3>
<ReorderableList <ReorderableList
v-if="checklistRows.length" v-if="checklistRows.length"
@@ -583,29 +612,13 @@ onMounted(() => {
</section> </section>
<section v-else-if="canEdit && activeTab === 'config'" class="detail-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')" /> <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> <h3 class="section-subtitle">{{ selectedConfig.label }}</h3>
<ReorderableList <ReorderableList
v-if="configRows.length" v-if="configRows.length"
@@ -634,31 +647,12 @@ onMounted(() => {
</section> </section>
<section v-else-if="canEdit && activeTab === 'languages'" class="detail-section"> <section v-else-if="canEdit && activeTab === 'languages'" class="detail-section">
<h2>{{ t('pages.admin.languages') }}</h2> <div class="detail-section__header">
<h2>{{ t('pages.admin.languages') }}</h2>
<form class="detail-section__body" @submit.prevent="saveLanguage"> <button type="button" class="ui-button ui-button--primary ui-button--small" :disabled="busy" @click="openNewLanguage">
<h3 class="section-subtitle">{{ editingLanguageCode ? t('pages.admin.editLanguage') : t('pages.admin.newLanguage') }}</h3> {{ t('common.new') }}
<div class="field"> </button>
<label for="language-code">{{ t('pages.admin.languageCode') }}</label> </div>
<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>
<ReorderableList <ReorderableList
v-if="languageRows.length" v-if="languageRows.length"
:items="languageRows" :items="languageRows"
@@ -785,5 +779,75 @@ onMounted(() => {
</ReorderableList> </ReorderableList>
<p v-else class="meta-line">{{ t('common.noRecords') }}</p> <p v-else class="meta-line">{{ t('common.noRecords') }}</p>
</section> </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> </section>
</template> </template>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import DetailSection from '../components/DetailSection.vue'; import DetailSection from '../components/DetailSection.vue';
@@ -8,12 +8,14 @@ import EntityChips from '../components/EntityChips.vue';
import PageHeader from '../components/PageHeader.vue'; import PageHeader from '../components/PageHeader.vue';
import Skeleton from '../components/Skeleton.vue'; import Skeleton from '../components/Skeleton.vue';
import { api, type HabitatDetail } from '../services/api'; import { api, type HabitatDetail } from '../services/api';
import HabitatEdit from './HabitatEdit.vue';
const route = useRoute(); const route = useRoute();
const { t } = useI18n(); const { t } = useI18n();
const habitat = ref<HabitatDetail | null>(null); const habitat = ref<HabitatDetail | null>(null);
const timeOfDays = ['早晨', '中午', '傍晚', '晚上']; const timeOfDays = ['早晨', '中午', '傍晚', '晚上'];
const weathers = ['晴天', '阴天', '雨天']; const weathers = ['晴天', '阴天', '雨天'];
const showEditor = computed(() => route.name === 'habitat-edit');
type PokemonRow = { type PokemonRow = {
id: number; id: number;
@@ -96,9 +98,30 @@ const pokemonRows = computed<PokemonRow[]>(() => {
})); }));
}); });
onMounted(async () => { async function loadHabitatDetail() {
habitat.value = await api.habitatDetail(String(route.params.id)); 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> </script>
<template> <template>
@@ -192,4 +215,6 @@ onMounted(async () => {
<EditHistoryPanel :entity="habitat" :history="habitat.editHistory" /> <EditHistoryPanel :entity="habitat" :history="habitat.editHistory" />
</div> </div>
</section> </section>
<HabitatEdit v-if="showEditor" />
</template> </template>

View File

@@ -2,7 +2,7 @@
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router'; 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 Skeleton from '../components/Skeleton.vue';
import StatusMessage from '../components/StatusMessage.vue'; import StatusMessage from '../components/StatusMessage.vue';
import SwitchGroup from '../components/SwitchGroup.vue'; import SwitchGroup from '../components/SwitchGroup.vue';
@@ -123,6 +123,10 @@ function groupPokemonAppearances(detail: HabitatDetail): HabitatAppearanceForm[]
return [...rows.values()]; return [...rows.values()];
} }
function closeEditor() {
void router.push(cancelTo.value);
}
async function loadEditor() { async function loadEditor() {
loading.value = true; loading.value = true;
message.value = ''; message.value = '';
@@ -213,17 +217,10 @@ onMounted(() => {
</script> </script>
<template> <template>
<section class="page-stack"> <Modal :title="pageTitle" :subtitle="t('pages.habitats.editSubtitle')" :close-label="t('common.close')" size="wide" @close="closeEditor">
<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>
<StatusMessage v-if="message" variant="danger">{{ message }}</StatusMessage> <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 <TranslationFields
id-prefix="habitat-name" id-prefix="habitat-name"
v-model:base-value="habitatForm.name" v-model:base-value="habitatForm.name"
@@ -294,18 +291,18 @@ onMounted(() => {
</div> </div>
<button type="button" class="plain-button" @click="addPokemonAppearance">{{ t('pages.habitats.addPokemon') }}</button> <button type="button" class="plain-button" @click="addPokemonAppearance">{{ t('pages.habitats.addPokemon') }}</button>
</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> </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"> <div v-for="index in 5" :key="index" class="field">
<Skeleton :width="index === 1 ? '52px' : '112px'" /> <Skeleton :width="index === 1 ? '52px' : '112px'" />
<Skeleton variant="box" height="44px" /> <Skeleton variant="box" height="44px" />
</div> </div>
</section> </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> </template>

View File

@@ -1,17 +1,21 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import EditMeta from '../components/EditMeta.vue'; import EditMeta from '../components/EditMeta.vue';
import EntityChips from '../components/EntityChips.vue'; import EntityChips from '../components/EntityChips.vue';
import EntityCard from '../components/EntityCard.vue'; import EntityCard from '../components/EntityCard.vue';
import PageHeader from '../components/PageHeader.vue'; import PageHeader from '../components/PageHeader.vue';
import Skeleton from '../components/Skeleton.vue'; import Skeleton from '../components/Skeleton.vue';
import { api, type Habitat } from '../services/api'; import { api, type Habitat } from '../services/api';
import HabitatEdit from './HabitatEdit.vue';
const habitats = ref<Habitat[]>([]); const habitats = ref<Habitat[]>([]);
const route = useRoute();
const { t } = useI18n(); const { t } = useI18n();
const loading = ref(true); const loading = ref(true);
const skeletonCardCount = 6; const skeletonCardCount = 6;
const showEditor = computed(() => route.name === 'habitat-new');
onMounted(async () => { onMounted(async () => {
habitats.value = await api.habitats(); habitats.value = await api.habitats();
@@ -50,5 +54,7 @@ onMounted(async () => {
<EntityChips :items="item.pokemon ?? []" /> <EntityChips :items="item.pokemon ?? []" />
</EntityCard> </EntityCard>
</div> </div>
<HabitatEdit v-if="showEditor" />
</section> </section>
</template> </template>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import DetailSection from '../components/DetailSection.vue'; import DetailSection from '../components/DetailSection.vue';
@@ -8,10 +8,12 @@ import EntityChips from '../components/EntityChips.vue';
import PageHeader from '../components/PageHeader.vue'; import PageHeader from '../components/PageHeader.vue';
import Skeleton from '../components/Skeleton.vue'; import Skeleton from '../components/Skeleton.vue';
import { api, type ItemDetail } from '../services/api'; import { api, type ItemDetail } from '../services/api';
import ItemEdit from './ItemEdit.vue';
const route = useRoute(); const route = useRoute();
const { t } = useI18n(); const { t } = useI18n();
const item = ref<ItemDetail | null>(null); const item = ref<ItemDetail | null>(null);
const showEditor = computed(() => route.name === 'item-edit');
const customization = computed(() => { const customization = computed(() => {
if (!item.value) { if (!item.value) {
@@ -25,9 +27,30 @@ const customization = computed(() => {
].filter(Boolean); ].filter(Boolean);
}); });
onMounted(async () => { async function loadItemDetail() {
item.value = await api.itemDetail(String(route.params.id)); 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> </script>
<template> <template>
@@ -157,4 +180,6 @@ onMounted(async () => {
<EditHistoryPanel :entity="item" :history="item.editHistory" /> <EditHistoryPanel :entity="item" :history="item.editHistory" />
</div> </div>
</section> </section>
<ItemEdit v-if="showEditor" />
</template> </template>

View File

@@ -2,7 +2,7 @@
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router'; 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 Skeleton from '../components/Skeleton.vue';
import StatusMessage from '../components/StatusMessage.vue'; import StatusMessage from '../components/StatusMessage.vue';
import TagsSelect from '../components/TagsSelect.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; return error instanceof Error && error.message ? error.message : fallback;
} }
function closeEditor() {
void router.push(cancelTo.value);
}
async function loadOptions() { async function loadOptions() {
const [loadedOptions, loadedLanguages] = await Promise.all([api.options(), api.languages()]); const [loadedOptions, loadedLanguages] = await Promise.all([api.options(), api.languages()]);
options.value = loadedOptions; options.value = loadedOptions;
@@ -153,17 +157,10 @@ onMounted(() => {
</script> </script>
<template> <template>
<section class="page-stack"> <Modal :title="pageTitle" :subtitle="t('pages.items.editSubtitle')" :close-label="t('common.close')" size="wide" @close="closeEditor">
<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>
<StatusMessage v-if="message" variant="danger">{{ message }}</StatusMessage> <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 <TranslationFields
id-prefix="item-name" id-prefix="item-name"
v-model:base-value="itemForm.name" v-model:base-value="itemForm.name"
@@ -236,18 +233,18 @@ onMounted(() => {
@create="createMultiOption('item-tags', 'favorite-things', $event, itemForm.tagIds)" @create="createMultiOption('item-tags', 'favorite-things', $event, itemForm.tagIds)"
/> />
</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> </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"> <div v-for="index in 6" :key="index" class="field">
<Skeleton :width="index === 1 ? '52px' : '88px'" /> <Skeleton :width="index === 1 ? '52px' : '88px'" />
<Skeleton variant="box" height="44px" /> <Skeleton variant="box" height="44px" />
</div> </div>
</section> </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> </template>

View File

@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'; import { computed, onMounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import EditMeta from '../components/EditMeta.vue'; import EditMeta from '../components/EditMeta.vue';
import EntityChips from '../components/EntityChips.vue'; import EntityChips from '../components/EntityChips.vue';
import EntityCard from '../components/EntityCard.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 Tabs, { type TabOption } from '../components/Tabs.vue';
import TagsSelect from '../components/TagsSelect.vue'; import TagsSelect from '../components/TagsSelect.vue';
import { api, type Item, type Options } from '../services/api'; import { api, type Item, type Options } from '../services/api';
import ItemEdit from './ItemEdit.vue';
const options = ref<Options | null>(null); const options = ref<Options | null>(null);
const route = useRoute();
const { t } = useI18n(); const { t } = useI18n();
const items = ref<Item[]>([]); const items = ref<Item[]>([]);
const loading = ref(true); const loading = ref(true);
@@ -35,6 +38,7 @@ const itemQuery = computed(() => ({
usageId: usageId.value, usageId: usageId.value,
tagIds: tagIds.value.join(',') tagIds: tagIds.value.join(',')
})); }));
const showEditor = computed(() => route.name === 'item-new');
async function loadItems() { async function loadItems() {
loading.value = true; loading.value = true;
@@ -134,5 +138,7 @@ watch(itemQuery, loadItems);
<EntityChips :items="item.tags" /> <EntityChips :items="item.tags" />
</EntityCard> </EntityCard>
</div> </div>
<ItemEdit v-if="showEditor" />
</section> </section>
</template> </template>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import DetailSection from '../components/DetailSection.vue'; import DetailSection from '../components/DetailSection.vue';
@@ -9,6 +9,7 @@ import PageHeader from '../components/PageHeader.vue';
import Skeleton from '../components/Skeleton.vue'; import Skeleton from '../components/Skeleton.vue';
import Tabs, { type TabOption } from '../components/Tabs.vue'; import Tabs, { type TabOption } from '../components/Tabs.vue';
import { api, type PokemonDetail } from '../services/api'; import { api, type PokemonDetail } from '../services/api';
import PokemonEdit from './PokemonEdit.vue';
const route = useRoute(); const route = useRoute();
const { t } = useI18n(); const { t } = useI18n();
@@ -98,6 +99,7 @@ const habitatRows = computed<HabitatRow[]>(() => {
})); }));
}); });
const skillDropRows = computed(() => pokemon.value?.skills.filter((skill) => skill.itemDrop) ?? []); const skillDropRows = computed(() => pokemon.value?.skills.filter((skill) => skill.itemDrop) ?? []);
const showEditor = computed(() => route.name === 'pokemon-edit');
const itemCategoryTabs = computed<TabOption[]>(() => { const itemCategoryTabs = computed<TabOption[]>(() => {
const categories = new Map<string, string>(); const categories = new Map<string, string>();
@@ -119,9 +121,30 @@ const favoriteThingItems = computed(() => {
return items.filter((item) => String(item.category.id) === itemCategoryTab.value); return items.filter((item) => String(item.category.id) === itemCategoryTab.value);
}); });
onMounted(async () => { async function loadPokemonDetail() {
pokemon.value = await api.pokemonDetail(String(route.params.id)); 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> </script>
<template> <template>
@@ -259,4 +282,6 @@ onMounted(async () => {
<EditHistoryPanel :entity="pokemon" :history="pokemon.editHistory" /> <EditHistoryPanel :entity="pokemon" :history="pokemon.editHistory" />
</div> </div>
</section> </section>
<PokemonEdit v-if="showEditor" />
</template> </template>

View File

@@ -2,7 +2,7 @@
import { computed, onMounted, ref, watch } from 'vue'; import { computed, onMounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router'; 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 Skeleton from '../components/Skeleton.vue';
import StatusMessage from '../components/StatusMessage.vue'; import StatusMessage from '../components/StatusMessage.vue';
import TagsSelect from '../components/TagsSelect.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'); return name ? t('pages.pokemon.skillDrop', { name }) : t('pages.pokemon.dropItem');
} }
function closeEditor() {
void router.push(cancelTo.value);
}
async function loadEditor() { async function loadEditor() {
loading.value = true; loading.value = true;
message.value = ''; message.value = '';
@@ -186,17 +190,10 @@ watch(() => pokemonForm.value.skillIds.slice(), syncSkillItemDrops);
</script> </script>
<template> <template>
<section class="page-stack"> <Modal :title="pageTitle" :subtitle="t('pages.pokemon.editSubtitle')" :close-label="t('common.close')" size="wide" @close="closeEditor">
<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>
<StatusMessage v-if="message" variant="danger">{{ message }}</StatusMessage> <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"> <div class="field">
<label for="pokemon-id">ID</label> <label for="pokemon-id">ID</label>
<input id="pokemon-id" v-model="pokemonForm.id" :disabled="isEditing" min="1" required type="number" /> <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>
</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> </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"> <div v-for="index in 5" :key="index" class="field">
<Skeleton :width="index === 1 ? '52px' : '88px'" /> <Skeleton :width="index === 1 ? '52px' : '88px'" />
<Skeleton variant="box" height="44px" /> <Skeleton variant="box" height="44px" />
</div> </div>
</section> </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> </template>

View File

@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'; import { computed, onMounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import EditMeta from '../components/EditMeta.vue'; import EditMeta from '../components/EditMeta.vue';
import EntityChips from '../components/EntityChips.vue'; import EntityChips from '../components/EntityChips.vue';
import EntityCard from '../components/EntityCard.vue'; import EntityCard from '../components/EntityCard.vue';
@@ -9,8 +10,10 @@ import PageHeader from '../components/PageHeader.vue';
import Skeleton from '../components/Skeleton.vue'; import Skeleton from '../components/Skeleton.vue';
import TagsSelect from '../components/TagsSelect.vue'; import TagsSelect from '../components/TagsSelect.vue';
import { api, type Options, type Pokemon } from '../services/api'; import { api, type Options, type Pokemon } from '../services/api';
import PokemonEdit from './PokemonEdit.vue';
const options = ref<Options | null>(null); const options = ref<Options | null>(null);
const route = useRoute();
const { t } = useI18n(); const { t } = useI18n();
const pokemon = ref<Pokemon[]>([]); const pokemon = ref<Pokemon[]>([]);
const loading = ref(true); const loading = ref(true);
@@ -31,6 +34,7 @@ const query = computed(() => ({
favoriteThingIds: favoriteThingIds.value.join(','), favoriteThingIds: favoriteThingIds.value.join(','),
favoriteThingMode: favoriteThingMode.value favoriteThingMode: favoriteThingMode.value
})); }));
const showEditor = computed(() => route.name === 'pokemon-new');
async function loadPokemon() { async function loadPokemon() {
loading.value = true; loading.value = true;
@@ -140,5 +144,7 @@ watch(query, loadPokemon);
<EntityChips :items="item.favorite_things" /> <EntityChips :items="item.favorite_things" />
</EntityCard> </EntityCard>
</div> </div>
<PokemonEdit v-if="showEditor" />
</section> </section>
</template> </template>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from 'vue'; import { computed, onMounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import DetailSection from '../components/DetailSection.vue'; import DetailSection from '../components/DetailSection.vue';
@@ -8,14 +8,37 @@ import EntityChips from '../components/EntityChips.vue';
import PageHeader from '../components/PageHeader.vue'; import PageHeader from '../components/PageHeader.vue';
import Skeleton from '../components/Skeleton.vue'; import Skeleton from '../components/Skeleton.vue';
import { api, type RecipeDetail } from '../services/api'; import { api, type RecipeDetail } from '../services/api';
import RecipeEdit from './RecipeEdit.vue';
const route = useRoute(); const route = useRoute();
const { t } = useI18n(); const { t } = useI18n();
const recipe = ref<RecipeDetail | null>(null); 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 () => { 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> </script>
<template> <template>
@@ -68,4 +91,6 @@ onMounted(async () => {
<EditHistoryPanel :entity="recipe" :history="recipe.editHistory" /> <EditHistoryPanel :entity="recipe" :history="recipe.editHistory" />
</div> </div>
</section> </section>
<RecipeEdit v-if="showEditor" />
</template> </template>

View File

@@ -2,7 +2,7 @@
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router'; 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 Skeleton from '../components/Skeleton.vue';
import StatusMessage from '../components/StatusMessage.vue'; import StatusMessage from '../components/StatusMessage.vue';
import TagsSelect from '../components/TagsSelect.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; return error instanceof Error && error.message ? error.message : fallback;
} }
function closeEditor() {
void router.push(cancelTo.value);
}
function preselectedItemId() { function preselectedItemId() {
const itemId = route.query.itemId; const itemId = route.query.itemId;
if (typeof itemId !== 'string') { if (typeof itemId !== 'string') {
@@ -141,17 +145,10 @@ onMounted(() => {
</script> </script>
<template> <template>
<section class="page-stack"> <Modal :title="pageTitle" :subtitle="t('pages.recipes.editSubtitle')" :close-label="t('common.close')" size="wide" @close="closeEditor">
<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>
<StatusMessage v-if="message" variant="danger">{{ message }}</StatusMessage> <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"> <div class="field">
<label for="recipe-item">{{ t('pages.recipes.item') }}</label> <label for="recipe-item">{{ t('pages.recipes.item') }}</label>
<TagsSelect <TagsSelect
@@ -193,18 +190,18 @@ onMounted(() => {
</div> </div>
<button type="button" class="plain-button" @click="addRecipeMaterial">{{ t('pages.recipes.addMaterial') }}</button> <button type="button" class="plain-button" @click="addRecipeMaterial">{{ t('pages.recipes.addMaterial') }}</button>
</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> </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"> <div v-for="index in 4" :key="index" class="field">
<Skeleton :width="index === 1 ? '52px' : '88px'" /> <Skeleton :width="index === 1 ? '52px' : '88px'" />
<Skeleton variant="box" height="44px" /> <Skeleton variant="box" height="44px" />
</div> </div>
</section> </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> </template>

View File

@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'; import { computed, onMounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import EditMeta from '../components/EditMeta.vue'; import EditMeta from '../components/EditMeta.vue';
import EntityCard from '../components/EntityCard.vue'; import EntityCard from '../components/EntityCard.vue';
import FilterPanel from '../components/FilterPanel.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 Tabs, { type TabOption } from '../components/Tabs.vue';
import TagsSelect from '../components/TagsSelect.vue'; import TagsSelect from '../components/TagsSelect.vue';
import { api, type Item, type Options } from '../services/api'; import { api, type Item, type Options } from '../services/api';
import RecipeEdit from './RecipeEdit.vue';
const options = ref<Options | null>(null); const options = ref<Options | null>(null);
const route = useRoute();
const { t } = useI18n(); const { t } = useI18n();
const items = ref<Item[]>([]); const items = ref<Item[]>([]);
const loading = ref(true); const loading = ref(true);
@@ -35,6 +38,7 @@ const itemQuery = computed(() => ({
tagIds: tagIds.value.join(','), tagIds: tagIds.value.join(','),
recipeOrder: 1 recipeOrder: 1
})); }));
const showEditor = computed(() => route.name === 'recipe-new');
function recipeTarget(item: Item) { function recipeTarget(item: Item) {
return item.recipe ? `/recipes/${item.recipe.id}` : undefined; return item.recipe ? `/recipes/${item.recipe.id}` : undefined;
@@ -149,5 +153,7 @@ watch(itemQuery, loadItems);
</RouterLink> </RouterLink>
</EntityCard> </EntityCard>
</div> </div>
<RecipeEdit v-if="showEditor" />
</section> </section>
</template> </template>