feat(posts): lock scheduled posts behind explicit unschedule + extract PostEditorHeader

Scheduled posts were silently editable: auto-save fired on every keystroke
with status='scheduled', and the backend either silently failed (per-platform
content-length, our new rule) or accepted invalid changes that blew up at
publish time. Editing a queued post is also a race with the publisher job.

UX change: scheduled state now reads-only the editor and exposes one
explicit escape — Unschedule to edit. The post drops to draft, user edits,
re-schedules. Matches Buffer/Hootsuite/Later/Postiz.

Implementation:
- New isLocked computed (= isReadOnly || isScheduled) gates save(),
  triggerAutosave(), debouncedSave(), togglePlatform(). submit() stays on
  isReadOnly so unschedulePost('draft') can still transition out.
- Editor body wraps in pointer-events-none + opacity-60 when isScheduled,
  visually dimming inputs without per-input :disabled plumbing.
- Header swaps to a violet "This post is scheduled / Unschedule to edit"
  banner replacing the entire normal action bar. Delete is hidden in
  this state — only escape is Unschedule, then act on the draft.

Refactor: extracted the entire header into PostEditorHeader.vue (matches
the PostEditorComposer / PostEditorSidebar family). Single <header> root
with v-if/v-else branches by status. ~85 lines out of Edit.vue.

Cleanup: dropped orphan posts.edit.scheduled_for translation key from
en/pt-BR/es — replaced by the new scheduled_overlay_* keys.
This commit is contained in:
Paulo Castellano 2026-05-11 20:57:17 -03:00
parent 45b5b41923
commit b9636c6f49
8 changed files with 150 additions and 109 deletions

View file

@ -234,7 +234,6 @@
'cancel' => 'Cancel',
'delete' => 'Delete',
'schedule_for' => 'Schedule for',
'scheduled_for' => 'Scheduled · :date',
'schedule_date' => 'Schedule date',
'unschedule' => 'Unschedule',
'saving' => 'Saving...',

View file

@ -230,7 +230,6 @@
'schedule' => 'Programar',
'delete' => 'Eliminar',
'schedule_for' => 'Programar para',
'scheduled_for' => 'Programado · :date',
'unschedule' => 'Desprogramar',
'saving' => 'Guardando...',
'saved' => 'Guardado',

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -230,7 +230,6 @@
'schedule' => 'Agendar',
'delete' => 'Excluir',
'schedule_for' => 'Agendar para',
'scheduled_for' => 'Agendado · :date',
'unschedule' => 'Desagendar',
'saving' => 'Salvando...',
'saved' => 'Salvo',

View file

