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:
parent
3ae4a3653a
commit
7733d573da
16 changed files with 256 additions and 300 deletions
|
|
@ -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' => [
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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' => [
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue