feat: redesign upload experience and visual branding
* feat: add animated flower step indicator * feat: add custom pixel cursor * feat: update branding, cursor, and artwork cards * feat: add paper texture background * style: update description card background * feat: redesign upload moodboard collage layout * style: update upload colors and add sliding mode toggle * fix: add static favicon and apple touch icons
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
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>
|
||||
<div class="w-64 max-w-full flex-none border border-line-strong bg-white px-4 py-3 shadow-sm lg:px-6 lg:py-5">
|
||||
<h3 class="text-sm font-semibold">{title}</h3>
|
||||
<p class="mt-2 text-xs leading-snug">{description}</p>
|
||||
</div>
|
||||
|
||||
68
src/lib/components/ui/FlowerCursor.svelte
Normal file
68
src/lib/components/ui/FlowerCursor.svelte
Normal file
@@ -0,0 +1,68 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import cursorUrl from '$lib/assets/cursor.svg';
|
||||
|
||||
let visible = $state(false);
|
||||
let x = $state(0);
|
||||
let y = $state(0);
|
||||
|
||||
onMount(() => {
|
||||
const canUseCustomCursor = window.matchMedia('(hover: hover) and (pointer: fine)').matches;
|
||||
if (!canUseCustomCursor) return;
|
||||
|
||||
document.documentElement.classList.add('flower-cursor');
|
||||
|
||||
function handlePointerMove(event) {
|
||||
x = event.clientX;
|
||||
y = event.clientY;
|
||||
visible = true;
|
||||
}
|
||||
|
||||
function handlePointerLeave() {
|
||||
visible = false;
|
||||
}
|
||||
|
||||
window.addEventListener('pointermove', handlePointerMove);
|
||||
document.addEventListener('mouseleave', handlePointerLeave);
|
||||
|
||||
return () => {
|
||||
document.documentElement.classList.remove('flower-cursor');
|
||||
window.removeEventListener('pointermove', handlePointerMove);
|
||||
document.removeEventListener('mouseleave', handlePointerLeave);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if visible}
|
||||
<div class="flower-cursor-layer" style={`transform: translate3d(${x}px, ${y}px, 0)`}>
|
||||
<img class="flower-cursor-icon" src={cursorUrl} alt="" aria-hidden="true" />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
:global(html.flower-cursor),
|
||||
:global(html.flower-cursor *) {
|
||||
cursor: none !important;
|
||||
}
|
||||
|
||||
.flower-cursor-layer {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 2147483647;
|
||||
pointer-events: none;
|
||||
translate: -0.3rem -0.3rem;
|
||||
}
|
||||
|
||||
.flower-cursor-icon {
|
||||
display: block;
|
||||
width: 2.35rem;
|
||||
height: 2.35rem;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.flower-cursor-layer {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,4 +1,6 @@
|
||||
<script>
|
||||
import logoUrl from '$lib/assets/logo.svg';
|
||||
|
||||
// `step` is 1-based; the matching dot is highlighted as the current step.
|
||||
let { step = 1, total = 7 } = $props();
|
||||
|
||||
@@ -7,15 +9,45 @@
|
||||
|
||||
<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>
|
||||
<img src={logoUrl} alt="" class="size-7 shrink-0 translate-y-px" aria-hidden="true" />
|
||||
<span class="text-lg leading-none 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>
|
||||
<svg
|
||||
class={[
|
||||
'shrink-0',
|
||||
dot === step - 1
|
||||
? 'flower-icon--current size-4.5 text-subtle'
|
||||
: 'size-3.5 text-placeholder'
|
||||
]}
|
||||
viewBox="0 0 64 64"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M32 6 C36 6 38.5 10.5 38 15.5 C42.5 12 48 12.5 50.5 16 C53 19.5 50.5 24.5 46 26.5 C51 27.5 54.5 31.5 53.5 36 C52.5 40.5 47 42 42.5 40 C45.5 44 45 49.5 41 52 C37 54.5 32.5 51.5 31 47 C29.5 51.5 25 54.5 21 52 C17 49.5 16.5 44 19.5 40 C15 42 9.5 40.5 8.5 36 C7.5 31.5 11 27.5 16 26.5 C11.5 24.5 9 19.5 11.5 16 C14 12.5 19.5 12 24 15.5 C23.5 10.5 28 6 32 6 Z"
|
||||
/>
|
||||
</svg>
|
||||
{/each}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<style>
|
||||
.flower-icon--current {
|
||||
animation: flower-spin 2.4s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes flower-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.flower-icon--current {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import { hydrateDevUpload } from '$lib/dev/hydrateUpload.js';
|
||||
import { getFlowObject, isDevSeeded } from '$lib/flowerFlow/session.js';
|
||||
|
||||
let { primaryFile = $bindable(null) } = $props();
|
||||
let { primaryFile = $bindable(null), caption = 'build their moodboard!' } = $props();
|
||||
|
||||
let colorFile = $state(null);
|
||||
let seasonFile = $state(null);
|
||||
@@ -12,7 +12,8 @@
|
||||
let locationFile = $state(null);
|
||||
|
||||
$effect(() => {
|
||||
primaryFile = colorFile ?? seasonFile ?? characterFile ?? locationFile ?? null;
|
||||
const next = colorFile ?? seasonFile ?? characterFile ?? locationFile ?? null;
|
||||
if (primaryFile !== next) primaryFile = next;
|
||||
});
|
||||
|
||||
onMount(async () => {
|
||||
@@ -35,65 +36,170 @@
|
||||
</script>
|
||||
|
||||
<div class="moodboard min-h-0 w-full flex-1">
|
||||
<UploadTile
|
||||
label="Color"
|
||||
bind:file={colorFile}
|
||||
class="tile tile-color aspect-4/5 h-full min-h-0 w-full max-lg:aspect-auto lg:aspect-auto"
|
||||
/>
|
||||
<UploadTile
|
||||
label="Season"
|
||||
bind:file={seasonFile}
|
||||
class="tile tile-season aspect-4/3 h-full min-h-0 w-full max-lg:aspect-auto lg:aspect-auto"
|
||||
/>
|
||||
<UploadTile
|
||||
label="Character"
|
||||
bind:file={characterFile}
|
||||
class="tile tile-character aspect-4/3 h-full min-h-0 w-full max-lg:aspect-auto lg:aspect-auto"
|
||||
/>
|
||||
<UploadTile
|
||||
label="Location"
|
||||
bind:file={locationFile}
|
||||
class="tile tile-location aspect-4/5 h-full min-h-0 w-full max-lg:aspect-auto lg:aspect-auto"
|
||||
/>
|
||||
<div class="collage">
|
||||
<span class="mood-number number-color">(01)</span>
|
||||
<span class="mood-number number-season">(02)</span>
|
||||
<span class="mood-number number-character">(03)</span>
|
||||
<span class="mood-number number-location">(04)</span>
|
||||
<span class="mood-caption">{caption}</span>
|
||||
|
||||
<UploadTile
|
||||
label="Color"
|
||||
bind:file={colorFile}
|
||||
class="moodboard-tile tile-color"
|
||||
/>
|
||||
<UploadTile
|
||||
label="Season"
|
||||
bind:file={seasonFile}
|
||||
class="moodboard-tile tile-season"
|
||||
/>
|
||||
<UploadTile
|
||||
label="Character"
|
||||
bind:file={characterFile}
|
||||
class="moodboard-tile tile-character"
|
||||
/>
|
||||
<UploadTile
|
||||
label="Location"
|
||||
bind:file={locationFile}
|
||||
class="moodboard-tile tile-location"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.moodboard {
|
||||
display: grid;
|
||||
gap: 0;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-rows: 1fr 1fr;
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
padding: 0.5rem 1.5rem 1rem;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.collage {
|
||||
position: relative;
|
||||
width: min(100%, 34rem);
|
||||
height: 100%;
|
||||
aspect-ratio: 4 / 5.2;
|
||||
max-height: 44rem;
|
||||
}
|
||||
|
||||
.moodboard :global(.moodboard-tile) {
|
||||
position: absolute;
|
||||
background: #fff;
|
||||
box-shadow: 0 10px 24px rgb(56 50 47 / 0.08);
|
||||
}
|
||||
|
||||
/* 01 — top-left portrait */
|
||||
:global(.tile-color) {
|
||||
top: 8%;
|
||||
left: 4%;
|
||||
width: 30%;
|
||||
aspect-ratio: 3 / 4;
|
||||
}
|
||||
|
||||
/* 02 — right portrait, dips below the top of 01 */
|
||||
:global(.tile-season) {
|
||||
top: 13%;
|
||||
right: 3%;
|
||||
width: 29%;
|
||||
aspect-ratio: 3 / 4;
|
||||
}
|
||||
|
||||
/* 03 — landscape, lower-left */
|
||||
:global(.tile-character) {
|
||||
top: 49%;
|
||||
left: 10%;
|
||||
width: 36%;
|
||||
aspect-ratio: 4 / 3;
|
||||
}
|
||||
|
||||
/* 04 — bottom-right portrait */
|
||||
:global(.tile-location) {
|
||||
top: 62%;
|
||||
right: 4%;
|
||||
width: 29%;
|
||||
aspect-ratio: 3 / 4;
|
||||
}
|
||||
|
||||
.mood-number,
|
||||
.mood-caption {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
color: var(--color-ink);
|
||||
}
|
||||
|
||||
.mood-number {
|
||||
font-size: clamp(1rem, 2.2vw, 1.5rem);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.number-color {
|
||||
top: 13%;
|
||||
left: 35%;
|
||||
}
|
||||
|
||||
.number-season {
|
||||
top: 38%;
|
||||
left: 60%;
|
||||
}
|
||||
|
||||
.number-character {
|
||||
top: 73%;
|
||||
left: 9%;
|
||||
}
|
||||
|
||||
.number-location {
|
||||
top: 58%;
|
||||
right: 11%;
|
||||
}
|
||||
|
||||
.mood-caption {
|
||||
/* horizontally centered over the toggle capsule (the left flex child of
|
||||
the bottom bar), not the section. Its center sits left of the collage
|
||||
midpoint because the "Continue" button occupies the bar's right side. */
|
||||
left: 29%;
|
||||
top: 84%;
|
||||
font-size: clamp(0.85rem, 1.7vw, 1.1rem);
|
||||
transform: translateX(-50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.moodboard {
|
||||
grid-template-rows: repeat(5, 1fr);
|
||||
grid-template-areas:
|
||||
'color season'
|
||||
'color season'
|
||||
'color location'
|
||||
'character location'
|
||||
'character location';
|
||||
min-height: 34rem;
|
||||
align-items: flex-start;
|
||||
padding: 1.5rem 1rem 7rem;
|
||||
}
|
||||
|
||||
:global(.tile-color) {
|
||||
grid-area: color;
|
||||
.collage {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
aspect-ratio: auto;
|
||||
min-height: 0;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
:global(.tile-season) {
|
||||
grid-area: season;
|
||||
.moodboard :global(.moodboard-tile) {
|
||||
position: relative;
|
||||
inset: auto;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
:global(.tile-color),
|
||||
:global(.tile-season),
|
||||
:global(.tile-location) {
|
||||
aspect-ratio: 3 / 4;
|
||||
}
|
||||
|
||||
:global(.tile-character) {
|
||||
grid-area: character;
|
||||
aspect-ratio: 4 / 3;
|
||||
}
|
||||
|
||||
:global(.tile-location) {
|
||||
grid-area: location;
|
||||
.mood-number,
|
||||
.mood-caption {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
import { hydrateDevUpload } from '$lib/dev/hydrateUpload.js';
|
||||
import { getFlowObject, isDevSeeded } from '$lib/flowerFlow/session.js';
|
||||
|
||||
let { primaryFile = $bindable(null) } = $props();
|
||||
let { primaryFile = $bindable(null), caption = 'upload their feed!' } = $props();
|
||||
|
||||
let firstFile = $state(null);
|
||||
let secondFile = $state(null);
|
||||
|
||||
$effect(() => {
|
||||
primaryFile = firstFile ?? secondFile ?? null;
|
||||
const next = firstFile ?? null;
|
||||
if (primaryFile !== next) primaryFile = next;
|
||||
});
|
||||
|
||||
onMount(async () => {
|
||||
@@ -23,7 +23,6 @@
|
||||
try {
|
||||
const files = await hydrateDevUpload(/** @type {Record<string, string>} */ (tiles));
|
||||
if (files.first) firstFile = files.first;
|
||||
if (files.second) secondFile = files.second;
|
||||
} catch {
|
||||
// dev seed 실패 시 빈 타일 유지
|
||||
}
|
||||
@@ -31,45 +30,89 @@
|
||||
</script>
|
||||
|
||||
<div class="feed min-h-0 w-full flex-1">
|
||||
<UploadTile
|
||||
bind:file={firstFile}
|
||||
class="tile-one aspect-4/5 h-full min-h-0 w-full max-lg:aspect-auto lg:aspect-auto"
|
||||
/>
|
||||
<UploadTile
|
||||
bind:file={secondFile}
|
||||
class="tile-two aspect-4/5 h-full min-h-0 w-full max-lg:aspect-auto lg:aspect-auto"
|
||||
/>
|
||||
<div class="sns-collage">
|
||||
<span class="sns-number">(01)</span>
|
||||
<span class="sns-caption">{caption}</span>
|
||||
|
||||
<UploadTile bind:file={firstFile} class="sns-tile" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.feed {
|
||||
display: grid;
|
||||
gap: 0;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-rows: 1fr;
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
padding: 0.5rem 1.5rem 1rem;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.sns-collage {
|
||||
position: relative;
|
||||
width: min(100%, 42rem);
|
||||
height: 100%;
|
||||
aspect-ratio: 4 / 5;
|
||||
max-height: 34rem;
|
||||
}
|
||||
|
||||
.feed :global(.sns-tile) {
|
||||
position: absolute;
|
||||
top: 12%;
|
||||
left: 50%;
|
||||
width: 58%;
|
||||
height: 46%;
|
||||
background: #fff;
|
||||
box-shadow: 0 10px 24px rgb(56 50 47 / 0.08);
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.sns-number,
|
||||
.sns-caption {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
color: var(--color-ink);
|
||||
}
|
||||
|
||||
.sns-number {
|
||||
top: 6%;
|
||||
left: 23%;
|
||||
font-size: clamp(1rem, 2.2vw, 1.5rem);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.sns-caption {
|
||||
left: 50%;
|
||||
bottom: 13%;
|
||||
font-size: clamp(0.9rem, 1.9vw, 1.25rem);
|
||||
transform: translateX(-50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.feed {
|
||||
grid-template-rows: repeat(5, 1fr);
|
||||
grid-template-areas:
|
||||
'. two'
|
||||
'one two'
|
||||
'one two'
|
||||
'one two'
|
||||
'one .';
|
||||
min-height: 34rem;
|
||||
align-items: flex-start;
|
||||
padding: 1.5rem 1rem 7rem;
|
||||
}
|
||||
|
||||
:global(.tile-one) {
|
||||
grid-area: one;
|
||||
.sns-collage {
|
||||
width: 100%;
|
||||
aspect-ratio: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
:global(.tile-two) {
|
||||
grid-area: two;
|
||||
.feed :global(.sns-tile) {
|
||||
position: relative;
|
||||
inset: auto;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
aspect-ratio: 4 / 5;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.sns-number,
|
||||
.sns-caption {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,7 +3,13 @@
|
||||
// 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 = '', file = $bindable(null) } = $props();
|
||||
let {
|
||||
label = null,
|
||||
showLabel = true,
|
||||
class: klass = '',
|
||||
style = '',
|
||||
file = $bindable(null)
|
||||
} = $props();
|
||||
|
||||
let preview = $state(null);
|
||||
|
||||
@@ -44,14 +50,14 @@
|
||||
|
||||
{#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}
|
||||
<div class="absolute inset-0 bg-linear-to-t from-ink/45 to-transparent"></div>
|
||||
{#if label && showLabel}
|
||||
<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"
|
||||
class="absolute top-3 right-3 rounded-full bg-ink/40 px-2.5 py-1 text-xs text-surface opacity-0 backdrop-blur-sm transition-opacity group-hover:opacity-100"
|
||||
>
|
||||
Change
|
||||
</span>
|
||||
@@ -63,7 +69,7 @@
|
||||
class="flex size-10 items-center justify-center rounded-full border border-current text-xl leading-none"
|
||||
aria-hidden="true">+</span
|
||||
>
|
||||
{#if label}
|
||||
{#if label && showLabel}
|
||||
<span class="text-sm tracking-[0.15em] uppercase">{label}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user