feat: edit content directly in previews
All checks were successful
deploy-socialize / image (push) Successful in 1m4s
deploy-socialize / deploy (push) Successful in 26s

This commit is contained in:
2026-05-09 12:21:27 -04:00
parent fc760736f8
commit ca68132546
2 changed files with 273 additions and 68 deletions

View File

@@ -22,6 +22,10 @@ The editor should use one shared content body for every selected target channel,
- Make target preview tabs compact. - Make target preview tabs compact.
- Move the create content entry point into the top app menu bar. - Move the create content entry point into the top app menu bar.
- Remove the asset-management tab from the content detail production panel. - Remove the asset-management tab from the content detail production panel.
- Move content editing into the channel preview cards instead of using a separate post text field.
- Keep target copy/title synchronized by default, with per-target opt-out.
- Show title editing only for networks that normally need titles, such as YouTube, Reddit, Website, and newsletters.
- Let each target choose its own media requirement.
- Preserve existing save payloads and backend contracts. - Preserve existing save payloads and backend contracts.
## Validation ## Validation
@@ -44,3 +48,7 @@ npm run build
- [x] Target channel tabs use compact icon-only buttons with channel names as accessible labels and tooltips. - [x] Target channel tabs use compact icon-only buttons with channel names as accessible labels and tooltips.
- [x] Create content is available from the top app menu bar. - [x] Create content is available from the top app menu bar.
- [x] Assets are no longer shown in the content detail production panel. - [x] Assets are no longer shown in the content detail production panel.
- [x] Authors edit the active channel preview directly.
- [x] Channel copy/title is synchronized by default and can be unsynchronized per target.
- [x] Title input only appears for title-oriented targets.
- [x] Each target can choose no media, image, video, clip, or carousel independently.

View File

