Update backend to return soft-deleted comments to their authors Add restore endpoint and frontend Undo button for deleted comments Retain comment body and author information upon deletion
1877 lines
69 KiB
Vue
1877 lines
69 KiB
Vue
<script setup lang="ts">
|
|
import { Icon } from '@iconify/vue';
|
|
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
|
|
import { useI18n } from 'vue-i18n';
|
|
import FilterPanel from '../components/FilterPanel.vue';
|
|
import LifeRatingControl from '../components/LifeRatingControl.vue';
|
|
import LifeReactionUsersModal from '../components/LifeReactionUsersModal.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';
|
|
import {
|
|
iconAdd,
|
|
iconCancel,
|
|
iconChevronDown,
|
|
iconComment,
|
|
iconDelete,
|
|
iconEdit,
|
|
iconExternal,
|
|
iconLife,
|
|
iconReactionFun,
|
|
iconReactionHelpful,
|
|
iconReactionLike,
|
|
iconReactionThanks,
|
|
iconReply,
|
|
iconSave,
|
|
iconSearch,
|
|
iconUndo,
|
|
iconVersion,
|
|
iconWarning
|
|
} from '../icons';
|
|
import {
|
|
api,
|
|
getAuthToken,
|
|
moderationUpdateEvent,
|
|
onAuthTokenChange,
|
|
setAuthToken,
|
|
type AiModerationStatus,
|
|
type AuthUser,
|
|
type GameVersion,
|
|
type Language,
|
|
type LifeCategory,
|
|
type LifeComment,
|
|
type LifePost,
|
|
type LifeReactionType,
|
|
type ModerationUpdateDetail
|
|
} from '../services/api';
|
|
|
|
type LifeCommentPageState = {
|
|
items: LifeComment[];
|
|
nextCursor: string | null;
|
|
hasMore: boolean;
|
|
total: number;
|
|
loading: boolean;
|
|
loadingMore: boolean;
|
|
loaded: boolean;
|
|
error: string;
|
|
};
|
|
|
|
type LifePostSort = 'latest' | 'oldest' | 'top-rated';
|
|
|
|
const { locale, t } = useI18n();
|
|
const posts = ref<LifePost[]>([]);
|
|
const lifeCategories = ref<LifeCategory[]>([]);
|
|
const gameVersions = ref<GameVersion[]>([]);
|
|
const languages = ref<Language[]>([]);
|
|
const currentUser = ref<AuthUser | null>(null);
|
|
const loading = ref(true);
|
|
const loadingMore = ref(false);
|
|
const authReady = ref(false);
|
|
const busy = ref(false);
|
|
const searchDraft = ref('');
|
|
const submittedSearch = ref('');
|
|
const activeCategoryId = ref('all');
|
|
const activeLanguageCode = ref('all');
|
|
const activeGameVersionId = ref('all');
|
|
const activeRateableFilter = ref('all');
|
|
const activeSort = ref<LifePostSort>('latest');
|
|
const body = ref('');
|
|
const selectedCategoryId = ref('');
|
|
const selectedGameVersionId = ref('');
|
|
const editingPostId = ref<number | null>(null);
|
|
const postModalOpen = ref(false);
|
|
const formError = ref('');
|
|
const loadError = ref('');
|
|
const commentBodies = ref<Record<number, string>>({});
|
|
const replyBodies = ref<Record<number, string>>({});
|
|
const replyTargetId = ref<number | null>(null);
|
|
const expandedComments = ref<Record<number, boolean>>({});
|
|
const commentPages = ref<Record<number, LifeCommentPageState>>({});
|
|
const commentBusyKey = ref('');
|
|
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 ratingBusyPostId = ref<number | null>(null);
|
|
const ratingErrors = ref<Record<number, string>>({});
|
|
const moderationBusyPostId = ref<number | null>(null);
|
|
const moderationErrors = ref<Record<number, string>>({});
|
|
const reactionUsersModal = ref<{ postId: number; reactionType: LifeReactionType | null } | null>(null);
|
|
const bodyInput = ref<HTMLTextAreaElement | null>(null);
|
|
const loadMoreSentinel = ref<HTMLElement | null>(null);
|
|
const lifePostPageSize = 20;
|
|
const lifeCommentPageSize = 20;
|
|
const bodyMaxLength = 2000;
|
|
const commentMaxLength = 1000;
|
|
const skeletonPostCount = 3;
|
|
const loadingMoreSkeletonCount = 2;
|
|
let removeAuthListener: (() => void) | null = null;
|
|
let feedObserver: IntersectionObserver | null = null;
|
|
let postsRequestId = 0;
|
|
const nextCursor = ref<string | null>(null);
|
|
const hasMorePosts = ref(false);
|
|
const loadMorePaused = ref(false);
|
|
const allCategoryValue = 'all';
|
|
const allLanguageValue = 'all';
|
|
const allGameVersionValue = 'all';
|
|
|
|
const reactionOptions = [
|
|
{ type: 'like', icon: iconReactionLike, labelKey: 'pages.life.reactionLike' },
|
|
{ type: 'helpful', icon: iconReactionHelpful, labelKey: 'pages.life.reactionHelpful' },
|
|
{ type: 'fun', icon: iconReactionFun, labelKey: 'pages.life.reactionFun' },
|
|
{ type: 'thanks', icon: iconReactionThanks, labelKey: 'pages.life.reactionThanks' }
|
|
] as const satisfies ReadonlyArray<{ type: LifeReactionType; icon: string; labelKey: string }>;
|
|
|
|
function can(permissionKey: string) {
|
|
return currentUser.value?.permissions.includes(permissionKey) === true;
|
|
}
|
|
|
|
const canPost = computed(() => can('life.posts.create'));
|
|
const canComment = computed(() => can('life.comments.create'));
|
|
const canReact = computed(() => can('life.reactions.set'));
|
|
const canRate = computed(() => can('life.ratings.set'));
|
|
const charactersLeft = computed(() => Math.max(0, bodyMaxLength - body.value.length));
|
|
const isEditing = computed(() => editingPostId.value !== null);
|
|
const searchQuery = computed(() => submittedSearch.value.trim());
|
|
const selectedFeedCategoryId = computed(() => {
|
|
const categoryId = Number(activeCategoryId.value);
|
|
return activeCategoryId.value === allCategoryValue || !Number.isInteger(categoryId) || categoryId <= 0 ? undefined : categoryId;
|
|
});
|
|
const selectedFeedLanguageCode = computed(() =>
|
|
activeLanguageCode.value === allLanguageValue ? undefined : activeLanguageCode.value
|
|
);
|
|
const selectedFeedGameVersionId = computed(() => {
|
|
const gameVersionId = Number(activeGameVersionId.value);
|
|
return activeGameVersionId.value === allGameVersionValue || !Number.isInteger(gameVersionId) || gameVersionId <= 0
|
|
? undefined
|
|
: gameVersionId;
|
|
});
|
|
const selectedRateableFilter = computed(() => {
|
|
if (activeRateableFilter.value === 'rateable') {
|
|
return true;
|
|
}
|
|
if (activeRateableFilter.value === 'not-rateable') {
|
|
return false;
|
|
}
|
|
return null;
|
|
});
|
|
const categoryFilterOptions = computed<TabOption[]>(() => [
|
|
{ value: allCategoryValue, label: t('pages.life.allCategories') },
|
|
...lifeCategories.value.map((category) => ({ value: String(category.id), label: category.name }))
|
|
]);
|
|
const languageFilterOptions = computed<TabOption[]>(() => [
|
|
{ value: allLanguageValue, label: t('pages.life.allLanguages') },
|
|
...languages.value.map((language) => ({ value: language.code, label: language.name }))
|
|
]);
|
|
const gameVersionFilterOptions = computed(() => [
|
|
{ value: allGameVersionValue, label: t('pages.life.allVersions') },
|
|
...gameVersions.value.map((version) => ({ value: String(version.id), label: version.name }))
|
|
]);
|
|
const rateableFilterOptions = computed(() => [
|
|
{ value: 'all', label: t('pages.life.allRatingModes') },
|
|
{ value: 'rateable', label: t('pages.life.rateableOnly') },
|
|
{ value: 'not-rateable', label: t('pages.life.notRateableOnly') }
|
|
]);
|
|
const sortOptions = computed<Array<{ value: LifePostSort; label: string }>>(() => [
|
|
{ value: 'latest', label: t('pages.life.sortLatest') },
|
|
{ value: 'oldest', label: t('pages.life.sortOldest') },
|
|
{ value: 'top-rated', label: t('pages.life.sortTopRated') }
|
|
]);
|
|
const defaultLifeCategoryId = computed(() => {
|
|
const category = lifeCategories.value.find((item) => item.isDefault);
|
|
return category ? String(category.id) : '';
|
|
});
|
|
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');
|
|
return isEditing.value ? t('pages.life.update') : t('pages.life.publish');
|
|
});
|
|
|
|
async function loadCurrentUser() {
|
|
authReady.value = false;
|
|
|
|
if (!getAuthToken()) {
|
|
currentUser.value = null;
|
|
authReady.value = true;
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await api.me();
|
|
currentUser.value = response.user;
|
|
} catch {
|
|
currentUser.value = null;
|
|
setAuthToken(null);
|
|
} finally {
|
|
authReady.value = true;
|
|
}
|
|
}
|
|
|
|
async function loadLifeCategories() {
|
|
try {
|
|
const options = await api.options();
|
|
lifeCategories.value = options.lifeCategories;
|
|
gameVersions.value = options.gameVersions;
|
|
|
|
if (activeCategoryId.value !== allCategoryValue && !lifeCategories.value.some((category) => String(category.id) === activeCategoryId.value)) {
|
|
activeCategoryId.value = allCategoryValue;
|
|
}
|
|
if (
|
|
activeGameVersionId.value !== allGameVersionValue &&
|
|
!gameVersions.value.some((gameVersion) => String(gameVersion.id) === activeGameVersionId.value)
|
|
) {
|
|
activeGameVersionId.value = allGameVersionValue;
|
|
}
|
|
if (!isEditing.value && postModalOpen.value && !selectedCategoryId.value) {
|
|
selectedCategoryId.value = defaultLifeCategoryId.value;
|
|
}
|
|
if (!isEditing.value && selectedCategoryId.value && !lifeCategories.value.some((category) => String(category.id) === selectedCategoryId.value)) {
|
|
selectedCategoryId.value = defaultLifeCategoryId.value;
|
|
}
|
|
if (selectedGameVersionId.value && !gameVersions.value.some((gameVersion) => String(gameVersion.id) === selectedGameVersionId.value)) {
|
|
selectedGameVersionId.value = '';
|
|
}
|
|
} catch (error) {
|
|
loadError.value = error instanceof Error && error.message ? error.message : t('errors.loadFailed');
|
|
}
|
|
}
|
|
|
|
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;
|
|
loadingMore.value = false;
|
|
loadError.value = '';
|
|
nextCursor.value = null;
|
|
hasMorePosts.value = false;
|
|
loadMorePaused.value = false;
|
|
|
|
try {
|
|
const page = await api.lifePosts({
|
|
limit: lifePostPageSize,
|
|
search: searchQuery.value,
|
|
categoryId: selectedFeedCategoryId.value,
|
|
language: selectedFeedLanguageCode.value,
|
|
gameVersionId: selectedFeedGameVersionId.value,
|
|
rateable: selectedRateableFilter.value,
|
|
sort: activeSort.value
|
|
});
|
|
if (requestId !== postsRequestId) {
|
|
return;
|
|
}
|
|
posts.value = page.items;
|
|
expandedComments.value = {};
|
|
commentPages.value = {};
|
|
nextCursor.value = page.nextCursor;
|
|
hasMorePosts.value = page.hasMore;
|
|
} catch (error) {
|
|
if (requestId === postsRequestId) {
|
|
loadError.value = error instanceof Error && error.message ? error.message : t('errors.loadFailed');
|
|
}
|
|
} finally {
|
|
if (requestId === postsRequestId) {
|
|
loading.value = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
async function loadMorePosts() {
|
|
if (loading.value || loadingMore.value || loadMorePaused.value || !hasMorePosts.value) {
|
|
return;
|
|
}
|
|
|
|
const cursor = nextCursor.value;
|
|
if (!cursor) {
|
|
hasMorePosts.value = false;
|
|
return;
|
|
}
|
|
|
|
const requestId = postsRequestId;
|
|
loadingMore.value = true;
|
|
loadError.value = '';
|
|
|
|
try {
|
|
const page = await api.lifePosts({
|
|
cursor,
|
|
limit: lifePostPageSize,
|
|
search: searchQuery.value,
|
|
categoryId: selectedFeedCategoryId.value,
|
|
language: selectedFeedLanguageCode.value,
|
|
gameVersionId: selectedFeedGameVersionId.value,
|
|
rateable: selectedRateableFilter.value,
|
|
sort: activeSort.value
|
|
});
|
|
if (requestId !== postsRequestId) {
|
|
return;
|
|
}
|
|
|
|
const existingIds = new Set(posts.value.map((post) => post.id));
|
|
posts.value = [...posts.value, ...page.items.filter((post) => !existingIds.has(post.id))];
|
|
nextCursor.value = page.nextCursor;
|
|
hasMorePosts.value = page.hasMore;
|
|
} catch (error) {
|
|
if (requestId === postsRequestId) {
|
|
loadError.value = error instanceof Error && error.message ? error.message : t('errors.loadFailed');
|
|
loadMorePaused.value = true;
|
|
}
|
|
} finally {
|
|
if (requestId === postsRequestId) {
|
|
loadingMore.value = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
function resetForm() {
|
|
body.value = '';
|
|
selectedCategoryId.value = '';
|
|
selectedGameVersionId.value = '';
|
|
editingPostId.value = null;
|
|
formError.value = '';
|
|
}
|
|
|
|
function payload() {
|
|
return {
|
|
body: body.value.trim(),
|
|
categoryId: selectedLifeCategoryId() ?? 0,
|
|
gameVersionId: selectedGameVersionForPost(),
|
|
languageCode: selectedFeedLanguageCode.value ?? null
|
|
};
|
|
}
|
|
|
|
function selectedLifeCategoryId() {
|
|
const categoryId = Number(selectedCategoryId.value);
|
|
return Number.isInteger(categoryId) && categoryId > 0 ? categoryId : null;
|
|
}
|
|
|
|
function selectedGameVersionForPost() {
|
|
const gameVersionId = Number(selectedGameVersionId.value);
|
|
return Number.isInteger(gameVersionId) && gameVersionId > 0 ? gameVersionId : null;
|
|
}
|
|
|
|
function submitSearch() {
|
|
const nextSearch = searchDraft.value.trim();
|
|
if (nextSearch === submittedSearch.value && !loadError.value) {
|
|
return;
|
|
}
|
|
|
|
submittedSearch.value = nextSearch;
|
|
void loadPosts();
|
|
}
|
|
|
|
function clearSearch() {
|
|
const hadSubmittedSearch = Boolean(submittedSearch.value);
|
|
if (!searchDraft.value && !hadSubmittedSearch) {
|
|
return;
|
|
}
|
|
|
|
searchDraft.value = '';
|
|
submittedSearch.value = '';
|
|
|
|
if (hadSubmittedSearch) {
|
|
void loadPosts();
|
|
}
|
|
}
|
|
|
|
function retryLoadMore() {
|
|
loadMorePaused.value = false;
|
|
void loadMorePosts();
|
|
}
|
|
|
|
function matchesCurrentFilters(post: LifePost) {
|
|
const keyword = searchQuery.value.toLowerCase();
|
|
const categoryId = selectedFeedCategoryId.value;
|
|
const gameVersionId = selectedFeedGameVersionId.value;
|
|
const rateable = selectedRateableFilter.value;
|
|
const matchesSearch = keyword === '' || post.body.toLowerCase().includes(keyword);
|
|
const matchesCategory = categoryId === undefined || post.category?.id === categoryId;
|
|
const matchesGameVersion = gameVersionId === undefined || post.gameVersion?.id === gameVersionId;
|
|
const matchesRateable = rateable === null || post.category?.isRateable === rateable;
|
|
const matchesLanguage =
|
|
selectedFeedLanguageCode.value === undefined || post.moderationLanguageCode === selectedFeedLanguageCode.value;
|
|
return matchesSearch && matchesCategory && matchesGameVersion && matchesRateable && matchesLanguage;
|
|
}
|
|
|
|
function openCreatePostModal() {
|
|
resetForm();
|
|
selectedCategoryId.value = defaultLifeCategoryId.value;
|
|
postModalOpen.value = true;
|
|
void nextTick(() => bodyInput.value?.focus());
|
|
}
|
|
|
|
function closePostModal() {
|
|
if (busy.value) {
|
|
return;
|
|
}
|
|
|
|
postModalOpen.value = false;
|
|
resetForm();
|
|
}
|
|
|
|
async function submitPost() {
|
|
if (!body.value.trim()) {
|
|
formError.value = t('pages.life.bodyRequired');
|
|
bodyInput.value?.focus();
|
|
return;
|
|
}
|
|
|
|
if (selectedLifeCategoryId() === null) {
|
|
formError.value = t('pages.life.categoryRequired');
|
|
document.getElementById('life-post-category')?.focus();
|
|
return;
|
|
}
|
|
|
|
busy.value = true;
|
|
formError.value = '';
|
|
|
|
try {
|
|
if (editingPostId.value !== null) {
|
|
const updated = await api.updateLifePost(editingPostId.value, payload());
|
|
replacePost(updated);
|
|
} else {
|
|
const created = await api.createLifePost(payload());
|
|
if (activeSort.value !== 'latest') {
|
|
void loadPosts();
|
|
} else if (matchesCurrentFilters(created)) {
|
|
posts.value = [created, ...posts.value];
|
|
}
|
|
}
|
|
resetForm();
|
|
postModalOpen.value = false;
|
|
} catch (error) {
|
|
formError.value =
|
|
error instanceof Error && error.message
|
|
? error.message
|
|
: isEditing.value
|
|
? t('pages.life.saveFailed')
|
|
: t('pages.life.postFailed');
|
|
} finally {
|
|
busy.value = false;
|
|
}
|
|
}
|
|
|
|
function canManage(post: LifePost) {
|
|
return (currentUser.value?.id === post.author?.id && can('life.posts.update')) || can('life.posts.update-any');
|
|
}
|
|
|
|
function canDeletePost(post: LifePost) {
|
|
return (currentUser.value?.id === post.author?.id && can('life.posts.delete')) || can('life.posts.delete-any');
|
|
}
|
|
|
|
function canManageComment(comment: LifeComment) {
|
|
return !comment.deleted && ((currentUser.value?.id === comment.author?.id && can('life.comments.delete')) || can('life.comments.delete-any'));
|
|
}
|
|
|
|
function canRestoreComment(comment: LifeComment) {
|
|
return comment.deleted && currentUser.value?.id === comment.author?.id && can('life.comments.delete');
|
|
}
|
|
|
|
function canSeeCommentModeration(comment: LifeComment) {
|
|
return moderationStatusVisible(comment.moderationStatus) && (currentUser.value?.id === comment.author?.id || can('life.comments.delete-any'));
|
|
}
|
|
|
|
function commentKey(postId: number) {
|
|
return `post-${postId}`;
|
|
}
|
|
|
|
function replyKey(commentId: number) {
|
|
return `reply-${commentId}`;
|
|
}
|
|
|
|
function initialCommentPage(post: LifePost): LifeCommentPageState {
|
|
return {
|
|
items: post.commentPreview,
|
|
nextCursor: null,
|
|
hasMore: post.commentCount > post.commentPreview.reduce((count, comment) => count + 1 + comment.replies.length, 0),
|
|
total: post.commentCount,
|
|
loading: false,
|
|
loadingMore: false,
|
|
loaded: false,
|
|
error: ''
|
|
};
|
|
}
|
|
|
|
function commentPage(post: LifePost) {
|
|
return commentPages.value[post.id] ?? initialCommentPage(post);
|
|
}
|
|
|
|
function setCommentPage(postId: number, page: LifeCommentPageState) {
|
|
commentPages.value = {
|
|
...commentPages.value,
|
|
[postId]: page
|
|
};
|
|
}
|
|
|
|
function commentsForPost(post: LifePost) {
|
|
return commentPage(post).items;
|
|
}
|
|
|
|
function commentCount(post: LifePost) {
|
|
return commentPage(post).total;
|
|
}
|
|
|
|
function reactionTotal(post: LifePost) {
|
|
return reactionOptions.reduce((count, option) => count + (post.reactionCounts[option.type] ?? 0), 0);
|
|
}
|
|
|
|
function reactionLabel(type: LifeReactionType) {
|
|
return t(reactionOptions.find((option) => option.type === type)?.labelKey ?? 'pages.life.react');
|
|
}
|
|
|
|
function reactionIcon(type: LifeReactionType | null) {
|
|
return reactionOptions.find((option) => option.type === type)?.icon ?? iconReactionLike;
|
|
}
|
|
|
|
function reactionButtonLabel(post: LifePost) {
|
|
return post.myReaction ? reactionLabel(post.myReaction) : t('pages.life.reactionLike');
|
|
}
|
|
|
|
function reactionOptionLabel(post: LifePost, type: LifeReactionType) {
|
|
return post.myReaction === type ? t('pages.life.removeReaction') : reactionLabel(type);
|
|
}
|
|
|
|
function reactionCountLabel(post: LifePost, type: LifeReactionType) {
|
|
return t('pages.life.reactionCountLabel', {
|
|
reaction: reactionLabel(type),
|
|
count: post.reactionCounts[type] ?? 0
|
|
});
|
|
}
|
|
|
|
function openReactionUsersModal(postId: number, reactionType: LifeReactionType | null = null) {
|
|
reactionUsersModal.value = { postId, reactionType };
|
|
}
|
|
|
|
function closeReactionUsersModal() {
|
|
reactionUsersModal.value = null;
|
|
}
|
|
|
|
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 moderationStatusVisible(status: AiModerationStatus) {
|
|
return status !== 'approved';
|
|
}
|
|
|
|
function canRetryModeration(post: LifePost) {
|
|
return post.moderationStatus !== 'approved' && post.moderationStatus !== 'reviewing' && canManage(post);
|
|
}
|
|
|
|
function moderationReasonVisible(status: AiModerationStatus, reason: string | null) {
|
|
return (status === 'rejected' || status === 'failed') && reason !== null && reason.trim() !== '';
|
|
}
|
|
|
|
function updateLifeCommentModeration(
|
|
items: LifeComment[],
|
|
commentId: number,
|
|
status: AiModerationStatus,
|
|
languageCode: string | null,
|
|
reason: string | null
|
|
): boolean {
|
|
for (const comment of items) {
|
|
if (comment.id === commentId) {
|
|
comment.moderationStatus = status;
|
|
comment.moderationLanguageCode = languageCode;
|
|
comment.moderationReason = reason;
|
|
return true;
|
|
}
|
|
|
|
if (updateLifeCommentModeration(comment.replies, commentId, status, languageCode, reason)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
function isModerationUpdateEvent(event: Event): event is CustomEvent<ModerationUpdateDetail> {
|
|
return event instanceof CustomEvent && event.detail?.type === 'moderation.updated';
|
|
}
|
|
|
|
function handleModerationUpdate(event: Event) {
|
|
if (!isModerationUpdateEvent(event)) {
|
|
return;
|
|
}
|
|
|
|
const { target, moderationStatus, moderationLanguageCode, moderationReason } = event.detail;
|
|
if (target.type === 'life-post' && target.lifePostId !== null) {
|
|
const currentPost = posts.value.find((post) => post.id === target.lifePostId);
|
|
if (!currentPost) {
|
|
return;
|
|
}
|
|
|
|
const updatedPost = {
|
|
...currentPost,
|
|
moderationStatus,
|
|
moderationLanguageCode,
|
|
moderationReason
|
|
};
|
|
if (!matchesCurrentFilters(updatedPost)) {
|
|
posts.value = posts.value.filter((post) => post.id !== updatedPost.id);
|
|
return;
|
|
}
|
|
posts.value = posts.value.map((post) => (post.id === updatedPost.id ? updatedPost : post));
|
|
return;
|
|
}
|
|
|
|
if (target.type !== 'life-comment' || target.lifePostId === null || target.lifeCommentId === null) {
|
|
return;
|
|
}
|
|
|
|
const currentPost = posts.value.find((post) => post.id === target.lifePostId);
|
|
if (!currentPost) {
|
|
return;
|
|
}
|
|
|
|
const page = commentPage(currentPost);
|
|
const updated = updateLifeCommentModeration(
|
|
page.items,
|
|
target.lifeCommentId,
|
|
moderationStatus,
|
|
moderationLanguageCode,
|
|
moderationReason
|
|
);
|
|
if (updated) {
|
|
setCommentPage(currentPost.id, { ...page, items: [...page.items] });
|
|
} else if (moderationStatus === 'approved' && areCommentsExpanded(currentPost.id)) {
|
|
void loadComments(currentPost, true);
|
|
}
|
|
}
|
|
|
|
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);
|
|
return;
|
|
}
|
|
|
|
const existingComments = commentPages.value[updatedPost.id];
|
|
if (existingComments) {
|
|
setCommentPage(updatedPost.id, {
|
|
...existingComments,
|
|
total: updatedPost.commentCount
|
|
});
|
|
}
|
|
posts.value = posts.value.map((post) => (post.id === updatedPost.id ? updatedPost : post));
|
|
}
|
|
|
|
function areCommentsExpanded(postId: number) {
|
|
return expandedComments.value[postId] === true;
|
|
}
|
|
|
|
function setCommentsExpanded(postId: number, expanded: boolean) {
|
|
expandedComments.value = {
|
|
...expandedComments.value,
|
|
[postId]: expanded
|
|
};
|
|
}
|
|
|
|
function mergeComments(existing: LifeComment[], incoming: LifeComment[]) {
|
|
const ids = new Set(existing.map((comment) => comment.id));
|
|
return [...existing, ...incoming.filter((comment) => !ids.has(comment.id))];
|
|
}
|
|
|
|
function replaceCommentInTree(comments: LifeComment[], updated: LifeComment): boolean {
|
|
for (let index = 0; index < comments.length; index += 1) {
|
|
const comment = comments[index];
|
|
if (!comment) {
|
|
continue;
|
|
}
|
|
if (comment.id === updated.id) {
|
|
comments[index] = { ...updated, replies: comment.replies };
|
|
return true;
|
|
}
|
|
|
|
if (replaceCommentInTree(comment.replies, updated)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
async function loadComments(post: LifePost, reset = false) {
|
|
const existing = commentPage(post);
|
|
if (existing.loading || existing.loadingMore || (!reset && existing.loaded && !existing.hasMore)) {
|
|
return;
|
|
}
|
|
|
|
const cursor = reset || !existing.loaded ? null : existing.nextCursor;
|
|
setCommentPage(post.id, {
|
|
...existing,
|
|
items: reset || !existing.loaded ? [] : existing.items,
|
|
loading: reset || !existing.loaded,
|
|
loadingMore: !reset && existing.loaded,
|
|
error: ''
|
|
});
|
|
|
|
try {
|
|
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,
|
|
nextCursor: page.nextCursor,
|
|
hasMore: page.hasMore,
|
|
total: page.total,
|
|
loading: false,
|
|
loadingMore: false,
|
|
loaded: true,
|
|
error: ''
|
|
});
|
|
post.commentCount = page.total;
|
|
} catch (error) {
|
|
setCommentPage(post.id, {
|
|
...existing,
|
|
loading: false,
|
|
loadingMore: false,
|
|
error: error instanceof Error && error.message ? error.message : t('errors.loadFailed')
|
|
});
|
|
}
|
|
}
|
|
|
|
function toggleComments(post: LifePost) {
|
|
const expanded = !areCommentsExpanded(post.id);
|
|
setCommentsExpanded(post.id, expanded);
|
|
if (expanded) {
|
|
void loadComments(post);
|
|
}
|
|
}
|
|
|
|
function isCommentBusy(key: string) {
|
|
return commentBusyKey.value === key;
|
|
}
|
|
|
|
function isReactionBusy(postId: number) {
|
|
return reactionBusyPostId.value === postId;
|
|
}
|
|
|
|
function isRatingBusy(postId: number) {
|
|
return ratingBusyPostId.value === postId;
|
|
}
|
|
|
|
function commentAuthorName(comment: LifeComment) {
|
|
return comment.author?.displayName ?? t('pages.life.byUnknown');
|
|
}
|
|
|
|
function commentInitial(comment: LifeComment) {
|
|
const name = commentAuthorName(comment);
|
|
return name.slice(0, 1).toUpperCase();
|
|
}
|
|
|
|
function setCommentError(key: string, message: string) {
|
|
commentErrors.value = { ...commentErrors.value, [key]: message };
|
|
}
|
|
|
|
function clearCommentError(key: string) {
|
|
const nextErrors = { ...commentErrors.value };
|
|
delete nextErrors[key];
|
|
commentErrors.value = nextErrors;
|
|
}
|
|
|
|
function updateCommentPage(post: LifePost, updater: (page: LifeCommentPageState) => LifeCommentPageState) {
|
|
setCommentPage(post.id, updater(commentPage(post)));
|
|
}
|
|
|
|
function setReactionError(postId: number, message: string) {
|
|
reactionErrors.value = { ...reactionErrors.value, [postId]: message };
|
|
}
|
|
|
|
function clearReactionError(postId: number) {
|
|
const nextErrors = { ...reactionErrors.value };
|
|
delete nextErrors[postId];
|
|
reactionErrors.value = nextErrors;
|
|
}
|
|
|
|
function setRatingError(postId: number, message: string) {
|
|
ratingErrors.value = { ...ratingErrors.value, [postId]: message };
|
|
}
|
|
|
|
function clearRatingError(postId: number) {
|
|
const nextErrors = { ...ratingErrors.value };
|
|
delete nextErrors[postId];
|
|
ratingErrors.value = nextErrors;
|
|
}
|
|
|
|
function canUseReactions() {
|
|
return canReact.value && reactionBusyPostId.value === null;
|
|
}
|
|
|
|
function canUseRatings(post: LifePost) {
|
|
return canRate.value && ratingBusyPostId.value === null && post.moderationStatus === 'approved' && post.category?.isRateable === true;
|
|
}
|
|
|
|
function closeReactionPicker() {
|
|
reactionPickerPostId.value = null;
|
|
}
|
|
|
|
function toggleReactionPicker(postId: number) {
|
|
if (!canUseReactions()) {
|
|
return;
|
|
}
|
|
|
|
clearReactionError(postId);
|
|
reactionPickerPostId.value = reactionPickerPostId.value === postId ? null : postId;
|
|
}
|
|
|
|
function closeReactionPickerFromDocument(event: MouseEvent) {
|
|
if (reactionPickerPostId.value === null || !(event.target instanceof Element)) {
|
|
return;
|
|
}
|
|
|
|
if (!event.target.closest('.life-reactions')) {
|
|
closeReactionPicker();
|
|
}
|
|
}
|
|
|
|
function closeReactionPickerFromKeyboard(event: KeyboardEvent) {
|
|
if (event.key === 'Escape' && reactionPickerPostId.value !== null) {
|
|
closeReactionPicker();
|
|
}
|
|
}
|
|
|
|
function handleReactionContextMenu(event: MouseEvent, postId: number) {
|
|
event.preventDefault();
|
|
toggleReactionPicker(postId);
|
|
}
|
|
|
|
function handleReactionKeydown(event: KeyboardEvent, postId: number) {
|
|
if (event.key !== 'ContextMenu' && !(event.shiftKey && event.key === 'F10')) {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
toggleReactionPicker(postId);
|
|
}
|
|
|
|
async function toggleDefaultReaction(post: LifePost) {
|
|
await toggleReaction(post, 'like');
|
|
}
|
|
|
|
async function toggleReaction(post: LifePost, reactionType: LifeReactionType) {
|
|
if (!canUseReactions() || post.moderationStatus !== 'approved') {
|
|
return;
|
|
}
|
|
|
|
reactionBusyPostId.value = post.id;
|
|
clearReactionError(post.id);
|
|
|
|
try {
|
|
const updatedPost =
|
|
post.myReaction === reactionType
|
|
? await api.deleteLifeReaction(post.id)
|
|
: await api.setLifeReaction(post.id, reactionType);
|
|
replacePost(updatedPost);
|
|
reactionPickerPostId.value = null;
|
|
} catch (error) {
|
|
setReactionError(post.id, error instanceof Error && error.message ? error.message : t('pages.life.reactionFailed'));
|
|
} finally {
|
|
reactionBusyPostId.value = null;
|
|
}
|
|
}
|
|
|
|
async function toggleRating(post: LifePost, rating: number) {
|
|
if (!canUseRatings(post)) {
|
|
return;
|
|
}
|
|
|
|
ratingBusyPostId.value = post.id;
|
|
clearRatingError(post.id);
|
|
|
|
try {
|
|
const updatedPost = post.myRating === rating ? await api.deleteLifeRating(post.id) : await api.setLifeRating(post.id, rating);
|
|
replacePost(updatedPost);
|
|
if (activeSort.value === 'top-rated') {
|
|
void loadPosts();
|
|
}
|
|
} catch (error) {
|
|
setRatingError(post.id, error instanceof Error && error.message ? error.message : t('pages.life.ratingFailed'));
|
|
} finally {
|
|
ratingBusyPostId.value = null;
|
|
}
|
|
}
|
|
|
|
function startEdit(post: LifePost) {
|
|
editingPostId.value = post.id;
|
|
body.value = post.body;
|
|
selectedCategoryId.value = post.category ? String(post.category.id) : '';
|
|
selectedGameVersionId.value = post.gameVersion ? String(post.gameVersion.id) : '';
|
|
formError.value = '';
|
|
postModalOpen.value = true;
|
|
void nextTick(() => bodyInput.value?.focus());
|
|
}
|
|
|
|
async function deletePost(post: LifePost) {
|
|
if (!window.confirm(t('pages.life.deleteConfirm'))) {
|
|
return;
|
|
}
|
|
|
|
loadError.value = '';
|
|
|
|
try {
|
|
await api.deleteLifePost(post.id);
|
|
posts.value = posts.value.filter((item) => item.id !== post.id);
|
|
if (editingPostId.value === post.id) {
|
|
resetForm();
|
|
postModalOpen.value = false;
|
|
}
|
|
} catch (error) {
|
|
loadError.value = error instanceof Error && error.message ? error.message : t('pages.life.deleteFailed');
|
|
}
|
|
}
|
|
|
|
function startReply(comment: LifeComment) {
|
|
replyTargetId.value = comment.id;
|
|
clearCommentError(replyKey(comment.id));
|
|
}
|
|
|
|
function cancelReply(commentId: number) {
|
|
replyTargetId.value = null;
|
|
replyBodies.value[commentId] = '';
|
|
clearCommentError(replyKey(commentId));
|
|
}
|
|
|
|
async function submitComment(post: LifePost) {
|
|
const key = commentKey(post.id);
|
|
const nextBody = (commentBodies.value[post.id] ?? '').trim();
|
|
if (!nextBody) {
|
|
setCommentError(key, t('pages.life.commentRequired'));
|
|
return;
|
|
}
|
|
|
|
commentBusyKey.value = key;
|
|
clearCommentError(key);
|
|
|
|
try {
|
|
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) => ({
|
|
...page,
|
|
items: mergeComments(page.items, [comment]),
|
|
total: nextTotal,
|
|
loaded: page.loaded || areCommentsExpanded(post.id)
|
|
}));
|
|
commentBodies.value[post.id] = '';
|
|
setCommentsExpanded(post.id, true);
|
|
} catch (error) {
|
|
setCommentError(key, error instanceof Error && error.message ? error.message : t('pages.life.commentFailed'));
|
|
} finally {
|
|
commentBusyKey.value = '';
|
|
}
|
|
}
|
|
|
|
async function submitReply(post: LifePost, comment: LifeComment) {
|
|
const key = replyKey(comment.id);
|
|
const nextBody = (replyBodies.value[comment.id] ?? '').trim();
|
|
if (!nextBody) {
|
|
setCommentError(key, t('pages.life.commentRequired'));
|
|
return;
|
|
}
|
|
|
|
commentBusyKey.value = key;
|
|
clearCommentError(key);
|
|
|
|
try {
|
|
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);
|
|
updateCommentPage(post, (page) => ({
|
|
...page,
|
|
total: nextTotal
|
|
}));
|
|
setCommentsExpanded(post.id, true);
|
|
cancelReply(comment.id);
|
|
} catch (error) {
|
|
setCommentError(key, error instanceof Error && error.message ? error.message : t('pages.life.replyFailed'));
|
|
} finally {
|
|
commentBusyKey.value = '';
|
|
}
|
|
}
|
|
|
|
function countCommentTree(comment: LifeComment): number {
|
|
return 1 + comment.replies.reduce((total, reply) => total + countCommentTree(reply), 0);
|
|
}
|
|
|
|
function removeCommentFromTree(comments: LifeComment[], id: number): number {
|
|
for (let index = 0; index < comments.length; index += 1) {
|
|
const comment = comments[index];
|
|
if (!comment) {
|
|
continue;
|
|
}
|
|
if (comment.id === id) {
|
|
const removedCount = countCommentTree(comment);
|
|
comments.splice(index, 1);
|
|
return removedCount;
|
|
}
|
|
|
|
const removedCount = removeCommentFromTree(comment.replies, id);
|
|
if (removedCount > 0) {
|
|
return removedCount;
|
|
}
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
function markOwnCommentDeleted(comments: LifeComment[], id: number): boolean {
|
|
for (const comment of comments) {
|
|
if (comment.id === id) {
|
|
comment.deleted = true;
|
|
return true;
|
|
}
|
|
|
|
if (markOwnCommentDeleted(comment.replies, id)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
async function deleteComment(post: LifePost, comment: LifeComment) {
|
|
if (!window.confirm(t('pages.life.deleteCommentConfirm'))) {
|
|
return;
|
|
}
|
|
|
|
const key = replyKey(comment.id);
|
|
clearCommentError(key);
|
|
|
|
try {
|
|
await api.deleteLifeComment(comment.id);
|
|
if (currentUser.value?.id === comment.author?.id) {
|
|
markOwnCommentDeleted(commentsForPost(post), comment.id);
|
|
updateCommentPage(post, (page) => ({
|
|
...page,
|
|
items: [...page.items]
|
|
}));
|
|
} else {
|
|
const removedCount = removeCommentFromTree(commentsForPost(post), comment.id);
|
|
if (removedCount > 0) {
|
|
const nextTotal = Math.max(0, commentCount(post) - removedCount);
|
|
post.commentCount = nextTotal;
|
|
updateCommentPage(post, (page) => ({
|
|
...page,
|
|
items: [...page.items],
|
|
total: nextTotal
|
|
}));
|
|
}
|
|
}
|
|
if (replyTargetId.value === comment.id) {
|
|
cancelReply(comment.id);
|
|
}
|
|
} catch (error) {
|
|
setCommentError(key, error instanceof Error && error.message ? error.message : t('pages.life.deleteCommentFailed'));
|
|
}
|
|
}
|
|
|
|
async function restoreComment(post: LifePost, comment: LifeComment) {
|
|
const key = replyKey(comment.id);
|
|
commentBusyKey.value = key;
|
|
clearCommentError(key);
|
|
|
|
try {
|
|
const restored = await api.restoreLifeComment(comment.id);
|
|
replaceCommentInTree(commentsForPost(post), restored);
|
|
updateCommentPage(post, (page) => ({
|
|
...page,
|
|
items: [...page.items]
|
|
}));
|
|
} catch (error) {
|
|
setCommentError(key, error instanceof Error && error.message ? error.message : t('pages.life.restoreCommentFailed'));
|
|
} finally {
|
|
commentBusyKey.value = '';
|
|
}
|
|
}
|
|
|
|
function formatPostTime(value: string) {
|
|
const date = new Date(value);
|
|
if (Number.isNaN(date.getTime())) {
|
|
return '';
|
|
}
|
|
|
|
return new Intl.DateTimeFormat(locale.value, {
|
|
dateStyle: 'medium',
|
|
timeStyle: 'short'
|
|
}).format(date);
|
|
}
|
|
|
|
function authorInitial(post: LifePost) {
|
|
const name = post.author?.displayName.trim() || t('pages.life.byUnknown');
|
|
return name.slice(0, 1).toUpperCase();
|
|
}
|
|
|
|
function disconnectFeedObserver() {
|
|
feedObserver?.disconnect();
|
|
feedObserver = null;
|
|
}
|
|
|
|
function observeLoadMore() {
|
|
disconnectFeedObserver();
|
|
|
|
if (loading.value || loadingMore.value || loadMorePaused.value || !hasMorePosts.value || !loadMoreSentinel.value) {
|
|
return;
|
|
}
|
|
|
|
if (typeof IntersectionObserver === 'undefined') {
|
|
void loadMorePosts();
|
|
return;
|
|
}
|
|
|
|
feedObserver = new IntersectionObserver(
|
|
(entries) => {
|
|
if (entries.some((entry) => entry.isIntersecting)) {
|
|
void loadMorePosts();
|
|
}
|
|
},
|
|
{ rootMargin: '360px 0px' }
|
|
);
|
|
feedObserver.observe(loadMoreSentinel.value);
|
|
}
|
|
|
|
watch([loadMoreSentinel, hasMorePosts, loading, loadingMore, loadMorePaused], observeLoadMore, { flush: 'post' });
|
|
watch(activeCategoryId, () => {
|
|
void loadPosts();
|
|
});
|
|
watch(activeLanguageCode, () => {
|
|
expandedComments.value = {};
|
|
commentPages.value = {};
|
|
void loadPosts();
|
|
});
|
|
watch(activeGameVersionId, () => {
|
|
void loadPosts();
|
|
});
|
|
watch(activeRateableFilter, () => {
|
|
void loadPosts();
|
|
});
|
|
watch(activeSort, () => {
|
|
void loadPosts();
|
|
});
|
|
watch(locale, () => {
|
|
void loadLanguages();
|
|
void loadLifeCategories();
|
|
void loadPosts();
|
|
});
|
|
|
|
onMounted(() => {
|
|
document.addEventListener('click', closeReactionPickerFromDocument);
|
|
document.addEventListener('keydown', closeReactionPickerFromKeyboard);
|
|
window.addEventListener(moderationUpdateEvent, handleModerationUpdate);
|
|
void loadCurrentUser();
|
|
void loadLanguages();
|
|
void loadLifeCategories();
|
|
void loadPosts();
|
|
removeAuthListener = onAuthTokenChange(() => {
|
|
void loadCurrentUser();
|
|
void loadPosts();
|
|
});
|
|
});
|
|
|
|
onUnmounted(() => {
|
|
document.removeEventListener('click', closeReactionPickerFromDocument);
|
|
document.removeEventListener('keydown', closeReactionPickerFromKeyboard);
|
|
window.removeEventListener(moderationUpdateEvent, handleModerationUpdate);
|
|
disconnectFeedObserver();
|
|
removeAuthListener?.();
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<section class="page-stack life-page">
|
|
<PageHeader :title="t('pages.life.title')" :subtitle="t('pages.life.subtitle')">
|
|
<template #kicker>{{ t('pages.life.kicker') }}</template>
|
|
</PageHeader>
|
|
|
|
<FilterPanel class="life-toolbar">
|
|
<form class="life-toolbar__search" role="search" @submit.prevent="submitSearch">
|
|
<div class="field life-toolbar__field">
|
|
<label for="life-search">{{ t('pages.life.search') }}</label>
|
|
<div class="life-search-control">
|
|
<input id="life-search" v-model="searchDraft" type="search" :placeholder="t('pages.life.searchPlaceholder')" />
|
|
<button
|
|
v-if="searchDraft || submittedSearch"
|
|
class="life-search-control__clear"
|
|
type="button"
|
|
:aria-label="t('pages.life.clearSearch')"
|
|
@click="clearSearch"
|
|
>
|
|
<Icon :icon="iconCancel" class="ui-icon" aria-hidden="true" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<button class="ui-button ui-button--ghost" type="submit">
|
|
<Icon :icon="iconSearch" class="ui-icon" aria-hidden="true" />
|
|
{{ t('common.search') }}
|
|
</button>
|
|
</form>
|
|
|
|
<div class="life-toolbar__filters">
|
|
<div class="field life-toolbar__select">
|
|
<label for="life-version-filter">{{ t('pages.life.versionFilter') }}</label>
|
|
<select id="life-version-filter" v-model="activeGameVersionId">
|
|
<option v-for="option in gameVersionFilterOptions" :key="option.value" :value="option.value">
|
|
{{ option.label }}
|
|
</option>
|
|
</select>
|
|
</div>
|
|
<div class="field life-toolbar__select">
|
|
<label for="life-rateable-filter">{{ t('pages.life.ratingFilter') }}</label>
|
|
<select id="life-rateable-filter" v-model="activeRateableFilter">
|
|
<option v-for="option in rateableFilterOptions" :key="option.value" :value="option.value">
|
|
{{ option.label }}
|
|
</option>
|
|
</select>
|
|
</div>
|
|
<div class="field life-toolbar__select">
|
|
<label for="life-sort">{{ t('pages.life.sort') }}</label>
|
|
<select id="life-sort" v-model="activeSort">
|
|
<option v-for="option in sortOptions" :key="option.value" :value="option.value">
|
|
{{ option.label }}
|
|
</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="life-toolbar__actions">
|
|
<button class="ui-button ui-button--primary" :disabled="!authReady" type="button" @click="openCreatePostModal">
|
|
<Icon :icon="iconAdd" class="ui-icon" aria-hidden="true" />
|
|
{{ t('pages.life.newPost') }}
|
|
</button>
|
|
</div>
|
|
</FilterPanel>
|
|
|
|
<StatusMessage v-if="loadError" variant="danger" :duration="0">{{ loadError }}</StatusMessage>
|
|
|
|
<Modal
|
|
:open="postModalOpen"
|
|
:title="postModalTitle"
|
|
:subtitle="t('pages.life.composerPrompt')"
|
|
:close-label="t('common.close')"
|
|
size="wide"
|
|
@close="closePostModal"
|
|
>
|
|
<div v-if="!authReady" class="life-composer__auth-skeleton" aria-hidden="true">
|
|
<Skeleton variant="box" height="112px" />
|
|
<Skeleton width="42%" />
|
|
</div>
|
|
|
|
<form v-else-if="canPost" class="life-form" @submit.prevent="submitPost">
|
|
<div class="field">
|
|
<label for="life-post-body">{{ t('pages.life.bodyLabel') }}</label>
|
|
<textarea
|
|
id="life-post-body"
|
|
ref="bodyInput"
|
|
v-model="body"
|
|
:maxlength="bodyMaxLength"
|
|
:placeholder="t('pages.life.bodyPlaceholder')"
|
|
required
|
|
></textarea>
|
|
<span class="life-form__counter">{{ t('pages.life.charactersLeft', { count: charactersLeft }) }}</span>
|
|
</div>
|
|
|
|
<div class="field">
|
|
<label for="life-post-category">{{ t('pages.life.category') }}</label>
|
|
<TagsSelect
|
|
id="life-post-category"
|
|
v-model="selectedCategoryId"
|
|
:options="lifeCategories"
|
|
:multiple="false"
|
|
:placeholder="t('pages.life.categoryPlaceholder')"
|
|
:search-placeholder="t('pages.life.searchCategories')"
|
|
dropdown-strategy="fixed"
|
|
/>
|
|
</div>
|
|
|
|
<div class="field">
|
|
<label for="life-post-version">{{ t('pages.life.gameVersion') }}</label>
|
|
<TagsSelect
|
|
id="life-post-version"
|
|
v-model="selectedGameVersionId"
|
|
:options="gameVersions"
|
|
:multiple="false"
|
|
:placeholder="t('pages.life.versionPlaceholder')"
|
|
:search-placeholder="t('pages.life.searchVersions')"
|
|
dropdown-strategy="fixed"
|
|
/>
|
|
</div>
|
|
|
|
<p v-if="formError" class="life-form__error" role="alert">{{ formError }}</p>
|
|
|
|
<div class="life-form__actions">
|
|
<button class="ui-button ui-button--primary" :disabled="busy || !body.trim()" type="submit">
|
|
<Icon :icon="isEditing ? iconSave : iconLife" class="ui-icon" aria-hidden="true" />
|
|
{{ submitLabel }}
|
|
</button>
|
|
<button class="ui-button ui-button--ghost" :disabled="busy" type="button" @click="closePostModal">
|
|
<Icon :icon="iconCancel" class="ui-icon" aria-hidden="true" />
|
|
{{ t('common.cancel') }}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
|
|
<div v-else class="life-auth-note">
|
|
<p>{{ currentUser ? t('pages.life.verifyPrompt') : t('pages.life.loginPrompt') }}</p>
|
|
<RouterLink v-if="!currentUser" class="ui-button ui-button--primary" :to="{ path: '/login', query: { redirect: '/life' } }">
|
|
{{ t('nav.login') }}
|
|
</RouterLink>
|
|
</div>
|
|
</Modal>
|
|
|
|
<LifeReactionUsersModal
|
|
v-if="reactionUsersModal"
|
|
:post-id="reactionUsersModal.postId"
|
|
:initial-reaction-type="reactionUsersModal.reactionType"
|
|
@close="closeReactionUsersModal"
|
|
/>
|
|
|
|
<Tabs id="life-language-filter" v-model="activeLanguageCode" :tabs="languageFilterOptions" :label="t('pages.life.languages')" />
|
|
<Tabs id="life-category-filter" v-model="activeCategoryId" :tabs="categoryFilterOptions" :label="t('pages.life.category')" />
|
|
|
|
<section class="life-feed" :aria-busy="loading || loadingMore" :aria-label="t('pages.life.kicker')">
|
|
<div v-if="loading" class="life-feed__list" :aria-label="t('pages.life.loading')">
|
|
<article v-for="index in skeletonPostCount" :key="index" class="life-post life-post--skeleton">
|
|
<div class="life-post__header">
|
|
<Skeleton variant="box" width="46px" height="46px" />
|
|
<div class="life-post__byline">
|
|
<Skeleton width="138px" />
|
|
<Skeleton width="96px" />
|
|
</div>
|
|
</div>
|
|
<Skeleton width="94%" />
|
|
<Skeleton width="76%" />
|
|
<Skeleton width="52%" />
|
|
</article>
|
|
</div>
|
|
|
|
<div v-else-if="posts.length" class="life-feed__list">
|
|
<article v-for="post in posts" :key="post.id" class="life-post">
|
|
<header class="life-post__header">
|
|
<div class="life-post__avatar" aria-hidden="true">{{ authorInitial(post) }}</div>
|
|
<div class="life-post__byline">
|
|
<RouterLink v-if="post.author" class="user-profile-link" :to="`/profile/${post.author.id}`">
|
|
{{ post.author.displayName }}
|
|
</RouterLink>
|
|
<strong v-else>{{ t('pages.life.byUnknown') }}</strong>
|
|
<span>
|
|
<time :datetime="post.createdAt">{{ formatPostTime(post.createdAt) }}</time>
|
|
<template v-if="post.updatedAt !== post.createdAt"> - {{ t('pages.life.edited') }}</template>
|
|
</span>
|
|
</div>
|
|
|
|
<div class="life-post__actions">
|
|
<RouterLink class="life-icon-button" :to="`/life/${post.id}`" :aria-label="t('pages.life.viewPost')">
|
|
<Icon :icon="iconExternal" class="ui-icon" aria-hidden="true" />
|
|
<span class="life-action-tooltip" role="tooltip">{{ t('pages.life.viewPost') }}</span>
|
|
</RouterLink>
|
|
<button v-if="canManage(post)" class="life-icon-button" type="button" :aria-label="t('pages.life.editPost')" @click="startEdit(post)">
|
|
<Icon :icon="iconEdit" class="ui-icon" aria-hidden="true" />
|
|
<span class="life-action-tooltip" role="tooltip">{{ t('pages.life.editPost') }}</span>
|
|
</button>
|
|
<button
|
|
v-if="canDeletePost(post)"
|
|
class="life-icon-button life-icon-button--danger"
|
|
type="button"
|
|
:aria-label="t('pages.life.deletePost')"
|
|
@click="deletePost(post)"
|
|
>
|
|
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
|
|
<span class="life-action-tooltip" role="tooltip">{{ t('pages.life.deletePost') }}</span>
|
|
</button>
|
|
</div>
|
|
</header>
|
|
|
|
<p class="life-post__body">{{ post.body }}</p>
|
|
|
|
<div v-if="post.category || post.gameVersion" class="life-post__tags" :aria-label="t('pages.life.postMeta')">
|
|
<span v-if="post.category" class="life-post__tag">{{ post.category.name }}</span>
|
|
<span v-if="post.gameVersion" class="life-post__tag life-post__tag--version">
|
|
<Icon :icon="iconVersion" class="ui-icon" aria-hidden="true" />
|
|
{{ post.gameVersion.name }}
|
|
</span>
|
|
</div>
|
|
<details v-if="post.gameVersion?.changeLog" class="life-version-note">
|
|
<summary>{{ t('pages.life.changeLog') }}</summary>
|
|
<p>{{ post.gameVersion.changeLog }}</p>
|
|
</details>
|
|
|
|
<div class="life-post__engagement">
|
|
<div class="life-post__engagement-actions">
|
|
<LifeRatingControl
|
|
v-if="post.category?.isRateable"
|
|
:rating-average="post.ratingAverage"
|
|
:rating-count="post.ratingCount"
|
|
:my-rating="post.myRating"
|
|
:disabled="!canUseRatings(post)"
|
|
:busy="isRatingBusy(post.id)"
|
|
@rate="toggleRating(post, $event)"
|
|
/>
|
|
|
|
<div class="life-reactions">
|
|
<div class="life-reaction-control">
|
|
<button
|
|
class="life-icon-button life-reaction-trigger"
|
|
:class="{ 'is-active': post.myReaction !== null }"
|
|
type="button"
|
|
:aria-controls="`life-reactions-${post.id}`"
|
|
:aria-expanded="reactionPickerPostId === post.id"
|
|
:aria-label="reactionButtonLabel(post)"
|
|
:disabled="!canReact || post.moderationStatus !== 'approved' || reactionBusyPostId !== null"
|
|
@click="toggleDefaultReaction(post)"
|
|
@contextmenu="handleReactionContextMenu($event, post.id)"
|
|
@keydown="handleReactionKeydown($event, post.id)"
|
|
>
|
|
<Icon :icon="reactionIcon(post.myReaction)" class="ui-icon" aria-hidden="true" />
|
|
<span class="life-action-tooltip" role="tooltip">{{ reactionButtonLabel(post) }}</span>
|
|
</button>
|
|
|
|
<button
|
|
class="life-icon-button life-reaction-menu-button"
|
|
type="button"
|
|
:aria-controls="`life-reactions-${post.id}`"
|
|
:aria-expanded="reactionPickerPostId === post.id"
|
|
:aria-label="t('pages.life.chooseReaction')"
|
|
:disabled="!canReact || post.moderationStatus !== 'approved' || reactionBusyPostId !== null"
|
|
@click="toggleReactionPicker(post.id)"
|
|
@contextmenu="handleReactionContextMenu($event, post.id)"
|
|
@keydown="handleReactionKeydown($event, post.id)"
|
|
>
|
|
<Icon :icon="iconChevronDown" class="ui-icon" aria-hidden="true" />
|
|
<span class="life-action-tooltip" role="tooltip">{{ t('pages.life.chooseReaction') }}</span>
|
|
</button>
|
|
</div>
|
|
|
|
<div
|
|
v-if="reactionPickerPostId === post.id && canReact"
|
|
:id="`life-reactions-${post.id}`"
|
|
class="life-reaction-picker"
|
|
role="group"
|
|
:aria-label="t('pages.life.reactionMenu')"
|
|
>
|
|
<button
|
|
v-for="option in reactionOptions"
|
|
:key="option.type"
|
|
class="life-reaction-option"
|
|
:class="{ 'is-active': post.myReaction === option.type }"
|
|
type="button"
|
|
:aria-pressed="post.myReaction === option.type"
|
|
:aria-label="reactionOptionLabel(post, option.type)"
|
|
:disabled="post.moderationStatus !== 'approved' || isReactionBusy(post.id)"
|
|
@click="toggleReaction(post, option.type)"
|
|
>
|
|
<Icon :icon="option.icon" class="ui-icon" aria-hidden="true" />
|
|
<span>{{ reactionLabel(option.type) }}</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<button
|
|
class="life-icon-button"
|
|
type="button"
|
|
: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" />
|
|
<span class="life-action-tooltip" role="tooltip">
|
|
{{ areCommentsExpanded(post.id) ? t('pages.life.hideComments') : t('pages.life.comment') }}
|
|
</span>
|
|
</button>
|
|
|
|
<div class="life-post__review-actions">
|
|
<StatusBadge
|
|
v-if="moderationStatusVisible(post.moderationStatus)"
|
|
:label="moderationLabel(post.moderationStatus)"
|
|
:tone="moderationTone(post.moderationStatus)"
|
|
compact
|
|
/>
|
|
<button
|
|
v-if="canRetryModeration(post)"
|
|
class="life-icon-button life-review-button"
|
|
type="button"
|
|
:aria-label="moderationBusyPostId === post.id ? t('pages.life.moderationRetrying') : t('pages.life.moderationRetry')"
|
|
:disabled="moderationBusyPostId === post.id"
|
|
@click="retryPostModeration(post)"
|
|
>
|
|
<Icon :icon="iconWarning" class="ui-icon" aria-hidden="true" />
|
|
<span class="life-action-tooltip" role="tooltip">
|
|
{{ moderationBusyPostId === post.id ? t('pages.life.moderationRetrying') : t('pages.life.moderationRetry') }}
|
|
</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="life-post__metrics">
|
|
<button
|
|
v-if="reactionTotal(post) > 0"
|
|
class="life-reaction-summary life-reaction-summary--button"
|
|
type="button"
|
|
:aria-label="t('pages.life.reactionsCount', { count: reactionTotal(post) })"
|
|
@click="openReactionUsersModal(post.id)"
|
|
>
|
|
<template v-for="option in reactionOptions" :key="option.type">
|
|
<span
|
|
v-if="post.reactionCounts[option.type] > 0"
|
|
class="life-reaction-summary__item"
|
|
:aria-label="reactionCountLabel(post, option.type)"
|
|
>
|
|
<Icon :icon="option.icon" class="ui-icon" aria-hidden="true" />
|
|
{{ post.reactionCounts[option.type] }}
|
|
<span class="life-action-tooltip" role="tooltip">{{ reactionCountLabel(post, option.type) }}</span>
|
|
</span>
|
|
</template>
|
|
</button>
|
|
|
|
<button
|
|
class="life-metric-button"
|
|
type="button"
|
|
: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" />
|
|
<span>{{ commentCount(post) }}</span>
|
|
<span class="life-action-tooltip" role="tooltip">{{ t('pages.life.commentsCount', { count: commentCount(post) }) }}</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<p v-if="canManage(post) && moderationReasonVisible(post.moderationStatus, post.moderationReason)" class="life-moderation-detail">
|
|
<strong>{{ t('pages.life.moderationReason') }}</strong>
|
|
<span>{{ post.moderationReason }}</span>
|
|
</p>
|
|
|
|
<p v-if="ratingErrors[post.id]" class="life-form__error" role="alert">{{ ratingErrors[post.id] }}</p>
|
|
<p v-if="moderationErrors[post.id]" class="life-form__error" role="alert">{{ moderationErrors[post.id] }}</p>
|
|
<p v-if="reactionErrors[post.id]" class="life-form__error" role="alert">{{ reactionErrors[post.id] }}</p>
|
|
|
|
<section
|
|
v-if="areCommentsExpanded(post.id)"
|
|
:id="`life-comments-${post.id}`"
|
|
class="life-comments"
|
|
:aria-label="t('pages.life.comments')"
|
|
>
|
|
<div class="life-comments__header">
|
|
<h3>{{ t('pages.life.comments') }}</h3>
|
|
<span>{{ commentCount(post) }}</span>
|
|
</div>
|
|
|
|
<form v-if="canComment" class="life-comment-form" @submit.prevent="submitComment(post)">
|
|
<div class="field">
|
|
<label :for="`life-comment-${post.id}`">{{ t('pages.life.comment') }}</label>
|
|
<textarea
|
|
:id="`life-comment-${post.id}`"
|
|
v-model="commentBodies[post.id]"
|
|
:maxlength="commentMaxLength"
|
|
:placeholder="t('pages.life.commentPlaceholder')"
|
|
></textarea>
|
|
</div>
|
|
<p v-if="commentErrors[commentKey(post.id)]" class="life-form__error" role="alert">
|
|
{{ commentErrors[commentKey(post.id)] }}
|
|
</p>
|
|
<button
|
|
class="ui-button ui-button--ghost ui-button--small"
|
|
:disabled="isCommentBusy(commentKey(post.id)) || !(commentBodies[post.id] ?? '').trim()"
|
|
type="submit"
|
|
>
|
|
<Icon :icon="iconComment" class="ui-icon" aria-hidden="true" />
|
|
{{ isCommentBusy(commentKey(post.id)) ? t('pages.life.postingComment') : t('pages.life.postComment') }}
|
|
</button>
|
|
</form>
|
|
|
|
<div v-if="commentPage(post).loading && !commentsForPost(post).length" class="life-comment-list" :aria-label="t('pages.life.loadingComments')">
|
|
<article v-for="index in 2" :key="`life-comments-loading-${post.id}-${index}`" class="life-comment">
|
|
<div class="life-comment__main">
|
|
<Skeleton variant="box" width="36px" height="36px" />
|
|
<div class="life-comment__content">
|
|
<Skeleton width="132px" />
|
|
<Skeleton width="86%" />
|
|
</div>
|
|
</div>
|
|
</article>
|
|
</div>
|
|
|
|
<p v-else-if="commentPage(post).error" class="life-form__error" role="alert">{{ commentPage(post).error }}</p>
|
|
|
|
<div v-else-if="commentsForPost(post).length" class="life-comment-list">
|
|
<article
|
|
v-for="comment in commentsForPost(post)"
|
|
:key="comment.id"
|
|
class="life-comment"
|
|
:class="{ 'is-deleted': comment.deleted }"
|
|
>
|
|
<div class="life-comment__main">
|
|
<div class="life-comment__avatar" aria-hidden="true">{{ commentInitial(comment) }}</div>
|
|
<div class="life-comment__content">
|
|
<div class="life-comment__meta">
|
|
<RouterLink v-if="comment.author" class="user-profile-link" :to="`/profile/${comment.author.id}`">
|
|
{{ comment.author.displayName }}
|
|
</RouterLink>
|
|
<strong v-else>{{ commentAuthorName(comment) }}</strong>
|
|
<time :datetime="comment.createdAt">{{ formatPostTime(comment.createdAt) }}</time>
|
|
<StatusBadge
|
|
v-if="canSeeCommentModeration(comment)"
|
|
:label="moderationLabel(comment.moderationStatus)"
|
|
:tone="moderationTone(comment.moderationStatus)"
|
|
compact
|
|
/>
|
|
</div>
|
|
<p class="life-comment__body">{{ comment.body }}</p>
|
|
<p
|
|
v-if="canSeeCommentModeration(comment) && moderationReasonVisible(comment.moderationStatus, comment.moderationReason)"
|
|
class="life-moderation-detail life-moderation-detail--comment"
|
|
>
|
|
<strong>{{ t('pages.life.moderationReason') }}</strong>
|
|
<span>{{ comment.moderationReason }}</span>
|
|
</p>
|
|
|
|
<div v-if="!comment.deleted || canRestoreComment(comment)" class="life-comment__actions">
|
|
<button
|
|
v-if="!comment.deleted && canComment"
|
|
class="life-icon-button life-icon-button--flat"
|
|
type="button"
|
|
:aria-label="t('pages.life.reply')"
|
|
@click="startReply(comment)"
|
|
>
|
|
<Icon :icon="iconReply" class="ui-icon" aria-hidden="true" />
|
|
<span class="life-action-tooltip" role="tooltip">{{ t('pages.life.reply') }}</span>
|
|
</button>
|
|
<button
|
|
v-if="canManageComment(comment)"
|
|
class="life-icon-button life-icon-button--flat life-icon-button--danger"
|
|
type="button"
|
|
:aria-label="t('pages.life.deleteComment')"
|
|
@click="deleteComment(post, comment)"
|
|
>
|
|
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
|
|
<span class="life-action-tooltip" role="tooltip">{{ t('pages.life.deleteComment') }}</span>
|
|
</button>
|
|
<button
|
|
v-if="canRestoreComment(comment)"
|
|
class="life-icon-button life-icon-button--flat"
|
|
type="button"
|
|
:aria-label="t('pages.life.restoreComment')"
|
|
:disabled="isCommentBusy(replyKey(comment.id))"
|
|
@click="restoreComment(post, comment)"
|
|
>
|
|
<Icon :icon="iconUndo" class="ui-icon" aria-hidden="true" />
|
|
<span class="life-action-tooltip" role="tooltip">{{ t('pages.life.restoreComment') }}</span>
|
|
</button>
|
|
</div>
|
|
|
|
<p v-if="commentErrors[replyKey(comment.id)]" class="life-form__error" role="alert">
|
|
{{ commentErrors[replyKey(comment.id)] }}
|
|
</p>
|
|
|
|
<form
|
|
v-if="canComment && replyTargetId === comment.id"
|
|
class="life-comment-form life-comment-form--reply"
|
|
@submit.prevent="submitReply(post, comment)"
|
|
>
|
|
<div class="field">
|
|
<label :for="`life-reply-${comment.id}`">{{ t('pages.life.reply') }}</label>
|
|
<textarea
|
|
:id="`life-reply-${comment.id}`"
|
|
v-model="replyBodies[comment.id]"
|
|
:maxlength="commentMaxLength"
|
|
:placeholder="t('pages.life.commentReplyPlaceholder')"
|
|
></textarea>
|
|
</div>
|
|
<div class="life-form__actions">
|
|
<button
|
|
class="ui-button ui-button--ghost ui-button--small"
|
|
:disabled="isCommentBusy(replyKey(comment.id)) || !(replyBodies[comment.id] ?? '').trim()"
|
|
type="submit"
|
|
>
|
|
<Icon :icon="iconReply" class="ui-icon" aria-hidden="true" />
|
|
{{ isCommentBusy(replyKey(comment.id)) ? t('pages.life.postingReply') : t('pages.life.postReply') }}
|
|
</button>
|
|
<button class="ui-button ui-button--ghost ui-button--small" type="button" @click="cancelReply(comment.id)">
|
|
<Icon :icon="iconCancel" class="ui-icon" aria-hidden="true" />
|
|
{{ t('pages.life.cancelReply') }}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
|
|
<div v-if="comment.replies.length" class="life-comment-replies">
|
|
<article
|
|
v-for="reply in comment.replies"
|
|
:key="reply.id"
|
|
class="life-comment life-comment--reply"
|
|
:class="{ 'is-deleted': reply.deleted }"
|
|
>
|
|
<div class="life-comment__avatar" aria-hidden="true">{{ commentInitial(reply) }}</div>
|
|
<div class="life-comment__content">
|
|
<div class="life-comment__meta">
|
|
<RouterLink v-if="reply.author" class="user-profile-link" :to="`/profile/${reply.author.id}`">
|
|
{{ reply.author.displayName }}
|
|
</RouterLink>
|
|
<strong v-else>{{ commentAuthorName(reply) }}</strong>
|
|
<time :datetime="reply.createdAt">{{ formatPostTime(reply.createdAt) }}</time>
|
|
<StatusBadge
|
|
v-if="canSeeCommentModeration(reply)"
|
|
:label="moderationLabel(reply.moderationStatus)"
|
|
:tone="moderationTone(reply.moderationStatus)"
|
|
compact
|
|
/>
|
|
</div>
|
|
<p class="life-comment__body">{{ reply.body }}</p>
|
|
<p
|
|
v-if="canSeeCommentModeration(reply) && moderationReasonVisible(reply.moderationStatus, reply.moderationReason)"
|
|
class="life-moderation-detail life-moderation-detail--comment"
|
|
>
|
|
<strong>{{ t('pages.life.moderationReason') }}</strong>
|
|
<span>{{ reply.moderationReason }}</span>
|
|
</p>
|
|
<div v-if="canManageComment(reply) || canRestoreComment(reply)" class="life-comment__actions">
|
|
<button
|
|
v-if="canManageComment(reply)"
|
|
class="life-icon-button life-icon-button--flat life-icon-button--danger"
|
|
type="button"
|
|
:aria-label="t('pages.life.deleteComment')"
|
|
@click="deleteComment(post, reply)"
|
|
>
|
|
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
|
|
<span class="life-action-tooltip" role="tooltip">{{ t('pages.life.deleteComment') }}</span>
|
|
</button>
|
|
<button
|
|
v-if="canRestoreComment(reply)"
|
|
class="life-icon-button life-icon-button--flat"
|
|
type="button"
|
|
:aria-label="t('pages.life.restoreComment')"
|
|
:disabled="isCommentBusy(replyKey(reply.id))"
|
|
@click="restoreComment(post, reply)"
|
|
>
|
|
<Icon :icon="iconUndo" class="ui-icon" aria-hidden="true" />
|
|
<span class="life-action-tooltip" role="tooltip">{{ t('pages.life.restoreComment') }}</span>
|
|
</button>
|
|
</div>
|
|
<p v-if="commentErrors[replyKey(reply.id)]" class="life-form__error" role="alert">
|
|
{{ commentErrors[replyKey(reply.id)] }}
|
|
</p>
|
|
</div>
|
|
</article>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</article>
|
|
</div>
|
|
|
|
<p v-else class="life-comments__empty">{{ t('pages.life.noComments') }}</p>
|
|
|
|
<div v-if="commentPage(post).hasMore && !commentPage(post).loading" class="life-feed__retry">
|
|
<button
|
|
class="ui-button ui-button--ghost ui-button--small"
|
|
type="button"
|
|
:disabled="commentPage(post).loadingMore"
|
|
@click="loadComments(post)"
|
|
>
|
|
<Icon :icon="iconChevronDown" class="ui-icon" aria-hidden="true" />
|
|
{{ commentPage(post).loadingMore ? t('common.loading') : t('pages.life.loadMoreComments') }}
|
|
</button>
|
|
</div>
|
|
</section>
|
|
</article>
|
|
|
|
<article v-for="index in loadingMore ? loadingMoreSkeletonCount : 0" :key="`life-more-${index}`" class="life-post life-post--skeleton">
|
|
<div class="life-post__header">
|
|
<Skeleton variant="box" width="46px" height="46px" />
|
|
<div class="life-post__byline">
|
|
<Skeleton width="138px" />
|
|
<Skeleton width="96px" />
|
|
</div>
|
|
</div>
|
|
<Skeleton width="94%" />
|
|
<Skeleton width="76%" />
|
|
<Skeleton width="52%" />
|
|
</article>
|
|
|
|
<div v-if="hasMorePosts" ref="loadMoreSentinel" class="life-feed__sentinel" aria-hidden="true"></div>
|
|
<div v-if="loadMorePaused && hasMorePosts" class="life-feed__retry">
|
|
<button class="ui-button ui-button--ghost ui-button--small" type="button" @click="retryLoadMore">
|
|
<Icon :icon="iconWarning" class="ui-icon" aria-hidden="true" />
|
|
{{ t('pages.life.retryFeed') }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-else class="life-empty">
|
|
<Icon :icon="searchQuery ? iconSearch : iconLife" class="life-empty__icon" aria-hidden="true" />
|
|
<div class="life-empty__copy">
|
|
<h2>{{ searchQuery ? t('pages.life.searchEmpty') : t('pages.life.empty') }}</h2>
|
|
<p>{{ searchQuery ? t('pages.life.searchEmptyHint') : t('pages.life.emptyHint') }}</p>
|
|
</div>
|
|
<button v-if="searchQuery" class="ui-button ui-button--ghost" type="button" @click="clearSearch">
|
|
<Icon :icon="iconCancel" class="ui-icon" aria-hidden="true" />
|
|
{{ t('pages.life.clearSearch') }}
|
|
</button>
|
|
<button v-else class="ui-button ui-button--primary" :disabled="!authReady" type="button" @click="openCreatePostModal">
|
|
<Icon :icon="iconAdd" class="ui-icon" aria-hidden="true" />
|
|
{{ t('pages.life.newPost') }}
|
|
</button>
|
|
</div>
|
|
</section>
|
|
</section>
|
|
</template>
|