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 Vase from './Vase.svelte';
|
||||||
import DescriptionCard from './DescriptionCard.svelte';
|
import DescriptionCard from './DescriptionCard.svelte';
|
||||||
import ComingSoonTape from './ComingSoonTape.svelte';
|
import ComingSoonTape from './ComingSoonTape.svelte';
|
||||||
|
import { downloadGeneratedImage } from '$lib/flowerFlow/downloadGeneratedImage.js';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
title = 'Title',
|
title = 'Title',
|
||||||
@@ -13,9 +14,29 @@
|
|||||||
variant = 'create1',
|
variant = 'create1',
|
||||||
/** edit Continue 이후 확정된 꽃다발만 전달 (그 전에는 null → Vase) */
|
/** edit Continue 이후 확정된 꽃다발만 전달 (그 전에는 null → Vase) */
|
||||||
imageSrc = null,
|
imageSrc = null,
|
||||||
|
/** result/map: raw image payload for download */
|
||||||
|
downloadImage = null,
|
||||||
/** generating 단계: 작품 중앙 Coming Soon 밴드 */
|
/** generating 단계: 작품 중앙 Coming Soon 밴드 */
|
||||||
comingSoon = false
|
comingSoon = false
|
||||||
} = $props();
|
} = $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>
|
</script>
|
||||||
|
|
||||||
<section
|
<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"
|
class="flex h-[11rem] shrink-0 items-end justify-center sm:h-[13rem] lg:h-[min(24rem,36vh)] lg:w-full"
|
||||||
>
|
>
|
||||||
{#if imageSrc}
|
{#if imageSrc}
|
||||||
<div class="mx-auto w-full max-w-24 shrink-0 overflow-hidden sm:max-w-28 lg:max-w-75">
|
<div class="mx-auto flex w-full max-w-24 shrink-0 flex-col items-center sm:max-w-28 lg:max-w-75">
|
||||||
<img
|
<div class="w-full overflow-hidden">
|
||||||
src={imageSrc}
|
<img
|
||||||
alt="Selected bouquet"
|
src={imageSrc}
|
||||||
class="aspect-[3/4] h-auto w-full object-contain object-center"
|
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>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<Vase {variant} />
|
<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 class="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-xs tracking-[0.2em] text-muted uppercase">Budget</p>
|
<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')}
|
₩{budget.toLocaleString('ko-KR')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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">
|
<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">
|
<header class="mb-10 space-y-3 lg:mb-14">
|
||||||
{#if !hasAnySelection}
|
{#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?
|
Who are we making flowers for?
|
||||||
</h1>
|
</h1>
|
||||||
<p class="text-sm text-muted">Pick a few details below</p>
|
<p class="text-sm text-muted">Pick a few details below</p>
|
||||||
{:else}
|
{: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}
|
{#if whatFor}
|
||||||
A <span class="font-semibold text-ink underline decoration-ink/30 underline-offset-4"
|
A <span class="font-semibold text-ink underline decoration-ink/30 underline-offset-4"
|
||||||
>{whatFor}</span
|
>{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 = '',
|
error = '',
|
||||||
retryLabel = '',
|
retryLabel = '',
|
||||||
canRetry = false,
|
canRetry = false,
|
||||||
onRetry = () => {},
|
onRetry = () => {}
|
||||||
onBack = () => {}
|
|
||||||
} = $props();
|
} = $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">
|
<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">
|
<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...
|
Creating your bouquet...
|
||||||
</h1>
|
</h1>
|
||||||
{#if retryLabel}
|
{#if retryLabel}
|
||||||
@@ -51,13 +50,6 @@
|
|||||||
Try again
|
Try again
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="border border-pill px-4 py-2 text-sm text-ink"
|
|
||||||
onclick={onBack}
|
|
||||||
>
|
|
||||||
Back to message
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script>
|
<script>
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { resolve } from '$app/paths';
|
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';
|
import GrowthMetaphorIllustration from '$lib/components/ui/landing/GrowthMetaphorIllustration.svelte';
|
||||||
|
|
||||||
function handleStart() {
|
function handleStart() {
|
||||||
@@ -10,31 +10,25 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section
|
<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"
|
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">
|
<FlowNav showBack={false} continueLabel="Start Creating ->" onContinue={handleStart} />
|
||||||
<GrowthMetaphorIllustration />
|
|
||||||
|
|
||||||
<p class="mt-3 text-left text-sm tracking-[0.18em] text-muted sm:mt-4 sm:text-base">
|
<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">
|
||||||
Every Bouquet Starts with a Muse
|
<div class="mx-auto flex min-h-0 w-full max-w-6xl flex-1 flex-col justify-center">
|
||||||
</p>
|
<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>
|
||||||
|
|
||||||
<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>
|
</section>
|
||||||
|
|||||||
@@ -49,7 +49,7 @@
|
|||||||
|
|
||||||
<div class="flex min-h-0 flex-1 flex-col">
|
<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">
|
<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
|
Find a nearby florist
|
||||||
</h1>
|
</h1>
|
||||||
<p class="mt-3 text-sm text-muted">Move the map, then refresh to search this area.</p>
|
<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"
|
placeholder="Write something from your heart"
|
||||||
rows="1"
|
rows="1"
|
||||||
aria-label="Write your message"
|
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>
|
></textarea>
|
||||||
<div class="mt-6 border-b border-pill lg:mt-3"></div>
|
<div class="mt-6 border-b border-pill lg:mt-3"></div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -3,13 +3,15 @@
|
|||||||
import UploadTile from './UploadTile.svelte';
|
import UploadTile from './UploadTile.svelte';
|
||||||
import { hydrateDevUpload } from '$lib/dev/hydrateUpload.js';
|
import { hydrateDevUpload } from '$lib/dev/hydrateUpload.js';
|
||||||
import { getFlowObject, isDevSeeded } from '$lib/flowerFlow/session.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 { primaryFile = $bindable(null), uploadedTiles = $bindable() } = $props();
|
||||||
|
|
||||||
let colorFile = $state(null);
|
const cached = readMoodboardFiles();
|
||||||
let seasonFile = $state(null);
|
let colorFile = $state(cached.color);
|
||||||
let characterFile = $state(null);
|
let seasonFile = $state(cached.season);
|
||||||
let locationFile = $state(null);
|
let characterFile = $state(cached.character);
|
||||||
|
let locationFile = $state(cached.location);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const next = colorFile ?? seasonFile ?? characterFile ?? locationFile ?? null;
|
const next = colorFile ?? seasonFile ?? characterFile ?? locationFile ?? null;
|
||||||
@@ -34,6 +36,15 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
writeMoodboardFiles({
|
||||||
|
color: colorFile,
|
||||||
|
season: seasonFile,
|
||||||
|
character: characterFile,
|
||||||
|
location: locationFile
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
const devUpload = getFlowObject('devUpload');
|
const devUpload = getFlowObject('devUpload');
|
||||||
if (!isDevSeeded() || !devUpload?.active) return;
|
if (!isDevSeeded() || !devUpload?.active) return;
|
||||||
@@ -48,7 +59,7 @@
|
|||||||
if (files.character) characterFile = files.character;
|
if (files.character) characterFile = files.character;
|
||||||
if (files.location) locationFile = files.location;
|
if (files.location) locationFile = files.location;
|
||||||
} catch {
|
} catch {
|
||||||
// dev seed 실패 시 빈 타일 유지
|
// dev seed 실패 시 캐시/빈 타일 유지
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -3,10 +3,11 @@
|
|||||||
import UploadTile from './UploadTile.svelte';
|
import UploadTile from './UploadTile.svelte';
|
||||||
import { hydrateDevUpload } from '$lib/dev/hydrateUpload.js';
|
import { hydrateDevUpload } from '$lib/dev/hydrateUpload.js';
|
||||||
import { getFlowObject, isDevSeeded } from '$lib/flowerFlow/session.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 { primaryFile = $bindable(null), hasImage = $bindable() } = $props();
|
||||||
|
|
||||||
let firstFile = $state(null);
|
let firstFile = $state(readSnsFile());
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const next = firstFile ?? null;
|
const next = firstFile ?? null;
|
||||||
@@ -18,6 +19,10 @@
|
|||||||
if (hasImage !== next) hasImage = next;
|
if (hasImage !== next) hasImage = next;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
writeSnsFile(firstFile);
|
||||||
|
});
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
const devUpload = getFlowObject('devUpload');
|
const devUpload = getFlowObject('devUpload');
|
||||||
if (!isDevSeeded() || !devUpload?.active) return;
|
if (!isDevSeeded() || !devUpload?.active) return;
|
||||||
@@ -29,7 +34,7 @@
|
|||||||
const files = await hydrateDevUpload(/** @type {Record<string, string>} */ (tiles));
|
const files = await hydrateDevUpload(/** @type {Record<string, string>} */ (tiles));
|
||||||
if (files.first) firstFile = files.first;
|
if (files.first) firstFile = files.first;
|
||||||
} catch {
|
} catch {
|
||||||
// dev seed 실패 시 빈 타일 유지
|
// dev seed 실패 시 캐시/빈 타일 유지
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -106,6 +106,33 @@ export async function fetchJob(jobId) {
|
|||||||
return parseResponse(response);
|
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
|
* @param {{ mimeType?: string, base64?: string, url?: string } | null | undefined} image
|
||||||
*/
|
*/
|
||||||
|
|||||||
66
src/lib/flowerFlow/downloadGeneratedImage.js
Normal file
66
src/lib/flowerFlow/downloadGeneratedImage.js
Normal 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);
|
||||||
|
}
|
||||||
@@ -61,6 +61,37 @@ export function getFlowUserInput() {
|
|||||||
return createOnly;
|
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 반환.
|
* Dev Fill 직후 create에 1회만 더미 폼 적용. 없으면 null 반환.
|
||||||
* @returns {{ who: string | null, whatFor: string | null, style: string | null, budget: number } | null}
|
* @returns {{ who: string | null, whatFor: string | null, style: string | null, budget: number } | null}
|
||||||
|
|||||||
10
src/lib/flowerFlow/uploadDraft.js
Normal file
10
src/lib/flowerFlow/uploadDraft.js
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
export {
|
||||||
|
clearUploadDraftCache,
|
||||||
|
readMoodboardFiles,
|
||||||
|
readPrimaryUploadFile,
|
||||||
|
readSnsFile,
|
||||||
|
readUploadDraftMode,
|
||||||
|
writeMoodboardFiles,
|
||||||
|
writeSnsFile,
|
||||||
|
writeUploadDraftMode
|
||||||
|
} from './uploadDraftCache.js';
|
||||||
67
src/lib/flowerFlow/uploadDraftCache.js
Normal file
67
src/lib/flowerFlow/uploadDraftCache.js
Normal 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;
|
||||||
|
}
|
||||||
@@ -5,6 +5,21 @@ import { RATE_LIMITS } from '$lib/server/rateLimit.js';
|
|||||||
import { MAX_MOOD_IMAGE_BYTES, MAX_MOOD_IMAGE_LABEL } from '$lib/server/uploadLimits.js';
|
import { MAX_MOOD_IMAGE_BYTES, MAX_MOOD_IMAGE_LABEL } from '$lib/server/uploadLimits.js';
|
||||||
import { enforceRateLimit, json, readUserInput, toErrorResponse } from '$lib/server/http.js';
|
import { enforceRateLimit, json, readUserInput, toErrorResponse } from '$lib/server/http.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} jobId
|
||||||
|
* @param {Uint8Array} imageBytes
|
||||||
|
* @param {string} mimeType
|
||||||
|
* @param {Record<string, unknown>} userInput
|
||||||
|
*/
|
||||||
|
async function runMoodAnalysis(jobId, imageBytes, mimeType, userInput) {
|
||||||
|
try {
|
||||||
|
const moodAnalysis = await analyzeImageMood(imageBytes, mimeType, userInput);
|
||||||
|
await updateJob(jobId, { moodAnalysis });
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Background mood analysis failed for job ${jobId}`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** @type {import('./$types').RequestHandler} */
|
/** @type {import('./$types').RequestHandler} */
|
||||||
export async function POST({ request, getClientAddress }) {
|
export async function POST({ request, getClientAddress }) {
|
||||||
try {
|
try {
|
||||||
@@ -31,13 +46,14 @@ export async function POST({ request, getClientAddress }) {
|
|||||||
const userInput = readUserInput(formData);
|
const userInput = readUserInput(formData);
|
||||||
const job = await createJob(userInput);
|
const job = await createJob(userInput);
|
||||||
const imageBytes = new Uint8Array(await image.arrayBuffer());
|
const imageBytes = new Uint8Array(await image.arrayBuffer());
|
||||||
const moodAnalysis = await analyzeImageMood(imageBytes, image.type || 'image/jpeg', userInput);
|
const mimeType = image.type || 'image/jpeg';
|
||||||
|
|
||||||
await updateJob(job.id, { moodAnalysis });
|
void runMoodAnalysis(job.id, imageBytes, mimeType, userInput);
|
||||||
|
|
||||||
return json({
|
return json({
|
||||||
jobId: job.id,
|
jobId: job.id,
|
||||||
moodAnalysis,
|
moodAnalysis: null,
|
||||||
|
pending: true,
|
||||||
mock: !isGeminiConfigured()
|
mock: !isGeminiConfigured()
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -5,21 +5,24 @@
|
|||||||
import Header from '$lib/components/ui/Header.svelte';
|
import Header from '$lib/components/ui/Header.svelte';
|
||||||
import Artwork from '$lib/components/ui/Artwork/Artwork.svelte';
|
import Artwork from '$lib/components/ui/Artwork/Artwork.svelte';
|
||||||
import ContextForm from '$lib/components/ui/create/ContextForm.svelte';
|
import ContextForm from '$lib/components/ui/create/ContextForm.svelte';
|
||||||
import FlowContinueBar, { FLOW_CONTINUE_BUTTON } from '$lib/components/ui/FlowContinueBar.svelte';
|
import FlowNav from '$lib/components/ui/FlowNav.svelte';
|
||||||
import {
|
import {
|
||||||
consumeDevCreateSnapshot,
|
consumeDevCreateSnapshot,
|
||||||
deleteFlowKey,
|
deleteFlowKey,
|
||||||
getFlowObject,
|
getFlowObject,
|
||||||
isDevSeeded,
|
isDevSeeded,
|
||||||
|
readCreateFormFromFlow,
|
||||||
|
saveCreateFormToFlow,
|
||||||
saveFlow
|
saveFlow
|
||||||
} from '$lib/flowerFlow/session.js';
|
} from '$lib/flowerFlow/session.js';
|
||||||
import { ARTWORK_CARD_DEFAULTS } from '$lib/flowerFlow/artworkCardCopy.js';
|
import { ARTWORK_CARD_DEFAULTS } from '$lib/flowerFlow/artworkCardCopy.js';
|
||||||
|
|
||||||
// 항상 빈 폼으로 시작 — Dev Fill은 onMount에서 1회만 스냅샷 적용
|
// sessionStorage에 저장된 값으로 시작 — Dev Fill은 onMount에서 1회 덮어씀
|
||||||
let who = $state(null);
|
const initialForm = readCreateFormFromFlow();
|
||||||
let whatFor = $state(null);
|
let who = $state(initialForm.who);
|
||||||
let style = $state(null);
|
let whatFor = $state(initialForm.whatFor);
|
||||||
let budget = $state(50_000);
|
let style = $state(initialForm.style);
|
||||||
|
let budget = $state(initialForm.budget);
|
||||||
|
|
||||||
const hasAnySelection = $derived(who !== null || whatFor !== null || style !== null);
|
const hasAnySelection = $derived(who !== null || whatFor !== null || style !== null);
|
||||||
|
|
||||||
@@ -60,6 +63,10 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
saveCreateFormToFlow({ who, whatFor, style, budget });
|
||||||
|
});
|
||||||
|
|
||||||
function handleContinue() {
|
function handleContinue() {
|
||||||
deleteFlowKey('devUpload');
|
deleteFlowKey('devUpload');
|
||||||
deleteFlowKey('devSeeded');
|
deleteFlowKey('devSeeded');
|
||||||
@@ -86,6 +93,7 @@
|
|||||||
class="flex h-dvh flex-col overflow-x-hidden bg-surface text-ink lg:h-screen lg:overflow-hidden"
|
class="flex h-dvh flex-col overflow-x-hidden bg-surface text-ink lg:h-screen lg:overflow-hidden"
|
||||||
>
|
>
|
||||||
<Header step={1} total={7} />
|
<Header step={1} total={7} />
|
||||||
|
<FlowNav backHref="/" onContinue={handleContinue} />
|
||||||
|
|
||||||
<main class="flex min-h-0 flex-1 flex-col lg:flex-row">
|
<main class="flex min-h-0 flex-1 flex-col lg:flex-row">
|
||||||
<Artwork
|
<Artwork
|
||||||
@@ -95,16 +103,10 @@
|
|||||||
cardMode={artworkCardMode}
|
cardMode={artworkCardMode}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<section class="relative flex min-h-0 flex-1 flex-col pb-[3.75rem] lg:overflow-hidden lg:pb-8">
|
<section class="relative flex min-h-0 flex-1 flex-col lg:overflow-hidden lg:pb-8">
|
||||||
<div class="min-h-0 flex-1 overflow-y-auto">
|
<div class="min-h-0 flex-1 overflow-y-auto">
|
||||||
<ContextForm bind:who bind:whatFor bind:style bind:budget />
|
<ContextForm bind:who bind:whatFor bind:style bind:budget />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FlowContinueBar>
|
|
||||||
<button type="button" onclick={handleContinue} class={FLOW_CONTINUE_BUTTON}>
|
|
||||||
Continue to upload ->
|
|
||||||
</button>
|
|
||||||
</FlowContinueBar>
|
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,19 +3,15 @@
|
|||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
import DescriptionCard from '$lib/components/ui/Artwork/DescriptionCard.svelte';
|
import DescriptionCard from '$lib/components/ui/Artwork/DescriptionCard.svelte';
|
||||||
import FlowContinueBar, { FLOW_CONTINUE_BUTTON } from '$lib/components/ui/FlowContinueBar.svelte';
|
import FlowNav from '$lib/components/ui/FlowNav.svelte';
|
||||||
|
import EditComposerBar from '$lib/components/ui/edit/EditComposerBar.svelte';
|
||||||
import Header from '$lib/components/ui/Header.svelte';
|
import Header from '$lib/components/ui/Header.svelte';
|
||||||
import { editImages, fetchJob, toDataUrl } from '$lib/flowerFlow/api.js';
|
import { editImages, fetchJob, toDataUrl } from '$lib/flowerFlow/api.js';
|
||||||
import { buildBriefBouquetTitle } from '$lib/flowerFlow/resolveRecipeFlowers.js';
|
import { buildBriefBouquetTitle } from '$lib/flowerFlow/resolveRecipeFlowers.js';
|
||||||
import { getFlowString, saveFlow } from '$lib/flowerFlow/session.js';
|
import { getFlowString, saveFlow } from '$lib/flowerFlow/session.js';
|
||||||
|
|
||||||
const jobId = getFlowString('jobId');
|
const jobId = getFlowString('jobId');
|
||||||
const QUICK_PROMPTS = [
|
const QUICK_PROMPTS = ['Make it more romantic', 'Use warmer colors', 'Add more volume'];
|
||||||
'Make it more romantic',
|
|
||||||
'Use warmer colors',
|
|
||||||
'Add more volume',
|
|
||||||
'Keep the same flowers'
|
|
||||||
];
|
|
||||||
|
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
@@ -36,15 +32,17 @@
|
|||||||
const hasAreaSelection = $derived(selectionPoints.length > 2);
|
const hasAreaSelection = $derived(selectionPoints.length > 2);
|
||||||
const title = $derived(buildBriefBouquetTitle(moodAnalysis));
|
const title = $derived(buildBriefBouquetTitle(moodAnalysis));
|
||||||
const description = $derived.by(() => {
|
const description = $derived.by(() => {
|
||||||
|
const intro = 'Tell us how you want to refine it.';
|
||||||
|
|
||||||
if (hasAreaSelection) {
|
if (hasAreaSelection) {
|
||||||
return 'Your prompt will apply to the marked area only.';
|
return `${intro} Your prompt will apply to the marked area only.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (areaSelectionActive) {
|
if (areaSelectionActive) {
|
||||||
return 'Use the pencil to draw a red outline, then describe that area.';
|
return `${intro} Use the pencil to draw a red outline, then describe that area.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return 'Tap the pencil on the image to mark an area, or edit the whole bouquet.';
|
return `${intro} Tap the pencil on the image to mark an area, or edit the whole bouquet.`;
|
||||||
});
|
});
|
||||||
const selectionPolyline = $derived(
|
const selectionPolyline = $derived(
|
||||||
selectionPoints.map((point) => `${point.x},${point.y}`).join(' ')
|
selectionPoints.map((point) => `${point.x},${point.y}`).join(' ')
|
||||||
@@ -212,7 +210,6 @@
|
|||||||
chatMessages = chatMessages.map((entry) =>
|
chatMessages = chatMessages.map((entry) =>
|
||||||
entry.id === assistantMessageId ? { ...entry, status: 'error', error: message } : entry
|
entry.id === assistantMessageId ? { ...entry, status: 'error', error: message } : entry
|
||||||
);
|
);
|
||||||
error = message;
|
|
||||||
} finally {
|
} finally {
|
||||||
editing = false;
|
editing = false;
|
||||||
}
|
}
|
||||||
@@ -338,6 +335,11 @@
|
|||||||
class="flex h-dvh flex-col overflow-x-hidden bg-surface text-ink lg:h-screen lg:overflow-hidden"
|
class="flex h-dvh flex-col overflow-x-hidden bg-surface text-ink lg:h-screen lg:overflow-hidden"
|
||||||
>
|
>
|
||||||
<Header step={5} total={7} />
|
<Header step={5} total={7} />
|
||||||
|
<FlowNav
|
||||||
|
backHref="/message"
|
||||||
|
onContinue={continueToResult}
|
||||||
|
continueDisabled={editing}
|
||||||
|
/>
|
||||||
|
|
||||||
<main class="flex min-h-0 flex-1 flex-col lg:flex-row">
|
<main class="flex min-h-0 flex-1 flex-col lg:flex-row">
|
||||||
<section
|
<section
|
||||||
@@ -368,22 +370,17 @@
|
|||||||
|
|
||||||
<section class="relative flex min-h-0 flex-1 flex-col overflow-hidden pb-44 lg:pb-8">
|
<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 lg:py-6">
|
<div 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">
|
<div bind:this={chatScrollEl} class="min-h-0 flex-1 space-y-4 overflow-y-auto px-1 py-0.5">
|
||||||
<p class="text-xs tracking-[0.2em] text-muted uppercase">Edit bouquet</p>
|
|
||||||
<h2 class="mt-1 text-lg">Tell us how you want to refine it.</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div bind:this={chatScrollEl} class="min-h-0 flex-1 space-y-4 overflow-y-auto pr-1">
|
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<p class="text-xs text-muted">Generated image</p>
|
|
||||||
{@render editableImageFrame(initialImage ?? generatedImage, chatMessages.length === 0)}
|
{@render editableImageFrame(initialImage ?? generatedImage, chatMessages.length === 0)}
|
||||||
|
<p class="text-xs text-muted">Generated image</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#each chatMessages as message (message.id)}
|
{#each chatMessages as message (message.id)}
|
||||||
{#if message.role === 'user'}
|
{#if message.role === 'user'}
|
||||||
<div class="flex justify-end">
|
<div class="flex justify-end">
|
||||||
<div
|
<div
|
||||||
class="max-w-[46%] rounded-3xl bg-pill px-4 py-3 text-sm leading-relaxed text-surface"
|
class="max-w-[46%] rounded-lg bg-pill px-4 py-3 text-sm leading-relaxed text-surface"
|
||||||
>
|
>
|
||||||
<p>{message.prompt}</p>
|
<p>{message.prompt}</p>
|
||||||
{#if message.mode === 'area'}
|
{#if message.mode === 'area'}
|
||||||
@@ -394,7 +391,7 @@
|
|||||||
{:else if message.status === 'pending'}
|
{:else if message.status === 'pending'}
|
||||||
<div class="flex justify-start">
|
<div class="flex justify-start">
|
||||||
<div
|
<div
|
||||||
class="max-w-[46%] rounded-3xl bg-track px-4 py-3 text-sm leading-relaxed text-muted ring-1 ring-black/5"
|
class="max-w-[46%] rounded-lg bg-track px-4 py-3 text-sm leading-relaxed text-muted ring-1 ring-line ring-inset"
|
||||||
>
|
>
|
||||||
Editing bouquet image...
|
Editing bouquet image...
|
||||||
</div>
|
</div>
|
||||||
@@ -402,7 +399,7 @@
|
|||||||
{:else if message.status === 'error'}
|
{:else if message.status === 'error'}
|
||||||
<div class="flex justify-start">
|
<div class="flex justify-start">
|
||||||
<div
|
<div
|
||||||
class="max-w-[46%] rounded-3xl bg-surface px-4 py-3 text-sm leading-relaxed text-red-600 ring-1 ring-red-200"
|
class="max-w-[46%] rounded-lg bg-surface px-4 py-3 text-sm leading-relaxed text-red-600 ring-1 ring-red-200 ring-inset"
|
||||||
>
|
>
|
||||||
{message.error}
|
{message.error}
|
||||||
</div>
|
</div>
|
||||||
@@ -415,25 +412,23 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex shrink-0 flex-wrap gap-2">
|
<EditComposerBar>
|
||||||
|
<div class="flex w-full flex-wrap gap-1.5">
|
||||||
{#each QUICK_PROMPTS as quickPrompt (quickPrompt)}
|
{#each QUICK_PROMPTS as quickPrompt (quickPrompt)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => addQuickPrompt(quickPrompt)}
|
onclick={() => addQuickPrompt(quickPrompt)}
|
||||||
class="rounded-full bg-placeholder px-3 py-1 text-xs text-ink hover:bg-line-strong"
|
class="rounded-full bg-track px-3 py-1 text-xs text-ink ring-1 ring-line ring-inset hover:bg-line"
|
||||||
>
|
>
|
||||||
{quickPrompt}
|
{quickPrompt}
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<FlowContinueBar class="lg:mx-auto lg:w-full lg:max-w-2xl">
|
|
||||||
{#if error}
|
{#if error}
|
||||||
<p class="rounded bg-surface/95 px-3 py-2 text-sm text-red-600 ring-1 ring-black/5">
|
<p class="text-xs text-red-600">{error}</p>
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -481,16 +476,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</EditComposerBar>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={editing}
|
|
||||||
onclick={continueToResult}
|
|
||||||
class={FLOW_CONTINUE_BUTTON}
|
|
||||||
>
|
|
||||||
Continue to result ->
|
|
||||||
</button>
|
|
||||||
</FlowContinueBar>
|
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,9 +3,10 @@
|
|||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
import Header from '$lib/components/ui/Header.svelte';
|
import Header from '$lib/components/ui/Header.svelte';
|
||||||
|
import FlowNav from '$lib/components/ui/FlowNav.svelte';
|
||||||
import Artwork from '$lib/components/ui/Artwork/Artwork.svelte';
|
import Artwork from '$lib/components/ui/Artwork/Artwork.svelte';
|
||||||
import GenerationActivityFeed from '$lib/components/ui/generating/GenerationActivityFeed.svelte';
|
import GenerationActivityFeed from '$lib/components/ui/generating/GenerationActivityFeed.svelte';
|
||||||
import { buildRecipe, generateImages } from '$lib/flowerFlow/api.js';
|
import { buildRecipe, generateImages, waitForMoodAnalysis } from '$lib/flowerFlow/api.js';
|
||||||
import {
|
import {
|
||||||
createGenerationProgress,
|
createGenerationProgress,
|
||||||
DEFAULT_ESTIMATED_MS,
|
DEFAULT_ESTIMATED_MS,
|
||||||
@@ -143,6 +144,15 @@
|
|||||||
const estimatedMs = flow.mock ? MOCK_ESTIMATED_MS : DEFAULT_ESTIMATED_MS;
|
const estimatedMs = flow.mock ? MOCK_ESTIMATED_MS : DEFAULT_ESTIMATED_MS;
|
||||||
progress.begin({ estimatedMs });
|
progress.begin({ estimatedMs });
|
||||||
|
|
||||||
|
if (!getFlowObject('moodAnalysis')) {
|
||||||
|
const moodAnalysis = await runWithRetry('Analyzing mood', () =>
|
||||||
|
waitForMoodAnalysis(jobId, {
|
||||||
|
onUpdate: (job) => saveFlow({ moodAnalysis: job.moodAnalysis, mock: job.mock })
|
||||||
|
})
|
||||||
|
);
|
||||||
|
saveFlow({ moodAnalysis });
|
||||||
|
}
|
||||||
|
|
||||||
const existingRecipe = getFlowObject('recipe');
|
const existingRecipe = getFlowObject('recipe');
|
||||||
if (!existingRecipe) {
|
if (!existingRecipe) {
|
||||||
const recipeResult = await runWithRetry('Building bouquet recipe', () =>
|
const recipeResult = await runWithRetry('Building bouquet recipe', () =>
|
||||||
@@ -199,10 +209,6 @@
|
|||||||
runGeneration();
|
runGeneration();
|
||||||
}
|
}
|
||||||
|
|
||||||
function backToMessage() {
|
|
||||||
goto(resolve('/message'));
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
active = true;
|
active = true;
|
||||||
progress = createGenerationProgress((index) => {
|
progress = createGenerationProgress((index) => {
|
||||||
@@ -223,6 +229,7 @@
|
|||||||
class="flex h-dvh flex-col overflow-x-hidden bg-surface text-ink lg:h-screen lg:overflow-hidden"
|
class="flex h-dvh flex-col overflow-x-hidden bg-surface text-ink lg:h-screen lg:overflow-hidden"
|
||||||
>
|
>
|
||||||
<Header step={4} total={7} />
|
<Header step={4} total={7} />
|
||||||
|
<FlowNav backHref="/message" showContinue={false} />
|
||||||
|
|
||||||
<main class="flex min-h-0 flex-1 flex-col lg:flex-row">
|
<main class="flex min-h-0 flex-1 flex-col lg:flex-row">
|
||||||
<Artwork
|
<Artwork
|
||||||
@@ -240,7 +247,6 @@
|
|||||||
{retryLabel}
|
{retryLabel}
|
||||||
{canRetry}
|
{canRetry}
|
||||||
onRetry={retry}
|
onRetry={retry}
|
||||||
onBack={backToMessage}
|
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
import Header from '$lib/components/ui/Header.svelte';
|
import Header from '$lib/components/ui/Header.svelte';
|
||||||
|
import FlowNav from '$lib/components/ui/FlowNav.svelte';
|
||||||
import Artwork from '$lib/components/ui/Artwork/Artwork.svelte';
|
import Artwork from '$lib/components/ui/Artwork/Artwork.svelte';
|
||||||
import MapPanel from '$lib/components/ui/map/MapPanel.svelte';
|
import MapPanel from '$lib/components/ui/map/MapPanel.svelte';
|
||||||
import { fetchJob, toDataUrl } from '$lib/flowerFlow/api.js';
|
import { fetchJob, toDataUrl } from '$lib/flowerFlow/api.js';
|
||||||
@@ -124,12 +125,14 @@
|
|||||||
class="flex h-dvh flex-col overflow-x-hidden bg-surface text-ink lg:h-screen lg:overflow-hidden"
|
class="flex h-dvh flex-col overflow-x-hidden bg-surface text-ink lg:h-screen lg:overflow-hidden"
|
||||||
>
|
>
|
||||||
<Header step={7} total={7} />
|
<Header step={7} total={7} />
|
||||||
|
<FlowNav backHref="/result" showContinue={false} />
|
||||||
|
|
||||||
<main class="flex min-h-0 flex-1 flex-col lg:flex-row">
|
<main class="flex min-h-0 flex-1 flex-col lg:flex-row">
|
||||||
<Artwork
|
<Artwork
|
||||||
title={artworkTitle}
|
title={artworkTitle}
|
||||||
description={artworkDescription}
|
description={artworkDescription}
|
||||||
imageSrc={bouquetImageSrc}
|
imageSrc={bouquetImageSrc}
|
||||||
|
downloadImage={selectedImage}
|
||||||
cardMode={artworkCardMode}
|
cardMode={artworkCardMode}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -6,12 +6,13 @@
|
|||||||
import Header from '$lib/components/ui/Header.svelte';
|
import Header from '$lib/components/ui/Header.svelte';
|
||||||
import Artwork from '$lib/components/ui/Artwork/Artwork.svelte';
|
import Artwork from '$lib/components/ui/Artwork/Artwork.svelte';
|
||||||
import MessageForm from '$lib/components/ui/message/MessageForm.svelte';
|
import MessageForm from '$lib/components/ui/message/MessageForm.svelte';
|
||||||
import FlowContinueBar, { FLOW_CONTINUE_BUTTON } from '$lib/components/ui/FlowContinueBar.svelte';
|
import FlowNav from '$lib/components/ui/FlowNav.svelte';
|
||||||
import { skipDevImages } from '$lib/flowerFlow/devSeed.js';
|
import { skipDevImages } from '$lib/flowerFlow/devSeed.js';
|
||||||
import {
|
import {
|
||||||
consumeDevMessageSnapshot,
|
consumeDevMessageSnapshot,
|
||||||
deleteFlowKey,
|
deleteFlowKey,
|
||||||
getFlowObject,
|
getFlowObject,
|
||||||
|
getFlowString,
|
||||||
getFlowUserInput,
|
getFlowUserInput,
|
||||||
isDevSeeded,
|
isDevSeeded,
|
||||||
loadFlow,
|
loadFlow,
|
||||||
@@ -21,8 +22,8 @@
|
|||||||
|
|
||||||
const userInput = getFlowUserInput();
|
const userInput = getFlowUserInput();
|
||||||
|
|
||||||
// 항상 빈 메시지로 시작 — Dev Fill은 onMount에서 1회만 스냅샷 적용
|
// sessionStorage에 저장된 값으로 시작 — Dev Fill은 onMount에서 1회 덮어씀
|
||||||
let message = $state('');
|
let message = $state(getFlowString('cardMessage'));
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
let skipping = $state(false);
|
let skipping = $state(false);
|
||||||
|
|
||||||
@@ -55,6 +56,10 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
saveFlow({ cardMessage: message });
|
||||||
|
});
|
||||||
|
|
||||||
function handleContinue() {
|
function handleContinue() {
|
||||||
const current = loadFlow();
|
const current = loadFlow();
|
||||||
if (!current.jobId) {
|
if (!current.jobId) {
|
||||||
@@ -110,6 +115,7 @@
|
|||||||
class="flex h-dvh flex-col overflow-x-hidden bg-surface text-ink lg:h-screen lg:overflow-hidden"
|
class="flex h-dvh flex-col overflow-x-hidden bg-surface text-ink lg:h-screen lg:overflow-hidden"
|
||||||
>
|
>
|
||||||
<Header step={3} total={7} />
|
<Header step={3} total={7} />
|
||||||
|
<FlowNav backHref="/upload" onContinue={handleContinue} />
|
||||||
|
|
||||||
<main class="flex min-h-0 flex-1 flex-col lg:flex-row">
|
<main class="flex min-h-0 flex-1 flex-col lg:flex-row">
|
||||||
<Artwork
|
<Artwork
|
||||||
@@ -119,32 +125,28 @@
|
|||||||
cardMode={artworkCardMode}
|
cardMode={artworkCardMode}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<section class="relative flex min-h-0 flex-1 flex-col pb-[3.75rem] lg:overflow-hidden lg:pb-8">
|
<section class="relative flex min-h-0 flex-1 flex-col lg:overflow-hidden lg:pb-8">
|
||||||
<div class="min-h-0 flex-1 overflow-y-auto">
|
<div class="min-h-0 flex-1 overflow-y-auto">
|
||||||
<MessageForm bind:message />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<FlowContinueBar>
|
|
||||||
{#if error}
|
{#if error}
|
||||||
<p class="rounded bg-surface/95 px-3 py-2 text-sm text-red-600 ring-1 ring-black/5">
|
<p class="mx-6 mb-4 rounded bg-surface/95 px-3 py-2 text-sm text-red-600 ring-1 ring-black/5 lg:mx-8">
|
||||||
{error}
|
{error}
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
{#if dev}
|
{#if dev}
|
||||||
<button
|
<div class="px-6 pt-4 lg:px-8">
|
||||||
type="button"
|
<button
|
||||||
disabled={skipping}
|
type="button"
|
||||||
onclick={skipWithDummyImages}
|
disabled={skipping}
|
||||||
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"
|
onclick={skipWithDummyImages}
|
||||||
title="AI 생성 없이 더미 이미지로 edit로 이동 (개발용)"
|
class="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"
|
||||||
>
|
title="AI 생성 없이 더미 이미지로 edit로 이동 (개발용)"
|
||||||
{skipping ? 'Skipping…' : 'Dev: Skip to edit (dummy images)'}
|
>
|
||||||
</button>
|
{skipping ? 'Skipping…' : 'Dev: Skip to edit (dummy images)'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<button type="button" onclick={handleContinue} class={FLOW_CONTINUE_BUTTON}>
|
<MessageForm bind:message />
|
||||||
Continue to generating ->
|
</div>
|
||||||
</button>
|
|
||||||
</FlowContinueBar>
|
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
import Header from '$lib/components/ui/Header.svelte';
|
import Header from '$lib/components/ui/Header.svelte';
|
||||||
import Artwork from '$lib/components/ui/Artwork/Artwork.svelte';
|
import Artwork from '$lib/components/ui/Artwork/Artwork.svelte';
|
||||||
import BouquetFlowerCarousel from '$lib/components/ui/result/BouquetFlowerCarousel.svelte';
|
import BouquetFlowerCarousel from '$lib/components/ui/result/BouquetFlowerCarousel.svelte';
|
||||||
import FlowContinueBar, { FLOW_CONTINUE_BUTTON } from '$lib/components/ui/FlowContinueBar.svelte';
|
import FlowNav from '$lib/components/ui/FlowNav.svelte';
|
||||||
import { fetchJob, toDataUrl } from '$lib/flowerFlow/api.js';
|
import { fetchJob, toDataUrl } from '$lib/flowerFlow/api.js';
|
||||||
import { getFlowerImageSrc } from '$lib/flowerFlow/flowerImagePaths.js';
|
import { getFlowerImageSrc } from '$lib/flowerFlow/flowerImagePaths.js';
|
||||||
import {
|
import {
|
||||||
@@ -55,6 +55,11 @@
|
|||||||
class="flex h-dvh flex-col overflow-x-hidden bg-surface text-ink lg:h-screen lg:overflow-hidden"
|
class="flex h-dvh flex-col overflow-x-hidden bg-surface text-ink lg:h-screen lg:overflow-hidden"
|
||||||
>
|
>
|
||||||
<Header step={6} total={7} />
|
<Header step={6} total={7} />
|
||||||
|
<FlowNav
|
||||||
|
backHref="/edit"
|
||||||
|
onContinue={() => goto(resolve('/map'))}
|
||||||
|
showContinue={!loading && !error}
|
||||||
|
/>
|
||||||
|
|
||||||
<main class="flex min-h-0 flex-1 flex-col lg:flex-row">
|
<main class="flex min-h-0 flex-1 flex-col lg:flex-row">
|
||||||
<Artwork
|
<Artwork
|
||||||
@@ -62,9 +67,10 @@
|
|||||||
title={artworkTitle}
|
title={artworkTitle}
|
||||||
description={artworkDescription}
|
description={artworkDescription}
|
||||||
imageSrc={bouquetImageSrc}
|
imageSrc={bouquetImageSrc}
|
||||||
|
downloadImage={selectedImage}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<section class="relative flex min-h-0 flex-1 flex-col pb-[3.75rem] lg:overflow-hidden lg:pb-8">
|
<section class="relative flex min-h-0 flex-1 flex-col lg:overflow-hidden lg:pb-8">
|
||||||
<div
|
<div
|
||||||
class="flex min-h-0 flex-1 flex-col justify-center overflow-hidden px-6 py-6 lg:px-8 lg:py-8"
|
class="flex min-h-0 flex-1 flex-col justify-center overflow-hidden px-6 py-6 lg:px-8 lg:py-8"
|
||||||
>
|
>
|
||||||
@@ -80,14 +86,6 @@
|
|||||||
<BouquetFlowerCarousel flowers={bouquetFlowers} />
|
<BouquetFlowerCarousel flowers={bouquetFlowers} />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if !loading && !error}
|
|
||||||
<FlowContinueBar>
|
|
||||||
<button type="button" onclick={() => goto(resolve('/map'))} class={FLOW_CONTINUE_BUTTON}>
|
|
||||||
Continue to map ->
|
|
||||||
</button>
|
|
||||||
</FlowContinueBar>
|
|
||||||
{/if}
|
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
import Artwork from '$lib/components/ui/Artwork/Artwork.svelte';
|
import Artwork from '$lib/components/ui/Artwork/Artwork.svelte';
|
||||||
import MoodboardGrid from '$lib/components/ui/upload/MoodboardGrid.svelte';
|
import MoodboardGrid from '$lib/components/ui/upload/MoodboardGrid.svelte';
|
||||||
import SnsFeedUpload from '$lib/components/ui/upload/SnsFeedUpload.svelte';
|
import SnsFeedUpload from '$lib/components/ui/upload/SnsFeedUpload.svelte';
|
||||||
import FlowContinueBar, { FLOW_CONTINUE_BUTTON } from '$lib/components/ui/FlowContinueBar.svelte';
|
import FlowNav from '$lib/components/ui/FlowNav.svelte';
|
||||||
import { analyzeMood } from '$lib/flowerFlow/api.js';
|
import { analyzeMood } from '$lib/flowerFlow/api.js';
|
||||||
import {
|
import {
|
||||||
deleteFlowKey,
|
deleteFlowKey,
|
||||||
@@ -14,25 +14,34 @@
|
|||||||
loadFlow,
|
loadFlow,
|
||||||
saveFlow
|
saveFlow
|
||||||
} from '$lib/flowerFlow/session.js';
|
} from '$lib/flowerFlow/session.js';
|
||||||
|
import {
|
||||||
|
readMoodboardFiles,
|
||||||
|
readPrimaryUploadFile,
|
||||||
|
readSnsFile,
|
||||||
|
readUploadDraftMode,
|
||||||
|
writeUploadDraftMode
|
||||||
|
} from '$lib/flowerFlow/uploadDraft.js';
|
||||||
|
|
||||||
const savedFlow = loadFlow();
|
const savedFlow = loadFlow();
|
||||||
const userInput = getFlowUserInput();
|
const userInput = getFlowUserInput();
|
||||||
|
|
||||||
const devUpload = savedFlow.devUpload;
|
const devUpload = savedFlow.devUpload;
|
||||||
|
const cachedMoodboard = readMoodboardFiles();
|
||||||
|
const savedUploadMode = readUploadDraftMode();
|
||||||
let mode = $state(
|
let mode = $state(
|
||||||
isDevSeeded() && devUpload?.active && typeof devUpload.mode === 'string'
|
isDevSeeded() && devUpload?.active && typeof devUpload.mode === 'string'
|
||||||
? devUpload.mode
|
? devUpload.mode
|
||||||
: 'moodboard'
|
: savedUploadMode
|
||||||
);
|
);
|
||||||
let primaryFile = $state(null);
|
let primaryFile = $state(readPrimaryUploadFile());
|
||||||
let moodboardTiles = $state({
|
let moodboardTiles = $state({
|
||||||
color: false,
|
color: !!cachedMoodboard.color,
|
||||||
season: false,
|
season: !!cachedMoodboard.season,
|
||||||
character: false,
|
character: !!cachedMoodboard.character,
|
||||||
location: false
|
location: !!cachedMoodboard.location
|
||||||
});
|
});
|
||||||
let snsHasImage = $state(false);
|
let snsHasImage = $state(!!readSnsFile());
|
||||||
let loading = $state(false);
|
let submitting = $state(false);
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
|
|
||||||
const recipientLabel = $derived.by(() => {
|
const recipientLabel = $derived.by(() => {
|
||||||
@@ -149,11 +158,15 @@
|
|||||||
return 'create2';
|
return 'create2';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
writeUploadDraftMode(mode);
|
||||||
|
});
|
||||||
|
|
||||||
async function continueToMessage() {
|
async function continueToMessage() {
|
||||||
error = '';
|
error = '';
|
||||||
|
|
||||||
const flow = loadFlow();
|
const flow = loadFlow();
|
||||||
if (flow.jobId && flow.moodAnalysis) {
|
if (flow.jobId) {
|
||||||
// Dev Fill 후 바로 message로 넘어갈 때 더미 플래그가 남지 않도록 정리
|
// Dev Fill 후 바로 message로 넘어갈 때 더미 플래그가 남지 않도록 정리
|
||||||
deleteFlowKey('devUpload');
|
deleteFlowKey('devUpload');
|
||||||
deleteFlowKey('devSeeded');
|
deleteFlowKey('devSeeded');
|
||||||
@@ -162,12 +175,16 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!primaryFile) {
|
||||||
|
primaryFile = readPrimaryUploadFile();
|
||||||
|
}
|
||||||
|
|
||||||
if (!primaryFile) {
|
if (!primaryFile) {
|
||||||
error = 'Upload at least one image to continue.';
|
error = 'Upload at least one image to continue.';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
loading = true;
|
submitting = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await analyzeMood(primaryFile, userInput);
|
const result = await analyzeMood(primaryFile, userInput);
|
||||||
@@ -177,7 +194,7 @@
|
|||||||
deleteFlowKey('cardMessage');
|
deleteFlowKey('cardMessage');
|
||||||
saveFlow({
|
saveFlow({
|
||||||
jobId: result.jobId,
|
jobId: result.jobId,
|
||||||
moodAnalysis: result.moodAnalysis,
|
moodAnalysis: result.moodAnalysis ?? null,
|
||||||
recipe: null,
|
recipe: null,
|
||||||
imagePrompt: null,
|
imagePrompt: null,
|
||||||
images: null,
|
images: null,
|
||||||
@@ -188,7 +205,7 @@
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
error = err instanceof Error ? err.message : 'Upload failed';
|
error = err instanceof Error ? err.message : 'Upload failed';
|
||||||
} finally {
|
} finally {
|
||||||
loading = false;
|
submitting = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -197,6 +214,11 @@
|
|||||||
class="flex h-dvh flex-col overflow-x-hidden bg-surface text-ink lg:h-screen lg:overflow-hidden"
|
class="flex h-dvh flex-col overflow-x-hidden bg-surface text-ink lg:h-screen lg:overflow-hidden"
|
||||||
>
|
>
|
||||||
<Header step={2} total={7} />
|
<Header step={2} total={7} />
|
||||||
|
<FlowNav
|
||||||
|
backHref="/create"
|
||||||
|
onContinue={continueToMessage}
|
||||||
|
continueDisabled={submitting}
|
||||||
|
/>
|
||||||
|
|
||||||
<main class="flex min-h-0 flex-1 flex-col lg:flex-row">
|
<main class="flex min-h-0 flex-1 flex-col lg:flex-row">
|
||||||
<Artwork
|
<Artwork
|
||||||
@@ -207,8 +229,14 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<section
|
<section
|
||||||
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"
|
class="relative flex min-h-0 flex-1 flex-col pt-4 lg:grid lg:grid-rows-[auto_minmax(0,1fr)] lg:overflow-hidden lg:pt-6 lg:pb-8"
|
||||||
>
|
>
|
||||||
|
{#if error}
|
||||||
|
<p class="mx-4 mb-3 rounded bg-surface/95 px-3 py-2 text-sm text-red-600 ring-1 ring-black/5 lg:mx-6">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="mb-3 flex shrink-0 justify-center px-4 lg:mb-4 lg:px-6">
|
<div class="mb-3 flex shrink-0 justify-center px-4 lg:mb-4 lg:px-6">
|
||||||
<div
|
<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"
|
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"
|
||||||
@@ -252,23 +280,6 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<SnsFeedUpload bind:primaryFile bind:hasImage={snsHasImage} />
|
<SnsFeedUpload bind:primaryFile bind:hasImage={snsHasImage} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<FlowContinueBar>
|
|
||||||
{#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}
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={loading}
|
|
||||||
onclick={continueToMessage}
|
|
||||||
class={FLOW_CONTINUE_BUTTON}
|
|
||||||
>
|
|
||||||
{loading ? 'Analyzing mood...' : 'Continue to message ->'}
|
|
||||||
</button>
|
|
||||||
</FlowContinueBar>
|
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user