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:
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
21
src/lib/components/ui/FlowContinueBar.svelte
Normal file
21
src/lib/components/ui/FlowContinueBar.svelte
Normal 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>
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
131
src/lib/components/ui/result/BouquetFlowerCard.svelte
Normal file
131
src/lib/components/ui/result/BouquetFlowerCard.svelte
Normal 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>
|
||||
29
src/lib/components/ui/result/BouquetFlowerCarousel.svelte
Normal file
29
src/lib/components/ui/result/BouquetFlowerCarousel.svelte
Normal 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}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
6
src/lib/flowerFlow/bouquetImageFormat.js
Normal file
6
src/lib/flowerFlow/bouquetImageFormat.js
Normal 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.';
|
||||
107
src/lib/flowerFlow/flowerCatalogKo.js
Normal file
107
src/lib/flowerFlow/flowerCatalogKo.js
Normal 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
|
||||
};
|
||||
}
|
||||
470
src/lib/flowerFlow/flowerCatalogLite.js
Normal file
470
src/lib/flowerFlow/flowerCatalogLite.js
Normal 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"
|
||||
}
|
||||
];
|
||||
222
src/lib/flowerFlow/resolveRecipeFlowers.js
Normal file
222
src/lib/flowerFlow/resolveRecipeFlowers.js
Normal 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);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user