Files
pokopiawiki.tootaio.com/frontend/src/views/LifeView.vue
xiaomai fa656a8d02 refactor(auth): migrate fully to HTTP-only cookie sessions
Remove client-side token storage and Authorization header injection
Backend login now only returns user data, omitting the session token
Remove Authorization from backend CORS allowed headers
Clean up obsolete VITE_* environment variable fallbacks
Update Modal component to use Vue useId() instead of Math.random()
2026-05-06 17:15:46 +08:00

2110 lines
79 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 LoadMoreSentinel from '../components/LoadMoreSentinel.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,
moderationUpdateEvent,
onAuthChange,
type AiModerationStatus,
type AuthUser,
type CommentSort,
type GameVersion,
type Language,
type LifeCategory,
type LifeComment,
type LifePost,
type LifePostsPage,
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';
type LifeFeedScope = 'all' | 'following';
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 activeFeedScope = ref<LifeFeedScope>('all');
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 commentSorts = ref<Record<number, CommentSort>>({});
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';
type LifeInitialData = {
options: { lifeCategories: LifeCategory[]; gameVersions: GameVersion[] } | null;
languages: Language[] | null;
posts: LifePostsPage | null;
};
const { data: initialData } = await useAsyncData<LifeInitialData>(
`life-feed-initial:${locale.value}`,
async () => {
const [optionsResult, languagesResult, postsResult] = await Promise.allSettled([
api.options(),
api.languages(),
api.lifePosts({
limit: lifePostPageSize,
sort: 'latest'
})
]);
return {
options:
optionsResult.status === 'fulfilled'
? { lifeCategories: optionsResult.value.lifeCategories, gameVersions: optionsResult.value.gameVersions }
: null,
languages: languagesResult.status === 'fulfilled' ? languagesResult.value.filter((language) => language.enabled) : null,
posts: postsResult.status === 'fulfilled' ? postsResult.value : null
};
},
{ default: () => ({ options: null, languages: null, posts: null }) }
);
lifeCategories.value = initialData.value.options?.lifeCategories ?? [];
gameVersions.value = initialData.value.options?.gameVersions ?? [];
languages.value = initialData.value.languages ?? [];
posts.value = initialData.value.posts?.items ?? [];
nextCursor.value = initialData.value.posts?.nextCursor ?? null;
hasMorePosts.value = initialData.value.posts?.hasMore ?? false;
const initialOptionsLoaded = ref(initialData.value.options !== null);
const initialLanguagesLoaded = ref(initialData.value.languages !== null);
const initialPostsLoaded = ref(initialData.value.posts !== null);
loading.value = !initialPostsLoaded.value;
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 canLikeComments = computed(() => can('life.comments.like'));
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 commentSortOptions = computed<Array<{ value: CommentSort; label: string }>>(() => [
{ value: 'oldest', label: t('pages.life.sortOldest') },
{ value: 'latest', label: t('pages.life.sortLatest') },
{ value: 'most-liked', label: t('pages.life.sortMostLiked') },
{ value: 'most-replied', label: t('pages.life.sortMostReplied') }
]);
const feedScopeOptions = computed<TabOption[]>(() => [
{ value: 'all', label: t('pages.life.allFeed') },
{ value: 'following', label: t('pages.life.followingFeed') }
]);
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;
try {
const response = await api.me();
currentUser.value = response.user;
} catch {
currentUser.value = null;
activeFeedScope.value = 'all';
} 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 params = {
limit: lifePostPageSize,
search: searchQuery.value,
categoryId: selectedFeedCategoryId.value,
language: selectedFeedLanguageCode.value,
gameVersionId: selectedFeedGameVersionId.value,
rateable: selectedRateableFilter.value,
sort: activeSort.value
};
const page = activeFeedScope.value === 'following' ? await api.followingLifePosts(params) : await api.lifePosts(params);
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 params = {
cursor,
limit: lifePostPageSize,
search: searchQuery.value,
categoryId: selectedFeedCategoryId.value,
language: selectedFeedLanguageCode.value,
gameVersionId: selectedFeedGameVersionId.value,
rateable: selectedRateableFilter.value,
sort: activeSort.value
};
const page = activeFeedScope.value === 'following' ? await api.followingLifePosts(params) : await api.lifePosts(params);
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' || activeFeedScope.value === 'following') {
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 likeKey(commentId: number) {
return `like-${commentId}`;
}
function commentSort(postId: number): CommentSort {
return commentSorts.value[postId] ?? 'oldest';
}
function setCommentSort(post: LifePost, sort: CommentSort) {
commentSorts.value = {
...commentSorts.value,
[post.id]: sort
};
void loadComments(post, true);
}
function handleCommentSortChange(post: LifePost, event: Event) {
if (event.target instanceof HTMLSelectElement) {
setCommentSort(post, event.target.value as CommentSort);
}
}
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,
sort: commentSort(post.id)
});
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 canLikeComment(comment: LifeComment) {
return canLikeComments.value && !comment.deleted && comment.moderationStatus === 'approved';
}
function canRetryCommentModeration(comment: LifeComment) {
return (
!comment.deleted &&
comment.moderationStatus !== 'approved' &&
comment.moderationStatus !== 'reviewing' &&
(currentUser.value?.id === comment.author?.id || can('life.comments.delete-any'))
);
}
function commentLikeLabel(comment: LifeComment) {
return comment.myLiked ? t('pages.life.unlikeComment') : t('pages.life.likeComment');
}
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);
if (commentSort(post.id) !== 'oldest') {
void loadComments(post, 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);
comment.replyCount += 1;
updateCommentPage(post, (page) => ({
...page,
total: nextTotal
}));
setCommentsExpanded(post.id, true);
cancelReply(comment.id);
if (commentSort(post.id) === 'most-replied') {
void loadComments(post, true);
}
} 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 = '';
}
}
async function retryCommentModeration(post: LifePost, comment: LifeComment) {
const key = replyKey(comment.id);
commentBusyKey.value = key;
clearCommentError(key);
try {
const updated = await api.retryLifeCommentModeration(comment.id);
replaceCommentInTree(commentsForPost(post), updated);
updateCommentPage(post, (page) => ({
...page,
items: [...page.items]
}));
} catch (error) {
setCommentError(key, error instanceof Error && error.message ? error.message : t('pages.life.moderationRetryFailed'));
} finally {
commentBusyKey.value = '';
}
}
async function toggleCommentLike(post: LifePost, comment: LifeComment) {
if (!canLikeComment(comment)) {
return;
}
const key = likeKey(comment.id);
commentBusyKey.value = key;
clearCommentError(key);
try {
const updated = comment.myLiked ? await api.deleteLifeCommentLike(comment.id) : await api.setLifeCommentLike(comment.id);
replaceCommentInTree(commentsForPost(post), updated);
updateCommentPage(post, (page) => ({
...page,
items: [...page.items]
}));
if (commentSort(post.id) === 'most-liked') {
void loadComments(post, true);
}
} catch (error) {
setCommentError(key, error instanceof Error && error.message ? error.message : t('pages.life.commentLikeFailed'));
} 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(activeFeedScope, () => {
void loadPosts();
});
watch(locale, () => {
void loadLanguages();
void loadLifeCategories();
void loadPosts();
});
onMounted(() => {
document.addEventListener('click', closeReactionPickerFromDocument);
document.addEventListener('keydown', closeReactionPickerFromKeyboard);
window.addEventListener(moderationUpdateEvent, handleModerationUpdate);
void (async () => {
await loadCurrentUser();
if (!initialLanguagesLoaded.value) {
await loadLanguages();
initialLanguagesLoaded.value = true;
}
if (!initialOptionsLoaded.value) {
await loadLifeCategories();
initialOptionsLoaded.value = true;
}
if (!initialPostsLoaded.value || currentUser.value) {
await loadPosts();
initialPostsLoaded.value = true;
}
})();
removeAuthListener = onAuthChange(() => {
void (async () => {
await loadCurrentUser();
await 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
v-if="currentUser"
id="life-feed-scope"
v-model="activeFeedScope"
:tabs="feedScopeOptions"
:label="t('pages.life.feedScope')"
/>
<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">
<div>
<h3>{{ t('pages.life.comments') }}</h3>
<span>{{ commentCount(post) }}</span>
</div>
<label class="life-comments__sort">
<span>{{ t('pages.life.sort') }}</span>
<select :value="commentSort(post.id)" @change="handleCommentSortChange(post, $event)">
<option v-for="option in commentSortOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</label>
</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"
class="life-icon-button life-icon-button--flat"
type="button"
:aria-label="commentLikeLabel(comment)"
:aria-pressed="comment.myLiked"
:disabled="!canLikeComment(comment) || isCommentBusy(likeKey(comment.id))"
@click="toggleCommentLike(post, comment)"
>
<Icon :icon="iconReactionLike" class="ui-icon" aria-hidden="true" />
<span class="life-comment__action-count">{{ comment.likeCount }}</span>
<span class="life-action-tooltip" role="tooltip">{{ t('pages.life.commentLikeCount', { count: comment.likeCount }) }}</span>
</button>
<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>
<button
v-if="canRetryCommentModeration(comment)"
class="life-icon-button life-icon-button--flat"
type="button"
:aria-label="isCommentBusy(replyKey(comment.id)) ? t('pages.life.moderationRetrying') : t('pages.life.moderationRetry')"
:disabled="isCommentBusy(replyKey(comment.id))"
@click="retryCommentModeration(post, comment)"
>
<Icon :icon="iconWarning" class="ui-icon" aria-hidden="true" />
<span class="life-action-tooltip" role="tooltip">
{{ isCommentBusy(replyKey(comment.id)) ? t('pages.life.moderationRetrying') : t('pages.life.moderationRetry') }}
</span>
</button>
</div>
<p v-if="commentErrors[likeKey(comment.id)]" class="life-form__error" role="alert">
{{ commentErrors[likeKey(comment.id)] }}
</p>
<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="!reply.deleted || canRestoreComment(reply)" class="life-comment__actions">
<button
v-if="!reply.deleted"
class="life-icon-button life-icon-button--flat"
type="button"
:aria-label="commentLikeLabel(reply)"
:aria-pressed="reply.myLiked"
:disabled="!canLikeComment(reply) || isCommentBusy(likeKey(reply.id))"
@click="toggleCommentLike(post, reply)"
>
<Icon :icon="iconReactionLike" class="ui-icon" aria-hidden="true" />
<span class="life-comment__action-count">{{ reply.likeCount }}</span>
<span class="life-action-tooltip" role="tooltip">{{ t('pages.life.commentLikeCount', { count: reply.likeCount }) }}</span>
</button>
<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>
<button
v-if="canRetryCommentModeration(reply)"
class="life-icon-button life-icon-button--flat"
type="button"
:aria-label="isCommentBusy(replyKey(reply.id)) ? t('pages.life.moderationRetrying') : t('pages.life.moderationRetry')"
:disabled="isCommentBusy(replyKey(reply.id))"
@click="retryCommentModeration(post, reply)"
>
<Icon :icon="iconWarning" class="ui-icon" aria-hidden="true" />
<span class="life-action-tooltip" role="tooltip">
{{ isCommentBusy(replyKey(reply.id)) ? t('pages.life.moderationRetrying') : t('pages.life.moderationRetry') }}
</span>
</button>
</div>
<p v-if="commentErrors[likeKey(reply.id)]" class="life-form__error" role="alert">
{{ commentErrors[likeKey(reply.id)] }}
</p>
<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>
<LoadMoreSentinel
:active="commentPage(post).hasMore && !commentPage(post).loading"
:disabled="commentPage(post).loadingMore"
@load="loadComments(post)"
/>
</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>