From 6812ddc4280070fa6f43197b95d378b7dfd3cfb8 Mon Sep 17 00:00:00 2001 From: xiaomai Date: Fri, 1 May 2026 13:44:34 +0800 Subject: [PATCH] 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 --- frontend/src/components/Modal.vue | 252 +++++++++++++++++++++++++ frontend/src/components/TagsSelect.vue | 3 +- frontend/src/i18n.ts | 2 + frontend/src/router/index.ts | 42 ++--- frontend/src/styles/main.css | 114 +++++++++++ frontend/src/views/AdminView.vue | 210 ++++++++++++++------- frontend/src/views/HabitatDetail.vue | 29 ++- frontend/src/views/HabitatEdit.vue | 31 ++- frontend/src/views/HabitatList.vue | 8 +- frontend/src/views/ItemDetail.vue | 29 ++- frontend/src/views/ItemEdit.vue | 31 ++- frontend/src/views/ItemsList.vue | 6 + frontend/src/views/PokemonDetail.vue | 29 ++- frontend/src/views/PokemonEdit.vue | 31 ++- frontend/src/views/PokemonList.vue | 6 + frontend/src/views/RecipeDetail.vue | 29 ++- frontend/src/views/RecipeEdit.vue | 31 ++- frontend/src/views/RecipeList.vue | 6 + 18 files changed, 717 insertions(+), 172 deletions(-) create mode 100644 frontend/src/components/Modal.vue diff --git a/frontend/src/components/Modal.vue b/frontend/src/components/Modal.vue new file mode 100644 index 0000000..7ffc452 --- /dev/null +++ b/frontend/src/components/Modal.vue @@ -0,0 +1,252 @@ + + + diff --git a/frontend/src/components/TagsSelect.vue b/frontend/src/components/TagsSelect.vue index f56d3e6..5a42635 100644 --- a/frontend/src/components/TagsSelect.vue +++ b/frontend/src/components/TagsSelect.vue @@ -211,7 +211,8 @@ function commitSearch() { } function onRootKeydown(event: KeyboardEvent) { - if (event.key === 'Escape') { + if (event.key === 'Escape' && isOpen.value) { + event.stopPropagation(); closeDropdown(); } } diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index fd8d9d9..ffef0ad 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -13,6 +13,7 @@ const messages = { back: 'Back', backToList: 'Back to list', cancel: 'Cancel', + close: 'Close', create: 'Create', delete: 'Delete', edit: 'Edit', @@ -266,6 +267,7 @@ const messages = { back: '返回', backToList: '返回列表', cancel: '取消', + close: '关闭', create: '创建', delete: '删除', edit: '编辑', diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index cb13c40..2bad664 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -1,16 +1,12 @@ import { createRouter, createWebHistory } from 'vue-router'; import PokemonList from '../views/PokemonList.vue'; import PokemonDetail from '../views/PokemonDetail.vue'; -import PokemonEdit from '../views/PokemonEdit.vue'; import HabitatList from '../views/HabitatList.vue'; import HabitatDetail from '../views/HabitatDetail.vue'; -import HabitatEdit from '../views/HabitatEdit.vue'; import ItemsList from '../views/ItemsList.vue'; import ItemDetail from '../views/ItemDetail.vue'; -import ItemEdit from '../views/ItemEdit.vue'; import RecipeList from '../views/RecipeList.vue'; import RecipeDetail from '../views/RecipeDetail.vue'; -import RecipeEdit from '../views/RecipeEdit.vue'; import DailyChecklistView from '../views/DailyChecklistView.vue'; import AdminView from '../views/AdminView.vue'; import LoginView from '../views/LoginView.vue'; @@ -22,29 +18,33 @@ export const router = createRouter({ history: createWebHistory(), routes: [ { path: '/', redirect: '/pokemon' }, - { path: '/pokemon', component: PokemonList }, - { path: '/pokemon/new', component: PokemonEdit, meta: { requiresVerified: true } }, - { path: '/pokemon/:id/edit', component: PokemonEdit, meta: { requiresVerified: true } }, - { path: '/pokemon/:id', component: PokemonDetail }, - { path: '/habitats', component: HabitatList }, - { path: '/habitats/new', component: HabitatEdit, meta: { requiresVerified: true } }, - { path: '/habitats/:id/edit', component: HabitatEdit, meta: { requiresVerified: true } }, - { path: '/habitats/:id', component: HabitatDetail }, - { path: '/items', component: ItemsList }, - { path: '/items/new', component: ItemEdit, meta: { requiresVerified: true } }, - { path: '/items/:id/edit', component: ItemEdit, meta: { requiresVerified: true } }, - { path: '/items/:id', component: ItemDetail }, - { path: '/recipes', component: RecipeList }, - { path: '/recipes/new', component: RecipeEdit, meta: { requiresVerified: true } }, - { path: '/recipes/:id/edit', component: RecipeEdit, meta: { requiresVerified: true } }, - { path: '/recipes/:id', component: RecipeDetail }, + { path: '/pokemon', name: 'pokemon-list', component: PokemonList }, + { path: '/pokemon/new', name: 'pokemon-new', component: PokemonList, meta: { requiresVerified: true, editorModal: true } }, + { path: '/pokemon/:id/edit', name: 'pokemon-edit', component: PokemonDetail, meta: { requiresVerified: true, editorModal: true } }, + { path: '/pokemon/:id', name: 'pokemon-detail', component: PokemonDetail }, + { path: '/habitats', name: 'habitat-list', component: HabitatList }, + { path: '/habitats/new', name: 'habitat-new', component: HabitatList, meta: { requiresVerified: true, editorModal: true } }, + { path: '/habitats/:id/edit', name: 'habitat-edit', component: HabitatDetail, meta: { requiresVerified: true, editorModal: true } }, + { path: '/habitats/:id', name: 'habitat-detail', component: HabitatDetail }, + { path: '/items', name: 'item-list', component: ItemsList }, + { path: '/items/new', name: 'item-new', component: ItemsList, meta: { requiresVerified: true, editorModal: true } }, + { path: '/items/:id/edit', name: 'item-edit', component: ItemDetail, meta: { requiresVerified: true, editorModal: true } }, + { path: '/items/:id', name: 'item-detail', component: ItemDetail }, + { path: '/recipes', name: 'recipe-list', component: RecipeList }, + { path: '/recipes/new', name: 'recipe-new', component: RecipeList, meta: { requiresVerified: true, editorModal: true } }, + { path: '/recipes/:id/edit', name: 'recipe-edit', component: RecipeDetail, meta: { requiresVerified: true, editorModal: true } }, + { path: '/recipes/:id', name: 'recipe-detail', component: RecipeDetail }, { path: '/checklist', component: DailyChecklistView }, { path: '/admin', component: AdminView, meta: { requiresVerified: true } }, { path: '/login', component: LoginView }, { path: '/register', component: RegisterView }, { 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) => { diff --git a/frontend/src/styles/main.css b/frontend/src/styles/main.css index 199fc68..1232bea 100644 --- a/frontend/src/styles/main.css +++ b/frontend/src/styles/main.css @@ -66,6 +66,10 @@ body { linear-gradient(180deg, var(--bg) 0%, var(--bg-alt) 100%); } +body.lock-scroll { + overflow: hidden; +} + a { color: inherit; text-decoration: none; @@ -545,6 +549,106 @@ button:disabled, 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 { position: relative; width: 100%; @@ -2008,4 +2112,14 @@ button:disabled, .appearance-row__main { grid-template-columns: 1fr; } + + .modal-footer { + align-items: stretch; + flex-direction: column-reverse; + } + + .modal-footer .link-button, + .modal-footer .plain-button { + width: 100%; + } } diff --git a/frontend/src/views/AdminView.vue b/frontend/src/views/AdminView.vue index b877d36..e31f003 100644 --- a/frontend/src/views/AdminView.vue +++ b/frontend/src/views/AdminView.vue @@ -1,6 +1,7 @@ diff --git a/frontend/src/views/HabitatEdit.vue b/frontend/src/views/HabitatEdit.vue index fcf399c..302d0d3 100644 --- a/frontend/src/views/HabitatEdit.vue +++ b/frontend/src/views/HabitatEdit.vue @@ -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(() => { diff --git a/frontend/src/views/HabitatList.vue b/frontend/src/views/HabitatList.vue index faad04f..45517a0 100644 --- a/frontend/src/views/HabitatList.vue +++ b/frontend/src/views/HabitatList.vue @@ -1,17 +1,21 @@ diff --git a/frontend/src/views/ItemEdit.vue b/frontend/src/views/ItemEdit.vue index 9d8cff7..5dc6176 100644 --- a/frontend/src/views/ItemEdit.vue +++ b/frontend/src/views/ItemEdit.vue @@ -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(() => { diff --git a/frontend/src/views/ItemsList.vue b/frontend/src/views/ItemsList.vue index ae5dfd9..294447a 100644 --- a/frontend/src/views/ItemsList.vue +++ b/frontend/src/views/ItemsList.vue @@ -1,6 +1,7 @@ diff --git a/frontend/src/views/PokemonEdit.vue b/frontend/src/views/PokemonEdit.vue index 06436dd..4e1a01c 100644 --- a/frontend/src/views/PokemonEdit.vue +++ b/frontend/src/views/PokemonEdit.vue @@ -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); diff --git a/frontend/src/views/PokemonList.vue b/frontend/src/views/PokemonList.vue index 5720d86..51f0fae 100644 --- a/frontend/src/views/PokemonList.vue +++ b/frontend/src/views/PokemonList.vue @@ -1,6 +1,7 @@ diff --git a/frontend/src/views/RecipeEdit.vue b/frontend/src/views/RecipeEdit.vue index 65cd41c..d9f4338 100644 --- a/frontend/src/views/RecipeEdit.vue +++ b/frontend/src/views/RecipeEdit.vue @@ -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(() => { diff --git a/frontend/src/views/RecipeList.vue b/frontend/src/views/RecipeList.vue index 2793aa6..1142f03 100644 --- a/frontend/src/views/RecipeList.vue +++ b/frontend/src/views/RecipeList.vue @@ -1,6 +1,7 @@