[feat] solution/site,docs: 엽서 쓰기를 발행본에 넣는다 — 그리기 규칙은 한 파일로

시연본에만 주입 스크립트로 있던 엽서 쓰기를 발행본 컴포넌트로 옮겼다(2026-09-14 대표:
"웹빌드 해도 엽서쓰기 나오게"). 사진이 있는 사이트면 섹션이 나간다.

★ 캔버스가 남의 도메인 사진에 오염되면 저장·공유가 SecurityError 로 죽는다 — 미리보기는
  보이는데 내보내기만 막히는, 눈으로는 못 찾는 종류다. 실측: 발행본 사진은 네이버 CDN 에 있고
  그쪽은 Access-Control-Allow-Origin 을 주지 않는다(curl -I 확인). CORS 로 먼저 받아 보고
  실패하면 CORS 없이 받아 **미리보기만** 세우고 저장·공유 단추를 감춘다.

- lib/postcard-canvas.ts(신규): 그리기만 한다. 여백 56 · 우표 칸 190 · 최대 4줄은 시연본과 같은 값
- sections/items/PostcardMakerSection.tsx(신규): 사진 고르기·입력(4줄 제한)·공유/저장
- HomePage: 섹션 설정에 자리가 없는 기능이라 오시는 길과 같은 방식으로 직접 낸다
- package-lock.json: playwright 선언에 맞춰 잠금 갱신(어제 커밋에서 lock 을 안 맞춰 npm ci 가 죽었다)
- DEVLOG: 근본 해결(사진을 우리 오리진으로 옮기기)은 아직 안 했다고 적어 둔다

검증: tsc 통과 · 발행본(/s/statata)에서 사진·문구·우표·소인·서명 줄이 그려지고,
      외부 사진이라 저장·공유 대신 안내문이 뜬다(스크린샷 확인)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mina Choi 2026-09-14 20:16:24 +09:00
parent 3b4c5c98bc
commit 93bd5435ac
6 changed files with 467 additions and 0 deletions

View File

@ -5,6 +5,25 @@
--- ---
## 2026-09-14 — 엽서 쓰기를 발행본에도 넣는다 (사진이 남의 도메인이면 저장·공유는 막힌다)
**무슨 일** — 시연본에만 주입 스크립트로 있던 '엽서 쓰기'(사진 고르기 + 한 마디 + 캔버스 엽서)를
발행본 컴포넌트로 옮겼다. 그리기 규칙은 `site/src/lib/postcard-canvas.ts` 한 곳에 두고,
화면·입력·공유는 `sections/items/PostcardMakerSection.tsx` 가 맡는다. 사진이 있는 사이트면 나간다.
**★ 저장·공유가 사진 출처에 걸린다** — 캔버스는 **남의 도메인 사진을 그리면 오염돼서**(tainted)
`toBlob` 이 SecurityError 로 막힌다. 미리보기는 멀쩡히 보이는데 저장·공유만 죽는, 눈으로는 못 찾는 종류다.
CORS 로 받으면 안 오염되지만 실측(2026-09-14) 발행본 사진은 네이버 CDN(`*.pstatic.net`)에 있고
그쪽은 `Access-Control-Allow-Origin` 을 주지 않는다 — `curl -I` 로 확인했다.
→ 지금은 **정직하게 막는다.** CORS 로 한 번 받아 보고, 실패하면 CORS 없이 다시 받아 미리보기만 세우고
저장·공유 단추를 아예 감춘다("이 사진은 다른 사이트에 올라와 있어 …"). 눌러도 안 되는 단추를 두지 않는다.
**근본 해결은 사진을 우리 오리진으로 옮기는 것이다.** 시연본이 `img/mirror/` 로 그렇게 하고 있고,
발행 파이프라인이 같은 일을 하면(빌드 때 내려받아 `out/s/<slug>/img/` 에 두고 payload 주소를 바꾼다)
저장·공유가 풀린다. 덤으로 외부 주소 만료·핫링크 문제도 같이 사라진다. **아직 안 했다.**
---
## 2026-09-14 — FAQ 를 20개까지 채운다 (펜션 공통 질문 30개 + 문의 안내) ## 2026-09-14 — FAQ 를 20개까지 채운다 (펜션 공통 질문 30개 + 문의 안내)
**무슨 일** — COPY 잡의 FAQ 생성 상한을 8 → 20 으로 올리고, 그래도 모자라면 펜션 공통 질문 카탈로그에서 **무슨 일** — COPY 잡의 FAQ 생성 상한을 8 → 20 으로 올리고, 그래도 모자라면 펜션 공통 질문 카탈로그에서