@ -0,0 +1,131 @@
<script setup lang="ts">
import { IconCalendar, IconCircleCheck, IconLoader2, IconTrash } from '@tabler/icons-vue';
import { computed } from 'vue';
import PickTimePopover from '@/components/posts/PickTimePopover.vue';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
interface Props {
post: { status: string };
isSaving: boolean;
showSaved: boolean;
isSubmitting: boolean;
isPostActionDisabled: boolean;
postActionTooltip: string;
pickTimeLabel: string;
}
const props = defineProps<Props>();
const hasPickedTime = defineModel<boolean>('hasPickedTime', { required: true });
const scheduledDateTime = defineModel<string>('scheduledDateTime', { required: true });
const emit = defineEmits<{
(e: 'delete'): void;
(e: 'unschedule'): void;
(e: 'submit', status: string): void;
}>();
const isReadOnly = computed(() => ['publishing', 'published', 'partially_published'].includes(props.post.status));
const isScheduled = computed(() => props.post.status === 'scheduled');
const isPublished = computed(() => ['published', 'partially_published'].includes(props.post.status));
</script>
<template>
<header
:class="[
'flex shrink-0 items-center gap-3 border-b-2 border-foreground px-4 py-3 md:px-6',
isScheduled ? 'bg-violet-100' : 'justify-between bg-card',
]"
>
<template v-if="isScheduled">
<div class="inline-flex size-9 shrink-0 items-center justify-center rounded-lg border-2 border-foreground bg-violet-200">
<IconCalendar class="size-4 text-foreground" stroke-width="2" />
</div>
<div class="flex-1 leading-tight">
<p class="text-sm font-semibold text-foreground">
{{ $t('posts.edit.scheduled_overlay_title') }}
</p>
<p class="text-xs text-foreground/70">
{{ $t('posts.edit.scheduled_overlay_subtitle', { date: pickTimeLabel }) }}
</p>
</div>
<Button
type="button"
variant="outline"
class="bg-background hover:bg-violet-50"
:disabled="isSubmitting"
@click="emit('unschedule')"
>
{{ $t('posts.edit.unschedule_cta') }}
</Button>
</template>
<template v-else>
<div class="flex items-center gap-3 pl-12 md:pl-0">
<span v-if="isSaving" class="flex items-center gap-1.5 text-xs font-semibold text-foreground/70">
<IconLoader2 class="size-3.5 animate-spin" />
{{ $t('posts.edit.saving') }}
</span>
<span v-else-if="showSaved" class="flex items-center gap-1.5 text-xs font-semibold text-emerald-700">
<IconCircleCheck class="size-3.5" stroke-width="2.5" />
{{ $t('posts.edit.saved') }}
</span>
<span v-else-if="isPublished" class="flex items-center gap-1.5 text-xs font-semibold text-emerald-700">
<IconCircleCheck class="size-3.5" stroke-width="2.5" />
{{ $t('posts.edit.status.published') }}
</span>
<span v-else class="flex items-center gap-1.5 text-xs font-semibold text-foreground/60">
<span class="size-2 rounded-full bg-foreground/40" />
{{ $t('posts.edit.draft') }}
</span>
</div>
<div v-if="!isReadOnly" class="flex items-center gap-2">
<TooltipProvider>
<Tooltip>
<TooltipTrigger as-child>
<Button
type="button"
variant="outline"
size="icon"
class="bg-rose-100 hover:bg-rose-200"
:disabled="isSaving || isSubmitting"
@click="emit('delete')"
>
<IconTrash class="size-4 text-rose-700" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ $t('posts.edit.delete') }}</TooltipContent>
</Tooltip>
</TooltipProvider>
<PickTimePopover
v-model="scheduledDateTime"
:disabled="isPostActionDisabled"
@confirm="hasPickedTime = true"
>
<Button
type="button"
variant="outline"
:disabled="isPostActionDisabled"
:title="postActionTooltip"
>
<IconCalendar class="size-4" />
{{ pickTimeLabel }}
</Button>
</PickTimePopover>
<Button
type="button"
:disabled="isPostActionDisabled"
:title="postActionTooltip"
@click="emit('submit', hasPickedTime ? 'scheduled' : 'publishing')"
>
{{ hasPickedTime ? $t('posts.edit.schedule') : $t('posts.edit.post_now') }}
</Button>
</div>
</template>
</header>
</template>

View file

