편집 요약 없음 |
편집 요약 없음 |
||
| 2,297번째 줄: | 2,297번째 줄: | ||
' float imgAR = u_imgSize.x / u_imgSize.y;', | ' float imgAR = u_imgSize.x / u_imgSize.y;', | ||
' float scrAR = u_res.x / u_res.y;', | ' float scrAR = u_res.x / u_res.y;', | ||
' float scale = scrAR / imgAR;', | ' vec2 result = uv;', | ||
' | ' if (scrAR > imgAR) {', | ||
' float scale = scrAR / imgAR;', | |||
' result.y = (uv.y - 0.5) * scale + 0.5;', | |||
' } else {', | |||
' float scale = imgAR / scrAR;', | |||
' result.x = (uv.x - 0.5) * scale + 0.5;', | |||
' }', | |||
' result = clamp(result, 0.0, 1.0);', | ' result = clamp(result, 0.0, 1.0);', | ||
' return result;', | ' return result;', | ||
2026년 5월 10일 (일) 22:33 판
mw.loader.load('https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.5.13/cropper.min.js');
function loadLangScript(done) {
$.getScript('/index.php?title=미디어위키:Lang.js&action=raw&ctype=text/javascript')
.done(function() {
if (typeof done === 'function') done();
})
.fail(function(a, b, c) {
console.error('Lang.js load failed:', b, c);
if (typeof done === 'function') done();
});
}
$(function() {
$('body').prepend(
'<div class="WW-bg" style="position:fixed;top:0;left:0;width:100%;height:100vh;"></div>'
);
});
// 페이지 전환 사운드
var transitionSound = new Audio('/index.php?title=특수:Redirect/file/Sfx-ui-001.mp3');
(function() {
var master = parseFloat(localStorage.getItem('clbi-audio-master') || 80) / 100;
var sfx = parseFloat(localStorage.getItem('clbi-audio-sfx') || 60) / 100;
var sfxOn = localStorage.getItem('clbi-audio-sfxOn') !== 'false';
transitionSound.volume = sfxOn ? master * sfx : 0;
})();
function playStaticSound() {
var master = parseFloat(localStorage.getItem('clbi-audio-master') || 80) / 100;
var sfx = parseFloat(localStorage.getItem('clbi-audio-sfx') || 60) / 100;
var sfxOn = localStorage.getItem('clbi-audio-sfxOn') !== 'false';
if (!sfxOn) return;
transitionSound.volume = master * sfx;
transitionSound.currentTime = 0;
transitionSound.play();
}
// 현재 언어 감지
function getCurrentLang() {
var langData = document.getElementById('clbi-lang-data');
return langData ? (langData.getAttribute('data-lang') || 'ko') : 'ko';
}
function normalizePageName(value) {
return String(value || '')
.split('?')[0]
.replace(/^\/index\.php\//, '')
.replace(/_/g, ' ')
.trim();
}
function buildWikiPath(title) {
return '/index.php/' + encodeURI(String(title || '').replace(/ /g, '_'));
}
// 국가_및_조합 전용 왼쪽 사이드바 이미지
function updateLeftSidebarNationsImage() {
var imageId = 'clbi-left-nations-image';
// SPA 이동 시 이전 문서에서 삽입했던 이미지를 먼저 제거한다.
// 이 처리가 없으면 국가_및_조합에서 다른 문서로 이동했을 때 이미지가 남을 수 있다.
$('#' + imageId).remove();
// 현재 문서명이 국가 및 조합일 때만 삽입한다.
// wgPageName은 보통 국가_및_조합처럼 언더스코어를 포함하므로 normalizePageName으로 통일해서 비교한다.
var pageName = normalizePageName(mw.config.get('wgPageName'));
var targetPage = normalizePageName('국가_및_조합');
if (pageName !== targetPage) {
return;
}
var $leftSidebar = $('#clbi-left-sidebar');
if (!$leftSidebar.length) {
return;
}
// 왼쪽 사이드바 기본 자식 순서:
// 0 = 언어 섹션, 1 = 카테고리 섹션.
// 이미지는 카테고리 섹션 바로 아래에 삽입한다.
var $categoryBox = $leftSidebar.children('.clbi-left-box').eq(1);
if (!$categoryBox.length) {
return;
}
// 컨테이너나 테두리를 추가하지 않고 img 요소만 넣는다.
// 실제 파일을 바꾸려면 Redirect/file 뒤 파일명만 교체하면 된다.
$categoryBox.after(
'<img id="' + imageId + '" ' +
'class="clbi-left-page-image" ' +
'src="/index.php?title=특수:Redirect/file/img-nations-sidebar-001.png" ' +
'alt="" loading="lazy">'
);
}
// 사이드바 업데이트
function updateSidebar() {
if (!window.LANG || !window.LANG_NAMES || !window.CAT_LINKS) {
setTimeout(updateSidebar, 100);
return;
}
var langData = document.getElementById('clbi-lang-data');
var currentLang = getCurrentLang();
var t = (window.LANG && window.LANG[currentLang]) ? window.LANG[currentLang] : window.LANG.ko;
var names = (window.LANG_NAMES && window.LANG_NAMES[currentLang]) ? window.LANG_NAMES[currentLang] : window.LANG_NAMES.ko;
var cl = (window.CAT_LINKS && window.CAT_LINKS[currentLang]) ? window.CAT_LINKS[currentLang] : window.CAT_LINKS.ko;
var allLangs = langData ? {
ko: langData.getAttribute('data-ko') || '',
en: langData.getAttribute('data-en') || '',
zh: langData.getAttribute('data-zh') || '',
ja: langData.getAttribute('data-ja') || ''
} : {
ko: '',
en: '',
zh: '',
ja: ''
};
$('#clbi-lang-current').text(names[currentLang]);
var langHtml = '';
$.each(['ko', 'en', 'zh', 'ja'], function(i, lang) {
if (lang === currentLang) return;
var target = allLangs[lang];
if (target) {
langHtml += '<div class="clbi-lang-link"><a href="' + buildWikiPath(target) + '?uselang=' + lang + '">' + names[lang] + '</a></div>';
} else {
langHtml += '<div class="clbi-lang-wip">' + names[lang] + '</div>';
}
});
$('#clbi-lang-list').html(langHtml);
$('#clbi-title-language').text(t.language);
$('#clbi-title-categories').text(t.categories);
$('#clbi-title-links').text(t.links);
$('#clbi-title-search a').text(t.search);
$('#clbi-search-input').attr('placeholder', t.search + '...');
$('#clbi-title-recent a').text(t.recentChanges);
$('#clbi-title-guide').text(t.guide);
$('#clbi-guide-link').text(t.getStarted);
$('#clbi-title-playlist').text(t.playlist);
$('#clbi-cat-main a').attr('href', buildWikiPath(cl.main));
$('#clbi-cat-main .clbi-cat-label').text(t.mainMenu);
$('#clbi-cat-nations a').attr('href', buildWikiPath(cl.nations));
$('#clbi-cat-nations .clbi-cat-label').text(t.nations);
$('#clbi-cat-corporations a').attr('href', buildWikiPath(cl.corporations));
$('#clbi-cat-corporations .clbi-cat-label').text(t.corporations);
$('#clbi-cat-military a').attr('href', buildWikiPath(cl.military));
$('#clbi-cat-military .clbi-cat-label').text(t.military);
$('#clbi-cat-history a').attr('href', buildWikiPath(cl.history));
$('#clbi-cat-history .clbi-cat-label').text(t.history);
$('#clbi-cat-personnel a').attr('href', buildWikiPath(cl.personnel));
$('#clbi-cat-personnel .clbi-cat-label').text(t.personnel);
$('#clbi-btn-contribution').text(t.contribution);
$('#clbi-btn-watchlist').text(t.watchlist);
$('#clbi-btn-preferences').text(t.preferences);
$('#clbi-btn-logout').text(t.logout);
$('#clbi-btn-login').text(t.login);
var pageName = normalizePageName(mw.config.get('wgPageName'));
var specialPage = String(mw.config.get('wgCanonicalSpecialPageName') || '');
$('.clbi-cat-btn').removeClass('clbi-cat-active');
$.each(['main', 'nations', 'corporations', 'military', 'history', 'personnel'], function(i, key) {
if (cl[key] && pageName === normalizePageName(cl[key])) {
$('#clbi-cat-' + key).addClass('clbi-cat-active');
}
});
$('.clbi-user-btn').removeClass('clbi-user-btn-active');
if (
specialPage === 'Contributions' ||
specialPage === '기여' ||
pageName.indexOf('특수:기여') === 0 ||
pageName.indexOf('Special:Contributions') === 0
) {
$('#clbi-btn-contribution').addClass('clbi-user-btn-active');
}
if (specialPage === 'Watchlist') {
$('#clbi-btn-watchlist').addClass('clbi-user-btn-active');
}
if (
specialPage === '설정' ||
pageName === '특수:설정' ||
pageName === 'Special:설정'
) {
$('#clbi-btn-preferences').addClass('clbi-user-btn-active');
}
$('.toggleBtn').each(function() {
var btn = $(this);
if (!$('#' + btn.data('target')).hasClass('folding-open')) {
btn.text(t.expand);
} else {
btn.text(t.collapse);
}
});
// 왼쪽 사이드바의 문서별 전용 삽입물을 갱신한다.
// 현재는 국가_및_조합 문서에서만 카테고리 섹션 아래 이미지를 삽입한다.
updateLeftSidebarNationsImage();
}
function canShowContentTools() {
// 비로그인 사용자는 편집/역사/공유 버튼을 숨김
if (!mw.config.get('wgUserName')) {
return false;
}
// MediaWiki가 현재 문서를 편집 가능하지 않다고 판단하면 숨김
var isEditable = mw.config.get('wgIsProbablyEditable');
if (isEditable === false) {
return false;
}
var relevantEditable = mw.config.get('wgRelevantPageIsProbablyEditable');
if (relevantEditable === false) {
return false;
}
return true;
}
function moveCatlinksToBottom() {
var main = $('.liberty-content-main');
var parserOutput = $('.liberty-content-main .mw-parser-output').first();
var catlinks = $('.catlinks');
if (!main.length || !catlinks.length) return;
catlinks.each(function () {
var cat = $(this);
if (parserOutput.length) {
cat.appendTo(parserOutput);
} else {
cat.appendTo(main);
}
});
}
// 대문 스타일
function applyMainPageStyle() {
var specialPage = mw.config.get('wgCanonicalSpecialPageName');
if (specialPage === 'Preferences') return;
var pageName = normalizePageName(mw.config.get('wgPageName'));
var hideTools = (pageName === '대문' || !canShowContentTools());
// 모든 문서에서 분류 바를 본문 컨테이너 아래로 이동
moveCatlinksToBottom();
if (pageName === '대문') {
$('.liberty-content-header').css('display', 'none');
$('.mw-page-title-main').addClass('clbi-hide');
$('.catlinks').css('display', 'none');
$('.liberty-content-main').css('border-radius', '5px 5px 0 0');
if ($('#clbi-main-logo').length === 0) {
$('.liberty-content').prepend(
'<div id="clbi-main-logo" style="text-align:center;padding:10px 0;">' +
'<img src="/index.php?title=특수:Redirect/file/Img-clbi-001.png" style="width:900px;height:auto;">' +
'</div>'
);
}
if ($('#clbi-main-crt-hero').length && $('#clbi-main-crt-hero-wrap').length === 0) {
var heroWrap = $('<div id="clbi-main-crt-hero-wrap"></div>');
if ($('#clbi-main-logo').length) {
heroWrap.insertAfter('#clbi-main-logo');
} else {
heroWrap.insertBefore('.liberty-content-main');
}
$('#clbi-main-crt-hero').appendTo('#clbi-main-crt-hero-wrap');
}
} else if ($('.screen-header').length > 0) {
$('.liberty-content-header').css('display', 'none');
$('.mw-page-title-main').addClass('clbi-hide');
$('.catlinks').css('display', '');
$('.liberty-content-main').css('border-radius', '5px');
$('#clbi-main-logo').remove();
$('#clbi-main-crt-hero-wrap').remove();
if ($('#clbi-tools-box').length === 0 && canShowContentTools()) {
var $toolsBox = $('<div id="clbi-tools-box" class="clbi-left-box"></div>');
var $toolsTitle = $('<div class="clbi-left-title"><span class="clbi-icon" style="--icon:var(--ic-ui-003)"></span> 관리</div>');
var $toolsContent = $('<div class="clbi-left-content"></div>');
$toolsContent.append($('.content-tools .btn-group').clone(true));
$toolsBox.append($toolsTitle).append($toolsContent);
$('#clbi-left-sidebar').append($toolsBox);
}
$('.content-tools').css('display', 'none');
$('.liberty-content').addClass('content-tools-hidden');
} else {
$('.liberty-content-header').css('display', '');
$('.mw-page-title-main').removeClass('clbi-hide');
$('.catlinks').css('display', '');
$('.liberty-content-main').css('border-radius', '');
$('#clbi-main-logo').remove();
$('#clbi-main-crt-hero-wrap').remove();
$('#clbi-tools-box').remove();
}
if (hideTools) {
$('.content-tools').css('display', 'none');
$('.liberty-content').addClass('content-tools-hidden');
} else if (!$('.screen-header').length) {
$('.content-tools').css('display', '');
$('.liberty-content').removeClass('content-tools-hidden');
}
updateSidebar();
}
// 본문 기본 목차 제거
function removeNativeTocFromContent() {
$('.liberty-content-main #toc, .liberty-content-main .toc').remove();
}
// 왼쪽 목차: MediaWiki 문단 ID 가져오기
function getHeadingId(heading) {
if (heading.id) {
return heading.id;
}
var headline = heading.querySelector('.mw-headline[id]');
if (headline && headline.id) {
return headline.id;
}
return '';
}
// 왼쪽 목차: MediaWiki 문단 제목 텍스트 가져오기
function getHeadingText(heading) {
var headline = heading.querySelector('.mw-headline');
var source = headline || heading;
var clone = source.cloneNode(true);
$(clone).find('.mw-editsection, .mw-editsection-bracket, .mw-editsection-divider').remove();
return (clone.textContent || '')
.replace(/\s+/g, ' ')
.trim();
}
// 왼쪽 목차: 긴 제목에 자동 스크롤 적용
function initTocTitleScroll(root) {
var $items = root
? $(root).find('.toc-scroll-text')
: $('#side-toc-box .toc-scroll-text');
$items.each(function () {
var $text = $(this);
var $wrap = $text.closest('.toc-scroll-wrap');
if (!$wrap.length) return;
var wrapW = Math.floor($wrap.width());
var textW = Math.ceil(this.scrollWidth);
// 왼쪽 목차: 레이아웃 계산이 끝나지 않았으면 이번 실행에서는 건드리지 않는다.
if (!wrapW || !textW) return;
if (textW <= wrapW + 12) {
// 왼쪽 목차: 칸을 넘지 않는 제목은 전체 텍스트를 그대로 보여준다.
$wrap.removeClass('is-scrolling');
if ($text.data('toc-scroll-enabled')) {
$text.css({
animation: '',
'animation-delay': '',
'--scroll-dist': ''
});
$text.removeData('toc-scroll-enabled');
$text.removeData('toc-scroll-key');
}
return;
}
var scrollDist = '-' + (textW - wrapW + 10) + 'px';
var duration = Math.max(7, textW / 38) * 1.25;
var scrollKey = scrollDist + '|' + duration;
// 왼쪽 목차: 긴 제목에는 오른쪽 페이드와 스크롤을 적용한다.
$wrap.addClass('is-scrolling');
// 왼쪽 목차: 같은 값으로 이미 적용된 애니메이션은 다시 초기화하지 않는다.
if ($text.data('toc-scroll-key') === scrollKey) {
return;
}
$text.data('toc-scroll-enabled', true);
$text.data('toc-scroll-key', scrollKey);
$text.css({
// 왼쪽 목차: 페이지 진입 직후에는 잠시 읽을 시간을 준 뒤 흐르게 한다.
animation: 'toc-scroll-blink-reset ' + duration + 's linear infinite',
'animation-delay': '1s',
'--scroll-dist': scrollDist
});
});
}
// 목차를 왼쪽 사이드바에 새로 생성
function moveTocToLeftSidebar() {
// 왼쪽 목차: MediaWiki가 만든 원래 목차는 본문에서 제거한다.
removeNativeTocFromContent();
var leftSidebar = document.getElementById('clbi-left-sidebar');
if (!leftSidebar) return;
var content =
document.querySelector('.liberty-content-main .mw-parser-output') ||
document.querySelector('.liberty-content-main');
if (!content) return;
var headings = Array.prototype.slice.call(
content.querySelectorAll('h2, h3')
).filter(function (heading) {
if (heading.closest('#toc, .toc, #side-toc-box')) return false;
var id = getHeadingId(heading);
var text = getHeadingText(heading);
if (!id || !text) return false;
return true;
});
var tocKey = headings.map(function (heading) {
return getHeadingId(heading) + '|' + getHeadingText(heading);
}).join('||');
var existingBox = document.getElementById('side-toc-box');
// 왼쪽 목차: 같은 문서에서 같은 목차를 이미 만들었다면 다시 지우고 만들지 않는다.
if (existingBox && existingBox.getAttribute('data-toc-key') === tocKey) {
initTocTitleScroll(existingBox);
return;
}
if (existingBox) {
existingBox.remove();
}
if (!headings.length) return;
var tocBox = document.createElement('div');
tocBox.className = 'clbi-left-box';
tocBox.id = 'side-toc-box';
tocBox.setAttribute('data-toc-key', tocKey);
var title = document.createElement('div');
title.className = 'clbi-left-title';
// 왼쪽 목차: 박스 제목은 Lang.js의 현재 UI 언어를 따른다.
var currentLang = getCurrentLang();
var t = (window.LANG && window.LANG[currentLang]) ? window.LANG[currentLang] : window.LANG.ko;
var tocTitleText = (t && t.toc) ? t.toc : '목차';
title.innerHTML =
'<span class="clbi-icon" style="--icon:var(--ic-ui-002)"></span> ' + tocTitleText;
var body = document.createElement('div');
body.className = 'clbi-left-content toc-sidebar-content';
var list = document.createElement('ul');
list.className = 'generated-toc';
headings.forEach(function (heading) {
var id = getHeadingId(heading);
var text = getHeadingText(heading);
var level = heading.tagName.toLowerCase() === 'h3' ? 3 : 2;
var item = document.createElement('li');
item.className = 'toc-level-' + level;
var link = document.createElement('a');
link.setAttribute('href', '#' + id);
// 왼쪽 목차: 긴 제목 스크롤을 위해 텍스트를 별도 span으로 감싼다.
var textWrap = document.createElement('span');
textWrap.className = 'toc-scroll-wrap';
var textSpan = document.createElement('span');
textSpan.className = 'toc-scroll-text';
textSpan.textContent = text;
textWrap.appendChild(textSpan);
link.appendChild(textWrap);
item.appendChild(link);
list.appendChild(item);
});
body.appendChild(list);
tocBox.appendChild(title);
tocBox.appendChild(body);
leftSidebar.appendChild(tocBox);
// 왼쪽 목차: DOM 배치가 끝난 뒤 긴 제목 스크롤 여부를 계산한다.
requestAnimationFrame(function () {
initTocTitleScroll(tocBox);
setTimeout(function () {
initTocTitleScroll(tocBox);
}, 120);
});
}
// 초기화 함수
function initSidebars() {
var header = $('.liberty-content-header');
var content = $('.liberty-content');
if (header.length && content.length) {
header.prependTo(content);
}
if ($('#clbi-right-sidebar').length === 0) {
var username = mw.config.get('wgUserName');
var isLoggedIn = username !== null;
var avatarSrc = isLoggedIn
? '/index.php?title=특수:Redirect/file/Pfp-' + username + '.png'
: '/index.php?title=특수:Redirect/file/Pfp-default.png';
var userBox;
if (isLoggedIn) {
userBox =
'<div class="clbi-right-box">' +
'<div style="display:flex;flex-direction:column;align-items:center;padding:14px 14px 10px;background:linear-gradient(to bottom, #171114 0%, #0a0909 100%);">' +
'<img src="' + avatarSrc + '" onerror="this.src=\'/index.php?title=특수:Redirect/file/Pfp-default.png\'" style="width:64px;height:64px;border-radius:5px;object-fit:cover;border:2px solid #854369;margin-bottom:8px;">' +
'<div style="position:relative;width:100%;margin-top:2px;height:18px;line-height:18px;text-align:center;">' +
'<button type="button" id="clbi-playlist-toggle" aria-label="플레이리스트" style="position:absolute;top:0;left:10px;background:none;border:none;padding:0;width:18px;height:18px;color:#E2E2E2;cursor:pointer;display:flex;align-items:center;justify-content:center;">' +
'<span class="clbi-icon" style="--icon:var(--ic-ui-009);width:16px;height:16px;"></span>' +
'</button>' +
'<a href="/index.php/사용자:' + username + '" style="font-size:13px;font-weight:700;color:#E2E2E2 !important;text-decoration:none !important;line-height:18px;display:inline-block;vertical-align:middle;max-width:calc(100% - 58px);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">' + username + '</a>' +
'<button type="button" id="clbi-notification-toggle" aria-label="알림" style="position:absolute;top:0;right:10px;background:none;border:none;padding:0;width:18px;height:18px;color:#E2E2E2;cursor:pointer;display:flex;align-items:center;justify-content:center;">' +
'<span class="clbi-icon" style="--icon:var(--ic-ui-009);width:16px;height:16px;"></span>' +
'<span id="clbi-notification-badge" style="display:none;position:absolute;top:-6px;right:-8px;min-width:14px;height:14px;padding:0 3px;border-radius:999px;background:#854369;color:#fff;font-size:9px;line-height:14px;font-weight:700;text-align:center;"></span>' +
'</button>' +
'</div>' +
'</div>' +
'<div class="clbi-right-content" style="border-top:1px solid #2a2a2a;padding:8px;">' +
'<a href="/index.php/특수:기여/' + username + '" class="clbi-user-btn" id="clbi-btn-contribution">기여</a>' +
'<a href="/index.php/특수:주시문서목록" class="clbi-user-btn" id="clbi-btn-watchlist">주시문서 목록</a>' +
'<a href="/index.php/특수:설정" class="clbi-user-btn" id="clbi-btn-preferences">설정</a>' +
'<a href="/index.php?title=특수:로그아웃&returnto=대문" class="clbi-user-btn clbi-user-btn-logout" id="clbi-btn-logout">로그아웃</a>' +
'</div>' +
'</div>';
} else {
userBox =
'<div class="clbi-right-box">' +
'<div style="display:flex;flex-direction:column;align-items:center;padding:14px 14px 10px;background:linear-gradient(to bottom, #171114 0%, #0a0909 100%);">' +
'<img src="/index.php?title=특수:Redirect/file/Pfp-default.png" style="width:64px;height:64px;border-radius:5px;object-fit:cover;border:2px solid #854369;margin-bottom:8px;">' +
'<span style="font-size:13px;font-weight:700;color:#E2E2E2;">Guest</span>' +
'</div>' +
'<div class="clbi-right-content" style="border-top:1px solid #2a2a2a;padding:8px;">' +
'<a href="/index.php?title=특수:로그인&returnto=대문" class="clbi-user-btn" id="clbi-btn-login">로그인</a>' +
'</div>' +
'</div>';
}
var recentBox =
'<div class="clbi-right-box">' +
'<div class="clbi-right-title" id="clbi-title-recent"><span class="clbi-icon" style="--icon:var(--ic-ui-006)"></span> <a href="/index.php/특수:최근바뀜" style="color:#E2E2E2 !important;text-decoration:none !important;">최근 변경</a></div>' +
'<div class="clbi-right-content" id="clbi-recent-list">불러오는 중...</div>' +
'</div>';
var linkBox =
'<div class="clbi-right-box">' +
'<div class="clbi-right-title" id="clbi-title-links"><span class="clbi-icon" style="--icon:var(--ic-ui-003)"></span> 링크</div>' +
'<div class="clbi-right-content clbi-link-box">' +
'<ul>' +
'<li><a href="https://discord.gg/ctaeJ9d3Q5" target="_blank">Discord</a></li>' +
'<li><a href="https://www.youtube.com/@nxdsxn" target="_blank">YouTube</a></li>' +
'<li><a href="https://x.com/nxd_sxn" target="_blank">X</a></li>' +
'</ul>' +
'</div>' +
'</div>';
var sidebar =
userBox +
'<div class="clbi-right-box">' +
'<div class="clbi-right-title" id="clbi-title-search"><span class="clbi-icon" style="--icon:var(--ic-ui-005)"></span> <a href="/index.php/특수:검색" style="color:#E2E2E2 !important;text-decoration:none !important;">검색</a></div>' +
'<div class="clbi-right-content">' +
'<input id="clbi-search-input" type="text" placeholder="검색...">' +
'<button id="clbi-search-btn">GO</button>' +
'</div>' +
'</div>' +
recentBox +
'<div class="clbi-right-box">' +
'<div class="clbi-right-title" id="clbi-title-guide"><span class="clbi-icon" style="--icon:var(--ic-ui-007)"></span> 가이드</div>' +
'<div class="clbi-right-content">' +
'<a href="/index.php/CLBI_Wiki/KR_시작하기_(CLBI)" id="clbi-guide-link">시작하기</a>' +
'</div>' +
'</div>' +
linkBox;
$('.content-wrapper').append('<div id="clbi-right-sidebar">' + sidebar + '</div>');
$('#clbi-search-btn').click(function() {
var query = $('#clbi-search-input').val();
if (query) {
window.location.href = '/index.php?search=' + encodeURIComponent(query);
}
});
$('#clbi-search-input').keypress(function(e) {
if (e.which === 13) $('#clbi-search-btn').click();
});
$.getJSON(
'/api.php?action=query&list=recentchanges&rclimit=5&rcprop=title|timestamp&format=json&rcnamespace=0&rctype=edit|new',
function(data) {
var items = data.query.recentchanges;
var html = '';
$.each(items, function(i, item) {
var label = timeAgo(item.timestamp);
html +=
'<div class="clbi-recent-item">' +
'<div class="clbi-recent-title-wrap">' +
'<a href="/index.php/' + encodeURIComponent(item.title) + '" class="clbi-recent-title">' + item.title + '</a>' +
'</div>' +
'<span class="clbi-recent-time">' + label + '</span>' +
'</div>';
});
$('#clbi-recent-list').html(html);
$('#clbi-recent-list .clbi-recent-item').each(function() {
var wrap = $(this).find('.clbi-recent-title-wrap');
var title = $(this).find('.clbi-recent-title');
var wrapW = wrap.width();
var titleW = title[0].scrollWidth;
if (titleW > wrapW + 20) {
var duration = titleW / 40;
title.css({
animation: 'clbi-scroll ' + duration + 's linear infinite',
'--scroll-dist': '-' + (titleW - wrapW + 8) + 'px'
});
}
});
}
).fail(function() {
$('#clbi-recent-list').html('불러오기 실패');
});
}
if ($('#clbi-left-sidebar').length === 0) {
var leftSidebar =
'<div id="clbi-left-sidebar">' +
'<div class="clbi-left-box">' +
'<div class="clbi-left-title"><span class="clbi-icon" style="--icon:var(--ic-ui-001)"></span> <span id="clbi-title-language">언어</span></div>' +
'<div class="clbi-left-content clbi-lang-box" id="clbi-lang-box">' +
'<div class="clbi-lang-current" id="clbi-lang-current">한국어</div>' +
'<div id="clbi-lang-list" style="display:none;"></div>' +
'</div>' +
'</div>' +
'<div class="clbi-left-box">' +
'<div class="clbi-left-title"><span class="clbi-icon" style="--icon:var(--ic-ui-002)"></span> <span id="clbi-title-categories">카테고리</span></div>' +
'<div class="clbi-left-content clbi-cat-box">' +
'<div class="clbi-cat-btn" id="clbi-cat-main"><a href="/index.php/대문"><div class="clbi-cat-text"><span class="clbi-cat-label">메인 메뉴</span><span class="clbi-cat-sub">MAIN MENU</span></div></a><div class="clbi-cat-arrow">▶</div></div>' +
'<div class="clbi-cat-btn" id="clbi-cat-nations"><a href="/index.php/국가_및_조합"><div class="clbi-cat-text"><span class="clbi-cat-label">국가 및 조합</span><span class="clbi-cat-sub">NATIONS & FACTIONS</span></div></a><div class="clbi-cat-arrow">▶</div></div>' +
'<div class="clbi-cat-btn" id="clbi-cat-corporations"><a href="/index.php/기업_및_공동체"><div class="clbi-cat-text"><span class="clbi-cat-label">기업 및 공동체</span><span class="clbi-cat-sub">CORPORATIONS & COMMUNITIES</span></div></a><div class="clbi-cat-arrow">▶</div></div>' +
'<div class="clbi-cat-btn" id="clbi-cat-military"><a href="/index.php/군_정치집단"><div class="clbi-cat-text"><span class="clbi-cat-label">군, 정치집단</span><span class="clbi-cat-sub">MILITARY & POLITICS</span></div></a><div class="clbi-cat-arrow">▶</div></div>' +
'<div class="clbi-cat-btn" id="clbi-cat-history"><a href="/index.php/역사적_사건"><div class="clbi-cat-text"><span class="clbi-cat-label">역사적 사건</span><span class="clbi-cat-sub">HISTORICAL EVENTS</span></div></a><div class="clbi-cat-arrow">▶</div></div>' +
'<div class="clbi-cat-btn" id="clbi-cat-personnel"><a href="/index.php/인물"><div class="clbi-cat-text"><span class="clbi-cat-label">인물</span><span class="clbi-cat-sub">KEY PERSONNEL</span></div></a><div class="clbi-cat-arrow">▶</div></div>' +
'</div>' +
'</div>' +
'</div>';
$('.content-wrapper').prepend(leftSidebar);
// 왼쪽 사이드바가 처음 생성된 직후에도 한 번 실행한다.
// 이후 언어 갱신과 SPA 이동 시에는 updateSidebar()에서 다시 실행된다.
updateLeftSidebarNationsImage();
$(document).on('click', '#clbi-lang-current', function() {
if ($('#clbi-lang-list').is(':hidden')) {
$('#clbi-lang-current').css('margin-bottom', '8px');
$('#clbi-lang-list').slideDown(200);
} else {
$('#clbi-lang-list').slideUp(200, function() {
$('#clbi-lang-current').css('margin-bottom', '0');
});
}
});
}
applyMainPageStyle();
$('#side-toc-box').remove();
mw.loader.using(['mediawiki.api']).then(function() {
setTimeout(function() {
initNotifications();
initPlaylistPopup();
initProfile();
moveTocToLeftSidebar();
}, 300);
setTimeout(moveTocToLeftSidebar, 800);
setTimeout(moveTocToLeftSidebar, 1500);
});
}
$(function() {
loadLangScript(function() {
setTimeout(function() {
initSidebars();
}, 100);
});
});
// SPA 네비게이션
function shouldSkip(url) {
return url.match(/action=edit|action=submit|action=history|action=delete|action=protect|action=purge|특수:로그인|특수:로그아웃|Special:UserLogin|Special:UserLogout|특수:사용자정보|특수:비밀번호바꾸기|uselang=/);
}
$(function() {
if (window._spaInitialized) return;
window._spaInitialized = true;
function isInternal(url) {
var a = document.createElement('a');
a.href = url;
return a.hostname === window.location.hostname;
}
function loadPage(url) {
fetch(url)
.then(function(res) {
return res.text();
})
.then(function(html) {
var parser = new DOMParser();
var doc = parser.parseFromString(html, 'text/html');
var scripts = doc.querySelectorAll('script');
for (var i = 0; i < scripts.length; i++) {
var src = scripts[i].textContent;
if (src.indexOf('wgNamespaceNumber') !== -1) {
var match = src.match(/"wgNamespaceNumber":(-?\d+)/);
if (match) mw.config.set('wgNamespaceNumber', parseInt(match[1], 10));
var matchTitle = src.match(/"wgTitle":"([^"]+)"/);
if (matchTitle) mw.config.set('wgTitle', matchTitle[1]);
var matchPage = src.match(/"wgPageName":"([^"]+)"/);
if (matchPage) mw.config.set('wgPageName', matchPage[1]);
var matchSpecial = src.match(/"wgCanonicalSpecialPageName":"([^"]+)"/);
if (matchSpecial) {
mw.config.set('wgCanonicalSpecialPageName', matchSpecial[1]);
} else {
mw.config.set('wgCanonicalSpecialPageName', false);
}
break;
}
}
var newContent = doc.querySelector('.liberty-content-main');
var newTitle = doc.querySelector('.mw-page-title-main');
var newHead = doc.querySelector('title');
var newHeader = doc.querySelector('.liberty-content-header');
if (newContent) {
$('#side-toc-box').remove();
$('.liberty-content-main').html(newContent.innerHTML);
$('.profile-card').remove();
$('body').removeClass('page-loading');
}
if (newTitle) {
$('.mw-page-title-main').html(newTitle.innerHTML);
}
if (newHead) {
document.title = newHead.textContent;
}
if (newHeader) {
$('.liberty-content-header').html(newHeader.innerHTML);
}
window.scrollTo(0, 0);
$('#clbi-lang-list').hide();
$('#clbi-lang-current').css('margin-bottom', '0');
mw.hook('wikipage.content').fire($('.liberty-content-main'));
applyMainPageStyle();
$('#side-toc-box').remove();
setTimeout(moveTocToLeftSidebar, 100);
setTimeout(moveTocToLeftSidebar, 500);
setTimeout(moveTocToLeftSidebar, 1200);
mw.loader.using(['mediawiki.api']).then(function() {
initProfile();
moveTocToLeftSidebar();
});
});
}
// 목차 링크는 전용 처리
$(document).on('click', '#side-toc-box a, #toc a, .toc a', function(e) {
var href = $(this).attr('href');
if (!href || href.charAt(0) !== '#') return;
var rawId = href.slice(1);
if (!rawId) return;
var decodedId = rawId;
try {
decodedId = decodeURIComponent(rawId);
} catch (err) {
decodedId = rawId;
}
var target = document.getElementById(decodedId);
if (!target && window.CSS && CSS.escape) {
target = document.querySelector('#' + CSS.escape(decodedId));
}
if (!target) return;
e.preventDefault();
e.stopPropagation();
var scrollTarget = target.closest('h2, h3') || target;
scrollTarget.scrollIntoView({
behavior: 'auto',
block: 'start'
});
history.replaceState(null, '', '#' + rawId);
});
$(document).on('click', 'a', function(e) {
var href = $(this).attr('href');
if (!href) return;
// 목차 링크는 별도 핸들러에서 처리
if ($(this).closest('#side-toc-box, #toc, .toc').length) return;
// 단순 해시 링크는 SPA 가로채기 제외
if (href.startsWith('#')) return;
var link = document.createElement('a');
link.href = href;
var samePath = decodeURIComponent(link.pathname) === decodeURIComponent(window.location.pathname);
var sameSearch = (link.search || '') === (window.location.search || '');
if (link.hash && samePath && sameSearch) return;
var currentBase = window.location.href.split('#')[0];
var targetBase = link.href.split('#')[0];
if (link.hash && currentBase === targetBase) return;
if (!isInternal(href)) return;
if (shouldSkip(href)) return;
e.preventDefault();
playStaticSound();
$('body').addClass('page-loading');
history.pushState(null, '', href);
loadPage(href);
});
window.addEventListener('popstate', function() {
loadPage(window.location.href);
});
});
// 시간 계산 함수
function timeAgo(timestamp) {
var now = new Date();
var date = new Date(timestamp);
var diff = Math.floor((now - date) / 1000);
if (diff < 60) return diff + '초 전';
if (diff < 3600) return Math.floor(diff / 60) + '분 전';
if (diff < 86400) return Math.floor(diff / 3600) + '시간 전';
return Math.floor(diff / 86400) + '일 전';
}
// 펼접 토글
// 펼접 토글
function getFoldTexts() {
var lang = getCurrentLang();
return (window.LANG && window.LANG[lang])
? window.LANG[lang]
: (window.LANG ? window.LANG.ko : { expand: '펼치기', collapse: '접기' });
}
function refreshOpenAncestors($start) {
$start.parents('[id^="collapsible"]').each(function () {
var $parent = $(this);
if (!$parent.hasClass('folding-open')) return;
// 이미 fully open 상태면 굳이 다시 잠그지 않음
if ($parent.data('fold-state') === 'open') {
return;
}
$parent.css('max-height', this.scrollHeight + 'px');
});
}
function bindInnerResizeUpdates($target) {
// 이미지 늦게 로드될 때 높이 갱신
$target.find('img').off('.foldimg').on('load.foldimg', function () {
if ($target.hasClass('folding-open')) {
if ($target.data('fold-state') !== 'open') {
$target.css('max-height', $target[0].scrollHeight + 'px');
}
refreshOpenAncestors($target);
}
});
}
function openFold($target, $btn) {
var t = getFoldTexts();
$target.data('fold-state', 'opening');
$target.addClass('folding-open');
// 열린 뒤 자연 확장 가능하게 만들기 위해 먼저 px로 열기
$target.css('max-height', '0px');
$target[0].offsetHeight;
$target.css('max-height', $target[0].scrollHeight + 'px');
$btn.text(t.collapse);
bindInnerResizeUpdates($target);
// 바깥 펼접 즉시 갱신
refreshOpenAncestors($target);
// 전환 끝나면 none으로 풀어서 중첩 펼접/동적 내용 증가를 자연스럽게 허용
$target.off('transitionend.foldopen').on('transitionend.foldopen', function (e) {
if (e.target !== this) return;
if (!$target.hasClass('folding-open')) return;
$target.css('max-height', 'none');
$target.data('fold-state', 'open');
refreshOpenAncestors($target);
});
// 늦게 렌더되는 콘텐츠 대응
requestAnimationFrame(function () {
if ($target.hasClass('folding-open') && $target.data('fold-state') !== 'open') {
$target.css('max-height', $target[0].scrollHeight + 'px');
refreshOpenAncestors($target);
}
});
setTimeout(function () {
if ($target.hasClass('folding-open') && $target.data('fold-state') !== 'open') {
$target.css('max-height', $target[0].scrollHeight + 'px');
refreshOpenAncestors($target);
}
}, 80);
setTimeout(function () {
if ($target.hasClass('folding-open') && $target.data('fold-state') !== 'open') {
$target.css('max-height', $target[0].scrollHeight + 'px');
refreshOpenAncestors($target);
}
}, 220);
}
function closeFold($target, $btn) {
var t = getFoldTexts();
// none 상태에서 닫으면 transition이 안 되므로 실제 높이로 고정
if ($target.css('max-height') === 'none' || $target.data('fold-state') === 'open') {
$target.css('max-height', $target[0].scrollHeight + 'px');
} else {
$target.css('max-height', $target[0].scrollHeight + 'px');
}
$target.data('fold-state', 'closing');
$target[0].offsetHeight;
$target.css('max-height', '0px');
$target.removeClass('folding-open');
$btn.text(t.expand);
refreshOpenAncestors($target);
setTimeout(function () {
refreshOpenAncestors($target);
$target.data('fold-state', 'closed');
}, 250);
}
$(function () {
$(document)
.off('click.clbiToggle')
.on('click.clbiToggle', '.toggleBtn', function () {
var $btn = $(this);
var targetId = $btn.data('target');
var $target = $('#' + targetId);
if (!$target.length) return;
var scrollY = window.scrollY;
if ($target.hasClass('folding-open')) {
closeFold($target, $btn);
} else {
openFold($target, $btn);
}
window.scrollTo(0, scrollY);
});
});
// ========== 프로필 시스템 ==========
function initProfile() {
$('.profile-card').remove();
var ns = mw.config.get('wgNamespaceNumber');
var title = mw.config.get('wgTitle');
if (ns === 2) {
var profileUser = title.split('/')[0];
renderProfile(profileUser);
}
if (mw.config.get('wgCanonicalSpecialPageName') === '사용자정보') {
initUserProfilePage();
}
}
function renderProfile(username) {
var api = new mw.Api();
api.get({
action: 'query',
list: 'users',
ususers: username,
usprop: 'editcount'
}).then(function(data) {
var user = data.query.users[0];
var contentEl = document.getElementById('mw-content-text');
if (!contentEl) return;
var pageContent = contentEl.querySelector('.mw-parser-output') || contentEl;
injectProfileCard(username, user, pageContent);
});
}
function injectProfileCard(username, userData, container) {
var isOwnPage = mw.config.get('wgUserName') === username;
var editCount = (userData && userData.editcount) ? userData.editcount : 0;
var editBtn = isOwnPage
? '<a href="/index.php/특수:사용자정보" class="profile-edit-btn">프로필 수정</a>'
: '';
var card = document.createElement('div');
card.className = 'profile-card';
card.innerHTML =
'<div class="profile-header">' +
'<div class="profile-avatar">' +
'<img src="/index.php?title=특수:Redirect/file/Pfp-' + username + '.png&width=120"' +
' onerror="this.src=\'/index.php?title=특수:Redirect/file/Pfp-default.png&width=120\'"' +
' alt="' + username + '">' +
'</div>' +
'<div class="profile-info">' +
'<h2 class="profile-username">' + username + '</h2>' +
'<div class="profile-name" data-field="name"></div>' +
'<div class="profile-role" data-field="role"></div>' +
'<div class="profile-discord" data-field="discord"></div>' +
'<div class="profile-bio" data-field="bio"></div>' +
'<div class="profile-badges" data-field="badges"></div>' +
'</div>' +
editBtn +
'</div>' +
'<div class="profile-stats">' +
'<div class="profile-stat">' +
'<span class="clbi-stat-value">' + editCount + '</span>' +
'<span class="clbi-stat-label">수정 횟수</span>' +
'</div>' +
'</div>';
$('.profile-card').remove();
container.insertBefore(card, container.firstChild);
loadProfileFields(username, card);
}
function loadProfileFields(username, card) {
var api = new mw.Api();
api.get({
action: 'userprofile',
user: username
}).then(function(data) {
var profile = data.userprofile;
updateProfileFields(card, {
name: profile.name || '',
discord: profile.discord || '',
role: profile.role || '',
bio: profile.bio || '',
badges: profile.badges || ''
});
}).fail(function() {
updateProfileFields(card, {
name: '',
discord: '',
role: '',
bio: '',
badges: ''
});
});
}
function updateProfileFields(card, data) {
var nameEl = card.querySelector('[data-field="name"]');
var roleEl = card.querySelector('[data-field="role"]');
var discordEl = card.querySelector('[data-field="discord"]');
var bioEl = card.querySelector('[data-field="bio"]');
var badgesEl = card.querySelector('[data-field="badges"]');
if (nameEl) nameEl.textContent = data.name || '';
if (roleEl) roleEl.textContent = data.role || '';
if (discordEl) discordEl.textContent = data.discord ? ('디스코드: ' + data.discord) : '';
if (bioEl) bioEl.textContent = data.bio || '';
if (badgesEl) {
if (data.badges) {
var badges = data.badges.split(',');
var html = '';
for (var i = 0; i < badges.length; i++) {
html += '<span class="clbi-badge">' + badges[i].trim() + '</span>';
}
badgesEl.innerHTML = html;
} else {
badgesEl.innerHTML = '';
}
}
}
// ========== 프로필 시스템 끝 ==========
// ========== 알림 시스템 ==========
function ensureNotificationPopup() {
if (document.getElementById('clbi-notification-popup')) return;
var popup = document.createElement('div');
popup.id = 'clbi-notification-popup';
popup.style.cssText =
'display:none;position:fixed;z-index:99999;width:320px;max-height:420px;' +
'background:#0a0909;border:2px solid #854369;border-radius:10px;' +
'box-shadow:0 0 0 1px #1a1a1a, 0 8px 24px rgba(0,0,0,0.55);overflow:hidden;';
popup.innerHTML =
'<div style="padding:10px 12px;border-bottom:2px solid #854369;background:linear-gradient(to bottom, #171114 0%, #0a0909 100%);color:#E2E2E2;font-size:13px;font-weight:700;display:flex;align-items:center;justify-content:space-between;gap:8px;">' +
'<span>알림</span>' +
'<button type="button" id="clbi-notification-readall" style="background:#171717;border:1px solid #854369;border-radius:6px;color:#E2E2E2;font-size:11px;font-weight:700;padding:4px 8px;cursor:pointer;">전체 읽음</button>' +
'</div>' +
'<div id="clbi-notification-list" style="max-height:320px;overflow-y:auto;padding:8px 0;color:#E2E2E2;font-size:12px;">불러오는 중...</div>' +
'<div style="padding:8px;border-top:1px solid #2a2a2a;background:#111;">' +
'<a href="/index.php?title=Special:Notifications" id="clbi-notification-more" style="display:block;width:100%;text-align:center;padding:8px 10px;border-radius:6px;background:#171717;border:1px solid #854369;color:#E2E2E2 !important;text-decoration:none !important;font-size:12px;font-weight:700;">더보기</a>' +
'</div>';
document.body.appendChild(popup);
}
function positionNotificationPopup() {
var btn = document.getElementById('clbi-notification-toggle');
var popup = document.getElementById('clbi-notification-popup');
if (!btn || !popup) return;
var rect = btn.getBoundingClientRect();
var top = rect.bottom + 2;
var left = rect.right - popup.offsetWidth;
if (left < 8) left = 8;
if (left + popup.offsetWidth > window.innerWidth - 8) {
left = window.innerWidth - popup.offsetWidth - 8;
}
if (top + popup.offsetHeight > window.innerHeight - 8) {
top = Math.max(8, window.innerHeight - popup.offsetHeight - 8);
}
popup.style.top = top + 'px';
popup.style.left = left + 'px';
}
function parseNotificationItemsFromHtml(html) {
var parser = new DOMParser();
var doc = parser.parseFromString(html, 'text/html');
var selectors = [
'.mw-echo-ui-notificationItemWidget',
'.mw-echo-ui-notificationsInboxWidgetRow',
'.echo-ui-notificationItemWidget',
'li[data-notification-id]',
'.mw-echo-notifications-list li'
];
var items = [];
for (var i = 0; i < selectors.length; i++) {
items = Array.prototype.slice.call(doc.querySelectorAll(selectors[i]));
if (items.length) break;
}
return items.slice(0, 5).map(function(item) {
var link = item.querySelector('a[href]');
var href = link ? link.getAttribute('href') : '/index.php?title=Special:Notifications';
var text = (item.textContent || '').replace(/\s+/g, ' ').trim();
var notificationId =
item.getAttribute('data-notification-id') ||
item.getAttribute('data-id') ||
item.getAttribute('data-notification') ||
'';
if (!notificationId) {
var anyWithId = item.querySelector('[data-notification-id], [data-id], [data-notification]');
if (anyWithId) {
notificationId =
anyWithId.getAttribute('data-notification-id') ||
anyWithId.getAttribute('data-id') ||
anyWithId.getAttribute('data-notification') ||
'';
}
}
if (href && href.indexOf('http') !== 0) {
href = href.charAt(0) === '/'
? href
: '/index.php' + (href.charAt(0) === '?' ? href : '/' + href);
}
return {
id: notificationId,
href: href,
text: text || '알림'
};
});
}
function renderNotificationPopup(items) {
var list = document.getElementById('clbi-notification-list');
var badge = document.getElementById('clbi-notification-badge');
if (!list) return;
if (!items || !items.length) {
list.innerHTML = '<div style="padding:14px 12px;color:#999;">표시할 알림이 없습니다.</div>';
if (badge) badge.style.display = 'none';
return;
}
var html = '';
for (var i = 0; i < items.length; i++) {
html +=
'<a href="' + items[i].href + '" class="clbi-notification-item" data-notification-id="' + (items[i].id || '') + '" style="display:block;padding:10px 12px;color:#E2E2E2 !important;text-decoration:none !important;border-bottom:1px solid #1f1f1f;line-height:1.5;">' +
items[i].text +
'</a>';
}
list.innerHTML = html;
if (badge) {
badge.textContent = items.length;
badge.style.display = 'block';
}
}
function loadNotificationsIntoPopup() {
var list = document.getElementById('clbi-notification-list');
if (list) {
list.innerHTML = '<div style="padding:14px 12px;color:#999;">불러오는 중...</div>';
}
fetch('/index.php?title=Special:Notifications', { credentials: 'same-origin' })
.then(function(res) {
return res.text();
})
.then(function(html) {
var items = parseNotificationItemsFromHtml(html);
renderNotificationPopup(items);
})
.catch(function(err) {
console.error(err);
if (list) {
list.innerHTML = '<div style="padding:14px 12px;color:#999;">알림을 불러오지 못했습니다.</div>';
}
});
}
function markAllNotificationsRead() {
return new mw.Api().postWithToken('csrf', {
action: 'echomarkread',
list: 'all'
});
}
function markNotificationReadById(notificationId) {
if (!notificationId) {
return $.Deferred().resolve().promise();
}
return new mw.Api().postWithToken('csrf', {
action: 'echomarkread',
list: notificationId
});
}
function initNotifications() {
var btn = document.getElementById('clbi-notification-toggle');
if (!btn) return;
ensureNotificationPopup();
loadNotificationsIntoPopup();
$(document)
.off('click.clbiNotificationToggle')
.on('click.clbiNotificationToggle', '#clbi-notification-toggle', function(e) {
e.preventDefault();
e.stopPropagation();
var popup = document.getElementById('clbi-notification-popup');
var playlistPopup = document.getElementById('clbi-playlist-popup');
if (!popup) return;
if (playlistPopup) {
playlistPopup.style.display = 'none';
}
if (popup.style.display === 'none' || popup.style.display === '') {
popup.style.display = 'block';
positionNotificationPopup();
loadNotificationsIntoPopup();
} else {
popup.style.display = 'none';
}
});
$(document)
.off('click.clbiNotificationOutside')
.on('click.clbiNotificationOutside', function(e) {
var popup = document.getElementById('clbi-notification-popup');
var toggle = document.getElementById('clbi-notification-toggle');
if (!popup || !toggle) return;
if (!popup.contains(e.target) && !toggle.contains(e.target)) {
popup.style.display = 'none';
}
});
$(document)
.off('click.clbiNotificationReadAll')
.on('click.clbiNotificationReadAll', '#clbi-notification-readall', function(e) {
e.preventDefault();
e.stopPropagation();
var button = this;
button.disabled = true;
button.textContent = '처리 중...';
markAllNotificationsRead()
.then(function() {
loadNotificationsIntoPopup();
})
.always(function() {
button.disabled = false;
button.textContent = '전체 읽음';
});
});
$(document)
.off('click.clbiNotificationItem')
.on('click.clbiNotificationItem', '.clbi-notification-item', function(e) {
e.preventDefault();
e.stopPropagation();
var href = this.getAttribute('href');
var notificationId = this.getAttribute('data-notification-id') || '';
markNotificationReadById(notificationId).always(function() {
loadNotificationsIntoPopup();
if (href) {
window.location.href = href;
}
});
});
$(window)
.off('resize.clbiNotification')
.on('resize.clbiNotification', function() {
var popup = document.getElementById('clbi-notification-popup');
if (popup && popup.style.display === 'block') {
positionNotificationPopup();
}
});
}
// ========== 알림 시스템 끝 ==========
// ========== 플레이리스트 팝업 시스템 ==========
function ensurePlaylistPopup() {
if (document.getElementById('clbi-playlist-popup')) return;
var popup = document.createElement('div');
popup.id = 'clbi-playlist-popup';
popup.style.cssText =
'display:none;position:fixed;z-index:99999;width:690px;height:540px;' +
'background:#0a0909;border:2px solid #854369;border-radius:10px;' +
'box-shadow:0 0 0 1px #1a1a1a, 0 8px 24px rgba(0,0,0,0.55);overflow:hidden;';
popup.innerHTML =
'<div style="padding:10px 12px;border-bottom:2px solid #854369;background:linear-gradient(to bottom, #171114 0%, #0a0909 100%);color:#E2E2E2;font-size:13px;font-weight:700;">' +
'<span>플레이리스트</span>' +
'</div>' +
'<div style="padding:0;background:#111;height:calc(100% - 42px);">' +
'<iframe style="border:none;width:100%;height:100%;" src="https://open.spotify.com/embed/playlist/32l4ke6djdQn8LoBp1ipR9?utm_source=generator&theme=0" allowfullscreen allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture" loading="lazy"></iframe>' +
'</div>';
document.body.appendChild(popup);
}
function positionPlaylistPopup() {
var btn = document.getElementById('clbi-playlist-toggle');
var popup = document.getElementById('clbi-playlist-popup');
if (!btn || !popup) return;
var rect = btn.getBoundingClientRect();
var top = rect.bottom + 2;
var left = rect.right - popup.offsetWidth;
if (left < 8) left = 8;
if (left + popup.offsetWidth > window.innerWidth - 8) {
left = window.innerWidth - popup.offsetWidth - 8;
}
if (top + popup.offsetHeight > window.innerHeight - 8) {
top = Math.max(8, window.innerHeight - popup.offsetHeight - 8);
}
popup.style.top = top + 'px';
popup.style.left = left + 'px';
}
function initPlaylistPopup() {
var btn = document.getElementById('clbi-playlist-toggle');
if (!btn) return;
ensurePlaylistPopup();
$(document)
.off('click.clbiPlaylistToggle')
.on('click.clbiPlaylistToggle', '#clbi-playlist-toggle', function(e) {
e.preventDefault();
e.stopPropagation();
var popup = document.getElementById('clbi-playlist-popup');
var notificationPopup = document.getElementById('clbi-notification-popup');
if (!popup) return;
if (notificationPopup) {
notificationPopup.style.display = 'none';
}
if (popup.style.display === 'none' || popup.style.display === '') {
popup.style.display = 'block';
positionPlaylistPopup();
} else {
popup.style.display = 'none';
}
});
$(document)
.off('click.clbiPlaylistOutside')
.on('click.clbiPlaylistOutside', function(e) {
var popup = document.getElementById('clbi-playlist-popup');
var toggle = document.getElementById('clbi-playlist-toggle');
if (!popup || !toggle) return;
if (!popup.contains(e.target) && !toggle.contains(e.target)) {
popup.style.display = 'none';
}
});
$(window)
.off('resize.clbiPlaylist')
.on('resize.clbiPlaylist', function() {
var popup = document.getElementById('clbi-playlist-popup');
if (popup && popup.style.display === 'block') {
positionPlaylistPopup();
}
});
}
// ========== 플레이리스트 팝업 시스템 끝 ==========
function initUserProfilePage() {
var saveBtn = document.getElementById('pref-save');
if (!saveBtn) return;
var adminEl = document.getElementById('pref-is-admin');
var isAdmin = adminEl && adminEl.getAttribute('data-admin') === '1';
var api = new mw.Api();
var selectedFile = null;
var cropper = null;
if (!document.getElementById('clbi-gallery-modal')) {
var gModal = document.createElement('div');
gModal.id = 'clbi-gallery-modal';
gModal.style.cssText =
'display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.85);z-index:99999;align-items:center;justify-content:center;';
gModal.innerHTML =
'<div style="background:#1e1e1e;border:2px solid #854369;border-radius:12px;padding:24px;max-width:480px;width:90%;display:flex;flex-direction:column;gap:16px;">' +
'<div style="display:flex;justify-content:space-between;align-items:center;">' +
'<span style="font-size:14px;font-weight:700;color:#e2e2e2;">프로필 사진 선택</span>' +
'<button type="button" id="clbi-gallery-close" style="background:none;border:none;color:#aaa;font-size:18px;cursor:pointer;">✕</button>' +
'</div>' +
'<button type="button" id="clbi-gallery-upload-btn" style="background:#2a2a2a;border:2px dashed #854369;border-radius:8px;padding:32px;color:#e2e2e2;cursor:pointer;display:flex;flex-direction:column;align-items:center;gap:8px;font-size:13px;width:100%;">' +
'<span style="font-size:32px;">🖼️</span>새 사진 업로드' +
'</button>' +
'<div id="clbi-gallery-history-section" style="display:none;">' +
'<div style="font-size:11px;color:#888;margin-bottom:8px;">이전 사진 — 클릭하면 바로 적용</div>' +
'<div id="clbi-gallery-history" style="display:flex;gap:8px;flex-wrap:wrap;"></div>' +
'</div>' +
'</div>';
document.body.appendChild(gModal);
}
if (!document.getElementById('clbi-crop-modal')) {
var cModal = document.createElement('div');
cModal.id = 'clbi-crop-modal';
cModal.style.cssText =
'display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.85);z-index:99999;align-items:center;justify-content:center;';
cModal.innerHTML =
'<div style="background:#1e1e1e;border:2px solid #854369;border-radius:12px;padding:24px;max-width:500px;width:90%;display:flex;flex-direction:column;gap:16px;">' +
'<div style="font-size:14px;font-weight:700;color:#e2e2e2;">사진 조정</div>' +
'<div style="width:100%;max-height:380px;overflow:hidden;border-radius:8px;">' +
'<img id="clbi-crop-image" style="max-width:100%;">' +
'</div>' +
'<div style="display:flex;gap:8px;justify-content:flex-end;">' +
'<button type="button" id="clbi-crop-cancel" style="background:#2a2a2a;color:#e2e2e2;border:1px solid #444;padding:8px 16px;border-radius:6px;cursor:pointer;">취소</button>' +
'<button type="button" id="clbi-crop-confirm" style="background:#854369;color:#fff;border:none;padding:8px 16px;border-radius:6px;cursor:pointer;">확정</button>' +
'</div>' +
'</div>';
document.body.appendChild(cModal);
}
var gModal = document.getElementById('clbi-gallery-modal');
var cModal = document.getElementById('clbi-crop-modal');
var cropImage = document.getElementById('clbi-crop-image');
var pfpInput = document.getElementById('pref-pfp-input');
function openGallery() {
gModal.style.display = 'flex';
var username = mw.config.get('wgUserName');
api.get({
action: 'query',
titles: '파일:Pfp-' + username + '.png',
prop: 'imageinfo',
iiprop: 'url|timestamp',
iilimit: 6
}).then(function(data) {
var pages = data.query.pages;
var page = pages[Object.keys(pages)[0]];
if (!page.imageinfo || page.imageinfo.length === 0) return;
var historyEl = document.getElementById('clbi-gallery-history');
var sectionEl = document.getElementById('clbi-gallery-history-section');
historyEl.innerHTML = '';
page.imageinfo.forEach(function(info, idx) {
var wrap = document.createElement('div');
wrap.style.cssText = 'position:relative;cursor:pointer;';
var img = document.createElement('img');
img.src = info.url;
img.style.cssText =
'width:72px;height:72px;object-fit:cover;border-radius:8px;border:2px solid #444;flex-shrink:0;';
if (idx === 0) {
img.style.borderColor = '#854369';
var badge = document.createElement('div');
badge.textContent = '현재';
badge.style.cssText =
'position:absolute;bottom:4px;left:50%;transform:translateX(-50%);background:#854369;color:#fff;font-size:9px;padding:1px 6px;border-radius:10px;';
wrap.appendChild(badge);
}
img.addEventListener('mouseenter', function() {
if (idx !== 0) img.style.borderColor = '#854369';
});
img.addEventListener('mouseleave', function() {
if (idx !== 0) img.style.borderColor = '#444';
});
img.addEventListener('click', function() {
fetch(info.url)
.then(function(r) {
return r.blob();
})
.then(function(blob) {
selectedFile = new File([blob], 'profile.png', { type: 'image/png' });
document.getElementById('pref-pfp-preview').src = URL.createObjectURL(blob);
gModal.style.display = 'none';
document.getElementById('pref-pfp-btn').textContent = '✓ 사진 선택됨';
});
});
wrap.appendChild(img);
historyEl.appendChild(wrap);
});
sectionEl.style.display = 'block';
});
}
function openCrop(src) {
cropImage.src = src;
cModal.style.display = 'flex';
if (cropper) {
cropper.destroy();
cropper = null;
}
setTimeout(function() {
cropper = new Cropper(cropImage, {
aspectRatio: 1,
viewMode: 1,
dragMode: 'move',
autoCropArea: 0.8,
cropBoxResizable: true,
cropBoxMovable: true
});
}, 150);
}
document.getElementById('pref-pfp-btn').addEventListener('click', function() {
openGallery();
});
document.getElementById('clbi-gallery-upload-btn').addEventListener('click', function() {
pfpInput.click();
});
document.getElementById('clbi-gallery-close').addEventListener('click', function() {
gModal.style.display = 'none';
});
pfpInput.addEventListener('change', function() {
var file = this.files[0];
if (!file) return;
gModal.style.display = 'none';
var reader = new FileReader();
reader.onload = function(e) {
openCrop(e.target.result);
};
reader.readAsDataURL(file);
});
document.getElementById('clbi-crop-cancel').addEventListener('click', function() {
cModal.style.display = 'none';
if (cropper) {
cropper.destroy();
cropper = null;
}
pfpInput.value = '';
});
document.getElementById('clbi-crop-confirm').addEventListener('click', function() {
if (!cropper) return;
var canvas = cropper.getCroppedCanvas({ width: 256, height: 256 });
if (!canvas) return;
canvas.toBlob(function(blob) {
selectedFile = new File([blob], 'profile.png', { type: 'image/png' });
document.getElementById('pref-pfp-preview').src = URL.createObjectURL(blob);
cModal.style.display = 'none';
cropper.destroy();
cropper = null;
document.getElementById('pref-pfp-btn').textContent = '✓ 사진 선택됨';
}, 'image/png');
});
var emailSaveBtn = document.getElementById('pref-email-save');
if (emailSaveBtn) {
emailSaveBtn.addEventListener('click', function() {
var statusEl = document.getElementById('pref-email-status');
var newEmail = document.getElementById('pref-new-email').value;
var password = document.getElementById('pref-email-password').value;
if (!newEmail || !password) {
statusEl.textContent = '이메일과 비밀번호를 입력해주세요.';
return;
}
statusEl.textContent = '변경 중...';
api.postWithToken('csrf', {
action: 'changeemail',
email: newEmail,
password: password
}).then(function() {
statusEl.textContent = '✓ 이메일 변경됨';
document.getElementById('pref-new-email').value = '';
document.getElementById('pref-email-password').value = '';
setTimeout(function() {
statusEl.textContent = '';
}, 3000);
}).fail(function(code, data) {
var msg = data && data.error && data.error.info ? data.error.info : '변경 실패';
statusEl.textContent = msg;
});
});
}
saveBtn.addEventListener('click', function() {
var statusEl = document.getElementById('pref-status');
statusEl.textContent = '저장 중...';
var promises = [];
if (selectedFile) {
var username = mw.config.get('wgUserName');
promises.push(
api.postWithToken('csrf', {
action: 'upload',
filename: 'Pfp-' + username + '.png',
ignorewarnings: true,
file: selectedFile,
format: 'json'
}, {
contentType: 'multipart/form-data'
})
);
}
var fields = ['name', 'discord', 'role', 'bio'];
if (isAdmin) fields.push('badges');
for (var i = 0; i < fields.length; i++) {
var el = document.getElementById('pref-' + fields[i]);
if (!el) continue;
promises.push(
api.postWithToken('csrf', {
action: 'options',
optionname: 'profile-' + fields[i],
optionvalue: el.value
})
);
}
$.when.apply($, promises)
.then(function() {
statusEl.textContent = '✓ 저장됨';
selectedFile = null;
document.getElementById('pref-pfp-btn').textContent = '사진 선택';
setTimeout(function() {
statusEl.textContent = '';
}, 2000);
})
.fail(function() {
statusEl.textContent = '저장 실패';
});
});
}
/* =========================================
Banner / CRT Page Monitor thumbnail slices
- base 이미지는 틀에 들어간 파일 문법 그대로 사용
- slice 레이어에는 300px MediaWiki 썸네일만 삽입
========================================= */
(function ($, mw) {
var thumbCache = {};
function parseSliceWidth(value) {
var parsed = parseInt(value, 10);
if (!isFinite(parsed) || parsed < 120) {
return 300;
}
return parsed;
}
function getImageSrc(img) {
return img ? (img.currentSrc || img.getAttribute('src') || img.src || '') : '';
}
function getFileNameFromSrc(src) {
var a;
var parts;
var fileName;
if (!src) return '';
a = document.createElement('a');
a.href = src;
parts = (a.pathname || '').split('/').filter(function (part) {
return !!part;
});
if (!parts.length) return '';
fileName = parts.pop();
/*
* MediaWiki thumb URL 예시:
* /images/thumb/a/ab/File.png/1000px-File.png
* /images/thumb/a/ab/File.svg/1000px-File.svg.png
*
* 이 경우 실제 파일명은 마지막 조각이 아니라 그 앞 조각이다.
*/
if (/^\d+px-/.test(fileName) && parts.length) {
fileName = parts.pop();
}
fileName = fileName.replace(/^\d+px-/, '');
try {
fileName = decodeURIComponent(fileName);
} catch (e) {}
return fileName;
}
function resolveThumbUrl(img, width, callback) {
var src = getImageSrc(img);
var fileName = getFileNameFromSrc(src);
var cacheKey;
var entry;
if (!src) return;
if (!fileName || !mw || !mw.loader) {
callback(src);
return;
}
cacheKey = fileName + '|' + width;
entry = thumbCache[cacheKey];
if (entry) {
if (entry.resolved) {
callback(entry.url || src);
} else {
entry.callbacks.push(callback);
}
return;
}
entry = {
resolved: false,
url: '',
callbacks: [callback]
};
thumbCache[cacheKey] = entry;
function finish(url) {
var callbacks = entry.callbacks.slice();
var i;
entry.resolved = true;
entry.url = url || src;
entry.callbacks = [];
for (i = 0; i < callbacks.length; i++) {
callbacks[i](entry.url);
}
}
mw.loader.using('mediawiki.api').done(function () {
var api = new mw.Api();
api.get({
action: 'query',
titles: 'File:' + fileName,
prop: 'imageinfo',
iiprop: 'url',
iiurlwidth: width,
formatversion: 2
}).done(function (data) {
var page;
var info;
if (
data &&
data.query &&
data.query.pages &&
data.query.pages.length
) {
page = data.query.pages[0];
if (
page &&
page.imageinfo &&
page.imageinfo.length
) {
info = page.imageinfo[0];
}
}
finish((info && (info.thumburl || info.url)) || src);
}).fail(function () {
finish(src);
});
}).fail(function () {
finish(src);
});
}
function applySliceImages(frame, thumbUrl) {
var slices;
var i;
var img;
if (!frame || !thumbUrl) return;
slices = frame.querySelectorAll('.crt-page-monitor-slice');
for (i = 0; i < slices.length; i++) {
slices[i].innerHTML = '';
img = document.createElement('img');
img.className = 'crt-page-monitor-slice-img';
img.src = thumbUrl;
img.alt = '';
img.decoding = 'async';
img.loading = 'eager';
img.setAttribute('aria-hidden', 'true');
slices[i].appendChild(img);
}
frame.setAttribute('data-crt-slices-ready', '1');
}
function initBannerFrame(frame) {
var baseImg;
var width;
if (!frame) return;
if (frame.getAttribute('data-crt-slices-ready') === '1') return;
baseImg = frame.querySelector('.crt-page-monitor-image-base img');
if (!baseImg) return;
width = parseSliceWidth(frame.getAttribute('data-crt-slice-width'));
resolveThumbUrl(baseImg, width, function (thumbUrl) {
if (!frame || !frame.parentNode) return;
applySliceImages(frame, thumbUrl);
});
}
function initBannerFrames(root) {
var scope = root && root.querySelectorAll ? root : document;
var frames = scope.querySelectorAll('.crt-page-monitor-frame');
var i;
for (i = 0; i < frames.length; i++) {
initBannerFrame(frames[i]);
}
}
$(function () {
initBannerFrames(document);
});
if (mw && mw.hook) {
mw.hook('wikipage.content').add(function ($content) {
initBannerFrames($content && $content[0] ? $content[0] : document);
});
}
})(jQuery, window.mw);
/* =========================================
Doc Tab System — Q/E 단축키 탭 전환
글리치 플리커 + RGB split + 방향 슬라이드
========================================= */
(function () {
'use strict';
var keydownBound = false;
function initDocTabs() {
var tabBars = document.querySelectorAll('.doc-tab-bar');
if (!tabBars.length) return;
tabBars.forEach(function (bar) {
if (bar.getAttribute('data-tabs-init')) return;
bar.setAttribute('data-tabs-init', '1');
var tabs = Array.from(bar.querySelectorAll('.doc-tab'));
if (!tabs.length) return;
var panel = bar.closest('.doc-panel');
var display = panel ? panel.querySelector('.doc-display') : null;
if (!display) display = document.getElementById('doc-main-display');
if (!display) return;
tabs.forEach(function (tab, i) {
tab.addEventListener('click', function () {
var currentIdx = tabs.findIndex(function (t) {
return t.classList.contains('active');
});
if (currentIdx === i) return;
switchTab(tabs, display, i, i > currentIdx ? 1 : -1);
});
});
var initIdx = tabs.findIndex(function (t) { return t.classList.contains('active'); });
if (initIdx !== -1) {
var initRef = tabs[initIdx].dataset.ref;
var initEl = initRef ? document.getElementById(initRef) : null;
display.innerHTML = initEl ? initEl.innerHTML : (tabs[initIdx].dataset.content || '');
}
});
if (!keydownBound) {
keydownBound = true;
document.addEventListener('keydown', function (e) {
var tag = document.activeElement.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA') return;
var bar = document.querySelector('.doc-tab-bar');
if (!bar) return;
var tabs = Array.from(bar.querySelectorAll('.doc-tab'));
var panel = bar.closest('.doc-panel');
var display = panel ? panel.querySelector('.doc-display') : null;
if (!display) display = document.getElementById('doc-main-display');
if (!display) return;
var activeIdx = tabs.findIndex(function (t) {
return t.classList.contains('active');
});
if (activeIdx === -1) return;
if (e.key === 'q' || e.key === 'Q') {
e.preventDefault();
var prev = (activeIdx - 1 + tabs.length) % tabs.length;
if (prev !== activeIdx) switchTab(tabs, display, prev, -1);
} else if (e.key === 'e' || e.key === 'E') {
e.preventDefault();
var next = (activeIdx + 1) % tabs.length;
if (next !== activeIdx) switchTab(tabs, display, next, 1);
}
});
}
}
var isAnimating = false;
function switchTab(tabs, display, nextIdx, dir) {
if (isAnimating) return;
isAnimating = true;
tabs.forEach(function (t) { t.classList.remove('active'); });
tabs[nextIdx].classList.add('active');
var ref = tabs[nextIdx].dataset.ref;
var nextContent;
if (ref) {
var refEl = document.getElementById(ref);
nextContent = refEl ? refEl.innerHTML : '';
} else {
nextContent = tabs[nextIdx].dataset.content || '';
}
glitchOut(display, dir, function () {
display.innerHTML = nextContent;
glitchIn(display, dir, function () {
isAnimating = false;
});
});
}
function glitchOut(el, dir, cb) {
var duration = 160;
var start = null;
var slideX = dir * 16;
function step(ts) {
if (!start) start = ts;
var p = Math.min((ts - start) / duration, 1);
var ease = p * p;
var tx = slideX * ease;
var skew = dir * ease * 1.0;
var opacity = 1 - ease;
var rgb = ease * 5;
el.style.transform = 'translateX(' + tx + 'px) skewX(' + skew + 'deg)';
el.style.opacity = opacity;
el.style.filter =
'drop-shadow(' + (-rgb) + 'px 0 0 rgba(80,160,255,0.75)) ' +
'drop-shadow(' + rgb + 'px 0 0 rgba(255,55,90,0.65)) ' +
'brightness(' + (1 + ease * 0.25) + ')';
if (p < 1) {
requestAnimationFrame(step);
} else {
el.style.opacity = '0';
cb();
}
}
requestAnimationFrame(step);
}
function glitchIn(el, dir, cb) {
var duration = 200;
var start = null;
var startX = -dir * 16;
el.style.transform = 'translateX(' + startX + 'px) skewX(' + (-dir * 1.0) + 'deg)';
el.style.opacity = '0';
function step(ts) {
if (!start) start = ts;
var p = Math.min((ts - start) / duration, 1);
var ease = 1 - Math.pow(1 - p, 3);
var tx = startX * (1 - ease);
var skew = -dir * 1.0 * (1 - ease);
var opacity = ease;
var rgb = (1 - ease) * 3;
var brightness = 1 + (1 - ease) * 0.35;
el.style.transform = 'translateX(' + tx + 'px) skewX(' + skew + 'deg)';
el.style.opacity = opacity;
el.style.filter =
'drop-shadow(' + (-rgb) + 'px 0 0 rgba(80,160,255,0.65)) ' +
'drop-shadow(' + rgb + 'px 0 0 rgba(255,55,90,0.55)) ' +
'brightness(' + brightness + ')';
if (p < 1) {
requestAnimationFrame(step);
} else {
el.style.transform = '';
el.style.opacity = '';
el.style.filter = '';
cb();
}
}
requestAnimationFrame(step);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initDocTabs);
} else {
initDocTabs();
}
if (typeof mw !== 'undefined' && mw.hook) {
mw.hook('wikipage.content').add(function () {
initDocTabs();
});
}
})();
/* =========================================
Doc Section Switch — 좌측 섹션 전환
========================================= */
$(document).on('click', '.doc-nav-item[data-section]', function () {
var name = $(this).attr('data-section');
var display = document.getElementById('doc-main-display');
var titleEl = document.getElementById('doc-center-title');
var tabBar = document.getElementById('doc-tab-bar-text');
if (!display) return;
$('.doc-nav-item[data-section]').removeClass('active');
$('.doc-nav-item[data-section="' + name + '"]').addClass('active');
if (name === 'text') {
if (titleEl) titleEl.textContent = '개요';
if (tabBar) $(tabBar).show();
var activeTab = tabBar ? tabBar.querySelector('.doc-tab.active') : null;
if (!activeTab && tabBar) activeTab = tabBar.querySelector('.doc-tab');
if (activeTab) {
var ref = activeTab.dataset.ref;
var refEl = ref ? document.getElementById(ref) : null;
display.innerHTML = refEl ? refEl.innerHTML : (activeTab.dataset.content || '');
}
} else {
if (titleEl) titleEl.textContent = name === 'factions' ? '세력' : name === 'people' ? '인물' : name;
if (tabBar) $(tabBar).hide();
var refEl = document.getElementById('doc-content-' + name);
display.innerHTML = refEl ? refEl.innerHTML : '';
}
});
/* =========================================
CRT WebGL Renderer — cool-retro-term IBM DOS style
========================================= */
(function () {
'use strict';
function createNoiseTexture(gl) {
var size = 512;
var data = new Uint8Array(size * size * 4);
var s = 12345;
function rand() {
s = (s * 1664525 + 1013904223) & 0xffffffff;
return (s >>> 0) / 0xffffffff;
}
for (var i = 0; i < data.length; i++) {
data[i] = (rand() * 255) | 0;
}
var tex = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, size, size, 0, gl.RGBA, gl.UNSIGNED_BYTE, data);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
return tex;
}
var VERT = [
'attribute vec2 a_pos;',
'varying vec2 v_uv;',
'void main() {',
' v_uv = vec2(a_pos.x * 0.5 + 0.5, 0.5 - a_pos.y * 0.5);',
' gl_Position = vec4(a_pos, 0.0, 1.0);',
'}'
].join('\n');
var FRAG = [
'precision mediump float;',
'uniform sampler2D u_tex;',
'uniform sampler2D u_noise;',
'uniform vec2 u_res;',
'uniform vec2 u_imgSize;',
'uniform float u_time;',
'uniform vec2 u_noiseScale;',
'varying vec2 v_uv;',
// Utilities
'float sum2(vec2 v) { return v.x + v.y; }',
'float min2(vec2 v) { return min(v.x, v.y); }',
'float rgb2grey(vec3 v) { return dot(v, vec3(0.21, 0.72, 0.04)); }',
// Cover UV — 좌우 맞추고 세로 클리핑 (object-fit: cover 방식)
'vec2 coverUV(vec2 uv) {',
' float imgAR = u_imgSize.x / u_imgSize.y;',
' float scrAR = u_res.x / u_res.y;',
' vec2 result = uv;',
' if (scrAR > imgAR) {',
' float scale = scrAR / imgAR;',
' result.y = (uv.y - 0.5) * scale + 0.5;',
' } else {',
' float scale = imgAR / scrAR;',
' result.x = (uv.x - 0.5) * scale + 0.5;',
' }',
' result = clamp(result, 0.0, 1.0);',
' return result;',
'}',
// Barrel distortion — QML 원본 공식 + 종횡비 보정
'vec2 barrel(vec2 v, vec2 cc, float k) {',
' float ar = u_res.x / u_res.y;',
' vec2 c2 = cc;',
' if (ar > 1.0) c2.x /= ar; else c2.y *= ar;',
' float dist = dot(c2, c2) * k;',
' return v - cc * (1.0 + dist) * dist;',
'}',
// Noise samplers
'vec4 sampleInitialNoise(float t) {',
' return texture2D(u_noise, vec2(fract(t/2048.0), fract(t/1048576.0)));',
'}',
// 고정 노이즈 — time offset 없음
'vec4 sampleScreenNoise(vec2 uv) {',
' return texture2D(u_noise, u_noiseScale * uv);',
'}',
// RGB shift — QML 비대칭 가중치
'vec3 applyRgbShift(vec2 uv, float shift) {',
' vec2 d = vec2(shift, 0.0);',
' vec3 r = texture2D(u_tex, coverUV(uv + d)).rgb;',
' vec3 c = texture2D(u_tex, coverUV(uv)).rgb;',
' vec3 l = texture2D(u_tex, coverUV(uv - d)).rgb;',
' return vec3(',
' l.r*0.10 + r.r*0.30 + c.r*0.60,',
' l.g*0.20 + r.g*0.20 + c.g*0.60,',
' l.b*0.30 + r.b*0.10 + c.b*0.60',
' );',
'}',
// Bloom — 포스포 글로우
'vec3 applyBloom(vec2 uv, float strength) {',
' vec2 px = 2.0 / u_res;',
' vec3 acc = vec3(0.0);',
' acc += texture2D(u_tex, coverUV(uv + vec2( px.x, 0.0))).rgb;',
' acc += texture2D(u_tex, coverUV(uv + vec2(-px.x, 0.0))).rgb;',
' acc += texture2D(u_tex, coverUV(uv + vec2( 0.0, px.y))).rgb;',
' acc += texture2D(u_tex, coverUV(uv + vec2( 0.0, -px.y))).rgb;',
' acc += texture2D(u_tex, coverUV(uv + vec2( px.x*1.5, px.y*1.5))).rgb * 0.5;',
' acc += texture2D(u_tex, coverUV(uv + vec2(-px.x*1.5, px.y*1.5))).rgb * 0.5;',
' acc += texture2D(u_tex, coverUV(uv + vec2( px.x*1.5, -px.y*1.5))).rgb * 0.5;',
' acc += texture2D(u_tex, coverUV(uv + vec2(-px.x*1.5, -px.y*1.5))).rgb * 0.5;',
' return acc / 6.0 * strength;',
'}',
// Scanlines — QML 방식
'vec3 applyScanlines(vec2 uv, vec3 col) {',
' float line = mod(uv.y * u_res.y, 2.0);',
' vec3 hi = ((1.0 + 0.30) - 0.2 * col) * col;',
' vec3 lo = ((1.0 - 0.30) + 0.1 * col) * col;',
' return line < 1.0 ? lo : hi;',
'}',
// Glowing line — 위→아래 스캔빔
'float glowingLine(vec2 uv, float t) {',
' float pos = fract(t * 0.16);',
' float lineY = pos * (u_res.y + 120.0) - 120.0;',
' float y = uv.y * u_res.y;',
' return fract(smoothstep(-120.0, 0.0, y - lineY));',
'}',
// Horizontal sync
'vec2 applyHSync(vec2 uv, vec4 noise, float strength) {',
' if (strength <= 0.0) return uv;',
' float randval = strength - noise.r;',
' float scale = step(0.0, randval) * randval * strength;',
' float freq = mix(4.0, 40.0, noise.g);',
' uv.x += sin((uv.y + u_time * 0.001) * freq) * scale;',
' return uv;',
'}',
'void main() {',
' vec2 cc = vec2(0.5) - v_uv;',
// 배럴 왜곡
' float curvature = 0.20;',
' vec2 uv = barrel(v_uv, cc, curvature);',
// 화면 밖 검정
' float inScreen = min2(step(vec2(0.0), uv) - step(vec2(1.0), uv));',
' if (inScreen < 0.5) { gl_FragColor = vec4(0.0, 0.0, 0.0, 1.0); return; }',
// 노이즈
' vec4 initNoise = sampleInitialNoise(u_time);',
' vec4 screenNoise = sampleScreenNoise(uv);',
// Horizontal sync
' uv = applyHSync(uv, initNoise, 0.006);',
// Jitter
' uv += (vec2(screenNoise.b, screenNoise.a) - 0.5) * 0.0006;',
// 베이스 컬러 (coverUV 적용)
' vec3 col = applyRgbShift(uv, 0.003);',
// Bloom
' col += applyBloom(uv, 0.22);',
// 뿌연 블러 (IBM DOS 스타일)
' vec2 bpx = 1.5 / u_res;',
' vec3 blurCol = vec3(0.0);',
' blurCol += texture2D(u_tex, coverUV(uv + vec2(-bpx.x, -bpx.y))).rgb;',
' blurCol += texture2D(u_tex, coverUV(uv + vec2( 0.0, -bpx.y))).rgb;',
' blurCol += texture2D(u_tex, coverUV(uv + vec2( bpx.x, -bpx.y))).rgb;',
' blurCol += texture2D(u_tex, coverUV(uv + vec2(-bpx.x, 0.0 ))).rgb;',
' blurCol += texture2D(u_tex, coverUV(uv + vec2( bpx.x, 0.0 ))).rgb;',
' blurCol += texture2D(u_tex, coverUV(uv + vec2(-bpx.x, bpx.y))).rgb;',
' blurCol += texture2D(u_tex, coverUV(uv + vec2( 0.0, bpx.y))).rgb;',
' blurCol += texture2D(u_tex, coverUV(uv + vec2( bpx.x, bpx.y))).rgb;',
' col = mix(col, blurCol / 8.0, 0.40);',
// Scanlines
' col = applyScanlines(uv, col);',
// Glowing line
' float glow = glowingLine(uv, u_time);',
' col += glow * 0.55 * vec3(0.85, 0.95, 1.0);',
// Static noise
' float dist = length(cc);',
' col += screenNoise.a * 0.07 * (1.0 - dist * 1.3);',
// IBM DOS 그린 포스포 틴트
' float grey = rgb2grey(col);',
' vec3 phosphor = vec3(0.75, 0.88, 1.0);',
' col = mix(col, grey * phosphor, 0.35);',
// Vignette
' vec2 vig = v_uv * (1.0 - v_uv);',
' col *= pow(vig.x * vig.y * 15.0, 0.25);',
// Flickering
' col *= 1.0 + (initNoise.g - 0.5) * 0.06;',
// Ambient light
' col += vec3(0.012) * (1.0 - dist) * (1.0 - dist);',
// 감마
' col = pow(clamp(col, 0.0, 1.0), vec3(0.90));',
' gl_FragColor = vec4(col, 1.0);',
'}'
].join('\n');
function initCRTCanvas(screen, imgEl) {
var existing = screen.querySelector('.crt-webgl-canvas');
if (existing) existing.remove();
var canvas = document.createElement('canvas');
canvas.className = 'crt-webgl-canvas';
canvas.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;z-index:19;pointer-events:none;display:block;';
screen.appendChild(canvas);
var gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl');
if (!gl) return;
function compile(type, src) {
var s = gl.createShader(type);
gl.shaderSource(s, src);
gl.compileShader(s);
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) {
console.error('[CRT shader]', gl.getShaderInfoLog(s));
}
return s;
}
var prog = gl.createProgram();
gl.attachShader(prog, compile(gl.VERTEX_SHADER, VERT));
gl.attachShader(prog, compile(gl.FRAGMENT_SHADER, FRAG));
gl.linkProgram(prog);
gl.useProgram(prog);
var buf = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1,-1, 1,-1, -1,1, 1,1]), gl.STATIC_DRAW);
var aPos = gl.getAttribLocation(prog, 'a_pos');
gl.enableVertexAttribArray(aPos);
gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0);
var uTex = gl.getUniformLocation(prog, 'u_tex');
var uNoise = gl.getUniformLocation(prog, 'u_noise');
var uRes = gl.getUniformLocation(prog, 'u_res');
var uImgSize = gl.getUniformLocation(prog, 'u_imgSize');
var uTime = gl.getUniformLocation(prog, 'u_time');
var uNoiseSc = gl.getUniformLocation(prog, 'u_noiseScale');
// 이미지 텍스처 (unit 0)
var imgTex = gl.createTexture();
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, imgTex);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
// 노이즈 텍스처 (unit 1)
gl.activeTexture(gl.TEXTURE1);
createNoiseTexture(gl);
var texReady = false;
function uploadImg() {
if (!imgEl || !imgEl.complete || !imgEl.naturalWidth) return;
try {
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, imgTex);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, imgEl);
texReady = true;
} catch(e) { console.error('[CRT] tex:', e); }
}
var lastW = 0, lastH = 0;
function resize() {
var w = screen.offsetWidth, h = screen.offsetHeight;
if (w === lastW && h === lastH) return;
lastW = w; lastH = h;
canvas.width = w; canvas.height = h;
gl.viewport(0, 0, w, h);
}
var raf;
var t0 = performance.now();
function render() {
raf = requestAnimationFrame(render);
if (!texReady) { uploadImg(); return; }
resize();
var t = (performance.now() - t0) / 1000;
gl.uniform1i(uTex, 0);
gl.uniform1i(uNoise, 1);
gl.uniform2f(uRes, canvas.width, canvas.height);
gl.uniform2f(uImgSize, imgEl.naturalWidth, imgEl.naturalHeight);
gl.uniform1f(uTime, t);
gl.uniform2f(uNoiseSc, canvas.width / 512.0, canvas.height / 512.0);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
}
if (imgEl.complete && imgEl.naturalWidth) { uploadImg(); }
else { imgEl.addEventListener('load', uploadImg); }
render();
screen._crtCleanup = function () { cancelAnimationFrame(raf); };
}
function initAllCRTScreens(root) {
var scope = root && root.querySelectorAll ? root : document;
scope.querySelectorAll('.crt-page-monitor-screen').forEach(function (screen) {
if (screen.getAttribute('data-crt-webgl') === '1') return;
screen.setAttribute('data-crt-webgl', '1');
var frame = screen.closest('.crt-page-monitor-frame');
if (!frame) return;
var imgEl = frame.querySelector('.crt-page-monitor-slice-img, .crt-page-monitor-image-base img');
if (imgEl && imgEl.complete && imgEl.naturalWidth) {
initCRTCanvas(screen, imgEl);
} else if (imgEl) {
imgEl.addEventListener('load', function () { initCRTCanvas(screen, imgEl); });
} else {
var obs = new MutationObserver(function () {
var img = frame.querySelector('.crt-page-monitor-slice-img');
if (!img) return;
obs.disconnect();
if (img.complete && img.naturalWidth) {
initCRTCanvas(screen, img);
} else {
img.addEventListener('load', function () { initCRTCanvas(screen, img); });
}
});
obs.observe(frame, { childList: true, subtree: true });
}
});
}
$(function () { initAllCRTScreens(document); });
if (typeof mw !== 'undefined' && mw.hook) {
mw.hook('wikipage.content').add(function ($c) {
document.querySelectorAll('.crt-page-monitor-screen').forEach(function (s) {
if (s._crtCleanup) s._crtCleanup();
s.removeAttribute('data-crt-webgl');
});
initAllCRTScreens($c && $c[0] ? $c[0] : document);
});
}
})();