Files
social-media/frontend/src/features/content/components/ContentCommentFeed.vue
Jonathan Bourdon 1ca6ab7117
All checks were successful
deploy-socialize / image (push) Successful in 50s
deploy-socialize / deploy (push) Successful in 19s
feat: centralize frontend Vuetify styling
2026-05-08 13:45:42 -04:00

388 lines
12 KiB
Vue

<script setup>
import { computed, ref } from 'vue';
import {
mdiCheckCircleOutline,
mdiDeleteOutline,
mdiDotsVertical,
mdiEmoticonPlusOutline,
mdiPencilOutline,
mdiReplyOutline,
} from '@mdi/js';
import AppAvatar from '@/components/AppAvatar.vue';
import ContentCommentComposer from '@/features/content/components/ContentCommentComposer.vue';
const props = defineProps({
comments: {
type: Array,
default: () => [],
},
members: {
type: Array,
default: () => [],
},
isPosting: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['submit-comment']);
const activeReplyCommentId = ref(null);
const commentThreads = computed(() => {
const repliesByParentId = new Map();
const roots = [];
for (const comment of props.comments) {
if (comment.parentCommentId) {
const existing = repliesByParentId.get(comment.parentCommentId) ?? [];
existing.push(comment);
repliesByParentId.set(comment.parentCommentId, existing);
} else {
roots.push(comment);
}
}
return roots.map(comment => ({
comment,
replies: repliesByParentId.get(comment.id) ?? [],
}));
});
function formatDateTime(value) {
return value ? new Date(value).toLocaleString() : '';
}
function hasImageAttachment(comment) {
return Boolean(comment.attachmentBlobUrl && comment.attachmentContentType?.startsWith('image/'));
}
function submitReply(payload) {
emit('submit-comment', payload);
activeReplyCommentId.value = null;
}
</script>
<template>
<div class="timeline-list">
<article
v-for="thread in commentThreads"
:key="thread.comment.id"
class="comment-row"
tabindex="0"
>
<div class="comment-row-header">
<div class="comment-author">
<AppAvatar
:name="thread.comment.authorDisplayName"
:email="thread.comment.authorEmail"
:src="thread.comment.authorPortraitUrl"
size="sm"
/>
<div class="comment-author-meta">
<strong>{{ thread.comment.authorDisplayName }}</strong>
<small>{{ formatDateTime(thread.comment.createdAt) }}</small>
</div>
</div>
<div class="comment-actions">
<v-btn variant="text" :ripple="false"
class="comment-action-button"
type="button"
title="Add reaction"
>
<v-icon :icon="mdiEmoticonPlusOutline" />
</v-btn>
<v-btn variant="text" :ripple="false"
class="comment-action-button"
type="button"
title="Resolve"
>
<v-icon :icon="mdiCheckCircleOutline" />
</v-btn>
<v-btn variant="text" :ripple="false"
class="comment-action-button"
type="button"
title="Reply"
@click="activeReplyCommentId = thread.comment.id"
>
<v-icon :icon="mdiReplyOutline" />
</v-btn>
<details class="comment-more-menu">
<summary
class="comment-action-button"
title="More comment actions"
>
<v-icon :icon="mdiDotsVertical" />
</summary>
<div class="comment-action-menu">
<v-btn variant="text" :ripple="false"
class="comment-menu-item"
type="button"
>
<v-icon :icon="mdiPencilOutline" />
Edit
</v-btn>
<v-btn variant="text" :ripple="false"
class="comment-menu-item danger"
type="button"
>
<v-icon :icon="mdiDeleteOutline" />
Delete
</v-btn>
</div>
</details>
</div>
</div>
<p
v-if="thread.comment.body"
class="comment-body"
>
{{ thread.comment.body }}
</p>
<a
v-if="hasImageAttachment(thread.comment)"
class="comment-attachment"
:href="thread.comment.attachmentBlobUrl"
target="_blank"
rel="noreferrer"
>
<img
:src="thread.comment.attachmentBlobUrl"
:alt="thread.comment.attachmentFileName || 'Comment attachment'"
/>
</a>
<div
v-if="thread.replies.length"
class="reply-list"
>
<article
v-for="reply in thread.replies"
:key="reply.id"
class="reply-row"
>
<AppAvatar
:name="reply.authorDisplayName"
:email="reply.authorEmail"
:src="reply.authorPortraitUrl"
size="sm"
/>
<div>
<div class="reply-meta">
<strong>{{ reply.authorDisplayName }}</strong>
<small>{{ formatDateTime(reply.createdAt) }}</small>
</div>
<p v-if="reply.body">{{ reply.body }}</p>
<a
v-if="hasImageAttachment(reply)"
class="comment-attachment reply-attachment"
:href="reply.attachmentBlobUrl"
target="_blank"
rel="noreferrer"
>
<img
:src="reply.attachmentBlobUrl"
:alt="reply.attachmentFileName || 'Reply attachment'"
/>
</a>
</div>
</article>
</div>
<ContentCommentComposer
v-if="activeReplyCommentId === thread.comment.id"
variant="reply"
:members="members"
:is-posting="isPosting"
:reply-target="thread.comment"
@submit-comment="submitReply"
@cancel-reply="activeReplyCommentId = null"
/>
</article>
<div
v-if="!commentThreads.length"
class="empty-note"
>
No comments yet.
</div>
</div>
</template>
<style scoped>
@reference "@/assets/main.css";
.timeline-list {
@apply flex flex-col gap-4;
}
.empty-note {
@apply text-sm leading-6;
color: var(--app-text-muted);
}
.comment-row {
@apply relative flex flex-col gap-3 rounded-[1rem] border p-4 transition;
background: #f8fafc;
border-color: var(--app-border-subtle);
outline: none;
}
.comment-row:hover,
.comment-row:focus-within,
.comment-row:focus {
background: var(--app-color-surface);
border-color: rgba(15, 118, 110, 0.24);
box-shadow: 0 16px 34px var(--app-border-subtle);
}
.comment-row-header {
@apply flex min-h-9 w-full items-center;
}
.comment-author {
@apply flex w-full min-w-0 items-center gap-3;
}
.comment-author-meta {
@apply flex w-full min-w-0 flex-col;
}
.comment-author strong {
@apply truncate text-sm;
color: var(--app-color-on-surface);
}
.comment-author small {
@apply text-xs leading-5;
color: #7c8798;
}
.comment-actions {
@apply absolute right-3 top-3 z-20 flex items-center gap-1 rounded-full border px-1 py-1 opacity-0 shadow-lg transition;
background: rgba(255, 255, 255, 0.92);
border-color: var(--app-border-subtle);
pointer-events: none;
backdrop-filter: blur(10px);
}
.comment-row:hover .comment-actions,
.comment-row:focus-within .comment-actions,
.comment-row:focus .comment-actions {
opacity: 1;
pointer-events: auto;
}
.comment-action-button {
@apply inline-flex h-8 w-8 items-center justify-center rounded-full transition;
color: var(--app-text-muted);
}
.comment-action-button:hover,
.comment-action-button:focus-visible {
background: rgba(15, 118, 110, 0.12);
color: var(--app-color-on-tertiary);
}
.comment-more-menu {
@apply relative;
}
.comment-more-menu summary {
list-style: none;
}
.comment-more-menu summary::-webkit-details-marker {
display: none;
}
.comment-more-menu[open] .comment-action-button {
background: rgba(15, 118, 110, 0.12);
color: var(--app-color-on-tertiary);
}
.comment-more-menu[open] .comment-action-menu,
.comment-more-menu:hover .comment-action-menu,
.comment-more-menu:focus-within .comment-action-menu {
display: flex;
}
.comment-action-menu {
@apply absolute right-0 top-[calc(100%+0.375rem)] z-20 hidden min-w-36 flex-col gap-1 rounded-[0.875rem] border p-1 shadow-xl;
background: #ffffff;
border-color: var(--app-control-active);
}
.comment-menu-item {
@apply flex items-center gap-2 rounded-[0.625rem] px-3 py-2 text-sm font-semibold transition;
color: var(--app-color-on-surface);
}
.comment-menu-item:hover,
.comment-menu-item:focus-visible {
background: rgba(15, 118, 110, 0.1);
color: var(--app-color-on-tertiary);
}
.comment-menu-item.danger {
color: var(--app-danger-muted);
}
.comment-menu-item.danger:hover,
.comment-menu-item.danger:focus-visible {
background: rgba(185, 28, 28, 0.1);
color: var(--app-danger-muted);
}
.comment-body {
@apply whitespace-pre-line text-sm leading-6;
color: var(--app-color-on-surface);
}
.comment-attachment {
@apply block w-fit max-w-full overflow-hidden rounded-[0.875rem] border;
border-color: var(--app-control-active);
background: #ffffff;
}
.comment-attachment img {
@apply block max-h-72 max-w-full object-contain;
}
.reply-attachment img {
@apply max-h-56;
}
.reply-list {
@apply ml-2 mt-2 flex flex-col gap-4;
}
.reply-row {
@apply flex items-start gap-3;
}
.reply-row > div {
@apply flex min-w-0 flex-col gap-1;
}
.reply-meta {
@apply flex min-w-0 flex-col;
}
.reply-meta strong {
@apply truncate text-sm;
color: var(--app-color-on-surface);
}
.reply-meta small {
@apply text-xs leading-5;
color: #7c8798;
}
.reply-row p {
@apply whitespace-pre-line text-sm leading-6;
color: var(--app-color-on-surface);
}
</style>