| <script lang="ts"> |
| import { toast } from 'svelte-sonner'; |
| import { onMount, getContext } from 'svelte'; |
| import { goto } from '$app/navigation'; |
|
|
| import { user } from '$lib/stores'; |
| import { imageGenerations, imageEdits } from '$lib/apis/images'; |
|
|
| import Spinner from '$lib/components/common/Spinner.svelte'; |
|
|
| const i18n = getContext('i18n'); |
|
|
| let loaded = false; |
| let loading = false; |
|
|
| let prompt = ''; |
| let sourceImages: string[] = []; |
| let generatedImages: { url: string }[] = []; |
|
|
| let promptTextareaElement: HTMLTextAreaElement; |
| let fileInputElement: HTMLInputElement; |
|
|
| const resizePromptTextarea = () => { |
| if (promptTextareaElement) { |
| promptTextareaElement.style.height = ''; |
| promptTextareaElement.style.height = Math.min(promptTextareaElement.scrollHeight, 150) + 'px'; |
| } |
| }; |
|
|
| const handleFileUpload = (event: Event) => { |
| const input = event.target as HTMLInputElement; |
| if (input.files) { |
| Array.from(input.files).forEach((file) => { |
| const reader = new FileReader(); |
| reader.onload = (e) => { |
| if (e.target?.result) { |
| sourceImages = [...sourceImages, e.target.result as string]; |
| } |
| }; |
| reader.readAsDataURL(file); |
| }); |
| } |
| }; |
|
|
| const handleDrop = (event: DragEvent) => { |
| event.preventDefault(); |
| const files = event.dataTransfer?.files; |
| if (files) { |
| Array.from(files).forEach((file) => { |
| if (file.type.startsWith('image/')) { |
| const reader = new FileReader(); |
| reader.onload = (e) => { |
| if (e.target?.result) { |
| sourceImages = [...sourceImages, e.target.result as string]; |
| } |
| }; |
| reader.readAsDataURL(file); |
| } |
| }); |
| } |
| }; |
|
|
| const removeImage = (index: number) => { |
| sourceImages = sourceImages.filter((_, i) => i !== index); |
| }; |
|
|
| const submitHandler = async () => { |
| if (!prompt.trim()) { |
| toast.error($i18n.t('Please enter a prompt')); |
| return; |
| } |
|
|
| loading = true; |
| try { |
| let result; |
| if (sourceImages.length > 0) { |
| console.log('Calling imageEdits with', sourceImages.length, 'images'); |
| result = await imageEdits( |
| localStorage.token, |
| sourceImages.length === 1 ? sourceImages[0] : sourceImages, |
| prompt |
| ); |
| } else { |
| console.log('Calling imageGenerations'); |
| result = await imageGenerations(localStorage.token, prompt); |
| } |
|
|
| console.log('Result:', result); |
| if (result) { |
| generatedImages = [...result, ...generatedImages]; |
| } |
| } catch (error) { |
| console.error('Image generation/edit error:', error); |
| toast.error(`${error}`); |
| } finally { |
| loading = false; |
| } |
| }; |
|
|
| const downloadImage = async (url: string, index: number) => { |
| try { |
| const response = await fetch(url); |
| const blob = await response.blob(); |
| const blobUrl = URL.createObjectURL(blob); |
| const a = document.createElement('a'); |
| a.href = blobUrl; |
| a.download = `image-${Date.now()}-${index}.png`; |
| a.click(); |
| URL.revokeObjectURL(blobUrl); |
| } catch (error) { |
| toast.error($i18n.t('Failed to download image')); |
| } |
| }; |
|
|
| onMount(async () => { |
| if ($user?.role !== 'admin') { |
| await goto('/'); |
| return; |
| } |
| loaded = true; |
| }); |
| </script> |
|
|
| <div class=" flex flex-col justify-between w-full overflow-y-auto h-full"> |
| <div class="mx-auto w-full md:px-0 h-full"> |
| <div class=" flex flex-col h-full px-4"> |
| |
| <div |
| class=" pt-0.5 pb-2.5 flex flex-col justify-between w-full flex-auto overflow-auto h-0" |
| id="images-container" |
| > |
| <div class=" h-full w-full flex flex-col"> |
| <div class="flex-1 p-1"> |
| {#if generatedImages.length > 0} |
| <div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3"> |
| {#each generatedImages as image, index} |
| <button |
| class="relative group cursor-pointer" |
| on:click={() => downloadImage(image.url, index)} |
| > |
| <img |
| src={image.url} |
| alt="" |
| class="w-full aspect-square object-cover rounded-lg border border-gray-100/30 dark:border-gray-850/30" |
| /> |
| <div |
| class="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition rounded-lg flex items-center justify-center" |
| > |
| <svg |
| xmlns="http://www.w3.org/2000/svg" |
| class="w-6 h-6 text-white" |
| viewBox="0 0 24 24" |
| fill="none" |
| stroke="currentColor" |
| stroke-width="2" |
| > |
| <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /> |
| <polyline points="7,10 12,15 17,10" /> |
| <line x1="12" y1="15" x2="12" y2="3" /> |
| </svg> |
| </div> |
| </button> |
| {/each} |
| </div> |
| {:else} |
| <div |
| class="h-full flex items-center justify-center text-gray-400 dark:text-gray-600 text-sm" |
| > |
| {$i18n.t('Generated images will appear here')} |
| </div> |
| {/if} |
| </div> |
| </div> |
| </div> |
| |
| |
| <div class="pb-3"> |
| <div |
| class="border border-gray-100/30 dark:border-gray-850/30 w-full px-3 py-2.5 rounded-xl" |
| > |
| |
| {#if sourceImages.length > 0} |
| <div class="flex flex-wrap gap-2 mb-2"> |
| {#each sourceImages as image, index} |
| <div class=" relative group"> |
| <div class="relative flex items-center"> |
| <img src={image} alt="" class="size-10 rounded-xl object-cover" /> |
| </div> |
| <div class=" absolute -top-1 -right-1"> |
| <button |
| class=" bg-white text-black border border-white rounded-full group-hover:visible invisible transition" |
| type="button" |
| on:click={() => removeImage(index)} |
| > |
| <svg |
| xmlns="http://www.w3.org/2000/svg" |
| viewBox="0 0 20 20" |
| fill="currentColor" |
| aria-hidden="true" |
| class="size-4" |
| > |
| <path |
| d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z" |
| /> |
| </svg> |
| </button> |
| </div> |
| </div> |
| {/each} |
| </div> |
| {/if} |
| |
| |
| <div class="py-0.5"> |
| <textarea |
| bind:this={promptTextareaElement} |
| bind:value={prompt} |
| class=" w-full h-full bg-transparent resize-none outline-hidden text-sm" |
| placeholder={sourceImages.length > 0 |
| ? $i18n.t('Describe the edit...') |
| : $i18n.t('Describe the image...')} |
| on:input={resizePromptTextarea} |
| on:focus={resizePromptTextarea} |
| on:keydown={(e) => { |
| if (e.key === 'Enter' && (e.metaKey || e.ctrlKey) && !loading) { |
| e.preventDefault(); |
| submitHandler(); |
| } |
| }} |
| rows="2" |
| /> |
| </div> |
| |
| |
| <div class="flex justify-between items-center gap-2 mt-2"> |
| <div class="shrink-0"> |
| <input |
| type="file" |
| accept="image/*" |
| multiple |
| class="hidden" |
| bind:this={fileInputElement} |
| on:change={handleFileUpload} |
| /> |
| <button |
| type="button" |
| class="px-3.5 py-1.5 text-sm font-medium bg-gray-50 hover:bg-gray-100 text-gray-900 dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-gray-200 transition rounded-lg" |
| on:click={() => fileInputElement?.click()} |
| on:dragover|preventDefault |
| on:drop={handleDrop} |
| > |
| {$i18n.t('Add Image')} |
| </button> |
| </div> |
| |
| <div class="flex gap-2 shrink-0"> |
| {#if !loading} |
| <button |
| disabled={prompt.trim() === ''} |
| class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-lg disabled:opacity-50 disabled:cursor-not-allowed" |
| on:click={submitHandler} |
| > |
| {$i18n.t('Run')} |
| </button> |
| {:else} |
| <button |
| class="px-3.5 py-1.5 text-sm font-medium bg-gray-300 text-black transition rounded-lg flex items-center gap-2" |
| disabled |
| > |
| <Spinner className="size-4" /> |
| {$i18n.t('Running...')} |
| </button> |
| {/if} |
| </div> |
| </div> |
| </div> |
| </div> |
| </div> |
| </div> |
| </div> |
|
|