refactor: in-page page headers, indies tabs, and post details polish

- Replace global AppHeader slots with in-page headers on Calendar, Edit,
  and Show pages — full-width canvas with the page-specific controls
  living in their own ink-bordered bar.
- Remove sidebar collapse mechanism: drop Cmd+B keyboard shortcut, force
  defaultOpen=true (ignore persisted cookie), make toggleSidebar a no-op
  on desktop. Sidebar is permanent on desktop, sheet behavior preserved
  on mobile.
- Redesign Tabs to match the design system: each TabsTrigger renders as
  a standalone sticker button (border-2 + rounded-md + shadow-xs), with
  amber-200 active state mirroring the "POST" badge from post-templates.
- Show page layout: cap preview Card to max-w-xl, preserve natural
  aspect for single-image posts (object-contain, max-h-[480px]), drop
  redundant stats grid, swap header sides (Back button left, status +
  date right with date first).
- Assets: bump GalleryBrowser search inputs to h-12 for comfortable
  browsing across uploads, Unsplash, and Giphy tabs.
- Fix PostPlatformMetrics loading-forever bug: hoist useHttp out of the
  onMounted callback into setup, and read the response array directly
  (backend returns the array at root, not under .data).
- i18n: rename posts.show.back_to_posts → posts.show.back; remove unused
  posts.show.summary.* keys.
This commit is contained in:
Paulo Castellano 2026-05-06 16:22:55 -03:00
parent 3ae4a3653a
commit 7733d573da
16 changed files with 256 additions and 300 deletions

View file

