diff --git a/lang/en/posts.php b/lang/en/posts.php index e4f52fed..3dbfe638 100644 --- a/lang/en/posts.php +++ b/lang/en/posts.php @@ -615,6 +615,11 @@ '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.', + 'bar_generating_one' => 'AI is generating your post…', + 'bar_generating_other' => 'AI is generating :count posts…', + 'bar_done' => 'Your post is ready!', + 'bar_done_cta' => 'View post', + 'bar_error' => 'Post generation failed.', 'create' => 'Create post', 'back' => 'Back', 'next' => 'Continue', diff --git a/lang/es/posts.php b/lang/es/posts.php index 61509fd8..e832ceda 100644 --- a/lang/es/posts.php +++ b/lang/es/posts.php @@ -616,6 +616,11 @@ '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.', + 'bar_generating_one' => 'La IA está generando tu post…', + 'bar_generating_other' => 'La IA está generando :count posts…', + 'bar_done' => '¡Tu post está listo!', + 'bar_done_cta' => 'Ver post', + 'bar_error' => 'Falló la generación del post.', 'create' => 'Crear post', 'back' => 'Atrás', 'next' => 'Continuar', diff --git a/lang/pt-BR/posts.php b/lang/pt-BR/posts.php index 72a8a361..9975cec3 100644 --- a/lang/pt-BR/posts.php +++ b/lang/pt-BR/posts.php @@ -615,6 +615,11 @@ '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.', + 'bar_generating_one' => 'IA gerando seu post…', + 'bar_generating_other' => 'IA gerando :count posts…', + 'bar_done' => 'Seu post ficou pronto!', + 'bar_done_cta' => 'Ver post', + 'bar_error' => 'A geração do post falhou.', 'create' => 'Criar post', 'back' => 'Voltar', 'next' => 'Continuar', diff --git a/resources/js/components/AiGenerationBar.vue b/resources/js/components/AiGenerationBar.vue new file mode 100644 index 00000000..3f893a6b --- /dev/null +++ b/resources/js/components/AiGenerationBar.vue @@ -0,0 +1,57 @@ + + + diff --git a/resources/js/composables/useAiGeneration.ts b/resources/js/composables/useAiGeneration.ts new file mode 100644 index 00000000..a8233691 --- /dev/null +++ b/resources/js/composables/useAiGeneration.ts @@ -0,0 +1,172 @@ +import { router } from '@inertiajs/vue3'; +import { echo } from '@laravel/echo-vue'; +import { computed, ref } from 'vue'; + +import { edit as editPostRoute } from '@/routes/app/posts'; + +/** + * Global state for in-flight AI post generations. + * + * Generation runs on the backend (the `StreamPostCreation` job) and broadcasts + * `.ai.creation.completed` on the private `user.{id}.ai-creation.{creationId}` + * channel. This composable is the SOLE OWNER of that channel subscription, so + * the "AI is generating…" notice persists across navigation (module-level state, + * SPA) and survives a hard reload via `sessionStorage`. + */ + +export interface AiGeneration { + /** creationId — identifies the generation. */ + id: string; + /** Full private channel name (without the `private-` prefix). */ + channel: string; + imageCount: number; + /** epoch ms — used by the safety timeout and by hydration. */ + startedAt: number; + status: 'loading' | 'done' | 'error'; + postId?: string; + error?: string; +} + +const STORAGE_KEY = 'ai-generations'; +/** After this, if nothing arrived, the bar disappears on its own (avoids getting stuck). */ +const MAX_LIFETIME_MS = 6 * 60 * 1000; +/** How long the "done"/"error" state stays visible before disappearing. */ +const DONE_TTL_MS = 12 * 1000; + +const generations = ref([]); +const subscribed = new Set(); +const timers = new Map>(); +let hydrated = false; + +const persist = (): void => { + try { + sessionStorage.setItem(STORAGE_KEY, JSON.stringify(generations.value)); + } catch { + /* sessionStorage unavailable — carry on without persisting */ + } +}; + +const clearTimer = (id: string): void => { + const timer = timers.get(id); + if (timer) { + clearTimeout(timer); + timers.delete(id); + } +}; + +const leaveChannel = (gen: AiGeneration): void => { + if (subscribed.has(gen.id)) { + echo().leave(`private-${gen.channel}`); + subscribed.delete(gen.id); + } +}; + +const remove = (id: string): void => { + const gen = generations.value.find((g) => g.id === id); + if (gen) { + leaveChannel(gen); + } + clearTimer(id); + generations.value = generations.value.filter((g) => g.id !== id); + persist(); +}; + +const complete = (id: string, postId?: string, error?: string): void => { + const gen = generations.value.find((g) => g.id === id); + if (!gen) { + return; + } + leaveChannel(gen); + clearTimer(id); + if (error || !postId) { + gen.status = 'error'; + gen.error = error ?? ''; + } else { + gen.status = 'done'; + gen.postId = postId; + } + persist(); + // The terminal state is transient: it disappears on its own after the TTL. + timers.set(id, setTimeout(() => remove(id), DONE_TTL_MS)); +}; + +const subscribe = (gen: AiGeneration): void => { + if (subscribed.has(gen.id)) { + return; + } + subscribed.add(gen.id); + echo() + .private(gen.channel) + .listen('.ai.creation.completed', (e: { post_id?: string; error?: string }) => { + complete(gen.id, e.post_id, e.error); + }); + // Safety: if the event never arrives, don't leave the bar stuck forever. + // Rebased on the real startedAt (hydrated generations already spent part of the time). + const remaining = Math.max(0, MAX_LIFETIME_MS - (Date.now() - gen.startedAt)); + timers.set(gen.id, setTimeout(() => remove(gen.id), remaining)); +}; + +const track = (input: { id: string; channel: string; imageCount?: number; startedAt?: number }): void => { + if (generations.value.some((g) => g.id === input.id)) { + return; + } + const gen: AiGeneration = { + id: input.id, + channel: input.channel, + imageCount: input.imageCount ?? 0, + startedAt: input.startedAt ?? Date.now(), + status: 'loading', + }; + generations.value = [...generations.value, gen]; + subscribe(gen); + persist(); +}; + +/** Re-subscribe to still-running generations after a hard reload. */ +const hydrate = (): void => { + if (hydrated) { + return; + } + hydrated = true; + let stored: AiGeneration[] = []; + try { + stored = JSON.parse(sessionStorage.getItem(STORAGE_KEY) ?? '[]') as AiGeneration[]; + } catch { + stored = []; + } + const now = Date.now(); + stored.forEach((g) => { + if (g.status === 'loading' && now - g.startedAt < MAX_LIFETIME_MS) { + track({ id: g.id, channel: g.channel, imageCount: g.imageCount, startedAt: g.startedAt }); + } + }); + persist(); +}; + +const openPost = (gen: AiGeneration): void => { + if (gen.postId) { + router.visit(editPostRoute(gen.postId).url); + } + remove(gen.id); +}; + +const loadingCount = computed(() => generations.value.filter((g) => g.status === 'loading').length); +const isGenerating = computed(() => loadingCount.value > 0); +const doneGeneration = computed(() => generations.value.find((g) => g.status === 'done') ?? null); +const errorGeneration = computed(() => generations.value.find((g) => g.status === 'error') ?? null); + +const find = (id: string) => computed(() => generations.value.find((g) => g.id === id) ?? null); + +export const useAiGeneration = () => ({ + generations, + isGenerating, + loadingCount, + doneGeneration, + errorGeneration, + track, + remove, + dismiss: remove, + openPost, + hydrate, + find, +}); diff --git a/resources/js/layouts/app/AppSidebarLayout.vue b/resources/js/layouts/app/AppSidebarLayout.vue index 5826f43f..4df97e3f 100644 --- a/resources/js/layouts/app/AppSidebarLayout.vue +++ b/resources/js/layouts/app/AppSidebarLayout.vue @@ -2,6 +2,7 @@ import { useHttp, usePage } from '@inertiajs/vue3'; import { onBeforeUnmount, onMounted } from 'vue'; +import AiGenerationBar from '@/components/AiGenerationBar.vue'; import AppHeader from '@/components/AppHeader.vue'; import AppSidebar from '@/components/AppSidebar.vue'; import Toast from '@/components/Toast.vue'; @@ -42,6 +43,7 @@ onBeforeUnmount(() => { +