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:
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
57
src/lib/components/ui/FlowNav.svelte
Normal file
57
src/lib/components/ui/FlowNav.svelte
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
12
src/lib/components/ui/edit/EditComposerBar.svelte
Normal file
12
src/lib/components/ui/edit/EditComposerBar.svelte
Normal 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>
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user