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>
|
||||
let { budget = $bindable(50_000) } = $props();
|
||||
let { budget = $bindable(50000) } = $props();
|
||||
|
||||
const min = 10_000;
|
||||
const max = 150_000;
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
who = $bindable(null),
|
||||
whatFor = $bindable(null),
|
||||
style = $bindable(null),
|
||||
budget = $bindable(50_000)
|
||||
budget = $bindable(50000)
|
||||
} = $props();
|
||||
|
||||
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 {'S' | 'M' | 'L'} size
|
||||
* @param {{ mode: string, prompt: string, selection: Array<{ x: number, y: number }> }} editInstruction
|
||||
*/
|
||||
export async function selectOption(jobId, size) {
|
||||
const response = await fetch('/api/flower-flow/select-option', {
|
||||
export async function editImages(jobId, editInstruction) {
|
||||
const response = await fetch('/api/flower-flow/edit-images', {
|
||||
method: 'POST',
|
||||
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);
|
||||
|
||||
@@ -2,7 +2,6 @@ import { readFileSync, existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { mockGeneratedImage } from '$lib/server/gemini/mock.js';
|
||||
|
||||
/** @typedef {import('../flowerFlow/jobStore.js').BouquetSize} BouquetSize */
|
||||
/** @typedef {import('../flowerFlow/jobStore.js').GeneratedImage} GeneratedImage */
|
||||
|
||||
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로 대체합니다.
|
||||
* @param {BouquetSize} size
|
||||
* @returns {GeneratedImage}
|
||||
*/
|
||||
function loadFixtureImage(size) {
|
||||
export function loadDevBouquetImage() {
|
||||
const baseDir = join(process.cwd(), 'static', 'dev');
|
||||
const extensions = ['.jpg', '.jpeg', '.png', '.webp', '.svg'];
|
||||
const names = ['bouquet', 'bouquet-m'];
|
||||
|
||||
for (const ext of extensions) {
|
||||
const filePath = join(baseDir, `bouquet-${size.toLowerCase()}${ext}`);
|
||||
if (!existsSync(filePath)) continue;
|
||||
for (const name of names) {
|
||||
for (const ext of extensions) {
|
||||
const filePath = join(baseDir, `${name}${ext}`);
|
||||
if (!existsSync(filePath)) continue;
|
||||
|
||||
const mimeType = MIME_BY_EXT[ext] ?? 'application/octet-stream';
|
||||
return {
|
||||
mimeType,
|
||||
base64: readFileSync(filePath).toString('base64')
|
||||
};
|
||||
const mimeType = MIME_BY_EXT[ext] ?? 'application/octet-stream';
|
||||
return {
|
||||
mimeType,
|
||||
base64: readFileSync(filePath).toString('base64')
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return mockGeneratedImage(size);
|
||||
}
|
||||
|
||||
/** @returns {Partial<Record<BouquetSize, GeneratedImage>>} */
|
||||
export function loadDevBouquetImages() {
|
||||
return {
|
||||
S: loadFixtureImage('S'),
|
||||
M: loadFixtureImage('M'),
|
||||
L: loadFixtureImage('L')
|
||||
};
|
||||
return mockGeneratedImage();
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
mockRecipe
|
||||
} from '$lib/server/gemini/mock.js';
|
||||
import { uploadGeneratedImages } from '$lib/server/flowerFlow/imageStorage.js';
|
||||
import { loadDevBouquetImages } from './loadFixtureImages.js';
|
||||
import { loadDevBouquetImage } from './loadFixtureImages.js';
|
||||
|
||||
/** @typedef {'options' | 'result'} DevSeedStage */
|
||||
|
||||
@@ -22,13 +22,13 @@ export async function seedDevJob(userInput, stage = 'result') {
|
||||
const floristNote = stage === 'result' ? mockFloristNote(recipe) : null;
|
||||
|
||||
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, {
|
||||
moodAnalysis,
|
||||
recipe,
|
||||
imagePrompt,
|
||||
images,
|
||||
...(stage === 'result' ? { selectedSize: 'M', floristNote } : {})
|
||||
...(stage === 'result' ? { floristNote } : {})
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -37,7 +37,6 @@ export async function seedDevJob(userInput, stage = 'result') {
|
||||
recipe,
|
||||
imagePrompt,
|
||||
images,
|
||||
selectedSize: stage === 'result' ? 'M' : null,
|
||||
floristNote,
|
||||
mock: true
|
||||
};
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
throwSupabaseError
|
||||
} from '$lib/server/supabase.js';
|
||||
|
||||
/** @typedef {import('./jobStore.js').BouquetSize} BouquetSize */
|
||||
/** @typedef {import('./jobStore.js').GeneratedImage} GeneratedImage */
|
||||
|
||||
const EXTENSION_BY_MIME = {
|
||||
@@ -21,11 +20,11 @@ function extensionForMime(mimeType) {
|
||||
|
||||
/**
|
||||
* @param {string} jobId
|
||||
* @param {BouquetSize} size
|
||||
* @param {GeneratedImage} image
|
||||
* @param {string} [revision='primary']
|
||||
* @returns {Promise<GeneratedImage>}
|
||||
*/
|
||||
export async function uploadGeneratedImage(jobId, size, image) {
|
||||
export async function uploadGeneratedImage(jobId, image, revision = 'primary') {
|
||||
if (!image.base64) {
|
||||
return image;
|
||||
}
|
||||
@@ -33,7 +32,7 @@ export async function uploadGeneratedImage(jobId, size, image) {
|
||||
const supabase = getSupabaseClient();
|
||||
const bucket = getSupabaseStorageBucket();
|
||||
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 { error } = await supabase.storage.from(bucket).upload(path, bytes, {
|
||||
@@ -56,20 +55,12 @@ export async function uploadGeneratedImage(jobId, size, image) {
|
||||
|
||||
/**
|
||||
* @param {string} jobId
|
||||
* @param {Partial<Record<BouquetSize, GeneratedImage>>} images
|
||||
* @returns {Promise<Partial<Record<BouquetSize, GeneratedImage>>>}
|
||||
* @param {GeneratedImage} image
|
||||
* @param {string} [revision]
|
||||
* @returns {Promise<{ primary: GeneratedImage }>}
|
||||
*/
|
||||
export async function uploadGeneratedImages(jobId, images) {
|
||||
/** @type {Partial<Record<BouquetSize, GeneratedImage>>} */
|
||||
const uploaded = {};
|
||||
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;
|
||||
export async function uploadGeneratedImages(jobId, image, revision) {
|
||||
return {
|
||||
primary: await uploadGeneratedImage(jobId, image, revision)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { getSupabaseClient, throwSupabaseError } from '$lib/server/supabase.js';
|
||||
|
||||
/** @typedef {'S' | 'M' | 'L'} BouquetSize */
|
||||
|
||||
/**
|
||||
* @typedef {Object} UserInput
|
||||
* @property {string} [relationship]
|
||||
@@ -50,8 +48,7 @@ import { getSupabaseClient, throwSupabaseError } from '$lib/server/supabase.js';
|
||||
* @property {MoodAnalysis | null} moodAnalysis
|
||||
* @property {BouquetRecipe | null} recipe
|
||||
* @property {string | null} imagePrompt
|
||||
* @property {Partial<Record<BouquetSize, GeneratedImage>>} images
|
||||
* @property {BouquetSize | null} selectedSize
|
||||
* @property {{ primary?: GeneratedImage }} images
|
||||
* @property {string | null} floristNote
|
||||
*/
|
||||
|
||||
@@ -68,7 +65,6 @@ function fromRow(row) {
|
||||
recipe: row.recipe ?? null,
|
||||
imagePrompt: row.image_prompt ?? null,
|
||||
images: row.images ?? {},
|
||||
selectedSize: row.selected_size ?? null,
|
||||
floristNote: row.florist_note ?? null
|
||||
};
|
||||
}
|
||||
@@ -85,7 +81,6 @@ function toRowPatch(patch) {
|
||||
if ('recipe' in patch) row.recipe = patch.recipe;
|
||||
if ('imagePrompt' in patch) row.image_prompt = patch.imagePrompt;
|
||||
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;
|
||||
|
||||
return row;
|
||||
@@ -105,7 +100,6 @@ export async function createJob(userInput = {}) {
|
||||
recipe: null,
|
||||
imagePrompt: null,
|
||||
images: {},
|
||||
selectedSize: null,
|
||||
floristNote: null
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/** @typedef {import('../flowerFlow/jobStore.js').BouquetSize} BouquetSize */
|
||||
/** @typedef {import('../flowerFlow/jobStore.js').GeneratedImage} GeneratedImage */
|
||||
|
||||
import { env } from '$env/dynamic/private';
|
||||
@@ -6,36 +5,6 @@ import { getImageModel, isGeminiConfigured } from './client.js';
|
||||
import { mockGeneratedImage } from './mock.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() {
|
||||
const configured = env.IMAGE_PROVIDER?.trim().toLowerCase();
|
||||
if (configured === 'mock' || configured === 'openai' || configured === 'gemini') {
|
||||
@@ -52,28 +21,27 @@ export function isImageGenerationConfigured() {
|
||||
|
||||
/**
|
||||
* @param {string} basePrompt
|
||||
* @param {BouquetSize} size
|
||||
* @returns {Promise<GeneratedImage>}
|
||||
*/
|
||||
export async function generateBouquetImage(basePrompt, size) {
|
||||
const prompt = `${basePrompt}\n\n${SIZE_CONSTRAINTS}\n\n${SIZE_PROMPTS[size]}`;
|
||||
export async function generateBouquetImage(basePrompt) {
|
||||
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();
|
||||
|
||||
// Explicit mock mode: develop the full flow without spending any image quota.
|
||||
if (provider === 'mock') {
|
||||
return mockGeneratedImage(size);
|
||||
return mockGeneratedImage();
|
||||
}
|
||||
|
||||
if (provider === 'openai') {
|
||||
if (!isOpenAIConfigured()) {
|
||||
return mockGeneratedImage(size);
|
||||
return mockGeneratedImage();
|
||||
}
|
||||
|
||||
return generateOpenAIImage(prompt);
|
||||
}
|
||||
|
||||
if (!isGeminiConfigured()) {
|
||||
return mockGeneratedImage(size);
|
||||
return mockGeneratedImage();
|
||||
}
|
||||
|
||||
const model = getImageModel();
|
||||
@@ -92,18 +60,3 @@ export async function generateBouquetImage(basePrompt, size) {
|
||||
|
||||
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').BouquetRecipe} BouquetRecipe */
|
||||
/** @typedef {import('../flowerFlow/jobStore.js').BouquetSize} BouquetSize */
|
||||
|
||||
/** @returns {MoodAnalysis} */
|
||||
export function mockMoodAnalysis() {
|
||||
@@ -46,11 +45,11 @@ export function mockImagePrompt(recipe) {
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
/** @param {BouquetSize} size */
|
||||
export function mockGeneratedImage(size) {
|
||||
/** @param {string} [label] */
|
||||
export function mockGeneratedImage(label = 'Bouquet') {
|
||||
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"/>
|
||||
<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>
|
||||
</svg>`;
|
||||
|
||||
|
||||
@@ -76,7 +76,7 @@ Rules:
|
||||
- White background, soft natural lighting
|
||||
- Korean florist style
|
||||
- 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`;
|
||||
|
||||
const result = await model.generateContent(prompt);
|
||||
|
||||
@@ -43,7 +43,7 @@ export async function POST({ request }) {
|
||||
moodboard: DEV_MOODBOARD_UPLOAD,
|
||||
sns: DEV_SNS_UPLOAD
|
||||
},
|
||||
...(stage === 'result' ? { selectedSize: 'M', floristNote: seeded.floristNote } : {})
|
||||
...(stage === 'result' ? { floristNote: seeded.floristNote } : {})
|
||||
},
|
||||
// create 폼 초기값 참고용 (relationship/occasion/style/budget만)
|
||||
formDefaults: DEV_USER_INPUT
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { dev } from '$app/environment';
|
||||
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 { uploadGeneratedImages } from '$lib/server/flowerFlow/imageStorage.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 recipe = job.recipe ?? mockRecipe(job.userInput);
|
||||
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 });
|
||||
|
||||
|
||||
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 { 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} */
|
||||
export async function POST({ request }) {
|
||||
try {
|
||||
const body = await readJsonBody(request);
|
||||
const jobId = typeof body.jobId === 'string' ? body.jobId : '';
|
||||
const size = typeof body.size === 'string' ? body.size : '';
|
||||
|
||||
if (!jobId) {
|
||||
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 selectedImage = job.images?.[/** @type {'S'|'M'|'L'} */ (size)];
|
||||
const selectedImage = job.images?.primary;
|
||||
|
||||
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;
|
||||
|
||||
await updateJob(jobId, {
|
||||
selectedSize: /** @type {'S'|'M'|'L'} */ (size),
|
||||
floristNote
|
||||
});
|
||||
await updateJob(jobId, { floristNote });
|
||||
|
||||
return json({
|
||||
jobId,
|
||||
selectedSize: size,
|
||||
selectedImage,
|
||||
floristNote,
|
||||
recipe: job.recipe,
|
||||
@@ -1,7 +1,7 @@
|
||||
import { requireJob, updateJob } from '$lib/server/flowerFlow/jobStore.js';
|
||||
import { buildImagePrompt } from '$lib/server/gemini/text.js';
|
||||
import {
|
||||
generateAllSizeImages,
|
||||
generateBouquetImage,
|
||||
getImageProvider,
|
||||
isImageGenerationConfigured
|
||||
} 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
|
||||
* 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.
|
||||
* @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();
|
||||
|
||||
@@ -30,8 +30,8 @@ function generateForJob(jobId, recipe) {
|
||||
|
||||
const task = (async () => {
|
||||
const imagePrompt = await buildImagePrompt(recipe);
|
||||
const generatedImages = await generateAllSizeImages(imagePrompt);
|
||||
const images = await uploadGeneratedImages(jobId, generatedImages);
|
||||
const generatedImage = await generateBouquetImage(imagePrompt);
|
||||
const images = await uploadGeneratedImages(jobId, generatedImage, `initial-${Date.now()}`);
|
||||
await updateJob(jobId, { imagePrompt, images });
|
||||
return { imagePrompt, images };
|
||||
})().finally(() => {
|
||||
@@ -58,7 +58,7 @@ export async function POST({ request }) {
|
||||
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(
|
||||
`[flower-flow] generate-images job=${jobId.slice(0, 8)} cached (already generated)`
|
||||
);
|
||||
|
||||
@@ -20,7 +20,6 @@ export async function GET({ url }) {
|
||||
recipe: job.recipe,
|
||||
imagePrompt: job.imagePrompt,
|
||||
images: job.images,
|
||||
selectedSize: job.selectedSize,
|
||||
floristNote: job.floristNote,
|
||||
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
|
||||
});
|
||||
|
||||
await goto(resolve('/options'));
|
||||
await goto(resolve('/edit'));
|
||||
} catch (err) {
|
||||
if (!active) return;
|
||||
|
||||
|
||||
@@ -29,11 +29,8 @@
|
||||
|
||||
const artworkTitle = $derived(selectedShopId ? 'Ready to order' : 'Your bouquet');
|
||||
|
||||
const artworkDescription = $derived(
|
||||
floristNote || 'Your selected bouquet design.'
|
||||
);
|
||||
const artworkDescription = $derived(floristNote || 'Your selected bouquet design.');
|
||||
|
||||
// options Continue(최종 사이즈 확정) 이후 selectedSize가 있을 때만 이미지 표시
|
||||
const bouquetImageSrc = $derived(selectedImage ? toDataUrl(selectedImage) : null);
|
||||
|
||||
/**
|
||||
@@ -78,8 +75,7 @@
|
||||
try {
|
||||
const job = await fetchJob(jobId);
|
||||
floristNote = job.floristNote ?? '';
|
||||
// options에서 S/M/L 선택 후 Continue 한 경우에만 job.selectedSize 존재
|
||||
selectedImage = job.selectedSize ? (job.images?.[job.selectedSize] ?? null) : null;
|
||||
selectedImage = job.images?.primary ?? null;
|
||||
|
||||
const order = buildFloristOrderMessage({
|
||||
userInput: { ...sessionUserInput, ...job.userInput },
|
||||
|
||||
@@ -88,7 +88,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
await goto(resolve('/options'));
|
||||
await goto(resolve('/edit'));
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -121,9 +121,9 @@
|
||||
disabled={skipping}
|
||||
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"
|
||||
title="AI 생성 없이 더미 이미지로 options로 이동 (개발용)"
|
||||
title="AI 생성 없이 더미 이미지로 edit로 이동 (개발용)"
|
||||
>
|
||||
{skipping ? 'Skipping…' : 'Dev: Skip to options (dummy images)'}
|
||||
{skipping ? 'Skipping…' : 'Dev: Skip to edit (dummy images)'}
|
||||
</button>
|
||||
{/if}
|
||||
<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 floristNote = $state('');
|
||||
let recipe = $state(null);
|
||||
let selectedSize = $state('');
|
||||
let mock = $state(false);
|
||||
|
||||
onMount(async () => {
|
||||
@@ -24,10 +23,9 @@
|
||||
|
||||
try {
|
||||
const job = await fetchJob(jobId);
|
||||
selectedImage = job.selectedSize ? job.images?.[job.selectedSize] : null;
|
||||
selectedImage = job.images?.primary ?? null;
|
||||
floristNote = job.floristNote ?? '';
|
||||
recipe = job.recipe ?? null;
|
||||
selectedSize = job.selectedSize ?? '';
|
||||
mock = Boolean(job.mock);
|
||||
loading = false;
|
||||
} catch (err) {
|
||||
@@ -65,11 +63,6 @@
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<h2 class="mb-2 text-lg">Florist note</h2>
|
||||
<p class="text-sm leading-relaxed text-muted">{floristNote}</p>
|
||||
|
||||
@@ -60,7 +60,6 @@
|
||||
imagePrompt: null,
|
||||
images: null,
|
||||
imagesJobId: null,
|
||||
selectedSize: null,
|
||||
floristNote: null,
|
||||
mock: result.mock
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user