feat(moderation): add AI moderation for user-generated content
Add AI moderation settings, caching, and status tracking Require AI approval for Life Posts, Comments, and Discussions Implement language filtering and moderation status UI Add retry mechanism for failed moderation checks
This commit is contained in:
@@ -2,15 +2,19 @@
|
||||
import { Icon } from '@iconify/vue';
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { iconCancel, iconComment, iconDelete, iconReply } from '../icons';
|
||||
import StatusBadge from './StatusBadge.vue';
|
||||
import Tabs, { type TabOption } from './Tabs.vue';
|
||||
import { iconCancel, iconComment, iconDelete, iconReply, iconWarning } from '../icons';
|
||||
import {
|
||||
api,
|
||||
getAuthToken,
|
||||
onAuthTokenChange,
|
||||
setAuthToken,
|
||||
type AiModerationStatus,
|
||||
type AuthUser,
|
||||
type DiscussionEntityType,
|
||||
type EntityDiscussionComment
|
||||
type EntityDiscussionComment,
|
||||
type Language
|
||||
} from '../services/api';
|
||||
import Skeleton from './Skeleton.vue';
|
||||
|
||||
@@ -21,6 +25,7 @@ const props = defineProps<{
|
||||
|
||||
const { locale, t } = useI18n();
|
||||
const comments = ref<EntityDiscussionComment[]>([]);
|
||||
const languages = ref<Language[]>([]);
|
||||
const currentUser = ref<AuthUser | null>(null);
|
||||
const loading = ref(true);
|
||||
const loadingMore = ref(false);
|
||||
@@ -33,8 +38,11 @@ const loadError = ref('');
|
||||
const formError = ref('');
|
||||
const commentErrors = ref<Record<string, string>>({});
|
||||
const commentInput = ref<HTMLTextAreaElement | null>(null);
|
||||
const activeLanguageCode = ref('all');
|
||||
const moderationBusyId = ref<number | null>(null);
|
||||
const commentMaxLength = 1000;
|
||||
const discussionPageSize = 20;
|
||||
const allLanguageValue = 'all';
|
||||
let requestId = 0;
|
||||
let removeAuthListener: (() => void) | null = null;
|
||||
const nextCursor = ref<string | null>(null);
|
||||
@@ -47,6 +55,11 @@ function can(permissionKey: string) {
|
||||
|
||||
const canComment = computed(() => can('discussions.comments.create'));
|
||||
const charactersLeft = computed(() => Math.max(0, commentMaxLength - body.value.length));
|
||||
const selectedLanguageCode = computed(() => (activeLanguageCode.value === allLanguageValue ? undefined : activeLanguageCode.value));
|
||||
const languageTabs = computed<TabOption[]>(() => [
|
||||
{ value: allLanguageValue, label: t('discussion.allLanguages') },
|
||||
...languages.value.map((language) => ({ value: language.code, label: language.name }))
|
||||
]);
|
||||
|
||||
async function loadCurrentUser() {
|
||||
authReady.value = false;
|
||||
@@ -68,6 +81,20 @@ async function loadCurrentUser() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLanguages() {
|
||||
try {
|
||||
languages.value = (await api.languages()).filter((language) => language.enabled);
|
||||
if (
|
||||
activeLanguageCode.value !== allLanguageValue &&
|
||||
!languages.value.some((language) => language.code === activeLanguageCode.value)
|
||||
) {
|
||||
activeLanguageCode.value = allLanguageValue;
|
||||
}
|
||||
} catch {
|
||||
languages.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
function mergeComments(existing: EntityDiscussionComment[], incoming: EntityDiscussionComment[]) {
|
||||
const ids = new Set(existing.map((comment) => comment.id));
|
||||
return [...existing, ...incoming.filter((comment) => !ids.has(comment.id))];
|
||||
@@ -89,7 +116,8 @@ async function loadDiscussion(reset = true) {
|
||||
try {
|
||||
const page = await api.entityDiscussion(props.entityType, props.entityId, {
|
||||
limit: discussionPageSize,
|
||||
cursor: reset ? null : nextCursor.value
|
||||
cursor: reset ? null : nextCursor.value,
|
||||
language: selectedLanguageCode.value
|
||||
});
|
||||
if (nextRequestId === requestId) {
|
||||
comments.value = reset ? page.items : mergeComments(comments.value, page.items);
|
||||
@@ -143,6 +171,36 @@ function canManageComment(comment: EntityDiscussionComment) {
|
||||
);
|
||||
}
|
||||
|
||||
function canSeeModeration(comment: EntityDiscussionComment) {
|
||||
return currentUser.value?.id === comment.author?.id || can('discussions.comments.delete-any');
|
||||
}
|
||||
|
||||
function canRetryModeration(comment: EntityDiscussionComment) {
|
||||
return !comment.deleted && comment.moderationStatus !== 'approved' && canSeeModeration(comment);
|
||||
}
|
||||
|
||||
function moderationLabel(status: AiModerationStatus) {
|
||||
const labels: Record<AiModerationStatus, string> = {
|
||||
unreviewed: t('discussion.moderationUnreviewed'),
|
||||
reviewing: t('discussion.moderationReviewing'),
|
||||
approved: t('discussion.moderationApproved'),
|
||||
rejected: t('discussion.moderationRejected'),
|
||||
failed: t('discussion.moderationFailed')
|
||||
};
|
||||
return labels[status];
|
||||
}
|
||||
|
||||
function moderationTone(status: AiModerationStatus) {
|
||||
const tones: Record<AiModerationStatus, 'info' | 'success' | 'warning' | 'danger' | 'neutral'> = {
|
||||
unreviewed: 'neutral',
|
||||
reviewing: 'info',
|
||||
approved: 'success',
|
||||
rejected: 'danger',
|
||||
failed: 'warning'
|
||||
};
|
||||
return tones[status];
|
||||
}
|
||||
|
||||
function commentAuthorName(comment: EntityDiscussionComment) {
|
||||
return comment.deleted ? t('discussion.deletedComment') : comment.author?.displayName ?? t('discussion.byUnknown');
|
||||
}
|
||||
@@ -190,7 +248,10 @@ async function submitComment() {
|
||||
formError.value = '';
|
||||
|
||||
try {
|
||||
const comment = await api.createEntityDiscussionComment(props.entityType, props.entityId, { body: nextBody });
|
||||
const comment = await api.createEntityDiscussionComment(props.entityType, props.entityId, {
|
||||
body: nextBody,
|
||||
languageCode: selectedLanguageCode.value ?? null
|
||||
});
|
||||
comments.value = [...comments.value, comment];
|
||||
commentTotal.value += 1;
|
||||
body.value = '';
|
||||
@@ -213,7 +274,10 @@ async function submitReply(comment: EntityDiscussionComment) {
|
||||
clearCommentError(key);
|
||||
|
||||
try {
|
||||
const reply = await api.createEntityDiscussionReply(props.entityType, props.entityId, comment.id, { body: nextBody });
|
||||
const reply = await api.createEntityDiscussionReply(props.entityType, props.entityId, comment.id, {
|
||||
body: nextBody,
|
||||
languageCode: selectedLanguageCode.value ?? comment.moderationLanguageCode
|
||||
});
|
||||
comment.replies.push(reply);
|
||||
commentTotal.value += 1;
|
||||
cancelReply(comment.id);
|
||||
@@ -224,6 +288,22 @@ async function submitReply(comment: EntityDiscussionComment) {
|
||||
}
|
||||
}
|
||||
|
||||
async function retryModeration(comment: EntityDiscussionComment) {
|
||||
const key = commentKey(comment.id);
|
||||
moderationBusyId.value = comment.id;
|
||||
clearCommentError(key);
|
||||
|
||||
try {
|
||||
const updated = await api.retryEntityDiscussionModeration(comment.id);
|
||||
comment.moderationStatus = updated.moderationStatus;
|
||||
comment.moderationLanguageCode = updated.moderationLanguageCode;
|
||||
} catch (error) {
|
||||
setCommentError(key, error instanceof Error && error.message ? error.message : t('discussion.moderationRetryFailed'));
|
||||
} finally {
|
||||
moderationBusyId.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
function markCommentDeleted(rows: EntityDiscussionComment[], id: number): boolean {
|
||||
for (const comment of rows) {
|
||||
if (comment.id === id) {
|
||||
@@ -272,8 +352,17 @@ watch(
|
||||
}
|
||||
);
|
||||
|
||||
watch(activeLanguageCode, () => {
|
||||
comments.value = [];
|
||||
nextCursor.value = null;
|
||||
hasMoreComments.value = false;
|
||||
commentTotal.value = 0;
|
||||
void loadDiscussion();
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
void loadCurrentUser();
|
||||
void loadLanguages();
|
||||
void loadDiscussion();
|
||||
removeAuthListener = onAuthTokenChange(() => {
|
||||
void loadCurrentUser();
|
||||
@@ -294,6 +383,8 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs id="entity-discussion-language" v-model="activeLanguageCode" :tabs="languageTabs" :label="t('discussion.languages')" />
|
||||
|
||||
<div v-if="!authReady" class="entity-discussion-skeleton" aria-hidden="true">
|
||||
<Skeleton variant="box" height="112px" />
|
||||
</div>
|
||||
@@ -352,6 +443,12 @@ onUnmounted(() => {
|
||||
</RouterLink>
|
||||
<strong v-else>{{ commentAuthorName(comment) }}</strong>
|
||||
<time :datetime="comment.createdAt">{{ formatDateTime(comment.createdAt) }}</time>
|
||||
<StatusBadge
|
||||
v-if="canSeeModeration(comment)"
|
||||
:label="moderationLabel(comment.moderationStatus)"
|
||||
:tone="moderationTone(comment.moderationStatus)"
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
<p v-if="!comment.deleted" class="entity-discussion-comment__body">{{ comment.body }}</p>
|
||||
|
||||
@@ -366,6 +463,19 @@ onUnmounted(() => {
|
||||
<Icon :icon="iconReply" class="ui-icon" aria-hidden="true" />
|
||||
<span class="life-action-tooltip" role="tooltip">{{ t('discussion.reply') }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="canRetryModeration(comment)"
|
||||
class="life-icon-button life-icon-button--flat"
|
||||
type="button"
|
||||
:aria-label="t('discussion.moderationRetry')"
|
||||
:disabled="moderationBusyId === comment.id"
|
||||
@click="retryModeration(comment)"
|
||||
>
|
||||
<Icon :icon="iconWarning" class="ui-icon" aria-hidden="true" />
|
||||
<span class="life-action-tooltip" role="tooltip">
|
||||
{{ moderationBusyId === comment.id ? t('discussion.moderationRetrying') : t('discussion.moderationRetry') }}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="canManageComment(comment)"
|
||||
class="life-icon-button life-icon-button--flat life-icon-button--danger"
|
||||
@@ -427,9 +537,28 @@ onUnmounted(() => {
|
||||
</RouterLink>
|
||||
<strong v-else>{{ commentAuthorName(reply) }}</strong>
|
||||
<time :datetime="reply.createdAt">{{ formatDateTime(reply.createdAt) }}</time>
|
||||
<StatusBadge
|
||||
v-if="canSeeModeration(reply)"
|
||||
:label="moderationLabel(reply.moderationStatus)"
|
||||
:tone="moderationTone(reply.moderationStatus)"
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
<p v-if="!reply.deleted" class="entity-discussion-comment__body">{{ reply.body }}</p>
|
||||
<div v-if="canManageComment(reply)" class="entity-discussion-comment__actions">
|
||||
<div v-if="canManageComment(reply) || canRetryModeration(reply)" class="entity-discussion-comment__actions">
|
||||
<button
|
||||
v-if="canRetryModeration(reply)"
|
||||
class="life-icon-button life-icon-button--flat"
|
||||
type="button"
|
||||
:aria-label="t('discussion.moderationRetry')"
|
||||
:disabled="moderationBusyId === reply.id"
|
||||
@click="retryModeration(reply)"
|
||||
>
|
||||
<Icon :icon="iconWarning" class="ui-icon" aria-hidden="true" />
|
||||
<span class="life-action-tooltip" role="tooltip">
|
||||
{{ moderationBusyId === reply.id ? t('discussion.moderationRetrying') : t('discussion.moderationRetry') }}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
class="life-icon-button life-icon-button--flat life-icon-button--danger"
|
||||
type="button"
|
||||
|
||||
@@ -245,10 +245,13 @@ export interface DailyChecklistItem {
|
||||
|
||||
export type LifeReactionType = 'like' | 'helpful' | 'fun' | 'thanks';
|
||||
export type LifeReactionCounts = Record<LifeReactionType, number>;
|
||||
export type AiModerationStatus = 'unreviewed' | 'reviewing' | 'approved' | 'rejected' | 'failed';
|
||||
|
||||
export interface LifePost {
|
||||
id: number;
|
||||
body: string;
|
||||
moderationStatus: AiModerationStatus;
|
||||
moderationLanguageCode: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
author: UserSummary | null;
|
||||
@@ -271,11 +274,13 @@ export interface LifePostsParams {
|
||||
limit?: number;
|
||||
search?: string;
|
||||
tagId?: string | number;
|
||||
language?: string;
|
||||
}
|
||||
|
||||
export interface CommentPageParams {
|
||||
cursor?: string | null;
|
||||
limit?: number;
|
||||
language?: string;
|
||||
}
|
||||
|
||||
export interface LifeComment {
|
||||
@@ -284,6 +289,8 @@ export interface LifeComment {
|
||||
parentCommentId: number | null;
|
||||
body: string;
|
||||
deleted: boolean;
|
||||
moderationStatus: AiModerationStatus;
|
||||
moderationLanguageCode: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
author: UserSummary | null;
|
||||
@@ -552,10 +559,12 @@ export interface DailyChecklistPayload {
|
||||
export interface LifePostPayload {
|
||||
body: string;
|
||||
tagIds: number[];
|
||||
languageCode?: string | null;
|
||||
}
|
||||
|
||||
export interface LifeCommentPayload {
|
||||
body: string;
|
||||
languageCode?: string | null;
|
||||
}
|
||||
|
||||
export type DiscussionEntityType = 'pokemon' | 'items' | 'recipes' | 'habitats';
|
||||
@@ -567,6 +576,8 @@ export interface EntityDiscussionComment {
|
||||
parentCommentId: number | null;
|
||||
body: string;
|
||||
deleted: boolean;
|
||||
moderationStatus: AiModerationStatus;
|
||||
moderationLanguageCode: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
author: UserSummary | null;
|
||||
@@ -601,6 +612,33 @@ export interface UserCommentActivityPage {
|
||||
|
||||
export interface EntityDiscussionCommentPayload {
|
||||
body: string;
|
||||
languageCode?: string | null;
|
||||
}
|
||||
|
||||
export type AiModerationApiFormat = 'gemini-generate-content' | 'openai-chat-completions';
|
||||
export type AiModerationAuthMode = 'query-key' | 'bearer-token';
|
||||
|
||||
export interface AiModerationSettings {
|
||||
enabled: boolean;
|
||||
apiFormat: AiModerationApiFormat;
|
||||
authMode: AiModerationAuthMode;
|
||||
endpoint: string;
|
||||
model: string;
|
||||
requestsPerMinute: number;
|
||||
apiKeyConfigured: boolean;
|
||||
updatedAt: string;
|
||||
updatedBy: UserSummary | null;
|
||||
}
|
||||
|
||||
export interface AiModerationSettingsPayload {
|
||||
enabled: boolean;
|
||||
apiFormat: AiModerationApiFormat;
|
||||
authMode: AiModerationAuthMode;
|
||||
endpoint: string;
|
||||
model: string;
|
||||
requestsPerMinute: number;
|
||||
apiKey?: string;
|
||||
clearApiKey?: boolean;
|
||||
}
|
||||
|
||||
export function buildQuery(params: Record<string, string | number | undefined>): string {
|
||||
@@ -773,6 +811,9 @@ export const api = {
|
||||
getJson<SystemWording[]>(`/api/admin/system-wordings${buildQuery(params)}`),
|
||||
updateSystemWording: (key: string, payload: { locale: string; value: string }) =>
|
||||
sendJson<SystemWording[]>(`/api/admin/system-wordings/${encodeURIComponent(key)}`, 'PUT', payload),
|
||||
aiModerationSettings: () => getJson<AiModerationSettings>('/api/admin/ai-moderation'),
|
||||
updateAiModerationSettings: (payload: AiModerationSettingsPayload) =>
|
||||
sendJson<AiModerationSettings>('/api/admin/ai-moderation', 'PUT', payload),
|
||||
register: (payload: RegisterPayload) => sendJson<{ message: string }>('/api/auth/register', 'POST', payload),
|
||||
verifyEmail: (token: string) =>
|
||||
sendJson<{ message: string; user: AuthUser }>('/api/auth/verify-email', 'POST', { token }),
|
||||
@@ -835,12 +876,15 @@ export const api = {
|
||||
cursor: params.cursor ?? undefined,
|
||||
limit: params.limit,
|
||||
search: params.search?.trim(),
|
||||
tagId: params.tagId
|
||||
tagId: params.tagId,
|
||||
language: params.language
|
||||
})}`
|
||||
),
|
||||
createLifePost: (payload: LifePostPayload) => sendJson<LifePost>('/api/life-posts', 'POST', payload),
|
||||
updateLifePost: (id: string | number, payload: LifePostPayload) =>
|
||||
sendJson<LifePost>(`/api/life-posts/${id}`, 'PUT', payload),
|
||||
retryLifePostModeration: (id: string | number) =>
|
||||
sendJson<LifePost>(`/api/life-posts/${id}/moderation/retry`, 'POST', {}),
|
||||
deleteLifePost: (id: string | number) => deleteJson(`/api/life-posts/${id}`),
|
||||
setLifeReaction: (id: string | number, reactionType: LifeReactionType) =>
|
||||
sendJson<LifePost>(`/api/life-posts/${id}/reaction`, 'PUT', { reactionType }),
|
||||
@@ -851,17 +895,21 @@ export const api = {
|
||||
getJson<LifeCommentsPage>(
|
||||
`/api/life-posts/${postId}/comments${buildQuery({
|
||||
cursor: params.cursor ?? undefined,
|
||||
limit: params.limit
|
||||
limit: params.limit,
|
||||
language: params.language
|
||||
})}`
|
||||
),
|
||||
createLifeCommentReply: (postId: string | number, commentId: string | number, payload: LifeCommentPayload) =>
|
||||
sendJson<LifeComment>(`/api/life-posts/${postId}/comments/${commentId}/replies`, 'POST', payload),
|
||||
retryLifeCommentModeration: (id: string | number) =>
|
||||
sendJson<LifeComment>(`/api/life-comments/${id}/moderation/retry`, 'POST', {}),
|
||||
deleteLifeComment: (id: string | number) => deleteJson(`/api/life-comments/${id}`),
|
||||
entityDiscussion: (entityType: DiscussionEntityType, entityId: string | number, params: CommentPageParams = {}) =>
|
||||
getJson<EntityDiscussionCommentsPage>(
|
||||
`/api/discussions/${entityType}/${entityId}/comments${buildQuery({
|
||||
cursor: params.cursor ?? undefined,
|
||||
limit: params.limit
|
||||
limit: params.limit,
|
||||
language: params.language
|
||||
})}`
|
||||
),
|
||||
createEntityDiscussionComment: (
|
||||
@@ -875,6 +923,8 @@ export const api = {
|
||||
commentId: string | number,
|
||||
payload: EntityDiscussionCommentPayload
|
||||
) => sendJson<EntityDiscussionComment>(`/api/discussions/${entityType}/${entityId}/comments/${commentId}/replies`, 'POST', payload),
|
||||
retryEntityDiscussionModeration: (id: string | number) =>
|
||||
sendJson<EntityDiscussionComment>(`/api/discussions/comments/${id}/moderation/retry`, 'POST', {}),
|
||||
deleteEntityDiscussionComment: (id: string | number) => deleteJson(`/api/discussions/comments/${id}`),
|
||||
uploadImage: (
|
||||
entityType: ImageUploadEntityType,
|
||||
|
||||
@@ -761,6 +761,10 @@ button:disabled,
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.ai-moderation-form {
|
||||
max-width: 680px;
|
||||
}
|
||||
|
||||
.pokemon-edit-form {
|
||||
height: clamp(420px, calc(100dvh - 188px), 640px);
|
||||
min-height: 0;
|
||||
@@ -1905,6 +1909,13 @@ button:disabled,
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.life-post__moderation {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.life-post__tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -29,6 +29,10 @@ import {
|
||||
import { defaultLocale, getCurrentLocale, loadSystemWordings, setCurrentLocale } from '../i18n';
|
||||
import {
|
||||
api,
|
||||
type AiModerationApiFormat,
|
||||
type AiModerationAuthMode,
|
||||
type AiModerationSettings,
|
||||
type AiModerationSettingsPayload,
|
||||
type AuthUser,
|
||||
type AdminUser,
|
||||
type ConfigType,
|
||||
@@ -53,6 +57,7 @@ type AdminTab =
|
||||
| 'users'
|
||||
| 'roles'
|
||||
| 'permissions'
|
||||
| 'aiModeration'
|
||||
| 'config'
|
||||
| 'languages'
|
||||
| 'wordings'
|
||||
@@ -70,6 +75,7 @@ const adminTabIcons: Record<AdminTab, AppIcon> = {
|
||||
users: iconProfile,
|
||||
roles: iconKey,
|
||||
permissions: iconKey,
|
||||
aiModeration: iconAdmin,
|
||||
config: iconAdmin,
|
||||
languages: iconTranslate,
|
||||
wordings: iconTranslate,
|
||||
@@ -105,7 +111,8 @@ const adminNavigationGroups = computed<AdminNavGroup[]>(() => {
|
||||
label: t('pages.admin.localizationGroup'),
|
||||
items: [
|
||||
{ key: 'languages', label: t('pages.admin.languages'), permission: 'admin.languages.read' },
|
||||
{ key: 'wordings', label: t('pages.admin.wordings'), permission: 'admin.wordings.read' }
|
||||
{ key: 'wordings', label: t('pages.admin.wordings'), permission: 'admin.wordings.read' },
|
||||
{ key: 'aiModeration', label: t('pages.admin.aiModeration'), permission: 'admin.ai-moderation.read' }
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -150,6 +157,7 @@ const itemRows = ref<Item[]>([]);
|
||||
const recipeRows = ref<Recipe[]>([]);
|
||||
const habitatRows = ref<Habitat[]>([]);
|
||||
const wordingRows = ref<SystemWording[]>([]);
|
||||
const aiModerationSettings = ref<AiModerationSettings | null>(null);
|
||||
const currentUser = ref<AuthUser | null>(null);
|
||||
const busy = ref(false);
|
||||
const contentLoading = ref(false);
|
||||
@@ -158,6 +166,16 @@ 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 wordingForm = ref({ key: '', locale: defaultLocale, value: '', defaultValue: '', placeholders: [] as string[] });
|
||||
const aiModerationForm = ref({
|
||||
enabled: true,
|
||||
apiFormat: 'gemini-generate-content' as AiModerationApiFormat,
|
||||
authMode: 'bearer-token' as AiModerationAuthMode,
|
||||
endpoint: 'https://ai.example.com/v1beta',
|
||||
model: 'gemini-2.0-flash-lite',
|
||||
requestsPerMinute: 10,
|
||||
apiKey: '',
|
||||
clearApiKey: false
|
||||
});
|
||||
const userRoleForm = ref({ userId: 0, roleIds: [] as number[] });
|
||||
const roleForm = ref({ id: 0, key: '', name: '', description: '', level: 100, enabled: true });
|
||||
const rolePermissionForm = ref({ roleId: 0, permissionIds: [] as number[] });
|
||||
@@ -255,6 +273,14 @@ const activeWordingSurfaceTab = computed({
|
||||
wordingSurface.value = value === 'frontend' || value === 'backend' || value === 'email' ? value : '';
|
||||
}
|
||||
});
|
||||
const aiModerationApiFormatOptions = computed<Array<{ value: AiModerationApiFormat; label: string }>>(() => [
|
||||
{ value: 'gemini-generate-content', label: t('pages.admin.aiModerationFormatGemini') },
|
||||
{ value: 'openai-chat-completions', label: t('pages.admin.aiModerationFormatOpenAi') }
|
||||
]);
|
||||
const aiModerationAuthModeOptions = computed<Array<{ value: AiModerationAuthMode; label: string }>>(() => [
|
||||
{ value: 'bearer-token', label: t('pages.admin.aiModerationAuthBearer') },
|
||||
{ value: 'query-key', label: t('pages.admin.aiModerationAuthQueryKey') }
|
||||
]);
|
||||
const filteredWordingRows = computed(() =>
|
||||
wordingRows.value.filter((item) => {
|
||||
if (wordingModule.value && item.module !== wordingModule.value) return false;
|
||||
@@ -365,6 +391,19 @@ function resetWordingForm() {
|
||||
wordingForm.value = { key: '', locale: wordingLocale.value || defaultLocale, value: '', defaultValue: '', placeholders: [] };
|
||||
}
|
||||
|
||||
function resetAiModerationForm(settings: AiModerationSettings | null = aiModerationSettings.value) {
|
||||
aiModerationForm.value = {
|
||||
enabled: settings?.enabled ?? true,
|
||||
apiFormat: settings?.apiFormat ?? 'gemini-generate-content',
|
||||
authMode: settings?.authMode ?? 'bearer-token',
|
||||
endpoint: settings?.endpoint ?? 'https://ai.example.com/v1beta',
|
||||
model: settings?.model ?? 'gemini-2.0-flash-lite',
|
||||
requestsPerMinute: settings?.requestsPerMinute ?? 10,
|
||||
apiKey: '',
|
||||
clearApiKey: false
|
||||
};
|
||||
}
|
||||
|
||||
function resetUserRoleForm() {
|
||||
userRoleForm.value = { userId: 0, roleIds: [] };
|
||||
}
|
||||
@@ -781,6 +820,11 @@ async function loadWordings() {
|
||||
wordingRows.value = await api.systemWordings({ locale: wordingLocale.value });
|
||||
}
|
||||
|
||||
async function loadAiModerationSettings() {
|
||||
aiModerationSettings.value = await api.aiModerationSettings();
|
||||
resetAiModerationForm(aiModerationSettings.value);
|
||||
}
|
||||
|
||||
async function reloadWordings() {
|
||||
await run(loadWordings);
|
||||
}
|
||||
@@ -799,6 +843,27 @@ async function saveWording() {
|
||||
});
|
||||
}
|
||||
|
||||
async function saveAiModerationSettings() {
|
||||
await run(async () => {
|
||||
const payload: AiModerationSettingsPayload = {
|
||||
enabled: aiModerationForm.value.enabled,
|
||||
apiFormat: aiModerationForm.value.apiFormat,
|
||||
authMode: aiModerationForm.value.authMode,
|
||||
endpoint: aiModerationForm.value.endpoint,
|
||||
model: aiModerationForm.value.model,
|
||||
requestsPerMinute: aiModerationForm.value.requestsPerMinute,
|
||||
clearApiKey: aiModerationForm.value.clearApiKey
|
||||
};
|
||||
|
||||
if (aiModerationForm.value.apiKey.trim()) {
|
||||
payload.apiKey = aiModerationForm.value.apiKey.trim();
|
||||
}
|
||||
|
||||
aiModerationSettings.value = await api.updateAiModerationSettings(payload);
|
||||
resetAiModerationForm(aiModerationSettings.value);
|
||||
});
|
||||
}
|
||||
|
||||
async function saveUserRoles() {
|
||||
await run(async () => {
|
||||
userRows.value = await api.updateAdminUserRoles(userRoleForm.value.userId, userRoleForm.value.roleIds);
|
||||
@@ -863,6 +928,7 @@ async function loadCurrentTab(showSkeleton = false) {
|
||||
if (activeTab.value === 'permissions') await loadPermissions();
|
||||
if (activeTab.value === 'languages') await loadLanguages();
|
||||
if (activeTab.value === 'wordings') await loadWordings();
|
||||
if (activeTab.value === 'aiModeration') await loadAiModerationSettings();
|
||||
if (activeTab.value === 'checklist') await loadChecklist();
|
||||
if (activeTab.value === 'pokemon') await loadPokemon();
|
||||
if (activeTab.value === 'items') await loadItems();
|
||||
@@ -1335,6 +1401,110 @@ onMounted(() => {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-else-if="canEdit && activeTab === 'aiModeration'" class="detail-section">
|
||||
<div class="detail-section__header">
|
||||
<div>
|
||||
<h2>{{ t('pages.admin.aiModeration') }}</h2>
|
||||
<p class="meta-line">
|
||||
{{
|
||||
aiModerationSettings?.apiKeyConfigured
|
||||
? t('pages.admin.aiModerationApiKeyConfigured')
|
||||
: t('pages.admin.aiModerationApiKeyMissing')
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form class="modal-edit-form ai-moderation-form" @submit.prevent="saveAiModerationSettings">
|
||||
<div class="check-row">
|
||||
<label>
|
||||
<input v-model="aiModerationForm.enabled" type="checkbox" :disabled="busy || !can('admin.ai-moderation.update')" />
|
||||
{{ t('pages.admin.aiModerationEnabled') }}
|
||||
</label>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="ai-moderation-format">{{ t('pages.admin.aiModerationFormat') }}</label>
|
||||
<select
|
||||
id="ai-moderation-format"
|
||||
v-model="aiModerationForm.apiFormat"
|
||||
:disabled="busy || !can('admin.ai-moderation.update')"
|
||||
required
|
||||
>
|
||||
<option v-for="option in aiModerationApiFormatOptions" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="ai-moderation-auth-mode">{{ t('pages.admin.aiModerationAuthMode') }}</label>
|
||||
<select
|
||||
id="ai-moderation-auth-mode"
|
||||
v-model="aiModerationForm.authMode"
|
||||
:disabled="busy || !can('admin.ai-moderation.update')"
|
||||
required
|
||||
>
|
||||
<option v-for="option in aiModerationAuthModeOptions" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="ai-moderation-endpoint">{{ t('pages.admin.aiModerationEndpoint') }}</label>
|
||||
<input
|
||||
id="ai-moderation-endpoint"
|
||||
v-model="aiModerationForm.endpoint"
|
||||
:disabled="busy || !can('admin.ai-moderation.update')"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="ai-moderation-model">{{ t('pages.admin.aiModerationModel') }}</label>
|
||||
<input
|
||||
id="ai-moderation-model"
|
||||
v-model="aiModerationForm.model"
|
||||
:disabled="busy || !can('admin.ai-moderation.update')"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="ai-moderation-rpm">{{ t('pages.admin.aiModerationRpm') }}</label>
|
||||
<input
|
||||
id="ai-moderation-rpm"
|
||||
v-model.number="aiModerationForm.requestsPerMinute"
|
||||
type="number"
|
||||
min="1"
|
||||
max="60"
|
||||
:disabled="busy || !can('admin.ai-moderation.update')"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="ai-moderation-api-key">{{ t('pages.admin.aiModerationApiKey') }}</label>
|
||||
<input
|
||||
id="ai-moderation-api-key"
|
||||
v-model="aiModerationForm.apiKey"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
:disabled="busy || !can('admin.ai-moderation.update')"
|
||||
/>
|
||||
</div>
|
||||
<div class="check-row">
|
||||
<label>
|
||||
<input
|
||||
v-model="aiModerationForm.clearApiKey"
|
||||
type="checkbox"
|
||||
:disabled="busy || !can('admin.ai-moderation.update') || !aiModerationSettings?.apiKeyConfigured"
|
||||
/>
|
||||
{{ t('pages.admin.aiModerationClearApiKey') }}
|
||||
</label>
|
||||
</div>
|
||||
<button v-if="can('admin.ai-moderation.update')" class="ui-button ui-button--primary" type="submit" :disabled="busy">
|
||||
<Icon :icon="iconSave" class="ui-icon" aria-hidden="true" />
|
||||
{{ busy ? t('common.saving') : t('common.save') }}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section v-else-if="canEdit && activeTab === 'pokemon'" class="detail-section">
|
||||
<h2>{{ t('pages.admin.pokemonList') }}</h2>
|
||||
<ReorderableList
|
||||
|
||||
@@ -6,6 +6,7 @@ import FilterPanel from '../components/FilterPanel.vue';
|
||||
import Modal from '../components/Modal.vue';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import Skeleton from '../components/Skeleton.vue';
|
||||
import StatusBadge from '../components/StatusBadge.vue';
|
||||
import StatusMessage from '../components/StatusMessage.vue';
|
||||
import Tabs, { type TabOption } from '../components/Tabs.vue';
|
||||
import TagsSelect from '../components/TagsSelect.vue';
|
||||
@@ -31,7 +32,9 @@ import {
|
||||
getAuthToken,
|
||||
onAuthTokenChange,
|
||||
setAuthToken,
|
||||
type AiModerationStatus,
|
||||
type AuthUser,
|
||||
type Language,
|
||||
type LifeComment,
|
||||
type LifePost,
|
||||
type LifeReactionType,
|
||||
@@ -52,6 +55,7 @@ type LifeCommentPageState = {
|
||||
const { locale, t } = useI18n();
|
||||
const posts = ref<LifePost[]>([]);
|
||||
const lifeTags = ref<NamedEntity[]>([]);
|
||||
const languages = ref<Language[]>([]);
|
||||
const currentUser = ref<AuthUser | null>(null);
|
||||
const loading = ref(true);
|
||||
const loadingMore = ref(false);
|
||||
@@ -60,6 +64,7 @@ const busy = ref(false);
|
||||
const searchDraft = ref('');
|
||||
const submittedSearch = ref('');
|
||||
const activeTagId = ref('all');
|
||||
const activeLanguageCode = ref('all');
|
||||
const body = ref('');
|
||||
const selectedTagIds = ref<string[]>([]);
|
||||
const editingPostId = ref<number | null>(null);
|
||||
@@ -76,6 +81,8 @@ const commentErrors = ref<Record<string, string>>({});
|
||||
const reactionPickerPostId = ref<number | null>(null);
|
||||
const reactionBusyPostId = ref<number | null>(null);
|
||||
const reactionErrors = ref<Record<number, string>>({});
|
||||
const moderationBusyPostId = ref<number | null>(null);
|
||||
const moderationErrors = ref<Record<number, string>>({});
|
||||
const bodyInput = ref<HTMLTextAreaElement | null>(null);
|
||||
const loadMoreSentinel = ref<HTMLElement | null>(null);
|
||||
const lifePostPageSize = 20;
|
||||
@@ -91,6 +98,7 @@ const nextCursor = ref<string | null>(null);
|
||||
const hasMorePosts = ref(false);
|
||||
const loadMorePaused = ref(false);
|
||||
const allTagValue = 'all';
|
||||
const allLanguageValue = 'all';
|
||||
|
||||
const reactionOptions = [
|
||||
{ type: 'like', icon: iconReactionLike, labelKey: 'pages.life.reactionLike' },
|
||||
@@ -113,10 +121,17 @@ const selectedFeedTagId = computed(() => {
|
||||
const tagId = Number(activeTagId.value);
|
||||
return activeTagId.value === allTagValue || !Number.isInteger(tagId) || tagId <= 0 ? undefined : tagId;
|
||||
});
|
||||
const selectedFeedLanguageCode = computed(() =>
|
||||
activeLanguageCode.value === allLanguageValue ? undefined : activeLanguageCode.value
|
||||
);
|
||||
const tagFilterOptions = computed<TabOption[]>(() => [
|
||||
{ value: allTagValue, label: t('pages.life.allTags') },
|
||||
...lifeTags.value.map((tag) => ({ value: String(tag.id), label: tag.name }))
|
||||
]);
|
||||
const languageFilterOptions = computed<TabOption[]>(() => [
|
||||
{ value: allLanguageValue, label: t('pages.life.allLanguages') },
|
||||
...languages.value.map((language) => ({ value: language.code, label: language.name }))
|
||||
]);
|
||||
const postModalTitle = computed(() => (isEditing.value ? t('pages.life.editPost') : t('pages.life.newPost')));
|
||||
const submitLabel = computed(() => {
|
||||
if (busy.value) return isEditing.value ? t('pages.life.updating') : t('pages.life.publishing');
|
||||
@@ -156,6 +171,21 @@ async function loadLifeTags() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLanguages() {
|
||||
try {
|
||||
languages.value = (await api.languages()).filter((language) => language.enabled);
|
||||
|
||||
if (
|
||||
activeLanguageCode.value !== allLanguageValue &&
|
||||
!languages.value.some((language) => language.code === activeLanguageCode.value)
|
||||
) {
|
||||
activeLanguageCode.value = allLanguageValue;
|
||||
}
|
||||
} catch (error) {
|
||||
loadError.value = error instanceof Error && error.message ? error.message : t('errors.loadFailed');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPosts() {
|
||||
const requestId = ++postsRequestId;
|
||||
loading.value = true;
|
||||
@@ -166,7 +196,12 @@ async function loadPosts() {
|
||||
loadMorePaused.value = false;
|
||||
|
||||
try {
|
||||
const page = await api.lifePosts({ limit: lifePostPageSize, search: searchQuery.value, tagId: selectedFeedTagId.value });
|
||||
const page = await api.lifePosts({
|
||||
limit: lifePostPageSize,
|
||||
search: searchQuery.value,
|
||||
tagId: selectedFeedTagId.value,
|
||||
language: selectedFeedLanguageCode.value
|
||||
});
|
||||
if (requestId !== postsRequestId) {
|
||||
return;
|
||||
}
|
||||
@@ -202,7 +237,13 @@ async function loadMorePosts() {
|
||||
loadError.value = '';
|
||||
|
||||
try {
|
||||
const page = await api.lifePosts({ cursor, limit: lifePostPageSize, search: searchQuery.value, tagId: selectedFeedTagId.value });
|
||||
const page = await api.lifePosts({
|
||||
cursor,
|
||||
limit: lifePostPageSize,
|
||||
search: searchQuery.value,
|
||||
tagId: selectedFeedTagId.value,
|
||||
language: selectedFeedLanguageCode.value
|
||||
});
|
||||
if (requestId !== postsRequestId) {
|
||||
return;
|
||||
}
|
||||
@@ -233,7 +274,8 @@ function resetForm() {
|
||||
function payload() {
|
||||
return {
|
||||
body: body.value.trim(),
|
||||
tagIds: selectedLifeTagIds()
|
||||
tagIds: selectedLifeTagIds(),
|
||||
languageCode: selectedFeedLanguageCode.value ?? null
|
||||
};
|
||||
}
|
||||
|
||||
@@ -275,7 +317,9 @@ function matchesCurrentFilters(post: LifePost) {
|
||||
const tagId = selectedFeedTagId.value;
|
||||
const matchesSearch = keyword === '' || post.body.toLowerCase().includes(keyword);
|
||||
const matchesTag = tagId === undefined || post.tags.some((tag) => tag.id === tagId);
|
||||
return matchesSearch && matchesTag;
|
||||
const matchesLanguage =
|
||||
selectedFeedLanguageCode.value === undefined || post.moderationLanguageCode === selectedFeedLanguageCode.value;
|
||||
return matchesSearch && matchesTag && matchesLanguage;
|
||||
}
|
||||
|
||||
function openCreatePostModal() {
|
||||
@@ -412,6 +456,50 @@ function reactionCountLabel(post: LifePost, type: LifeReactionType) {
|
||||
});
|
||||
}
|
||||
|
||||
function moderationLabel(status: AiModerationStatus) {
|
||||
const labels: Record<AiModerationStatus, string> = {
|
||||
unreviewed: t('pages.life.moderationUnreviewed'),
|
||||
reviewing: t('pages.life.moderationReviewing'),
|
||||
approved: t('pages.life.moderationApproved'),
|
||||
rejected: t('pages.life.moderationRejected'),
|
||||
failed: t('pages.life.moderationFailed')
|
||||
};
|
||||
return labels[status];
|
||||
}
|
||||
|
||||
function moderationTone(status: AiModerationStatus) {
|
||||
const tones: Record<AiModerationStatus, 'info' | 'success' | 'warning' | 'danger' | 'neutral'> = {
|
||||
unreviewed: 'neutral',
|
||||
reviewing: 'info',
|
||||
approved: 'success',
|
||||
rejected: 'danger',
|
||||
failed: 'warning'
|
||||
};
|
||||
return tones[status];
|
||||
}
|
||||
|
||||
function canRetryModeration(post: LifePost) {
|
||||
return post.moderationStatus !== 'approved' && canManage(post);
|
||||
}
|
||||
|
||||
async function retryPostModeration(post: LifePost) {
|
||||
moderationBusyPostId.value = post.id;
|
||||
const nextErrors = { ...moderationErrors.value };
|
||||
delete nextErrors[post.id];
|
||||
moderationErrors.value = nextErrors;
|
||||
|
||||
try {
|
||||
replacePost(await api.retryLifePostModeration(post.id));
|
||||
} catch (error) {
|
||||
moderationErrors.value = {
|
||||
...moderationErrors.value,
|
||||
[post.id]: error instanceof Error && error.message ? error.message : t('pages.life.moderationRetryFailed')
|
||||
};
|
||||
} finally {
|
||||
moderationBusyPostId.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
function replacePost(updatedPost: LifePost) {
|
||||
if (!matchesCurrentFilters(updatedPost)) {
|
||||
posts.value = posts.value.filter((post) => post.id !== updatedPost.id);
|
||||
@@ -460,7 +548,7 @@ async function loadComments(post: LifePost, reset = false) {
|
||||
});
|
||||
|
||||
try {
|
||||
const page = await api.lifeComments(post.id, { limit: lifeCommentPageSize, cursor });
|
||||
const page = await api.lifeComments(post.id, { limit: lifeCommentPageSize, cursor, language: selectedFeedLanguageCode.value });
|
||||
const nextItems = reset || !existing.loaded ? page.items : mergeComments(existing.items, page.items);
|
||||
setCommentPage(post.id, {
|
||||
items: nextItems,
|
||||
@@ -584,7 +672,7 @@ async function toggleDefaultReaction(post: LifePost) {
|
||||
}
|
||||
|
||||
async function toggleReaction(post: LifePost, reactionType: LifeReactionType) {
|
||||
if (!canUseReactions()) {
|
||||
if (!canUseReactions() || post.moderationStatus !== 'approved') {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -656,7 +744,10 @@ async function submitComment(post: LifePost) {
|
||||
clearCommentError(key);
|
||||
|
||||
try {
|
||||
const comment = await api.createLifeComment(post.id, { body: nextBody });
|
||||
const comment = await api.createLifeComment(post.id, {
|
||||
body: nextBody,
|
||||
languageCode: selectedFeedLanguageCode.value ?? post.moderationLanguageCode
|
||||
});
|
||||
const nextTotal = commentCount(post) + 1;
|
||||
post.commentCount = nextTotal;
|
||||
updateCommentPage(post, (page) => ({
|
||||
@@ -686,7 +777,10 @@ async function submitReply(post: LifePost, comment: LifeComment) {
|
||||
clearCommentError(key);
|
||||
|
||||
try {
|
||||
const reply = await api.createLifeCommentReply(post.id, comment.id, { body: nextBody });
|
||||
const reply = await api.createLifeCommentReply(post.id, comment.id, {
|
||||
body: nextBody,
|
||||
languageCode: selectedFeedLanguageCode.value ?? comment.moderationLanguageCode ?? post.moderationLanguageCode
|
||||
});
|
||||
const nextTotal = commentCount(post) + 1;
|
||||
post.commentCount = nextTotal;
|
||||
comment.replies.push(reply);
|
||||
@@ -788,7 +882,13 @@ watch([loadMoreSentinel, hasMorePosts, loading, loadingMore, loadMorePaused], ob
|
||||
watch(activeTagId, () => {
|
||||
void loadPosts();
|
||||
});
|
||||
watch(activeLanguageCode, () => {
|
||||
expandedComments.value = {};
|
||||
commentPages.value = {};
|
||||
void loadPosts();
|
||||
});
|
||||
watch(locale, () => {
|
||||
void loadLanguages();
|
||||
void loadLifeTags();
|
||||
void loadPosts();
|
||||
});
|
||||
@@ -797,6 +897,7 @@ onMounted(() => {
|
||||
document.addEventListener('click', closeReactionPickerFromDocument);
|
||||
document.addEventListener('keydown', closeReactionPickerFromKeyboard);
|
||||
void loadCurrentUser();
|
||||
void loadLanguages();
|
||||
void loadLifeTags();
|
||||
void loadPosts();
|
||||
removeAuthListener = onAuthTokenChange(() => {
|
||||
@@ -913,6 +1014,7 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Tabs id="life-language-filter" v-model="activeLanguageCode" :tabs="languageFilterOptions" :label="t('pages.life.languages')" />
|
||||
<Tabs id="life-tag-filter" v-model="activeTagId" :tabs="tagFilterOptions" :label="t('pages.life.tags')" />
|
||||
|
||||
<section class="life-feed" :aria-busy="loading || loadingMore" :aria-label="t('pages.life.kicker')">
|
||||
@@ -964,6 +1066,21 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="life-post__moderation">
|
||||
<StatusBadge :label="moderationLabel(post.moderationStatus)" :tone="moderationTone(post.moderationStatus)" compact />
|
||||
<button
|
||||
v-if="canRetryModeration(post)"
|
||||
class="ui-button ui-button--ghost ui-button--small"
|
||||
type="button"
|
||||
:disabled="moderationBusyPostId === post.id"
|
||||
@click="retryPostModeration(post)"
|
||||
>
|
||||
<Icon :icon="iconWarning" class="ui-icon" aria-hidden="true" />
|
||||
{{ moderationBusyPostId === post.id ? t('pages.life.moderationRetrying') : t('pages.life.moderationRetry') }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="moderationErrors[post.id]" class="life-form__error" role="alert">{{ moderationErrors[post.id] }}</p>
|
||||
|
||||
<p class="life-post__body">{{ post.body }}</p>
|
||||
|
||||
<div v-if="post.tags.length" class="life-post__tags" :aria-label="t('pages.life.tags')">
|
||||
@@ -981,7 +1098,7 @@ onUnmounted(() => {
|
||||
:aria-controls="`life-reactions-${post.id}`"
|
||||
:aria-expanded="reactionPickerPostId === post.id"
|
||||
:aria-label="reactionButtonLabel(post)"
|
||||
:disabled="!canReact || reactionBusyPostId !== null"
|
||||
:disabled="!canReact || post.moderationStatus !== 'approved' || reactionBusyPostId !== null"
|
||||
@click="toggleDefaultReaction(post)"
|
||||
@contextmenu="handleReactionContextMenu($event, post.id)"
|
||||
@keydown="handleReactionKeydown($event, post.id)"
|
||||
@@ -996,7 +1113,7 @@ onUnmounted(() => {
|
||||
:aria-controls="`life-reactions-${post.id}`"
|
||||
:aria-expanded="reactionPickerPostId === post.id"
|
||||
:aria-label="t('pages.life.chooseReaction')"
|
||||
:disabled="!canReact || reactionBusyPostId !== null"
|
||||
:disabled="!canReact || post.moderationStatus !== 'approved' || reactionBusyPostId !== null"
|
||||
@click="toggleReactionPicker(post.id)"
|
||||
@contextmenu="handleReactionContextMenu($event, post.id)"
|
||||
@keydown="handleReactionKeydown($event, post.id)"
|
||||
@@ -1021,7 +1138,7 @@ onUnmounted(() => {
|
||||
type="button"
|
||||
:aria-pressed="post.myReaction === option.type"
|
||||
:aria-label="reactionOptionLabel(post, option.type)"
|
||||
:disabled="isReactionBusy(post.id)"
|
||||
:disabled="post.moderationStatus !== 'approved' || isReactionBusy(post.id)"
|
||||
@click="toggleReaction(post, option.type)"
|
||||
>
|
||||
<Icon :icon="option.icon" class="ui-icon" aria-hidden="true" />
|
||||
@@ -1036,6 +1153,7 @@ onUnmounted(() => {
|
||||
:aria-controls="`life-comments-${post.id}`"
|
||||
:aria-expanded="areCommentsExpanded(post.id)"
|
||||
:aria-label="areCommentsExpanded(post.id) ? t('pages.life.hideComments') : t('pages.life.comment')"
|
||||
:disabled="post.moderationStatus !== 'approved'"
|
||||
@click="toggleComments(post)"
|
||||
>
|
||||
<Icon :icon="iconComment" class="ui-icon" aria-hidden="true" />
|
||||
@@ -1070,6 +1188,7 @@ onUnmounted(() => {
|
||||
:aria-controls="`life-comments-${post.id}`"
|
||||
:aria-expanded="areCommentsExpanded(post.id)"
|
||||
:aria-label="t('pages.life.commentsCount', { count: commentCount(post) })"
|
||||
:disabled="post.moderationStatus !== 'approved'"
|
||||
@click="toggleComments(post)"
|
||||
>
|
||||
<Icon :icon="iconComment" class="ui-icon" aria-hidden="true" />
|
||||
|
||||
Reference in New Issue
Block a user