@ -1,11 +1,6 @@
<script setup lang="ts">
import { Head, router } from '@inertiajs/vue3';
import {
IconCalendar,
IconCircleCheck,
IconLoader2,
IconTrash,
} from '@tabler/icons-vue';
import { IconLoader2 } from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n';
import { computed, onUnmounted, ref, watch } from 'vue';
@ -13,10 +8,8 @@ import ConfirmDeleteModal from '@/components/ConfirmDeleteModal.vue';
import AiGenerateDialog from '@/components/posts/ai/AiGenerateDialog.vue';
import AiReviewDialog from '@/components/posts/ai/AiReviewDialog.vue';
import PostEditorComposer from '@/components/posts/editor/PostEditorComposer.vue';
import PostEditorHeader from '@/components/posts/editor/PostEditorHeader.vue';
import PostEditorSidebar from '@/components/posts/editor/PostEditorSidebar.vue';
import PickTimePopover from '@/components/posts/PickTimePopover.vue';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { usePostEcho } from '@/composables/echo/usePostEcho';
import { getMediaItemIssue } from '@/composables/useMedia';
import { getMediaRulesForContentType } from '@/composables/useMediaRules';
@ -519,100 +512,20 @@ usePostEcho(post.value.id, '.post.comment.created', (e: any) => {
<AppLayout :full-width="true">
<div class="flex flex-col flex-1 min-h-0">
<header
v-if="isScheduled"
class="flex shrink-0 items-center gap-3 border-b-2 border-foreground bg-violet-100 px-4 py-3 md:px-6"
>
<div class="inline-flex size-9 shrink-0 items-center justify-center rounded-lg border-2 border-foreground bg-violet-200">
<IconCalendar class="size-4 text-foreground" stroke-width="2" />
</div>
<div class="flex-1 leading-tight">
<p class="text-sm font-semibold text-foreground">
{{ $t('posts.edit.scheduled_overlay_title') }}
</p>
<p class="text-xs text-foreground/70">
{{ $t('posts.edit.scheduled_overlay_subtitle', { date: pickTimeLabel }) }}
</p>
</div>
<Button
type="button"
variant="outline"
class="bg-background hover:bg-violet-50"
:disabled="isSubmitting"
@click="unschedulePost"
>
{{ $t('posts.edit.unschedule_cta') }}
</Button>
</header>
<header
v-else
class="flex shrink-0 items-center justify-between gap-3 border-b-2 border-foreground bg-card px-4 py-3 md:px-6"
>
<div class="flex items-center gap-3 pl-12 md:pl-0">
<span v-if="isSaving" class="flex items-center gap-1.5 text-xs font-semibold text-foreground/70">
<IconLoader2 class="size-3.5 animate-spin" />
{{ $t('posts.edit.saving') }}
</span>
<span v-else-if="showSaved" class="flex items-center gap-1.5 text-xs font-semibold text-emerald-700">
<IconCircleCheck class="size-3.5" stroke-width="2.5" />
{{ $t('posts.edit.saved') }}
</span>
<span v-else-if="isPublished" class="flex items-center gap-1.5 text-xs font-semibold text-emerald-700">
<IconCircleCheck class="size-3.5" stroke-width="2.5" />
{{ $t('posts.edit.status.published') }}
</span>
<span v-else class="flex items-center gap-1.5 text-xs font-semibold text-foreground/60">
<span class="size-2 rounded-full bg-foreground/40" />
{{ $t('posts.edit.draft') }}
</span>
</div>
<div v-if="!isReadOnly" class="flex items-center gap-2">
<TooltipProvider>
<Tooltip>
<TooltipTrigger as-child>
<Button
type="button"
variant="outline"
size="icon"
class="bg-rose-100 hover:bg-rose-200"
:disabled="isSaving || isSubmitting"
@click="deletePost"
>
<IconTrash class="size-4 text-rose-700" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ $t('posts.edit.delete') }}</TooltipContent>
</Tooltip>
</TooltipProvider>
<PickTimePopover
v-model="scheduledDateTime"
:disabled="isPostActionDisabled"
@confirm="hasPickedTime = true"
>
<Button
type="button"
variant="outline"
:disabled="isPostActionDisabled"
:title="postActionTooltip"
>
<IconCalendar class="size-4" />
{{ pickTimeLabel }}
</Button>
</PickTimePopover>
<Button
type="button"
:disabled="isPostActionDisabled"
:title="postActionTooltip"
@click="submit(hasPickedTime ? 'scheduled' : 'publishing')"
>
{{ hasPickedTime ? $t('posts.edit.schedule') : $t('posts.edit.post_now') }}
</Button>
</div>
</header>
<PostEditorHeader
:post="post"
:is-saving="isSaving"
:show-saved="showSaved"
:is-submitting="isSubmitting"
:is-post-action-disabled="isPostActionDisabled"
:post-action-tooltip="postActionTooltip"
:pick-time-label="pickTimeLabel"
v-model:has-picked-time="hasPickedTime"
v-model:scheduled-date-time="scheduledDateTime"
@delete="deletePost"
@unschedule="unschedulePost"
@submit="submit"
/>
<div class="relative flex-1 overflow-hidden">
<div