Move global navigation to a responsive sidebar drawer in AppShell Replace sidebars in detail pages and Life feed with Tab components Add mobile topbar with hamburger menu for navigation
1132 lines
41 KiB
Vue
1132 lines
41 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 Modal from '../components/Modal.vue';
|
|
import PageHeader from '../components/PageHeader.vue';
|
|
import Skeleton from '../components/Skeleton.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,
|
|
iconLife,
|
|
iconReactionFun,
|
|
iconReactionHelpful,
|
|
iconReactionLike,
|
|
iconReactionThanks,
|
|
iconReply,
|
|
iconSave,
|
|
iconSearch,
|
|
iconWarning
|
|
} from '../icons';
|
|
import {
|
|
api,
|
|
getAuthToken,
|
|
onAuthTokenChange,
|
|
setAuthToken,
|
|
type AuthUser,
|
|
type LifeComment,
|
|
type LifePost,
|
|
type LifeReactionType,
|
|
type NamedEntity
|
|
} from '../services/api';
|
|
|
|
const { locale, t } = useI18n();
|
|
const posts = ref<LifePost[]>([]);
|
|
const lifeTags = ref<NamedEntity[]>([]);
|
|
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 activeTagId = ref('all');
|
|
const body = ref('');
|
|
const selectedTagIds = ref<string[]>([]);
|
|
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 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 bodyInput = ref<HTMLTextAreaElement | null>(null);
|
|
const loadMoreSentinel = ref<HTMLElement | null>(null);
|
|
const lifePostPageSize = 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 allTagValue = 'all';
|
|
|
|
const reactionOptions = [
|
|
{ type: 'like', icon: iconReactionLike, labelKey: 'pages.life.reactionLike' },
|
|
{ type: 'helpful', icon: iconReactionHelpful, labelKey: 'pages.life.reactionHelpful' },
|
|
{ type: 'fun', icon: iconReactionFun, labelKey: 'pages.life.reactionFun' },
|
|
{ type: 'thanks', icon: iconReactionThanks, labelKey: 'pages.life.reactionThanks' }
|
|
] as const satisfies ReadonlyArray<{ type: LifeReactionType; icon: string; labelKey: string }>;
|
|
|
|
const canPost = computed(() => currentUser.value?.emailVerified === true);
|
|
const charactersLeft = computed(() => Math.max(0, bodyMaxLength - body.value.length));
|
|
const isEditing = computed(() => editingPostId.value !== null);
|
|
const searchQuery = computed(() => submittedSearch.value.trim());
|
|
const selectedFeedTagId = computed(() => {
|
|
const tagId = Number(activeTagId.value);
|
|
return activeTagId.value === allTagValue || !Number.isInteger(tagId) || tagId <= 0 ? undefined : tagId;
|
|
});
|
|
const tagFilterOptions = computed<TabOption[]>(() => [
|
|
{ value: allTagValue, label: t('pages.life.allTags') },
|
|
...lifeTags.value.map((tag) => ({ value: String(tag.id), label: tag.name }))
|
|
]);
|
|
const postModalTitle = computed(() => (isEditing.value ? t('pages.life.editPost') : t('pages.life.newPost')));
|
|
const submitLabel = computed(() => {
|
|
if (busy.value) return isEditing.value ? t('pages.life.updating') : t('pages.life.publishing');
|
|
return isEditing.value ? t('pages.life.update') : t('pages.life.publish');
|
|
});
|
|
|
|
async function loadCurrentUser() {
|
|
authReady.value = false;
|
|
|
|
if (!getAuthToken()) {
|
|
currentUser.value = null;
|
|
authReady.value = true;
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await api.me();
|
|
currentUser.value = response.user;
|
|
} catch {
|
|
currentUser.value = null;
|
|
setAuthToken(null);
|
|
} finally {
|
|
authReady.value = true;
|
|
}
|
|
}
|
|
|
|
async function loadLifeTags() {
|
|
try {
|
|
const options = await api.options();
|
|
lifeTags.value = options.lifeTags;
|
|
|
|
if (activeTagId.value !== allTagValue && !lifeTags.value.some((tag) => String(tag.id) === activeTagId.value)) {
|
|
activeTagId.value = allTagValue;
|
|
}
|
|
} catch (error) {
|
|
loadError.value = error instanceof Error && error.message ? error.message : t('errors.loadFailed');
|
|
}
|
|
}
|
|
|
|
async function loadPosts() {
|
|
const requestId = ++postsRequestId;
|
|
loading.value = true;
|
|
loadingMore.value = false;
|
|
loadError.value = '';
|
|
nextCursor.value = null;
|
|
hasMorePosts.value = false;
|
|
loadMorePaused.value = false;
|
|
|
|
try {
|
|
const page = await api.lifePosts({ limit: lifePostPageSize, search: searchQuery.value, tagId: selectedFeedTagId.value });
|
|
if (requestId !== postsRequestId) {
|
|
return;
|
|
}
|
|
posts.value = page.items;
|
|
nextCursor.value = page.nextCursor;
|
|
hasMorePosts.value = page.hasMore;
|
|
} catch (error) {
|
|
if (requestId === postsRequestId) {
|
|
loadError.value = error instanceof Error && error.message ? error.message : t('errors.loadFailed');
|
|
}
|
|
} finally {
|
|
if (requestId === postsRequestId) {
|
|
loading.value = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
async function loadMorePosts() {
|
|
if (loading.value || loadingMore.value || loadMorePaused.value || !hasMorePosts.value) {
|
|
return;
|
|
}
|
|
|
|
const cursor = nextCursor.value;
|
|
if (!cursor) {
|
|
hasMorePosts.value = false;
|
|
return;
|
|
}
|
|
|
|
const requestId = postsRequestId;
|
|
loadingMore.value = true;
|
|
loadError.value = '';
|
|
|
|
try {
|
|
const page = await api.lifePosts({ cursor, limit: lifePostPageSize, search: searchQuery.value, tagId: selectedFeedTagId.value });
|
|
if (requestId !== postsRequestId) {
|
|
return;
|
|
}
|
|
|
|
const existingIds = new Set(posts.value.map((post) => post.id));
|
|
posts.value = [...posts.value, ...page.items.filter((post) => !existingIds.has(post.id))];
|
|
nextCursor.value = page.nextCursor;
|
|
hasMorePosts.value = page.hasMore;
|
|
} catch (error) {
|
|
if (requestId === postsRequestId) {
|
|
loadError.value = error instanceof Error && error.message ? error.message : t('errors.loadFailed');
|
|
loadMorePaused.value = true;
|
|
}
|
|
} finally {
|
|
if (requestId === postsRequestId) {
|
|
loadingMore.value = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
function resetForm() {
|
|
body.value = '';
|
|
selectedTagIds.value = [];
|
|
editingPostId.value = null;
|
|
formError.value = '';
|
|
}
|
|
|
|
function payload() {
|
|
return {
|
|
body: body.value.trim(),
|
|
tagIds: selectedTagIds.value.map((tagId) => Number(tagId)).filter((tagId) => Number.isInteger(tagId) && tagId > 0)
|
|
};
|
|
}
|
|
|
|
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 tagId = selectedFeedTagId.value;
|
|
const matchesSearch = keyword === '' || post.body.toLowerCase().includes(keyword);
|
|
const matchesTag = tagId === undefined || post.tags.some((tag) => tag.id === tagId);
|
|
return matchesSearch && matchesTag;
|
|
}
|
|
|
|
function openCreatePostModal() {
|
|
resetForm();
|
|
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;
|
|
}
|
|
|
|
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 (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;
|
|
}
|
|
|
|
function canManageComment(comment: LifeComment) {
|
|
return !comment.deleted && currentUser.value?.id === comment.author?.id;
|
|
}
|
|
|
|
function commentKey(postId: number) {
|
|
return `post-${postId}`;
|
|
}
|
|
|
|
function replyKey(commentId: number) {
|
|
return `reply-${commentId}`;
|
|
}
|
|
|
|
function commentCount(post: LifePost) {
|
|
return post.comments.reduce((count, comment) => count + 1 + comment.replies.length, 0);
|
|
}
|
|
|
|
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 replacePost(updatedPost: LifePost) {
|
|
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));
|
|
}
|
|
|
|
function areCommentsExpanded(postId: number) {
|
|
return expandedComments.value[postId] === true;
|
|
}
|
|
|
|
function setCommentsExpanded(postId: number, expanded: boolean) {
|
|
expandedComments.value = {
|
|
...expandedComments.value,
|
|
[postId]: expanded
|
|
};
|
|
}
|
|
|
|
function toggleComments(postId: number) {
|
|
setCommentsExpanded(postId, !areCommentsExpanded(postId));
|
|
}
|
|
|
|
function isCommentBusy(key: string) {
|
|
return commentBusyKey.value === key;
|
|
}
|
|
|
|
function isReactionBusy(postId: number) {
|
|
return reactionBusyPostId.value === postId;
|
|
}
|
|
|
|
function commentAuthorName(comment: LifeComment) {
|
|
return comment.deleted ? t('pages.life.commentDeleted') : 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 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 canUseReactions() {
|
|
return canPost.value && reactionBusyPostId.value === null;
|
|
}
|
|
|
|
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()) {
|
|
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;
|
|
}
|
|
}
|
|
|
|
function startEdit(post: LifePost) {
|
|
editingPostId.value = post.id;
|
|
body.value = post.body;
|
|
selectedTagIds.value = post.tags.map((tag) => String(tag.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 });
|
|
post.comments.push(comment);
|
|
commentBodies.value[post.id] = '';
|
|
setCommentsExpanded(post.id, true);
|
|
} catch (error) {
|
|
setCommentError(key, error instanceof Error && error.message ? error.message : t('pages.life.commentFailed'));
|
|
} finally {
|
|
commentBusyKey.value = '';
|
|
}
|
|
}
|
|
|
|
async function submitReply(post: LifePost, comment: LifeComment) {
|
|
const key = replyKey(comment.id);
|
|
const nextBody = (replyBodies.value[comment.id] ?? '').trim();
|
|
if (!nextBody) {
|
|
setCommentError(key, t('pages.life.commentRequired'));
|
|
return;
|
|
}
|
|
|
|
commentBusyKey.value = key;
|
|
clearCommentError(key);
|
|
|
|
try {
|
|
const reply = await api.createLifeCommentReply(post.id, comment.id, { body: nextBody });
|
|
comment.replies.push(reply);
|
|
setCommentsExpanded(post.id, true);
|
|
cancelReply(comment.id);
|
|
} catch (error) {
|
|
setCommentError(key, error instanceof Error && error.message ? error.message : t('pages.life.replyFailed'));
|
|
} finally {
|
|
commentBusyKey.value = '';
|
|
}
|
|
}
|
|
|
|
function markCommentDeleted(comments: LifeComment[], id: number): boolean {
|
|
for (const comment of comments) {
|
|
if (comment.id === id) {
|
|
comment.deleted = true;
|
|
comment.body = '';
|
|
comment.author = null;
|
|
return true;
|
|
}
|
|
|
|
if (markCommentDeleted(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);
|
|
markCommentDeleted(post.comments, comment.id);
|
|
if (replyTargetId.value === comment.id) {
|
|
cancelReply(comment.id);
|
|
}
|
|
} catch (error) {
|
|
setCommentError(key, error instanceof Error && error.message ? error.message : t('pages.life.deleteCommentFailed'));
|
|
}
|
|
}
|
|
|
|
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(activeTagId, () => {
|
|
void loadPosts();
|
|
});
|
|
watch(locale, () => {
|
|
void loadLifeTags();
|
|
void loadPosts();
|
|
});
|
|
|
|
onMounted(() => {
|
|
document.addEventListener('click', closeReactionPickerFromDocument);
|
|
document.addEventListener('keydown', closeReactionPickerFromKeyboard);
|
|
void loadCurrentUser();
|
|
void loadLifeTags();
|
|
void loadPosts();
|
|
removeAuthListener = onAuthTokenChange(() => {
|
|
void loadCurrentUser();
|
|
void loadPosts();
|
|
});
|
|
});
|
|
|
|
onUnmounted(() => {
|
|
document.removeEventListener('click', closeReactionPickerFromDocument);
|
|
document.removeEventListener('keydown', closeReactionPickerFromKeyboard);
|
|
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__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-tags">{{ t('pages.life.tags') }}</label>
|
|
<TagsSelect
|
|
id="life-post-tags"
|
|
v-model="selectedTagIds"
|
|
:options="lifeTags"
|
|
:placeholder="t('pages.life.tagPlaceholder')"
|
|
:search-placeholder="t('pages.life.searchTags')"
|
|
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>
|
|
|
|
<Tabs id="life-tag-filter" v-model="activeTagId" :tabs="tagFilterOptions" :label="t('pages.life.tags')" />
|
|
|
|
<section class="life-feed" :aria-busy="loading || loadingMore" :aria-label="t('pages.life.kicker')">
|
|
<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">
|
|
<strong>{{ post.author?.displayName ?? 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 v-if="canManage(post)" class="life-post__actions">
|
|
<button 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
|
|
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.tags.length" class="life-post__tags" :aria-label="t('pages.life.tags')">
|
|
<span v-for="tag in post.tags" :key="tag.id" class="life-post__tag">{{ tag.name }}</span>
|
|
</div>
|
|
|
|
<div class="life-post__engagement">
|
|
<div class="life-post__engagement-actions">
|
|
<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="!canPost || 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="!canPost || 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 && canPost"
|
|
: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="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')"
|
|
@click="toggleComments(post.id)"
|
|
>
|
|
<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>
|
|
|
|
<div class="life-post__metrics">
|
|
<div
|
|
v-if="reactionTotal(post) > 0"
|
|
class="life-reaction-summary"
|
|
:aria-label="t('pages.life.reactionsCount', { count: reactionTotal(post) })"
|
|
>
|
|
<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>
|
|
</div>
|
|
|
|
<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) })"
|
|
@click="toggleComments(post.id)"
|
|
>
|
|
<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="reactionErrors[post.id]" class="life-form__error" role="alert">{{ reactionErrors[post.id] }}</p>
|
|
|
|
<section
|
|
v-if="areCommentsExpanded(post.id)"
|
|
:id="`life-comments-${post.id}`"
|
|
class="life-comments"
|
|
:aria-label="t('pages.life.comments')"
|
|
>
|
|
<div class="life-comments__header">
|
|
<h3>{{ t('pages.life.comments') }}</h3>
|
|
<span>{{ commentCount(post) }}</span>
|
|
</div>
|
|
|
|
<form v-if="canPost" 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="post.comments.length" class="life-comment-list">
|
|
<article
|
|
v-for="comment in post.comments"
|
|
: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">
|
|
<strong>{{ commentAuthorName(comment) }}</strong>
|
|
<time :datetime="comment.createdAt">{{ formatPostTime(comment.createdAt) }}</time>
|
|
</div>
|
|
<p v-if="!comment.deleted" class="life-comment__body">{{ comment.body }}</p>
|
|
|
|
<div v-if="!comment.deleted" class="life-comment__actions">
|
|
<button
|
|
v-if="canPost"
|
|
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>
|
|
</div>
|
|
|
|
<p v-if="commentErrors[replyKey(comment.id)]" class="life-form__error" role="alert">
|
|
{{ commentErrors[replyKey(comment.id)] }}
|
|
</p>
|
|
|
|
<form
|
|
v-if="canPost && 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">
|
|
<strong>{{ commentAuthorName(reply) }}</strong>
|
|
<time :datetime="reply.createdAt">{{ formatPostTime(reply.createdAt) }}</time>
|
|
</div>
|
|
<p v-if="!reply.deleted" class="life-comment__body">{{ reply.body }}</p>
|
|
<div v-if="canManageComment(reply)" class="life-comment__actions">
|
|
<button
|
|
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>
|
|
</div>
|
|
<p v-if="commentErrors[replyKey(reply.id)]" class="life-form__error" role="alert">
|
|
{{ commentErrors[replyKey(reply.id)] }}
|
|
</p>
|
|
</div>
|
|
</article>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</article>
|
|
</div>
|
|
|
|
<p v-else class="life-comments__empty">{{ t('pages.life.noComments') }}</p>
|
|
</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>
|