feat: edit content directly in previews
This commit is contained in:
@@ -96,8 +96,27 @@
|
||||
const selectedPlacements = computed(() =>
|
||||
form.placements.filter(placement => placement.channelId || placement.channelName || placement.network)
|
||||
);
|
||||
const primaryPublicationMessage = computed(() => {
|
||||
const syncedMessage = selectedPlacements.value
|
||||
.find(placement => isPlacementSynced(placement) && placement.message?.trim())
|
||||
?.message;
|
||||
const firstPlacementMessage = selectedPlacements.value
|
||||
.find(placement => placement.message?.trim())
|
||||
?.message;
|
||||
|
||||
return form.body.trim() || syncedMessage?.trim() || firstPlacementMessage?.trim() || '';
|
||||
});
|
||||
const derivedTitle = computed(() => {
|
||||
const firstLine = form.body
|
||||
const explicitTitle = form.title.trim() || selectedPlacements.value
|
||||
.find(placement => placement.title?.trim())
|
||||
?.title
|
||||
?.trim();
|
||||
|
||||
if (explicitTitle) {
|
||||
return explicitTitle.length > 80 ? `${explicitTitle.slice(0, 77)}...` : explicitTitle;
|
||||
}
|
||||
|
||||
const firstLine = primaryPublicationMessage.value
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.find(Boolean);
|
||||
@@ -116,7 +135,6 @@
|
||||
return selectedPlacements.value.find(placement => placement.id === activePlacementId.value)
|
||||
?? selectedPlacements.value[0];
|
||||
});
|
||||
const sharedPreviewText = computed(() => form.body.trim() || 'Draft the shared caption once. Every selected channel will preview this same content.');
|
||||
const sharedPreviewTags = computed(() => parseHashtags(form.hashtags));
|
||||
const workspaceHashtagFeed = computed(() => {
|
||||
const selected = new Set(sharedPreviewTags.value.map(tag => normalizeHashtag(tag).toLowerCase()));
|
||||
@@ -218,6 +236,9 @@
|
||||
};
|
||||
});
|
||||
});
|
||||
const activePlacementUsesTitle = computed(() =>
|
||||
activePlacement.value ? placementUsesTitle(activePlacement.value) : false
|
||||
);
|
||||
|
||||
function blankPlacement(channel = null) {
|
||||
return {
|
||||
@@ -226,28 +247,15 @@
|
||||
channelId: channel?.id ?? '',
|
||||
channelName: channel?.name ?? '',
|
||||
variantLabel: '',
|
||||
title: form.title,
|
||||
message: form.body,
|
||||
isSynced: true,
|
||||
hashtags: form.hashtags,
|
||||
mediaKind: defaultMediaKind(channel?.network ?? ''),
|
||||
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) {
|
||||
if (channel) {
|
||||
const existing = form.placements.find(placement => placement.channelId === channel.id);
|
||||
@@ -288,12 +296,88 @@
|
||||
return form.placements.some(placement => placement.channelId === channelId);
|
||||
}
|
||||
|
||||
function addMedia(placement) {
|
||||
placement.mediaItems.push(blankMedia());
|
||||
function isPlacementSynced(placement) {
|
||||
return placement?.isSynced !== false;
|
||||
}
|
||||
|
||||
function removeMedia(placement, mediaId) {
|
||||
placement.mediaItems = placement.mediaItems.filter(media => media.id !== mediaId);
|
||||
function updatePlacementSync(placement, value) {
|
||||
placement.isSynced = value;
|
||||
|
||||
if (value) {
|
||||
placement.title = form.title;
|
||||
placement.message = form.body;
|
||||
placement.hashtags = form.hashtags;
|
||||
}
|
||||
}
|
||||
|
||||
function placementMessage(placement) {
|
||||
if (!placement) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return isPlacementSynced(placement) ? form.body : placement.message;
|
||||
}
|
||||
|
||||
function updatePlacementMessage(placement, value) {
|
||||
if (isPlacementSynced(placement)) {
|
||||
form.body = value;
|
||||
syncSharedPlacements();
|
||||
return;
|
||||
}
|
||||
|
||||
placement.message = value;
|
||||
}
|
||||
|
||||
function placementTitle(placement) {
|
||||
if (!placement) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return isPlacementSynced(placement) ? form.title : placement.title;
|
||||
}
|
||||
|
||||
function updatePlacementTitle(placement, value) {
|
||||
if (isPlacementSynced(placement)) {
|
||||
form.title = value;
|
||||
syncSharedPlacements();
|
||||
return;
|
||||
}
|
||||
|
||||
placement.title = value;
|
||||
}
|
||||
|
||||
function syncSharedPlacements() {
|
||||
for (const placement of form.placements) {
|
||||
if (!isPlacementSynced(placement)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
placement.title = form.title;
|
||||
placement.message = form.body;
|
||||
placement.hashtags = form.hashtags;
|
||||
}
|
||||
}
|
||||
|
||||
function placementUsesTitle(placement) {
|
||||
const normalized = (placement?.network ?? '').toLowerCase();
|
||||
return normalized.includes('youtube') ||
|
||||
normalized.includes('reddit') ||
|
||||
normalized.includes('website') ||
|
||||
normalized.includes('newsletter');
|
||||
}
|
||||
|
||||
function defaultMediaKind(network) {
|
||||
const normalized = (network ?? '').toLowerCase();
|
||||
|
||||
if (normalized.includes('youtube') || normalized.includes('tiktok')) {
|
||||
return 'Video';
|
||||
}
|
||||
|
||||
if (normalized.includes('instagram')) {
|
||||
return 'Image';
|
||||
}
|
||||
|
||||
return 'None';
|
||||
}
|
||||
|
||||
function removeHashtag(tagToRemove) {
|
||||
@@ -432,8 +516,11 @@
|
||||
channelId,
|
||||
channelName,
|
||||
variantLabel: placement.variantLabel ?? '',
|
||||
message: form.body,
|
||||
hashtags: form.hashtags,
|
||||
title: placement.title ?? form.title,
|
||||
message: placement.message ?? form.body,
|
||||
isSynced: placement.isSynced !== false,
|
||||
hashtags: placement.hashtags ?? form.hashtags,
|
||||
mediaKind: placement.mediaKind ?? defaultMediaKind(network),
|
||||
mediaItems: (placement.mediaItems ?? []).map(media => ({
|
||||
id: media.id ?? crypto.randomUUID(),
|
||||
mediaType: media.mediaType ?? 'Image',
|
||||
@@ -454,11 +541,7 @@
|
||||
body: form.body,
|
||||
hashtags: form.hashtags,
|
||||
changeSummary: form.changeSummary,
|
||||
placements: form.placements.map(placement => ({
|
||||
...placement,
|
||||
message: form.body,
|
||||
hashtags: form.hashtags,
|
||||
})),
|
||||
placements: form.placements,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -484,8 +567,11 @@
|
||||
channelId: channel?.id ?? '',
|
||||
channelName: channel?.name ?? target,
|
||||
variantLabel: '',
|
||||
title: item.value?.title ?? '',
|
||||
message: item.value?.publicationMessage ?? '',
|
||||
isSynced: true,
|
||||
hashtags: item.value?.hashtags ?? '',
|
||||
mediaKind: defaultMediaKind(channel?.network ?? ''),
|
||||
mediaItems: [],
|
||||
};
|
||||
});
|
||||
@@ -563,7 +649,9 @@
|
||||
async function saveContent() {
|
||||
saveError.message = '';
|
||||
|
||||
if (!form.body.trim() || !form.campaignId || !form.placements.length) {
|
||||
syncSharedPlacements();
|
||||
|
||||
if (!primaryPublicationMessage.value || !form.campaignId || !form.placements.length) {
|
||||
saveError.message = 'Post text, campaign, and at least one channel are required.';
|
||||
return;
|
||||
}
|
||||
@@ -576,7 +664,7 @@
|
||||
const payload = {
|
||||
title: derivedTitle.value,
|
||||
campaignId: form.campaignId,
|
||||
publicationMessage: form.body.trim(),
|
||||
publicationMessage: primaryPublicationMessage.value,
|
||||
publicationTargets: placementSummary.value,
|
||||
hashtags: form.hashtags.trim(),
|
||||
dueDate: null,
|
||||
@@ -934,16 +1022,6 @@
|
||||
<span>{{ placementSummary || 'No channels selected yet' }}</span>
|
||||
</div>
|
||||
|
||||
<div class="form-grid">
|
||||
<v-textarea
|
||||
v-model="form.body"
|
||||
class="field-wide"
|
||||
label="Post text"
|
||||
rows="5"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
/>
|
||||
|
||||
<label class="field field-wide">
|
||||
<span>Shared hashtags</span>
|
||||
<div class="hashtags-input-shell">
|
||||
@@ -999,7 +1077,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content-section preview-editor-section">
|
||||
@@ -1056,6 +1133,7 @@
|
||||
<div class="preview-stage">
|
||||
<article
|
||||
v-if="activePlacement"
|
||||
:key="activePlacement.id"
|
||||
class="social-preview-card"
|
||||
:class="`is-${previewNetworkClass(activePlacement.network)}`"
|
||||
>
|
||||
@@ -1069,27 +1147,68 @@
|
||||
<span>{{ previewHandle(activePlacement) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<v-btn variant="text" :ripple="false"
|
||||
class="preview-remove"
|
||||
type="button"
|
||||
:aria-label="`Remove ${previewProfileName(activePlacement)}`"
|
||||
@click="removePlacement(activePlacement.id)"
|
||||
>
|
||||
<v-icon :icon="mdiClose" />
|
||||
</v-btn>
|
||||
<div class="preview-controls">
|
||||
<label class="sync-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="isPlacementSynced(activePlacement)"
|
||||
@change="updatePlacementSync(activePlacement, $event.target.checked)"
|
||||
/>
|
||||
<span>Sync</span>
|
||||
</label>
|
||||
|
||||
<select
|
||||
v-model="activePlacement.mediaKind"
|
||||
class="media-kind-select"
|
||||
aria-label="Media type"
|
||||
>
|
||||
<option value="None">No media</option>
|
||||
<option value="Image">Image</option>
|
||||
<option value="Video">Video</option>
|
||||
<option value="Clip">Clip</option>
|
||||
<option value="Carousel">Carousel</option>
|
||||
</select>
|
||||
|
||||
<v-btn variant="text" :ripple="false"
|
||||
class="preview-remove"
|
||||
type="button"
|
||||
:aria-label="`Remove ${previewProfileName(activePlacement)}`"
|
||||
@click="removePlacement(activePlacement.id)"
|
||||
>
|
||||
<v-icon :icon="mdiClose" />
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="previewNetworkClass(activePlacement.network) === 'youtube'"
|
||||
class="youtube-preview"
|
||||
>
|
||||
<div class="youtube-player">
|
||||
<div
|
||||
v-if="activePlacement.mediaKind !== 'None'"
|
||||
class="youtube-player"
|
||||
>
|
||||
<v-icon :icon="mdiYoutube" />
|
||||
<span>{{ activePlacement.mediaKind }}</span>
|
||||
</div>
|
||||
<div class="youtube-meta">
|
||||
<strong>{{ form.title || 'Untitled video' }}</strong>
|
||||
<input
|
||||
v-if="activePlacementUsesTitle"
|
||||
class="preview-title-input"
|
||||
type="text"
|
||||
:value="placementTitle(activePlacement)"
|
||||
placeholder="Video title"
|
||||
@input="updatePlacementTitle(activePlacement, $event.target.value)"
|
||||
/>
|
||||
<span>{{ previewProfileName(activePlacement) }}</span>
|
||||
<p>{{ sharedPreviewText }}</p>
|
||||
<div
|
||||
class="preview-copy-editor"
|
||||
contenteditable="true"
|
||||
role="textbox"
|
||||
aria-multiline="true"
|
||||
data-placeholder="Write the video description..."
|
||||
@input="updatePlacementMessage(activePlacement, $event.currentTarget.innerText)"
|
||||
>{{ placementMessage(activePlacement) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1097,7 +1216,14 @@
|
||||
v-else-if="previewNetworkClass(activePlacement.network) === 'x'"
|
||||
class="x-preview"
|
||||
>
|
||||
<p>{{ sharedPreviewText }}</p>
|
||||
<div
|
||||
class="preview-copy-editor is-x"
|
||||
contenteditable="true"
|
||||
role="textbox"
|
||||
aria-multiline="true"
|
||||
data-placeholder="Write the post..."
|
||||
@input="updatePlacementMessage(activePlacement, $event.currentTarget.innerText)"
|
||||
>{{ placementMessage(activePlacement) }}</div>
|
||||
<div
|
||||
v-if="sharedPreviewTags.length"
|
||||
class="preview-tags"
|
||||
@@ -1121,10 +1247,29 @@
|
||||
v-else
|
||||
class="feed-preview"
|
||||
>
|
||||
<div class="feed-media">
|
||||
<div
|
||||
v-if="activePlacement.mediaKind !== 'None'"
|
||||
class="feed-media"
|
||||
>
|
||||
<v-icon :icon="channelIcon(activePlacement.network)" />
|
||||
<span>{{ activePlacement.mediaKind }}</span>
|
||||
</div>
|
||||
<p>{{ sharedPreviewText }}</p>
|
||||
<input
|
||||
v-if="activePlacementUsesTitle"
|
||||
class="preview-title-input"
|
||||
type="text"
|
||||
:value="placementTitle(activePlacement)"
|
||||
placeholder="Post title"
|
||||
@input="updatePlacementTitle(activePlacement, $event.target.value)"
|
||||
/>
|
||||
<div
|
||||
class="preview-copy-editor"
|
||||
contenteditable="true"
|
||||
role="textbox"
|
||||
aria-multiline="true"
|
||||
data-placeholder="Write the post..."
|
||||
@input="updatePlacementMessage(activePlacement, $event.currentTarget.innerText)"
|
||||
>{{ placementMessage(activePlacement) }}</div>
|
||||
<div
|
||||
v-if="sharedPreviewTags.length"
|
||||
class="preview-tags"
|
||||
@@ -1652,6 +1797,29 @@
|
||||
@apply justify-between;
|
||||
}
|
||||
|
||||
.preview-controls {
|
||||
@apply flex flex-wrap items-center justify-end gap-2;
|
||||
}
|
||||
|
||||
.sync-toggle {
|
||||
@apply inline-flex min-h-8 items-center gap-2 rounded-full border px-3 py-1 text-xs font-bold;
|
||||
background: var(--app-control-subtle);
|
||||
border-color: var(--app-border-subtle);
|
||||
color: var(--app-color-on-surface);
|
||||
}
|
||||
|
||||
.sync-toggle input {
|
||||
@apply h-4 w-4 accent-teal-700;
|
||||
}
|
||||
|
||||
.media-kind-select {
|
||||
@apply h-8 rounded-full border px-3 text-xs font-bold;
|
||||
background: var(--app-color-on-primary);
|
||||
border-color: var(--app-border-subtle);
|
||||
color: var(--app-color-on-surface);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.preview-profile div:last-child {
|
||||
@apply flex min-w-0 flex-col;
|
||||
}
|
||||
@@ -1673,6 +1841,39 @@
|
||||
color: var(--app-text-muted);
|
||||
}
|
||||
|
||||
.preview-title-input {
|
||||
@apply w-full border-0 bg-transparent p-0 text-xl font-black;
|
||||
color: var(--app-color-on-surface);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.preview-title-input::placeholder {
|
||||
color: var(--app-text-muted);
|
||||
}
|
||||
|
||||
.preview-copy-editor {
|
||||
@apply min-h-28 whitespace-pre-line rounded-[0.875rem] border p-3 text-sm leading-6;
|
||||
background: rgba(248, 250, 252, 0.72);
|
||||
border-color: var(--app-border-subtle);
|
||||
color: var(--app-color-on-surface);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.preview-copy-editor:focus {
|
||||
border-color: rgba(15, 118, 110, 0.42);
|
||||
box-shadow: 0 0 0 3px rgba(15, 118, 110, 0.1);
|
||||
}
|
||||
|
||||
.preview-copy-editor:empty::before {
|
||||
content: attr(data-placeholder);
|
||||
color: var(--app-text-muted);
|
||||
}
|
||||
|
||||
.preview-copy-editor.is-x {
|
||||
@apply min-h-40 text-lg leading-8;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.youtube-preview,
|
||||
.feed-preview,
|
||||
.x-preview {
|
||||
@@ -1685,6 +1886,13 @@
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.youtube-player span,
|
||||
.feed-media span {
|
||||
@apply rounded-full px-3 py-1 text-xs font-black uppercase tracking-[0.14em];
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.youtube-player :deep(.v-icon) {
|
||||
@apply text-6xl;
|
||||
}
|
||||
@@ -1698,29 +1906,18 @@
|
||||
color: var(--app-color-on-surface);
|
||||
}
|
||||
|
||||
.youtube-meta p,
|
||||
.x-preview p,
|
||||
.feed-preview p {
|
||||
@apply whitespace-pre-line text-sm leading-6;
|
||||
color: var(--app-color-on-surface);
|
||||
}
|
||||
|
||||
.x-preview {
|
||||
@apply rounded-[1rem] border p-4;
|
||||
border-color: var(--app-border-subtle);
|
||||
}
|
||||
|
||||
.x-preview p {
|
||||
@apply text-lg leading-8;
|
||||
}
|
||||
|
||||
.x-engagement {
|
||||
@apply mt-auto grid grid-cols-4 gap-2 border-t pt-3 text-center;
|
||||
border-color: var(--app-border-subtle);
|
||||
}
|
||||
|
||||
.feed-media {
|
||||
@apply grid aspect-[4/5] w-full place-items-center rounded-[1rem];
|
||||
@apply grid aspect-[4/5] w-full place-items-center gap-3 rounded-[1rem];
|
||||
background: linear-gradient(135deg, #0f766e, #f97316);
|
||||
color: white;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user