refine: redesigned layout, flower cards, edit sync, and bouquet preview framing

* refine: move upload mode toggle to top and compact it

* refine: simplify upload layout and remove editorial numbers

* refine: unify flow continue bar and redesign result page layout

* refine: show bouquet flowers as scrollable cards on result page

* refine: add flip-to-Korean on result flower cards

* refine: improve result rationale and sync recipe on edit

* refine: shorten edit title and align bouquet image framing to 3:4
This commit is contained in:
Chaewon Lee
2026-06-14 17:21:20 +09:00
committed by GitHub
parent 4b27c82036
commit 07e4eeaca3
25 changed files with 1303 additions and 409 deletions

View File

@@ -9,7 +9,7 @@ IMAGE_PROVIDER=openai
OPENAI_API_KEY=your_openai_api_key_here
OPENAI_IMAGE_MODEL=gpt-image-1
# Bouquet preview (generating flow)
OPENAI_IMAGE_SIZE=1024x1024
OPENAI_IMAGE_SIZE=1024x1536
# Flower catalog batch (scripts/generate-flower-catalog.js) — portrait cards
OPENAI_IMAGE_CATALOG_SIZE=1024x1536
OPENAI_IMAGE_CATALOG_QUALITY=low

View File

@@ -33,7 +33,7 @@
<img
src={imageSrc}
alt="Selected bouquet"
class="aspect-[3/4] h-auto w-full object-cover"
class="aspect-[3/4] h-auto w-full object-contain object-center"
/>
</div>
{:else}

View File

@@ -3,6 +3,6 @@
</script>
<div class="w-64 max-w-full flex-none border border-line-strong bg-white px-4 py-3 shadow-sm lg:px-6 lg:py-5">
<h3 class="text-sm font-semibold">{title}</h3>
<p class="mt-2 text-xs leading-snug">{description}</p>
<h3 class="text-sm leading-snug font-semibold">{title}</h3>
<p class="mt-2 text-xs leading-relaxed">{description}</p>
</div>

View File

@@ -0,0 +1,21 @@
<script module>
export const FLOW_CONTINUE_BUTTON =
'w-full px-2 py-3 text-sm whitespace-nowrap text-ink underline-offset-4 hover:underline disabled:opacity-50 lg:w-auto';
</script>
<script>
/**
* Fixed bottom on mobile, bottom-right of the right panel on desktop.
* Pair with a section using `pb-[3.75rem] lg:pb-8` (taller pb on edit if the bar has extra controls).
*/
let { class: klass = '', children } = $props();
</script>
<div
class={[
'fixed right-0 bottom-0 left-0 z-20 space-y-2 bg-placeholder/30 px-4 pb-5 lg:static lg:flex lg:shrink-0 lg:flex-col lg:items-end lg:bg-transparent lg:px-6 lg:pb-0',
klass
]}
>
{@render children()}
</div>

View File

