feat: add upload page UI (moodboard + SNS feed)

* feat: add upload page initial UI

* feat: add moodboard and SNS feed upload layouts

* fix: improve mobile upload page layout
This commit is contained in:
Chaewon Lee
2026-06-08 15:02:44 +09:00
committed by GitHub
parent 8186066d3a
commit e9dcd22b53
11 changed files with 347 additions and 1 deletions

View File

@@ -0,0 +1,28 @@
<svg width="320" height="452" viewBox="0 0 320 452" fill="none" xmlns="http://www.w3.org/2000/svg">
<foreignObject x="48" y="225" width="228" height="115"><div xmlns="http://www.w3.org/1999/xhtml" style="backdrop-filter:blur(10px);clip-path:url(#bgblur_0_30_37_clip_path);height:100%;width:100%"></div></foreignObject><path data-figma-bg-blur-radius="20" d="M177.866 245L68 290.254L177.866 320L256 283.39L177.866 245Z" fill="#7D7D7D" fill-opacity="0.7"/>
<foreignObject x="91" y="393" width="138" height="79"><div xmlns="http://www.w3.org/1999/xhtml" style="backdrop-filter:blur(10px);clip-path:url(#bgblur_1_30_37_clip_path);height:100%;width:100%"></div></foreignObject><path data-figma-bg-blur-radius="20" d="M159.14 452L111 443.25L131.632 424L169.8 413L209 442.5L159.14 452Z" fill="#7D7D7D" fill-opacity="0.7"/>
<foreignObject x="48" y="270" width="160" height="193"><div xmlns="http://www.w3.org/1999/xhtml" style="backdrop-filter:blur(10px);clip-path:url(#bgblur_2_30_37_clip_path);height:100%;width:100%"></div></foreignObject><path data-figma-bg-blur-radius="20" d="M188 307.566L68 290L124.932 379.865V391.576L111.295 443L166.523 421.87V391.576L188 307.566Z" fill="#BBBBBB" fill-opacity="0.7"/>
<path d="M149 335.5C167.167 318.333 199 271.7 181 222.5C158.5 161 232.5 146.5 247.5 144.5C262.5 142.5 311 132.5 300 82" stroke="#7D7D7D" stroke-width="6"/>
<path d="M182.608 409.5C164.441 392.333 132.608 345.7 150.608 296.5C173.108 235 99.108 220.5 84.108 218.5C69.108 216.5 20.608 206.5 31.608 156" stroke="#7D7D7D" stroke-width="6"/>
<foreignObject x="219" y="36" width="111" height="91"><div xmlns="http://www.w3.org/1999/xhtml" style="backdrop-filter:blur(5px);clip-path:url(#bgblur_3_30_37_clip_path);height:100%;width:100%"></div></foreignObject><rect data-figma-bg-blur-radius="10" x="229" y="46" width="91" height="71" fill="#D9D9D9" fill-opacity="0.8"/>
<foreignObject x="181" y="5" width="87" height="51"><div xmlns="http://www.w3.org/1999/xhtml" style="backdrop-filter:blur(5px);clip-path:url(#bgblur_4_30_37_clip_path);height:100%;width:100%"></div></foreignObject><rect data-figma-bg-blur-radius="10" x="191" y="15" width="67" height="31" fill="#D9D9D9" fill-opacity="0.8"/>
<foreignObject x="248" y="-10" width="46" height="35"><div xmlns="http://www.w3.org/1999/xhtml" style="backdrop-filter:blur(5px);clip-path:url(#bgblur_5_30_37_clip_path);height:100%;width:100%"></div></foreignObject><rect data-figma-bg-blur-radius="10" x="258" width="26" height="15" fill="#D9D9D9" fill-opacity="0.8"/>
<foreignObject x="128" y="128" width="111" height="91"><div xmlns="http://www.w3.org/1999/xhtml" style="backdrop-filter:blur(5px);clip-path:url(#bgblur_6_30_37_clip_path);height:100%;width:100%"></div></foreignObject><rect data-figma-bg-blur-radius="10" x="138" y="138" width="91" height="71" fill="#D9D9D9" fill-opacity="0.8"/>
<foreignObject x="-10" y="98" width="80" height="80"><div xmlns="http://www.w3.org/1999/xhtml" style="backdrop-filter:blur(5px);clip-path:url(#bgblur_7_30_37_clip_path);height:100%;width:100%"></div></foreignObject><rect data-figma-bg-blur-radius="10" y="108" width="60" height="60" fill="#D9D9D9" fill-opacity="0.8"/>
<foreignObject x="47" y="52" width="43" height="43"><div xmlns="http://www.w3.org/1999/xhtml" style="backdrop-filter:blur(5px);clip-path:url(#bgblur_8_30_37_clip_path);height:100%;width:100%"></div></foreignObject><rect data-figma-bg-blur-radius="10" x="57" y="62" width="23" height="23" fill="#D9D9D9" fill-opacity="0.8"/>
<foreignObject x="11" y="75" width="59" height="43"><div xmlns="http://www.w3.org/1999/xhtml" style="backdrop-filter:blur(5px);clip-path:url(#bgblur_9_30_37_clip_path);height:100%;width:100%"></div></foreignObject><rect data-figma-bg-blur-radius="10" x="21" y="108" width="23" height="39" transform="rotate(-90 21 108)" fill="#D9D9D9" fill-opacity="0.8"/>
<foreignObject x="117" y="274" width="148" height="178"><div xmlns="http://www.w3.org/1999/xhtml" style="backdrop-filter:blur(5px);clip-path:url(#bgblur_10_30_37_clip_path);height:100%;width:100%"></div></foreignObject><path data-figma-bg-blur-radius="10" d="M127 326.86L255 284L193.383 379.612V390.263L208.362 442L170.915 431.095L127 326.86Z" fill="#D9D9D9" fill-opacity="0.5"/>
<defs>
<clipPath id="bgblur_0_30_37_clip_path" transform="translate(-48 -225)"><path d="M177.866 245L68 290.254L177.866 320L256 283.39L177.866 245Z"/>
</clipPath><clipPath id="bgblur_1_30_37_clip_path" transform="translate(-91 -393)"><path d="M159.14 452L111 443.25L131.632 424L169.8 413L209 442.5L159.14 452Z"/>
</clipPath><clipPath id="bgblur_2_30_37_clip_path" transform="translate(-48 -270)"><path d="M188 307.566L68 290L124.932 379.865V391.576L111.295 443L166.523 421.87V391.576L188 307.566Z"/>
</clipPath><clipPath id="bgblur_3_30_37_clip_path" transform="translate(-219 -36)"><rect x="229" y="46" width="91" height="71"/>
</clipPath><clipPath id="bgblur_4_30_37_clip_path" transform="translate(-181 -5)"><rect x="191" y="15" width="67" height="31"/>
</clipPath><clipPath id="bgblur_5_30_37_clip_path" transform="translate(-248 10)"><rect x="258" width="26" height="15"/>
</clipPath><clipPath id="bgblur_6_30_37_clip_path" transform="translate(-128 -128)"><rect x="138" y="138" width="91" height="71"/>
</clipPath><clipPath id="bgblur_7_30_37_clip_path" transform="translate(10 -98)"><rect y="108" width="60" height="60"/>
</clipPath><clipPath id="bgblur_8_30_37_clip_path" transform="translate(-47 -52)"><rect x="57" y="62" width="23" height="23"/>
</clipPath><clipPath id="bgblur_9_30_37_clip_path" transform="translate(-11 -75)"><rect x="21" y="108" width="23" height="39" transform="rotate(-90 21 108)"/>
</clipPath><clipPath id="bgblur_10_30_37_clip_path" transform="translate(-117 -274)"><path d="M127 326.86L255 284L193.383 379.612V390.263L208.362 442L170.915 431.095L127 326.86Z"/>
</clipPath></defs>
</svg>

