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: 이지은 <ijieun@ijieun-ui-MacBookPro.local> Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -3,7 +3,12 @@
|
||||
import Vase from './Vase.svelte';
|
||||
import DescriptionCard from './DescriptionCard.svelte';
|
||||
|
||||
let { title = 'Title', description = 'Description Description Description' } = $props();
|
||||
let {
|
||||
title = 'Title',
|
||||
description = 'Description Description Description',
|
||||
/** options Continue 이후 확정된 꽃다발만 전달 (그 전에는 null → Vase) */
|
||||
imageSrc = null
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<section
|
||||
@@ -13,7 +18,17 @@
|
||||
<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 />
|
||||
{#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>
|
||||
</section>
|
||||
|
||||
53
src/lib/components/ui/map/FloristOrderMessage.svelte
Normal file
53
src/lib/components/ui/map/FloristOrderMessage.svelte
Normal file
@@ -0,0 +1,53 @@
|
||||
<script>
|
||||
/** @typedef {{ text: string, highlight: boolean }} OrderMessageSegment */
|
||||
|
||||
let {
|
||||
plainText = '',
|
||||
segments = /** @type {OrderMessageSegment[]} */ ([])
|
||||
} = $props();
|
||||
|
||||
let copied = $state(false);
|
||||
|
||||
const hasMessage = $derived(Boolean(plainText?.trim()));
|
||||
|
||||
async function handleCopy() {
|
||||
if (!hasMessage) return;
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(plainText);
|
||||
copied = true;
|
||||
setTimeout(() => {
|
||||
copied = false;
|
||||
}, 2000);
|
||||
} catch {
|
||||
// 클립보드 API 미지원 환경
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex items-start justify-between 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>
|
||||
{:else}
|
||||
<p class="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>
|
||||
235
src/lib/components/ui/map/KakaoMap.svelte
Normal file
235
src/lib/components/ui/map/KakaoMap.svelte
Normal file
@@ -0,0 +1,235 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { env } from '$env/dynamic/public';
|
||||
|
||||
let {
|
||||
initialLat = 37.5665,
|
||||
initialLng = 126.978,
|
||||
shops = [],
|
||||
selectedId = null,
|
||||
fitBounds = false,
|
||||
panTo = null,
|
||||
oncenterchange,
|
||||
onselect
|
||||
} = $props();
|
||||
|
||||
let container = $state(null);
|
||||
let mapReady = $state(false);
|
||||
let mapError = $state('');
|
||||
|
||||
const mapKey = env.PUBLIC_KAKAO_MAP_KEY;
|
||||
|
||||
/** @type {ReturnType<typeof window.kakao.maps.Map> | null} */
|
||||
let mapInstance = $state(null);
|
||||
/** @type {ReturnType<typeof window.kakao.maps.InfoWindow> | null} */
|
||||
let infoWindow = null;
|
||||
/** @type {Map<string, { marker: ReturnType<typeof window.kakao.maps.Marker>; shop: (typeof shops)[number] }>} */
|
||||
let shopMarkerMap = new Map();
|
||||
|
||||
function relayoutMap() {
|
||||
mapInstance?.relayout?.();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} value
|
||||
*/
|
||||
function escapeHtml(value) {
|
||||
return value
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {(typeof shops)[number]} shop
|
||||
*/
|
||||
function buildInfoContent(shop) {
|
||||
const distance = shop.distance
|
||||
? `<p style="margin:4px 0 0;font-size:12px;color:#666">${escapeHtml(shop.distance)}</p>`
|
||||
: '';
|
||||
const phone = shop.phone
|
||||
? `<p style="margin:4px 0 0;font-size:12px;color:#666">${escapeHtml(shop.phone)}</p>`
|
||||
: '';
|
||||
|
||||
return `<div style="padding:15px 12px;min-width:200px;max-width:240px;font-family:system-ui,sans-serif;line-height:1.4">
|
||||
<p style="margin:0;font-size:15px;font-weight:600;color:#1a1a1a">${escapeHtml(shop.name)}</p>
|
||||
<p style="margin:4px 0 0;font-size:13px;color:#555">${escapeHtml(shop.address)}</p>
|
||||
${distance}
|
||||
${phone}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string | null} shopId
|
||||
*/
|
||||
function showInfoForShop(shopId) {
|
||||
const map = mapInstance;
|
||||
if (!map || !mapReady || !window.kakao?.maps || !infoWindow) return;
|
||||
|
||||
for (const [id, { marker }] of shopMarkerMap) {
|
||||
marker.setZIndex(id === shopId ? 2 : 0);
|
||||
}
|
||||
|
||||
if (!shopId) {
|
||||
infoWindow.close();
|
||||
return;
|
||||
}
|
||||
|
||||
const entry = shopMarkerMap.get(shopId);
|
||||
if (!entry) {
|
||||
infoWindow.close();
|
||||
return;
|
||||
}
|
||||
|
||||
infoWindow.setContent(buildInfoContent(entry.shop));
|
||||
infoWindow.open(map, entry.marker);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
if (!mapKey) {
|
||||
mapError = 'Set PUBLIC_KAKAO_MAP_KEY in .env to show the map.';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await loadKakaoScript(mapKey);
|
||||
if (cancelled || !container || !window.kakao?.maps) return;
|
||||
|
||||
const center = new window.kakao.maps.LatLng(initialLat, initialLng);
|
||||
const map = new window.kakao.maps.Map(container, { center, level: 5 });
|
||||
mapInstance = map;
|
||||
infoWindow = new window.kakao.maps.InfoWindow({ removable: true });
|
||||
mapReady = true;
|
||||
|
||||
window.kakao.maps.event.addListener(map, 'idle', () => {
|
||||
const c = map.getCenter();
|
||||
oncenterchange?.(c.getLat(), c.getLng());
|
||||
});
|
||||
|
||||
requestAnimationFrame(() => relayoutMap());
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
mapError = err instanceof Error ? err.message : 'Failed to load map';
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
infoWindow?.close();
|
||||
infoWindow = null;
|
||||
for (const { marker } of shopMarkerMap.values()) {
|
||||
marker.setMap(null);
|
||||
}
|
||||
shopMarkerMap.clear();
|
||||
mapInstance = null;
|
||||
mapReady = false;
|
||||
};
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const map = mapInstance;
|
||||
if (!map || !mapReady || !window.kakao?.maps) return;
|
||||
|
||||
const shopList = shops;
|
||||
const shouldFit = fitBounds;
|
||||
|
||||
infoWindow?.close();
|
||||
|
||||
for (const { marker } of shopMarkerMap.values()) {
|
||||
marker.setMap(null);
|
||||
}
|
||||
shopMarkerMap.clear();
|
||||
|
||||
const bounds = new window.kakao.maps.LatLngBounds();
|
||||
let hasMarker = false;
|
||||
|
||||
for (const shop of shopList) {
|
||||
const shopLat = Number(shop.lat);
|
||||
const shopLng = Number(shop.lng);
|
||||
if (!Number.isFinite(shopLat) || !Number.isFinite(shopLng)) continue;
|
||||
|
||||
const position = new window.kakao.maps.LatLng(shopLat, shopLng);
|
||||
const marker = new window.kakao.maps.Marker({
|
||||
position,
|
||||
map
|
||||
});
|
||||
|
||||
window.kakao.maps.event.addListener(marker, 'click', () => {
|
||||
onselect?.(shop.id);
|
||||
});
|
||||
|
||||
shopMarkerMap.set(shop.id, { marker, shop });
|
||||
bounds.extend(position);
|
||||
hasMarker = true;
|
||||
}
|
||||
|
||||
if (hasMarker && shouldFit) {
|
||||
map.setBounds(bounds);
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => relayoutMap());
|
||||
});
|
||||
|
||||
// 마커·리스트 선택 시 정보창 표시
|
||||
$effect(() => {
|
||||
const id = selectedId;
|
||||
const shopList = shops;
|
||||
if (!mapReady || shopList.length === 0) return;
|
||||
|
||||
showInfoForShop(id);
|
||||
});
|
||||
|
||||
// 리스트에서 가게 선택 시에만 이동 (panTo가 바뀔 때)
|
||||
$effect(() => {
|
||||
const map = mapInstance;
|
||||
const target = panTo;
|
||||
if (!map || !mapReady || !window.kakao?.maps || !target) return;
|
||||
|
||||
const centerLat = Number(target.lat);
|
||||
const centerLng = Number(target.lng);
|
||||
if (!Number.isFinite(centerLat) || !Number.isFinite(centerLng)) return;
|
||||
|
||||
map.panTo(new window.kakao.maps.LatLng(centerLat, centerLng));
|
||||
});
|
||||
|
||||
/**
|
||||
* @param {string} key
|
||||
*/
|
||||
function loadKakaoScript(key) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (window.kakao?.maps) {
|
||||
window.kakao.maps.load(resolve);
|
||||
return;
|
||||
}
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.src = `https://dapi.kakao.com/v2/maps/sdk.js?appkey=${key}&autoload=false`;
|
||||
script.onload = () => window.kakao.maps.load(resolve);
|
||||
script.onerror = () => reject(new Error('Kakao Maps script failed to load'));
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="relative min-h-64 flex-1 overflow-hidden bg-track lg:min-h-0">
|
||||
<div bind:this={container} class="absolute inset-0"></div>
|
||||
|
||||
{#if mapError}
|
||||
<div
|
||||
class="absolute inset-0 flex items-center justify-center bg-track px-6 text-center text-sm text-muted"
|
||||
>
|
||||
{mapError}
|
||||
</div>
|
||||
{:else if !mapReady}
|
||||
<div
|
||||
class="absolute inset-0 flex items-center justify-center bg-track text-sm text-muted"
|
||||
>
|
||||
Loading map...
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
93
src/lib/components/ui/map/MapPanel.svelte
Normal file
93
src/lib/components/ui/map/MapPanel.svelte
Normal file
@@ -0,0 +1,93 @@
|
||||
<script>
|
||||
import FloristOrderMessage from './FloristOrderMessage.svelte';
|
||||
import KakaoMap from './KakaoMap.svelte';
|
||||
import ShopList from './ShopList.svelte';
|
||||
|
||||
let {
|
||||
shops = [],
|
||||
loading = false,
|
||||
error = '',
|
||||
selectedShopId = $bindable(null),
|
||||
mock = false,
|
||||
fitBounds = false,
|
||||
orderPlainText = '',
|
||||
orderSegments = [],
|
||||
onrefresh
|
||||
} = $props();
|
||||
|
||||
const DEFAULT_LAT = 37.5665;
|
||||
const DEFAULT_LNG = 126.978;
|
||||
|
||||
let mapCenterLat = $state(DEFAULT_LAT);
|
||||
let mapCenterLng = $state(DEFAULT_LNG);
|
||||
let panTarget = $state(null);
|
||||
|
||||
function handleCenterChange(lat, lng) {
|
||||
mapCenterLat = lat;
|
||||
mapCenterLng = lng;
|
||||
}
|
||||
|
||||
function handleShopSelect(id) {
|
||||
selectedShopId = id;
|
||||
const shop = shops.find((s) => s.id === id);
|
||||
if (shop?.lat != null && shop?.lng != null) {
|
||||
panTarget = { lat: shop.lat, lng: shop.lng };
|
||||
}
|
||||
}
|
||||
|
||||
function handleRefresh() {
|
||||
onrefresh?.(mapCenterLat, mapCenterLng);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex min-h-0 flex-1 flex-col">
|
||||
<header class="shrink-0 px-6 py-8 md:px-10 lg:px-12 lg:py-10">
|
||||
<h1 class="text-3xl leading-relaxed font-light text-muted md:text-4xl lg:text-[2.75rem]">
|
||||
Find a nearby florist
|
||||
</h1>
|
||||
<p class="mt-3 text-sm text-muted">Move the map, then refresh to search this area.</p>
|
||||
{#if mock}
|
||||
<p class="mt-2 text-xs text-muted">Showing sample shops (no Kakao API key).</p>
|
||||
{/if}
|
||||
<div class="mt-6 border-b border-pill lg:mt-8"></div>
|
||||
</header>
|
||||
|
||||
<div class="shrink-0 px-6 pb-4 md:px-10 lg:px-12">
|
||||
<FloristOrderMessage plainText={orderPlainText} segments={orderSegments} />
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<p class="px-6 text-sm text-red-600 md:px-10 lg:px-12">{error}</p>
|
||||
{/if}
|
||||
|
||||
<div class="flex min-h-0 flex-1 flex-col gap-6 px-6 pb-8 md:px-10 lg:flex-row lg:px-12 lg:pb-10">
|
||||
<div class="relative flex min-h-64 flex-1 flex-col overflow-hidden border border-line lg:min-h-0">
|
||||
<KakaoMap
|
||||
initialLat={DEFAULT_LAT}
|
||||
initialLng={DEFAULT_LNG}
|
||||
{shops}
|
||||
selectedId={selectedShopId}
|
||||
{fitBounds}
|
||||
panTo={panTarget}
|
||||
oncenterchange={handleCenterChange}
|
||||
onselect={handleShopSelect}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={loading}
|
||||
onclick={handleRefresh}
|
||||
class="absolute top-3 right-3 z-10 rounded bg-surface/95 px-3 py-2 text-xs text-ink shadow-md ring-1 ring-black/10 backdrop-blur hover:bg-surface disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Searching...' : 'Refresh'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="w-full shrink-0 lg:w-72 lg:overflow-y-auto">
|
||||
{#if loading && shops.length === 0}
|
||||
<p class="text-sm text-muted">Searching for flower shops...</p>
|
||||
{:else}
|
||||
<ShopList shops={shops} bind:selectedId={selectedShopId} onselect={handleShopSelect} />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
29
src/lib/components/ui/map/ShopList.svelte
Normal file
29
src/lib/components/ui/map/ShopList.svelte
Normal file
@@ -0,0 +1,29 @@
|
||||
<script>
|
||||
let { shops = [], selectedId = $bindable(null), onselect } = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
{#each shops as shop (shop.id)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => onselect(shop.id)}
|
||||
class={[
|
||||
'rounded border px-4 py-3 text-left transition-colors',
|
||||
selectedId === shop.id
|
||||
? 'border-pill bg-pill text-surface'
|
||||
: 'border-line-strong bg-surface text-ink hover:border-ink/30'
|
||||
]}
|
||||
>
|
||||
<p class="text-sm font-medium">{shop.name}</p>
|
||||
<p class="mt-1 text-xs opacity-80">{shop.address}</p>
|
||||
{#if shop.distance}
|
||||
<p class="mt-1 text-xs opacity-70">{shop.distance}</p>
|
||||
{/if}
|
||||
{#if selectedId === shop.id && shop.phone}
|
||||
<p class="mt-1 text-xs opacity-70">{shop.phone}</p>
|
||||
{/if}
|
||||
</button>
|
||||
{:else}
|
||||
<p class="text-sm text-muted">No flower shops found nearby.</p>
|
||||
{/each}
|
||||
</div>
|
||||
40
src/lib/components/ui/options/OptionCard.svelte
Normal file
40
src/lib/components/ui/options/OptionCard.svelte
Normal file
@@ -0,0 +1,40 @@
|
||||
<script>
|
||||
import { toDataUrl } from '$lib/flowerFlow/api.js';
|
||||
|
||||
let {
|
||||
size,
|
||||
image = null,
|
||||
selectedSize = $bindable(null)
|
||||
} = $props();
|
||||
|
||||
const selected = $derived(selectedSize === size);
|
||||
const dimmed = $derived(selectedSize !== null && selectedSize !== size);
|
||||
|
||||
function handleClick() {
|
||||
selectedSize = size;
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- 세로(4:5) 꽃다발 미리보기 — 클릭 시 해당 사이즈 선택 -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleClick}
|
||||
aria-pressed={selected}
|
||||
aria-label="Select size {size}"
|
||||
class={[
|
||||
'relative min-h-0 w-full cursor-pointer overflow-hidden bg-track text-left transition-all duration-300',
|
||||
selected ? 'scale-[1.02] opacity-100 ring-2 ring-pill ring-inset' : '',
|
||||
dimmed ? 'opacity-25' : 'opacity-100',
|
||||
!dimmed && !selected ? 'hover:opacity-90' : ''
|
||||
]}
|
||||
>
|
||||
<div class="aspect-4/5 w-full">
|
||||
{#if image}
|
||||
<img
|
||||
src={toDataUrl(image)}
|
||||
alt="Bouquet option {size}"
|
||||
class="pointer-events-none h-full w-full object-cover"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
49
src/lib/components/ui/options/OptionsPanel.svelte
Normal file
49
src/lib/components/ui/options/OptionsPanel.svelte
Normal file
@@ -0,0 +1,49 @@
|
||||
<script>
|
||||
import OptionGroup from '$lib/components/ui/create/OptionGroup.svelte';
|
||||
import OptionCard from './OptionCard.svelte';
|
||||
|
||||
let { images = {}, loading = false, selectedSize = $bindable(null) } = $props();
|
||||
|
||||
const sizeOptions = ['S', 'M', 'L'];
|
||||
</script>
|
||||
|
||||
<div class="flex min-h-0 flex-1 flex-col justify-center px-6 py-10 md:px-12 lg:px-16 lg:py-12">
|
||||
{#if loading}
|
||||
<div class="flex flex-1 items-center justify-center">
|
||||
<p class="text-sm text-muted">Loading options...</p>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- options-reorganized.png: S/M/L 세로 이미지 가로 3열 -->
|
||||
<div class="options-grid mb-10 min-h-0 w-full lg:mb-14">
|
||||
{#each sizeOptions as size (size)}
|
||||
<OptionCard {size} image={images[size]} bind:selectedSize />
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<OptionGroup
|
||||
label="Choose the size of the bouquet"
|
||||
options={sizeOptions}
|
||||
selected={selectedSize}
|
||||
onchange={(v) => (selectedSize = v)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.options-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 0.75rem;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
align-content: center;
|
||||
max-height: min(70vh, 36rem);
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.options-grid {
|
||||
gap: 1rem;
|
||||
max-height: min(65vh, 40rem);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,8 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import UploadTile from './UploadTile.svelte';
|
||||
import { hydrateDevUpload } from '$lib/dev/hydrateUpload.js';
|
||||
import { getFlowObject, isDevSeeded } from '$lib/flowerFlow/session.js';
|
||||
|
||||
let { primaryFile = $bindable(null) } = $props();
|
||||
|
||||
@@ -12,36 +15,23 @@
|
||||
primaryFile = colorFile ?? seasonFile ?? characterFile ?? locationFile ?? null;
|
||||
});
|
||||
|
||||
const tiles = [
|
||||
{
|
||||
key: 'color',
|
||||
label: 'Color',
|
||||
aspect: 'aspect-4/5',
|
||||
bindFile: () => colorFile,
|
||||
setFile: (v) => (colorFile = v)
|
||||
},
|
||||
{
|
||||
key: 'season',
|
||||
label: 'Season',
|
||||
aspect: 'aspect-4/3',
|
||||
bindFile: () => seasonFile,
|
||||
setFile: (v) => (seasonFile = v)
|
||||
},
|
||||
{
|
||||
key: 'character',
|
||||
label: 'Character',
|
||||
aspect: 'aspect-4/3',
|
||||
bindFile: () => characterFile,
|
||||
setFile: (v) => (characterFile = v)
|
||||
},
|
||||
{
|
||||
key: 'location',
|
||||
label: 'Location',
|
||||
aspect: 'aspect-4/5',
|
||||
bindFile: () => locationFile,
|
||||
setFile: (v) => (locationFile = v)
|
||||
onMount(async () => {
|
||||
const devUpload = getFlowObject('devUpload');
|
||||
if (!isDevSeeded() || !devUpload?.active) return;
|
||||
|
||||
const tiles = devUpload.moodboard;
|
||||
if (!tiles || typeof tiles !== 'object') return;
|
||||
|
||||
try {
|
||||
const files = await hydrateDevUpload(/** @type {Record<string, string>} */ (tiles));
|
||||
if (files.color) colorFile = files.color;
|
||||
if (files.season) seasonFile = files.season;
|
||||
if (files.character) characterFile = files.character;
|
||||
if (files.location) locationFile = files.location;
|
||||
} catch {
|
||||
// dev seed 실패 시 빈 타일 유지
|
||||
}
|
||||
];
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="moodboard min-h-0 w-full flex-1">
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import UploadTile from './UploadTile.svelte';
|
||||
import { hydrateDevUpload } from '$lib/dev/hydrateUpload.js';
|
||||
import { getFlowObject, isDevSeeded } from '$lib/flowerFlow/session.js';
|
||||
|
||||
let { primaryFile = $bindable(null) } = $props();
|
||||
|
||||
@@ -9,6 +12,22 @@
|
||||
$effect(() => {
|
||||
primaryFile = firstFile ?? secondFile ?? null;
|
||||
});
|
||||
|
||||
onMount(async () => {
|
||||
const devUpload = getFlowObject('devUpload');
|
||||
if (!isDevSeeded() || !devUpload?.active) return;
|
||||
|
||||
const tiles = devUpload.sns;
|
||||
if (!tiles || typeof tiles !== 'object') return;
|
||||
|
||||
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 실패 시 빈 타일 유지
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="feed min-h-0 w-full flex-1">
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
<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
|
||||
@@ -12,13 +10,19 @@
|
||||
function pick(event) {
|
||||
const picked = event.currentTarget.files?.[0];
|
||||
if (!picked) return;
|
||||
if (preview) URL.revokeObjectURL(preview);
|
||||
file = picked;
|
||||
preview = URL.createObjectURL(picked);
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
if (preview) URL.revokeObjectURL(preview);
|
||||
// 부모에서 File을 주입할 때(Dev seed 등) 미리보기 동기화
|
||||
$effect(() => {
|
||||
if (!file) {
|
||||
preview = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const url = URL.createObjectURL(file);
|
||||
preview = url;
|
||||
return () => URL.revokeObjectURL(url);
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user