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:
252
frontend/src/components/Modal.vue
Normal file
252
frontend/src/components/Modal.vue
Normal 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>
|
||||
@@ -211,7 +211,8 @@ function commitSearch() {
|
||||
}
|
||||
|
||||
function onRootKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') {
|
||||
if (event.key === 'Escape' && isOpen.value) {
|
||||
event.stopPropagation();
|
||||
closeDropdown();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: '编辑',
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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