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.
- Move the create content entry point into the top app menu bar.
- 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.
## 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] Create content is available from the top app menu bar.
- [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(() =>
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">
@@ -1000,7 +1078,6 @@
</div>
</label>
</div>
</div>
<div class="content-section preview-editor-section">
<div class="section-title-row">
@@ -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,6 +1147,28 @@
<span>{{ previewHandle(activePlacement) }}</span>
</div>
</div>
<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"
@@ -1078,18 +1178,37 @@
<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;
}