From 93bd5435ac68ba136152df182a7f68875f1de1e8 Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Mon, 14 Sep 2026 20:16:24 +0900 Subject: [PATCH] =?UTF-8?q?[feat]=20solution/site,docs:=20=EC=97=BD?= =?UTF-8?q?=EC=84=9C=20=EC=93=B0=EA=B8=B0=EB=A5=BC=20=EB=B0=9C=ED=96=89?= =?UTF-8?q?=EB=B3=B8=EC=97=90=20=EB=84=A3=EB=8A=94=EB=8B=A4=20=E2=80=94=20?= =?UTF-8?q?=EA=B7=B8=EB=A6=AC=EA=B8=B0=20=EA=B7=9C=EC=B9=99=EC=9D=80=20?= =?UTF-8?q?=ED=95=9C=20=ED=8C=8C=EC=9D=BC=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 시연본에만 주입 스크립트로 있던 엽서 쓰기를 발행본 컴포넌트로 옮겼다(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) --- docs/DEVLOG.md | 19 ++ package-lock.json | 32 +++ solution/site/src/lib/postcard-canvas.ts | 236 ++++++++++++++++++ solution/site/src/pages/HomePage.tsx | 6 + solution/site/src/sections/index.ts | 1 + .../sections/items/PostcardMakerSection.tsx | 173 +++++++++++++ 6 files changed, 467 insertions(+) create mode 100644 solution/site/src/lib/postcard-canvas.ts create mode 100644 solution/site/src/sections/items/PostcardMakerSection.tsx diff --git a/docs/DEVLOG.md b/docs/DEVLOG.md index 65a2880..d902a8b 100644 --- a/docs/DEVLOG.md +++ b/docs/DEVLOG.md @@ -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//img/` 에 두고 payload 주소를 바꾼다) + 저장·공유가 풀린다. 덤으로 외부 주소 만료·핫링크 문제도 같이 사라진다. **아직 안 했다.** + +--- + ## 2026-09-14 — FAQ 를 20개까지 채운다 (펜션 공통 질문 30개 + 문의 안내) **무슨 일** — COPY 잡의 FAQ 생성 상한을 8 → 20 으로 올리고, 그래도 모자라면 펜션 공통 질문 카탈로그에서 diff --git a/package-lock.json b/package-lock.json index d6075fc..4e350f5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,9 @@ "solution/site", "admin/frontend" ], + "devDependencies": { + "playwright": "^1.63.0" + }, "engines": { "node": ">=20" } @@ -7337,6 +7340,35 @@ "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": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/pony-cause/-/pony-cause-1.1.1.tgz", diff --git a/solution/site/src/lib/postcard-canvas.ts b/solution/site/src/lib/postcard-canvas.ts new file mode 100644 index 0000000..e6c2e96 --- /dev/null +++ b/solution/site/src/lib/postcard-canvas.ts @@ -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((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 { + 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; +} diff --git a/solution/site/src/pages/HomePage.tsx b/solution/site/src/pages/HomePage.tsx index df4b31c..767a755 100644 --- a/solution/site/src/pages/HomePage.tsx +++ b/solution/site/src/pages/HomePage.tsx @@ -14,6 +14,7 @@ import { LocalGuideSection, WeatherSection, LocationSection, + PostcardMakerSection, SpaceSection, UnitsSection, StayBookingSection, @@ -111,6 +112,11 @@ export function HomePage() { ); })} + {/* 엽서 쓰기 — 사진이 있으면 낸다 (2026-09-14 대표: "웹빌드 해도 엽서쓰기 나오게"). + 섹션 설정에 자리가 없는 기능이라 오시는 길과 같은 방식으로 여기서 직접 낸다. + 사진이 한 장도 없으면 만들 엽서가 없으므로 컴포넌트가 스스로 빠진다. */} + + {/* 섹션 설정에 없더라도 오시는 길은 항상 나간다 — 위치 질의의 근거다. */} {!isSectionEnabled(payload, 'map') && } diff --git a/solution/site/src/sections/index.ts b/solution/site/src/sections/index.ts index e7dfe7e..da78d34 100644 --- a/solution/site/src/sections/index.ts +++ b/solution/site/src/sections/index.ts @@ -21,3 +21,4 @@ export {SiteFooter} from './SiteFooter'; export {MobileTabBar} from './MobileTabBar'; // 붙여넣기 아이템 — 데이터가 fact 가 아니라 사장님이 넣은 JSON 에서 온다(theme.sections[].data). export {ITEM_SECTIONS} from './items'; +export {PostcardMakerSection} from './items/PostcardMakerSection'; diff --git a/solution/site/src/sections/items/PostcardMakerSection.tsx b/solution/site/src/sections/items/PostcardMakerSection.tsx new file mode 100644 index 0000000..774b407 --- /dev/null +++ b/solution/site/src/sections/items/PostcardMakerSection.tsx @@ -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(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 { + 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('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 ( +
+
+ +
+
+
+ {photos.map((photo, i) => ( + + ))} +
+
+ +