o2o-site-AEO/solution/site/scripts/mockup/inject.js
Mina Choi 5e2def200b [chore] site/mockup: 번들 갱신·문서 정리 · 2안(사진) 빌더 스크립트 추가
- vendor/ 옛 해시 번들을 retired/ 로 옮기고 새 해시로 교체
- build_photo6.py 신설 — /s/stay6 "2안(사진)" 판, 슬러그별로 다시 구울 수 있다
  (python3 build_photo6.py <slug>)
- build_pension.py·build_reading.py·build_stay6.py·patch_stay.py 정리
- AUTOPLAY.md·README.md·audit-all.mjs 갱신
2026-09-23 13:18:51 +09:00

1905 lines
103 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* /s/stay 목업 주입분 — ① 캐치프레이즈 순환 ② 헤더 안 미니 플레이어.
*
* ★ 플레이어는 자리를 세 번 옮겼다. 지금은 **사이트 헤더 안**이다.
* ① 하단 바 → 모바일에서 발행본의 탭바(fixed bottom:0 · 61px · z-50 — 위치·전화·예약)를 덮었다.
* ② 상단 바 → 헤더와 두 줄이 되어 화면 위 120px 이 크롬이 됐다("조금 이상한데").
* ③ 헤더 안 아이콘 — 줄이 늘지 않는다. 원판·곡명 없이 재생·다음·목록 세 단추뿐이고,
* 곡 정보는 목록을 열 때만 보인다.
*
* ★ 헤더는 React 가 그린다. 그래서 두 가지를 지킨다.
* · 단추는 헤더 안에 꽂지만, 없어졌는지 감시해서 다시 꽂는다(하이드레이션 재렌더가 지운다).
* · 목록 패널은 #root **밖**(body 직속)에 둔다 — 안에 두면 재렌더에 통째로 날아간다.
*
* ★ 재생은 곡의 audioUrl(사장님이 직접 만든 음원)만 쓴다. 파일은 `/s/<slug>/audio/…` 에 있고,
* nginx 의 `^~ /s/` 가 그대로 서빙한다(Range 206 확인). 옛 가요 네 곡은 우리 음원이 아니라
* 플레이어에 올리지 않는다 — 가요 다방 섹션에는 그대로 남는다.
* ★ 가사는 payload 에 있을 때만 낸다. 직접 만든 곡이라 원문을 실어도 되는 유일한 경우다.
*/
(function () {
'use strict';
var P = window.__SITE_PAYLOAD__;
if (!P) return;
var REDUCED = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function pick(list) {
return list && list.length ? list[Math.floor(Math.random() * list.length)] : null;
}
/* ══ ① 캐치프레이즈 ══════════════════════════════════════════════════════
* 일반(랜덤) → 계절 → 일반 → 월 → 일반 → 날씨 순으로 한 바퀴를 만들고 그 바퀴를 돈다.
* 일반은 자루에서 뽑아 쓰고 자루가 비면 다시 채운다 — 매번 랜덤이면 같은 문장이 연달아 나온다.
*/
function catchphrases() {
var box = P.narrative && P.narrative.catchphrases;
return (box && box.items) || [];
}
/* 3개월 단위. 12·1·2 겨울 / 3·4·5 봄 / 6·7·8 여름 / 9·10·11 가을 */
var SEASON_OF_MONTH = ['겨울', '겨울', '봄', '봄', '봄', '여름', '여름', '여름', '가을', '가을', '가을', '겨울'];
function startCatchphrase() {
var items = catchphrases();
if (items.length === 0) return;
/* ★ 대표 문구는 고정이고, 순환은 그 **아래 줄**에서 돈다 (2026-09-11 사장님 지시:
"메인 모토는 고정으로 하고, 그 아래 계속 변동되는 모토가 돌아가는 방향").
예전에는 대표 문구 자리를 통째로 갈아 끼워 "히로쓰 가옥 담 너머…" 가 5초 뒤 사라졌다 —
숙소 이름 다음으로 남아야 할 한 줄이 첫 화면에서 지워지는 셈이었다. */
/* ★ 대표 문구는 고정이고, 순환은 그 **아래 줄**에서 돈다 (2026-09-11 사장님 지시:
"메인 모토는 고정으로 하고, 그 아래 계속 변동되는 모토가 돌아가는 방향").
예전에는 대표 문구 자리를 통째로 갈아 끼워 "히로쓰 가옥 담 너머…" 가 5초 뒤 사라졌다 —
숙소 이름 다음으로 남아야 할 한 줄이 첫 화면에서 지워지는 셈이었다. */
var base = ((P.narrative && P.narrative.tagline) || '').trim();
var node = null;
/* ★ 노드를 들고 있지 않고 매번 확인한다.
이 목업은 payload 를 손으로 고친 것이라 구워진 마크업과 어긋나고, React 는 하이드레이션에
실패하면 #root 를 **통째로 다시 그린다** — 대표 문구 <p> 가 새 노드로 바뀌고 그 아래 붙여 둔
줄은 같이 사라진다. 한 번 잡아 둔 참조로 계속 쓰면 그 순간부터 아무 일도 안 일어나면서 오류도 안 난다. */
function findTagline() {
var candidates = document.querySelectorAll('#root p');
for (var i = 0; i < candidates.length; i += 1) {
if (candidates[i].textContent.trim() === base) return candidates[i];
}
return null;
}
function ensureNode() {
if (node && node.isConnected) return node;
var tagline = findTagline();
if (!tagline) return null;
node = document.createElement('p');
node.id = 'w4d-sub';
node.className = 'w4d-sub measure';
tagline.insertAdjacentElement('afterend', node);
reserve();
return node;
}
if (!base || !findTagline()) return;
/* 줄 높이를 **가장 긴 문구**에 맞춰 박는다. 히어로는 아래 정렬이라 이 줄이 한 줄↔두 줄을 오가면
숙소 이름과 대표 문구가 통째로 위아래로 튄다. 지금 폭에서 문구를 다 재 보고 가장 높은 값을 쓴다. */
function reserve() {
var width = node.getBoundingClientRect().width;
if (!width) return;
var probe = node.cloneNode(false);
probe.removeAttribute('id');
probe.style.cssText = 'position:absolute;visibility:hidden;pointer-events:none;width:' + width + 'px';
node.parentNode.appendChild(probe);
var max = 0;
items.forEach(function (it) {
probe.textContent = it.text;
max = Math.max(max, probe.getBoundingClientRect().height);
});
probe.remove();
node.style.minHeight = Math.ceil(max) + 'px';
}
var resizeTimer = null;
window.addEventListener('resize', function () {
window.clearTimeout(resizeTimer);
resizeTimer = window.setTimeout(function () { if (node && node.isConnected) reserve(); }, 200);
});
var byKind = function (kind) {
return items.filter(function (it) { return it.kind === kind; });
};
var weather = null; // 못 받아 오면 날씨 문장은 아예 순환에 넣지 않는다.
var bag = [];
var cycle = [];
function nextGeneral() {
if (bag.length === 0) {
bag = byKind('general').slice();
for (var j = bag.length - 1; j > 0; j -= 1) {
var k = Math.floor(Math.random() * (j + 1));
var t = bag[j]; bag[j] = bag[k]; bag[k] = t;
}
}
return bag.pop();
}
function buildCycle() {
var now = new Date();
var month = now.getMonth() + 1;
var season = SEASON_OF_MONTH[month - 1];
var out = [nextGeneral()];
out.push(pick(byKind('season').filter(function (it) { return it.season === season; })));
out.push(nextGeneral());
out.push(pick(byKind('month').filter(function (it) { return it.month === month; })));
out.push(nextGeneral());
if (weather) {
out.push(pick(byKind('weather').filter(function (it) { return it.weather === weather; })));
}
return out.filter(Boolean);
}
function ensureNode() {
if (node && node.isConnected) return node;
var tagline = findTagline();
if (!tagline) return null;
node = document.createElement('p');
node.id = 'w4d-sub';
node.className = 'w4d-sub measure';
tagline.insertAdjacentElement('afterend', node);
reserve();
return node;
}
if (!base || !findTagline()) return;
/* 줄 높이를 **가장 긴 문구**에 맞춰 박는다. 히어로는 아래 정렬이라 이 줄이 한 줄↔두 줄을 오가면
숙소 이름과 대표 문구가 통째로 위아래로 튄다. 지금 폭에서 문구를 다 재 보고 가장 높은 값을 쓴다. */
function reserve() {
var width = node.getBoundingClientRect().width;
if (!width) return;
var probe = node.cloneNode(false);
probe.removeAttribute('id');
probe.style.cssText = 'position:absolute;visibility:hidden;pointer-events:none;width:' + width + 'px';
node.parentNode.appendChild(probe);
var max = 0;
items.forEach(function (it) {
probe.textContent = it.text;
max = Math.max(max, probe.getBoundingClientRect().height);
});
probe.remove();
node.style.minHeight = Math.ceil(max) + 'px';
}
var resizeTimer = null;
window.addEventListener('resize', function () {
window.clearTimeout(resizeTimer);
resizeTimer = window.setTimeout(function () { if (node && node.isConnected) reserve(); }, 200);
});
var cycle = [];
function buildCycle() { return items.filter(function (item) { return item.kind === "general"; }); }
/* 문장 하나를 단어 칸으로 조립한다. innerHTML 을 안 쓰는 이유는 문구가 데이터라서다 —
지금은 우리가 쓴 문장이지만, 이 자리는 나중에 서버가 채운다. */
function paint(text) {
var line = document.createElement('span');
line.className = 'w4d-line';
text.split(/\s+/).forEach(function (word, index) {
if (index > 0) line.appendChild(document.createTextNode(' '));
var mask = document.createElement('span');
mask.className = 'w4d-w';
var inner = document.createElement('span');
inner.textContent = word;
// 단어마다 조금씩 늦게 선다. 55ms 면 열 단어가 0.5초 안에 다 서서 읽기를 안 기다리게 한다.
inner.style.setProperty('--w4d-d', index * 55 + 'ms');
mask.appendChild(inner);
line.appendChild(mask);
});
node.textContent = '';
node.appendChild(line);
// 클래스를 한 프레임 뒤에 건다 — 같은 프레임에 붙이면 애니메이션 시작 상태가 없다.
window.requestAnimationFrame(function () { line.classList.add('w4d-line--in'); });
}
function show(text) {
var fresh = !(node && node.isConnected);
if (!ensureNode()) return;
if (REDUCED) { node.textContent = text; return; }
var current = node.firstElementChild;
if (current && current.classList.contains('w4d-line')) {
current.classList.remove('w4d-line--in');
current.classList.add('w4d-line--out');
}
// 새로 붙인 줄은 비어 있으니 나갈 문장이 없다 — 기다리지 않고 바로 세운다.
window.setTimeout(function () {
if (!ensureNode()) return;
paint(text);
}, fresh ? 0 : 300);
}
function tick() {
if (cycle.length === 0) cycle = buildCycle();
var next = cycle.shift();
if (next && next.text) show(next.text);
}
/* 날씨는 렌더러가 쓰는 그 엔드포인트로 받는다(같은 오리진). 실패하면 조용히 넘어간다 —
날씨 문장 몇 개 때문에 순환이 멈추면 안 된다. */
function refreshWeather() {
var place = P.place || {};
if (!place.regionCode || place.latitude == null) return;
var query = 'region_code=' + encodeURIComponent(place.regionCode) +
'&latitude=' + place.latitude + '&longitude=' + place.longitude;
fetch('/v1/local/weather?' + query)
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (body) {
if (!body || !body.weather) return;
var code = Number(body.weather.weather_code);
// 렌더러(use-live-weather.ts)와 같은 분류를 쓴다 — 갈리면 화면의 날씨와 문장이 어긋난다.
weather = code === 0 ? '맑음'
: code >= 1 && code <= 3 ? '구름많음'
: code >= 50 && code <= 69 ? '비'
: code >= 70 && code <= 79 ? '눈' : '흐림';
})
.catch(function () { /* 구운 값 그대로 둔다 */ });
}
refreshWeather();
window.setInterval(refreshWeather, 10 * 60 * 1000);
// 대표 문구가 먼저 읽히고 나서 아래 줄이 선다 — 둘이 한꺼번에 뜨면 어느 쪽이 숙소의 한 줄인지 안 읽힌다.
window.setTimeout(function () {
tick();
window.setInterval(function () { if (!document.hidden && !REDUCED) tick(); }, 6000);
}, 1200);
}
/* ══ ② 헤더 안 미니 플레이어 ═══════════════════════════════════════════════
* ★ 단추는 둘뿐이다 — 재생/멈춤과 목록 (2026-09-10 사장님: 셔플 단추 "얘는 빼도 되지 않을까?").
* 곡이 끝나면 어차피 무작위로 다음 곡이 걸리므로 셔플 단추가 할 일이 없었다.
* 다른 곡을 지금 듣고 싶으면 목록에서 고른다 — 그게 더 짧은 길이다. */
var ICON = {
/* ★ 카세트가 먼저 선다 (2026-09-10 사장님: "전혀 뮤직플레이어 같지가 않아").
세모·셔플·줄 세 개만 두면 헤더의 다른 아이콘과 구별이 안 된다 — 무엇을 하는 물건인지가
한눈에 안 읽힌다. 카세트는 그 자체가 '노래' 라는 표시이고, 재생 중에는 릴이 돈다. */
cassette: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6">' +
'<rect x="2" y="5" width="20" height="14" rx="2"/>' +
'<g class="w4d-reel"><circle cx="8.5" cy="12" r="2.6"/><path d="M8.5 9.4v5.2M5.9 12h5.2"/></g>' +
'<g class="w4d-reel"><circle cx="15.5" cy="12" r="2.6"/><path d="M15.5 9.4v5.2M12.9 12h5.2"/></g>' +
'<path d="M6 19l1.5-2.4h9L18 19"/></svg>',
note: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M20 4v10.5a3.5 3.5 0 1 1-2-3.16V7.8l-8 1.6v7.1a3.5 3.5 0 1 1-2-3.16V6.2z"/></svg>',
play: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>',
pause: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M7 5h3.4v14H7zm6.6 0H17v14h-3.4z"/></svg>',
/* ★ 햄버거(줄 세 개)를 쓰지 않는다 (2026-09-11 사장님: "햄버거 메뉴 버튼이랑 음악 메뉴
아이콘이랑 같아서 헷갈림"). 바로 옆에 사이트 메뉴 단추가 같은 모양으로 서 있었다.
줄 끝에 음표를 달아 **재생 목록**으로 읽히게 한다. */
list: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ' +
'stroke-linecap="round"><path d="M4 7h11M4 12h11M4 17h7"/>' +
'<path d="M19 17.5V9l3 1.2"/><circle cx="17.4" cy="17.6" r="1.7" fill="currentColor" stroke="none"/></svg>',
close: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 6l12 12M18 6L6 18"/></svg>',
};
/* 플레이어가 트는 것은 **숙소가 만든 곡**이다(payload.narrative.ownSongs).
가요 다방 섹션(theme.sections.songs)은 이 도시를 노래한 옛 곡이라 우리 음원이 없다 —
목록에 세우면 누를 때마다 유튜브로 튕겨 나간다. 두 목록은 섞지 않는다. */
function songsOfPayload() {
var box = P.narrative && P.narrative.ownSongs;
return (box && box.items) || [];
}
function startPlayer() {
var songs = songsOfPayload().filter(function (song) { return song.audioUrl; });
if (songs.length === 0) return;
/* ★ **시작 곡만 무작위, 그다음은 순차**다 (2026-09-11 사장님 지시).
매번 무작위로 뽑으면 다섯 곡을 한 바퀴 도는 데 순서가 없어 "아까 그 곡" 을 다시 찾기 어렵다.
손님마다 다른 곡으로 시작하되(들어올 때마다 같은 곡이면 배경음악이 하나인 것처럼 들린다),
한 번 시작하면 A면 순서대로 끝까지 돌고 처음으로 돌아온다. */
var cur = Math.floor(Math.random() * songs.length);
var playing = false;
var userPaused = false;
function nextIndex() {
return (cur + 1) % songs.length;
}
/* ── 헤더에 꽂을 단추 세 개 ─────────────────────────────────────── */
var mini = document.createElement('span');
mini.id = 'w4d-mini';
mini.innerHTML =
'<span id="w4d-tape" aria-hidden="true" title="머뭄이 만든 노래">' + ICON.cassette + '</span>' +
'<span id="w4d-now" aria-live="polite"></span>' +
'<button type="button" id="w4d-play" title="머뭄의 노래 재생">' + ICON.play + '</button>' +
'<button type="button" id="w4d-toggle" title="노래 목록" aria-expanded="false">' + ICON.list + '</button>';
/* ── 목록 패널 (body 직속) ──────────────────────────────────────── */
var panel = document.createElement('div');
panel.id = 'w4d-panel';
panel.hidden = true;
panel.innerHTML =
'<div class="w4d-head">' +
'<span class="w4d-head-t">머뭄이 만든 노래</span>' +
'<span class="w4d-head-n">' + songs.length + '곡</span>' +
'<button type="button" id="w4d-close" title="닫기" aria-label="닫기">' + ICON.close + '</button></div>' +
'<ol></ol>' +
'<div id="w4d-lyrics-box" hidden></div>';
document.body.appendChild(panel);
var listOl = panel.querySelector('ol');
var $ = function (id) { return document.getElementById(id); };
var audio = new Audio();
audio.preload = 'none'; // 손님이 누를 때까지 6MB 를 받지 않는다
audio.addEventListener('timeupdate', paintRow);
audio.addEventListener('durationchange', paintRow);
audio.addEventListener('ended', function () { load(nextIndex(), true); });
audio.addEventListener('error', function () { playing = false; render(); });
function clock(sec) {
if (typeof sec !== 'number' || !isFinite(sec)) return '';
var m = Math.floor(sec / 60);
var s = Math.floor(sec % 60);
return m + ':' + (s < 10 ? '0' : '') + s;
}
function meta(song) {
return [song.artist, song.year].filter(Boolean).join(' · ');
}
/* 목록 한 줄 = 판 하나 + 제목/가수 + 시간.
★ 줄 높이를 48px 아래로 내리지 않는다 — 손가락으로 누르는 자리다(모바일). */
function buildList() {
listOl.innerHTML = '';
songs.forEach(function (song, index) {
var li = document.createElement('li');
var btn = document.createElement('button');
btn.type = 'button';
btn.className = 'w4d-item';
btn.innerHTML =
'<span class="w4d-mark">' +
'<span class="w4d-disc-sm"></span>' +
'<span class="w4d-eq" aria-hidden="true"><i></i><i></i><i></i></span>' +
'</span>' +
'<span class="w4d-info"><span class="w4d-t"></span><span class="w4d-sub"></span></span>' +
'<span class="w4d-time"></span>';
btn.querySelector('.w4d-disc-sm').style.setProperty('--w4d-lbl', song.labelColor || '#bf2f1b');
btn.querySelector('.w4d-t').textContent = song.title;
btn.querySelector('.w4d-sub').textContent = song.artist || '';
btn.addEventListener('click', function () { load(index, true); });
li.appendChild(btn);
listOl.appendChild(li);
});
}
/* 시간은 지금 재생 중인 줄에만 적는다 — 헤더에는 곡명도 시간도 두지 않기로 했다.
★ 나머지 줄은 비워 둔다. 예전에는 가수 이름을 넣었는데 다섯 곡이 전부 '스테이,머뭄' 이라
같은 글자가 다섯 줄 반복됐다(가수는 줄 안에 이미 있다). */
function paintRow() {
var rows = listOl.querySelectorAll('.w4d-item');
for (var i = 0; i < rows.length; i += 1) {
var slot = rows[i].querySelector('.w4d-time');
if (i !== cur || !audio.src) { slot.textContent = ''; continue; }
slot.textContent = playing || audio.currentTime > 0
? clock(audio.currentTime) + (audio.duration ? ' / ' + clock(audio.duration) : '')
: '';
}
}
function render() {
var song = songs[cur] || {};
/* 헤더에 **지금 나오는 곡명**을 적는다 (2026-09-11 사장님: "현재 곡명이 나오는 게 맞지 않나").
소리가 나는데 무슨 곡인지 화면 어디에도 없으면 손님이 목록을 열어 봐야 안다. */
$('w4d-now').textContent = song.title || '';
$('w4d-play').innerHTML = playing ? ICON.pause : ICON.play;
$('w4d-play').dataset.playing = playing ? '1' : '0';
mini.dataset.playing = playing ? '1' : '0';
$('w4d-play').title = (playing ? '멈춤 · ' : '재생 · ') + (song.title || '');
var rows = listOl.querySelectorAll('.w4d-item');
for (var i = 0; i < rows.length; i += 1) {
rows[i].setAttribute('aria-current', i === cur ? 'true' : 'false');
rows[i].dataset.playing = i === cur && playing ? '1' : '0';
}
var box = $('w4d-lyrics-box');
if (song.lyrics) { box.textContent = song.lyrics; box.hidden = false; } else { box.hidden = true; }
paintRow();
}
function stop() {
playing = false;
audio.pause(); // 자리는 남긴다 — 다시 누르면 이어 듣는다
}
function load(index, alsoPlay) {
cur = (index + songs.length) % songs.length;
stop();
audio.removeAttribute('src');
render();
if (alsoPlay) play();
}
var FADE_MS = 1200;
var fadeTimer = 0;
function fadeIn() {
window.clearInterval(fadeTimer);
try { audio.volume = 0; } catch (err) { return; }
if (audio.volume > 0.01) return;
var t0 = Date.now();
fadeTimer = window.setInterval(function () {
var k = Math.min(1, (Date.now() - t0) / FADE_MS);
try { audio.volume = k * k; } catch (err) { k = 1; }
if (k >= 1) window.clearInterval(fadeTimer);
}, 40);
}
function play() {
var song = songs[cur] || {};
if (!song.audioUrl) return;
if (audio.getAttribute('src') !== song.audioUrl) audio.src = song.audioUrl;
fadeIn();
var started = audio.play();
// 자동재생 차단·파일 오류는 예외가 아니라 거절된 약속으로 온다.
if (started && started.catch) started.catch(function () { playing = false; render(); });
playing = true;
render();
return started; // 자동재생이 막혔는지 부르는 쪽이 알아야 한다
}
/* 패널은 fixed 라 헤더 밑에 손으로 붙인다 — 헤더가 sticky 라 스크롤해도 자리가 같다. */
/* 패널 자리. 좁은 화면에서는 좌우를 화면에 맞춰 펼친다 — 320px 고정이면 390px 화면에서
한쪽이 잘리거나 글자가 두 줄로 접힌다(2026-09-11 사장님: "모바일 대응이 안 되어 있음"). */
function placePanel() {
var header = document.querySelector('#root header');
var box = header ? header.getBoundingClientRect() : null;
panel.style.top = (box ? box.bottom + 6 : 66) + 'px';
if (window.innerWidth <= 640) {
panel.style.left = '8px';
panel.style.right = '8px';
} else {
panel.style.left = 'auto';
var anchor = mini.getBoundingClientRect();
panel.style.right = Math.max(12, window.innerWidth - anchor.right) + 'px';
}
}
function togglePanel(force) {
var open = typeof force === 'boolean' ? force : panel.hidden;
panel.hidden = !open;
$('w4d-toggle').setAttribute('aria-expanded', String(open));
if (open) placePanel();
}
mini.addEventListener('click', function (event) {
var btn = event.target.closest('button');
if (!btn) return;
disarmAutoplay(); // 손님이 직접 잡았다 — 첫 손길 대기를 푼다
if (btn.id === 'w4d-play') {
if (playing) { userPaused = true; stop(); render(); }
else { userPaused = false; play(); }
}
if (btn.id === 'w4d-toggle') { togglePanel(); }
});
panel.querySelector('#w4d-close').addEventListener('click', function () { togglePanel(false); });
window.addEventListener('resize', function () { if (!panel.hidden) placePanel(); });
window.addEventListener('scroll', function () { if (!panel.hidden) placePanel(); }, {passive: true});
document.addEventListener('click', function (event) {
if (panel.hidden) return;
if (panel.contains(event.target) || mini.contains(event.target)) return;
togglePanel(false);
});
/* 가요 다방 섹션의 판을 누르면 그 곡이 얹힌다. 섹션에는 옛 가요도 서 있어 순번이 어긋나므로
제목으로 찾는다. 우리 목록에 없는 곡(옛 가요)이면 섹션이 알아서 한다. */
document.addEventListener('click', function (event) {
var btn = event.target && event.target.closest ? event.target.closest('#songs button') : null;
if (!btn) return;
var label = (btn.textContent || '').trim();
var index = -1;
songs.forEach(function (song, i) { if (index < 0 && song.title === label) index = i; });
if (index >= 0) load(index, false);
});
/* ★ 헤더에 꽂고, 지워지면 다시 꽂는다.
React 가 하이드레이션에 실패하면 #root 를 통째로 다시 그린다 — 그때 단추가 사라진다.
한 번 꽂고 끝내면 그 순간부터 조용히 없어진다(오류도 안 난다). */
function mount() {
if (mini.isConnected) return true;
var header = document.querySelector('#root header');
if (!header) return false;
// 헤더 오른쪽 묶음(전화·메뉴 단추가 있는 자리) 맨 앞에 꽂는다.
var slot = header.querySelector('.shell > div:last-child') ||
header.querySelector('div:last-child');
if (!slot) return false;
slot.insertBefore(mini, slot.firstChild);
return true;
}
/* ★ 자동재생 — 들어오면 바로 튼다 (2026-09-14 대표 지시. 2026-09-11 에는 "자동 빼" 였다).
브라우저는 소리 나는 재생을 사용자 조작 없이 막는다(정책이고, 거절된 약속으로 온다).
그래서 두 단계다 — ① 일단 걸어 보고 ② 막히면 **손님이 처음 만지는 순간** 켠다.
★ 첫 손길 리스너는 **플레이어 안에서 난 이벤트를 흘려보낸다.** 예전 판은 window 캡처라
재생 단추의 클릭을 먼저 먹었고, 단추 핸들러가 다시 꺼서 두 번 눌러야 켜졌다(2026-09-11).
지금은 버블 단계에 걸고 `#w4d-mini`·`#w4d-panel` 안이면 그냥 돌아선다 — 그 클릭은
단추 핸들러의 몫이고, 그 핸들러가 대기를 푼다.
★ 시작 곡이 무작위인 것은 그대로다. */
/* ★ 두 벌로 나눈다. 브라우저가 소리를 풀어 주는 것은 **활성화를 주는 입력**뿐이다
(mousedown·pointerdown/up·touchend·keydown — HTML 표준 "activation triggering input").
mousemove·wheel·scroll 은 아무리 받아도 활성화가 안 붙는다.
실측 2026-09-14, 킹서버 · evaluate 를 안 쓴 측정:
가만히 / mousemove / wheel → `navigator.userActivation.isActive` 계속 false
클릭 → true
★ 그런데도 약한 입력을 버리지 않는다 — 크롬이 이 출처에 이미 자동재생을 허락한
상태(Media Engagement)면 그때는 아무 입력에서나 켜지고, 그게 제일 빠르다.
★ 다만 **약한 입력에만 스로틀을 건다.** 예전 판은 한 벌로 묶어 1초 스로틀을 걸었는데,
클릭 직전의 mousemove 가 그 1초를 먹어서 **정작 활성화를 주는 mousedown 이 걸러졌다**
— 눌러도 안 켜졌다(실측 2026-09-14: 클릭 뒤 activation=true 인데 play=0). */
var STRONG = ['pointerdown', 'mousedown', 'pointerup', 'mouseup', 'click',
'keydown', 'touchstart', 'touchend'];
var WEAK = ['wheel', 'scroll', 'mousemove', 'pointermove', 'touchmove'];
var GESTURES = STRONG.concat(WEAK);
var lastWeak = 0;
function onFirstTouch(event) {
/* ★ **코드가 쏜 이벤트는 손길이 아니다.** 대기는 한 번뿐이라, 가짜 이벤트가 먼저
먹으면 자동재생은 영영 안 켜진다 — 브라우저는 신뢰된 조작에만 소리를 허락하므로
그때 부른 play() 는 반드시 거절된다.
실측(2026-09-14): 목업 body 에 `class="site"` 를 붙여 레일 CSS 가 되살아나자
`tameRails()` 의 `press()` 가 레일 13개에 pointerdown 을 쏘았고(자동 넘김을 세우는
정상 동작이다), 로드 1.05초에 그게 전부 이 자리로 들어와 대기를 태웠다.
그 뒤로는 손님이 아무 데나 눌러도 음악이 안 켜졌다. */
if (!event || !event.isTrusted) return;
var node = event.target;
if (node && node.closest && node.closest('#w4d-mini, #w4d-panel')) return;
/* ★ **켜진 뒤에** 대기를 푼다. 예전에는 먼저 풀고 play() 를 불렀는데, wheel·scroll 은
브라우저가 "조작" 으로 쳐 주지 않아 그 자리의 play() 는 거절된다 — 손님이 그냥
스크롤만 한 번 해도 대기가 타 버리고, 그 뒤로는 어디를 눌러도 안 켜졌다.
지금은 거절되면 대기를 그대로 두고 다음 손길을 기다린다. */
if (WEAK.indexOf(event.type) >= 0) {
// 약한 입력은 초당 수십 번 온다. 거절될 것을 그 횟수만큼 부르면 화면이 깜빡인다.
var now = Date.now();
if (now - lastWeak < 1000) return;
lastWeak = now;
}
var started = play();
if (started && started.then) started.then(disarmAutoplay, function () { /* 다음 손길에 다시 */ });
else disarmAutoplay();
}
function setArmed(on) {
mini.setAttribute('data-armed', on ? '1' : '0');
var button = document.getElementById('w4d-play');
if (button) button.title = on ? '화면을 한 번 누르면 음악이 켜집니다' : '머뭄의 노래 재생';
}
function disarmAutoplay() {
setArmed(false);
GESTURES.forEach(function (name) { window.removeEventListener(name, onFirstTouch); });
}
function armAutoplay() {
setArmed(true);
GESTURES.forEach(function (name) {
window.addEventListener(name, onFirstTouch, {passive: true});
});
}
function autoplay() {
var started = play();
if (started && started.then) {
started.then(disarmAutoplay, armAutoplay);
} else {
armAutoplay(); // 약속을 안 주는 브라우저 — 켜졌는지 알 수 없으니 대기도 같이 건다
}
/* 탭을 다시 열 때 한 번 더 걸어 본다. 다른 탭에서 열어 둔 페이지는 브라우저가
소리를 아예 막아 두는데, 앞으로 나오는 그 순간은 조작으로 쳐 주는 경우가 있다.
이미 켜져 있으면 아무 일도 안 한다. */
document.addEventListener('visibilitychange', function () {
if (document.visibilityState !== 'visible' || playing || userPaused) return;
var again = play();
if (again && again.then) again.then(disarmAutoplay, function () { /* 계속 대기 */ });
});
}
buildList();
mount();
render();
autoplay();
if (window.MutationObserver) {
new MutationObserver(function () {
if (!mini.isConnected) { mount(); render(); }
}).observe(document.getElementById('root') || document.body, {childList: true, subtree: true});
}
console.log('[w4d] 미니 플레이어 — 머뭄이 만든 곡 ' + songs.length + '곡');
}
/* ══ ③ 군산 읽기 ═══════════════════════════════════════════════════════════
* 받은 자료(군산 365 이야기 DB)의 주제를 갈래로 묶어 보여준다.
*
* ★ 왜 주입인가 — 렌더러에 이 섹션이 없다. 렌더러를 고치면 번들 해시가 바뀌어 전 사이트
* 재굽기 + 전체 재업로드가 따라온다(README 2.1). 목업에만 필요한 것은 여기서 덮는다.
* ★ **독립 섹션이 아니라 '군산 이야기'(`#story`) 탭 하나다** (2026-09-14 대표: "굳이
* 나눌 필요가 없는것 같고" — 맞는 말이다. 가요·인물·연표·엽서·일력이 이미 그 탭 묶음에
* 있는데 이것만 FAQ 앞에 따로 서 있을 이유가 없다). `#story` 의 tablist 에 버튼을 하나
* 더 꽂고, 그 옆에 패널을 하나 더 붙인다 — 나머지 다섯 개와 같은 자리, 같은 모양이다.
* ★ **탭은 React 상태로 도는데 이 탭만 아니다.** React 의 `active` 는 그대로 두고
* (React 가 모르는 탭이라 못 건드린다), 내 탭을 누르면 **다섯 개를 전부 손으로 숨기고**
* 내 것만 보인다. 반대로 진짜 탭(다섯 개 중 하나)을 누르면 **내 패널만 손으로 숨긴다**
* — React 는 자기 다섯 개를 알아서 정리하니 그건 안 건드린다.
* ★ 소개 문장("이 도시를 다섯 갈래로 봅니다")의 숫자도 여섯으로 고쳐 쓴다 — 탭 개수를
* 문장에 안 박는 게 이 컴포넌트의 원칙인데(`COUNT_WORD`), 그 문장이 세는 `tabs.length`
* 가 React 상태라 내 탭을 모른다. 안 고치면 "다섯 갈래"라 해 놓고 탭이 여섯 개인
* 꼴이 난다 — `daily` 가 한동안 빈칸이던 것과 같은 종류의 조용한 어긋남이다.
* ★ 처음엔 31꼭지를 한 화면에 전부 폈다. 그런데 **너무 길어 스크롤 압박**이 됐다
* (2026-09-14 대표: "내용들이 여러개 안해도되고 3-4개 정도 그룹묶어서 랜덤하게 보여주게").
* → 갈래마다 3~4개씩 뽑아 갈래 순서대로 잇는 방식을 거쳐, 지금은 **갈래를 가리지
* 않고 전체 34가지 중 5~6개만 무작위로** 뽑는다(2026-09-14 대표: "34개중 15~19개인데
* 왜 34개라고 써놔?" → "그냥 5-6개 정도만 보여주는거 어때?" → "랜덤으로"). 나머지는
* 다음에 들어왔을 때 나온다 — 접은 게 아니라 돌려 보여주는 것이라 `<details>` 를 쓰지
* 않는다(문서에 다 남겨야 하는 FAQ·일력과는 다른 경우다). payload 의 `subtitle` 도
* 전체 개수를 내걸지 않게 고쳤다(`build_reading.py`) — 화면과 문구가 어긋나면 안 된다.
* → **방문할 때마다 다시 섞는다**(빌드타임에 한 번 고정하지 않는다). 매 페이지 로드가
* 새 셔플이라 손님마다, 새로고침마다 다른 조합을 본다.
* ★ React 가 #root 를 다시 그리면 사라진다 — 감시해서 다시 꽂는다(플레이어와 같은 수법).
* 단, 무작위 조합은 **첫 마운트 때 한 번만** 뽑는다 — 재마운트마다 다시 뽑으면 하이드레이션
* 재렌더가 잦은 페이지에서 손님이 읽던 카드가 눈앞에서 바뀐다.
*/
function shuffled(list) {
var copy = list.slice();
for (var i = copy.length - 1; i > 0; i -= 1) {
var j = Math.floor(Math.random() * (i + 1));
var tmp = copy[i]; copy[i] = copy[j]; copy[j] = tmp;
}
return copy;
}
function readingData() {
var sections = (P.theme && P.theme.sections) || [];
for (var i = 0; i < sections.length; i += 1) {
if (sections[i].id !== 'reading') continue;
try {
return JSON.parse(sections[i].data);
} catch (err) {
return null;
}
}
return null;
}
function escapeHtml(text) {
return String(text == null ? '' : text)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
/* ★ 카로셀 껍데기는 새로 만들지 않는다 — **이 사이트가 이미 쓰는 것**을 그대로 가져다 쓴다
(2026-09-14 대표: "기존 카로셀 쓰라고, 니가 뭔데 니맘대로 그지같이 구현해"— 맞는 말이다).
`site/src/lib/ui/Carousel.tsx` 가 만드는 마크업·클래스를 손으로 그대로 찍어낸다.
★ `.slider-viewport`(overflow-x:auto + scroll-snap) · `.slider-track`(flex) 는
React 컴포넌트 전용이 아니라 **번들에 이미 컴파일된 전역 CSS**다 — 이 페이지의 다른
레일(인물 열전 등)이 지금 쓰고 있는 그 클래스라 새 CSS 를 안 써도 그대로 먹는다.
embla 는 못 붙인다(inject.js 는 React 번들 밖의 순정 스크립트라 embla 인스턴스를
가져다 쓸 수 없다) — 그런데 Carousel.tsx 자신도 "**스크립트 없이도 내용이 보여야
한다**" 며 embla 가 붙기 전 기본 상태를 이 스크롤+snap 상자로 정의해 뒀다. 즉 지금
이 카드가 보이는 모양은 실제 레일의 **무-스크립트 상태와 정확히 같다.**
★ 점(dots)·화살표도 Carousel.tsx 의 클래스를 그대로 옮긴다(`h-1.5 rounded-full
bg-current opacity-70/20`, `tap border-line rounded-full border` 등) — 전부 Tailwind
유틸리티라 전역에 이미 있다. inject.css 에 카로셀용 CSS를 새로 안 만드는 이유다. */
/* '군산 이야기' 탭 버튼의 on/off 인라인 스타일 — StorySection.tsx 의 값 그대로
(`common.tsx` ITEM_INK · ITEM_INVERSE_INK · ITEM_BORDER). React 가 다른 다섯 탭에
주는 것과 같은 색이라야 여섯 번째가 "끼어든 것"처럼 안 보인다. */
var STORY_TAB_ON = 'background-color:var(--tpl-text, #09090b);' +
'color:var(--tpl-bg, #ffffff);border-color:var(--tpl-text, #09090b)';
var STORY_TAB_OFF = 'border-color:var(--tpl-border, #d6d3d1)';
// lucide 의 chevron-left/right 와 같은 path 다 — NavButton(Carousel.tsx)이 쓰는 그 아이콘.
// 군산 읽기·가요 다방 화살표가 같이 쓰므로 함수 밖(모듈 스코프)에 한 벌만 둔다.
var CHEVRON_L = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ' +
'stroke-linecap="round" stroke-linejoin="round" class="size-5"><path d="m15 18-6-6 6-6"/></svg>';
var CHEVRON_R = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ' +
'stroke-linecap="round" stroke-linejoin="round" class="size-5"><path d="m9 18 6-6-6-6"/></svg>';
var NAV_BTN = 'tap border-line flex items-center justify-center rounded-full border ' +
'transition-opacity hover:opacity-70 disabled:opacity-25';
function startReading() {
var env = readingData();
if (!env || !env.items || !env.items.length) return;
/* ★ **포스트 하나씩만** (2026-09-14 대표: "포스트 하나씩만 보여주라고, 한 줄인거
나랑 장난하자는거?"). 3~4개를 한 슬라이드에 욱여넣으면 카드 하나가 좁아져 본문이
한 줄로 접혔다. 지금은 슬라이드 하나 = 꼭지 하나, 전체 폭을 다 쓴다.
★ '~면' 이라고는 안 쓴다(2026-09-14 대표: "문학면? 말이 안 되는거 같네") — 신문
마스트헤드를 뺀 지금은 '면'(신문 지면)이라는 틀이 없다. 그냥 갈래 이름(문학·역사·…)만
카드 위에 이름표로 붙인다.
★ **갈래를 가리지 않고 전체에서 5~6개만** (2026-09-14 대표: "34개중 15~19개인데 왜
34개라고 써놔?" → "그냥 5-6개 정도만 보여주는거 어때?" → "랜덤으로"). 갈래마다
3~4개씩 뽑던 전에는 한 번에 15~19개가 떴는데, payload 의 subtitle 은 "34가지"라고
적혀 있어 화면과 문구가 어긋났다. 지금은 34가지를 갈래 구분 없이 통째로 섞어 5개
또는 6개만 뽑는다. */
var count = Math.random() < 0.5 ? 5 : 6;
var picked = shuffled(env.items).slice(0, count).map(function (item) {
return {group: item.group, item: item};
});
if (!picked.length) return;
var trackId = 'w4d-read-track';
/* ★ 제목을 단다 (2026-09-14 대표: "군산읽기만 타이틀이 없서 통일 시켜"). 탭 버튼
라벨과 겹쳐 보여서 처음엔 뺐는데, 정작 실제 다섯 탭(가요 다방·인물 열전 등)은
전부 안에도 <h2> 를 그대로 세워 둔다(`StorySection.tsx`: "탭 이름과 겹쳐 보이지만
…읽는 기계에게는 그 제목이 다섯 덩이를 가르는 유일한 표지다") — 나만 빼서 되레
튀었다. 같은 마크업(`header.mb-8.sm:mb-10 > h2.h2 + p.measure`)으로 맞춘다. */
var html = '<header class="mb-8 sm:mb-10">' +
'<h2 id="w4d-reading-heading" class="h2">' + escapeHtml(env.title || '군산 읽기') + '</h2>' +
'<p class="measure mt-3 text-[length:var(--fs-sm)] opacity-70">' + escapeHtml(env.subtitle) + '</p>' +
'</header>';
/* ↓ 여기부터 Carousel.tsx 의 실제 출력(화살표 header · slider-viewport/track · 점 · 카운터)을
그대로 옮긴 것이다 — 클래스 이름 하나까지 같다(파일 머리 주석 참고). */
html += '<div class="mb-3 flex justify-end gap-2">' +
'<button type="button" class="w4d-read-prev ' + NAV_BTN + '" aria-label="군산 읽기 이전" disabled>' + CHEVRON_L + '</button>' +
'<button type="button" class="w4d-read-next ' + NAV_BTN + '" aria-label="군산 읽기 다음">' + CHEVRON_R + '</button>' +
'</div>';
html += '<div class="relative">' +
'<div class="slider-viewport" role="group" aria-roledescription="캐러셀" ' +
'aria-label="군산 읽기" tabindex="0">' +
'<div class="slider-track" style="gap:1rem" id="' + trackId + '">';
picked.forEach(function (row, index) {
var item = row.item;
var source = item.source || {};
// 출처는 항목마다 단다 — 개별 문서 대신 네이버 검색 링크로 통일했다(2026-09-14
// 대표: "확인필요 없애고 링크는 네이버 링크로", `build_reading.py` `naver()`).
var credit = source.url
? '<a href="' + escapeHtml(source.url) + '" target="_blank" rel="noopener noreferrer">' +
escapeHtml(source.name) + '</a>'
: escapeHtml(source.name || '');
html += '<div class="min-w-0 shrink-0 grow-0 w4d-read-basis" aria-roledescription="슬라이드" ' +
'id="w4d-read-slide-' + index + '">' +
'<article class="w4d-read-card">' +
'<p class="w4d-read-page">' + escapeHtml(row.group) +
(item.year ? ' · <span class="w4d-read-year">' + escapeHtml(item.year) + '</span>' : '') + '</p>' +
'<h3>' + escapeHtml(item.title) + '</h3>' +
'<p class="w4d-read-body">' + escapeHtml(item.body) + '</p>' +
'<p class="w4d-read-src">출처 · ' + credit + '</p></article></div>';
});
html += '</div></div></div>';
html += '<div class="mt-4 flex items-center justify-center gap-3">';
if (picked.length <= 12) {
html += '<ul class="flex items-center gap-1.5">';
picked.forEach(function (_row, index) {
html += '<li><button type="button" class="w4d-read-dot h-1.5 rounded-full transition-all ' +
(index === 0 ? 'w-6 bg-current opacity-70' : 'w-1.5 bg-current opacity-20 hover:opacity-45') + '" ' +
'aria-label="' + (index + 1) + '번째로" aria-current="' + (index === 0 ? 'true' : 'false') + '" ' +
'aria-controls="' + trackId + '" data-index="' + index + '"></button></li>';
});
html += '</ul>';
}
html += '<span class="w4d-read-counter text-current/90 text-[length:var(--fs-xs)] tabular-nums">' +
'<span data-cur>1</span> / ' + picked.length + '</span></div>';
var panel = document.createElement('div');
panel.id = 'w4d-reading-panel';
panel.setAttribute('aria-labelledby', 'w4d-reading-heading');
panel.hidden = true; // 처음엔 다른 탭(가요 다방 등)이 보인다 — React 의 기본 active 를 안 뺏는다
// `.shell` 은 이 사이트가 폭·좌우 여백을 맞출 때 쓰는 그 전역 클래스다 — 바로 위
// `#story .shell` 과 같은 너비라 탭이 바뀌어도 좌우 선이 흔들리지 않는다.
panel.innerHTML = '<div class="shell">' + html + '</div>';
var tabBtn = document.createElement('button');
tabBtn.type = 'button';
tabBtn.setAttribute('role', 'tab');
tabBtn.setAttribute('aria-selected', 'false');
tabBtn.setAttribute('aria-controls', 'w4d-reading-panel');
tabBtn.className = 'rounded-full border px-4 py-1.5 text-[length:var(--fs-sm)] font-bold ' +
'transition-opacity hover:opacity-80';
tabBtn.style.cssText = STORY_TAB_OFF;
tabBtn.textContent = '군산 읽기';
/* 자리 — '군산 이야기' 탭 묶음 **안**이다. `#story` 다음에 진짜 탭 패널 다섯 개가
형제로 이어지고(각자 안에 #songs·#daily·#people·#chronicle·#postcard 를 하나씩
담는다), 그 마지막 뒤에 내 패널을 하나 더 잇는다 — FAQ 앞이라는 점은 그대로다. */
var KNOWN_TABS = ['songs', 'daily', 'people', 'chronicle', 'postcard'];
function mount() {
var tablist = document.querySelector('#story [role="tablist"]');
var story = document.getElementById('story');
if (!tablist || !story || !story.parentNode) return false;
if (!tabBtn.isConnected) tablist.appendChild(tabBtn);
if (!panel.isConnected) {
var anchor = story;
var sib = story.nextElementSibling;
while (sib && KNOWN_TABS.some(function (id) { return sib.querySelector('#' + id); })) {
anchor = sib;
sib = sib.nextElementSibling;
}
anchor.parentNode.insertBefore(panel, anchor.nextElementSibling);
}
// "이 도시를 다섯 갈래로 봅니다" — 진짜 탭 개수(React state)는 내 탭을 모르니
// +1 을 손으로 반영한다. 매 재마운트마다 다시 검사해도 이미 고쳤으면 그대로 둔다.
var countWord = {1: '한', 2: '두', 3: '세', 4: '네', 5: '다섯', 6: '여섯',
7: '일곱', 8: '여덟', 9: '아홉', 10: '열'};
var real = tablist.querySelectorAll('[role="tab"]').length - 1; // 방금 내가 하나 더했다
var subtitle = story.querySelector('p.measure');
if (subtitle && real > 0) {
var word = countWord[real + 1] || String(real + 1);
subtitle.textContent = subtitle.textContent.replace(
/^이 도시를 .+ 갈래로 봅니다/, '이 도시를 ' + word + ' 갈래로 봅니다');
}
return true;
}
if (!mount()) return;
/* ── 탭 전환 ────────────────────────────────────────────────────────────
* ★ 버그였던 자리 (2026-09-14 대표 제보: "시간의 골목 누르면 사라지기만 함, 탭활성화도").
* `hidden` 은 React 가 `active` 상태로 매번 다시 계산해 주는 prop(`hidden={index!==active}`)
* 이라, 내가 여기서 그 값을 직접 true 로 덮으면 React 의 실제 상태와 DOM 이 어긋난다.
* 그 상태로 손님이 **같은 탭을 다시 누르면** `setActive(sameIndex)` 는 React 입장에서
* 상태가 안 바뀐 것이라 리렌더를 생략한다(bail-out) — `hidden` 은 내가 true 로 덮은 채
* 영영 안 돌아온다. 버튼 스타일(`aria-selected`·style)도 React 가 매 렌더 계산해 주는
* 값이라 같은 이유로 OFF 에 갇힌다. → **React 가 관리하는 속성은 아예 안 건드린다.**
* 대신 내가 전적으로 소유한 `style.display` 로만 가리고(그 프롭은 JSX 에 없어 React 가
* 절대 안 건드린다), 되돌아올 때는 그 오버라이드만 지운다 — `hidden` 은 그동안 한 번도
* 틀린 적이 없으니 지우자마자 바로 맞는 것이 보인다. 버튼도 "방금 누른 그 탭"을 직접
* 켜 준다 — React 리렌더가 생략돼도 항상 맞게.
*/
function selectMine() {
var tablist = document.querySelector('#story [role="tablist"]');
if (tablist) {
Array.prototype.forEach.call(tablist.querySelectorAll('[role="tab"]'), function (btn) {
if (btn === tabBtn) return;
btn.setAttribute('aria-selected', 'false');
btn.style.cssText = STORY_TAB_OFF;
});
}
var story = document.getElementById('story');
var sib = story && story.nextElementSibling;
while (sib && sib !== panel) {
if (KNOWN_TABS.some(function (id) { return sib.querySelector('#' + id); })) sib.style.display = 'none';
sib = sib.nextElementSibling;
}
tabBtn.setAttribute('aria-selected', 'true');
tabBtn.style.cssText = STORY_TAB_ON;
panel.hidden = false;
}
tabBtn.addEventListener('click', selectMine);
document.addEventListener('click', function (event) {
var real = event.target.closest && event.target.closest('#story [role="tab"]');
if (!real || real === tabBtn) return;
panel.hidden = true;
tabBtn.setAttribute('aria-selected', 'false');
tabBtn.style.cssText = STORY_TAB_OFF;
// 방금 누른 진짜 탭을 직접 켠다 — 같은 탭을 다시 눌러 React 리렌더가 생략돼도 맞다.
real.setAttribute('aria-selected', 'true');
real.style.cssText = STORY_TAB_ON;
var story = document.getElementById('story');
var sib = story && story.nextElementSibling;
while (sib && sib !== panel) {
if (KNOWN_TABS.some(function (id) { return sib.querySelector('#' + id); })) sib.style.display = '';
sib = sib.nextElementSibling;
}
});
if (window.MutationObserver) {
/* ★ 무작위 조합은 여기서 다시 뽑지 않는다 — panel 은 이미 innerHTML 이 채워진 노드라,
재마운트는 **같은 노드를 그대로 다시 꽂는 것**뿐이다(플레이어 패널과 같은 수법).
React 가 다시 그려도 손님이 보던 조합·지금 켜져 있던 탭은 그대로 남는다. */
new MutationObserver(function () {
if (!tabBtn.isConnected || !panel.isConnected) mount();
}).observe(document.getElementById('root') || document.body, {childList: true, subtree: true});
}
/* ── 카로셀 넘기기 ──────────────────────────────────────────────────────
* ★ **embla-carousel 코어**(파일 머리 주석)로 움직인다 — 이 사이트의 다른 레일과
* 같은 라이브러리, 같은 드래그 물리다. `align:'center'` 라 가운데 한 장이 크게
* 오고 양옆이 살짝 걸친다(2026-09-14 대표 지시).
* ★ **스크립트 없이도 봐야 한다**(Carousel.tsx 의 원칙과 같다) — embla 를 못 붙이면
* `.slider-viewport` 는 `overflow-x:auto` 기본 상태 그대로 남아 손가락·트랙패드로는
* 여전히 밀린다. 그때는 점·화살표를 `scrollIntoView` 로 대신 움직인다.
*/
var viewport = panel.querySelector('.slider-viewport');
var track = document.getElementById(trackId);
var slideEls = Array.prototype.slice.call(panel.querySelectorAll('[id^="w4d-read-slide-"]'));
var dots = Array.prototype.slice.call(panel.querySelectorAll('.w4d-read-dot'));
var counter = panel.querySelector('.w4d-read-counter [data-cur]');
var prevBtn = panel.querySelector('.w4d-read-prev');
var nextBtn = panel.querySelector('.w4d-read-next');
if (!viewport || !track || !slideEls.length) return;
function paint(active, canPrev, canNext) {
dots.forEach(function (dot, index) {
var on = index === active;
dot.setAttribute('aria-current', on ? 'true' : 'false');
dot.className = 'w4d-read-dot h-1.5 rounded-full transition-all ' +
(on ? 'w-6 bg-current opacity-70' : 'w-1.5 bg-current opacity-20 hover:opacity-45');
});
if (counter) counter.textContent = String(active + 1);
if (prevBtn) prevBtn.disabled = !canPrev;
if (nextBtn) nextBtn.disabled = !canNext;
}
var emblaApi = null;
if (window.EmblaCarousel) {
emblaApi = window.EmblaCarousel(viewport, {align: 'center', loop: false, containScroll: 'trimSnaps'});
viewport.setAttribute('data-slider', 'on'); // CSS 가 여기부터 스크롤 상자를 드래그 영역으로 바꾼다
var sync = function () {
paint(emblaApi.selectedScrollSnap(), emblaApi.canScrollPrev(), emblaApi.canScrollNext());
};
emblaApi.on('select', sync);
emblaApi.on('reInit', sync);
// 드래그 중 텍스트가 잡히면 카드가 안 끌린다 — 커서도 grabbing 으로 바꾼다(Carousel.tsx 와 동일).
emblaApi.on('pointerDown', function () { viewport.setAttribute('data-dragging', 'true'); });
emblaApi.on('pointerUp', function () { viewport.removeAttribute('data-dragging'); });
dots.forEach(function (dot) {
dot.addEventListener('click', function () { emblaApi.scrollTo(Number(dot.dataset.index)); });
});
if (prevBtn) prevBtn.addEventListener('click', function () { emblaApi.scrollPrev(); });
if (nextBtn) nextBtn.addEventListener('click', function () { emblaApi.scrollNext(); });
sync();
} else {
// embla 스크립트가 막혔거나 못 받아 왔을 때 — 가로 스크롤 자체는 CSS 만으로 이미 된다.
var goTo = function (index) {
slideEls[index].scrollIntoView({behavior: 'smooth', inline: 'center', block: 'nearest'});
};
dots.forEach(function (dot) {
dot.addEventListener('click', function () { goTo(Number(dot.dataset.index)); });
});
var findActive = function () {
var mid = viewport.scrollLeft + viewport.clientWidth / 2;
var active = 0;
for (var i = 0; i < slideEls.length; i += 1) {
if (slideEls[i].offsetLeft <= mid) active = i;
}
return active;
};
if (prevBtn) prevBtn.addEventListener('click', function () { goTo(Math.max(0, findActive() - 1)); });
if (nextBtn) nextBtn.addEventListener('click', function () { goTo(Math.min(slideEls.length - 1, findActive() + 1)); });
var syncTimer = null;
viewport.addEventListener('scroll', function () {
window.clearTimeout(syncTimer);
syncTimer = window.setTimeout(function () {
var active = findActive();
paint(active, active > 0, active < slideEls.length - 1);
}, 100);
}, {passive: true});
paint(0, false, slideEls.length > 1);
}
console.log('[w4d] 군산 읽기 ' + picked.length + '꼭지 카로셀(' +
(emblaApi ? 'embla' : 'CSS scroll-snap') + ') · 매번 무작위 재구성');
}
/* ══ ③-b 가요 다방 판꽂이 — 화살표 + 실제 카로셀 ═══════════════════════════════
* (2026-09-14 대표: "옆으로 갈수있는 버튼도 주고 slider.js 적용해줘")
*
* ★ 렌더러는 이 판 목록을 `Carousel.tsx` 로 감싸지 않는다 — 그냥
* `<div className="flex flex-wrap ...">{판들}</div>` 이다(`SongsSection.tsx`).
* 그래서 "군산 읽기" 처럼 이미 있는 `.slider-viewport` 를 마크업에서 그대로 못 쓰고,
* **이 상자 하나를 감싸는 바깥 상자를 새로 만들어야** embla 를 붙일 자리(viewport)가
* 생긴다 — embla 는 "바깥(viewport) 안에 트랙 하나, 그 트랙 안에 슬라이드들"이라는
* 2단 구조를 기대한다.
* ★ **판 버튼들은 옮기지 않는다.** 그 버튼들을 담은 상자(`track`, `:has()` 로 잡는 그
* div) 하나만 새 `viewport` 안으로 옮긴다 — 버튼 25개를 하나씩 옮기는 게 아니라
* 부모 상자 하나만 옮기는 것이라 위험이 작다.
* ★ **React 참조는 안 깨진다.** `setPlaying` 으로 판을 고르면 React 는 이 `track` 노드를
* (같은 자리, 같은 key 구조라) 다시 만들지 않고 속성만 고친다 — 그 노드를 누가
* 감쌌는지는 React 가 안 본다. 그래서 옮겨 놓은 뒤에도 클릭이 계속 잘 먹는다.
* 전체가 통째로 다시 그려지는 경우(하이드레이션 재렌더)만 대비해 감시해 다시 감싼다.
* ★ 스크롤/스냅 CSS 를 내가 새로 안 만든다 — `.slider-viewport` 클래스 자체가 이미
* 그 스타일과 `[data-slider='on']` 전환까지 갖고 있다(전역 번들, "군산 읽기" 와 같은
* 근거). embla 가 못 붙어도 이 클래스 하나로 손가락 스크롤은 이미 된다.
*/
function startSongsRail() {
var track = document.querySelector('#songs div:has(> button > .w4-disc-mini)');
if (!track) return false;
if (track.parentNode && track.parentNode.classList.contains('slider-viewport')) return true;
var viewport = document.createElement('div');
viewport.className = 'slider-viewport';
viewport.setAttribute('role', 'group');
viewport.setAttribute('aria-roledescription', '캐러셀');
viewport.setAttribute('aria-label', '가요 다방 판 고르기');
viewport.tabIndex = 0;
track.parentNode.insertBefore(viewport, track);
viewport.appendChild(track);
var nav = document.createElement('div');
// mt-4 — 위 검은 턴테이블 카드와 거리를 준다. 원래 이 자리(판 목록 div) 자체가
// mt-4 였는데, 그 div 를 한 단 더 감싸면서(viewport) 그 여백이 안쪽으로 들어가
// 버렸다 — 화살표 줄이 카드에 바로 붙어 보인 이유(2026-09-14 대표 지적).
nav.className = 'mt-4 mb-3 flex justify-end gap-2';
nav.innerHTML =
'<button type="button" class="w4d-songs-prev ' + NAV_BTN + '" aria-label="가요 다방 이전" disabled>' + CHEVRON_L + '</button>' +
'<button type="button" class="w4d-songs-next ' + NAV_BTN + '" aria-label="가요 다방 다음">' + CHEVRON_R + '</button>';
viewport.parentNode.insertBefore(nav, viewport);
// 카운터 — Carousel.tsx 도 점(dots)이 12개를 넘으면 점은 숨기고 이 숫자 줄만 남긴다.
// 여기는 25~50곡이라 항상 그 경우다.
var counter = document.createElement('p');
counter.className = 'mt-2 text-center text-current/90 text-[length:var(--fs-xs)] tabular-nums';
counter.innerHTML = '<span data-cur>1</span> / ' + track.children.length;
viewport.parentNode.insertBefore(counter, viewport.nextSibling);
var prevBtn = nav.querySelector('.w4d-songs-prev');
var nextBtn = nav.querySelector('.w4d-songs-next');
var curEl = counter.querySelector('[data-cur]');
if (window.EmblaCarousel) {
// align:'start' — Carousel.tsx 의 규칙과 같다("사진은 가운데, 카드 목록은 왼쪽").
// 판 목록은 사진이 아니라 고르는 목록이라 왼쪽 정렬이다.
var api = window.EmblaCarousel(viewport, {align: 'start', loop: false, containScroll: 'trimSnaps'});
viewport.setAttribute('data-slider', 'on');
var sync = function () {
curEl.textContent = String(api.selectedScrollSnap() + 1);
prevBtn.disabled = !api.canScrollPrev();
nextBtn.disabled = !api.canScrollNext();
};
api.on('select', sync);
api.on('reInit', sync);
api.on('pointerDown', function () { viewport.setAttribute('data-dragging', 'true'); });
api.on('pointerUp', function () { viewport.removeAttribute('data-dragging'); });
prevBtn.addEventListener('click', function () { api.scrollPrev(); });
nextBtn.addEventListener('click', function () { api.scrollNext(); });
sync();
} else {
// embla 를 못 붙였을 때 — `.slider-viewport` 기본 상태가 이미 스크롤 상자라
// 손가락·트랙패드로는 여전히 밀린다. 화살표만 판 한 장 폭만큼 밀어 대신한다.
var step = function () {
var first = track.querySelector('button');
return (first ? first.getBoundingClientRect().width : 68) + 14;
};
var syncFallback = function () {
prevBtn.disabled = viewport.scrollLeft <= 2;
nextBtn.disabled = viewport.scrollLeft >= viewport.scrollWidth - viewport.clientWidth - 2;
curEl.textContent = String(Math.round(viewport.scrollLeft / step()) + 1);
};
prevBtn.addEventListener('click', function () { viewport.scrollBy({left: -step(), behavior: 'smooth'}); });
nextBtn.addEventListener('click', function () { viewport.scrollBy({left: step(), behavior: 'smooth'}); });
var songsSyncTimer = null;
viewport.addEventListener('scroll', function () {
window.clearTimeout(songsSyncTimer);
songsSyncTimer = window.setTimeout(syncFallback, 100);
}, {passive: true});
syncFallback();
}
return true;
}
/* ══ ④ 링크 걸기 ═══════════════════════════════════════════════════════════
* 렌더러는 일정의 "지도 검색 …" 과 엽서의 장소를 **글자로만** 낸다. 손님이 그 자리에서
* 지도를 열 방법이 없다 — 이름을 복사해 다른 앱에 붙여야 한다.
* 렌더러를 고치면 다른 사이트까지 바뀌므로(목업만 손대는 규약), 여기서 DOM 을 덧칠한다.
*
* ★ 네이버 지도 검색 주소를 쓴다. 좌표로 핀을 찍는 주소는 앱·웹에서 형태가 달라 한쪽이 깨진다.
* ★ React 가 다시 그리면 사라지므로 감시해서 다시 건다.
*/
function sectionItems(id) {
var sections = (P.theme && P.theme.sections) || [];
for (var i = 0; i < sections.length; i += 1) {
if (sections[i].id !== id) continue;
try {
var env = typeof sections[i].data === 'string' ? JSON.parse(sections[i].data) : sections[i].data;
return (env && env.items) || [];
} catch (err) {
return [];
}
}
return [];
}
function mapUrl(query) {
return 'https://map.naver.com/p/search/' + encodeURIComponent(query);
}
function linkify() {
credits();
// 일정: "10:2011:50 · 지도 검색 군산근대역사박물관"
var rows = document.querySelectorAll('#itinerary li p');
Array.prototype.forEach.call(rows, function (row) {
if (row.querySelector('a')) return;
var text = row.textContent || '';
var mark = text.indexOf('지도 검색 ');
if (mark < 0) return;
var query = text.slice(mark + 6).trim();
if (!query) return;
var head = document.createTextNode(text.slice(0, mark));
var link = document.createElement('a');
link.href = mapUrl(query);
link.target = '_blank';
link.rel = 'noopener noreferrer nofollow';
link.className = 'w4d-link';
link.textContent = '지도에서 열기 ↗';
row.textContent = '';
row.appendChild(head);
row.appendChild(link);
});
/* 엽서: **payload 의 place** 로 링크를 만든다.
화면의 소인 글자를 긁었더니 "군산 군산" 같은 주소가 나왔다 — 소인은 지명이 아니라
도장 문구다(예: "군산 內港"). 카드 순서와 payload 항목 순서가 같으므로 자리로 짝짓는다. */
var cards = document.querySelectorAll('#postcard article');
var postcards = sectionItems('postcard');
Array.prototype.forEach.call(cards, function (card, index) {
if (card.querySelector('a.w4d-link')) return;
var item = postcards[index];
var place = item && (item.place || item.postmark);
if (!place) return;
var tags = Array.prototype.filter.call(card.querySelectorAll('p'), function (el) {
return (el.textContent || '').trim().charAt(0) === '#';
})[0];
if (!tags) return;
var link = document.createElement('a');
link.href = mapUrl(place.indexOf('군산') >= 0 ? place : '군산 ' + place);
link.target = '_blank';
link.rel = 'noopener noreferrer nofollow';
link.className = 'w4d-link';
link.textContent = ' 지도에서 열기 ↗';
tags.appendChild(link);
});
}
/* 인물 사진의 저작자 표시.
★ 이 목업이 물고 있는 렌더러 번들은 `imageCredit` 을 그리지 않는 옛 판이다(고쳐도 다른
사이트까지 다시 구워야 해서 손대지 않는다). 그런데 사진은 CC BY·BY-SA·공공누리라
**저작자 표시가 라이선스 조건**이다 — 안 나오면 침해다. 그래서 여기서 붙인다. */
function credits() {
var cards = document.querySelectorAll('#people article');
var items = sectionItems('people');
Array.prototype.forEach.call(cards, function (card, index) {
if (card.querySelector('.w4d-credit')) return;
var item = items[index];
if (!item || !item.imageCredit || !card.querySelector('img')) return;
var line = card.querySelector('p:last-of-type') || card;
var span = document.createElement('p');
span.className = 'w4d-credit';
span.textContent = item.imageCredit;
line.parentElement.appendChild(span);
});
}
function watchLinks() {
linkify();
if (!window.MutationObserver) return;
var timer = null;
new MutationObserver(function () {
window.clearTimeout(timer);
timer = window.setTimeout(linkify, 300);
}).observe(document.getElementById('root') || document.body, {childList: true, subtree: true});
}
/* ══ ⑤ 카로셀 — 손님이 만지는 동안에는 자동으로 넘기지 않는다 ══════════════════
*
* ★ 이 페이지의 카로셀은 **구현이 둘**이다.
* ① embla-carousel(라이브러리) — 일정·축제·주변 안내. `.slider-viewport` 를 쓰고
* 자동 넘김은 `embla.scrollNext()` 로 움직인다.
* ② 손으로 만든 가로 스크롤 상자 — 객실·영상·갤러리. `box.scrollBy()` 로 움직인다.
* 정책만 `site/src/lib/ui/use-rail-autoplay.ts` 한 벌을 같이 쓴다.
* 처음에는 ②만 막았다(`scrollBy` 를 삼키는 방식) — 그래서 **일정 카로셀만 계속 넘어갔다**
* (2026-09-11 사장님: "스케줄 보려고 밑에 스크롤 했는데 그다음에 바로 자동 넘어가").
*
* ★ 그래서 훅이 **이미 가진 정지 장치**를 쓴다.
* 훅은 레일 위 `pointerdown` 에 정지 카운터를 올리고 window `pointerup` 에 내린다.
* 구현이 둘이어도 이 카운터는 하나다 — 여기에 손을 얹으면 embla 든 스크롤 상자든 같이 멈춘다.
* (`button: 2` 로 쏜다. embla 는 주 버튼이 아닌 pointerdown 을 드래그로 치지 않아
* 손님의 진짜 드래그를 방해하지 않는다.)
*
* ★ 두 가지로 나눠 대응한다.
* · **쿨타임** — 페이지를 세로로 스크롤하는 것도 "읽는 중" 이다. 무엇이든 만지면
* 7초 동안 모든 레일이 멈춘다. 손을 떼고 7초가 지나야 다시 돈다.
* · **영구 정지** — 레일을 직접 민 경우. 자동 넘김이 하려던 일("옆에 더 있다")은
* 그 손님에게 이미 전달됐다. 그 레일은 다시 돌지 않는다.
*/
var COOLDOWN_MS = 7000;
function tameRails() {
var quietUntil = 0;
var holding = false;
var forever = []; // 손님이 직접 민 레일
var rails = [];
/*
* ★ 손이 화면에 닿아 있는 동안은 무조건 멈춘다 (2026-09-11, 사장님: "유저가 터치로 잡고 있는데 자동으로 넘어가는 게 문제")
* 쿨타임(7초)만으로는 못 막는다. 손가락을 대고 가만히 읽고 있으면 touchmove 도 scroll 도
* 안 오니 쿨타임이 그냥 끝난다. 게다가 브라우저는 터치가 페이지 스크롤로 넘어가는 순간
* **손가락이 아직 닿아 있는데 `pointercancel` 을 쏜다** — 훅의 정지 카운터가 거기서 풀린다.
* 실측: touchstart 0.0s → pointercancel 7.5s → 8.4s 에 손가락 밑에서 카드가 넘어갔다.
* 그래서 pointer 가 아니라 **touches 개수**로 센다. 손이 떨어져야 0 이 된다.
*/
var fingers = 0;
var mouseDown = false;
function collect() {
rails = Array.prototype.slice.call(document.querySelectorAll('#root .slider-viewport'));
Array.prototype.forEach.call(document.querySelectorAll('#root *'), function (el) {
if (rails.indexOf(el) >= 0) return;
var flow = window.getComputedStyle(el).overflowX;
if ((flow === 'auto' || flow === 'scroll') && el.scrollWidth - el.clientWidth > 40) {
rails.push(el);
}
});
}
function press(rail) {
rail.dispatchEvent(new PointerEvent('pointerdown', {bubbles: true, button: 2, buttons: 2}));
}
function holdAll() {
collect();
rails.forEach(press);
holding = true;
}
function releaseAll() {
// 해제는 window 하나로 전부에게 간다(훅이 window 에서 받는다). 영구 정지분은 곧바로 다시 잡는다.
window.dispatchEvent(new PointerEvent('pointerup', {bubbles: true, button: 2}));
holding = false;
forever.forEach(press);
}
function railOf(node) {
while (node && node.nodeType === 1 && node !== document.body) {
if (rails.indexOf(node) >= 0) return node;
node = node.parentElement;
}
return null;
}
/*
* ★ "만졌다" 와 "밀었다" 를 가른다 (2026-09-11, 사장님: "한번 스크롤한뒤에는 auto play 아예 안되는데")
* 전에는 레일 위에서 일어난 pointerdown·touchstart·wheel 이면 **곧바로** 영구 정지로 보냈다.
* 그런데 페이지를 세로로 내릴 때 손가락(과 마우스 커서)은 거의 항상 레일 위를 지난다 —
* 일정 카로셀은 화면 가운데를 가득 채우니까. 그래서 **한 번 스크롤하면 그 레일이 영영 멈췄다.**
* 실측: 세로 휠 한 번 → 20초 동안 0px.
* 이제 **레일이 실제로 가로로 움직였을 때만** 영구 정지다. 제스처 시작 때 위치를 적어 두고
* 손을 뗀 뒤(관성 350ms) 다시 재서, 달라졌으면 손님이 민 것이다.
*/
function railPos(rail) {
var slid = 0;
var first = rail.firstElementChild;
if (first) {
var tf = window.getComputedStyle(first).transform;
if (tf && tf !== 'none') {
try { slid = new DOMMatrix(tf).m41; } catch (err) { slid = 0; }
}
}
return rail.scrollLeft + slid;
}
var pending = null; // {rail, pos} — 지금 진행 중인 제스처
function markIfMoved() {
if (!pending) return;
var rail = pending.rail;
var was = pending.pos;
pending = null;
window.setTimeout(function () {
// 8px 여유 — 탭 한 번에도 embla 가 스냅을 다시 잡느라 소수점이 흔들린다.
if (Math.abs(railPos(rail) - was) < 8) return;
if (forever.indexOf(rail) < 0) { forever.push(rail); press(rail); }
}, 350);
}
['pointerdown', 'touchstart', 'touchmove', 'wheel', 'keydown', 'scroll'].forEach(function (type) {
window.addEventListener(type, function (event) {
if (!event.isTrusted) return;
quietUntil = Date.now() + COOLDOWN_MS;
if (event.touches) fingers = event.touches.length;
if (type === 'pointerdown' && event.pointerType === 'mouse') mouseDown = true;
if (type === 'scroll' || type === 'keydown' || type === 'touchmove') return;
collect();
var rail = railOf(event.target);
if (!rail) return;
if (type === 'wheel') {
// 가로 휠·트랙패드 가로 스와이프만 "민 것"이다. 세로는 페이지를 내리는 중이다.
if (Math.abs(event.deltaX) > Math.abs(event.deltaY) && Math.abs(event.deltaX) > 2) {
pending = {rail: rail, pos: railPos(rail)};
markIfMoved();
}
return;
}
pending = {rail: rail, pos: railPos(rail)};
}, true);
});
['pointerup', 'pointercancel', 'touchend', 'touchcancel'].forEach(function (type) {
window.addEventListener(type, function (event) {
if (!event.isTrusted) return;
quietUntil = Date.now() + COOLDOWN_MS;
// 손가락 수는 touch 계열에서만 갱신한다 — pointercancel 은 손이 닿아 있어도 온다.
if (event.touches) fingers = event.touches.length;
if (event.pointerType === 'mouse') mouseDown = false;
if (fingers === 0 && !mouseDown) markIfMoved();
}, true);
});
// 250ms 마다 조용해야 할 때인지 본다. 타이머를 직접 만지지 않고 훅의 정지 장치만 건드린다.
window.setInterval(function () {
var quiet = fingers > 0 || mouseDown || Date.now() < quietUntil;
if (quiet && !holding) holdAll();
else if (!quiet && holding) releaseAll();
}, 250);
}
/* ══ ⑥ 엽서 쓰기 — 스테이,머뭄 엽서를 직접 써서 공유 ═══════════════════════════
* (2026-09-14 대표 지시 세 번에 걸쳐 정리됐다)
* ① "오늘의 엽서를 진짜 엽서처럼 자기가 텍스트 입력해서 공유하면 어떨까?"
* ② "오늘의 엽서 칸을 빼고 이 엽서 포맷으로 스테이 머뭄 관련 엽서를 쓰는 걸로 하자.
* 이건 축제소개 전에 섹션으로 빼줘." — 도시 엽서(옛 postcard 탭)는 **없앤다**.
* 이 기능은 더 이상 '군산 이야기' 탭 하나가 아니라 **독립 섹션**이고, 사진도
* 군산 명소가 아니라 **이 숙소 사진**(payload.media)을 쓴다.
* ③ "이미지는 스테이머뭄 이미지 선택으로" · "엔터치면 줄바꿈 부탁"
* ④ "태그 지우셈 UI깨짐 #스테이머뭄" · "플레이스홀더도 그냥 일반적인 문구로 —
* 스테이머뭄뿐만 아니라 다른 숙박업소나 카페도 써야 하는데 이렇게 써놓으면
* 어떡해" — **이 업소 이름을 코드에 못 박지 않는다.** 해시태그 줄(#스테이머뭄
* #군산여행)은 통째로 뺐고, 입력창 placeholder 의 예문도 이 업소 이름이 빠진
* 일반 문구로 바꿨다. 우표 옆 소인(원 안의 상호명)도 "스테이"·"머뭄" 고정
* 두 글자가 아니라 `placeName` 을 받아 어느 상호든 접어 넣는 `stampLines()`
* 로 바꿨다 — 이 스크립트가 다른 업소에 그대로 옮겨져도 코드를 안 고쳐도 된다.
*
* ★ **og:image 링크 카드가 아니라 파일 그대로 공유다**(2026-09-14 대표 확정, 별도 논의).
* 이 백엔드엔 익명 업로드 엔드포인트가 없다(사진은 전부 로그인한 사장님 전용).
* Web Share API 로 캔버스 이미지를 파일째 공유 시트에 넘긴다 — 카카오톡이 그 시트에
* 있으면 백엔드 없이도 이미지가 그대로 대화방에 올라간다. 데스크톱처럼 파일 공유를
* 못 받는 브라우저는 "이미지로 저장" 버튼으로 대신한다(항상 같이 둔다).
* ★ **글꼴은 화면과 같은 것**(대표: "폰트 동일한걸로") — 이미 떠 있는 `.serif` 요소의
* computed font-family 를 그대로 `ctx.font` 에 옮긴다.
* ★ **모양은 진짜 엽서 카드(`PostcardSection.tsx`)를 그대로 베낀다** — 사진(위) +
* 괘선으로 가른 두 칸(왼쪽 문구, 오른쪽 우표+소인) + 맨 아래 서명 줄.
* 새로 만든 위젯이 아니라 "이 사이트에 있던 그 엽서를 내가 쓴 것"처럼 보여야 한다.
* ★ **자리 — 독립 섹션, `#location`(오시는 길)과 `#festival`(계절별 축제) 사이**다.
* 더는 '군산 이야기' 탭이 아니라서 React 의 tab hidden 로직에 안 걸린다 — 늘 보인다.
* ★ 옛 '오늘의 엽서' 탭은 `patch_stay.py` 에서 payload 의 `postcard` 섹션 데이터를
* 비워 없앤다(StorySection 은 `items.length > 0` 인 탭만 세운다 — data 를 비우는 것만
* 으로 렌더러를 안 건드리고 탭이 사라진다).
*/
function postcardMaker() {
// ★ 4장으로 자르지 않는다(2026-09-14 대표: "이미지는 다 가져와서 그냥 카로셀로
// 보여주든가 하셈") — 숙소 사진 전부(지금 9장)를 후보로 둔다. 개수가 늘면 한 줄에
// 다 못 앉으니 아래서 썸네일 줄 자체를 카로셀(`.slider-viewport` + embla)로 만든다.
var photos = (P.media || []).filter(function (m) { return m.url; });
if (!photos.length) return false;
if (document.getElementById('w4d-pm-section')) return true; // 이미 붙어 있다
var placeName = (P.place && (P.place.name || P.place.title)) || '스테이,머뭄';
var region = (P.place && (P.place.regionName || P.place.address || '')) || '전북 군산';
var regionShort = String(region).split(' ').slice(0, 2).join(' ');
var section = document.createElement('section');
section.id = 'w4d-pm-section';
section.setAttribute('aria-labelledby', 'w4d-pm-heading');
section.innerHTML =
'<div class="shell">' +
'<header class="mb-8 sm:mb-10">' +
'<h2 id="w4d-pm-heading" class="h2">엽서 쓰기</h2>' +
'<p class="mt-3 whitespace-nowrap text-[length:var(--fs-sm)] opacity-70">' +
'사진 고르고 한마디 적으면 엽서 완성</p>' +
'</header>' +
'<div class="w4d-pm-layout">' +
'<canvas class="w4d-pm-canvas" width="1080" height="1080" aria-label="내가 만든 엽서 미리보기"></canvas>' +
'<div class="w4d-pm-form">' +
/* 썸네일 줄도 이 사이트의 진짜 카로셀 껍데기를 그대로 쓴다(가요 다방·군산 읽기와
같은 근거 — README `startSongsRail` 주석 참고). role=radiogroup 은 안쪽
track 이 아니라 이 뷰포트에 둔다 — 스크린리더가 "그룹" 보다 "라디오군" 을
먼저 읽어야 사진 고르기라는 게 전달된다. */
'<div class="w4d-pm-thumbs-viewport slider-viewport" role="radiogroup" ' +
'aria-label="엽서 사진 고르기(' + photos.length + '장)" tabindex="0">' +
'<div class="w4d-pm-thumbs slider-track" id="w4d-pm-thumbs-track">' +
photos.map(function (m, i) {
return '<button type="button" class="w4d-pm-thumb" data-i="' + i + '" ' +
'aria-pressed="' + (i === 0 ? 'true' : 'false') + '" aria-label="사진 ' + (i + 1) + '">' +
'<img src="' + escapeHtml(m.url) + '" alt="" loading="lazy"></button>';
}).join('') +
'</div></div>' +
'<textarea class="w4d-pm-text" maxlength="60" rows="3" ' +
/* ★ 이 업소 이름을 예시에 박지 않는다(2026-09-14 대표: "다른 숙박업소나 카페도
써야 하는데 이렇게 써놓으면 어떡해") — 어느 업종에 붙여도 맞는 말로 남긴다. */
'placeholder="엽서에 쓸 한 마디를 적어 보세요"></textarea>' +
'<p class="w4d-pm-count"><span data-len>0</span>/60</p>' +
'<div class="w4d-pm-actions">' +
'<button type="button" class="w4d-pm-share"></button>' +
'<button type="button" class="w4d-pm-save">이미지로 저장</button>' +
'</div>' +
'<p class="w4d-pm-hint" hidden>이 브라우저는 공유 시트를 못 열어 저장으로 대신합니다.</p>' +
'</div></div></div>';
function mount() {
if (section.isConnected) return true;
var festival = document.getElementById('festival');
if (festival && festival.parentNode) {
festival.parentNode.insertBefore(section, festival);
return true;
}
var location = document.getElementById('location');
if (location && location.parentNode) {
location.parentNode.insertBefore(section, location.nextElementSibling);
return true;
}
return false;
}
if (!mount()) return false;
var canvas = section.querySelector('.w4d-pm-canvas');
var ctx = canvas.getContext('2d');
var textarea = section.querySelector('.w4d-pm-text');
var lenEl = section.querySelector('[data-len]');
// draw() 안 사진 아래 레이아웃(margin·우표 칸 폭 190)과 같은 값이다 — 하나를 고치면
// 이 상수도 같이 고친다. 여기서도 필요한 이유: 몇 줄로 접힐지 **입력 중에 미리 재서**
// MAX_LINES 를 넘기지 못하게 막는다(2026-09-14 대표: "4줄이상 못쓰게 막으라고").
var TEXT_COL_W = canvas.width - 56 - 190 - 56 - 24;
var MAX_LINES = 4;
var thumbs = Array.prototype.slice.call(section.querySelectorAll('.w4d-pm-thumb'));
// 썸네일 카로셀 — embla 가 있으면 붙이고, 없으면 `.slider-viewport` 기본 스크롤로 남는다
// (가요 다방과 같은 폴백 규약). 사진을 고르는 라디오 그룹이라 화살표·점은 안 둔다 —
// 판만 보고 눌러서 고르면 되는 자리다.
var thumbsViewport = section.querySelector('.w4d-pm-thumbs-viewport');
if (thumbsViewport && window.EmblaCarousel) {
var thumbsApi = window.EmblaCarousel(thumbsViewport, {align: 'start', loop: false, containScroll: 'trimSnaps'});
thumbsViewport.setAttribute('data-slider', 'on');
thumbsApi.on('pointerDown', function () { thumbsViewport.setAttribute('data-dragging', 'true'); });
thumbsApi.on('pointerUp', function () { thumbsViewport.removeAttribute('data-dragging'); });
}
var shareBtn = section.querySelector('.w4d-pm-share');
var saveBtn = section.querySelector('.w4d-pm-save');
var hint = section.querySelector('.w4d-pm-hint');
/* ★ 버튼 문구에 "카카오톡" 을 못 박지 않는다 (2026-09-14 대표: "맥에서 메세지로밖에
안되는데..? 카카오톡 등으로 공유가 아니잖아 그럼" — 실측·정확한 지적).
`navigator.share` 가 여는 시트는 **OS 가 채운다** — iOS·Android 는 설치된 앱이
공유 대상으로 등록되어 카카오톡이 뜨지만, **macOS 데스크톱은 앱이 시스템 공유
확장(NSSharingService)에 거의 등록을 안 해서** 메시지·메모·AirDrop 정도만 뜬다.
카카오톡 macOS 앱도 그 확장을 안 만든다 — 그래서 맥에서는 "메시지"만 보이는 게
버그가 아니라 OS 사양이다. 대신 **손끝으로 조작하는 기기(coarse pointer)에서만**
"카카오톡 등으로 공유" 라고 적고, 마우스로 조작하는 기기(주로 데스크톱)에서는
그 앱 이름을 빼고 그냥 "공유하기" 라고 적는다 — 버튼이 실제로 하는 일은 같지만
손님이 기대할 결과를 기기에 맞게 정직하게 알려준다. */
var isTouch = window.matchMedia && window.matchMedia('(pointer: coarse)').matches;
shareBtn.textContent = isTouch ? '카카오톡 등으로 공유' : '공유하기';
var picked = 0;
var images = {}; // url → HTMLImageElement(로드 완료분)
var redrawTimer = null;
function loadImage(url) {
if (images[url]) return Promise.resolve(images[url]);
return new Promise(function (resolve, reject) {
var img = new Image();
img.onload = function () { images[url] = img; resolve(img); };
img.onerror = reject;
img.src = url;
});
}
/* 엽서 문구용 줄바꿈. 캔버스는 CSS 처럼 자동 줄바꿈을 안 해 준다.
★ **엔터는 그대로 줄바꿈이다**(2026-09-14 대표: "엔터치면 줄바꿈 부탁") — 먼저
`\n` 으로 문단을 가르고, 각 문단만 폭에 맞춰 다시 접는다. `\s+` 로 한 번에 쪼개면
사용자가 누른 엔터가 스페이스와 같은 취급을 받아 그대로 이어 붙어 버린다. */
function wrapLines(text, maxWidth) {
var lines = [];
text.split('\n').forEach(function (para) {
if (para.trim() === '') { lines.push(''); return; }
var words = para.split(' ');
var line = '';
words.forEach(function (word) {
var 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; }
/* ★ 공백 없는 긴 글자열은 단어 단위로 안 접힌다 (2026-09-14 대표 제보 — 자음만
줄줄이 이어친 문구가 우표 칸까지 넘쳐 나갔다). `stampLines()` 와 같은 방법으로
글자 단위로도 접는다. */
var chunk = '';
for (var i = 0; i < word.length; i += 1) {
var next = chunk + word[i];
if (ctx.measureText(next).width > maxWidth && chunk) {
lines.push(chunk);
chunk = word[i];
} else {
chunk = next;
}
}
line = chunk;
});
if (line) lines.push(line);
});
return lines;
}
/* 소인(우표 옆 회전 원) 안 상호명 줄바꿈 — `wrapLines` 와 따로 둔다.
★ **이 업소 이름을 박아 두지 않는다**(2026-09-14 대표: "다른 숙박업소나 카페도
써야 하는데 이렇게 써놓으면 어떡해"). "스테이"·"머뭄" 두 글자씩 고정 두 줄이던 걸
빼고, **어느 상호가 와도** 원 안에 맞게 접는다. 콤마·공백처럼 상호 안의 자연스러운
경계에서 먼저 끊고, 그 조각 하나가 원보다 넓으면 글자 단위로도 접는다 —
"스테이,머뭄" 처럼 사이 공백 없는 이름도 있어 `wrapLines`(공백 기준)만으로는
한 줄로 넘쳐 버린다. */
function stampLines(name, maxWidth) {
var parts = name.split(/[,\s]+/).filter(Boolean);
var lines = [];
var line = '';
parts.forEach(function (part) {
var 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;
}
var chunk = '';
for (var i = 0; i < part.length; i += 1) {
var next = chunk + part[i];
if (ctx.measureText(next).width > maxWidth && chunk) {
lines.push(chunk);
chunk = part[i];
} else {
chunk = next;
}
}
line = chunk;
});
if (line) lines.push(line);
return lines;
}
function draw() {
var photo = photos[picked];
var size = canvas.width; // 정사각형(1080) — 카톡 등 공유의 1:1 관례와 같다
var serifFont = 'serif';
var probe = document.querySelector('.serif');
if (probe) {
var family = getComputedStyle(probe).fontFamily;
if (family) serifFont = family;
}
var ink = '#1b1a15';
var paper = '#efe7d3';
var accent = '#bf2f1b';
var margin = 56;
ctx.clearRect(0, 0, size, size);
ctx.fillStyle = paper;
ctx.fillRect(0, 0, size, size);
loadImage(photo.url).then(function (img) {
/* ── 위: 사진 (실제 카드의 aspect-3/2 앞면과 같은 비율) ── */
var photoX = margin, photoY = margin;
var photoW = size - margin * 2;
var photoH = photoW / 1.5;
var scale = Math.max(photoW / img.width, photoH / img.height);
var w = img.width * scale, h = img.height * scale;
ctx.save();
ctx.beginPath();
ctx.rect(photoX, photoY, photoW, photoH);
ctx.clip();
ctx.drawImage(img, photoX + (photoW - w) / 2, photoY + (photoH - h) / 2, w, h);
ctx.restore();
ctx.strokeStyle = 'rgba(27,26,21,0.25)';
ctx.lineWidth = 1;
ctx.strokeRect(photoX + 0.5, photoY + 0.5, photoW - 1, photoH - 1);
/* ── 아래: 괘선으로 가른 두 칸(실제 카드 grid-cols-[1fr_auto] 그대로) ── */
var bodyY = photoY + photoH + 36;
var stampColW = 190;
var dividerX = size - margin - stampColW;
var textColW = dividerX - margin - 24;
ctx.strokeStyle = 'rgba(27,26,21,0.35)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(dividerX, bodyY);
ctx.lineTo(dividerX, size - margin - 46);
ctx.stroke();
// 왼쪽 — 사용자 문구(“ ” 인용부호, 실제 카드와 같은 서체)
// ★ 해시태그 줄은 뺐다(2026-09-14 대표: "태그 지우셈 UI깨짐") — 짧은 문구 뒤에
// 고정 해시태그(#스테이머뭄 #군산여행)를 더 얹었더니 두 줄짜리 긴 문구에서는
// 해시태그가 우표 칸 밑으로 겹쳐 나갔다. 문구 하나만 남긴다.
var text = textarea.value.trim() || '오늘, 이 숙소에서 하루를 보냅니다.';
ctx.fillStyle = ink;
ctx.font = '600 46px ' + serifFont;
ctx.textAlign = 'left';
ctx.textBaseline = 'alphabetic';
var wrapped = wrapLines('“' + text + '”', textColW);
// 입력 쪽(textarea input 핸들러)에서 이미 4줄을 못 넘게 막지만, 여기서도 한 번 더
// 자른다 — 그래야 어떤 경로로 그려지든(붙여넣기 등) 카드가 절대 안 깨진다.
var lines = wrapped.slice(0, MAX_LINES);
if (wrapped.length > MAX_LINES) {
var last = lines[MAX_LINES - 1];
while (last.length && ctx.measureText(last + '…').width > textColW) last = last.slice(0, -1);
lines[MAX_LINES - 1] = last + '…';
}
var lineHeight = 60;
lines.forEach(function (line, i) {
ctx.fillText(line, margin, bodyY + 50 + i * lineHeight);
});
// 오른쪽 — 우표 + 소인(실제 카드의 郵票 박스·회전 원 그대로)
var stampCx = dividerX + 24 + stampColW / 2 - 24;
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 ' + serifFont;
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 ' + serifFont;
// 상호가 무엇이든(숙소·카페 어디에도 이 스크립트가 그대로 쓰인다) 원 안에
// 맞춰 접는다 — 이 업소 이름을 고정으로 박지 않는다.
var nameLines = stampLines(placeName, 100).slice(0, 2);
var nameLH = 26;
var nameStartY = 8 - ((nameLines.length - 1) * nameLH) / 2;
nameLines.forEach(function (line, i) {
ctx.fillText(line, 0, nameStartY + i * nameLH);
});
ctx.restore();
// 맨 아래 — 점선 + 서명 줄(실제 카드의 출처 줄과 같은 자리)
var 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 ' + serifFont;
ctx.textAlign = 'left';
ctx.fillText(placeName + ' · ' + regionShort, margin, footY + 6);
});
}
function scheduleDraw() {
window.clearTimeout(redrawTimer);
redrawTimer = window.setTimeout(draw, 80);
}
thumbs.forEach(function (btn, i) {
btn.addEventListener('click', function () {
picked = i;
thumbs.forEach(function (t, j) { t.setAttribute('aria-pressed', j === i ? 'true' : 'false'); });
scheduleDraw();
});
});
// 몇 줄로 접힐지는 폭·서체에 달려 있어 글자 수로는 못 잰다 — draw() 와 같은
// wrapLines() 로 실제 접은 결과를 재서 넘으면 되돌린다(2026-09-14 대표: "4줄이상
// 못쓰게 막으라고"). 자음만 이어치거나 엔터를 여러 번 눌러도 여기서 막힌다.
var lastGoodText = textarea.value;
textarea.addEventListener('input', function () {
if (wrapLines('“' + textarea.value + '”', TEXT_COL_W).length > MAX_LINES) {
textarea.value = lastGoodText;
} else {
lastGoodText = textarea.value;
}
lenEl.textContent = String(textarea.value.length);
scheduleDraw();
});
textarea.addEventListener('keydown', function (event) {
// Enter 는 기본 동작(줄바꿈)을 그대로 둔다 — 폼 전송이 아니므로 막을 이유가 없다.
// 여기서는 눌린 즉시 다시 그려 손님이 줄바꿈 결과를 바로 본다(input 이벤트보다 빠르다).
if (event.key === 'Enter') scheduleDraw();
});
function exportBlob(cb) {
canvas.toBlob(function (blob) { cb(blob); }, 'image/png', 0.95);
}
/* ★ 사진 + 링크만 보낸다, 캡션 문구는 안 넣는다(2026-09-14 대표: "이미지+내용+링크
하니까 안예쁨" — 손님이 쓴 문구는 이미 canvas 안 엽서 그림에 박혀 있는데, 시트에
또 같은 문구를 글자로 한 번 더 얹으면 같은 말이 두 번 보였다). `files` 와 `url`
을 같은 navigator.share() 호출에 같이 넣는 조합은 기기마다 지원이 갈려서, 파일이
실리는 경우엔 링크를 `text` 자리에 적어 붙인다(문구 없이 링크 한 줄만). */
shareBtn.addEventListener('click', function () {
var urlMeta = document.querySelector('meta[property="og:url"]');
var pageUrl = (urlMeta && urlMeta.content) || (location.origin + location.pathname);
exportBlob(function (blob) {
if (!blob) return;
var file = new File([blob], 'stay-postcard.png', {type: 'image/png'});
if (navigator.canShare && navigator.canShare({files: [file]})) {
navigator.share({files: [file], title: placeName + ' 엽서', text: pageUrl})
.catch(function () { /* 손님이 시트를 닫은 것도 여기로 온다 — 오류로 안 다룬다 */ });
} else if (navigator.share) {
// 파일 공유를 못 받는 브라우저(주로 데스크톱)는 링크만이라도 시트로 보낸다.
navigator.share({title: placeName + ' 엽서', url: pageUrl})
.catch(function () { /* 위와 같은 이유로 오류로 안 다룬다 */ });
hint.hidden = false;
hint.textContent = '이 브라우저는 사진은 같이 못 보내 링크만 보냈습니다. 사진은 "이미지로 저장" 을 눌러 주세요.';
} else {
hint.hidden = false;
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url; a.download = 'stay-postcard.png'; a.click();
URL.revokeObjectURL(url);
}
});
});
saveBtn.addEventListener('click', function () {
exportBlob(function (blob) {
if (!blob) return;
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url; a.download = 'stay-postcard.png'; a.click();
URL.revokeObjectURL(url);
});
});
// 폰트가 이 시점에 아직 안 실렸을 수 있다 — 실리고 나서 한 번 더 그린다.
if (document.fonts && document.fonts.ready) {
document.fonts.ready.then(draw);
}
draw();
return true;
}
/* ══ ⑧ 사진 갤러리 쪽 넘김 — 넓은 화면에서만 ═══════════════════════════════
* ★ 왜 (2026-09-14 사장님: "사진갤러리 전체 렌더링 말고 페이징 좀. 너무 많아 데스크톱에서는")
* `GallerySection` 은 좁은 화면에서 한 장씩 미는 캐러셀이고, 넓은 화면에서는 같은 <ul> 이
* 4열 격자가 된다(`lg:grid lg:grid-cols-4`). 사진 37장이면 열 줄 — 갤러리 하나가
* 페이지를 끊는다. 그래서 넓은 화면에서만 12장(3줄)씩 끊어 보여 준다.
*
* ★ <li> 를 **지우지 않는다.** 숨기기는 `data-w4d-gal-on='0'` + 주입 CSS 가 하고, 그 규칙은
* `min-width: 1024px` 안에만 있다. 두 가지 이유다.
* · 좁은 화면 캐러셀은 손대지 않아야 한다 — 거기선 이미 한 번에 한 장이다.
* · 사진이 DOM 에 남아 있어야 alt 문장이 문서에 남는다. 렌더러도 같은 이유로 배리에이션
* 셋 모두에서 사진을 전부 싣는다(`GallerySection.tsx` 머리주석).
*
* ★ 크게 보기(모달)와 어긋나지 않는다. 모달의 앞/뒤는 React 가 `images` **배열 색인**으로
* 도는 것이라 화면에 무엇이 보이는지와 무관하다 — 3쪽에서 연 사진도 ←/→ 로 37장을 다 돈다.
*
* ★ 페이저는 React 가 그리는 상자(`div.relative`) 안에 꽂는다. 재렌더에 날아가므로
* #root 를 감시해 다시 꽂는다(② 헤더 단추와 같은 방식). 다시 부를 수 있게 만들었다 —
* 이미 꽂혀 있으면 표시만 다시 찍는다.
*/
var GAL_PER_PAGE = 12; // 4열 × 3줄. 줄 수를 바꾸려면 4의 배수로 둔다
function galleryPager() {
var box = document.getElementById('gallery');
if (!box) return;
var list = box.querySelector('ul');
if (!list) return;
var items = list.children;
var total = items.length;
// 한 쪽에 다 들어가면 페이저를 만들지 않는다 — 단추 한 개짜리 줄만 남는다.
if (total <= GAL_PER_PAGE) return;
var pages = Math.ceil(total / GAL_PER_PAGE);
var pager = box.querySelector('.w4d-gal-pager');
var fresh = !pager;
if (fresh) {
pager = document.createElement('nav');
pager.className = 'w4d-gal-pager';
pager.setAttribute('aria-label', '사진 갤러리 쪽 넘기기');
pager.dataset.w4dGalPage = '1';
var html = '<button type="button" data-w4d-gal-step="-1" aria-label="이전 쪽"></button>';
for (var i = 1; i <= pages; i += 1) {
html += '<button type="button" data-w4d-gal-page="' + i + '">' + i + '</button>';
}
html += '<button type="button" data-w4d-gal-step="1" aria-label="다음 쪽"></button>' +
'<span class="w4d-gal-total">사진 ' + total + '장</span>';
pager.innerHTML = html;
pager.addEventListener('click', function (event) {
var hit = event.target.closest('button');
if (!hit) return;
var now = Number(pager.dataset.w4dGalPage) || 1;
var next = hit.dataset.w4dGalPage
? Number(hit.dataset.w4dGalPage)
: now + Number(hit.dataset.w4dGalStep);
if (next < 1 || next > pages || next === now) return;
pager.dataset.w4dGalPage = String(next);
paint();
});
}
function paint() {
var page = Number(pager.dataset.w4dGalPage) || 1;
var from = (page - 1) * GAL_PER_PAGE;
list.dataset.w4dGal = '1';
for (var i = 0; i < items.length; i += 1) {
items[i].dataset.w4dGalOn = (i >= from && i < from + GAL_PER_PAGE) ? '1' : '0';
}
var buttons = pager.querySelectorAll('button');
for (var b = 0; b < buttons.length; b += 1) {
var button = buttons[b];
if (button.dataset.w4dGalPage) {
button.setAttribute('aria-current', Number(button.dataset.w4dGalPage) === page ? 'true' : 'false');
} else {
var step = Number(button.dataset.w4dGalStep);
button.disabled = (page + step) < 1 || (page + step) > pages;
}
}
}
paint();
if (fresh) list.parentElement.appendChild(pager);
}
/* ★ `load` 를 기다리지 않는다. 이 페이지는 경로 지도 때문에 OSM 타일 <img> 가 151장이라
(일정 20개 × 2~3일) load 는 그게 다 끝나야 나온다 — 타일이 느리면 영원히 안 뜬다. */
/* ★ 어느 판을 보고 있는지 콘솔에서 바로 알게 한다. `/s/` 는 5분 캐시라 "고쳤는데
안 된다" 의 대부분이 **옛 파일을 보고 있는 것**이었다 — 그걸 눈으로 가른다.
`__W4D_BUILD__` 는 patch_stay.py 가 굽는 시각(UPDATED_AT)으로 바꿔 박는다. */
console.log('[w4d] 주입분 로드 · 빌드 __W4D_BUILD__');
try { startPlayer(); } catch (err) { console.warn('[w4d] 플레이어 실패', err); }
void startReading;
try {
startSongsRail();
if (window.MutationObserver) {
// ★ 판 하나 고르는 클릭(`setPlaying`)에는 안 걸린다 — track 노드가 그대로라
// `.slider-viewport` 를 계속 물고 있다. 이 감시는 **하이드레이션 재렌더처럼
// #songs 통째가 다시 그려질 때만** 다시 감싼다.
new MutationObserver(function () { startSongsRail(); })
.observe(document.getElementById('root') || document.body, {childList: true, subtree: true});
}
console.log('[w4d] 가요 다방 카로셀 — 화살표 + embla');
} catch (err) { console.warn('[w4d] 가요 다방 카로셀 실패', err); }
void postcardMaker;
try { tameRails(); console.log('[w4d] 카로셀 — 만지는 동안 정지(7초) · 직접 민 레일은 영구 정지'); }
catch (err) { console.warn('[w4d] 카로셀 실패', err); }
try {
galleryPager();
if (window.MutationObserver) {
// 재렌더가 <li> 를 다시 만들면 표시(data-w4d-gal-on)가 사라진다 — 다시 찍는다.
// 속성은 감시하지 않는다(childList 만) — 우리가 찍는 속성에 우리가 다시 깨어난다.
new MutationObserver(function () { galleryPager(); })
.observe(document.getElementById('root') || document.body, {childList: true, subtree: true});
}
console.log('[w4d] 사진 갤러리 — 넓은 화면 ' + GAL_PER_PAGE + '장씩');
} catch (err) { console.warn('[w4d] 갤러리 쪽 넘김 실패', err); }
/* ⑩ 푸터 '만든 곳' 한 줄 — 렌더러에 **있는데** 이 목업이 문 번들에는 없다.
★ 왜 주입인가 (2026-09-15 대표: "푸터 하단에 Web4ai ai o2o 얘기도 적어주셈 / 반영반영")
`patch_stay.py` 가 정적 HTML 에 같은 줄을 박아 두지만, 이 한 장이 물고 있는 번들은
2026-09-09 판이라 그 줄을 모른다 — 하이드레이션이 끝나는 순간 리액트가 **지운다**
(실측 2026-09-15: HTML 에는 있는데 화면에는 0건). 번들을 갈아 끼우면 풀리지만,
그건 목업 전체를 최신 렌더러로 다시 그리는 일이라 이 한 장을 깨뜨릴 수 있다
(AGENTS.md 함정 1). 그래서 렌더러에 없는 것들과 **같은 방식**으로 얹는다.
★ 마크업은 `SiteFooter.tsx` 가 내보내는 것과 같다 — 번들을 갈아 끼우는 날
리액트가 그리는 줄과 겹치면 안 되므로, 이미 있으면 얹지 않는다. */
function startMadeBy() {
var HREF = 'https://www.o2osolution.ai/';
function put() {
var footer = document.querySelector('footer');
if (!footer) return false;
if (footer.querySelector('a[href="' + HREF + '"]')) return true; // 번들이 이미 그렸다
var box = footer.querySelector('.shell') || footer.firstElementChild || footer;
var p = document.createElement('p');
p.className = 'pt-3 text-[length:var(--fs-xs)] opacity-85';
p.innerHTML = '<a href="' + HREF + '" target="_blank" rel="noopener noreferrer" ' +
'class="underline-offset-2 hover:underline">AI O2O</a>의 Web4Ai로 만든 사이트입니다.';
box.appendChild(p);
return true;
}
put();
// 리액트가 #root 를 다시 그리면 사라진다 — 감시해서 다시 얹는다(플레이어와 같은 수법).
if (window.MutationObserver) {
new MutationObserver(function () { put(); })
.observe(document.getElementById('root') || document.body, {childList: true, subtree: true});
}
}
window.setTimeout(function () {
try {
watchLinks();
console.log('[w4d] 링크 걸기 — 일정 · 엽서');
} catch (err) { console.warn('[w4d] 링크 실패', err); }
void startCatchphrase;
try {
startMadeBy();
console.log('[w4d] 푸터 — AI O2O · Web4Ai');
} catch (err) { console.warn('[w4d] 푸터 표기 실패', err); }
}, 1200);
})();