feat: simplify content editor flow
All checks were successful
deploy-socialize / image (push) Successful in 50s
deploy-socialize / deploy (push) Successful in 20s

This commit is contained in:
2026-05-09 11:25:11 -04:00
parent 581d286a1c
commit ebfa37f8cd
4 changed files with 42 additions and 122 deletions

View File

@@ -18,6 +18,9 @@ The editor should use one shared content body for every selected target channel,
- Show selected targets in a vertical rail and render preview cards that look closer to social platform previews.
- Convert shared hashtags into chip-style entry with suggestions from already used hashtags.
- Show a workspace hashtag feed so authors can reuse existing tags.
- Keep the editor focused on post text and target channels by removing confusing title, calendar, change summary, and base-caption fields.
- Make target preview tabs compact.
- Move the create content entry point into the main app menu.
- Preserve existing save payloads and backend contracts.
## Validation
@@ -36,3 +39,6 @@ npm run build
- [x] Hashtags commit to removable chips as they are entered.
- [x] Already used hashtags are available as suggestions.
- [x] The editor shows a workspace hashtag feed with usage counts.
- [x] The editor no longer shows title, calendar, change summary, or base-caption fields.
- [x] Target channel preview tabs use compact buttons.
- [x] Create content is available from the main app menu.

View File

