feat(posts): make the post editor usable on mobile

- Top switcher (Compose / Channels / Preview / Comments) reveals the panel that was hidden below lg
- Sticky bottom action bar puts schedule/publish/delete within thumb reach
- Composer media actions and comment toolbar are visible on touch (no hover needed); media grid is 3-up
- Clamp media picker and emoji picker widths to the viewport
This commit is contained in:
Paulo Castellano 2026-07-17 18:38:20 -03:00
parent a92a87ac46
commit b7b3809790
27 changed files with 420 additions and 102 deletions

View file

@ -363,6 +363,7 @@
'unschedule_cta' => 'ألغِ الجدولة للتعديل',
'tabs' => [
'compose' => 'الكتابة',
'preview' => 'معاينة',
'channels' => 'القنوات',
'comments' => 'التعليقات',

View file

@ -365,6 +365,7 @@
'unschedule_cta' => 'Zum Bearbeiten Planung aufheben',
'tabs' => [
'compose' => 'Verfassen',
'preview' => 'Vorschau',
'channels' => 'Kanäle',
'comments' => 'Kommentare',

View file

@ -363,6 +363,7 @@
'unschedule_cta' => 'Αναίρεση προγραμματισμού για επεξεργασία',
'tabs' => [
'compose' => 'Σύνταξη',
'preview' => 'Προεπισκόπηση',
'channels' => 'Κανάλια',
'comments' => 'Σχόλια',

View file

@ -363,6 +363,7 @@
'unschedule_cta' => 'Unschedule to edit',
'tabs' => [
'compose' => 'Compose',
'preview' => 'Preview',
'channels' => 'Channels',
'comments' => 'Comments',

View file

@ -363,6 +363,7 @@
'unschedule_cta' => 'Cancelar para editar',
'tabs' => [
'compose' => 'Redactar',
'preview' => 'Vista previa',
'channels' => 'Canales',
'comments' => 'Comentarios',

View file

@ -363,6 +363,7 @@
'unschedule_cta' => 'Déprogrammer pour modifier',
'tabs' => [
'compose' => 'Rédiger',
'preview' => 'Aperçu',
'channels' => 'Canaux',
'comments' => 'Commentaires',

View file

@ -363,6 +363,7 @@
'unschedule_cta' => 'Annulla programmazione per modificare',
'tabs' => [
'compose' => 'Componi',
'preview' => 'Anteprima',
'channels' => 'Canali',
'comments' => 'Commenti',

View file

@ -363,6 +363,7 @@
'unschedule_cta' => '予約を解除して編集',
'tabs' => [
'compose' => '作成',
'preview' => 'プレビュー',
'channels' => 'チャンネル',
'comments' => 'コメント',

View file

@ -363,6 +363,7 @@
'unschedule_cta' => '편집하려면 예약 취소',
'tabs' => [
'compose' => '작성',
'preview' => '미리보기',
'channels' => '채널',
'comments' => '댓글',

View file

@ -363,6 +363,7 @@
'unschedule_cta' => 'Planning opheffen om te bewerken',
'tabs' => [
'compose' => 'Opstellen',
'preview' => 'Voorbeeld',
'channels' => 'Kanalen',
'comments' => 'Reacties',

View file

@ -363,6 +363,7 @@
'unschedule_cta' => 'Anuluj planowanie, aby edytować',
'tabs' => [
'compose' => 'Utwórz',
'preview' => 'Podgląd',
'channels' => 'Kanały',
'comments' => 'Komentarze',

View file

@ -363,6 +363,7 @@
'unschedule_cta' => 'Desagendar para editar',
'tabs' => [
'compose' => 'Escrever',
'preview' => 'Pré-visualização',
'channels' => 'Canais',
'comments' => 'Comentários',

View file

@ -363,6 +363,7 @@
'unschedule_cta' => 'Отменить планирование для редактирования',
'tabs' => [
'compose' => 'Написать',
'preview' => 'Предпросмотр',
'channels' => 'Каналы',
'comments' => 'Комментарии',

View file

@ -365,6 +365,7 @@
'unschedule_cta' => 'Düzenlemek için zamanlamayı kaldır',
'tabs' => [
'compose' => 'Oluştur',
'preview' => 'Önizleme',
'channels' => 'Kanallar',
'comments' => 'Yorumlar',

View file

@ -363,6 +363,7 @@
'unschedule_cta' => '取消排期以编辑',
'tabs' => [
'compose' => '撰写',
'preview' => '预览',
'channels' => '渠道',
'comments' => '评论',

View file

@ -121,7 +121,7 @@ onBeforeUnmount(() => {
</script>
<template>
<div class="flex w-[340px] flex-col overflow-hidden rounded-[10px] bg-card text-foreground">
<div class="flex w-[min(340px,calc(100vw-2rem))] flex-col overflow-hidden rounded-[10px] bg-card text-foreground">
<div class="border-b-2 border-foreground/10 p-2">
<Input
v-model="search"

View file

@ -50,7 +50,7 @@ defineExpose({ open, close });
<template>
<Dialog v-model:open="isOpen">
<DialogContent class="flex h-[85vh] max-w-5xl flex-col gap-0 p-0 sm:max-w-5xl">
<DialogContent class="flex h-[85vh] max-w-[calc(100%-2rem)] flex-col gap-0 p-0 sm:max-w-5xl">
<DialogHeader class="border-b px-6 py-4">
<DialogTitle>{{ trans('posts.edit.media_picker.title') }}</DialogTitle>
</DialogHeader>

View file

@ -556,19 +556,19 @@ watch(() => props.postId, () => {
<!-- Floating toolbar -->
<div
v-if="hoveredCommentId === comment.id && editingComment?.id !== comment.id"
class="absolute -top-3 right-2 z-50 flex items-center gap-0.5 rounded-md border-2 border-foreground bg-card px-1 py-0.5 shadow-2xs"
v-if="editingComment?.id !== comment.id"
class="absolute -top-3 right-2 z-50 flex items-center gap-0.5 rounded-md border-2 border-foreground bg-card px-1 py-0.5 opacity-100 shadow-2xs transition-opacity lg:opacity-0 lg:group-hover:opacity-100 lg:group-focus-within:opacity-100"
>
<TooltipProvider :delay-duration="200">
<Tooltip>
<TooltipTrigger as-child>
<button class="rounded p-1 text-muted-foreground hover:bg-foreground/5 hover:text-foreground" @mouseenter="showEmojiPicker(comment.id)"><IconMoodSmile class="h-3.5 w-3.5" /></button>
<button class="rounded p-1 text-muted-foreground hover:bg-foreground/5 hover:text-foreground" @click="showEmojiPicker(comment.id)"><IconMoodSmile class="h-3.5 w-3.5" /></button>
</TooltipTrigger>
<TooltipContent side="top" class="text-xs">React</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<button class="rounded p-1 text-muted-foreground hover:bg-foreground/5 hover:text-foreground" @click="startReply(comment)"><IconArrowBackUp class="h-3.5 w-3.5" /></button>
<button data-testid="comment-reply" class="rounded p-1 text-muted-foreground hover:bg-foreground/5 hover:text-foreground" @click="startReply(comment)"><IconArrowBackUp class="h-3.5 w-3.5" /></button>
</TooltipTrigger>
<TooltipContent side="top" class="text-xs">{{ $t('comments.reply') }}</TooltipContent>
</Tooltip>
@ -669,13 +669,13 @@ watch(() => props.postId, () => {
<!-- Reply floating toolbar -->
<div
v-if="hoveredCommentId === reply.id && editingComment?.id !== reply.id"
class="absolute -top-3 right-2 z-50 flex items-center gap-0.5 rounded-md border-2 border-foreground bg-card px-1 py-0.5 shadow-2xs"
v-if="editingComment?.id !== reply.id"
class="absolute -top-3 right-2 z-50 flex items-center gap-0.5 rounded-md border-2 border-foreground bg-card px-1 py-0.5 opacity-100 shadow-2xs transition-opacity lg:opacity-0 lg:group-hover:opacity-100 lg:group-focus-within:opacity-100"
>
<TooltipProvider :delay-duration="200">
<Tooltip>
<TooltipTrigger as-child>
<button class="rounded p-1 text-muted-foreground hover:bg-foreground/5 hover:text-foreground" @mouseenter="showEmojiPicker(reply.id)"><IconMoodSmile class="h-3.5 w-3.5" /></button>
<button class="rounded p-1 text-muted-foreground hover:bg-foreground/5 hover:text-foreground" @click="showEmojiPicker(reply.id)"><IconMoodSmile class="h-3.5 w-3.5" /></button>
</TooltipTrigger>
<TooltipContent side="top" class="text-xs">React</TooltipContent>
</Tooltip>

View file

@ -0,0 +1,52 @@
<script setup lang="ts">
import PostEditorActions from '@/components/posts/editor/PostEditorActions.vue';
withDefaults(
defineProps<{
isReadOnly: boolean;
isScheduled: boolean;
canEdit?: boolean;
isSaving: boolean;
isSubmitting: boolean;
isPostActionDisabled: boolean;
postActionTooltip: string;
pickTimeLabel: string;
}>(),
{
canEdit: true,
},
);
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;
}>();
</script>
<template>
<div
v-if="canEdit && !isReadOnly"
data-testid="editor-action-bar"
class="sticky bottom-0 z-30 flex items-center justify-end gap-2 border-t-2 border-foreground bg-card px-4 py-3 lg:hidden"
>
<PostEditorActions
:is-read-only="isReadOnly"
:is-scheduled="isScheduled"
:can-edit="canEdit"
:is-saving="isSaving"
: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="emit('delete')"
@unschedule="emit('unschedule')"
@submit="emit('submit', $event)"
/>
</div>
</template>

View file

@ -0,0 +1,116 @@
<script setup lang="ts">
import { IconCalendar, IconTrash } from '@tabler/icons-vue';
import { computed } from 'vue';
import InputError from '@/components/InputError.vue';
import PickTimePopover from '@/components/posts/PickTimePopover.vue';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { usePageErrors } from '@/composables/usePageErrors';
import { PostStatus } from '@/types/post';
withDefaults(
defineProps<{
isReadOnly: boolean;
isScheduled: boolean;
canEdit?: boolean;
isSaving: boolean;
isSubmitting: boolean;
isPostActionDisabled: boolean;
postActionTooltip: string;
pickTimeLabel: string;
}>(),
{
canEdit: true,
},
);
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 errors = usePageErrors();
const scheduledAtError = computed(() => errors.value.scheduled_at);
</script>
<template>
<Button
v-if="isScheduled && canEdit"
type="button"
variant="outline"
class="bg-background hover:bg-violet-50"
:disabled="isSubmitting"
@click="emit('unschedule')"
>
{{ $t('posts.edit.unschedule_cta') }}
</Button>
<div v-else-if="!isReadOnly && canEdit" class="flex flex-col items-end gap-1">
<div 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>
<Tooltip>
<TooltipTrigger as-child>
<span tabindex="0">
<PickTimePopover
v-model="scheduledDateTime"
:disabled="isPostActionDisabled"
@confirm="hasPickedTime = true"
>
<Button
type="button"
variant="outline"
:disabled="isPostActionDisabled"
>
<IconCalendar class="size-4" />
{{ pickTimeLabel }}
</Button>
</PickTimePopover>
</span>
</TooltipTrigger>
<TooltipContent v-if="postActionTooltip" class="max-w-xs whitespace-pre-line">
{{ postActionTooltip }}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<span tabindex="0">
<Button
type="button"
:disabled="isPostActionDisabled"
@click="emit('submit', hasPickedTime ? PostStatus.Scheduled : PostStatus.Publishing)"
>
{{ hasPickedTime ? $t('posts.edit.schedule') : $t('posts.edit.post_now') }}
</Button>
</span>
</TooltipTrigger>
<TooltipContent v-if="postActionTooltip" class="max-w-xs whitespace-pre-line">
{{ postActionTooltip }}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<InputError :message="scheduledAtError" />
</div>
</template>

View file

@ -237,7 +237,7 @@ const onAltTextSave = (alt: string): void => {
<div class="relative">
<!-- Media grid (top) always shown so "Add" tile is discoverable -->
<div v-if="!readOnly || media.length" class="mb-6">
<div class="grid grid-cols-4 gap-2">
<div class="grid grid-cols-3 gap-2 sm:grid-cols-4">
<div
v-for="(item, index) in media"
:key="item.id"
@ -320,7 +320,7 @@ const onAltTextSave = (alt: string): void => {
<span
v-if="media.length > 1 && !readOnly"
class="absolute left-1.5 top-1.5 inline-flex size-6 cursor-grab items-center justify-center rounded-md border-2 border-foreground bg-card text-foreground opacity-0 shadow-2xs transition-opacity group-hover:opacity-100 group-focus:opacity-100"
class="absolute left-1.5 top-1.5 inline-flex size-6 cursor-grab items-center justify-center rounded-md border-2 border-foreground bg-card text-foreground opacity-100 shadow-2xs lg:opacity-0 transition-opacity lg:group-hover:opacity-100 lg:group-focus:opacity-100"
>
<IconGripVertical class="size-3.5" />
</span>
@ -328,7 +328,7 @@ const onAltTextSave = (alt: string): void => {
<button
v-if="canRegenerateWithAi(item)"
type="button"
class="absolute bottom-1.5 left-1.5 inline-flex h-6 cursor-pointer items-center gap-1 rounded-md border-2 border-foreground bg-card px-1.5 text-[10px] font-semibold text-foreground opacity-0 shadow-2xs transition-all hover:bg-violet-100 group-hover:opacity-100 group-focus:opacity-100"
class="absolute bottom-1.5 left-1.5 inline-flex h-6 cursor-pointer items-center gap-1 rounded-md border-2 border-foreground bg-card px-1.5 text-[10px] font-semibold text-foreground opacity-100 shadow-2xs lg:opacity-0 transition-all hover:bg-violet-100 lg:group-hover:opacity-100 lg:group-focus:opacity-100"
@click.stop="emit('open-ai-regenerate-image', item.id)"
>
<IconRefresh class="size-3" />
@ -340,7 +340,7 @@ const onAltTextSave = (alt: string): void => {
type="button"
:title="$t('posts.edit.alt_text.edit')"
:aria-label="$t('posts.edit.alt_text.edit')"
class="absolute right-9 top-1.5 inline-flex size-6 cursor-pointer items-center justify-center rounded-md border-2 border-foreground bg-card text-foreground opacity-0 shadow-2xs transition-all hover:bg-violet-100 group-hover:opacity-100 group-focus:opacity-100"
class="absolute right-9 top-1.5 inline-flex size-6 cursor-pointer items-center justify-center rounded-md border-2 border-foreground bg-card text-foreground opacity-100 shadow-2xs lg:opacity-0 transition-all hover:bg-violet-100 lg:group-hover:opacity-100 lg:group-focus:opacity-100"
data-testid="alt-text-button"
@click.stop="openAltText(index)"
>
@ -350,7 +350,8 @@ const onAltTextSave = (alt: string): void => {
<button
v-if="!readOnly"
type="button"
class="absolute right-1.5 top-1.5 inline-flex size-6 cursor-pointer items-center justify-center rounded-md border-2 border-foreground bg-card text-foreground opacity-0 shadow-2xs transition-all hover:bg-rose-100 hover:text-rose-700 group-hover:opacity-100 group-focus:opacity-100"
class="absolute right-1.5 top-1.5 inline-flex size-6 cursor-pointer items-center justify-center rounded-md border-2 border-foreground bg-card text-foreground opacity-100 shadow-2xs lg:opacity-0 transition-all hover:bg-rose-100 hover:text-rose-700 lg:group-hover:opacity-100 lg:group-focus:opacity-100"
data-testid="media-remove"
@click.stop="removeMedia(item.id)"
>
<IconTrash class="size-3.5" />

View file

@ -1,12 +1,8 @@
<script setup lang="ts">
import { IconCalendar, IconCircleCheck, IconLoader2, IconTrash } from '@tabler/icons-vue';
import { IconCalendar, IconCircleCheck, IconLoader2 } from '@tabler/icons-vue';
import { computed } from 'vue';
import InputError from '@/components/InputError.vue';
import PickTimePopover from '@/components/posts/PickTimePopover.vue';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { usePageErrors } from '@/composables/usePageErrors';
import PostEditorActions from '@/components/posts/editor/PostEditorActions.vue';
import { PostStatus } from '@/types/post';
interface Props {
@ -39,9 +35,6 @@ const PUBLISHED_STATUSES: readonly string[] = [PostStatus.Published, PostStatus.
const isReadOnly = computed(() => READONLY_STATUSES.includes(props.post.status));
const isScheduled = computed(() => props.post.status === PostStatus.Scheduled);
const isPublished = computed(() => PUBLISHED_STATUSES.includes(props.post.status));
const errors = usePageErrors();
const scheduledAtError = computed(() => errors.value.scheduled_at);
</script>
<template>
@ -63,16 +56,23 @@ const scheduledAtError = computed(() => errors.value.scheduled_at);
{{ $t('posts.edit.scheduled_overlay_subtitle', { date: pickTimeLabel }) }}
</p>
</div>
<Button
v-if="canEdit"
type="button"
variant="outline"
class="bg-background hover:bg-violet-50"
:disabled="isSubmitting"
@click="emit('unschedule')"
>
{{ $t('posts.edit.unschedule_cta') }}
</Button>
<div class="hidden lg:flex">
<PostEditorActions
:is-read-only="isReadOnly"
:is-scheduled="isScheduled"
:can-edit="canEdit"
:is-saving="isSaving"
: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="emit('delete')"
@unschedule="emit('unschedule')"
@submit="emit('submit', $event)"
/>
</div>
</template>
<template v-else>
@ -95,68 +95,22 @@ const scheduledAtError = computed(() => errors.value.scheduled_at);
</span>
</div>
<div v-if="!isReadOnly && canEdit" class="flex flex-col items-end gap-1">
<div 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>
<Tooltip>
<TooltipTrigger as-child>
<span tabindex="0">
<PickTimePopover
v-model="scheduledDateTime"
:disabled="isPostActionDisabled"
@confirm="hasPickedTime = true"
>
<Button
type="button"
variant="outline"
:disabled="isPostActionDisabled"
>
<IconCalendar class="size-4" />
{{ pickTimeLabel }}
</Button>
</PickTimePopover>
</span>
</TooltipTrigger>
<TooltipContent v-if="postActionTooltip" class="max-w-xs whitespace-pre-line">
{{ postActionTooltip }}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<span tabindex="0">
<Button
type="button"
:disabled="isPostActionDisabled"
@click="emit('submit', hasPickedTime ? PostStatus.Scheduled : PostStatus.Publishing)"
>
{{ hasPickedTime ? $t('posts.edit.schedule') : $t('posts.edit.post_now') }}
</Button>
</span>
</TooltipTrigger>
<TooltipContent v-if="postActionTooltip" class="max-w-xs whitespace-pre-line">
{{ postActionTooltip }}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<InputError :message="scheduledAtError" />
<div class="hidden lg:flex">
<PostEditorActions
:is-read-only="isReadOnly"
:is-scheduled="isScheduled"
:can-edit="canEdit"
:is-saving="isSaving"
: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="emit('delete')"
@unschedule="emit('unschedule')"
@submit="emit('submit', $event)"
/>
</div>
</template>
</header>

View file

@ -0,0 +1,33 @@
<script setup lang="ts">
type MobileView = 'compose' | 'channels' | 'preview' | 'comments';
const activeView = defineModel<MobileView>('activeView', { required: true });
const items: { key: MobileView; label: string }[] = [
{ key: 'compose', label: 'posts.edit.tabs.compose' },
{ key: 'channels', label: 'posts.edit.tabs.channels' },
{ key: 'preview', label: 'posts.edit.tabs.preview' },
{ key: 'comments', label: 'posts.edit.tabs.comments' },
];
</script>
<template>
<div
data-testid="editor-mobile-nav"
class="flex shrink-0 gap-1 overflow-x-auto border-b-2 border-foreground bg-card px-2 py-2 lg:hidden [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
>
<button
v-for="item in items"
:key="item.key"
type="button"
:data-testid="`editor-nav-${item.key}`"
class="shrink-0 rounded-lg border-2 px-3 py-1.5 text-sm font-semibold transition-colors"
:class="activeView === item.key
? 'border-foreground bg-violet-100 text-foreground'
: 'border-transparent text-foreground/60 hover:text-foreground'"
@click="activeView = item.key"
>
{{ $t(item.label) }}
</button>
</div>
</template>

View file

@ -91,7 +91,7 @@ defineExpose({
<template>
<Tabs v-model="activeTab" class="h-full flex flex-col">
<TabsList class="mx-4 mt-4 w-fit shrink-0 self-start">
<TabsList class="mx-4 mt-4 hidden w-fit shrink-0 self-start lg:inline-flex">
<TabsTrigger value="preview">{{ $t('posts.edit.tabs.preview') }}</TabsTrigger>
<TabsTrigger value="schedule">{{ $t('posts.edit.tabs.channels') }}</TabsTrigger>
<TabsTrigger value="comments">{{ $t('posts.edit.tabs.comments') }}</TabsTrigger>
@ -107,7 +107,7 @@ defineExpose({
/>
</TabsContent>
<TabsContent value="schedule" force-mount :class="['flex-1 overflow-y-auto p-4', { hidden: activeTab !== 'schedule' }]">
<TabsContent value="schedule" force-mount data-testid="channels-panel" :class="['flex-1 overflow-y-auto p-4', { hidden: activeTab !== 'schedule' }]">
<ScheduleTab
:post-platforms="post.post_platforms"
:selected-platform-ids="selectedPlatformIds"

View file

@ -173,11 +173,11 @@ const channels = computed<Channel[]>(() =>
:key="pp.id"
class="flex items-center justify-between rounded-xl border-2 border-foreground bg-card p-3 shadow-2xs"
>
<div class="flex items-center gap-2">
<span class="inline-flex size-5 items-center justify-center overflow-hidden rounded-full border-2 border-foreground bg-card">
<div class="flex min-w-0 items-center gap-2">
<span class="inline-flex size-5 shrink-0 items-center justify-center overflow-hidden rounded-full border-2 border-foreground bg-card">
<img :src="getPlatformLogo(pp.platform)" :alt="pp.platform" class="size-full object-cover" />
</span>
<span class="text-sm font-bold text-foreground">{{ getPlatformDisplayName(pp) }}</span>
<span class="truncate text-sm font-bold text-foreground">{{ getPlatformDisplayName(pp) }}</span>
</div>
<div class="flex items-center gap-2">
<Badge v-if="pp.status === PostPlatformStatus.Published" variant="success">{{ $t('posts.edit.status.published') }}</Badge>

View file

@ -8,8 +8,10 @@ import ConfirmDeleteModal from '@/components/ConfirmDeleteModal.vue';
import AiGenerateDialog from '@/components/posts/ai/AiGenerateDialog.vue';
import AiRegenerateImageDialog from '@/components/posts/ai/AiRegenerateImageDialog.vue';
import AiReviewDialog from '@/components/posts/ai/AiReviewDialog.vue';
import PostEditorActionBar from '@/components/posts/editor/PostEditorActionBar.vue';
import PostEditorComposer from '@/components/posts/editor/PostEditorComposer.vue';
import PostEditorHeader from '@/components/posts/editor/PostEditorHeader.vue';
import PostEditorMobileNav from '@/components/posts/editor/PostEditorMobileNav.vue';
import PostEditorTabs from '@/components/posts/editor/PostEditorTabs.vue';
import { usePostEcho } from '@/composables/echo/usePostEcho';
import {
@ -209,6 +211,24 @@ const initialTabFromQuery = (() => {
})();
const initialHighlightCommentId = queryParams?.get('comment') ?? null;
const activeTab = ref(initialTabFromQuery);
// On mobile a single switcher (PostEditorMobileNav) drives which panel shows;
// 'compose' reveals the composer, the rest reveal the tabs panel. It writes into
// the shared `activeTab` (mapping 'channels' to the existing 'schedule' tab value)
// so the tabs panel stays the single source of truth.
type MobileView = 'compose' | 'channels' | 'preview' | 'comments';
const mobileView = ref<MobileView>('compose');
const mobileViewToTab: Record<Exclude<MobileView, 'compose'>, string> = {
channels: 'schedule',
preview: 'preview',
comments: 'comments',
};
watch(mobileView, (view) => {
if (view !== 'compose') {
activeTab.value = mobileViewToTab[view];
}
});
const deleteModal = ref<InstanceType<typeof ConfirmDeleteModal> | null>(null);
const editorTabsRef = ref<InstanceType<typeof PostEditorTabs> | null>(null);
@ -372,6 +392,8 @@ usePostEcho(post.value.id, '.post.comment.created', (e: any) => {
@submit="submit"
/>
<PostEditorMobileNav v-model:active-view="mobileView" />
<div class="relative flex-1 overflow-hidden">
<div
v-if="isPublishing"
@ -394,7 +416,10 @@ usePostEcho(post.value.id, '.post.comment.created', (e: any) => {
class="flex h-full"
:class="{ 'pointer-events-none select-none opacity-60': isScheduled }"
>
<div class="w-full overflow-y-auto lg:w-2/3 lg:border-r-2 lg:border-foreground">
<div
class="w-full overflow-y-auto lg:w-2/3 lg:border-r-2 lg:border-foreground"
:class="{ 'hidden lg:block': mobileView !== 'compose' }"
>
<PostEditorComposer
v-model:content="content"
v-model:media="media"
@ -409,7 +434,10 @@ usePostEcho(post.value.id, '.post.comment.created', (e: any) => {
/>
</div>
<div class="hidden lg:block lg:w-1/3 overflow-hidden">
<div
class="w-full overflow-hidden lg:block lg:w-1/3"
:class="{ 'hidden lg:block': mobileView === 'compose' }"
>
<PostEditorTabs
ref="editorTabsRef"
v-model:active-tab="activeTab"
@ -437,6 +465,22 @@ usePostEcho(post.value.id, '.post.comment.created', (e: any) => {
</div>
</div>
</div>
<PostEditorActionBar
:is-read-only="isReadOnly"
:is-scheduled="isScheduled"
:can-edit="canCreatePost"
:is-saving="isSaving"
: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>
</AppLayout>

View file

@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
use App\Enums\UserWorkspace\Role;
use App\Models\Post;
use App\Models\PostComment;
use App\Models\User;
use App\Models\Workspace;
/**
* Seed an authenticated user whose current workspace holds a fresh draft post,
* and act as them. Returns the post so each test can drive its editor.
*/
function seedMobileEditorPost(array $postAttributes = []): Post
{
$user = User::factory()->create();
$workspace = Workspace::factory()->create(['user_id' => $user->id]);
$workspace->members()->attach($user->id, ['role' => Role::Member->value]);
$user->update(['current_workspace_id' => $workspace->id]);
$post = Post::factory()->create(array_merge([
'workspace_id' => $workspace->id,
'user_id' => $user->id,
'content' => 'hello from mobile',
], $postAttributes));
test()->actingAs($user);
return $post;
}
/**
* Poll browser-side until the testid element has laid out (width/height > 0).
* These Pest browser assertions do not auto-wait, so settle async UI first.
*/
function waitForTestId(mixed $page, string $testId): void
{
$page->script(<<<JS
(async () => {
const sel = '[data-testid="{$testId}"]';
for (let i = 0; i < 100; i++) {
const el = document.querySelector(sel);
if (el && el.getBoundingClientRect().height > 0) return;
await new Promise((r) => setTimeout(r, 50));
}
})();
JS);
}
test('the schedule/publish action bar is reachable on a phone', function () {
$post = seedMobileEditorPost();
$page = visit(route('app.posts.edit', $post))->resize(375, 812);
waitForTestId($page, 'editor-action-bar');
$page->assertVisible('@editor-action-bar');
});
test('the mobile switcher reveals the channels panel', function () {
$post = seedMobileEditorPost();
$page = visit(route('app.posts.edit', $post))->resize(375, 812);
$page->assertVisible('@editor-mobile-nav')
->click('@editor-nav-channels');
waitForTestId($page, 'channels-panel');
$page->assertVisible('@channels-panel');
});
test('media tile actions are visible on a phone without hover', function () {
$post = seedMobileEditorPost([
'media' => [[
'id' => 'm1',
'type' => 'image',
'mime_type' => 'image/png',
'path' => 'uploads/x.png',
'url' => 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
]],
]);
$page = visit(route('app.posts.edit', $post))->resize(375, 812);
waitForTestId($page, 'media-remove');
$page->assertVisible('@media-remove');
});
test('comment actions are visible on a phone without hover', function () {
$post = seedMobileEditorPost();
PostComment::factory()->create([
'post_id' => $post->id,
'user_id' => $post->user_id,
'body' => 'a comment on the go',
]);
$page = visit(route('app.posts.edit', $post))->resize(375, 812);
$page->click('@editor-nav-comments');
waitForTestId($page, 'comment-reply');
$page->assertVisible('@comment-reply');
});