feat: polish bouquet edit flow (chat UI, pencil overlay, prompt tuning)
* feat: add dynamic artwork copy to upload page * feat: add pencil overlay for partial bouquet edits * feat: move edit step hints to description card * feat: add chat-style edit flow and tighten edit image prompts
This commit is contained in:
@@ -4,7 +4,11 @@
|
||||
import { hydrateDevUpload } from '$lib/dev/hydrateUpload.js';
|
||||
import { getFlowObject, isDevSeeded } from '$lib/flowerFlow/session.js';
|
||||
|
||||
let { primaryFile = $bindable(null), caption = 'build their moodboard!', filledCount = $bindable(0), allFilled = $bindable(false) } = $props();
|
||||
let {
|
||||
primaryFile = $bindable(null),
|
||||
uploadedTiles = $bindable(),
|
||||
caption = 'build their moodboard!'
|
||||
} = $props();
|
||||
|
||||
let colorFile = $state(null);
|
||||
let seasonFile = $state(null);
|
||||
@@ -17,9 +21,21 @@
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const count = [colorFile, seasonFile, characterFile, locationFile].filter(Boolean).length;
|
||||
filledCount = count;
|
||||
allFilled = count === 4;
|
||||
const next = {
|
||||
color: !!colorFile,
|
||||
season: !!seasonFile,
|
||||
character: !!characterFile,
|
||||
location: !!locationFile
|
||||
};
|
||||
|
||||
if (
|
||||
uploadedTiles?.color !== next.color ||
|
||||
uploadedTiles?.season !== next.season ||
|
||||
uploadedTiles?.character !== next.character ||
|
||||
uploadedTiles?.location !== next.location
|
||||
) {
|
||||
uploadedTiles = next;
|
||||
}
|
||||
});
|
||||
|
||||
onMount(async () => {
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
import { hydrateDevUpload } from '$lib/dev/hydrateUpload.js';
|
||||
import { getFlowObject, isDevSeeded } from '$lib/flowerFlow/session.js';
|
||||
|
||||
let { primaryFile = $bindable(null), caption = 'upload their feed!', filledCount = $bindable(0), allFilled = $bindable(false) } = $props();
|
||||
let { primaryFile = $bindable(null), hasImage = $bindable(), caption = 'upload their feed!' } =
|
||||
$props();
|
||||
|
||||
let firstFile = $state(null);
|
||||
|
||||
@@ -14,8 +15,8 @@
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
filledCount = firstFile ? 1 : 0;
|
||||
allFilled = Boolean(firstFile);
|
||||
const next = !!firstFile;
|
||||
if (hasImage !== next) hasImage = next;
|
||||
});
|
||||
|
||||
onMount(async () => {
|
||||
|
||||
@@ -21,10 +21,14 @@ export function isImageGenerationConfigured() {
|
||||
|
||||
/**
|
||||
* @param {string} basePrompt
|
||||
* @param {{ edit?: boolean }} [options]
|
||||
* @returns {Promise<GeneratedImage>}
|
||||
*/
|
||||
export async function generateBouquetImage(basePrompt) {
|
||||
const prompt = `${basePrompt}\n\nGenerate one final bouquet image. Keep it realistic, orderable from a real florist, front-facing, and suitable for a customer preview.`;
|
||||
export async function generateBouquetImage(basePrompt, options = {}) {
|
||||
const suffix = options.edit
|
||||
? 'Generate exactly one edited bouquet image. Show a single bouquet only, centered in frame. Do not show two bouquets, no side-by-side comparison, no before/after layout, and no duplicate arrangements. Keep it realistic, orderable from a real florist, front-facing, and suitable for a customer preview.'
|
||||
: 'Generate one final bouquet image. Keep it realistic, orderable from a real florist, front-facing, and suitable for a customer preview.';
|
||||
const prompt = `${basePrompt}\n\n${suffix}`;
|
||||
const provider = getImageProvider();
|
||||
|
||||
// Explicit mock mode: develop the full flow without spending any image quota.
|
||||
|
||||
@@ -32,7 +32,9 @@ function describeEditInstruction(instruction) {
|
||||
'EDIT REQUEST:',
|
||||
instruction.prompt,
|
||||
'',
|
||||
'Preserve the same bouquet concept, camera angle, background, wrapping style, and realistic florist photography unless the edit request explicitly says otherwise.'
|
||||
'This is a refinement of one existing bouquet photo, not a new collage.',
|
||||
'Preserve the same bouquet concept, camera angle, background, wrapping style, and realistic florist photography unless the edit request explicitly says otherwise.',
|
||||
'Output exactly one bouquet in a single composition. Never show two bouquets, side-by-side views, comparison panels, or duplicated arrangements.'
|
||||
];
|
||||
|
||||
if (instruction.mode === 'area') {
|
||||
@@ -78,7 +80,7 @@ export async function POST({ request }) {
|
||||
console.log(
|
||||
`[flower-flow] edit-images job=${jobId.slice(0, 8)} provider=${getImageProvider()} mode=${mode} → generating...`
|
||||
);
|
||||
const generatedImage = await generateBouquetImage(editPrompt);
|
||||
const generatedImage = await generateBouquetImage(editPrompt, { edit: true });
|
||||
const images = await uploadGeneratedImages(
|
||||
jobId,
|
||||
generatedImage,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { onMount, tick } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import DescriptionCard from '$lib/components/ui/Artwork/DescriptionCard.svelte';
|
||||
@@ -18,7 +18,7 @@
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
let prompt = $state('');
|
||||
let mode = $state('whole');
|
||||
let areaSelectionActive = $state(false);
|
||||
let drawing = $state(false);
|
||||
let selectionPoints = $state([]);
|
||||
let initialImage = $state(null);
|
||||
@@ -26,20 +26,50 @@
|
||||
let recipe = $state(null);
|
||||
let editing = $state(false);
|
||||
let continuing = $state(false);
|
||||
let editHistory = $state([]);
|
||||
/** @type {Array<{ id: string, role: 'user' | 'assistant', prompt?: string, mode?: string, status?: 'pending' | 'done' | 'error', afterImage?: { mimeType: string, base64: string } | null, error?: string }>} */
|
||||
let chatMessages = $state([]);
|
||||
/** @type {HTMLDivElement | null} */
|
||||
let chatScrollEl = $state(null);
|
||||
|
||||
const imageSrc = $derived(toDataUrl(generatedImage));
|
||||
const hasAreaSelection = $derived(selectionPoints.length > 2);
|
||||
const title = $derived(recipe?.concept ?? 'Generated bouquet');
|
||||
const description = $derived(
|
||||
recipe?.mainFlowers?.length
|
||||
? `${recipe.mainFlowers.join(', ')} · ${recipe.wrapping ?? 'Custom wrap'}`
|
||||
: 'Review and refine your bouquet before choosing a size.'
|
||||
);
|
||||
const description = $derived.by(() => {
|
||||
if (hasAreaSelection) {
|
||||
return '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 '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(' ')
|
||||
);
|
||||
const canSaveAreaPrompt = $derived(mode !== 'area' || selectionPoints.length > 2);
|
||||
const latestEditId = $derived(editHistory[editHistory.length - 1]?.id ?? '');
|
||||
const latestAssistantId = $derived.by(() => {
|
||||
for (let index = chatMessages.length - 1; index >= 0; index -= 1) {
|
||||
const message = chatMessages[index];
|
||||
if (message.role === 'assistant' && message.status === 'done') {
|
||||
return message.id;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
});
|
||||
|
||||
async function scrollChatToBottom() {
|
||||
await tick();
|
||||
if (!chatScrollEl) return;
|
||||
chatScrollEl.scrollTop = chatScrollEl.scrollHeight;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
chatMessages.length;
|
||||
editing;
|
||||
scrollChatToBottom();
|
||||
});
|
||||
|
||||
/**
|
||||
* @param {PointerEvent} event
|
||||
@@ -54,7 +84,7 @@
|
||||
|
||||
/** @param {PointerEvent} event */
|
||||
function startDrawing(event) {
|
||||
if (mode !== 'area') return;
|
||||
if (!areaSelectionActive) return;
|
||||
event.preventDefault();
|
||||
/** @type {SVGElement} */ (event.currentTarget).setPointerCapture(event.pointerId);
|
||||
drawing = true;
|
||||
@@ -63,7 +93,7 @@
|
||||
|
||||
/** @param {PointerEvent} event */
|
||||
function draw(event) {
|
||||
if (!drawing || mode !== 'area') return;
|
||||
if (!drawing || !areaSelectionActive) return;
|
||||
event.preventDefault();
|
||||
selectionPoints = [...selectionPoints, getPoint(event)];
|
||||
}
|
||||
@@ -72,30 +102,52 @@
|
||||
drawing = false;
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
function startAreaSelection() {
|
||||
areaSelectionActive = true;
|
||||
}
|
||||
|
||||
function cancelAreaSelection() {
|
||||
areaSelectionActive = false;
|
||||
drawing = false;
|
||||
selectionPoints = [];
|
||||
}
|
||||
|
||||
function toggleAreaTool() {
|
||||
if (areaSelectionActive) {
|
||||
cancelAreaSelection();
|
||||
return;
|
||||
}
|
||||
|
||||
startAreaSelection();
|
||||
}
|
||||
|
||||
/** @param {string} text */
|
||||
function addQuickPrompt(text) {
|
||||
prompt = prompt ? `${prompt}, ${text.toLowerCase()}` : text;
|
||||
}
|
||||
|
||||
/** @param {KeyboardEvent} event */
|
||||
function handlePromptKeydown(event) {
|
||||
if (event.key !== 'Enter' || event.shiftKey) return;
|
||||
event.preventDefault();
|
||||
applyEdit();
|
||||
}
|
||||
|
||||
function getEditInstruction() {
|
||||
if (!prompt.trim()) {
|
||||
error = 'Tell us what to change first.';
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!canSaveAreaPrompt) {
|
||||
error = 'Draw the area you want to change first.';
|
||||
if (selectionPoints.length > 0 && !hasAreaSelection) {
|
||||
error = 'Finish drawing the area you want to change, or cancel with X.';
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
mode,
|
||||
mode: hasAreaSelection ? 'area' : 'whole',
|
||||
prompt: prompt.trim(),
|
||||
selection: mode === 'area' ? selectionPoints : []
|
||||
selection: hasAreaSelection ? selectionPoints : []
|
||||
};
|
||||
}
|
||||
|
||||
@@ -103,30 +155,47 @@
|
||||
const instruction = getEditInstruction();
|
||||
if (!instruction || !jobId) return;
|
||||
|
||||
editing = true;
|
||||
const assistantMessageId = `assistant-${Date.now()}`;
|
||||
|
||||
chatMessages = [
|
||||
...chatMessages,
|
||||
{
|
||||
id: `user-${Date.now()}`,
|
||||
role: 'user',
|
||||
prompt: instruction.prompt,
|
||||
mode: instruction.mode
|
||||
},
|
||||
{
|
||||
id: assistantMessageId,
|
||||
role: 'assistant',
|
||||
status: 'pending'
|
||||
}
|
||||
];
|
||||
prompt = '';
|
||||
cancelAreaSelection();
|
||||
error = '';
|
||||
editing = true;
|
||||
|
||||
try {
|
||||
const result = await editImages(jobId, instruction);
|
||||
const afterImage = result.images?.primary ?? null;
|
||||
generatedImage = afterImage;
|
||||
editHistory = [
|
||||
...editHistory,
|
||||
{
|
||||
id: `${Date.now()}-${editHistory.length}`,
|
||||
instruction,
|
||||
afterImage
|
||||
}
|
||||
];
|
||||
prompt = '';
|
||||
selectionPoints = [];
|
||||
chatMessages = chatMessages.map((message) =>
|
||||
message.id === assistantMessageId
|
||||
? { ...message, status: 'done', afterImage }
|
||||
: message
|
||||
);
|
||||
saveFlow({
|
||||
editInstruction: instruction,
|
||||
imagePrompt: result.imagePrompt,
|
||||
mock: result.mock
|
||||
});
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'Edit failed';
|
||||
const message = err instanceof Error ? err.message : 'Edit failed';
|
||||
chatMessages = chatMessages.map((entry) =>
|
||||
entry.id === assistantMessageId ? { ...entry, status: 'error', error: message } : entry
|
||||
);
|
||||
error = message;
|
||||
} finally {
|
||||
editing = false;
|
||||
}
|
||||
@@ -174,6 +243,89 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
{#snippet editableImageFrame(image, editable = false)}
|
||||
<div class="relative w-[42%] overflow-hidden bg-track ring-1 ring-black/5">
|
||||
{#if image}
|
||||
<img
|
||||
src={toDataUrl(image)}
|
||||
alt="Generated bouquet"
|
||||
class={[
|
||||
'aspect-[4/5] w-full object-contain',
|
||||
editable && areaSelectionActive ? 'opacity-90' : ''
|
||||
]}
|
||||
draggable="false"
|
||||
/>
|
||||
{:else}
|
||||
<div class="aspect-[4/5] w-full bg-placeholder"></div>
|
||||
{/if}
|
||||
|
||||
{#if editable && image}
|
||||
<button
|
||||
type="button"
|
||||
class="absolute top-2 right-2 z-20 flex size-8 items-center justify-center rounded-full bg-white/95 text-ink shadow-md ring-1 ring-black/10 transition-colors hover:bg-white"
|
||||
aria-label={areaSelectionActive ? 'Cancel area selection' : 'Select area to edit'}
|
||||
onclick={toggleAreaTool}
|
||||
>
|
||||
{#if areaSelectionActive}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
class="size-4"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M18 6 6 18" />
|
||||
<path d="m6 6 12 12" />
|
||||
</svg>
|
||||
{:else}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="size-4"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M12 20h9" />
|
||||
<path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z" />
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
{#if areaSelectionActive}
|
||||
<svg
|
||||
class="absolute inset-0 z-10 h-full w-full touch-none"
|
||||
viewBox="0 0 100 100"
|
||||
preserveAspectRatio="none"
|
||||
role="application"
|
||||
aria-label="Draw an area to edit"
|
||||
onpointerdown={startDrawing}
|
||||
onpointermove={draw}
|
||||
onpointerup={stopDrawing}
|
||||
onpointercancel={stopDrawing}
|
||||
onpointerleave={stopDrawing}
|
||||
>
|
||||
{#if selectionPoints.length > 1}
|
||||
<polyline
|
||||
points={selectionPolyline}
|
||||
fill="rgba(239,68,68,0.12)"
|
||||
stroke="#ef4444"
|
||||
stroke-width="1.2"
|
||||
vector-effect="non-scaling-stroke"
|
||||
/>
|
||||
{/if}
|
||||
</svg>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<div
|
||||
class="flex h-dvh flex-col overflow-x-hidden bg-surface text-ink lg:h-screen lg:overflow-hidden"
|
||||
>
|
||||
@@ -199,156 +351,57 @@
|
||||
</section>
|
||||
|
||||
<section class="relative flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<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 pb-36 lg:py-6 lg:pb-6"
|
||||
>
|
||||
<div class="shrink-0">
|
||||
<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 class="min-h-0 flex-1 space-y-4 overflow-y-auto pr-1">
|
||||
<div bind:this={chatScrollEl} class="min-h-0 flex-1 space-y-4 overflow-y-auto pr-1">
|
||||
<div class="space-y-2">
|
||||
<p class="text-xs text-muted">Generated image</p>
|
||||
<div class="relative w-[42%] overflow-hidden bg-track ring-1 ring-black/5">
|
||||
{#if initialImage}
|
||||
<img
|
||||
src={toDataUrl(initialImage)}
|
||||
alt="Generated bouquet"
|
||||
class={[
|
||||
'aspect-[4/5] w-full object-contain',
|
||||
mode === 'area' && editHistory.length === 0 ? 'opacity-75' : ''
|
||||
]}
|
||||
draggable="false"
|
||||
/>
|
||||
{:else if imageSrc}
|
||||
<img
|
||||
src={imageSrc}
|
||||
alt="Generated bouquet"
|
||||
class={[
|
||||
'aspect-[4/5] w-full object-contain',
|
||||
mode === 'area' && editHistory.length === 0 ? 'opacity-75' : ''
|
||||
]}
|
||||
draggable="false"
|
||||
/>
|
||||
{:else}
|
||||
<div class="aspect-[4/5] w-full bg-placeholder"></div>
|
||||
{/if}
|
||||
|
||||
{#if mode === 'area' && editHistory.length === 0}
|
||||
<svg
|
||||
class="absolute inset-0 h-full w-full touch-none"
|
||||
viewBox="0 0 100 100"
|
||||
preserveAspectRatio="none"
|
||||
role="application"
|
||||
aria-label="Draw an area to edit"
|
||||
onpointerdown={startDrawing}
|
||||
onpointermove={draw}
|
||||
onpointerup={stopDrawing}
|
||||
onpointercancel={stopDrawing}
|
||||
onpointerleave={stopDrawing}
|
||||
>
|
||||
{#if selectionPoints.length > 1}
|
||||
<polyline
|
||||
points={selectionPolyline}
|
||||
fill="rgba(255,255,255,0.18)"
|
||||
stroke="white"
|
||||
stroke-width="0.8"
|
||||
stroke-dasharray="1.4 1.2"
|
||||
vector-effect="non-scaling-stroke"
|
||||
/>
|
||||
{#each selectionPoints.filter((_, index) => index % 8 === 0) as point, index (index)}
|
||||
<circle cx={point.x} cy={point.y} r="0.8" fill="white" />
|
||||
{/each}
|
||||
{/if}
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
{@render editableImageFrame(initialImage ?? generatedImage, chatMessages.length === 0)}
|
||||
</div>
|
||||
|
||||
{#each editHistory as edit (edit.id)}
|
||||
<div class="space-y-4">
|
||||
{#each chatMessages as message (message.id)}
|
||||
{#if message.role === 'user'}
|
||||
<div class="flex justify-end">
|
||||
<div
|
||||
class="max-w-[46%] rounded-3xl bg-pill px-4 py-3 text-sm leading-relaxed text-surface"
|
||||
>
|
||||
<p>{edit.instruction.prompt}</p>
|
||||
{#if edit.instruction.mode === 'area'}
|
||||
<p>{message.prompt}</p>
|
||||
{#if message.mode === 'area'}
|
||||
<p class="mt-2 text-xs opacity-70">Selected area only</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="relative w-[42%] overflow-hidden bg-track ring-1 ring-black/5">
|
||||
{#if edit.afterImage}
|
||||
<img
|
||||
src={toDataUrl(edit.afterImage)}
|
||||
alt="Edited bouquet result"
|
||||
class={[
|
||||
'aspect-[4/5] w-full object-contain',
|
||||
mode === 'area' && edit.id === latestEditId ? 'opacity-75' : ''
|
||||
]}
|
||||
draggable="false"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if mode === 'area' && edit.id === latestEditId}
|
||||
<svg
|
||||
class="absolute inset-0 h-full w-full touch-none"
|
||||
viewBox="0 0 100 100"
|
||||
preserveAspectRatio="none"
|
||||
role="application"
|
||||
aria-label="Draw an area to edit"
|
||||
onpointerdown={startDrawing}
|
||||
onpointermove={draw}
|
||||
onpointerup={stopDrawing}
|
||||
onpointercancel={stopDrawing}
|
||||
onpointerleave={stopDrawing}
|
||||
>
|
||||
{#if selectionPoints.length > 1}
|
||||
<polyline
|
||||
points={selectionPolyline}
|
||||
fill="rgba(255,255,255,0.18)"
|
||||
stroke="white"
|
||||
stroke-width="0.8"
|
||||
stroke-dasharray="1.4 1.2"
|
||||
vector-effect="non-scaling-stroke"
|
||||
/>
|
||||
{#each selectionPoints.filter((_, index) => index % 8 === 0) as point, index (index)}
|
||||
<circle cx={point.x} cy={point.y} r="0.8" fill="white" />
|
||||
{/each}
|
||||
{/if}
|
||||
</svg>
|
||||
{/if}
|
||||
{:else if message.status === 'pending'}
|
||||
<div class="flex justify-start">
|
||||
<div
|
||||
class="max-w-[46%] rounded-3xl bg-track px-4 py-3 text-sm leading-relaxed text-muted ring-1 ring-black/5"
|
||||
>
|
||||
Editing bouquet image...
|
||||
</div>
|
||||
</div>
|
||||
{:else if message.status === 'error'}
|
||||
<div class="flex justify-start">
|
||||
<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"
|
||||
>
|
||||
{message.error}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-2">
|
||||
{@render editableImageFrame(message.afterImage, message.id === latestAssistantId)}
|
||||
<p class="text-xs text-muted">Result</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 rounded-full bg-track p-1 ring-1 ring-black/5">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (mode = 'whole')}
|
||||
class={[
|
||||
'flex-1 rounded-full px-4 py-2 text-sm transition-colors',
|
||||
mode === 'whole' ? 'bg-pill text-surface' : 'text-muted hover:text-ink'
|
||||
]}
|
||||
>
|
||||
Whole image
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (mode = 'area')}
|
||||
class={[
|
||||
'flex-1 rounded-full px-4 py-2 text-sm transition-colors',
|
||||
mode === 'area' ? 'bg-pill text-surface' : 'text-muted hover:text-ink'
|
||||
]}
|
||||
>
|
||||
Select area
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 flex-wrap gap-2">
|
||||
{#each QUICK_PROMPTS as quickPrompt (quickPrompt)}
|
||||
<button
|
||||
@@ -360,63 +413,70 @@
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="shrink-0 space-y-2">
|
||||
<div
|
||||
class="fixed right-0 bottom-0 left-0 z-20 space-y-2 border-t border-line/60 bg-surface/95 px-4 pt-3 pb-5 backdrop-blur-sm lg:static lg:mx-auto lg:w-full lg:max-w-2xl lg:border-t-0 lg:bg-transparent lg:px-6 lg:pt-0 lg:pb-6 lg:backdrop-blur-none"
|
||||
>
|
||||
{#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}
|
||||
|
||||
<div class="flex items-center gap-2 rounded-full border border-pill bg-surface py-1.5 pr-1.5 pl-5">
|
||||
<textarea
|
||||
bind:value={prompt}
|
||||
rows="2"
|
||||
placeholder={mode === 'area'
|
||||
rows="1"
|
||||
placeholder={hasAreaSelection
|
||||
? 'Tell me how to change the selected area...'
|
||||
: 'Tell me how you would like to change your bouquet...'}
|
||||
class="w-full resize-none rounded-[2rem] border border-pill bg-surface px-6 py-3 text-sm outline-none placeholder:text-muted"
|
||||
: areaSelectionActive
|
||||
? 'Draw on the image, then describe the area...'
|
||||
: 'Tell me how you would like to change your bouquet...'}
|
||||
onkeydown={handlePromptKeydown}
|
||||
class="max-h-24 min-h-6 flex-1 resize-none border-none bg-transparent py-1 text-sm leading-normal outline-none placeholder:text-muted"
|
||||
></textarea>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-between gap-3 text-xs text-muted">
|
||||
<p>
|
||||
{#if mode === 'area'}
|
||||
Draw over the bouquet, then describe only that selected area.
|
||||
{:else}
|
||||
Prompt applies to the whole generated bouquet.
|
||||
{/if}
|
||||
</p>
|
||||
|
||||
{#if selectionPoints.length > 0}
|
||||
<button type="button" class="underline hover:text-ink" onclick={clearSelection}>
|
||||
Clear selection
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={editing ? 'Applying edit' : 'Send edit'}
|
||||
disabled={!prompt.trim() || editing || continuing}
|
||||
onclick={applyEdit}
|
||||
class="flex size-9 shrink-0 items-center justify-center rounded-full bg-pill text-surface transition-opacity disabled:opacity-40"
|
||||
>
|
||||
{#if editing}
|
||||
<span
|
||||
class="size-4 animate-spin rounded-full border-2 border-surface/30 border-t-surface"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
{:else}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="size-4"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M12 19V5" />
|
||||
<path d="m5 12 7-7 7 7" />
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="shrink-0 space-y-2">
|
||||
{#if error}
|
||||
<p class="rounded bg-surface/95 px-3 py-2 text-sm text-red-600 ring-1 ring-black/5">
|
||||
{error}
|
||||
</p>
|
||||
{:else if editing}
|
||||
<p class="rounded bg-surface/95 px-3 py-2 text-sm text-muted ring-1 ring-black/5">
|
||||
Editing bouquet image...
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="border border-pill px-4 py-3 text-sm text-ink disabled:opacity-50"
|
||||
disabled={!prompt.trim() || editing || continuing}
|
||||
onclick={applyEdit}
|
||||
>
|
||||
{editing ? 'Applying...' : 'Apply edit'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="bg-pill px-4 py-3 text-sm text-surface"
|
||||
disabled={editing || continuing}
|
||||
onclick={continueToResult}
|
||||
>
|
||||
{continuing ? 'Preparing result...' : 'Continue to result'}
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
disabled={editing || continuing}
|
||||
onclick={continueToResult}
|
||||
class="px-2 py-2 text-sm whitespace-nowrap text-ink underline-offset-4 hover:underline disabled:opacity-50"
|
||||
>
|
||||
{continuing ? 'Preparing result...' : 'Continue to result ->'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -24,11 +24,21 @@
|
||||
: 'moodboard'
|
||||
);
|
||||
let primaryFile = $state(null);
|
||||
let filledCount = $state(0);
|
||||
let allFilled = $state(false);
|
||||
let moodboardTiles = $state({
|
||||
color: false,
|
||||
season: false,
|
||||
character: false,
|
||||
location: false
|
||||
});
|
||||
let snsHasImage = $state(false);
|
||||
let loading = $state(false);
|
||||
let error = $state('');
|
||||
|
||||
const recipientLabel = $derived.by(() => {
|
||||
const who = typeof userInput.relationship === 'string' ? userInput.relationship : '';
|
||||
return who ? who.toLowerCase() : 'them';
|
||||
});
|
||||
|
||||
const recipientPronoun = $derived.by(() => {
|
||||
const style = typeof userInput.style === 'string' ? userInput.style.toLowerCase() : '';
|
||||
if (style === 'masculine') return 'his';
|
||||
@@ -36,35 +46,97 @@
|
||||
return 'their';
|
||||
});
|
||||
|
||||
const hasUserContext = $derived(
|
||||
Boolean(userInput.relationship || userInput.occasion || userInput.style)
|
||||
);
|
||||
const MOODBOARD_TILE_COPY = {
|
||||
color: {
|
||||
title: 'A hint of color',
|
||||
description:
|
||||
'The first thread pulled. Warm or cool, bold or shy. Their palette begins to speak.'
|
||||
},
|
||||
season: {
|
||||
title: 'Season in the air',
|
||||
description: 'Spring lightness or winter hush. Time of year will breathe through the bouquet.'
|
||||
},
|
||||
character: {
|
||||
title: 'Their character',
|
||||
description: 'A face, a gesture, a presence. Something in them is starting to take floral form.'
|
||||
},
|
||||
location: {
|
||||
title: 'A sense of place',
|
||||
description:
|
||||
'City grit or quiet coast. Where they belong roots the arrangement in memory.'
|
||||
}
|
||||
};
|
||||
|
||||
const artworkTitle = $derived.by(() => {
|
||||
const who = userInput.relationship;
|
||||
const whatFor = userInput.occasion;
|
||||
if (!hasUserContext) return 'Title';
|
||||
const occasion = whatFor ? `A ${whatFor} bouquet for` : 'A bouquet for';
|
||||
return `${occasion} ${who ?? '...'}`;
|
||||
const artworkCopy = $derived.by(() => {
|
||||
if (mode === 'sns') {
|
||||
if (snsHasImage) {
|
||||
return {
|
||||
title: 'Feed captured',
|
||||
description:
|
||||
'We will look at the photos and colors in their feed to sense what kind of bouquet fits them.'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: 'Their social world',
|
||||
description: `Upload a screenshot of ${recipientPronoun} feed. One glance is often enough to sense the mood.`
|
||||
};
|
||||
}
|
||||
|
||||
const uploaded = /** @type {const} */ (['color', 'season', 'character', 'location']).filter(
|
||||
(key) => moodboardTiles[key]
|
||||
);
|
||||
const count = uploaded.length;
|
||||
|
||||
if (count === 0) {
|
||||
return {
|
||||
title: 'Gather their mood',
|
||||
description: `Four small glimpses of color, season, character, and place. Together they become the palette for a bouquet made for ${recipientLabel}.`
|
||||
};
|
||||
}
|
||||
|
||||
if (count === 1) {
|
||||
return MOODBOARD_TILE_COPY[uploaded[0]];
|
||||
}
|
||||
|
||||
if (count === 4) {
|
||||
return {
|
||||
title: 'A moodboard whole',
|
||||
description:
|
||||
'Color, season, character, and place. The collage is complete, and their bouquet is ready to take shape.'
|
||||
};
|
||||
}
|
||||
|
||||
if (count === 2) {
|
||||
return {
|
||||
title: 'Taking shape',
|
||||
description:
|
||||
'The moodboard is finding its rhythm. Keep adding. Each image is another note in their story.'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: 'Almost there',
|
||||
description: 'One last glimpse and their world will be fully gathered on the page.'
|
||||
};
|
||||
});
|
||||
|
||||
const artworkDescription = $derived(
|
||||
hasUserContext
|
||||
? `${userInput.style ?? '—'} style · ₩${Number(userInput.budget ?? 50_000).toLocaleString('ko-KR')} budget`
|
||||
: 'Description Description Description'
|
||||
);
|
||||
const artworkTitle = $derived(artworkCopy.title);
|
||||
const artworkDescription = $derived(artworkCopy.description);
|
||||
|
||||
/** create2(시작) → upload1(1장+) → upload2(전체 채움) */
|
||||
const artworkVariant = $derived.by(() => {
|
||||
if (allFilled) return 'upload2';
|
||||
if (filledCount > 0) return 'upload1';
|
||||
return 'create2';
|
||||
});
|
||||
if (mode === 'sns') {
|
||||
if (snsHasImage) return 'upload2';
|
||||
return 'create2';
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
void mode;
|
||||
filledCount = 0;
|
||||
allFilled = false;
|
||||
const count = ['color', 'season', 'character', 'location'].filter(
|
||||
(key) => moodboardTiles[key]
|
||||
).length;
|
||||
if (count === 4) return 'upload2';
|
||||
if (count > 0) return 'upload1';
|
||||
return 'create2';
|
||||
});
|
||||
|
||||
async function continueToMessage() {
|
||||
@@ -126,15 +198,13 @@
|
||||
{#if mode === 'moodboard'}
|
||||
<MoodboardGrid
|
||||
bind:primaryFile
|
||||
bind:filledCount
|
||||
bind:allFilled
|
||||
bind:uploadedTiles={moodboardTiles}
|
||||
caption={`build ${recipientPronoun} moodboard!`}
|
||||
/>
|
||||
{:else}
|
||||
<SnsFeedUpload
|
||||
bind:primaryFile
|
||||
bind:filledCount
|
||||
bind:allFilled
|
||||
bind:hasImage={snsHasImage}
|
||||
caption={`upload ${recipientPronoun} feed!`}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user