@@ -1,4 +1,5 @@
<script>
import { resolve } from '$app/paths';
import logoUrl from '$lib/assets/logo.svg';
// `step` is 1-based; the matching dot is highlighted as the current step.
@@ -8,10 +9,14 @@
</script>
<header class="flex items-center justify-between border-b border-line px-6 py-5 md:px-10">
<div class="flex items-center gap-3">
<a
href={resolve('/')}
class="flex cursor-pointer items-center gap-3"
aria-label="AI Florist home"
>
<img src={logoUrl} alt="" class="size-7 shrink-0 translate-y-px" aria-hidden="true" />
<span class="text-lg leading-none tracking-wide">AI Florist</span>
</div>
</a>
<div class="flex items-center gap-3 sm:gap-4">
{#each dots as dot (dot)}

View File

@@ -42,7 +42,7 @@
{/if}
</h1>
<p class="text-sm text-muted">
{style ?? ''} | ₩{budget.toLocaleString('ko-KR')}
{style ?? '...'} | ₩{budget.toLocaleString('ko-KR')}
</p>
{/if}
</header>

View File

@@ -0,0 +1,131 @@
<script>
import flowerIconUrl from '$lib/assets/flower.svg';
let {
name,
nameKo,
wordOfFlower,
wordOfFlowerKo,
imageSrc,
role = 'main'
} = $props();
let flipped = $state(false);
const roleLabel = $derived(
role === 'main' ? 'Main' : role === 'greenery' ? 'Greenery' : 'Filler'
);
const roleLabelKo = $derived(
role === 'main' ? '메인' : role === 'greenery' ? '그리너리' : '필러'
);
function toggleFlip() {
flipped = !flipped;
}
</script>
<button
type="button"
class="flip-card h-[16.25rem] w-40 shrink-0 snap-start cursor-pointer border-none bg-transparent p-0 text-left"
aria-label={flipped ? `${nameKo} card, show English` : `${name} card, show Korean`}
onclick={toggleFlip}
>
<div class="flip-card-inner h-full" class:is-flipped={flipped}>
<article
class="flip-card-face flex h-full flex-col overflow-hidden rounded-2xl border border-line-strong bg-white shadow-sm"
>
<div class="flex h-6 shrink-0 items-center gap-1.5 px-3 pt-3">
<img src={flowerIconUrl} alt="" class="size-3.5 shrink-0" aria-hidden="true" />
<span class="text-xs leading-none text-ink">{roleLabel}</span>
</div>
<div class="relative mx-2 mt-2 min-h-0 flex-1">
<img
src={imageSrc}
alt={name}
class="h-full w-full object-contain object-bottom"
loading="lazy"
/>
</div>
<div class="shrink-0 px-3 pb-4 pt-2">
<h3
class="flex min-h-8 items-center justify-center text-center text-sm leading-tight tracking-wide text-ink"
>
<span class="line-clamp-2">{name}</span>
</h3>
<p class="line-clamp-2 text-center text-[0.6875rem] leading-snug text-ink">
{wordOfFlower}
</p>
</div>
</article>
<article
class="flip-card-face flip-card-back flex h-full flex-col overflow-hidden rounded-2xl border border-line-strong bg-white shadow-sm"
aria-hidden={!flipped}
>
<div class="flex h-6 shrink-0 items-center gap-1.5 px-3 pt-3">
<img src={flowerIconUrl} alt="" class="size-3.5 shrink-0" aria-hidden="true" />
<span class="text-xs leading-none text-ink">{roleLabelKo}</span>
</div>
<div class="relative mx-2 mt-2 min-h-0 flex-1">
<img
src={imageSrc}
alt={nameKo}
class="h-full w-full object-contain object-bottom"
loading="lazy"
/>
</div>
<div class="shrink-0 px-3 pb-4 pt-2">
<h3
class="flex min-h-8 items-center justify-center text-center text-sm leading-tight tracking-wide text-ink"
>
<span class="line-clamp-2">{nameKo}</span>
</h3>
<p class="line-clamp-2 text-center text-[0.6875rem] leading-snug text-ink">
{wordOfFlowerKo}
</p>
</div>
</article>
</div>
</button>
<style>
.flip-card {
perspective: 1000px;
}
.flip-card-inner {
position: relative;
width: 100%;
transform-style: preserve-3d;
transition: transform 0.55s cubic-bezier(0.4, 0.2, 0.2, 1);
}
.flip-card-inner.is-flipped {
transform: rotateY(180deg);
}
.flip-card-face {
width: 100%;
height: 100%;
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
}
.flip-card-back {
position: absolute;
inset: 0;
transform: rotateY(180deg);
}
@media (prefers-reduced-motion: reduce) {
.flip-card-inner {
transition: none;
}
}
</style>

View File

@@ -0,0 +1,29 @@
<script>
import BouquetFlowerCard from './BouquetFlowerCard.svelte';
/** @type {{ id: number, name: string, nameKo: string, wordOfFlower: string, wordOfFlowerKo: string, imageSrc: string, role?: 'main' | 'sub' | 'greenery' }[]} */
let { flowers = [] } = $props();
</script>
{#if flowers.length === 0}
<p class="text-sm text-muted">Flower details will appear once the bouquet recipe is ready.</p>
{:else}
<div class="min-h-0 w-full">
<p class="mb-4 text-xs tracking-[0.2em] text-muted uppercase">Flowers in your bouquet</p>
<div
class="flex snap-x snap-mandatory gap-4 overflow-x-auto px-0.5 py-1 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
>
{#each flowers as flower (flower.id)}
<BouquetFlowerCard
name={flower.name}
nameKo={flower.nameKo}
wordOfFlower={flower.wordOfFlower}
wordOfFlowerKo={flower.wordOfFlowerKo}
imageSrc={flower.imageSrc}
role={flower.role ?? 'main'}
/>
{/each}
</div>
</div>
{/if}

View File

@@ -4,11 +4,7 @@
import { hydrateDevUpload } from '$lib/dev/hydrateUpload.js';
import { getFlowObject, isDevSeeded } from '$lib/flowerFlow/session.js';
let {
primaryFile = $bindable(null),
uploadedTiles = $bindable(),
caption = 'build their moodboard!'
} = $props();
let { primaryFile = $bindable(null), uploadedTiles = $bindable() } = $props();
let colorFile = $state(null);
let seasonFile = $state(null);
@@ -58,170 +54,28 @@
</script>
<div class="moodboard min-h-0 w-full flex-1">
<div class="collage">
<span class="mood-number number-color">(01)</span>
<span class="mood-number number-season">(02)</span>
<span class="mood-number number-character">(03)</span>
<span class="mood-number number-location">(04)</span>
<span class="mood-caption">{caption}</span>
<UploadTile
label="Color"
bind:file={colorFile}
class="moodboard-tile tile-color"
/>
<UploadTile
label="Season"
bind:file={seasonFile}
class="moodboard-tile tile-season"
/>
<UploadTile
label="Character"
bind:file={characterFile}
class="moodboard-tile tile-character"
/>
<UploadTile
label="Location"
bind:file={locationFile}
class="moodboard-tile tile-location"
/>
</div>
<UploadTile label="Color" bind:file={colorFile} class="tile tile-color" />
<UploadTile label="Season" bind:file={seasonFile} class="tile tile-season" />
<UploadTile label="Character" bind:file={characterFile} class="tile tile-character" />
<UploadTile label="Location" bind:file={locationFile} class="tile tile-location" />
</div>
<style>
.moodboard {
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
padding: 0.5rem 1.5rem 1rem;
}
.collage {
position: relative;
width: min(100%, 34rem);
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-rows: repeat(2, minmax(0, 1fr));
gap: 1.25rem;
width: 100%;
max-width: 32rem;
height: 100%;
aspect-ratio: 4 / 5.2;
max-height: 44rem;
margin: 0 auto;
padding: 0.75rem 1.5rem 0;
}
.moodboard :global(.moodboard-tile) {
position: absolute;
background: #fff;
box-shadow: 0 10px 24px rgb(56 50 47 / 0.08);
}
/* 01 — top-left portrait */
:global(.tile-color) {
top: 8%;
left: 4%;
width: 30%;
aspect-ratio: 3 / 4;
}
/* 02 — right portrait, dips below the top of 01 */
:global(.tile-season) {
top: 13%;
right: 3%;
width: 29%;
aspect-ratio: 3 / 4;
}
/* 03 — landscape, lower-left */
:global(.tile-character) {
top: 49%;
left: 10%;
width: 36%;
aspect-ratio: 4 / 3;
}
/* 04 — bottom-right portrait */
:global(.tile-location) {
top: 62%;
right: 4%;
width: 29%;
aspect-ratio: 3 / 4;
}
.mood-number,
.mood-caption {
position: absolute;
z-index: 2;
pointer-events: none;
color: var(--color-ink);
}
.mood-number {
font-size: clamp(1rem, 2.2vw, 1.5rem);
line-height: 1;
}
.number-color {
top: 13%;
left: 35%;
}
.number-season {
top: 38%;
left: 60%;
}
.number-character {
top: 73%;
left: 9%;
}
.number-location {
top: 58%;
right: 11%;
}
.mood-caption {
/* horizontally centered over the toggle capsule (the left flex child of
the bottom bar), not the section. Its center sits left of the collage
midpoint because the "Continue" button occupies the bar's right side. */
left: 29%;
top: 84%;
font-size: clamp(0.85rem, 1.7vw, 1.1rem);
transform: translateX(-50%);
white-space: nowrap;
}
@media (max-width: 767px) {
.moodboard {
align-items: flex-start;
padding: 1.5rem 1rem 7rem;
}
.collage {
display: grid;
width: 100%;
aspect-ratio: auto;
min-height: 0;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1.25rem;
}
.moodboard :global(.moodboard-tile) {
position: relative;
inset: auto;
width: 100%;
height: auto;
}
:global(.tile-color),
:global(.tile-season),
:global(.tile-location) {
aspect-ratio: 3 / 4;
}
:global(.tile-character) {
aspect-ratio: 4 / 3;
}
.mood-number,
.mood-caption {
display: none;
}
.moodboard :global(.tile) {
min-height: 0;
height: 100%;
width: 100%;
}
</style>

View File

@@ -4,8 +4,7 @@
import { hydrateDevUpload } from '$lib/dev/hydrateUpload.js';
import { getFlowObject, isDevSeeded } from '$lib/flowerFlow/session.js';
let { primaryFile = $bindable(null), hasImage = $bindable(), caption = 'upload their feed!' } =
$props();
let { primaryFile = $bindable(null), hasImage = $bindable() } = $props();
let firstFile = $state(null);
@@ -36,12 +35,7 @@
</script>
<div class="feed min-h-0 w-full flex-1">
<div class="sns-collage">
<span class="sns-number">(01)</span>
<span class="sns-caption">{caption}</span>
<UploadTile bind:file={firstFile} class="sns-tile" />
</div>
<UploadTile bind:file={firstFile} class="sns-tile" />
</div>
<style>
@@ -49,76 +43,15 @@
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
padding: 0.5rem 1.5rem 1rem;
}
.sns-collage {
position: relative;
width: min(100%, 42rem);
height: 100%;
aspect-ratio: 4 / 5;
max-height: 34rem;
padding: 0.75rem 1.5rem 0;
}
.feed :global(.sns-tile) {
position: absolute;
top: 12%;
left: 50%;
width: 58%;
height: 46%;
background: #fff;
box-shadow: 0 10px 24px rgb(56 50 47 / 0.08);
transform: translateX(-50%);
}
.sns-number,
.sns-caption {
position: absolute;
z-index: 2;
pointer-events: none;
color: var(--color-ink);
}
.sns-number {
top: 6%;
left: 23%;
font-size: clamp(1rem, 2.2vw, 1.5rem);
line-height: 1;
}
.sns-caption {
left: 50%;
bottom: 13%;
font-size: clamp(0.9rem, 1.9vw, 1.25rem);
transform: translateX(-50%);
white-space: nowrap;
}
@media (max-width: 767px) {
.feed {
align-items: flex-start;
padding: 1.5rem 1rem 7rem;
}
.sns-collage {
width: 100%;
aspect-ratio: auto;
min-height: 0;
}
.feed :global(.sns-tile) {
position: relative;
inset: auto;
width: 100%;
height: auto;
aspect-ratio: 4 / 5;
transform: none;
}
.sns-number,
.sns-caption {
display: none;
}
height: 100%;
max-height: 100%;
width: auto;
max-width: min(20rem, 100%);
aspect-ratio: 4 / 5;
}
</style>

View File

@@ -6,7 +6,7 @@ export const DEV_USER_INPUT = {
budget: 50_000
};
export const DEV_CARD_MESSAGE = 'Wishing you the happiest birthday with love always.';
export const DEV_CARD_MESSAGE = 'Wishing you the happiest birthday, with love always.';
/** message Continue 후 userInput.notes 형태 */
export const DEV_USER_INPUT_WITH_NOTES = {

View File

@@ -0,0 +1,6 @@
/** Bouquet preview framing shared by Artwork, edit chat, and image generation prompts. */
export const BOUQUET_IMAGE_ASPECT = '3:4';
export const BOUQUET_IMAGE_ASPECT_PROMPT =
'Vertical portrait composition with a 3:4 aspect ratio (width:height). Frame the full bouquet without cropping stems or wrapping.';

View File

@@ -0,0 +1,107 @@
/** Korean display names and 꽃말 keyed by flower id */
/** @type {Record<number, { nameKo: string, wordOfFlowerKo: string }>} */
export const flowerCatalogKoById = {
1: { nameKo: '수선화', wordOfFlowerKo: '자존' },
2: { nameKo: '스톡', wordOfFlowerKo: '변함없는 아름다움' },
3: { nameKo: '아마릴리스', wordOfFlowerKo: '화려한 미' },
4: { nameKo: '스위트피', wordOfFlowerKo: '행복한 만남' },
5: { nameKo: '안스리움', wordOfFlowerKo: '열정' },
6: { nameKo: '프리지아', wordOfFlowerKo: '순결' },
7: { nameKo: '튤립', wordOfFlowerKo: '자애' },
8: { nameKo: '히아신스', wordOfFlowerKo: '사랑의 기쁨' },
9: { nameKo: '라넌큘러스', wordOfFlowerKo: '매력' },
10: { nameKo: '라일락', wordOfFlowerKo: '첫사랑' },
11: { nameKo: '붓꽃', wordOfFlowerKo: '좋은 소식' },
12: { nameKo: '모란', wordOfFlowerKo: '부귀영화' },
13: { nameKo: '작약', wordOfFlowerKo: '수줍음' },
14: { nameKo: '장미', wordOfFlowerKo: '열정적인 사랑' },
15: { nameKo: '눈꽃동백', wordOfFlowerKo: '우아함' },
16: { nameKo: '카네이션', wordOfFlowerKo: '여성의 사랑' },
17: { nameKo: '클레마티스', wordOfFlowerKo: '고귀함' },
18: { nameKo: '백합', wordOfFlowerKo: '순결' },
19: { nameKo: '수국', wordOfFlowerKo: '변덕' },
20: { nameKo: '아가판서스', wordOfFlowerKo: '연애편지' },
21: { nameKo: '알리움', wordOfFlowerKo: '끝없는 슬픔' },
22: { nameKo: '도라지', wordOfFlowerKo: '우쭐함' },
23: { nameKo: '떡잎', wordOfFlowerKo: '굳건한 애정' },
24: { nameKo: '양귀비', wordOfFlowerKo: '위로' },
25: { nameKo: '다알리아', wordOfFlowerKo: '감사' },
26: { nameKo: '연꽃', wordOfFlowerKo: '순결' },
27: { nameKo: '용담', wordOfFlowerKo: '슬플 때 사랑한다' },
28: { nameKo: '해바라기', wordOfFlowerKo: '숭배' },
29: { nameKo: '국화', wordOfFlowerKo: '순결' },
30: { nameKo: '맨드라미', wordOfFlowerKo: '뜨거운 사랑' },
31: { nameKo: '아네모네', wordOfFlowerKo: '진실' },
32: { nameKo: '코스모스', wordOfFlowerKo: '순수한 마음' },
33: { nameKo: '상사화', wordOfFlowerKo: '다시 만날 수 없는 사랑' },
34: { nameKo: '거베라', wordOfFlowerKo: '신비' },
35: { nameKo: '칼라', wordOfFlowerKo: '기쁨' },
36: { nameKo: '극락조', wordOfFlowerKo: '신비' },
37: { nameKo: '헬레보어', wordOfFlowerKo: '존재의 이유' },
38: { nameKo: '리시안셔스', wordOfFlowerKo: '감사' },
39: { nameKo: '스카비오사', wordOfFlowerKo: '모든 것을 잃음' },
40: { nameKo: '왁스플라워', wordOfFlowerKo: '영원한 사랑' },
41: { nameKo: '카스피아', wordOfFlowerKo: '추억' },
42: { nameKo: '루스커스', wordOfFlowerKo: '인내' },
43: { nameKo: '베로니카', wordOfFlowerKo: '충실' },
44: { nameKo: '솔리더고', wordOfFlowerKo: '격려' },
45: { nameKo: '부바르디아', wordOfFlowerKo: '열정' },
46: { nameKo: '트위디아', wordOfFlowerKo: '나를 믿어줘' },
47: { nameKo: '크라스페디아', wordOfFlowerKo: '건강' },
48: { nameKo: '아마란스', wordOfFlowerKo: '불멸' },
49: { nameKo: '애미', wordOfFlowerKo: '안식처' },
50: { nameKo: '니겔라', wordOfFlowerKo: '혼란' },
51: { nameKo: '브루니아', wordOfFlowerKo: '단결' },
52: { nameKo: '금어초', wordOfFlowerKo: '욕망' },
53: { nameKo: '루핀', wordOfFlowerKo: '삶에 대한 열망' },
54: { nameKo: '글라디올러스', wordOfFlowerKo: '비밀스러운 만남' },
55: { nameKo: '디기탈리스', wordOfFlowerKo: '뜨거운 사랑' },
56: { nameKo: '델피니움', wordOfFlowerKo: '내 마음을 알아줘' },
57: { nameKo: '살비아', wordOfFlowerKo: '타오르는 마음' },
58: { nameKo: '머스카리', wordOfFlowerKo: '실망' },
59: { nameKo: '물망초', wordOfFlowerKo: '진실한 사랑' },
60: { nameKo: '스타티스', wordOfFlowerKo: '영원한 사랑' },
61: { nameKo: '아스틸베', wordOfFlowerKo: '수줍음' },
62: { nameKo: '헬리크리섬', wordOfFlowerKo: '영원히 기억해' },
63: { nameKo: '수레국화', wordOfFlowerKo: '행복' },
64: { nameKo: '꽈리', wordOfFlowerKo: '거짓' },
65: { nameKo: '천일홍', wordOfFlowerKo: '불멸' },
66: { nameKo: '심듀움', wordOfFlowerKo: '희망' },
67: { nameKo: '안개꽃', wordOfFlowerKo: '순수한 기쁨' },
68: { nameKo: '카틀레야', wordOfFlowerKo: '당신은 아름다워요' },
69: { nameKo: '온시디움', wordOfFlowerKo: '순수한 마음' },
70: { nameKo: '덴드로비움', wordOfFlowerKo: '아름다움' },
71: { nameKo: '반다', wordOfFlowerKo: '애정의 표시' },
72: { nameKo: '호랑가시나무', wordOfFlowerKo: '보호' },
73: { nameKo: '남천', wordOfFlowerKo: '변치 않는 사랑' },
74: { nameKo: '고양이버들', wordOfFlowerKo: '자유' },
75: { nameKo: '떡갈고사리', wordOfFlowerKo: '매력' },
76: { nameKo: '목화', wordOfFlowerKo: '어머니의 사랑' },
77: { nameKo: '편백', wordOfFlowerKo: '침착' },
78: { nameKo: '뷰티베리', wordOfFlowerKo: '지혜' },
79: { nameKo: '갈대', wordOfFlowerKo: '신념' },
80: { nameKo: '조', wordOfFlowerKo: '평등' },
81: { nameKo: '민트', wordOfFlowerKo: '덕' },
82: { nameKo: '아스파라거스', wordOfFlowerKo: '변함없음' },
83: { nameKo: '억새', wordOfFlowerKo: '은퇴' },
84: { nameKo: '유칼립트스', wordOfFlowerKo: '추억' },
85: { nameKo: '담쟁이덩굴', wordOfFlowerKo: '굳건한 마음' },
86: { nameKo: '올리브', wordOfFlowerKo: '평화' },
87: { nameKo: '조피아', wordOfFlowerKo: '포옹' },
88: { nameKo: '벚꽃', wordOfFlowerKo: '정신적인 아름다움' },
89: { nameKo: '개나리', wordOfFlowerKo: '희망' },
90: { nameKo: '매화', wordOfFlowerKo: '순수한 마음' },
91: { nameKo: '목련', wordOfFlowerKo: '자연에 대한 사랑' },
92: { nameKo: '홍단', wordOfFlowerKo: '우정' },
93: { nameKo: '모과꽃', wordOfFlowerKo: '신뢰' }
};
/** @param {number} id @param {string} fallbackName @param {string} fallbackWord */
export function getFlowerKo(id, fallbackName, fallbackWord) {
const entry = flowerCatalogKoById[id];
return {
nameKo: entry?.nameKo ?? fallbackName,
wordOfFlowerKo: entry?.wordOfFlowerKo ?? fallbackWord
};
}

View File

@@ -0,0 +1,470 @@
/** Client-safe flower catalog (id, name, wordOfFlower) */
/** @type {{ id: number, name: string, wordOfFlower: string }[]} */
export const flowerCatalogLite = [
{
"id": 1,
"name": "Daffodil",
"wordOfFlower": "self-love"
},
{
"id": 2,
"name": "Stock",
"wordOfFlower": "lasting beauty"
},
{
"id": 3,
"name": "Amaryllis",
"wordOfFlower": "dazzling beauty"
},
{
"id": 4,
"name": "Sweet Pea",
"wordOfFlower": "delicate pleasures"
},
{
"id": 5,
"name": "Anthurium",
"wordOfFlower": "hospitality"
},
{
"id": 6,
"name": "Freesia",
"wordOfFlower": "purity"
},
{
"id": 7,
"name": "Tulip",
"wordOfFlower": "benevolence"
},
{
"id": 8,
"name": "Hyacinth",
"wordOfFlower": "joy of the heart"
},
{
"id": 9,
"name": "Ranunculus",
"wordOfFlower": "radiant charm"
},
{
"id": 10,
"name": "Lilac",
"wordOfFlower": "memories of youth"
},
{
"id": 11,
"name": "Iris",
"wordOfFlower": "good news"
},
{
"id": 12,
"name": "Tree Peony",
"wordOfFlower": "wealth and honor"
},
{
"id": 13,
"name": "Peony",
"wordOfFlower": "shyness"
},
{
"id": 14,
"name": "Rose",
"wordOfFlower": "passionate love"
},
{
"id": 15,
"name": "Snowball Viburnum",
"wordOfFlower": "grace"
},
{
"id": 16,
"name": "Carnation",
"wordOfFlower": "a woman's affection"
},
{
"id": 17,
"name": "Clematis",
"wordOfFlower": "nobility"
},
{
"id": 18,
"name": "Lily",
"wordOfFlower": "purity"
},
{
"id": 19,
"name": "Hydrangea",
"wordOfFlower": "heartlessness"
},
{
"id": 20,
"name": "Agapanthus",
"wordOfFlower": "love letter"
},
{
"id": 21,
"name": "Allium",
"wordOfFlower": "endless sorrow"
},
{
"id": 22,
"name": "Bellflower (Campanula)",
"wordOfFlower": "a coquettish look"
},
{
"id": 23,
"name": "China Aster (Callistephus)",
"wordOfFlower": "trusting love"
},
{
"id": 24,
"name": "Poppy (Papaver)",
"wordOfFlower": "consolation"
},
{
"id": 25,
"name": "Dahlia",
"wordOfFlower": "gratitude"
},
{
"id": 26,
"name": "Lotus",
"wordOfFlower": "purity"
},
{
"id": 27,
"name": "Gentian",
"wordOfFlower": "I love you when you're sad"
},
{
"id": 28,
"name": "Sunflower",
"wordOfFlower": "adoration"
},
{
"id": 29,
"name": "Chrysanthemum",
"wordOfFlower": "purity"
},
{
"id": 30,
"name": "Cockscomb (Celosia)",
"wordOfFlower": "ardent love"
},
{
"id": 31,
"name": "Anemone",
"wordOfFlower": "sincerity"
},
{
"id": 32,
"name": "Cosmos",
"wordOfFlower": "a girl's pure heart"
},
{
"id": 33,
"name": "Red Spider Lily (Lycoris radiata)",
"wordOfFlower": "true love"
},
{
"id": 34,
"name": "Gerbera",
"wordOfFlower": "mystery"
},
{
"id": 35,
"name": "Calla Lily",
"wordOfFlower": "joy"
},
{
"id": 36,
"name": "Bird of Paradise (Strelitzia)",
"wordOfFlower": "mystery"
},
{
"id": 37,
"name": "Hellebore",
"wordOfFlower": "reason for being"
},
{
"id": 38,
"name": "Lisianthus (Eustoma)",
"wordOfFlower": "appreciation"
},
{
"id": 39,
"name": "Scabiosa",
"wordOfFlower": "I have lost all"
},
{
"id": 40,
"name": "Wax Flower (Chamelaucium)",
"wordOfFlower": "lasting love"
},
{
"id": 41,
"name": "Caspia (Limonium)",
"wordOfFlower": "remembrance"
},
{
"id": 42,
"name": "Ruscus",
"wordOfFlower": "endurance"
},
{
"id": 43,
"name": "Veronica (Speedwell)",
"wordOfFlower": "fidelity"
},
{
"id": 44,
"name": "Solidago (Goldenrod)",
"wordOfFlower": "encouragement"
},
{
"id": 45,
"name": "Bouvardia",
"wordOfFlower": "enthusiasm"
},
{
"id": 46,
"name": "Tweedia (Oxypetalum)",
"wordOfFlower": "believe in me"
},
{
"id": 47,
"name": "Craspedia (Billy Balls)",
"wordOfFlower": "good health"
},
{
"id": 48,
"name": "Amaranthus",
"wordOfFlower": "immortality"
},
{
"id": 49,
"name": "Queen Anne's Lace (Ammi)",
"wordOfFlower": "sanctuary"
},
{
"id": 50,
"name": "Nigella (Love-in-a-mist)",
"wordOfFlower": "perplexity"
},
{
"id": 51,
"name": "Brunia",
"wordOfFlower": "unity"
},
{
"id": 52,
"name": "Snapdragon",
"wordOfFlower": "desire"
},
{
"id": 53,
"name": "Lupine",
"wordOfFlower": "lust for life"
},
{
"id": 54,
"name": "Gladiolus",
"wordOfFlower": "secret meeting"
},
{
"id": 55,
"name": "Foxglove (Digitalis)",
"wordOfFlower": "ardent love"
},
{
"id": 56,
"name": "Delphinium",
"wordOfFlower": "understand my heart"
},
{
"id": 57,
"name": "Salvia (Scarlet Sage)",
"wordOfFlower": "burning heart"
},
{
"id": 58,
"name": "Grape Hyacinth (Muscari)",
"wordOfFlower": "disappointment"
},
{
"id": 59,
"name": "Forget-me-not",
"wordOfFlower": "true love"
},
{
"id": 60,
"name": "Statice (Limonium)",
"wordOfFlower": "eternal love"
},
{
"id": 61,
"name": "Astilbe",
"wordOfFlower": "bashfulness"
},
{
"id": 62,
"name": "Strawflower (Helichrysum)",
"wordOfFlower": "always remember"
},
{
"id": 63,
"name": "Cornflower (Centaurea)",
"wordOfFlower": "happiness"
},
{
"id": 64,
"name": "Chinese Lantern (Physalis)",
"wordOfFlower": "falsehood"
},
{
"id": 65,
"name": "Globe Amaranth (Gomphrena)",
"wordOfFlower": "immortality"
},
{
"id": 66,
"name": "Showy Stonecrop (Sedum)",
"wordOfFlower": "hope"
},
{
"id": 67,
"name": "Baby's Breath (Gypsophila)",
"wordOfFlower": "earnest joy"
},
{
"id": 68,
"name": "Cattleya",
"wordOfFlower": "you are a beauty"
},
{
"id": 69,
"name": "Oncidium",
"wordOfFlower": "innocent heart"
},
{
"id": 70,
"name": "Dendrobium",
"wordOfFlower": "a beauty"
},
{
"id": 71,
"name": "Vanda Orchid",
"wordOfFlower": "a token of affection"
},
{
"id": 72,
"name": "Holly",
"wordOfFlower": "protection"
},
{
"id": 73,
"name": "Nandina (Heavenly Bamboo)",
"wordOfFlower": "enduring love"
},
{
"id": 74,
"name": "Pussy Willow (Salix gracilistyla)",
"wordOfFlower": "freedom"
},
{
"id": 75,
"name": "Maidenhair Fern (Adiantum)",
"wordOfFlower": "charm"
},
{
"id": 76,
"name": "Cotton",
"wordOfFlower": "mother's love"
},
{
"id": 77,
"name": "Hosta (Plantain Lily)",
"wordOfFlower": "composure"
},
{
"id": 78,
"name": "Beautyberry (Callicarpa)",
"wordOfFlower": "intelligence"
},
{
"id": 79,
"name": "Reed",
"wordOfFlower": "faith"
},
{
"id": 80,
"name": "Foxtail Millet",
"wordOfFlower": "equality"
},
{
"id": 81,
"name": "Mint",
"wordOfFlower": "virtue"
},
{
"id": 82,
"name": "Asparagus Fern",
"wordOfFlower": "constancy"
},
{
"id": 83,
"name": "Silver Grass (Miscanthus)",
"wordOfFlower": "retirement"
},
{
"id": 84,
"name": "Eucalyptus",
"wordOfFlower": "memories"
},
{
"id": 85,
"name": "Ivy (Hedera)",
"wordOfFlower": "a steadfast heart"
},
{
"id": 86,
"name": "Olive",
"wordOfFlower": "peace"
},
{
"id": 87,
"name": "Mock Orange (Pittosporum tobira)",
"wordOfFlower": "embrace"
},
{
"id": 88,
"name": "Cherry Blossom",
"wordOfFlower": "spiritual beauty"
},
{
"id": 89,
"name": "Forsythia",
"wordOfFlower": "hope"
},
{
"id": 90,
"name": "Plum Blossom (Prunus mume)",
"wordOfFlower": "pure heart"
},
{
"id": 91,
"name": "Magnolia",
"wordOfFlower": "love of nature"
},
{
"id": 92,
"name": "Redbud (Cercis chinensis)",
"wordOfFlower": "friendship"
},
{
"id": 93,
"name": "Flowering Quince (Chaenomeles)",
"wordOfFlower": "trust"
}
];

View File

@@ -0,0 +1,222 @@
import { flowerCatalogLite } from './flowerCatalogLite.js';
import { getFlowerKo } from './flowerCatalogKo.js';
/**
* @typedef {{ id: number, name: string, nameKo: string, wordOfFlower: string, wordOfFlowerKo: string, imageSrc: string, label: string, role: 'main' | 'sub' | 'greenery' }} RecipeFlowerCard
*/
/** @param {string} name */
function normalizeName(name) {
return name
.toLowerCase()
.replace(/[()'".]/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
/** @param {string} name */
function primaryName(name) {
return normalizeName(name.split('(')[0]);
}
/**
* Match a recipe flower string (e.g. "Pink tulip") to a catalog entry.
* @param {string} label
* @returns {(typeof flowerCatalogLite)[number] | null}
*/
function matchCatalogFlower(label) {
const normalized = normalizeName(label);
for (const flower of flowerCatalogLite) {
const catalogPrimary = primaryName(flower.name);
if (
normalized === catalogPrimary ||
normalized.includes(catalogPrimary) ||
catalogPrimary.includes(normalized)
) {
return flower;
}
}
return null;
}
/**
* @param {{ mainFlowers?: string[], subFlowers?: string[], greenery?: string[] } | null | undefined} recipe
* @param {(id: number) => string} getImageSrc
* @returns {RecipeFlowerCard[]}
*/
export function resolveRecipeFlowers(recipe, getImageSrc) {
if (!recipe) return [];
/** @type {RecipeFlowerCard[]} */
const cards = [];
/** @type {Set<number>} */
const seenIds = new Set();
/** @param {string[] | undefined} labels @param {'main' | 'sub' | 'greenery'} role */
const addFlowers = (labels, role) => {
for (const label of labels ?? []) {
if (!label) continue;
const match = matchCatalogFlower(label);
if (!match || seenIds.has(match.id)) continue;
seenIds.add(match.id);
const ko = getFlowerKo(match.id, match.name, match.wordOfFlower);
cards.push({
id: match.id,
name: match.name,
nameKo: ko.nameKo,
wordOfFlower: match.wordOfFlower,
wordOfFlowerKo: ko.wordOfFlowerKo,
label,
role,
imageSrc: getImageSrc(match.id)
});
}
};
addFlowers(recipe.mainFlowers, 'main');
addFlowers(recipe.subFlowers, 'sub');
addFlowers(recipe.greenery, 'greenery');
return cards;
}
/**
* @param {string | null | undefined} text
* @param {number} [maxLength=140]
*/
export function truncateDescription(text, maxLength = 140) {
if (!text?.trim()) return '';
const trimmed = text.trim();
if (trimmed.length <= maxLength) return trimmed;
return `${trimmed.slice(0, maxLength - 1).trimEnd()}`;
}
/**
* @param {string[]} [items]
* @param {number} [limit=2]
*/
function pickKeywords(items, limit = 2) {
if (!items?.length) return '';
return items.filter(Boolean).slice(0, limit).join(', ');
}
/**
* Short mood-led title for the result description card.
* @param {{ moodKeywords?: string[], styleImpression?: string[] } | null | undefined} moodAnalysis
*/
export function buildBriefBouquetTitle(moodAnalysis) {
if (!moodAnalysis) return 'Your bouquet';
const keywords = [
...(moodAnalysis.styleImpression ?? []),
...(moodAnalysis.moodKeywords ?? [])
].filter(Boolean);
if (keywords.length === 0) return 'Your bouquet';
return keywords
.slice(0, 2)
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' & ');
}
/**
* @param {{ notes?: string } | null | undefined} userInput
*/
export function extractCardMessage(userInput) {
const notes = userInput?.notes?.trim();
if (!notes) return '';
const prefix = 'Card message: ';
return notes.startsWith(prefix) ? notes.slice(prefix.length).trim() : notes;
}
/**
* @param {{ mainFlowers?: string[] } | null | undefined} recipe
*/
function getPrimaryFlowerFromRecipe(recipe) {
const label = recipe?.mainFlowers?.[0];
if (!label) return null;
return matchCatalogFlower(label);
}
/**
* Why this bouquet fits — mood from images, message, and main flower language.
* @param {{ moodKeywords?: string[], styleImpression?: string[], colorPalette?: string[] } | null | undefined} moodAnalysis
* @param {{ relationship?: string, notes?: string } | null | undefined} userInput
* @param {{ mainFlowers?: string[] } | null | undefined} recipe
*/
export function buildBouquetRationale(moodAnalysis, userInput, recipe) {
const recipient = userInput?.relationship?.trim();
const subject = recipient ? `${recipient}'s` : 'The';
const cardMessage = extractCardMessage(userInput);
const mainFlower = getPrimaryFlowerFromRecipe(recipe);
const mood = pickKeywords(
[...(moodAnalysis?.moodKeywords ?? []), ...(moodAnalysis?.styleImpression ?? [])],
2
);
const colors = pickKeywords(moodAnalysis?.colorPalette, 2);
/** @type {string[]} */
const parts = [];
if (mood && colors) {
parts.push(`${subject} ${mood} mood and ${colors} tones came through in the moodboard.`);
} else if (mood) {
parts.push(`${subject} ${mood} mood came through in the moodboard.`);
} else if (colors) {
parts.push(`${subject} ${colors} tones came through in the moodboard.`);
} else if (!moodAnalysis) {
parts.push('We shaped this bouquet from the feeling in the images.');
}
if (cardMessage && mainFlower) {
const messageRef =
cardMessage.length <= 40 ? `your message, "${cardMessage}"` : 'your message';
parts.push(
`For ${messageRef}, ${mainFlower.name} (${mainFlower.wordOfFlower}) felt like the right fit.`
);
} else if (cardMessage) {
parts.push('Your message helped guide the flowers we chose.');
} else if (mainFlower) {
parts.push(`${mainFlower.name} (${mainFlower.wordOfFlower}) anchors the bouquet.`);
}
if (parts.length === 0) {
return recipient
? `This bouquet reflects the feeling in ${recipient}'s images.`
: 'This bouquet reflects the feeling in the images.';
}
return parts.join(' ');
}
/**
* @param {{ concept?: string, shape?: string, mainFlowers?: string[], wrapping?: string } | null | undefined} recipe
* @param {number} [maxLength=140]
*/
export function buildBriefBouquetDescription(recipe, maxLength = 140) {
if (!recipe) return 'A bouquet shaped from their mood.';
const mains = recipe.mainFlowers?.slice(0, 2).join(' and ');
const parts = [];
if (recipe.shape) parts.push(recipe.shape);
if (mains) parts.push(`featuring ${mains}`);
if (recipe.wrapping) {
const wrap = recipe.wrapping.split(' with ')[0];
parts.push(`wrapped in ${wrap}`);
}
const text = parts.join(', ');
if (!text) return truncateDescription(recipe.concept, maxLength);
return truncateDescription(text.charAt(0).toUpperCase() + text.slice(1), maxLength);
}

View File

@@ -1,6 +1,7 @@
/** @typedef {import('../flowerFlow/jobStore.js').GeneratedImage} GeneratedImage */
import { env } from '$env/dynamic/private';
import { BOUQUET_IMAGE_ASPECT_PROMPT } from '../../flowerFlow/bouquetImageFormat.js';
import { getImageModel, isGeminiConfigured } from './client.js';
import { mockGeneratedImage } from './mock.js';
import { generateOpenAIImage, isOpenAIConfigured } from '../openai/image.js';
@@ -26,8 +27,8 @@ export function isImageGenerationConfigured() {
*/
export async function generateBouquetImage(basePrompt, options = {}) {
const suffix = options.edit
? 'Generate exactly one edited bouquet image. Show a single bouquet only, centered in frame. Do not show two bouquets, no side-by-side comparison, no before/after layout, and no duplicate arrangements. Keep it realistic, orderable from a real florist, front-facing, and suitable for a customer preview.'
: 'Generate one final bouquet image. Keep it realistic, orderable from a real florist, front-facing, and suitable for a customer preview.';
? `Generate exactly one edited bouquet image. Show a single bouquet only, centered in frame. Do not show two bouquets, no side-by-side comparison, no before/after layout, and no duplicate arrangements. ${BOUQUET_IMAGE_ASPECT_PROMPT} Keep it realistic, orderable from a real florist, front-facing, and suitable for a customer preview.`
: `Generate one final bouquet image. ${BOUQUET_IMAGE_ASPECT_PROMPT} Keep it realistic, orderable from a real florist, front-facing, and suitable for a customer preview.`;
const prompt = `${basePrompt}\n\n${suffix}`;
const provider = getImageProvider();

View File

@@ -21,9 +21,30 @@ export function mockRecipe(userInput = {}) {
? `around ₩${userInput.budget.toLocaleString('en-US')}`
: 'around ₩50,000';
const notes = userInput.notes?.toLowerCase() ?? '';
/** @type {string[]} */
let mainFlowers = ['Pink tulip'];
/** @type {string} */
let concept = 'Soft Romantic Tulip Bouquet';
if (/love|사랑/.test(notes)) {
mainFlowers = ['Red rose'];
concept = 'Romantic Rose Bouquet';
} else if (/thank|grateful|감사/.test(notes)) {
mainFlowers = ['Dahlia'];
concept = 'Grateful Dahlia Bouquet';
} else if (/proud|congratul/.test(notes)) {
mainFlowers = ['Sunflower'];
concept = 'Celebratory Sunflower Bouquet';
} else if (/birthday|happy/.test(notes)) {
mainFlowers = ['Gerbera'];
concept = 'Cheerful Gerbera Bouquet';
}
return {
concept: 'Soft Romantic Tulip Bouquet',
mainFlowers: ['Pink tulip'],
concept,
mainFlowers,
subFlowers: ["Baby's breath", 'Seasonal white flowers'],
greenery: ['Eucalyptus'],
colors: ['pale pink', 'ivory', 'soft green'],
@@ -41,7 +62,8 @@ export function mockImagePrompt(recipe) {
`Use ${recipe.mainFlowers.join(', ')} as the main flower, mixed with ${recipe.subFlowers.join(', ')}, and ${recipe.greenery.join(', ')}.`,
`Use a ${recipe.colors.join(', ')} color palette.`,
`Wrap it with ${recipe.wrapping}.`,
'White background, soft natural lighting, Korean florist style.'
'White background, soft natural lighting, Korean florist style.',
'Vertical portrait composition with a 3:4 aspect ratio (width:height). Frame the full bouquet without cropping.'
].join(' ');
}
@@ -63,3 +85,47 @@ export function mockGeneratedImage(label = 'Bouquet') {
export function mockFloristNote(recipe) {
return `A ${recipe.shape} built around ${recipe.mainFlowers.join(' and ')}, softened with ${recipe.subFlowers.join(', ')} and ${recipe.greenery.join(', ')}. The palette stays ${recipe.colors.join(', ')} with ${recipe.wrapping}. Budget target: ${recipe.budget}.`;
}
/**
* Apply a simple swap edit to the recipe in mock mode (e.g. "change tulip to rose").
* @param {BouquetRecipe} recipe
* @param {string} editPrompt
* @returns {BouquetRecipe}
*/
export function mockApplyRecipeEdit(recipe, editPrompt) {
/** @type {BouquetRecipe} */
const updated = structuredClone(recipe);
const lower = editPrompt.toLowerCase();
const swapMatch =
lower.match(/(?:change|replace|swap)\s+(.+?)\s+(?:to|with|into)\s+(.+)/) ??
lower.match(/(.+?)\s+(?:to|into)\s+(.+)/);
if (!swapMatch) return updated;
const fromToken = swapMatch[1].trim().replace(/[.!?]$/, '');
const toToken = swapMatch[2].trim().replace(/[.!?]$/, '');
if (!fromToken || !toToken) return updated;
/** @param {string[]} labels */
const replaceInList = (labels) =>
labels.map((label) => {
if (!label.toLowerCase().includes(fromToken)) return label;
const colorPrefix = label.match(/^(\w+)\s+/i)?.[1];
const capitalizedTo =
toToken.charAt(0).toUpperCase() + toToken.slice(1).toLowerCase();
if (colorPrefix && !fromToken.includes(' ')) {
return `${colorPrefix} ${capitalizedTo}`;
}
return label.replace(new RegExp(fromToken, 'i'), capitalizedTo);
});
updated.mainFlowers = replaceInList(updated.mainFlowers);
updated.subFlowers = replaceInList(updated.subFlowers);
updated.greenery = replaceInList(updated.greenery);
return updated;
}

View File

@@ -3,6 +3,7 @@
/** @typedef {import('../flowerFlow/jobStore.js').UserInput} UserInput */
import { matchFlowersFromMood } from '../flowerFlow/flowerDB.js';
import { BOUQUET_IMAGE_ASPECT_PROMPT } from '../../flowerFlow/bouquetImageFormat.js';
import { getTextModel, isGeminiConfigured, parseJsonFromText } from './client.js';
import { mockRecipe } from './mock.js';
@@ -54,6 +55,7 @@ Return JSON only:
Rules:
- Use ONLY exact candidate names from the lists above. Do not invent, rename, or substitute flowers.
- If userInput.notes contains a card message, choose mainFlowers whose wordOfFlower (on each candidate) best matches what the card message says. Flower language fit for the message is the top priority for mainFlowers when a card message exists.
- mainFlowers must come from candidates.main only (1-2 items).
- subFlowers must combine candidates.filler and/or candidates.line only (2-4 items total).
- greenery must come from candidates.foliage only (1-2 items).
@@ -90,7 +92,8 @@ Rules:
- White background, soft natural lighting
- Korean florist style
- Describe bouquet composition only (flower types, colors, wrapping, mood)
- Do NOT specify alternate size variants — generate one final customer preview image
- ${BOUQUET_IMAGE_ASPECT_PROMPT}
- Do NOT specify alternate size variants; generate one final customer preview image
- Return plain text only, no markdown`;
const result = await model.generateContent(prompt);
@@ -119,3 +122,46 @@ Return plain text only.`;
const result = await model.generateContent(prompt);
return result.response.text().trim();
}
/**
* Update a bouquet recipe to reflect a customer edit request.
* @param {BouquetRecipe} recipe
* @param {string} editPrompt
* @returns {Promise<BouquetRecipe>}
*/
export async function applyRecipeEdit(recipe, editPrompt) {
if (!isGeminiConfigured()) {
const { mockApplyRecipeEdit } = await import('./mock.js');
return mockApplyRecipeEdit(recipe, editPrompt);
}
const model = getTextModel();
const prompt = `You are a professional florist assistant.
Update this bouquet recipe so it matches the customer's edit request.
Current recipe:
${JSON.stringify(recipe, null, 2)}
Edit request:
${editPrompt}
Return JSON only with the same schema:
{
"concept": string,
"mainFlowers": string[],
"subFlowers": string[],
"greenery": string[],
"colors": string[],
"wrapping": string,
"shape": string,
"budget": string
}
Rules:
- Change only what the edit request implies; keep unrelated fields the same.
- Use realistic florist flower names.
- mainFlowers, subFlowers, and greenery must stay consistent with the edit.`;
const result = await model.generateContent(prompt);
return /** @type {BouquetRecipe} */ (parseJsonFromText(result.response.text()));
}

View File

@@ -27,7 +27,7 @@ export async function generateOpenAIImage(prompt) {
const response = await getOpenAIClient().images.generate({
model: env.OPENAI_IMAGE_MODEL || 'gpt-image-1',
prompt,
size: env.OPENAI_IMAGE_SIZE || '1024x1024',
size: env.OPENAI_IMAGE_SIZE || '1024x1536',
n: 1
});

View File

@@ -5,7 +5,7 @@ import {
getImageProvider,
isImageGenerationConfigured
} from '$lib/server/gemini/image.js';
import { buildImagePrompt } from '$lib/server/gemini/text.js';
import { buildImagePrompt, applyRecipeEdit } from '$lib/server/gemini/text.js';
import { json, readJsonBody, toErrorResponse } from '$lib/server/http.js';
/**
@@ -74,7 +74,8 @@ export async function POST({ request }) {
return json({ error: 'recipe is missing. Run recipe first.', code: 'bad_request' }, 400);
}
const basePrompt = job.imagePrompt ?? (await buildImagePrompt(job.recipe));
const updatedRecipe = await applyRecipeEdit(job.recipe, prompt);
const basePrompt = job.imagePrompt ?? (await buildImagePrompt(updatedRecipe));
const editPrompt = `${basePrompt}\n\n${describeEditInstruction({ mode, prompt, selection })}`;
console.log(
@@ -86,13 +87,19 @@ export async function POST({ request }) {
generatedImage,
`edit-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
);
await updateJob(jobId, { imagePrompt: editPrompt, images, floristNote: null });
await updateJob(jobId, {
recipe: updatedRecipe,
imagePrompt: editPrompt,
images,
floristNote: null
});
console.log(
`[flower-flow] edit-images job=${jobId.slice(0, 8)} OK (mock=${!isImageGenerationConfigured()})`
);
return json({
jobId,
recipe: updatedRecipe,
imagePrompt: editPrompt,
images,
mock: !isImageGenerationConfigured()

View File

@@ -5,6 +5,9 @@
import Header from '$lib/components/ui/Header.svelte';
import Artwork from '$lib/components/ui/Artwork/Artwork.svelte';
import ContextForm from '$lib/components/ui/create/ContextForm.svelte';
import FlowContinueBar, {
FLOW_CONTINUE_BUTTON
} from '$lib/components/ui/FlowContinueBar.svelte';
import {
consumeDevCreateSnapshot,
deleteFlowKey,
@@ -31,7 +34,7 @@
const artworkDescription = $derived(
hasAnySelection
? `${style ?? ''} style · ₩${budget.toLocaleString('ko-KR')} budget`
? `${style ?? '...'} style · ₩${budget.toLocaleString('ko-KR')} budget`
: 'Description Description Description'
);
@@ -86,20 +89,16 @@
<main class="flex min-h-0 flex-1 flex-col lg:flex-row">
<Artwork variant={artworkVariant} title={artworkTitle} description={artworkDescription} />
<section class="relative flex min-h-0 flex-1 flex-col lg:overflow-y-auto">
<ContextForm bind:who bind:whatFor bind:style bind:budget />
<div
class="fixed right-0 bottom-0 left-0 z-20 px-4 pb-5 lg:absolute lg:right-8 lg:bottom-8 lg:left-auto lg:w-72 lg:px-0"
>
<button
type="button"
onclick={handleContinue}
class="w-full bg-pill px-4 py-3 text-sm text-surface"
>
Continue to upload
</button>
<section class="relative flex min-h-0 flex-1 flex-col pb-[3.75rem] lg:overflow-hidden lg:pb-8">
<div class="min-h-0 flex-1 overflow-y-auto">
<ContextForm bind:who bind:whatFor bind:style bind:budget />
</div>
<FlowContinueBar>
<button type="button" onclick={handleContinue} class={FLOW_CONTINUE_BUTTON}>
Continue to upload ->
</button>
</FlowContinueBar>
</section>
</main>
</div>

View File

@@ -3,8 +3,12 @@
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import DescriptionCard from '$lib/components/ui/Artwork/DescriptionCard.svelte';
import FlowContinueBar, {
FLOW_CONTINUE_BUTTON
} from '$lib/components/ui/FlowContinueBar.svelte';
import Header from '$lib/components/ui/Header.svelte';
import { editImages, fetchJob, finalizeJob, toDataUrl } from '$lib/flowerFlow/api.js';
import { buildBriefBouquetTitle } from '$lib/flowerFlow/resolveRecipeFlowers.js';
import { getFlowString, saveFlow } from '$lib/flowerFlow/session.js';
const jobId = getFlowString('jobId');
@@ -23,7 +27,7 @@
let selectionPoints = $state([]);
let initialImage = $state(null);
let generatedImage = $state(null);
let recipe = $state(null);
let moodAnalysis = $state(null);
let editing = $state(false);
let continuing = $state(false);
/** @type {Array<{ id: string, role: 'user' | 'assistant', prompt?: string, mode?: string, status?: 'pending' | 'done' | 'error', afterImage?: { mimeType: string, base64: string } | null, error?: string }>} */
@@ -33,7 +37,7 @@
const imageSrc = $derived(toDataUrl(generatedImage));
const hasAreaSelection = $derived(selectionPoints.length > 2);
const title = $derived(recipe?.concept ?? 'Generated bouquet');
const title = $derived(buildBriefBouquetTitle(moodAnalysis));
const description = $derived.by(() => {
if (hasAreaSelection) {
return 'Your prompt will apply to the marked area only.';
@@ -234,7 +238,7 @@
initialImage = job.images.primary;
generatedImage = job.images.primary;
recipe = job.recipe ?? null;
moodAnalysis = job.moodAnalysis ?? null;
loading = false;
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to load generated bouquet';
@@ -244,19 +248,19 @@
</script>
{#snippet editableImageFrame(image, editable = false)}
<div class="relative w-[42%] overflow-hidden bg-track ring-1 ring-black/5">
<div class="relative w-full max-w-44 sm:max-w-52 overflow-hidden bg-track ring-1 ring-black/5">
{#if image}
<img
src={toDataUrl(image)}
alt="Generated bouquet"
class={[
'aspect-[4/5] w-full object-contain',
'aspect-[3/4] w-full object-contain object-center',
editable && areaSelectionActive ? 'opacity-90' : ''
]}
draggable="false"
/>
{:else}
<div class="aspect-[4/5] w-full bg-placeholder"></div>
<div class="aspect-[3/4] w-full bg-placeholder"></div>
{/if}
{#if editable && image}
@@ -336,13 +340,17 @@
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 items-center justify-center gap-6">
<div class="overflow-hidden bg-track shadow-sm ring-1 ring-black/5">
<div class="w-full max-w-24 overflow-hidden bg-track shadow-sm ring-1 ring-black/5 sm:max-w-28 lg:max-w-75">
{#if loading}
<div class="aspect-[4/5] w-full animate-pulse bg-placeholder"></div>
<div class="aspect-[3/4] w-full animate-pulse bg-placeholder"></div>
{:else if imageSrc}
<img src={imageSrc} alt="Generated bouquet" class="aspect-[4/5] w-full object-cover" />
<img
src={imageSrc}
alt="Generated bouquet"
class="aspect-[3/4] w-full object-contain object-center"
/>
{:else}
<div class="aspect-[4/5] w-full bg-placeholder"></div>
<div class="aspect-[3/4] w-full bg-placeholder"></div>
{/if}
</div>
@@ -350,9 +358,9 @@
</div>
</section>
<section class="relative flex min-h-0 flex-1 flex-col overflow-hidden">
<section class="relative flex min-h-0 flex-1 flex-col overflow-hidden pb-44 lg:pb-8">
<div
class="mx-auto flex min-h-0 w-full max-w-2xl flex-1 flex-col gap-4 px-6 py-5 pb-36 lg:py-6 lg:pb-6"
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>
@@ -415,16 +423,14 @@
</div>
</div>
<div
class="fixed right-0 bottom-0 left-0 z-20 space-y-2 border-t border-line/60 bg-surface/95 px-4 pt-3 pb-5 backdrop-blur-sm lg:static lg:mx-auto lg:w-full lg:max-w-2xl lg:border-t-0 lg:bg-transparent lg:px-6 lg:pt-0 lg:pb-6 lg:backdrop-blur-none"
>
<FlowContinueBar class="lg:mx-auto lg:w-full lg:max-w-2xl">
{#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}
<div class="flex items-center gap-2 rounded-full border border-pill bg-surface py-1.5 pr-1.5 pl-5">
<div class="flex w-full items-center gap-2 rounded-full border border-pill bg-surface py-1.5 pr-1.5 pl-5">
<textarea
bind:value={prompt}
rows="1"
@@ -468,17 +474,15 @@
</button>
</div>
<div class="flex justify-end">
<button
type="button"
disabled={editing || continuing}
onclick={continueToResult}
class="px-2 py-2 text-sm whitespace-nowrap text-ink underline-offset-4 hover:underline disabled:opacity-50"
>
{continuing ? 'Preparing result...' : 'Continue to result ->'}
</button>
</div>
</div>
<button
type="button"
disabled={editing || continuing}
onclick={continueToResult}
class={FLOW_CONTINUE_BUTTON}
>
{continuing ? 'Preparing result...' : 'Continue to result ->'}
</button>
</FlowContinueBar>
</section>
</main>
</div>

View File

@@ -6,6 +6,9 @@
import Header from '$lib/components/ui/Header.svelte';
import Artwork from '$lib/components/ui/Artwork/Artwork.svelte';
import MessageForm from '$lib/components/ui/message/MessageForm.svelte';
import FlowContinueBar, {
FLOW_CONTINUE_BUTTON
} from '$lib/components/ui/FlowContinueBar.svelte';
import { skipDevImages } from '$lib/flowerFlow/devSeed.js';
import {
consumeDevMessageSnapshot,
@@ -106,12 +109,12 @@
<main class="flex min-h-0 flex-1 flex-col lg:flex-row">
<Artwork variant={artworkVariant} title={artworkTitle} description={artworkDescription} />
<section class="relative flex min-h-0 flex-1 flex-col lg:overflow-y-auto">
<MessageForm bind:message />
<section class="relative flex min-h-0 flex-1 flex-col pb-[3.75rem] lg:overflow-hidden lg:pb-8">
<div class="min-h-0 flex-1 overflow-y-auto">
<MessageForm bind:message />
</div>
<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"
>
<FlowContinueBar>
{#if error}
<p class="rounded bg-surface/95 px-3 py-2 text-sm text-red-600 ring-1 ring-black/5">
{error}
@@ -122,20 +125,16 @@
type="button"
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"
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 lg:w-auto"
title="AI 생성 없이 더미 이미지로 edit로 이동 (개발용)"
>
{skipping ? 'Skipping…' : 'Dev: Skip to edit (dummy images)'}
</button>
{/if}
<button
type="button"
onclick={handleContinue}
class="w-full bg-pill px-4 py-3 text-sm text-surface"
>
Continue to generating
<button type="button" onclick={handleContinue} class={FLOW_CONTINUE_BUTTON}>
Continue to generating ->
</button>
</div>
</FlowContinueBar>
</section>
</main>
</div>

View File

@@ -3,16 +3,29 @@
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 BouquetFlowerCarousel from '$lib/components/ui/result/BouquetFlowerCarousel.svelte';
import FlowContinueBar, {
FLOW_CONTINUE_BUTTON
} from '$lib/components/ui/FlowContinueBar.svelte';
import { fetchJob, toDataUrl } from '$lib/flowerFlow/api.js';
import { getFlowerImageSrc } from '$lib/flowerFlow/flowerImagePaths.js';
import { resolveRecipeFlowers, buildBouquetRationale, buildBriefBouquetTitle } from '$lib/flowerFlow/resolveRecipeFlowers.js';
import { getFlowString } from '$lib/flowerFlow/session.js';
let loading = $state(true);
let error = $state('');
let selectedImage = $state(null);
let floristNote = $state('');
let recipe = $state(null);
let moodAnalysis = $state(null);
let userInput = $state(null);
let mock = $state(false);
const artworkTitle = $derived(buildBriefBouquetTitle(moodAnalysis));
const artworkDescription = $derived(buildBouquetRationale(moodAnalysis, userInput, recipe));
const bouquetImageSrc = $derived(selectedImage ? toDataUrl(selectedImage) : null);
const bouquetFlowers = $derived(resolveRecipeFlowers(recipe, getFlowerImageSrc));
onMount(async () => {
const jobId = getFlowString('jobId');
@@ -24,8 +37,9 @@
try {
const job = await fetchJob(jobId);
selectedImage = job.images?.primary ?? null;
floristNote = job.floristNote ?? '';
recipe = job.recipe ?? null;
moodAnalysis = job.moodAnalysis ?? null;
userInput = job.userInput ?? null;
mock = Boolean(job.mock);
loading = false;
} catch (err) {
@@ -35,61 +49,41 @@
});
</script>
<div class="min-h-dvh bg-surface text-ink">
<div
class="flex h-dvh flex-col overflow-x-hidden bg-surface text-ink lg:h-screen lg:overflow-hidden"
>
<Header step={6} total={7} />
<main class="mx-auto max-w-5xl px-6 py-10">
<h1 class="mb-2 text-2xl">Result</h1>
<p class="mb-8 text-sm text-muted">Your selected bouquet and florist note.</p>
<main class="flex min-h-0 flex-1 flex-col lg:flex-row">
<Artwork
variant="generated"
title={artworkTitle}
description={artworkDescription}
imageSrc={bouquetImageSrc}
/>
{#if loading}
<p class="text-sm text-muted">Loading result...</p>
{:else if error}
<p class="text-sm text-red-600">{error}</p>
{:else}
{#if mock}
<p class="mb-4 text-sm text-muted">Running in mock mode (no Gemini API key).</p>
{/if}
<div class="grid gap-8 lg:grid-cols-2">
<div class="aspect-[3/4] overflow-hidden bg-track">
{#if selectedImage}
<img
src={toDataUrl(selectedImage)}
alt="Selected bouquet"
class="h-full w-full object-cover"
/>
{/if}
</div>
<div class="space-y-6">
<div>
<h2 class="mb-2 text-lg">Florist note</h2>
<p class="text-sm leading-relaxed text-muted">{floristNote}</p>
</div>
{#if recipe}
<div>
<h2 class="mb-2 text-lg">Recipe</h2>
<ul class="space-y-1 text-sm text-muted">
<li><strong>Concept:</strong> {recipe.concept}</li>
<li><strong>Main:</strong> {recipe.mainFlowers?.join(', ')}</li>
<li><strong>Sub:</strong> {recipe.subFlowers?.join(', ')}</li>
<li><strong>Greenery:</strong> {recipe.greenery?.join(', ')}</li>
<li><strong>Wrapping:</strong> {recipe.wrapping}</li>
</ul>
</div>
<section class="relative flex min-h-0 flex-1 flex-col pb-[3.75rem] lg:overflow-hidden lg:pb-8">
<div class="flex min-h-0 flex-1 flex-col justify-center overflow-hidden px-6 py-6 lg:px-8 lg:py-8">
{#if loading}
<p class="text-sm text-muted">Loading result...</p>
{:else if error}
<p class="text-sm text-red-600">{error}</p>
{:else}
{#if mock}
<p class="mb-6 text-sm text-muted">Running in mock mode (no Gemini API key).</p>
{/if}
<button
type="button"
class="bg-pill px-4 py-2 text-sm text-surface"
onclick={() => goto(resolve('/map'))}
>
Continue to map
</button>
</div>
<BouquetFlowerCarousel flowers={bouquetFlowers} />
{/if}
</div>
{/if}
{#if !loading && !error}
<FlowContinueBar>
<button type="button" onclick={() => goto(resolve('/map'))} class={FLOW_CONTINUE_BUTTON}>
Continue to map ->
</button>
</FlowContinueBar>
{/if}
</section>
</main>
</div>

View File

@@ -5,6 +5,9 @@
import Artwork from '$lib/components/ui/Artwork/Artwork.svelte';
import MoodboardGrid from '$lib/components/ui/upload/MoodboardGrid.svelte';
import SnsFeedUpload from '$lib/components/ui/upload/SnsFeedUpload.svelte';
import FlowContinueBar, {
FLOW_CONTINUE_BUTTON
} from '$lib/components/ui/FlowContinueBar.svelte';
import { analyzeMood } from '$lib/flowerFlow/api.js';
import {
deleteFlowKey,
@@ -193,25 +196,53 @@
<Artwork variant={artworkVariant} title={artworkTitle} description={artworkDescription} />
<section
class="relative flex min-h-0 flex-1 flex-col pt-6 pb-[4.75rem] lg:grid lg:grid-rows-[minmax(0,1fr)_auto] lg:overflow-hidden lg:pt-8 lg:pb-8"
class="relative flex min-h-0 flex-1 flex-col pt-4 pb-[3.75rem] lg:grid lg:grid-rows-[auto_minmax(0,1fr)_auto] lg:overflow-hidden lg:pt-6 lg:pb-8"
>
<div class="mb-3 flex shrink-0 justify-center px-4 lg:mb-4 lg:px-6">
<div
class="relative grid w-full max-w-[15rem] grid-cols-2 items-center rounded-full bg-white p-1 shadow-md ring-1 ring-black/5"
role="tablist"
aria-label="Upload mode"
>
<span
class="pointer-events-none absolute inset-y-1 left-1 w-[calc(50%-0.25rem)] rounded-full bg-pill transition-transform duration-300 ease-out motion-reduce:transition-none"
style:transform={mode === 'moodboard' ? 'translateX(100%)' : 'translateX(0)'}
aria-hidden="true"
></span>
<button
type="button"
role="tab"
aria-selected={mode === 'sns'}
onclick={() => (mode = 'sns')}
class={[
'relative z-10 w-full rounded-full px-2 py-1.5 text-center text-xs whitespace-nowrap transition-colors',
mode === 'sns' ? 'text-surface' : 'text-muted hover:text-ink'
]}
>
SNS Feed
</button>
<button
type="button"
role="tab"
aria-selected={mode === 'moodboard'}
onclick={() => (mode = 'moodboard')}
class={[
'relative z-10 w-full rounded-full px-2 py-1.5 text-center text-xs whitespace-nowrap transition-colors',
mode === 'moodboard' ? 'text-surface' : 'text-muted hover:text-ink'
]}
>
Moodboard
</button>
</div>
</div>
{#if mode === 'moodboard'}
<MoodboardGrid
bind:primaryFile
bind:uploadedTiles={moodboardTiles}
caption={`build ${recipientPronoun} moodboard!`}
/>
<MoodboardGrid bind:primaryFile bind:uploadedTiles={moodboardTiles} />
{:else}
<SnsFeedUpload
bind:primaryFile
bind:hasImage={snsHasImage}
caption={`upload ${recipientPronoun} feed!`}
/>
<SnsFeedUpload bind:primaryFile bind:hasImage={snsHasImage} />
{/if}
<div
class="fixed right-0 bottom-0 left-0 z-20 space-y-2 px-4 pb-5 lg:static lg:mx-auto lg:flex lg:w-full lg:max-w-2xl lg:items-center lg:gap-3 lg:space-y-0 lg:px-6 lg:pb-0"
>
<FlowContinueBar>
{#if error}
<p class="rounded bg-surface/95 px-3 py-2 text-sm text-red-600 ring-1 ring-black/5">
{error}
@@ -222,42 +253,11 @@
type="button"
disabled={loading}
onclick={continueToMessage}
class="w-full px-2 py-3 text-sm whitespace-nowrap text-ink underline-offset-4 hover:underline disabled:opacity-50 lg:order-2 lg:w-auto"
class={FLOW_CONTINUE_BUTTON}
>
{loading ? 'Analyzing mood...' : 'Continue to message ->'}
</button>
<div
class="relative grid w-full grid-cols-2 items-center rounded-full bg-white p-1.5 shadow-xl ring-1 ring-black/5 lg:order-1 lg:flex-1"
>
<!-- sliding dark thumb: covers one cell, glides to the active one -->
<span
class="pointer-events-none absolute inset-y-1.5 left-1.5 w-[calc(50%-0.375rem)] rounded-full bg-pill transition-transform duration-300 ease-out motion-reduce:transition-none"
style:transform={mode === 'moodboard' ? 'translateX(100%)' : 'translateX(0)'}
aria-hidden="true"
></span>
<button
type="button"
onclick={() => (mode = 'sns')}
class={[
'relative z-10 w-full rounded-full px-3 py-2.5 text-center text-sm whitespace-nowrap transition-colors',
mode === 'sns' ? 'text-surface' : 'text-muted hover:text-ink'
]}
>
Upload SNS Feed
</button>
<button
type="button"
onclick={() => (mode = 'moodboard')}
class={[
'relative z-10 w-full rounded-full px-3 py-2.5 text-center text-sm whitespace-nowrap transition-colors',
mode === 'moodboard' ? 'text-surface' : 'text-muted hover:text-ink'
]}
>
Build Moodboard
</button>
</div>
</div>
</FlowContinueBar>
</section>
</main>
</div>