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:
@@ -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
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user