@@ -96,8 +96,27 @@
const selectedPlacements = computed(() => const selectedPlacements = computed(() =>
form.placements.filter(placement => placement.channelId || placement.channelName || placement.network) 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 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') .split('\n')
.map(line => line.trim()) .map(line => line.trim())
.find(Boolean); .find(Boolean);
@@ -116,7 +135,6 @@
return selectedPlacements.value.find(placement => placement.id === activePlacementId.value) return selectedPlacements.value.find(placement => placement.id === activePlacementId.value)
?? selectedPlacements.value[0]; ?? 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 sharedPreviewTags = computed(() => parseHashtags(form.hashtags));
const workspaceHashtagFeed = computed(() => { const workspaceHashtagFeed = computed(() => {
const selected = new Set(sharedPreviewTags.value.map(tag => normalizeHashtag(tag).toLowerCase())); 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) { function blankPlacement(channel = null) {
return { return {
@@ -226,28 +247,15 @@
channelId: channel?.id ?? '', channelId: channel?.id ?? '',
channelName: channel?.name ?? '', channelName: channel?.name ?? '',
variantLabel: '', variantLabel: '',
title: form.title,
message: form.body, message: form.body,
isSynced: true,
hashtags: form.hashtags, hashtags: form.hashtags,
mediaKind: defaultMediaKind(channel?.network ?? ''),
mediaItems: [], 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) { function addPlacement(channel = null) {
if (channel) { if (channel) {
const existing = form.placements.find(placement => placement.channelId === channel.id); const existing = form.placements.find(placement => placement.channelId === channel.id);
@@ -288,12 +296,88 @@
return form.placements.some(placement => placement.channelId === channelId); return form.placements.some(placement => placement.channelId === channelId);
} }
function addMedia(placement) { function isPlacementSynced(placement) {
placement.mediaItems.push(blankMedia()); return placement?.isSynced !== false;
} }
function removeMedia(placement, mediaId) { function updatePlacementSync(placement, value) {
placement.mediaItems = placement.mediaItems.filter(media => media.id !== mediaId); 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) { function removeHashtag(tagToRemove) {
@@ -432,8 +516,11 @@
channelId, channelId,
channelName, channelName,
variantLabel: placement.variantLabel ?? '', variantLabel: placement.variantLabel ?? '',
message: form.body, title: placement.title ?? form.title,
hashtags: form.hashtags, message: placement.message ?? form.body,
isSynced: placement.isSynced !== false,
hashtags: placement.hashtags ?? form.hashtags,
mediaKind: placement.mediaKind ?? defaultMediaKind(network),
mediaItems: (placement.mediaItems ?? []).map(media => ({ mediaItems: (placement.mediaItems ?? []).map(media => ({
id: media.id ?? crypto.randomUUID(), id: media.id ?? crypto.randomUUID(),
mediaType: media.mediaType ?? 'Image', mediaType: media.mediaType ?? 'Image',
@@ -454,11 +541,7 @@
body: form.body, body: form.body,
hashtags: form.hashtags, hashtags: form.hashtags,
changeSummary: form.changeSummary, changeSummary: form.changeSummary,
placements: form.placements.map(placement => ({ placements: form.placements,
...placement,
message: form.body,
hashtags: form.hashtags,
})),
})); }));
} }
@@ -484,8 +567,11 @@
channelId: channel?.id ?? '', channelId: channel?.id ?? '',
channelName: channel?.name ?? target, channelName: channel?.name ?? target,
variantLabel: '', variantLabel: '',
title: item.value?.title ?? '',
message: item.value?.publicationMessage ?? '', message: item.value?.publicationMessage ?? '',
isSynced: true,
hashtags: item.value?.hashtags ?? '', hashtags: item.value?.hashtags ?? '',
mediaKind: defaultMediaKind(channel?.network ?? ''),
mediaItems: [], mediaItems: [],
}; };
}); });
@@ -563,7 +649,9 @@
async function saveContent() { async function saveContent() {
saveError.message = ''; 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.'; saveError.message = 'Post text, campaign, and at least one channel are required.';
return; return;
} }
@@ -576,7 +664,7 @@
const payload = { const payload = {
title: derivedTitle.value, title: derivedTitle.value,
campaignId: form.campaignId, campaignId: form.campaignId,
publicationMessage: form.body.trim(), publicationMessage: primaryPublicationMessage.value,
publicationTargets: placementSummary.value, publicationTargets: placementSummary.value,
hashtags: form.hashtags.trim(), hashtags: form.hashtags.trim(),
dueDate: null, dueDate: null,
@@ -934,16 +1022,6 @@
<span>{{ placementSummary || 'No channels selected yet' }}</span> <span>{{ placementSummary || 'No channels selected yet' }}</span>
</div> </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"> <label class="field field-wide">
<span>Shared hashtags</span> <span>Shared hashtags</span>
<div class="hashtags-input-shell"> <div class="hashtags-input-shell">
@@ -999,7 +1077,6 @@
</div> </div>
</div> </div>
</label> </label>
</div>
</div> </div>
<div class="content-section preview-editor-section"> <div class="content-section preview-editor-section">
@@ -1056,6 +1133,7 @@
<div class="preview-stage"> <div class="preview-stage">
<article <article
v-if="activePlacement" v-if="activePlacement"
:key="activePlacement.id"
class="social-preview-card" class="social-preview-card"
:class="`is-${previewNetworkClass(activePlacement.network)}`" :class="`is-${previewNetworkClass(activePlacement.network)}`"
> >
@@ -1069,27 +1147,68 @@
<span>{{ previewHandle(activePlacement) }}</span> <span>{{ previewHandle(activePlacement) }}</span>
</div> </div>
</div> </div>
<v-btn variant="text" :ripple="false" <div class="preview-controls">
class="preview-remove" <label class="sync-toggle">
type="button" <input
:aria-label="`Remove ${previewProfileName(activePlacement)}`" type="checkbox"
@click="removePlacement(activePlacement.id)" :checked="isPlacementSynced(activePlacement)"
> @change="updatePlacementSync(activePlacement, $event.target.checked)"
<v-icon :icon="mdiClose" /> />
</v-btn> <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>
<div <div
v-if="previewNetworkClass(activePlacement.network) === 'youtube'" v-if="previewNetworkClass(activePlacement.network) === 'youtube'"
class="youtube-preview" class="youtube-preview"
> >
<div class="youtube-player"> <div
v-if="activePlacement.mediaKind !== 'None'"
class="youtube-player"
>
<v-icon :icon="mdiYoutube" /> <v-icon :icon="mdiYoutube" />
<span>{{ activePlacement.mediaKind }}</span>
</div> </div>
<div class="youtube-meta"> <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> <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>
</div> </div>
@@ -1097,7 +1216,14 @@
v-else-if="previewNetworkClass(activePlacement.network) === 'x'" v-else-if="previewNetworkClass(activePlacement.network) === 'x'"
class="x-preview" 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 <div
v-if="sharedPreviewTags.length" v-if="sharedPreviewTags.length"
class="preview-tags" class="preview-tags"
@@ -1121,10 +1247,29 @@
v-else v-else
class="feed-preview" class="feed-preview"
> >
<div class="feed-media"> <div
v-if="activePlacement.mediaKind !== 'None'"
class="feed-media"
>
<v-icon :icon="channelIcon(activePlacement.network)" /> <v-icon :icon="channelIcon(activePlacement.network)" />
<span>{{ activePlacement.mediaKind }}</span>
</div> </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 <div
v-if="sharedPreviewTags.length" v-if="sharedPreviewTags.length"
class="preview-tags" class="preview-tags"
@@ -1652,6 +1797,29 @@
@apply justify-between; @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 { .preview-profile div:last-child {
@apply flex min-w-0 flex-col; @apply flex min-w-0 flex-col;
} }
@@ -1673,6 +1841,39 @@
color: var(--app-text-muted); 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, .youtube-preview,
.feed-preview, .feed-preview,
.x-preview { .x-preview {
@@ -1685,6 +1886,13 @@
color: #ef4444; 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) { .youtube-player :deep(.v-icon) {
@apply text-6xl; @apply text-6xl;
} }
@@ -1698,29 +1906,18 @@
color: var(--app-color-on-surface); 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 { .x-preview {
@apply rounded-[1rem] border p-4; @apply rounded-[1rem] border p-4;
border-color: var(--app-border-subtle); border-color: var(--app-border-subtle);
} }
.x-preview p {
@apply text-lg leading-8;
}
.x-engagement { .x-engagement {
@apply mt-auto grid grid-cols-4 gap-2 border-t pt-3 text-center; @apply mt-auto grid grid-cols-4 gap-2 border-t pt-3 text-center;
border-color: var(--app-border-subtle); border-color: var(--app-border-subtle);
} }
.feed-media { .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); background: linear-gradient(135deg, #0f766e, #f97316);
color: white; color: white;
} }