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()
2110 lines
79 KiB
Vue
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>
|