fix: flow navigation, upload UX, and edit page polish

* chore: reduce flow page heading and input text sizes

* feat: add bouquet image download on result and map pages

* chore: polish edit page chat UI and quick prompts

* perf: run mood analysis in background after upload

* feat: add header FlowNav and persist form state when navigating back
This commit is contained in:
Chaewon Lee
2026-06-16 01:19:15 +09:00
committed by GitHub
parent 0414393be7
commit 71da3f2c17
25 changed files with 507 additions and 196 deletions

View File

@@ -3,6 +3,7 @@
import Vase from './Vase.svelte';
import DescriptionCard from './DescriptionCard.svelte';
import ComingSoonTape from './ComingSoonTape.svelte';
import { downloadGeneratedImage } from '$lib/flowerFlow/downloadGeneratedImage.js';
let {
title = 'Title',
@@ -13,9 +14,29 @@
variant = 'create1',
/** edit Continue 이후 확정된 꽃다발만 전달 (그 전에는 null → Vase) */
imageSrc = null,
/** result/map: raw image payload for download */
downloadImage = null,
/** generating 단계: 작품 중앙 Coming Soon 밴드 */
comingSoon = false
} = $props();
let downloading = $state(false);
let downloadError = $state('');
async function handleDownload() {
if (!downloadImage || downloading) return;
downloading = true;
downloadError = '';
try {
await downloadGeneratedImage(downloadImage, title);
} catch (err) {
downloadError = err instanceof Error ? err.message : 'Download failed';
} finally {
downloading = false;
}
}
</script>
<section
@@ -31,12 +52,27 @@
class="flex h-[11rem] shrink-0 items-end justify-center sm:h-[13rem] lg:h-[min(24rem,36vh)] lg:w-full"
>
{#if imageSrc}
<div class="mx-auto w-full max-w-24 shrink-0 overflow-hidden sm:max-w-28 lg:max-w-75">
<img
src={imageSrc}
alt="Selected bouquet"
class="aspect-[3/4] h-auto w-full object-contain object-center"
/>
<div class="mx-auto flex w-full max-w-24 shrink-0 flex-col items-center sm:max-w-28 lg:max-w-75">
<div class="w-full overflow-hidden">
<img
src={imageSrc}
alt="Selected bouquet"
class="aspect-[3/4] h-auto w-full object-contain object-center"
/>
</div>
{#if downloadImage}
<button
type="button"
disabled={downloading}
onclick={handleDownload}
class="mt-2 text-xs text-muted underline-offset-4 hover:text-ink hover:underline disabled:opacity-50"
>
{downloading ? 'Downloading...' : 'Download image'}
</button>
{#if downloadError}
<p class="mt-1 text-center text-[0.65rem] text-red-600">{downloadError}</p>
{/if}
{/if}
</div>
{:else}
<Vase {variant} />

View File

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

View File

@@ -0,0 +1,57 @@
<script module>
export const FLOW_NAV_LINK =
'text-sm whitespace-nowrap text-ink underline-offset-4 hover:underline disabled:cursor-not-allowed disabled:opacity-50';
</script>
<script>
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
let {
backHref = '',
onBack = undefined,
backLabel = '<- Back',
continueLabel = 'Continue ->',
onContinue = undefined,
continueDisabled = false,
showBack = true,
showContinue = true
} = $props();
function handleBack() {
if (onBack) {
onBack();
return;
}
if (backHref) {
goto(resolve(backHref));
}
}
</script>
<nav
class="flex shrink-0 items-center justify-between border-b border-line px-6 py-2.5 md:px-10"
aria-label="Flow navigation"
>
{#if showBack && (backHref || onBack)}
<button type="button" class={FLOW_NAV_LINK} onclick={handleBack}>
{backLabel}
</button>
{:else}
<span aria-hidden="true"></span>
{/if}
{#if showContinue && onContinue}
<button
type="button"
class={FLOW_NAV_LINK}
disabled={continueDisabled}
onclick={onContinue}
>
{continueLabel}
</button>
{:else}
<span aria-hidden="true"></span>
{/if}
</nav>

View File

@@ -13,7 +13,7 @@
<div class="space-y-4">
<div>
<p class="text-xs tracking-[0.2em] text-muted uppercase">Budget</p>
<p class="mt-2 text-3xl font-semibold tracking-tight">
<p class="mt-2 text-2xl font-semibold tracking-tight">
{budget.toLocaleString('ko-KR')}
</p>
</div>

View File

@@ -19,12 +19,12 @@
<div class="flex flex-1 flex-col justify-center px-6 py-10 md:px-12 lg:px-16 lg:py-16">
<header class="mb-10 space-y-3 lg:mb-14">
{#if !hasAnySelection}
<h1 class="text-3xl leading-relaxed font-light text-muted md:text-4xl lg:text-[2.75rem]">
<h1 class="text-2xl leading-relaxed font-light text-muted md:text-3xl lg:text-[2rem]">
Who are we making flowers for?
</h1>
<p class="text-sm text-muted">Pick a few details below</p>
{:else}
<h1 class="text-3xl leading-tight font-light text-muted md:text-4xl lg:text-[2.75rem]">
<h1 class="text-2xl leading-tight font-light text-muted md:text-3xl lg:text-[2rem]">
{#if whatFor}
A <span class="font-semibold text-ink underline decoration-ink/30 underline-offset-4"
>{whatFor}</span

View File

@@ -0,0 +1,12 @@
<script>
let { class: klass = '', children } = $props();
</script>
<div
class={[
'fixed right-0 bottom-0 left-0 z-20 space-y-1.5 bg-placeholder/30 px-4 pb-5 lg:static lg:mx-auto lg:w-full lg:max-w-2xl lg:bg-transparent lg:px-6 lg:pb-0',
klass
]}
>
{@render children()}
</div>

View File

@@ -8,8 +8,7 @@
error = '',
retryLabel = '',
canRetry = false,
onRetry = () => {},
onBack = () => {}
onRetry = () => {}
} = $props();
/**
@@ -26,7 +25,7 @@
<div class="flex flex-1 flex-col justify-center px-6 py-10 md:px-12 lg:px-16 lg:py-16">
<header class="mb-10 space-y-3 lg:mb-14">
<h1 class="text-3xl leading-relaxed font-light text-muted md:text-4xl lg:text-[2.75rem]">
<h1 class="text-2xl leading-relaxed font-light text-muted md:text-3xl lg:text-[2rem]">
Creating your bouquet...
</h1>
{#if retryLabel}
@@ -51,13 +50,6 @@
Try again
</button>
{/if}
<button
type="button"
class="border border-pill px-4 py-2 text-sm text-ink"
onclick={onBack}
>
Back to message
</button>
</div>
</div>
{/if}

View File

@@ -1,7 +1,7 @@
<script>
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import FlowContinueBar, { FLOW_CONTINUE_BUTTON } from '$lib/components/ui/FlowContinueBar.svelte';
import FlowNav from '$lib/components/ui/FlowNav.svelte';
import GrowthMetaphorIllustration from '$lib/components/ui/landing/GrowthMetaphorIllustration.svelte';
function handleStart() {
@@ -10,31 +10,25 @@
</script>
<section
class="relative flex min-h-dvh flex-col bg-surface px-6 py-8 pb-[3.75rem] font-sans text-ink sm:px-10 sm:py-10 lg:px-14 lg:pb-8"
class="relative flex min-h-dvh flex-col bg-surface font-sans text-ink"
aria-label="Every bouquet starts with a muse — seed to bouquet growth metaphor"
>
<div class="mx-auto flex min-h-0 w-full max-w-6xl flex-1 flex-col justify-center">
<GrowthMetaphorIllustration />
<FlowNav showBack={false} continueLabel="Start Creating ->" onContinue={handleStart} />
<p class="mt-3 text-left text-sm tracking-[0.18em] text-muted sm:mt-4 sm:text-base">
Every Bouquet Starts with a Muse
</p>
<div class="flex flex-1 flex-col px-6 pt-8 pb-8 sm:px-10 sm:pt-10 sm:pb-10 lg:px-14">
<div class="mx-auto flex min-h-0 w-full max-w-6xl flex-1 flex-col justify-center">
<GrowthMetaphorIllustration />
<p class="mt-3 text-left text-sm tracking-[0.18em] text-muted sm:mt-4 sm:text-base">
Every Bouquet Starts with a Muse
</p>
</div>
<div class="mx-auto w-full max-w-6xl pt-10 pb-2">
<p class="text-lg leading-none tracking-wide text-ink">AI Florist</p>
<h1 class="mt-2 text-4xl leading-none font-bold tracking-wide sm:text-5xl lg:text-6xl">
Fleumuse
</h1>
</div>
</div>
<div class="mx-auto w-full max-w-6xl pt-10 pb-2">
<p class="text-lg leading-none tracking-wide text-ink">AI Florist</p>
<h1 class="mt-2 text-4xl leading-none font-bold tracking-wide sm:text-5xl lg:text-6xl">
Fleumuse
</h1>
</div>
<!--
create 등 플로우 페이지 FlowContinueBar와 동일 위치:
mobile — 하단 고정 / desktop — 우측 56% 패널 하단 오른쪽
-->
<FlowContinueBar class="lg:!fixed lg:top-auto lg:right-0 lg:bottom-8 lg:left-[44%]">
<button type="button" onclick={handleStart} class={FLOW_CONTINUE_BUTTON}>
Start Creating ->
</button>
</FlowContinueBar>
</section>

View File

@@ -49,7 +49,7 @@
<div class="flex min-h-0 flex-1 flex-col">
<header class="shrink-0 px-6 py-8 md:px-10 lg:px-12 lg:py-10">
<h1 class="text-3xl leading-relaxed font-light text-muted md:text-4xl lg:text-[2.75rem]">
<h1 class="text-2xl leading-relaxed font-light text-muted md:text-3xl lg:text-[2rem]">
Find a nearby florist
</h1>
<p class="mt-3 text-sm text-muted">Move the map, then refresh to search this area.</p>

View File

@@ -21,7 +21,7 @@
placeholder="Write something from your heart"
rows="1"
aria-label="Write your message"
class="w-full resize-none border-none bg-transparent text-3xl leading-relaxed font-light text-ink placeholder:text-muted focus:outline-none md:text-4xl lg:text-[2.75rem]"
class="w-full resize-none border-none bg-transparent text-2xl leading-relaxed font-light text-ink placeholder:text-muted focus:outline-none md:text-3xl lg:text-[2rem]"
></textarea>
<div class="mt-6 border-b border-pill lg:mt-3"></div>
</header>

View File

@@ -3,13 +3,15 @@
import UploadTile from './UploadTile.svelte';
import { hydrateDevUpload } from '$lib/dev/hydrateUpload.js';
import { getFlowObject, isDevSeeded } from '$lib/flowerFlow/session.js';
import { readMoodboardFiles, writeMoodboardFiles } from '$lib/flowerFlow/uploadDraft.js';
let { primaryFile = $bindable(null), uploadedTiles = $bindable() } = $props();
let colorFile = $state(null);
let seasonFile = $state(null);
let characterFile = $state(null);
let locationFile = $state(null);
const cached = readMoodboardFiles();
let colorFile = $state(cached.color);
let seasonFile = $state(cached.season);
let characterFile = $state(cached.character);
let locationFile = $state(cached.location);
$effect(() => {
const next = colorFile ?? seasonFile ?? characterFile ?? locationFile ?? null;
@@ -34,6 +36,15 @@
}
});
$effect(() => {
writeMoodboardFiles({
color: colorFile,
season: seasonFile,
character: characterFile,
location: locationFile
});
});
onMount(async () => {
const devUpload = getFlowObject('devUpload');
if (!isDevSeeded() || !devUpload?.active) return;
@@ -48,7 +59,7 @@
if (files.character) characterFile = files.character;
if (files.location) locationFile = files.location;
} catch {
// dev seed 실패 시 빈 타일 유지
// dev seed 실패 시 캐시/빈 타일 유지
}
});
</script>

View File

@@ -3,10 +3,11 @@
import UploadTile from './UploadTile.svelte';
import { hydrateDevUpload } from '$lib/dev/hydrateUpload.js';
import { getFlowObject, isDevSeeded } from '$lib/flowerFlow/session.js';
import { readSnsFile, writeSnsFile } from '$lib/flowerFlow/uploadDraft.js';
let { primaryFile = $bindable(null), hasImage = $bindable() } = $props();
let firstFile = $state(null);
let firstFile = $state(readSnsFile());
$effect(() => {
const next = firstFile ?? null;
@@ -18,6 +19,10 @@
if (hasImage !== next) hasImage = next;
});
$effect(() => {
writeSnsFile(firstFile);
});
onMount(async () => {
const devUpload = getFlowObject('devUpload');
if (!isDevSeeded() || !devUpload?.active) return;
@@ -29,7 +34,7 @@
const files = await hydrateDevUpload(/** @type {Record<string, string>} */ (tiles));
if (files.first) firstFile = files.first;
} catch {
// dev seed 실패 시 빈 타일 유지
// dev seed 실패 시 캐시/빈 타일 유지
}
});
</script>

View File

@@ -106,6 +106,33 @@ export async function fetchJob(jobId) {
return parseResponse(response);
}
/**
* Poll until mood analysis is stored on the job.
* @param {string} jobId
* @param {{ intervalMs?: number, timeoutMs?: number, onUpdate?: (job: Awaited<ReturnType<typeof fetchJob>>) => void }} [options]
*/
export async function waitForMoodAnalysis(jobId, options = {}) {
const intervalMs = options.intervalMs ?? 1_000;
const timeoutMs = options.timeoutMs ?? 90_000;
const started = Date.now();
while (Date.now() - started < timeoutMs) {
const job = await fetchJob(jobId);
if (job.moodAnalysis) {
options.onUpdate?.(job);
return job.moodAnalysis;
}
await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
throw new GenerationError('Mood analysis is taking longer than expected. Please try again.', {
code: 'mood_analysis_timeout',
retryable: true
});
}
/**
* @param {{ mimeType?: string, base64?: string, url?: string } | null | undefined} image
*/

View File

@@ -0,0 +1,66 @@
const EXTENSION_BY_MIME = {
'image/jpeg': 'jpg',
'image/png': 'png',
'image/webp': 'webp'
};
/**
* @param {string} [title]
*/
function buildDownloadFilename(title) {
const slug = (title ?? '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
.slice(0, 48);
return slug || 'bouquet';
}
/**
* @param {string} mimeType
*/
function extensionForMime(mimeType) {
return EXTENSION_BY_MIME[mimeType] ?? 'png';
}
/**
* @param {Blob} blob
* @param {string} filename
*/
function triggerDownload(blob, filename) {
const blobUrl = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = blobUrl;
link.download = filename;
link.click();
URL.revokeObjectURL(blobUrl);
}
/**
* @param {{ mimeType?: string, base64?: string, url?: string } | null | undefined} image
* @param {string} [title]
*/
export async function downloadGeneratedImage(image, title) {
if (!image?.base64 && !image?.url) return;
const mimeType = image.mimeType || 'image/png';
const filename = `${buildDownloadFilename(title)}.${extensionForMime(mimeType)}`;
if (image.base64) {
const binary = atob(image.base64);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index);
}
triggerDownload(new Blob([bytes], { type: mimeType }), filename);
return;
}
const response = await fetch(image.url);
if (!response.ok) {
throw new Error('Failed to download bouquet image');
}
triggerDownload(await response.blob(), filename);
}

View File

@@ -61,6 +61,37 @@ export function getFlowUserInput() {
return createOnly;
}
/**
* @returns {{ who: string | null, whatFor: string | null, style: string | null, budget: number }}
*/
export function readCreateFormFromFlow() {
const input = getFlowUserInput();
return {
who: typeof input.relationship === 'string' ? input.relationship : null,
whatFor: typeof input.occasion === 'string' ? input.occasion : null,
style: typeof input.style === 'string' ? input.style : null,
budget: typeof input.budget === 'number' ? input.budget : 50_000
};
}
/**
* @param {{ who: string | null, whatFor: string | null, style: string | null, budget: number }} form
*/
export function saveCreateFormToFlow(form) {
const existing = getFlowObject('userInput') ?? {};
saveFlow({
userInput: {
...existing,
relationship: form.who ?? undefined,
occasion: form.whatFor ?? undefined,
style: form.style ?? undefined,
budget: Number(form.budget)
}
});
}
/**
* Dev Fill 직후 create에 1회만 더미 폼 적용. 없으면 null 반환.
* @returns {{ who: string | null, whatFor: string | null, style: string | null, budget: number } | null}

View File

@@ -0,0 +1,10 @@
export {
clearUploadDraftCache,
readMoodboardFiles,
readPrimaryUploadFile,
readSnsFile,
readUploadDraftMode,
writeMoodboardFiles,
writeSnsFile,
writeUploadDraftMode
} from './uploadDraftCache.js';

View File

@@ -0,0 +1,67 @@
/** @typedef {'moodboard' | 'sns'} UploadMode */
/** @type {UploadMode} */
let mode = 'moodboard';
/** @type {{ color: File | null, season: File | null, character: File | null, location: File | null }} */
let moodboard = {
color: null,
season: null,
character: null,
location: null
};
/** @type {File | null} */
let sns = null;
/** @returns {UploadMode} */
export function readUploadDraftMode() {
return mode;
}
/** @param {UploadMode} next */
export function writeUploadDraftMode(next) {
mode = next;
}
/** @returns {{ color: File | null, season: File | null, character: File | null, location: File | null }} */
export function readMoodboardFiles() {
return {
color: moodboard.color,
season: moodboard.season,
character: moodboard.character,
location: moodboard.location
};
}
/** @param {Record<string, File | null | undefined>} files */
export function writeMoodboardFiles(files) {
moodboard = {
color: files.color ?? null,
season: files.season ?? null,
character: files.character ?? null,
location: files.location ?? null
};
}
/** @returns {File | null} */
export function readSnsFile() {
return sns;
}
/** @param {File | null | undefined} file */
export function writeSnsFile(file) {
sns = file ?? null;
}
/** @returns {File | null} */
export function readPrimaryUploadFile() {
if (mode === 'sns') return sns;
return moodboard.color ?? moodboard.season ?? moodboard.character ?? moodboard.location ?? null;
}
export function clearUploadDraftCache() {
mode = 'moodboard';
moodboard = { color: null, season: null, character: null, location: null };
sns = null;
}