@@ -105,6 +105,18 @@
const selectedPlacements = computed(() =>
form.placements.filter(placement => placement.channelId || placement.channelName || placement.network)
);
const derivedTitle = computed(() => {
const firstLine = form.body
.split('\n')
.map(line => line.trim())
.find(Boolean);
if (firstLine) {
return firstLine.length > 80 ? `${firstLine.slice(0, 77)}...` : firstLine;
}
return form.title.trim() || 'Untitled content';
});
const activePlacement = computed(() => {
if (!selectedPlacements.value.length) {
return null;
@@ -565,8 +577,8 @@
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.';
if (!form.body.trim() || !form.campaignId || !form.placements.length) {
saveError.message = 'Post text, campaign, and at least one channel are required.';
return;
}
@@ -576,12 +588,12 @@
}
const payload = {
title: form.title.trim(),
title: derivedTitle.value,
campaignId: form.campaignId,
publicationMessage: form.body.trim(),
publicationTargets: placementSummary.value,
hashtags: form.hashtags.trim(),
dueDate: form.dueDate ? new Date(form.dueDate).toISOString() : null,
dueDate: null,
};
if (isCreateMode.value) {
@@ -606,7 +618,7 @@
try {
await detailStore.createRevision(contentItemId.value, {
...payload,
changeSummary: form.changeSummary.trim() || 'Updated content plan',
changeSummary: 'Updated content',
});
persistDraft();
@@ -967,13 +979,8 @@
<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>
<h1>{{ isCreateMode ? 'Compose post' : 'Edit post' }}</h1>
<p>{{ placementSummary || 'Choose target channels and write the post once.' }}</p>
</div>
<div class="header-actions">
@@ -1018,97 +1025,20 @@
<main class="content-panel">
<div class="content-section">
<div class="section-title-row">
<strong>Content</strong>
<strong>Post</strong>
<span>{{ placementSummary || 'No channels selected yet' }}</span>
</div>
<div class="form-grid">
<v-text-field
v-model="form.title"
label="Title"
<v-textarea
v-model="form.body"
class="field-wide"
label="Post text"
rows="5"
variant="outlined"
hide-details
/>
<v-select
v-model="form.campaignId"
:items="availableCampaigns"
label="Campaign"
item-title="name"
item-value="id"
placeholder="Select a campaign"
variant="outlined"
hide-details
/>
<v-text-field
v-model="form.dueDate"
label="Due date"
type="date"
variant="outlined"
hide-details
/>
<div class="date-context field-wide">
<div class="date-context-days">
<v-btn variant="text" :ripple="false"
v-for="day in dateContextDays"
:key="day.key"
class="date-context-day"
:class="{
active: day.isSelected,
marked: day.events.length,
}"
type="button"
@click="selectContextDate(day.key)"
>
<span>{{ formatContextDay(day.date) }}</span>
<strong>{{ day.events.length }}</strong>
</v-btn>
</div>
<div
v-if="selectedDateCalendarEvents.length"
class="date-context-panel"
>
<v-btn variant="text" :ripple="false"
v-for="event in selectedDateCalendarEvents"
:key="event.id"
class="calendar-context-pill"
:style="{ '--calendar-color': calendarEventColor(event) }"
type="button"
@click="showCalendarEvent(event)"
>
{{ event.title }}
</v-btn>
</div>
<div
v-else-if="selectedDateKey"
class="date-context-empty"
>
{{ t('contentItems.dateContext.noEvents') }}
</div>
</div>
<v-text-field
v-model="form.changeSummary"
class="field-wide"
label="Change summary"
placeholder="What changed in this revision?"
variant="outlined"
hide-details
/>
<v-textarea
v-model="form.body"
class="field-wide"
label="Shared brief / base caption"
rows="4"
variant="outlined"
hide-details
/>
<label class="field field-wide">
<span>Shared hashtags</span>
<div class="hashtags-input-shell">
@@ -1916,7 +1846,7 @@
}
.target-channel {
@apply grid min-h-16 w-full grid-cols-[1.25rem_2rem_minmax(0,1fr)] items-center gap-3 rounded-[0.875rem] border px-3 py-2 text-left transition;
@apply grid min-h-11 w-full grid-cols-[1rem_1.75rem_minmax(0,1fr)] items-center gap-2 rounded-[0.75rem] border px-2.5 py-1.5 text-left transition;
background: var(--app-control-subtle);
border-color: transparent;
color: var(--app-color-on-surface);
@@ -1944,7 +1874,7 @@
.target-network,
.preview-avatar {
@apply grid h-8 w-8 place-items-center rounded-full;
@apply grid h-7 w-7 place-items-center rounded-full;
background: rgba(15, 118, 110, 0.12);
color: var(--app-color-on-tertiary);
}
@@ -1964,11 +1894,11 @@
}
.target-channel strong {
@apply text-sm font-bold;
@apply text-xs font-bold;
}
.target-channel small {
@apply text-xs;
@apply text-[0.68rem];
color: var(--app-text-muted);
}
@@ -1985,12 +1915,16 @@
}
.selected-preview-tab {
@apply inline-flex min-h-10 shrink-0 items-center gap-2 rounded-full border px-3 py-2 text-sm font-bold transition;
@apply inline-flex min-h-8 max-w-36 shrink-0 items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-bold transition;
background: var(--app-color-on-primary);
border-color: var(--app-border-subtle);
color: var(--app-color-on-surface);
}
.selected-preview-tab span {
@apply truncate;
}
.selected-preview-tab.active {
background: var(--app-color-on-surface);
border-color: var(--app-color-on-surface);

View File

@@ -97,16 +97,6 @@
}
switch (route.name) {
case 'workspace-dashboard':
case 'content-items':
return authStore.isManager || authStore.isProvider
? [{
key: 'create-content',
label: t('contentItems.newItem'),
icon: mdiPlus,
route: { name: 'content-item-create' },
}]
: [];
case 'campaigns':
return [{
key: 'create-campaign',

View File

@@ -57,6 +57,7 @@
const primaryLinks = [
{ to: '/app/dashboard', labelKey: 'nav.overview', icon: mdiHomeOutline },
{ to: { name: 'content-item-create' }, key: 'create-content', labelKey: 'contentItems.newItem', icon: mdiPlus },
{ to: '/app/media-library', labelKey: 'nav.mediaLibrary', icon: mdiImageMultipleOutline },
{ to: '/app/developer/release-notes', labelKey: 'nav.releaseNotes', icon: mdiSourceCommit, roles: ['developer'], badge: 'commits' },
];
@@ -475,7 +476,7 @@
<div class="sidebar-section sidebar-primary-links">
<v-btn
v-for="link in visiblePrimaryLinks"
:key="link.to"
:key="link.key ?? link.to"
:to="link.to"
class="sidebar-link sidebar-control"
active-class="sidebar-link-active sidebar-control-active"
@@ -527,17 +528,6 @@
</span>
</span>
</v-btn>
<v-btn
v-if="isExpanded"
:to="{ name: 'content-item-create' }"
class="sidebar-section-action sidebar-icon-button"
variant="text"
:ripple="false"
:title="t('contentItems.newItem')"
>
<v-icon :icon="mdiPlus" />
</v-btn>
</div>
</div>