feat: simplify content editor flow
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user