Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions lang/en/posts.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
5 changes: 5 additions & 0 deletions lang/es/posts.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
5 changes: 5 additions & 0 deletions lang/pt-BR/posts.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
57 changes: 57 additions & 0 deletions resources/js/components/AiGenerationBar.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<script setup lang="ts">
import { IconAlertTriangle, IconArrowRight, IconCircleCheck, IconLoader2, IconX } from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n';
import { computed, onMounted } from 'vue';

import { useAiGeneration } from '@/composables/useAiGeneration';

const { isGenerating, loadingCount, doneGeneration, errorGeneration, openPost, dismiss, hydrate } = useAiGeneration();

onMounted(() => hydrate());

const generatingLabel = computed(() =>
loadingCount.value <= 1
? trans('posts.create.steps.bar_generating_one')
: trans('posts.create.steps.bar_generating_other', { count: String(loadingCount.value) }),
);
</script>

<template>
<!-- Global AI generation bar. Persists across navigation (state lives in the composable). -->
<div
v-if="isGenerating"
class="flex h-8 shrink-0 items-center justify-center gap-2 border-b-2 border-foreground bg-orange-400 px-4 text-xs font-bold text-orange-950"
>
<IconLoader2 class="size-3.5 animate-spin" stroke-width="2.5" />
<span>{{ generatingLabel }}</span>
</div>

<button
v-else-if="doneGeneration"
type="button"
class="flex h-8 w-full shrink-0 cursor-pointer items-center justify-center gap-2 border-b-2 border-foreground bg-primary px-4 text-xs font-bold text-primary-foreground transition-colors hover:bg-primary/90"
@click="openPost(doneGeneration)"
>
<IconCircleCheck class="size-3.5" stroke-width="2.5" />
<span>{{ $t('posts.create.steps.bar_done') }}</span>
<span class="inline-flex items-center gap-0.5 underline underline-offset-2">
{{ $t('posts.create.steps.bar_done_cta') }}
<IconArrowRight class="size-3.5" stroke-width="2.5" />
</span>
</button>

<div
v-else-if="errorGeneration"
class="flex h-8 shrink-0 items-center justify-center gap-2 border-b-2 border-foreground bg-rose-100 px-4 text-xs font-bold text-rose-700"
>
<IconAlertTriangle class="size-3.5" stroke-width="2.5" />
<span>{{ $t('posts.create.steps.bar_error') }}</span>
<button
type="button"
class="cursor-pointer opacity-70 transition-opacity hover:opacity-100"
@click="dismiss(errorGeneration.id)"
>
<IconX class="size-3.5" />
</button>
</div>
</template>
172 changes: 172 additions & 0 deletions resources/js/composables/useAiGeneration.ts
Original file line number Diff line number Diff line change
@@ -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<AiGeneration[]>([]);
const subscribed = new Set<string>();
const timers = new Map<string, ReturnType<typeof setTimeout>>();
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,
});
2 changes: 2 additions & 0 deletions resources/js/layouts/app/AppSidebarLayout.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -42,6 +43,7 @@ onBeforeUnmount(() => {
<SidebarProvider :default-open="isOpen">
<AppSidebar />
<SidebarInset class="overflow-x-hidden">
<AiGenerationBar />
<AppHeader v-if="$slots['header'] || $slots['header-actions']">
<template v-if="$slots['header']" #left>
<slot name="header" />
Expand Down
47 changes: 24 additions & 23 deletions resources/js/pages/posts/ai/Loading.vue
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
<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 { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';

import { Button } from '@/components/ui/button';
import { useAiGeneration } from '@/composables/useAiGeneration';
import date from '@/date';
import AppLayout from '@/layouts/AppLayout.vue';
import { calendar as calendarRoute } from '@/routes/app';
Expand All @@ -22,7 +22,8 @@ const props = defineProps<{
const status = ref<'loading' | 'error'>('loading');
const errorMessage = ref('');

let echoChannel: any = null;
const aiGeneration = useAiGeneration();
const tracked = aiGeneration.find(props.creationId);

const TEXT_BASELINE_SECONDS = 30;
const PER_IMAGE_SECONDS = 35;
Expand Down Expand Up @@ -61,25 +62,23 @@ const progress = computed(() => {
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;
// The channel subscription lives in the global composable (sole owner), so the
// "AI is generating…" notice persists even if the user leaves this screen. Here
// we only react to the result of this page's generation.
watch(tracked, (gen) => {
if (!gen) {
return;
}
};
if (gen.status === 'error') {
status.value = 'error';
errorMessage.value = gen.error || trans('posts.create.steps.preview_error');
return;
}
if (gen.status === 'done' && gen.postId) {
aiGeneration.remove(props.creationId);
router.visit(editPostRoute(gen.postId).url);
}
}, { deep: true });

const leave = () => {
router.visit(calendarRoute().url);
Expand All @@ -90,7 +89,8 @@ const createAnother = () => {
};

onMounted(() => {
subscribe();
aiGeneration.hydrate();
aiGeneration.track({ id: props.creationId, channel: props.channel, imageCount: props.imageCount });
tipTimer = setInterval(() => {
tipIndex.value = (tipIndex.value + 1) % tipKeys.length;
}, 5000);
Expand All @@ -100,7 +100,8 @@ onMounted(() => {
});

onBeforeUnmount(() => {
unsubscribe();
// We don't leave the channel here: the global composable owns the
// subscription, so the generation keeps being tracked on other screens.
if (tipTimer) clearInterval(tipTimer);
if (elapsedTimer) clearInterval(elapsedTimer);
});
Expand Down