Merge pull request #187 from trypostit/feat/mobile-experience

Improve the mobile experience (post editor + app-wide polish)
This commit is contained in:
Paulo Castellano 2026-07-18 14:37:22 -03:00 committed by GitHub
commit 132f84f404
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
92 changed files with 942 additions and 328 deletions

View file

@ -231,7 +231,7 @@ ## Frontend (Vue/TypeScript)
## Dialogs
- In `<DialogFooter>`, put the **primary action button first** in the markup, then secondary/cancel (e.g. Save → Cancel). `DialogFooter` uses `flex-col-reverse` on mobile and `sm:flex-row sm:justify-start` on desktop, so the first child is the leftmost action on larger screens.
- In `<DialogFooter>`, put the **primary action button first** in the markup, then secondary/cancel (e.g. Save → Cancel). `DialogFooter` uses `flex-col` on mobile (primary on top, cancel at the bottom) and `sm:flex-row sm:justify-start` on desktop, so the first child is the leftmost action on larger screens.
- Match sibling dialogs in the same feature area before inventing a new footer layout.
## AI agents (`app/Ai/Agents`)

View file

@ -15,6 +15,7 @@
use App\Actions\Automation\Automation\UpdateAutomation;
use App\Actions\Automation\Run\RetryRunFromNode;
use App\Actions\Automation\Run\TestAutomation;
use App\Enums\Automation\Status;
use App\Http\Controllers\Controller;
use App\Http\Requests\App\Automations\ActivateAutomationRequest;
use App\Http\Requests\App\Automations\InspectFeedRequest;
@ -77,7 +78,11 @@ public function show(Automation $automation): RedirectResponse
{
$this->authorize('view', $automation);
return redirect()->route('app.automations.workflow', $automation->id);
// A draft opens on the builder to be set up; a live automation (active
// or paused) opens on its metrics, where the user watches it run.
$tab = $automation->status === Status::Draft ? 'workflow' : 'metrics';
return redirect()->route("app.automations.{$tab}", $automation->id);
}
public function workflow(Automation $automation, GetAutomationEditorData $editorData): Response

View file

