o2o-site-AEO/solution/site/src/sections/items/PostcardMakerSection.tsx
Mina Choi 6b9e01d876 [feat] solution: 지역 읽기 섹션 · 엽서 공유를 풀고 · 기존 사이트는 자산 주소만 갈아 끼운다
세 가지가 한 줄기다 — 목업에만 있던 것을 제품으로 옮기면서, 그게 이미 나가 있는
사이트를 건드리지 않게 하는 데까지가 한 변경이다.

① 지역 읽기(mockup/README T7) — 목업은 주입 스크립트로 그렸고 렌더러엔 없었다.
   새로 발행한 업장에서는 영영 빈자리였다(`daily` 가 프롬프트를 빌더에만 둬서 서버가
   그 종류를 몰랐던 것과 같은 사고).
   · shared: `ReadingItem` · `SECTION_ITEM_REQUIRED_KEY.reading` · `reading` 프롬프트
     (프롬프트 단일 출처는 `section-prompts.ts` 하나다 — 코드에 문장을 박지 않는다)
   · backend: STORY_KINDS 등록. `_SEARCH_LINK_KINDS` — 이 종류는 모델의 URL 을 안 받고
     제목으로 만든 네이버 검색 링크를 코드가 붙인다(주소를 짐작해 적으면 없는 문서로 간다)
   · site: '지역 이야기' 여섯 번째 탭. 구운 HTML 은 앞에서 여섯 꼭지, 붙은 뒤 한 번 섞는다
   · 탭 이름은 `{지명} 읽기` — '군산' 을 코드에 박지 않는다

② 엽서 공유가 모든 발행 사이트에서 막혀 있던 것. 사진이 `*.pstatic.net` ·
   `tong.visitkorea.or.kr` 에 있고 그쪽이 `Access-Control-Allow-Origin` 을 안 준다
   (실측 세 곳 모두 없음) — 캔버스가 오염돼 `toBlob` 이 죽는다. 클라이언트에서는 못 넘는다.
   · `prerender.ts mirrorMedia`: 굽기 전에 `s/<slug>/img/<주소해시>.<확장자>` 로 받고
     payload 주소를 우리 오리진 절대주소로 바꾼다(og:image·JSON-LD 도 같은 값을 쓴다)
   · 못 받으면 원래 주소를 쓴다. 파일명이 주소 해시라 다시 구워도 안 받는다
   · `originUrl`·`sourceType` 은 그대로 — DECISIONS 1-2 가 "불가" 면 CRAWL 제외가 먹어야 한다
   · 엽서 미리보기를 240px 로 묶었다(대표: "엽서 ui 너무 큼")

③ **기동이 전부 다시 굽지 않는다** (대표: "전체 재굽기 할 필요가 없어, 사장님이
   재발행하면 끝인데 / css js만 안 깨지게 하란 말이야").
   렌더러를 한 줄 고칠 때마다 이미 나가 있는 사이트의 HTML 이 통째로 바뀌던 자리다.
   · `watch-payloads.mjs`: 기동 = `--refresh-assets` 하나. 한 번도 안 구워진 payload 만 굽는다
   · `prerender.ts refreshBakedAssets`: 구워진 HTML 의 `assets/index-<해시>.css|js` 파일명만
     새 번들로 바꾼다. 내용·payload·접두사는 그대로. 보호 슬러그는 건너뛴다
   · 그래서 ①②는 **다음 발행 때** 그 사이트에 들어간다

문서: AGENTS.md 함정 둘(사진 내려받기 · 기동은 안 굽는다) 추가, 전체 재굽기를 전제하던
옛 항목 둘을 고쳤다. mockup/README T7 은 "제품에 들어갔다" 로, DATA_MODEL 의 STORY kind 목록 갱신.

검증: tsc·eslint 통과(site·frontend), site 79 passed(읽기 4건 추가).
실측 — buru 굽기: 사진 10장 내려받고 og:image 가 우리 주소, 재굽기 때 0건;
`--refresh-assets`: 옛 해시로 바꿔 둔 index.html 1곳이 새 번들 주소로 바뀌고 내용은 그대로.
백엔드 테스트는 이 기계의 5432 가 다른 터널에 물려 있어 못 돌렸다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 09:31:19 +09:00

182 lines
8.9 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 {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 넘는 정사각이 서서, 예약하러 온 손님이
이 한 칸을 스크롤로 넘겨야 다음 섹션에 닿는다. 미리보기는 손바닥만 하면 된다.
★ 320px 도 크다고 했다(2026-09-15, 같은 날 두 번째) — 240px 로 내렸다. */}
<div className="mx-auto grid max-w-2xl gap-4 sm:grid-cols-[minmax(0,240px)_minmax(0,1fr)] sm:items-start">
<canvas
ref={canvasRef}
width={POSTCARD_SIZE}
height={POSTCARD_SIZE}
aria-label="내가 만든 엽서 미리보기"
className="border-line mx-auto w-full max-w-[240px] border"
/>
<div className="flex flex-col gap-2.5">
<div className="slider-viewport" role="radiogroup" aria-label={`엽서 사진 고르기(${photos.length}장)`} tabIndex={0}>
<div className="slider-track" style={{gap: '0.5rem'}}>
{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>
))}
</div>
</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>
);
}