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:
parent
72c9c93a85
commit
8a9ff81a96
2 changed files with 182 additions and 145 deletions
|
|
@ -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>
|
||||
|
|
|
|||
172
resources/js/composables/useAiMediaRegeneration.ts
Normal file
172
resources/js/composables/useAiMediaRegeneration.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
|
||||
Loading…
Reference in a new issue