@ -3,6 +3,7 @@
declare(strict_types=1);
return [
'mobile_notice' => 'يعمل محرر الأتمتة بشكل أفضل على شاشة أكبر. افتحه على جهاز كمبيوتر مكتبي لإنشاء سير عملك.',
'title' => 'الأتمتة',
'default_name' => 'أتمتة جديدة',

View file

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

View file

@ -3,6 +3,7 @@
declare(strict_types=1);
return [
'mobile_notice' => 'Der Automatisierungs-Editor funktioniert am besten auf einem größeren Bildschirm. Öffne ihn auf einem Desktop, um deinen Workflow zu erstellen.',
'title' => 'Automatisierungen',
'default_name' => 'Neue Automatisierung',

View file

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

View file

@ -3,6 +3,7 @@
declare(strict_types=1);
return [
'mobile_notice' => 'Ο επεξεργαστής αυτοματισμών λειτουργεί καλύτερα σε μεγαλύτερη οθόνη. Άνοιξέ τον σε υπολογιστή για να δημιουργήσεις τη ροή εργασίας σου.',
'title' => 'Αυτοματισμοί',
'default_name' => 'Νέος αυτοματισμός',

View file

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

View file

@ -3,6 +3,7 @@
declare(strict_types=1);
return [
'mobile_notice' => 'The automation editor works best on a larger screen. Open it on a desktop to build your workflow.',
'title' => 'Automations',
'default_name' => 'New automation',

View file

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

View file

@ -3,6 +3,7 @@
declare(strict_types=1);
return [
'mobile_notice' => 'El editor de automatizaciones funciona mejor en una pantalla más grande. Ábrelo en un ordenador para crear tu flujo de trabajo.',
'title' => 'Automatizaciones',
'default_name' => 'Nueva automatización',

View file

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

View file

@ -3,6 +3,7 @@
declare(strict_types=1);
return [
'mobile_notice' => 'L\'éditeur d\'automatisation fonctionne mieux sur un grand écran. Ouvrez-le sur un ordinateur pour créer votre workflow.',
'title' => 'Automatisations',
'default_name' => 'Nouvelle automatisation',

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

@ -3,6 +3,7 @@
declare(strict_types=1);
return [
'mobile_notice' => 'L\'editor delle automazioni funziona meglio su uno schermo più grande. Aprilo su un desktop per creare il tuo flusso di lavoro.',
'title' => 'Automazioni',
'default_name' => 'Nuova automazione',

View file

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

View file

@ -3,6 +3,7 @@
declare(strict_types=1);
return [
'mobile_notice' => 'オートメーションエディタは大きな画面での利用に最適です。ワークフローを作成するにはデスクトップで開いてください。',
'title' => 'オートメーション',
'default_name' => '新しいオートメーション',

View file

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

View file

@ -3,6 +3,7 @@
declare(strict_types=1);
return [
'mobile_notice' => '자동화 편집기는 큰 화면에서 가장 잘 작동합니다. 워크플로를 만들려면 데스크톱에서 열어주세요.',
'title' => '자동화',
'default_name' => '새 자동화',

View file

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

View file

@ -3,6 +3,7 @@
declare(strict_types=1);
return [
'mobile_notice' => 'De automatiserings-editor werkt het best op een groter scherm. Open het op een desktop om je workflow te bouwen.',
'title' => 'Automatiseringen',
'default_name' => 'Nieuwe automatisering',

View file

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

View file

@ -3,6 +3,7 @@
declare(strict_types=1);
return [
'mobile_notice' => 'Edytor automatyzacji działa najlepiej na większym ekranie. Otwórz go na komputerze, aby zbudować swój workflow.',
'title' => 'Automatyzacje',
'default_name' => 'Nowa automatyzacja',

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

@ -3,6 +3,7 @@
declare(strict_types=1);
return [
'mobile_notice' => 'O editor de automações funciona melhor em uma tela maior. Abra no desktop para montar seu fluxo de trabalho.',
'title' => 'Automações',
'default_name' => 'Nova automação',

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

@ -3,6 +3,7 @@
declare(strict_types=1);
return [
'mobile_notice' => 'Редактор автоматизаций лучше всего работает на большом экране. Откройте его на компьютере, чтобы создать рабочий процесс.',
'title' => 'Автоматизации',
'default_name' => 'Новая автоматизация',

View file

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

View file

@ -3,6 +3,7 @@
declare(strict_types=1);
return [
'mobile_notice' => 'Otomasyon düzenleyicisi daha büyük bir ekranda en iyi şekilde çalışır. İş akışınızı oluşturmak için masaüstünde açın.',
'title' => 'Otomasyonlar',
'default_name' => 'Yeni otomasyon',

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

@ -3,6 +3,7 @@
declare(strict_types=1);
return [
'mobile_notice' => '自动化编辑器在更大的屏幕上体验最佳。请在电脑上打开以搭建你的工作流。',
'title' => '自动化',
'default_name' => '新建自动化',

View file

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

View file

@ -17,6 +17,7 @@ import {
IconPhoto,
IconPencil,
IconPlus,
IconSelector,
IconSettings,
IconTag,
} from '@tabler/icons-vue';
@ -45,6 +46,7 @@ import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from '@/components/ui/sidebar';
import { useActiveUrl } from '@/composables/useActiveUrl';
import { useWorkspaceRole } from '@/composables/useWorkspaceRole';
@ -70,6 +72,10 @@ const subscriptionPastDue = computed<boolean>(() => Boolean(page.props.auth.subs
const { canCreatePost, canManageAccounts, canManageAutomations, canCreateWorkspace } = useWorkspaceRole();
// Open the workspace menu to the side on desktop (like the user menu) but keep
// it below the trigger on mobile, where the sidebar is an overlay sheet.
const { isMobile } = useSidebar();
const mainNavItems = computed<NavItem[]>(() => [
{
title: trans('sidebar.posts.calendar'),
@ -196,11 +202,11 @@ const handleCreateWorkspace = () => {
{{ currentWorkspace?.name ?? $t('sidebar.select_workspace') }}
</span>
</div>
<IconChevronRight class="ml-auto size-4" />
<component :is="isMobile ? IconSelector : IconChevronRight" class="ml-auto size-4" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent class="w-(--reka-dropdown-menu-trigger-width) min-w-56"
align="start" side="right" :side-offset="4">
align="start" :side="isMobile ? 'bottom' : 'right'" :side-offset="4">
<DropdownMenuLabel>
{{ $t('sidebar.workspaces') }}
</DropdownMenuLabel>

View file

@ -42,7 +42,7 @@ const segments = computed<Segment[]>(() => {
</script>
<template>
<p class="mt-0.5 whitespace-pre-wrap text-sm">
<p class="mt-0.5 whitespace-pre-wrap break-words text-sm">
<template v-for="(seg, i) in segments" :key="i">
<span
v-if="seg.type === 'mention'"

View file

@ -141,7 +141,7 @@ defineExpose({ open, openCollection, close });
<div
v-if="currentItem?.altText"
data-testid="lightbox-alt-text"
class="pointer-events-none absolute left-1/2 max-w-2xl -translate-x-1/2 rounded-lg bg-black/70 px-4 py-2 text-center text-sm text-white backdrop-blur-sm"
class="pointer-events-none absolute left-1/2 max-w-[calc(100%-2rem)] -translate-x-1/2 rounded-lg bg-black/70 px-4 py-2 text-center text-sm text-white backdrop-blur-sm sm:max-w-2xl"
:class="showNav ? 'bottom-14' : 'bottom-4'"
>
{{ currentItem.altText }}

View file

@ -1,6 +1,6 @@
<script setup lang="ts">
import { usePage } from '@inertiajs/vue3';
import { IconChevronRight } from '@tabler/icons-vue';
import { IconChevronRight, IconSelector } from '@tabler/icons-vue';
import { computed } from 'vue';
import NotificationBell from '@/components/NotificationBell.vue';
@ -38,7 +38,7 @@ const { isMobile } = useSidebar();
data-test="sidebar-menu-button"
>
<UserInfo :user="user" />
<IconChevronRight class="ml-auto size-4" />
<component :is="isMobile ? IconSelector : IconChevronRight" class="ml-auto size-4" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent

View file

@ -214,7 +214,7 @@ onBeforeUnmount(() => {
<div
v-if="show"
ref="panel"
class="fixed bottom-2 left-[17rem] z-50 flex h-[32rem] w-[22rem] flex-col overflow-hidden rounded-xl border-2 border-foreground bg-card shadow-md"
class="fixed inset-x-2 bottom-2 z-50 flex h-[32rem] max-h-[calc(100svh-1rem)] flex-col overflow-hidden rounded-xl border-2 border-foreground bg-card shadow-md sm:inset-x-auto sm:left-[17rem] sm:w-[22rem]"
>
<!-- Header -->
<div class="flex items-center justify-between gap-2 border-b-2 border-foreground/10 px-4 py-3">
@ -226,7 +226,7 @@ onBeforeUnmount(() => {
<TooltipTrigger as-child>
<button
type="button"
class="inline-flex size-7 cursor-pointer items-center justify-center rounded-md border-2 border-foreground bg-card text-foreground shadow-2xs transition-all hover:bg-violet-100"
class="inline-flex size-7 max-sm:size-9 cursor-pointer items-center justify-center rounded-md border-2 border-foreground bg-card text-foreground shadow-2xs transition-all hover:bg-violet-100"
@click="handleMarkAllAsRead"
>
<IconChecks class="size-3.5" stroke-width="2.5" />
@ -238,7 +238,7 @@ onBeforeUnmount(() => {
<TooltipTrigger as-child>
<button
type="button"
class="inline-flex size-7 cursor-pointer items-center justify-center rounded-md border-2 border-foreground bg-card text-foreground shadow-2xs transition-all hover:bg-violet-100"
class="inline-flex size-7 max-sm:size-9 cursor-pointer items-center justify-center rounded-md border-2 border-foreground bg-card text-foreground shadow-2xs transition-all hover:bg-violet-100"
@click="handleArchiveAll"
>
<IconArchive class="size-3.5" stroke-width="2.5" />
@ -248,7 +248,7 @@ onBeforeUnmount(() => {
</Tooltip>
<button
type="button"
class="inline-flex size-7 cursor-pointer items-center justify-center rounded-full border-2 border-foreground bg-card text-foreground shadow-2xs transition-all hover:-rotate-90 hover:bg-rose-100"
class="inline-flex size-7 max-sm:size-9 cursor-pointer items-center justify-center rounded-full border-2 border-foreground bg-card text-foreground shadow-2xs transition-all hover:-rotate-90 hover:bg-rose-100"
@click="close"
>
<IconX class="size-3.5" stroke-width="2.5" />
@ -281,7 +281,7 @@ onBeforeUnmount(() => {
<TooltipTrigger as-child>
<button
type="button"
class="inline-flex size-7 cursor-pointer items-center justify-center rounded-md text-foreground/60 transition-colors hover:bg-foreground/10 hover:text-foreground"
class="inline-flex size-7 max-sm:size-9 cursor-pointer items-center justify-center rounded-md text-foreground/60 transition-colors hover:bg-foreground/10 hover:text-foreground"
@click="handleMarkAsRead(notification)"
>
<IconCheck class="size-3.5" stroke-width="2.5" />

View file

@ -9,7 +9,7 @@ defineProps<{
<template>
<header class="space-y-1">
<h1
class="text-4xl font-semibold leading-tight text-foreground"
class="text-2xl font-semibold leading-tight text-foreground sm:text-4xl"
style="font-family: var(--font-display)"
>
{{ title }}

View file

@ -9,7 +9,7 @@ withDefaults(defineProps<Props>(), {
</script>
<template>
<div class="relative mx-auto select-none origin-top">
<div class="relative mx-auto origin-top scale-90 select-none lg:scale-100">
<!-- Phone Frame - iPhone 14 Pro style -->
<div class="relative bg-[#1a1a1a] rounded-[44px] p-[10px] ring-1 ring-white/10">
<!-- Inner frame -->

View file

@ -48,7 +48,7 @@ const select = (account: AnalyticsAccount) => {
role="combobox"
:aria-expanded="open"
:disabled="accounts.length === 0"
class="h-auto min-w-[240px] justify-start gap-3 px-3 py-2 text-left font-medium"
class="h-auto w-full justify-start gap-3 px-3 py-2 text-left font-medium sm:w-auto sm:min-w-[240px]"
>
<template v-if="selected">
<div class="relative shrink-0">

View file

@ -700,7 +700,7 @@ onUnmounted(() => {
<div
v-if="!isPicker"
class="absolute inset-0 flex flex-col justify-between bg-foreground/60 p-2 opacity-0 transition-opacity group-hover:opacity-100"
class="absolute inset-0 flex flex-col justify-between bg-transparent p-2 opacity-100 transition-opacity lg:bg-foreground/60 lg:opacity-0 lg:group-hover:opacity-100"
>
<div class="flex justify-end gap-1.5">
<TooltipProvider>
@ -717,6 +717,7 @@ onUnmounted(() => {
variant="outline"
size="icon"
class="size-8 bg-rose-100 hover:bg-rose-200"
data-testid="gallery-asset-delete"
@click.stop="handleDelete(asset.id)"
>
<IconTrash class="size-4 text-rose-700" />
@ -771,7 +772,7 @@ onUnmounted(() => {
/>
</div>
<div class="absolute inset-0 flex flex-col justify-between bg-foreground/60 p-2 opacity-0 transition-opacity group-hover:opacity-100">
<div class="absolute inset-0 flex flex-col justify-between bg-transparent p-2 opacity-100 transition-opacity lg:bg-foreground/60 lg:opacity-0 lg:group-hover:opacity-100">
<div class="flex justify-end gap-1.5">
<TooltipProvider v-if="!isPicker">
<Tooltip>
@ -868,7 +869,7 @@ onUnmounted(() => {
<img :src="gif.url_preview" :alt="gif.title || 'GIF'" class="size-full object-cover" loading="lazy" />
</div>
<div class="absolute inset-0 flex flex-col justify-between bg-foreground/60 p-2 opacity-0 transition-opacity group-hover:opacity-100">
<div class="absolute inset-0 flex flex-col justify-between bg-transparent p-2 opacity-100 transition-opacity lg:bg-foreground/60 lg:opacity-0 lg:group-hover:opacity-100">
<div class="flex justify-end gap-1.5">
<TooltipProvider v-if="!isPicker">
<Tooltip>

View file

@ -3,6 +3,7 @@ import { Link } from '@inertiajs/vue3';
import { IconArrowLeft, IconCircleCheck, IconCircleDot, IconCircleX } from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n';
import AutomationMobileBackHeader from '@/components/automations/AutomationMobileBackHeader.vue';
import AutomationTabsNav from '@/components/automations/AutomationTabsNav.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
@ -26,7 +27,9 @@ const statusConfig = (status: string) => {
<template>
<div class="flex-shrink-0">
<header class="flex items-center justify-between gap-4 border-b-2 border-foreground/10 bg-card px-4 py-2">
<AutomationMobileBackHeader />
<header class="hidden items-center justify-between gap-4 border-b-2 border-foreground/10 bg-card px-4 py-2 lg:flex">
<div class="flex min-w-0 items-center gap-3">
<Link :href="automationsIndex.url()">
<Button variant="outline" size="icon-sm">

View file

@ -0,0 +1,21 @@
<script setup lang="ts">
import { Link } from '@inertiajs/vue3';
import { IconArrowLeft } from '@tabler/icons-vue';
import { Button } from '@/components/ui/button';
import { index as automationsIndex } from '@/routes/app/automations';
</script>
<template>
<header
data-testid="automation-mobile-back"
class="flex items-center border-b-2 border-foreground/10 bg-card py-3 pl-16 pr-4 md:pl-4 lg:hidden"
>
<Link :href="automationsIndex.url()" class="block w-full">
<Button variant="outline" class="w-full justify-start gap-2">
<IconArrowLeft class="size-4" />
{{ $t('common.back') }}
</Button>
</Link>
</header>
</template>

View file

@ -18,13 +18,13 @@ const tabs = computed(() => [
</script>
<template>
<nav class="flex items-center gap-6 border-b-2 border-foreground/10 px-4">
<nav class="flex items-center gap-4 overflow-x-auto border-b-2 border-foreground/10 px-4 lg:gap-6 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<Link
v-for="tab in tabs"
:key="tab.key"
:href="tab.href"
:dusk="`automation-tab-${tab.key}`"
class="-mb-0.5 border-b-2 py-2.5 text-sm font-medium transition-colors"
class="-mb-0.5 shrink-0 whitespace-nowrap border-b-2 py-2.5 text-sm font-medium transition-colors"
:class="
tab.key === current
? 'border-primary text-foreground'

View file

@ -58,26 +58,28 @@ const clear = () => {
variant="outline"
role="combobox"
:aria-expanded="open"
class="justify-between gap-2 font-normal"
class="w-full justify-between gap-2 font-normal sm:w-auto"
>
<IconTag class="size-4 shrink-0 opacity-60" />
<div class="flex min-w-0 items-center gap-2">
<IconTag class="size-4 shrink-0 opacity-60" />
<template v-if="selectedLabels.length === 0">
<span class="text-foreground/70">{{ trans('posts.filter_by_label') }}</span>
</template>
<template v-else>
<div class="flex flex-wrap items-center gap-1">
<LabelBadge
v-for="label in selectedLabels.slice(0, 3)"
:key="label.id"
:label="label"
/>
<span
v-if="selectedLabels.length > 3"
class="text-xs font-bold text-foreground/60"
>+{{ selectedLabels.length - 3 }}</span>
</div>
</template>
<template v-if="selectedLabels.length === 0">
<span class="text-foreground/70">{{ trans('posts.filter_by_label') }}</span>
</template>
<template v-else>
<div class="flex flex-wrap items-center gap-1">
<LabelBadge
v-for="label in selectedLabels.slice(0, 3)"
:key="label.id"
:label="label"
/>
<span
v-if="selectedLabels.length > 3"
class="text-xs font-bold text-foreground/60"
>+{{ selectedLabels.length - 3 }}</span>
</div>
</template>
</div>
<TooltipProvider v-if="selectedIds.length" :delay-duration="200">
<Tooltip>

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

@ -91,7 +91,7 @@ const remove = () => {
<DialogTrigger as-child :disabled="disabled">
<slot :open="open" />
</DialogTrigger>
<DialogContent class="w-auto max-w-fit gap-0 p-0 sm:max-w-fit" :show-close-button="false">
<DialogContent class="w-auto max-w-[calc(100%-2rem)] gap-0 p-0 sm:max-w-fit" :show-close-button="false">
<DialogTitle class="sr-only">{{ $t('posts.edit.pick_time') }}</DialogTitle>
<div class="flex justify-center px-3 pt-3">
@ -99,7 +99,7 @@ const remove = () => {
</div>
<div class="border-t p-3">
<div class="flex items-center gap-2">
<div class="flex flex-wrap items-center gap-2">
<span class="text-sm text-muted-foreground">{{ $t('posts.edit.time') }}</span>
<Select v-model="selectedHour">
<SelectTrigger class="w-[84px]"><SelectValue placeholder="HH" /></SelectTrigger>

View file

@ -104,7 +104,7 @@ watch(open, () => {
<div v-if="status !== 'idle'" class="grid gap-2">
<Label class="text-[11px] font-black uppercase tracking-widest text-foreground/60">{{ $t('posts.ai.generate.preview_label') }}</Label>
<div class="min-h-[120px] whitespace-pre-wrap rounded-lg border-2 border-foreground bg-card px-3 py-2 text-sm font-medium text-foreground shadow-2xs">{{ previewText || '...' }}</div>
<div class="min-h-[120px] whitespace-pre-wrap break-words rounded-lg border-2 border-foreground bg-card px-3 py-2 text-sm font-medium text-foreground shadow-2xs">{{ previewText || '...' }}</div>
<p v-if="status === 'failed'" class="text-xs font-semibold text-rose-700">{{ errorMessage }}</p>
</div>
</div>

View file

@ -113,11 +113,11 @@ watch(open, (isOpen) => {
<div class="flex items-start justify-between gap-3">
<div class="min-w-0 flex-1 space-y-1.5">
<p class="flex flex-wrap items-center gap-x-2 gap-y-1 text-sm leading-relaxed">
<span class="rounded-md border-2 border-foreground bg-rose-100 px-1.5 py-0.5 font-bold text-rose-700 line-through decoration-rose-700/60 shadow-2xs">
<span class="rounded-md border-2 border-foreground bg-rose-100 px-1.5 py-0.5 font-bold text-rose-700 line-through decoration-rose-700/60 shadow-2xs break-words">
{{ s.original }}
</span>
<span class="font-bold text-foreground/40">→</span>
<span class="rounded-md border-2 border-foreground bg-emerald-100 px-1.5 py-0.5 font-bold text-emerald-700 shadow-2xs">
<span class="rounded-md border-2 border-foreground bg-emerald-100 px-1.5 py-0.5 font-bold text-emerald-700 shadow-2xs break-words">
{{ s.suggestion }}
</span>
</p>

View file

@ -78,7 +78,6 @@ const newBody = ref('');
const replyingTo = ref<Comment | null>(null);
const editingComment = ref<Comment | null>(null);
const editBody = ref('');
const hoveredCommentId = ref<string | null>(null);
const emojiPickerCommentId = ref<string | null>(null);
const scrollContainer = ref<HTMLDivElement | null>(null);
@ -494,8 +493,7 @@ watch(() => props.postId, () => {
:data-comment-id="comment.id"
class="group relative rounded-lg py-1.5 px-2 transition-colors"
:class="highlightedId === comment.id ? 'bg-violet-100 ring-2 ring-foreground' : 'hover:bg-foreground/5'"
@mouseenter="hoveredCommentId = comment.id"
@mouseleave="hoveredCommentId = null; emojiPickerCommentId = null"
@mouseleave="emojiPickerCommentId = null"
>
<!-- Editing mode -->
<div v-if="editingComment?.id === comment.id" class="space-y-2">
@ -556,19 +554,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>
@ -607,8 +605,7 @@ watch(() => props.postId, () => {
:data-comment-id="reply.id"
class="group relative ml-8 rounded-lg py-1.5 px-2 transition-colors"
:class="highlightedId === reply.id ? 'bg-violet-100 ring-2 ring-foreground' : 'hover:bg-foreground/5'"
@mouseenter="hoveredCommentId = reply.id"
@mouseleave="hoveredCommentId = null; emojiPickerCommentId = null"
@mouseleave="emojiPickerCommentId = null"
>
<!-- Editing reply -->
<div v-if="editingComment?.id === reply.id" class="space-y-2">
@ -669,13 +666,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,117 @@
<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 w-full flex-col gap-1 lg:w-auto lg:items-end">
<div class="flex w-full items-center gap-2 lg:w-auto">
<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" class="flex-1 lg:flex-none">
<Button
type="button"
class="w-full lg:w-auto"
: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,125 +35,70 @@ 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>
<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',
isScheduled ? 'bg-violet-100' : 'bg-card',
]"
>
<!-- Left: the scheduled banner, or the editable-state status.
Both clear the floating hamburger on mobile (pl-12 md:pl-0). -->
<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
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>
</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 && 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 class="flex min-w-0 flex-1 items-start gap-3 pl-12 md:items-center md:pl-0">
<div class="inline-flex size-10 shrink-0 items-center justify-center rounded-lg border-2 border-foreground bg-violet-200 md:size-9">
<IconCalendar class="size-4 text-foreground" stroke-width="2" />
</div>
<div class="min-w-0 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>
<InputError :message="scheduledAtError" />
</div>
</template>
<div v-else 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>
<!-- Actions: one instance for both states, pushed right on desktop.
On mobile they live in the sticky bottom bar, so this stays hidden. -->
<div class="ml-auto 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>
</header>
</template>

View file

@ -0,0 +1,46 @@
<script setup lang="ts">
import { computed } from 'vue';
import { PostStatus } from '@/types/post';
type MobileView = 'compose' | 'channels' | 'preview' | 'comments';
const props = defineProps<{
status: string;
}>();
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' },
];
// When not scheduled the header is hidden on mobile and this nav is the top bar,
// so it must clear the floating hamburger.
const isTopBar = computed(() => props.status !== PostStatus.Scheduled);
</script>
<template>
<div
data-testid="editor-mobile-nav"
class="flex shrink-0 gap-1 overflow-x-auto border-b-2 border-foreground bg-card py-3 pr-2 lg:hidden [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
:class="isTopBar ? 'pl-16' : 'pl-2'"
>
<button
v-for="item in items"
:key="item.key"
type="button"
:data-testid="`editor-nav-${item.key}`"
class="inline-flex h-10 shrink-0 items-center rounded-md border-2 px-3 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

@ -74,7 +74,7 @@ const activeContentType = computed((): string | undefined => {
class="size-9 shrink-0 rounded-full border-2"
:class="activeId === pp.id ? 'border-foreground shadow-2xs' : 'border-foreground/20'"
/>
<span class="absolute -bottom-1 -right-1 inline-flex size-4 items-center justify-center overflow-hidden rounded-full border-2 border-foreground bg-card shadow-2xs">
<span class="absolute -bottom-1 -right-1 inline-flex size-5 items-center justify-center overflow-hidden rounded-full border-2 border-foreground bg-card shadow-2xs lg:size-4">
<img
:src="getPlatformLogo(pp.platform)"
:alt="pp.platform"
@ -94,7 +94,7 @@ const activeContentType = computed((): string | undefined => {
</div>
</div>
<div class="flex flex-1 items-start justify-center px-4 pb-8 pt-6">
<div class="flex flex-1 items-start justify-center overflow-x-hidden px-4 pb-8 pt-6">
<PhoneMockup v-if="activePlatform">
<PlatformPreview
:platform="activePlatform.platform"

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

@ -83,7 +83,7 @@ const changeRole = (member: Member, role: string) => {
<template>
<div class="flex flex-col space-y-6">
<div class="flex items-center justify-between gap-4">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<HeadingSmall
:title="$t('settings.workspace.members_heading')"
:description="$t('settings.workspace.members_description')"
@ -115,7 +115,7 @@ const changeRole = (member: Member, role: string) => {
<TableCell>
<DropdownMenu v-if="canManageTeam && member.id !== currentUserId">
<DropdownMenuTrigger as-child>
<Button variant="outline" size="icon" class="size-8">
<Button variant="outline" size="icon" class="size-9">
<IconDots class="size-4" />
</Button>
</DropdownMenuTrigger>

View file

@ -33,7 +33,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
v-bind="{ ...$attrs, ...forwarded }"
:class="
cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 max-h-[85dvh] overflow-y-auto rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
props.class,
)
"

View file

@ -34,7 +34,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
v-bind="{ ...$attrs, ...forwarded }"
:class="
cn(
'bg-card data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-2xl border-2 border-foreground p-6 shadow-lg duration-200 sm:max-w-lg',
'bg-card data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 max-h-[85dvh] overflow-y-auto rounded-2xl border-2 border-foreground p-6 shadow-lg duration-200 sm:max-w-lg',
props.class,
)"
>

View file

@ -8,7 +8,7 @@ const props = defineProps<{ class?: HTMLAttributes["class"] }>()
<template>
<div
data-slot="dialog-footer"
:class="cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-start', props.class)"
:class="cn('flex flex-col gap-2 sm:flex-row sm:justify-start', props.class)"
>
<slot />
</div>

View file

@ -32,7 +32,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
<DialogContent
:class="
cn(
'relative z-50 grid w-full max-w-lg my-8 gap-4 border border-border bg-background p-6 shadow-lg duration-200 sm:rounded-lg md:w-full',
'relative z-50 grid w-full max-w-[calc(100%-2rem)] my-8 gap-4 border border-border bg-background p-6 shadow-lg duration-200 sm:max-w-lg sm:rounded-lg md:w-full',
props.class,
)
"

View file

@ -1,6 +1,6 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { IconLayoutSidebarLeftCollapse, IconLayoutSidebarLeftExpand } from "@tabler/icons-vue"
import { IconLayoutSidebarLeftCollapse, IconLayoutSidebarLeftExpand, IconMenu2 } from "@tabler/icons-vue"
import { cn } from "@/lib/utils"
import { Button } from '@/components/ui/button'
import { useSidebar } from "./utils"
@ -18,10 +18,11 @@ const { isMobile, state, toggleSidebar } = useSidebar()
data-slot="sidebar-trigger"
variant="ghost"
size="icon"
:class="cn('h-7 w-7', props.class)"
:class="cn('h-9 w-9', props.class)"
@click="toggleSidebar"
>
<IconLayoutSidebarLeftExpand v-if="isMobile || state === 'collapsed'" />
<IconMenu2 v-if="isMobile" />
<IconLayoutSidebarLeftExpand v-else-if="state === 'collapsed'" />
<IconLayoutSidebarLeftCollapse v-else />
<span class="sr-only">Toggle Sidebar</span>
</Button>

View file

@ -15,7 +15,7 @@ const delegatedProps = reactiveOmit(props, "class")
data-slot="tabs-list"
v-bind="delegatedProps"
:class="cn(
'inline-flex h-10 w-fit items-center gap-2',
'inline-flex w-fit max-w-full items-center gap-2 overflow-x-auto p-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden',
props.class,
)"
>

View file

@ -52,7 +52,7 @@ onBeforeUnmount(() => {
</AppHeader>
<SidebarTrigger
v-else
class="absolute left-2 top-2 z-30 size-9 rounded-md border-2 border-foreground bg-card text-foreground shadow-2xs md:hidden"
class="absolute left-4 top-3 z-30 size-10 rounded-md border-2 border-foreground bg-card text-foreground shadow-2xs md:hidden"
/>
<div
:class="
@ -62,11 +62,14 @@ onBeforeUnmount(() => {
"
>
<div
:class="
:class="[
fullWidth
? 'flex min-h-0 flex-1 flex-col'
: 'mx-auto w-full max-w-7xl'
"
: 'mx-auto w-full max-w-7xl',
!fullWidth && !$slots['header'] && !$slots['header-actions']
? 'pt-14 md:pt-0'
: '',
]"
>
<slot />
</div>

View file

@ -85,8 +85,8 @@ const platforms = [
</script>
<template>
<div class="grid min-h-svh lg:grid-cols-2">
<div class="flex flex-col gap-4 p-6 md:p-10">
<div class="grid min-h-svh grid-cols-1 lg:grid-cols-2">
<div class="flex min-w-0 flex-col gap-4 p-6 md:p-10">
<div class="flex items-start">
<img
src="/images/trypost/logo-light.png"

View file

@ -71,7 +71,7 @@ const pageUrl = (username: string | null): string | null =>
class="flex items-center gap-4 rounded-lg border bg-card p-4"
dusk="facebook-page"
>
<Avatar class="h-12 w-12 rounded-lg">
<Avatar class="h-12 w-12 shrink-0 rounded-lg">
<AvatarImage v-if="page.picture" :src="page.picture" class="object-cover" />
<AvatarFallback class="rounded-lg bg-blue-100 dark:bg-blue-900">
<IconBrandFacebook class="h-6 w-6 text-blue-600 dark:text-blue-400" />
@ -92,7 +92,7 @@ const pageUrl = (username: string | null): string | null =>
@click="openExternal(pageUrl(page.username))"
>
<IconExternalLink class="h-4 w-4" />
{{ $t('accounts.facebook.view') }}
<span class="hidden sm:inline">{{ $t('accounts.facebook.view') }}</span>
</Button>
<Button size="sm" dusk="choose-facebook-page" :disabled="form.processing" @click="handleSelectPage(page)">
{{ $t('accounts.facebook.choose') }}

View file

@ -73,7 +73,7 @@ const igUrl = (username: string): string => `https://www.instagram.com/${usernam
class="flex items-center gap-4 rounded-lg border bg-card p-4"
dusk="instagram-page"
>
<Avatar class="h-12 w-12 rounded-lg">
<Avatar class="h-12 w-12 shrink-0 rounded-lg">
<AvatarImage v-if="page.ig_picture" :src="page.ig_picture" class="object-cover" />
<AvatarFallback class="rounded-lg bg-pink-100 dark:bg-pink-900">
<IconBrandInstagram class="h-6 w-6 text-pink-600 dark:text-pink-400" />
@ -90,7 +90,7 @@ const igUrl = (username: string): string => `https://www.instagram.com/${usernam
@click="openExternal(igUrl(page.ig_username))"
>
<IconExternalLink class="h-4 w-4" />
{{ $t('accounts.instagram_facebook.view') }}
<span class="hidden sm:inline">{{ $t('accounts.instagram_facebook.view') }}</span>
</Button>
<Button size="sm" dusk="choose-instagram-page" :disabled="form.processing" @click="handleSelectPage(page)">
{{ $t('accounts.instagram_facebook.choose') }}

View file

@ -93,7 +93,7 @@ const organizationUrl = (vanity: string | null): string | null =>
class="flex items-center gap-4 rounded-lg border bg-card p-4"
dusk="linkedin-identity-person"
>
<Avatar class="h-12 w-12 rounded-lg">
<Avatar class="h-12 w-12 shrink-0 rounded-lg">
<AvatarImage v-if="person.avatar" :src="person.avatar" class="object-cover" />
<AvatarFallback class="rounded-lg bg-blue-100 dark:bg-blue-900">
<IconUser class="h-6 w-6 text-blue-600 dark:text-blue-400" />
@ -121,7 +121,7 @@ const organizationUrl = (vanity: string | null): string | null =>
@click="openExternal(personUrl(person.vanity_name))"
>
<IconExternalLink class="h-4 w-4" />
{{ $t('accounts.linkedin.view') }}
<span class="hidden sm:inline">{{ $t('accounts.linkedin.view') }}</span>
</Button>
<Button size="sm" dusk="choose-person" :disabled="form.processing" @click="choosePerson">
{{ $t('accounts.linkedin.choose') }}
@ -135,7 +135,7 @@ const organizationUrl = (vanity: string | null): string | null =>
class="flex items-center gap-4 rounded-lg border bg-card p-4"
dusk="linkedin-identity-organization"
>
<Avatar class="h-12 w-12 rounded-lg">
<Avatar class="h-12 w-12 shrink-0 rounded-lg">
<AvatarImage v-if="org.logo" :src="org.logo" class="object-cover" />
<AvatarFallback class="rounded-lg bg-blue-100 dark:bg-blue-900">
<IconBuilding class="h-6 w-6 text-blue-600 dark:text-blue-400" />
@ -163,7 +163,7 @@ const organizationUrl = (vanity: string | null): string | null =>
@click="openExternal(organizationUrl(org.vanity_name))"
>
<IconExternalLink class="h-4 w-4" />
{{ $t('accounts.linkedin.view') }}
<span class="hidden sm:inline">{{ $t('accounts.linkedin.view') }}</span>
</Button>
<Button size="sm" dusk="choose-organization" :disabled="form.processing" @click="chooseOrganization(org)">
{{ $t('accounts.linkedin.choose') }}

View file

@ -58,7 +58,7 @@ const platformSupportsDateRange = computed(() => {
>
<div class="flex flex-wrap items-center justify-between gap-3">
<PageHeader :title="$t('sidebar.analytics')" />
<div class="flex flex-wrap items-center gap-3">
<div class="flex w-full flex-wrap items-center gap-3 sm:w-auto">
<AnalyticsAccountSelector
:accounts="accounts"
:selected-id="selectedAccountId"

View file

@ -57,17 +57,17 @@ const inviteUrl = computed(() => `/invites/${props.invite.id}`);
</CardHeader>
<CardContent class="space-y-6">
<div class="rounded-lg bg-muted p-4 space-y-2">
<div v-if="invite.workspace" class="flex justify-between text-sm">
<span class="text-muted-foreground">{{ $t('auth.accept_invite.workspace') }}</span>
<span class="font-medium">{{ invite.workspace.name }}</span>
<div v-if="invite.workspace" class="flex justify-between gap-3 text-sm">
<span class="shrink-0 text-muted-foreground">{{ $t('auth.accept_invite.workspace') }}</span>
<span class="min-w-0 truncate font-medium">{{ invite.workspace.name }}</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-muted-foreground">{{ $t('auth.accept_invite.your_role') }}</span>
<span class="font-medium">{{ invite.role.label }}</span>
<div class="flex justify-between gap-3 text-sm">
<span class="shrink-0 text-muted-foreground">{{ $t('auth.accept_invite.your_role') }}</span>
<span class="min-w-0 truncate font-medium">{{ invite.role.label }}</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-muted-foreground">{{ $t('auth.accept_invite.email') }}</span>
<span class="font-medium">{{ invite.email }}</span>
<div class="flex justify-between gap-3 text-sm">
<span class="shrink-0 text-muted-foreground">{{ $t('auth.accept_invite.email') }}</span>
<span class="min-w-0 truncate font-medium">{{ invite.email }}</span>
</div>
</div>

View file

@ -1,6 +1,6 @@
<script setup lang="ts">
import { Head, router } from '@inertiajs/vue3';
import { IconBolt, IconLifebuoy } from '@tabler/icons-vue';
import { IconBolt, IconDeviceDesktop, IconLifebuoy } from '@tabler/icons-vue';
import { Background } from '@vue-flow/background';
import { Controls } from '@vue-flow/controls';
import {
@ -24,6 +24,7 @@ import '@vue-flow/controls/dist/style.css';
import AutomationConnectionLine from '@/components/automations/AutomationConnectionLine.vue';
import AutomationHeader from '@/components/automations/AutomationHeader.vue';
import AutomationMobileBackHeader from '@/components/automations/AutomationMobileBackHeader.vue';
import ConditionNodeConfig from '@/components/automations/config/ConditionNodeConfig.vue';
import DelayNodeConfig from '@/components/automations/config/DelayNodeConfig.vue';
import EndNodeConfig from '@/components/automations/config/EndNodeConfig.vue';
@ -450,7 +451,9 @@ const defaultEdgeOptions = {
<AppLayout full-width>
<div class="flex min-h-0 flex-1 flex-col bg-background">
<AutomationHeader :automation="automation" current="workflow">
<AutomationMobileBackHeader />
<AutomationHeader :automation="automation" current="workflow" class="hidden lg:block">
<template #actions>
<TooltipProvider>
<Tooltip>
@ -475,7 +478,7 @@ const defaultEdgeOptions = {
</template>
</AutomationHeader>
<div class="flex flex-1 overflow-hidden">
<div class="hidden flex-1 overflow-hidden lg:flex">
<main
class="automations-canvas-host relative flex-1"
@drop="onDrop"
@ -547,6 +550,16 @@ const defaultEdgeOptions = {
/>
</aside>
</div>
<div
data-testid="automation-mobile-notice"
class="flex flex-1 flex-col items-center justify-center gap-3 p-8 text-center lg:hidden"
>
<div class="inline-flex size-14 items-center justify-center rounded-2xl border-2 border-foreground bg-violet-200 shadow-2xs">
<IconDeviceDesktop class="size-7 text-foreground" stroke-width="2" />
</div>
<p class="max-w-sm text-sm text-foreground/70">{{ $t('automations.mobile_notice') }}</p>
</div>
</div>
</AppLayout>
</template>

View file

@ -20,6 +20,7 @@ import {
import dayjs from '@/dayjs';
import AppLayout from '@/layouts/AppLayout.vue';
import {
metrics as metricsAutomation,
store as storeAutomation,
workflow as workflowAutomation,
} from '@/routes/app/automations';
@ -56,6 +57,13 @@ const handleCreate = () => {
onFinish: () => { isCreating.value = false; },
});
};
// A draft opens on the builder to be set up; a live automation opens on its
// metrics, where the user watches it run.
const openAutomation = (automation: Automation) => {
const route = automation.status === 'draft' ? workflowAutomation : metricsAutomation;
router.visit(route.url(automation.id));
};
</script>
<template>
@ -63,7 +71,7 @@ const handleCreate = () => {
<AppLayout>
<div class="flex h-full flex-1 flex-col gap-6 px-6 py-8">
<div class="flex items-center justify-between">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<PageHeader :title="$t('automations.title')" />
<Button @click="handleCreate" :disabled="isCreating">
@ -101,7 +109,7 @@ const handleCreate = () => {
v-for="automation in automations.data"
:key="automation.id"
class="cursor-pointer"
@click="router.visit(workflowAutomation.url(automation.id))"
@click="openAutomation(automation)"
>
<TableCell class="font-medium">{{ automation.name }}</TableCell>
<TableCell>

View file

@ -169,64 +169,67 @@ const toggleExpand = async (invocation: Invocation) => {
<template>
<AutomationDetailLayout :automation="automation" current="invocations">
<div class="space-y-4 p-4">
<div class="flex flex-wrap items-center gap-2">
<Select v-model="statusFilter">
<SelectTrigger
dusk="invocations-status-filter"
class="w-44"
>
<SelectValue>{{ statusLabel }}</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{{
$t('automations.invocations.filter.all')
}}</SelectItem>
<SelectItem value="completed">{{
$t('automations.status_run.completed')
}}</SelectItem>
<SelectItem value="failed">{{
$t('automations.status_run.failed')
}}</SelectItem>
<SelectItem value="running">{{
$t('automations.status_run.running')
}}</SelectItem>
<SelectItem value="waiting">{{
$t('automations.status_run.waiting')
}}</SelectItem>
</SelectContent>
</Select>
<div class="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center">
<div class="flex items-center gap-2 sm:contents">
<Select v-model="statusFilter">
<SelectTrigger
dusk="invocations-status-filter"
class="w-full sm:w-44"
>
<SelectValue>{{ statusLabel }}</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{{
$t('automations.invocations.filter.all')
}}</SelectItem>
<SelectItem value="completed">{{
$t('automations.status_run.completed')
}}</SelectItem>
<SelectItem value="failed">{{
$t('automations.status_run.failed')
}}</SelectItem>
<SelectItem value="running">{{
$t('automations.status_run.running')
}}</SelectItem>
<SelectItem value="waiting">{{
$t('automations.status_run.waiting')
}}</SelectItem>
</SelectContent>
</Select>
<TooltipProvider>
<Tooltip>
<TooltipTrigger as-child>
<Button
variant="outline"
size="icon"
class="shrink-0 sm:order-last"
:aria-label="
$t('automations.invocations.refresh')
"
:disabled="isRefreshing"
dusk="invocations-refresh"
@click="reload"
>
<IconRefresh
class="size-4"
:class="{ 'animate-spin': isRefreshing }"
/>
</Button>
</TooltipTrigger>
<TooltipContent>{{
$t('automations.invocations.refresh')
}}</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<Input
v-model="search"
:placeholder="
$t('automations.invocations.search_placeholder')
"
class="max-w-xs"
class="sm:max-w-xs"
dusk="invocations-search"
/>
<TooltipProvider>
<Tooltip>
<TooltipTrigger as-child>
<Button
variant="outline"
size="icon"
:aria-label="
$t('automations.invocations.refresh')
"
:disabled="isRefreshing"
dusk="invocations-refresh"
@click="reload"
>
<IconRefresh
class="size-4"
:class="{ 'animate-spin': isRefreshing }"
/>
</Button>
</TooltipTrigger>
<TooltipContent>{{
$t('automations.invocations.refresh')
}}</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div class="relative">

View file

@ -72,7 +72,7 @@ const platformLabel = (platform: string): string => platform.charAt(0).toUpperCa
<template>
<AutomationDetailLayout :automation="automation" current="metrics">
<div class="space-y-6 p-4">
<div class="flex items-center justify-between gap-2">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<h2 class="text-sm font-semibold text-foreground/70">{{ $t('automations.metrics.overview') }}</h2>
<DateRangePicker v-model="dateRange" dusk="metrics-range" />
</div>
@ -85,9 +85,9 @@ const platformLabel = (platform: string): string => platform.charAt(0).toUpperCa
</div>
<div class="rounded-xl border-2 border-foreground/10 bg-card p-4">
<div class="mb-3 flex items-center justify-between">
<div class="mb-3 flex flex-wrap items-center justify-between gap-2">
<h3 class="text-sm font-semibold">{{ $t('automations.metrics.runs_over_time') }}</h3>
<div class="flex items-center gap-4">
<div class="flex flex-wrap items-center gap-4">
<div v-for="item in legend" :key="item.key" class="flex items-center gap-1.5">
<span class="size-2.5 rounded-full" :style="{ backgroundColor: item.color }"></span>
<span class="text-xs text-foreground/60">{{ $t(item.label) }}</span>

View file

@ -109,7 +109,7 @@ onUnmounted(() => {
<span class="size-3 rounded-full border border-foreground bg-amber-300" />
<span class="size-3 rounded-full border border-foreground bg-emerald-300" />
</div>
<div class="ml-2 truncate text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
<div class="ml-2 min-w-0 truncate text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
trypost.it · checkout
</div>
<span class="ml-auto inline-flex items-center gap-1.5 rounded-md border-2 border-foreground bg-foreground px-2 py-0.5 text-[10px] font-black uppercase tracking-widest text-background shadow-2xs">

View file

@ -85,13 +85,13 @@ const hasActiveSearch = computed(() => Boolean(searchQuery.value?.trim()));
<div class="flex h-full flex-1 flex-col gap-6 px-6 py-8">
<PageHeader :title="$t('labels.title')" :description="$t('labels.description')" />
<div class="flex items-center justify-between gap-3">
<div class="relative">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="relative w-full sm:w-auto">
<IconSearch class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
v-model="searchQuery"
:placeholder="trans('labels.search')"
class="w-64 pl-9"
class="w-full pl-9 sm:w-64"
/>
</div>

View file

@ -253,8 +253,32 @@ const formatTime = (scheduledAt: string): string => {
<AppLayout :fullWidth="true">
<div class="flex flex-col h-full">
<header class="grid shrink-0 grid-cols-[auto_1fr_auto] items-center gap-3 border-b-2 border-foreground bg-card px-4 py-3 md:px-6">
<div class="flex items-center gap-2 pl-12 md:pl-0">
<!-- Mobile header: nav + date jump on top, full-width New post below -->
<header class="flex shrink-0 flex-col gap-2 border-b-2 border-foreground bg-card px-4 py-3 lg:hidden">
<div class="flex items-center justify-between gap-2 pl-12">
<div class="flex items-center gap-2">
<Button variant="outline" size="icon" class="shrink-0" @click="navigate(-1)">
<IconChevronLeft class="size-4" />
</Button>
<Button variant="outline" @click="goToToday">
{{ $t('calendar.today') }}
</Button>
<Button variant="outline" size="icon" class="shrink-0" @click="navigate(1)">
<IconChevronRight class="size-4" />
</Button>
</div>
<div class="w-36 shrink-0">
<DatePicker v-model="selectedDate" :show-time="false" @update:model-value="(v: any) => goToDate(v)" />
</div>
</div>
<Link v-if="canCreatePost" :href="createPost.url()" class="block">
<Button class="w-full">{{ $t('calendar.new_post') }}</Button>
</Link>
</header>
<!-- Desktop header: nav · title · view switcher + new post -->
<header class="hidden shrink-0 grid-cols-[auto_1fr_auto] items-center gap-3 border-b-2 border-foreground bg-card px-6 py-3 lg:grid">
<div class="flex items-center gap-2">
<Button variant="outline" size="icon" @click="navigate(-1)">
<IconChevronLeft class="size-4" />
</Button>
@ -264,15 +288,14 @@ const formatTime = (scheduledAt: string): string => {
<Button variant="outline" size="icon" @click="navigate(1)">
<IconChevronRight class="size-4" />
</Button>
<DatePicker v-if="isMobile" v-model="selectedDate" @update:model-value="(v: any) => goToDate(v)" />
</div>
<div class="flex items-center justify-center">
<span class="truncate text-sm font-bold capitalize text-foreground">
{{ headerTitle }}
</span>
</div>
<div class="flex items-center gap-2">
<Tabs v-if="!isMobile" :default-value="view" @update:model-value="switchView">
<div class="flex items-center justify-end gap-2">
<Tabs :default-value="view" @update:model-value="switchView">
<TabsList>
<TabsTrigger value="day">{{ $t('calendar.day') }}</TabsTrigger>
<TabsTrigger value="week">{{ $t('calendar.week') }}</TabsTrigger>

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);
@ -356,20 +376,30 @@ usePostEcho(post.value.id, '.post.comment.created', (e: any) => {
<AppLayout :full-width="true">
<div class="flex flex-col flex-1 min-h-0">
<PostEditorHeader
:post="post"
:can-edit="canCreatePost"
: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"
<!-- On mobile the status moves into the switcher and the actions into the
bottom bar, so the header is desktop-only — except the scheduled banner,
which stays as the locked-state explanation. -->
<div :class="isScheduled ? 'shrink-0' : 'hidden shrink-0 lg:block'">
<PostEditorHeader
:post="post"
:can-edit="canCreatePost"
: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>
<PostEditorMobileNav
v-model:active-view="mobileView"
:status="post.status"
/>
<div class="relative flex-1 overflow-hidden">
@ -394,7 +424,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 +442,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 +473,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

@ -183,22 +183,22 @@ useWorkspaceEcho(
<PageHeader :title="pageTitle" />
<!-- Toolbar -->
<div class="flex items-center justify-between gap-3">
<div class="flex items-center gap-3">
<div class="relative">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center">
<div class="relative w-full sm:w-64">
<IconSearch class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-foreground/60" />
<Input
v-model="searchQuery"
:placeholder="trans('posts.search')"
class="w-64 pl-9"
class="w-full pl-9"
/>
</div>
<LabelFilter v-if="labels.length" v-model="selectedLabelIds" :labels="labels" />
</div>
<Link v-if="canCreatePost" :href="createPost.url()">
<Button>{{ $t('posts.new_post') }}</Button>
<Link v-if="canCreatePost" :href="createPost.url()" class="w-full sm:w-auto">
<Button class="w-full sm:w-auto">{{ $t('posts.new_post') }}</Button>
</Link>
</div>

View file

@ -122,8 +122,8 @@ usePostEcho(props.post.id, '.post.platform.status.updated', () => {
</div>
<div v-else class="flex flex-col flex-1 min-h-0">
<header 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-2 pl-12 md:pl-0">
<header class="flex shrink-0 flex-col gap-3 border-b-2 border-foreground bg-card px-4 py-3 md:flex-row md:items-center md:justify-between md:px-6">
<div class="pl-12 md:pl-0">
<Link :href="postsIndex.url()">
<Button variant="outline">
<IconArrowLeft class="size-4" />
@ -131,9 +131,9 @@ usePostEcho(props.post.id, '.post.platform.status.updated', () => {
</Button>
</Link>
</div>
<div class="flex flex-wrap items-center justify-end gap-3">
<span class="flex items-center gap-1.5 text-sm font-medium text-foreground/70">
<IconCalendar class="size-4 text-foreground/60" />
<div class="flex flex-wrap items-center justify-between gap-x-3 gap-y-2 md:flex-nowrap md:justify-end">
<span class="flex items-start gap-1.5 text-sm font-medium text-foreground/70">
<IconCalendar class="mt-0.5 size-4 shrink-0 text-foreground/60" />
<span v-if="post.published_at">
{{ $t('posts.show.published_on', { date: formatDateTime(post.published_at) }) }}
</span>
@ -142,7 +142,7 @@ usePostEcho(props.post.id, '.post.platform.status.updated', () => {
</span>
<span v-else>{{ $t('posts.show.draft') }}</span>
</span>
<Badge :variant="postStatus.variant">
<Badge :variant="postStatus.variant" class="shrink-0">
<component :is="postStatus.icon" class="size-3" />
{{ postStatus.label }}
</Badge>
@ -155,7 +155,7 @@ usePostEcho(props.post.id, '.post.platform.status.updated', () => {
<div class="mx-auto max-w-2xl px-6 py-10">
<!-- Media grid (top) — separate rounded tiles, 4-col -->
<div v-if="post.media.length > 0" class="mb-6">
<div class="grid grid-cols-4 gap-2">
<div class="grid grid-cols-3 gap-2 sm:grid-cols-4">
<button
v-for="(item, i) in post.media"
:key="item.id"

View file

@ -73,7 +73,7 @@ const tabs = computed(() => [
<SettingsTabsNav :tabs="tabs" active="api-keys" />
<div class="flex items-center justify-between gap-4">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<HeadingSmall
:title="$t('settings.api_keys.heading')"
:description="$t('settings.api_keys.description')"
@ -128,7 +128,7 @@ const tabs = computed(() => [
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="outline" size="icon" class="size-8">
<Button variant="outline" size="icon" class="size-9">
<IconDots class="size-4" />
</Button>
</DropdownMenuTrigger>

View file

@ -91,13 +91,13 @@ const hasActiveSearch = computed(() => Boolean(searchQuery.value?.trim()));
<div class="flex h-full flex-1 flex-col gap-6 px-6 py-8">
<PageHeader :title="$t('signatures.title')" />
<div class="flex items-center justify-between gap-3">
<div class="relative">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="relative w-full sm:w-auto">
<IconSearch class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
v-model="searchQuery"
:placeholder="trans('signatures.search')"
class="w-64 pl-9"
class="w-full pl-9 sm:w-64"
/>
</div>
@ -130,7 +130,7 @@ const hasActiveSearch = computed(() => Boolean(searchQuery.value?.trim()));
@click="openEditDialog(signature)"
>
<TableCell>{{ signature.name }}</TableCell>
<TableCell class="max-w-md">
<TableCell class="max-w-[160px] sm:max-w-md">
<p class="truncate">{{ signature.content }}</p>
</TableCell>
<TableCell>{{ formatDate(signature.created_at) }}</TableCell>

View file

@ -41,14 +41,14 @@ const switchToWorkspace = (workspace: Workspace) => {
<div
v-for="workspace in workspaces"
:key="workspace.id"
class="flex cursor-pointer items-center gap-3 rounded-lg border p-4 transition-colors hover:bg-accent/50"
:class="workspace.id === currentWorkspaceId ? 'border-primary/50 bg-accent/30' : ''"
class="flex cursor-pointer items-center gap-3 rounded-xl border-2 border-foreground bg-card p-4 shadow-2xs transition-all hover:-translate-y-0.5 hover:shadow-md"
:class="workspace.id === currentWorkspaceId ? 'bg-violet-100' : ''"
@click="switchToWorkspace(workspace)"
>
<Avatar
:src="workspace.logo_url"
:name="workspace.name"
class="size-10 shrink-0 rounded-lg"
class="size-10 shrink-0 rounded-lg border-2 border-foreground"
fallback-class="bg-muted text-muted-foreground"
/>
<div class="min-w-0 flex-1">

View file

@ -41,14 +41,14 @@ defineProps<Props>();
<AppLayout :title="workspace.name">
<div class="flex flex-col gap-6 p-6">
<div class="flex items-center justify-between">
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 class="text-2xl font-bold tracking-tight">{{ workspace.name }}</h1>
<p class="text-muted-foreground">
Workspace dashboard
</p>
</div>
<div class="flex gap-2">
<div class="flex flex-wrap gap-2">
<Link :href="settings.url()">
<Button variant="ghost" size="icon">
<IconSettings class="h-4 w-4" />

View file

@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
use App\Enums\UserWorkspace\Role;
use App\Models\Automation;
use App\Models\User;
use App\Models\Workspace;
test('the automation builder shows a desktop-recommended notice on a phone', function () {
$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]);
$automation = Automation::factory()->create(['workspace_id' => $workspace->id]);
$this->actingAs($user);
$page = visit(route('app.automations.workflow', $automation))->resize(375, 812);
$page->script(<<<'JS'
(async () => {
const sel = '[data-testid="automation-mobile-notice"]';
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);
$page->assertVisible('@automation-mobile-notice');
// The desktop-only builder header (name, status, guide, save, tabs) is
// replaced by a minimal back-only header on a phone — nothing here is
// actionable, so only the back button and the floating hamburger remain.
$page->assertVisible('@automation-mobile-back');
});
test('the automation builder shows the full header on desktop, not the minimal one', function () {
$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]);
$automation = Automation::factory()->create(['workspace_id' => $workspace->id]);
$this->actingAs($user);
$page = visit(route('app.automations.workflow', $automation))->resize(1280, 900);
$page->assertMissing('@automation-mobile-back');
$page->assertMissing('@automation-mobile-notice');
});

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');
});

View file

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
use App\Enums\UserWorkspace\Role;
use App\Models\Media;
use App\Models\User;
use App\Models\Workspace;
test('gallery upload card actions are visible on a phone without hover', function () {
$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]);
Media::factory()->for($workspace, 'mediable')->create([
'collection' => 'assets',
'original_filename' => 'on-the-go.jpg',
]);
$this->actingAs($user);
$page = visit(route('app.assets.index'))->resize(375, 812);
// Uploads are fetched async after mount; settle before asserting.
$page->script(<<<'JS'
(async () => {
const sel = '[data-testid="gallery-asset-delete"]';
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);
$page->assertVisible('@gallery-asset-delete');
});

View file

@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
use App\Enums\UserWorkspace\Role;
use App\Models\Automation;
use App\Models\Post;
use App\Models\PostPlatform;
use App\Models\User;
use App\Models\Workspace;
/**
* Guards against horizontal overflow on mobile across the layout variants
* (AuthLayout, default AppLayout, full-width editor, automations detail).
* Reproduces the AuthSplitLayout long-name overflow that a fixed grid column
* caused. Returns the widest offending element on failure for quick triage.
*/
function assertNoHorizontalOverflow(mixed $page, string $label): void
{
$result = $page->script(<<<'JS'
(async () => {
for (let i = 0; i < 80; i++) {
if (document.querySelector('h1, h2, main, [data-sidebar="trigger"]')) break;
await new Promise((r) => setTimeout(r, 50));
}
await new Promise((r) => setTimeout(r, 400));
const vw = window.innerWidth;
let worst = null;
document.querySelectorAll('*').forEach((el) => {
const r = el.getBoundingClientRect();
if (r.right > vw + 2 && (!worst || r.right > worst.right)) {
worst = { right: Math.round(r.right), tag: el.tagName.toLowerCase(), cls: (el.getAttribute('class') || '').slice(0, 80) };
}
});
return { sw: document.documentElement.scrollWidth, iw: vw, worst };
})()
JS);
$sw = (int) ($result['sw'] ?? 0);
$iw = (int) ($result['iw'] ?? 375);
$worst = $result['worst'] ?? null;
$detail = $worst ? " widest: <{$worst['tag']} class=\"{$worst['cls']}\"> right={$worst['right']}" : '';
expect($sw - $iw)->toBeLessThanOrEqual(2, "{$label} overflows: scrollWidth {$sw} > innerWidth {$iw}.{$detail}");
}
test('key pages do not overflow horizontally on a phone', function () {
$user = User::factory()->create();
$workspace = Workspace::factory()->create([
'user_id' => $user->id,
// A long name previously stretched the AuthSplitLayout grid column past the viewport.
'name' => 'A Really Very Extremely Long Workspace Name That Should Truncate Instead Of Overflowing',
]);
$workspace->members()->attach($user->id, ['role' => Role::Member->value]);
$user->update(['current_workspace_id' => $workspace->id]);
$post = Post::factory()->create([
'workspace_id' => $workspace->id,
'user_id' => $user->id,
'content' => 'Launching our new mobile editor today!',
]);
PostPlatform::factory()->count(2)->create(['post_id' => $post->id]);
$automation = Automation::factory()->create(['workspace_id' => $workspace->id]);
$this->actingAs($user);
$pages = [
'workspaces (AuthLayout)' => route('app.workspaces.index'),
'posts index (default)' => route('app.posts.index'),
'post editor (full-width)' => route('app.posts.edit', $post),
'calendar (full-width)' => route('app.calendar'),
'settings (tabs)' => route('app.api-keys.index'),
'automation metrics (detail)' => route('app.automations.metrics', $automation),
];
foreach ($pages as $label => $url) {
assertNoHorizontalOverflow(visit($url)->resize(375, 812), $label);
}
});

View file

@ -17,12 +17,26 @@
$this->automation = Automation::factory()->for($this->workspace)->create();
});
it('redirects the bare automation URL to the workflow tab', function () {
it('redirects a draft automation URL to the workflow tab', function () {
$this->actingAs($this->user)
->get(route('app.automations.show', $this->automation->id))
->assertRedirect(route('app.automations.workflow', $this->automation->id));
});
it('redirects a live automation URL to the metrics tab', function () {
$active = Automation::factory()->for($this->workspace)->active()->create();
$this->actingAs($this->user)
->get(route('app.automations.show', $active->id))
->assertRedirect(route('app.automations.metrics', $active->id));
$paused = Automation::factory()->for($this->workspace)->paused()->create();
$this->actingAs($this->user)
->get(route('app.automations.show', $paused->id))
->assertRedirect(route('app.automations.metrics', $paused->id));
});
it('renders the workflow editor on the workflow tab', function () {
$this->actingAs($this->user)
->get(route('app.automations.workflow', $this->automation->id))