32
package-lock.json generated
View File

@ -13,6 +13,9 @@
"solution/site", "solution/site",
"admin/frontend" "admin/frontend"
], ],
"devDependencies": {
"playwright": "^1.63.0"
},
"engines": { "engines": {
"node": ">=20" "node": ">=20"
} }
@ -7337,6 +7340,35 @@
"pathe": "^2.0.3" "pathe": "^2.0.3"
} }
}, },
"node_modules/playwright": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
"integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.63.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright-core": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
"integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/pony-cause": { "node_modules/pony-cause": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/pony-cause/-/pony-cause-1.1.1.tgz", "resolved": "https://registry.npmjs.org/pony-cause/-/pony-cause-1.1.1.tgz",

View File

@ -0,0 +1,236 @@
/**
* .
*
* . `solution/site/scripts/mockup/inject.js` `postcardMaker`
* (2026-09-14 ), .
* 56 · 190 · 4 .
* . · · (PostcardMakerSection)
* .
*/
/** 정사각형 1080 — 공유 시트에 올라가는 이미지의 1:1 관례와 같다. */
export const POSTCARD_SIZE = 1080;
/** 문구는 넉 줄까지만 (2026-09-14 대표: "4줄이상 못쓰게 막으라고"). */
export const MAX_LINES = 4;
const MARGIN = 56;
const STAMP_COL_W = 190;
const INK = '#1b1a15';
const PAPER = '#efe7d3';
const ACCENT = '#bf2f1b';
/** 문구가 들어갈 칸의 폭. 입력을 막을 때도 이 값으로 미리 재야 화면과 그림이 갈리지 않는다. */
export function textColumnWidth(size: number = POSTCARD_SIZE): number {
return size - MARGIN - STAMP_COL_W - MARGIN - 24;
}
/**
* CSS .
* . `\n`
* .
* ( ) . .
*/
export function wrapLines(ctx: CanvasRenderingContext2D, text: string, maxWidth: number): string[] {
const lines: string[] = [];
text.split('\n').forEach((para) => {
if (para.trim() === '') {
lines.push('');
return;
}
let line = '';
para.split(' ').forEach((word) => {
const test = line ? `${line} ${word}` : word;
if (ctx.measureText(test).width <= maxWidth) {
line = test;
return;
}
if (line) lines.push(line);
if (ctx.measureText(word).width <= maxWidth) {
line = word;
return;
}
let chunk = '';
for (const ch of word) {
const next = chunk + ch;
if (ctx.measureText(next).width > maxWidth && chunk) {
lines.push(chunk);
chunk = ch;
} else {
chunk = next;
}
}
line = chunk;
});
if (line) lines.push(line);
});
return lines;
}
/**
* ( ) .
* . · ,
* ("스테이,머뭄" ).
*/
function stampLines(ctx: CanvasRenderingContext2D, name: string, maxWidth: number): string[] {
const lines: string[] = [];
let line = '';
name.split(/[,\s]+/).filter(Boolean).forEach((part) => {
const test = line ? `${line} ${part}` : part;
if (ctx.measureText(test).width <= maxWidth) {
line = test;
return;
}
if (line) lines.push(line);
if (ctx.measureText(part).width <= maxWidth) {
line = part;
return;
}
let chunk = '';
for (const ch of part) {
const next = chunk + ch;
if (ctx.measureText(next).width > maxWidth && chunk) {
lines.push(chunk);
chunk = ch;
} else {
chunk = next;
}
}
line = chunk;
});
if (line) lines.push(line);
return lines;
}
/**
* . `exportable` ** ** .
*
* (tainted) `toBlob`
* · SecurityError , .
* CORS . `crossOrigin="anonymous"` .
* (2026-09-14): CDN(`*.pstatic.net`)
* `Access-Control-Allow-Origin` ,
* **CORS ** (· ).
* ( `img/mirror/` ).
*/
async function loadImage(url: string): Promise<{img: HTMLImageElement; exportable: boolean}> {
const tryLoad = (crossOrigin: string | null) => new Promise<HTMLImageElement>((resolve, reject) => {
const img = new Image();
if (crossOrigin) img.crossOrigin = crossOrigin;
img.onload = () => resolve(img);
img.onerror = reject;
img.src = url;
});
try {
return {img: await tryLoad('anonymous'), exportable: true};
} catch {
return {img: await tryLoad(null), exportable: false};
}
}
/** 이 사이트가 쓰는 세리프를 그대로 쓴다 — 화면의 카드와 그림의 글씨가 갈리지 않게. */
function serifFamily(): string {
const probe = document.querySelector('.serif');
const family = probe ? getComputedStyle(probe).fontFamily : '';
return family || 'serif';
}
/** 돌려주는 값: 이 그림을 파일로 뽑을 수 있는가(사진이 오염시키지 않았는가). */
export async function drawPostcard(
canvas: HTMLCanvasElement,
{photoUrl, text, placeName, region}: {photoUrl: string; text: string; placeName: string; region: string},
): Promise<boolean> {
const ctx = canvas.getContext('2d');
if (!ctx) return false;
const size = canvas.width;
const serif = serifFamily();
ctx.clearRect(0, 0, size, size);
ctx.fillStyle = PAPER;
ctx.fillRect(0, 0, size, size);
const {img, exportable} = await loadImage(photoUrl);
// 위 — 사진(실제 카드 앞면과 같은 3:2)
const photoW = size - MARGIN * 2;
const photoH = photoW / 1.5;
const scale = Math.max(photoW / img.width, photoH / img.height);
const w = img.width * scale;
const h = img.height * scale;
ctx.save();
ctx.beginPath();
ctx.rect(MARGIN, MARGIN, photoW, photoH);
ctx.clip();
ctx.drawImage(img, MARGIN + (photoW - w) / 2, MARGIN + (photoH - h) / 2, w, h);
ctx.restore();
ctx.strokeStyle = 'rgba(27,26,21,0.25)';
ctx.lineWidth = 1;
ctx.strokeRect(MARGIN + 0.5, MARGIN + 0.5, photoW - 1, photoH - 1);
// 아래 — 괘선으로 가른 두 칸(문구 | 우표)
const bodyY = MARGIN + photoH + 36;
const dividerX = size - MARGIN - STAMP_COL_W;
const textColW = textColumnWidth(size);
ctx.strokeStyle = 'rgba(27,26,21,0.35)';
ctx.beginPath();
ctx.moveTo(dividerX, bodyY);
ctx.lineTo(dividerX, size - MARGIN - 46);
ctx.stroke();
// 왼쪽 — 손님이 쓴 한 마디
ctx.fillStyle = INK;
ctx.font = `600 46px ${serif}`;
ctx.textAlign = 'left';
ctx.textBaseline = 'alphabetic';
const wrapped = wrapLines(ctx, `${text.trim() || '오늘, 이 숙소에서 하루를 보냅니다.'}`, textColW);
// 입력에서 이미 막지만 여기서도 자른다 — 붙여넣기 같은 다른 경로로 들어와도 카드가 안 깨지게.
const lines = wrapped.slice(0, MAX_LINES);
if (wrapped.length > MAX_LINES) {
let last = lines[MAX_LINES - 1];
while (last.length && ctx.measureText(`${last}`).width > textColW) last = last.slice(0, -1);
lines[MAX_LINES - 1] = `${last}`;
}
lines.forEach((line, i) => ctx.fillText(line, MARGIN, bodyY + 50 + i * 60));
// 오른쪽 — 우표 칸과 소인
const stampCx = dividerX + STAMP_COL_W / 2;
ctx.strokeStyle = INK;
ctx.lineWidth = 1.5;
ctx.setLineDash([4, 4]);
ctx.strokeRect(stampCx - 44, bodyY, 88, 108);
ctx.setLineDash([]);
ctx.fillStyle = INK;
ctx.font = `400 20px ${serif}`;
ctx.textAlign = 'center';
ctx.fillText('郵票', stampCx, bodyY + 46);
ctx.fillText('10원', stampCx, bodyY + 78);
ctx.save();
ctx.translate(stampCx, bodyY + 210);
ctx.rotate((-6 * Math.PI) / 180);
ctx.strokeStyle = ACCENT;
ctx.lineWidth = 3;
ctx.beginPath();
ctx.arc(0, 0, 62, 0, Math.PI * 2);
ctx.stroke();
ctx.fillStyle = ACCENT;
ctx.font = `600 22px ${serif}`;
const nameLines = stampLines(ctx, placeName, 100).slice(0, 2);
const startY = 8 - ((nameLines.length - 1) * 26) / 2;
nameLines.forEach((line, i) => ctx.fillText(line, 0, startY + i * 26));
ctx.restore();
// 맨 아래 — 점선과 서명 줄
const footY = size - MARGIN - 30;
ctx.strokeStyle = 'rgba(27,26,21,0.3)';
ctx.setLineDash([3, 3]);
ctx.beginPath();
ctx.moveTo(MARGIN, footY - 20);
ctx.lineTo(size - MARGIN, footY - 20);
ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle = 'rgba(27,26,21,0.6)';
ctx.font = `400 22px ${serif}`;
ctx.textAlign = 'left';
ctx.fillText(`${placeName} · ${region}`, MARGIN, footY + 6);
return exportable;
}

View File

@ -14,6 +14,7 @@ import {
LocalGuideSection, LocalGuideSection,
WeatherSection, WeatherSection,
LocationSection, LocationSection,
PostcardMakerSection,
SpaceSection, SpaceSection,
UnitsSection, UnitsSection,
StayBookingSection, StayBookingSection,
@ -111,6 +112,11 @@ export function HomePage() {
); );
})} })}
{/* (2026-09-14 : " ").
.
. */}
<PostcardMakerSection />
{/* 섹션 설정에 없더라도 오시는 길은 항상 나간다 — 위치 질의의 근거다. */} {/* 섹션 설정에 없더라도 오시는 길은 항상 나간다 — 위치 질의의 근거다. */}
{!isSectionEnabled(payload, 'map') && <LocationSection />} {!isSectionEnabled(payload, 'map') && <LocationSection />}

