refactor: introduce VideoPreview component and standardize media handling across post previews
This commit is contained in:
parent
148a2f432f
commit
ff1cc63d9b
38 changed files with 477 additions and 327 deletions
|
|
@ -8,8 +8,8 @@
|
|||
|
||||
class UserAiCreationChannel
|
||||
{
|
||||
public function join(User $user, string $userId, string $creationId): bool
|
||||
public function join(User $user, User $owner, string $creationId): bool
|
||||
{
|
||||
return $user->id === $userId;
|
||||
return $user->is($owner);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@
|
|||
|
||||
class UserAiGenerationChannel
|
||||
{
|
||||
public function join(User $user, string $userId, string $generationId): bool
|
||||
public function join(User $user, User $owner, string $generationId): bool
|
||||
{
|
||||
return $user->id === $userId;
|
||||
return $user->is($owner);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ enum Type: string
|
|||
case PostPublished = 'post_published';
|
||||
case PostFailed = 'post_failed';
|
||||
case PostPartiallyPublished = 'post_partially_published';
|
||||
case PostReady = 'post_ready';
|
||||
case AccountDisconnected = 'account_disconnected';
|
||||
case InviteReceived = 'invite_received';
|
||||
case MemberJoined = 'member_joined';
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ public function broadcastAs(): string
|
|||
|
||||
public function broadcastOn(): PrivateChannel
|
||||
{
|
||||
return new PrivateChannel("users.{$this->userId}.ai-creation.{$this->creationId}");
|
||||
return new PrivateChannel("user.{$this->userId}.ai-creation.{$this->creationId}");
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -8,8 +8,11 @@
|
|||
use App\Jobs\Ai\StreamPostCreation;
|
||||
use App\Models\SocialAccount;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Str;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response as InertiaResponse;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class PostAiCreateController extends Controller
|
||||
|
|
@ -52,7 +55,18 @@ public function start(StartPostCreationRequest $request): JsonResponse
|
|||
|
||||
return response()->json([
|
||||
'creation_id' => $creationId,
|
||||
'channel' => "users.{$request->user()->id}.ai-creation.{$creationId}",
|
||||
'channel' => "user.{$request->user()->id}.ai-creation.{$creationId}",
|
||||
], Response::HTTP_ACCEPTED);
|
||||
}
|
||||
|
||||
public function loading(Request $request, string $creationId): InertiaResponse
|
||||
{
|
||||
return Inertia::render('posts/ai/Loading', [
|
||||
'creationId' => $creationId,
|
||||
'channel' => "user.{$request->user()->id}.ai-creation.{$creationId}",
|
||||
'imageCount' => (int) $request->query('images', '0'),
|
||||
'format' => (string) $request->query('format', ''),
|
||||
'prompt' => (string) $request->query('prompt', ''),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ public function generate(GeneratePostContentRequest $request, Post $post): JsonR
|
|||
|
||||
return response()->json([
|
||||
'generation_id' => $generationId,
|
||||
'channel' => "users.{$request->user()->id}.ai-gen.{$generationId}",
|
||||
'channel' => "user.{$request->user()->id}.ai-gen.{$generationId}",
|
||||
], Response::HTTP_ACCEPTED);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ public function handle(): void
|
|||
currentContent: $this->currentContent,
|
||||
);
|
||||
|
||||
$channel = new PrivateChannel("users.{$this->userId}.ai-gen.{$this->generationId}");
|
||||
$channel = new PrivateChannel("user.{$this->userId}.ai-gen.{$this->generationId}");
|
||||
|
||||
try {
|
||||
$response = $agent->broadcast($this->prompt, $channel, now: true);
|
||||
|
|
|
|||
|
|
@ -9,8 +9,11 @@
|
|||
use App\Ai\Agents\PostContentHumanizer;
|
||||
use App\Enums\Media\Source;
|
||||
use App\Enums\Media\Type as MediaType;
|
||||
use App\Enums\Notification\Channel as NotificationChannel;
|
||||
use App\Enums\Notification\Type as NotificationType;
|
||||
use App\Enums\PostPlatform\ContentType;
|
||||
use App\Events\Ai\PostCreationReady;
|
||||
use App\Jobs\SendNotification;
|
||||
use App\Models\Post;
|
||||
use App\Models\SocialAccount;
|
||||
use App\Models\User;
|
||||
|
|
@ -219,11 +222,7 @@ private function handleCarousel(Workspace $workspace, ?SocialAccount $socialAcco
|
|||
|
||||
$post = $this->createPost($workspace, $caption, $media, $socialAccount);
|
||||
|
||||
PostCreationReady::dispatch(
|
||||
userId: $this->userId,
|
||||
creationId: $this->creationId,
|
||||
postId: $post->id,
|
||||
);
|
||||
$this->notifyReady($workspace, $post);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -263,11 +262,7 @@ private function handleSingle(Workspace $workspace, ?SocialAccount $socialAccoun
|
|||
$caption = $supportsCaption ? $rawContent : '';
|
||||
$post = $this->createPost($workspace, $caption, $media, $socialAccount);
|
||||
|
||||
PostCreationReady::dispatch(
|
||||
userId: $this->userId,
|
||||
creationId: $this->creationId,
|
||||
postId: $post->id,
|
||||
);
|
||||
$this->notifyReady($workspace, $post);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -308,6 +303,29 @@ private function createPost(Workspace $workspace, string $content, array $media,
|
|||
return $post;
|
||||
}
|
||||
|
||||
private function notifyReady(Workspace $workspace, Post $post): void
|
||||
{
|
||||
PostCreationReady::dispatch(
|
||||
userId: $this->userId,
|
||||
creationId: $this->creationId,
|
||||
postId: $post->id,
|
||||
);
|
||||
|
||||
$user = User::findOrFail($this->userId);
|
||||
|
||||
$locale = $workspace->content_language ?? 'en';
|
||||
|
||||
SendNotification::dispatch(
|
||||
user: $user,
|
||||
workspaceId: $workspace->id,
|
||||
type: NotificationType::PostReady,
|
||||
channel: NotificationChannel::InApp,
|
||||
title: trans('notifications.post_ready.title', [], $locale),
|
||||
body: trans('notifications.post_ready.body', [], $locale),
|
||||
data: ['post_id' => $post->id],
|
||||
);
|
||||
}
|
||||
|
||||
private function aspectRatioFor(ContentType $type): ?string
|
||||
{
|
||||
$dims = $type->aiImageDimensions();
|
||||
|
|
|
|||
10
lang/en/notifications.php
Normal file
10
lang/en/notifications.php
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'post_ready' => [
|
||||
'title' => 'Your post is ready',
|
||||
'body' => 'The AI just finished. Tap to review and publish.',
|
||||
],
|
||||
];
|
||||
|
|
@ -243,8 +243,6 @@
|
|||
'title' => 'No platform selected',
|
||||
'description' => 'Select a platform to publish to see the preview.',
|
||||
],
|
||||
'drag_drop' => 'Drop files to upload',
|
||||
'drag_drop_hint' => 'Drag & drop files here, or use the buttons above',
|
||||
'drop_zone_title' => 'Add media',
|
||||
'drop_zone_subtitle' => 'Drag & drop files or click to browse',
|
||||
'add' => 'Add',
|
||||
|
|
@ -501,9 +499,21 @@
|
|||
'prompt_title' => 'Describe your post',
|
||||
'prompt_label' => 'What is this post about?',
|
||||
'prompt_placeholder' => 'e.g. Announce our new carousel feature for Instagram',
|
||||
'generating_title' => 'Generating',
|
||||
'generation_loading' => 'Generating your post. This can take up to a minute.',
|
||||
'preview_error' => 'Something went wrong. Please try again.',
|
||||
'loading_page_title' => 'Generating your post',
|
||||
'loading_eta' => 'Estimated time: about :minutes.',
|
||||
'loading_eta_minute_one' => '1 minute',
|
||||
'loading_eta_minute_other' => ':count minutes',
|
||||
'loading_leave_title' => 'You can keep working.',
|
||||
'loading_leave_body' => 'We will notify you when the post is ready.',
|
||||
'loading_leave_cta' => 'Go to calendar',
|
||||
'loading_create_another_cta' => 'Create another post',
|
||||
'loading_tip_credits' => 'Each AI image uses about 15 credits.',
|
||||
'loading_tip_edit' => 'You will be able to edit everything once the post is ready.',
|
||||
'loading_tip_draft' => 'Generated posts land in your drafts.',
|
||||
'loading_tip_brand' => 'Tweak your brand settings to influence future posts.',
|
||||
'loading_tip_carousel' => 'Carousels deliver one slide per uploaded image.',
|
||||
'loading_tip_quality' => 'Image quality is set to balance speed and cost.',
|
||||
'create' => 'Create post',
|
||||
'back' => 'Back',
|
||||
'next' => 'Continue',
|
||||
|
|
|
|||
10
lang/es/notifications.php
Normal file
10
lang/es/notifications.php
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'post_ready' => [
|
||||
'title' => 'Tu publicación está lista',
|
||||
'body' => 'La IA terminó. Toca para revisar y publicar.',
|
||||
],
|
||||
];
|
||||
|
|
@ -238,8 +238,6 @@
|
|||
'title' => 'Ninguna plataforma seleccionada',
|
||||
'description' => 'Selecciona una plataforma para publicar y ver la vista previa.',
|
||||
],
|
||||
'drag_drop' => 'Suelta los archivos para subir',
|
||||
'drag_drop_hint' => 'Arrastra archivos aquí o usa los botones de arriba',
|
||||
'drop_zone_title' => 'Añadir media',
|
||||
'drop_zone_subtitle' => 'Arrastra archivos o haz clic para seleccionar',
|
||||
'add' => 'Añadir',
|
||||
|
|
@ -502,9 +500,21 @@
|
|||
'prompt_title' => 'Describe tu post',
|
||||
'prompt_label' => '¿De qué trata este post?',
|
||||
'prompt_placeholder' => 'Ej. Anuncia nuestra nueva función de carrusel para Instagram',
|
||||
'generating_title' => 'Generando',
|
||||
'generation_loading' => 'Generando tu publicación. Esto puede tardar hasta un minuto.',
|
||||
'preview_error' => 'Algo salió mal. Por favor, inténtalo de nuevo.',
|
||||
'loading_page_title' => 'Generando tu publicación',
|
||||
'loading_eta' => 'Tiempo estimado: cerca de :minutes.',
|
||||
'loading_eta_minute_one' => '1 minuto',
|
||||
'loading_eta_minute_other' => ':count minutos',
|
||||
'loading_leave_title' => 'Puedes seguir trabajando.',
|
||||
'loading_leave_body' => 'Te avisamos cuando la publicación esté lista.',
|
||||
'loading_leave_cta' => 'Ir al calendario',
|
||||
'loading_create_another_cta' => 'Crear otra publicación',
|
||||
'loading_tip_credits' => 'Cada imagen IA usa unos 15 créditos.',
|
||||
'loading_tip_edit' => 'Podrás editar todo cuando la publicación esté lista.',
|
||||
'loading_tip_draft' => 'Las publicaciones generadas van directo a tus borradores.',
|
||||
'loading_tip_brand' => 'Ajusta tu marca para influir en las próximas publicaciones.',
|
||||
'loading_tip_carousel' => 'Los carruseles generan una diapositiva por imagen solicitada.',
|
||||
'loading_tip_quality' => 'La calidad balancea velocidad y costo.',
|
||||
'create' => 'Crear post',
|
||||
'back' => 'Atrás',
|
||||
'next' => 'Continuar',
|
||||
|
|
|
|||
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
10
lang/pt-BR/notifications.php
Normal file
10
lang/pt-BR/notifications.php
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'post_ready' => [
|
||||
'title' => 'Seu post está pronto',
|
||||
'body' => 'A AI terminou. Toque pra revisar e publicar.',
|
||||
],
|
||||
];
|
||||
|
|
@ -238,8 +238,6 @@
|
|||
'title' => 'Nenhuma plataforma selecionada',
|
||||
'description' => 'Selecione uma plataforma para publicar e ver o preview.',
|
||||
],
|
||||
'drag_drop' => 'Solte os arquivos para enviar',
|
||||
'drag_drop_hint' => 'Arraste arquivos aqui ou use os botões acima',
|
||||
'drop_zone_title' => 'Adicionar mídia',
|
||||
'drop_zone_subtitle' => 'Arraste arquivos ou clique para selecionar',
|
||||
'add' => 'Adicionar',
|
||||
|
|
@ -501,9 +499,21 @@
|
|||
'prompt_title' => 'Descreva seu post',
|
||||
'prompt_label' => 'Sobre o que é este post?',
|
||||
'prompt_placeholder' => 'Ex. Anunciar nossa nova função de carrossel para o Instagram',
|
||||
'generating_title' => 'Gerando',
|
||||
'generation_loading' => 'Gerando seu post. Isso pode levar até um minuto.',
|
||||
'preview_error' => 'Algo deu errado. Por favor, tente novamente.',
|
||||
'loading_page_title' => 'Gerando seu post',
|
||||
'loading_eta' => 'Tempo estimado: cerca de :minutes.',
|
||||
'loading_eta_minute_one' => '1 minuto',
|
||||
'loading_eta_minute_other' => ':count minutos',
|
||||
'loading_leave_title' => 'Você pode continuar trabalhando.',
|
||||
'loading_leave_body' => 'A gente te avisa assim que o post ficar pronto.',
|
||||
'loading_leave_cta' => 'Ir pro calendário',
|
||||
'loading_create_another_cta' => 'Criar outro post',
|
||||
'loading_tip_credits' => 'Cada imagem AI consome cerca de 15 créditos.',
|
||||
'loading_tip_edit' => 'Você poderá editar tudo quando o post ficar pronto.',
|
||||
'loading_tip_draft' => 'Posts gerados vão direto pros seus rascunhos.',
|
||||
'loading_tip_brand' => 'Ajuste sua marca pra influenciar os próximos posts.',
|
||||
'loading_tip_carousel' => 'Carrosséis geram um slide por imagem solicitada.',
|
||||
'loading_tip_quality' => 'A qualidade equilibra velocidade e custo.',
|
||||
'create' => 'Criar post',
|
||||
'back' => 'Voltar',
|
||||
'next' => 'Continuar',
|
||||
|
|
|
|||
|
|
@ -4,16 +4,24 @@ import { computed, onUnmounted, watch } from 'vue';
|
|||
|
||||
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
|
||||
|
||||
interface MediaItem {
|
||||
url: string;
|
||||
type: 'image' | 'video';
|
||||
}
|
||||
|
||||
interface Props {
|
||||
/** Single-image mode (backward compatible). */
|
||||
src?: string | null;
|
||||
/** Multi-image mode — pass the full list and bind v-model:index to control which one is shown. */
|
||||
/** Multi-image mode (legacy) — list of image URLs. */
|
||||
images?: string[];
|
||||
/** Multi-media mode — list of items with type. Use this for mixed image/video. */
|
||||
items?: MediaItem[];
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
src: null,
|
||||
images: () => [],
|
||||
items: () => [],
|
||||
});
|
||||
|
||||
const index = defineModel<number | null>('index', { default: null });
|
||||
|
|
@ -22,27 +30,33 @@ const emit = defineEmits<{
|
|||
close: [];
|
||||
}>();
|
||||
|
||||
// Multi-image takes precedence; falls back to single src when no list provided.
|
||||
const allImages = computed<string[]>(() =>
|
||||
props.images.length > 0 ? props.images : (props.src ? [props.src] : []),
|
||||
);
|
||||
// Resolve to a unified MediaItem[] regardless of which prop variant was used.
|
||||
const allItems = computed<MediaItem[]>(() => {
|
||||
if (props.items.length > 0) return props.items;
|
||||
if (props.images.length > 0) {
|
||||
return props.images.map((url) => ({ url, type: 'image' as const }));
|
||||
}
|
||||
if (props.src) return [{ url: props.src, type: 'image' as const }];
|
||||
return [];
|
||||
});
|
||||
|
||||
// In multi-image mode the dialog is open when index is a number. In single src
|
||||
// mode (legacy) it's open whenever src is set.
|
||||
// In multi-item mode the dialog is open when index is a number. Single-src mode
|
||||
// (legacy) is open whenever src is set.
|
||||
const isOpen = computed({
|
||||
get: () => allImages.value.length > 0 && (props.images.length === 0 || index.value !== null),
|
||||
get: () => allItems.value.length > 0
|
||||
&& (props.images.length === 0 && props.items.length === 0 ? true : index.value !== null),
|
||||
set: (val) => {
|
||||
if (!val) emit('close');
|
||||
},
|
||||
});
|
||||
|
||||
const safeIndex = computed(() =>
|
||||
Math.max(0, Math.min(index.value ?? 0, allImages.value.length - 1)),
|
||||
Math.max(0, Math.min(index.value ?? 0, allItems.value.length - 1)),
|
||||
);
|
||||
const currentImage = computed(() => allImages.value[safeIndex.value] ?? null);
|
||||
const currentItem = computed(() => allItems.value[safeIndex.value] ?? null);
|
||||
const hasPrev = computed(() => safeIndex.value > 0);
|
||||
const hasNext = computed(() => safeIndex.value < allImages.value.length - 1);
|
||||
const showNav = computed(() => allImages.value.length > 1);
|
||||
const hasNext = computed(() => safeIndex.value < allItems.value.length - 1);
|
||||
const showNav = computed(() => allItems.value.length > 1);
|
||||
|
||||
const goPrev = () => {
|
||||
if (hasPrev.value) index.value = safeIndex.value - 1;
|
||||
|
|
@ -83,20 +97,31 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown));
|
|||
class="max-w-5xl gap-0 border-0 bg-transparent p-0 shadow-none outline-none focus:outline-none focus-visible:outline-none sm:max-w-5xl"
|
||||
:show-close-button="false"
|
||||
>
|
||||
<DialogTitle class="sr-only">Image preview</DialogTitle>
|
||||
<DialogTitle class="sr-only">Media preview</DialogTitle>
|
||||
<div class="relative flex justify-center">
|
||||
<img
|
||||
v-if="currentImage"
|
||||
:src="currentImage"
|
||||
v-if="currentItem && currentItem.type === 'image'"
|
||||
:src="currentItem.url"
|
||||
alt="Preview"
|
||||
class="max-h-[85vh] max-w-full cursor-pointer rounded-2xl object-contain"
|
||||
@click="emit('close')"
|
||||
/>
|
||||
|
||||
<video
|
||||
v-else-if="currentItem && currentItem.type === 'video'"
|
||||
:key="currentItem.url"
|
||||
:src="currentItem.url"
|
||||
class="max-h-[85vh] max-w-full rounded-2xl bg-black"
|
||||
controls
|
||||
autoplay
|
||||
preload="metadata"
|
||||
playsinline
|
||||
/>
|
||||
|
||||
<button
|
||||
v-if="showNav && hasPrev"
|
||||
type="button"
|
||||
aria-label="Previous image"
|
||||
aria-label="Previous"
|
||||
class="absolute left-2 top-1/2 -translate-y-1/2 rounded-full bg-black/50 p-2 text-white transition hover:bg-black/70"
|
||||
@click.stop="goPrev"
|
||||
>
|
||||
|
|
@ -106,7 +131,7 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown));
|
|||
<button
|
||||
v-if="showNav && hasNext"
|
||||
type="button"
|
||||
aria-label="Next image"
|
||||
aria-label="Next"
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 rounded-full bg-black/50 p-2 text-white transition hover:bg-black/70"
|
||||
@click.stop="goNext"
|
||||
>
|
||||
|
|
@ -117,7 +142,7 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown));
|
|||
v-if="showNav"
|
||||
class="absolute bottom-3 left-1/2 -translate-x-1/2 rounded-full bg-black/60 px-3 py-1 text-xs text-white tabular-nums"
|
||||
>
|
||||
{{ safeIndex + 1 }} / {{ allImages.length }}
|
||||
{{ safeIndex + 1 }} / {{ allItems.length }}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ withDefaults(defineProps<Props>(), {
|
|||
<template>
|
||||
<div class="relative mx-auto select-none origin-top">
|
||||
<!-- Phone Frame - iPhone 14 Pro style -->
|
||||
<div class="relative bg-[#1a1a1a] rounded-[44px] p-[10px] shadow-2xl ring-1 ring-white/10">
|
||||
<div class="relative bg-[#1a1a1a] rounded-[44px] p-[10px] ring-1 ring-white/10">
|
||||
<!-- Inner frame -->
|
||||
<div class="relative bg-[#0a0a0a] rounded-[34px] overflow-hidden">
|
||||
<!-- Screen Content - iPhone 14 Pro proportions (393x852 scaled down) -->
|
||||
|
|
|
|||
|
|
@ -1,14 +1,13 @@
|
|||
<script setup lang="ts">
|
||||
import { router, useHttp } from '@inertiajs/vue3';
|
||||
import { echo } from '@laravel/echo-vue';
|
||||
import {
|
||||
IconArrowLeft,
|
||||
IconCheck,
|
||||
IconLoader2,
|
||||
IconRefresh,
|
||||
} from '@tabler/icons-vue';
|
||||
import { trans } from 'laravel-vue-i18n';
|
||||
import { computed, onUnmounted, ref, watch } from 'vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { toast } from 'vue-sonner';
|
||||
|
||||
|
||||
import { start as startRoute } from '@/actions/App/Http/Controllers/App/PostAiCreateController';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
|
@ -16,7 +15,7 @@ import { Label } from '@/components/ui/label';
|
|||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { getPlatformLogo } from '@/composables/usePlatformLogo';
|
||||
import { ContentType, type ContentTypeValue } from '@/enums/content-type';
|
||||
import { edit as editPostRoute } from '@/routes/app/posts';
|
||||
import { loading as loadingRoute } from '@/routes/app/posts/ai';
|
||||
|
||||
interface SocialAccount {
|
||||
id: string;
|
||||
|
|
@ -36,17 +35,13 @@ const props = withDefaults(defineProps<Props>(), {
|
|||
date: null,
|
||||
});
|
||||
|
||||
type WizardStep = 'configure' | 'generating';
|
||||
|
||||
const emit = defineEmits<{
|
||||
/** Parent mirrors this in the PageHeader for context. */
|
||||
'update:stepHeader': [{ title: string; description: string }];
|
||||
/** Back button on the configure step asks parent to leave the AI flow. */
|
||||
/** Back button asks parent to leave the AI flow. */
|
||||
cancel: [];
|
||||
}>();
|
||||
|
||||
const step = ref<WizardStep>('configure');
|
||||
|
||||
// Selections
|
||||
const selectedFormat = ref<ContentTypeValue | null>(null);
|
||||
const selectedAccountId = ref<string | null>(null);
|
||||
|
|
@ -54,12 +49,7 @@ const includeImages = ref(true);
|
|||
const imageCount = ref(2);
|
||||
const promptText = ref('');
|
||||
|
||||
// Generation state
|
||||
const submitting = ref(false);
|
||||
const generationStatus = ref<'loading' | 'error'>('loading');
|
||||
const generationError = ref('');
|
||||
let echoChannel: any = null;
|
||||
let subscribedChannelName: string | null = null;
|
||||
|
||||
const httpStart = useHttp<{
|
||||
format: string | null;
|
||||
|
|
@ -172,69 +162,17 @@ const selectFormat = (format: ContentTypeValue) => {
|
|||
}
|
||||
};
|
||||
|
||||
// Step header text — the parent reflects this in the PageHeader.
|
||||
const stepHeaderFor = (s: WizardStep) => {
|
||||
switch (s) {
|
||||
case 'configure':
|
||||
return {
|
||||
title: trans('posts.create.ai_title'),
|
||||
description: trans('posts.create.ai_configure_description'),
|
||||
};
|
||||
case 'generating':
|
||||
return {
|
||||
title: trans('posts.create.steps.generating_title'),
|
||||
description: '',
|
||||
};
|
||||
}
|
||||
};
|
||||
emit('update:stepHeader', {
|
||||
title: trans('posts.create.ai_title'),
|
||||
description: trans('posts.create.ai_configure_description'),
|
||||
});
|
||||
|
||||
const goToStep = (s: WizardStep) => {
|
||||
step.value = s;
|
||||
emit('update:stepHeader', stepHeaderFor(s));
|
||||
};
|
||||
|
||||
emit('update:stepHeader', stepHeaderFor(step.value));
|
||||
|
||||
const goBack = () => {
|
||||
if (step.value === 'configure') {
|
||||
emit('cancel');
|
||||
} else if (step.value === 'generating') {
|
||||
unsubscribeEcho();
|
||||
goToStep('configure');
|
||||
}
|
||||
};
|
||||
|
||||
const unsubscribeEcho = () => {
|
||||
if (echoChannel && subscribedChannelName) {
|
||||
echo().leave(`private-${subscribedChannelName}`);
|
||||
echoChannel = null;
|
||||
subscribedChannelName = null;
|
||||
}
|
||||
};
|
||||
|
||||
const subscribeToCreation = (userId: string, creationId: string) => {
|
||||
unsubscribeEcho();
|
||||
const channelName = `users.${userId}.ai-creation.${creationId}`;
|
||||
subscribedChannelName = channelName;
|
||||
|
||||
echoChannel = echo().private(channelName).listen('.ai.creation.completed', (e: any) => {
|
||||
unsubscribeEcho();
|
||||
if (e.error || !e.post_id) {
|
||||
generationStatus.value = 'error';
|
||||
generationError.value = e.error ?? trans('posts.create.steps.preview_error');
|
||||
return;
|
||||
}
|
||||
router.visit(editPostRoute(e.post_id).url);
|
||||
});
|
||||
};
|
||||
const goBack = () => emit('cancel');
|
||||
|
||||
const startGeneration = async () => {
|
||||
if (!canSubmit.value || submitting.value) return;
|
||||
|
||||
submitting.value = true;
|
||||
generationStatus.value = 'loading';
|
||||
generationError.value = '';
|
||||
goToStep('generating');
|
||||
|
||||
httpStart.format = selectedFormat.value;
|
||||
httpStart.social_account_id = selectedAccountId.value;
|
||||
|
|
@ -244,26 +182,29 @@ const startGeneration = async () => {
|
|||
|
||||
try {
|
||||
const data = await httpStart.post(startRoute.url()) as { creation_id: string; channel: string };
|
||||
const userId = data.channel.split('.')[1] ?? '';
|
||||
subscribeToCreation(userId, data.creation_id);
|
||||
|
||||
router.visit(loadingRoute(
|
||||
{ creationId: data.creation_id },
|
||||
{
|
||||
query: {
|
||||
images: String(submittedImageCount.value),
|
||||
format: selectedFormat.value ?? '',
|
||||
prompt: promptText.value.trim(),
|
||||
},
|
||||
},
|
||||
).url);
|
||||
} catch (err: any) {
|
||||
generationStatus.value = 'error';
|
||||
generationError.value = err?.response?.data?.message ?? trans('posts.create.steps.preview_error');
|
||||
} finally {
|
||||
toast.error(err?.response?.data?.message ?? trans('posts.create.steps.preview_error'));
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const retryGeneration = () => startGeneration();
|
||||
|
||||
onUnmounted(() => unsubscribeEcho());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- Back button — sticker arrow + ink label, mirrors the marketing site -->
|
||||
<button
|
||||
v-if="step !== 'generating' || generationStatus === 'error'"
|
||||
type="button"
|
||||
class="group inline-flex cursor-pointer items-center gap-1.5 text-sm font-semibold text-foreground/70 transition-colors hover:text-foreground"
|
||||
@click="goBack"
|
||||
|
|
@ -273,9 +214,6 @@ onUnmounted(() => unsubscribeEcho());
|
|||
</span>
|
||||
{{ $t('posts.create.steps.back') }}
|
||||
</button>
|
||||
|
||||
<!-- ====== Step 1: Configure (everything in one screen) ====== -->
|
||||
<template v-if="step === 'configure'">
|
||||
<!-- Format -->
|
||||
<div class="space-y-2">
|
||||
<Label class="text-sm font-bold">{{ $t('posts.create.steps.format_title') }}</Label>
|
||||
|
|
@ -386,33 +324,9 @@ onUnmounted(() => unsubscribeEcho());
|
|||
|
||||
<!-- Generate -->
|
||||
<div v-if="selectedFormat" class="flex justify-end pt-1">
|
||||
<Button :disabled="!canSubmit" @click="startGeneration">
|
||||
<Button :disabled="!canSubmit || submitting" @click="startGeneration">
|
||||
{{ $t('posts.ai.generate.start') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ====== Step 2: Generating ====== -->
|
||||
<template v-else-if="step === 'generating'">
|
||||
<div v-if="generationStatus === 'loading'" class="flex flex-col items-center gap-4 rounded-2xl border-2 border-foreground bg-card py-16 text-center shadow-2xs">
|
||||
<div class="inline-flex size-12 -rotate-2 items-center justify-center rounded-2xl border-2 border-foreground bg-violet-200 shadow-2xs">
|
||||
<IconLoader2 class="size-6 animate-spin text-foreground" stroke-width="2" />
|
||||
</div>
|
||||
<p class="text-sm font-semibold text-foreground/70">{{ $t('posts.create.steps.generation_loading') }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<div class="rounded-xl border-2 border-foreground bg-rose-50 p-4 shadow-2xs">
|
||||
<p class="text-sm font-semibold text-rose-700">{{ generationError || $t('posts.create.steps.preview_error') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<Button variant="outline" @click="retryGeneration">
|
||||
<IconRefresh class="size-4" />
|
||||
{{ $t('posts.create.steps.retry') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,9 @@
|
|||
<script setup lang="ts">
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconCloudUpload,
|
||||
IconGripVertical,
|
||||
IconHash,
|
||||
IconLibraryPhoto,
|
||||
IconLoader2,
|
||||
IconMoodSmile,
|
||||
IconSparkles,
|
||||
IconTrash,
|
||||
|
|
@ -22,9 +20,8 @@ import SignaturesModal from '@/components/posts/SignaturesModal.vue';
|
|||
import { Button } from '@/components/ui/button';
|
||||
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { formatBytes, readFileMetadata } from '@/composables/useMedia';
|
||||
import { formatBytes } from '@/composables/useMedia';
|
||||
import { getPlatformLabel, getPlatformLogo } from '@/composables/usePlatformLogo';
|
||||
import { store as storeAsset } from '@/routes/app/assets';
|
||||
|
||||
interface MediaItem {
|
||||
id: string;
|
||||
|
|
@ -67,8 +64,6 @@ const emit = defineEmits<{
|
|||
(e: 'open-ai-review'): void;
|
||||
}>();
|
||||
|
||||
const isDragging = ref(false);
|
||||
const uploading = ref(false);
|
||||
const emojiOpen = ref(false);
|
||||
const mediaPickerDialog = ref<InstanceType<typeof MediaPickerDialog> | null>(null);
|
||||
const signaturesModal = ref<InstanceType<typeof SignaturesModal> | null>(null);
|
||||
|
|
@ -78,20 +73,18 @@ const dragOverIndex = ref<number | null>(null);
|
|||
const mediaThumbRefs = ref<HTMLElement[]>([]);
|
||||
const previewIndex = ref<number | null>(null);
|
||||
|
||||
// Image-only URLs (videos are skipped) in the same order as `media`. The
|
||||
// preview index is computed against THIS list to keep arrow navigation tight.
|
||||
const previewImages = computed(() =>
|
||||
media.value.filter((m) => !isVideo(m)).map((m) => m.url),
|
||||
const previewItems = computed<{ url: string; type: 'image' | 'video' }[]>(() =>
|
||||
media.value.map((m) => ({
|
||||
url: m.url,
|
||||
type: isVideo(m) ? 'video' : 'image',
|
||||
})),
|
||||
);
|
||||
|
||||
const openPreview = (item: MediaItem) => {
|
||||
if (isVideo(item)) return;
|
||||
const idx = previewImages.value.indexOf(item.url);
|
||||
const idx = media.value.findIndex((m) => m.id === item.id);
|
||||
previewIndex.value = idx >= 0 ? idx : 0;
|
||||
};
|
||||
|
||||
const csrfToken = document.querySelector<HTMLMetaElement>('meta[name="csrf-token"]')?.content ?? '';
|
||||
|
||||
const isVideo = (item: MediaItem): boolean =>
|
||||
item.type === 'video' || Boolean(item.mime_type?.startsWith('video/'));
|
||||
|
||||
|
|
@ -133,60 +126,6 @@ const overflowParts = computed(() => {
|
|||
};
|
||||
});
|
||||
|
||||
const handleDrop = (event: DragEvent) => {
|
||||
isDragging.value = false;
|
||||
if (event.dataTransfer?.files && event.dataTransfer.files.length > 0) {
|
||||
uploadFiles(Array.from(event.dataTransfer.files));
|
||||
}
|
||||
};
|
||||
|
||||
const uploadFiles = async (files: File[]) => {
|
||||
uploading.value = true;
|
||||
|
||||
for (const file of files) {
|
||||
const clientMeta = await readFileMetadata(file);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('media', file);
|
||||
if (clientMeta.width) formData.append('meta[width]', String(clientMeta.width));
|
||||
if (clientMeta.height) formData.append('meta[height]', String(clientMeta.height));
|
||||
if (clientMeta.duration) formData.append('meta[duration]', String(clientMeta.duration));
|
||||
|
||||
try {
|
||||
const response = await fetch(storeAsset.url(), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'X-CSRF-TOKEN': csrfToken,
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) continue;
|
||||
|
||||
const data = await response.json();
|
||||
media.value = [
|
||||
...media.value,
|
||||
{
|
||||
id: data.id,
|
||||
path: data.path,
|
||||
url: data.url,
|
||||
type: data.type,
|
||||
mime_type: data.mime_type,
|
||||
original_filename: data.original_filename,
|
||||
size: data.size,
|
||||
meta: data.meta,
|
||||
},
|
||||
];
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
uploading.value = false;
|
||||
};
|
||||
|
||||
const removeMedia = (mediaId: string) => {
|
||||
media.value = media.value.filter((m) => m.id !== mediaId);
|
||||
};
|
||||
|
|
@ -276,12 +215,7 @@ const issueLabel = (reason: string): string => trans(`posts.form.warnings.${reas
|
|||
|
||||
<template>
|
||||
<div class="mx-auto max-w-2xl px-6 py-10">
|
||||
<div
|
||||
class="relative"
|
||||
@dragover.prevent="isDragging = true"
|
||||
@dragleave.prevent="isDragging = false"
|
||||
@drop.prevent="handleDrop"
|
||||
>
|
||||
<div class="relative">
|
||||
<!-- Media grid (top) — always shown so "Add" tile is discoverable -->
|
||||
<div class="mb-6">
|
||||
<div class="grid grid-cols-4 gap-2">
|
||||
|
|
@ -452,9 +386,6 @@ const issueLabel = (reason: string): string => trans(`posts.form.warnings.${reas
|
|||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<span v-if="uploading" class="flex items-center gap-1.5 text-xs font-medium text-foreground/60">
|
||||
<IconLoader2 class="size-3.5 animate-spin" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Per-platform counters (below menu, above textarea) -->
|
||||
|
|
@ -499,27 +430,12 @@ const issueLabel = (reason: string): string => trans(`posts.form.warnings.${reas
|
|||
/>
|
||||
</div>
|
||||
|
||||
<!-- Drag-drop overlay (full-bleed over the editor area) -->
|
||||
<div
|
||||
v-if="isDragging"
|
||||
class="pointer-events-none absolute -inset-6 z-10 flex flex-col items-center justify-center gap-3 rounded-2xl border-2 border-dashed border-foreground bg-violet-100/80 backdrop-blur-sm"
|
||||
>
|
||||
<div class="inline-flex size-12 -rotate-3 items-center justify-center rounded-2xl border-2 border-foreground bg-violet-200 shadow-2xs">
|
||||
<IconCloudUpload class="size-6 text-foreground" stroke-width="2" />
|
||||
</div>
|
||||
<p
|
||||
class="text-xl font-semibold text-foreground"
|
||||
style="font-family: var(--font-display)"
|
||||
>
|
||||
{{ $t('posts.edit.drag_drop') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SignaturesModal ref="signaturesModal" :signatures="signatures" @select="appendSignature" />
|
||||
<MediaPickerDialog ref="mediaPickerDialog" @select="addMediaFromGallery" />
|
||||
<ImagePreviewDialog
|
||||
:images="previewImages"
|
||||
:items="previewItems"
|
||||
:index="previewIndex"
|
||||
@update:index="previewIndex = $event"
|
||||
@close="previewIndex = null"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import VideoPreview from "@/components/posts/previews/VideoPreview.vue";
|
||||
import { isVideoMedia, type MediaItem } from '@/composables/useMedia';
|
||||
|
||||
interface SocialAccount {
|
||||
|
|
@ -88,8 +89,7 @@ defineProps<Props>();
|
|||
}">
|
||||
<img v-if="!isVideoMedia(item)" :src="item.url" :alt="item.original_filename"
|
||||
class="w-full h-full object-cover" />
|
||||
<video v-else :src="item.url" class="w-full h-full object-cover bg-black" muted loop
|
||||
playsinline />
|
||||
<VideoPreview v-else :src="item.url" video-class="w-full h-full object-cover bg-black" />
|
||||
<div v-if="media.length > 4 && index === 3"
|
||||
class="absolute inset-0 bg-black/60 flex items-center justify-center">
|
||||
<span class="text-white text-xl font-semibold">+{{ media.length - 4 }}</span>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import VideoPreview from "@/components/posts/previews/VideoPreview.vue";
|
||||
import { isVideoMedia, type MediaItem } from '@/composables/useMedia';
|
||||
|
||||
interface SocialAccount {
|
||||
|
|
@ -77,8 +78,7 @@ defineProps<Props>();
|
|||
}">
|
||||
<img v-if="!isVideoMedia(item)" :src="item.url" :alt="item.original_filename"
|
||||
class="w-full h-full object-cover" />
|
||||
<video v-else :src="item.url" class="w-full h-full object-cover bg-black" muted loop
|
||||
playsinline />
|
||||
<VideoPreview v-else :src="item.url" video-class="w-full h-full object-cover bg-black" />
|
||||
<div v-if="media.length > 4 && index === 3"
|
||||
class="absolute inset-0 bg-black/70 flex items-center justify-center">
|
||||
<span class="text-white text-3xl font-semibold">+{{ media.length - 4 }}</span>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import VideoPreview from "@/components/posts/previews/VideoPreview.vue";
|
||||
import { isVideoMedia, type MediaItem } from '@/composables/useMedia';
|
||||
|
||||
interface SocialAccount {
|
||||
|
|
@ -60,8 +61,7 @@ defineProps<Props>();
|
|||
}">
|
||||
<img v-if="!isVideoMedia(item)" :src="item.url" :alt="item.original_filename"
|
||||
class="w-full h-full object-cover" />
|
||||
<video v-else :src="item.url" class="w-full h-full object-cover bg-black" muted loop
|
||||
playsinline />
|
||||
<VideoPreview v-else :src="item.url" video-class="w-full h-full object-cover bg-black" />
|
||||
<!-- Hide button -->
|
||||
<button
|
||||
class="absolute top-2 right-2 bg-black/60 text-white text-[12px] px-2 py-0.5 rounded">
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import VideoPreview from "@/components/posts/previews/VideoPreview.vue";
|
||||
import { IconPhoto, IconStack2 } from '@tabler/icons-vue';
|
||||
import { computed } from 'vue';
|
||||
|
||||
|
|
@ -51,13 +52,10 @@ const isCarousel = computed(() => props.contentType === 'pinterest_carousel');
|
|||
:alt="media[0].original_filename"
|
||||
class="w-full aspect-[2/3] object-cover"
|
||||
/>
|
||||
<video
|
||||
<VideoPreview
|
||||
v-else
|
||||
:src="media[0].url"
|
||||
class="w-full aspect-[2/3] object-cover bg-black"
|
||||
muted
|
||||
loop
|
||||
playsinline
|
||||
video-class="w-full aspect-[2/3] object-cover bg-black"
|
||||
/>
|
||||
<!-- Video indicator -->
|
||||
<div v-if="isVideoMedia(media[0])" class="absolute bottom-2 left-2 bg-black/60 text-white text-[10px] px-2 py-0.5 rounded-full flex items-center gap-1">
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
import { IconChevronLeft, IconChevronRight, IconPhoto } from '@tabler/icons-vue';
|
||||
import { computed, ref, watch, type Component } from 'vue';
|
||||
|
||||
import VideoPreview from '@/components/posts/previews/VideoPreview.vue';
|
||||
import { isVideoMedia, type MediaItem } from '@/composables/useMedia';
|
||||
|
||||
interface Props {
|
||||
|
|
@ -54,13 +55,10 @@ const goToSlide = (index: number) => {
|
|||
<template>
|
||||
<template v-if="media.length > 0">
|
||||
<template v-for="(item, index) in media" :key="item.id">
|
||||
<video
|
||||
<VideoPreview
|
||||
v-if="isVideoMedia(item) && index === currentIndex"
|
||||
:src="item.url"
|
||||
:class="mediaClass"
|
||||
muted
|
||||
loop
|
||||
playsinline
|
||||
:video-class="mediaClass"
|
||||
/>
|
||||
<img
|
||||
v-else-if="index === currentIndex"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import VideoPreview from "@/components/posts/previews/VideoPreview.vue";
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { isVideoMedia, type MediaItem } from '@/composables/useMedia';
|
||||
|
|
@ -97,8 +98,7 @@ const username = computed(() => props.socialAccount.username || props.socialAcco
|
|||
}">
|
||||
<img v-if="!isVideoMedia(item)" :src="item.url" :alt="item.original_filename"
|
||||
class="w-full h-full object-cover" />
|
||||
<video v-else :src="item.url" class="w-full h-full object-cover bg-black" muted loop
|
||||
playsinline />
|
||||
<VideoPreview v-else :src="item.url" video-class="w-full h-full object-cover bg-black" />
|
||||
<div v-if="media.length > 4 && index === 3"
|
||||
class="absolute inset-0 bg-black/60 flex items-center justify-center">
|
||||
<span class="text-white text-xl font-semibold">+{{ media.length - 4 }}</span>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import VideoPreview from "@/components/posts/previews/VideoPreview.vue";
|
||||
import { IconPlus } from '@tabler/icons-vue';
|
||||
import { computed } from 'vue';
|
||||
|
||||
|
|
@ -40,7 +41,7 @@ const username = computed(() => props.socialAccount.username || props.socialAcco
|
|||
<div class="absolute inset-0">
|
||||
<!-- Video content -->
|
||||
<div v-if="media.length > 0 && isVideoMedia(media[0])" class="w-full h-full">
|
||||
<video :src="media[0].url" class="w-full h-full object-cover" muted loop playsinline />
|
||||
<VideoPreview :src="media[0].url" />
|
||||
</div>
|
||||
<div v-else-if="media.length > 0" class="w-full h-full">
|
||||
<img :src="media[0].url" :alt="media[0].original_filename" class="w-full h-full object-cover" />
|
||||
|
|
|
|||
54
resources/js/components/posts/previews/VideoPreview.vue
Normal file
54
resources/js/components/posts/previews/VideoPreview.vue
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
<script setup lang="ts">
|
||||
import { IconPlayerPlayFilled } from '@tabler/icons-vue';
|
||||
import { ref } from 'vue';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
src: string;
|
||||
videoClass?: string;
|
||||
}>(),
|
||||
{
|
||||
videoClass: 'w-full h-full object-cover',
|
||||
},
|
||||
);
|
||||
|
||||
const videoRef = ref<HTMLVideoElement | null>(null);
|
||||
const isPlaying = ref(false);
|
||||
|
||||
const toggle = () => {
|
||||
const el = videoRef.value;
|
||||
if (!el) return;
|
||||
if (el.paused) {
|
||||
void el.play();
|
||||
} else {
|
||||
el.pause();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative h-full w-full" @click="toggle">
|
||||
<video
|
||||
ref="videoRef"
|
||||
:src="props.src"
|
||||
:class="props.videoClass"
|
||||
playsinline
|
||||
preload="metadata"
|
||||
@play="isPlaying = true"
|
||||
@pause="isPlaying = false"
|
||||
@ended="isPlaying = false"
|
||||
/>
|
||||
<button
|
||||
v-show="!isPlaying"
|
||||
type="button"
|
||||
class="absolute inset-0 flex cursor-pointer items-center justify-center bg-black/10 transition-colors hover:bg-black/20"
|
||||
aria-label="Play"
|
||||
>
|
||||
<span
|
||||
class="flex size-14 items-center justify-center rounded-full bg-black/55 ring-1 ring-white/30 backdrop-blur-sm transition-transform hover:scale-110"
|
||||
>
|
||||
<IconPlayerPlayFilled class="size-7 text-white drop-shadow" />
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import VideoPreview from "@/components/posts/previews/VideoPreview.vue";
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { isVideoMedia, type MediaItem } from '@/composables/useMedia';
|
||||
|
|
@ -95,8 +96,7 @@ const username = computed(() => props.socialAccount.username || 'username');
|
|||
}">
|
||||
<img v-if="!isVideoMedia(item)" :src="item.url" :alt="item.original_filename"
|
||||
class="w-full h-full object-cover" />
|
||||
<video v-else :src="item.url" class="w-full h-full object-cover bg-black" muted loop
|
||||
playsinline />
|
||||
<VideoPreview v-else :src="item.url" video-class="w-full h-full object-cover bg-black" />
|
||||
<!-- Video duration badge -->
|
||||
<div v-if="isVideoMedia(item)"
|
||||
class="absolute bottom-2 left-2 bg-black/70 text-white text-[13px] px-1.5 py-0.5 rounded">
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import VideoPreview from "@/components/posts/previews/VideoPreview.vue";
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { isVideoMedia, type MediaItem } from '@/composables/useMedia';
|
||||
|
|
@ -39,7 +40,7 @@ const username = computed(() => props.socialAccount.username || props.socialAcco
|
|||
<div class="absolute inset-0">
|
||||
<!-- Video content -->
|
||||
<div v-if="media.length > 0 && isVideoMedia(media[0])" class="w-full h-full">
|
||||
<video :src="media[0].url" class="w-full h-full object-cover" muted loop playsinline />
|
||||
<VideoPreview :src="media[0].url" />
|
||||
</div>
|
||||
<div v-else-if="media.length > 0" class="w-full h-full">
|
||||
<img :src="media[0].url" :alt="media[0].original_filename" class="w-full h-full object-cover" />
|
||||
|
|
|
|||
|
|
@ -156,51 +156,6 @@ export const getMediaItemIssue = (item: MediaItem, contentType: string): string
|
|||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Read metadata from a File in the browser before uploading.
|
||||
* Returns width/height for images, width/height/duration for videos.
|
||||
*/
|
||||
export const readFileMetadata = async (file: File): Promise<{ width?: number; height?: number; duration?: number }> => {
|
||||
if (file.type.startsWith('image/')) {
|
||||
return new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
const url = URL.createObjectURL(file);
|
||||
img.onload = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
resolve({ width: img.naturalWidth, height: img.naturalHeight });
|
||||
};
|
||||
img.onerror = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
resolve({});
|
||||
};
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
if (file.type.startsWith('video/')) {
|
||||
return new Promise((resolve) => {
|
||||
const video = document.createElement('video');
|
||||
const url = URL.createObjectURL(file);
|
||||
video.preload = 'metadata';
|
||||
video.onloadedmetadata = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
resolve({
|
||||
width: video.videoWidth,
|
||||
height: video.videoHeight,
|
||||
duration: video.duration,
|
||||
});
|
||||
};
|
||||
video.onerror = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
resolve({});
|
||||
};
|
||||
video.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
return {};
|
||||
};
|
||||
|
||||
export const isVideoMedia = (item: MediaItem | null | undefined): boolean => {
|
||||
if (! item) return false;
|
||||
return item.type === 'video' || Boolean(item.mime_type?.startsWith('video/'));
|
||||
|
|
|
|||
168
resources/js/pages/posts/ai/Loading.vue
Normal file
168
resources/js/pages/posts/ai/Loading.vue
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
<script setup lang="ts">
|
||||
import { Head, router } from '@inertiajs/vue3';
|
||||
import { echo } from '@laravel/echo-vue';
|
||||
import { IconLoader2, IconSparkles } from '@tabler/icons-vue';
|
||||
import { trans } from 'laravel-vue-i18n';
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
import { calendar as calendarRoute } from '@/routes/app';
|
||||
import { create as createPostRoute, edit as editPostRoute } from '@/routes/app/posts';
|
||||
|
||||
const props = defineProps<{
|
||||
creationId: string;
|
||||
channel: string;
|
||||
imageCount: number;
|
||||
format: string;
|
||||
prompt: string;
|
||||
}>();
|
||||
|
||||
const status = ref<'loading' | 'error'>('loading');
|
||||
const errorMessage = ref('');
|
||||
|
||||
let echoChannel: any = null;
|
||||
|
||||
const TEXT_BASELINE_SECONDS = 30;
|
||||
const PER_IMAGE_SECONDS = 35;
|
||||
|
||||
const estimatedSeconds = computed(() => TEXT_BASELINE_SECONDS + props.imageCount * PER_IMAGE_SECONDS);
|
||||
|
||||
const minutesLabel = computed(() => {
|
||||
const minutes = Math.max(1, Math.ceil(estimatedSeconds.value / 60));
|
||||
const key = minutes === 1 ? 'posts.create.steps.loading_eta_minute_one' : 'posts.create.steps.loading_eta_minute_other';
|
||||
return trans(key, { count: String(minutes) });
|
||||
});
|
||||
|
||||
const etaLabel = computed(() => trans('posts.create.steps.loading_eta', { minutes: minutesLabel.value }));
|
||||
|
||||
const tipKeys = [
|
||||
'posts.create.steps.loading_tip_credits',
|
||||
'posts.create.steps.loading_tip_edit',
|
||||
'posts.create.steps.loading_tip_draft',
|
||||
'posts.create.steps.loading_tip_brand',
|
||||
'posts.create.steps.loading_tip_carousel',
|
||||
'posts.create.steps.loading_tip_quality',
|
||||
] as const;
|
||||
|
||||
const tipIndex = ref(0);
|
||||
let tipTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const currentTip = computed(() => trans(tipKeys[tipIndex.value % tipKeys.length]));
|
||||
|
||||
const elapsed = ref(0);
|
||||
let elapsedTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const elapsedLabel = computed(() => {
|
||||
const minutes = Math.floor(elapsed.value / 60);
|
||||
const seconds = elapsed.value % 60;
|
||||
return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
|
||||
});
|
||||
|
||||
const progress = computed(() => {
|
||||
const ratio = elapsed.value / estimatedSeconds.value;
|
||||
return Math.min(0.95, ratio);
|
||||
});
|
||||
|
||||
const subscribe = () => {
|
||||
echoChannel = echo()
|
||||
.private(props.channel)
|
||||
.listen('.ai.creation.completed', (e: { post_id?: string; error?: string }) => {
|
||||
if (e.error || !e.post_id) {
|
||||
status.value = 'error';
|
||||
errorMessage.value = e.error ?? trans('posts.create.steps.preview_error');
|
||||
return;
|
||||
}
|
||||
router.visit(editPostRoute(e.post_id).url);
|
||||
});
|
||||
};
|
||||
|
||||
const unsubscribe = () => {
|
||||
if (echoChannel) {
|
||||
echo().leave(`private-${props.channel}`);
|
||||
echoChannel = null;
|
||||
}
|
||||
};
|
||||
|
||||
const leave = () => {
|
||||
router.visit(calendarRoute().url);
|
||||
};
|
||||
|
||||
const createAnother = () => {
|
||||
router.visit(createPostRoute().url);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
subscribe();
|
||||
tipTimer = setInterval(() => {
|
||||
tipIndex.value = (tipIndex.value + 1) % tipKeys.length;
|
||||
}, 5000);
|
||||
elapsedTimer = setInterval(() => {
|
||||
elapsed.value += 1;
|
||||
}, 1000);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
unsubscribe();
|
||||
if (tipTimer) clearInterval(tipTimer);
|
||||
if (elapsedTimer) clearInterval(elapsedTimer);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Head :title="$t('posts.create.steps.loading_page_title')" />
|
||||
|
||||
<AppLayout>
|
||||
<div class="mx-auto flex w-full max-w-2xl flex-col items-center gap-6 px-4 py-12">
|
||||
<div class="inline-flex size-14 -rotate-2 items-center justify-center rounded-2xl border-2 border-foreground bg-violet-200 shadow-2xs">
|
||||
<IconLoader2 v-if="status === 'loading'" class="size-7 animate-spin text-foreground" stroke-width="2" />
|
||||
<IconSparkles v-else class="size-7 text-foreground" stroke-width="2" />
|
||||
</div>
|
||||
|
||||
<h1 class="text-center text-2xl font-bold text-foreground">
|
||||
{{ $t('posts.create.steps.loading_page_title') }}
|
||||
</h1>
|
||||
|
||||
<div v-if="status === 'loading'" class="flex w-full flex-col items-center gap-4">
|
||||
<p class="text-center text-sm text-foreground/70">{{ etaLabel }}</p>
|
||||
|
||||
<div class="w-full max-w-md">
|
||||
<div class="h-2 w-full overflow-hidden rounded-full border-2 border-foreground bg-card">
|
||||
<div
|
||||
class="h-full bg-foreground transition-[width] duration-700 ease-out"
|
||||
:style="{ width: `${Math.round(progress * 100)}%` }"
|
||||
></div>
|
||||
</div>
|
||||
<div class="mt-1.5 flex justify-between text-[11px] font-mono text-foreground/50">
|
||||
<span>{{ elapsedLabel }}</span>
|
||||
<span>{{ minutesLabel }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex min-h-[3rem] w-full max-w-lg items-center justify-center rounded-xl border-2 border-foreground bg-card px-5 py-3 shadow-2xs">
|
||||
<p class="text-center text-sm text-foreground/80 transition-opacity">
|
||||
💡 {{ currentTip }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 flex w-full max-w-lg flex-col items-center gap-3 rounded-2xl border-2 border-foreground bg-card p-5 text-center shadow-2xs">
|
||||
<p class="text-base font-bold text-foreground">{{ $t('posts.create.steps.loading_leave_title') }}</p>
|
||||
<p class="text-sm text-foreground/70">{{ $t('posts.create.steps.loading_leave_body') }}</p>
|
||||
<div class="flex flex-wrap items-center justify-center gap-2 pt-1">
|
||||
<Button @click="createAnother">{{ $t('posts.create.steps.loading_create_another_cta') }}</Button>
|
||||
<Button variant="outline" @click="leave">{{ $t('posts.create.steps.loading_leave_cta') }}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex w-full max-w-lg flex-col items-center gap-4">
|
||||
<div class="w-full rounded-xl border-2 border-foreground bg-rose-50 p-4 shadow-2xs">
|
||||
<p class="text-center text-sm font-semibold text-rose-700">
|
||||
{{ errorMessage || $t('posts.create.steps.preview_error') }}
|
||||
</p>
|
||||
</div>
|
||||
<Button @click="leave">{{ $t('posts.create.steps.loading_leave_cta') }}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
|
@ -164,6 +164,7 @@
|
|||
Route::post('posts/{post}/ai/generate', [PostAiGenerateController::class, 'generate'])->name('app.posts.ai.generate');
|
||||
Route::post('posts/{post}/ai/review', [PostAiReviewController::class, 'review'])->name('app.posts.ai.review');
|
||||
Route::post('posts/ai/create', [PostAiCreateController::class, 'start'])->name('app.posts.ai.create');
|
||||
Route::get('posts/ai/{creationId}/loading', [PostAiCreateController::class, 'loading'])->name('app.posts.ai.loading')->whereUuid('creationId');
|
||||
|
||||
// Post Comments
|
||||
Route::get('posts/{post}/comments', [PostCommentController::class, 'index'])->name('app.posts.comments.index');
|
||||
|
|
|
|||
|
|
@ -15,6 +15,6 @@
|
|||
|
||||
Broadcast::channel('workspace.{workspace}.user.{owner}', WorkspaceUserChannel::class);
|
||||
|
||||
Broadcast::channel('users.{userId}.ai-gen.{generationId}', UserAiGenerationChannel::class);
|
||||
Broadcast::channel('user.{owner}.ai-gen.{generationId}', UserAiGenerationChannel::class);
|
||||
|
||||
Broadcast::channel('users.{userId}.ai-creation.{creationId}', UserAiCreationChannel::class);
|
||||
Broadcast::channel('user.{owner}.ai-creation.{creationId}', UserAiCreationChannel::class);
|
||||
|
|
|
|||
|
|
@ -87,7 +87,7 @@
|
|||
|
||||
$creationId = $response->json('creation_id');
|
||||
expect($creationId)->toBeString()->not->toBeEmpty();
|
||||
expect($response->json('channel'))->toBe("users.{$this->user->id}.ai-creation.{$creationId}");
|
||||
expect($response->json('channel'))->toBe("user.{$this->user->id}.ai-creation.{$creationId}");
|
||||
|
||||
Bus::assertDispatched(StreamPostCreation::class, function ($job) use ($creationId, $account) {
|
||||
return $job->userId === $this->user->id
|
||||
|
|
@ -142,3 +142,29 @@
|
|||
|
||||
Bus::assertNotDispatched(StreamPostCreation::class);
|
||||
});
|
||||
|
||||
test('loading page requires authentication', function () {
|
||||
$this->get(route('app.posts.ai.loading', '019e0532-7b74-7369-b238-a5f2a93d12b7'))
|
||||
->assertStatus(Response::HTTP_FOUND);
|
||||
});
|
||||
|
||||
test('loading page renders the Inertia component with channel and query context', function () {
|
||||
$creationId = '019e0532-7b74-7369-b238-a5f2a93d12b7';
|
||||
|
||||
$this->actingAs($this->user)
|
||||
->get(route('app.posts.ai.loading', $creationId).'?images=5&format=instagram_carousel&prompt=Hello')
|
||||
->assertInertia(fn ($page) => $page
|
||||
->component('posts/ai/Loading')
|
||||
->where('creationId', $creationId)
|
||||
->where('channel', "user.{$this->user->id}.ai-creation.{$creationId}")
|
||||
->where('imageCount', 5)
|
||||
->where('format', 'instagram_carousel')
|
||||
->where('prompt', 'Hello')
|
||||
);
|
||||
});
|
||||
|
||||
test('loading page rejects non-uuid creation ids', function () {
|
||||
$this->actingAs($this->user)
|
||||
->get('/posts/ai/not-a-uuid/loading')
|
||||
->assertStatus(Response::HTTP_NOT_FOUND);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@
|
|||
|
||||
$generationId = $response->json('generation_id');
|
||||
expect($generationId)->toBeString()->not->toBeEmpty();
|
||||
expect($response->json('channel'))->toBe("users.{$this->user->id}.ai-gen.{$generationId}");
|
||||
expect($response->json('channel'))->toBe("user.{$this->user->id}.ai-gen.{$generationId}");
|
||||
|
||||
Bus::assertDispatched(StreamPostContent::class, function ($job) use ($generationId) {
|
||||
return $job->workspaceId === $this->workspace->id
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
$user = User::factory()->create();
|
||||
$channel = new UserAiGenerationChannel;
|
||||
|
||||
expect($channel->join($user, $user->id, 'some-uuid'))->toBeTrue();
|
||||
expect($channel->join($user, $user, 'some-uuid'))->toBeTrue();
|
||||
});
|
||||
|
||||
test('user cannot join another users generation channel', function () {
|
||||
|
|
@ -17,5 +17,5 @@
|
|||
$other = User::factory()->create();
|
||||
$channel = new UserAiGenerationChannel;
|
||||
|
||||
expect($channel->join($user, $other->id, 'some-uuid'))->toBeFalse();
|
||||
expect($channel->join($user, $other, 'some-uuid'))->toBeFalse();
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue