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 @@