feat: refine content calendar experience
This commit is contained in:
@@ -218,7 +218,7 @@
|
||||
|
||||
.login-brand-mark {
|
||||
@apply flex h-11 w-11 items-center justify-center rounded-2xl text-lg font-black;
|
||||
background: linear-gradient(135deg, #ff8a3d 0%, #ef4444 100%);
|
||||
background: var(--socialize-brand-gradient);
|
||||
color: #fffaf2;
|
||||
}
|
||||
|
||||
|
||||
69
frontend/src/features/content/components/ColorPalette.vue
Normal file
69
frontend/src/features/content/components/ColorPalette.vue
Normal file
@@ -0,0 +1,69 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: '#2F80ED',
|
||||
},
|
||||
colors: {
|
||||
type: Array,
|
||||
default: () => [
|
||||
'#2F80ED',
|
||||
'#0F766E',
|
||||
'#16A34A',
|
||||
'#F59E0B',
|
||||
'#EF4444',
|
||||
'#EC4899',
|
||||
'#8B5CF6',
|
||||
'#475569',
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="color-palette">
|
||||
<button
|
||||
v-for="color in props.colors"
|
||||
:key="color"
|
||||
class="color-option"
|
||||
:class="{ active: color.toLowerCase() === props.modelValue.toLowerCase() }"
|
||||
type="button"
|
||||
:style="{ background: color }"
|
||||
:aria-label="color"
|
||||
@click="emit('update:modelValue', color)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.color-palette {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1.75rem);
|
||||
gap: 0.5rem;
|
||||
width: max-content;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid rgba(23, 32, 51, 0.1);
|
||||
border-radius: 0.75rem;
|
||||
background: #ffffff;
|
||||
border-color: rgba(23, 32, 51, 0.1);
|
||||
box-shadow: 0 18px 40px rgba(23, 32, 51, 0.14);
|
||||
}
|
||||
|
||||
.color-option {
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.9);
|
||||
border-radius: 9999px;
|
||||
border-color: rgba(255, 255, 255, 0.9);
|
||||
box-shadow: 0 0 0 1px rgba(23, 32, 51, 0.12);
|
||||
transition: box-shadow 0.15s ease, transform 0.15s ease;
|
||||
}
|
||||
|
||||
.color-option:hover,
|
||||
.color-option.active {
|
||||
transform: scale(1.08);
|
||||
box-shadow: 0 0 0 2px #172033;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,389 @@
|
||||
<script setup>
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
import { mdiAt, mdiClose, mdiImagePlusOutline, mdiLockOutline, mdiSend } from '@mdi/js';
|
||||
import AppAvatar from '@/components/AppAvatar.vue';
|
||||
import { useUserProfileStore } from '@/features/user-profile/stores/userProfileStore.js';
|
||||
|
||||
const props = defineProps({
|
||||
members: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
isPosting: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
replyTarget: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
variant: {
|
||||
type: String,
|
||||
default: 'default',
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['submit-comment', 'cancel-reply']);
|
||||
const userProfileStore = useUserProfileStore();
|
||||
const mediaFileInput = ref(null);
|
||||
|
||||
const form = reactive({
|
||||
body: '',
|
||||
isInternal: false,
|
||||
mediaFile: null,
|
||||
showMentionPicker: false,
|
||||
});
|
||||
|
||||
const currentUserEmail = computed(() => userProfileStore.user?.email ?? '');
|
||||
const currentUserName = computed(() => userProfileStore.alias);
|
||||
const isReplyVariant = computed(() => props.variant === 'reply');
|
||||
const canSubmit = computed(() =>
|
||||
Boolean(form.body.trim() || form.mediaFile) &&
|
||||
!props.isPosting
|
||||
);
|
||||
|
||||
function submitComment() {
|
||||
if (!canSubmit.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bodyParts = [];
|
||||
const body = form.body.trim();
|
||||
|
||||
if (form.isInternal) {
|
||||
bodyParts.push('[Internal]');
|
||||
}
|
||||
|
||||
if (body) {
|
||||
bodyParts.push(body);
|
||||
}
|
||||
|
||||
emit('submit-comment', {
|
||||
body: bodyParts.join('\n\n'),
|
||||
isInternal: form.isInternal,
|
||||
mediaReference: form.mediaFile?.name ?? null,
|
||||
mediaFile: form.mediaFile,
|
||||
mediaFileName: form.mediaFile?.name ?? null,
|
||||
mediaFileSize: form.mediaFile?.size ?? null,
|
||||
mediaFileType: form.mediaFile?.type || null,
|
||||
parentCommentId: props.replyTarget?.id ?? null,
|
||||
});
|
||||
|
||||
form.body = '';
|
||||
form.isInternal = false;
|
||||
form.mediaFile = null;
|
||||
form.showMentionPicker = false;
|
||||
if (mediaFileInput.value) {
|
||||
mediaFileInput.value.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
function openMediaPicker() {
|
||||
mediaFileInput.value?.click();
|
||||
form.showMentionPicker = false;
|
||||
}
|
||||
|
||||
function selectMediaFile(event) {
|
||||
form.mediaFile = event.target.files?.[0] ?? null;
|
||||
}
|
||||
|
||||
function clearMediaFile() {
|
||||
form.mediaFile = null;
|
||||
if (mediaFileInput.value) {
|
||||
mediaFileInput.value.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
function toggleMentionPicker() {
|
||||
form.showMentionPicker = !form.showMentionPicker;
|
||||
}
|
||||
|
||||
function insertMention(member) {
|
||||
const label = member.displayName || member.email;
|
||||
|
||||
if (!label) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mention = `@${label.replace(/\s+/g, '')}`;
|
||||
const separator = form.body && !/\s$/.test(form.body) ? ' ' : '';
|
||||
form.body = `${form.body}${separator}${mention} `;
|
||||
form.showMentionPicker = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="comment-composer"
|
||||
:class="variant"
|
||||
>
|
||||
<div
|
||||
v-if="replyTarget && !isReplyVariant"
|
||||
class="reply-context"
|
||||
>
|
||||
<div>
|
||||
<span>Replying to</span>
|
||||
<strong>{{ replyTarget.authorDisplayName }}</strong>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
title="Cancel reply"
|
||||
@click="emit('cancel-reply')"
|
||||
>
|
||||
<v-icon :icon="mdiClose" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="comment-composer-main">
|
||||
<AppAvatar
|
||||
v-if="!isReplyVariant"
|
||||
:name="currentUserName"
|
||||
:email="currentUserEmail"
|
||||
:src="userProfileStore.portraitUrl"
|
||||
size="md"
|
||||
/>
|
||||
|
||||
<textarea
|
||||
v-model="form.body"
|
||||
class="comment-textarea"
|
||||
:placeholder="replyTarget ? 'Write a reply...' : 'Write a comment...'"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="form.mediaFile"
|
||||
class="selected-media-file"
|
||||
>
|
||||
<span>{{ form.mediaFile.name }}</span>
|
||||
<button
|
||||
type="button"
|
||||
title="Remove selected media"
|
||||
@click="clearMediaFile"
|
||||
>
|
||||
<v-icon :icon="mdiClose" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="form.showMentionPicker"
|
||||
class="mention-picker"
|
||||
>
|
||||
<button
|
||||
v-for="member in members"
|
||||
:key="member.id"
|
||||
class="mention-option"
|
||||
type="button"
|
||||
@click="insertMention(member)"
|
||||
>
|
||||
<AppAvatar
|
||||
:name="member.displayName"
|
||||
:email="member.email"
|
||||
size="sm"
|
||||
/>
|
||||
<span>{{ member.displayName }}</span>
|
||||
</button>
|
||||
|
||||
<div
|
||||
v-if="!members.length"
|
||||
class="empty-note"
|
||||
>
|
||||
No workspace members are available to mention.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="comment-composer-toolbar">
|
||||
<div class="comment-tool-actions">
|
||||
<label
|
||||
class="icon-tool-button internal-toggle"
|
||||
:class="{ active: form.isInternal }"
|
||||
title="Internal comment"
|
||||
>
|
||||
<input
|
||||
v-model="form.isInternal"
|
||||
type="checkbox"
|
||||
/>
|
||||
<v-icon :icon="mdiLockOutline" />
|
||||
</label>
|
||||
<button
|
||||
class="icon-tool-button"
|
||||
type="button"
|
||||
title="Upload media from computer"
|
||||
:class="{ active: form.mediaFile }"
|
||||
@click="openMediaPicker"
|
||||
>
|
||||
<v-icon :icon="mdiImagePlusOutline" />
|
||||
</button>
|
||||
<input
|
||||
ref="mediaFileInput"
|
||||
class="sr-only"
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/jpg"
|
||||
@change="selectMediaFile"
|
||||
/>
|
||||
<button
|
||||
class="icon-tool-button"
|
||||
type="button"
|
||||
title="Mention a member"
|
||||
:class="{ active: form.showMentionPicker }"
|
||||
@click="toggleMentionPicker"
|
||||
>
|
||||
<v-icon :icon="mdiAt" />
|
||||
</button>
|
||||
<button
|
||||
class="post-button"
|
||||
type="button"
|
||||
:disabled="!canSubmit"
|
||||
@click="submitComment"
|
||||
>
|
||||
<v-icon :icon="mdiSend" />
|
||||
{{ isPosting ? 'Posting...' : 'Post' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.comment-composer {
|
||||
@apply flex flex-col gap-3 rounded-[1.25rem] border p-4;
|
||||
background: #fffdf8;
|
||||
border-color: rgba(23, 32, 51, 0.12);
|
||||
}
|
||||
|
||||
.comment-composer.reply {
|
||||
@apply rounded-[1rem] p-3;
|
||||
background: rgba(255, 253, 248, 0.84);
|
||||
}
|
||||
|
||||
.comment-composer-main {
|
||||
@apply flex items-start gap-3;
|
||||
}
|
||||
|
||||
.reply-context {
|
||||
@apply flex items-center justify-between gap-3 rounded-[1rem] border px-3 py-2;
|
||||
background: rgba(15, 118, 110, 0.06);
|
||||
border-color: rgba(15, 118, 110, 0.14);
|
||||
}
|
||||
|
||||
.reply-context div {
|
||||
@apply flex min-w-0 items-center gap-2 text-sm;
|
||||
}
|
||||
|
||||
.reply-context span {
|
||||
color: #526178;
|
||||
}
|
||||
|
||||
.reply-context strong {
|
||||
@apply truncate;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.reply-context button {
|
||||
@apply inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full transition;
|
||||
color: #526178;
|
||||
}
|
||||
|
||||
.reply-context button:hover,
|
||||
.reply-context button:focus-visible {
|
||||
background: rgba(15, 118, 110, 0.12);
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.comment-textarea {
|
||||
@apply min-h-24 flex-1 resize-y border-0 bg-transparent text-sm leading-6;
|
||||
color: #172033;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.comment-textarea::placeholder {
|
||||
color: #7c8798;
|
||||
}
|
||||
|
||||
.selected-media-file {
|
||||
@apply flex items-center justify-between gap-3 rounded-[1rem] border px-3 py-2 text-sm;
|
||||
background: rgba(23, 32, 51, 0.03);
|
||||
border-color: rgba(23, 32, 51, 0.08);
|
||||
}
|
||||
|
||||
.selected-media-file span {
|
||||
@apply min-w-0 truncate font-semibold;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.selected-media-file button {
|
||||
@apply inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full transition;
|
||||
color: #526178;
|
||||
}
|
||||
|
||||
.selected-media-file button:hover,
|
||||
.selected-media-file button:focus-visible {
|
||||
background: rgba(185, 28, 28, 0.1);
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.mention-picker {
|
||||
@apply grid max-h-52 gap-2 overflow-y-auto rounded-[1rem] border p-2;
|
||||
background: rgba(23, 32, 51, 0.03);
|
||||
border-color: rgba(23, 32, 51, 0.08);
|
||||
}
|
||||
|
||||
.mention-option {
|
||||
@apply flex items-center gap-3 rounded-[0.875rem] px-2 py-2 text-left text-sm font-semibold transition;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.mention-option:hover {
|
||||
background: rgba(15, 118, 110, 0.1);
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.empty-note {
|
||||
@apply text-sm leading-6;
|
||||
color: #526178;
|
||||
}
|
||||
|
||||
.comment-composer-toolbar {
|
||||
@apply flex items-center justify-end gap-2 border-t pt-3;
|
||||
border-color: rgba(23, 32, 51, 0.08);
|
||||
}
|
||||
|
||||
.internal-toggle {
|
||||
@apply cursor-pointer;
|
||||
}
|
||||
|
||||
.internal-toggle input {
|
||||
@apply sr-only;
|
||||
}
|
||||
|
||||
.comment-tool-actions {
|
||||
@apply flex min-w-0 items-center justify-end gap-2;
|
||||
}
|
||||
|
||||
.icon-tool-button,
|
||||
.post-button {
|
||||
@apply inline-flex min-h-10 items-center justify-center gap-2 rounded-full px-3 text-sm font-bold transition;
|
||||
}
|
||||
|
||||
.icon-tool-button {
|
||||
@apply w-10;
|
||||
background: rgba(23, 32, 51, 0.06);
|
||||
color: #526178;
|
||||
}
|
||||
|
||||
.icon-tool-button:hover,
|
||||
.icon-tool-button.active {
|
||||
background: rgba(15, 118, 110, 0.12);
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.post-button {
|
||||
@apply px-4;
|
||||
background: #172033;
|
||||
color: #fffaf2;
|
||||
}
|
||||
|
||||
.post-button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
</style>
|
||||
386
frontend/src/features/content/components/ContentCommentFeed.vue
Normal file
386
frontend/src/features/content/components/ContentCommentFeed.vue
Normal 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>
|
||||
@@ -128,6 +128,29 @@ export const useCalendarIntegrationsStore = defineStore('calendar-integrations',
|
||||
}
|
||||
}
|
||||
|
||||
async function updateSource(sourceId, payload) {
|
||||
if (!sourceId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const response = await client.put(`/api/calendar-integrations/sources/${sourceId}`, payload);
|
||||
const updatedSource = response.data;
|
||||
if (updatedSource) {
|
||||
sources.value = sources.value.map(source =>
|
||||
source.id === updatedSource.id ? updatedSource : source
|
||||
);
|
||||
}
|
||||
return updatedSource;
|
||||
} catch (updateError) {
|
||||
console.error('Failed to update calendar source:', updateError);
|
||||
error.value = 'Failed to update calendar source.';
|
||||
throw updateError;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshSource(sourceId) {
|
||||
if (!sourceId) {
|
||||
return null;
|
||||
@@ -176,6 +199,7 @@ export const useCalendarIntegrationsStore = defineStore('calendar-integrations',
|
||||
fetchEvents,
|
||||
searchCatalog,
|
||||
createSource,
|
||||
updateSource,
|
||||
refreshSource,
|
||||
toggleSourceVisibility,
|
||||
};
|
||||
|
||||
@@ -129,11 +129,8 @@ export const useContentItemDetailStore = defineStore('content-item-detail', () =
|
||||
actions.comment = true;
|
||||
|
||||
try {
|
||||
const response = await client.post('/api/comments', {
|
||||
...payload,
|
||||
contentItemId,
|
||||
workspaceId: currentItemWorkspaceId(),
|
||||
});
|
||||
const requestPayload = buildCommentPayload(contentItemId, payload);
|
||||
const response = await client.post('/api/comments', requestPayload);
|
||||
if (response.data) {
|
||||
comments.value = [...comments.value, response.data];
|
||||
await fetchActivity(contentItemId);
|
||||
@@ -144,6 +141,22 @@ export const useContentItemDetailStore = defineStore('content-item-detail', () =
|
||||
}
|
||||
}
|
||||
|
||||
function buildCommentPayload(contentItemId, payload) {
|
||||
const workspaceId = currentItemWorkspaceId();
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('workspaceId', workspaceId);
|
||||
formData.append('contentItemId', contentItemId);
|
||||
formData.append('body', payload.body ?? '');
|
||||
if (payload.parentCommentId) {
|
||||
formData.append('parentCommentId', payload.parentCommentId);
|
||||
}
|
||||
if (payload.mediaFile) {
|
||||
formData.append('attachment', payload.mediaFile, payload.mediaFile.name || 'comment-attachment');
|
||||
}
|
||||
return formData;
|
||||
}
|
||||
|
||||
async function submitDecision(contentItemId, approvalId, payload) {
|
||||
actions.decision = true;
|
||||
|
||||
|
||||
@@ -4,10 +4,11 @@
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useSessionStorage } from '@vueuse/core';
|
||||
import { mdiArrowLeft } from '@mdi/js';
|
||||
import AppAvatar from '@/components/AppAvatar.vue';
|
||||
import { useChannelsStore } from '@/features/channels/stores/channelsStore.js';
|
||||
import { useClientsStore } from '@/features/clients/stores/clientsStore.js';
|
||||
import ContentApprovalPanel from '@/features/content/components/ContentApprovalPanel.vue';
|
||||
import ContentCommentComposer from '@/features/content/components/ContentCommentComposer.vue';
|
||||
import ContentCommentFeed from '@/features/content/components/ContentCommentFeed.vue';
|
||||
import { useCalendarIntegrationsStore } from '@/features/content/stores/calendarIntegrationsStore.js';
|
||||
import { useContentItemDetailStore } from '@/features/content/stores/contentItemDetailStore.js';
|
||||
import { useContentItemsStore } from '@/features/content/stores/contentItemsStore.js';
|
||||
@@ -39,10 +40,6 @@
|
||||
placements: [],
|
||||
});
|
||||
|
||||
const commentForm = reactive({
|
||||
body: '',
|
||||
});
|
||||
|
||||
const assetForm = reactive({
|
||||
assetType: 'Image',
|
||||
displayName: '',
|
||||
@@ -102,6 +99,11 @@
|
||||
{ key: 'assets', label: 'Assets', count: detailStore.assets.length },
|
||||
{ key: 'activity', label: 'Activity', count: detailStore.activity.length },
|
||||
]);
|
||||
const workspaceMembers = computed(() =>
|
||||
contentWorkspaceId.value
|
||||
? workspaceStore.membersByWorkspace[contentWorkspaceId.value] ?? []
|
||||
: []
|
||||
);
|
||||
const selectedDateKey = computed(() => /^\d{4}-\d{2}-\d{2}$/.test(form.dueDate) ? form.dueDate : '');
|
||||
const contextAnchorDate = computed(() => selectedDateKey.value ? parseDateKey(selectedDateKey.value) : startOfDay(new Date()));
|
||||
const calendarFetchRange = computed(() => {
|
||||
@@ -450,13 +452,12 @@
|
||||
await detailStore.submitDecision(contentItemId.value, approvalId, payload);
|
||||
}
|
||||
|
||||
async function submitComment() {
|
||||
if (!contentItemId.value || !commentForm.body.trim()) {
|
||||
async function submitComment(payload) {
|
||||
if (!contentItemId.value || !payload?.body?.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
await detailStore.addComment(contentItemId.value, { body: commentForm.body.trim() });
|
||||
commentForm.body = '';
|
||||
await detailStore.addComment(contentItemId.value, payload);
|
||||
}
|
||||
|
||||
function inferGoogleDriveFileId(value) {
|
||||
@@ -674,6 +675,7 @@
|
||||
await Promise.all([
|
||||
calendarStore.fetchSources(workspaceId),
|
||||
calendarStore.fetchEvents({ workspaceId, startDate, endDate }),
|
||||
workspaceStore.fetchMembers(workspaceId),
|
||||
]);
|
||||
},
|
||||
{ immediate: true }
|
||||
@@ -1127,50 +1129,18 @@
|
||||
</div>
|
||||
|
||||
<template v-if="activeProductionTab === 'comments'">
|
||||
<div class="panel-stack">
|
||||
<label class="field field-wide">
|
||||
<span>New comment</span>
|
||||
<textarea v-model="commentForm.body"></textarea>
|
||||
</label>
|
||||
<button
|
||||
class="primary-button"
|
||||
:disabled="detailStore.actions.comment"
|
||||
@click="submitComment"
|
||||
>
|
||||
{{ detailStore.actions.comment ? 'Posting...' : 'Post comment' }}
|
||||
</button>
|
||||
</div>
|
||||
<ContentCommentComposer
|
||||
:members="workspaceMembers"
|
||||
:is-posting="detailStore.actions.comment"
|
||||
@submit-comment="submitComment"
|
||||
/>
|
||||
|
||||
<div class="timeline-list">
|
||||
<article
|
||||
v-for="comment in detailStore.comments"
|
||||
:key="comment.id"
|
||||
class="timeline-row"
|
||||
>
|
||||
<div class="identity-row align-start">
|
||||
<AppAvatar
|
||||
:name="comment.authorDisplayName"
|
||||
:email="comment.authorEmail"
|
||||
:src="comment.authorPortraitUrl"
|
||||
size="sm"
|
||||
/>
|
||||
<div>
|
||||
<strong>{{ comment.authorDisplayName }}</strong>
|
||||
<span>{{ comment.body }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="timeline-actions">
|
||||
<small>{{ formatDateTime(comment.createdAt) }}</small>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div
|
||||
v-if="!detailStore.comments.length"
|
||||
class="empty-note"
|
||||
>
|
||||
No comments yet.
|
||||
</div>
|
||||
</div>
|
||||
<ContentCommentFeed
|
||||
:comments="detailStore.comments"
|
||||
:members="workspaceMembers"
|
||||
:is-posting="detailStore.actions.comment"
|
||||
@submit-comment="submitComment"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-else-if="activeProductionTab === 'revisions'">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -35,13 +35,20 @@
|
||||
|
||||
.feedback-entry-button {
|
||||
@apply flex h-12 items-center gap-2 rounded-full border px-4 text-sm font-bold shadow-lg transition-colors;
|
||||
background: #172033;
|
||||
border-color: rgba(255, 255, 255, 0.4);
|
||||
color: #fffaf2;
|
||||
background: var(--socialize-accent-strong);
|
||||
border-color: rgba(255, 255, 255, 0.55);
|
||||
color: #ffffff;
|
||||
box-shadow: 0 16px 34px var(--socialize-accent-strong-shadow);
|
||||
}
|
||||
|
||||
.feedback-entry-button:hover {
|
||||
background: #0f766e;
|
||||
background: color-mix(in srgb, var(--socialize-accent-strong) 82%, var(--socialize-primary));
|
||||
box-shadow: 0 18px 38px var(--socialize-accent-strong-shadow);
|
||||
}
|
||||
|
||||
.feedback-entry-button:focus-visible {
|
||||
outline: 3px solid color-mix(in srgb, var(--socialize-accent) 35%, transparent);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.feedback-entry-button span {
|
||||
|
||||
Reference in New Issue
Block a user