feat: add generating image page, artwork, and map translation

* feat: add options/map flow, dev seed, and artwork fixes

Options page, Kakao map with florist order message, dev tooling, and
create/message dummy gating — without secrets in .env.example.

Co-authored-by: Cursor <cursoragent@cursor.com>

* with generating page + art work

---------

Co-authored-by: 이지은 <ijieun@ijieun-ui-MacBookPro.local>
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Chaewon Lee
2026-06-14 09:43:35 +09:00
committed by GitHub
parent 921dfd55f4
commit 80b84bd2ed
25 changed files with 851 additions and 110 deletions

View File

@@ -3,25 +3,62 @@
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import Header from '$lib/components/ui/Header.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 { clearFlow, getFlowObject, loadFlow, saveFlow } from '$lib/flowerFlow/session.js';
import { createGenerationProgress, DEFAULT_ESTIMATED_MS, MOCK_ESTIMATED_MS } from '$lib/flowerFlow/generationProgress.js';
import { createGeneratingArtworkCycle } from '$lib/flowerFlow/generatingArtworkCycle.js';
import {
clearFlow,
getFlowObject,
getFlowString,
getFlowUserInput,
loadFlow,
saveFlow
} from '$lib/flowerFlow/session.js';
const MAX_RETRIES = 5;
const userInput = getFlowUserInput();
const cardMessage = getFlowString('cardMessage');
let status = $state('Preparing bouquet recipe...');
const artworkTitle = $derived.by(() => {
const who = typeof userInput.relationship === 'string' ? userInput.relationship : null;
const whatFor = typeof userInput.occasion === 'string' ? userInput.occasion : null;
if (!who && !whatFor) return 'Your bouquet';
const occasion = whatFor ? `A ${whatFor} bouquet for` : 'A bouquet for';
return `${occasion} ${who ?? '...'}`;
});
const artworkDescription = $derived(cardMessage || '잠시 관리중 ~');
/** @type {import('$lib/components/ui/Artwork/artworkVariants.js').ArtworkVariant} */
let artworkVariant = $state('create2');
let activeStepIndex = $state(0);
let retryLabel = $state('');
let error = $state('');
let canRetry = $state(false);
let active = true;
/** @type {ReturnType<typeof createGenerationProgress> | null} */
let progress = null;
/** @type {ReturnType<typeof createGeneratingArtworkCycle> | null} */
let artworkCycle = null;
function startArtworkCycle() {
artworkCycle?.dispose();
artworkCycle = createGeneratingArtworkCycle((variant) => {
artworkVariant = variant;
});
artworkCycle.start();
}
/** @param {number} ms */
function wait(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
return new Promise((resolveWait) => setTimeout(resolveWait, ms));
}
/**
* Read the structured fields the server now sends. Falls back to message
* sniffing only if an older/unstructured error slips through.
* @param {any} err
*/
function classify(err) {
@@ -42,9 +79,6 @@
}
/**
* Run a task with a finite, classified retry policy: permanent errors stop
* immediately, transient ones retry up to MAX_RETRIES respecting the
* server-provided delay, and the real error is surfaced either way.
* @template T
* @param {string} label
* @param {() => Promise<T>} task
@@ -55,8 +89,8 @@
while (active) {
try {
status =
attempt === 0 ? label : `Retrying ${label.toLowerCase()} (${attempt}/${MAX_RETRIES})...`;
retryLabel =
attempt === 0 ? '' : `Retrying ${label.toLowerCase()} (${attempt}/${MAX_RETRIES})`;
error = '';
return await task();
} catch (err) {
@@ -68,7 +102,7 @@
attempt += 1;
const seconds = Math.round(retryAfterMs / 1000);
status = `AI provider is busy. Retrying in ${seconds}s (${attempt}/${MAX_RETRIES})...`;
retryLabel = `AI provider is busy. Retrying in ${seconds}s (${attempt}/${MAX_RETRIES})`;
await wait(retryAfterMs);
}
}
@@ -77,10 +111,15 @@
}
async function runGeneration() {
if (!progress) return;
canRetry = false;
error = '';
retryLabel = '';
const flow = loadFlow();
const jobId = typeof flow.jobId === 'string' ? flow.jobId : '';
const userInput = getFlowObject('userInput') ?? {};
const sessionUserInput = getFlowObject('userInput') ?? {};
if (!jobId) {
await goto(resolve('/create'));
@@ -88,21 +127,27 @@
}
try {
const estimatedMs = flow.mock ? MOCK_ESTIMATED_MS : DEFAULT_ESTIMATED_MS;
progress.begin({ estimatedMs });
const existingRecipe = getFlowObject('recipe');
if (!existingRecipe) {
const recipeResult = await runWithRetry('Building bouquet recipe...', () =>
buildRecipe(jobId, userInput)
const recipeResult = await runWithRetry('Building bouquet recipe', () =>
buildRecipe(jobId, sessionUserInput)
);
saveFlow({ recipe: recipeResult.recipe });
}
const imageResult = await runWithRetry('Generating bouquet image...', () =>
const imageResult = await runWithRetry('Generating bouquet image', () =>
generateImages(jobId)
);
// Do NOT persist the multi-MB base64 images in sessionStorage — Safari caps
// it at ~5MB and throws "QuotaExceededError: The quota has been exceeded."
// The images already live in Supabase Storage via the job; the options
// The images already live in Supabase Storage via the job; the edit
// and result pages fetch them by jobId. We only keep lightweight metadata here.
await progress.finishWhenReady();
saveFlow({
imagesJobId: jobId,
imagePrompt: imageResult.imagePrompt,
@@ -119,61 +164,65 @@
const stale =
code === 'job_not_found' || (err && typeof err === 'object' && err.status === 404);
if (stale) {
// Keep the user's entered context (relationship/occasion/etc.), drop the
// dead job, and re-upload to mint a fresh one.
const userInput = getFlowObject('userInput');
const preservedInput = getFlowObject('userInput');
clearFlow();
if (userInput) saveFlow({ userInput });
error = '';
status = 'This session expired. Starting over...';
if (preservedInput) saveFlow({ userInput: preservedInput });
retryLabel = '';
await goto(resolve('/upload'));
return;
}
const { permanent } = classify(err);
error = err instanceof Error ? err.message : 'Generation failed';
status = permanent ? 'Generation is blocked.' : 'Still failing after several retries.';
retryLabel = permanent ? 'Generation is blocked.' : 'Still failing after several retries.';
canRetry = true;
progress?.reset();
}
}
function retry() {
if (!active) return;
startArtworkCycle();
runGeneration();
}
function backToMessage() {
goto(resolve('/message'));
}
onMount(() => {
active = true;
progress = createGenerationProgress((index) => {
activeStepIndex = index;
});
startArtworkCycle();
runGeneration();
return () => {
active = false;
progress?.dispose();
artworkCycle?.dispose();
};
});
</script>
<div class="min-h-dvh bg-surface text-ink">
<div
class="flex h-dvh flex-col overflow-x-hidden bg-surface text-ink lg:h-screen lg:overflow-hidden"
>
<Header step={4} total={7} />
<main class="mx-auto flex max-w-xl flex-col items-start px-6 py-16">
<h1 class="mb-3 text-2xl">Generating</h1>
<p class="text-sm text-muted">{status}</p>
<main class="flex min-h-0 flex-1 flex-col lg:flex-row">
<Artwork comingSoon variant={artworkVariant} title={artworkTitle} description={artworkDescription} />
{#if error}
<p class="mt-6 text-sm text-red-600">{error}</p>
<div class="mt-4 flex gap-3">
{#if canRetry}
<button type="button" class="bg-pill px-4 py-2 text-sm text-surface" onclick={retry}>
Try again
</button>
{/if}
<button
type="button"
class="border border-pill px-4 py-2 text-sm text-ink"
onclick={() => goto(resolve('/message'))}
>
Back to message
</button>
</div>
{/if}
<section class="relative flex min-h-0 flex-1 flex-col lg:overflow-y-auto">
<GenerationActivityFeed
{activeStepIndex}
{error}
{retryLabel}
{canRetry}
onRetry={retry}
onBack={backToMessage}
/>
</section>
</main>
</div>