대표 지적 여섯을 한 번에 고친다. - **재발행이 Suno 를 다시 부르던 것**(`song_service.ensure_song`). `build_service` 가 `publish=True` 마다 이 함수를 불렀는데 가드가 없었다 — 내용이 하나도 안 바뀐 재발행에도 Gemini 가사 1회 + Suno 작곡 1회가 그대로 나갔고, 다섯 번 누르면 유료 호출 다섯 번에 `place_songs` 행 다섯 개다. 화면은 최신 READY 한 곡만 쓰므로 나머지는 돈만 쓴다. → READY 곡이 있으면 건너뛴다. 일부러 다시 만드는 길은 SONG 잡(`force=True`)으로 남긴다 - **엽서 쓰기 자리**를 계절별 축제 바로 앞으로(시연본과 같게). 목록 맨 뒤라 FAQ 보다도 아래였다. 축제가 없는 사이트는 종전대로 맨 뒤 - **엽서가 모바일에서 화면을 밀어내던 것** — 그리드 칸의 기본 `min-width:auto` 가 사진 레일의 최소 너비(실측 390px 화면에서 2,056px)를 그대로 받아 칸이 밖으로 나갔다. `min-w-0` 과 캔버스 비율 고정(`aspect-square h-auto`). 실측 넘침 0px · 캔버스 358×358 - **가요 다방 판이 한 색이던 것** — 폴백 팔레트를 시연본(`/s/stay`)이 쓰는 23색 그대로 옮겼다(대표: "그냥 정해놔 · /s/stay 보고 맞춰라"). 목록을 새로 짜면 시연본과 갈린다 - **푸터에 만든 곳 표기** — `AI O2O의 Web4Ai로 만든 사이트입니다.`(o2osolution.ai 링크). 발행본(`SiteFooter`)과 시연본(`patch_stay.py`) 둘 다 - **에디터 캔버스의 푸터·모바일 탭바 제거** — 발행본이 iframe 안에서 둘 다 이미 그린다 (`SiteFooter` · `MobileTabBar`). 바깥에 한 벌 더 그려 두 번 서 있었다 - **시연본 자동재생 끔**(`inject.js`, 대표: "클릭하고 나서 되니까 사람들이 에러로 보잖아"). 세 번째 뒤집기고 이번 이유는 다르다 — 정책을 못 이겨서가 아니라 정책에 걸린 모습이 고장으로 읽혀서다. 함수는 남기고 호출만 뺐다. 제품 렌더러엔 원래 없다 검증: tsc·eslint 통과(site·frontend), site 79 passed. 실제로 구워서 확인 — 섹션 순서 `…오시는 길 → 엽서쓰기 → 계절별 축제 → …`, 푸터에 o2osolution.ai 링크, 모바일 가로 넘침 0. 킹서버 실측: 재발행 안 한 사이트(joyyy·bbbb)도 중계로 공유 버튼이 뜬다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
195 lines
10 KiB
TypeScript
195 lines
10 KiB
TypeScript
/**
|
|
* 엽서 쓰기 — 손님이 사진을 고르고 한 마디를 적어 그 자리에서 엽서를 만들어 공유한다.
|
|
*
|
|
* ★ 시연본(`scripts/mockup/inject.js` postcardMaker)에서 옮겨 왔다 (2026-09-14 대표:
|
|
* "웹빌드 해도 엽서쓰기 나오게"). 시연본은 주입 스크립트라 발행본에는 없었다.
|
|
* ★ 그림 규칙은 `@site/lib/postcard-canvas` 한 곳에 있다 — 여기서는 고르기·입력·공유만 한다.
|
|
* ★ **파일 그대로 공유**다(대표 확정). Web Share API 가 파일을 받으면 이미지가 그대로 대화방에
|
|
* 올라간다. 못 받는 브라우저(주로 데스크톱)는 링크만 보내고, 그것도 안 되면 저장으로 떨어진다.
|
|
* ★ 버튼 문구에 앱 이름을 못 박지 않는다 — 공유 시트는 OS 가 채운다. 손끝으로 쓰는 기기에서만
|
|
* "카카오톡 등으로 공유" 라고 적는다(맥에서는 메시지·AirDrop 정도만 떠서 거짓말이 된다).
|
|
*/
|
|
import {useEffect, useRef, useState} from 'react';
|
|
import {useSite} from '@site/lib/site-context';
|
|
import {Carousel, Section} from '@site/lib/ui';
|
|
import {MAX_LINES, POSTCARD_SIZE, drawPostcard, textColumnWidth, wrapLines} from '@site/lib/postcard-canvas';
|
|
|
|
const FILE_NAME = 'postcard.png';
|
|
|
|
export function PostcardMakerSection() {
|
|
const payload = useSite();
|
|
const photos = (payload.media ?? []).filter((m) => m.url);
|
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
const [picked, setPicked] = useState(0);
|
|
const [text, setText] = useState('');
|
|
const [hint, setHint] = useState('');
|
|
const [touch, setTouch] = useState(false);
|
|
/* 사진이 남의 도메인이면 캔버스가 오염돼 파일로 못 뽑는다(postcard-canvas.loadImage 주석).
|
|
굽는 쪽이 사진을 우리 오리진으로 옮기고부터(prerender `mirrorMedia`) 여기 걸리는 건
|
|
**내려받기에 실패한 사진**뿐이다. 그때는 미리보기만 남기고 저장·공유를 막는다 —
|
|
눌러도 안 되는 단추를 두지 않는다. */
|
|
const [exportable, setExportable] = useState(true);
|
|
|
|
useEffect(() => {
|
|
setTouch(window.matchMedia?.('(pointer: coarse)').matches ?? false);
|
|
}, []);
|
|
|
|
const placeName = payload.place.name;
|
|
const region = String(payload.place.address ?? '').split(' ').slice(0, 2).join(' ');
|
|
|
|
useEffect(() => {
|
|
const canvas = canvasRef.current;
|
|
const photo = photos[picked];
|
|
if (!canvas || !photo?.url) return;
|
|
// 글자를 칠 때마다 다시 그린다. 80ms 는 시연본과 같은 값 — 한 글자마다 그리면 캔버스가 버벅인다.
|
|
const timer = window.setTimeout(() => {
|
|
void drawPostcard(canvas, {photoUrl: photo.url, text, placeName, region}).then(setExportable);
|
|
}, 80);
|
|
return () => window.clearTimeout(timer);
|
|
}, [picked, text, placeName, region, photos]);
|
|
|
|
// 폰트가 늦게 실리면 글씨가 기본 서체로 그려진 채 남는다 — 실리고 나서 한 번 더 그린다.
|
|
useEffect(() => {
|
|
const canvas = canvasRef.current;
|
|
const photo = photos[picked];
|
|
if (!canvas || !photo?.url || !document.fonts?.ready) return;
|
|
void document.fonts.ready.then(() => drawPostcard(canvas, {photoUrl: photo.url, text, placeName, region}).then(setExportable));
|
|
// 첫 그림 한 번만 — 이후는 위 효과가 맡는다.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
if (photos.length === 0) return null;
|
|
|
|
/** 몇 줄로 접힐지는 폭·서체에 달려 글자 수로는 못 잰다 — 그릴 때와 같은 함수로 재서 막는다. */
|
|
function withinLineLimit(next: string): boolean {
|
|
const ctx = canvasRef.current?.getContext('2d');
|
|
if (!ctx) return true;
|
|
ctx.font = `600 46px serif`;
|
|
return wrapLines(ctx, `“${next}”`, textColumnWidth()).length <= MAX_LINES;
|
|
}
|
|
|
|
function exportBlob(): Promise<Blob | null> {
|
|
return new Promise((resolve) => {
|
|
const canvas = canvasRef.current;
|
|
if (!canvas) return resolve(null);
|
|
canvas.toBlob((blob) => resolve(blob), 'image/png', 0.95);
|
|
});
|
|
}
|
|
|
|
function download(blob: Blob) {
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = FILE_NAME;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
async function share() {
|
|
const blob = await exportBlob();
|
|
if (!blob) return;
|
|
const pageUrl = document.querySelector<HTMLMetaElement>('meta[property="og:url"]')?.content
|
|
?? `${location.origin}${location.pathname}`;
|
|
const file = new File([blob], FILE_NAME, {type: 'image/png'});
|
|
// 손님이 시트를 닫은 것도 거절로 온다 — 오류로 다루지 않는다.
|
|
if (navigator.canShare?.({files: [file]})) {
|
|
await navigator.share({files: [file], title: `${placeName} 엽서`, text: pageUrl}).catch(() => {});
|
|
return;
|
|
}
|
|
if (navigator.share) {
|
|
await navigator.share({title: `${placeName} 엽서`, url: pageUrl}).catch(() => {});
|
|
setHint('이 브라우저는 사진을 같이 못 보내 링크만 보냈습니다. 사진은 "이미지로 저장" 을 눌러 주세요.');
|
|
return;
|
|
}
|
|
download(blob);
|
|
setHint('이 브라우저는 공유 시트를 못 열어 저장으로 대신했습니다.');
|
|
}
|
|
|
|
return (
|
|
<Section
|
|
id="postcard-maker"
|
|
title="엽서 쓰기"
|
|
lead={`${placeName}에서 보내는 한 장을 직접 써 봅니다. 사진을 고르고 한 마디를 적으면 그 자리에서 엽서가 되고, 그대로 공유하거나 저장할 수 있습니다.`}
|
|
>
|
|
{/* ★ 폭을 묶어 둔다 (2026-09-15 대표: "엽서 ui 너무 큼"). 엽서는 1:1 이라 열 폭을
|
|
그대로 주면 넓은 화면에서 한 변이 700px 넘는 정사각이 서서, 예약하러 온 손님이
|
|
이 한 칸을 스크롤로 넘겨야 다음 섹션에 닿는다. 미리보기는 손바닥만 하면 된다.
|
|
★ 폭은 두 번 왔다 갔다 했다(2026-09-15): 320 → 240(너무 작다) → **360**.
|
|
기준은 "한 화면에서 엽서와 입력칸이 나란히 보이는 가장 큰 크기" 다. */}
|
|
<div className="mx-auto grid max-w-3xl gap-5 sm:grid-cols-[minmax(0,360px)_minmax(0,1fr)] sm:items-start">
|
|
<canvas
|
|
ref={canvasRef}
|
|
width={POSTCARD_SIZE}
|
|
height={POSTCARD_SIZE}
|
|
aria-label="내가 만든 엽서 미리보기"
|
|
/* ★ 비율을 CSS 로 못 박는다 (2026-09-15 대표: "모바일에서 깨지잖아").
|
|
`<canvas>` 에 width 만 주면 브라우저에 따라 **높이가 속성값(1080px) 그대로** 남아
|
|
1080 폭짜리 그림이 좁은 폭에 눌린 채 늘어난다. `aspect-square h-auto` 로 못 박으면
|
|
어느 브라우저에서도 정사각이다. `block` 은 inline 기준선 아래 생기는 틈을 없앤다. */
|
|
className="border-line mx-auto block aspect-square h-auto w-full max-w-[360px] border"
|
|
/>
|
|
{/* ★ `min-w-0` 이 없으면 모바일에서 칸이 통째로 밀려난다 (2026-09-15 대표: "모바일에서
|
|
엽서 넘침"). 그리드 칸의 기본 `min-width:auto` 는 **안에 든 것의 최소 너비**다 —
|
|
사진 레일은 썸네일을 옆으로 죽 늘어놓으므로 그 값이 2,000px 을 넘었고(실측 390px
|
|
화면에서 칸이 2056px), 칸이 화면 밖으로 나갔다. 스크롤 상자를 품은 칸에는 늘 붙인다. */}
|
|
<div className="flex min-w-0 flex-col gap-2.5">
|
|
{/* ★ 이 사이트가 쓰는 캐러셀을 그대로 쓴다 (2026-09-15 대표: "왜 이미지 리스트에
|
|
slide js 안 걸려 있음?"). 처음엔 `.slider-viewport`·`.slider-track` 마크업만
|
|
손으로 찍었는데, 그 둘은 **스크립트가 붙기 전의 기본 상태**다(Carousel.tsx 주석)
|
|
— 손가락·트랙패드로만 밀리고 휠 마우스로는 옆에 더 있는 줄도 모른다. */}
|
|
<div className="min-w-0" role="radiogroup" aria-label={`엽서 사진 고르기(${photos.length}장)`}>
|
|
<Carousel label="엽서 사진" gap={0.5} dots={false} autoplay={false}>
|
|
{photos.map((photo, i) => (
|
|
<button
|
|
key={photo.url}
|
|
type="button"
|
|
onClick={() => setPicked(i)}
|
|
role="radio"
|
|
aria-checked={i === picked}
|
|
aria-label={`사진 ${i + 1}`}
|
|
className="border-line size-10 shrink-0 overflow-hidden rounded border transition-opacity"
|
|
style={{opacity: i === picked ? 1 : 0.5}}
|
|
>
|
|
<img src={photo.url} alt="" loading="lazy" className="size-full object-cover" />
|
|
</button>
|
|
))}
|
|
</Carousel>
|
|
</div>
|
|
|
|
<textarea
|
|
rows={2}
|
|
maxLength={60}
|
|
value={text}
|
|
placeholder="엽서에 쓸 한 마디를 적어 보세요"
|
|
onChange={(e) => { if (withinLineLimit(e.target.value)) setText(e.target.value); }}
|
|
className="border-line w-full rounded border p-2.5 text-[length:var(--fs-sm)]"
|
|
/>
|
|
<p className="text-muted -mt-1 text-[length:var(--fs-xs)]">{text.length}/60</p>
|
|
|
|
{exportable ? (
|
|
<div className="flex flex-wrap gap-2">
|
|
<button type="button" onClick={() => void share()} className="tap border-line rounded-full border px-3.5 py-1.5 text-[length:var(--fs-sm)] font-bold">
|
|
{touch ? '카카오톡 등으로 공유' : '공유하기'}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => void exportBlob().then((b) => b && download(b))}
|
|
className="tap border-line rounded-full border px-3.5 py-1.5 text-[length:var(--fs-sm)]"
|
|
>
|
|
이미지로 저장
|
|
</button>
|
|
</div>
|
|
) : (
|
|
/* 브라우저 정책이라 여기서는 못 넘는다 — 사진이 이 사이트 주소로 와야 풀리고,
|
|
그 일은 굽는 쪽(prerender `mirrorMedia`)이 한다. 여기까지 왔다는 건 그게 실패했다는 뜻. */
|
|
<p className="text-muted text-[length:var(--fs-sm)]">
|
|
이 사진은 다른 사이트에 올라와 있어 저장·공유가 막혀 있습니다. 화면으로만 보여 드립니다.
|
|
</p>
|
|
)}
|
|
{hint && <p className="text-muted text-[length:var(--fs-xs)]">{hint}</p>}
|
|
</div>
|
|
</div>
|
|
</Section>
|
|
);
|
|
}
|