diff --git a/src/lib/components/ui/Artwork/Artwork.svelte b/src/lib/components/ui/Artwork/Artwork.svelte index 1099884..6ed83a2 100644 --- a/src/lib/components/ui/Artwork/Artwork.svelte +++ b/src/lib/components/ui/Artwork/Artwork.svelte @@ -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; + } + }
{#if imageSrc} -
- Selected bouquet +
+
+ Selected bouquet +
+ {#if downloadImage} + + {#if downloadError} +

{downloadError}

+ {/if} + {/if}
{:else} diff --git a/src/lib/components/ui/FlowContinueBar.svelte b/src/lib/components/ui/FlowContinueBar.svelte deleted file mode 100644 index eff60ef..0000000 --- a/src/lib/components/ui/FlowContinueBar.svelte +++ /dev/null @@ -1,21 +0,0 @@ - - - - -
- {@render children()} -
diff --git a/src/lib/components/ui/FlowNav.svelte b/src/lib/components/ui/FlowNav.svelte new file mode 100644 index 0000000..e26f607 --- /dev/null +++ b/src/lib/components/ui/FlowNav.svelte @@ -0,0 +1,57 @@ + + + + + diff --git a/src/lib/components/ui/create/BudgetSlider.svelte b/src/lib/components/ui/create/BudgetSlider.svelte index eb02614..76eecb6 100644 --- a/src/lib/components/ui/create/BudgetSlider.svelte +++ b/src/lib/components/ui/create/BudgetSlider.svelte @@ -13,7 +13,7 @@

Budget

-

+

₩{budget.toLocaleString('ko-KR')}

diff --git a/src/lib/components/ui/create/ContextForm.svelte b/src/lib/components/ui/create/ContextForm.svelte index 19cc2fd..cb1e133 100644 --- a/src/lib/components/ui/create/ContextForm.svelte +++ b/src/lib/components/ui/create/ContextForm.svelte @@ -19,12 +19,12 @@
{#if !hasAnySelection} -

+

Who are we making flowers for?

Pick a few details below

{:else} -

+

{#if whatFor} A {whatFor} + let { class: klass = '', children } = $props(); + + +
+ {@render children()} +
diff --git a/src/lib/components/ui/generating/GenerationActivityFeed.svelte b/src/lib/components/ui/generating/GenerationActivityFeed.svelte index a906dc8..8e84904 100644 --- a/src/lib/components/ui/generating/GenerationActivityFeed.svelte +++ b/src/lib/components/ui/generating/GenerationActivityFeed.svelte @@ -8,8 +8,7 @@ error = '', retryLabel = '', canRetry = false, - onRetry = () => {}, - onBack = () => {} + onRetry = () => {} } = $props(); /** @@ -26,7 +25,7 @@
-

+

Creating your bouquet...

{#if retryLabel} @@ -51,13 +50,6 @@ Try again {/if} -

{/if} diff --git a/src/lib/components/ui/landing/LandingHero.svelte b/src/lib/components/ui/landing/LandingHero.svelte index 05772cc..88ff389 100644 --- a/src/lib/components/ui/landing/LandingHero.svelte +++ b/src/lib/components/ui/landing/LandingHero.svelte @@ -1,7 +1,7 @@
-
- + -

- Every Bouquet Starts with a Muse -

+
+
+ + +

+ Every Bouquet Starts with a Muse +

+
+ +
+

AI Florist

+

+ Fleumuse +

+
- -
-

AI Florist

-

- Fleumuse -

-
- - - - -
diff --git a/src/lib/components/ui/map/MapPanel.svelte b/src/lib/components/ui/map/MapPanel.svelte index b2c9e8d..cbd9855 100644 --- a/src/lib/components/ui/map/MapPanel.svelte +++ b/src/lib/components/ui/map/MapPanel.svelte @@ -49,7 +49,7 @@
-

+

Find a nearby florist

Move the map, then refresh to search this area.

diff --git a/src/lib/components/ui/message/MessageForm.svelte b/src/lib/components/ui/message/MessageForm.svelte index 8db21a8..ad08e5c 100644 --- a/src/lib/components/ui/message/MessageForm.svelte +++ b/src/lib/components/ui/message/MessageForm.svelte @@ -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]" >
diff --git a/src/lib/components/ui/upload/MoodboardGrid.svelte b/src/lib/components/ui/upload/MoodboardGrid.svelte index 4838179..334630e 100644 --- a/src/lib/components/ui/upload/MoodboardGrid.svelte +++ b/src/lib/components/ui/upload/MoodboardGrid.svelte @@ -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 실패 시 캐시/빈 타일 유지 } }); diff --git a/src/lib/components/ui/upload/SnsFeedUpload.svelte b/src/lib/components/ui/upload/SnsFeedUpload.svelte index 0814e19..c30e6f4 100644 --- a/src/lib/components/ui/upload/SnsFeedUpload.svelte +++ b/src/lib/components/ui/upload/SnsFeedUpload.svelte @@ -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} */ (tiles)); if (files.first) firstFile = files.first; } catch { - // dev seed 실패 시 빈 타일 유지 + // dev seed 실패 시 캐시/빈 타일 유지 } }); diff --git a/src/lib/flowerFlow/api.js b/src/lib/flowerFlow/api.js index 7eb978c..a77999b 100644 --- a/src/lib/flowerFlow/api.js +++ b/src/lib/flowerFlow/api.js @@ -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>) => 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 */ diff --git a/src/lib/flowerFlow/downloadGeneratedImage.js b/src/lib/flowerFlow/downloadGeneratedImage.js new file mode 100644 index 0000000..5b61005 --- /dev/null +++ b/src/lib/flowerFlow/downloadGeneratedImage.js @@ -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); +} diff --git a/src/lib/flowerFlow/session.js b/src/lib/flowerFlow/session.js index fc4e712..c9e8a7f 100644 --- a/src/lib/flowerFlow/session.js +++ b/src/lib/flowerFlow/session.js @@ -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} diff --git a/src/lib/flowerFlow/uploadDraft.js b/src/lib/flowerFlow/uploadDraft.js new file mode 100644 index 0000000..25c9abe --- /dev/null +++ b/src/lib/flowerFlow/uploadDraft.js @@ -0,0 +1,10 @@ +export { + clearUploadDraftCache, + readMoodboardFiles, + readPrimaryUploadFile, + readSnsFile, + readUploadDraftMode, + writeMoodboardFiles, + writeSnsFile, + writeUploadDraftMode +} from './uploadDraftCache.js'; diff --git a/src/lib/flowerFlow/uploadDraftCache.js b/src/lib/flowerFlow/uploadDraftCache.js new file mode 100644 index 0000000..8ae1970 --- /dev/null +++ b/src/lib/flowerFlow/uploadDraftCache.js @@ -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} 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; +} diff --git a/src/routes/api/flower-flow/mood-analysis/+server.js b/src/routes/api/flower-flow/mood-analysis/+server.js index 5346511..8758910 100644 --- a/src/routes/api/flower-flow/mood-analysis/+server.js +++ b/src/routes/api/flower-flow/mood-analysis/+server.js @@ -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 { enforceRateLimit, json, readUserInput, toErrorResponse } from '$lib/server/http.js'; +/** + * @param {string} jobId + * @param {Uint8Array} imageBytes + * @param {string} mimeType + * @param {Record} 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} */ export async function POST({ request, getClientAddress }) { try { @@ -31,13 +46,14 @@ export async function POST({ request, getClientAddress }) { const userInput = readUserInput(formData); const job = await createJob(userInput); 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({ jobId: job.id, - moodAnalysis, + moodAnalysis: null, + pending: true, mock: !isGeminiConfigured() }); } catch (error) { diff --git a/src/routes/create/+page.svelte b/src/routes/create/+page.svelte index da15f96..e8e10de 100644 --- a/src/routes/create/+page.svelte +++ b/src/routes/create/+page.svelte @@ -5,21 +5,24 @@ import Header from '$lib/components/ui/Header.svelte'; import Artwork from '$lib/components/ui/Artwork/Artwork.svelte'; import ContextForm from '$lib/components/ui/create/ContextForm.svelte'; - import FlowContinueBar, { FLOW_CONTINUE_BUTTON } from '$lib/components/ui/FlowContinueBar.svelte'; + import FlowNav from '$lib/components/ui/FlowNav.svelte'; import { consumeDevCreateSnapshot, deleteFlowKey, getFlowObject, isDevSeeded, + readCreateFormFromFlow, + saveCreateFormToFlow, saveFlow } from '$lib/flowerFlow/session.js'; import { ARTWORK_CARD_DEFAULTS } from '$lib/flowerFlow/artworkCardCopy.js'; - // 항상 빈 폼으로 시작 — Dev Fill은 onMount에서 1회만 스냅샷 적용 - let who = $state(null); - let whatFor = $state(null); - let style = $state(null); - let budget = $state(50_000); + // sessionStorage에 저장된 값으로 시작 — Dev Fill은 onMount에서 1회 덮어씀 + const initialForm = readCreateFormFromFlow(); + let who = $state(initialForm.who); + let whatFor = $state(initialForm.whatFor); + let style = $state(initialForm.style); + let budget = $state(initialForm.budget); const hasAnySelection = $derived(who !== null || whatFor !== null || style !== null); @@ -60,6 +63,10 @@ } }); + $effect(() => { + saveCreateFormToFlow({ who, whatFor, style, budget }); + }); + function handleContinue() { deleteFlowKey('devUpload'); 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" >
+
-
+
- - - -
diff --git a/src/routes/edit/+page.svelte b/src/routes/edit/+page.svelte index b8ced30..871cad4 100644 --- a/src/routes/edit/+page.svelte +++ b/src/routes/edit/+page.svelte @@ -3,19 +3,15 @@ import { goto } from '$app/navigation'; import { resolve } from '$app/paths'; import DescriptionCard from '$lib/components/ui/Artwork/DescriptionCard.svelte'; - import FlowContinueBar, { FLOW_CONTINUE_BUTTON } from '$lib/components/ui/FlowContinueBar.svelte'; + import 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 { editImages, fetchJob, toDataUrl } from '$lib/flowerFlow/api.js'; import { buildBriefBouquetTitle } from '$lib/flowerFlow/resolveRecipeFlowers.js'; import { getFlowString, saveFlow } from '$lib/flowerFlow/session.js'; const jobId = getFlowString('jobId'); - const QUICK_PROMPTS = [ - 'Make it more romantic', - 'Use warmer colors', - 'Add more volume', - 'Keep the same flowers' - ]; + const QUICK_PROMPTS = ['Make it more romantic', 'Use warmer colors', 'Add more volume']; let loading = $state(true); let error = $state(''); @@ -36,15 +32,17 @@ const hasAreaSelection = $derived(selectionPoints.length > 2); const title = $derived(buildBriefBouquetTitle(moodAnalysis)); const description = $derived.by(() => { + const intro = 'Tell us how you want to refine it.'; + 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) { - 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( selectionPoints.map((point) => `${point.x},${point.y}`).join(' ') @@ -212,7 +210,6 @@ chatMessages = chatMessages.map((entry) => entry.id === assistantMessageId ? { ...entry, status: 'error', error: message } : entry ); - error = message; } finally { 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" >
+
-
-

Edit bouquet

-

Tell us how you want to refine it.

-
- -
+
-

Generated image

{@render editableImageFrame(initialImage ?? generatedImage, chatMessages.length === 0)} +

Generated image

{#each chatMessages as message (message.id)} {#if message.role === 'user'}

{message.prompt}

{#if message.mode === 'area'} @@ -394,7 +391,7 @@ {:else if message.status === 'pending'}
Editing bouquet image...
@@ -402,7 +399,7 @@ {:else if message.status === 'error'}
{message.error}
@@ -415,25 +412,23 @@ {/if} {/each}
+
-
+ +
{#each QUICK_PROMPTS as quickPrompt (quickPrompt)} {/each}
-
- {#if error} -

- {error} -

+

{error}

{/if}
- - -
+
diff --git a/src/routes/generating/+page.svelte b/src/routes/generating/+page.svelte index 3f1cf76..9ed8829 100644 --- a/src/routes/generating/+page.svelte +++ b/src/routes/generating/+page.svelte @@ -3,9 +3,10 @@ import { goto } from '$app/navigation'; import { resolve } from '$app/paths'; 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 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 { createGenerationProgress, DEFAULT_ESTIMATED_MS, @@ -143,6 +144,15 @@ const estimatedMs = flow.mock ? MOCK_ESTIMATED_MS : DEFAULT_ESTIMATED_MS; 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'); if (!existingRecipe) { const recipeResult = await runWithRetry('Building bouquet recipe', () => @@ -199,10 +209,6 @@ runGeneration(); } - function backToMessage() { - goto(resolve('/message')); - } - onMount(() => { active = true; 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" >
+
diff --git a/src/routes/map/+page.svelte b/src/routes/map/+page.svelte index 1c1c7ab..2028018 100644 --- a/src/routes/map/+page.svelte +++ b/src/routes/map/+page.svelte @@ -3,6 +3,7 @@ import { goto } from '$app/navigation'; import { resolve } from '$app/paths'; 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 MapPanel from '$lib/components/ui/map/MapPanel.svelte'; 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" >
+
diff --git a/src/routes/message/+page.svelte b/src/routes/message/+page.svelte index d0838c9..c96bfde 100644 --- a/src/routes/message/+page.svelte +++ b/src/routes/message/+page.svelte @@ -6,12 +6,13 @@ import Header from '$lib/components/ui/Header.svelte'; import Artwork from '$lib/components/ui/Artwork/Artwork.svelte'; import MessageForm from '$lib/components/ui/message/MessageForm.svelte'; - import FlowContinueBar, { FLOW_CONTINUE_BUTTON } from '$lib/components/ui/FlowContinueBar.svelte'; + import FlowNav from '$lib/components/ui/FlowNav.svelte'; import { skipDevImages } from '$lib/flowerFlow/devSeed.js'; import { consumeDevMessageSnapshot, deleteFlowKey, getFlowObject, + getFlowString, getFlowUserInput, isDevSeeded, loadFlow, @@ -21,8 +22,8 @@ const userInput = getFlowUserInput(); - // 항상 빈 메시지로 시작 — Dev Fill은 onMount에서 1회만 스냅샷 적용 - let message = $state(''); + // sessionStorage에 저장된 값으로 시작 — Dev Fill은 onMount에서 1회 덮어씀 + let message = $state(getFlowString('cardMessage')); let error = $state(''); let skipping = $state(false); @@ -55,6 +56,10 @@ } }); + $effect(() => { + saveFlow({ cardMessage: message }); + }); + function handleContinue() { const current = loadFlow(); 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" >
+
-
+
- -
- - {#if error} -

+

{error}

{/if} {#if dev} - +
+ +
{/if} - -
+ +
diff --git a/src/routes/result/+page.svelte b/src/routes/result/+page.svelte index 18368f7..e2fb86a 100644 --- a/src/routes/result/+page.svelte +++ b/src/routes/result/+page.svelte @@ -5,7 +5,7 @@ import Header from '$lib/components/ui/Header.svelte'; import Artwork from '$lib/components/ui/Artwork/Artwork.svelte'; import BouquetFlowerCarousel from '$lib/components/ui/result/BouquetFlowerCarousel.svelte'; - import FlowContinueBar, { FLOW_CONTINUE_BUTTON } from '$lib/components/ui/FlowContinueBar.svelte'; + import FlowNav from '$lib/components/ui/FlowNav.svelte'; import { fetchJob, toDataUrl } from '$lib/flowerFlow/api.js'; import { getFlowerImageSrc } from '$lib/flowerFlow/flowerImagePaths.js'; import { @@ -55,6 +55,11 @@ class="flex h-dvh flex-col overflow-x-hidden bg-surface text-ink lg:h-screen lg:overflow-hidden" >
+ goto(resolve('/map'))} + showContinue={!loading && !error} + />
-
+
@@ -80,14 +86,6 @@ {/if}
- - {#if !loading && !error} - - - - {/if}
diff --git a/src/routes/upload/+page.svelte b/src/routes/upload/+page.svelte index 64395e7..7bdd5e1 100644 --- a/src/routes/upload/+page.svelte +++ b/src/routes/upload/+page.svelte @@ -5,7 +5,7 @@ import Artwork from '$lib/components/ui/Artwork/Artwork.svelte'; import MoodboardGrid from '$lib/components/ui/upload/MoodboardGrid.svelte'; import SnsFeedUpload from '$lib/components/ui/upload/SnsFeedUpload.svelte'; - import FlowContinueBar, { FLOW_CONTINUE_BUTTON } from '$lib/components/ui/FlowContinueBar.svelte'; + import FlowNav from '$lib/components/ui/FlowNav.svelte'; import { analyzeMood } from '$lib/flowerFlow/api.js'; import { deleteFlowKey, @@ -14,25 +14,34 @@ loadFlow, saveFlow } from '$lib/flowerFlow/session.js'; + import { + readMoodboardFiles, + readPrimaryUploadFile, + readSnsFile, + readUploadDraftMode, + writeUploadDraftMode + } from '$lib/flowerFlow/uploadDraft.js'; const savedFlow = loadFlow(); const userInput = getFlowUserInput(); const devUpload = savedFlow.devUpload; + const cachedMoodboard = readMoodboardFiles(); + const savedUploadMode = readUploadDraftMode(); let mode = $state( isDevSeeded() && devUpload?.active && typeof devUpload.mode === 'string' ? devUpload.mode - : 'moodboard' + : savedUploadMode ); - let primaryFile = $state(null); + let primaryFile = $state(readPrimaryUploadFile()); let moodboardTiles = $state({ - color: false, - season: false, - character: false, - location: false + color: !!cachedMoodboard.color, + season: !!cachedMoodboard.season, + character: !!cachedMoodboard.character, + location: !!cachedMoodboard.location }); - let snsHasImage = $state(false); - let loading = $state(false); + let snsHasImage = $state(!!readSnsFile()); + let submitting = $state(false); let error = $state(''); const recipientLabel = $derived.by(() => { @@ -149,11 +158,15 @@ return 'create2'; }); + $effect(() => { + writeUploadDraftMode(mode); + }); + async function continueToMessage() { error = ''; const flow = loadFlow(); - if (flow.jobId && flow.moodAnalysis) { + if (flow.jobId) { // Dev Fill 후 바로 message로 넘어갈 때 더미 플래그가 남지 않도록 정리 deleteFlowKey('devUpload'); deleteFlowKey('devSeeded'); @@ -162,12 +175,16 @@ return; } + if (!primaryFile) { + primaryFile = readPrimaryUploadFile(); + } + if (!primaryFile) { error = 'Upload at least one image to continue.'; return; } - loading = true; + submitting = true; try { const result = await analyzeMood(primaryFile, userInput); @@ -177,7 +194,7 @@ deleteFlowKey('cardMessage'); saveFlow({ jobId: result.jobId, - moodAnalysis: result.moodAnalysis, + moodAnalysis: result.moodAnalysis ?? null, recipe: null, imagePrompt: null, images: null, @@ -188,7 +205,7 @@ } catch (err) { error = err instanceof Error ? err.message : 'Upload failed'; } finally { - loading = false; + submitting = false; } } @@ -197,6 +214,11 @@ class="flex h-dvh flex-col overflow-x-hidden bg-surface text-ink lg:h-screen lg:overflow-hidden" >
+
+ {#if error} +

+ {error} +

+ {/if} +
{/if} - - - {#if error} -

- {error} -

- {/if} - - -