feat: refine content calendar experience

This commit is contained in:
2026-05-05 23:25:58 -04:00
parent b66c10b681
commit a7535d460d
72 changed files with 3233 additions and 1310 deletions

View File

@@ -0,0 +1,386 @@
<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">
<button
class="comment-action-button"
type="button"
title="Add reaction"
>
<v-icon :icon="mdiEmoticonPlusOutline" />
</button>
<button
class="comment-action-button"
type="button"
title="Resolve"
>
<v-icon :icon="mdiCheckCircleOutline" />
</button>
<button
class="comment-action-button"
type="button"
title="Reply"
@click="activeReplyCommentId = thread.comment.id"
>
<v-icon :icon="mdiReplyOutline" />
</button>
<details class="comment-more-menu">
<summary
class="comment-action-button"
title="More comment actions"
>
<v-icon :icon="mdiDotsVertical" />
</summary>
<div class="comment-action-menu">
<button
class="comment-menu-item"
type="button"
>
<v-icon :icon="mdiPencilOutline" />
Edit
</button>
<button
class="comment-menu-item danger"
type="button"
>
<v-icon :icon="mdiDeleteOutline" />
Delete
</button>
</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>
.timeline-list {
@apply flex flex-col gap-4;
}
.empty-note {
@apply text-sm leading-6;
color: #526178;
}
.comment-row {
@apply relative flex flex-col gap-3 rounded-[1rem] border p-4 transition;
background: #f8fafc;
border-color: rgba(23, 32, 51, 0.08);
outline: none;
}
.comment-row:hover,
.comment-row:focus-within,
.comment-row:focus {
background: #fffdf8;
border-color: rgba(15, 118, 110, 0.24);
box-shadow: 0 16px 34px rgba(23, 32, 51, 0.08);
}
.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: #172033;
}
.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: rgba(23, 32, 51, 0.08);
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: #526178;
}
.comment-action-button:hover,
.comment-action-button:focus-visible {
background: rgba(15, 118, 110, 0.12);
color: #0f766e;
}
.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: #0f766e;
}
.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: rgba(23, 32, 51, 0.1);
}
.comment-menu-item {
@apply flex items-center gap-2 rounded-[0.625rem] px-3 py-2 text-sm font-semibold transition;
color: #172033;
}
.comment-menu-item:hover,
.comment-menu-item:focus-visible {
background: rgba(15, 118, 110, 0.1);
color: #0f766e;
}
.comment-menu-item.danger {
color: #b91c1c;
}
.comment-menu-item.danger:hover,
.comment-menu-item.danger:focus-visible {
background: rgba(185, 28, 28, 0.1);
color: #b91c1c;
}
.comment-body {
@apply whitespace-pre-line text-sm leading-6;
color: #172033;
}
.comment-attachment {
@apply block w-fit max-w-full overflow-hidden rounded-[0.875rem] border;
border-color: rgba(23, 32, 51, 0.1);
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: #172033;
}
.reply-meta small {
@apply text-xs leading-5;
color: #7c8798;
}
.reply-row p {
@apply whitespace-pre-line text-sm leading-6;
color: #172033;
}
</style>