2019 lines
109 KiB
JavaScript
2019 lines
109 KiB
JavaScript
/* /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;
|
||
|
||
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();
|
||
}
|
||
|
||
function play() {
|
||
var song = songs[cur] || {};
|
||
if (!song.audioUrl) return;
|
||
if (audio.getAttribute('src') !== song.audioUrl) audio.src = song.audioUrl;
|
||
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) { stop(); render(); } else { 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 disarmAutoplay() {
|
||
GESTURES.forEach(function (name) { window.removeEventListener(name, onFirstTouch); });
|
||
}
|
||
|
||
function armAutoplay() {
|
||
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) 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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||
.replace(/"/g, '"');
|
||
}
|
||
|
||
/* ★ 카로셀 껍데기는 새로 만들지 않는다 — **이 사이트가 이미 쓰는 것**을 그대로 가져다 쓴다
|
||
(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/55 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/55 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:20–11: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="measure mt-3 text-[length:var(--fs-sm)] opacity-70">' +
|
||
escapeHtml(placeName) + '에서 보내는 한 장을 직접 써 봅니다. 사진을 고르고 한 마디를 적으면 ' +
|
||
'그 자리에서 엽서가 되고, 그대로 공유하거나 저장할 수 있습니다.</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-11 사장님: "그외만 … 으로 바꿔줘 · 목업만이야")
|
||
* 카드는 하늘을 넷(맑음·흐림·비·눈)으로만 가르므로 **구름많음(코드 1~3)과 그 외**(안개 45 ·
|
||
* 소나기 80~82 · 뇌우 95~99 …)가 payload 의 같은 칸(`local.weather.notes.흐림`)을 본다 —
|
||
* 칸이 하나라 payload 로는 둘을 못 가른다. 렌더러(`derive.ts weatherMood`)를 가르는 건
|
||
* 제품을 건드리는 일이고, 목업은 재굽기 대상이 아니라 고쳐도 이 페이지엔 오지 않는다.
|
||
* ★ 문장을 여러 벌 돌린다 (2026-09-14 사장님: "케이스당 여러개로 만들어서 랜덤으로")
|
||
* 전에는 케이스마다 한 줄이라 새로고침해도 같은 문장이었다. 이제 하늘 5 · 기온대 5,
|
||
* 각 다섯 벌씩 `local.weather.noteSets` · `tempNoteSets` 에 있다(patch_stay.py 가 넣는다).
|
||
* **자루에서 뽑아 쓰고 비면 다시 채운다** — 매번 랜덤이면 같은 문장이 연달아 나온다
|
||
* (캐치프레이즈와 같은 규칙).
|
||
* ★ 뽑는 것은 **들어올 때 한 번**이다. 화면에 떠 있는 동안 저절로 바뀌지 않는다 —
|
||
* 읽는 중에 문장이 갈리는 것이 손님에게는 고장으로 보인다. 10분마다 도는 날씨 갱신에서
|
||
* 하늘·기온대가 달라졌을 때만 다시 뽑는다.
|
||
* ★ 굽힌 판에는 케이스마다 첫 문장이 박혀 있다. 그래서 DOM 에서 바꿀 줄을 **아는 문장 전부**와
|
||
* 대조해 찾는다 — 한 번 갈아 끼운 뒤에는 원래 문장이 거기 없기 때문이다.
|
||
* ★ React 는 날씨를 10분마다 다시 받아 그리면서 payload 문장으로 되돌린다
|
||
* (`use-live-weather`) — 옵저버로 되돌아간 것을 보고 다시 넣는다. 이미 맞으면 손대지
|
||
* 않으므로 옵저버가 자기 변경을 다시 보는 순환은 생기지 않는다.
|
||
*/
|
||
/* 렌더러의 기온대(`site/src/lib/derive.ts weatherBand`)와 **같은 경계**여야 한다 —
|
||
화면의 배지는 렌더러가 붙이고 문장만 여기서 갈아 끼우므로, 어긋나면 '추움' 배지 옆에
|
||
혹서 문장이 선다. */
|
||
function bandOf(temperature) {
|
||
if (temperature >= 30) return '혹서';
|
||
if (temperature >= 25) return '더움';
|
||
if (temperature >= 20) return '선선';
|
||
if (temperature >= 10) return '쌀쌀';
|
||
return '추움';
|
||
}
|
||
|
||
/* 카드가 '흐림' 칸을 쓰는 코드 가운데 **구름많음이 아닌** 것. 렌더러의 분류에서 맑음·비·눈에
|
||
해당하는 구간과 1~3 을 뺀 나머지가 전부 '그외'다. */
|
||
function skyOf(code) {
|
||
if (code === 0) return '맑음';
|
||
if (code >= 1 && code <= 3) return '구름많음';
|
||
if (code >= 50 && code <= 69) return '비';
|
||
if (code >= 70 && code <= 79) return '눈';
|
||
return '그외';
|
||
}
|
||
|
||
/* 구운 payload 의 조건 문자열 → 같은 다섯. 렌더러의 `derive.ts weatherMood` 와 순서를
|
||
맞춘다 — "비/눈" 같은 합성 표기에서 눈을 먼저 본다. */
|
||
function skyOfText(text) {
|
||
if (/눈|설/.test(text)) return '눈';
|
||
if (/비|우|소나기/.test(text)) return '비';
|
||
if (/구름/.test(text)) return '구름많음';
|
||
if (/흐림|안개|박무/.test(text)) return '그외';
|
||
return '맑음';
|
||
}
|
||
|
||
/* 자루 — 한 바퀴를 다 쓰기 전에는 같은 문장이 다시 나오지 않는다. */
|
||
function bagger(pools) {
|
||
var bags = {};
|
||
return function next(key) {
|
||
var pool = pools[key];
|
||
if (!pool || !pool.length) return null;
|
||
if (!bags[key] || !bags[key].length) {
|
||
bags[key] = pool.slice();
|
||
if (bags[key].length > 1) bags[key].sort(function () { return Math.random() - 0.5; });
|
||
}
|
||
return bags[key].shift();
|
||
};
|
||
}
|
||
|
||
function startWeatherNote() {
|
||
var w = (P.local && P.local.weather) || {};
|
||
var place = P.place || {};
|
||
var skyPools = w.noteSets || {};
|
||
var tempPools = w.tempNoteSets || {};
|
||
|
||
/* 아는 문장 전부. 굽힌 판의 값(notes·tempNotes)도 넣는다 — 첫 화면은 그것이 서 있다. */
|
||
function known(pools, baked) {
|
||
var set = Object.create(null); // 문장이 키다 — 프로토타입의 이름과 부딪히지 않게 빈 객체로 판다.
|
||
Object.keys(pools).forEach(function (k) {
|
||
(pools[k] || []).forEach(function (t) { set[t] = true; });
|
||
});
|
||
Object.keys(baked || {}).forEach(function (k) { set[baked[k]] = true; });
|
||
return set;
|
||
}
|
||
var skyKnown = known(skyPools, w.notes);
|
||
var tempKnown = known(tempPools, w.tempNotes);
|
||
if (!Object.keys(skyKnown).length && !Object.keys(tempKnown).length) return;
|
||
|
||
var nextSky = bagger(skyPools);
|
||
var nextTemp = bagger(tempPools);
|
||
var sky = null; // 지금 하늘 케이스
|
||
var band = null; // 지금 기온대
|
||
var line = {sky: null, temp: null};
|
||
|
||
/* 문장 하나를 갈아 끼운다. 접었다 펴는 건 눈이 따라오게 하려는 것뿐이라, 실패하면 그냥 바꾼다. */
|
||
function put(node, text) {
|
||
if (!node || !text || node.textContent.trim() === text) return;
|
||
if (REDUCED) { node.textContent = text; return; }
|
||
node.setAttribute('data-w4d-fade', '');
|
||
node.style.opacity = '0';
|
||
window.setTimeout(function () {
|
||
node.textContent = text;
|
||
node.style.opacity = '';
|
||
}, 300);
|
||
}
|
||
|
||
/* 하늘 줄은 <p> 자체가 문장이고, 기온 줄은 배지 <span> 옆의 <span> 이 문장이다.
|
||
클래스로 찾지 않는다 — 번들 판이 바뀌면 달라지고 두 줄 다 id 가 없다. */
|
||
function apply() {
|
||
var section = document.getElementById('weather');
|
||
if (!section) return;
|
||
var nodes = section.querySelectorAll('p, p span');
|
||
for (var i = 0; i < nodes.length; i += 1) {
|
||
var node = nodes[i];
|
||
if (node.children.length) continue; // 잎만 본다
|
||
var text = node.textContent.trim();
|
||
if (line.sky && skyKnown[text]) { put(node, line.sky); continue; }
|
||
if (line.temp && tempKnown[text]) { put(node, line.temp); }
|
||
}
|
||
}
|
||
|
||
function reroll() {
|
||
if (sky) line.sky = nextSky(sky) || line.sky;
|
||
if (band) line.temp = nextTemp(band) || line.temp;
|
||
apply();
|
||
}
|
||
|
||
var pending = null;
|
||
var observer = new window.MutationObserver(function () {
|
||
window.clearTimeout(pending);
|
||
pending = window.setTimeout(apply, 100);
|
||
});
|
||
var root = document.getElementById('root');
|
||
if (root) observer.observe(root, {childList: true, subtree: true, characterData: true});
|
||
|
||
/* 렌더러가 보는 그 엔드포인트다(같은 오리진). 실패하면 구운 문장을 그대로 둔다. */
|
||
function refresh() {
|
||
if (!place.regionCode || place.latitude == null) return;
|
||
var query = 'region_code=' + encodeURIComponent(place.regionCode) +
|
||
'&latitude=' + place.latitude + '&longitude=' + place.longitude;
|
||
window.fetch('/v1/local/weather?' + query)
|
||
.then(function (r) { return r.ok ? r.json() : null; })
|
||
.then(function (body) {
|
||
if (!body || !body.weather) return;
|
||
var nextSkyCase = skyOf(Number(body.weather.weather_code));
|
||
var nextBand = bandOf(Number(body.weather.temperature));
|
||
// 케이스가 그대로면 문장도 그대로 둔다 — 10분마다 눈앞에서 갈리면 안 된다.
|
||
if (nextSkyCase === sky && nextBand === band) return;
|
||
sky = nextSkyCase;
|
||
band = nextBand;
|
||
reroll();
|
||
})
|
||
.catch(function () { /* 구운 문장 그대로 */ });
|
||
}
|
||
|
||
/* 굽힌 값으로 먼저 한 벌 뽑아 둔다 — 날씨 API 가 늦거나 죽어도 문장은 돌아간다.
|
||
구운 payload 에는 코드가 없고 사람이 읽는 문자열뿐이라 그쪽에서 되짚는다. */
|
||
sky = skyOfText(String(w.condition || ''));
|
||
band = bandOf(Number(w.temperature));
|
||
reroll();
|
||
refresh();
|
||
window.setInterval(refresh, 10 * 60 * 1000);
|
||
|
||
}
|
||
|
||
/* ══ ⑧ 사진 갤러리 쪽 넘김 — 넓은 화면에서만 ═══════════════════════════════
|
||
* ★ 왜 (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); }
|
||
try { startReading(); } catch (err) { console.warn('[w4d] 군산 읽기 실패', err); }
|
||
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); }
|
||
try {
|
||
postcardMaker();
|
||
if (window.MutationObserver) {
|
||
new MutationObserver(function () { postcardMaker(); })
|
||
.observe(document.getElementById('root') || document.body, {childList: true, subtree: true});
|
||
}
|
||
console.log('[w4d] 엽서 직접 쓰기');
|
||
} catch (err) { console.warn('[w4d] 엽서 만들기 실패', err); }
|
||
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); }
|
||
window.setTimeout(function () {
|
||
try {
|
||
watchLinks();
|
||
console.log('[w4d] 링크 걸기 — 일정 · 엽서');
|
||
} catch (err) { console.warn('[w4d] 링크 실패', err); }
|
||
try {
|
||
startCatchphrase();
|
||
console.log('[w4d] 캐치프레이즈 ' + catchphrases().length + '개 · 7초마다 전환');
|
||
} catch (err) { console.warn('[w4d] 캐치프레이즈 실패', err); }
|
||
try {
|
||
startWeatherNote();
|
||
console.log('[w4d] 날씨 문구 — 하늘 5 · 기온대 5, 케이스마다 다섯 벌 중 하나');
|
||
} catch (err) { console.warn('[w4d] 날씨 문구 실패', err); }
|
||
}, 1200);
|
||
})();
|