수집한 객실·이용 정보가 발행 화면에 연결되지 않던 경로를 보완하고, 숙소 소개와 지역 맛집 표시를 개선한다. - NOL 브라우저 수집 어댑터와 수집·반영 스크립트 추가 - 크롤링 fact 즉시 노출 및 직접 입력·정정값 보호 - 이용안내 항목별 구조화와 기존 표 연결, 원문 UI 비표시 - 군산 한일옥 고정 등록과 지역 맛집 탐색·보강 경로 추가 - 숙소 소개 요약, 히어로 문구, 지역 콘텐츠·목업 표시 개선 검증: 작업 트리 기준 site 타입·린트·빌드 및 안내 렌더링 테스트 통과, PC·모바일 화면 확인. 스테이징 diff 공백 검사 통과. 사용자 요청에 따라 현재 스테이징된 55개 파일만 포함하며 미스테이징 문서·테스트 등은 제외.
683 lines
34 KiB
JavaScript
683 lines
34 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초 뒤 사라졌다 —
|
||
숙소 이름 다음으로 남아야 할 한 줄이 첫 화면에서 지워지는 셈이었다. */
|
||
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 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);
|
||
}
|
||
|
||
// 대표 문구가 먼저 읽히고 나서 아래 줄이 선다 — 둘이 한꺼번에 뜨면 어느 쪽이 숙소의 한 줄인지 안 읽힌다.
|
||
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();
|
||
}
|
||
|
||
/* 패널은 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;
|
||
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-11 사장님: "그럼 자동 빼").
|
||
크롬·사파리는 소리 나는 재생을 사용자 조작 없이 허용하지 않는다. 그래서 "막히면
|
||
손님이 처음 만지는 순간 켠다" 를 넣어 봤는데, 그 리스너가 window 캡처라
|
||
**재생 단추의 클릭을 먼저 먹었다** — 첫 클릭에 켜졌다가 단추 핸들러가 다시 꺼서
|
||
두 번 눌러야 켜졌다. 자동재생을 빼면 그 충돌 자체가 없어진다.
|
||
시작 곡이 무작위인 것은 그대로다 — 손님이 재생을 누르면 그 곡부터 순서대로 돈다. */
|
||
|
||
buildList();
|
||
mount();
|
||
render();
|
||
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 + '곡');
|
||
}
|
||
|
||
|
||
/* ══ ③ 링크 걸기 ═══════════════════════════════════════════════════════════
|
||
* 렌더러는 일정의 "지도 검색 …" 과 엽서의 장소를 **글자로만** 낸다. 손님이 그 자리에서
|
||
* 지도를 열 방법이 없다 — 이름을 복사해 다른 앱에 붙여야 한다.
|
||
* 렌더러를 고치면 다른 사이트까지 바뀌므로(목업만 손대는 규약), 여기서 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);
|
||
}
|
||
|
||
/* ★ `load` 를 기다리지 않는다. 이 페이지는 경로 지도 때문에 OSM 타일 <img> 가 151장이라
|
||
(일정 20개 × 2~3일) load 는 그게 다 끝나야 나온다 — 타일이 느리면 영원히 안 뜬다. */
|
||
console.log('[w4d] 주입분 로드');
|
||
try { startPlayer(); } catch (err) { console.warn('[w4d] 플레이어 실패', err); }
|
||
try { tameRails(); console.log('[w4d] 카로셀 — 만지는 동안 정지(7초) · 직접 민 레일은 영구 정지'); }
|
||
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); }
|
||
}, 1200);
|
||
})();
|