View File

@ -21,3 +21,4 @@ export {SiteFooter} from './SiteFooter';
export {MobileTabBar} from './MobileTabBar'; export {MobileTabBar} from './MobileTabBar';
// 붙여넣기 아이템 — 데이터가 fact 가 아니라 사장님이 넣은 JSON 에서 온다(theme.sections[].data). // 붙여넣기 아이템 — 데이터가 fact 가 아니라 사장님이 넣은 JSON 에서 온다(theme.sections[].data).
export {ITEM_SECTIONS} from './items'; export {ITEM_SECTIONS} from './items';
export {PostcardMakerSection} from './items/PostcardMakerSection';

View File

@ -0,0 +1,173 @@
/**
* .
*
* (`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 ).
· . */
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}에서 보내는 한 장을 직접 써 봅니다. 사진을 고르고 한 마디를 적으면 그 자리에서 엽서가 되고, 그대로 공유하거나 저장할 수 있습니다.`}
>
<div className="grid gap-6 sm:grid-cols-[minmax(0,1fr)_minmax(0,320px)]">
<canvas
ref={canvasRef}
width={POSTCARD_SIZE}
height={POSTCARD_SIZE}
aria-label="내가 만든 엽서 미리보기"
className="border-line w-full border"
/>
<div className="flex flex-col gap-3">
<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)}
aria-pressed={i === picked}
aria-label={`사진 ${i + 1}`}
className="border-line size-16 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={3}
maxLength={60}
value={text}
placeholder="엽서에 쓸 한 마디를 적어 보세요"
onChange={(e) => { if (withinLineLimit(e.target.value)) setText(e.target.value); }}
className="border-line w-full rounded border p-3 text-[length:var(--fs-sm)]"
/>
<p className="text-muted 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-4 py-2 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-4 py-2 text-[length:var(--fs-sm)]"
>
</button>
</div>
) : (
/* 브라우저 정책이라 여기서는 못 넘는다 — 사진이 이 사이트 주소로 올라와야 풀린다. */
<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>
);
}