After

Width:  |  Height:  |  Size: 5.7 KiB

View File

@@ -0,0 +1,19 @@
<script>
// The exhibited artwork — always shown on the left, acting like a step indicator.
import Vase from './Vase.svelte';
import DescriptionCard from './DescriptionCard.svelte';
let { title = 'Title', description = 'Description Description Description' } = $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"
>
<!-- mobile: compact row · desktop: centered column -->
<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"
>
<Vase />
<DescriptionCard {title} {description} />
</div>
</section>

View File

@@ -0,0 +1,8 @@
<script>
let { title = 'Title', description = 'Description Description Description' } = $props();
</script>
<div class="min-w-0 flex-1 border border-line-strong px-4 py-3 lg:w-54 lg:flex-none lg:px-6 lg:py-5">
<h3 class="text-sm">{title}</h3>
<p class="mt-2 text-xs leading-snug">{description}</p>
</div>

View File

@@ -0,0 +1,11 @@
<script>
import vaseIllustration from '$lib/assets/vase-illustration.svg';
</script>
<img
src={vaseIllustration}
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"
/>

View File

@@ -0,0 +1,21 @@
<script>
// `step` is 1-based; the matching dot is highlighted as the current step.
let { step = 1, total = 6 } = $props();
const dots = $derived(Array.from({ length: total }, (_, i) => i));
</script>
<header class="flex items-center justify-between border-b border-line px-6 py-5 md:px-10">
<div class="flex items-center gap-3">
<span class="size-4 shrink-0 bg-placeholder"></span>
<span class="text-lg tracking-wide">AI Florist</span>
</div>
<div class="flex items-center gap-3 sm:gap-4">
{#each dots as dot (dot)}
<span
class={['rounded-[1px]', dot === step - 1 ? 'size-2.5 bg-subtle' : 'size-2 bg-placeholder']}
></span>
{/each}
</div>
</header>

View File

@@ -0,0 +1,62 @@
<script>
import UploadTile from './UploadTile.svelte';
// One reference image per category, laid out as an interlocking collage
// (offset seams, varied heights) instead of equal quarters.
const tiles = [
{ key: 'color', label: 'Color', aspect: 'aspect-4/5' },
{ key: 'season', label: 'Season', aspect: 'aspect-4/3' },
{ key: 'character', label: 'Character', aspect: 'aspect-4/3' },
{ key: 'location', label: 'Location', aspect: 'aspect-4/5' }
];
</script>
<div class="moodboard w-full min-h-0 flex-1">
{#each tiles as tile (tile.key)}
<UploadTile
label={tile.label}
class="tile tile-{tile.key} h-full min-h-0 w-full max-lg:aspect-auto lg:aspect-auto {tile.aspect}"
/>
{/each}
</div>
<style>
.moodboard {
display: grid;
gap: 0;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
width: 100%;
flex: 1;
min-height: 0;
}
@media (min-width: 1024px) {
.moodboard {
grid-template-rows: repeat(5, 1fr);
grid-template-areas:
'color season'
'color season'
'color location'
'character location'
'character location';
min-height: 34rem;
}
:global(.tile-color) {
grid-area: color;
}
:global(.tile-season) {
grid-area: season;
}
:global(.tile-character) {
grid-area: character;
}
:global(.tile-location) {
grid-area: location;
}
}
</style>

View File

@@ -0,0 +1,45 @@
<script>
import UploadTile from './UploadTile.svelte';
// Two SNS feed screenshots. On desktop they fill the panel edge-to-edge in
// a staggered composition (one raised on the right, one dropped on the
// left); below that they fall back to a simple side-by-side / stacked grid.
</script>
<div class="feed w-full min-h-0 flex-1">
<UploadTile class="tile-one h-full min-h-0 w-full max-lg:aspect-auto lg:aspect-auto aspect-4/5" />
<UploadTile class="tile-two h-full min-h-0 w-full max-lg:aspect-auto lg:aspect-auto aspect-4/5" />
</div>
<style>
.feed {
display: grid;
gap: 0;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr;
width: 100%;
flex: 1;
min-height: 0;
}
@media (min-width: 1024px) {
.feed {
grid-template-rows: repeat(5, 1fr);
grid-template-areas:
'. two'
'one two'
'one two'
'one two'
'one .';
min-height: 34rem;
}
:global(.tile-one) {
grid-area: one;
}
:global(.tile-two) {
grid-area: two;
}
}
</style>

View File

@@ -0,0 +1,66 @@
<script>
import { onDestroy } from 'svelte';
// A single click-to-upload slot: a light bordered placeholder when empty,
// the chosen image (cover) when filled. Layout (size / grid placement) is
// supplied by the parent via `class` and `style` so the same tile works in
// both the moodboard and the SNS feed.
let { label = null, class: klass = '', style = '' } = $props();
let preview = $state(null);
function pick(event) {
const file = event.currentTarget.files?.[0];
if (!file) return;
if (preview) URL.revokeObjectURL(preview);
preview = URL.createObjectURL(file);
}
onDestroy(() => {
if (preview) URL.revokeObjectURL(preview);
});
</script>
<label
class={[
'group relative flex cursor-pointer items-center justify-center overflow-hidden bg-track transition-colors',
!preview && 'border border-line hover:border-line-strong',
klass
]}
{style}
>
<input
type="file"
accept="image/*"
class="sr-only"
aria-label={label ? `Add a ${label} image` : 'Add an image'}
onchange={pick}
/>
{#if preview}
<img src={preview} alt={label ?? ''} class="h-full w-full object-cover" />
<div class="absolute inset-0 bg-gradient-to-t from-black/45 to-transparent"></div>
{#if label}
<span class="absolute bottom-3 left-4 text-sm tracking-[0.15em] text-surface uppercase"
>{label}</span
>
{/if}
<span
class="absolute top-3 right-3 rounded-full bg-black/40 px-2.5 py-1 text-xs text-surface opacity-0 backdrop-blur-sm transition-opacity group-hover:opacity-100"
>
Change
</span>
{:else}
<div
class="flex flex-col items-center gap-3 text-subtle transition-transform group-hover:scale-105"
>
<span
class="flex size-10 items-center justify-center rounded-full border border-current text-xl leading-none"
aria-hidden="true">+</span
>
{#if label}
<span class="text-sm tracking-[0.15em] uppercase">{label}</span>
{/if}
</div>
{/if}
</label>

View File

@@ -5,5 +5,10 @@
let { children } = $props();
</script>
<svelte:head><link rel="icon" href={favicon} /></svelte:head>
<svelte:head>
<link rel="icon" href={favicon} />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous" />
<link href="https://fonts.googleapis.com/css2?family=Orbit&display=swap" rel="stylesheet" />
</svelte:head>
{@render children()}

View File

@@ -1 +1,17 @@
@import 'tailwindcss';
@theme {
/* Typography — Orbit everywhere (set as the default font) */
--font-sans: 'Orbit', monospace;
/* Color tokens */
--color-surface: #eeeeee; /* page background, text on dark */
--color-ink: #38322f; /* primary text */
--color-pill: #1a1a1a; /* active toggle pill */
--color-track: #f4f4f4; /* toggle track */
--color-muted: #9e9e9e; /* inactive / secondary text */
--color-subtle: #7d7d7d; /* accent gray (active step dot, illustration) */
--color-placeholder: #d9d9d9; /* placeholder fills, inactive step dots */
--color-line: #e5e7eb; /* dividers / light borders */
--color-line-strong: #d1d5db; /* card border */
}

View File

@@ -0,0 +1,65 @@
<script>
import Header from '$lib/components/ui/Header.svelte';
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';
// "Build Moodboard" is selected by default in the design
let mode = $state('moodboard');
</script>
<!--
On desktop the split layout is locked to the viewport height so the left
artwork stays put while switching modes. The right panel is a full-bleed
upload canvas with the mode toggle floating over it.
-->
<div
class="flex h-dvh flex-col overflow-x-hidden bg-surface text-ink lg:h-screen lg:overflow-hidden"
>
<Header step={3} total={6} />
<main class="flex min-h-0 flex-1 flex-col lg:flex-row">
<Artwork />
<!-- Right panel: full-bleed workspace + floating tab switch -->
<section
class="relative flex min-h-0 flex-1 flex-col pb-[4.75rem] lg:overflow-hidden lg:pb-0"
>
{#if mode === 'moodboard'}
<MoodboardGrid />
{:else}
<SnsFeedUpload />
{/if}
<!-- full-width on mobile; centered pill on desktop -->
<div
class="fixed right-0 bottom-0 left-0 z-20 px-4 pb-5 lg:absolute lg:right-auto lg:bottom-8 lg:left-1/2 lg:w-auto lg:-translate-x-1/2 lg:px-0 lg:pb-0"
>
<div
class="flex w-full items-center rounded-full bg-surface/95 p-1.5 shadow-xl ring-1 ring-black/5 backdrop-blur lg:w-auto"
>
<button
type="button"
onclick={() => (mode = 'sns')}
class={[
'flex-1 rounded-full px-4 py-2.5 text-center text-sm whitespace-nowrap transition-colors lg:flex-none lg:px-5',
mode === 'sns' ? 'bg-pill text-surface' : 'text-muted hover:text-ink'
]}
>
Upload SNS Feed
</button>
<button
type="button"
onclick={() => (mode = 'moodboard')}
class={[
'flex-1 rounded-full px-4 py-2.5 text-center text-sm whitespace-nowrap transition-colors lg:flex-none lg:px-5',
mode === 'moodboard' ? 'bg-pill text-surface' : 'text-muted hover:text-ink'
]}
>
Build Moodboard
</button>
</div>
</div>
</section>
</main>
</div>