feat: remove options page and add edit page
This commit is contained in:
19
src/lib/assets/flower.svg
Normal file
19
src/lib/assets/flower.svg
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<svg width="64" height="64" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path fill="#000000" d="
|
||||||
|
M32 6
|
||||||
|
C36 6 38.5 10.5 38 15.5
|
||||||
|
C42.5 12 48 12.5 50.5 16
|
||||||
|
C53 19.5 50.5 24.5 46 26.5
|
||||||
|
C51 27.5 54.5 31.5 53.5 36
|
||||||
|
C52.5 40.5 47 42 42.5 40
|
||||||
|
C45.5 44 45 49.5 41 52
|
||||||
|
C37 54.5 32.5 51.5 31 47
|
||||||
|
C29.5 51.5 25 54.5 21 52
|
||||||
|
C17 49.5 16.5 44 19.5 40
|
||||||
|
C15 42 9.5 40.5 8.5 36
|
||||||
|
C7.5 31.5 11 27.5 16 26.5
|
||||||
|
C11.5 24.5 9 19.5 11.5 16
|
||||||
|
C14 12.5 19.5 12 24 15.5
|
||||||
|
C23.5 10.5 28 6 32 6
|
||||||
|
Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 540 B |
@@ -1,5 +1,5 @@
|
|||||||
<script>
|
<script>
|
||||||
let { budget = $bindable(50_000) } = $props();
|
let { budget = $bindable(50000) } = $props();
|
||||||
|
|
||||||
const min = 10_000;
|
const min = 10_000;
|
||||||
const max = 150_000;
|
const max = 150_000;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
who = $bindable(null),
|
who = $bindable(null),
|
||||||
whatFor = $bindable(null),
|
whatFor = $bindable(null),
|
||||||
style = $bindable(null),
|
style = $bindable(null),
|
||||||
budget = $bindable(50_000)
|
budget = $bindable(50000)
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const hasAnySelection = $derived(who !== null || whatFor !== null || style !== null);
|
const hasAnySelection = $derived(who !== null || whatFor !== null || style !== null);
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
<script>
|
|
||||||
import { toDataUrl } from '$lib/flowerFlow/api.js';
|
|
||||||
|
|
||||||
let {
|
|
||||||
size,
|
|
||||||
image = null,
|
|
||||||
selectedSize = $bindable(null)
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
const selected = $derived(selectedSize === size);
|
|
||||||
const dimmed = $derived(selectedSize !== null && selectedSize !== size);
|
|
||||||
|
|
||||||
function handleClick() {
|
|
||||||
selectedSize = size;
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<!-- 세로(4:5) 꽃다발 미리보기 — 클릭 시 해당 사이즈 선택 -->
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={handleClick}
|
|
||||||
aria-pressed={selected}
|
|
||||||
aria-label="Select size {size}"
|
|
||||||
class={[
|
|
||||||
'relative min-h-0 w-full cursor-pointer overflow-hidden bg-track text-left transition-all duration-300',
|
|
||||||
selected ? 'scale-[1.02] opacity-100 ring-2 ring-pill ring-inset' : '',
|
|
||||||
dimmed ? 'opacity-25' : 'opacity-100',
|
|
||||||
!dimmed && !selected ? 'hover:opacity-90' : ''
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<div class="aspect-4/5 w-full">
|
|
||||||
{#if image}
|
|
||||||
<img
|
|
||||||
src={toDataUrl(image)}
|
|
||||||
alt="Bouquet option {size}"
|
|
||||||
class="pointer-events-none h-full w-full object-cover"
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
<script>
|
|
||||||
import OptionGroup from '$lib/components/ui/create/OptionGroup.svelte';
|
|
||||||
import OptionCard from './OptionCard.svelte';
|
|
||||||
|
|
||||||
let { images = {}, loading = false, selectedSize = $bindable(null) } = $props();
|
|
||||||
|
|
||||||
const sizeOptions = ['S', 'M', 'L'];
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="flex min-h-0 flex-1 flex-col justify-center px-6 py-10 md:px-12 lg:px-16 lg:py-12">
|
|
||||||
{#if loading}
|
|
||||||
<div class="flex flex-1 items-center justify-center">
|
|
||||||
<p class="text-sm text-muted">Loading options...</p>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<!-- options-reorganized.png: S/M/L 세로 이미지 가로 3열 -->
|
|
||||||
<div class="options-grid mb-10 min-h-0 w-full lg:mb-14">
|
|
||||||
{#each sizeOptions as size (size)}
|
|
||||||
<OptionCard {size} image={images[size]} bind:selectedSize />
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<OptionGroup
|
|
||||||
label="Choose the size of the bouquet"
|
|
||||||
options={sizeOptions}
|
|
||||||
selected={selectedSize}
|
|
||||||
onchange={(v) => (selectedSize = v)}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.options-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(3, 1fr);
|
|
||||||
gap: 0.75rem;
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
align-content: center;
|
|
||||||
max-height: min(70vh, 36rem);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
|
||||||
.options-grid {
|
|
||||||
gap: 1rem;
|
|
||||||
max-height: min(65vh, 40rem);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -88,13 +88,24 @@ export async function generateImages(jobId) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {string} jobId
|
* @param {string} jobId
|
||||||
* @param {'S' | 'M' | 'L'} size
|
* @param {{ mode: string, prompt: string, selection: Array<{ x: number, y: number }> }} editInstruction
|
||||||
*/
|
*/
|
||||||
export async function selectOption(jobId, size) {
|
export async function editImages(jobId, editInstruction) {
|
||||||
const response = await fetch('/api/flower-flow/select-option', {
|
const response = await fetch('/api/flower-flow/edit-images', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ jobId, size })
|
body: JSON.stringify({ jobId, ...editInstruction })
|
||||||
|
});
|
||||||
|
|
||||||
|
return parseResponse(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {string} jobId */
|
||||||
|
export async function finalizeJob(jobId) {
|
||||||
|
const response = await fetch('/api/flower-flow/finalize', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ jobId })
|
||||||
});
|
});
|
||||||
|
|
||||||
return parseResponse(response);
|
return parseResponse(response);
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { readFileSync, existsSync } from 'node:fs';
|
|||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { mockGeneratedImage } from '$lib/server/gemini/mock.js';
|
import { mockGeneratedImage } from '$lib/server/gemini/mock.js';
|
||||||
|
|
||||||
/** @typedef {import('../flowerFlow/jobStore.js').BouquetSize} BouquetSize */
|
|
||||||
/** @typedef {import('../flowerFlow/jobStore.js').GeneratedImage} GeneratedImage */
|
/** @typedef {import('../flowerFlow/jobStore.js').GeneratedImage} GeneratedImage */
|
||||||
|
|
||||||
const MIME_BY_EXT = {
|
const MIME_BY_EXT = {
|
||||||
@@ -14,34 +13,27 @@ const MIME_BY_EXT = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* static/dev/bouquet-{size}.{jpg|png|svg} 를 읽습니다.
|
* static/dev/bouquet.{jpg|png|svg} 또는 기존 bouquet-m 파일을 읽습니다.
|
||||||
* 파일이 없으면 mock SVG로 대체합니다.
|
* 파일이 없으면 mock SVG로 대체합니다.
|
||||||
* @param {BouquetSize} size
|
|
||||||
* @returns {GeneratedImage}
|
* @returns {GeneratedImage}
|
||||||
*/
|
*/
|
||||||
function loadFixtureImage(size) {
|
export function loadDevBouquetImage() {
|
||||||
const baseDir = join(process.cwd(), 'static', 'dev');
|
const baseDir = join(process.cwd(), 'static', 'dev');
|
||||||
const extensions = ['.jpg', '.jpeg', '.png', '.webp', '.svg'];
|
const extensions = ['.jpg', '.jpeg', '.png', '.webp', '.svg'];
|
||||||
|
const names = ['bouquet', 'bouquet-m'];
|
||||||
|
|
||||||
for (const ext of extensions) {
|
for (const name of names) {
|
||||||
const filePath = join(baseDir, `bouquet-${size.toLowerCase()}${ext}`);
|
for (const ext of extensions) {
|
||||||
if (!existsSync(filePath)) continue;
|
const filePath = join(baseDir, `${name}${ext}`);
|
||||||
|
if (!existsSync(filePath)) continue;
|
||||||
|
|
||||||
const mimeType = MIME_BY_EXT[ext] ?? 'application/octet-stream';
|
const mimeType = MIME_BY_EXT[ext] ?? 'application/octet-stream';
|
||||||
return {
|
return {
|
||||||
mimeType,
|
mimeType,
|
||||||
base64: readFileSync(filePath).toString('base64')
|
base64: readFileSync(filePath).toString('base64')
|
||||||
};
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return mockGeneratedImage(size);
|
return mockGeneratedImage();
|
||||||
}
|
|
||||||
|
|
||||||
/** @returns {Partial<Record<BouquetSize, GeneratedImage>>} */
|
|
||||||
export function loadDevBouquetImages() {
|
|
||||||
return {
|
|
||||||
S: loadFixtureImage('S'),
|
|
||||||
M: loadFixtureImage('M'),
|
|
||||||
L: loadFixtureImage('L')
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
mockRecipe
|
mockRecipe
|
||||||
} from '$lib/server/gemini/mock.js';
|
} from '$lib/server/gemini/mock.js';
|
||||||
import { uploadGeneratedImages } from '$lib/server/flowerFlow/imageStorage.js';
|
import { uploadGeneratedImages } from '$lib/server/flowerFlow/imageStorage.js';
|
||||||
import { loadDevBouquetImages } from './loadFixtureImages.js';
|
import { loadDevBouquetImage } from './loadFixtureImages.js';
|
||||||
|
|
||||||
/** @typedef {'options' | 'result'} DevSeedStage */
|
/** @typedef {'options' | 'result'} DevSeedStage */
|
||||||
|
|
||||||
@@ -22,13 +22,13 @@ export async function seedDevJob(userInput, stage = 'result') {
|
|||||||
const floristNote = stage === 'result' ? mockFloristNote(recipe) : null;
|
const floristNote = stage === 'result' ? mockFloristNote(recipe) : null;
|
||||||
|
|
||||||
const job = await createJob(userInput);
|
const job = await createJob(userInput);
|
||||||
const images = await uploadGeneratedImages(job.id, loadDevBouquetImages());
|
const images = await uploadGeneratedImages(job.id, loadDevBouquetImage(), `dev-${Date.now()}`);
|
||||||
await updateJob(job.id, {
|
await updateJob(job.id, {
|
||||||
moodAnalysis,
|
moodAnalysis,
|
||||||
recipe,
|
recipe,
|
||||||
imagePrompt,
|
imagePrompt,
|
||||||
images,
|
images,
|
||||||
...(stage === 'result' ? { selectedSize: 'M', floristNote } : {})
|
...(stage === 'result' ? { floristNote } : {})
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -37,7 +37,6 @@ export async function seedDevJob(userInput, stage = 'result') {
|
|||||||
recipe,
|
recipe,
|
||||||
imagePrompt,
|
imagePrompt,
|
||||||
images,
|
images,
|
||||||
selectedSize: stage === 'result' ? 'M' : null,
|
|
||||||
floristNote,
|
floristNote,
|
||||||
mock: true
|
mock: true
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import {
|
|||||||
throwSupabaseError
|
throwSupabaseError
|
||||||
} from '$lib/server/supabase.js';
|
} from '$lib/server/supabase.js';
|
||||||
|
|
||||||
/** @typedef {import('./jobStore.js').BouquetSize} BouquetSize */
|
|
||||||
/** @typedef {import('./jobStore.js').GeneratedImage} GeneratedImage */
|
/** @typedef {import('./jobStore.js').GeneratedImage} GeneratedImage */
|
||||||
|
|
||||||
const EXTENSION_BY_MIME = {
|
const EXTENSION_BY_MIME = {
|
||||||
@@ -21,11 +20,11 @@ function extensionForMime(mimeType) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {string} jobId
|
* @param {string} jobId
|
||||||
* @param {BouquetSize} size
|
|
||||||
* @param {GeneratedImage} image
|
* @param {GeneratedImage} image
|
||||||
|
* @param {string} [revision='primary']
|
||||||
* @returns {Promise<GeneratedImage>}
|
* @returns {Promise<GeneratedImage>}
|
||||||
*/
|
*/
|
||||||
export async function uploadGeneratedImage(jobId, size, image) {
|
export async function uploadGeneratedImage(jobId, image, revision = 'primary') {
|
||||||
if (!image.base64) {
|
if (!image.base64) {
|
||||||
return image;
|
return image;
|
||||||
}
|
}
|
||||||
@@ -33,7 +32,7 @@ export async function uploadGeneratedImage(jobId, size, image) {
|
|||||||
const supabase = getSupabaseClient();
|
const supabase = getSupabaseClient();
|
||||||
const bucket = getSupabaseStorageBucket();
|
const bucket = getSupabaseStorageBucket();
|
||||||
const mimeType = image.mimeType || 'image/png';
|
const mimeType = image.mimeType || 'image/png';
|
||||||
const path = `${jobId}/${size.toLowerCase()}.${extensionForMime(mimeType)}`;
|
const path = `${jobId}/${revision}.${extensionForMime(mimeType)}`;
|
||||||
const bytes = Buffer.from(image.base64, 'base64');
|
const bytes = Buffer.from(image.base64, 'base64');
|
||||||
|
|
||||||
const { error } = await supabase.storage.from(bucket).upload(path, bytes, {
|
const { error } = await supabase.storage.from(bucket).upload(path, bytes, {
|
||||||
@@ -56,20 +55,12 @@ export async function uploadGeneratedImage(jobId, size, image) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {string} jobId
|
* @param {string} jobId
|
||||||
* @param {Partial<Record<BouquetSize, GeneratedImage>>} images
|
* @param {GeneratedImage} image
|
||||||
* @returns {Promise<Partial<Record<BouquetSize, GeneratedImage>>>}
|
* @param {string} [revision]
|
||||||
|
* @returns {Promise<{ primary: GeneratedImage }>}
|
||||||
*/
|
*/
|
||||||
export async function uploadGeneratedImages(jobId, images) {
|
export async function uploadGeneratedImages(jobId, image, revision) {
|
||||||
/** @type {Partial<Record<BouquetSize, GeneratedImage>>} */
|
return {
|
||||||
const uploaded = {};
|
primary: await uploadGeneratedImage(jobId, image, revision)
|
||||||
const sizes = /** @type {BouquetSize[]} */ (['S', 'M', 'L']);
|
};
|
||||||
|
|
||||||
for (const size of sizes) {
|
|
||||||
const image = images[size];
|
|
||||||
if (image) {
|
|
||||||
uploaded[size] = await uploadGeneratedImage(jobId, size, image);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return uploaded;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { getSupabaseClient, throwSupabaseError } from '$lib/server/supabase.js';
|
import { getSupabaseClient, throwSupabaseError } from '$lib/server/supabase.js';
|
||||||
|
|
||||||
/** @typedef {'S' | 'M' | 'L'} BouquetSize */
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {Object} UserInput
|
* @typedef {Object} UserInput
|
||||||
* @property {string} [relationship]
|
* @property {string} [relationship]
|
||||||
@@ -50,8 +48,7 @@ import { getSupabaseClient, throwSupabaseError } from '$lib/server/supabase.js';
|
|||||||
* @property {MoodAnalysis | null} moodAnalysis
|
* @property {MoodAnalysis | null} moodAnalysis
|
||||||
* @property {BouquetRecipe | null} recipe
|
* @property {BouquetRecipe | null} recipe
|
||||||
* @property {string | null} imagePrompt
|
* @property {string | null} imagePrompt
|
||||||
* @property {Partial<Record<BouquetSize, GeneratedImage>>} images
|
* @property {{ primary?: GeneratedImage }} images
|
||||||
* @property {BouquetSize | null} selectedSize
|
|
||||||
* @property {string | null} floristNote
|
* @property {string | null} floristNote
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -68,7 +65,6 @@ function fromRow(row) {
|
|||||||
recipe: row.recipe ?? null,
|
recipe: row.recipe ?? null,
|
||||||
imagePrompt: row.image_prompt ?? null,
|
imagePrompt: row.image_prompt ?? null,
|
||||||
images: row.images ?? {},
|
images: row.images ?? {},
|
||||||
selectedSize: row.selected_size ?? null,
|
|
||||||
floristNote: row.florist_note ?? null
|
floristNote: row.florist_note ?? null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -85,7 +81,6 @@ function toRowPatch(patch) {
|
|||||||
if ('recipe' in patch) row.recipe = patch.recipe;
|
if ('recipe' in patch) row.recipe = patch.recipe;
|
||||||
if ('imagePrompt' in patch) row.image_prompt = patch.imagePrompt;
|
if ('imagePrompt' in patch) row.image_prompt = patch.imagePrompt;
|
||||||
if ('images' in patch) row.images = patch.images ?? {};
|
if ('images' in patch) row.images = patch.images ?? {};
|
||||||
if ('selectedSize' in patch) row.selected_size = patch.selectedSize;
|
|
||||||
if ('floristNote' in patch) row.florist_note = patch.floristNote;
|
if ('floristNote' in patch) row.florist_note = patch.floristNote;
|
||||||
|
|
||||||
return row;
|
return row;
|
||||||
@@ -105,7 +100,6 @@ export async function createJob(userInput = {}) {
|
|||||||
recipe: null,
|
recipe: null,
|
||||||
imagePrompt: null,
|
imagePrompt: null,
|
||||||
images: {},
|
images: {},
|
||||||
selectedSize: null,
|
|
||||||
floristNote: null
|
floristNote: null
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
/** @typedef {import('../flowerFlow/jobStore.js').BouquetSize} BouquetSize */
|
|
||||||
/** @typedef {import('../flowerFlow/jobStore.js').GeneratedImage} GeneratedImage */
|
/** @typedef {import('../flowerFlow/jobStore.js').GeneratedImage} GeneratedImage */
|
||||||
|
|
||||||
import { env } from '$env/dynamic/private';
|
import { env } from '$env/dynamic/private';
|
||||||
@@ -6,36 +5,6 @@ import { getImageModel, isGeminiConfigured } from './client.js';
|
|||||||
import { mockGeneratedImage } from './mock.js';
|
import { mockGeneratedImage } from './mock.js';
|
||||||
import { generateOpenAIImage, isOpenAIConfigured } from '../openai/image.js';
|
import { generateOpenAIImage, isOpenAIConfigured } from '../openai/image.js';
|
||||||
|
|
||||||
/** S/M/L 공통 — recipe 구성 유지, 볼륨만 변경 */
|
|
||||||
const SIZE_CONSTRAINTS = `CRITICAL: This is a size variant of the SAME bouquet design.
|
|
||||||
- Use EXACTLY the same main flowers, sub flowers, greenery, colors, and wrapping from the recipe above.
|
|
||||||
- Do NOT add, remove, or substitute any flower types or colors.
|
|
||||||
- Do NOT change wrapping paper style, ribbon, or background.
|
|
||||||
- Only change the NUMBER OF STEMS and overall bouquet VOLUME/DENSITY.
|
|
||||||
- Same studio product photo style, front-facing bouquet, white/neutral background.`;
|
|
||||||
|
|
||||||
/** @type {Record<BouquetSize, string>} */
|
|
||||||
const SIZE_PROMPTS = {
|
|
||||||
S: `SIZE: SMALL (S) — budget / compact version.
|
|
||||||
- Slim, compact bouquet; smallest of the three size options.
|
|
||||||
- Fewer stems: roughly 40% of a standard bouquet (about 3-5 main flower blooms visible).
|
|
||||||
- Narrow silhouette, delicate and minimal volume.
|
|
||||||
- Wrapping paper proportionally smaller, hugging a small stem count.`,
|
|
||||||
M: `SIZE: MEDIUM (M) — standard version.
|
|
||||||
- Balanced, everyday florist bouquet; noticeably fuller than S.
|
|
||||||
- Moderate stems: roughly 70% of a premium bouquet (about 6-9 main flower blooms visible).
|
|
||||||
- Wider and rounder than S; filler and greenery scaled up proportionally.
|
|
||||||
- Standard wrapping volume, natural full-but-not-oversized shape.`,
|
|
||||||
L: `SIZE: LARGE (L) — premium / generous version.
|
|
||||||
- Most voluminous and dense; clearly larger than M.
|
|
||||||
- Abundant stems: full premium bouquet (about 10-15+ main flower blooms visible).
|
|
||||||
- Wide, lush, grand arrangement; maximum filler and greenery density.
|
|
||||||
- Largest wrapping spread, framing a generous bouquet.`
|
|
||||||
};
|
|
||||||
|
|
||||||
/** @type {BouquetSize[]} */
|
|
||||||
const ALL_SIZES = ['S', 'M', 'L'];
|
|
||||||
|
|
||||||
export function getImageProvider() {
|
export function getImageProvider() {
|
||||||
const configured = env.IMAGE_PROVIDER?.trim().toLowerCase();
|
const configured = env.IMAGE_PROVIDER?.trim().toLowerCase();
|
||||||
if (configured === 'mock' || configured === 'openai' || configured === 'gemini') {
|
if (configured === 'mock' || configured === 'openai' || configured === 'gemini') {
|
||||||
@@ -52,28 +21,27 @@ export function isImageGenerationConfigured() {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {string} basePrompt
|
* @param {string} basePrompt
|
||||||
* @param {BouquetSize} size
|
|
||||||
* @returns {Promise<GeneratedImage>}
|
* @returns {Promise<GeneratedImage>}
|
||||||
*/
|
*/
|
||||||
export async function generateBouquetImage(basePrompt, size) {
|
export async function generateBouquetImage(basePrompt) {
|
||||||
const prompt = `${basePrompt}\n\n${SIZE_CONSTRAINTS}\n\n${SIZE_PROMPTS[size]}`;
|
const prompt = `${basePrompt}\n\nGenerate one final bouquet image. Keep it realistic, orderable from a real florist, front-facing, and suitable for a customer preview.`;
|
||||||
const provider = getImageProvider();
|
const provider = getImageProvider();
|
||||||
|
|
||||||
// Explicit mock mode: develop the full flow without spending any image quota.
|
// Explicit mock mode: develop the full flow without spending any image quota.
|
||||||
if (provider === 'mock') {
|
if (provider === 'mock') {
|
||||||
return mockGeneratedImage(size);
|
return mockGeneratedImage();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (provider === 'openai') {
|
if (provider === 'openai') {
|
||||||
if (!isOpenAIConfigured()) {
|
if (!isOpenAIConfigured()) {
|
||||||
return mockGeneratedImage(size);
|
return mockGeneratedImage();
|
||||||
}
|
}
|
||||||
|
|
||||||
return generateOpenAIImage(prompt);
|
return generateOpenAIImage(prompt);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isGeminiConfigured()) {
|
if (!isGeminiConfigured()) {
|
||||||
return mockGeneratedImage(size);
|
return mockGeneratedImage();
|
||||||
}
|
}
|
||||||
|
|
||||||
const model = getImageModel();
|
const model = getImageModel();
|
||||||
@@ -92,18 +60,3 @@ export async function generateBouquetImage(basePrompt, size) {
|
|||||||
|
|
||||||
throw new Error('Gemini image model did not return image data');
|
throw new Error('Gemini image model did not return image data');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} basePrompt
|
|
||||||
* @returns {Promise<Partial<Record<BouquetSize, GeneratedImage>>>}
|
|
||||||
*/
|
|
||||||
export async function generateAllSizeImages(basePrompt) {
|
|
||||||
/** @type {Partial<Record<BouquetSize, GeneratedImage>>} */
|
|
||||||
const images = {};
|
|
||||||
|
|
||||||
for (const size of ALL_SIZES) {
|
|
||||||
images[size] = await generateBouquetImage(basePrompt, size);
|
|
||||||
}
|
|
||||||
|
|
||||||
return images;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
/** @typedef {import('../flowerFlow/jobStore.js').MoodAnalysis} MoodAnalysis */
|
/** @typedef {import('../flowerFlow/jobStore.js').MoodAnalysis} MoodAnalysis */
|
||||||
/** @typedef {import('../flowerFlow/jobStore.js').BouquetRecipe} BouquetRecipe */
|
/** @typedef {import('../flowerFlow/jobStore.js').BouquetRecipe} BouquetRecipe */
|
||||||
/** @typedef {import('../flowerFlow/jobStore.js').BouquetSize} BouquetSize */
|
|
||||||
|
|
||||||
/** @returns {MoodAnalysis} */
|
/** @returns {MoodAnalysis} */
|
||||||
export function mockMoodAnalysis() {
|
export function mockMoodAnalysis() {
|
||||||
@@ -46,11 +45,11 @@ export function mockImagePrompt(recipe) {
|
|||||||
].join(' ');
|
].join(' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @param {BouquetSize} size */
|
/** @param {string} [label] */
|
||||||
export function mockGeneratedImage(size) {
|
export function mockGeneratedImage(label = 'Bouquet') {
|
||||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="768" height="1024" viewBox="0 0 768 1024">
|
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="768" height="1024" viewBox="0 0 768 1024">
|
||||||
<rect width="768" height="1024" fill="#f7f3ef"/>
|
<rect width="768" height="1024" fill="#f7f3ef"/>
|
||||||
<text x="50%" y="48%" text-anchor="middle" font-size="42" fill="#6b5b53" font-family="Arial">Mock Bouquet ${size}</text>
|
<text x="50%" y="48%" text-anchor="middle" font-size="42" fill="#6b5b53" font-family="Arial">Mock ${label}</text>
|
||||||
<text x="50%" y="54%" text-anchor="middle" font-size="22" fill="#9a8d84" font-family="Arial">Set GEMINI_API_KEY for real images</text>
|
<text x="50%" y="54%" text-anchor="middle" font-size="22" fill="#9a8d84" font-family="Arial">Set GEMINI_API_KEY for real images</text>
|
||||||
</svg>`;
|
</svg>`;
|
||||||
|
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ Rules:
|
|||||||
- White background, soft natural lighting
|
- White background, soft natural lighting
|
||||||
- Korean florist style
|
- Korean florist style
|
||||||
- Describe bouquet composition only (flower types, colors, wrapping, mood)
|
- Describe bouquet composition only (flower types, colors, wrapping, mood)
|
||||||
- Do NOT specify bouquet size, stem count, or S/M/L — size variants are applied in a separate step
|
- Do NOT specify alternate size variants — generate one final customer preview image
|
||||||
- Return plain text only, no markdown`;
|
- Return plain text only, no markdown`;
|
||||||
|
|
||||||
const result = await model.generateContent(prompt);
|
const result = await model.generateContent(prompt);
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export async function POST({ request }) {
|
|||||||
moodboard: DEV_MOODBOARD_UPLOAD,
|
moodboard: DEV_MOODBOARD_UPLOAD,
|
||||||
sns: DEV_SNS_UPLOAD
|
sns: DEV_SNS_UPLOAD
|
||||||
},
|
},
|
||||||
...(stage === 'result' ? { selectedSize: 'M', floristNote: seeded.floristNote } : {})
|
...(stage === 'result' ? { floristNote: seeded.floristNote } : {})
|
||||||
},
|
},
|
||||||
// create 폼 초기값 참고용 (relationship/occasion/style/budget만)
|
// create 폼 초기값 참고용 (relationship/occasion/style/budget만)
|
||||||
formDefaults: DEV_USER_INPUT
|
formDefaults: DEV_USER_INPUT
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { dev } from '$app/environment';
|
import { dev } from '$app/environment';
|
||||||
import { json } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
import { loadDevBouquetImages } from '$lib/server/dev/loadFixtureImages.js';
|
import { loadDevBouquetImage } from '$lib/server/dev/loadFixtureImages.js';
|
||||||
import { requireJob, updateJob } from '$lib/server/flowerFlow/jobStore.js';
|
import { requireJob, updateJob } from '$lib/server/flowerFlow/jobStore.js';
|
||||||
import { uploadGeneratedImages } from '$lib/server/flowerFlow/imageStorage.js';
|
import { uploadGeneratedImages } from '$lib/server/flowerFlow/imageStorage.js';
|
||||||
import { mockImagePrompt, mockMoodAnalysis, mockRecipe } from '$lib/server/gemini/mock.js';
|
import { mockImagePrompt, mockMoodAnalysis, mockRecipe } from '$lib/server/gemini/mock.js';
|
||||||
@@ -24,7 +24,11 @@ export async function POST({ request }) {
|
|||||||
const moodAnalysis = job.moodAnalysis ?? mockMoodAnalysis();
|
const moodAnalysis = job.moodAnalysis ?? mockMoodAnalysis();
|
||||||
const recipe = job.recipe ?? mockRecipe(job.userInput);
|
const recipe = job.recipe ?? mockRecipe(job.userInput);
|
||||||
const imagePrompt = job.imagePrompt ?? mockImagePrompt(recipe);
|
const imagePrompt = job.imagePrompt ?? mockImagePrompt(recipe);
|
||||||
const images = await uploadGeneratedImages(jobId, loadDevBouquetImages());
|
const images = await uploadGeneratedImages(
|
||||||
|
jobId,
|
||||||
|
loadDevBouquetImage(),
|
||||||
|
`dev-skip-${Date.now()}`
|
||||||
|
);
|
||||||
|
|
||||||
await updateJob(jobId, { moodAnalysis, recipe, imagePrompt, images });
|
await updateJob(jobId, { moodAnalysis, recipe, imagePrompt, images });
|
||||||
|
|
||||||
|
|||||||
101
src/routes/api/flower-flow/edit-images/+server.js
Normal file
101
src/routes/api/flower-flow/edit-images/+server.js
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { requireJob, updateJob } from '$lib/server/flowerFlow/jobStore.js';
|
||||||
|
import { uploadGeneratedImages } from '$lib/server/flowerFlow/imageStorage.js';
|
||||||
|
import {
|
||||||
|
generateBouquetImage,
|
||||||
|
getImageProvider,
|
||||||
|
isImageGenerationConfigured
|
||||||
|
} from '$lib/server/gemini/image.js';
|
||||||
|
import { buildImagePrompt } from '$lib/server/gemini/text.js';
|
||||||
|
import { json, readJsonBody, toErrorResponse } from '$lib/server/http.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {unknown} value
|
||||||
|
*/
|
||||||
|
function isPointArray(value) {
|
||||||
|
return (
|
||||||
|
Array.isArray(value) &&
|
||||||
|
value.every(
|
||||||
|
(point) =>
|
||||||
|
point &&
|
||||||
|
typeof point === 'object' &&
|
||||||
|
typeof point.x === 'number' &&
|
||||||
|
typeof point.y === 'number'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{ mode: string, prompt: string, selection: unknown }} instruction
|
||||||
|
*/
|
||||||
|
function describeEditInstruction(instruction) {
|
||||||
|
const lines = [
|
||||||
|
'EDIT REQUEST:',
|
||||||
|
instruction.prompt,
|
||||||
|
'',
|
||||||
|
'Preserve the same bouquet concept, camera angle, background, wrapping style, and realistic florist photography unless the edit request explicitly says otherwise.'
|
||||||
|
];
|
||||||
|
|
||||||
|
if (instruction.mode === 'area') {
|
||||||
|
lines.push(
|
||||||
|
'The user drew a target area on the image. Apply the edit only to that visual region as much as possible, while keeping the rest of the bouquet unchanged.',
|
||||||
|
`Selection points are normalized image coordinates: ${JSON.stringify(instruction.selection)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {import('./$types').RequestHandler} */
|
||||||
|
export async function POST({ request }) {
|
||||||
|
try {
|
||||||
|
const body = await readJsonBody(request);
|
||||||
|
const jobId = typeof body.jobId === 'string' ? body.jobId : '';
|
||||||
|
const prompt = typeof body.prompt === 'string' ? body.prompt.trim() : '';
|
||||||
|
const mode = body.mode === 'area' ? 'area' : 'whole';
|
||||||
|
const selection = isPointArray(body.selection) ? body.selection : [];
|
||||||
|
|
||||||
|
if (!jobId) {
|
||||||
|
return json({ error: 'jobId is required', code: 'bad_request' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!prompt) {
|
||||||
|
return json({ error: 'prompt is required', code: 'bad_request' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === 'area' && selection.length < 3) {
|
||||||
|
return json({ error: 'selection is required for area edits', code: 'bad_request' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const job = await requireJob(jobId);
|
||||||
|
|
||||||
|
if (!job.recipe) {
|
||||||
|
return json({ error: 'recipe is missing. Run recipe first.', code: 'bad_request' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const basePrompt = job.imagePrompt ?? (await buildImagePrompt(job.recipe));
|
||||||
|
const editPrompt = `${basePrompt}\n\n${describeEditInstruction({ mode, prompt, selection })}`;
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[flower-flow] edit-images job=${jobId.slice(0, 8)} provider=${getImageProvider()} mode=${mode} → generating...`
|
||||||
|
);
|
||||||
|
const generatedImage = await generateBouquetImage(editPrompt);
|
||||||
|
const images = await uploadGeneratedImages(
|
||||||
|
jobId,
|
||||||
|
generatedImage,
|
||||||
|
`edit-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||||
|
);
|
||||||
|
await updateJob(jobId, { imagePrompt: editPrompt, images, floristNote: null });
|
||||||
|
console.log(
|
||||||
|
`[flower-flow] edit-images job=${jobId.slice(0, 8)} OK (mock=${!isImageGenerationConfigured()})`
|
||||||
|
);
|
||||||
|
|
||||||
|
return json({
|
||||||
|
jobId,
|
||||||
|
imagePrompt: editPrompt,
|
||||||
|
images,
|
||||||
|
mock: !isImageGenerationConfigured()
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return toErrorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,41 +3,29 @@ import { buildFloristNote } from '$lib/server/gemini/text.js';
|
|||||||
import { isGeminiConfigured } from '$lib/server/gemini/client.js';
|
import { isGeminiConfigured } from '$lib/server/gemini/client.js';
|
||||||
import { json, readJsonBody, toErrorResponse } from '$lib/server/http.js';
|
import { json, readJsonBody, toErrorResponse } from '$lib/server/http.js';
|
||||||
|
|
||||||
/** @type {import('$lib/server/flowerFlow/jobStore.js').BouquetSize[]} */
|
|
||||||
const VALID_SIZES = ['S', 'M', 'L'];
|
|
||||||
|
|
||||||
/** @type {import('./$types').RequestHandler} */
|
/** @type {import('./$types').RequestHandler} */
|
||||||
export async function POST({ request }) {
|
export async function POST({ request }) {
|
||||||
try {
|
try {
|
||||||
const body = await readJsonBody(request);
|
const body = await readJsonBody(request);
|
||||||
const jobId = typeof body.jobId === 'string' ? body.jobId : '';
|
const jobId = typeof body.jobId === 'string' ? body.jobId : '';
|
||||||
const size = typeof body.size === 'string' ? body.size : '';
|
|
||||||
|
|
||||||
if (!jobId) {
|
if (!jobId) {
|
||||||
return json({ error: 'jobId is required' }, 400);
|
return json({ error: 'jobId is required' }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!VALID_SIZES.includes(size)) {
|
|
||||||
return json({ error: 'size must be one of S, M, or L' }, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
const job = await requireJob(jobId);
|
const job = await requireJob(jobId);
|
||||||
const selectedImage = job.images?.[/** @type {'S'|'M'|'L'} */ (size)];
|
const selectedImage = job.images?.primary;
|
||||||
|
|
||||||
if (!selectedImage) {
|
if (!selectedImage) {
|
||||||
return json({ error: 'selected size image is missing. Run generate-images first.' }, 400);
|
return json({ error: 'generated image is missing. Run generate-images first.' }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const floristNote = job.recipe ? await buildFloristNote(job.recipe) : null;
|
const floristNote = job.recipe ? await buildFloristNote(job.recipe) : null;
|
||||||
|
|
||||||
await updateJob(jobId, {
|
await updateJob(jobId, { floristNote });
|
||||||
selectedSize: /** @type {'S'|'M'|'L'} */ (size),
|
|
||||||
floristNote
|
|
||||||
});
|
|
||||||
|
|
||||||
return json({
|
return json({
|
||||||
jobId,
|
jobId,
|
||||||
selectedSize: size,
|
|
||||||
selectedImage,
|
selectedImage,
|
||||||
floristNote,
|
floristNote,
|
||||||
recipe: job.recipe,
|
recipe: job.recipe,
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { requireJob, updateJob } from '$lib/server/flowerFlow/jobStore.js';
|
import { requireJob, updateJob } from '$lib/server/flowerFlow/jobStore.js';
|
||||||
import { buildImagePrompt } from '$lib/server/gemini/text.js';
|
import { buildImagePrompt } from '$lib/server/gemini/text.js';
|
||||||
import {
|
import {
|
||||||
generateAllSizeImages,
|
generateBouquetImage,
|
||||||
getImageProvider,
|
getImageProvider,
|
||||||
isImageGenerationConfigured
|
isImageGenerationConfigured
|
||||||
} from '$lib/server/gemini/image.js';
|
} from '$lib/server/gemini/image.js';
|
||||||
@@ -19,7 +19,7 @@ function isMockImage(image) {
|
|||||||
* Dedupe concurrent generation for the same job. Without this, a remount or
|
* Dedupe concurrent generation for the same job. Without this, a remount or
|
||||||
* double-navigation can fire several generate-images requests at once, which is
|
* double-navigation can fire several generate-images requests at once, which is
|
||||||
* a common way to *cause* the very rate limits this page then keeps retrying.
|
* a common way to *cause* the very rate limits this page then keeps retrying.
|
||||||
* @type {Map<string, Promise<{ imagePrompt: string, images: Partial<Record<import('$lib/server/flowerFlow/jobStore.js').BouquetSize, import('$lib/server/flowerFlow/jobStore.js').GeneratedImage>> }>>}
|
* @type {Map<string, Promise<{ imagePrompt: string, images: { primary: import('$lib/server/flowerFlow/jobStore.js').GeneratedImage } }>>}
|
||||||
*/
|
*/
|
||||||
const inFlight = new Map();
|
const inFlight = new Map();
|
||||||
|
|
||||||
@@ -30,8 +30,8 @@ function generateForJob(jobId, recipe) {
|
|||||||
|
|
||||||
const task = (async () => {
|
const task = (async () => {
|
||||||
const imagePrompt = await buildImagePrompt(recipe);
|
const imagePrompt = await buildImagePrompt(recipe);
|
||||||
const generatedImages = await generateAllSizeImages(imagePrompt);
|
const generatedImage = await generateBouquetImage(imagePrompt);
|
||||||
const images = await uploadGeneratedImages(jobId, generatedImages);
|
const images = await uploadGeneratedImages(jobId, generatedImage, `initial-${Date.now()}`);
|
||||||
await updateJob(jobId, { imagePrompt, images });
|
await updateJob(jobId, { imagePrompt, images });
|
||||||
return { imagePrompt, images };
|
return { imagePrompt, images };
|
||||||
})().finally(() => {
|
})().finally(() => {
|
||||||
@@ -58,7 +58,7 @@ export async function POST({ request }) {
|
|||||||
return json({ error: 'recipe is missing. Run recipe first.', code: 'bad_request' }, 400);
|
return json({ error: 'recipe is missing. Run recipe first.', code: 'bad_request' }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (job.images?.M && !isMockImage(job.images.M)) {
|
if (job.images?.primary && !isMockImage(job.images.primary)) {
|
||||||
console.log(
|
console.log(
|
||||||
`[flower-flow] generate-images job=${jobId.slice(0, 8)} cached (already generated)`
|
`[flower-flow] generate-images job=${jobId.slice(0, 8)} cached (already generated)`
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ export async function GET({ url }) {
|
|||||||
recipe: job.recipe,
|
recipe: job.recipe,
|
||||||
imagePrompt: job.imagePrompt,
|
imagePrompt: job.imagePrompt,
|
||||||
images: job.images,
|
images: job.images,
|
||||||
selectedSize: job.selectedSize,
|
|
||||||
floristNote: job.floristNote,
|
floristNote: job.floristNote,
|
||||||
mock: !isGeminiConfigured()
|
mock: !isGeminiConfigured()
|
||||||
});
|
});
|
||||||
|
|||||||
426
src/routes/edit/+page.svelte
Normal file
426
src/routes/edit/+page.svelte
Normal file
@@ -0,0 +1,426 @@
|
|||||||
|
<script>
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
|
import Header from '$lib/components/ui/Header.svelte';
|
||||||
|
import { editImages, fetchJob, finalizeJob, toDataUrl } from '$lib/flowerFlow/api.js';
|
||||||
|
import { getFlowString, saveFlow } from '$lib/flowerFlow/session.js';
|
||||||
|
|
||||||
|
const jobId = getFlowString('jobId');
|
||||||
|
const QUICK_PROMPTS = [
|
||||||
|
'Make it more romantic',
|
||||||
|
'Use warmer colors',
|
||||||
|
'Add more volume',
|
||||||
|
'Keep the same flowers'
|
||||||
|
];
|
||||||
|
|
||||||
|
let loading = $state(true);
|
||||||
|
let error = $state('');
|
||||||
|
let prompt = $state('');
|
||||||
|
let mode = $state('whole');
|
||||||
|
let drawing = $state(false);
|
||||||
|
let selectionPoints = $state([]);
|
||||||
|
let initialImage = $state(null);
|
||||||
|
let generatedImage = $state(null);
|
||||||
|
let recipe = $state(null);
|
||||||
|
let editing = $state(false);
|
||||||
|
let continuing = $state(false);
|
||||||
|
let editHistory = $state([]);
|
||||||
|
|
||||||
|
const imageSrc = $derived(toDataUrl(generatedImage));
|
||||||
|
const title = $derived(recipe?.concept ?? 'Generated bouquet');
|
||||||
|
const description = $derived(
|
||||||
|
recipe?.mainFlowers?.length
|
||||||
|
? `${recipe.mainFlowers.join(', ')} · ${recipe.wrapping ?? 'Custom wrap'}`
|
||||||
|
: 'Review and refine your bouquet before choosing a size.'
|
||||||
|
);
|
||||||
|
const selectionPolyline = $derived(
|
||||||
|
selectionPoints.map((point) => `${point.x},${point.y}`).join(' ')
|
||||||
|
);
|
||||||
|
const canSaveAreaPrompt = $derived(mode !== 'area' || selectionPoints.length > 2);
|
||||||
|
const latestEditId = $derived(editHistory[editHistory.length - 1]?.id ?? '');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {PointerEvent} event
|
||||||
|
*/
|
||||||
|
function getPoint(event) {
|
||||||
|
const rect = /** @type {SVGElement} */ (event.currentTarget).getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
x: Math.max(0, Math.min(100, ((event.clientX - rect.left) / rect.width) * 100)),
|
||||||
|
y: Math.max(0, Math.min(100, ((event.clientY - rect.top) / rect.height) * 100))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {PointerEvent} event */
|
||||||
|
function startDrawing(event) {
|
||||||
|
if (mode !== 'area') return;
|
||||||
|
event.preventDefault();
|
||||||
|
/** @type {SVGElement} */ (event.currentTarget).setPointerCapture(event.pointerId);
|
||||||
|
drawing = true;
|
||||||
|
selectionPoints = [getPoint(event)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {PointerEvent} event */
|
||||||
|
function draw(event) {
|
||||||
|
if (!drawing || mode !== 'area') return;
|
||||||
|
event.preventDefault();
|
||||||
|
selectionPoints = [...selectionPoints, getPoint(event)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopDrawing() {
|
||||||
|
drawing = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSelection() {
|
||||||
|
selectionPoints = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {string} text */
|
||||||
|
function addQuickPrompt(text) {
|
||||||
|
prompt = prompt ? `${prompt}, ${text.toLowerCase()}` : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEditInstruction() {
|
||||||
|
if (!prompt.trim()) {
|
||||||
|
error = 'Tell us what to change first.';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!canSaveAreaPrompt) {
|
||||||
|
error = 'Draw the area you want to change first.';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
mode,
|
||||||
|
prompt: prompt.trim(),
|
||||||
|
selection: mode === 'area' ? selectionPoints : []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyEdit() {
|
||||||
|
const instruction = getEditInstruction();
|
||||||
|
if (!instruction || !jobId) return;
|
||||||
|
|
||||||
|
editing = true;
|
||||||
|
error = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await editImages(jobId, instruction);
|
||||||
|
const afterImage = result.images?.primary ?? null;
|
||||||
|
generatedImage = afterImage;
|
||||||
|
editHistory = [
|
||||||
|
...editHistory,
|
||||||
|
{
|
||||||
|
id: `${Date.now()}-${editHistory.length}`,
|
||||||
|
instruction,
|
||||||
|
afterImage
|
||||||
|
}
|
||||||
|
];
|
||||||
|
prompt = '';
|
||||||
|
selectionPoints = [];
|
||||||
|
saveFlow({
|
||||||
|
editInstruction: instruction,
|
||||||
|
imagePrompt: result.imagePrompt,
|
||||||
|
mock: result.mock
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
error = err instanceof Error ? err.message : 'Edit failed';
|
||||||
|
} finally {
|
||||||
|
editing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function continueToResult() {
|
||||||
|
if (!jobId) {
|
||||||
|
await goto(resolve('/create'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
continuing = true;
|
||||||
|
error = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
await finalizeJob(jobId);
|
||||||
|
await goto(resolve('/result'));
|
||||||
|
} catch (err) {
|
||||||
|
error = err instanceof Error ? err.message : 'Failed to continue to result';
|
||||||
|
continuing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
if (!jobId) {
|
||||||
|
await goto(resolve('/create'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const job = await fetchJob(jobId);
|
||||||
|
if (!job.images?.primary) {
|
||||||
|
await goto(resolve('/generating'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
initialImage = job.images.primary;
|
||||||
|
generatedImage = job.images.primary;
|
||||||
|
recipe = job.recipe ?? null;
|
||||||
|
loading = false;
|
||||||
|
} catch (err) {
|
||||||
|
error = err instanceof Error ? err.message : 'Failed to load generated bouquet';
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="flex h-dvh flex-col overflow-x-hidden bg-surface text-ink lg:h-screen lg:overflow-hidden"
|
||||||
|
>
|
||||||
|
<Header step={5} total={7} />
|
||||||
|
|
||||||
|
<main class="flex min-h-0 flex-1 flex-col lg:flex-row">
|
||||||
|
<section
|
||||||
|
class="flex min-h-0 w-full shrink-0 flex-col border-b border-line px-6 py-6 lg:w-[44%] lg:border-r lg:border-b-0 lg:px-10 lg:py-8"
|
||||||
|
>
|
||||||
|
<div class="mx-auto flex min-h-0 w-full max-w-100 flex-1 flex-col justify-center gap-6">
|
||||||
|
<div class="overflow-hidden bg-track shadow-sm ring-1 ring-black/5">
|
||||||
|
{#if loading}
|
||||||
|
<div class="aspect-[4/5] w-full animate-pulse bg-placeholder"></div>
|
||||||
|
{:else if imageSrc}
|
||||||
|
<img src={imageSrc} alt="Generated bouquet" class="aspect-[4/5] w-full object-cover" />
|
||||||
|
{:else}
|
||||||
|
<div class="aspect-[4/5] w-full bg-placeholder"></div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border border-line-strong bg-surface px-5 py-4">
|
||||||
|
<h1 class="text-sm">{title}</h1>
|
||||||
|
<p class="mt-2 text-xs leading-relaxed text-muted">{description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="relative flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||||
|
<div class="mx-auto flex min-h-0 w-full max-w-2xl flex-1 flex-col gap-4 px-6 py-5 lg:py-6">
|
||||||
|
<div class="shrink-0">
|
||||||
|
<p class="text-xs tracking-[0.2em] text-muted uppercase">Edit bouquet</p>
|
||||||
|
<h2 class="mt-1 text-lg">Tell us how you want to refine it.</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="min-h-0 flex-1 space-y-4 overflow-y-auto pr-1">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<p class="text-xs text-muted">Generated image</p>
|
||||||
|
<div class="relative w-[42%] overflow-hidden bg-track ring-1 ring-black/5">
|
||||||
|
{#if initialImage}
|
||||||
|
<img
|
||||||
|
src={toDataUrl(initialImage)}
|
||||||
|
alt="Generated bouquet"
|
||||||
|
class={[
|
||||||
|
'aspect-[4/5] w-full object-contain',
|
||||||
|
mode === 'area' && editHistory.length === 0 ? 'opacity-75' : ''
|
||||||
|
]}
|
||||||
|
draggable="false"
|
||||||
|
/>
|
||||||
|
{:else if imageSrc}
|
||||||
|
<img
|
||||||
|
src={imageSrc}
|
||||||
|
alt="Generated bouquet"
|
||||||
|
class={[
|
||||||
|
'aspect-[4/5] w-full object-contain',
|
||||||
|
mode === 'area' && editHistory.length === 0 ? 'opacity-75' : ''
|
||||||
|
]}
|
||||||
|
draggable="false"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<div class="aspect-[4/5] w-full bg-placeholder"></div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if mode === 'area' && editHistory.length === 0}
|
||||||
|
<svg
|
||||||
|
class="absolute inset-0 h-full w-full touch-none"
|
||||||
|
viewBox="0 0 100 100"
|
||||||
|
preserveAspectRatio="none"
|
||||||
|
role="application"
|
||||||
|
aria-label="Draw an area to edit"
|
||||||
|
onpointerdown={startDrawing}
|
||||||
|
onpointermove={draw}
|
||||||
|
onpointerup={stopDrawing}
|
||||||
|
onpointercancel={stopDrawing}
|
||||||
|
onpointerleave={stopDrawing}
|
||||||
|
>
|
||||||
|
{#if selectionPoints.length > 1}
|
||||||
|
<polyline
|
||||||
|
points={selectionPolyline}
|
||||||
|
fill="rgba(255,255,255,0.18)"
|
||||||
|
stroke="white"
|
||||||
|
stroke-width="0.8"
|
||||||
|
stroke-dasharray="1.4 1.2"
|
||||||
|
vector-effect="non-scaling-stroke"
|
||||||
|
/>
|
||||||
|
{#each selectionPoints.filter((_, index) => index % 8 === 0) as point, index (index)}
|
||||||
|
<circle cx={point.x} cy={point.y} r="0.8" fill="white" />
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#each editHistory as edit (edit.id)}
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<div
|
||||||
|
class="max-w-[46%] rounded-3xl bg-pill px-4 py-3 text-sm leading-relaxed text-surface"
|
||||||
|
>
|
||||||
|
<p>{edit.instruction.prompt}</p>
|
||||||
|
{#if edit.instruction.mode === 'area'}
|
||||||
|
<p class="mt-2 text-xs opacity-70">Selected area only</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="relative w-[42%] overflow-hidden bg-track ring-1 ring-black/5">
|
||||||
|
{#if edit.afterImage}
|
||||||
|
<img
|
||||||
|
src={toDataUrl(edit.afterImage)}
|
||||||
|
alt="Edited bouquet result"
|
||||||
|
class={[
|
||||||
|
'aspect-[4/5] w-full object-contain',
|
||||||
|
mode === 'area' && edit.id === latestEditId ? 'opacity-75' : ''
|
||||||
|
]}
|
||||||
|
draggable="false"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if mode === 'area' && edit.id === latestEditId}
|
||||||
|
<svg
|
||||||
|
class="absolute inset-0 h-full w-full touch-none"
|
||||||
|
viewBox="0 0 100 100"
|
||||||
|
preserveAspectRatio="none"
|
||||||
|
role="application"
|
||||||
|
aria-label="Draw an area to edit"
|
||||||
|
onpointerdown={startDrawing}
|
||||||
|
onpointermove={draw}
|
||||||
|
onpointerup={stopDrawing}
|
||||||
|
onpointercancel={stopDrawing}
|
||||||
|
onpointerleave={stopDrawing}
|
||||||
|
>
|
||||||
|
{#if selectionPoints.length > 1}
|
||||||
|
<polyline
|
||||||
|
points={selectionPolyline}
|
||||||
|
fill="rgba(255,255,255,0.18)"
|
||||||
|
stroke="white"
|
||||||
|
stroke-width="0.8"
|
||||||
|
stroke-dasharray="1.4 1.2"
|
||||||
|
vector-effect="non-scaling-stroke"
|
||||||
|
/>
|
||||||
|
{#each selectionPoints.filter((_, index) => index % 8 === 0) as point, index (index)}
|
||||||
|
<circle cx={point.x} cy={point.y} r="0.8" fill="white" />
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-muted">Result</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex shrink-0 rounded-full bg-track p-1 ring-1 ring-black/5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => (mode = 'whole')}
|
||||||
|
class={[
|
||||||
|
'flex-1 rounded-full px-4 py-2 text-sm transition-colors',
|
||||||
|
mode === 'whole' ? 'bg-pill text-surface' : 'text-muted hover:text-ink'
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
Whole image
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => (mode = 'area')}
|
||||||
|
class={[
|
||||||
|
'flex-1 rounded-full px-4 py-2 text-sm transition-colors',
|
||||||
|
mode === 'area' ? 'bg-pill text-surface' : 'text-muted hover:text-ink'
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
Select area
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex shrink-0 flex-wrap gap-2">
|
||||||
|
{#each QUICK_PROMPTS as quickPrompt (quickPrompt)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => addQuickPrompt(quickPrompt)}
|
||||||
|
class="rounded-full bg-placeholder px-3 py-1 text-xs text-ink hover:bg-line-strong"
|
||||||
|
>
|
||||||
|
{quickPrompt}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="shrink-0 space-y-2">
|
||||||
|
<textarea
|
||||||
|
bind:value={prompt}
|
||||||
|
rows="2"
|
||||||
|
placeholder={mode === 'area'
|
||||||
|
? 'Tell me how to change the selected area...'
|
||||||
|
: 'Tell me how you would like to change your bouquet...'}
|
||||||
|
class="w-full resize-none rounded-[2rem] border border-pill bg-surface px-6 py-3 text-sm outline-none placeholder:text-muted"
|
||||||
|
></textarea>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3 text-xs text-muted">
|
||||||
|
<p>
|
||||||
|
{#if mode === 'area'}
|
||||||
|
Draw over the bouquet, then describe only that selected area.
|
||||||
|
{:else}
|
||||||
|
Prompt applies to the whole generated bouquet.
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{#if selectionPoints.length > 0}
|
||||||
|
<button type="button" class="underline hover:text-ink" onclick={clearSelection}>
|
||||||
|
Clear selection
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="shrink-0 space-y-2">
|
||||||
|
{#if error}
|
||||||
|
<p class="rounded bg-surface/95 px-3 py-2 text-sm text-red-600 ring-1 ring-black/5">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
{:else if editing}
|
||||||
|
<p class="rounded bg-surface/95 px-3 py-2 text-sm text-muted ring-1 ring-black/5">
|
||||||
|
Editing bouquet image...
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="border border-pill px-4 py-3 text-sm text-ink disabled:opacity-50"
|
||||||
|
disabled={!prompt.trim() || editing || continuing}
|
||||||
|
onclick={applyEdit}
|
||||||
|
>
|
||||||
|
{editing ? 'Applying...' : 'Apply edit'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="bg-pill px-4 py-3 text-sm text-surface"
|
||||||
|
disabled={editing || continuing}
|
||||||
|
onclick={continueToResult}
|
||||||
|
>
|
||||||
|
{continuing ? 'Preparing result...' : 'Continue to result'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
@@ -109,7 +109,7 @@
|
|||||||
mock: imageResult.mock
|
mock: imageResult.mock
|
||||||
});
|
});
|
||||||
|
|
||||||
await goto(resolve('/options'));
|
await goto(resolve('/edit'));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
|
|
||||||
|
|||||||
@@ -29,11 +29,8 @@
|
|||||||
|
|
||||||
const artworkTitle = $derived(selectedShopId ? 'Ready to order' : 'Your bouquet');
|
const artworkTitle = $derived(selectedShopId ? 'Ready to order' : 'Your bouquet');
|
||||||
|
|
||||||
const artworkDescription = $derived(
|
const artworkDescription = $derived(floristNote || 'Your selected bouquet design.');
|
||||||
floristNote || 'Your selected bouquet design.'
|
|
||||||
);
|
|
||||||
|
|
||||||
// options Continue(최종 사이즈 확정) 이후 selectedSize가 있을 때만 이미지 표시
|
|
||||||
const bouquetImageSrc = $derived(selectedImage ? toDataUrl(selectedImage) : null);
|
const bouquetImageSrc = $derived(selectedImage ? toDataUrl(selectedImage) : null);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -78,8 +75,7 @@
|
|||||||
try {
|
try {
|
||||||
const job = await fetchJob(jobId);
|
const job = await fetchJob(jobId);
|
||||||
floristNote = job.floristNote ?? '';
|
floristNote = job.floristNote ?? '';
|
||||||
// options에서 S/M/L 선택 후 Continue 한 경우에만 job.selectedSize 존재
|
selectedImage = job.images?.primary ?? null;
|
||||||
selectedImage = job.selectedSize ? (job.images?.[job.selectedSize] ?? null) : null;
|
|
||||||
|
|
||||||
const order = buildFloristOrderMessage({
|
const order = buildFloristOrderMessage({
|
||||||
userInput: { ...sessionUserInput, ...job.userInput },
|
userInput: { ...sessionUserInput, ...job.userInput },
|
||||||
|
|||||||
@@ -88,7 +88,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await goto(resolve('/options'));
|
await goto(resolve('/edit'));
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -121,9 +121,9 @@
|
|||||||
disabled={skipping}
|
disabled={skipping}
|
||||||
onclick={skipWithDummyImages}
|
onclick={skipWithDummyImages}
|
||||||
class="w-full rounded border border-dashed border-subtle/60 px-4 py-2.5 text-xs text-muted hover:border-subtle hover:text-ink disabled:opacity-50"
|
class="w-full rounded border border-dashed border-subtle/60 px-4 py-2.5 text-xs text-muted hover:border-subtle hover:text-ink disabled:opacity-50"
|
||||||
title="AI 생성 없이 더미 이미지로 options로 이동 (개발용)"
|
title="AI 생성 없이 더미 이미지로 edit로 이동 (개발용)"
|
||||||
>
|
>
|
||||||
{skipping ? 'Skipping…' : 'Dev: Skip to options (dummy images)'}
|
{skipping ? 'Skipping…' : 'Dev: Skip to edit (dummy images)'}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,104 +0,0 @@
|
|||||||
<script>
|
|
||||||
import { onMount } from 'svelte';
|
|
||||||
import { goto } from '$app/navigation';
|
|
||||||
import { resolve } from '$app/paths';
|
|
||||||
import Header from '$lib/components/ui/Header.svelte';
|
|
||||||
import Artwork from '$lib/components/ui/Artwork/Artwork.svelte';
|
|
||||||
import OptionsPanel from '$lib/components/ui/options/OptionsPanel.svelte';
|
|
||||||
import { fetchJob, selectOption } from '$lib/flowerFlow/api.js';
|
|
||||||
import { getFlowString } from '$lib/flowerFlow/session.js';
|
|
||||||
|
|
||||||
const jobId = getFlowString('jobId');
|
|
||||||
|
|
||||||
let images = $state({});
|
|
||||||
let recipe = $state(null);
|
|
||||||
let loading = $state(true);
|
|
||||||
let loadingSize = $state(null);
|
|
||||||
let error = $state('');
|
|
||||||
let selectedSize = $state(null);
|
|
||||||
|
|
||||||
const artworkTitle = $derived(recipe?.concept ?? 'Title');
|
|
||||||
|
|
||||||
const artworkDescription = $derived(
|
|
||||||
recipe?.mainFlowers?.length
|
|
||||||
? `${recipe.mainFlowers.join(', ')} · ${recipe.wrapping ?? 'Custom wrap'}`
|
|
||||||
: 'Description Description Description'
|
|
||||||
);
|
|
||||||
|
|
||||||
onMount(async () => {
|
|
||||||
if (!jobId) {
|
|
||||||
// 와이어프레임 확인용: job 없어도 레이아웃은 보여 줌
|
|
||||||
loading = false;
|
|
||||||
error = 'Start from /create and complete the flow to see generated bouquets.';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const job = await fetchJob(jobId);
|
|
||||||
if (!job.images?.M) {
|
|
||||||
await goto(resolve('/generating'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
images = job.images;
|
|
||||||
recipe = job.recipe ?? null;
|
|
||||||
loading = false;
|
|
||||||
} catch {
|
|
||||||
await goto(resolve('/generating'));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
async function handleContinue() {
|
|
||||||
if (!selectedSize) {
|
|
||||||
error = 'Choose a bouquet size to continue.';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!jobId) {
|
|
||||||
error = 'Complete create → upload → message → generating first.';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
loadingSize = selectedSize;
|
|
||||||
error = '';
|
|
||||||
|
|
||||||
try {
|
|
||||||
await selectOption(jobId, /** @type {'S'|'M'|'L'} */ (selectedSize));
|
|
||||||
await goto(resolve('/result'));
|
|
||||||
} catch (err) {
|
|
||||||
error = err instanceof Error ? err.message : 'Selection failed';
|
|
||||||
loadingSize = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="flex h-dvh flex-col overflow-x-hidden bg-surface text-ink lg:h-screen lg:overflow-hidden"
|
|
||||||
>
|
|
||||||
<Header step={5} total={7} />
|
|
||||||
|
|
||||||
<main class="flex min-h-0 flex-1 flex-col lg:flex-row">
|
|
||||||
<Artwork title={artworkTitle} description={artworkDescription} />
|
|
||||||
|
|
||||||
<section class="relative flex min-h-0 flex-1 flex-col pb-[4.75rem] lg:overflow-y-auto lg:pb-0">
|
|
||||||
<OptionsPanel {images} {loading} bind:selectedSize />
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="fixed right-0 bottom-0 left-0 z-20 space-y-2 px-4 pb-5 lg:absolute lg:right-8 lg:bottom-8 lg:left-auto lg:w-72 lg:px-0"
|
|
||||||
>
|
|
||||||
{#if error}
|
|
||||||
<p class="rounded bg-surface/95 px-3 py-2 text-sm text-red-600 ring-1 ring-black/5">
|
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={!selectedSize || Boolean(loadingSize)}
|
|
||||||
onclick={handleContinue}
|
|
||||||
class="w-full bg-pill px-4 py-3 text-sm text-surface disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{loadingSize ? 'Selecting...' : 'Continue to result'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
@@ -11,7 +11,6 @@
|
|||||||
let selectedImage = $state(null);
|
let selectedImage = $state(null);
|
||||||
let floristNote = $state('');
|
let floristNote = $state('');
|
||||||
let recipe = $state(null);
|
let recipe = $state(null);
|
||||||
let selectedSize = $state('');
|
|
||||||
let mock = $state(false);
|
let mock = $state(false);
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
@@ -24,10 +23,9 @@
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const job = await fetchJob(jobId);
|
const job = await fetchJob(jobId);
|
||||||
selectedImage = job.selectedSize ? job.images?.[job.selectedSize] : null;
|
selectedImage = job.images?.primary ?? null;
|
||||||
floristNote = job.floristNote ?? '';
|
floristNote = job.floristNote ?? '';
|
||||||
recipe = job.recipe ?? null;
|
recipe = job.recipe ?? null;
|
||||||
selectedSize = job.selectedSize ?? '';
|
|
||||||
mock = Boolean(job.mock);
|
mock = Boolean(job.mock);
|
||||||
loading = false;
|
loading = false;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -65,11 +63,6 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<div>
|
|
||||||
<h2 class="mb-2 text-lg">Selected size</h2>
|
|
||||||
<p class="text-sm text-muted">{selectedSize || 'Not selected'}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h2 class="mb-2 text-lg">Florist note</h2>
|
<h2 class="mb-2 text-lg">Florist note</h2>
|
||||||
<p class="text-sm leading-relaxed text-muted">{floristNote}</p>
|
<p class="text-sm leading-relaxed text-muted">{floristNote}</p>
|
||||||
|
|||||||
@@ -60,7 +60,6 @@
|
|||||||
imagePrompt: null,
|
imagePrompt: null,
|
||||||
images: null,
|
images: null,
|
||||||
imagesJobId: null,
|
imagesJobId: null,
|
||||||
selectedSize: null,
|
|
||||||
floristNote: null,
|
floristNote: null,
|
||||||
mock: result.mock
|
mock: result.mock
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user