1052 lines
38 KiB
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>
|