Merge pull request #187 from trypostit/feat/mobile-experience
Improve the mobile experience (post editor + app-wide polish)
This commit is contained in:
commit
132f84f404
92 changed files with 942 additions and 328 deletions
|
|
@ -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`)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'mobile_notice' => 'يعمل محرر الأتمتة بشكل أفضل على شاشة أكبر. افتحه على جهاز كمبيوتر مكتبي لإنشاء سير عملك.',
|
||||
'title' => 'الأتمتة',
|
||||
'default_name' => 'أتمتة جديدة',
|
||||
|
||||
|
|
|
|||
|
|
@ -363,6 +363,7 @@
|
|||
'unschedule_cta' => 'ألغِ الجدولة للتعديل',
|
||||
|
||||
'tabs' => [
|
||||
'compose' => 'الكتابة',
|
||||
'preview' => 'معاينة',
|
||||
'channels' => 'القنوات',
|
||||
'comments' => 'التعليقات',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
||||
|
|
|
|||
|
|
@ -365,6 +365,7 @@
|
|||
'unschedule_cta' => 'Zum Bearbeiten Planung aufheben',
|
||||
|
||||
'tabs' => [
|
||||
'compose' => 'Verfassen',
|
||||
'preview' => 'Vorschau',
|
||||
'channels' => 'Kanäle',
|
||||
'comments' => 'Kommentare',
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'mobile_notice' => 'Ο επεξεργαστής αυτοματισμών λειτουργεί καλύτερα σε μεγαλύτερη οθόνη. Άνοιξέ τον σε υπολογιστή για να δημιουργήσεις τη ροή εργασίας σου.',
|
||||
'title' => 'Αυτοματισμοί',
|
||||
'default_name' => 'Νέος αυτοματισμός',
|
||||
|
||||
|
|
|
|||
|
|
@ -363,6 +363,7 @@
|
|||
'unschedule_cta' => 'Αναίρεση προγραμματισμού για επεξεργασία',
|
||||
|
||||
'tabs' => [
|
||||
'compose' => 'Σύνταξη',
|
||||
'preview' => 'Προεπισκόπηση',
|
||||
'channels' => 'Κανάλια',
|
||||
'comments' => 'Σχόλια',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
||||
|
|
|
|||
|
|
@ -363,6 +363,7 @@
|
|||
'unschedule_cta' => 'Unschedule to edit',
|
||||
|
||||
'tabs' => [
|
||||
'compose' => 'Compose',
|
||||
'preview' => 'Preview',
|
||||
'channels' => 'Channels',
|
||||
'comments' => 'Comments',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
||||
|
|
|
|||
|
|
@ -363,6 +363,7 @@
|
|||
'unschedule_cta' => 'Cancelar para editar',
|
||||
|
||||
'tabs' => [
|
||||
'compose' => 'Redactar',
|
||||
'preview' => 'Vista previa',
|
||||
'channels' => 'Canales',
|
||||
'comments' => 'Comentarios',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
||||
|
|
|
|||
|
|
@ -363,6 +363,7 @@
|
|||
'unschedule_cta' => 'Déprogrammer pour modifier',
|
||||
|
||||
'tabs' => [
|
||||
'compose' => 'Rédiger',
|
||||
'preview' => 'Aperçu',
|
||||
'channels' => 'Canaux',
|
||||
'comments' => 'Commentaires',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
||||
|
|
|
|||
|
|
@ -363,6 +363,7 @@
|
|||
'unschedule_cta' => 'Annulla programmazione per modificare',
|
||||
|
||||
'tabs' => [
|
||||
'compose' => 'Componi',
|
||||
'preview' => 'Anteprima',
|
||||
'channels' => 'Canali',
|
||||
'comments' => 'Commenti',
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'mobile_notice' => 'オートメーションエディタは大きな画面での利用に最適です。ワークフローを作成するにはデスクトップで開いてください。',
|
||||
'title' => 'オートメーション',
|
||||
'default_name' => '新しいオートメーション',
|
||||
|
||||
|
|
|
|||
|
|
@ -363,6 +363,7 @@
|
|||
'unschedule_cta' => '予約を解除して編集',
|
||||
|
||||
'tabs' => [
|
||||
'compose' => '作成',
|
||||
'preview' => 'プレビュー',
|
||||
'channels' => 'チャンネル',
|
||||
'comments' => 'コメント',
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'mobile_notice' => '자동화 편집기는 큰 화면에서 가장 잘 작동합니다. 워크플로를 만들려면 데스크톱에서 열어주세요.',
|
||||
'title' => '자동화',
|
||||
'default_name' => '새 자동화',
|
||||
|
||||
|
|
|
|||
|
|
@ -363,6 +363,7 @@
|
|||
'unschedule_cta' => '편집하려면 예약 취소',
|
||||
|
||||
'tabs' => [
|
||||
'compose' => '작성',
|
||||
'preview' => '미리보기',
|
||||
'channels' => '채널',
|
||||
'comments' => '댓글',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
||||
|
|
|
|||
|
|
@ -363,6 +363,7 @@
|
|||
'unschedule_cta' => 'Planning opheffen om te bewerken',
|
||||
|
||||
'tabs' => [
|
||||
'compose' => 'Opstellen',
|
||||
'preview' => 'Voorbeeld',
|
||||
'channels' => 'Kanalen',
|
||||
'comments' => 'Reacties',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
||||
|
|
|
|||
|
|
@ -363,6 +363,7 @@
|
|||
'unschedule_cta' => 'Anuluj planowanie, aby edytować',
|
||||
|
||||
'tabs' => [
|
||||
'compose' => 'Utwórz',
|
||||
'preview' => 'Podgląd',
|
||||
'channels' => 'Kanały',
|
||||
'comments' => 'Komentarze',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
||||
|
|
|
|||
|
|
@ -363,6 +363,7 @@
|
|||
'unschedule_cta' => 'Desagendar para editar',
|
||||
|
||||
'tabs' => [
|
||||
'compose' => 'Escrever',
|
||||
'preview' => 'Pré-visualização',
|
||||
'channels' => 'Canais',
|
||||
'comments' => 'Comentários',
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'mobile_notice' => 'Редактор автоматизаций лучше всего работает на большом экране. Откройте его на компьютере, чтобы создать рабочий процесс.',
|
||||
'title' => 'Автоматизации',
|
||||
'default_name' => 'Новая автоматизация',
|
||||
|
||||
|
|
|
|||
|
|
@ -363,6 +363,7 @@
|
|||
'unschedule_cta' => 'Отменить планирование для редактирования',
|
||||
|
||||
'tabs' => [
|
||||
'compose' => 'Написать',
|
||||
'preview' => 'Предпросмотр',
|
||||
'channels' => 'Каналы',
|
||||
'comments' => 'Комментарии',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
||||
|
|
|
|||
|
|
@ -365,6 +365,7 @@
|
|||
'unschedule_cta' => 'Düzenlemek için zamanlamayı kaldır',
|
||||
|
||||
'tabs' => [
|
||||
'compose' => 'Oluştur',
|
||||
'preview' => 'Önizleme',
|
||||
'channels' => 'Kanallar',
|
||||
'comments' => 'Yorumlar',
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'mobile_notice' => '自动化编辑器在更大的屏幕上体验最佳。请在电脑上打开以搭建你的工作流。',
|
||||
'title' => '自动化',
|
||||
'default_name' => '新建自动化',
|
||||
|
||||
|
|
|
|||
|
|
@ -363,6 +363,7 @@
|
|||
'unschedule_cta' => '取消排期以编辑',
|
||||
|
||||
'tabs' => [
|
||||
'compose' => '撰写',
|
||||
'preview' => '预览',
|
||||
'channels' => '渠道',
|
||||
'comments' => '评论',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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'"
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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 -->
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
52
resources/js/components/posts/editor/PostEditorActionBar.vue
Normal file
52
resources/js/components/posts/editor/PostEditorActionBar.vue
Normal 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>
|
||||
117
resources/js/components/posts/editor/PostEditorActions.vue
Normal file
117
resources/js/components/posts/editor/PostEditorActions.vue
Normal 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>
|
||||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
46
resources/js/components/posts/editor/PostEditorMobileNav.vue
Normal file
46
resources/js/components/posts/editor/PostEditorMobileNav.vue
Normal 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>
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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') }}
|
||||
|
|
|
|||
|
|
@ -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') }}
|
||||
|
|
|
|||
|
|
@ -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') }}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
55
tests/Browser/MobileAutomationsTest.php
Normal file
55
tests/Browser/MobileAutomationsTest.php
Normal 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');
|
||||
});
|
||||
103
tests/Browser/MobileEditorTest.php
Normal file
103
tests/Browser/MobileEditorTest.php
Normal 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');
|
||||
});
|
||||
38
tests/Browser/MobileGalleryTest.php
Normal file
38
tests/Browser/MobileGalleryTest.php
Normal 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');
|
||||
});
|
||||
79
tests/Browser/MobileOverflowTest.php
Normal file
79
tests/Browser/MobileOverflowTest.php
Normal 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);
|
||||
}
|
||||
});
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
Loading…
Reference in a new issue