feat(frontend): replace native confirms and enhance form controls
Add ConfirmDialog to replace window.confirm for delete actions Enhance SwitchGroup with grid layout, descriptions, and disabled state Update AdminView to use TagsSelect and SwitchGroup for better UX
This commit is contained in:
47
frontend/src/components/ConfirmDialog.vue
Normal file
47
frontend/src/components/ConfirmDialog.vue
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Icon } from '@iconify/vue';
|
||||||
|
import Modal from './Modal.vue';
|
||||||
|
import { iconCancel, iconDelete } from '../icons';
|
||||||
|
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
confirmLabel: string;
|
||||||
|
cancelLabel: string;
|
||||||
|
closeLabel: string;
|
||||||
|
busy?: boolean;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
busy: false
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
cancel: [];
|
||||||
|
confirm: [];
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Modal
|
||||||
|
:title="title"
|
||||||
|
:close-label="closeLabel"
|
||||||
|
:close-on-backdrop="!busy"
|
||||||
|
:close-on-escape="!busy"
|
||||||
|
@close="emit('cancel')"
|
||||||
|
>
|
||||||
|
<p class="confirm-dialog__message">{{ message }}</p>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<button type="button" class="link-button link-button--danger" :disabled="busy" @click="emit('confirm')">
|
||||||
|
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ confirmLabel }}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="plain-button" :disabled="busy" @click="emit('cancel')">
|
||||||
|
<Icon :icon="iconCancel" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ cancelLabel }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import EditMeta from './EditMeta.vue';
|
||||||
import type { EditHistoryAction, EditHistoryEntry, EditInfo, UserSummary } from '../services/api';
|
import type { EditHistoryAction, EditHistoryEntry, EditInfo, UserSummary } from '../services/api';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -169,11 +170,7 @@ function formatDateTime(value: string): string {
|
|||||||
<div>
|
<div>
|
||||||
<dt>{{ t('history.lastEdited') }}</dt>
|
<dt>{{ t('history.lastEdited') }}</dt>
|
||||||
<dd>
|
<dd>
|
||||||
<RouterLink v-if="props.entity.updatedBy" class="user-profile-link" :to="`/profile/${props.entity.updatedBy.id}`">
|
<EditMeta :entity="props.entity" :show-label="false" />
|
||||||
{{ props.entity.updatedBy.displayName }}
|
|
||||||
</RouterLink>
|
|
||||||
<strong v-else>{{ displayName(props.entity.updatedBy) }}</strong>
|
|
||||||
<time :datetime="props.entity.updatedAt">{{ formatDateTime(props.entity.updatedAt) }}</time>
|
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
|
|||||||
@@ -2,9 +2,15 @@
|
|||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import type { EditInfo } from '../services/api';
|
import type { EditInfo } from '../services/api';
|
||||||
|
|
||||||
defineProps<{
|
withDefaults(
|
||||||
entity: EditInfo;
|
defineProps<{
|
||||||
}>();
|
entity: EditInfo;
|
||||||
|
showLabel?: boolean;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
showLabel: true
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const { locale, t } = useI18n();
|
const { locale, t } = useI18n();
|
||||||
|
|
||||||
@@ -18,11 +24,11 @@ function formatDateTime(value: string): string {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<p class="edit-meta">
|
<p class="edit-meta">
|
||||||
{{ t('history.lastEdited') }}:
|
<template v-if="showLabel">{{ t('history.lastEdited') }}: </template>
|
||||||
<RouterLink v-if="entity.updatedBy" class="user-profile-link" :to="`/profile/${entity.updatedBy.id}`">
|
<RouterLink v-if="entity.updatedBy" class="user-profile-link" :to="`/profile/${entity.updatedBy.id}`">
|
||||||
{{ entity.updatedBy.displayName }}
|
{{ entity.updatedBy.displayName }}
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<span v-else>{{ t('common.system') }}</span>
|
<span v-else>{{ t('common.system') }}</span>
|
||||||
/ {{ formatDateTime(entity.updatedAt) }}
|
/ <time :datetime="entity.updatedAt">{{ formatDateTime(entity.updatedAt) }}</time>
|
||||||
</p>
|
</p>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { Icon } from '@iconify/vue';
|
import { Icon } from '@iconify/vue';
|
||||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import ConfirmDialog from './ConfirmDialog.vue';
|
||||||
import LoadMoreSentinel from './LoadMoreSentinel.vue';
|
import LoadMoreSentinel from './LoadMoreSentinel.vue';
|
||||||
import StatusBadge from './StatusBadge.vue';
|
import StatusBadge from './StatusBadge.vue';
|
||||||
import Tabs, { type TabOption } from './Tabs.vue';
|
import Tabs, { type TabOption } from './Tabs.vue';
|
||||||
@@ -52,6 +53,8 @@ let removeAuthListener: (() => void) | null = null;
|
|||||||
const nextCursor = ref<string | null>(null);
|
const nextCursor = ref<string | null>(null);
|
||||||
const hasMoreComments = ref(false);
|
const hasMoreComments = ref(false);
|
||||||
const commentTotal = ref(0);
|
const commentTotal = ref(0);
|
||||||
|
const pendingDeleteComment = ref<EntityDiscussionComment | null>(null);
|
||||||
|
const deleteConfirmBusy = ref(false);
|
||||||
|
|
||||||
function can(permissionKey: string) {
|
function can(permissionKey: string) {
|
||||||
return currentUser.value?.permissions.includes(permissionKey) === true;
|
return currentUser.value?.permissions.includes(permissionKey) === true;
|
||||||
@@ -462,11 +465,34 @@ function markCommentDeleted(rows: EntityDiscussionComment[], id: number): boolea
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteComment(comment: EntityDiscussionComment) {
|
function requestDeleteComment(comment: EntityDiscussionComment) {
|
||||||
if (!window.confirm(t('discussion.deleteConfirm'))) {
|
pendingDeleteComment.value = comment;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDeleteConfirm() {
|
||||||
|
if (deleteConfirmBusy.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pendingDeleteComment.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDeleteComment() {
|
||||||
|
const comment = pendingDeleteComment.value;
|
||||||
|
if (!comment) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteConfirmBusy.value = true;
|
||||||
|
try {
|
||||||
|
await deleteComment(comment);
|
||||||
|
pendingDeleteComment.value = null;
|
||||||
|
} finally {
|
||||||
|
deleteConfirmBusy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteComment(comment: EntityDiscussionComment) {
|
||||||
const key = commentKey(comment.id);
|
const key = commentKey(comment.id);
|
||||||
clearCommentError(key);
|
clearCommentError(key);
|
||||||
|
|
||||||
@@ -648,7 +674,7 @@ onUnmounted(() => {
|
|||||||
class="life-icon-button life-icon-button--flat life-icon-button--danger"
|
class="life-icon-button life-icon-button--flat life-icon-button--danger"
|
||||||
type="button"
|
type="button"
|
||||||
:aria-label="t('discussion.deleteComment')"
|
:aria-label="t('discussion.deleteComment')"
|
||||||
@click="deleteComment(comment)"
|
@click="requestDeleteComment(comment)"
|
||||||
>
|
>
|
||||||
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
|
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
|
||||||
<span class="life-action-tooltip" role="tooltip">{{ t('discussion.deleteComment') }}</span>
|
<span class="life-action-tooltip" role="tooltip">{{ t('discussion.deleteComment') }}</span>
|
||||||
@@ -750,7 +776,7 @@ onUnmounted(() => {
|
|||||||
class="life-icon-button life-icon-button--flat life-icon-button--danger"
|
class="life-icon-button life-icon-button--flat life-icon-button--danger"
|
||||||
type="button"
|
type="button"
|
||||||
:aria-label="t('discussion.deleteComment')"
|
:aria-label="t('discussion.deleteComment')"
|
||||||
@click="deleteComment(reply)"
|
@click="requestDeleteComment(reply)"
|
||||||
>
|
>
|
||||||
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
|
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
|
||||||
<span class="life-action-tooltip" role="tooltip">{{ t('discussion.deleteComment') }}</span>
|
<span class="life-action-tooltip" role="tooltip">{{ t('discussion.deleteComment') }}</span>
|
||||||
@@ -778,5 +804,17 @@ onUnmounted(() => {
|
|||||||
<p>{{ t('discussion.emptyHint') }}</p>
|
<p>{{ t('discussion.emptyHint') }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
v-if="pendingDeleteComment"
|
||||||
|
:title="t('discussion.deleteComment')"
|
||||||
|
:message="t('discussion.deleteConfirm')"
|
||||||
|
:confirm-label="t('common.delete')"
|
||||||
|
:cancel-label="t('common.cancel')"
|
||||||
|
:close-label="t('common.close')"
|
||||||
|
:busy="deleteConfirmBusy"
|
||||||
|
@cancel="closeDeleteConfirm"
|
||||||
|
@confirm="confirmDeleteComment"
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,29 +1,32 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
export type SwitchGroupOption = {
|
export type SwitchGroupOption = {
|
||||||
value: string;
|
value: string | number;
|
||||||
label: string;
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
disabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
modelValue: string[];
|
modelValue: Array<string | number>;
|
||||||
options: SwitchGroupOption[];
|
options: SwitchGroupOption[];
|
||||||
|
layout?: 'inline' | 'grid';
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'update:modelValue': [value: string[]];
|
'update:modelValue': [value: Array<string | number>];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
function optionId(index: number) {
|
function optionId(index: number) {
|
||||||
return `${props.id}-${index}`;
|
return `${props.id}-${index}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSelected(value: string) {
|
function isSelected(value: string | number) {
|
||||||
return props.modelValue.includes(value);
|
return props.modelValue.includes(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateOption(value: string, event: Event) {
|
function updateOption(value: string | number, event: Event) {
|
||||||
if (!(event.target instanceof HTMLInputElement)) return;
|
if (!(event.target instanceof HTMLInputElement)) return;
|
||||||
|
|
||||||
const { checked } = event.target;
|
const { checked } = event.target;
|
||||||
@@ -43,14 +46,23 @@ function updateOption(value: string, event: Event) {
|
|||||||
<template>
|
<template>
|
||||||
<fieldset class="switch-group">
|
<fieldset class="switch-group">
|
||||||
<legend>{{ label }}</legend>
|
<legend>{{ label }}</legend>
|
||||||
<div class="switch-group__options">
|
<div class="switch-group__options" :class="{ 'switch-group__options--grid': layout === 'grid' }">
|
||||||
<label v-for="(option, index) in options" :key="option.value" class="switch-control switch-control--stacked">
|
<label
|
||||||
<span class="switch-control__label">{{ option.label }}</span>
|
v-for="(option, index) in options"
|
||||||
|
:key="option.value"
|
||||||
|
class="switch-control switch-control--stacked"
|
||||||
|
:class="{ 'switch-control--disabled': option.disabled }"
|
||||||
|
>
|
||||||
|
<span class="switch-control__copy">
|
||||||
|
<span class="switch-control__label">{{ option.label }}</span>
|
||||||
|
<span v-if="option.description" class="switch-control__description">{{ option.description }}</span>
|
||||||
|
</span>
|
||||||
<input
|
<input
|
||||||
:id="optionId(index)"
|
:id="optionId(index)"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
:checked="isSelected(option.value)"
|
:checked="isSelected(option.value)"
|
||||||
:value="option.value"
|
:value="option.value"
|
||||||
|
:disabled="option.disabled"
|
||||||
@change="updateOption(option.value, $event)"
|
@change="updateOption(option.value, $event)"
|
||||||
/>
|
/>
|
||||||
<span class="switch-track" aria-hidden="true"></span>
|
<span class="switch-track" aria-hidden="true"></span>
|
||||||
|
|||||||
@@ -1318,6 +1318,11 @@ svg {
|
|||||||
--btn-fg: #ffffff;
|
--btn-fg: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.link-button--danger {
|
||||||
|
--btn-bg: var(--danger);
|
||||||
|
--btn-fg: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
.ui-button--ghost,
|
.ui-button--ghost,
|
||||||
.plain-button,
|
.plain-button,
|
||||||
.row-actions button,
|
.row-actions button,
|
||||||
@@ -2762,6 +2767,14 @@ button:disabled,
|
|||||||
font-weight: 850;
|
font-weight: 850;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.confirm-dialog__message {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 750;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
.checklist-list {
|
.checklist-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
@@ -7534,6 +7547,12 @@ button:disabled,
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.switch-group__options--grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
.switch-control {
|
.switch-control {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -7553,7 +7572,29 @@ button:disabled,
|
|||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.switch-group__options--grid .switch-control--stacked {
|
||||||
|
min-height: 52px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
justify-items: stretch;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-control);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch-control--disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.58;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch-control__copy {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.switch-control__label {
|
.switch-control__label {
|
||||||
|
display: block;
|
||||||
color: var(--ink-soft);
|
color: var(--ink-soft);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
@@ -7561,6 +7602,21 @@ button:disabled,
|
|||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.switch-control__description {
|
||||||
|
display: block;
|
||||||
|
margin-top: 2px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 650;
|
||||||
|
line-height: 1.3;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch-group__options--grid .switch-control__label,
|
||||||
|
.switch-group__options--grid .switch-control__description {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
.switch-control input {
|
.switch-control input {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inline-size: 1px;
|
inline-size: 1px;
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ 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';
|
||||||
import StatusMessage from '../components/StatusMessage.vue';
|
import StatusMessage from '../components/StatusMessage.vue';
|
||||||
|
import SwitchGroup, { type SwitchGroupOption } from '../components/SwitchGroup.vue';
|
||||||
|
import TagsSelect, { type TagsSelectOption } from '../components/TagsSelect.vue';
|
||||||
import Tabs, { type TabOption } from '../components/Tabs.vue';
|
import Tabs, { type TabOption } from '../components/Tabs.vue';
|
||||||
import TranslationFields from '../components/TranslationFields.vue';
|
import TranslationFields from '../components/TranslationFields.vue';
|
||||||
import {
|
import {
|
||||||
@@ -369,6 +371,31 @@ const dishModalTitle = computed(() => (dishForm.value.id ? t('pages.dish.editDis
|
|||||||
const dishRows = computed(() => dishCategoryRows.value.flatMap((category) => category.dishes));
|
const dishRows = computed(() => dishCategoryRows.value.flatMap((category) => category.dishes));
|
||||||
const selectedDishFormCategory = computed(() => dishCategoryRows.value.find((category) => String(category.id) === dishForm.value.categoryId) ?? null);
|
const selectedDishFormCategory = computed(() => dishCategoryRows.value.find((category) => String(category.id) === dishForm.value.categoryId) ?? null);
|
||||||
const dishAllowsSecondSecondaryMaterial = computed(() => (selectedDishFormCategory.value?.totalMaterialQuantity ?? 0) > 2);
|
const dishAllowsSecondSecondaryMaterial = computed(() => (selectedDishFormCategory.value?.totalMaterialQuantity ?? 0) > 2);
|
||||||
|
const dishItemSelectOptions = computed<TagsSelectOption[]>(() => dishItemRows.value.map((item) => ({ id: item.id, name: item.name })));
|
||||||
|
const optionalDishItemSelectOptions = computed<TagsSelectOption[]>(() => [{ id: '', name: t('common.none') }, ...dishItemSelectOptions.value]);
|
||||||
|
const dishCategorySelectOptions = computed<TagsSelectOption[]>(() =>
|
||||||
|
dishCategoryRows.value.map((category) => ({ id: category.id, name: category.name }))
|
||||||
|
);
|
||||||
|
const dishFlavorSelectOptions = computed<TagsSelectOption[]>(() => dishFlavorRows.value.map((flavor) => ({ id: flavor.id, name: flavor.name })));
|
||||||
|
const optionalDishSkillSelectOptions = computed<TagsSelectOption[]>(() => [
|
||||||
|
{ id: '', name: t('common.none') },
|
||||||
|
...dishSkillRows.value.map((skill) => ({ id: skill.id, name: skill.name }))
|
||||||
|
]);
|
||||||
|
const dishCategoryFormValid = computed(
|
||||||
|
() =>
|
||||||
|
dishCategoryForm.value.name.trim() !== '' &&
|
||||||
|
dishCategoryForm.value.effect.trim() !== '' &&
|
||||||
|
dishCategoryForm.value.cookwareItemId !== '' &&
|
||||||
|
dishCategoryForm.value.mainMaterialItemId !== '' &&
|
||||||
|
Number(dishCategoryForm.value.totalMaterialQuantity) >= 2
|
||||||
|
);
|
||||||
|
const dishFormValid = computed(
|
||||||
|
() =>
|
||||||
|
dishForm.value.categoryId !== '' &&
|
||||||
|
dishForm.value.itemId !== '' &&
|
||||||
|
dishForm.value.flavorId !== '' &&
|
||||||
|
dishForm.value.mosslaxEffect.trim() !== ''
|
||||||
|
);
|
||||||
const languageModalTitle = computed(() => (editingLanguageCode.value ? t('pages.admin.editLanguage') : t('pages.admin.newLanguage')));
|
const languageModalTitle = computed(() => (editingLanguageCode.value ? t('pages.admin.editLanguage') : t('pages.admin.newLanguage')));
|
||||||
const wordingModalTitle = computed(() => t('pages.admin.editWording'));
|
const wordingModalTitle = computed(() => t('pages.admin.editWording'));
|
||||||
const roleModalTitle = computed(() => (roleForm.value.id ? t('pages.admin.editRole') : t('pages.admin.newRole')));
|
const roleModalTitle = computed(() => (roleForm.value.id ? t('pages.admin.editRole') : t('pages.admin.newRole')));
|
||||||
@@ -386,6 +413,26 @@ const permissionGroups = computed(() => {
|
|||||||
}
|
}
|
||||||
return [...groups.entries()].map(([category, permissions]) => ({ category, permissions }));
|
return [...groups.entries()].map(([category, permissions]) => ({ category, permissions }));
|
||||||
});
|
});
|
||||||
|
const userRoleSwitchOptions = computed<SwitchGroupOption[]>(() =>
|
||||||
|
roleRows.value.map((role) => ({
|
||||||
|
value: role.id,
|
||||||
|
label: role.name,
|
||||||
|
description: role.description,
|
||||||
|
disabled: busy.value || !role.enabled
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
const userRoleSwitchValue = computed<Array<string | number>>({
|
||||||
|
get: () => userRoleForm.value.roleIds,
|
||||||
|
set: (values) => {
|
||||||
|
userRoleForm.value.roleIds = values.map((value) => Number(value)).sort((a, b) => a - b);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const rolePermissionSwitchValue = computed<Array<string | number>>({
|
||||||
|
get: () => rolePermissionForm.value.permissionIds,
|
||||||
|
set: (values) => {
|
||||||
|
rolePermissionForm.value.permissionIds = values.map((value) => Number(value)).sort((a, b) => a - b);
|
||||||
|
}
|
||||||
|
});
|
||||||
const wordingLocaleOptions = computed(() =>
|
const wordingLocaleOptions = computed(() =>
|
||||||
languageRows.value.length
|
languageRows.value.length
|
||||||
? languageRows.value
|
? languageRows.value
|
||||||
@@ -525,24 +572,13 @@ function rolePermissionCount(role: RoleDetail) {
|
|||||||
return t('pages.admin.permissionCount', { count: role.permissionIds.length });
|
return t('pages.admin.permissionCount', { count: role.permissionIds.length });
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleUserRole(roleId: number) {
|
function permissionSwitchOptions(permissions: Permission[]): SwitchGroupOption[] {
|
||||||
const roleIds = new Set(userRoleForm.value.roleIds);
|
return permissions.map((permission) => ({
|
||||||
if (roleIds.has(roleId)) {
|
value: permission.id,
|
||||||
roleIds.delete(roleId);
|
label: permission.name,
|
||||||
} else {
|
description: permission.key,
|
||||||
roleIds.add(roleId);
|
disabled: busy.value || !permission.enabled
|
||||||
}
|
}));
|
||||||
userRoleForm.value.roleIds = [...roleIds].sort((a, b) => a - b);
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleRolePermission(permissionId: number) {
|
|
||||||
const permissionIds = new Set(rolePermissionForm.value.permissionIds);
|
|
||||||
if (permissionIds.has(permissionId)) {
|
|
||||||
permissionIds.delete(permissionId);
|
|
||||||
} else {
|
|
||||||
permissionIds.add(permissionId);
|
|
||||||
}
|
|
||||||
rolePermissionForm.value.permissionIds = [...permissionIds].sort((a, b) => a - b);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function errorText(error: unknown, fallback: string) {
|
function errorText(error: unknown, fallback: string) {
|
||||||
@@ -1129,6 +1165,10 @@ function dishPayloadForSave() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function saveDishCategory() {
|
async function saveDishCategory() {
|
||||||
|
if (!dishCategoryFormValid.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await run(async () => {
|
await run(async () => {
|
||||||
const payload = dishCategoryPayloadForSave();
|
const payload = dishCategoryPayloadForSave();
|
||||||
if (dishCategoryForm.value.id) {
|
if (dishCategoryForm.value.id) {
|
||||||
@@ -1142,6 +1182,10 @@ async function saveDishCategory() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function saveDish() {
|
async function saveDish() {
|
||||||
|
if (!dishFormValid.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await run(async () => {
|
await run(async () => {
|
||||||
const payload = dishPayloadForSave();
|
const payload = dishPayloadForSave();
|
||||||
if (dishForm.value.id) {
|
if (dishForm.value.id) {
|
||||||
@@ -2537,20 +2581,7 @@ onMounted(() => {
|
|||||||
<strong>{{ editingUser.displayName }}</strong>
|
<strong>{{ editingUser.displayName }}</strong>
|
||||||
<span class="meta-line">{{ editingUser.email }}</span>
|
<span class="meta-line">{{ editingUser.email }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="permission-grid" role="group" :aria-label="t('pages.admin.roles')">
|
<SwitchGroup id="admin-user-roles" v-model="userRoleSwitchValue" :label="t('pages.admin.roles')" :options="userRoleSwitchOptions" layout="grid" />
|
||||||
<label v-for="role in roleRows" :key="role.id" class="permission-toggle">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
:checked="userRoleForm.roleIds.includes(role.id)"
|
|
||||||
:disabled="busy || !role.enabled"
|
|
||||||
@change="toggleUserRole(role.id)"
|
|
||||||
/>
|
|
||||||
<span>
|
|
||||||
<strong>{{ role.name }}</strong>
|
|
||||||
<small>{{ role.description }}</small>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<template #footer>
|
<template #footer>
|
||||||
@@ -2607,22 +2638,14 @@ onMounted(() => {
|
|||||||
<span class="meta-line">{{ editingRole.description }}</span>
|
<span class="meta-line">{{ editingRole.description }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="permission-groups">
|
<div class="permission-groups">
|
||||||
<section v-for="group in permissionGroups" :key="group.category" class="permission-group">
|
<section v-for="(group, index) in permissionGroups" :key="group.category" class="permission-group">
|
||||||
<h3>{{ group.category }}</h3>
|
<SwitchGroup
|
||||||
<div class="permission-grid" role="group" :aria-label="group.category">
|
:id="`admin-role-permissions-${index}`"
|
||||||
<label v-for="permission in group.permissions" :key="permission.id" class="permission-toggle">
|
v-model="rolePermissionSwitchValue"
|
||||||
<input
|
:label="group.category"
|
||||||
type="checkbox"
|
:options="permissionSwitchOptions(group.permissions)"
|
||||||
:checked="rolePermissionForm.permissionIds.includes(permission.id)"
|
layout="grid"
|
||||||
:disabled="busy || !permission.enabled"
|
/>
|
||||||
@change="toggleRolePermission(permission.id)"
|
|
||||||
/>
|
|
||||||
<span>
|
|
||||||
<strong>{{ permission.name }}</strong>
|
|
||||||
<small>{{ permission.key }}</small>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -2713,10 +2736,14 @@ onMounted(() => {
|
|||||||
/>
|
/>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="dish-category-cookware">{{ t('pages.dish.cookware') }}</label>
|
<label for="dish-category-cookware">{{ t('pages.dish.cookware') }}</label>
|
||||||
<select id="dish-category-cookware" v-model="dishCategoryForm.cookwareItemId" required>
|
<TagsSelect
|
||||||
<option value="">{{ t('common.none') }}</option>
|
id="dish-category-cookware"
|
||||||
<option v-for="item in dishItemRows" :key="`cookware-${item.id}`" :value="String(item.id)">{{ item.name }}</option>
|
v-model="dishCategoryForm.cookwareItemId"
|
||||||
</select>
|
:options="dishItemSelectOptions"
|
||||||
|
:multiple="false"
|
||||||
|
:placeholder="t('common.select')"
|
||||||
|
:search-placeholder="t('pages.pokemon.searchItems')"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="dish-category-total-material-quantity">{{ t('pages.dish.totalMaterialQuantity') }}</label>
|
<label for="dish-category-total-material-quantity">{{ t('pages.dish.totalMaterialQuantity') }}</label>
|
||||||
@@ -2724,10 +2751,14 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="dish-category-main-material">{{ t('pages.dish.mainMaterial') }}</label>
|
<label for="dish-category-main-material">{{ t('pages.dish.mainMaterial') }}</label>
|
||||||
<select id="dish-category-main-material" v-model="dishCategoryForm.mainMaterialItemId" required>
|
<TagsSelect
|
||||||
<option value="">{{ t('common.none') }}</option>
|
id="dish-category-main-material"
|
||||||
<option v-for="item in dishItemRows" :key="`category-main-material-${item.id}`" :value="String(item.id)">{{ item.name }}</option>
|
v-model="dishCategoryForm.mainMaterialItemId"
|
||||||
</select>
|
:options="dishItemSelectOptions"
|
||||||
|
:multiple="false"
|
||||||
|
:placeholder="t('common.select')"
|
||||||
|
:search-placeholder="t('pages.pokemon.searchItems')"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<TranslationFields
|
<TranslationFields
|
||||||
@@ -2742,7 +2773,7 @@ onMounted(() => {
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<button type="submit" form="admin-dish-category-form" class="link-button" :disabled="busy">
|
<button type="submit" form="admin-dish-category-form" class="link-button" :disabled="busy || !dishCategoryFormValid">
|
||||||
<Icon :icon="iconSave" class="ui-icon" aria-hidden="true" />
|
<Icon :icon="iconSave" class="ui-icon" aria-hidden="true" />
|
||||||
{{ busy ? t('common.saving') : t('common.save') }}
|
{{ busy ? t('common.saving') : t('common.save') }}
|
||||||
</button>
|
</button>
|
||||||
@@ -2758,47 +2789,71 @@ onMounted(() => {
|
|||||||
<div class="dish-form-row dish-form-row--3">
|
<div class="dish-form-row dish-form-row--3">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="dish-category">{{ t('pages.dish.category') }}</label>
|
<label for="dish-category">{{ t('pages.dish.category') }}</label>
|
||||||
<select id="dish-category" v-model="dishForm.categoryId" required>
|
<TagsSelect
|
||||||
<option value="">{{ t('common.none') }}</option>
|
id="dish-category"
|
||||||
<option v-for="category in dishCategoryRows" :key="`dish-category-option-${category.id}`" :value="String(category.id)">{{ category.name }}</option>
|
v-model="dishForm.categoryId"
|
||||||
</select>
|
:options="dishCategorySelectOptions"
|
||||||
|
:multiple="false"
|
||||||
|
:placeholder="t('common.select')"
|
||||||
|
:search-placeholder="t('pages.dish.category')"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="dish-item">{{ t('pages.dish.dishItem') }}</label>
|
<label for="dish-item">{{ t('pages.dish.dishItem') }}</label>
|
||||||
<select id="dish-item" v-model="dishForm.itemId" required>
|
<TagsSelect
|
||||||
<option value="">{{ t('common.none') }}</option>
|
id="dish-item"
|
||||||
<option v-for="item in dishItemRows" :key="`dish-item-${item.id}`" :value="String(item.id)">{{ item.name }}</option>
|
v-model="dishForm.itemId"
|
||||||
</select>
|
:options="dishItemSelectOptions"
|
||||||
|
:multiple="false"
|
||||||
|
:placeholder="t('common.select')"
|
||||||
|
:search-placeholder="t('pages.pokemon.searchItems')"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="dish-flavor">{{ t('pages.dish.flavor') }}</label>
|
<label for="dish-flavor">{{ t('pages.dish.flavor') }}</label>
|
||||||
<select id="dish-flavor" v-model="dishForm.flavorId" required>
|
<TagsSelect
|
||||||
<option value="">{{ t('common.none') }}</option>
|
id="dish-flavor"
|
||||||
<option v-for="flavor in dishFlavorRows" :key="`dish-flavor-${flavor.id}`" :value="String(flavor.id)">{{ flavor.name }}</option>
|
v-model="dishForm.flavorId"
|
||||||
</select>
|
:options="dishFlavorSelectOptions"
|
||||||
|
:multiple="false"
|
||||||
|
:placeholder="t('common.select')"
|
||||||
|
:search-placeholder="t('pages.dish.flavor')"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="dish-form-row dish-form-row--3">
|
<div class="dish-form-row dish-form-row--3">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="dish-secondary-material-1">{{ t('pages.dish.secondaryMaterial') }}</label>
|
<label for="dish-secondary-material-1">{{ t('pages.dish.secondaryMaterial') }}</label>
|
||||||
<select id="dish-secondary-material-1" v-model="dishForm.secondaryMaterialItemIds[0]">
|
<TagsSelect
|
||||||
<option value="">{{ t('common.none') }}</option>
|
id="dish-secondary-material-1"
|
||||||
<option v-for="item in dishItemRows" :key="`dish-secondary-material-1-${item.id}`" :value="String(item.id)">{{ item.name }}</option>
|
v-model="dishForm.secondaryMaterialItemIds[0]"
|
||||||
</select>
|
:options="optionalDishItemSelectOptions"
|
||||||
|
:multiple="false"
|
||||||
|
:placeholder="t('common.none')"
|
||||||
|
:search-placeholder="t('pages.pokemon.searchItems')"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="dishAllowsSecondSecondaryMaterial" class="field">
|
<div v-if="dishAllowsSecondSecondaryMaterial" class="field">
|
||||||
<label for="dish-secondary-material-2">{{ t('pages.dish.secondSecondaryMaterial') }}</label>
|
<label for="dish-secondary-material-2">{{ t('pages.dish.secondSecondaryMaterial') }}</label>
|
||||||
<select id="dish-secondary-material-2" v-model="dishForm.secondaryMaterialItemIds[1]">
|
<TagsSelect
|
||||||
<option value="">{{ t('common.none') }}</option>
|
id="dish-secondary-material-2"
|
||||||
<option v-for="item in dishItemRows" :key="`dish-secondary-material-2-${item.id}`" :value="String(item.id)">{{ item.name }}</option>
|
v-model="dishForm.secondaryMaterialItemIds[1]"
|
||||||
</select>
|
:options="optionalDishItemSelectOptions"
|
||||||
|
:multiple="false"
|
||||||
|
:placeholder="t('common.none')"
|
||||||
|
:search-placeholder="t('pages.pokemon.searchItems')"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="dish-pokemon-skill">{{ t('pages.dish.pokemonSkill') }}</label>
|
<label for="dish-pokemon-skill">{{ t('pages.dish.pokemonSkill') }}</label>
|
||||||
<select id="dish-pokemon-skill" v-model="dishForm.pokemonSkillId">
|
<TagsSelect
|
||||||
<option value="">{{ t('common.none') }}</option>
|
id="dish-pokemon-skill"
|
||||||
<option v-for="skill in dishSkillRows" :key="`dish-skill-${skill.id}`" :value="String(skill.id)">{{ skill.name }}</option>
|
v-model="dishForm.pokemonSkillId"
|
||||||
</select>
|
:options="optionalDishSkillSelectOptions"
|
||||||
|
:multiple="false"
|
||||||
|
:placeholder="t('common.none')"
|
||||||
|
:search-placeholder="t('pages.dish.pokemonSkill')"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<TranslationFields
|
<TranslationFields
|
||||||
@@ -2813,7 +2868,7 @@ onMounted(() => {
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<button type="submit" form="admin-dish-form" class="link-button" :disabled="busy">
|
<button type="submit" form="admin-dish-form" class="link-button" :disabled="busy || !dishFormValid">
|
||||||
<Icon :icon="iconSave" class="ui-icon" aria-hidden="true" />
|
<Icon :icon="iconSave" class="ui-icon" aria-hidden="true" />
|
||||||
{{ busy ? t('common.saving') : t('common.save') }}
|
{{ busy ? t('common.saving') : t('common.save') }}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Icon } from '@iconify/vue';
|
|||||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
import { computed, onMounted, onUnmounted, 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 ConfirmDialog from '../components/ConfirmDialog.vue';
|
||||||
import LifeRatingControl from '../components/LifeRatingControl.vue';
|
import LifeRatingControl from '../components/LifeRatingControl.vue';
|
||||||
import LifeReactionUsersModal from '../components/LifeReactionUsersModal.vue';
|
import LifeReactionUsersModal from '../components/LifeReactionUsersModal.vue';
|
||||||
import LoadMoreSentinel from '../components/LoadMoreSentinel.vue';
|
import LoadMoreSentinel from '../components/LoadMoreSentinel.vue';
|
||||||
@@ -68,6 +69,8 @@ const ratingErrors = ref<Record<number, string>>({});
|
|||||||
const moderationBusyPostId = ref<number | null>(null);
|
const moderationBusyPostId = ref<number | null>(null);
|
||||||
const moderationErrors = ref<Record<number, string>>({});
|
const moderationErrors = ref<Record<number, string>>({});
|
||||||
const reactionUsersModal = ref<{ postId: number; reactionType: LifeReactionType | null } | null>(null);
|
const reactionUsersModal = ref<{ postId: number; reactionType: LifeReactionType | null } | null>(null);
|
||||||
|
const pendingDeleteComment = ref<LifeComment | null>(null);
|
||||||
|
const deleteConfirmBusy = ref(false);
|
||||||
const lifeCommentPageSize = 20;
|
const lifeCommentPageSize = 20;
|
||||||
const commentMaxLength = 1000;
|
const commentMaxLength = 1000;
|
||||||
let removeAuthListener: (() => void) | null = null;
|
let removeAuthListener: (() => void) | null = null;
|
||||||
@@ -707,10 +710,6 @@ function markOwnCommentDeleted(items: LifeComment[], id: number): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function deleteComment(comment: LifeComment) {
|
async function deleteComment(comment: LifeComment) {
|
||||||
if (!window.confirm(t('pages.life.deleteCommentConfirm'))) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const key = replyKey(comment.id);
|
const key = replyKey(comment.id);
|
||||||
clearCommentError(key);
|
clearCommentError(key);
|
||||||
|
|
||||||
@@ -737,6 +736,33 @@ async function deleteComment(comment: LifeComment) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function requestDeleteComment(comment: LifeComment) {
|
||||||
|
pendingDeleteComment.value = comment;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDeleteConfirm() {
|
||||||
|
if (deleteConfirmBusy.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingDeleteComment.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDeleteComment() {
|
||||||
|
const comment = pendingDeleteComment.value;
|
||||||
|
if (!comment) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteConfirmBusy.value = true;
|
||||||
|
try {
|
||||||
|
await deleteComment(comment);
|
||||||
|
pendingDeleteComment.value = null;
|
||||||
|
} finally {
|
||||||
|
deleteConfirmBusy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function restoreComment(comment: LifeComment) {
|
async function restoreComment(comment: LifeComment) {
|
||||||
const key = replyKey(comment.id);
|
const key = replyKey(comment.id);
|
||||||
commentBusyKey.value = key;
|
commentBusyKey.value = key;
|
||||||
@@ -1160,7 +1186,7 @@ onUnmounted(() => {
|
|||||||
class="life-icon-button life-icon-button--flat life-icon-button--danger"
|
class="life-icon-button life-icon-button--flat life-icon-button--danger"
|
||||||
type="button"
|
type="button"
|
||||||
:aria-label="t('pages.life.deleteComment')"
|
:aria-label="t('pages.life.deleteComment')"
|
||||||
@click="deleteComment(comment)"
|
@click="requestDeleteComment(comment)"
|
||||||
>
|
>
|
||||||
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
|
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
|
||||||
<span class="life-action-tooltip" role="tooltip">{{ t('pages.life.deleteComment') }}</span>
|
<span class="life-action-tooltip" role="tooltip">{{ t('pages.life.deleteComment') }}</span>
|
||||||
@@ -1277,7 +1303,7 @@ onUnmounted(() => {
|
|||||||
class="life-icon-button life-icon-button--flat life-icon-button--danger"
|
class="life-icon-button life-icon-button--flat life-icon-button--danger"
|
||||||
type="button"
|
type="button"
|
||||||
:aria-label="t('pages.life.deleteComment')"
|
:aria-label="t('pages.life.deleteComment')"
|
||||||
@click="deleteComment(reply)"
|
@click="requestDeleteComment(reply)"
|
||||||
>
|
>
|
||||||
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
|
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
|
||||||
<span class="life-action-tooltip" role="tooltip">{{ t('pages.life.deleteComment') }}</span>
|
<span class="life-action-tooltip" role="tooltip">{{ t('pages.life.deleteComment') }}</span>
|
||||||
@@ -1333,6 +1359,18 @@ onUnmounted(() => {
|
|||||||
<h2>{{ t('pages.life.empty') }}</h2>
|
<h2>{{ t('pages.life.empty') }}</h2>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
v-if="pendingDeleteComment"
|
||||||
|
:title="t('pages.life.deleteComment')"
|
||||||
|
:message="t('pages.life.deleteCommentConfirm')"
|
||||||
|
:confirm-label="t('common.delete')"
|
||||||
|
:cancel-label="t('common.cancel')"
|
||||||
|
:close-label="t('common.close')"
|
||||||
|
:busy="deleteConfirmBusy"
|
||||||
|
@cancel="closeDeleteConfirm"
|
||||||
|
@confirm="confirmDeleteComment"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { Icon } from '@iconify/vue';
|
import { Icon } from '@iconify/vue';
|
||||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
|
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import ConfirmDialog from '../components/ConfirmDialog.vue';
|
||||||
import FilterPanel from '../components/FilterPanel.vue';
|
import FilterPanel from '../components/FilterPanel.vue';
|
||||||
import LifeRatingControl from '../components/LifeRatingControl.vue';
|
import LifeRatingControl from '../components/LifeRatingControl.vue';
|
||||||
import LifeReactionUsersModal from '../components/LifeReactionUsersModal.vue';
|
import LifeReactionUsersModal from '../components/LifeReactionUsersModal.vue';
|
||||||
@@ -63,6 +64,7 @@ type LifeCommentPageState = {
|
|||||||
|
|
||||||
type LifePostSort = 'latest' | 'oldest' | 'top-rated';
|
type LifePostSort = 'latest' | 'oldest' | 'top-rated';
|
||||||
type LifeFeedScope = 'all' | 'following';
|
type LifeFeedScope = 'all' | 'following';
|
||||||
|
type PendingLifeDelete = { type: 'post'; post: LifePost } | { type: 'comment'; post: LifePost; comment: LifeComment };
|
||||||
|
|
||||||
const { locale, t } = useI18n();
|
const { locale, t } = useI18n();
|
||||||
const posts = ref<LifePost[]>([]);
|
const posts = ref<LifePost[]>([]);
|
||||||
@@ -105,6 +107,8 @@ const ratingErrors = ref<Record<number, string>>({});
|
|||||||
const moderationBusyPostId = ref<number | null>(null);
|
const moderationBusyPostId = ref<number | null>(null);
|
||||||
const moderationErrors = ref<Record<number, string>>({});
|
const moderationErrors = ref<Record<number, string>>({});
|
||||||
const reactionUsersModal = ref<{ postId: number; reactionType: LifeReactionType | null } | null>(null);
|
const reactionUsersModal = ref<{ postId: number; reactionType: LifeReactionType | null } | null>(null);
|
||||||
|
const pendingDelete = ref<PendingLifeDelete | null>(null);
|
||||||
|
const deleteConfirmBusy = ref(false);
|
||||||
const bodyInput = ref<HTMLTextAreaElement | null>(null);
|
const bodyInput = ref<HTMLTextAreaElement | null>(null);
|
||||||
const loadMoreSentinel = ref<HTMLElement | null>(null);
|
const loadMoreSentinel = ref<HTMLElement | null>(null);
|
||||||
const lifePostPageSize = 20;
|
const lifePostPageSize = 20;
|
||||||
@@ -122,6 +126,12 @@ const loadMorePaused = ref(false);
|
|||||||
const allCategoryValue = 'all';
|
const allCategoryValue = 'all';
|
||||||
const allLanguageValue = 'all';
|
const allLanguageValue = 'all';
|
||||||
const allGameVersionValue = 'all';
|
const allGameVersionValue = 'all';
|
||||||
|
const deleteConfirmTitle = computed(() =>
|
||||||
|
pendingDelete.value?.type === 'comment' ? t('pages.life.deleteComment') : t('pages.life.deletePost')
|
||||||
|
);
|
||||||
|
const deleteConfirmMessage = computed(() =>
|
||||||
|
pendingDelete.value?.type === 'comment' ? t('pages.life.deleteCommentConfirm') : t('pages.life.deleteConfirm')
|
||||||
|
);
|
||||||
|
|
||||||
type LifeInitialData = {
|
type LifeInitialData = {
|
||||||
options: { lifeCategories: LifeCategory[]; gameVersions: GameVersion[] } | null;
|
options: { lifeCategories: LifeCategory[]; gameVersions: GameVersion[] } | null;
|
||||||
@@ -1049,10 +1059,6 @@ function startEdit(post: LifePost) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function deletePost(post: LifePost) {
|
async function deletePost(post: LifePost) {
|
||||||
if (!window.confirm(t('pages.life.deleteConfirm'))) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
loadError.value = '';
|
loadError.value = '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -1067,6 +1073,10 @@ async function deletePost(post: LifePost) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function requestDeletePost(post: LifePost) {
|
||||||
|
pendingDelete.value = { type: 'post', post };
|
||||||
|
}
|
||||||
|
|
||||||
function startReply(comment: LifeComment) {
|
function startReply(comment: LifeComment) {
|
||||||
replyTargetId.value = comment.id;
|
replyTargetId.value = comment.id;
|
||||||
clearCommentError(replyKey(comment.id));
|
clearCommentError(replyKey(comment.id));
|
||||||
@@ -1191,10 +1201,6 @@ function markOwnCommentDeleted(comments: LifeComment[], id: number): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function deleteComment(post: LifePost, comment: LifeComment) {
|
async function deleteComment(post: LifePost, comment: LifeComment) {
|
||||||
if (!window.confirm(t('pages.life.deleteCommentConfirm'))) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const key = replyKey(comment.id);
|
const key = replyKey(comment.id);
|
||||||
clearCommentError(key);
|
clearCommentError(key);
|
||||||
|
|
||||||
@@ -1226,6 +1232,37 @@ async function deleteComment(post: LifePost, comment: LifeComment) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function requestDeleteComment(post: LifePost, comment: LifeComment) {
|
||||||
|
pendingDelete.value = { type: 'comment', post, comment };
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDeleteConfirm() {
|
||||||
|
if (deleteConfirmBusy.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingDelete.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDelete() {
|
||||||
|
const target = pendingDelete.value;
|
||||||
|
if (!target) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteConfirmBusy.value = true;
|
||||||
|
try {
|
||||||
|
if (target.type === 'post') {
|
||||||
|
await deletePost(target.post);
|
||||||
|
} else {
|
||||||
|
await deleteComment(target.post, target.comment);
|
||||||
|
}
|
||||||
|
pendingDelete.value = null;
|
||||||
|
} finally {
|
||||||
|
deleteConfirmBusy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function restoreComment(post: LifePost, comment: LifeComment) {
|
async function restoreComment(post: LifePost, comment: LifeComment) {
|
||||||
const key = replyKey(comment.id);
|
const key = replyKey(comment.id);
|
||||||
commentBusyKey.value = key;
|
commentBusyKey.value = key;
|
||||||
@@ -1601,7 +1638,7 @@ onUnmounted(() => {
|
|||||||
class="life-icon-button life-icon-button--danger"
|
class="life-icon-button life-icon-button--danger"
|
||||||
type="button"
|
type="button"
|
||||||
:aria-label="t('pages.life.deletePost')"
|
:aria-label="t('pages.life.deletePost')"
|
||||||
@click="deletePost(post)"
|
@click="requestDeletePost(post)"
|
||||||
>
|
>
|
||||||
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
|
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
|
||||||
<span class="life-action-tooltip" role="tooltip">{{ t('pages.life.deletePost') }}</span>
|
<span class="life-action-tooltip" role="tooltip">{{ t('pages.life.deletePost') }}</span>
|
||||||
@@ -1896,7 +1933,7 @@ onUnmounted(() => {
|
|||||||
class="life-icon-button life-icon-button--flat life-icon-button--danger"
|
class="life-icon-button life-icon-button--flat life-icon-button--danger"
|
||||||
type="button"
|
type="button"
|
||||||
:aria-label="t('pages.life.deleteComment')"
|
:aria-label="t('pages.life.deleteComment')"
|
||||||
@click="deleteComment(post, comment)"
|
@click="requestDeleteComment(post, comment)"
|
||||||
>
|
>
|
||||||
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
|
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
|
||||||
<span class="life-action-tooltip" role="tooltip">{{ t('pages.life.deleteComment') }}</span>
|
<span class="life-action-tooltip" role="tooltip">{{ t('pages.life.deleteComment') }}</span>
|
||||||
@@ -2013,7 +2050,7 @@ onUnmounted(() => {
|
|||||||
class="life-icon-button life-icon-button--flat life-icon-button--danger"
|
class="life-icon-button life-icon-button--flat life-icon-button--danger"
|
||||||
type="button"
|
type="button"
|
||||||
:aria-label="t('pages.life.deleteComment')"
|
:aria-label="t('pages.life.deleteComment')"
|
||||||
@click="deleteComment(post, reply)"
|
@click="requestDeleteComment(post, reply)"
|
||||||
>
|
>
|
||||||
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
|
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
|
||||||
<span class="life-action-tooltip" role="tooltip">{{ t('pages.life.deleteComment') }}</span>
|
<span class="life-action-tooltip" role="tooltip">{{ t('pages.life.deleteComment') }}</span>
|
||||||
@@ -2104,6 +2141,18 @@ onUnmounted(() => {
|
|||||||
{{ t('pages.life.newPost') }}
|
{{ t('pages.life.newPost') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
v-if="pendingDelete"
|
||||||
|
:title="deleteConfirmTitle"
|
||||||
|
:message="deleteConfirmMessage"
|
||||||
|
:confirm-label="t('common.delete')"
|
||||||
|
:cancel-label="t('common.cancel')"
|
||||||
|
:close-label="t('common.close')"
|
||||||
|
:busy="deleteConfirmBusy"
|
||||||
|
@cancel="closeDeleteConfirm"
|
||||||
|
@confirm="confirmDelete"
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
59408
repomix-output.xml
Normal file
59408
repomix-output.xml
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user