refactor(posts): simplify AiRegenerateImageDialog logic and enhance type safety

- Replaced custom regeneration logic with a composable for improved organization and reusability.
- Streamlined state management and error handling, enhancing clarity and maintainability.
- Removed unused imports and interfaces, reducing code complexity and improving readability.
- Updated computed properties for better form submission validation and user experience.
This commit is contained in:
Paulo Castellano 2026-05-21 19:40:42 -03:00
parent 72c9c93a85
commit 8a9ff81a96
2 changed files with 182 additions and 145 deletions

View file

@ -1,33 +1,13 @@
<script setup lang="ts">
import { useHttp } from '@inertiajs/vue3';
import { echo } from '@laravel/echo-vue';
import { trans } from 'laravel-vue-i18n';
import { computed, onBeforeUnmount, ref, watch } from 'vue';
import { toast } from 'vue-sonner';
import { watch } from 'vue';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { regenerateMedia as regeneratePostAiMedia } from '@/routes/app/posts/ai';
import { useAiMediaRegeneration, type RegenerationPayload } from '@/composables/useAiMediaRegeneration';
import type { MediaItem } from '@/types/media';
interface RegenerationPayload {
media: MediaItem;
targetMediaId: string;
}
type RegenerationStatus = 'idle' | 'starting' | 'processing';
interface RegenerationStartResponse {
channel?: string;
}
interface RegenerationEvent {
media: MediaItem | null;
error?: string | null;
}
const props = defineProps<{
postId: string;
mediaItem: MediaItem | null;
@ -39,139 +19,24 @@ const emit = defineEmits<{
(e: 'regenerated', payload: RegenerationPayload): void;
}>();
const instruction = ref('');
const errorMessage = ref<string | null>(null);
const status = ref<RegenerationStatus>('idle');
let subscribedChannel: string | null = null;
let regenerationTimeout: ReturnType<typeof setTimeout> | null = null;
const REGENERATION_TIMEOUT_MS = 180_000;
const httpRegenerate = useHttp<{ instruction: string }>({
instruction: '',
const { instruction, errorMessage, status, isBusy, isProcessing, canSubmit, submit, resetState, blockDismissWhileBusy } = useAiMediaRegeneration({
postId: props.postId,
getMediaItem: () => props.mediaItem,
onRegenerated: (payload) => emit('regenerated', payload),
onCompleted: () => {
open.value = false;
},
});
const isBusy = computed(() => status.value !== 'idle');
const normalizedInstruction = computed(() => instruction.value.trim());
const canSubmit = computed(() => normalizedInstruction.value.length > 0 && !isBusy.value);
const unsubscribe = () => {
if (subscribedChannel) {
echo().leave(`private-${subscribedChannel}`);
subscribedChannel = null;
}
};
const clearRegenerationTimeout = () => {
if (regenerationTimeout !== null) {
clearTimeout(regenerationTimeout);
regenerationTimeout = null;
}
};
const setIdleWithError = (message: string) => {
errorMessage.value = message;
status.value = 'idle';
};
const resetState = () => {
instruction.value = '';
errorMessage.value = null;
status.value = 'idle';
clearRegenerationTimeout();
unsubscribe();
};
const blockDismissWhileBusy = (event: Event) => {
if (isBusy.value) {
event.preventDefault();
}
};
const handleRegenerationResult = (event: RegenerationEvent) => {
clearRegenerationTimeout();
if (event.error || !event.media || !props.mediaItem) {
setIdleWithError(event.error ?? trans('posts.ai.image_regenerate.errors.unavailable'));
unsubscribe();
return;
}
toast.success(trans('posts.ai.image_regenerate.success'));
emit('regenerated', {
media: event.media,
targetMediaId: props.mediaItem.id,
});
resetState();
open.value = false;
};
const subscribe = (channel: string) => {
subscribedChannel = channel;
status.value = 'processing';
clearRegenerationTimeout();
regenerationTimeout = setTimeout(() => {
setIdleWithError(trans('posts.ai.image_regenerate.errors.timeout'));
unsubscribe();
}, REGENERATION_TIMEOUT_MS);
echo()
.private(channel)
.listen('.ai.media.regenerated', (event: RegenerationEvent) => handleRegenerationResult(event));
};
const submit = async () => {
const mediaItem = props.mediaItem;
const instructionValue = normalizedInstruction.value;
if (!mediaItem) {
return;
}
if (!instructionValue) {
setIdleWithError(trans('posts.ai.image_regenerate.errors.required'));
return;
}
errorMessage.value = null;
status.value = 'starting';
httpRegenerate.instruction = instructionValue;
try {
const response = await httpRegenerate.post(
regeneratePostAiMedia.url({ post: props.postId, mediaId: mediaItem.id }),
) as RegenerationStartResponse;
const channel = String(response.channel ?? '');
if (!channel) {
throw new Error('Missing channel in regeneration response.');
}
subscribe(channel);
} catch (error: unknown) {
const responseMessage = (error as { response?: { data?: { message?: string } } })?.response?.data?.message;
setIdleWithError(responseMessage ?? trans('posts.ai.image_regenerate.errors.start_failed'));
}
};
watch(open, (isOpen) => {
if (!isOpen) {
if (status.value === 'processing') {
if (isProcessing.value) {
open.value = true;
return;
}
resetState();
}
});
onBeforeUnmount(() => {
clearRegenerationTimeout();
unsubscribe();
});
</script>
<template>

View file

@ -0,0 +1,172 @@
import { useHttp } from '@inertiajs/vue3';
import { echo } from '@laravel/echo-vue';
import { trans } from 'laravel-vue-i18n';
import { computed, onUnmounted, ref } from 'vue';
import { toast } from 'vue-sonner';
import { regenerateMedia as regeneratePostAiMedia } from '@/routes/app/posts/ai';
import type { MediaItem } from '@/types/media';
export interface RegenerationPayload {
media: MediaItem;
targetMediaId: string;
}
type RegenerationStatus = 'idle' | 'starting' | 'processing';
interface RegenerationStartResponse {
channel?: string;
}
interface RegenerationEvent {
media: MediaItem | null;
error?: string | null;
}
interface UseAiMediaRegenerationOptions {
postId: string;
getMediaItem: () => MediaItem | null;
onRegenerated: (payload: RegenerationPayload) => void;
onCompleted?: () => void;
}
const REGENERATION_TIMEOUT_MS = 180_000;
export const useAiMediaRegeneration = (options: UseAiMediaRegenerationOptions) => {
const instruction = ref('');
const errorMessage = ref<string | null>(null);
const status = ref<RegenerationStatus>('idle');
const httpRegenerate = useHttp<{ instruction: string }>({
instruction: '',
});
let subscribedChannel: string | null = null;
let regenerationTimeout: ReturnType<typeof setTimeout> | null = null;
const isBusy = computed(() => status.value !== 'idle');
const isProcessing = computed(() => status.value === 'processing');
const normalizedInstruction = computed(() => instruction.value.trim());
const canSubmit = computed(() => normalizedInstruction.value.length > 0 && !isBusy.value);
const unsubscribe = () => {
if (subscribedChannel) {
echo().leave(`private-${subscribedChannel}`);
subscribedChannel = null;
}
};
const clearRegenerationTimeout = () => {
if (regenerationTimeout !== null) {
clearTimeout(regenerationTimeout);
regenerationTimeout = null;
}
};
const setIdleWithError = (message: string) => {
errorMessage.value = message;
status.value = 'idle';
};
const resetState = () => {
instruction.value = '';
errorMessage.value = null;
status.value = 'idle';
clearRegenerationTimeout();
unsubscribe();
};
const blockDismissWhileBusy = (event: Event) => {
if (isBusy.value) {
event.preventDefault();
}
};
const handleRegenerationResult = (event: RegenerationEvent) => {
clearRegenerationTimeout();
const mediaItem = options.getMediaItem();
if (event.error || !event.media || !mediaItem) {
setIdleWithError(event.error ?? trans('posts.ai.image_regenerate.errors.unavailable'));
unsubscribe();
return;
}
toast.success(trans('posts.ai.image_regenerate.success'));
options.onRegenerated({
media: event.media,
targetMediaId: mediaItem.id,
});
resetState();
options.onCompleted?.();
};
const subscribe = (channel: string) => {
subscribedChannel = channel;
status.value = 'processing';
clearRegenerationTimeout();
regenerationTimeout = setTimeout(() => {
setIdleWithError(trans('posts.ai.image_regenerate.errors.timeout'));
unsubscribe();
}, REGENERATION_TIMEOUT_MS);
echo()
.private(channel)
.listen('.ai.media.regenerated', (event: RegenerationEvent) => handleRegenerationResult(event));
};
const submit = async () => {
const mediaItem = options.getMediaItem();
const instructionValue = normalizedInstruction.value;
if (!mediaItem) {
return;
}
if (!instructionValue) {
setIdleWithError(trans('posts.ai.image_regenerate.errors.required'));
return;
}
errorMessage.value = null;
status.value = 'starting';
httpRegenerate.instruction = instructionValue;
try {
const response = (await httpRegenerate.post(
regeneratePostAiMedia.url({ post: options.postId, mediaId: mediaItem.id }),
)) as RegenerationStartResponse;
const channel = String(response.channel ?? '');
if (!channel) {
throw new Error('Missing channel in regeneration response.');
}
subscribe(channel);
} catch (error: unknown) {
const responseMessage = (error as { response?: { data?: { message?: string } } })?.response?.data?.message;
setIdleWithError(responseMessage ?? trans('posts.ai.image_regenerate.errors.start_failed'));
}
};
onUnmounted(() => {
clearRegenerationTimeout();
unsubscribe();
});
return {
instruction,
errorMessage,
status,
isBusy,
isProcessing,
canSubmit,
submit,
resetState,
blockDismissWhileBusy,
};
};