@ -200,6 +200,7 @@
'show' => [
'title' => 'Post Details',
'edit' => 'Edit',
'back' => 'Back',
'no_content' => 'No caption',
'platforms' => 'Platforms',
'no_platforms' => 'No platforms selected.',
@ -212,11 +213,6 @@
'metrics_loading' => 'Loading metrics…',
'metrics_unavailable' => 'Metrics unavailable for this platform yet.',
'metrics_empty' => 'No metrics returned.',
'summary' => [
'platforms' => 'Platforms',
'published' => 'Published',
'failed' => 'Failed',
],
],
'edit' => [

View file

@ -200,6 +200,7 @@
'show' => [
'title' => 'Detalles del post',
'edit' => 'Editar',
'back' => 'Volver',
'no_content' => 'Sin texto',
'platforms' => 'Plataformas',
'no_platforms' => 'Ninguna plataforma seleccionada.',
@ -212,11 +213,6 @@
'metrics_loading' => 'Cargando métricas…',
'metrics_unavailable' => 'Métricas aún no disponibles para esta plataforma.',
'metrics_empty' => 'No se devolvieron métricas.',
'summary' => [
'platforms' => 'Plataformas',
'published' => 'Publicado',
'failed' => 'Falló',
],
],
'edit' => [

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -200,6 +200,7 @@
'show' => [
'title' => 'Detalhes do Post',
'edit' => 'Editar',
'back' => 'Voltar',
'no_content' => 'Sem legenda',
'platforms' => 'Plataformas',
'no_platforms' => 'Nenhuma plataforma selecionada.',
@ -212,11 +213,6 @@
'metrics_loading' => 'Carregando métricas…',
'metrics_unavailable' => 'Métricas ainda não disponíveis para esta plataforma.',
'metrics_empty' => 'Nenhuma métrica retornada.',
'summary' => [
'platforms' => 'Plataformas',
'published' => 'Publicado',
'failed' => 'Falhou',
],
],
'edit' => [

View file

@ -39,7 +39,6 @@ import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from '@/components/ui/sidebar';
import { useActiveUrl } from '@/composables/useActiveUrl';
import { useFeatureAccess } from '@/composables/useFeatureAccess';
@ -61,8 +60,6 @@ const page = usePage();
const currentWorkspace = computed<Workspace | null>(() => page.props.auth.currentWorkspace as Workspace | null);
const workspaces = computed<Workspace[]>(() => page.props.auth.workspaces as Workspace[]);
const { state: sidebarState } = useSidebar();
const mainNavItems = computed<NavItem[]>(() => [
{
title: trans('sidebar.posts.calendar'),
@ -143,7 +140,7 @@ const handleCreateWorkspace = () => {
</script>
<template>
<Sidebar collapsible="icon">
<Sidebar collapsible="offcanvas">
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
@ -191,9 +188,8 @@ const handleCreateWorkspace = () => {
<!-- Create Post Button -->
<div v-if="currentWorkspace" class="px-2 py-2">
<Link :href="createPost.url()" class="block">
<Button :size="sidebarState === 'collapsed' ? 'icon' : 'default'" class="w-full">
<IconPlus v-if="sidebarState === 'collapsed'" class="size-4" />
<span v-if="sidebarState === 'expanded'">{{ $t('sidebar.create_post') }}</span>
<Button class="w-full">
{{ $t('sidebar.create_post') }}
</Button>
</Link>
</div>

View file

@ -22,14 +22,14 @@ import UserMenuContent from './UserMenuContent.vue';
const page = usePage();
const user = computed(() => page.props.auth.user);
const currentWorkspace = computed(() => page.props.auth.currentWorkspace);
const { isMobile, state } = useSidebar();
const { isMobile } = useSidebar();
</script>
<template>
<SidebarMenu>
<SidebarMenuItem>
<div class="flex items-center gap-1">
<NotificationBell v-if="currentWorkspace && state === 'expanded'" />
<NotificationBell v-if="currentWorkspace" />
<DropdownMenu>
<DropdownMenuTrigger as-child>
<SidebarMenuButton

View file

@ -584,12 +584,12 @@ onUnmounted(() => {
</div>
<div class="relative mb-4">
<IconSearch class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-foreground/60" />
<IconSearch class="pointer-events-none absolute left-3.5 top-1/2 size-5 -translate-y-1/2 text-foreground/60" />
<Input
v-model="uploadsSearch"
type="search"
:placeholder="trans('assets.search_placeholder')"
class="pl-9"
class="h-12 pl-11 text-base"
/>
</div>
@ -679,11 +679,11 @@ onUnmounted(() => {
<!-- Stock Photos (Unsplash) -->
<TabsContent value="stock" class="mt-6" @vue:mounted="onUnsplashTabMounted">
<div class="relative mb-4">
<IconSearch class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-foreground/60" />
<IconSearch class="pointer-events-none absolute left-3.5 top-1/2 size-5 -translate-y-1/2 text-foreground/60" />
<Input
v-model="unsplashQuery"
:placeholder="trans('assets.unsplash.search_placeholder')"
class="pl-9"
class="h-12 pl-11 text-base"
@input="searchUnsplashFn"
/>
</div>
@ -781,11 +781,11 @@ onUnmounted(() => {
<!-- GIFs (Giphy) -->
<TabsContent value="gifs" class="mt-6" @vue:mounted="onGiphyTabMounted">
<div class="relative mb-4">
<IconSearch class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-foreground/60" />
<IconSearch class="pointer-events-none absolute left-3.5 top-1/2 size-5 -translate-y-1/2 text-foreground/60" />
<Input
v-model="giphyQuery"
:placeholder="trans('assets.giphy.search_placeholder')"
class="pl-9"
class="h-12 pl-11 text-base"
@input="searchGiphyFn"
/>
</div>

View file

@ -10,6 +10,8 @@ interface Metric {
value: number;
}
type MetricsResponse = Metric[] | { unsupported: true; reason: string };
interface Props {
postId: string;
postPlatformId: string;
@ -28,15 +30,16 @@ const formatNumber = (n: number): string => {
return n.toString();
};
const http = useHttp<Record<string, never>, MetricsResponse>({});
onMounted(async () => {
try {
const response = await useHttp().get(
const response = await http.get(
metricsRoute.url({ post: props.postId, postPlatform: props.postPlatformId }),
) as { data: unknown };
const data = response.data;
);
if (Array.isArray(data)) {
metrics.value = data;
if (Array.isArray(response)) {
metrics.value = response;
}
} catch {
// Swallow empty state hides itself.

View file

@ -1,17 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes, Ref } from "vue"
import { defaultDocument, useEventListener, useMediaQuery, useVModel } from "@vueuse/core"
import { useMediaQuery, useVModel } from "@vueuse/core"
import { TooltipProvider } from "reka-ui"
import { computed, ref } from "vue"
import { cn } from "@/lib/utils"
import { provideSidebarContext, SIDEBAR_COOKIE_MAX_AGE, SIDEBAR_COOKIE_NAME, SIDEBAR_KEYBOARD_SHORTCUT, SIDEBAR_WIDTH, SIDEBAR_WIDTH_ICON } from "./utils"
import { provideSidebarContext, SIDEBAR_COOKIE_MAX_AGE, SIDEBAR_COOKIE_NAME, SIDEBAR_WIDTH, SIDEBAR_WIDTH_ICON } from "./utils"
const props = withDefaults(defineProps<{
defaultOpen?: boolean
open?: boolean
class?: HTMLAttributes["class"]
}>(), {
defaultOpen: !defaultDocument?.cookie.includes(`${SIDEBAR_COOKIE_NAME}=false`),
defaultOpen: true,
open: undefined,
})
@ -38,17 +38,13 @@ const setOpenMobile = (value: boolean) => {
openMobile.value = value
}
// Helper to toggle the sidebar.
// Helper to toggle the sidebar. Desktop toggling is intentionally a no-op:
// the app does not support hiding/collapsing the sidebar on desktop.
const toggleSidebar = () => {
return isMobile.value ? setOpenMobile(!openMobile.value) : setOpen(!open.value)
}
useEventListener("keydown", (event: KeyboardEvent) => {
if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) {
event.preventDefault()
toggleSidebar()
if (isMobile.value) {
setOpenMobile(!openMobile.value)
}
})
}
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.

View file

@ -15,7 +15,7 @@ const delegatedProps = reactiveOmit(props, "class")
data-slot="tabs-list"
v-bind="delegatedProps"
:class="cn(
'inline-flex h-10 w-fit items-center justify-center gap-1 rounded-full border-2 border-foreground bg-card p-1 shadow-2xs',
'inline-flex w-fit items-center gap-2',
props.class,
)"
>

View file

@ -16,7 +16,7 @@ const forwardedProps = useForwardProps(delegatedProps)
<TabsTrigger
data-slot="tabs-trigger"
:class="cn(
'inline-flex h-full cursor-pointer items-center justify-center gap-1.5 whitespace-nowrap rounded-full px-4 text-sm font-semibold text-foreground/60 transition-colors hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-foreground data-[state=active]:text-background [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4',
'inline-flex h-9 cursor-pointer items-center justify-center gap-1.5 whitespace-nowrap rounded-md border-2 border-foreground bg-card px-3 text-sm font-bold text-foreground shadow-xs transition-all hover:bg-accent hover:shadow-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-amber-200 data-[state=active]:hover:bg-amber-300 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4',
props.class,
)"
v-bind="forwardedProps"

View file

@ -246,39 +246,39 @@ const formatTime = (scheduledAt: string): string => {
<Head :title="$t('calendar.title')" />
<AppLayout :fullWidth="true">
<template #header>
<Button variant="outline" size="icon" @click="navigate(-1)">
<IconChevronLeft class="size-4" />
</Button>
<Button variant="outline" @click="goToToday">
{{ $t('calendar.today') }}
</Button>
<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)" />
<span class="text-sm font-bold capitalize text-foreground">
{{ headerTitle }}
</span>
</template>
<template #header-actions>
<div class="flex items-center gap-2">
<Tabs v-if="!isMobile" :default-value="view" @update:model-value="switchView">
<TabsList>
<TabsTrigger value="day">{{ $t('calendar.day') }}</TabsTrigger>
<TabsTrigger value="week">{{ $t('calendar.week') }}</TabsTrigger>
<TabsTrigger value="month">{{ $t('calendar.month') }}</TabsTrigger>
</TabsList>
</Tabs>
<Link :href="createPost.url()">
<Button>{{ $t('calendar.new_post') }}</Button>
</Link>
</div>
</template>
<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">
<Button variant="outline" size="icon" @click="navigate(-1)">
<IconChevronLeft class="size-4" />
</Button>
<Button variant="outline" @click="goToToday">
{{ $t('calendar.today') }}
</Button>
<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">
<TabsList>
<TabsTrigger value="day">{{ $t('calendar.day') }}</TabsTrigger>
<TabsTrigger value="week">{{ $t('calendar.week') }}</TabsTrigger>
<TabsTrigger value="month">{{ $t('calendar.month') }}</TabsTrigger>
</TabsList>
</Tabs>
<Link :href="createPost.url()">
<Button>{{ $t('calendar.new_post') }}</Button>
</Link>
</div>
</header>
<!-- Day View (mobile or when view=day) -->
<div v-if="effectiveView === 'day'" class="flex-1 overflow-y-auto">

View file

@ -440,99 +440,96 @@ useEcho(`post.${post.value.id}`, '.PostCommentCreated', (e: any) => {
<Head :title="$t('posts.edit.title')" />
<AppLayout :full-width="true">
<template #header-actions>
<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-if="!isScheduled" 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 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-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-if="!isScheduled" 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>
<template v-if="!isReadOnly">
<span class="h-5 w-px bg-foreground/15" />
<div v-if="!isReadOnly" class="flex items-center gap-2">
<TooltipProvider>
<Tooltip>
<TooltipTrigger as-child>
<Button
type="button"
variant="outline"
size="icon"
class="bg-rose-100 hover:bg-rose-200"
:disabled="isSaving || isSubmitting"
@click="deletePost"
>
<IconTrash class="size-4 text-rose-700" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ $t('posts.edit.delete') }}</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger as-child>
<template v-if="isScheduled">
<PickTimePopover
v-model="scheduledDateTime"
:disabled="isSubmitting"
show-remove
@confirm="hasPickedTime = true"
@remove="unschedulePost"
>
<Button
type="button"
variant="outline"
size="icon"
class="size-8 bg-rose-100 hover:bg-rose-200"
:disabled="isSaving || isSubmitting"
@click="deletePost"
class="bg-violet-100 hover:bg-violet-200"
:disabled="isSubmitting"
>
<IconTrash class="size-4 text-rose-700" />
<IconCalendar class="size-4 text-violet-700" />
<span class="font-bold text-violet-900">
{{ $t('posts.edit.scheduled_for', { date: pickTimeLabel }) }}
</span>
</Button>
</TooltipTrigger>
<TooltipContent>{{ $t('posts.edit.delete') }}</TooltipContent>
</Tooltip>
</TooltipProvider>
<template v-if="isScheduled">
<PickTimePopover
v-model="scheduledDateTime"
:disabled="isSubmitting"
show-remove
@confirm="hasPickedTime = true"
@remove="unschedulePost"
>
<Button
type="button"
variant="outline"
size="sm"
class="bg-violet-100 hover:bg-violet-200"
:disabled="isSubmitting"
</PickTimePopover>
</template>
<template v-else>
<PickTimePopover
v-model="scheduledDateTime"
:disabled="isPostActionDisabled"
@confirm="hasPickedTime = true"
>
<IconCalendar class="size-4 text-violet-700" />
<span class="font-bold text-violet-900">
{{ $t('posts.edit.scheduled_for', { date: pickTimeLabel }) }}
</span>
</Button>
</PickTimePopover>
</template>
<template v-else>
<PickTimePopover
v-model="scheduledDateTime"
:disabled="isPostActionDisabled"
@confirm="hasPickedTime = true"
>
<Button
type="button"
variant="outline"
:disabled="isPostActionDisabled"
:title="postActionTooltip"
>
<IconCalendar class="size-4" />
{{ pickTimeLabel }}
</Button>
</PickTimePopover>
<Button
type="button"
variant="outline"
size="sm"
:disabled="isPostActionDisabled"
:title="postActionTooltip"
@click="submit(hasPickedTime ? 'scheduled' : 'publishing')"
>
<IconCalendar class="size-4" />
{{ pickTimeLabel }}
{{ hasPickedTime ? $t('posts.edit.schedule') : $t('posts.edit.post_now') }}
</Button>
</PickTimePopover>
</template>
</div>
</header>
<Button
type="button"
size="sm"
:disabled="isPostActionDisabled"
:title="postActionTooltip"
@click="submit(hasPickedTime ? 'scheduled' : 'publishing')"
>
{{ hasPickedTime ? $t('posts.edit.schedule') : $t('posts.edit.post_now') }}
</Button>
</template>
</template>
</template>
<div class="flex flex-col flex-1 min-h-0">
<div class="relative flex-1 overflow-hidden">
<div
v-if="isPublishing"

View file

@ -1,7 +1,7 @@
<script setup lang="ts">
import { Head, router } from '@inertiajs/vue3';
import { Head, Link, router } from '@inertiajs/vue3';
import { useEcho } from '@laravel/echo-vue';
import { IconCalendar, IconExternalLink, IconLoader2, IconX } from '@tabler/icons-vue';
import { IconArrowLeft, IconCalendar, IconExternalLink, IconLoader2, IconX } from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n';
import { computed, ref } from 'vue';
@ -9,6 +9,7 @@ import LabelBadge from '@/components/labels/LabelBadge.vue';
import PostPlatformMetrics from '@/components/posts/PostPlatformMetrics.vue';
import { Avatar } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { getPlatformLabel, getPlatformLogo } from '@/composables/usePlatformLogo';
@ -70,8 +71,6 @@ const props = defineProps<{
}>();
const enabledPlatforms = computed(() => props.post.platforms.filter((pp) => pp.enabled));
const publishedCount = computed(() => enabledPlatforms.value.filter((pp) => pp.status === 'published').length);
const failedCount = computed(() => enabledPlatforms.value.filter((pp) => pp.status === 'failed').length);
const isPublishing = computed(() => props.post.status === 'publishing');
@ -130,30 +129,38 @@ useEcho(`post.${props.post.id}`, '.PostPlatformStatusUpdated', () => {
</p>
</div>
<div v-else class="grid flex-1 grid-cols-1 lg:grid-cols-2 lg:divide-x-2 lg:divide-foreground/10">
<!-- LEFT: post preview -->
<div class="border-b-2 border-foreground/10 p-6 lg:border-b-0">
<div class="flex flex-col gap-4">
<!-- Status row -->
<div class="flex flex-wrap items-center gap-3">
<Badge :variant="postStatus.variant">
<component :is="postStatus.icon" class="size-3" />
{{ postStatus.label }}
</Badge>
<span class="flex items-center gap-1.5 text-sm font-medium text-foreground/70">
<IconCalendar class="size-4 text-foreground/60" />
<span v-if="post.published_at">
{{ $t('posts.show.published_on', { date: formatDateTime(post.published_at) }) }}
</span>
<span v-else-if="post.scheduled_at">
{{ $t('posts.show.scheduled_for', { date: formatDateTime(post.scheduled_at) }) }}
</span>
<span v-else>{{ $t('posts.show.draft') }}</span>
<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">
<Link :href="postsIndex.url()">
<Button variant="outline">
<IconArrowLeft class="size-4" />
{{ $t('posts.show.back') }}
</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" />
<span v-if="post.published_at">
{{ $t('posts.show.published_on', { date: formatDateTime(post.published_at) }) }}
</span>
</div>
<span v-else-if="post.scheduled_at">
{{ $t('posts.show.scheduled_for', { date: formatDateTime(post.scheduled_at) }) }}
</span>
<span v-else>{{ $t('posts.show.draft') }}</span>
</span>
<Badge :variant="postStatus.variant">
<component :is="postStatus.icon" class="size-3" />
{{ postStatus.label }}
</Badge>
</div>
</header>
<!-- Post preview card -->
<Card class="overflow-hidden py-0">
<div class="grid flex-1 grid-cols-1 overflow-y-auto lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] lg:overflow-hidden">
<!-- LEFT: post preview -->
<div class="border-b-2 border-foreground/10 p-6 lg:overflow-y-auto lg:border-b-0 lg:border-r-2 lg:border-foreground">
<Card class="mx-auto max-w-xl overflow-hidden py-0">
<CardContent v-if="post.content" class="p-6">
<p class="whitespace-pre-wrap text-sm leading-relaxed text-foreground">{{ post.content }}</p>
</CardContent>
@ -166,20 +173,23 @@ useEcho(`post.${props.post.id}`, '.PostPlatformStatusUpdated', () => {
v-for="(item, i) in post.media"
:key="item.id"
type="button"
class="group relative aspect-square cursor-pointer overflow-hidden bg-muted transition-opacity hover:opacity-90"
:class="[
'group relative cursor-pointer overflow-hidden bg-muted transition-opacity hover:opacity-90',
post.media.length === 1 ? 'flex items-center justify-center' : 'aspect-square',
]"
@click="openLightbox(i)"
>
<video
v-if="item.type === 'video' || item.mime_type?.startsWith('video/')"
:src="item.url"
class="size-full object-cover"
:class="post.media.length === 1 ? 'max-h-[480px] w-full object-contain' : 'size-full object-cover'"
muted
/>
<img
v-else
:src="item.url"
:alt="item.original_filename"
class="size-full object-cover"
:class="post.media.length === 1 ? 'max-h-[480px] w-full object-contain' : 'size-full object-cover'"
loading="lazy"
/>
</button>
@ -197,124 +207,90 @@ useEcho(`post.${props.post.id}`, '.PostPlatformStatusUpdated', () => {
</div>
</Card>
</div>
</div>
<!-- RIGHT: platforms breakdown -->
<div class="flex flex-col gap-4 p-6">
<!-- Summary stats -->
<div v-if="enabledPlatforms.length > 0" class="grid grid-cols-3 gap-4">
<Card>
<CardContent class="p-6">
<p class="text-[11px] font-black uppercase tracking-widest text-foreground/60">
{{ $t('posts.show.summary.platforms') }}
</p>
<p class="mt-2 text-3xl font-bold tabular-nums tracking-tight text-foreground">
{{ enabledPlatforms.length }}
</p>
<!-- RIGHT: platforms breakdown -->
<div class="flex flex-col gap-4 p-6 lg:overflow-y-auto">
<h2 class="text-[11px] font-black uppercase tracking-widest text-foreground/60">
{{ $t('posts.show.platforms') }}
<span class="ml-1 text-foreground/40">({{ enabledPlatforms.length }})</span>
</h2>
<Card v-if="enabledPlatforms.length === 0" class="py-0">
<CardContent class="p-8 text-center text-sm font-medium text-foreground/60">
{{ $t('posts.show.no_platforms') }}
</CardContent>
</Card>
<Card>
<CardContent class="p-6">
<p class="text-[11px] font-black uppercase tracking-widest text-foreground/60">
{{ $t('posts.show.summary.published') }}
</p>
<p class="mt-2 text-3xl font-bold tabular-nums tracking-tight text-foreground">
{{ publishedCount }}
</p>
</CardContent>
</Card>
<Card>
<CardContent class="p-6">
<p class="text-[11px] font-black uppercase tracking-widest text-foreground/60">
{{ $t('posts.show.summary.failed') }}
</p>
<p class="mt-2 text-3xl font-bold tabular-nums tracking-tight text-foreground">
{{ failedCount }}
</p>
</CardContent>
</Card>
</div>
<!-- Platforms list -->
<h2 class="text-[11px] font-black uppercase tracking-widest text-foreground/60">
{{ $t('posts.show.platforms') }}
</h2>
<Card v-if="enabledPlatforms.length === 0" class="py-0">
<CardContent class="p-8 text-center text-sm font-medium text-foreground/60">
{{ $t('posts.show.no_platforms') }}
</CardContent>
</Card>
<div v-else class="space-y-3">
<Card v-for="pp in enabledPlatforms" :key="pp.id" class="overflow-hidden py-0">
<CardContent class="p-0">
<div class="flex items-center gap-3 p-4">
<div class="relative shrink-0">
<Avatar
:src="getDisplayAvatar(pp)"
:name="getDisplayName(pp)"
class="size-11 rounded-full border-2 border-foreground 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">
<img
:src="getPlatformLogo(pp.platform)"
:alt="pp.platform"
class="size-full object-cover"
<div v-else class="space-y-3">
<Card v-for="pp in enabledPlatforms" :key="pp.id" class="overflow-hidden py-0">
<CardContent class="p-0">
<div class="flex items-center gap-3 p-4">
<div class="relative shrink-0">
<Avatar
:src="getDisplayAvatar(pp)"
:name="getDisplayName(pp)"
class="size-11 rounded-full border-2 border-foreground shadow-2xs"
/>
</span>
</div>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-bold text-foreground">{{ getDisplayName(pp) }}</p>
<p class="truncate text-xs font-medium text-foreground/60">
<span v-if="getDisplayUsername(pp)">@{{ getDisplayUsername(pp) }} · </span>
{{ getPlatformLabel(pp.platform) }}
</p>
<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">
<img
:src="getPlatformLogo(pp.platform)"
:alt="pp.platform"
class="size-full object-cover"
/>
</span>
</div>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-bold text-foreground">{{ getDisplayName(pp) }}</p>
<p class="truncate text-xs font-medium text-foreground/60">
<span v-if="getDisplayUsername(pp)">@{{ getDisplayUsername(pp) }} · </span>
{{ getPlatformLabel(pp.platform) }}
</p>
</div>
<div class="flex shrink-0 items-center gap-2">
<Badge :variant="getPlatformStatusConfig(pp.status).variant">
<component
:is="getPlatformStatusConfig(pp.status).icon"
class="size-3"
:class="pp.status === 'publishing' ? 'animate-spin' : ''"
/>
{{ getPlatformStatusConfig(pp.status).label }}
</Badge>
<TooltipProvider v-if="pp.status === 'published' && pp.platform_url" :delay-duration="200">
<Tooltip>
<TooltipTrigger as-child>
<a
:href="pp.platform_url"
target="_blank"
rel="noopener noreferrer"
class="inline-flex size-8 cursor-pointer items-center justify-center rounded-full border-2 border-foreground bg-card text-foreground shadow-2xs transition-transform hover:rotate-3 hover:bg-violet-100"
>
<IconExternalLink class="size-4" stroke-width="2.5" />
</a>
</TooltipTrigger>
<TooltipContent>{{ $t('posts.show.view_on_platform') }}</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
<div class="flex shrink-0 items-center gap-2">
<Badge :variant="getPlatformStatusConfig(pp.status).variant">
<component
:is="getPlatformStatusConfig(pp.status).icon"
class="size-3"
:class="pp.status === 'publishing' ? 'animate-spin' : ''"
/>
{{ getPlatformStatusConfig(pp.status).label }}
</Badge>
<TooltipProvider v-if="pp.status === 'published' && pp.platform_url" :delay-duration="200">
<Tooltip>
<TooltipTrigger as-child>
<a
:href="pp.platform_url"
target="_blank"
rel="noopener noreferrer"
class="inline-flex size-8 cursor-pointer items-center justify-center rounded-full border-2 border-foreground bg-card text-foreground shadow-2xs transition-transform hover:rotate-3 hover:bg-violet-100"
>
<IconExternalLink class="size-4" stroke-width="2.5" />
</a>
</TooltipTrigger>
<TooltipContent>{{ $t('posts.show.view_on_platform') }}</TooltipContent>
</Tooltip>
</TooltipProvider>
<!-- Failed: error message -->
<div
v-if="pp.status === 'failed' && pp.error_message"
class="border-t-2 border-foreground/10 bg-rose-50 px-4 py-3 text-xs font-semibold text-rose-700"
>
{{ pp.error_message }}
</div>
</div>
<!-- Failed: error message -->
<div
v-if="pp.status === 'failed' && pp.error_message"
class="border-t-2 border-foreground/10 bg-rose-50 px-4 py-3 text-xs font-semibold text-rose-700"
>
{{ pp.error_message }}
</div>
<!-- Published: metrics -->
<PostPlatformMetrics
v-if="pp.status === 'published'"
:post-id="post.id"
:post-platform-id="pp.id"
/>
</CardContent>
</Card>
<!-- Published: metrics -->
<PostPlatformMetrics
v-if="pp.status === 'published'"
:post-id="post.id"
:post-platform-id="pp.id"
/>
</CardContent>
</Card>
</div>
</div>
</div>
</div>