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:
@@ -2,33 +2,50 @@
|
||||
// The exhibited artwork — always shown on the left, acting like a step indicator.
|
||||
import Vase from './Vase.svelte';
|
||||
import DescriptionCard from './DescriptionCard.svelte';
|
||||
import ComingSoonTape from './ComingSoonTape.svelte';
|
||||
|
||||
let {
|
||||
title = 'Title',
|
||||
description = 'Description Description Description',
|
||||
/** options Continue 이후 확정된 꽃다발만 전달 (그 전에는 null → Vase) */
|
||||
imageSrc = null
|
||||
/** @type {import('./artworkVariants.js').ArtworkVariant} */
|
||||
variant = 'create1',
|
||||
/** edit Continue 이후 확정된 꽃다발만 전달 (그 전에는 null → Vase) */
|
||||
imageSrc = null,
|
||||
/** generating 단계: 작품 중앙 Coming Soon 밴드 */
|
||||
comingSoon = false
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<section
|
||||
class="flex w-full shrink-0 flex-col border-b border-line lg:min-h-0 lg:w-[44%] lg:shrink-0 lg:overflow-y-auto lg:border-r lg:border-b-0"
|
||||
class="relative flex w-full shrink-0 flex-col border-b border-line lg:min-h-0 lg:h-full lg:w-[44%] lg:shrink-0 lg:overflow-y-auto lg:border-r lg:border-b-0"
|
||||
>
|
||||
<!-- mobile: compact row · desktop: centered column -->
|
||||
<!--
|
||||
mobile: row · desktop: 꽃 슬롯 높이 고정 → 설명 카드 길이와 무관하게 Y·크기 유지
|
||||
-->
|
||||
<div
|
||||
class="mx-auto flex w-full max-w-100 flex-row items-center gap-12 px-6 py-5 lg:flex-1 lg:flex-col lg:items-center lg:justify-center lg:gap-10 lg:px-6 lg:py-12"
|
||||
class="mx-auto flex min-h-0 w-full max-w-100 flex-1 flex-row items-start gap-8 px-6 py-6 lg:flex-col lg:items-center lg:justify-start lg:gap-4 lg:px-6 lg:pb-8 lg:pt-[calc(50%-5rem)]"
|
||||
>
|
||||
{#if imageSrc}
|
||||
<div class="mx-auto w-full max-w-24 shrink-0 overflow-hidden sm:max-w-28 lg:max-w-75">
|
||||
<img
|
||||
src={imageSrc}
|
||||
alt="Selected bouquet"
|
||||
class="aspect-[3/4] h-auto w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<Vase />
|
||||
{/if}
|
||||
<DescriptionCard {title} {description} />
|
||||
<div
|
||||
class="flex h-[11rem] shrink-0 items-end justify-center sm:h-[13rem] lg:h-[min(24rem,36vh)] lg:w-full"
|
||||
>
|
||||
{#if imageSrc}
|
||||
<div class="mx-auto w-full max-w-24 shrink-0 overflow-hidden sm:max-w-28 lg:max-w-75">
|
||||
<img
|
||||
src={imageSrc}
|
||||
alt="Selected bouquet"
|
||||
class="aspect-[3/4] h-auto w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<Vase {variant} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 shrink-0 lg:w-full lg:flex lg:justify-center">
|
||||
<DescriptionCard {title} {description} />
|
||||
</div>
|
||||
</div>
|
||||
{#if comingSoon}
|
||||
<ComingSoonTape />
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
11
src/lib/components/ui/Artwork/ComingSoonTape.svelte
Normal file
11
src/lib/components/ui/Artwork/ComingSoonTape.svelte
Normal file
@@ -0,0 +1,11 @@
|
||||
<!-- generating: 왼쪽 섹션 전체 가로 + blur, vase 높이 중앙 -->
|
||||
<div
|
||||
class="pointer-events-none absolute inset-x-0 top-[calc(50%-1.25rem)] z-30 -translate-y-1/2"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div
|
||||
class="w-full border-y border-subtle/30 bg-muted/45 py-2.5 text-center backdrop-blur-md lg:py-3"
|
||||
>
|
||||
<p class="text-sm font-light tracking-wide text-ink lg:text-base">Coming Soon</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,11 +1,16 @@
|
||||
<script>
|
||||
import vaseIllustration from '$lib/assets/vase-illustration.svg';
|
||||
import { getArtworkSrc } from './artworkVariants.js';
|
||||
|
||||
/** @type {import('./artworkVariants.js').ArtworkVariant} */
|
||||
let { variant = 'create1' } = $props();
|
||||
|
||||
const src = $derived(getArtworkSrc(variant));
|
||||
</script>
|
||||
|
||||
<img
|
||||
src={vaseIllustration}
|
||||
{src}
|
||||
alt=""
|
||||
class="mx-auto h-auto w-full max-w-24 shrink-0 sm:max-w-28 lg:max-w-75"
|
||||
width="320"
|
||||
height="452"
|
||||
width="328"
|
||||
height="443"
|
||||
/>
|
||||
|
||||
32
src/lib/components/ui/Artwork/artworkVariants.js
Normal file
32
src/lib/components/ui/Artwork/artworkVariants.js
Normal file
@@ -0,0 +1,32 @@
|
||||
import create1 from '$lib/assets/artwork/1.create1.svg';
|
||||
import create2 from '$lib/assets/artwork/2.create2.svg';
|
||||
import upload1 from '$lib/assets/artwork/3.upload1.svg';
|
||||
import upload2 from '$lib/assets/artwork/4.upload2.svg';
|
||||
import message1 from '$lib/assets/artwork/5.message1.svg';
|
||||
import generated from '$lib/assets/artwork/6.generated.svg';
|
||||
|
||||
/** @typedef {'create1' | 'create2' | 'upload1' | 'upload2' | 'message1' | 'generated'} ArtworkVariant */
|
||||
|
||||
/** @type {Record<ArtworkVariant, string>} */
|
||||
export const ARTWORK_SRC = {
|
||||
create1,
|
||||
create2,
|
||||
upload1,
|
||||
upload2,
|
||||
message1,
|
||||
generated
|
||||
};
|
||||
|
||||
/** generating 페이지 순환 프레임 */
|
||||
export const GENERATING_ARTWORK_CYCLE = /** @type {const} */ ([
|
||||
'create2',
|
||||
'upload1',
|
||||
'upload2',
|
||||
'message1',
|
||||
'generated'
|
||||
]);
|
||||
|
||||
/** @param {ArtworkVariant} [variant='create1'] */
|
||||
export function getArtworkSrc(variant = 'create1') {
|
||||
return ARTWORK_SRC[variant] ?? ARTWORK_SRC.create1;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<script>
|
||||
import GenerationStepItem from './GenerationStepItem.svelte';
|
||||
import { GENERATION_STEPS, GENERATION_STEP_COUNT } from './generationSteps.js';
|
||||
|
||||
let {
|
||||
/** 현재 active 단계 (0–6). GENERATION_STEP_COUNT이면 전부 완료 */
|
||||
activeStepIndex = 0,
|
||||
error = '',
|
||||
retryLabel = '',
|
||||
canRetry = false,
|
||||
onRetry = () => {},
|
||||
onBack = () => {}
|
||||
} = $props();
|
||||
|
||||
/**
|
||||
* @param {number} index
|
||||
* @returns {'completed' | 'active' | 'pending'}
|
||||
*/
|
||||
function stepStatus(index) {
|
||||
if (activeStepIndex >= GENERATION_STEP_COUNT) return 'completed';
|
||||
if (index < activeStepIndex) return 'completed';
|
||||
if (index === activeStepIndex) return 'active';
|
||||
return 'pending';
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-1 flex-col justify-center px-6 py-10 md:px-12 lg:px-16 lg:py-16">
|
||||
<header class="mb-10 space-y-3 lg:mb-14">
|
||||
<h1 class="text-3xl leading-relaxed font-light text-muted md:text-4xl lg:text-[2.75rem]">
|
||||
Creating your bouquet...
|
||||
</h1>
|
||||
{#if retryLabel}
|
||||
<p class="text-sm text-muted">{retryLabel}</p>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
<ol class="space-y-4 lg:space-y-5" aria-label="Bouquet creation progress">
|
||||
{#each GENERATION_STEPS as label, index (label)}
|
||||
<GenerationStepItem {label} status={stepStatus(index)} />
|
||||
{/each}
|
||||
</ol>
|
||||
|
||||
{#if error}
|
||||
<div class="mt-10 space-y-4">
|
||||
<p class="rounded bg-surface/95 px-3 py-2 text-sm text-red-600 ring-1 ring-black/5">
|
||||
{error}
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
{#if canRetry}
|
||||
<button type="button" class="bg-pill px-4 py-2 text-sm text-surface" onclick={onRetry}>
|
||||
Try again
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="border border-pill px-4 py-2 text-sm text-ink"
|
||||
onclick={onBack}
|
||||
>
|
||||
Back to message
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
41
src/lib/components/ui/generating/GenerationStepItem.svelte
Normal file
41
src/lib/components/ui/generating/GenerationStepItem.svelte
Normal file
@@ -0,0 +1,41 @@
|
||||
<script>
|
||||
/** @type {'completed' | 'active' | 'pending'} */
|
||||
let { label, status = 'pending' } = $props();
|
||||
</script>
|
||||
|
||||
<li
|
||||
class={[
|
||||
'flex items-start gap-3 text-lg tracking-wide transition-all duration-500 md:text-xs',
|
||||
status === 'completed' && 'step-completed text-ink opacity-100',
|
||||
status === 'active' && 'text-ink',
|
||||
status === 'pending' && 'text-muted/50'
|
||||
]}
|
||||
>
|
||||
<span class="mt-0.5 w-5 shrink-0 text-center leading-none" aria-hidden="true">
|
||||
{#if status === 'completed'}
|
||||
<span class="inline-block">✓</span>
|
||||
{:else if status === 'active'}
|
||||
<span class="inline-block animate-pulse">●</span>
|
||||
{:else}
|
||||
<span class="inline-block">○</span>
|
||||
{/if}
|
||||
</span>
|
||||
<span class="leading-snug">{label}</span>
|
||||
</li>
|
||||
|
||||
<style>
|
||||
.step-completed {
|
||||
animation: stepFadeIn 0.5s ease-out;
|
||||
}
|
||||
|
||||
@keyframes stepFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
12
src/lib/components/ui/generating/generationSteps.js
Normal file
12
src/lib/components/ui/generating/generationSteps.js
Normal file
@@ -0,0 +1,12 @@
|
||||
/** AI activity feed 단계 라벨 (generating 페이지) */
|
||||
export const GENERATION_STEPS = [
|
||||
'Understanding who this bouquet is for',
|
||||
'Discovering their mood and aesthetic',
|
||||
'Finding flowers that match their personality',
|
||||
'Exploring meaningful flower symbolism',
|
||||
'Designing the bouquet composition',
|
||||
'Choosing the wrapping style',
|
||||
'Adding the finishing ribbon'
|
||||
];
|
||||
|
||||
export const GENERATION_STEP_COUNT = GENERATION_STEPS.length;
|
||||
@@ -1,20 +1,49 @@
|
||||
<script>
|
||||
/** @typedef {{ text: string, highlight: boolean }} OrderMessageSegment */
|
||||
|
||||
let {
|
||||
plainText = '',
|
||||
segments = /** @type {OrderMessageSegment[]} */ ([])
|
||||
enPlainText = '',
|
||||
koPlainText = ''
|
||||
} = $props();
|
||||
|
||||
/** @type {'ko' | 'en'} */
|
||||
let activeLang = $state('ko');
|
||||
let textEn = $state('');
|
||||
let textKo = $state('');
|
||||
let seeded = $state(false);
|
||||
let copied = $state(false);
|
||||
|
||||
const hasMessage = $derived(Boolean(plainText?.trim()));
|
||||
$effect(() => {
|
||||
if (!seeded && (enPlainText || koPlainText)) {
|
||||
textEn = enPlainText;
|
||||
textKo = koPlainText;
|
||||
seeded = true;
|
||||
}
|
||||
});
|
||||
|
||||
const activeText = $derived(activeLang === 'ko' ? textKo : textEn);
|
||||
const hasMessage = $derived(
|
||||
Boolean(activeText?.trim()) || Boolean(textEn?.trim()) || Boolean(textKo?.trim())
|
||||
);
|
||||
|
||||
/** @param {Event & { currentTarget: HTMLTextAreaElement }} event */
|
||||
function handleInput(event) {
|
||||
const value = event.currentTarget.value;
|
||||
if (activeLang === 'ko') {
|
||||
textKo = value;
|
||||
} else {
|
||||
textEn = value;
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {'ko' | 'en'} lang */
|
||||
function setLanguage(lang) {
|
||||
activeLang = lang;
|
||||
}
|
||||
|
||||
async function handleCopy() {
|
||||
if (!hasMessage) return;
|
||||
if (!activeText.trim()) return;
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(plainText);
|
||||
await navigator.clipboard.writeText(activeText);
|
||||
copied = true;
|
||||
setTimeout(() => {
|
||||
copied = false;
|
||||
@@ -25,29 +54,50 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="flex items-start gap-3">
|
||||
{#if hasMessage}
|
||||
<p class="min-w-0 flex-1 text-sm leading-relaxed text-muted">
|
||||
{#each segments as segment, index (index)}
|
||||
{#if segment.highlight}
|
||||
<span class="text-pill">{'{'}</span><span class="font-medium text-ink"
|
||||
>{segment.text}</span
|
||||
><span class="text-pill">{'}'}</span>
|
||||
{:else}
|
||||
{segment.text}
|
||||
{/if}
|
||||
{/each}
|
||||
</p>
|
||||
<textarea
|
||||
class="min-h-[5.5rem] min-w-0 flex-1 resize-y rounded border border-line bg-transparent px-3 py-2 text-sm leading-relaxed text-muted focus:border-line-strong focus:outline-none"
|
||||
rows={4}
|
||||
value={activeText}
|
||||
oninput={handleInput}
|
||||
aria-label={activeLang === 'ko' ? '꽃집 주문 멘트 (한국어)' : 'Florist order message (English)'}
|
||||
></textarea>
|
||||
{:else}
|
||||
<p class="text-sm text-muted">Complete the flow to generate your order message.</p>
|
||||
<p class="min-w-0 flex-1 text-sm text-muted">Complete the flow to generate your order message.</p>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={!hasMessage}
|
||||
onclick={handleCopy}
|
||||
class="shrink-0 rounded bg-pill px-3 py-1.5 text-xs text-surface disabled:opacity-40"
|
||||
>
|
||||
{copied ? 'Copied!' : 'Copy'}
|
||||
</button>
|
||||
<div class="flex shrink-0 flex-col items-stretch gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!hasMessage}
|
||||
onclick={handleCopy}
|
||||
class="rounded bg-pill px-3 py-1.5 text-xs text-surface disabled:opacity-40"
|
||||
>
|
||||
{copied ? 'Copied!' : 'Copy'}
|
||||
</button>
|
||||
|
||||
<div class="flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!hasMessage}
|
||||
onclick={() => setLanguage('ko')}
|
||||
class="flex-1 rounded border px-2 py-1.5 text-xs disabled:opacity-40 {activeLang === 'ko'
|
||||
? 'border-pill bg-pill text-surface'
|
||||
: 'border-line text-muted hover:border-line-strong'}"
|
||||
>
|
||||
Kor
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!hasMessage}
|
||||
onclick={() => setLanguage('en')}
|
||||
class="flex-1 rounded border px-2 py-1.5 text-xs disabled:opacity-40 {activeLang === 'en'
|
||||
? 'border-pill bg-pill text-surface'
|
||||
: 'border-line text-muted hover:border-line-strong'}"
|
||||
>
|
||||
Eng
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
mock = false,
|
||||
fitBounds = false,
|
||||
orderPlainText = '',
|
||||
orderSegments = [],
|
||||
orderKoPlainText = '',
|
||||
onrefresh
|
||||
} = $props();
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
</header>
|
||||
|
||||
<div class="shrink-0 px-6 pb-4 md:px-10 lg:px-12">
|
||||
<FloristOrderMessage plainText={orderPlainText} segments={orderSegments} />
|
||||
<FloristOrderMessage enPlainText={orderPlainText} koPlainText={orderKoPlainText} />
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import { hydrateDevUpload } from '$lib/dev/hydrateUpload.js';
|
||||
import { getFlowObject, isDevSeeded } from '$lib/flowerFlow/session.js';
|
||||
|
||||
let { primaryFile = $bindable(null), caption = 'build their moodboard!' } = $props();
|
||||
let { primaryFile = $bindable(null), caption = 'build their moodboard!', filledCount = $bindable(0), allFilled = $bindable(false) } = $props();
|
||||
|
||||
let colorFile = $state(null);
|
||||
let seasonFile = $state(null);
|
||||
@@ -16,6 +16,12 @@
|
||||
if (primaryFile !== next) primaryFile = next;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const count = [colorFile, seasonFile, characterFile, locationFile].filter(Boolean).length;
|
||||
filledCount = count;
|
||||
allFilled = count === 4;
|
||||
});
|
||||
|
||||
onMount(async () => {
|
||||
const devUpload = getFlowObject('devUpload');
|
||||
if (!isDevSeeded() || !devUpload?.active) return;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import { hydrateDevUpload } from '$lib/dev/hydrateUpload.js';
|
||||
import { getFlowObject, isDevSeeded } from '$lib/flowerFlow/session.js';
|
||||
|
||||
let { primaryFile = $bindable(null), caption = 'upload their feed!' } = $props();
|
||||
let { primaryFile = $bindable(null), caption = 'upload their feed!', filledCount = $bindable(0), allFilled = $bindable(false) } = $props();
|
||||
|
||||
let firstFile = $state(null);
|
||||
|
||||
@@ -13,6 +13,11 @@
|
||||
if (primaryFile !== next) primaryFile = next;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
filledCount = firstFile ? 1 : 0;
|
||||
allFilled = Boolean(firstFile);
|
||||
});
|
||||
|
||||
onMount(async () => {
|
||||
const devUpload = getFlowObject('devUpload');
|
||||
if (!isDevSeeded() || !devUpload?.active) return;
|
||||
|
||||
Reference in New Issue
Block a user