Files
social-media/frontend/src/features/content/views/ContentItemDetailView.vue

1052 lines
38 KiB
Vue

<script setup>
import { computed, onBeforeUnmount, reactive, watch } from 'vue';
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 { useContentItemDetailStore } from '@/features/content/stores/contentItemDetailStore.js';
import { useContentItemsStore } from '@/features/content/stores/contentItemsStore.js';
import { useCampaignsStore } from '@/features/campaigns/stores/campaignsStore.js';
import { useWorkspaceStore } from '@/features/workspaces/stores/workspaceStore.js';
const route = useRoute();
const router = useRouter();
const workspaceStore = useWorkspaceStore();
const campaignsStore = useCampaignsStore();
const clientsStore = useClientsStore();
const channelsStore = useChannelsStore();
const contentItemsStore = useContentItemsStore();
const detailStore = useContentItemDetailStore();
const editorDrafts = useSessionStorage('content-editor-drafts', {}, {
mergeDefaults: true,
});
const form = reactive({
title: '',
campaignId: '',
dueDate: '',
body: '',
hashtags: '',
changeSummary: '',
placements: [],
});
const commentForm = reactive({
body: '',
});
const saveError = reactive({
message: '',
});
const isCreateMode = computed(() => route.name === 'content-item-create');
const contentItemId = computed(() => isCreateMode.value ? null : route.params.id);
const item = computed(() => detailStore.item);
const availableCampaigns = computed(() => campaignsStore.campaigns);
const availableChannels = computed(() => channelsStore.channels);
const groupedChannels = computed(() => {
const groups = new Map();
for (const channel of availableChannels.value) {
const network = channel.network || 'Other';
const existing = groups.get(network) ?? [];
existing.push(channel);
groups.set(network, existing);
}
return Array.from(groups.entries()).map(([network, channels]) => ({
network,
channels,
}));
});
const placementSummary = computed(() =>
form.placements
.map(placement => placement.channelName || placement.network)
.filter(Boolean)
.join(', ')
);
const operationalClient = computed(() => clientsStore.operationalClient);
const campaignNameById = computed(() =>
new Map(campaignsStore.campaigns.map(campaign => [campaign.id, campaign.name]))
);
const editorKey = computed(() => isCreateMode.value ? `new:${route.query.campaignId ?? 'default'}` : String(route.params.id));
const approvalMode = computed(() => workspaceStore.activeWorkspace?.approvalMode ?? 'Required');
function blankPlacement(channel = null) {
return {
id: crypto.randomUUID(),
network: channel?.network ?? '',
channelId: channel?.id ?? '',
channelName: channel?.name ?? '',
variantLabel: '',
message: form.body,
hashtags: form.hashtags,
mediaItems: [],
};
}
function blankMedia() {
return {
id: crypto.randomUUID(),
mediaType: 'Image',
label: '',
url: '',
};
}
function syncPlacementChannel(placement, value) {
const channel = availableChannels.value.find(candidate => candidate.id === value);
placement.channelId = value;
placement.channelName = channel?.name ?? '';
placement.network = channel?.network ?? placement.network;
}
function addPlacement(channel = null) {
form.placements.push(blankPlacement(channel));
}
function removePlacement(placementId) {
form.placements = form.placements.filter(placement => placement.id !== placementId);
}
function addMedia(placement) {
placement.mediaItems.push(blankMedia());
}
function removeMedia(placement, mediaId) {
placement.mediaItems = placement.mediaItems.filter(media => media.id !== mediaId);
}
function removeHashtag(tagToRemove) {
form.hashtags = parseHashtags(form.hashtags)
.filter(tag => tag.toLowerCase() !== tagToRemove.toLowerCase())
.join(' ');
}
function parseHashtags(value) {
return (value ?? '')
.split(/[,\s]+/)
.map(tag => tag.trim())
.filter(Boolean);
}
function parseTargets(value) {
return (value ?? '')
.split(',')
.map(entry => entry.trim())
.filter(Boolean);
}
function serializeDraft() {
return JSON.parse(JSON.stringify({
title: form.title,
campaignId: form.campaignId,
dueDate: form.dueDate,
body: form.body,
hashtags: form.hashtags,
changeSummary: form.changeSummary,
placements: form.placements,
}));
}
function restoreDraft(draft) {
form.title = draft.title ?? '';
form.campaignId = draft.campaignId ?? availableCampaigns.value[0]?.id ?? '';
form.dueDate = draft.dueDate ?? '';
form.body = draft.body ?? '';
form.hashtags = draft.hashtags ?? '';
form.changeSummary = draft.changeSummary ?? '';
form.placements = (draft.placements ?? []).map(placement => ({
id: placement.id ?? crypto.randomUUID(),
network: placement.network ?? '',
channelId: placement.channelId ?? '',
channelName: placement.channelName ?? '',
variantLabel: placement.variantLabel ?? '',
message: placement.message ?? '',
hashtags: placement.hashtags ?? '',
mediaItems: (placement.mediaItems ?? []).map(media => ({
id: media.id ?? crypto.randomUUID(),
mediaType: media.mediaType ?? 'Image',
label: media.label ?? '',
url: media.url ?? '',
})),
}));
}
function buildDraftFromItem() {
const campaignId = item.value?.campaignId ?? '';
const placements = parseTargets(item.value?.publicationTargets).map(target => {
const channel = availableChannels.value.find(candidate => candidate.name.toLowerCase() === target.toLowerCase());
return {
id: crypto.randomUUID(),
network: channel?.network ?? '',
channelId: channel?.id ?? '',
channelName: channel?.name ?? target,
variantLabel: '',
message: item.value?.publicationMessage ?? '',
hashtags: item.value?.hashtags ?? '',
mediaItems: [],
};
});
restoreDraft({
title: item.value?.title ?? '',
campaignId,
dueDate: item.value?.dueDate ? new Date(item.value.dueDate).toISOString().slice(0, 10) : '',
body: item.value?.publicationMessage ?? '',
hashtags: item.value?.hashtags ?? '',
changeSummary: '',
placements: placements.length ? placements : [blankPlacement()],
});
}
function buildDraftForNew() {
const campaignIdFromRoute = typeof route.query.campaignId === 'string' ? route.query.campaignId : '';
restoreDraft({
title: '',
campaignId: availableCampaigns.value.some(campaign => campaign.id === campaignIdFromRoute)
? campaignIdFromRoute
: availableCampaigns.value[0]?.id ?? '',
dueDate: '',
body: '',
hashtags: '',
changeSummary: '',
placements: [],
});
}
async function hydrateEditor() {
saveError.message = '';
if (isCreateMode.value) {
const draft = editorDrafts.value[editorKey.value];
if (draft) {
restoreDraft(draft);
} else {
buildDraftForNew();
}
return;
}
if (!contentItemId.value) {
return;
}
await detailStore.fetchContentItemDetail(contentItemId.value);
const draft = editorDrafts.value[editorKey.value];
if (draft) {
restoreDraft(draft);
return;
}
buildDraftFromItem();
}
function persistDraft() {
editorDrafts.value = {
...editorDrafts.value,
[editorKey.value]: serializeDraft(),
};
}
function clearDraft(key = editorKey.value) {
const nextDrafts = { ...editorDrafts.value };
delete nextDrafts[key];
editorDrafts.value = nextDrafts;
}
async function saveContent() {
saveError.message = '';
if (!form.title.trim() || !form.campaignId || !form.placements.length) {
saveError.message = 'Title, campaign, and at least one channel are required.';
return;
}
if (isCreateMode.value && !operationalClient.value?.id) {
saveError.message = 'This workspace needs an operational account before content can be created.';
return;
}
const payload = {
title: form.title.trim(),
campaignId: form.campaignId,
publicationMessage: form.body.trim(),
publicationTargets: placementSummary.value,
hashtags: form.hashtags.trim(),
dueDate: form.dueDate ? new Date(form.dueDate).toISOString() : null,
};
if (isCreateMode.value) {
try {
const created = await contentItemsStore.createContentItem({
clientId: operationalClient.value.id,
...payload,
});
editorDrafts.value = {
...editorDrafts.value,
[String(created.id)]: serializeDraft(),
};
clearDraft();
await router.replace({ name: 'content-item-detail', params: { id: created.id } });
} catch (error) {
saveError.message = 'The content item could not be created.';
}
return;
}
try {
await detailStore.createRevision(contentItemId.value, {
...payload,
changeSummary: form.changeSummary.trim() || 'Updated content plan',
});
persistDraft();
form.changeSummary = '';
} catch (error) {
saveError.message = 'The content revision could not be saved.';
}
}
async function submitDecision(approvalId, payload) {
if (!contentItemId.value) {
return;
}
await detailStore.submitDecision(contentItemId.value, approvalId, payload);
}
async function submitComment() {
if (!contentItemId.value || !commentForm.body.trim()) {
return;
}
await detailStore.addComment(contentItemId.value, { body: commentForm.body.trim() });
commentForm.body = '';
}
async function navigateBackToContent() {
const returnTo = typeof route.query.returnTo === 'string' ? route.query.returnTo : '';
const previousPath = router.options.history.state.back;
if (returnTo.startsWith('/app/content')) {
await router.push(returnTo);
return;
}
if (typeof previousPath === 'string' && previousPath.startsWith('/app/content')) {
router.back();
return;
}
await router.push({ name: 'content-items' });
}
function formatDateTime(value) {
return value ? new Date(value).toLocaleString() : '';
}
watch(
() => [
isCreateMode.value,
route.params.id,
route.query.campaignId,
availableCampaigns.value.length,
availableChannels.value.length,
],
async () => {
await hydrateEditor();
},
{ immediate: true }
);
watch(
() => [
form.title,
form.campaignId,
form.dueDate,
form.body,
form.hashtags,
form.changeSummary,
form.placements,
],
() => {
persistDraft();
},
{ deep: true }
);
onBeforeUnmount(() => {
detailStore.reset();
});
</script>
<template>
<section class="editor-shell">
<button
class="back-button"
type="button"
@click="navigateBackToContent"
>
<v-icon :icon="mdiArrowLeft" />
Back to content
</button>
<div
v-if="!isCreateMode && detailStore.isLoading"
class="page-message"
>
Loading content item...
</div>
<div
v-else-if="!isCreateMode && detailStore.error"
class="page-message error"
>
{{ detailStore.error }}
</div>
<template v-else>
<div class="editor-header">
<div>
<div class="eyebrow">{{ isCreateMode ? 'New content' : 'Content item' }}</div>
<h1>{{ form.title || 'Untitled content' }}</h1>
<p>
{{ campaignNameById.get(form.campaignId) || 'Choose a campaign' }}
<template v-if="!isCreateMode && item">
· {{ item.status }}
</template>
</p>
</div>
<div class="header-actions">
<div
v-if="!isCreateMode && item"
class="status-badges"
>
<span class="meta-chip">{{ item.status }}</span>
<span class="meta-chip">{{ item.currentRevisionLabel }}</span>
</div>
<button
class="primary-button"
:disabled="contentItemsStore.isCreating || detailStore.actions.revision"
@click="saveContent"
>
{{ isCreateMode
? (contentItemsStore.isCreating ? 'Creating...' : 'Create content')
: (detailStore.actions.revision ? 'Saving...' : 'Save revision') }}
</button>
</div>
</div>
<div
v-if="saveError.message"
class="page-message error"
>
{{ saveError.message }}
</div>
<div class="editor-grid">
<section class="work-panel">
<ContentApprovalPanel
:approvals="detailStore.approvals"
:approval-mode="approvalMode"
:content-status="item?.status ?? 'Draft'"
:is-create-mode="isCreateMode"
:is-submitting-decision="detailStore.actions.decision"
@submit-decision="submitDecision"
/>
<main class="content-panel">
<div class="content-section">
<div class="section-title-row">
<strong>Content</strong>
<span>{{ placementSummary || 'No channels selected yet' }}</span>
</div>
<div class="form-grid">
<label class="field">
<span>Title</span>
<input
v-model="form.title"
type="text"
/>
</label>
<label class="field">
<span>Campaign</span>
<select v-model="form.campaignId">
<option
disabled
value=""
>
Select a campaign
</option>
<option
v-for="campaign in availableCampaigns"
:key="campaign.id"
:value="campaign.id"
>
{{ campaign.name }}
</option>
</select>
</label>
<label class="field">
<span>Due date</span>
<input
v-model="form.dueDate"
type="date"
/>
</label>
<label class="field field-wide">
<span>Change summary</span>
<input
v-model="form.changeSummary"
type="text"
placeholder="What changed in this revision?"
/>
</label>
<label class="field field-wide">
<span>Shared brief / base caption</span>
<textarea v-model="form.body"></textarea>
</label>
<label class="field field-wide">
<span>Shared hashtags</span>
<div class="hashtags-input-shell">
<div
v-if="parseHashtags(form.hashtags).length"
class="hashtags-editor"
>
<v-chip
v-for="tag in parseHashtags(form.hashtags)"
:key="`form-${tag}`"
class="hashtag-chip"
size="small"
rounded="pill"
variant="tonal"
color="deep-orange"
closable
@click:close="removeHashtag(tag)"
>
{{ tag.startsWith('#') ? tag : `#${tag}` }}
</v-chip>
</div>
<input
v-model="form.hashtags"
type="text"
class="hashtags-inline-input"
placeholder="#launch #campaign #brand"
/>
</div>
</label>
</div>
</div>
<div class="content-section">
<div class="section-title-row">
<strong>Channels and variants</strong>
<button
class="secondary-button"
type="button"
@click="addPlacement()"
>
Add channel
</button>
</div>
<div
v-if="groupedChannels.length"
class="channel-suggestions"
>
<button
v-for="group in groupedChannels"
:key="group.network"
class="network-pill"
type="button"
@click="addPlacement(group.channels[0])"
>
{{ group.network }}
</button>
</div>
<div
v-if="form.placements.length"
class="placement-list"
>
<article
v-for="placement in form.placements"
:key="placement.id"
class="placement-card"
>
<div class="placement-header">
<div>
<strong>{{ placement.channelName || placement.network || 'Channel' }}</strong>
<span>{{ placement.variantLabel || 'Custom variant' }}</span>
</div>
<button
class="link-button"
type="button"
@click="removePlacement(placement.id)"
>
Remove
</button>
</div>
<div class="form-grid compact-grid">
<label class="field">
<span>Network</span>
<input
v-model="placement.network"
type="text"
placeholder="Instagram, YouTube..."
/>
</label>
<label class="field">
<span>Channel</span>
<select
v-model="placement.channelId"
@change="syncPlacementChannel(placement, placement.channelId)"
>
<option value="">Select a configured channel</option>
<option
v-for="channel in availableChannels"
:key="channel.id"
:value="channel.id"
>
{{ channel.name }}{{ channel.network ? ` · ${channel.network}` : '' }}
</option>
</select>
</label>
<label class="field">
<span>Channel name</span>
<input
v-model="placement.channelName"
type="text"
placeholder="IG Feed, YouTube Main..."
/>
</label>
<label class="field">
<span>Variant label</span>
<input
v-model="placement.variantLabel"
type="text"
placeholder="Reel version, Shorts version..."
/>
</label>
<label class="field field-wide">
<span>Channel-specific caption</span>
<textarea v-model="placement.message"></textarea>
</label>
<label class="field field-wide">
<span>Channel-specific hashtags</span>
<input
v-model="placement.hashtags"
type="text"
placeholder="#product #launch"
/>
</label>
</div>
<div class="media-section">
<div class="section-title-row">
<strong>Media</strong>
<button
class="secondary-button"
type="button"
@click="addMedia(placement)"
>
Add media
</button>
</div>
<div
v-if="placement.mediaItems.length"
class="media-list"
>
<div
v-for="media in placement.mediaItems"
:key="media.id"
class="media-card"
>
<div class="form-grid compact-grid">
<label class="field">
<span>Media type</span>
<select v-model="media.mediaType">
<option value="Image">Image</option>
<option value="Video">Video</option>
<option value="Document">Document</option>
</select>
</label>
<label class="field">
<span>Label</span>
<input
v-model="media.label"
type="text"
placeholder="Cover image, YouTube video..."
/>
</label>
<label class="field field-wide">
<span>Media URL / reference</span>
<input
v-model="media.url"
type="text"
placeholder="Google Drive link or asset URL"
/>
</label>
</div>
<button
class="link-button"
type="button"
@click="removeMedia(placement, media.id)"
>
Remove media
</button>
</div>
</div>
<div
v-else
class="empty-note"
>
Add media per channel, for example a video for YouTube or an image for Instagram.
</div>
</div>
</article>
</div>
<div
v-else
class="empty-note"
>
Add at least one channel to define where this content will be published.
</div>
</div>
</main>
</section>
<aside class="panel side-panel">
<div class="panel-heading">
<strong>Comments</strong>
<span v-if="!isCreateMode">{{ detailStore.comments.length }} items</span>
</div>
<div
v-if="isCreateMode"
class="empty-note"
>
Save the content first to start the comment thread.
</div>
<template v-else>
<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>
<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>
<button
v-if="!comment.isResolved"
class="link-button"
@click="detailStore.resolveComment(contentItemId, comment.id)"
>
Resolve
</button>
<small v-else>Resolved</small>
</div>
</article>
</div>
</template>
</aside>
</div>
</template>
</section>
</template>
<style scoped>
.editor-shell {
@apply mx-auto flex w-full max-w-[110rem] flex-col gap-5 px-5 py-8 md:px-8;
}
.page-message {
@apply rounded-[1.25rem] border p-4 text-sm;
background: rgba(255, 255, 255, 0.9);
border-color: rgba(23, 32, 51, 0.08);
color: #526178;
}
.page-message.error {
color: #b91c1c;
}
.editor-header {
@apply flex flex-col gap-4 rounded-[1.75rem] border p-6 lg:flex-row lg:items-start lg:justify-between;
background: rgba(255, 255, 255, 0.92);
border-color: rgba(23, 32, 51, 0.08);
}
.eyebrow {
@apply text-xs font-bold uppercase tracking-[0.24em];
color: #0f766e;
}
.editor-header h1 {
@apply mt-2 text-4xl font-black;
color: #172033;
}
.editor-header p,
.panel-heading span,
.timeline-row span,
.timeline-row small,
.empty-note,
.placement-header span,
.section-title-row span {
@apply text-sm leading-6;
color: #526178;
}
.header-actions,
.status-badges {
@apply flex flex-wrap items-center gap-3;
}
.meta-chip {
@apply rounded-full px-4 py-2 text-xs font-bold uppercase tracking-[0.16em];
background: rgba(23, 32, 51, 0.08);
color: #172033;
}
.back-button,
.primary-button,
.secondary-button {
@apply inline-flex items-center justify-center gap-2 rounded-full px-5 py-3 text-sm font-bold transition;
}
.back-button {
@apply w-fit border;
background: rgba(255, 255, 255, 0.88);
border-color: rgba(23, 32, 51, 0.12);
color: #172033;
}
.back-button:hover {
background: #172033;
color: #fffaf2;
}
.primary-button {
background: #172033;
color: #fffaf2;
}
.secondary-button {
background: rgba(23, 32, 51, 0.06);
color: #172033;
}
.editor-grid {
@apply grid gap-4 xl:grid-cols-[minmax(0,1fr)_22rem];
}
.work-panel {
@apply grid min-w-0 grid-cols-[2.75rem_minmax(0,1fr)] items-start gap-4 rounded-[1.75rem] border p-5;
background: rgba(255, 255, 255, 0.9);
border-color: rgba(23, 32, 51, 0.08);
}
.panel {
@apply flex min-h-0 flex-col gap-5 rounded-[1.75rem] border p-5;
background: rgba(255, 255, 255, 0.9);
border-color: rgba(23, 32, 51, 0.08);
}
.side-panel {
@apply xl:sticky xl:top-28 xl:max-h-[calc(100vh-9rem)] xl:overflow-y-auto;
}
.content-panel {
@apply flex min-h-0 flex-col gap-6;
}
.panel-heading,
.section-title-row,
.placement-header {
@apply flex items-start justify-between gap-3;
}
.panel-heading strong,
.section-title-row strong,
.placement-header strong,
.timeline-row strong {
color: #172033;
}
.panel-stack,
.content-section,
.placement-list,
.media-section,
.media-list,
.card-stack,
.timeline-list {
@apply flex flex-col gap-4;
}
.form-grid {
@apply grid gap-4 md:grid-cols-2;
}
.compact-grid {
@apply md:grid-cols-2;
}
.field {
@apply flex flex-col gap-2;
}
.field-wide {
@apply md:col-span-2;
}
.field span {
@apply text-sm font-semibold;
color: #172033;
}
.field input,
.field select,
.field textarea {
@apply rounded-[1rem] border px-4 py-3 text-sm;
background: #fffdf8;
border-color: rgba(23, 32, 51, 0.12);
color: #172033;
outline: none;
}
.field textarea {
min-height: 7rem;
resize: vertical;
}
.hashtags-input-shell {
@apply flex flex-wrap items-center gap-2 rounded-[1rem] border px-4 py-3;
background: #fffdf8;
border-color: rgba(23, 32, 51, 0.12);
}
.hashtags-editor {
@apply flex flex-wrap gap-2;
}
.hashtags-inline-input {
@apply min-w-[12rem] flex-1 border-0 bg-transparent p-0 text-sm;
color: #172033;
outline: none;
}
.hashtag-chip {
@apply font-bold;
}
.network-pill {
@apply rounded-full border px-4 py-2 text-xs font-bold uppercase tracking-[0.16em];
background: rgba(23, 32, 51, 0.04);
border-color: rgba(23, 32, 51, 0.08);
color: #172033;
}
.channel-suggestions {
@apply flex flex-wrap gap-2;
}
.placement-card,
.media-card {
@apply rounded-[1.25rem] border p-4;
background: #fffaf2;
border-color: rgba(23, 32, 51, 0.08);
}
.identity-row {
@apply flex items-center gap-3;
}
.identity-row.align-start {
@apply items-start;
}
.timeline-row {
@apply flex flex-col gap-3 rounded-[1rem] border p-4;
background: #f8fafc;
border-color: rgba(23, 32, 51, 0.08);
}
.timeline-actions {
@apply flex items-center justify-between gap-3;
}
.timeline-list.compact .timeline-row {
@apply p-3;
}
.link-button {
@apply text-sm font-semibold;
color: #0f766e;
}
@media (max-width: 1279px) {
.side-panel {
position: static;
max-height: none;
overflow: visible;
}
}
</style>