편집 요약 없음 |
편집 요약 없음 |
||
| (같은 사용자의 중간 판 210개는 보이지 않습니다) | |||
| 1번째 줄: | 1번째 줄: | ||
mw.loader.load('https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.5.13/cropper.min.js'); | mw.loader.load('https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.5.13/cropper.min.js'); | ||
mw.loader.load('/index.php?title=MediaWiki:DevTools.js&action=raw&ctype=text/javascript'); | |||
mw.loader.load('/index.php?title=MediaWiki:CategoryNav.js&action=raw&ctype=text/javascript'); | |||
mw.loader.load('/index.php?title=MediaWiki:AnecdoteViewer.js&action=raw&ctype=text/javascript'); | |||
(function () { | |||
'use strict'; | |||
var SYSTEM_TITLE_NAMESPACES = { | |||
var | '-1': true, | ||
'4': true, | |||
'5': true, | |||
'6': true, | |||
'7': true, | |||
'8': true, | |||
'9': true, | |||
'10': true, | |||
'11': true, | |||
'12': true, | |||
'13': true, | |||
'14': true, | |||
'15': true, | |||
'828': true, | |||
'829': true | |||
}; | |||
function normalizePageNameForShell(value) { | |||
return String(value || '') | |||
.split('?')[0] | |||
.replace(/^\/index\.php\//, '') | |||
.replace(/_/g, ' ') | |||
} | .trim(); | ||
} | |||
function | function readCurrentPageNameForShell() { | ||
var pageName = mw.config.get('wgPageName') || ''; | |||
if (pageName) { | |||
return normalizePageNameForShell(pageName); | |||
} | |||
return normalizePageNameForShell(window.location.pathname || ''); | |||
} | |||
} | |||
// | function isAnecdoteNamespaceForShell() { | ||
function | var namespaceNumber = Number(mw.config.get('wgNamespaceNumber')); | ||
var canonicalNamespace = String(mw.config.get('wgCanonicalNamespace') || '').toLowerCase(); | |||
var pageName = readCurrentPageNameForShell(); | |||
} | |||
return namespaceNumber === 3000 || | |||
canonicalNamespace === 'anecdote' || | |||
/^(anecdote|에넥도트):/i.test(pageName); | |||
} | |||
function isBackendOrSystemPageForShell() { | |||
var namespaceNumber = Number(mw.config.get('wgNamespaceNumber')); | |||
var action = String(mw.config.get('wgAction') || 'view').toLowerCase(); | |||
var contentModel = String(mw.config.get('wgPageContentModel') || '').toLowerCase(); | |||
var pageName = readCurrentPageNameForShell(); | |||
var lowerPageName = pageName.toLowerCase(); | |||
if (action && action !== 'view') { | |||
return true; | |||
} | |||
if (pageName === '대문') { | |||
return false; | |||
} | |||
} | |||
if (SYSTEM_TITLE_NAMESPACES[String(namespaceNumber)]) { | |||
return true; | |||
} | } | ||
if (contentModel === 'css' || contentModel === 'javascript' || contentModel === 'json' || contentModel === 'sanitized-css') { | |||
return true; | |||
} | |||
if (/\.(css|js|json)$/i.test(pageName)) { | |||
return true; | |||
} | |||
if (/^(mediawiki|미디어위키|special|특수):/i.test(pageName)) { | |||
return true; | |||
} | |||
return false; | |||
return; | |||
} | } | ||
var | function isMediaWikiSystemAssetPageForShell() { | ||
var namespaceNumber = Number(mw.config.get('wgNamespaceNumber')); | |||
var pageName = readCurrentPageNameForShell(); | |||
var contentModel = String(mw.config.get('wgPageContentModel') || '').toLowerCase(); | |||
return namespaceNumber === 8 && | |||
(/\.(css|js)$/i.test(pageName) || contentModel === 'css' || contentModel === 'javascript' || contentModel === 'sanitized-css'); | |||
} | } | ||
var systemDocRawFetchToken = 0; | |||
function cleanupLegacySystemDocCodeMutationsForShell() { | |||
document.querySelectorAll('.clbi-system-doc-codepane').forEach(function (pane) { | |||
var parent; | |||
if (!pane || !pane.parentNode) return; | |||
parent = pane.parentNode; | |||
while (pane.firstChild) { | |||
return; | parent.insertBefore(pane.firstChild, pane); | ||
} | |||
parent.removeChild(pane); | |||
}); | |||
document.querySelectorAll('.clbi-system-doc-codebox').forEach(function (node) { | |||
node.classList.remove('clbi-system-doc-codebox'); | |||
node.removeAttribute('data-clbi-system-doc-codebox'); | |||
node.removeAttribute('style'); | |||
}); | |||
} | |||
function getSystemDocOutputForShell() { | |||
return document.querySelector('.liberty-content-main .mw-parser-output'); | |||
} | } | ||
var | function findSystemDocSourceNodeForShell() { | ||
var output = getSystemDocOutputForShell(); | |||
var children; | |||
var preferred; | |||
if (!output) return null; | |||
children = Array.prototype.slice.call(output.children || []) | |||
.filter(function (el) { | |||
return el && el.nodeType === 1 && | |||
el.id !== 'clbi-system-doc-indicator-row' && | |||
el.id !== 'clbi-system-source-viewer' && | |||
!el.classList.contains('catlinks') && | |||
(el.textContent || '').trim().length > 200; | |||
}); | |||
preferred = children.filter(function (el) { | |||
return el.matches && el.matches('.mw-highlight, .mw-code, pre'); | |||
})[0]; | |||
return preferred || children.sort(function (a, b) { | |||
return (b.textContent || '').trim().length - (a.textContent || '').trim().length; | |||
})[0] || null; | |||
} | |||
var | function getSystemDocRawUrlForShell() { | ||
var title = mw.config.get('wgPageName') || readCurrentPageNameForShell(); | |||
if (window.mw && mw.util && typeof mw.util.getUrl === 'function') { | |||
if ( | return mw.util.getUrl(title, { | ||
action: 'raw', | |||
ctype: 'text/plain', | |||
_: String(Date.now()) | |||
}); | |||
} | } | ||
return '/index.php?title=' + encodeURIComponent(title) + '&action=raw&ctype=text/plain&_=' + Date.now(); | |||
} | |||
function removeSystemDocSourceViewerForShell() { | |||
var viewer = document.getElementById('clbi-system-source-viewer'); | |||
if (viewer && viewer.parentNode) { | |||
viewer.parentNode.removeChild(viewer); | |||
} | |||
document.querySelectorAll('.clbi-system-original-source-hidden').forEach(function (node) { | |||
node.classList.remove('clbi-system-original-source-hidden'); | |||
node.removeAttribute('data-clbi-system-source-hidden'); | |||
node.style.removeProperty('display'); | |||
}); | |||
cleanupLegacySystemDocCodeMutationsForShell(); | |||
} | |||
function ensureSystemDocSourceViewerForShell() { | |||
var output = getSystemDocOutputForShell(); | |||
var source; | |||
var viewer; | |||
var fallbackText; | |||
if (!output || !isMediaWikiSystemAssetPageForShell()) return null; | |||
cleanupLegacySystemDocCodeMutationsForShell(); | |||
source = findSystemDocSourceNodeForShell(); | |||
if (!source) return null; | |||
viewer = document.getElementById('clbi-system-source-viewer'); | |||
if (!viewer) { | |||
viewer = document.createElement('pre'); | |||
viewer.id = 'clbi-system-source-viewer'; | |||
viewer.className = 'clbi-system-source-viewer'; | |||
output.appendChild(viewer); | |||
} | } | ||
fallbackText = source.textContent || ''; | |||
if (!viewer.textContent && fallbackText) { | |||
viewer.textContent = fallbackText; | |||
} | |||
source.classList.add('clbi-system-original-source-hidden'); | |||
source.setAttribute('data-clbi-system-source-hidden', 'true'); | |||
source.style.setProperty('display', 'none', 'important'); | |||
return viewer; | |||
} | } | ||
function renderSystemDocSourceViewerForShell() { | |||
var viewer; | |||
var pageName; | |||
var token; | |||
var currentScrollTop; | |||
if (!isMediaWikiSystemAssetPageForShell()) return; | |||
pageName = String(mw.config.get('wgPageName') || readCurrentPageNameForShell()); | |||
viewer = document.getElementById('clbi-system-source-viewer'); | |||
pageName = | |||
/* | |||
시스템 문서 뷰어가 이미 만들어져 있고 raw 원문도 로드된 상태라면 | |||
if ( | 다시 source 탐색/숨김/스타일 재적용을 하지 않는다. | ||
DevTools Elements 패널에서 body가 계속 파랗게 깜빡이던 원인은 | |||
MutationObserver가 이 재적용을 반복해서 DOM attribute mutation을 만들었기 때문이다. | |||
*/ | |||
if ( | |||
viewer && | |||
viewer.getAttribute('data-clbi-raw-title') === pageName && | |||
viewer.getAttribute('data-clbi-raw-loaded') === '1' | |||
) { | |||
return; | |||
} | } | ||
viewer = ensureSystemDocSourceViewerForShell(); | |||
if (!viewer) return; | |||
currentScrollTop = viewer.scrollTop || 0; | |||
viewer.setAttribute('data-clbi-raw-title', pageName); | |||
token = ++systemDocRawFetchToken; | |||
fetch(getSystemDocRawUrlForShell(), { credentials: 'same-origin' }) | |||
.then(function (res) { | |||
if (!res.ok) throw new Error('raw fetch failed ' + res.status); | |||
return res.text(); | |||
}) | |||
.then(function (text) { | |||
if (token !== systemDocRawFetchToken) return; | |||
currentScrollTop = viewer.scrollTop || currentScrollTop || 0; | |||
if (text && viewer.textContent !== text) { | |||
viewer.textContent = text; | |||
} | |||
viewer.setAttribute('data-clbi-raw-loaded', '1'); | |||
viewer.scrollTop = currentScrollTop; | |||
}) | |||
.catch(function () { | |||
viewer.setAttribute('data-clbi-raw-loaded', '0'); | |||
}); | |||
} | } | ||
var | function removeSystemDocIndicatorForShell() { | ||
var existing = document.getElementById('clbi-system-doc-indicator-row'); | |||
if (document.body) { | |||
document.body.classList.remove('clbi-system-doc-page'); | |||
} | |||
if (existing && existing.parentNode) { | |||
existing.parentNode.removeChild(existing); | |||
} | |||
removeSystemDocSourceViewerForShell(); | |||
} | } | ||
return | function renderSystemDocIndicatorForShell() { | ||
var pageName; | |||
var extMatch; | |||
var ext; | |||
var row; | |||
var box; | |||
var meta; | |||
var label; | |||
var type; | |||
var title; | |||
var anchor; | |||
var main; | |||
if (!document.body || !isMediaWikiSystemAssetPageForShell()) return; | |||
pageName = readCurrentPageNameForShell(); | |||
extMatch = pageName.match(/\.(css|js)$/i); | |||
ext = extMatch ? extMatch[1].toUpperCase() : 'DOC'; | |||
document.body.classList.add('clbi-system-doc-page'); | |||
row = document.getElementById('clbi-system-doc-indicator-row'); | |||
if ( | if (!row) { | ||
row = document.createElement('div'); | |||
row.id = 'clbi-system-doc-indicator-row'; | |||
row.className = 'clbi-system-doc-indicator-row'; | |||
box = document.createElement('div'); | |||
box.className = 'clbi-system-doc-indicator'; | |||
meta = document.createElement('div'); | |||
meta.className = 'clbi-system-doc-meta'; | |||
label = document.createElement('span'); | |||
label.className = 'clbi-system-doc-label'; | |||
label.textContent = 'SYSTEM DOCUMENT'; | |||
type = document.createElement('span'); | |||
type.className = 'clbi-system-doc-type'; | |||
title = document.createElement('div'); | |||
title.className = 'clbi-system-doc-title'; | |||
meta.appendChild(label); | |||
meta.appendChild(type); | |||
box.appendChild(meta); | |||
box.appendChild(title); | |||
row.appendChild(box); | |||
anchor = getSystemDocOutputForShell(); | |||
main = document.querySelector('.liberty-content-main'); | |||
if (anchor && anchor.parentNode) { | |||
anchor.parentNode.insertBefore(row, anchor); | |||
} else if (main) { | |||
main.insertBefore(row, main.firstChild); | |||
} | |||
} | } | ||
type = row.querySelector('.clbi-system-doc-type'); | |||
title = row.querySelector('.clbi-system-doc-title'); | |||
if (type) type.textContent = ext; | |||
if (title) title.textContent = pageName; | |||
} else { | |||
renderSystemDocSourceViewerForShell(); | |||
} | |||
var PAGE_TITLE_TARGET_SELECTORS = [ | |||
'.liberty-content-header', | |||
'.liberty-content-header .title', | |||
'.liberty-content-header .title h1', | |||
'.liberty-content-header h1', | |||
'#firstHeading', | |||
'.firstHeading', | |||
'.mw-first-heading', | |||
'.page-heading', | |||
'.page-header', | |||
'.mw-page-title-main', | |||
'.mw-page-title-namespace', | |||
'.mw-page-title-separator' | |||
]; | |||
var pageShellObserverStarted = false; | |||
var pageShellObserverTimer = null; | |||
function setPageTitleDomHidden(hidden) { | |||
var nodes = document.querySelectorAll(PAGE_TITLE_TARGET_SELECTORS.join(',')); | |||
nodes.forEach(function (node) { | |||
if (!node || !node.style) return; | |||
if (hidden) { | |||
node.setAttribute('data-clbi-title-hidden', 'true'); | |||
node.style.setProperty('display', 'none', 'important'); | |||
} else if (node.getAttribute('data-clbi-title-hidden') === 'true') { | |||
node.removeAttribute('data-clbi-title-hidden'); | |||
node.style.removeProperty('display'); | |||
} | } | ||
}); | |||
} | |||
function applyPageShellClasses() { | |||
var body = document.body; | |||
var isSystemPage; | |||
if ( | if (!body) return; | ||
isSystemPage = isBackendOrSystemPageForShell(); | |||
body.classList.remove('page-title-hidden', 'page-title-visible', 'backend-system-page', 'anecdote-namespace-page'); | |||
if (!isMediaWikiSystemAssetPageForShell()) { | |||
body.classList.remove('clbi-system-doc-page'); | |||
removeSystemDocIndicatorForShell(); | |||
} | |||
if (isAnecdoteNamespaceForShell()) { | |||
body.classList.add('anecdote-namespace-page'); | |||
} | } | ||
if (isMediaWikiSystemAssetPageForShell()) { | |||
body.classList.add('page-title-hidden', 'backend-system-page', 'clbi-system-doc-page'); | |||
setPageTitleDomHidden(true); | |||
renderSystemDocIndicatorForShell(); | |||
} else if (isSystemPage) { | |||
body.classList.add('page-title-visible', 'backend-system-page'); | |||
setPageTitleDomHidden(false); | |||
} else { | |||
body.classList.add('page-title-hidden'); | |||
setPageTitleDomHidden(true); | |||
} | |||
} | } | ||
function applyPageShellClassesDeferred() { | |||
applyPageShellClasses(); | |||
window.setTimeout(applyPageShellClasses, 0); | |||
window.setTimeout(applyPageShellClasses, 80); | |||
window.setTimeout(applyPageShellClasses, 250); | |||
} | } | ||
function startPageShellObserver() { | |||
var observer; | |||
if (pageShellObserverStarted || !window.MutationObserver || !document.body) return; | |||
pageShellObserverStarted = true; | |||
function | observer = new MutationObserver(function (mutations) { | ||
var i; | |||
var target; | |||
/* | |||
시스템 CSS/JS 문서는 applyPageShellClasses()가 초기에 한 번 | |||
인디케이터와 source viewer를 만든 뒤에는 MutationObserver가 다시 | |||
같은 렌더링을 반복할 필요가 없다. 이 반복이 DevTools에서 body/요소가 | |||
계속 플래시되는 직접 원인이다. | |||
SPA 전환 뒤의 처리는 loadPage()와 wikipage.content hook에서 따로 호출된다. | |||
*/ | |||
if (isMediaWikiSystemAssetPageForShell()) { | |||
for (i = 0; i < mutations.length; i += 1) { | |||
target = mutations[i] && mutations[i].target; | |||
if ( | |||
} | target && | ||
target.nodeType === 1 && | |||
( | |||
target.id === 'clbi-system-source-viewer' || | |||
target.id === 'clbi-system-doc-indicator-row' || | |||
(target.closest && target.closest('#clbi-system-source-viewer, #clbi-system-doc-indicator-row')) | |||
) | |||
) { | |||
return; | |||
} | |||
} | |||
if ( | |||
document.getElementById('clbi-system-doc-indicator-row') && | |||
document.getElementById('clbi-system-source-viewer') | |||
) { | |||
return; | |||
} | |||
} | |||
if (pageShellObserverTimer) return; | |||
pageShellObserverTimer = window.setTimeout(function () { | |||
pageShellObserverTimer = null; | |||
applyPageShellClasses(); | |||
}, 50); | |||
}); | |||
observer.observe(document.body, { | |||
childList: true, | |||
subtree: true | |||
}); | |||
} | |||
if (document.readyState === 'loading') { | |||
document.addEventListener('DOMContentLoaded', function () { | |||
applyPageShellClassesDeferred(); | |||
startPageShellObserver(); | |||
}); | |||
} else { | |||
applyPageShellClassesDeferred(); | |||
startPageShellObserver(); | |||
} | |||
if (mw.hook) { | |||
mw.hook('wikipage.content').add(applyPageShellClassesDeferred); | |||
} | |||
window.CLBI_PAGE_SHELL = { | |||
refresh: applyPageShellClasses, | |||
isBackendOrSystemPage: isBackendOrSystemPageForShell, | |||
isSystemAssetPage: isMediaWikiSystemAssetPageForShell, | |||
renderSystemDocIndicator: renderSystemDocIndicatorForShell, | |||
removeSystemDocIndicator: removeSystemDocIndicatorForShell, | |||
refreshSystemDocSourceViewer: renderSystemDocSourceViewerForShell | |||
}; | |||
}()); | |||
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 initHalftoneBackground() { | |||
try { | |||
initWebGLHalftoneBackground(); | |||
} catch (err) { | |||
console.error('WebGL halftone background failed:', err); | |||
} | |||
} | |||
function initWebGLHalftoneBackground() { | |||
var canvasId = 'site-halftone-bg'; | |||
var existing = document.getElementById(canvasId); | |||
var canvas = existing || document.createElement('canvas'); | |||
if (!existing) { | |||
} | canvas.id = canvasId; | ||
canvas.setAttribute('aria-hidden', 'true'); | |||
document.body.insertBefore(canvas, document.body.firstChild || null); | |||
} | |||
canvas.style.position = 'fixed'; | |||
canvas.style.inset = '0'; | |||
canvas.style.width = '100vw'; | |||
canvas.style.height = '100vh'; | |||
canvas.style.pointerEvents = 'none'; | |||
canvas.style.background = '#000000'; | |||
var gl = canvas.getContext('webgl', { | |||
alpha: false, | |||
antialias: false, | |||
depth: false, | |||
stencil: false, | |||
preserveDrawingBuffer: false, | |||
powerPreference: 'high-performance' | |||
}) || canvas.getContext('experimental-webgl'); | |||
if (!gl) { | |||
console.warn('WebGL background unavailable.'); | |||
return; | |||
} | |||
var vertexSrc = [ | |||
'attribute vec2 a_position;', | |||
'void main() {', | |||
' gl_Position = vec4(a_position, 0.0, 1.0);', | |||
'}' | |||
].join('\n'); | |||
var fragmentSrc = [ | |||
'precision mediump float;', | |||
'uniform vec2 u_resolution;', | |||
'uniform float u_time;', | |||
'const float TAU = 6.28318530718;', | |||
'float gaussian(float v, float r) {', | |||
' return exp(-((v * v) / max(0.0001, r * r)));', | |||
'}', | |||
'float hash(vec2 p) {', | |||
' return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123);', | |||
'}', | |||
'float bucketAlpha(float a) {', | |||
' float i = floor(a * 9.0);', | |||
' if (i < 1.0) return 0.040;', | |||
' if (i < 2.0) return 0.080;', | |||
' if (i < 3.0) return 0.135;', | |||
' if (i < 4.0) return 0.210;', | |||
' if (i < 5.0) return 0.310;', | |||
' if (i < 6.0) return 0.430;', | |||
' if (i < 7.0) return 0.580;', | |||
' if (i < 8.0) return 0.760;', | |||
' return 0.920;', | |||
'}', | |||
'void main() {', | |||
' vec2 frag = gl_FragCoord.xy;', | |||
' float spacing = 5.0;', | |||
' float dotSize = 1.08;', | |||
' vec2 grid = floor(frag / spacing);', | |||
' vec2 inCell = mod(frag, spacing);', | |||
' vec2 dotOrigin = vec2(1.0, 1.0);', | |||
' vec2 dotCenter = dotOrigin + vec2(dotSize * 0.5);', | |||
' vec2 local = abs(inCell - dotCenter);', | |||
' float noise = hash(grid);', | |||
' float size = dotSize + noise * 0.18;', | |||
' float dotMask = 1.0 - smoothstep(size * 0.5, size * 0.5 + 0.22, max(local.x, local.y));', | |||
' vec2 uv = frag / u_resolution;', | |||
' float centerLine = 0.50 +', | |||
' sin((uv.y * 1.32 + 0.08) * TAU) * 0.070 +', | |||
' sin((uv.y * 3.18 + 0.34) * TAU) * 0.030;', | |||
' float u = uv.x - centerLine;', | |||
' float absU = abs(u);', | |||
' float sideLift = smoothstep(0.065, 0.44, absU);', | |||
' float valley = gaussian(u, 0.150);', | |||
' float t = u_time;', | |||
' float leftRibbonCenter = -0.28 + sin((uv.y * 3.20 + 0.12) * TAU) * 0.050;', | |||
' float rightRibbonCenter = 0.27 + sin((uv.y * 2.85 + 0.56) * TAU) * 0.055;', | |||
' float leftRibbon = gaussian(u - leftRibbonCenter, 0.105);', | |||
' float rightRibbon = gaussian(u - rightRibbonCenter, 0.110);', | |||
' float foldedU = u +', | |||
' sin((uv.y * 4.40 + 0.22) * TAU) * 0.050 * (0.3 + sideLift) +', | |||
' sin((uv.y * 7.20 + uv.x * 1.10) * TAU) * 0.022;', | |||
' float verticalFold = pow(0.5 + 0.5 * cos(((foldedU * 3.05) + (sin(uv.y * TAU * 2.35) * 0.18)) * TAU), 2.5);', | |||
' float diagonalFold = pow(0.5 + 0.5 * cos(((foldedU * 1.80) - (uv.y * 1.12) + 0.18) * TAU), 2.1);', | |||
' float waist = gaussian(uv.y - 0.50, 0.25) * gaussian(absU - 0.20, 0.19);', | |||
' float grain = (noise - 0.5) * 0.050;', | |||
' float staticField =', | |||
' 0.055 +', | |||
' sideLift * 0.210 +', | |||
' (leftRibbon + rightRibbon) * 0.145 +', | |||
' verticalFold * (0.055 + sideLift * 0.115) +', | |||
' diagonalFold * 0.045 +', | |||
' waist * 0.060 -', | |||
' valley * 0.150 +', | |||
' grain;', | |||
' float alpha = staticField;', | |||
' alpha += 0.115 * (leftRibbon + rightRibbon) * sin(t * 0.00030 + ((uv.y * 1.9) + sideLift * 0.4) * TAU);', | |||
' alpha += 0.095 * verticalFold * (0.4 + sideLift) * sin(t * 0.00041 + ((uv.y * 2.7) + foldedU * 0.65) * TAU);', | |||
' alpha += 0.070 * waist * sin(t * 0.00053 + ((uv.y * 3.1) - absU * 0.8) * TAU);', | |||
' alpha += 0.060 * (1.0 - valley) * diagonalFold * sin(t * 0.00067 + ((uv.y * 1.4) + uv.x * 0.6) * TAU);', | |||
' alpha += 0.038 * (0.35 + sideLift) * (0.35 + noise) * sin(t * 0.00079 + ((uv.y * 4.6) + noise * 0.8) * TAU);', | |||
' alpha = bucketAlpha(clamp(alpha, 0.025, 0.96));', | |||
' float value = alpha * dotMask;', | |||
' gl_FragColor = vec4(vec3(0.8862745 * value), 1.0);', | |||
'}' | |||
].join('\n'); | |||
function compileShader(type, source) { | |||
var shader = gl.createShader(type); | |||
gl.shaderSource(shader, source); | |||
gl.compileShader(shader); | |||
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { | |||
console.error('WebGL shader compile error:', gl.getShaderInfoLog(shader)); | |||
return; | gl.deleteShader(shader); | ||
return null; | |||
} | } | ||
return shader; | |||
} | |||
var vertexShader = compileShader(gl.VERTEX_SHADER, vertexSrc); | |||
var fragmentShader = compileShader(gl.FRAGMENT_SHADER, fragmentSrc); | |||
if (!vertexShader || !fragmentShader) return; | |||
var program = gl.createProgram(); | |||
gl.attachShader(program, vertexShader); | |||
gl.attachShader(program, fragmentShader); | |||
gl.linkProgram(program); | |||
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { | |||
console.error('WebGL program link error:', gl.getProgramInfoLog(program)); | |||
return; | |||
} | |||
var | var positionLoc = gl.getAttribLocation(program, 'a_position'); | ||
var resolutionLoc = gl.getUniformLocation(program, 'u_resolution'); | |||
var timeLoc = gl.getUniformLocation(program, 'u_time'); | |||
var | var buffer = gl.createBuffer(); | ||
gl.bindBuffer(gl.ARRAY_BUFFER, buffer); | |||
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([ | |||
-1, -1, | |||
1, -1, | |||
-1, 1, | |||
-1, 1, | |||
1, -1, | |||
1, 1 | |||
]), gl.STATIC_DRAW); | |||
gl.useProgram(program); | |||
gl.bindBuffer(gl.ARRAY_BUFFER, buffer); | |||
gl.enableVertexAttribArray(positionLoc); | |||
gl.vertexAttribPointer(positionLoc, 2, gl.FLOAT, false, 0, 0); | |||
var | function resize() { | ||
var dpr = Math.min(window.devicePixelRatio || 1, 1.5); | |||
var cssW = Math.max(1, window.innerWidth || document.documentElement.clientWidth || 1); | |||
var cssH = Math.max(1, window.innerHeight || document.documentElement.clientHeight || 1); | |||
var w = Math.max(1, Math.floor(cssW * dpr)); | |||
var h = Math.max(1, Math.floor(cssH * dpr)); | |||
if (canvas.width !== w || canvas.height !== h) { | |||
canvas.width = w; | |||
canvas.height = h; | |||
canvas.style.width = cssW + 'px'; | |||
canvas.style.height = cssH + 'px'; | |||
gl.viewport(0, 0, w, h); | |||
} | |||
} | |||
var prefersReducedMotion = false; | |||
try { | |||
prefersReducedMotion = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches; | |||
} catch (err) {} | |||
var lastFrame = 0; | |||
var frameInterval = prefersReducedMotion ? 1000 : 66; | |||
var startTime = performance.now(); | |||
function render(now) { | |||
requestAnimationFrame(render); | |||
if (document.hidden) return; | |||
if (now - lastFrame < frameInterval) return; | |||
lastFrame = now; | |||
resize(); | |||
gl.clearColor(0, 0, 0, 1); | |||
gl.clear(gl.COLOR_BUFFER_BIT); | |||
gl.uniform2f(resolutionLoc, canvas.width, canvas.height); | |||
gl.uniform1f(timeLoc, now - startTime); | |||
gl.drawArrays(gl.TRIANGLES, 0, 6); | |||
} | } | ||
resize(); | |||
requestAnimationFrame(render); | |||
} | |||
var CLBI_SVG_BELL = '<svg class="profile-svg profile-svg-bell" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10.268 21a2 2 0 0 0 3.464 0"/><path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326"/></svg>'; | |||
var CLBI_SVG_BELL_DOT = '<svg class="profile-svg profile-svg-bell-dot" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10.268 21a2 2 0 0 0 3.464 0"/><path d="M11.68 2.009A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673c-.824-.85-1.678-1.731-2.21-3.348"/><circle cx="18" cy="5" r="3"/></svg>'; | |||
var CLBI_SVG_LIST = '<svg class="profile-svg profile-svg-list" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 5h.01"/><path d="M3 12h.01"/><path d="M3 19h.01"/><path d="M8 5h13"/><path d="M8 12h13"/><path d="M8 19h13"/></svg>'; | |||
var CLBI_SVG_LANGUAGES = '<svg class="profile-svg profile-svg-languages" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m5 8 6 6"/><path d="m4 14 6-6 2-3"/><path d="M2 5h12"/><path d="M7 2h1"/><path d="m22 22-5-10-5 10"/><path d="M14 18h6"/></svg>'; | |||
var CLBI_SVG_POWER = '<svg class="profile-svg profile-svg-power" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 2v10"/><path d="M18.4 6.6a9 9 0 1 1-12.77.04"/></svg>'; | |||
var CLBI_SVG_SETTINGS = '<svg class="profile-svg profile-svg-settings" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915"/><circle cx="12" cy="12" r="3"/></svg>'; | |||
var CLBI_SVG_SCAN_TEXT = '<svg class="profile-svg profile-svg-scan-text" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 7V5a2 2 0 0 1 2-2h2"/><path d="M17 3h2a2 2 0 0 1 2 2v2"/><path d="M21 17v2a2 2 0 0 1-2 2h-2"/><path d="M7 21H5a2 2 0 0 1-2-2v-2"/><path d="M7 8h8"/><path d="M7 12h10"/><path d="M7 16h6"/></svg>'; | |||
var CLBI_SVG_SCAN_EYE = '<svg class="profile-svg profile-svg-scan-eye" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 7V5a2 2 0 0 1 2-2h2"/><path d="M17 3h2a2 2 0 0 1 2 2v2"/><path d="M21 17v2a2 2 0 0 1-2 2h-2"/><path d="M7 21H5a2 2 0 0 1-2-2v-2"/><circle cx="12" cy="12" r="1"/><path d="M18.944 12.33a1 1 0 0 0 0-.66 7.5 7.5 0 0 0-13.888 0 1 1 0 0 0 0 .66 7.5 7.5 0 0 0 13.888 0"/></svg>'; | |||
var CLBI_SVG_NEWSPAPER = '<svg class="profile-svg profile-svg-newspaper" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 18h-5"/><path d="M18 14h-8"/><path d="M4 22h16a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16a2 2 0 0 1-4 0v-9a2 2 0 0 1 2-2h2"/><rect width="8" height="4" x="10" y="6" rx="1"/></svg>'; | |||
var CLBI_SVG_TROPHY = '<svg class="profile-svg profile-svg-trophy" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 14.66v1.626a2 2 0 0 1-.976 1.696A5 5 0 0 0 7 21.978"/><path d="M14 14.66v1.626a2 2 0 0 0 .976 1.696A5 5 0 0 1 17 21.978"/><path d="M18 9h1.5a1 1 0 0 0 0-5H18"/><path d="M4 22h16"/><path d="M6 9a6 6 0 0 0 12 0V3a1 1 0 0 0-1-1H7a1 1 0 0 0-1 1z"/><path d="M6 9H4.5a1 1 0 0 1 0-5H6"/></svg>'; | |||
var CLBI_SVG_PACKAGE = '<svg class="profile-svg profile-svg-package" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3v6"/><path d="M16.76 3a2 2 0 0 1 1.8 1.1l2.23 4.479a2 2 0 0 1 .21.891V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V9.472a2 2 0 0 1 .211-.894L5.45 4.1A2 2 0 0 1 7.24 3z"/><path d="M3.054 9.013h17.893"/></svg>'; | |||
var PROFILE_RENDER_TOKEN = 0; | |||
function invalidateProfileRender() { | |||
PROFILE_RENDER_TOKEN++; | |||
} | |||
$(function() { | |||
initHalftoneBackground(); | |||
// ── 상·하단 네비게이션 바 ── | |||
function buildClbiNavHtml(position) { | |||
var isBottom = position === 'bottom'; | |||
var base = isBottom ? 'clbi-bottom' : 'clbi-top'; | |||
var shortBase = isBottom ? 'clbi-bnav' : 'clbi-tnav'; | |||
var wrapId = base + '-nav-wrap'; | |||
var navId = base + '-nav'; | |||
var mainId = base + '-nav-main'; | |||
var tabsId = base + '-nav-tabs'; | |||
var searchId = base + '-nav-search'; | |||
var inputId = isBottom ? 'clbi-bottom-search-input' : 'clbi-top-search-input'; | |||
var rightId = base + '-nav-right'; | |||
var worldId = shortBase + '-worldbuilding'; | |||
var infoId = shortBase + '-info'; | |||
var subId = isBottom ? 'clbi-bottom-sub-worldbuilding' : 'clbi-sub-worldbuilding'; | |||
var subInnerId = subId + '-inner'; | |||
return '' + | |||
'<div id="' + wrapId + '">' + | |||
'<div id="' + navId + '">' + | |||
'<div id="' + mainId + '">' + | |||
'<div id="' + tabsId + '">' + | |||
'<a class="clbi-top-nav-item" href="/index.php/대문">' + | |||
'<img class="clbi-tnav-icon" src="/index.php?title=특수:Redirect/file/Ic-main-menu-001.png" alt="">' + | |||
'<span class="clbi-tnav-label">메인 메뉴</span>' + | |||
'</a>' + | |||
'<a class="clbi-top-nav-item" href="/index.php/프로젝트:소개">' + | |||
'<img class="clbi-tnav-icon" src="/index.php?title=특수:Redirect/file/Ic-project-001.png" alt="">' + | |||
'<span class="clbi-tnav-label">프로젝트</span>' + | |||
'</a>' + | |||
'<div class="clbi-top-nav-item" id="' + worldId + '">' + | |||
'<img class="clbi-tnav-icon" src="/index.php?title=특수:Redirect/file/Ic-worldbuilding-001.png" alt="">' + | |||
'<span class="clbi-tnav-label">세계관</span>' + | |||
'<span class="clbi-tnav-arrow">▾</span>' + | |||
'</div>' + | |||
'</div>' + | |||
'<div id="' + searchId + '">' + | |||
'<input type="text" id="' + inputId + '" placeholder="검색...">' + | |||
'</div>' + | |||
'<div id="' + rightId + '">' + | |||
'<div class="clbi-top-nav-item" id="' + infoId + '">' + | |||
'<span class="clbi-tnav-label">ℹ</span>' + | |||
'</div>' + | |||
'</div>' + | |||
'</div>' + | |||
'<div id="' + subId + '">' + | |||
'<div id="' + subInnerId + '">' + | |||
'<div class="clbi-tnav-sub-list">' + | |||
'<a class="clbi-tnav-sub-item" href="/index.php/역사적_사건">역사적 사건</a>' + | |||
'<a class="clbi-tnav-sub-item" href="/index.php/설정">설정</a>' + | |||
'<a class="clbi-tnav-sub-item" href="/index.php/국가_및_조합">국가 및 조합</a>' + | |||
'<a class="clbi-tnav-sub-item" href="/index.php/기업_및_공동체">기업 및 공동체</a>' + | |||
'<a class="clbi-tnav-sub-item" href="/index.php/군_정치집단">군, 정치집단</a>' + | |||
'<a class="clbi-tnav-sub-item" href="/index.php/인물">인물</a>' + | |||
'</div>' + | |||
'</div>' + | |||
'</div>' + | |||
'</div>' + | |||
'</div>'; | |||
} | |||
function normalizeClbiShellDomOrder() { | |||
var contentWrapper = document.querySelector('.content-wrapper'); | |||
var topNav = document.getElementById('clbi-top-nav-wrap'); | |||
var bottomNav = document.getElementById('clbi-bottom-nav-wrap'); | |||
var canvas = document.getElementById('site-halftone-bg'); | |||
var anchor; | |||
var viewportH; | |||
var topRect; | |||
var wrapperRect; | |||
var needsRecovery; | |||
var expectedWrapperTop; | |||
var host; | |||
if (!contentWrapper || !topNav || !bottomNav || !document.body) return; | |||
/* | |||
CLBI shell can live inside a Liberty <section>. Some skins/layouts give that | |||
section a flow context that lets the top nav visually overlap the content | |||
wrapper even when DOM sibling order is correct. Mark the common parent and | |||
let Layout.css force a simple vertical flow for the shell. | |||
*/ | |||
host = topNav.parentElement === contentWrapper.parentElement && | |||
contentWrapper.parentElement === bottomNav.parentElement | |||
? contentWrapper.parentElement | |||
: null; | |||
if (host) { | |||
host.classList.add('clbi-shell-host'); | |||
} | |||
viewportH = window.innerHeight || document.documentElement.clientHeight || 0; | |||
topRect = topNav.getBoundingClientRect(); | |||
wrapperRect = contentWrapper.getBoundingClientRect(); | |||
expectedWrapperTop = topRect.bottom + 8; | |||
needsRecovery = false; | |||
if (viewportH > 0) { | |||
if (topRect.top >= viewportH * 0.55) needsRecovery = true; | |||
if (wrapperRect.top >= viewportH * 0.60) needsRecovery = true; | |||
} | |||
if (topRect.top > 240 || wrapperRect.top > 320) { | |||
needsRecovery = true; | |||
} | |||
if (wrapperRect.top < expectedWrapperTop - 1) { | |||
needsRecovery = true; | |||
}, | } | ||
}); | |||
if (!needsRecovery) { | |||
document.body.classList.add('clbi-shell-ready'); | |||
return; | |||
} | |||
anchor = canvas && canvas.parentNode === document.body | |||
? canvas.nextSibling | |||
: document.body.firstChild; | |||
document.body.insertBefore(topNav, anchor); | |||
document.body.insertBefore(contentWrapper, topNav.nextSibling); | |||
document.body.insertBefore(bottomNav, contentWrapper.nextSibling); | |||
document.body.classList.add('clbi-shell-ready'); | |||
} | |||
window.normalizeClbiShellDomOrder = normalizeClbiShellDomOrder; | |||
var $contentWrapper = $('.content-wrapper').first(); | |||
if ($contentWrapper.length) { | |||
$('#clbi-top-nav-wrap, #clbi-bottom-nav-wrap').remove(); | |||
$contentWrapper.before(buildClbiNavHtml('top')); | |||
$contentWrapper.after(buildClbiNavHtml('bottom')); | |||
if (typeof window.normalizeClbiShellDomOrder === 'function') window.normalizeClbiShellDomOrder(); | |||
} | } | ||
function updateClbiShellMetrics() { | |||
function | var top = document.getElementById('clbi-top-nav-wrap'); | ||
var | var bottom = document.getElementById('clbi-bottom-nav-wrap'); | ||
var | var root = document.documentElement; | ||
var topH = 0; | |||
var bottomH = 0; | |||
if (!root) return; | |||
if (top) { | |||
topH = Math.ceil(top.getBoundingClientRect().height || top.offsetHeight || 0); | |||
} | |||
if ( | if (bottom) { | ||
bottomH = Math.ceil(bottom.getBoundingClientRect().height || bottom.offsetHeight || 0); | |||
} | } | ||
root.style.setProperty('--clbi-top-nav-outer-h', topH + 'px'); | |||
root.style.setProperty('--clbi-bottom-nav-outer-h', bottomH + 'px'); | |||
} | |||
function scheduleClbiShellMetrics() { | |||
updateClbiShellMetrics(); | |||
if (typeof scheduleAdaptiveLeftRecentItems === 'function') { | |||
scheduleAdaptiveLeftRecentItems(); | |||
} | |||
if (typeof scheduleClbiContentBottomGap === 'function') { | |||
scheduleClbiContentBottomGap(); | |||
} | |||
window.setTimeout(function () { | |||
updateClbiShellMetrics(); | |||
if (typeof scheduleAdaptiveLeftRecentItems === 'function') { | |||
scheduleAdaptiveLeftRecentItems(); | |||
} | |||
if (typeof scheduleClbiContentBottomGap === 'function') { | |||
scheduleClbiContentBottomGap(); | |||
} | } | ||
}, 0); | |||
window.setTimeout(function () { | |||
updateClbiShellMetrics(); | |||
if (typeof scheduleAdaptiveLeftRecentItems === 'function') { | |||
scheduleAdaptiveLeftRecentItems(); | |||
} | |||
if (typeof scheduleClbiContentBottomGap === 'function') { | |||
scheduleClbiContentBottomGap(); | |||
} | |||
}, 80); | |||
window.setTimeout(function () { | |||
updateClbiShellMetrics(); | |||
if (typeof scheduleAdaptiveLeftRecentItems === 'function') { | |||
scheduleAdaptiveLeftRecentItems(); | |||
} | |||
if (typeof scheduleClbiContentBottomGap === 'function') { | |||
scheduleClbiContentBottomGap(); | |||
} | |||
}, 240); | |||
} | |||
function watchClbiShellMetrics() { | |||
var top = document.getElementById('clbi-top-nav-wrap'); | |||
var bottom = document.getElementById('clbi-bottom-nav-wrap'); | |||
var observer; | |||
scheduleClbiShellMetrics(); | |||
$(window).on('resize orientationchange', scheduleClbiShellMetrics); | |||
$(window).on('resize.clbiRecentViewport orientationchange.clbiRecentViewport', function () { scheduleAdaptiveLeftRecentItems(); scheduleClbiContentBottomGap(); }); | |||
$(window).on('resize.clbiContentBottomGap orientationchange.clbiContentBottomGap', scheduleClbiContentBottomGap); | |||
if (window.ResizeObserver) { | |||
observer = new ResizeObserver(scheduleClbiShellMetrics); | |||
} | if (top) observer.observe(top); | ||
if (bottom) observer.observe(bottom); | |||
window.CLBI_SHELL_RESIZE_OBSERVER = observer; | |||
} | |||
} | |||
function bindClbiWorldbuildingToggle(buttonSelector, menuSelector) { | |||
$(buttonSelector).on('click', function() { | |||
var $menu = $(menuSelector); | |||
var $btn = $(this); | |||
$menu.toggleClass('worldbuilding-open'); | |||
$btn.toggleClass('clbi-tnav-active', $menu.hasClass('worldbuilding-open')); | |||
scheduleClbiShellMetrics(); | |||
}); | |||
} | |||
bindClbiWorldbuildingToggle('#clbi-tnav-worldbuilding', '#clbi-sub-worldbuilding'); | |||
bindClbiWorldbuildingToggle('#clbi-bnav-worldbuilding', '#clbi-bottom-sub-worldbuilding'); | |||
$('#clbi-top-search-input, #clbi-bottom-search-input').on('keydown', function(e) { | |||
if (e.key === 'Enter') { | |||
var q = $(this).val().trim(); | |||
if (q) window.location.href = '/index.php?search=' + encodeURIComponent(q); | |||
} | } | ||
}); | |||
watchClbiShellMetrics(); | |||
}); | |||
// 페이지 전환 사운드 | |||
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 getLangShortCode(lang) { | |||
var map = { ko: 'KR', en: 'EN', zh: 'ZH', ja: 'JA', ru: 'RU', es: 'ES' }; | |||
return map[lang] || String(lang || '').toUpperCase(); | |||
} | |||
function getLanguageTargetTitle(lang) { | |||
var data = document.getElementById('clbi-lang-data'); | |||
if (!data || !lang) return ''; | |||
var keys = [ | |||
'data-' + lang, | |||
'data-page-' + lang, | |||
'data-title-' + lang, | |||
'data-target-' + lang, | |||
'data-lang-' + lang | |||
]; | |||
for (var i = 0; i < keys.length; i++) { | |||
var value = data.getAttribute(keys[i]); | |||
if (value) return value; | |||
} | |||
return ''; | |||
} | |||
var SIDEBAR_LANG_SVG_NS = 'http://www.w3.org/2000/svg'; | |||
var SIDEBAR_LANGUAGE_STATUS_TITLE = 'MediaWiki:LanguageStatus.json'; | |||
var SIDEBAR_LANGUAGE_LABELS = { | |||
ko: '한국어', | |||
en: 'English', | |||
zh: '中文', | |||
ja: '日本語', | |||
ru: 'Русский', | |||
es: 'Español' | |||
}; | |||
var SIDEBAR_LANGUAGE_DIAL_LABELS = { | |||
ko: '한국어', | |||
en: 'ENG', | |||
zh: '中文', | |||
ja: '日本語', | |||
ru: 'РУС', | |||
es: 'ESP' | |||
}; | |||
var SIDEBAR_LANGUAGE_STATUS_VALUES = { | |||
available: true, | |||
wip: true, | |||
unavailable: true | |||
}; | |||
var sidebarLanguageStatusRegistry = {}; | |||
var sidebarLanguageStatusLoaded = false; | |||
var sidebarLanguageStatusLoading = false; | |||
var sidebarLanguageStatusCallbacks = []; | |||
var sidebarLanguageState = { | |||
order: ['ko', 'en', 'zh', 'ja', 'ru', 'es'], | |||
currentLang: 'ko', | |||
baseIndex: 0, | |||
selectedIndex: 0, | |||
rotation: 0, | |||
dragging: false, | |||
dragMoved: false, | |||
dragStartX: 0, | |||
dragStartY: 0, | |||
dragStartRotation: 0, | |||
dragAxis: null, | |||
pointerCaptured: false, | |||
lastX: 0, | |||
lastTime: 0, | |||
releaseVelocity: 0, | |||
suppressClickUntil: 0, | |||
raf: null, | |||
pendingRotation: 0, | |||
snapTimer: null, | |||
inertiaRaf: null, | |||
navigateTimer: null, | |||
bound: false, | |||
boundElement: null, | |||
rotor: null, | |||
cx: 101, | |||
cy: 119, | |||
outerR: 109, | |||
innerR: 28, | |||
sectorAngle: 30, | |||
halfSector: 15, | |||
repeats: 8, | |||
dragSensitivity: 0.58, | |||
maxSpinVelocity: 1.75, | |||
minSpinVelocity: 0.055, | |||
spinDecel: 0.00185 | |||
}; | |||
function createSidebarLanguageSvgEl(tag) { | |||
return document.createElementNS(SIDEBAR_LANG_SVG_NS, tag); | |||
} | |||
function normalizeSidebarLanguageIndex(index) { | |||
var length = sidebarLanguageState.order.length; | |||
var normalized = index % length; | |||
return normalized < 0 ? normalized + length : normalized; | |||
} | |||
function getSidebarLanguageName(lang) { | |||
return SIDEBAR_LANGUAGE_LABELS[lang] || String(lang || '').toUpperCase(); | |||
} | |||
function getSidebarLanguageDialName(lang) { | |||
return SIDEBAR_LANGUAGE_DIAL_LABELS[lang] || getSidebarLanguageName(lang); | |||
} | |||
function normalizeSidebarLanguageStatusValue(value) { | |||
value = String(value == null ? '' : value).toLowerCase().trim(); | |||
return SIDEBAR_LANGUAGE_STATUS_VALUES[value] ? value : ''; | |||
} | |||
function getSidebarLanguageStatusPageKey() { | |||
var raw = String(mw.config.get('wgPageName') || '').trim(); | |||
var normalized = normalizePageName(raw); | |||
return normalized || raw || '대문'; | |||
} | |||
var | function getSidebarLanguageStatusEntry() { | ||
var registry = sidebarLanguageStatusRegistry || {}; | |||
var pages = registry.pages && typeof registry.pages === 'object' ? registry.pages : registry; | |||
var raw = String(mw.config.get('wgPageName') || '').trim(); | |||
var normalized = normalizePageName(raw); | |||
var title = String(mw.config.get('wgTitle') || '').trim(); | |||
var keys = [ | |||
normalized, | |||
raw, | |||
raw.replace(/_/g, ' '), | |||
normalized.replace(/ /g, '_'), | |||
title, | |||
title.replace(/_/g, ' ') | |||
]; | |||
var i; | |||
for (i = 0; i < keys.length; i += 1) { | |||
if (keys[i] && pages[keys[i]] && typeof pages[keys[i]] === 'object') { | |||
return pages[keys[i]]; | |||
} | |||
} | |||
return {}; | |||
} | |||
var | function getSidebarLanguageStatusOverride(lang) { | ||
var entry = getSidebarLanguageStatusEntry(); | |||
return normalizeSidebarLanguageStatusValue(entry[lang]); | |||
} | |||
function flushSidebarLanguageStatusCallbacks() { | |||
var callbacks = sidebarLanguageStatusCallbacks.slice(); | |||
sidebarLanguageStatusCallbacks.length = 0; | |||
callbacks.forEach(function(callback) { | |||
if (typeof callback === 'function') { | |||
callback(sidebarLanguageStatusRegistry); | |||
} | |||
}); | |||
} | |||
function loadSidebarLanguageStatusRegistry(callback, force) { | |||
if (typeof callback === 'function') { | |||
sidebarLanguageStatusCallbacks.push(callback); | |||
} | |||
if (sidebarLanguageStatusLoaded && !force) { | |||
flushSidebarLanguageStatusCallbacks(); | |||
return; | |||
} | } | ||
if ( | if (sidebarLanguageStatusLoading) return; | ||
sidebarLanguageStatusLoading = true; | |||
$.ajax({ | |||
url: mw.util.getUrl(SIDEBAR_LANGUAGE_STATUS_TITLE, { | |||
action: 'raw', | |||
ctype: 'application/json', | |||
_: String(Date.now()) | |||
}), | |||
dataType: 'text', | |||
cache: false | |||
}).done(function(text) { | |||
var parsed = {}; | |||
try { | |||
parsed = text ? JSON.parse(text) : {}; | |||
} catch (err) { | |||
console.error('LanguageStatus.json parse failed:', err); | |||
parsed = {}; | |||
} | |||
sidebarLanguageStatusRegistry = parsed && typeof parsed === 'object' ? parsed : {}; | |||
}).fail(function() { | |||
sidebarLanguageStatusRegistry = {}; | |||
}).always(function() { | |||
sidebarLanguageStatusLoaded = true; | |||
sidebarLanguageStatusLoading = false; | |||
flushSidebarLanguageStatusCallbacks(); | |||
}); | }); | ||
} | |||
window.CLBI_LANGUAGE_STATUS = { | |||
}); | title: SIDEBAR_LANGUAGE_STATUS_TITLE, | ||
languages: sidebarLanguageState.order.slice(), | |||
labels: SIDEBAR_LANGUAGE_LABELS, | |||
dialLabels: SIDEBAR_LANGUAGE_DIAL_LABELS, | |||
getPageKey: getSidebarLanguageStatusPageKey, | |||
getRegistry: function() { | |||
return sidebarLanguageStatusRegistry || {}; | |||
}, | |||
reload: function(callback) { | |||
sidebarLanguageStatusLoaded = false; | |||
loadSidebarLanguageStatusRegistry(function() { | |||
renderSidebarLanguageBox(); | |||
if (typeof callback === 'function') callback(sidebarLanguageStatusRegistry); | |||
}, true); | |||
}, | |||
refreshDial: function() { | |||
renderSidebarLanguageBox(); | |||
} | |||
}; | |||
function getSidebarLanguageMeta(lang) { | |||
var currentLang = getCurrentLang(); | |||
var targetTitle = getLanguageTargetTitle(lang); | |||
var isCurrent = lang === currentLang; | |||
return { | |||
lang: lang, | |||
code: getLangShortCode(lang), | |||
name: getSidebarLanguageName(lang), | |||
dialName: getSidebarLanguageDialName(lang), | |||
targetTitle: targetTitle, | |||
isCurrent: isCurrent, | |||
canMove: !!targetTitle && !isCurrent | |||
}; | |||
} | |||
function getSidebarLanguageStatus(meta) { | |||
var override; | |||
if (!meta) { | |||
return { | |||
className: 'is-locked', | |||
label: 'UNAVAILABLE', | |||
canApply: false | |||
}; | |||
} | |||
if (meta.isCurrent) { | |||
return { | |||
className: 'is-current', | |||
label: 'CURRENT', | |||
canApply: false | |||
}; | |||
} | |||
override = getSidebarLanguageStatusOverride(meta.lang); | |||
if (override === 'wip') { | |||
return { | |||
className: 'is-locked', | |||
label: 'WIP', | |||
canApply: false | |||
}; | |||
} | |||
if (override === 'unavailable') { | |||
return { | |||
className: 'is-locked', | |||
label: 'UNAVAILABLE', | |||
canApply: false | |||
}; | |||
} | |||
if (override === 'available' || meta.targetTitle) { | |||
return { | |||
className: meta.targetTitle ? 'is-ready' : 'is-locked', | |||
label: meta.targetTitle ? 'AVAILABLE' : 'UNAVAILABLE', | |||
canApply: !!meta.targetTitle | |||
}; | |||
} | |||
return { | |||
className: 'is-locked', | |||
label: 'UNAVAILABLE', | |||
canApply: false | |||
}; | |||
} | |||
function sidebarLanguageRad(deg) { | |||
return (deg * Math.PI) / 180; | |||
} | |||
function sidebarLanguagePointAt(radius, deg) { | |||
var state = sidebarLanguageState; | |||
} | var angle = sidebarLanguageRad(deg); | ||
} | |||
return { | |||
x: state.cx + Math.sin(angle) * radius, | |||
y: state.cy - Math.cos(angle) * radius | |||
}; | |||
} | |||
function getSidebarLanguageSectorPath(start, end) { | |||
function | var state = sidebarLanguageState; | ||
var | var p1 = sidebarLanguagePointAt(state.outerR, start); | ||
var | var p2 = sidebarLanguagePointAt(state.outerR, end); | ||
var | var p3 = sidebarLanguagePointAt(state.innerR, end); | ||
var p4 = sidebarLanguagePointAt(state.innerR, start); | |||
var largeArc = Math.abs(end - start) > 180 ? 1 : 0; | |||
return [ | |||
'M', p1.x.toFixed(3), p1.y.toFixed(3), | |||
'A', state.outerR, state.outerR, 0, largeArc, 1, p2.x.toFixed(3), p2.y.toFixed(3), | |||
'L', p3.x.toFixed(3), p3.y.toFixed(3), | |||
'A', state.innerR, state.innerR, 0, largeArc, 0, p4.x.toFixed(3), p4.y.toFixed(3), | |||
'Z' | |||
].join(' '); | |||
} | } | ||
function getSidebarLanguageShellPath() { | |||
return getSidebarLanguageSectorPath(-68, 68); | |||
function | |||
return ( | |||
} | } | ||
function | function getSidebarLanguageByStep(step) { | ||
var state = sidebarLanguageState; | |||
var index = normalizeSidebarLanguageIndex(state.baseIndex + step); | |||
return { | |||
index: index, | |||
meta: getSidebarLanguageMeta(state.order[index]) | |||
}; | |||
} | |||
function getSidebarLanguagePreviewIndex() { | |||
var state = sidebarLanguageState; | |||
var step = Math.round(-state.rotation / state.sectorAngle); | |||
return normalizeSidebarLanguageIndex(state.baseIndex + step); | |||
} | } | ||
function | function getSidebarLanguagePreviewMeta() { | ||
var state = sidebarLanguageState; | |||
return getSidebarLanguageMeta(state.order[getSidebarLanguagePreviewIndex()]); | |||
} | |||
} | |||
function | function makeSidebarLanguageSector(step) { | ||
var | var state = sidebarLanguageState; | ||
var item = getSidebarLanguageByStep(step); | |||
var group = createSidebarLanguageSvgEl('g'); | |||
var path = createSidebarLanguageSvgEl('path'); | |||
var label = createSidebarLanguageSvgEl('text'); | |||
var labelY = state.cy - 78; | |||
var angle = step * state.sectorAngle; | |||
group.setAttribute('class', 'sidebar-lang-sector-group'); | |||
group.setAttribute('data-step', String(step)); | |||
group.setAttribute('data-index', String(item.index)); | |||
group.setAttribute('data-lang', item.meta.lang); | |||
group.setAttribute('transform', 'rotate(' + angle + ' ' + state.cx + ' ' + state.cy + ')'); | |||
path.setAttribute('class', 'sidebar-lang-sector'); | |||
path.setAttribute('d', getSidebarLanguageSectorPath(-state.halfSector, state.halfSector)); | |||
label.setAttribute('class', 'sidebar-lang-sector-label'); | |||
label.setAttribute('x', String(state.cx)); | |||
label.setAttribute('y', String(labelY + 5)); | |||
label.textContent = item.meta.dialName || item.meta.name; | |||
group.appendChild(path); | |||
group.appendChild(label); | |||
group.addEventListener('click', function(e) { | |||
if (sidebarLanguageState.dragging || performance.now() < sidebarLanguageState.suppressClickUntil) return; | |||
e.preventDefault(); | |||
e.stopPropagation(); | |||
cancelSidebarLanguageSpin(); | |||
snapSidebarLanguageToStep(parseInt(group.getAttribute('data-step') || '0', 10), true); | |||
}); | |||
return group; | |||
} | |||
function renderSidebarLanguageWheel() { | |||
var state = sidebarLanguageState; | |||
var fan = document.getElementById('clbi-sidebar-lang-fan'); | |||
var svg; | |||
var defs; | |||
var clip; | |||
var clipPath; | |||
var shadowBlur; | |||
var blur; | |||
var fixedDepthGradient; | |||
var shell; | |||
var clipped; | |||
var rotor; | |||
var fixedDepthPath; | |||
var fixedFocus; | |||
var shadowSoft; | |||
var shadowHard; | |||
var rim; | |||
var pointer; | |||
var tri; | |||
var line; | |||
var step; | |||
if (!fan) return; | |||
fan.innerHTML = ''; | |||
svg = createSidebarLanguageSvgEl('svg'); | |||
svg.setAttribute('class', 'sidebar-lang-fan-svg'); | |||
svg.setAttribute('viewBox', '0 0 202 150'); | |||
svg.setAttribute('preserveAspectRatio', 'xMidYMid meet'); | |||
svg.setAttribute('role', 'img'); | |||
svg.setAttribute('aria-label', '언어 선택 다이얼'); | |||
defs = createSidebarLanguageSvgEl('defs'); | |||
clip = createSidebarLanguageSvgEl('clipPath'); | |||
clip.setAttribute('id', 'clbi-sidebar-language-fan-clip'); | |||
clipPath = createSidebarLanguageSvgEl('path'); | |||
clipPath.setAttribute('d', getSidebarLanguageShellPath()); | |||
clip.appendChild(clipPath); | |||
shadowBlur = createSidebarLanguageSvgEl('filter'); | |||
shadowBlur.setAttribute('id', 'clbi-sidebar-language-shadow-blur'); | |||
shadowBlur.setAttribute('x', '-20%'); | |||
shadowBlur.setAttribute('y', '-20%'); | |||
shadowBlur.setAttribute('width', '140%'); | |||
shadowBlur.setAttribute('height', '140%'); | |||
blur = createSidebarLanguageSvgEl('feGaussianBlur'); | |||
blur.setAttribute('stdDeviation', '3'); | |||
shadowBlur.appendChild(blur); | |||
fixedDepthGradient = createSidebarLanguageSvgEl('linearGradient'); | |||
fixedDepthGradient.setAttribute('id', 'clbi-sidebar-language-fixed-depth'); | |||
fixedDepthGradient.setAttribute('x1', '0'); | |||
fixedDepthGradient.setAttribute('y1', '0'); | |||
fixedDepthGradient.setAttribute('x2', '0'); | |||
fixedDepthGradient.setAttribute('y2', '1'); | |||
[ | |||
['0%', '#ffffff', '0.030'], | |||
['34%', '#ffffff', '0.006'], | |||
} | ['58%', '#000000', '0.030'], | ||
['100%', '#000000', '0.250'] | |||
].forEach(function(item) { | |||
var stop = createSidebarLanguageSvgEl('stop'); | |||
stop.setAttribute('offset', item[0]); | |||
stop.setAttribute('stop-color', item[1]); | |||
stop.setAttribute('stop-opacity', item[2]); | |||
fixedDepthGradient.appendChild(stop); | |||
}); | |||
defs.appendChild(clip); | |||
defs.appendChild(shadowBlur); | |||
defs.appendChild(fixedDepthGradient); | |||
svg.appendChild(defs); | |||
shell = createSidebarLanguageSvgEl('path'); | |||
shell.setAttribute('class', 'sidebar-lang-shell'); | |||
shell.setAttribute('d', getSidebarLanguageShellPath()); | |||
svg.appendChild(shell); | |||
clipped = createSidebarLanguageSvgEl('g'); | |||
clipped.setAttribute('clip-path', 'url(#clbi-sidebar-language-fan-clip)'); | |||
rotor = createSidebarLanguageSvgEl('g'); | |||
rotor.setAttribute('id', 'clbi-sidebar-lang-wheel-rotor'); | |||
rotor.setAttribute('class', 'sidebar-lang-wheel-rotor'); | |||
for (step = -state.repeats; step <= state.repeats; step += 1) { | |||
rotor.appendChild(makeSidebarLanguageSector(step)); | |||
} | } | ||
clipped.appendChild(rotor); | |||
svg.appendChild(clipped); | |||
fixedDepthPath = createSidebarLanguageSvgEl('path'); | |||
fixedDepthPath.setAttribute('class', 'sidebar-lang-fixed-depth'); | |||
fixedDepthPath.setAttribute('d', getSidebarLanguageShellPath()); | |||
svg.appendChild(fixedDepthPath); | |||
fixedFocus = createSidebarLanguageSvgEl('path'); | |||
fixedFocus.setAttribute('class', 'sidebar-lang-fixed-focus'); | |||
fixedFocus.setAttribute('d', getSidebarLanguageSectorPath(-state.halfSector, state.halfSector)); | |||
svg.appendChild(fixedFocus); | |||
shadowSoft = createSidebarLanguageSvgEl('path'); | |||
shadowSoft.setAttribute('class', 'sidebar-lang-inner-shadow-soft'); | |||
shadowSoft.setAttribute('d', getSidebarLanguageShellPath()); | |||
svg.appendChild(shadowSoft); | |||
shadowHard = createSidebarLanguageSvgEl('path'); | |||
shadowHard.setAttribute('class', 'sidebar-lang-inner-shadow-hard'); | |||
shadowHard.setAttribute('d', getSidebarLanguageShellPath()); | |||
svg.appendChild(shadowHard); | |||
rim = createSidebarLanguageSvgEl('path'); | |||
rim.setAttribute('class', 'sidebar-lang-rim'); | |||
rim.setAttribute('d', getSidebarLanguageShellPath()); | |||
svg.appendChild(rim); | |||
pointer = createSidebarLanguageSvgEl('g'); | |||
pointer.setAttribute('class', 'sidebar-lang-fixed-pointer'); | |||
pointer.setAttribute('clip-path', 'url(#clbi-sidebar-language-fan-clip)'); | |||
tri = createSidebarLanguageSvgEl('path'); | |||
tri.setAttribute('class', 'sidebar-lang-pointer-triangle'); | |||
tri.setAttribute('d', 'M ' + (state.cx - 10) + ' 10 L ' + (state.cx + 10) + ' 10 L ' + state.cx + ' 26 Z'); | |||
pointer.appendChild(tri); | |||
line = createSidebarLanguageSvgEl('line'); | |||
line.setAttribute('class', 'sidebar-lang-pointer-line'); | |||
line.setAttribute('x1', String(state.cx)); | |||
line.setAttribute('x2', String(state.cx)); | |||
line.setAttribute('y1', '24'); | |||
line.setAttribute('y2', '112'); | |||
pointer.appendChild(line); | |||
svg.appendChild(pointer); | |||
fan.appendChild(svg); | |||
state.rotor = rotor; | |||
setSidebarLanguageRotation(state.rotation, false); | |||
} | } | ||
function | function updateSidebarLanguageDial() { | ||
var | var state = sidebarLanguageState; | ||
var meta = getSidebarLanguagePreviewMeta(); | |||
var status = getSidebarLanguageStatus(meta); | |||
var selector = document.getElementById('clbi-sidebar-lang-selector'); | |||
var apply = document.getElementById('clbi-sidebar-lang-apply'); | |||
var selectedValue = document.getElementById('clbi-sidebar-lang-selected-value'); | |||
var availabilityPanel = document.getElementById('clbi-sidebar-lang-availability-panel'); | |||
var availabilityValue = document.getElementById('clbi-sidebar-lang-availability-value'); | |||
if (selectedValue) { | |||
selectedValue.textContent = meta.name; | |||
} | |||
} | if (availabilityPanel) { | ||
availabilityPanel.classList.remove('is-ready', 'is-current', 'is-locked'); | |||
availabilityPanel.classList.add(status.className); | |||
} | |||
} | |||
if (availabilityValue) { | |||
availabilityValue.textContent = status.label; | |||
} | |||
if ( | if (apply) { | ||
apply.classList.toggle('is-disabled', !status.canApply); | |||
apply.setAttribute('aria-disabled', status.canApply ? 'false' : 'true'); | |||
apply.setAttribute('aria-label', status.canApply ? (meta.name + ' 적용') : (meta.isCurrent ? '현재 언어' : '사용할 수 없는 언어')); | |||
} | |||
if ( | if (selector) { | ||
selector.setAttribute('data-selected-lang', meta.lang); | |||
selector.setAttribute('data-selected-code', meta.code); | |||
selector.classList.toggle('is-current', meta.isCurrent); | |||
selector.classList.toggle('is-ready', status.canApply); | |||
selector.classList.toggle('is-locked', !status.canApply && !meta.isCurrent); | |||
selector.classList.toggle('is-dragging', !!state.dragging); | |||
selector.classList.toggle('is-spinning', !!state.inertiaRaf); | |||
} | |||
return { | |||
meta: meta, | |||
status: status | |||
}; | |||
} | |||
function setSidebarLanguageRotation(value, animate) { | |||
} else { | var state = sidebarLanguageState; | ||
state.rotation = value; | |||
updateSidebarLanguageDial(); | |||
if (!state.rotor) return; | |||
if (animate) { | |||
$('#clbi-sidebar-lang-selector').addClass('is-snapping'); | |||
} else { | |||
$('#clbi-sidebar-lang-selector').removeClass('is-snapping'); | |||
} | } | ||
state.rotor.style.transform = 'rotate(' + state.rotation.toFixed(3) + 'deg)'; | |||
} | } | ||
function requestSidebarLanguageRotation(value) { | |||
function | var state = sidebarLanguageState; | ||
state.pendingRotation = value; | |||
if (state.raf) return; | |||
state.raf = requestAnimationFrame(function() { | |||
state.raf = null; | |||
setSidebarLanguageRotation(state.pendingRotation, false); | |||
}); | |||
} | |||
function cancelSidebarLanguageSpin() { | |||
var state = sidebarLanguageState; | |||
if (state.inertiaRaf) { | |||
cancelAnimationFrame(state.inertiaRaf); | |||
state.inertiaRaf = null; | |||
} | |||
$('#clbi-sidebar-lang-selector').removeClass('is-spinning'); | |||
} | } | ||
function | function finishSidebarLanguageSnap(nearestIndex, callback) { | ||
var | var state = sidebarLanguageState; | ||
state.baseIndex = normalizeSidebarLanguageIndex(nearestIndex); | |||
state.selectedIndex = state.baseIndex; | |||
state.rotation = 0; | |||
state.dragging = false; | |||
$('#clbi-sidebar-lang-selector').removeClass('is-snapping is-dragging is-spinning'); | |||
renderSidebarLanguageWheel(); | |||
updateSidebarLanguageDial(); | |||
if (typeof callback === 'function') { | |||
if ( | callback(getSidebarLanguageMeta(state.order[state.selectedIndex])); | ||
} | } | ||
} | |||
function snapSidebarLanguageToStep(step, animate, callback) { | |||
var state = sidebarLanguageState; | |||
var targetRotation = -step * state.sectorAngle; | |||
var nearestIndex = normalizeSidebarLanguageIndex(state.baseIndex + step); | |||
cancelSidebarLanguageSpin(); | |||
clearTimeout(state.snapTimer); | |||
state.selectedIndex = nearestIndex; | |||
setSidebarLanguageRotation(targetRotation, !!animate); | |||
state.snapTimer = setTimeout(function() { | |||
finishSidebarLanguageSnap(nearestIndex, callback); | |||
}, animate ? 230 : 0); | |||
} | |||
function snapSidebarLanguageNearest(callback) { | |||
var state = sidebarLanguageState; | |||
var step = Math.round(-state.rotation / state.sectorAngle); | |||
snapSidebarLanguageToStep(step, true, callback); | |||
} | } | ||
function | function startSidebarLanguageInertiaSpin(initialVelocity) { | ||
var | var state = sidebarLanguageState; | ||
var | var velocity; | ||
var lastFrame; | |||
cancelSidebarLanguageSpin(); | |||
velocity = Math.max(-state.maxSpinVelocity, Math.min(state.maxSpinVelocity, initialVelocity)); | |||
if (Math.abs(velocity) < state.minSpinVelocity) { | |||
snapSidebarLanguageNearest(); | |||
return; | |||
} | } | ||
$('#clbi-sidebar-lang-selector').addClass('is-spinning'); | |||
var | lastFrame = performance.now(); | ||
var | |||
var | function frame(now) { | ||
var dt = Math.min(34, Math.max(1, now - lastFrame)); | |||
var sign = velocity < 0 ? -1 : 1; | |||
var nextSpeed; | |||
lastFrame = now; | |||
state.rotation += velocity * dt; | |||
setSidebarLanguageRotation(state.rotation, false); | |||
nextSpeed = Math.max(0, Math.abs(velocity) - (state.spinDecel * dt)); | |||
velocity = sign * nextSpeed; | |||
if ( | if (nextSpeed <= state.minSpinVelocity) { | ||
state.inertiaRaf = null; | |||
$('#clbi-sidebar-lang-selector').removeClass('is-spinning'); | |||
snapSidebarLanguageNearest(); | |||
return; | |||
} | } | ||
state.inertiaRaf = requestAnimationFrame(frame); | |||
} | |||
state.inertiaRaf = requestAnimationFrame(frame); | |||
} | } | ||
function | function scheduleSidebarLanguageNavigation(meta) { | ||
var | var status = getSidebarLanguageStatus(meta); | ||
if (!meta || !status.canApply) return; | |||
clearTimeout(sidebarLanguageState.navigateTimer); | |||
sidebarLanguageState.navigateTimer = setTimeout(function() { | |||
var title = getLanguageTargetTitle(meta.lang); | |||
if (!title || meta.lang === getCurrentLang()) return; | |||
window.location.href = buildWikiPath(title); | |||
}, 70); | |||
} | |||
function setSidebarLanguageSelection(lang) { | |||
var state = sidebarLanguageState; | |||
var index = state.order.indexOf(lang); | |||
if (index < 0) index = state.order.indexOf(getCurrentLang()); | |||
if (index < 0) index = 0; | |||
if (state.raf) { | |||
cancelAnimationFrame(state.raf); | |||
state.raf = null; | |||
} | } | ||
cancelSidebarLanguageSpin(); | |||
clearTimeout(state.snapTimer); | |||
state.currentLang = lang; | |||
state.baseIndex = index; | |||
state.selectedIndex = index; | |||
state.rotation = 0; | |||
state.dragging = false; | |||
state.dragMoved = false; | |||
state.releaseVelocity = 0; | |||
renderSidebarLanguageWheel(); | |||
updateSidebarLanguageDial(); | |||
} | |||
function moveSidebarLanguageSelection(delta) { | |||
snapSidebarLanguageToStep(-delta, true); | |||
} | } | ||
function | function bindSidebarLanguageSelector() { | ||
var | var state = sidebarLanguageState; | ||
var selector = document.getElementById('clbi-sidebar-lang-selector'); | |||
var fan = document.getElementById('clbi-sidebar-lang-fan'); | |||
var apply = document.getElementById('clbi-sidebar-lang-apply'); | |||
if (!selector || !fan || !apply) return; | |||
if (state.bound && state.boundElement === selector) return; | |||
state.bound = true; | |||
state.boundElement = selector; | |||
fan.addEventListener('pointerdown', function(e) { | |||
cancelSidebarLanguageSpin(); | |||
clearTimeout(state.snapTimer); | |||
state.dragging = true; | |||
state.dragMoved = false; | |||
state.dragStartX = e.clientX; | |||
state.dragStartY = e.clientY || 0; | |||
state.dragStartRotation = state.rotation; | |||
state.dragAxis = null; | |||
state.pointerCaptured = false; | |||
state.lastX = e.clientX; | |||
state.lastTime = performance.now(); | |||
state.releaseVelocity = 0; | |||
selector.classList.add('is-dragging'); | |||
selector.classList.remove('is-snapping'); | |||
/* | |||
Vertical page scrolling must stay available when the pointer starts on | |||
the language dial. Capture and preventDefault are delayed until a | |||
horizontal drag is confirmed. | |||
*/ | |||
}); | }); | ||
function | fan.addEventListener('pointermove', function(e) { | ||
var now; | |||
var totalDx; | |||
var totalDy; | |||
var frameDx; | |||
var dt; | |||
var instantVelocity; | |||
if (!state.dragging) return; | |||
totalDx = e.clientX - state.dragStartX; | |||
. | totalDy = (e.clientY || 0) - state.dragStartY; | ||
if (!state.dragAxis && (Math.abs(totalDx) > 4 || Math.abs(totalDy) > 4)) { | |||
state.dragAxis = Math.abs(totalDx) >= Math.abs(totalDy) ? 'x' : 'y'; | |||
if ( | if (state.dragAxis === 'y') { | ||
state.dragging = false; | |||
state.dragMoved = false; | |||
state.dragAxis = null; | |||
state.pointerCaptured = false; | |||
selector.classList.remove('is-dragging'); | |||
return; | |||
} | } | ||
if ( | if (fan.setPointerCapture && e.pointerId != null) { | ||
try { | |||
fan.setPointerCapture(e.pointerId); | |||
state.pointerCaptured = true; | |||
} catch (err) { | |||
state.pointerCaptured = false; | |||
} | |||
} | } | ||
}); | } | ||
if (state.dragAxis !== 'x') return; | |||
now = performance.now(); | |||
frameDx = e.clientX - state.lastX; | |||
dt = Math.max(1, now - state.lastTime); | |||
if (Math.abs(totalDx) > 3) state.dragMoved = true; | |||
instantVelocity = (frameDx * state.dragSensitivity) / dt; | |||
state.releaseVelocity = (state.releaseVelocity * 0.62) + (instantVelocity * 0.38); | |||
. | state.lastX = e.clientX; | ||
state.lastTime = now; | |||
requestSidebarLanguageRotation(state.dragStartRotation + totalDx * state.dragSensitivity); | |||
e.preventDefault(); | |||
e.stopPropagation(); | |||
}); | |||
function finishDrag(e) { | |||
var velocityAge; | |||
var throwVelocity; | |||
var wasHorizontal; | |||
if (!state.dragging) return; | |||
wasHorizontal = state.dragAxis === 'x'; | |||
state.dragging = false; | |||
selector.classList.remove('is-dragging'); | |||
if (fan.releasePointerCapture && state.pointerCaptured && e && e.pointerId != null) { | |||
try { fan.releasePointerCapture(e.pointerId); } catch (err) {} | |||
} | |||
} | state.pointerCaptured = false; | ||
state.dragAxis = null; | |||
if (!wasHorizontal && !state.dragMoved) { | |||
return; | |||
} | |||
velocityAge = performance.now() - state.lastTime; | |||
throwVelocity = velocityAge > 120 ? 0 : state.releaseVelocity; | |||
if (state.dragMoved) { | |||
state.suppressClickUntil = performance.now() + 180; | |||
} | |||
if (state.dragMoved && Math.abs(throwVelocity) >= state.minSpinVelocity) { | |||
startSidebarLanguageInertiaSpin(throwVelocity); | |||
} else { | |||
snapSidebarLanguageNearest(); | |||
} | |||
if (e) { | |||
e.preventDefault(); | e.preventDefault(); | ||
e.stopPropagation(); | e.stopPropagation(); | ||
} | |||
} | |||
fan.addEventListener('pointerup', finishDrag); | |||
fan.addEventListener('pointercancel', finishDrag); | |||
fan.addEventListener('lostpointercapture', function() { | |||
if (!state.dragging) return; | |||
state.dragging = false; | |||
state.pointerCaptured = false; | |||
state.dragAxis = null; | |||
selector.classList.remove('is-dragging'); | |||
if (state.dragMoved && Math.abs(state.releaseVelocity) >= state.minSpinVelocity) { | |||
state.suppressClickUntil = performance.now() + 180; | |||
. | startSidebarLanguageInertiaSpin(state.releaseVelocity); | ||
} else { | |||
snapSidebarLanguageNearest(); | |||
} | |||
}); | |||
apply.addEventListener('click', function(e) { | |||
e.preventDefault(); | |||
e.stopPropagation(); | |||
snapSidebarLanguageNearest(function(meta) { | |||
scheduleSidebarLanguageNavigation(meta); | |||
}); | }); | ||
} | }); | ||
selector.addEventListener('keydown', function(e) { | |||
function | if (e.key === 'ArrowLeft') { | ||
moveSidebarLanguageSelection(-1); | |||
e.preventDefault(); | |||
} | |||
if (e.key === 'ArrowRight') { | |||
moveSidebarLanguageSelection(1); | |||
e.preventDefault(); | |||
} | |||
if (e.key === 'Enter' || e.key === ' ') { | |||
apply.click(); | |||
e.preventDefault(); | |||
} | |||
}); | |||
} | |||
function renderSidebarLanguageBox() { | |||
bindSidebarLanguageSelector(); | |||
setSidebarLanguageSelection(getCurrentLang()); | |||
if (!sidebarLanguageStatusLoaded) { | |||
loadSidebarLanguageStatusRegistry(function() { | |||
setSidebarLanguageSelection(getCurrentLang()); | |||
}); | |||
} | |||
} | } | ||
function | function loadRecentChangesList(targetSelector, limit) { | ||
var | var $target = $(targetSelector); | ||
if (! | if (!$target.length) return; | ||
var | var lang = getCurrentLang(); | ||
var | var t = (window.LANG && window.LANG[lang]) ? window.LANG[lang] : (window.LANG ? window.LANG.ko : null); | ||
var | var isNewsList = $target.closest('.clbi-left-news-box').length > 0; | ||
function escapeHtml(value) { | |||
return String(value == null ? '' : value) | |||
.replace(/&/g, '&') | |||
.replace(/</g, '<') | |||
.replace(/>/g, '>') | |||
.replace(/"/g, '"') | |||
.replace(/'/g, '''); | |||
} | } | ||
$target.html((t && t.loading) ? t.loading : '불러오는 중...'); | |||
$.getJSON( | |||
'/api.php?action=query&list=recentchanges&rclimit=' + encodeURIComponent(limit || 5) + '&rcprop=title|timestamp|user&format=json&rcnamespace=0&rctype=edit|new', | |||
function(data) { | |||
var items = data && data.query ? data.query.recentchanges : []; | |||
var html = ''; | |||
if (!items || !items.length) { | |||
$target.html('표시할 변경 사항이 없습니다.'); | |||
return; | |||
} | |||
$.each(items, function(i, item) { | |||
var label = timeAgo(item.timestamp); | |||
var title = item.title || ''; | |||
var userName = item.user || 'Unknown'; | |||
var pageHref = buildWikiPath(title); | |||
var avatarSrc = '/index.php?title=특수:Redirect/file/Pfp-' + encodeURIComponent(userName) + '.png'; | |||
if (isNewsList) { | |||
html += | |||
'<a href="' + escapeHtml(pageHref) + '" class="news-recent-item">' + | |||
'<img class="news-recent-avatar" src="' + escapeHtml(avatarSrc) + '" alt="" onerror="this.onerror=null;this.src=\'/index.php?title=특수:Redirect/file/Pfp-default.png\';">' + | |||
'<div class="news-recent-main">' + | |||
'<div class="news-recent-title-wrap">' + | |||
'<span class="news-recent-title">' + escapeHtml(title) + '</span>' + | |||
'</div>' + | |||
'<div class="news-recent-meta">' + | |||
'<span class="news-recent-user">@' + escapeHtml(userName) + '</span>' + | |||
'</div>' + | |||
'</div>' + | |||
'<span class="news-recent-time">' + escapeHtml(label) + '</span>' + | |||
'</a>'; | |||
} else { | |||
html += | |||
'<div class="clbi-recent-item">' + | |||
'<div class="clbi-recent-title-wrap">' + | |||
'<a href="' + escapeHtml(pageHref) + '" class="clbi-recent-title">' + escapeHtml(title) + '</a>' + | |||
'</div>' + | |||
'<span class="clbi-recent-time">' + escapeHtml(label) + '</span>' + | |||
'</div>'; | |||
} | |||
}); | |||
if (isNewsList) { | |||
$target.html( | |||
'<div class="news-recent-viewport">' + | |||
'<div class="news-recent-stack">' + html + '</div>' + | |||
'</div>' | |||
); | |||
if (typeof ensureNewsBottomFinish === 'function') { | |||
ensureNewsBottomFinish(); | |||
} | |||
} else { | |||
$target.html(html); | |||
} | } | ||
if ( | if (isNewsList && typeof scheduleAdaptiveLeftRecentItems === 'function') { | ||
scheduleAdaptiveLeftRecentItems(); | |||
} | } | ||
$target.find(isNewsList ? '.news-recent-item' : '.clbi-recent-item').each(function() { | |||
var wrap = $(this).find(isNewsList ? '.news-recent-title-wrap' : '.clbi-recent-title-wrap'); | |||
var title = $(this).find(isNewsList ? '.news-recent-title' : '.clbi-recent-title'); | |||
if (!wrap.length || !title.length) return; | |||
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() { | |||
var lang = getCurrentLang(); | |||
var t = (window.LANG && window.LANG[lang]) ? window.LANG[lang] : (window.LANG ? window.LANG.ko : null); | |||
$target.html((t && t.loadFail) ? t.loadFail : '불러오기 실패'); | |||
}); | |||
} | } | ||
var | function ensureRecentViewport() { | ||
var | var list = document.getElementById('clbi-left-recent-list'); | ||
var viewport; | |||
var stack; | |||
var children; | |||
if (!list) return null; | |||
viewport = list.querySelector(':scope > .news-recent-viewport'); | |||
stack = viewport ? viewport.querySelector(':scope > .news-recent-stack') : null; | |||
if (viewport && stack) return viewport; | |||
children = Array.prototype.slice.call(list.children || []); | |||
viewport = document.createElement('div'); | |||
viewport.className = 'news-recent-viewport'; | |||
stack = document.createElement('div'); | |||
stack.className = 'news-recent-stack'; | |||
children.forEach(function (child) { | |||
if (child.classList && child.classList.contains('news-recent-viewport')) return; | |||
stack.appendChild(child); | |||
}); | |||
viewport.appendChild(stack); | |||
list.appendChild(viewport); | |||
return viewport; | |||
} | |||
function ensureNewsBottomFinish() { | |||
var newsBox = document.querySelector('#clbi-left-sidebar .clbi-left-news-box'); | |||
var content = newsBox ? newsBox.querySelector('.clbi-news-box') : null; | |||
var finish; | |||
if (! | if (!content) return null; | ||
finish = content.querySelector(':scope > .news-bottom-finish'); | |||
document. | if (!finish) { | ||
finish = document.createElement('div'); | |||
finish.className = 'news-bottom-finish'; | |||
finish.setAttribute('aria-hidden', 'true'); | |||
content.appendChild(finish); | |||
} | } | ||
return finish; | |||
} | |||
function updateAdaptiveLeftRecentItems() { | |||
var list = document.getElementById('clbi-left-recent-list'); | |||
var main = document.querySelector('.liberty-content-main'); | |||
var newsBox; | |||
var newsContent; | |||
var viewport; | |||
var finish; | |||
var items; | |||
var followingHeight; | |||
var sibling; | |||
var newsRect; | |||
var mainRect; | |||
var naturalHeight; | |||
var availableHeight; | |||
var constrainedHeight; | |||
var contentRect; | |||
var listRect; | |||
var recentHeight; | |||
var finishHeight = 12; | |||
var minNewsHeight = 150; | |||
var minRecentHeight = 32; | |||
if (!list || !main) return; | |||
newsBox = list.closest('.clbi-left-news-box'); | |||
newsContent = newsBox ? newsBox.querySelector('.clbi-news-box') : null; | |||
viewport = ensureRecentViewport(); | |||
finish = ensureNewsBottomFinish(); | |||
items = Array.prototype.slice.call(list.querySelectorAll('.news-recent-item')); | |||
if (!newsBox || !newsContent || !viewport || !finish || !items.length) return; | |||
/* | |||
기본 상태에서는 원래 내용 높이만 차지한다. | |||
이 상태를 먼저 만들어 naturalHeight를 측정한다. | |||
*/ | |||
newsBox.classList.remove('is-adaptive-constrained'); | |||
newsBox.style.removeProperty('--adaptive-news-h'); | |||
list.classList.remove('is-adaptive-faded'); | |||
list.removeAttribute('data-adaptive-limit'); | |||
list.style.removeProperty('--adaptive-recent-h'); | |||
items.forEach(function (item) { | |||
item.classList.remove('is-adaptive-hidden'); | |||
}); | |||
followingHeight = 0; | |||
sibling = newsBox.nextElementSibling; | |||
while (sibling) { | |||
if (sibling.offsetParent !== null) { | |||
followingHeight += Math.ceil(sibling.getBoundingClientRect().height || 0) + 8; | |||
} | |||
sibling = sibling.nextElementSibling; | |||
} | |||
newsRect = newsBox.getBoundingClientRect(); | |||
mainRect = main.getBoundingClientRect(); | |||
naturalHeight = Math.ceil(newsRect.height); | |||
availableHeight = Math.floor(mainRect.bottom - newsRect.top - followingHeight); | |||
/* | |||
충분한 높이에서는 절대 늘리지 않는다. | |||
뉴스 박스는 항목을 담는 컨테이너이므로 자연 높이를 유지한다. | |||
부족할 때만 본문 하단 기준선 안으로 접어 넣는다. | |||
*/ | |||
if (naturalHeight <= availableHeight) { | |||
return; | |||
} | } | ||
constrainedHeight = Math.max(minNewsHeight, availableHeight); | |||
newsBox.style.setProperty('--adaptive-news-h', constrainedHeight + 'px'); | |||
newsBox.classList.add('is-adaptive-constrained'); | |||
/* | |||
adaptive 상태에서 하단 마감은 실제 finish 행이 담당한다. | |||
recent feed는 finish 위에서만 끝난다. | |||
*/ | |||
newsBox.offsetHeight; | |||
contentRect = newsContent.getBoundingClientRect(); | |||
listRect = list.getBoundingClientRect(); | |||
recentHeight = Math.floor(contentRect.bottom - listRect.top - finishHeight); | |||
recentHeight = Math.max(minRecentHeight, recentHeight); | |||
list.style.setProperty('--adaptive-recent-h', recentHeight + 'px'); | |||
list.classList.add('is-adaptive-faded'); | |||
if (typeof scheduleClbiContentBottomGap === 'function') { | |||
scheduleClbiContentBottomGap(); | |||
} | } | ||
} | |||
function scheduleAdaptiveLeftRecentItems() { | |||
window.requestAnimationFrame(function () { | |||
updateAdaptiveLeftRecentItems(); | |||
}); | }); | ||
document. | window.setTimeout(updateAdaptiveLeftRecentItems, 80); | ||
window.setTimeout(updateAdaptiveLeftRecentItems, 240); | |||
} | |||
function updateClbiContentBottomGap(iteration) { | |||
var content = document.querySelector('.container-fluid.liberty-content'); | |||
var main = document.querySelector('.liberty-content-main'); | |||
var bottomNav = document.getElementById('clbi-bottom-nav-wrap'); | |||
var desiredGap = 8; | |||
var contentRect; | |||
var bottomTop; | |||
var targetHeight; | |||
var currentHeight; | |||
iteration = iteration || 0; | |||
if (!content || !main || !bottomNav) return; | |||
/* | |||
이전 방식은 --clbi-content-extra 값을 더하고 빼서 간격을 맞추려 했다. | |||
하지만 중앙 본문은 .content-wrapper / .container-fluid / .liberty-content-main | |||
세 층을 거치기 때문에, 변수 보정이 실제 시각 하단에 안정적으로 닿지 않았다. | |||
이제는 하단 네비의 실제 top을 기준으로 .container-fluid.liberty-content의 | |||
실제 높이를 직접 지정한다. | |||
목표: | |||
.liberty-content-main.bottom === #clbi-bottom-nav-wrap.top - 8px | |||
*/ | |||
contentRect = content.getBoundingClientRect(); | |||
bottomTop = bottomNav.getBoundingClientRect().top; | |||
targetHeight = Math.floor(bottomTop - contentRect.top - desiredGap); | |||
targetHeight = Math.max(120, targetHeight); | |||
currentHeight = Math.round(content.getBoundingClientRect().height); | |||
content.style.setProperty('--clbi-content-extra', '0px'); | |||
content.style.setProperty('height', targetHeight + 'px', 'important'); | |||
content.style.setProperty('max-height', targetHeight + 'px', 'important'); | |||
if (Math.abs(currentHeight - targetHeight) >= 1 && iteration < 4) { | |||
window.requestAnimationFrame(function () { | |||
updateClbiContentBottomGap(iteration + 1); | |||
}); | |||
} | |||
} | |||
function scheduleClbiContentBottomGap() { | |||
window.requestAnimationFrame(function () { | |||
updateClbiContentBottomGap(0); | |||
}); | }); | ||
window.setTimeout(function () { | |||
updateClbiContentBottomGap(0); | |||
}, 40); | |||
window.setTimeout(function () { | |||
updateClbiContentBottomGap(0); | |||
}, 120); | |||
window.setTimeout(function () { | |||
updateClbiContentBottomGap(0); | |||
}, 280); | |||
window.setTimeout(function () { | |||
updateClbiContentBottomGap(0); | |||
}, 520); | |||
} | |||
// 국가_및_조합 전용 왼쪽 사이드바 이미지 | |||
function updateLeftSidebarNationsImage() { | |||
$('#clbi-left-nations-image').remove(); | |||
} | |||
function setProfileActionLabel(selector, text) { | |||
var target = $(selector); | |||
var label = target.find('.profile-action-label'); | |||
if (label.length) { | |||
label.text(text); | |||
} else { | |||
target.text(text); | |||
} | |||
} | |||
// 사이드바 업데이트 | |||
function updateSidebar() { | |||
if (!window.LANG) { | |||
setTimeout(updateSidebar, 100); | |||
return; | |||
} | |||
var currentLang = getCurrentLang(); | |||
var t = (window.LANG && window.LANG[currentLang]) ? window.LANG[currentLang] : window.LANG.ko; | |||
var newsTitle = t.news || '뉴스'; | |||
var changelogTitle = t.changelog || '체인지로그'; | |||
var recentTitle = t.recentChanges || '최근 변경'; | |||
var languageTitle = t.language || '언어'; | |||
$('#clbi-title-left-language').text(languageTitle); | |||
renderSidebarLanguageBox(); | |||
$('#clbi-title-left-news').text(newsTitle); | |||
$('#clbi-left-news-changelog-main').text(changelogTitle); | |||
$('#clbi-left-news-recent-main').text(recentTitle); | |||
$('#clbi-title-search a').text(t.search); | |||
$('#clbi-search-input').attr('placeholder', t.search + '...'); | |||
$('#clbi-title-recent a').text(recentTitle); | |||
$('#clbi-title-guide-label').text(t.guide); | |||
$('#clbi-guide-link').text(t.getStarted); | |||
$('#clbi-title-links-label').text(t.links); | |||
setProfileActionLabel('#clbi-btn-contribution', t.contribution); | |||
setProfileActionLabel('#clbi-btn-watchlist', t.watchlist); | |||
setProfileActionLabel('#clbi-btn-preferences', t.preferences); | |||
setProfileActionLabel('#clbi-btn-logout', t.logout); | |||
setProfileActionLabel('#clbi-btn-login', t.login); | |||
var pageName = normalizePageName(mw.config.get('wgPageName')); | |||
var specialPage = String(mw.config.get('wgCanonicalSpecialPageName') || ''); | |||
$('#clbi-left-news-changelog-main').text(changelogTitle); | |||
$('#clbi-left-news-recent-title').text('RECENT CHANGES'); | |||
$('.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'); | |||
} | |||
function | $('.toggleBtn').each(function() { | ||
var | var btn = $(this); | ||
if (! | if (!$('#' + btn.data('target')).hasClass('folding-open')) { | ||
btn.text(t.expand); | |||
} else { | |||
btn.text(t.collapse); | |||
} | } | ||
}); | |||
return | 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 initCategoryNavIfAvailable(root) { | |||
/* | |||
CategoryNav.js는 대문 카테고리 네비를 SVG로 생성한다. | |||
Common.js가 SPA로 본문을 갈아끼운 뒤에는 MediaWiki 원래 페이지 로드와 달리 | |||
CategoryNav.js의 초기 DOMContentLoaded만으로는 새 mount를 다시 잡지 못할 수 있다. | |||
CategoryNav.js 자체도 mw.hook('wikipage.content')를 듣지만, 로드 순서와 SPA 타이밍이 | |||
엇갈릴 수 있으므로 Common.js 쪽에서도 존재 여부를 확인한 뒤 한 번 더 호출한다. | |||
이 함수는 CategoryNav.js가 아직 로드되지 않았으면 아무 것도 하지 않는다. | |||
*/ | |||
if ( | |||
window.CLBI && | |||
window.CLBI.categoryNav && | |||
typeof window.CLBI.categoryNav.init === 'function' | |||
) { | |||
window.CLBI.categoryNav.init(root || document); | |||
} | } | ||
} | |||
function removeLegacyMainPageHero() { | |||
/* | |||
기존 대문 전용 레거시 요소 정리 | |||
----------------------------------------- | |||
이전 대문 구조에서는 Common.js가 본문 바깥에 #clbi-main-logo를 직접 삽입하고, | |||
본문 안의 #clbi-main-crt-hero를 #clbi-main-crt-hero-wrap으로 감싸서 | |||
.liberty-content-main 위쪽으로 재배치했다. | |||
새 대문은 본문 내부의 .main-portal이 로고, 알림, 카테고리 네비, 이미지 피드, | |||
방명록, 상태 패널을 모두 담당한다. 따라서 Common.js가 별도 로고나 CRT 래퍼를 | |||
삽입하면 새 로고/콘텐츠와 중복된다. | |||
여기서는 JS가 만들던 바깥 로고와 CRT 래퍼를 제거하고, 예전 대문 원본이나 | |||
캐시된 렌더 결과에 남아 있을 수 있는 #clbi-main-crt-hero도 제거한다. | |||
*/ | |||
$('#clbi-main-logo').remove(); | |||
$('#clbi-main-crt-hero-wrap').remove(); | |||
$('#clbi-main-crt-hero').remove(); | |||
} | |||
function setNativePageTitleHiddenHard(hidden) { | |||
var selectors = [ | |||
'.liberty-content-header', | |||
'.liberty-content-header .title', | |||
'.liberty-content-header .title h1', | |||
'.liberty-content-header h1', | |||
'#firstHeading', | |||
'.firstHeading', | |||
'.mw-first-heading', | |||
'.page-heading', | |||
'.page-header', | |||
'.mw-page-title-main', | |||
'.mw-page-title-namespace', | |||
'.mw-page-title-separator' | |||
]; | |||
document.querySelectorAll(selectors.join(',')).forEach(function(node) { | |||
if (!node || !node.style) return; | |||
if ( | if (hidden) { | ||
node.setAttribute('data-clbi-title-hidden', 'true'); | |||
node.style.setProperty('display', 'none', 'important'); | |||
} else { | node.style.setProperty('visibility', 'hidden', 'important'); | ||
node.style.setProperty('height', '0', 'important'); | |||
node.style.setProperty('min-height', '0', 'important'); | |||
node.style.setProperty('margin', '0', 'important'); | |||
node.style.setProperty('padding', '0', 'important'); | |||
node.style.setProperty('overflow', 'hidden', 'important'); | |||
} else if (node.getAttribute('data-clbi-title-hidden') === 'true') { | |||
node.removeAttribute('data-clbi-title-hidden'); | |||
node.style.removeProperty('display'); | |||
node.style.removeProperty('visibility'); | |||
node.style.removeProperty('height'); | |||
node.style.removeProperty('min-height'); | |||
node.style.removeProperty('margin'); | |||
node.style.removeProperty('padding'); | |||
node.style.removeProperty('overflow'); | |||
} | } | ||
}); | |||
} | |||
function applyDefaultPageTitleVisibility() { | |||
var hideTitle = true; | |||
var isSystemAssetPage = false; | |||
if (window.CLBI_PAGE_SHELL && typeof window.CLBI_PAGE_SHELL.isSystemAssetPage === 'function') { | |||
isSystemAssetPage = window.CLBI_PAGE_SHELL.isSystemAssetPage(); | |||
} | |||
function | if (isSystemAssetPage) { | ||
hideTitle = true; | |||
} else if (window.CLBI_PAGE_SHELL && typeof window.CLBI_PAGE_SHELL.isBackendOrSystemPage === 'function') { | |||
hideTitle = !window.CLBI_PAGE_SHELL.isBackendOrSystemPage(); | |||
} | |||
$('body') | |||
.toggleClass('page-title-hidden', hideTitle) | |||
.toggleClass('page-title-visible', !hideTitle) | |||
.toggleClass('clbi-system-doc-page', isSystemAssetPage); | |||
$('.content-tools').css('display', 'none'); | |||
if (isSystemAssetPage && window.CLBI_PAGE_SHELL && typeof window.CLBI_PAGE_SHELL.renderSystemDocIndicator === 'function') { | |||
window.CLBI_PAGE_SHELL.renderSystemDocIndicator(); | |||
} else if (!isSystemAssetPage && window.CLBI_PAGE_SHELL && typeof window.CLBI_PAGE_SHELL.removeSystemDocIndicator === 'function') { | |||
window.CLBI_PAGE_SHELL.removeSystemDocIndicator(); | |||
} | |||
if (hideTitle) { | |||
$('.liberty-content-header').css('display', 'none'); | |||
$('.mw-page-title-main, .mw-page-title-namespace, .mw-page-title-separator').addClass('clbi-hide'); | |||
$('#firstHeading, .firstHeading, .mw-first-heading, .page-heading, .page-header').css('display', 'none'); | |||
setNativePageTitleHiddenHard(true); | |||
} else { | |||
$('.liberty-content-header').css('display', ''); | |||
$('.mw-page-title-main, .mw-page-title-namespace, .mw-page-title-separator').removeClass('clbi-hide'); | |||
$('#firstHeading, .firstHeading, .mw-first-heading, .page-heading, .page-header').css('display', ''); | |||
setNativePageTitleHiddenHard(false); | |||
} | |||
} | |||
function applyMainPageStyle() { | |||
var specialPage = mw.config.get('wgCanonicalSpecialPageName'); | |||
if (specialPage === 'Preferences') return; | |||
var pageName = normalizePageName(mw.config.get('wgPageName')); | |||
var namespaceNumber = mw.config.get('wgNamespaceNumber'); | |||
var isMainPage = (pageName === '대문'); | |||
var isUserProfilePage = (namespaceNumber === 2); | |||
var isScreenDoc = ($('.screen-header').length > 0); | |||
var hideTools = (isMainPage || isUserProfilePage || !canShowContentTools()); | |||
$('body').toggleClass('user-profile-page', isUserProfilePage); | |||
$('body').toggleClass('clbi-main-page', isMainPage); | |||
// 모든 문서에서 분류 바를 본문 컨테이너 아래로 이동 | |||
moveCatlinksToBottom(); | |||
if (isMainPage) { | |||
$('.liberty-content-header').css('display', 'none'); | |||
$('.mw-page-title-main').addClass('clbi-hide'); | |||
setNativePageTitleHiddenHard(true); | |||
$('.catlinks').css('display', 'none'); | |||
$('.liberty-content-main').css('border-radius', '0'); | |||
// 새 대문은 .main-portal 본문 구조가 로고/히어로를 담당한다. | |||
// Common.js의 구식 바깥 로고/CRT 재배치 루틴은 사용하지 않는다. | |||
removeLegacyMainPageHero(); | |||
$('#clbi-tools-box').remove(); | |||
$('.content-tools').css('display', 'none'); | |||
initCategoryNavIfAvailable(document); | |||
} else if (isUserProfilePage) { | |||
$('.liberty-content-header').css('display', 'none'); | |||
$('.mw-page-title-main, .mw-page-title-namespace, .mw-page-title-separator').addClass('clbi-hide'); | |||
$('.catlinks').css('display', 'none'); | |||
$('.liberty-content-main').css('border-radius', '0'); | |||
$('#clbi-main-logo').remove(); | |||
$('#clbi-main-crt-hero-wrap').remove(); | |||
$('#clbi-main-crt-hero').remove(); | |||
$('#clbi-tools-box').remove(); | |||
$('.content-tools').css('display', 'none'); | |||
} else if (isScreenDoc) { | |||
$('.liberty-content-header').css('display', 'none'); | |||
$('.mw-page-title-main').addClass('clbi-hide'); | |||
$('.catlinks').css('display', ''); | |||
$('.liberty-content-main').css('border-radius', '0'); | |||
$('#clbi-main-logo').remove(); | |||
$('#clbi-main-crt-hero-wrap').remove(); | |||
if ( | 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'); | |||
} else { | |||
$('.liberty-content-header').css('display', ''); | |||
$('.mw-page-title-main, .mw-page-title-namespace, .mw-page-title-separator').removeClass('clbi-hide'); | |||
$('.catlinks').css('display', ''); | |||
$('.liberty-content-main').css('border-radius', '0'); | |||
$('#clbi-main-logo').remove(); | |||
$('#clbi-main-crt-hero-wrap').remove(); | |||
$('#clbi-tools-box').remove(); | |||
} | } | ||
if (!isUserProfilePage) { | |||
$('.profile-card').remove(); | |||
$('.user-profile-portal').removeClass('user-profile-portal'); | |||
} | } | ||
$( | $('.content-tools').css('display', 'none'); | ||
applyDefaultPageTitleVisibility(); | |||
updateSidebar(); | |||
} | |||
/ | // 본문 기본 목차 제거 | ||
function removeNativeTocFromContent() { | |||
$('.liberty-content-main #toc, .liberty-content-main .toc').remove(); | |||
} | |||
// 왼쪽 목차: MediaWiki 문단 ID 가져오기 | |||
function getHeadingId(heading) { | |||
if (heading.id) { | |||
return heading.id; | |||
} | |||
var | 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 ( | 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="sidebar-title-svg" aria-hidden="true">' + CLBI_SVG_LIST + '</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 | |||
var link = document.createElement('a'); | |||
link.setAttribute('href', '#' + id); | |||
// 왼쪽 목차: 긴 제목 스크롤을 위해 텍스트를 별도 span으로 감싼다. | |||
var textWrap = document.createElement('span'); | |||
textWrap.className = 'toc-scroll-wrap'; | |||
var | |||
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); | |||
}); | |||
} | |||
// 우측 광고판: 이미지/번역 캡션 목록 | |||
' | var RIGHT_BILLBOARD_ITEMS = [ | ||
' | { | ||
' | file: 'Side-visual-001.png', | ||
' | alt: 'PROOF TO THE WORLD / YOU ONCE PART OF IT', | ||
' | duration: 3000, | ||
' | caption: [ | ||
' | '"당신이 한때 이 세계의', | ||
' | '', | ||
' | '일부였다는 것을 증명하십시오"' | ||
' | ] | ||
}, | |||
{ | |||
file: 'Side-visual-002.png', | |||
alt: 'APPLY NOW', | |||
duration: 1000, | |||
caption: [ | |||
'"지금 지원하세요!"' | |||
] | |||
}, | |||
{ | |||
file: 'Side-visual-003.png', | |||
alt: 'APPLY NOW', | |||
duration: 1000, | |||
caption: [ | |||
'"지금 지원하세요!"' | |||
] | |||
}, | |||
{ | |||
file: 'Side-visual-002.png', | |||
alt: 'APPLY NOW', | |||
duration: 1000, | |||
caption: [ | |||
'"지금 지원하세요!"' | |||
] | |||
} | |||
]; | |||
function getRightBillboardItem(index) { | |||
var items = RIGHT_BILLBOARD_ITEMS; | |||
if (!items || !items.length) { | |||
return { | |||
file: 'Side-visual-001.png', | |||
alt: '', | |||
caption: [] | |||
}; | |||
} | |||
var normalized = index % items.length; | |||
if (normalized < 0) normalized += items.length; | |||
return items[normalized]; | |||
} | |||
function getRightBillboardImageUrl(fileName) { | |||
return '/index.php?title=특수:Redirect/file/' + encodeURIComponent(fileName || 'Side-visual-001.png'); | |||
} | |||
function getRightBillboardCaptionHtml(item) { | |||
var lines = item && item.caption ? item.caption : []; | |||
var html = ''; | |||
function escapeCaptionText(value) { | |||
' | return String(value == null ? '' : value) | ||
.replace(/&/g, '&') | |||
.replace(/</g, '<') | |||
.replace(/>/g, '>') | |||
.replace(/\"/g, '"') | |||
.replace(/'/g, '''); | |||
} | |||
lines.forEach(function(line) { | |||
' | var text = String(line == null ? '' : line); | ||
var isGap = !text.trim(); | |||
var className = 'right-billboard-caption-line' + (isGap ? ' is-gap' : ''); | |||
html += '<span class="' + className + '">' + (isGap ? ' ' : escapeCaptionText(text)) + '</span>'; | |||
}); | |||
return html; | |||
} | |||
function setRightBillboardItem(index) { | |||
var box = document.querySelector('.right-billboard-box'); | |||
if (!box) return; | |||
var item = getRightBillboardItem(index); | |||
var src = getRightBillboardImageUrl(item.file); | |||
var images = box.querySelectorAll('.right-billboard-image'); | |||
var caption = box.querySelector('#right-billboard-caption'); | |||
var emptySub = box.querySelector('.right-billboard-empty-sub'); | |||
box.setAttribute('data-billboard-index', String(index)); | |||
box.classList.remove('is-empty'); | |||
Array.prototype.forEach.call(images, function(img) { | |||
' | img.style.display = ''; | ||
img.setAttribute('src', src); | |||
img.setAttribute('alt', img.classList.contains('right-billboard-image-base') ? (item.alt || '') : ''); | |||
}); | |||
if (caption) { | |||
caption.innerHTML = getRightBillboardCaptionHtml(item); | |||
} | |||
if (emptySub) { | |||
emptySub.textContent = item.file || 'Side-visual-001.png'; | |||
} | |||
} | |||
function getRightBillboardItemDuration(item) { | |||
var duration = item && item.duration ? parseInt(item.duration, 10) : 3000; | |||
if (Number.isNaN(duration) || duration < 500) { | |||
duration = 3000; | |||
} | |||
return duration; | |||
} | |||
function initRightBillboardCarousel() { | |||
var box = document.querySelector('.right-billboard-box'); | |||
if (!box || box.getAttribute('data-billboard-ready') === '1') return; | |||
box.setAttribute('data-billboard-ready', '1'); | |||
box.setAttribute('data-billboard-index', '0'); | |||
setRightBillboardItem(0); | |||
if (!RIGHT_BILLBOARD_ITEMS || RIGHT_BILLBOARD_ITEMS.length <= 1) return; | |||
function scheduleNext() { | |||
var current = parseInt(box.getAttribute('data-billboard-index') || '0', 10); | |||
if (Number.isNaN(current)) current = 0; | |||
var currentItem = getRightBillboardItem(current); | |||
var delay = getRightBillboardItemDuration(currentItem); | |||
window.setTimeout(function() { | |||
if (!document.body.contains(box)) return; | |||
if (!document.hidden) { | |||
setRightBillboardItem(current + 1); | |||
} | |||
scheduleNext(); | |||
}, delay); | |||
} | |||
scheduleNext(); | |||
} | |||
function escapeRightBillboardAttr(value) { | |||
return String(value == null ? '' : value) | |||
.replace(/&/g, '&') | |||
.replace(/</g, '<') | |||
.replace(/>/g, '>') | |||
.replace(/"/g, '"') | |||
.replace(/'/g, '''); | |||
} | |||
function buildRightBillboardBox() { | |||
var billboardInitial = getRightBillboardItem(0); | |||
var billboardSrc = getRightBillboardImageUrl(billboardInitial.file); | |||
var | return '' + | ||
'<div class="clbi-right-box right-billboard-box" data-billboard-index="0">' + | |||
'<div class="clbi-right-title right-billboard-title">' + | |||
'<span class="right-billboard-title-text"><span class="right-billboard-title-main">Ad</span><span class="right-billboard-title-note">(not really)</span></span>' + | |||
'</div>' + | |||
'<div class="right-billboard-body">' + | |||
'<div class="right-billboard-recess">' + | |||
'<div class="right-billboard-screen">' + | |||
'<img id="right-billboard-image" class="right-billboard-image right-billboard-image-base" src="' + escapeRightBillboardAttr(billboardSrc) + '" alt="' + escapeRightBillboardAttr(billboardInitial.alt || '') + '" onload="var b=this.closest(\'.right-billboard-box\'); if(b){b.classList.remove(\'is-empty\');}" onerror="this.onerror=null;this.style.display=\'none\';var b=this.closest(\'.right-billboard-box\'); if(b){b.classList.add(\'is-empty\');}">' + | |||
'<img class="right-billboard-image right-billboard-image-bloom" src="' + escapeRightBillboardAttr(billboardSrc) + '" alt="" aria-hidden="true" onerror="this.onerror=null;this.style.display=\'none\';">' + | |||
'<img class="right-billboard-image right-billboard-slice right-billboard-slice-a" src="' + escapeRightBillboardAttr(billboardSrc) + '" alt="" aria-hidden="true" onerror="this.onerror=null;this.style.display=\'none\';">' + | |||
'<img class="right-billboard-image right-billboard-slice right-billboard-slice-b" src="' + escapeRightBillboardAttr(billboardSrc) + '" alt="" aria-hidden="true" onerror="this.onerror=null;this.style.display=\'none\';">' + | |||
'<img class="right-billboard-image right-billboard-slice right-billboard-slice-c" src="' + escapeRightBillboardAttr(billboardSrc) + '" alt="" aria-hidden="true" onerror="this.onerror=null;this.style.display=\'none\';">' + | |||
'<div class="right-billboard-glitch" aria-hidden="true"></div>' + | |||
'<div class="right-billboard-tear" aria-hidden="true"></div>' + | |||
'<div class="right-billboard-empty" aria-hidden="true">' + | |||
'<span class="right-billboard-empty-main">SIGNAL EMPTY</span>' + | |||
'<span class="right-billboard-empty-sub">' + escapeRightBillboardAttr(billboardInitial.file || 'Side-visual-001.png') + '</span>' + | |||
'</div>' + | |||
'</div>' + | |||
'</div>' + | |||
'<div id="right-billboard-caption" class="right-billboard-caption" aria-hidden="true">' + getRightBillboardCaptionHtml(billboardInitial) + '</div>' + | |||
'</div>' + | |||
'</div>'; | |||
} | |||
function buildSiteInformationBox() { | |||
return '' + | |||
'<div class="clbi-right-box site-info-sidebar">' + | |||
'<div class="clbi-right-title site-info-title">' + | |||
'<span>SITE INFORMATION</span>' + | |||
'<span class="site-info-title-meta">LINKS</span>' + | |||
'</div>' + | |||
'<div class="clbi-right-content site-info-content">' + | |||
'<div class="policy-list">' + | |||
'<div class="policy-row"><a href="/index.php/개인정보처리방침">개인정보처리방침</a><span class="policy-mark" aria-hidden="true">›</span></div>' + | |||
'<div class="policy-row"><a href="/index.php/면책_조항">면책 조항</a><span class="policy-mark" aria-hidden="true">›</span></div>' + | |||
'<div class="policy-row"><a href="/index.php/라이선스">라이선스</a><span class="policy-mark" aria-hidden="true">›</span></div>' + | |||
'<div class="policy-row"><a href="/index.php/크레딧">크레딧</a><span class="policy-mark" aria-hidden="true">›</span></div>' + | |||
'</div>' + | |||
'<div class="social-strip">' + | |||
'<span class="social-icon"><a href="https://discord.gg/ctaeJ9d3Q5" target="_blank" rel="noopener noreferrer">DC</a></span>' + | |||
'<span class="social-icon"><a href="https://www.youtube.com/@nxdsxn" target="_blank" rel="noopener noreferrer">YT</a></span>' + | |||
'<span class="social-icon"><a href="https://x.com/nxd_sxn" target="_blank" rel="noopener noreferrer">X</a></span>' + | |||
'<span class="social-icon"><a href="/index.php/프로젝트:소개">WIP:</a></span>' + | |||
'</div>' + | |||
'</div>' + | |||
'</div>'; | |||
} | |||
// 초기화 함수 | |||
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 | var userBox; | ||
if (isLoggedIn) { | |||
userBox = | |||
'<div class="clbi-right-box profile-card-box">' + | |||
'<div id="clbi-user-avatar-wrap" class="profile-identity-panel">' + | |||
'<div class="profile-avatar-stage">' + | |||
'<img id="clbi-user-avatar" src="' + avatarSrc + '" onerror="this.onerror=null;this.src=\'/index.php?title=특수:Redirect/file/Pfp-default.png\';">' + | |||
'</div>' + | |||
'<div id="clbi-user-name-row" class="profile-name-row">' + | |||
'<a href="/index.php/사용자:' + username + '" id="clbi-user-name">' + username + '</a>' + | |||
'</div>' + | |||
'</div>' + | |||
'<div class="clbi-right-content profile-action-box">' + | |||
'<div class="profile-quick-actions" aria-label="프로필 빠른 메뉴">' + | |||
'<button type="button" class="profile-quick-btn" id="profile-quick-inventory" aria-label="인벤토리"><span class="profile-quick-icon" aria-hidden="true">' + CLBI_SVG_PACKAGE + '</span><span class="profile-quick-tip" aria-hidden="true">인벤토리</span></button>' + | |||
'<button type="button" class="profile-quick-btn" id="profile-quick-achievements" aria-label="업적"><span class="profile-quick-icon" aria-hidden="true">' + CLBI_SVG_TROPHY + '</span><span class="profile-quick-tip" aria-hidden="true">업적</span></button>' + | |||
'<button type="button" class="profile-quick-btn" id="profile-quick-notifications" aria-label="알림"><span id="profile-quick-notification-icon" class="profile-quick-icon" aria-hidden="true">' + CLBI_SVG_BELL + '</span><span class="profile-quick-tip" aria-hidden="true">알림</span></button>' + | |||
'</div>' + | |||
'<a href="/index.php/특수:기여/' + username + '" class="clbi-user-btn" id="clbi-btn-contribution"><span class="profile-action-icon" aria-hidden="true">' + CLBI_SVG_SCAN_TEXT + '</span><span class="profile-action-label">기여</span><i class="hn hn-angle-right-solid profile-action-arrow" aria-hidden="true"></i></a>' + | |||
'<a href="/index.php/특수:주시문서목록" class="clbi-user-btn" id="clbi-btn-watchlist"><span class="profile-action-icon" aria-hidden="true">' + CLBI_SVG_SCAN_EYE + '</span><span class="profile-action-label">주시문서 목록</span><i class="hn hn-angle-right-solid profile-action-arrow" aria-hidden="true"></i></a>' + | |||
'<a href="/index.php/특수:설정" class="clbi-user-btn" id="clbi-btn-preferences"><span class="profile-action-icon" aria-hidden="true">' + CLBI_SVG_SETTINGS + '</span><span class="profile-action-label">설정</span><i class="hn hn-angle-right-solid profile-action-arrow" aria-hidden="true"></i></a>' + | |||
'<a href="/index.php?title=특수:로그아웃&returnto=대문" class="clbi-user-btn clbi-user-btn-logout" id="clbi-btn-logout"><span class="profile-action-icon" aria-hidden="true">' + CLBI_SVG_POWER + '</span><span class="profile-action-label">로그아웃</span><i class="hn hn-angle-right-solid profile-action-arrow" aria-hidden="true"></i></a>' + | |||
'</div>' + | |||
'</div>'; | |||
} else { | |||
userBox = | |||
'<div class="clbi-right-box profile-card-box">' + | |||
'<div id="clbi-user-avatar-wrap" class="profile-identity-panel">' + | |||
'<div class="profile-avatar-stage">' + | |||
'<img id="clbi-user-avatar" src="/index.php?title=특수:Redirect/file/Pfp-default.png">' + | |||
'</div>' + | |||
'<div id="clbi-user-name-row" class="profile-name-row profile-name-row-guest">' + | |||
'<span id="clbi-user-name">Guest</span>' + | |||
'</div>' + | |||
'</div>' + | |||
'<div class="clbi-right-content profile-action-box">' + | |||
'<a href="/index.php?title=특수:로그인&returnto=대문" class="clbi-user-btn" id="clbi-btn-login"><span class="profile-action-icon" aria-hidden="true">' + CLBI_SVG_POWER + '</span><span class="profile-action-label">로그인</span><i class="hn hn-angle-right-solid profile-action-arrow" aria-hidden="true"></i></a>' + | |||
'</div>' + | |||
'</div>'; | |||
} | |||
var rightVisualBox = ''; | |||
var siteInformationBox = ''; | |||
try { | |||
rightVisualBox = buildRightBillboardBox(); | |||
} catch (err) { | |||
console.error('Right billboard build failed:', err); | |||
rightVisualBox = ''; | |||
} | |||
try { | |||
siteInformationBox = buildSiteInformationBox(); | |||
} catch (err) { | |||
console.error('Site information build failed:', err); | |||
siteInformationBox = ''; | |||
} | } | ||
var sidebar = userBox + rightVisualBox + siteInformationBox; | |||
$('.content-wrapper').append('<div id="clbi-right-sidebar">' + sidebar + '</div>'); | |||
} | } | ||
$( | if ($('#clbi-left-sidebar').length === 0) { | ||
var leftSidebar = | |||
'<div id="clbi-left-sidebar">' + | |||
'<div class="clbi-left-box clbi-left-lang-box">' + | |||
'<div class="clbi-left-title">' + | |||
'<span class="sidebar-title-svg clbi-left-language-icon" aria-hidden="true">' + CLBI_SVG_LANGUAGES + '</span> ' + | |||
'<span id="clbi-title-left-language">언어</span>' + | |||
'</div>' + | |||
'<div class="clbi-left-content sidebar-lang-box">' + | |||
'<div id="clbi-sidebar-lang-selector" class="sidebar-lang-selector sidebar-lang-dial" tabindex="0" role="group" aria-label="언어 선택">' + | |||
'<div id="clbi-sidebar-lang-dial-stage" class="sidebar-lang-dial-stage">' + | |||
'<div id="clbi-sidebar-lang-fan" class="sidebar-lang-fan" aria-hidden="true"></div>' + | |||
'<div id="clbi-sidebar-lang-selected-panel" class="sidebar-lang-status-panel sidebar-lang-status-left" aria-hidden="true">' + | |||
'<span id="clbi-sidebar-lang-selected-value" class="sidebar-lang-status-value">한국어</span>' + | |||
'</div>' + | |||
'<div id="clbi-sidebar-lang-availability-panel" class="sidebar-lang-status-panel sidebar-lang-status-right is-current" aria-hidden="true">' + | |||
'<span id="clbi-sidebar-lang-availability-value" class="sidebar-lang-status-value">CURRENT</span>' + | |||
'</div>' + | |||
'<button type="button" id="clbi-sidebar-lang-apply" class="sidebar-lang-apply" aria-label="언어 적용">' + | |||
'<span class="sidebar-lang-apply-mark" aria-hidden="true">✓</span>' + | |||
'</button>' + | |||
'</div>' + | |||
'</div>' + | |||
'</div>' + | |||
'</div>' + | |||
'<div class="clbi-left-box clbi-left-news-box">' + | |||
'<div class="clbi-left-title">' + | |||
'<span class="news-title-icon sidebar-title-svg" aria-hidden="true">' + CLBI_SVG_NEWSPAPER + '</span> ' + | |||
'<span id="clbi-title-left-news">뉴스</span>' + | |||
'</div>' + | |||
'<div class="clbi-left-content clbi-news-box">' + | |||
'<div class="news-feed-title" id="clbi-left-news-changelog-title">CHANGELOG</div>' + | |||
'<div class="news-left-changelog-feed">' + | |||
'<a href="/index.php/체인지로그" class="news-post-item">' + | |||
'<div class="news-post-title-wrap">' + | |||
'<span class="news-post-title" id="clbi-left-news-changelog-main">체인지로그</span>' + | |||
'<i class="hn hn-angle-right-solid news-post-arrow" aria-hidden="true"></i>' + | |||
'</div>' + | |||
'<span class="news-post-tag">POST</span>' + | |||
'</a>' + | |||
'</div>' + | |||
'<div class="news-divider"></div>' + | |||
'<div class="news-feed-title" id="clbi-left-news-recent-title">RECENT CHANGES</div>' + | |||
'<div class="news-left-recent-feed" id="clbi-left-recent-list">불러오는 중...</div>' + | |||
'</div>' + | |||
'</div>' + | |||
'</div>'; | |||
$('.content-wrapper').prepend(leftSidebar); | |||
renderSidebarLanguageBox(); | |||
loadRecentChangesList('#clbi-left-recent-list', 10); | |||
scheduleAdaptiveLeftRecentItems(); | |||
scheduleClbiContentBottomGap(); | |||
updateLeftSidebarNationsImage(); | |||
} | |||
try { | |||
initRightBillboardCarousel(); | |||
} catch (err) { | |||
console.error('Right billboard carousel failed:', err); | |||
} | |||
if (typeof window.normalizeClbiShellDomOrder === 'function') window.normalizeClbiShellDomOrder(); | |||
applyMainPageStyle(); | |||
initCategoryNavIfAvailable(document); | |||
if (window.ProgressSystemWebUi && typeof window.ProgressSystemWebUi.boot === 'function') { | |||
window.ProgressSystemWebUi.boot('initSidebars'); | |||
} | } | ||
$('#side-toc-box').remove(); | |||
mw.loader.using(['mediawiki.api']).then(function() { | |||
setTimeout(function() { | |||
initNotifications(); | |||
initProfile(); | |||
moveTocToLeftSidebar(); | |||
}, 300); | |||
setTimeout(moveTocToLeftSidebar, 800); | |||
setTimeout(moveTocToLeftSidebar, 1500); | |||
}); | |||
} | |||
$(function() { | |||
loadLangScript(function() { | |||
setTimeout(function() { | |||
initSidebars(); | |||
}, 100); | |||
}); | |||
}); | |||
$(document).on('click.profileQuickPlaceholder', '#profile-quick-inventory, #profile-quick-achievements', function(e) { | |||
e.preventDefault(); | |||
e.stopPropagation(); | |||
}); | |||
// 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) { | |||
invalidateProfileRender(); | |||
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 matchArticle = src.match(/"wgArticleId":(\d+)/); | |||
if (matchArticle) { | |||
mw.config.set('wgArticleId', parseInt(matchArticle[1], 10)); | |||
} else { | |||
mw.config.set('wgArticleId', 0); | |||
} | |||
var matchIsMainPage = src.match(/"wgIsMainPage":(true|false)/); | |||
if (matchIsMainPage) { | |||
mw.config.set('wgIsMainPage', matchIsMainPage[1] === 'true'); | |||
} else { | |||
mw.config.set('wgIsMainPage', false); | |||
} | |||
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(); | |||
$('.profile-card').remove(); | |||
$('.user-profile-portal').removeClass('user-profile-portal'); | |||
$('.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); | |||
} | |||
if (typeof window.normalizeClbiShellDomOrder === 'function') window.normalizeClbiShellDomOrder(); | |||
window.scrollTo(0, 0); | |||
mw.hook('wikipage.content').fire($('.liberty-content-main')); | |||
applyMainPageStyle(); | |||
initCategoryNavIfAvailable(document); | |||
if (window.ProgressSystemWebUi && typeof window.ProgressSystemWebUi.handleSpaPageView === 'function') { | |||
window.ProgressSystemWebUi.handleSpaPageView(); | |||
} else if (window.ProgressSystemWebUi && typeof window.ProgressSystemWebUi.boot === 'function') { | |||
window.ProgressSystemWebUi.boot('spa'); | |||
} | |||
$('#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) { | |||
// 휠 클릭, 새 탭 열기, 보조키 이동은 브라우저 기본 동작을 유지한다. | |||
if (e.which && e.which !== 1) return; | |||
if (e.button && e.button !== 0) return; | |||
if (e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return; | |||
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(); | |||
$('.user-profile-portal').removeClass('user-profile-portal'); | |||
var token = ++PROFILE_RENDER_TOKEN; | |||
var ns = mw.config.get('wgNamespaceNumber'); | |||
var title = mw.config.get('wgTitle'); | |||
var specialPage = mw.config.get('wgCanonicalSpecialPageName'); | |||
var isProfileSettings = specialPage === '사용자정보'; | |||
$('body').toggleClass('user-profile-page', ns === 2); | |||
$('body').toggleClass('user-profile-settings-page', isProfileSettings); | |||
if (ns === 2) { | |||
var profileUser = title.split('/')[0]; | |||
renderProfile(profileUser, token); | |||
} | |||
if (isProfileSettings) { | |||
initUserProfilePage(); | |||
} | |||
} | |||
function renderProfile(username, token) { | |||
var api = new mw.Api(); | |||
api.get({ | |||
action: 'query', | |||
list: 'users', | |||
ususers: username, | |||
usprop: 'editcount' | |||
}).then(function(data) { | |||
if (token !== PROFILE_RENDER_TOKEN) return; | |||
if (mw.config.get('wgNamespaceNumber') !== 2) return; | |||
var currentTitle = String(mw.config.get('wgTitle') || '').split('/')[0]; | |||
if (currentTitle !== username) return; | |||
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; | |||
function escapeHtml(value) { | |||
return String(value == null ? '' : value) | |||
.replace(/&/g, '&') | |||
.replace(/</g, '<') | |||
.replace(/>/g, '>') | |||
.replace(/"/g, '"') | |||
.replace(/'/g, '''); | |||
} | |||
container.classList.add('user-profile-portal'); | |||
var safeUsername = escapeHtml(username); | |||
var avatarSrc = '/index.php?title=특수:Redirect/file/Pfp-' + encodeURIComponent(username) + '.png&width=220'; | |||
var fallbackSrc = '/index.php?title=특수:Redirect/file/Pfp-default.png&width=220'; | |||
var editBtn = isOwnPage | |||
? '<a href="/index.php/특수:사용자정보" class="profile-edit-btn"><span class="profile-edit-label">프로필 수정</span><span class="profile-edit-arrow">›</span></a>' | |||
: ''; | |||
var progressHtml = isOwnPage | |||
? '<div class="profile-page-progress is-syncing" data-profile-progress>' + | |||
'<div class="profile-section-title">LEVEL RECORD</div>' + | |||
'<div class="profile-page-progress-body">' + | |||
'<div class="profile-page-progress-row">' + | |||
'<span class="profile-page-level">SYNC</span>' + | |||
'<span class="profile-page-total-xp">— XP</span>' + | |||
'</div>' + | |||
'<div class="profile-page-xp-bar" aria-hidden="true"><div class="profile-page-xp-fill"></div></div>' + | |||
'<div class="profile-page-progress-sub">SYNCING</div>' + | |||
'<div class="profile-page-progress-meta">TODAY — · DISCOVERED —</div>' + | |||
'</div>' + | |||
'</div>' | |||
: ''; | |||
var card = document.createElement('div'); | |||
card.className = 'profile-card profile-page-console'; | |||
card.innerHTML = | |||
'<div class="profile-card-titlebar">' + | |||
'<span>USER PROFILE</span>' + | |||
'<span>OFFICIAL ARCHIVE</span>' + | |||
'</div>' + | |||
'<div class="profile-card-body">' + | |||
'<div class="profile-identity-row">' + | |||
'<div class="profile-avatar-bay">' + | |||
'<img src="' + avatarSrc + '" onerror="this.onerror=null;this.src=\'' + fallbackSrc + '\';" alt="' + safeUsername + '">' + | |||
'</div>' + | |||
'<div class="profile-info-panel">' + | |||
'<div class="profile-nameplate">' + | |||
'<h2 class="profile-username">' + safeUsername + '</h2>' + | |||
editBtn + | |||
'</div>' + | |||
'<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>' + | |||
'</div>' + | |||
'<div class="profile-lower-grid">' + | |||
'<div class="profile-bio-panel">' + | |||
'<div class="profile-section-title">BIOGRAPHY</div>' + | |||
'<div class="profile-bio" data-field="bio"></div>' + | |||
'</div>' + | |||
'<div class="profile-stats-panel">' + | |||
'<div class="profile-section-title">RECORD</div>' + | |||
'<div class="profile-stats">' + | |||
progressHtml + | |||
'<div class="profile-stat-grid">' + | |||
'<div class="profile-stat">' + | |||
'<span class="clbi-stat-value">' + editCount + '</span>' + | |||
'<span class="clbi-stat-label">수정 횟수</span>' + | |||
'</div>' + | |||
'<div class="profile-stat" data-contrib-pages-stat>' + | |||
'<span class="clbi-stat-value" data-contrib-pages-value>SYNC</span>' + | |||
'<span class="clbi-stat-label">기여 문서</span>' + | |||
'</div>' + | |||
'</div>' + | |||
'<div class="profile-info-grid">' + | |||
'<div class="profile-info-stat">' + | |||
'<span class="profile-info-label">TIME</span>' + | |||
'<span class="profile-info-value" data-profile-time>UTC --:--</span>' + | |||
'</div>' + | |||
'<div class="profile-info-stat">' + | |||
'<span class="profile-info-label">LANGUAGE</span>' + | |||
'<span class="profile-info-value" data-profile-language>—</span>' + | |||
'</div>' + | |||
'</div>' + | |||
'</div>' + | |||
'</div>' + | |||
'</div>' + | |||
'</div>'; | |||
$('.profile-card').remove(); | |||
container.insertBefore(card, container.firstChild); | |||
loadProfileFields(username, card); | |||
loadProfileContributionPages(username, card); | |||
updateProfilePageEnvironment(card, null); | |||
if (isOwnPage) { | |||
loadProfileProgressForUserPage(card); | |||
} | |||
} | |||
function getProfileLanguageLabel() { | |||
var lang = getCurrentLang(); | |||
return SIDEBAR_LANGUAGE_LABELS[lang] || (lang ? lang.toUpperCase() : '—'); | |||
} | |||
function updateProfilePageEnvironment(card, summary) { | |||
if (!card) return; | |||
var timezone = summary && summary.timezone ? summary.timezone : 'UTC'; | |||
var timeEl = card.querySelector('[data-profile-time]'); | |||
var langEl = card.querySelector('[data-profile-language]'); | |||
if (timeEl) { | |||
try { | |||
timeEl.textContent = timezone + ' ' + new Intl.DateTimeFormat('ko-KR', { | |||
hour: '2-digit', | |||
minute: '2-digit', | |||
hour12: false, | |||
timeZone: timezone | |||
}).format(new Date()); | |||
} catch (err) { | |||
timeEl.textContent = 'UTC ' + new Intl.DateTimeFormat('ko-KR', { | |||
hour: '2-digit', | |||
minute: '2-digit', | |||
hour12: false, | |||
timeZone: 'UTC' | |||
}).format(new Date()); | |||
} | |||
} | |||
if (langEl) { | |||
langEl.textContent = getProfileLanguageLabel(); | |||
} | |||
} | |||
function loadProfileContributionPages(username, card) { | |||
if (!username || !card) return; | |||
if (!mw.loader || typeof mw.loader.using !== 'function') return; | |||
var valueEl = card.querySelector('[data-contrib-pages-value]'); | |||
if (valueEl) valueEl.textContent = 'SYNC'; | |||
mw.loader.using(['mediawiki.api']).then(function () { | |||
var api = new mw.Api(); | |||
var pages = Object.create(null); | |||
var cont = {}; | |||
var guard = 0; | |||
function requestNext() { | |||
guard++; | |||
var params = Object.assign({ | |||
action: 'query', | |||
list: 'usercontribs', | |||
ucuser: username, | |||
ucnamespace: 0, | |||
ucprop: 'title', | |||
uclimit: 'max', | |||
format: 'json', | |||
formatversion: 2 | |||
}, cont); | |||
return api.get(params).then(function (data) { | |||
var rows = data && data.query && data.query.usercontribs ? data.query.usercontribs : []; | |||
rows.forEach(function (row) { | |||
if (row && row.title) pages[row.title] = true; | |||
}); | |||
if (data && data.continue && data.continue.uccontinue && guard < 40) { | |||
cont = data.continue; | |||
return requestNext(); | |||
} | |||
if (valueEl) valueEl.textContent = Object.keys(pages).length; | |||
}); | |||
} | |||
requestNext().fail(function () { | |||
if (valueEl) valueEl.textContent = '—'; | |||
}); | |||
}); | |||
} | |||
function loadProfileProgressForUserPage(card) { | |||
if (!mw.config.get('wgUserName')) return; | |||
if (!mw.loader || typeof mw.loader.using !== 'function') return; | |||
mw.loader.using(['mediawiki.api']).then(function () { | |||
var api = new mw.Api(); | |||
api.get({ | |||
action: 'progress_summary', | |||
format: 'json', | |||
formatversion: 2 | |||
}).then(function (data) { | |||
var payload = data && data.progress_summary; | |||
if (!payload || !payload.available || !payload.summary) return; | |||
updateUserPageProgress(card, payload.summary); | |||
updateProfilePageEnvironment(card, payload.summary); | |||
}); | |||
}); | |||
} | |||
function updateUserPageProgress(card, summary) { | |||
var panel = card.querySelector('[data-profile-progress]'); | |||
if (!panel || !summary) return; | |||
var level = summary.level || 1; | |||
var totalXp = summary.totalXp || 0; | |||
var xpIntoLevel = summary.xpIntoLevel || 0; | |||
var xpForNext = summary.xpForNextLevel || 1; | |||
var percent = Math.max(0, Math.min(100, summary.progressPercent || 0)); | |||
var isMaxLevel = !!summary.isMaxLevel; | |||
var dailyXp = summary.dailyXp || 0; | |||
var discoveries = summary.discoveryCount || 0; | |||
panel.classList.remove('is-syncing'); | |||
panel.classList.toggle('is-max-level', isMaxLevel); | |||
var levelEl = panel.querySelector('.profile-page-level'); | |||
var totalEl = panel.querySelector('.profile-page-total-xp'); | |||
var fillEl = panel.querySelector('.profile-page-xp-fill'); | |||
var subEl = panel.querySelector('.profile-page-progress-sub'); | |||
var metaEl = panel.querySelector('.profile-page-progress-meta'); | |||
if (levelEl) levelEl.textContent = (isMaxLevel ? 'MAX ' : 'LVL ') + level; | |||
if (totalEl) totalEl.textContent = totalXp + ' XP'; | |||
if (fillEl) fillEl.style.width = percent + '%'; | |||
if (subEl) subEl.textContent = isMaxLevel ? 'MAX LEVEL' : (xpIntoLevel + ' / ' + xpForNext + ' TO NEXT'); | |||
if (metaEl) metaEl.textContent = 'TODAY ' + dailyXp + ' XP · DISCOVERED ' + discoveries; | |||
} | |||
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 || '' | |||
}); | |||
}).fail(function() { | |||
updateProfileFields(card, { | |||
name: '', | |||
discord: '', | |||
role: '', | |||
bio: '' | |||
}); | |||
}); | |||
} | |||
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"]'); | |||
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 || ''; | |||
} | |||
// ========== 프로필 시스템 끝 ========== | |||
// ========== 알림 시스템 ========== | |||
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:5px;' + | |||
'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('profile-quick-notifications'); | |||
var popup = document.getElementById('clbi-notification-popup'); | |||
if (!btn || !popup) return; | |||
var rect = btn.getBoundingClientRect(); | |||
var top = rect.bottom + 6; | |||
var left = rect.left + (rect.width / 2) - (popup.offsetWidth / 2); | |||
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, rect.top - popup.offsetHeight - 6); | |||
} | |||
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 setNotificationIcon(hasItems) { | |||
var quickIcon = document.getElementById('profile-quick-notification-icon'); | |||
var svg = hasItems ? CLBI_SVG_BELL_DOT : CLBI_SVG_BELL; | |||
if (quickIcon) { | |||
quickIcon.innerHTML = svg; | |||
quickIcon.classList.toggle('has-notifications', !!hasItems); | |||
} | |||
} | |||
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'; | |||
setNotificationIcon(false); | |||
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'; | |||
} | |||
setNotificationIcon(true); | |||
} | |||
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 quickBtn = document.getElementById('profile-quick-notifications'); | |||
if (!quickBtn) return; | |||
ensureNotificationPopup(); | |||
loadNotificationsIntoPopup(); | |||
$(document) | |||
.off('click.clbiNotificationToggle') | |||
.on('click.clbiNotificationToggle', '#profile-quick-notifications', function(e) { | |||
e.preventDefault(); | |||
e.stopPropagation(); | |||
var popup = document.getElementById('clbi-notification-popup'); | |||
if (!popup) return; | |||
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 quickToggle = document.getElementById('profile-quick-notifications'); | |||
if (!popup) return; | |||
if (!popup.contains(e.target) && (!quickToggle || !quickToggle.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 initUserProfilePage() { | |||
$('body').addClass('user-profile-settings-page'); | |||
var saveBtn = document.getElementById('pref-save'); | |||
if (!saveBtn) return; | |||
function getPrefRow(id) { | |||
var el = document.getElementById(id); | |||
if (!el) return null; | |||
return el.closest('.clbi-pref-row') || el.parentNode; | |||
} | |||
function removePrefRow(id) { | |||
var row = getPrefRow(id); | |||
if (row && row.parentNode) { | |||
row.parentNode.removeChild(row); | |||
} | |||
} | |||
function createPrefSection(className, titleText) { | |||
var section = document.createElement('div'); | |||
section.className = 'clbi-pref-section ' + className; | |||
var title = document.createElement('div'); | |||
title.className = 'clbi-pref-section-title'; | |||
title.textContent = titleText; | |||
var body = document.createElement('div'); | |||
body.className = 'clbi-pref-section-body'; | |||
section.appendChild(title); | |||
section.appendChild(body); | |||
return { | |||
section: section, | |||
body: body | |||
}; | |||
} | |||
function moveRowToSection(id, targetBody, className) { | |||
var row = getPrefRow(id); | |||
if (!row || !targetBody) return false; | |||
row.classList.add('clbi-pref-row-key-' + className); | |||
targetBody.appendChild(row); | |||
return true; | |||
} | |||
function rebuildProfileSettingsLayout() { | |||
var root = document.querySelector('.clbi-prefs-profile'); | |||
if (!root || root.dataset.profileSettingsReworked === '1') return; | |||
root.dataset.profileSettingsReworked = '1'; | |||
root.classList.add('profile-settings-console'); | |||
removePrefRow('pref-badges'); | |||
var originalRows = Array.prototype.slice.call(root.querySelectorAll('.clbi-pref-row')); | |||
var actionNodes = []; | |||
if (saveBtn.parentNode === root || saveBtn.closest('.clbi-prefs-profile') === root) { | |||
actionNodes.push(saveBtn); | |||
} | |||
var statusNode = document.getElementById('pref-status'); | |||
if (statusNode && statusNode.closest('.clbi-prefs-profile') === root) { | |||
actionNodes.push(statusNode); | |||
} | |||
var main = document.createElement('div'); | |||
main.className = 'clbi-pref-main-grid'; | |||
var media = createPrefSection('clbi-pref-section-media', 'PROFILE IMAGE'); | |||
var identity = createPrefSection('clbi-pref-section-identity', 'IDENTITY RECORD'); | |||
var bio = createPrefSection('clbi-pref-section-bio', 'BIOGRAPHY'); | |||
var account = createPrefSection('clbi-pref-section-account', 'ACCOUNT CONTACT'); | |||
var misc = createPrefSection('clbi-pref-section-misc', 'OTHER OPTIONS'); | |||
main.appendChild(media.section); | |||
main.appendChild(identity.section); | |||
main.appendChild(bio.section); | |||
main.appendChild(account.section); | |||
main.appendChild(misc.section); | |||
root.innerHTML = ''; | |||
root.appendChild(main); | |||
moveRowToSection('pref-pfp-preview', media.body, 'pfp'); | |||
moveRowToSection('pref-pfp-btn', media.body, 'pfp'); | |||
moveRowToSection('pref-pfp-input', media.body, 'pfp'); | |||
moveRowToSection('pref-name', identity.body, 'name'); | |||
moveRowToSection('pref-role', identity.body, 'role'); | |||
moveRowToSection('pref-discord', identity.body, 'discord'); | |||
moveRowToSection('pref-bio', bio.body, 'bio'); | |||
moveRowToSection('pref-new-email', account.body, 'email'); | |||
moveRowToSection('pref-email-password', account.body, 'email'); | |||
moveRowToSection('pref-email-save', account.body, 'email'); | |||
originalRows.forEach(function (row) { | |||
if (!row.parentNode && !row.className.match(/clbi-pref-row-key-/)) { | |||
misc.body.appendChild(row); | |||
} | |||
}); | |||
if (!misc.body.children.length) { | |||
misc.section.parentNode.removeChild(misc.section); | |||
} | |||
var actions = document.createElement('div'); | |||
actions.className = 'clbi-pref-actions'; | |||
if (saveBtn) actions.appendChild(saveBtn); | |||
if (statusNode) actions.appendChild(statusNode); | |||
root.appendChild(actions); | |||
} | |||
rebuildProfileSettingsLayout(); | |||
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']; | |||
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;', | |||
'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)); }', | |||
'vec2 coverUV(vec2 uv) {', | |||
' float imgAR = u_imgSize.x / u_imgSize.y;', | |||
' float scrAR = u_res.x / u_res.y;', | |||
' float scale = imgAR / scrAR;', | |||
' float offsetY = (1.0 - scale) * 0.5;', | |||
' return vec2(uv.x, uv.y * scale + offsetY);', | |||
'}', | |||
'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;', | |||
'}', | |||
'vec4 sampleInitialNoise(float t) {', | |||
' return texture2D(u_noise, vec2(fract(t/2048.0), fract(t/1048576.0)));', | |||
'}', | |||
'vec4 sampleScreenNoise(vec2 uv) {', | |||
' return texture2D(u_noise, u_noiseScale * uv);', | |||
'}', | |||
'vec3 applyRgbShift(vec2 texUV, float shift) {', | |||
' vec2 d = vec2(shift, 0.0);', | |||
' vec3 r = texture2D(u_tex, clamp(texUV + d, 0.0, 1.0)).rgb;', | |||
' vec3 c = texture2D(u_tex, texUV).rgb;', | |||
' vec3 l = texture2D(u_tex, clamp(texUV - d, 0.0, 1.0)).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', | |||
' );', | |||
'}', | |||
'vec3 applyBloom(vec2 texUV, float strength) {', | |||
' vec2 px = 2.0 / u_res;', | |||
' vec3 acc = vec3(0.0);', | |||
' acc += texture2D(u_tex, clamp(texUV + vec2( px.x, 0.0), 0.0, 1.0)).rgb;', | |||
' acc += texture2D(u_tex, clamp(texUV + vec2(-px.x, 0.0), 0.0, 1.0)).rgb;', | |||
' acc += texture2D(u_tex, clamp(texUV + vec2( 0.0, px.y), 0.0, 1.0)).rgb;', | |||
' acc += texture2D(u_tex, clamp(texUV + vec2( 0.0, -px.y), 0.0, 1.0)).rgb;', | |||
' acc += texture2D(u_tex, clamp(texUV + vec2( px.x, px.y), 0.0, 1.0)).rgb * 0.5;', | |||
' acc += texture2D(u_tex, clamp(texUV + vec2(-px.x, px.y), 0.0, 1.0)).rgb * 0.5;', | |||
' acc += texture2D(u_tex, clamp(texUV + vec2( px.x, -px.y), 0.0, 1.0)).rgb * 0.5;', | |||
' acc += texture2D(u_tex, clamp(texUV + vec2(-px.x, -px.y), 0.0, 1.0)).rgb * 0.5;', | |||
' return acc / 6.0 * strength;', | |||
'}', | |||
'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;', | |||
'}', | |||
'vec3 applyRasterization(vec2 uv, vec3 col) {', | |||
' float t = u_time;', | |||
' vec2 noiseUV = uv + vec2(fract(t * 0.030), fract(t * 0.060));', | |||
' float wobbleX = (texture2D(u_noise, noiseUV * 0.8).r - 0.5) * 0.0018;', | |||
' float wobbleY = (texture2D(u_noise, noiseUV * 0.8 + 0.5).r - 0.5) * 0.0008;', | |||
' vec2 wobbledUV = clamp(uv + vec2(wobbleX, wobbleY), 0.0, 1.0);', | |||
' vec3 wobbled = texture2D(u_tex, wobbledUV).rgb;', | |||
' return mix(col, wobbled, 0.35);', | |||
'}', | |||
'float glowingLine(vec2 uv, float t) {', | |||
' float pos = fract(t * 0.2);', | |||
' float lineY = pos * (u_res.y + 330.0) - 120.0;', | |||
' float y = uv.y * u_res.y;', | |||
' return fract(smoothstep(-300.0, 0.0, y - lineY));', | |||
'}', | |||
'vec2 applyHSync(vec2 uv, vec4 noise, float strength) {', | |||
' 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.18;', | |||
' 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; }', | |||
' vec2 texUV = clamp(coverUV(uv), 0.0, 1.0);', | |||
' vec4 initNoise = sampleInitialNoise(u_time);', | |||
' vec4 screenNoise = sampleScreenNoise(uv);', | |||
' texUV = applyHSync(texUV, initNoise, 0.006);', | |||
' texUV = clamp(texUV, 0.0, 1.0);', | |||
' texUV += (vec2(screenNoise.b, screenNoise.a) - 0.5) * 0.0006;', | |||
' texUV = clamp(texUV, 0.0, 1.0);', | |||
' vec3 col = applyRgbShift(texUV, 0.003);', | |||
' col += applyBloom(texUV, 0.22);', | |||
' vec2 bpx = 1.5 / u_res;', | |||
' vec3 blurCol = vec3(0.0);', | |||
' blurCol += texture2D(u_tex, clamp(texUV + vec2(-bpx.x, -bpx.y), 0.0, 1.0)).rgb;', | |||
' blurCol += texture2D(u_tex, clamp(texUV + vec2( 0.0, -bpx.y), 0.0, 1.0)).rgb;', | |||
' blurCol += texture2D(u_tex, clamp(texUV + vec2( bpx.x, -bpx.y), 0.0, 1.0)).rgb;', | |||
' blurCol += texture2D(u_tex, clamp(texUV + vec2(-bpx.x, 0.0 ), 0.0, 1.0)).rgb;', | |||
' blurCol += texture2D(u_tex, clamp(texUV + vec2( bpx.x, 0.0 ), 0.0, 1.0)).rgb;', | |||
' blurCol += texture2D(u_tex, clamp(texUV + vec2(-bpx.x, bpx.y), 0.0, 1.0)).rgb;', | |||
' blurCol += texture2D(u_tex, clamp(texUV + vec2( 0.0, bpx.y), 0.0, 1.0)).rgb;', | |||
' blurCol += texture2D(u_tex, clamp(texUV + vec2( bpx.x, bpx.y), 0.0, 1.0)).rgb;', | |||
' col = mix(col, blurCol / 8.0, 0.40);', | |||
' col = applyScanlines(uv, col);', | |||
' col = applyRasterization(texUV, col);', | |||
' float glow = glowingLine(uv, u_time);', | |||
' col += glow * 0.08 * vec3(0.85, 0.95, 1.0);', | |||
' float dist = length(cc);', | |||
' col += screenNoise.a * 0.07 * (1.0 - dist * 1.3);', | |||
' float grey = rgb2grey(col);', | |||
' vec3 phosphor = vec3(0.75, 0.88, 1.0);', | |||
' col = mix(col, grey * phosphor, 0.35);', | |||
' vec2 vig = v_uv * (1.0 - v_uv);', | |||
' col *= pow(vig.x * vig.y * 15.0, 0.25);', | |||
' col *= 1.0 + (initNoise.g - 0.5) * 0.06;', | |||
' 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'); | |||
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); | |||
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); | |||
}); | |||
} | |||
})(); | |||
/* ========================================= | |||
Progress System UI | |||
MediaWiki:Common.js controlled frontend | |||
========================================= */ | |||
(function (mw, $) { | |||
'use strict'; | |||
if (window.ProgressSystemWebUiInitialized) return; | |||
window.ProgressSystemWebUiInitialized = true; | |||
var api = null; | |||
function withApi(done, fail) { | |||
if (api) { | |||
done(api); | |||
return; | |||
} | |||
if (!mw.loader || typeof mw.loader.using !== 'function') { | |||
if (typeof fail === 'function') fail(); | |||
return; | |||
} | |||
mw.loader.using(['mediawiki.api']).then(function () { | |||
api = new mw.Api(); | |||
done(api); | |||
}, function () { | |||
if (typeof fail === 'function') fail(); | |||
}); | |||
} | |||
var inFlightPageIds = new Set(); | |||
var handledPageIds = new Set(); | |||
var notificationQueue = []; | |||
var notificationActive = false; | |||
var summaryRequested = false; | |||
var currentSummary = null; | |||
var pendingSummary = null; | |||
var pendingOptions = null; | |||
var visibilityBound = false; | |||
var barTimerA = null; | |||
var barTimerB = null; | |||
var barTimerC = null; | |||
function isLoggedIn() { | |||
return !!mw.config.get('wgUserName'); | |||
} | |||
function getPageId() { | |||
var id = parseInt(mw.config.get('wgArticleId') || 0, 10); | |||
return Number.isFinite(id) ? id : 0; | |||
} | |||
function isRewardableClientSide() { | |||
if (!isLoggedIn()) return false; | |||
if (parseInt(mw.config.get('wgNamespaceNumber'), 10) !== 0) return false; | |||
if (mw.config.get('wgIsMainPage')) return false; | |||
if (getPageId() <= 0) return false; | |||
return true; | |||
} | |||
function getPanelHtml() { | |||
return '' + | |||
'<div id="progress-panel" class="profile-progress-block is-syncing" aria-live="polite" data-progress-state="syncing">' + | |||
'<div class="progress-title-row" hidden></div>' + | |||
'<div class="progress-level-row">' + | |||
'<span class="progress-level-label">SYNC</span>' + | |||
'<span class="progress-total-xp">— XP</span>' + | |||
'</div>' + | |||
'<div class="progress-xp-bar" aria-hidden="true">' + | |||
'<div class="progress-xp-gain"></div>' + | |||
'<div class="progress-xp-fill"></div>' + | |||
'</div>' + | |||
'<div class="progress-sub-row">' + | |||
'<span class="progress-xp-next">SYNCING</span>' + | |||
'<span class="progress-daily-xp">TODAY —</span>' + | |||
'</div>' + | |||
'<div class="progress-discovery-row">DISCOVERED —</div>' + | |||
'</div>'; | |||
} | |||
function getDividerHtml() { | |||
return '<div id="profile-progress-divider" class="profile-divider" aria-hidden="true"></div>'; | |||
} | |||
function setPanelSync($panel) { | |||
if (!$panel || !$panel.length) return; | |||
$panel.addClass('is-syncing').removeClass('is-max-level').attr('data-progress-state', 'syncing'); | |||
$panel.find('.progress-title-row').text('').prop('hidden', true); | |||
$panel.find('.progress-level-label').text('SYNC'); | |||
$panel.find('.progress-total-xp').text('— XP'); | |||
$panel.find('.progress-xp-next').text('SYNCING'); | |||
$panel.find('.progress-daily-xp').text('TODAY —'); | |||
$panel.find('.progress-discovery-row').text('DISCOVERED —'); | |||
$panel.find('.progress-xp-fill').css({ transition: 'none', width: '0%' }); | |||
$panel.find('.progress-xp-gain').css({ transition: 'none', width: '0%', opacity: 0 }); | |||
} | |||
function placePanel($panel) { | |||
var $right = $('#clbi-right-sidebar'); | |||
if (!$right.length) return false; | |||
var $userBox = $right.children('.clbi-right-box').first(); | |||
if (!$userBox.length) return false; | |||
var $buttonArea = $userBox.children('.clbi-right-content').first(); | |||
var $oldFallback = $panel.closest('.progress-panel-fallback'); | |||
if ($buttonArea.length) { | |||
var $divider = $('#profile-progress-divider'); | |||
$panel.insertBefore($buttonArea); | |||
if (!$divider.length) { | |||
$divider = $(getDividerHtml()); | |||
} | |||
$divider.insertAfter($panel); | |||
} else { | |||
$('#profile-progress-divider').remove(); | |||
$userBox.append($panel); | |||
} | |||
if ($oldFallback.length && !$oldFallback.find('#progress-panel').length) { | |||
$oldFallback.remove(); | |||
} | |||
return true; | |||
} | |||
function ensurePanel() { | |||
if (!isLoggedIn()) return $(); | |||
var $right = $('#clbi-right-sidebar'); | |||
if (!$right.length) return $(); | |||
var $panel = $('#progress-panel'); | |||
if (!$panel.length) { | |||
$panel = $(getPanelHtml()); | |||
if (!placePanel($panel)) return $(); | |||
setPanelSync($panel); | |||
} else { | |||
$panel.addClass('profile-progress-block'); | |||
placePanel($panel); | |||
if (!currentSummary && $panel.attr('data-progress-state') !== 'syncing') { | |||
setPanelSync($panel); | |||
} | |||
} | |||
return $('#progress-panel'); | |||
} | |||
function clampPercent(value) { | |||
return Math.max(0, Math.min(100, value || 0)); | |||
} | |||
function hasXpNotification(items) { | |||
if (!items || !items.length) return false; | |||
return items.some(function (item) { | |||
return item && item.type === 'xp' && parseInt(item.amount || 0, 10) > 0; | |||
}); | |||
} | |||
function clearBarTimers() { | |||
[barTimerA, barTimerB, barTimerC].forEach(function (timer) { | |||
if (timer) clearTimeout(timer); | |||
}); | |||
barTimerA = null; | |||
barTimerB = null; | |||
barTimerC = null; | |||
} | |||
function setBarInstant($fill, $gain, percent) { | |||
clearBarTimers(); | |||
percent = clampPercent(percent); | |||
$fill.css({ transition: 'none', width: percent + '%' }); | |||
$gain.css({ transition: 'none', left: '0%', width: '0%', opacity: 0 }); | |||
if ($fill[0]) $fill[0].offsetHeight; | |||
$fill.css({ transition: '' }); | |||
$gain.css({ transition: '' }); | |||
} | |||
function animateGain($fill, $gain, fromPercent, toPercent, levelChanged) { | |||
clearBarTimers(); | |||
fromPercent = clampPercent(fromPercent); | |||
toPercent = clampPercent(toPercent); | |||
$fill.css({ transition: 'none', width: fromPercent + '%' }); | |||
if (levelChanged) { | |||
var firstDelta = Math.max(0, 100 - fromPercent); | |||
$gain.css({ | |||
transition: 'none', | |||
opacity: firstDelta > 0 ? 1 : 0, | |||
left: fromPercent + '%', | |||
width: firstDelta + '%' | |||
}); | |||
if ($fill[0]) $fill[0].offsetHeight; | |||
barTimerA = setTimeout(function () { | |||
$fill.css({ | |||
transition: 'width 540ms cubic-bezier(0.22, 0.7, 0.18, 1)', | |||
width: '100%' | |||
}); | |||
}, 260); | |||
barTimerB = setTimeout(function () { | |||
$fill.css({ transition: 'none', width: '0%' }); | |||
$gain.css({ transition: 'none', opacity: toPercent > 0 ? 1 : 0, left: '0%', width: toPercent + '%' }); | |||
if ($fill[0]) $fill[0].offsetHeight; | |||
$fill.css({ | |||
transition: 'width 460ms cubic-bezier(0.22, 0.7, 0.18, 1)', | |||
width: toPercent + '%' | |||
}); | |||
}, 860); | |||
barTimerC = setTimeout(function () { | |||
$gain.css({ transition: 'opacity 180ms ease', opacity: 0 }); | |||
}, 1380); | |||
return; | |||
} | |||
var delta = Math.max(0, toPercent - fromPercent); | |||
if (delta <= 0.15) { | |||
setBarInstant($fill, $gain, toPercent); | |||
return; | |||
} | |||
$gain.css({ | |||
transition: 'none', | |||
opacity: 1, | |||
left: fromPercent + '%', | |||
width: delta + '%' | |||
}); | |||
if ($fill[0]) $fill[0].offsetHeight; | |||
barTimerA = setTimeout(function () { | |||
$fill.css({ | |||
transition: 'width 560ms cubic-bezier(0.22, 0.7, 0.18, 1)', | |||
width: toPercent + '%' | |||
}); | |||
}, 260); | |||
barTimerB = setTimeout(function () { | |||
$gain.css({ transition: 'opacity 180ms ease', opacity: 0 }); | |||
}, 940); | |||
} | |||
function updatePanel(summary, options) { | |||
if (!summary) return; | |||
options = options || {}; | |||
var $panel = ensurePanel(); | |||
if (!$panel.length) { | |||
pendingSummary = $.extend({}, summary); | |||
pendingOptions = $.extend({}, options); | |||
return; | |||
} | |||
var level = summary.level || 1; | |||
var totalXp = summary.totalXp || 0; | |||
var xpIntoLevel = summary.xpIntoLevel || 0; | |||
var xpForNext = summary.xpForNextLevel || 1; | |||
var percent = clampPercent(summary.progressPercent); | |||
var isMaxLevel = !!summary.isMaxLevel; | |||
var dailyXp = summary.dailyXp || 0; | |||
var discoveries = summary.discoveryCount || 0; | |||
var title = summary.equippedTitle || summary.title || ''; | |||
$panel.removeClass('is-syncing').toggleClass('is-max-level', isMaxLevel).attr('data-progress-state', 'ready'); | |||
$panel.find('.progress-level-label').text((isMaxLevel ? 'MAX ' : 'LVL ') + level); | |||
$panel.find('.progress-total-xp').text(totalXp + ' XP'); | |||
$panel.find('.progress-xp-next').text(isMaxLevel ? 'MAX LEVEL' : (xpIntoLevel + ' / ' + xpForNext + ' TO NEXT')); | |||
$panel.find('.progress-daily-xp').text('TODAY ' + dailyXp + ' XP'); | |||
$panel.find('.progress-discovery-row').text('DISCOVERED ' + discoveries); | |||
var $title = $panel.find('.progress-title-row'); | |||
if (title) { | |||
$title.text(title).prop('hidden', false); | |||
} else { | |||
$title.text('').prop('hidden', true); | |||
} | |||
var $fill = $panel.find('.progress-xp-fill'); | |||
var $gain = $panel.find('.progress-xp-gain'); | |||
var animate = !!options.animateGain && currentSummary && totalXp > (currentSummary.totalXp || 0); | |||
if (animate) { | |||
animateGain( | |||
$fill, | |||
$gain, | |||
clampPercent(currentSummary.progressPercent), | |||
percent, | |||
level !== (currentSummary.level || 1) | |||
); | |||
} else { | |||
setBarInstant($fill, $gain, percent); | |||
} | |||
currentSummary = $.extend({}, summary); | |||
pendingSummary = null; | |||
pendingOptions = null; | |||
} | |||
function requestSummary() { | |||
if (!isLoggedIn()) return; | |||
if (summaryRequested) return; | |||
summaryRequested = true; | |||
withApi(function (api) { | |||
api.get({ | |||
action: 'progress_summary', | |||
format: 'json', | |||
formatversion: 2 | |||
}).then(function (data) { | |||
var payload = data && data.progress_summary; | |||
if (payload && payload.available && payload.summary) { | |||
updatePanel(payload.summary, { animateGain: false }); | |||
} | |||
}).always(function () { | |||
summaryRequested = false; | |||
}); | |||
}, function () { | |||
summaryRequested = false; | |||
}); | |||
} | |||
function queueNotifications(items) { | |||
if (!items || !items.length) return; | |||
items.forEach(function (item) { | |||
if (!item) return; | |||
notificationQueue.push(item); | |||
}); | |||
showNextNotification(); | |||
} | |||
function notificationText(item) { | |||
if (item.type === 'xp') { | |||
return '+' + (item.amount || 0) + ' XP · ' + (item.label || '문서 열람'); | |||
} | |||
if (item.type === 'achievement') { | |||
var xp = item.amount ? ' · +' + item.amount + ' XP' : ''; | |||
return '업적 달성 · ' + (item.label || '새 업적') + xp; | |||
} | |||
if (item.type === 'level') { | |||
return item.label || '레벨 상승'; | |||
} | |||
return item.label || '보상 획득'; | |||
} | |||
function showNextNotification() { | |||
if (notificationActive) return; | |||
if (!notificationQueue.length) return; | |||
notificationActive = true; | |||
var item = notificationQueue.shift(); | |||
var $root = $('#progress-toast-root'); | |||
if (!$root.length) { | |||
$('body').append('<div id="progress-toast-root"></div>'); | |||
$root = $('#progress-toast-root'); | |||
} | |||
var $toast = $('<div class="progress-toast"></div>'); | |||
$toast.text(notificationText(item)); | |||
$root.append($toast); | |||
requestAnimationFrame(function () { | |||
$toast.addClass('is-visible'); | |||
}); | |||
setTimeout(function () { | |||
$toast.removeClass('is-visible'); | |||
setTimeout(function () { | |||
$toast.remove(); | |||
notificationActive = false; | |||
showNextNotification(); | |||
}, 220); | |||
}, 2600); | |||
} | |||
function applyPendingSummaryIfPossible() { | |||
if (!pendingSummary) return; | |||
updatePanel(pendingSummary, pendingOptions || { animateGain: false }); | |||
} | |||
function handlePageView() { | |||
ensurePanel(); | |||
applyPendingSummaryIfPossible(); | |||
if (!isRewardableClientSide()) { | |||
requestSummary(); | |||
return; | |||
} | |||
var pageId = getPageId(); | |||
if (handledPageIds.has(pageId)) { | |||
requestSummary(); | |||
return; | |||
} | |||
if (inFlightPageIds.has(pageId)) { | |||
requestSummary(); | |||
return; | |||
} | |||
inFlightPageIds.add(pageId); | |||
withApi(function (api) { | |||
api.postWithToken('csrf', { | |||
action: 'progress_view', | |||
format: 'json', | |||
formatversion: 2, | |||
errorformat: 'plaintext', | |||
pageid: pageId | |||
}).then(function (data) { | |||
var payload = data && data.progress_view; | |||
if (!payload) return; | |||
handledPageIds.add(pageId); | |||
var animate = hasXpNotification(payload.notifications); | |||
if (payload.summary) { | |||
updatePanel(payload.summary, { animateGain: animate }); | |||
} | |||
if (payload.notifications && payload.notifications.length) { | |||
queueNotifications(payload.notifications); | |||
} | |||
}).catch(function () { | |||
requestSummary(); | |||
}).always(function () { | |||
inFlightPageIds.delete(pageId); | |||
}); | |||
}, function () { | |||
inFlightPageIds.delete(pageId); | |||
requestSummary(); | |||
}); | |||
} | |||
function bindVisibilitySync() { | |||
if (visibilityBound) return; | |||
visibilityBound = true; | |||
document.addEventListener('visibilitychange', function () { | |||
if (document.visibilityState === 'visible') { | |||
requestSummary(); | |||
} | |||
}); | |||
} | |||
function bootProgressSystem(reason) { | |||
ensurePanel(); | |||
applyPendingSummaryIfPossible(); | |||
if (isRewardableClientSide()) { | |||
handlePageView(); | |||
} else { | |||
requestSummary(); | |||
} | |||
} | |||
function handleSpaPageView() { | |||
ensurePanel(); | |||
applyPendingSummaryIfPossible(); | |||
requestAnimationFrame(function () { | |||
setTimeout(function () { | |||
handlePageView(); | |||
}, 80); | |||
}); | |||
} | |||
function applySummary(summary, options) { | |||
updatePanel(summary, options || { animateGain: false }); | |||
} | |||
window.ProgressSystemWebUi = { | |||
boot: bootProgressSystem, | |||
requestSummary: requestSummary, | |||
applySummary: applySummary, | |||
handlePageView: handlePageView, | |||
handleSpaPageView: handleSpaPageView, | |||
ensurePanel: ensurePanel | |||
}; | |||
$(function () { | |||
bindVisibilitySync(); | |||
bootProgressSystem('documentReady'); | |||
}); | |||
mw.hook('wikipage.content').add(function () { | |||
ensurePanel(); | |||
applyPendingSummaryIfPossible(); | |||
}); | |||
})(mediaWiki, jQuery); | |||
2026년 6월 4일 (목) 04:21 기준 최신판
mw.loader.load('https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.5.13/cropper.min.js');
mw.loader.load('/index.php?title=MediaWiki:DevTools.js&action=raw&ctype=text/javascript');
mw.loader.load('/index.php?title=MediaWiki:CategoryNav.js&action=raw&ctype=text/javascript');
mw.loader.load('/index.php?title=MediaWiki:AnecdoteViewer.js&action=raw&ctype=text/javascript');
(function () {
'use strict';
var SYSTEM_TITLE_NAMESPACES = {
'-1': true,
'4': true,
'5': true,
'6': true,
'7': true,
'8': true,
'9': true,
'10': true,
'11': true,
'12': true,
'13': true,
'14': true,
'15': true,
'828': true,
'829': true
};
function normalizePageNameForShell(value) {
return String(value || '')
.split('?')[0]
.replace(/^\/index\.php\//, '')
.replace(/_/g, ' ')
.trim();
}
function readCurrentPageNameForShell() {
var pageName = mw.config.get('wgPageName') || '';
if (pageName) {
return normalizePageNameForShell(pageName);
}
return normalizePageNameForShell(window.location.pathname || '');
}
function isAnecdoteNamespaceForShell() {
var namespaceNumber = Number(mw.config.get('wgNamespaceNumber'));
var canonicalNamespace = String(mw.config.get('wgCanonicalNamespace') || '').toLowerCase();
var pageName = readCurrentPageNameForShell();
return namespaceNumber === 3000 ||
canonicalNamespace === 'anecdote' ||
/^(anecdote|에넥도트):/i.test(pageName);
}
function isBackendOrSystemPageForShell() {
var namespaceNumber = Number(mw.config.get('wgNamespaceNumber'));
var action = String(mw.config.get('wgAction') || 'view').toLowerCase();
var contentModel = String(mw.config.get('wgPageContentModel') || '').toLowerCase();
var pageName = readCurrentPageNameForShell();
var lowerPageName = pageName.toLowerCase();
if (action && action !== 'view') {
return true;
}
if (pageName === '대문') {
return false;
}
if (SYSTEM_TITLE_NAMESPACES[String(namespaceNumber)]) {
return true;
}
if (contentModel === 'css' || contentModel === 'javascript' || contentModel === 'json' || contentModel === 'sanitized-css') {
return true;
}
if (/\.(css|js|json)$/i.test(pageName)) {
return true;
}
if (/^(mediawiki|미디어위키|special|특수):/i.test(pageName)) {
return true;
}
return false;
}
function isMediaWikiSystemAssetPageForShell() {
var namespaceNumber = Number(mw.config.get('wgNamespaceNumber'));
var pageName = readCurrentPageNameForShell();
var contentModel = String(mw.config.get('wgPageContentModel') || '').toLowerCase();
return namespaceNumber === 8 &&
(/\.(css|js)$/i.test(pageName) || contentModel === 'css' || contentModel === 'javascript' || contentModel === 'sanitized-css');
}
var systemDocRawFetchToken = 0;
function cleanupLegacySystemDocCodeMutationsForShell() {
document.querySelectorAll('.clbi-system-doc-codepane').forEach(function (pane) {
var parent;
if (!pane || !pane.parentNode) return;
parent = pane.parentNode;
while (pane.firstChild) {
parent.insertBefore(pane.firstChild, pane);
}
parent.removeChild(pane);
});
document.querySelectorAll('.clbi-system-doc-codebox').forEach(function (node) {
node.classList.remove('clbi-system-doc-codebox');
node.removeAttribute('data-clbi-system-doc-codebox');
node.removeAttribute('style');
});
}
function getSystemDocOutputForShell() {
return document.querySelector('.liberty-content-main .mw-parser-output');
}
function findSystemDocSourceNodeForShell() {
var output = getSystemDocOutputForShell();
var children;
var preferred;
if (!output) return null;
children = Array.prototype.slice.call(output.children || [])
.filter(function (el) {
return el && el.nodeType === 1 &&
el.id !== 'clbi-system-doc-indicator-row' &&
el.id !== 'clbi-system-source-viewer' &&
!el.classList.contains('catlinks') &&
(el.textContent || '').trim().length > 200;
});
preferred = children.filter(function (el) {
return el.matches && el.matches('.mw-highlight, .mw-code, pre');
})[0];
return preferred || children.sort(function (a, b) {
return (b.textContent || '').trim().length - (a.textContent || '').trim().length;
})[0] || null;
}
function getSystemDocRawUrlForShell() {
var title = mw.config.get('wgPageName') || readCurrentPageNameForShell();
if (window.mw && mw.util && typeof mw.util.getUrl === 'function') {
return mw.util.getUrl(title, {
action: 'raw',
ctype: 'text/plain',
_: String(Date.now())
});
}
return '/index.php?title=' + encodeURIComponent(title) + '&action=raw&ctype=text/plain&_=' + Date.now();
}
function removeSystemDocSourceViewerForShell() {
var viewer = document.getElementById('clbi-system-source-viewer');
if (viewer && viewer.parentNode) {
viewer.parentNode.removeChild(viewer);
}
document.querySelectorAll('.clbi-system-original-source-hidden').forEach(function (node) {
node.classList.remove('clbi-system-original-source-hidden');
node.removeAttribute('data-clbi-system-source-hidden');
node.style.removeProperty('display');
});
cleanupLegacySystemDocCodeMutationsForShell();
}
function ensureSystemDocSourceViewerForShell() {
var output = getSystemDocOutputForShell();
var source;
var viewer;
var fallbackText;
if (!output || !isMediaWikiSystemAssetPageForShell()) return null;
cleanupLegacySystemDocCodeMutationsForShell();
source = findSystemDocSourceNodeForShell();
if (!source) return null;
viewer = document.getElementById('clbi-system-source-viewer');
if (!viewer) {
viewer = document.createElement('pre');
viewer.id = 'clbi-system-source-viewer';
viewer.className = 'clbi-system-source-viewer';
output.appendChild(viewer);
}
fallbackText = source.textContent || '';
if (!viewer.textContent && fallbackText) {
viewer.textContent = fallbackText;
}
source.classList.add('clbi-system-original-source-hidden');
source.setAttribute('data-clbi-system-source-hidden', 'true');
source.style.setProperty('display', 'none', 'important');
return viewer;
}
function renderSystemDocSourceViewerForShell() {
var viewer;
var pageName;
var token;
var currentScrollTop;
if (!isMediaWikiSystemAssetPageForShell()) return;
pageName = String(mw.config.get('wgPageName') || readCurrentPageNameForShell());
viewer = document.getElementById('clbi-system-source-viewer');
/*
시스템 문서 뷰어가 이미 만들어져 있고 raw 원문도 로드된 상태라면
다시 source 탐색/숨김/스타일 재적용을 하지 않는다.
DevTools Elements 패널에서 body가 계속 파랗게 깜빡이던 원인은
MutationObserver가 이 재적용을 반복해서 DOM attribute mutation을 만들었기 때문이다.
*/
if (
viewer &&
viewer.getAttribute('data-clbi-raw-title') === pageName &&
viewer.getAttribute('data-clbi-raw-loaded') === '1'
) {
return;
}
viewer = ensureSystemDocSourceViewerForShell();
if (!viewer) return;
currentScrollTop = viewer.scrollTop || 0;
viewer.setAttribute('data-clbi-raw-title', pageName);
token = ++systemDocRawFetchToken;
fetch(getSystemDocRawUrlForShell(), { credentials: 'same-origin' })
.then(function (res) {
if (!res.ok) throw new Error('raw fetch failed ' + res.status);
return res.text();
})
.then(function (text) {
if (token !== systemDocRawFetchToken) return;
currentScrollTop = viewer.scrollTop || currentScrollTop || 0;
if (text && viewer.textContent !== text) {
viewer.textContent = text;
}
viewer.setAttribute('data-clbi-raw-loaded', '1');
viewer.scrollTop = currentScrollTop;
})
.catch(function () {
viewer.setAttribute('data-clbi-raw-loaded', '0');
});
}
function removeSystemDocIndicatorForShell() {
var existing = document.getElementById('clbi-system-doc-indicator-row');
if (document.body) {
document.body.classList.remove('clbi-system-doc-page');
}
if (existing && existing.parentNode) {
existing.parentNode.removeChild(existing);
}
removeSystemDocSourceViewerForShell();
}
function renderSystemDocIndicatorForShell() {
var pageName;
var extMatch;
var ext;
var row;
var box;
var meta;
var label;
var type;
var title;
var anchor;
var main;
if (!document.body || !isMediaWikiSystemAssetPageForShell()) return;
pageName = readCurrentPageNameForShell();
extMatch = pageName.match(/\.(css|js)$/i);
ext = extMatch ? extMatch[1].toUpperCase() : 'DOC';
document.body.classList.add('clbi-system-doc-page');
row = document.getElementById('clbi-system-doc-indicator-row');
if (!row) {
row = document.createElement('div');
row.id = 'clbi-system-doc-indicator-row';
row.className = 'clbi-system-doc-indicator-row';
box = document.createElement('div');
box.className = 'clbi-system-doc-indicator';
meta = document.createElement('div');
meta.className = 'clbi-system-doc-meta';
label = document.createElement('span');
label.className = 'clbi-system-doc-label';
label.textContent = 'SYSTEM DOCUMENT';
type = document.createElement('span');
type.className = 'clbi-system-doc-type';
title = document.createElement('div');
title.className = 'clbi-system-doc-title';
meta.appendChild(label);
meta.appendChild(type);
box.appendChild(meta);
box.appendChild(title);
row.appendChild(box);
anchor = getSystemDocOutputForShell();
main = document.querySelector('.liberty-content-main');
if (anchor && anchor.parentNode) {
anchor.parentNode.insertBefore(row, anchor);
} else if (main) {
main.insertBefore(row, main.firstChild);
}
}
type = row.querySelector('.clbi-system-doc-type');
title = row.querySelector('.clbi-system-doc-title');
if (type) type.textContent = ext;
if (title) title.textContent = pageName;
renderSystemDocSourceViewerForShell();
}
var PAGE_TITLE_TARGET_SELECTORS = [
'.liberty-content-header',
'.liberty-content-header .title',
'.liberty-content-header .title h1',
'.liberty-content-header h1',
'#firstHeading',
'.firstHeading',
'.mw-first-heading',
'.page-heading',
'.page-header',
'.mw-page-title-main',
'.mw-page-title-namespace',
'.mw-page-title-separator'
];
var pageShellObserverStarted = false;
var pageShellObserverTimer = null;
function setPageTitleDomHidden(hidden) {
var nodes = document.querySelectorAll(PAGE_TITLE_TARGET_SELECTORS.join(','));
nodes.forEach(function (node) {
if (!node || !node.style) return;
if (hidden) {
node.setAttribute('data-clbi-title-hidden', 'true');
node.style.setProperty('display', 'none', 'important');
} else if (node.getAttribute('data-clbi-title-hidden') === 'true') {
node.removeAttribute('data-clbi-title-hidden');
node.style.removeProperty('display');
}
});
}
function applyPageShellClasses() {
var body = document.body;
var isSystemPage;
if (!body) return;
isSystemPage = isBackendOrSystemPageForShell();
body.classList.remove('page-title-hidden', 'page-title-visible', 'backend-system-page', 'anecdote-namespace-page');
if (!isMediaWikiSystemAssetPageForShell()) {
body.classList.remove('clbi-system-doc-page');
removeSystemDocIndicatorForShell();
}
if (isAnecdoteNamespaceForShell()) {
body.classList.add('anecdote-namespace-page');
}
if (isMediaWikiSystemAssetPageForShell()) {
body.classList.add('page-title-hidden', 'backend-system-page', 'clbi-system-doc-page');
setPageTitleDomHidden(true);
renderSystemDocIndicatorForShell();
} else if (isSystemPage) {
body.classList.add('page-title-visible', 'backend-system-page');
setPageTitleDomHidden(false);
} else {
body.classList.add('page-title-hidden');
setPageTitleDomHidden(true);
}
}
function applyPageShellClassesDeferred() {
applyPageShellClasses();
window.setTimeout(applyPageShellClasses, 0);
window.setTimeout(applyPageShellClasses, 80);
window.setTimeout(applyPageShellClasses, 250);
}
function startPageShellObserver() {
var observer;
if (pageShellObserverStarted || !window.MutationObserver || !document.body) return;
pageShellObserverStarted = true;
observer = new MutationObserver(function (mutations) {
var i;
var target;
/*
시스템 CSS/JS 문서는 applyPageShellClasses()가 초기에 한 번
인디케이터와 source viewer를 만든 뒤에는 MutationObserver가 다시
같은 렌더링을 반복할 필요가 없다. 이 반복이 DevTools에서 body/요소가
계속 플래시되는 직접 원인이다.
SPA 전환 뒤의 처리는 loadPage()와 wikipage.content hook에서 따로 호출된다.
*/
if (isMediaWikiSystemAssetPageForShell()) {
for (i = 0; i < mutations.length; i += 1) {
target = mutations[i] && mutations[i].target;
if (
target &&
target.nodeType === 1 &&
(
target.id === 'clbi-system-source-viewer' ||
target.id === 'clbi-system-doc-indicator-row' ||
(target.closest && target.closest('#clbi-system-source-viewer, #clbi-system-doc-indicator-row'))
)
) {
return;
}
}
if (
document.getElementById('clbi-system-doc-indicator-row') &&
document.getElementById('clbi-system-source-viewer')
) {
return;
}
}
if (pageShellObserverTimer) return;
pageShellObserverTimer = window.setTimeout(function () {
pageShellObserverTimer = null;
applyPageShellClasses();
}, 50);
});
observer.observe(document.body, {
childList: true,
subtree: true
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function () {
applyPageShellClassesDeferred();
startPageShellObserver();
});
} else {
applyPageShellClassesDeferred();
startPageShellObserver();
}
if (mw.hook) {
mw.hook('wikipage.content').add(applyPageShellClassesDeferred);
}
window.CLBI_PAGE_SHELL = {
refresh: applyPageShellClasses,
isBackendOrSystemPage: isBackendOrSystemPageForShell,
isSystemAssetPage: isMediaWikiSystemAssetPageForShell,
renderSystemDocIndicator: renderSystemDocIndicatorForShell,
removeSystemDocIndicator: removeSystemDocIndicatorForShell,
refreshSystemDocSourceViewer: renderSystemDocSourceViewerForShell
};
}());
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 initHalftoneBackground() {
try {
initWebGLHalftoneBackground();
} catch (err) {
console.error('WebGL halftone background failed:', err);
}
}
function initWebGLHalftoneBackground() {
var canvasId = 'site-halftone-bg';
var existing = document.getElementById(canvasId);
var canvas = existing || document.createElement('canvas');
if (!existing) {
canvas.id = canvasId;
canvas.setAttribute('aria-hidden', 'true');
document.body.insertBefore(canvas, document.body.firstChild || null);
}
canvas.style.position = 'fixed';
canvas.style.inset = '0';
canvas.style.width = '100vw';
canvas.style.height = '100vh';
canvas.style.pointerEvents = 'none';
canvas.style.background = '#000000';
var gl = canvas.getContext('webgl', {
alpha: false,
antialias: false,
depth: false,
stencil: false,
preserveDrawingBuffer: false,
powerPreference: 'high-performance'
}) || canvas.getContext('experimental-webgl');
if (!gl) {
console.warn('WebGL background unavailable.');
return;
}
var vertexSrc = [
'attribute vec2 a_position;',
'void main() {',
' gl_Position = vec4(a_position, 0.0, 1.0);',
'}'
].join('\n');
var fragmentSrc = [
'precision mediump float;',
'uniform vec2 u_resolution;',
'uniform float u_time;',
'const float TAU = 6.28318530718;',
'float gaussian(float v, float r) {',
' return exp(-((v * v) / max(0.0001, r * r)));',
'}',
'float hash(vec2 p) {',
' return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123);',
'}',
'float bucketAlpha(float a) {',
' float i = floor(a * 9.0);',
' if (i < 1.0) return 0.040;',
' if (i < 2.0) return 0.080;',
' if (i < 3.0) return 0.135;',
' if (i < 4.0) return 0.210;',
' if (i < 5.0) return 0.310;',
' if (i < 6.0) return 0.430;',
' if (i < 7.0) return 0.580;',
' if (i < 8.0) return 0.760;',
' return 0.920;',
'}',
'void main() {',
' vec2 frag = gl_FragCoord.xy;',
' float spacing = 5.0;',
' float dotSize = 1.08;',
' vec2 grid = floor(frag / spacing);',
' vec2 inCell = mod(frag, spacing);',
' vec2 dotOrigin = vec2(1.0, 1.0);',
' vec2 dotCenter = dotOrigin + vec2(dotSize * 0.5);',
' vec2 local = abs(inCell - dotCenter);',
' float noise = hash(grid);',
' float size = dotSize + noise * 0.18;',
' float dotMask = 1.0 - smoothstep(size * 0.5, size * 0.5 + 0.22, max(local.x, local.y));',
' vec2 uv = frag / u_resolution;',
' float centerLine = 0.50 +',
' sin((uv.y * 1.32 + 0.08) * TAU) * 0.070 +',
' sin((uv.y * 3.18 + 0.34) * TAU) * 0.030;',
' float u = uv.x - centerLine;',
' float absU = abs(u);',
' float sideLift = smoothstep(0.065, 0.44, absU);',
' float valley = gaussian(u, 0.150);',
' float t = u_time;',
' float leftRibbonCenter = -0.28 + sin((uv.y * 3.20 + 0.12) * TAU) * 0.050;',
' float rightRibbonCenter = 0.27 + sin((uv.y * 2.85 + 0.56) * TAU) * 0.055;',
' float leftRibbon = gaussian(u - leftRibbonCenter, 0.105);',
' float rightRibbon = gaussian(u - rightRibbonCenter, 0.110);',
' float foldedU = u +',
' sin((uv.y * 4.40 + 0.22) * TAU) * 0.050 * (0.3 + sideLift) +',
' sin((uv.y * 7.20 + uv.x * 1.10) * TAU) * 0.022;',
' float verticalFold = pow(0.5 + 0.5 * cos(((foldedU * 3.05) + (sin(uv.y * TAU * 2.35) * 0.18)) * TAU), 2.5);',
' float diagonalFold = pow(0.5 + 0.5 * cos(((foldedU * 1.80) - (uv.y * 1.12) + 0.18) * TAU), 2.1);',
' float waist = gaussian(uv.y - 0.50, 0.25) * gaussian(absU - 0.20, 0.19);',
' float grain = (noise - 0.5) * 0.050;',
' float staticField =',
' 0.055 +',
' sideLift * 0.210 +',
' (leftRibbon + rightRibbon) * 0.145 +',
' verticalFold * (0.055 + sideLift * 0.115) +',
' diagonalFold * 0.045 +',
' waist * 0.060 -',
' valley * 0.150 +',
' grain;',
' float alpha = staticField;',
' alpha += 0.115 * (leftRibbon + rightRibbon) * sin(t * 0.00030 + ((uv.y * 1.9) + sideLift * 0.4) * TAU);',
' alpha += 0.095 * verticalFold * (0.4 + sideLift) * sin(t * 0.00041 + ((uv.y * 2.7) + foldedU * 0.65) * TAU);',
' alpha += 0.070 * waist * sin(t * 0.00053 + ((uv.y * 3.1) - absU * 0.8) * TAU);',
' alpha += 0.060 * (1.0 - valley) * diagonalFold * sin(t * 0.00067 + ((uv.y * 1.4) + uv.x * 0.6) * TAU);',
' alpha += 0.038 * (0.35 + sideLift) * (0.35 + noise) * sin(t * 0.00079 + ((uv.y * 4.6) + noise * 0.8) * TAU);',
' alpha = bucketAlpha(clamp(alpha, 0.025, 0.96));',
' float value = alpha * dotMask;',
' gl_FragColor = vec4(vec3(0.8862745 * value), 1.0);',
'}'
].join('\n');
function compileShader(type, source) {
var shader = gl.createShader(type);
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
console.error('WebGL shader compile error:', gl.getShaderInfoLog(shader));
gl.deleteShader(shader);
return null;
}
return shader;
}
var vertexShader = compileShader(gl.VERTEX_SHADER, vertexSrc);
var fragmentShader = compileShader(gl.FRAGMENT_SHADER, fragmentSrc);
if (!vertexShader || !fragmentShader) return;
var program = gl.createProgram();
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
console.error('WebGL program link error:', gl.getProgramInfoLog(program));
return;
}
var positionLoc = gl.getAttribLocation(program, 'a_position');
var resolutionLoc = gl.getUniformLocation(program, 'u_resolution');
var timeLoc = gl.getUniformLocation(program, 'u_time');
var buffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([
-1, -1,
1, -1,
-1, 1,
-1, 1,
1, -1,
1, 1
]), gl.STATIC_DRAW);
gl.useProgram(program);
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.enableVertexAttribArray(positionLoc);
gl.vertexAttribPointer(positionLoc, 2, gl.FLOAT, false, 0, 0);
function resize() {
var dpr = Math.min(window.devicePixelRatio || 1, 1.5);
var cssW = Math.max(1, window.innerWidth || document.documentElement.clientWidth || 1);
var cssH = Math.max(1, window.innerHeight || document.documentElement.clientHeight || 1);
var w = Math.max(1, Math.floor(cssW * dpr));
var h = Math.max(1, Math.floor(cssH * dpr));
if (canvas.width !== w || canvas.height !== h) {
canvas.width = w;
canvas.height = h;
canvas.style.width = cssW + 'px';
canvas.style.height = cssH + 'px';
gl.viewport(0, 0, w, h);
}
}
var prefersReducedMotion = false;
try {
prefersReducedMotion = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
} catch (err) {}
var lastFrame = 0;
var frameInterval = prefersReducedMotion ? 1000 : 66;
var startTime = performance.now();
function render(now) {
requestAnimationFrame(render);
if (document.hidden) return;
if (now - lastFrame < frameInterval) return;
lastFrame = now;
resize();
gl.clearColor(0, 0, 0, 1);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.uniform2f(resolutionLoc, canvas.width, canvas.height);
gl.uniform1f(timeLoc, now - startTime);
gl.drawArrays(gl.TRIANGLES, 0, 6);
}
resize();
requestAnimationFrame(render);
}
var CLBI_SVG_BELL = '<svg class="profile-svg profile-svg-bell" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10.268 21a2 2 0 0 0 3.464 0"/><path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326"/></svg>';
var CLBI_SVG_BELL_DOT = '<svg class="profile-svg profile-svg-bell-dot" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10.268 21a2 2 0 0 0 3.464 0"/><path d="M11.68 2.009A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673c-.824-.85-1.678-1.731-2.21-3.348"/><circle cx="18" cy="5" r="3"/></svg>';
var CLBI_SVG_LIST = '<svg class="profile-svg profile-svg-list" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 5h.01"/><path d="M3 12h.01"/><path d="M3 19h.01"/><path d="M8 5h13"/><path d="M8 12h13"/><path d="M8 19h13"/></svg>';
var CLBI_SVG_LANGUAGES = '<svg class="profile-svg profile-svg-languages" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m5 8 6 6"/><path d="m4 14 6-6 2-3"/><path d="M2 5h12"/><path d="M7 2h1"/><path d="m22 22-5-10-5 10"/><path d="M14 18h6"/></svg>';
var CLBI_SVG_POWER = '<svg class="profile-svg profile-svg-power" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 2v10"/><path d="M18.4 6.6a9 9 0 1 1-12.77.04"/></svg>';
var CLBI_SVG_SETTINGS = '<svg class="profile-svg profile-svg-settings" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915"/><circle cx="12" cy="12" r="3"/></svg>';
var CLBI_SVG_SCAN_TEXT = '<svg class="profile-svg profile-svg-scan-text" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 7V5a2 2 0 0 1 2-2h2"/><path d="M17 3h2a2 2 0 0 1 2 2v2"/><path d="M21 17v2a2 2 0 0 1-2 2h-2"/><path d="M7 21H5a2 2 0 0 1-2-2v-2"/><path d="M7 8h8"/><path d="M7 12h10"/><path d="M7 16h6"/></svg>';
var CLBI_SVG_SCAN_EYE = '<svg class="profile-svg profile-svg-scan-eye" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 7V5a2 2 0 0 1 2-2h2"/><path d="M17 3h2a2 2 0 0 1 2 2v2"/><path d="M21 17v2a2 2 0 0 1-2 2h-2"/><path d="M7 21H5a2 2 0 0 1-2-2v-2"/><circle cx="12" cy="12" r="1"/><path d="M18.944 12.33a1 1 0 0 0 0-.66 7.5 7.5 0 0 0-13.888 0 1 1 0 0 0 0 .66 7.5 7.5 0 0 0 13.888 0"/></svg>';
var CLBI_SVG_NEWSPAPER = '<svg class="profile-svg profile-svg-newspaper" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 18h-5"/><path d="M18 14h-8"/><path d="M4 22h16a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16a2 2 0 0 1-4 0v-9a2 2 0 0 1 2-2h2"/><rect width="8" height="4" x="10" y="6" rx="1"/></svg>';
var CLBI_SVG_TROPHY = '<svg class="profile-svg profile-svg-trophy" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 14.66v1.626a2 2 0 0 1-.976 1.696A5 5 0 0 0 7 21.978"/><path d="M14 14.66v1.626a2 2 0 0 0 .976 1.696A5 5 0 0 1 17 21.978"/><path d="M18 9h1.5a1 1 0 0 0 0-5H18"/><path d="M4 22h16"/><path d="M6 9a6 6 0 0 0 12 0V3a1 1 0 0 0-1-1H7a1 1 0 0 0-1 1z"/><path d="M6 9H4.5a1 1 0 0 1 0-5H6"/></svg>';
var CLBI_SVG_PACKAGE = '<svg class="profile-svg profile-svg-package" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3v6"/><path d="M16.76 3a2 2 0 0 1 1.8 1.1l2.23 4.479a2 2 0 0 1 .21.891V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V9.472a2 2 0 0 1 .211-.894L5.45 4.1A2 2 0 0 1 7.24 3z"/><path d="M3.054 9.013h17.893"/></svg>';
var PROFILE_RENDER_TOKEN = 0;
function invalidateProfileRender() {
PROFILE_RENDER_TOKEN++;
}
$(function() {
initHalftoneBackground();
// ── 상·하단 네비게이션 바 ──
function buildClbiNavHtml(position) {
var isBottom = position === 'bottom';
var base = isBottom ? 'clbi-bottom' : 'clbi-top';
var shortBase = isBottom ? 'clbi-bnav' : 'clbi-tnav';
var wrapId = base + '-nav-wrap';
var navId = base + '-nav';
var mainId = base + '-nav-main';
var tabsId = base + '-nav-tabs';
var searchId = base + '-nav-search';
var inputId = isBottom ? 'clbi-bottom-search-input' : 'clbi-top-search-input';
var rightId = base + '-nav-right';
var worldId = shortBase + '-worldbuilding';
var infoId = shortBase + '-info';
var subId = isBottom ? 'clbi-bottom-sub-worldbuilding' : 'clbi-sub-worldbuilding';
var subInnerId = subId + '-inner';
return '' +
'<div id="' + wrapId + '">' +
'<div id="' + navId + '">' +
'<div id="' + mainId + '">' +
'<div id="' + tabsId + '">' +
'<a class="clbi-top-nav-item" href="/index.php/대문">' +
'<img class="clbi-tnav-icon" src="/index.php?title=특수:Redirect/file/Ic-main-menu-001.png" alt="">' +
'<span class="clbi-tnav-label">메인 메뉴</span>' +
'</a>' +
'<a class="clbi-top-nav-item" href="/index.php/프로젝트:소개">' +
'<img class="clbi-tnav-icon" src="/index.php?title=특수:Redirect/file/Ic-project-001.png" alt="">' +
'<span class="clbi-tnav-label">프로젝트</span>' +
'</a>' +
'<div class="clbi-top-nav-item" id="' + worldId + '">' +
'<img class="clbi-tnav-icon" src="/index.php?title=특수:Redirect/file/Ic-worldbuilding-001.png" alt="">' +
'<span class="clbi-tnav-label">세계관</span>' +
'<span class="clbi-tnav-arrow">▾</span>' +
'</div>' +
'</div>' +
'<div id="' + searchId + '">' +
'<input type="text" id="' + inputId + '" placeholder="검색...">' +
'</div>' +
'<div id="' + rightId + '">' +
'<div class="clbi-top-nav-item" id="' + infoId + '">' +
'<span class="clbi-tnav-label">ℹ</span>' +
'</div>' +
'</div>' +
'</div>' +
'<div id="' + subId + '">' +
'<div id="' + subInnerId + '">' +
'<div class="clbi-tnav-sub-list">' +
'<a class="clbi-tnav-sub-item" href="/index.php/역사적_사건">역사적 사건</a>' +
'<a class="clbi-tnav-sub-item" href="/index.php/설정">설정</a>' +
'<a class="clbi-tnav-sub-item" href="/index.php/국가_및_조합">국가 및 조합</a>' +
'<a class="clbi-tnav-sub-item" href="/index.php/기업_및_공동체">기업 및 공동체</a>' +
'<a class="clbi-tnav-sub-item" href="/index.php/군_정치집단">군, 정치집단</a>' +
'<a class="clbi-tnav-sub-item" href="/index.php/인물">인물</a>' +
'</div>' +
'</div>' +
'</div>' +
'</div>' +
'</div>';
}
function normalizeClbiShellDomOrder() {
var contentWrapper = document.querySelector('.content-wrapper');
var topNav = document.getElementById('clbi-top-nav-wrap');
var bottomNav = document.getElementById('clbi-bottom-nav-wrap');
var canvas = document.getElementById('site-halftone-bg');
var anchor;
var viewportH;
var topRect;
var wrapperRect;
var needsRecovery;
var expectedWrapperTop;
var host;
if (!contentWrapper || !topNav || !bottomNav || !document.body) return;
/*
CLBI shell can live inside a Liberty <section>. Some skins/layouts give that
section a flow context that lets the top nav visually overlap the content
wrapper even when DOM sibling order is correct. Mark the common parent and
let Layout.css force a simple vertical flow for the shell.
*/
host = topNav.parentElement === contentWrapper.parentElement &&
contentWrapper.parentElement === bottomNav.parentElement
? contentWrapper.parentElement
: null;
if (host) {
host.classList.add('clbi-shell-host');
}
viewportH = window.innerHeight || document.documentElement.clientHeight || 0;
topRect = topNav.getBoundingClientRect();
wrapperRect = contentWrapper.getBoundingClientRect();
expectedWrapperTop = topRect.bottom + 8;
needsRecovery = false;
if (viewportH > 0) {
if (topRect.top >= viewportH * 0.55) needsRecovery = true;
if (wrapperRect.top >= viewportH * 0.60) needsRecovery = true;
}
if (topRect.top > 240 || wrapperRect.top > 320) {
needsRecovery = true;
}
if (wrapperRect.top < expectedWrapperTop - 1) {
needsRecovery = true;
}
if (!needsRecovery) {
document.body.classList.add('clbi-shell-ready');
return;
}
anchor = canvas && canvas.parentNode === document.body
? canvas.nextSibling
: document.body.firstChild;
document.body.insertBefore(topNav, anchor);
document.body.insertBefore(contentWrapper, topNav.nextSibling);
document.body.insertBefore(bottomNav, contentWrapper.nextSibling);
document.body.classList.add('clbi-shell-ready');
}
window.normalizeClbiShellDomOrder = normalizeClbiShellDomOrder;
var $contentWrapper = $('.content-wrapper').first();
if ($contentWrapper.length) {
$('#clbi-top-nav-wrap, #clbi-bottom-nav-wrap').remove();
$contentWrapper.before(buildClbiNavHtml('top'));
$contentWrapper.after(buildClbiNavHtml('bottom'));
if (typeof window.normalizeClbiShellDomOrder === 'function') window.normalizeClbiShellDomOrder();
}
function updateClbiShellMetrics() {
var top = document.getElementById('clbi-top-nav-wrap');
var bottom = document.getElementById('clbi-bottom-nav-wrap');
var root = document.documentElement;
var topH = 0;
var bottomH = 0;
if (!root) return;
if (top) {
topH = Math.ceil(top.getBoundingClientRect().height || top.offsetHeight || 0);
}
if (bottom) {
bottomH = Math.ceil(bottom.getBoundingClientRect().height || bottom.offsetHeight || 0);
}
root.style.setProperty('--clbi-top-nav-outer-h', topH + 'px');
root.style.setProperty('--clbi-bottom-nav-outer-h', bottomH + 'px');
}
function scheduleClbiShellMetrics() {
updateClbiShellMetrics();
if (typeof scheduleAdaptiveLeftRecentItems === 'function') {
scheduleAdaptiveLeftRecentItems();
}
if (typeof scheduleClbiContentBottomGap === 'function') {
scheduleClbiContentBottomGap();
}
window.setTimeout(function () {
updateClbiShellMetrics();
if (typeof scheduleAdaptiveLeftRecentItems === 'function') {
scheduleAdaptiveLeftRecentItems();
}
if (typeof scheduleClbiContentBottomGap === 'function') {
scheduleClbiContentBottomGap();
}
}, 0);
window.setTimeout(function () {
updateClbiShellMetrics();
if (typeof scheduleAdaptiveLeftRecentItems === 'function') {
scheduleAdaptiveLeftRecentItems();
}
if (typeof scheduleClbiContentBottomGap === 'function') {
scheduleClbiContentBottomGap();
}
}, 80);
window.setTimeout(function () {
updateClbiShellMetrics();
if (typeof scheduleAdaptiveLeftRecentItems === 'function') {
scheduleAdaptiveLeftRecentItems();
}
if (typeof scheduleClbiContentBottomGap === 'function') {
scheduleClbiContentBottomGap();
}
}, 240);
}
function watchClbiShellMetrics() {
var top = document.getElementById('clbi-top-nav-wrap');
var bottom = document.getElementById('clbi-bottom-nav-wrap');
var observer;
scheduleClbiShellMetrics();
$(window).on('resize orientationchange', scheduleClbiShellMetrics);
$(window).on('resize.clbiRecentViewport orientationchange.clbiRecentViewport', function () { scheduleAdaptiveLeftRecentItems(); scheduleClbiContentBottomGap(); });
$(window).on('resize.clbiContentBottomGap orientationchange.clbiContentBottomGap', scheduleClbiContentBottomGap);
if (window.ResizeObserver) {
observer = new ResizeObserver(scheduleClbiShellMetrics);
if (top) observer.observe(top);
if (bottom) observer.observe(bottom);
window.CLBI_SHELL_RESIZE_OBSERVER = observer;
}
}
function bindClbiWorldbuildingToggle(buttonSelector, menuSelector) {
$(buttonSelector).on('click', function() {
var $menu = $(menuSelector);
var $btn = $(this);
$menu.toggleClass('worldbuilding-open');
$btn.toggleClass('clbi-tnav-active', $menu.hasClass('worldbuilding-open'));
scheduleClbiShellMetrics();
});
}
bindClbiWorldbuildingToggle('#clbi-tnav-worldbuilding', '#clbi-sub-worldbuilding');
bindClbiWorldbuildingToggle('#clbi-bnav-worldbuilding', '#clbi-bottom-sub-worldbuilding');
$('#clbi-top-search-input, #clbi-bottom-search-input').on('keydown', function(e) {
if (e.key === 'Enter') {
var q = $(this).val().trim();
if (q) window.location.href = '/index.php?search=' + encodeURIComponent(q);
}
});
watchClbiShellMetrics();
});
// 페이지 전환 사운드
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 getLangShortCode(lang) {
var map = { ko: 'KR', en: 'EN', zh: 'ZH', ja: 'JA', ru: 'RU', es: 'ES' };
return map[lang] || String(lang || '').toUpperCase();
}
function getLanguageTargetTitle(lang) {
var data = document.getElementById('clbi-lang-data');
if (!data || !lang) return '';
var keys = [
'data-' + lang,
'data-page-' + lang,
'data-title-' + lang,
'data-target-' + lang,
'data-lang-' + lang
];
for (var i = 0; i < keys.length; i++) {
var value = data.getAttribute(keys[i]);
if (value) return value;
}
return '';
}
var SIDEBAR_LANG_SVG_NS = 'http://www.w3.org/2000/svg';
var SIDEBAR_LANGUAGE_STATUS_TITLE = 'MediaWiki:LanguageStatus.json';
var SIDEBAR_LANGUAGE_LABELS = {
ko: '한국어',
en: 'English',
zh: '中文',
ja: '日本語',
ru: 'Русский',
es: 'Español'
};
var SIDEBAR_LANGUAGE_DIAL_LABELS = {
ko: '한국어',
en: 'ENG',
zh: '中文',
ja: '日本語',
ru: 'РУС',
es: 'ESP'
};
var SIDEBAR_LANGUAGE_STATUS_VALUES = {
available: true,
wip: true,
unavailable: true
};
var sidebarLanguageStatusRegistry = {};
var sidebarLanguageStatusLoaded = false;
var sidebarLanguageStatusLoading = false;
var sidebarLanguageStatusCallbacks = [];
var sidebarLanguageState = {
order: ['ko', 'en', 'zh', 'ja', 'ru', 'es'],
currentLang: 'ko',
baseIndex: 0,
selectedIndex: 0,
rotation: 0,
dragging: false,
dragMoved: false,
dragStartX: 0,
dragStartY: 0,
dragStartRotation: 0,
dragAxis: null,
pointerCaptured: false,
lastX: 0,
lastTime: 0,
releaseVelocity: 0,
suppressClickUntil: 0,
raf: null,
pendingRotation: 0,
snapTimer: null,
inertiaRaf: null,
navigateTimer: null,
bound: false,
boundElement: null,
rotor: null,
cx: 101,
cy: 119,
outerR: 109,
innerR: 28,
sectorAngle: 30,
halfSector: 15,
repeats: 8,
dragSensitivity: 0.58,
maxSpinVelocity: 1.75,
minSpinVelocity: 0.055,
spinDecel: 0.00185
};
function createSidebarLanguageSvgEl(tag) {
return document.createElementNS(SIDEBAR_LANG_SVG_NS, tag);
}
function normalizeSidebarLanguageIndex(index) {
var length = sidebarLanguageState.order.length;
var normalized = index % length;
return normalized < 0 ? normalized + length : normalized;
}
function getSidebarLanguageName(lang) {
return SIDEBAR_LANGUAGE_LABELS[lang] || String(lang || '').toUpperCase();
}
function getSidebarLanguageDialName(lang) {
return SIDEBAR_LANGUAGE_DIAL_LABELS[lang] || getSidebarLanguageName(lang);
}
function normalizeSidebarLanguageStatusValue(value) {
value = String(value == null ? '' : value).toLowerCase().trim();
return SIDEBAR_LANGUAGE_STATUS_VALUES[value] ? value : '';
}
function getSidebarLanguageStatusPageKey() {
var raw = String(mw.config.get('wgPageName') || '').trim();
var normalized = normalizePageName(raw);
return normalized || raw || '대문';
}
function getSidebarLanguageStatusEntry() {
var registry = sidebarLanguageStatusRegistry || {};
var pages = registry.pages && typeof registry.pages === 'object' ? registry.pages : registry;
var raw = String(mw.config.get('wgPageName') || '').trim();
var normalized = normalizePageName(raw);
var title = String(mw.config.get('wgTitle') || '').trim();
var keys = [
normalized,
raw,
raw.replace(/_/g, ' '),
normalized.replace(/ /g, '_'),
title,
title.replace(/_/g, ' ')
];
var i;
for (i = 0; i < keys.length; i += 1) {
if (keys[i] && pages[keys[i]] && typeof pages[keys[i]] === 'object') {
return pages[keys[i]];
}
}
return {};
}
function getSidebarLanguageStatusOverride(lang) {
var entry = getSidebarLanguageStatusEntry();
return normalizeSidebarLanguageStatusValue(entry[lang]);
}
function flushSidebarLanguageStatusCallbacks() {
var callbacks = sidebarLanguageStatusCallbacks.slice();
sidebarLanguageStatusCallbacks.length = 0;
callbacks.forEach(function(callback) {
if (typeof callback === 'function') {
callback(sidebarLanguageStatusRegistry);
}
});
}
function loadSidebarLanguageStatusRegistry(callback, force) {
if (typeof callback === 'function') {
sidebarLanguageStatusCallbacks.push(callback);
}
if (sidebarLanguageStatusLoaded && !force) {
flushSidebarLanguageStatusCallbacks();
return;
}
if (sidebarLanguageStatusLoading) return;
sidebarLanguageStatusLoading = true;
$.ajax({
url: mw.util.getUrl(SIDEBAR_LANGUAGE_STATUS_TITLE, {
action: 'raw',
ctype: 'application/json',
_: String(Date.now())
}),
dataType: 'text',
cache: false
}).done(function(text) {
var parsed = {};
try {
parsed = text ? JSON.parse(text) : {};
} catch (err) {
console.error('LanguageStatus.json parse failed:', err);
parsed = {};
}
sidebarLanguageStatusRegistry = parsed && typeof parsed === 'object' ? parsed : {};
}).fail(function() {
sidebarLanguageStatusRegistry = {};
}).always(function() {
sidebarLanguageStatusLoaded = true;
sidebarLanguageStatusLoading = false;
flushSidebarLanguageStatusCallbacks();
});
}
window.CLBI_LANGUAGE_STATUS = {
title: SIDEBAR_LANGUAGE_STATUS_TITLE,
languages: sidebarLanguageState.order.slice(),
labels: SIDEBAR_LANGUAGE_LABELS,
dialLabels: SIDEBAR_LANGUAGE_DIAL_LABELS,
getPageKey: getSidebarLanguageStatusPageKey,
getRegistry: function() {
return sidebarLanguageStatusRegistry || {};
},
reload: function(callback) {
sidebarLanguageStatusLoaded = false;
loadSidebarLanguageStatusRegistry(function() {
renderSidebarLanguageBox();
if (typeof callback === 'function') callback(sidebarLanguageStatusRegistry);
}, true);
},
refreshDial: function() {
renderSidebarLanguageBox();
}
};
function getSidebarLanguageMeta(lang) {
var currentLang = getCurrentLang();
var targetTitle = getLanguageTargetTitle(lang);
var isCurrent = lang === currentLang;
return {
lang: lang,
code: getLangShortCode(lang),
name: getSidebarLanguageName(lang),
dialName: getSidebarLanguageDialName(lang),
targetTitle: targetTitle,
isCurrent: isCurrent,
canMove: !!targetTitle && !isCurrent
};
}
function getSidebarLanguageStatus(meta) {
var override;
if (!meta) {
return {
className: 'is-locked',
label: 'UNAVAILABLE',
canApply: false
};
}
if (meta.isCurrent) {
return {
className: 'is-current',
label: 'CURRENT',
canApply: false
};
}
override = getSidebarLanguageStatusOverride(meta.lang);
if (override === 'wip') {
return {
className: 'is-locked',
label: 'WIP',
canApply: false
};
}
if (override === 'unavailable') {
return {
className: 'is-locked',
label: 'UNAVAILABLE',
canApply: false
};
}
if (override === 'available' || meta.targetTitle) {
return {
className: meta.targetTitle ? 'is-ready' : 'is-locked',
label: meta.targetTitle ? 'AVAILABLE' : 'UNAVAILABLE',
canApply: !!meta.targetTitle
};
}
return {
className: 'is-locked',
label: 'UNAVAILABLE',
canApply: false
};
}
function sidebarLanguageRad(deg) {
return (deg * Math.PI) / 180;
}
function sidebarLanguagePointAt(radius, deg) {
var state = sidebarLanguageState;
var angle = sidebarLanguageRad(deg);
return {
x: state.cx + Math.sin(angle) * radius,
y: state.cy - Math.cos(angle) * radius
};
}
function getSidebarLanguageSectorPath(start, end) {
var state = sidebarLanguageState;
var p1 = sidebarLanguagePointAt(state.outerR, start);
var p2 = sidebarLanguagePointAt(state.outerR, end);
var p3 = sidebarLanguagePointAt(state.innerR, end);
var p4 = sidebarLanguagePointAt(state.innerR, start);
var largeArc = Math.abs(end - start) > 180 ? 1 : 0;
return [
'M', p1.x.toFixed(3), p1.y.toFixed(3),
'A', state.outerR, state.outerR, 0, largeArc, 1, p2.x.toFixed(3), p2.y.toFixed(3),
'L', p3.x.toFixed(3), p3.y.toFixed(3),
'A', state.innerR, state.innerR, 0, largeArc, 0, p4.x.toFixed(3), p4.y.toFixed(3),
'Z'
].join(' ');
}
function getSidebarLanguageShellPath() {
return getSidebarLanguageSectorPath(-68, 68);
}
function getSidebarLanguageByStep(step) {
var state = sidebarLanguageState;
var index = normalizeSidebarLanguageIndex(state.baseIndex + step);
return {
index: index,
meta: getSidebarLanguageMeta(state.order[index])
};
}
function getSidebarLanguagePreviewIndex() {
var state = sidebarLanguageState;
var step = Math.round(-state.rotation / state.sectorAngle);
return normalizeSidebarLanguageIndex(state.baseIndex + step);
}
function getSidebarLanguagePreviewMeta() {
var state = sidebarLanguageState;
return getSidebarLanguageMeta(state.order[getSidebarLanguagePreviewIndex()]);
}
function makeSidebarLanguageSector(step) {
var state = sidebarLanguageState;
var item = getSidebarLanguageByStep(step);
var group = createSidebarLanguageSvgEl('g');
var path = createSidebarLanguageSvgEl('path');
var label = createSidebarLanguageSvgEl('text');
var labelY = state.cy - 78;
var angle = step * state.sectorAngle;
group.setAttribute('class', 'sidebar-lang-sector-group');
group.setAttribute('data-step', String(step));
group.setAttribute('data-index', String(item.index));
group.setAttribute('data-lang', item.meta.lang);
group.setAttribute('transform', 'rotate(' + angle + ' ' + state.cx + ' ' + state.cy + ')');
path.setAttribute('class', 'sidebar-lang-sector');
path.setAttribute('d', getSidebarLanguageSectorPath(-state.halfSector, state.halfSector));
label.setAttribute('class', 'sidebar-lang-sector-label');
label.setAttribute('x', String(state.cx));
label.setAttribute('y', String(labelY + 5));
label.textContent = item.meta.dialName || item.meta.name;
group.appendChild(path);
group.appendChild(label);
group.addEventListener('click', function(e) {
if (sidebarLanguageState.dragging || performance.now() < sidebarLanguageState.suppressClickUntil) return;
e.preventDefault();
e.stopPropagation();
cancelSidebarLanguageSpin();
snapSidebarLanguageToStep(parseInt(group.getAttribute('data-step') || '0', 10), true);
});
return group;
}
function renderSidebarLanguageWheel() {
var state = sidebarLanguageState;
var fan = document.getElementById('clbi-sidebar-lang-fan');
var svg;
var defs;
var clip;
var clipPath;
var shadowBlur;
var blur;
var fixedDepthGradient;
var shell;
var clipped;
var rotor;
var fixedDepthPath;
var fixedFocus;
var shadowSoft;
var shadowHard;
var rim;
var pointer;
var tri;
var line;
var step;
if (!fan) return;
fan.innerHTML = '';
svg = createSidebarLanguageSvgEl('svg');
svg.setAttribute('class', 'sidebar-lang-fan-svg');
svg.setAttribute('viewBox', '0 0 202 150');
svg.setAttribute('preserveAspectRatio', 'xMidYMid meet');
svg.setAttribute('role', 'img');
svg.setAttribute('aria-label', '언어 선택 다이얼');
defs = createSidebarLanguageSvgEl('defs');
clip = createSidebarLanguageSvgEl('clipPath');
clip.setAttribute('id', 'clbi-sidebar-language-fan-clip');
clipPath = createSidebarLanguageSvgEl('path');
clipPath.setAttribute('d', getSidebarLanguageShellPath());
clip.appendChild(clipPath);
shadowBlur = createSidebarLanguageSvgEl('filter');
shadowBlur.setAttribute('id', 'clbi-sidebar-language-shadow-blur');
shadowBlur.setAttribute('x', '-20%');
shadowBlur.setAttribute('y', '-20%');
shadowBlur.setAttribute('width', '140%');
shadowBlur.setAttribute('height', '140%');
blur = createSidebarLanguageSvgEl('feGaussianBlur');
blur.setAttribute('stdDeviation', '3');
shadowBlur.appendChild(blur);
fixedDepthGradient = createSidebarLanguageSvgEl('linearGradient');
fixedDepthGradient.setAttribute('id', 'clbi-sidebar-language-fixed-depth');
fixedDepthGradient.setAttribute('x1', '0');
fixedDepthGradient.setAttribute('y1', '0');
fixedDepthGradient.setAttribute('x2', '0');
fixedDepthGradient.setAttribute('y2', '1');
[
['0%', '#ffffff', '0.030'],
['34%', '#ffffff', '0.006'],
['58%', '#000000', '0.030'],
['100%', '#000000', '0.250']
].forEach(function(item) {
var stop = createSidebarLanguageSvgEl('stop');
stop.setAttribute('offset', item[0]);
stop.setAttribute('stop-color', item[1]);
stop.setAttribute('stop-opacity', item[2]);
fixedDepthGradient.appendChild(stop);
});
defs.appendChild(clip);
defs.appendChild(shadowBlur);
defs.appendChild(fixedDepthGradient);
svg.appendChild(defs);
shell = createSidebarLanguageSvgEl('path');
shell.setAttribute('class', 'sidebar-lang-shell');
shell.setAttribute('d', getSidebarLanguageShellPath());
svg.appendChild(shell);
clipped = createSidebarLanguageSvgEl('g');
clipped.setAttribute('clip-path', 'url(#clbi-sidebar-language-fan-clip)');
rotor = createSidebarLanguageSvgEl('g');
rotor.setAttribute('id', 'clbi-sidebar-lang-wheel-rotor');
rotor.setAttribute('class', 'sidebar-lang-wheel-rotor');
for (step = -state.repeats; step <= state.repeats; step += 1) {
rotor.appendChild(makeSidebarLanguageSector(step));
}
clipped.appendChild(rotor);
svg.appendChild(clipped);
fixedDepthPath = createSidebarLanguageSvgEl('path');
fixedDepthPath.setAttribute('class', 'sidebar-lang-fixed-depth');
fixedDepthPath.setAttribute('d', getSidebarLanguageShellPath());
svg.appendChild(fixedDepthPath);
fixedFocus = createSidebarLanguageSvgEl('path');
fixedFocus.setAttribute('class', 'sidebar-lang-fixed-focus');
fixedFocus.setAttribute('d', getSidebarLanguageSectorPath(-state.halfSector, state.halfSector));
svg.appendChild(fixedFocus);
shadowSoft = createSidebarLanguageSvgEl('path');
shadowSoft.setAttribute('class', 'sidebar-lang-inner-shadow-soft');
shadowSoft.setAttribute('d', getSidebarLanguageShellPath());
svg.appendChild(shadowSoft);
shadowHard = createSidebarLanguageSvgEl('path');
shadowHard.setAttribute('class', 'sidebar-lang-inner-shadow-hard');
shadowHard.setAttribute('d', getSidebarLanguageShellPath());
svg.appendChild(shadowHard);
rim = createSidebarLanguageSvgEl('path');
rim.setAttribute('class', 'sidebar-lang-rim');
rim.setAttribute('d', getSidebarLanguageShellPath());
svg.appendChild(rim);
pointer = createSidebarLanguageSvgEl('g');
pointer.setAttribute('class', 'sidebar-lang-fixed-pointer');
pointer.setAttribute('clip-path', 'url(#clbi-sidebar-language-fan-clip)');
tri = createSidebarLanguageSvgEl('path');
tri.setAttribute('class', 'sidebar-lang-pointer-triangle');
tri.setAttribute('d', 'M ' + (state.cx - 10) + ' 10 L ' + (state.cx + 10) + ' 10 L ' + state.cx + ' 26 Z');
pointer.appendChild(tri);
line = createSidebarLanguageSvgEl('line');
line.setAttribute('class', 'sidebar-lang-pointer-line');
line.setAttribute('x1', String(state.cx));
line.setAttribute('x2', String(state.cx));
line.setAttribute('y1', '24');
line.setAttribute('y2', '112');
pointer.appendChild(line);
svg.appendChild(pointer);
fan.appendChild(svg);
state.rotor = rotor;
setSidebarLanguageRotation(state.rotation, false);
}
function updateSidebarLanguageDial() {
var state = sidebarLanguageState;
var meta = getSidebarLanguagePreviewMeta();
var status = getSidebarLanguageStatus(meta);
var selector = document.getElementById('clbi-sidebar-lang-selector');
var apply = document.getElementById('clbi-sidebar-lang-apply');
var selectedValue = document.getElementById('clbi-sidebar-lang-selected-value');
var availabilityPanel = document.getElementById('clbi-sidebar-lang-availability-panel');
var availabilityValue = document.getElementById('clbi-sidebar-lang-availability-value');
if (selectedValue) {
selectedValue.textContent = meta.name;
}
if (availabilityPanel) {
availabilityPanel.classList.remove('is-ready', 'is-current', 'is-locked');
availabilityPanel.classList.add(status.className);
}
if (availabilityValue) {
availabilityValue.textContent = status.label;
}
if (apply) {
apply.classList.toggle('is-disabled', !status.canApply);
apply.setAttribute('aria-disabled', status.canApply ? 'false' : 'true');
apply.setAttribute('aria-label', status.canApply ? (meta.name + ' 적용') : (meta.isCurrent ? '현재 언어' : '사용할 수 없는 언어'));
}
if (selector) {
selector.setAttribute('data-selected-lang', meta.lang);
selector.setAttribute('data-selected-code', meta.code);
selector.classList.toggle('is-current', meta.isCurrent);
selector.classList.toggle('is-ready', status.canApply);
selector.classList.toggle('is-locked', !status.canApply && !meta.isCurrent);
selector.classList.toggle('is-dragging', !!state.dragging);
selector.classList.toggle('is-spinning', !!state.inertiaRaf);
}
return {
meta: meta,
status: status
};
}
function setSidebarLanguageRotation(value, animate) {
var state = sidebarLanguageState;
state.rotation = value;
updateSidebarLanguageDial();
if (!state.rotor) return;
if (animate) {
$('#clbi-sidebar-lang-selector').addClass('is-snapping');
} else {
$('#clbi-sidebar-lang-selector').removeClass('is-snapping');
}
state.rotor.style.transform = 'rotate(' + state.rotation.toFixed(3) + 'deg)';
}
function requestSidebarLanguageRotation(value) {
var state = sidebarLanguageState;
state.pendingRotation = value;
if (state.raf) return;
state.raf = requestAnimationFrame(function() {
state.raf = null;
setSidebarLanguageRotation(state.pendingRotation, false);
});
}
function cancelSidebarLanguageSpin() {
var state = sidebarLanguageState;
if (state.inertiaRaf) {
cancelAnimationFrame(state.inertiaRaf);
state.inertiaRaf = null;
}
$('#clbi-sidebar-lang-selector').removeClass('is-spinning');
}
function finishSidebarLanguageSnap(nearestIndex, callback) {
var state = sidebarLanguageState;
state.baseIndex = normalizeSidebarLanguageIndex(nearestIndex);
state.selectedIndex = state.baseIndex;
state.rotation = 0;
state.dragging = false;
$('#clbi-sidebar-lang-selector').removeClass('is-snapping is-dragging is-spinning');
renderSidebarLanguageWheel();
updateSidebarLanguageDial();
if (typeof callback === 'function') {
callback(getSidebarLanguageMeta(state.order[state.selectedIndex]));
}
}
function snapSidebarLanguageToStep(step, animate, callback) {
var state = sidebarLanguageState;
var targetRotation = -step * state.sectorAngle;
var nearestIndex = normalizeSidebarLanguageIndex(state.baseIndex + step);
cancelSidebarLanguageSpin();
clearTimeout(state.snapTimer);
state.selectedIndex = nearestIndex;
setSidebarLanguageRotation(targetRotation, !!animate);
state.snapTimer = setTimeout(function() {
finishSidebarLanguageSnap(nearestIndex, callback);
}, animate ? 230 : 0);
}
function snapSidebarLanguageNearest(callback) {
var state = sidebarLanguageState;
var step = Math.round(-state.rotation / state.sectorAngle);
snapSidebarLanguageToStep(step, true, callback);
}
function startSidebarLanguageInertiaSpin(initialVelocity) {
var state = sidebarLanguageState;
var velocity;
var lastFrame;
cancelSidebarLanguageSpin();
velocity = Math.max(-state.maxSpinVelocity, Math.min(state.maxSpinVelocity, initialVelocity));
if (Math.abs(velocity) < state.minSpinVelocity) {
snapSidebarLanguageNearest();
return;
}
$('#clbi-sidebar-lang-selector').addClass('is-spinning');
lastFrame = performance.now();
function frame(now) {
var dt = Math.min(34, Math.max(1, now - lastFrame));
var sign = velocity < 0 ? -1 : 1;
var nextSpeed;
lastFrame = now;
state.rotation += velocity * dt;
setSidebarLanguageRotation(state.rotation, false);
nextSpeed = Math.max(0, Math.abs(velocity) - (state.spinDecel * dt));
velocity = sign * nextSpeed;
if (nextSpeed <= state.minSpinVelocity) {
state.inertiaRaf = null;
$('#clbi-sidebar-lang-selector').removeClass('is-spinning');
snapSidebarLanguageNearest();
return;
}
state.inertiaRaf = requestAnimationFrame(frame);
}
state.inertiaRaf = requestAnimationFrame(frame);
}
function scheduleSidebarLanguageNavigation(meta) {
var status = getSidebarLanguageStatus(meta);
if (!meta || !status.canApply) return;
clearTimeout(sidebarLanguageState.navigateTimer);
sidebarLanguageState.navigateTimer = setTimeout(function() {
var title = getLanguageTargetTitle(meta.lang);
if (!title || meta.lang === getCurrentLang()) return;
window.location.href = buildWikiPath(title);
}, 70);
}
function setSidebarLanguageSelection(lang) {
var state = sidebarLanguageState;
var index = state.order.indexOf(lang);
if (index < 0) index = state.order.indexOf(getCurrentLang());
if (index < 0) index = 0;
if (state.raf) {
cancelAnimationFrame(state.raf);
state.raf = null;
}
cancelSidebarLanguageSpin();
clearTimeout(state.snapTimer);
state.currentLang = lang;
state.baseIndex = index;
state.selectedIndex = index;
state.rotation = 0;
state.dragging = false;
state.dragMoved = false;
state.releaseVelocity = 0;
renderSidebarLanguageWheel();
updateSidebarLanguageDial();
}
function moveSidebarLanguageSelection(delta) {
snapSidebarLanguageToStep(-delta, true);
}
function bindSidebarLanguageSelector() {
var state = sidebarLanguageState;
var selector = document.getElementById('clbi-sidebar-lang-selector');
var fan = document.getElementById('clbi-sidebar-lang-fan');
var apply = document.getElementById('clbi-sidebar-lang-apply');
if (!selector || !fan || !apply) return;
if (state.bound && state.boundElement === selector) return;
state.bound = true;
state.boundElement = selector;
fan.addEventListener('pointerdown', function(e) {
cancelSidebarLanguageSpin();
clearTimeout(state.snapTimer);
state.dragging = true;
state.dragMoved = false;
state.dragStartX = e.clientX;
state.dragStartY = e.clientY || 0;
state.dragStartRotation = state.rotation;
state.dragAxis = null;
state.pointerCaptured = false;
state.lastX = e.clientX;
state.lastTime = performance.now();
state.releaseVelocity = 0;
selector.classList.add('is-dragging');
selector.classList.remove('is-snapping');
/*
Vertical page scrolling must stay available when the pointer starts on
the language dial. Capture and preventDefault are delayed until a
horizontal drag is confirmed.
*/
});
fan.addEventListener('pointermove', function(e) {
var now;
var totalDx;
var totalDy;
var frameDx;
var dt;
var instantVelocity;
if (!state.dragging) return;
totalDx = e.clientX - state.dragStartX;
totalDy = (e.clientY || 0) - state.dragStartY;
if (!state.dragAxis && (Math.abs(totalDx) > 4 || Math.abs(totalDy) > 4)) {
state.dragAxis = Math.abs(totalDx) >= Math.abs(totalDy) ? 'x' : 'y';
if (state.dragAxis === 'y') {
state.dragging = false;
state.dragMoved = false;
state.dragAxis = null;
state.pointerCaptured = false;
selector.classList.remove('is-dragging');
return;
}
if (fan.setPointerCapture && e.pointerId != null) {
try {
fan.setPointerCapture(e.pointerId);
state.pointerCaptured = true;
} catch (err) {
state.pointerCaptured = false;
}
}
}
if (state.dragAxis !== 'x') return;
now = performance.now();
frameDx = e.clientX - state.lastX;
dt = Math.max(1, now - state.lastTime);
if (Math.abs(totalDx) > 3) state.dragMoved = true;
instantVelocity = (frameDx * state.dragSensitivity) / dt;
state.releaseVelocity = (state.releaseVelocity * 0.62) + (instantVelocity * 0.38);
state.lastX = e.clientX;
state.lastTime = now;
requestSidebarLanguageRotation(state.dragStartRotation + totalDx * state.dragSensitivity);
e.preventDefault();
e.stopPropagation();
});
function finishDrag(e) {
var velocityAge;
var throwVelocity;
var wasHorizontal;
if (!state.dragging) return;
wasHorizontal = state.dragAxis === 'x';
state.dragging = false;
selector.classList.remove('is-dragging');
if (fan.releasePointerCapture && state.pointerCaptured && e && e.pointerId != null) {
try { fan.releasePointerCapture(e.pointerId); } catch (err) {}
}
state.pointerCaptured = false;
state.dragAxis = null;
if (!wasHorizontal && !state.dragMoved) {
return;
}
velocityAge = performance.now() - state.lastTime;
throwVelocity = velocityAge > 120 ? 0 : state.releaseVelocity;
if (state.dragMoved) {
state.suppressClickUntil = performance.now() + 180;
}
if (state.dragMoved && Math.abs(throwVelocity) >= state.minSpinVelocity) {
startSidebarLanguageInertiaSpin(throwVelocity);
} else {
snapSidebarLanguageNearest();
}
if (e) {
e.preventDefault();
e.stopPropagation();
}
}
fan.addEventListener('pointerup', finishDrag);
fan.addEventListener('pointercancel', finishDrag);
fan.addEventListener('lostpointercapture', function() {
if (!state.dragging) return;
state.dragging = false;
state.pointerCaptured = false;
state.dragAxis = null;
selector.classList.remove('is-dragging');
if (state.dragMoved && Math.abs(state.releaseVelocity) >= state.minSpinVelocity) {
state.suppressClickUntil = performance.now() + 180;
startSidebarLanguageInertiaSpin(state.releaseVelocity);
} else {
snapSidebarLanguageNearest();
}
});
apply.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
snapSidebarLanguageNearest(function(meta) {
scheduleSidebarLanguageNavigation(meta);
});
});
selector.addEventListener('keydown', function(e) {
if (e.key === 'ArrowLeft') {
moveSidebarLanguageSelection(-1);
e.preventDefault();
}
if (e.key === 'ArrowRight') {
moveSidebarLanguageSelection(1);
e.preventDefault();
}
if (e.key === 'Enter' || e.key === ' ') {
apply.click();
e.preventDefault();
}
});
}
function renderSidebarLanguageBox() {
bindSidebarLanguageSelector();
setSidebarLanguageSelection(getCurrentLang());
if (!sidebarLanguageStatusLoaded) {
loadSidebarLanguageStatusRegistry(function() {
setSidebarLanguageSelection(getCurrentLang());
});
}
}
function loadRecentChangesList(targetSelector, limit) {
var $target = $(targetSelector);
if (!$target.length) return;
var lang = getCurrentLang();
var t = (window.LANG && window.LANG[lang]) ? window.LANG[lang] : (window.LANG ? window.LANG.ko : null);
var isNewsList = $target.closest('.clbi-left-news-box').length > 0;
function escapeHtml(value) {
return String(value == null ? '' : value)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
$target.html((t && t.loading) ? t.loading : '불러오는 중...');
$.getJSON(
'/api.php?action=query&list=recentchanges&rclimit=' + encodeURIComponent(limit || 5) + '&rcprop=title|timestamp|user&format=json&rcnamespace=0&rctype=edit|new',
function(data) {
var items = data && data.query ? data.query.recentchanges : [];
var html = '';
if (!items || !items.length) {
$target.html('표시할 변경 사항이 없습니다.');
return;
}
$.each(items, function(i, item) {
var label = timeAgo(item.timestamp);
var title = item.title || '';
var userName = item.user || 'Unknown';
var pageHref = buildWikiPath(title);
var avatarSrc = '/index.php?title=특수:Redirect/file/Pfp-' + encodeURIComponent(userName) + '.png';
if (isNewsList) {
html +=
'<a href="' + escapeHtml(pageHref) + '" class="news-recent-item">' +
'<img class="news-recent-avatar" src="' + escapeHtml(avatarSrc) + '" alt="" onerror="this.onerror=null;this.src=\'/index.php?title=특수:Redirect/file/Pfp-default.png\';">' +
'<div class="news-recent-main">' +
'<div class="news-recent-title-wrap">' +
'<span class="news-recent-title">' + escapeHtml(title) + '</span>' +
'</div>' +
'<div class="news-recent-meta">' +
'<span class="news-recent-user">@' + escapeHtml(userName) + '</span>' +
'</div>' +
'</div>' +
'<span class="news-recent-time">' + escapeHtml(label) + '</span>' +
'</a>';
} else {
html +=
'<div class="clbi-recent-item">' +
'<div class="clbi-recent-title-wrap">' +
'<a href="' + escapeHtml(pageHref) + '" class="clbi-recent-title">' + escapeHtml(title) + '</a>' +
'</div>' +
'<span class="clbi-recent-time">' + escapeHtml(label) + '</span>' +
'</div>';
}
});
if (isNewsList) {
$target.html(
'<div class="news-recent-viewport">' +
'<div class="news-recent-stack">' + html + '</div>' +
'</div>'
);
if (typeof ensureNewsBottomFinish === 'function') {
ensureNewsBottomFinish();
}
} else {
$target.html(html);
}
if (isNewsList && typeof scheduleAdaptiveLeftRecentItems === 'function') {
scheduleAdaptiveLeftRecentItems();
}
$target.find(isNewsList ? '.news-recent-item' : '.clbi-recent-item').each(function() {
var wrap = $(this).find(isNewsList ? '.news-recent-title-wrap' : '.clbi-recent-title-wrap');
var title = $(this).find(isNewsList ? '.news-recent-title' : '.clbi-recent-title');
if (!wrap.length || !title.length) return;
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() {
var lang = getCurrentLang();
var t = (window.LANG && window.LANG[lang]) ? window.LANG[lang] : (window.LANG ? window.LANG.ko : null);
$target.html((t && t.loadFail) ? t.loadFail : '불러오기 실패');
});
}
function ensureRecentViewport() {
var list = document.getElementById('clbi-left-recent-list');
var viewport;
var stack;
var children;
if (!list) return null;
viewport = list.querySelector(':scope > .news-recent-viewport');
stack = viewport ? viewport.querySelector(':scope > .news-recent-stack') : null;
if (viewport && stack) return viewport;
children = Array.prototype.slice.call(list.children || []);
viewport = document.createElement('div');
viewport.className = 'news-recent-viewport';
stack = document.createElement('div');
stack.className = 'news-recent-stack';
children.forEach(function (child) {
if (child.classList && child.classList.contains('news-recent-viewport')) return;
stack.appendChild(child);
});
viewport.appendChild(stack);
list.appendChild(viewport);
return viewport;
}
function ensureNewsBottomFinish() {
var newsBox = document.querySelector('#clbi-left-sidebar .clbi-left-news-box');
var content = newsBox ? newsBox.querySelector('.clbi-news-box') : null;
var finish;
if (!content) return null;
finish = content.querySelector(':scope > .news-bottom-finish');
if (!finish) {
finish = document.createElement('div');
finish.className = 'news-bottom-finish';
finish.setAttribute('aria-hidden', 'true');
content.appendChild(finish);
}
return finish;
}
function updateAdaptiveLeftRecentItems() {
var list = document.getElementById('clbi-left-recent-list');
var main = document.querySelector('.liberty-content-main');
var newsBox;
var newsContent;
var viewport;
var finish;
var items;
var followingHeight;
var sibling;
var newsRect;
var mainRect;
var naturalHeight;
var availableHeight;
var constrainedHeight;
var contentRect;
var listRect;
var recentHeight;
var finishHeight = 12;
var minNewsHeight = 150;
var minRecentHeight = 32;
if (!list || !main) return;
newsBox = list.closest('.clbi-left-news-box');
newsContent = newsBox ? newsBox.querySelector('.clbi-news-box') : null;
viewport = ensureRecentViewport();
finish = ensureNewsBottomFinish();
items = Array.prototype.slice.call(list.querySelectorAll('.news-recent-item'));
if (!newsBox || !newsContent || !viewport || !finish || !items.length) return;
/*
기본 상태에서는 원래 내용 높이만 차지한다.
이 상태를 먼저 만들어 naturalHeight를 측정한다.
*/
newsBox.classList.remove('is-adaptive-constrained');
newsBox.style.removeProperty('--adaptive-news-h');
list.classList.remove('is-adaptive-faded');
list.removeAttribute('data-adaptive-limit');
list.style.removeProperty('--adaptive-recent-h');
items.forEach(function (item) {
item.classList.remove('is-adaptive-hidden');
});
followingHeight = 0;
sibling = newsBox.nextElementSibling;
while (sibling) {
if (sibling.offsetParent !== null) {
followingHeight += Math.ceil(sibling.getBoundingClientRect().height || 0) + 8;
}
sibling = sibling.nextElementSibling;
}
newsRect = newsBox.getBoundingClientRect();
mainRect = main.getBoundingClientRect();
naturalHeight = Math.ceil(newsRect.height);
availableHeight = Math.floor(mainRect.bottom - newsRect.top - followingHeight);
/*
충분한 높이에서는 절대 늘리지 않는다.
뉴스 박스는 항목을 담는 컨테이너이므로 자연 높이를 유지한다.
부족할 때만 본문 하단 기준선 안으로 접어 넣는다.
*/
if (naturalHeight <= availableHeight) {
return;
}
constrainedHeight = Math.max(minNewsHeight, availableHeight);
newsBox.style.setProperty('--adaptive-news-h', constrainedHeight + 'px');
newsBox.classList.add('is-adaptive-constrained');
/*
adaptive 상태에서 하단 마감은 실제 finish 행이 담당한다.
recent feed는 finish 위에서만 끝난다.
*/
newsBox.offsetHeight;
contentRect = newsContent.getBoundingClientRect();
listRect = list.getBoundingClientRect();
recentHeight = Math.floor(contentRect.bottom - listRect.top - finishHeight);
recentHeight = Math.max(minRecentHeight, recentHeight);
list.style.setProperty('--adaptive-recent-h', recentHeight + 'px');
list.classList.add('is-adaptive-faded');
if (typeof scheduleClbiContentBottomGap === 'function') {
scheduleClbiContentBottomGap();
}
}
function scheduleAdaptiveLeftRecentItems() {
window.requestAnimationFrame(function () {
updateAdaptiveLeftRecentItems();
});
window.setTimeout(updateAdaptiveLeftRecentItems, 80);
window.setTimeout(updateAdaptiveLeftRecentItems, 240);
}
function updateClbiContentBottomGap(iteration) {
var content = document.querySelector('.container-fluid.liberty-content');
var main = document.querySelector('.liberty-content-main');
var bottomNav = document.getElementById('clbi-bottom-nav-wrap');
var desiredGap = 8;
var contentRect;
var bottomTop;
var targetHeight;
var currentHeight;
iteration = iteration || 0;
if (!content || !main || !bottomNav) return;
/*
이전 방식은 --clbi-content-extra 값을 더하고 빼서 간격을 맞추려 했다.
하지만 중앙 본문은 .content-wrapper / .container-fluid / .liberty-content-main
세 층을 거치기 때문에, 변수 보정이 실제 시각 하단에 안정적으로 닿지 않았다.
이제는 하단 네비의 실제 top을 기준으로 .container-fluid.liberty-content의
실제 높이를 직접 지정한다.
목표:
.liberty-content-main.bottom === #clbi-bottom-nav-wrap.top - 8px
*/
contentRect = content.getBoundingClientRect();
bottomTop = bottomNav.getBoundingClientRect().top;
targetHeight = Math.floor(bottomTop - contentRect.top - desiredGap);
targetHeight = Math.max(120, targetHeight);
currentHeight = Math.round(content.getBoundingClientRect().height);
content.style.setProperty('--clbi-content-extra', '0px');
content.style.setProperty('height', targetHeight + 'px', 'important');
content.style.setProperty('max-height', targetHeight + 'px', 'important');
if (Math.abs(currentHeight - targetHeight) >= 1 && iteration < 4) {
window.requestAnimationFrame(function () {
updateClbiContentBottomGap(iteration + 1);
});
}
}
function scheduleClbiContentBottomGap() {
window.requestAnimationFrame(function () {
updateClbiContentBottomGap(0);
});
window.setTimeout(function () {
updateClbiContentBottomGap(0);
}, 40);
window.setTimeout(function () {
updateClbiContentBottomGap(0);
}, 120);
window.setTimeout(function () {
updateClbiContentBottomGap(0);
}, 280);
window.setTimeout(function () {
updateClbiContentBottomGap(0);
}, 520);
}
// 국가_및_조합 전용 왼쪽 사이드바 이미지
function updateLeftSidebarNationsImage() {
$('#clbi-left-nations-image').remove();
}
function setProfileActionLabel(selector, text) {
var target = $(selector);
var label = target.find('.profile-action-label');
if (label.length) {
label.text(text);
} else {
target.text(text);
}
}
// 사이드바 업데이트
function updateSidebar() {
if (!window.LANG) {
setTimeout(updateSidebar, 100);
return;
}
var currentLang = getCurrentLang();
var t = (window.LANG && window.LANG[currentLang]) ? window.LANG[currentLang] : window.LANG.ko;
var newsTitle = t.news || '뉴스';
var changelogTitle = t.changelog || '체인지로그';
var recentTitle = t.recentChanges || '최근 변경';
var languageTitle = t.language || '언어';
$('#clbi-title-left-language').text(languageTitle);
renderSidebarLanguageBox();
$('#clbi-title-left-news').text(newsTitle);
$('#clbi-left-news-changelog-main').text(changelogTitle);
$('#clbi-left-news-recent-main').text(recentTitle);
$('#clbi-title-search a').text(t.search);
$('#clbi-search-input').attr('placeholder', t.search + '...');
$('#clbi-title-recent a').text(recentTitle);
$('#clbi-title-guide-label').text(t.guide);
$('#clbi-guide-link').text(t.getStarted);
$('#clbi-title-links-label').text(t.links);
setProfileActionLabel('#clbi-btn-contribution', t.contribution);
setProfileActionLabel('#clbi-btn-watchlist', t.watchlist);
setProfileActionLabel('#clbi-btn-preferences', t.preferences);
setProfileActionLabel('#clbi-btn-logout', t.logout);
setProfileActionLabel('#clbi-btn-login', t.login);
var pageName = normalizePageName(mw.config.get('wgPageName'));
var specialPage = String(mw.config.get('wgCanonicalSpecialPageName') || '');
$('#clbi-left-news-changelog-main').text(changelogTitle);
$('#clbi-left-news-recent-title').text('RECENT CHANGES');
$('.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 initCategoryNavIfAvailable(root) {
/*
CategoryNav.js는 대문 카테고리 네비를 SVG로 생성한다.
Common.js가 SPA로 본문을 갈아끼운 뒤에는 MediaWiki 원래 페이지 로드와 달리
CategoryNav.js의 초기 DOMContentLoaded만으로는 새 mount를 다시 잡지 못할 수 있다.
CategoryNav.js 자체도 mw.hook('wikipage.content')를 듣지만, 로드 순서와 SPA 타이밍이
엇갈릴 수 있으므로 Common.js 쪽에서도 존재 여부를 확인한 뒤 한 번 더 호출한다.
이 함수는 CategoryNav.js가 아직 로드되지 않았으면 아무 것도 하지 않는다.
*/
if (
window.CLBI &&
window.CLBI.categoryNav &&
typeof window.CLBI.categoryNav.init === 'function'
) {
window.CLBI.categoryNav.init(root || document);
}
}
function removeLegacyMainPageHero() {
/*
기존 대문 전용 레거시 요소 정리
-----------------------------------------
이전 대문 구조에서는 Common.js가 본문 바깥에 #clbi-main-logo를 직접 삽입하고,
본문 안의 #clbi-main-crt-hero를 #clbi-main-crt-hero-wrap으로 감싸서
.liberty-content-main 위쪽으로 재배치했다.
새 대문은 본문 내부의 .main-portal이 로고, 알림, 카테고리 네비, 이미지 피드,
방명록, 상태 패널을 모두 담당한다. 따라서 Common.js가 별도 로고나 CRT 래퍼를
삽입하면 새 로고/콘텐츠와 중복된다.
여기서는 JS가 만들던 바깥 로고와 CRT 래퍼를 제거하고, 예전 대문 원본이나
캐시된 렌더 결과에 남아 있을 수 있는 #clbi-main-crt-hero도 제거한다.
*/
$('#clbi-main-logo').remove();
$('#clbi-main-crt-hero-wrap').remove();
$('#clbi-main-crt-hero').remove();
}
function setNativePageTitleHiddenHard(hidden) {
var selectors = [
'.liberty-content-header',
'.liberty-content-header .title',
'.liberty-content-header .title h1',
'.liberty-content-header h1',
'#firstHeading',
'.firstHeading',
'.mw-first-heading',
'.page-heading',
'.page-header',
'.mw-page-title-main',
'.mw-page-title-namespace',
'.mw-page-title-separator'
];
document.querySelectorAll(selectors.join(',')).forEach(function(node) {
if (!node || !node.style) return;
if (hidden) {
node.setAttribute('data-clbi-title-hidden', 'true');
node.style.setProperty('display', 'none', 'important');
node.style.setProperty('visibility', 'hidden', 'important');
node.style.setProperty('height', '0', 'important');
node.style.setProperty('min-height', '0', 'important');
node.style.setProperty('margin', '0', 'important');
node.style.setProperty('padding', '0', 'important');
node.style.setProperty('overflow', 'hidden', 'important');
} else if (node.getAttribute('data-clbi-title-hidden') === 'true') {
node.removeAttribute('data-clbi-title-hidden');
node.style.removeProperty('display');
node.style.removeProperty('visibility');
node.style.removeProperty('height');
node.style.removeProperty('min-height');
node.style.removeProperty('margin');
node.style.removeProperty('padding');
node.style.removeProperty('overflow');
}
});
}
function applyDefaultPageTitleVisibility() {
var hideTitle = true;
var isSystemAssetPage = false;
if (window.CLBI_PAGE_SHELL && typeof window.CLBI_PAGE_SHELL.isSystemAssetPage === 'function') {
isSystemAssetPage = window.CLBI_PAGE_SHELL.isSystemAssetPage();
}
if (isSystemAssetPage) {
hideTitle = true;
} else if (window.CLBI_PAGE_SHELL && typeof window.CLBI_PAGE_SHELL.isBackendOrSystemPage === 'function') {
hideTitle = !window.CLBI_PAGE_SHELL.isBackendOrSystemPage();
}
$('body')
.toggleClass('page-title-hidden', hideTitle)
.toggleClass('page-title-visible', !hideTitle)
.toggleClass('clbi-system-doc-page', isSystemAssetPage);
$('.content-tools').css('display', 'none');
if (isSystemAssetPage && window.CLBI_PAGE_SHELL && typeof window.CLBI_PAGE_SHELL.renderSystemDocIndicator === 'function') {
window.CLBI_PAGE_SHELL.renderSystemDocIndicator();
} else if (!isSystemAssetPage && window.CLBI_PAGE_SHELL && typeof window.CLBI_PAGE_SHELL.removeSystemDocIndicator === 'function') {
window.CLBI_PAGE_SHELL.removeSystemDocIndicator();
}
if (hideTitle) {
$('.liberty-content-header').css('display', 'none');
$('.mw-page-title-main, .mw-page-title-namespace, .mw-page-title-separator').addClass('clbi-hide');
$('#firstHeading, .firstHeading, .mw-first-heading, .page-heading, .page-header').css('display', 'none');
setNativePageTitleHiddenHard(true);
} else {
$('.liberty-content-header').css('display', '');
$('.mw-page-title-main, .mw-page-title-namespace, .mw-page-title-separator').removeClass('clbi-hide');
$('#firstHeading, .firstHeading, .mw-first-heading, .page-heading, .page-header').css('display', '');
setNativePageTitleHiddenHard(false);
}
}
function applyMainPageStyle() {
var specialPage = mw.config.get('wgCanonicalSpecialPageName');
if (specialPage === 'Preferences') return;
var pageName = normalizePageName(mw.config.get('wgPageName'));
var namespaceNumber = mw.config.get('wgNamespaceNumber');
var isMainPage = (pageName === '대문');
var isUserProfilePage = (namespaceNumber === 2);
var isScreenDoc = ($('.screen-header').length > 0);
var hideTools = (isMainPage || isUserProfilePage || !canShowContentTools());
$('body').toggleClass('user-profile-page', isUserProfilePage);
$('body').toggleClass('clbi-main-page', isMainPage);
// 모든 문서에서 분류 바를 본문 컨테이너 아래로 이동
moveCatlinksToBottom();
if (isMainPage) {
$('.liberty-content-header').css('display', 'none');
$('.mw-page-title-main').addClass('clbi-hide');
setNativePageTitleHiddenHard(true);
$('.catlinks').css('display', 'none');
$('.liberty-content-main').css('border-radius', '0');
// 새 대문은 .main-portal 본문 구조가 로고/히어로를 담당한다.
// Common.js의 구식 바깥 로고/CRT 재배치 루틴은 사용하지 않는다.
removeLegacyMainPageHero();
$('#clbi-tools-box').remove();
$('.content-tools').css('display', 'none');
initCategoryNavIfAvailable(document);
} else if (isUserProfilePage) {
$('.liberty-content-header').css('display', 'none');
$('.mw-page-title-main, .mw-page-title-namespace, .mw-page-title-separator').addClass('clbi-hide');
$('.catlinks').css('display', 'none');
$('.liberty-content-main').css('border-radius', '0');
$('#clbi-main-logo').remove();
$('#clbi-main-crt-hero-wrap').remove();
$('#clbi-main-crt-hero').remove();
$('#clbi-tools-box').remove();
$('.content-tools').css('display', 'none');
} else if (isScreenDoc) {
$('.liberty-content-header').css('display', 'none');
$('.mw-page-title-main').addClass('clbi-hide');
$('.catlinks').css('display', '');
$('.liberty-content-main').css('border-radius', '0');
$('#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');
} else {
$('.liberty-content-header').css('display', '');
$('.mw-page-title-main, .mw-page-title-namespace, .mw-page-title-separator').removeClass('clbi-hide');
$('.catlinks').css('display', '');
$('.liberty-content-main').css('border-radius', '0');
$('#clbi-main-logo').remove();
$('#clbi-main-crt-hero-wrap').remove();
$('#clbi-tools-box').remove();
}
if (!isUserProfilePage) {
$('.profile-card').remove();
$('.user-profile-portal').removeClass('user-profile-portal');
}
$('.content-tools').css('display', 'none');
applyDefaultPageTitleVisibility();
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="sidebar-title-svg" aria-hidden="true">' + CLBI_SVG_LIST + '</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);
});
}
// 우측 광고판: 이미지/번역 캡션 목록
var RIGHT_BILLBOARD_ITEMS = [
{
file: 'Side-visual-001.png',
alt: 'PROOF TO THE WORLD / YOU ONCE PART OF IT',
duration: 3000,
caption: [
'"당신이 한때 이 세계의',
'',
'일부였다는 것을 증명하십시오"'
]
},
{
file: 'Side-visual-002.png',
alt: 'APPLY NOW',
duration: 1000,
caption: [
'"지금 지원하세요!"'
]
},
{
file: 'Side-visual-003.png',
alt: 'APPLY NOW',
duration: 1000,
caption: [
'"지금 지원하세요!"'
]
},
{
file: 'Side-visual-002.png',
alt: 'APPLY NOW',
duration: 1000,
caption: [
'"지금 지원하세요!"'
]
}
];
function getRightBillboardItem(index) {
var items = RIGHT_BILLBOARD_ITEMS;
if (!items || !items.length) {
return {
file: 'Side-visual-001.png',
alt: '',
caption: []
};
}
var normalized = index % items.length;
if (normalized < 0) normalized += items.length;
return items[normalized];
}
function getRightBillboardImageUrl(fileName) {
return '/index.php?title=특수:Redirect/file/' + encodeURIComponent(fileName || 'Side-visual-001.png');
}
function getRightBillboardCaptionHtml(item) {
var lines = item && item.caption ? item.caption : [];
var html = '';
function escapeCaptionText(value) {
return String(value == null ? '' : value)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/\"/g, '"')
.replace(/'/g, ''');
}
lines.forEach(function(line) {
var text = String(line == null ? '' : line);
var isGap = !text.trim();
var className = 'right-billboard-caption-line' + (isGap ? ' is-gap' : '');
html += '<span class="' + className + '">' + (isGap ? ' ' : escapeCaptionText(text)) + '</span>';
});
return html;
}
function setRightBillboardItem(index) {
var box = document.querySelector('.right-billboard-box');
if (!box) return;
var item = getRightBillboardItem(index);
var src = getRightBillboardImageUrl(item.file);
var images = box.querySelectorAll('.right-billboard-image');
var caption = box.querySelector('#right-billboard-caption');
var emptySub = box.querySelector('.right-billboard-empty-sub');
box.setAttribute('data-billboard-index', String(index));
box.classList.remove('is-empty');
Array.prototype.forEach.call(images, function(img) {
img.style.display = '';
img.setAttribute('src', src);
img.setAttribute('alt', img.classList.contains('right-billboard-image-base') ? (item.alt || '') : '');
});
if (caption) {
caption.innerHTML = getRightBillboardCaptionHtml(item);
}
if (emptySub) {
emptySub.textContent = item.file || 'Side-visual-001.png';
}
}
function getRightBillboardItemDuration(item) {
var duration = item && item.duration ? parseInt(item.duration, 10) : 3000;
if (Number.isNaN(duration) || duration < 500) {
duration = 3000;
}
return duration;
}
function initRightBillboardCarousel() {
var box = document.querySelector('.right-billboard-box');
if (!box || box.getAttribute('data-billboard-ready') === '1') return;
box.setAttribute('data-billboard-ready', '1');
box.setAttribute('data-billboard-index', '0');
setRightBillboardItem(0);
if (!RIGHT_BILLBOARD_ITEMS || RIGHT_BILLBOARD_ITEMS.length <= 1) return;
function scheduleNext() {
var current = parseInt(box.getAttribute('data-billboard-index') || '0', 10);
if (Number.isNaN(current)) current = 0;
var currentItem = getRightBillboardItem(current);
var delay = getRightBillboardItemDuration(currentItem);
window.setTimeout(function() {
if (!document.body.contains(box)) return;
if (!document.hidden) {
setRightBillboardItem(current + 1);
}
scheduleNext();
}, delay);
}
scheduleNext();
}
function escapeRightBillboardAttr(value) {
return String(value == null ? '' : value)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
function buildRightBillboardBox() {
var billboardInitial = getRightBillboardItem(0);
var billboardSrc = getRightBillboardImageUrl(billboardInitial.file);
return '' +
'<div class="clbi-right-box right-billboard-box" data-billboard-index="0">' +
'<div class="clbi-right-title right-billboard-title">' +
'<span class="right-billboard-title-text"><span class="right-billboard-title-main">Ad</span><span class="right-billboard-title-note">(not really)</span></span>' +
'</div>' +
'<div class="right-billboard-body">' +
'<div class="right-billboard-recess">' +
'<div class="right-billboard-screen">' +
'<img id="right-billboard-image" class="right-billboard-image right-billboard-image-base" src="' + escapeRightBillboardAttr(billboardSrc) + '" alt="' + escapeRightBillboardAttr(billboardInitial.alt || '') + '" onload="var b=this.closest(\'.right-billboard-box\'); if(b){b.classList.remove(\'is-empty\');}" onerror="this.onerror=null;this.style.display=\'none\';var b=this.closest(\'.right-billboard-box\'); if(b){b.classList.add(\'is-empty\');}">' +
'<img class="right-billboard-image right-billboard-image-bloom" src="' + escapeRightBillboardAttr(billboardSrc) + '" alt="" aria-hidden="true" onerror="this.onerror=null;this.style.display=\'none\';">' +
'<img class="right-billboard-image right-billboard-slice right-billboard-slice-a" src="' + escapeRightBillboardAttr(billboardSrc) + '" alt="" aria-hidden="true" onerror="this.onerror=null;this.style.display=\'none\';">' +
'<img class="right-billboard-image right-billboard-slice right-billboard-slice-b" src="' + escapeRightBillboardAttr(billboardSrc) + '" alt="" aria-hidden="true" onerror="this.onerror=null;this.style.display=\'none\';">' +
'<img class="right-billboard-image right-billboard-slice right-billboard-slice-c" src="' + escapeRightBillboardAttr(billboardSrc) + '" alt="" aria-hidden="true" onerror="this.onerror=null;this.style.display=\'none\';">' +
'<div class="right-billboard-glitch" aria-hidden="true"></div>' +
'<div class="right-billboard-tear" aria-hidden="true"></div>' +
'<div class="right-billboard-empty" aria-hidden="true">' +
'<span class="right-billboard-empty-main">SIGNAL EMPTY</span>' +
'<span class="right-billboard-empty-sub">' + escapeRightBillboardAttr(billboardInitial.file || 'Side-visual-001.png') + '</span>' +
'</div>' +
'</div>' +
'</div>' +
'<div id="right-billboard-caption" class="right-billboard-caption" aria-hidden="true">' + getRightBillboardCaptionHtml(billboardInitial) + '</div>' +
'</div>' +
'</div>';
}
function buildSiteInformationBox() {
return '' +
'<div class="clbi-right-box site-info-sidebar">' +
'<div class="clbi-right-title site-info-title">' +
'<span>SITE INFORMATION</span>' +
'<span class="site-info-title-meta">LINKS</span>' +
'</div>' +
'<div class="clbi-right-content site-info-content">' +
'<div class="policy-list">' +
'<div class="policy-row"><a href="/index.php/개인정보처리방침">개인정보처리방침</a><span class="policy-mark" aria-hidden="true">›</span></div>' +
'<div class="policy-row"><a href="/index.php/면책_조항">면책 조항</a><span class="policy-mark" aria-hidden="true">›</span></div>' +
'<div class="policy-row"><a href="/index.php/라이선스">라이선스</a><span class="policy-mark" aria-hidden="true">›</span></div>' +
'<div class="policy-row"><a href="/index.php/크레딧">크레딧</a><span class="policy-mark" aria-hidden="true">›</span></div>' +
'</div>' +
'<div class="social-strip">' +
'<span class="social-icon"><a href="https://discord.gg/ctaeJ9d3Q5" target="_blank" rel="noopener noreferrer">DC</a></span>' +
'<span class="social-icon"><a href="https://www.youtube.com/@nxdsxn" target="_blank" rel="noopener noreferrer">YT</a></span>' +
'<span class="social-icon"><a href="https://x.com/nxd_sxn" target="_blank" rel="noopener noreferrer">X</a></span>' +
'<span class="social-icon"><a href="/index.php/프로젝트:소개">WIP:</a></span>' +
'</div>' +
'</div>' +
'</div>';
}
// 초기화 함수
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 profile-card-box">' +
'<div id="clbi-user-avatar-wrap" class="profile-identity-panel">' +
'<div class="profile-avatar-stage">' +
'<img id="clbi-user-avatar" src="' + avatarSrc + '" onerror="this.onerror=null;this.src=\'/index.php?title=특수:Redirect/file/Pfp-default.png\';">' +
'</div>' +
'<div id="clbi-user-name-row" class="profile-name-row">' +
'<a href="/index.php/사용자:' + username + '" id="clbi-user-name">' + username + '</a>' +
'</div>' +
'</div>' +
'<div class="clbi-right-content profile-action-box">' +
'<div class="profile-quick-actions" aria-label="프로필 빠른 메뉴">' +
'<button type="button" class="profile-quick-btn" id="profile-quick-inventory" aria-label="인벤토리"><span class="profile-quick-icon" aria-hidden="true">' + CLBI_SVG_PACKAGE + '</span><span class="profile-quick-tip" aria-hidden="true">인벤토리</span></button>' +
'<button type="button" class="profile-quick-btn" id="profile-quick-achievements" aria-label="업적"><span class="profile-quick-icon" aria-hidden="true">' + CLBI_SVG_TROPHY + '</span><span class="profile-quick-tip" aria-hidden="true">업적</span></button>' +
'<button type="button" class="profile-quick-btn" id="profile-quick-notifications" aria-label="알림"><span id="profile-quick-notification-icon" class="profile-quick-icon" aria-hidden="true">' + CLBI_SVG_BELL + '</span><span class="profile-quick-tip" aria-hidden="true">알림</span></button>' +
'</div>' +
'<a href="/index.php/특수:기여/' + username + '" class="clbi-user-btn" id="clbi-btn-contribution"><span class="profile-action-icon" aria-hidden="true">' + CLBI_SVG_SCAN_TEXT + '</span><span class="profile-action-label">기여</span><i class="hn hn-angle-right-solid profile-action-arrow" aria-hidden="true"></i></a>' +
'<a href="/index.php/특수:주시문서목록" class="clbi-user-btn" id="clbi-btn-watchlist"><span class="profile-action-icon" aria-hidden="true">' + CLBI_SVG_SCAN_EYE + '</span><span class="profile-action-label">주시문서 목록</span><i class="hn hn-angle-right-solid profile-action-arrow" aria-hidden="true"></i></a>' +
'<a href="/index.php/특수:설정" class="clbi-user-btn" id="clbi-btn-preferences"><span class="profile-action-icon" aria-hidden="true">' + CLBI_SVG_SETTINGS + '</span><span class="profile-action-label">설정</span><i class="hn hn-angle-right-solid profile-action-arrow" aria-hidden="true"></i></a>' +
'<a href="/index.php?title=특수:로그아웃&returnto=대문" class="clbi-user-btn clbi-user-btn-logout" id="clbi-btn-logout"><span class="profile-action-icon" aria-hidden="true">' + CLBI_SVG_POWER + '</span><span class="profile-action-label">로그아웃</span><i class="hn hn-angle-right-solid profile-action-arrow" aria-hidden="true"></i></a>' +
'</div>' +
'</div>';
} else {
userBox =
'<div class="clbi-right-box profile-card-box">' +
'<div id="clbi-user-avatar-wrap" class="profile-identity-panel">' +
'<div class="profile-avatar-stage">' +
'<img id="clbi-user-avatar" src="/index.php?title=특수:Redirect/file/Pfp-default.png">' +
'</div>' +
'<div id="clbi-user-name-row" class="profile-name-row profile-name-row-guest">' +
'<span id="clbi-user-name">Guest</span>' +
'</div>' +
'</div>' +
'<div class="clbi-right-content profile-action-box">' +
'<a href="/index.php?title=특수:로그인&returnto=대문" class="clbi-user-btn" id="clbi-btn-login"><span class="profile-action-icon" aria-hidden="true">' + CLBI_SVG_POWER + '</span><span class="profile-action-label">로그인</span><i class="hn hn-angle-right-solid profile-action-arrow" aria-hidden="true"></i></a>' +
'</div>' +
'</div>';
}
var rightVisualBox = '';
var siteInformationBox = '';
try {
rightVisualBox = buildRightBillboardBox();
} catch (err) {
console.error('Right billboard build failed:', err);
rightVisualBox = '';
}
try {
siteInformationBox = buildSiteInformationBox();
} catch (err) {
console.error('Site information build failed:', err);
siteInformationBox = '';
}
var sidebar = userBox + rightVisualBox + siteInformationBox;
$('.content-wrapper').append('<div id="clbi-right-sidebar">' + sidebar + '</div>');
}
if ($('#clbi-left-sidebar').length === 0) {
var leftSidebar =
'<div id="clbi-left-sidebar">' +
'<div class="clbi-left-box clbi-left-lang-box">' +
'<div class="clbi-left-title">' +
'<span class="sidebar-title-svg clbi-left-language-icon" aria-hidden="true">' + CLBI_SVG_LANGUAGES + '</span> ' +
'<span id="clbi-title-left-language">언어</span>' +
'</div>' +
'<div class="clbi-left-content sidebar-lang-box">' +
'<div id="clbi-sidebar-lang-selector" class="sidebar-lang-selector sidebar-lang-dial" tabindex="0" role="group" aria-label="언어 선택">' +
'<div id="clbi-sidebar-lang-dial-stage" class="sidebar-lang-dial-stage">' +
'<div id="clbi-sidebar-lang-fan" class="sidebar-lang-fan" aria-hidden="true"></div>' +
'<div id="clbi-sidebar-lang-selected-panel" class="sidebar-lang-status-panel sidebar-lang-status-left" aria-hidden="true">' +
'<span id="clbi-sidebar-lang-selected-value" class="sidebar-lang-status-value">한국어</span>' +
'</div>' +
'<div id="clbi-sidebar-lang-availability-panel" class="sidebar-lang-status-panel sidebar-lang-status-right is-current" aria-hidden="true">' +
'<span id="clbi-sidebar-lang-availability-value" class="sidebar-lang-status-value">CURRENT</span>' +
'</div>' +
'<button type="button" id="clbi-sidebar-lang-apply" class="sidebar-lang-apply" aria-label="언어 적용">' +
'<span class="sidebar-lang-apply-mark" aria-hidden="true">✓</span>' +
'</button>' +
'</div>' +
'</div>' +
'</div>' +
'</div>' +
'<div class="clbi-left-box clbi-left-news-box">' +
'<div class="clbi-left-title">' +
'<span class="news-title-icon sidebar-title-svg" aria-hidden="true">' + CLBI_SVG_NEWSPAPER + '</span> ' +
'<span id="clbi-title-left-news">뉴스</span>' +
'</div>' +
'<div class="clbi-left-content clbi-news-box">' +
'<div class="news-feed-title" id="clbi-left-news-changelog-title">CHANGELOG</div>' +
'<div class="news-left-changelog-feed">' +
'<a href="/index.php/체인지로그" class="news-post-item">' +
'<div class="news-post-title-wrap">' +
'<span class="news-post-title" id="clbi-left-news-changelog-main">체인지로그</span>' +
'<i class="hn hn-angle-right-solid news-post-arrow" aria-hidden="true"></i>' +
'</div>' +
'<span class="news-post-tag">POST</span>' +
'</a>' +
'</div>' +
'<div class="news-divider"></div>' +
'<div class="news-feed-title" id="clbi-left-news-recent-title">RECENT CHANGES</div>' +
'<div class="news-left-recent-feed" id="clbi-left-recent-list">불러오는 중...</div>' +
'</div>' +
'</div>' +
'</div>';
$('.content-wrapper').prepend(leftSidebar);
renderSidebarLanguageBox();
loadRecentChangesList('#clbi-left-recent-list', 10);
scheduleAdaptiveLeftRecentItems();
scheduleClbiContentBottomGap();
updateLeftSidebarNationsImage();
}
try {
initRightBillboardCarousel();
} catch (err) {
console.error('Right billboard carousel failed:', err);
}
if (typeof window.normalizeClbiShellDomOrder === 'function') window.normalizeClbiShellDomOrder();
applyMainPageStyle();
initCategoryNavIfAvailable(document);
if (window.ProgressSystemWebUi && typeof window.ProgressSystemWebUi.boot === 'function') {
window.ProgressSystemWebUi.boot('initSidebars');
}
$('#side-toc-box').remove();
mw.loader.using(['mediawiki.api']).then(function() {
setTimeout(function() {
initNotifications();
initProfile();
moveTocToLeftSidebar();
}, 300);
setTimeout(moveTocToLeftSidebar, 800);
setTimeout(moveTocToLeftSidebar, 1500);
});
}
$(function() {
loadLangScript(function() {
setTimeout(function() {
initSidebars();
}, 100);
});
});
$(document).on('click.profileQuickPlaceholder', '#profile-quick-inventory, #profile-quick-achievements', function(e) {
e.preventDefault();
e.stopPropagation();
});
// 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) {
invalidateProfileRender();
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 matchArticle = src.match(/"wgArticleId":(\d+)/);
if (matchArticle) {
mw.config.set('wgArticleId', parseInt(matchArticle[1], 10));
} else {
mw.config.set('wgArticleId', 0);
}
var matchIsMainPage = src.match(/"wgIsMainPage":(true|false)/);
if (matchIsMainPage) {
mw.config.set('wgIsMainPage', matchIsMainPage[1] === 'true');
} else {
mw.config.set('wgIsMainPage', false);
}
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();
$('.profile-card').remove();
$('.user-profile-portal').removeClass('user-profile-portal');
$('.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);
}
if (typeof window.normalizeClbiShellDomOrder === 'function') window.normalizeClbiShellDomOrder();
window.scrollTo(0, 0);
mw.hook('wikipage.content').fire($('.liberty-content-main'));
applyMainPageStyle();
initCategoryNavIfAvailable(document);
if (window.ProgressSystemWebUi && typeof window.ProgressSystemWebUi.handleSpaPageView === 'function') {
window.ProgressSystemWebUi.handleSpaPageView();
} else if (window.ProgressSystemWebUi && typeof window.ProgressSystemWebUi.boot === 'function') {
window.ProgressSystemWebUi.boot('spa');
}
$('#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) {
// 휠 클릭, 새 탭 열기, 보조키 이동은 브라우저 기본 동작을 유지한다.
if (e.which && e.which !== 1) return;
if (e.button && e.button !== 0) return;
if (e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return;
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();
$('.user-profile-portal').removeClass('user-profile-portal');
var token = ++PROFILE_RENDER_TOKEN;
var ns = mw.config.get('wgNamespaceNumber');
var title = mw.config.get('wgTitle');
var specialPage = mw.config.get('wgCanonicalSpecialPageName');
var isProfileSettings = specialPage === '사용자정보';
$('body').toggleClass('user-profile-page', ns === 2);
$('body').toggleClass('user-profile-settings-page', isProfileSettings);
if (ns === 2) {
var profileUser = title.split('/')[0];
renderProfile(profileUser, token);
}
if (isProfileSettings) {
initUserProfilePage();
}
}
function renderProfile(username, token) {
var api = new mw.Api();
api.get({
action: 'query',
list: 'users',
ususers: username,
usprop: 'editcount'
}).then(function(data) {
if (token !== PROFILE_RENDER_TOKEN) return;
if (mw.config.get('wgNamespaceNumber') !== 2) return;
var currentTitle = String(mw.config.get('wgTitle') || '').split('/')[0];
if (currentTitle !== username) return;
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;
function escapeHtml(value) {
return String(value == null ? '' : value)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
container.classList.add('user-profile-portal');
var safeUsername = escapeHtml(username);
var avatarSrc = '/index.php?title=특수:Redirect/file/Pfp-' + encodeURIComponent(username) + '.png&width=220';
var fallbackSrc = '/index.php?title=특수:Redirect/file/Pfp-default.png&width=220';
var editBtn = isOwnPage
? '<a href="/index.php/특수:사용자정보" class="profile-edit-btn"><span class="profile-edit-label">프로필 수정</span><span class="profile-edit-arrow">›</span></a>'
: '';
var progressHtml = isOwnPage
? '<div class="profile-page-progress is-syncing" data-profile-progress>' +
'<div class="profile-section-title">LEVEL RECORD</div>' +
'<div class="profile-page-progress-body">' +
'<div class="profile-page-progress-row">' +
'<span class="profile-page-level">SYNC</span>' +
'<span class="profile-page-total-xp">— XP</span>' +
'</div>' +
'<div class="profile-page-xp-bar" aria-hidden="true"><div class="profile-page-xp-fill"></div></div>' +
'<div class="profile-page-progress-sub">SYNCING</div>' +
'<div class="profile-page-progress-meta">TODAY — · DISCOVERED —</div>' +
'</div>' +
'</div>'
: '';
var card = document.createElement('div');
card.className = 'profile-card profile-page-console';
card.innerHTML =
'<div class="profile-card-titlebar">' +
'<span>USER PROFILE</span>' +
'<span>OFFICIAL ARCHIVE</span>' +
'</div>' +
'<div class="profile-card-body">' +
'<div class="profile-identity-row">' +
'<div class="profile-avatar-bay">' +
'<img src="' + avatarSrc + '" onerror="this.onerror=null;this.src=\'' + fallbackSrc + '\';" alt="' + safeUsername + '">' +
'</div>' +
'<div class="profile-info-panel">' +
'<div class="profile-nameplate">' +
'<h2 class="profile-username">' + safeUsername + '</h2>' +
editBtn +
'</div>' +
'<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>' +
'</div>' +
'<div class="profile-lower-grid">' +
'<div class="profile-bio-panel">' +
'<div class="profile-section-title">BIOGRAPHY</div>' +
'<div class="profile-bio" data-field="bio"></div>' +
'</div>' +
'<div class="profile-stats-panel">' +
'<div class="profile-section-title">RECORD</div>' +
'<div class="profile-stats">' +
progressHtml +
'<div class="profile-stat-grid">' +
'<div class="profile-stat">' +
'<span class="clbi-stat-value">' + editCount + '</span>' +
'<span class="clbi-stat-label">수정 횟수</span>' +
'</div>' +
'<div class="profile-stat" data-contrib-pages-stat>' +
'<span class="clbi-stat-value" data-contrib-pages-value>SYNC</span>' +
'<span class="clbi-stat-label">기여 문서</span>' +
'</div>' +
'</div>' +
'<div class="profile-info-grid">' +
'<div class="profile-info-stat">' +
'<span class="profile-info-label">TIME</span>' +
'<span class="profile-info-value" data-profile-time>UTC --:--</span>' +
'</div>' +
'<div class="profile-info-stat">' +
'<span class="profile-info-label">LANGUAGE</span>' +
'<span class="profile-info-value" data-profile-language>—</span>' +
'</div>' +
'</div>' +
'</div>' +
'</div>' +
'</div>' +
'</div>';
$('.profile-card').remove();
container.insertBefore(card, container.firstChild);
loadProfileFields(username, card);
loadProfileContributionPages(username, card);
updateProfilePageEnvironment(card, null);
if (isOwnPage) {
loadProfileProgressForUserPage(card);
}
}
function getProfileLanguageLabel() {
var lang = getCurrentLang();
return SIDEBAR_LANGUAGE_LABELS[lang] || (lang ? lang.toUpperCase() : '—');
}
function updateProfilePageEnvironment(card, summary) {
if (!card) return;
var timezone = summary && summary.timezone ? summary.timezone : 'UTC';
var timeEl = card.querySelector('[data-profile-time]');
var langEl = card.querySelector('[data-profile-language]');
if (timeEl) {
try {
timeEl.textContent = timezone + ' ' + new Intl.DateTimeFormat('ko-KR', {
hour: '2-digit',
minute: '2-digit',
hour12: false,
timeZone: timezone
}).format(new Date());
} catch (err) {
timeEl.textContent = 'UTC ' + new Intl.DateTimeFormat('ko-KR', {
hour: '2-digit',
minute: '2-digit',
hour12: false,
timeZone: 'UTC'
}).format(new Date());
}
}
if (langEl) {
langEl.textContent = getProfileLanguageLabel();
}
}
function loadProfileContributionPages(username, card) {
if (!username || !card) return;
if (!mw.loader || typeof mw.loader.using !== 'function') return;
var valueEl = card.querySelector('[data-contrib-pages-value]');
if (valueEl) valueEl.textContent = 'SYNC';
mw.loader.using(['mediawiki.api']).then(function () {
var api = new mw.Api();
var pages = Object.create(null);
var cont = {};
var guard = 0;
function requestNext() {
guard++;
var params = Object.assign({
action: 'query',
list: 'usercontribs',
ucuser: username,
ucnamespace: 0,
ucprop: 'title',
uclimit: 'max',
format: 'json',
formatversion: 2
}, cont);
return api.get(params).then(function (data) {
var rows = data && data.query && data.query.usercontribs ? data.query.usercontribs : [];
rows.forEach(function (row) {
if (row && row.title) pages[row.title] = true;
});
if (data && data.continue && data.continue.uccontinue && guard < 40) {
cont = data.continue;
return requestNext();
}
if (valueEl) valueEl.textContent = Object.keys(pages).length;
});
}
requestNext().fail(function () {
if (valueEl) valueEl.textContent = '—';
});
});
}
function loadProfileProgressForUserPage(card) {
if (!mw.config.get('wgUserName')) return;
if (!mw.loader || typeof mw.loader.using !== 'function') return;
mw.loader.using(['mediawiki.api']).then(function () {
var api = new mw.Api();
api.get({
action: 'progress_summary',
format: 'json',
formatversion: 2
}).then(function (data) {
var payload = data && data.progress_summary;
if (!payload || !payload.available || !payload.summary) return;
updateUserPageProgress(card, payload.summary);
updateProfilePageEnvironment(card, payload.summary);
});
});
}
function updateUserPageProgress(card, summary) {
var panel = card.querySelector('[data-profile-progress]');
if (!panel || !summary) return;
var level = summary.level || 1;
var totalXp = summary.totalXp || 0;
var xpIntoLevel = summary.xpIntoLevel || 0;
var xpForNext = summary.xpForNextLevel || 1;
var percent = Math.max(0, Math.min(100, summary.progressPercent || 0));
var isMaxLevel = !!summary.isMaxLevel;
var dailyXp = summary.dailyXp || 0;
var discoveries = summary.discoveryCount || 0;
panel.classList.remove('is-syncing');
panel.classList.toggle('is-max-level', isMaxLevel);
var levelEl = panel.querySelector('.profile-page-level');
var totalEl = panel.querySelector('.profile-page-total-xp');
var fillEl = panel.querySelector('.profile-page-xp-fill');
var subEl = panel.querySelector('.profile-page-progress-sub');
var metaEl = panel.querySelector('.profile-page-progress-meta');
if (levelEl) levelEl.textContent = (isMaxLevel ? 'MAX ' : 'LVL ') + level;
if (totalEl) totalEl.textContent = totalXp + ' XP';
if (fillEl) fillEl.style.width = percent + '%';
if (subEl) subEl.textContent = isMaxLevel ? 'MAX LEVEL' : (xpIntoLevel + ' / ' + xpForNext + ' TO NEXT');
if (metaEl) metaEl.textContent = 'TODAY ' + dailyXp + ' XP · DISCOVERED ' + discoveries;
}
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 || ''
});
}).fail(function() {
updateProfileFields(card, {
name: '',
discord: '',
role: '',
bio: ''
});
});
}
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"]');
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 || '';
}
// ========== 프로필 시스템 끝 ==========
// ========== 알림 시스템 ==========
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:5px;' +
'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('profile-quick-notifications');
var popup = document.getElementById('clbi-notification-popup');
if (!btn || !popup) return;
var rect = btn.getBoundingClientRect();
var top = rect.bottom + 6;
var left = rect.left + (rect.width / 2) - (popup.offsetWidth / 2);
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, rect.top - popup.offsetHeight - 6);
}
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 setNotificationIcon(hasItems) {
var quickIcon = document.getElementById('profile-quick-notification-icon');
var svg = hasItems ? CLBI_SVG_BELL_DOT : CLBI_SVG_BELL;
if (quickIcon) {
quickIcon.innerHTML = svg;
quickIcon.classList.toggle('has-notifications', !!hasItems);
}
}
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';
setNotificationIcon(false);
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';
}
setNotificationIcon(true);
}
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 quickBtn = document.getElementById('profile-quick-notifications');
if (!quickBtn) return;
ensureNotificationPopup();
loadNotificationsIntoPopup();
$(document)
.off('click.clbiNotificationToggle')
.on('click.clbiNotificationToggle', '#profile-quick-notifications', function(e) {
e.preventDefault();
e.stopPropagation();
var popup = document.getElementById('clbi-notification-popup');
if (!popup) return;
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 quickToggle = document.getElementById('profile-quick-notifications');
if (!popup) return;
if (!popup.contains(e.target) && (!quickToggle || !quickToggle.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 initUserProfilePage() {
$('body').addClass('user-profile-settings-page');
var saveBtn = document.getElementById('pref-save');
if (!saveBtn) return;
function getPrefRow(id) {
var el = document.getElementById(id);
if (!el) return null;
return el.closest('.clbi-pref-row') || el.parentNode;
}
function removePrefRow(id) {
var row = getPrefRow(id);
if (row && row.parentNode) {
row.parentNode.removeChild(row);
}
}
function createPrefSection(className, titleText) {
var section = document.createElement('div');
section.className = 'clbi-pref-section ' + className;
var title = document.createElement('div');
title.className = 'clbi-pref-section-title';
title.textContent = titleText;
var body = document.createElement('div');
body.className = 'clbi-pref-section-body';
section.appendChild(title);
section.appendChild(body);
return {
section: section,
body: body
};
}
function moveRowToSection(id, targetBody, className) {
var row = getPrefRow(id);
if (!row || !targetBody) return false;
row.classList.add('clbi-pref-row-key-' + className);
targetBody.appendChild(row);
return true;
}
function rebuildProfileSettingsLayout() {
var root = document.querySelector('.clbi-prefs-profile');
if (!root || root.dataset.profileSettingsReworked === '1') return;
root.dataset.profileSettingsReworked = '1';
root.classList.add('profile-settings-console');
removePrefRow('pref-badges');
var originalRows = Array.prototype.slice.call(root.querySelectorAll('.clbi-pref-row'));
var actionNodes = [];
if (saveBtn.parentNode === root || saveBtn.closest('.clbi-prefs-profile') === root) {
actionNodes.push(saveBtn);
}
var statusNode = document.getElementById('pref-status');
if (statusNode && statusNode.closest('.clbi-prefs-profile') === root) {
actionNodes.push(statusNode);
}
var main = document.createElement('div');
main.className = 'clbi-pref-main-grid';
var media = createPrefSection('clbi-pref-section-media', 'PROFILE IMAGE');
var identity = createPrefSection('clbi-pref-section-identity', 'IDENTITY RECORD');
var bio = createPrefSection('clbi-pref-section-bio', 'BIOGRAPHY');
var account = createPrefSection('clbi-pref-section-account', 'ACCOUNT CONTACT');
var misc = createPrefSection('clbi-pref-section-misc', 'OTHER OPTIONS');
main.appendChild(media.section);
main.appendChild(identity.section);
main.appendChild(bio.section);
main.appendChild(account.section);
main.appendChild(misc.section);
root.innerHTML = '';
root.appendChild(main);
moveRowToSection('pref-pfp-preview', media.body, 'pfp');
moveRowToSection('pref-pfp-btn', media.body, 'pfp');
moveRowToSection('pref-pfp-input', media.body, 'pfp');
moveRowToSection('pref-name', identity.body, 'name');
moveRowToSection('pref-role', identity.body, 'role');
moveRowToSection('pref-discord', identity.body, 'discord');
moveRowToSection('pref-bio', bio.body, 'bio');
moveRowToSection('pref-new-email', account.body, 'email');
moveRowToSection('pref-email-password', account.body, 'email');
moveRowToSection('pref-email-save', account.body, 'email');
originalRows.forEach(function (row) {
if (!row.parentNode && !row.className.match(/clbi-pref-row-key-/)) {
misc.body.appendChild(row);
}
});
if (!misc.body.children.length) {
misc.section.parentNode.removeChild(misc.section);
}
var actions = document.createElement('div');
actions.className = 'clbi-pref-actions';
if (saveBtn) actions.appendChild(saveBtn);
if (statusNode) actions.appendChild(statusNode);
root.appendChild(actions);
}
rebuildProfileSettingsLayout();
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'];
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;',
'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)); }',
'vec2 coverUV(vec2 uv) {',
' float imgAR = u_imgSize.x / u_imgSize.y;',
' float scrAR = u_res.x / u_res.y;',
' float scale = imgAR / scrAR;',
' float offsetY = (1.0 - scale) * 0.5;',
' return vec2(uv.x, uv.y * scale + offsetY);',
'}',
'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;',
'}',
'vec4 sampleInitialNoise(float t) {',
' return texture2D(u_noise, vec2(fract(t/2048.0), fract(t/1048576.0)));',
'}',
'vec4 sampleScreenNoise(vec2 uv) {',
' return texture2D(u_noise, u_noiseScale * uv);',
'}',
'vec3 applyRgbShift(vec2 texUV, float shift) {',
' vec2 d = vec2(shift, 0.0);',
' vec3 r = texture2D(u_tex, clamp(texUV + d, 0.0, 1.0)).rgb;',
' vec3 c = texture2D(u_tex, texUV).rgb;',
' vec3 l = texture2D(u_tex, clamp(texUV - d, 0.0, 1.0)).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',
' );',
'}',
'vec3 applyBloom(vec2 texUV, float strength) {',
' vec2 px = 2.0 / u_res;',
' vec3 acc = vec3(0.0);',
' acc += texture2D(u_tex, clamp(texUV + vec2( px.x, 0.0), 0.0, 1.0)).rgb;',
' acc += texture2D(u_tex, clamp(texUV + vec2(-px.x, 0.0), 0.0, 1.0)).rgb;',
' acc += texture2D(u_tex, clamp(texUV + vec2( 0.0, px.y), 0.0, 1.0)).rgb;',
' acc += texture2D(u_tex, clamp(texUV + vec2( 0.0, -px.y), 0.0, 1.0)).rgb;',
' acc += texture2D(u_tex, clamp(texUV + vec2( px.x, px.y), 0.0, 1.0)).rgb * 0.5;',
' acc += texture2D(u_tex, clamp(texUV + vec2(-px.x, px.y), 0.0, 1.0)).rgb * 0.5;',
' acc += texture2D(u_tex, clamp(texUV + vec2( px.x, -px.y), 0.0, 1.0)).rgb * 0.5;',
' acc += texture2D(u_tex, clamp(texUV + vec2(-px.x, -px.y), 0.0, 1.0)).rgb * 0.5;',
' return acc / 6.0 * strength;',
'}',
'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;',
'}',
'vec3 applyRasterization(vec2 uv, vec3 col) {',
' float t = u_time;',
' vec2 noiseUV = uv + vec2(fract(t * 0.030), fract(t * 0.060));',
' float wobbleX = (texture2D(u_noise, noiseUV * 0.8).r - 0.5) * 0.0018;',
' float wobbleY = (texture2D(u_noise, noiseUV * 0.8 + 0.5).r - 0.5) * 0.0008;',
' vec2 wobbledUV = clamp(uv + vec2(wobbleX, wobbleY), 0.0, 1.0);',
' vec3 wobbled = texture2D(u_tex, wobbledUV).rgb;',
' return mix(col, wobbled, 0.35);',
'}',
'float glowingLine(vec2 uv, float t) {',
' float pos = fract(t * 0.2);',
' float lineY = pos * (u_res.y + 330.0) - 120.0;',
' float y = uv.y * u_res.y;',
' return fract(smoothstep(-300.0, 0.0, y - lineY));',
'}',
'vec2 applyHSync(vec2 uv, vec4 noise, float strength) {',
' 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.18;',
' 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; }',
' vec2 texUV = clamp(coverUV(uv), 0.0, 1.0);',
' vec4 initNoise = sampleInitialNoise(u_time);',
' vec4 screenNoise = sampleScreenNoise(uv);',
' texUV = applyHSync(texUV, initNoise, 0.006);',
' texUV = clamp(texUV, 0.0, 1.0);',
' texUV += (vec2(screenNoise.b, screenNoise.a) - 0.5) * 0.0006;',
' texUV = clamp(texUV, 0.0, 1.0);',
' vec3 col = applyRgbShift(texUV, 0.003);',
' col += applyBloom(texUV, 0.22);',
' vec2 bpx = 1.5 / u_res;',
' vec3 blurCol = vec3(0.0);',
' blurCol += texture2D(u_tex, clamp(texUV + vec2(-bpx.x, -bpx.y), 0.0, 1.0)).rgb;',
' blurCol += texture2D(u_tex, clamp(texUV + vec2( 0.0, -bpx.y), 0.0, 1.0)).rgb;',
' blurCol += texture2D(u_tex, clamp(texUV + vec2( bpx.x, -bpx.y), 0.0, 1.0)).rgb;',
' blurCol += texture2D(u_tex, clamp(texUV + vec2(-bpx.x, 0.0 ), 0.0, 1.0)).rgb;',
' blurCol += texture2D(u_tex, clamp(texUV + vec2( bpx.x, 0.0 ), 0.0, 1.0)).rgb;',
' blurCol += texture2D(u_tex, clamp(texUV + vec2(-bpx.x, bpx.y), 0.0, 1.0)).rgb;',
' blurCol += texture2D(u_tex, clamp(texUV + vec2( 0.0, bpx.y), 0.0, 1.0)).rgb;',
' blurCol += texture2D(u_tex, clamp(texUV + vec2( bpx.x, bpx.y), 0.0, 1.0)).rgb;',
' col = mix(col, blurCol / 8.0, 0.40);',
' col = applyScanlines(uv, col);',
' col = applyRasterization(texUV, col);',
' float glow = glowingLine(uv, u_time);',
' col += glow * 0.08 * vec3(0.85, 0.95, 1.0);',
' float dist = length(cc);',
' col += screenNoise.a * 0.07 * (1.0 - dist * 1.3);',
' float grey = rgb2grey(col);',
' vec3 phosphor = vec3(0.75, 0.88, 1.0);',
' col = mix(col, grey * phosphor, 0.35);',
' vec2 vig = v_uv * (1.0 - v_uv);',
' col *= pow(vig.x * vig.y * 15.0, 0.25);',
' col *= 1.0 + (initNoise.g - 0.5) * 0.06;',
' 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');
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);
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);
});
}
})();
/* =========================================
Progress System UI
MediaWiki:Common.js controlled frontend
========================================= */
(function (mw, $) {
'use strict';
if (window.ProgressSystemWebUiInitialized) return;
window.ProgressSystemWebUiInitialized = true;
var api = null;
function withApi(done, fail) {
if (api) {
done(api);
return;
}
if (!mw.loader || typeof mw.loader.using !== 'function') {
if (typeof fail === 'function') fail();
return;
}
mw.loader.using(['mediawiki.api']).then(function () {
api = new mw.Api();
done(api);
}, function () {
if (typeof fail === 'function') fail();
});
}
var inFlightPageIds = new Set();
var handledPageIds = new Set();
var notificationQueue = [];
var notificationActive = false;
var summaryRequested = false;
var currentSummary = null;
var pendingSummary = null;
var pendingOptions = null;
var visibilityBound = false;
var barTimerA = null;
var barTimerB = null;
var barTimerC = null;
function isLoggedIn() {
return !!mw.config.get('wgUserName');
}
function getPageId() {
var id = parseInt(mw.config.get('wgArticleId') || 0, 10);
return Number.isFinite(id) ? id : 0;
}
function isRewardableClientSide() {
if (!isLoggedIn()) return false;
if (parseInt(mw.config.get('wgNamespaceNumber'), 10) !== 0) return false;
if (mw.config.get('wgIsMainPage')) return false;
if (getPageId() <= 0) return false;
return true;
}
function getPanelHtml() {
return '' +
'<div id="progress-panel" class="profile-progress-block is-syncing" aria-live="polite" data-progress-state="syncing">' +
'<div class="progress-title-row" hidden></div>' +
'<div class="progress-level-row">' +
'<span class="progress-level-label">SYNC</span>' +
'<span class="progress-total-xp">— XP</span>' +
'</div>' +
'<div class="progress-xp-bar" aria-hidden="true">' +
'<div class="progress-xp-gain"></div>' +
'<div class="progress-xp-fill"></div>' +
'</div>' +
'<div class="progress-sub-row">' +
'<span class="progress-xp-next">SYNCING</span>' +
'<span class="progress-daily-xp">TODAY —</span>' +
'</div>' +
'<div class="progress-discovery-row">DISCOVERED —</div>' +
'</div>';
}
function getDividerHtml() {
return '<div id="profile-progress-divider" class="profile-divider" aria-hidden="true"></div>';
}
function setPanelSync($panel) {
if (!$panel || !$panel.length) return;
$panel.addClass('is-syncing').removeClass('is-max-level').attr('data-progress-state', 'syncing');
$panel.find('.progress-title-row').text('').prop('hidden', true);
$panel.find('.progress-level-label').text('SYNC');
$panel.find('.progress-total-xp').text('— XP');
$panel.find('.progress-xp-next').text('SYNCING');
$panel.find('.progress-daily-xp').text('TODAY —');
$panel.find('.progress-discovery-row').text('DISCOVERED —');
$panel.find('.progress-xp-fill').css({ transition: 'none', width: '0%' });
$panel.find('.progress-xp-gain').css({ transition: 'none', width: '0%', opacity: 0 });
}
function placePanel($panel) {
var $right = $('#clbi-right-sidebar');
if (!$right.length) return false;
var $userBox = $right.children('.clbi-right-box').first();
if (!$userBox.length) return false;
var $buttonArea = $userBox.children('.clbi-right-content').first();
var $oldFallback = $panel.closest('.progress-panel-fallback');
if ($buttonArea.length) {
var $divider = $('#profile-progress-divider');
$panel.insertBefore($buttonArea);
if (!$divider.length) {
$divider = $(getDividerHtml());
}
$divider.insertAfter($panel);
} else {
$('#profile-progress-divider').remove();
$userBox.append($panel);
}
if ($oldFallback.length && !$oldFallback.find('#progress-panel').length) {
$oldFallback.remove();
}
return true;
}
function ensurePanel() {
if (!isLoggedIn()) return $();
var $right = $('#clbi-right-sidebar');
if (!$right.length) return $();
var $panel = $('#progress-panel');
if (!$panel.length) {
$panel = $(getPanelHtml());
if (!placePanel($panel)) return $();
setPanelSync($panel);
} else {
$panel.addClass('profile-progress-block');
placePanel($panel);
if (!currentSummary && $panel.attr('data-progress-state') !== 'syncing') {
setPanelSync($panel);
}
}
return $('#progress-panel');
}
function clampPercent(value) {
return Math.max(0, Math.min(100, value || 0));
}
function hasXpNotification(items) {
if (!items || !items.length) return false;
return items.some(function (item) {
return item && item.type === 'xp' && parseInt(item.amount || 0, 10) > 0;
});
}
function clearBarTimers() {
[barTimerA, barTimerB, barTimerC].forEach(function (timer) {
if (timer) clearTimeout(timer);
});
barTimerA = null;
barTimerB = null;
barTimerC = null;
}
function setBarInstant($fill, $gain, percent) {
clearBarTimers();
percent = clampPercent(percent);
$fill.css({ transition: 'none', width: percent + '%' });
$gain.css({ transition: 'none', left: '0%', width: '0%', opacity: 0 });
if ($fill[0]) $fill[0].offsetHeight;
$fill.css({ transition: '' });
$gain.css({ transition: '' });
}
function animateGain($fill, $gain, fromPercent, toPercent, levelChanged) {
clearBarTimers();
fromPercent = clampPercent(fromPercent);
toPercent = clampPercent(toPercent);
$fill.css({ transition: 'none', width: fromPercent + '%' });
if (levelChanged) {
var firstDelta = Math.max(0, 100 - fromPercent);
$gain.css({
transition: 'none',
opacity: firstDelta > 0 ? 1 : 0,
left: fromPercent + '%',
width: firstDelta + '%'
});
if ($fill[0]) $fill[0].offsetHeight;
barTimerA = setTimeout(function () {
$fill.css({
transition: 'width 540ms cubic-bezier(0.22, 0.7, 0.18, 1)',
width: '100%'
});
}, 260);
barTimerB = setTimeout(function () {
$fill.css({ transition: 'none', width: '0%' });
$gain.css({ transition: 'none', opacity: toPercent > 0 ? 1 : 0, left: '0%', width: toPercent + '%' });
if ($fill[0]) $fill[0].offsetHeight;
$fill.css({
transition: 'width 460ms cubic-bezier(0.22, 0.7, 0.18, 1)',
width: toPercent + '%'
});
}, 860);
barTimerC = setTimeout(function () {
$gain.css({ transition: 'opacity 180ms ease', opacity: 0 });
}, 1380);
return;
}
var delta = Math.max(0, toPercent - fromPercent);
if (delta <= 0.15) {
setBarInstant($fill, $gain, toPercent);
return;
}
$gain.css({
transition: 'none',
opacity: 1,
left: fromPercent + '%',
width: delta + '%'
});
if ($fill[0]) $fill[0].offsetHeight;
barTimerA = setTimeout(function () {
$fill.css({
transition: 'width 560ms cubic-bezier(0.22, 0.7, 0.18, 1)',
width: toPercent + '%'
});
}, 260);
barTimerB = setTimeout(function () {
$gain.css({ transition: 'opacity 180ms ease', opacity: 0 });
}, 940);
}
function updatePanel(summary, options) {
if (!summary) return;
options = options || {};
var $panel = ensurePanel();
if (!$panel.length) {
pendingSummary = $.extend({}, summary);
pendingOptions = $.extend({}, options);
return;
}
var level = summary.level || 1;
var totalXp = summary.totalXp || 0;
var xpIntoLevel = summary.xpIntoLevel || 0;
var xpForNext = summary.xpForNextLevel || 1;
var percent = clampPercent(summary.progressPercent);
var isMaxLevel = !!summary.isMaxLevel;
var dailyXp = summary.dailyXp || 0;
var discoveries = summary.discoveryCount || 0;
var title = summary.equippedTitle || summary.title || '';
$panel.removeClass('is-syncing').toggleClass('is-max-level', isMaxLevel).attr('data-progress-state', 'ready');
$panel.find('.progress-level-label').text((isMaxLevel ? 'MAX ' : 'LVL ') + level);
$panel.find('.progress-total-xp').text(totalXp + ' XP');
$panel.find('.progress-xp-next').text(isMaxLevel ? 'MAX LEVEL' : (xpIntoLevel + ' / ' + xpForNext + ' TO NEXT'));
$panel.find('.progress-daily-xp').text('TODAY ' + dailyXp + ' XP');
$panel.find('.progress-discovery-row').text('DISCOVERED ' + discoveries);
var $title = $panel.find('.progress-title-row');
if (title) {
$title.text(title).prop('hidden', false);
} else {
$title.text('').prop('hidden', true);
}
var $fill = $panel.find('.progress-xp-fill');
var $gain = $panel.find('.progress-xp-gain');
var animate = !!options.animateGain && currentSummary && totalXp > (currentSummary.totalXp || 0);
if (animate) {
animateGain(
$fill,
$gain,
clampPercent(currentSummary.progressPercent),
percent,
level !== (currentSummary.level || 1)
);
} else {
setBarInstant($fill, $gain, percent);
}
currentSummary = $.extend({}, summary);
pendingSummary = null;
pendingOptions = null;
}
function requestSummary() {
if (!isLoggedIn()) return;
if (summaryRequested) return;
summaryRequested = true;
withApi(function (api) {
api.get({
action: 'progress_summary',
format: 'json',
formatversion: 2
}).then(function (data) {
var payload = data && data.progress_summary;
if (payload && payload.available && payload.summary) {
updatePanel(payload.summary, { animateGain: false });
}
}).always(function () {
summaryRequested = false;
});
}, function () {
summaryRequested = false;
});
}
function queueNotifications(items) {
if (!items || !items.length) return;
items.forEach(function (item) {
if (!item) return;
notificationQueue.push(item);
});
showNextNotification();
}
function notificationText(item) {
if (item.type === 'xp') {
return '+' + (item.amount || 0) + ' XP · ' + (item.label || '문서 열람');
}
if (item.type === 'achievement') {
var xp = item.amount ? ' · +' + item.amount + ' XP' : '';
return '업적 달성 · ' + (item.label || '새 업적') + xp;
}
if (item.type === 'level') {
return item.label || '레벨 상승';
}
return item.label || '보상 획득';
}
function showNextNotification() {
if (notificationActive) return;
if (!notificationQueue.length) return;
notificationActive = true;
var item = notificationQueue.shift();
var $root = $('#progress-toast-root');
if (!$root.length) {
$('body').append('<div id="progress-toast-root"></div>');
$root = $('#progress-toast-root');
}
var $toast = $('<div class="progress-toast"></div>');
$toast.text(notificationText(item));
$root.append($toast);
requestAnimationFrame(function () {
$toast.addClass('is-visible');
});
setTimeout(function () {
$toast.removeClass('is-visible');
setTimeout(function () {
$toast.remove();
notificationActive = false;
showNextNotification();
}, 220);
}, 2600);
}
function applyPendingSummaryIfPossible() {
if (!pendingSummary) return;
updatePanel(pendingSummary, pendingOptions || { animateGain: false });
}
function handlePageView() {
ensurePanel();
applyPendingSummaryIfPossible();
if (!isRewardableClientSide()) {
requestSummary();
return;
}
var pageId = getPageId();
if (handledPageIds.has(pageId)) {
requestSummary();
return;
}
if (inFlightPageIds.has(pageId)) {
requestSummary();
return;
}
inFlightPageIds.add(pageId);
withApi(function (api) {
api.postWithToken('csrf', {
action: 'progress_view',
format: 'json',
formatversion: 2,
errorformat: 'plaintext',
pageid: pageId
}).then(function (data) {
var payload = data && data.progress_view;
if (!payload) return;
handledPageIds.add(pageId);
var animate = hasXpNotification(payload.notifications);
if (payload.summary) {
updatePanel(payload.summary, { animateGain: animate });
}
if (payload.notifications && payload.notifications.length) {
queueNotifications(payload.notifications);
}
}).catch(function () {
requestSummary();
}).always(function () {
inFlightPageIds.delete(pageId);
});
}, function () {
inFlightPageIds.delete(pageId);
requestSummary();
});
}
function bindVisibilitySync() {
if (visibilityBound) return;
visibilityBound = true;
document.addEventListener('visibilitychange', function () {
if (document.visibilityState === 'visible') {
requestSummary();
}
});
}
function bootProgressSystem(reason) {
ensurePanel();
applyPendingSummaryIfPossible();
if (isRewardableClientSide()) {
handlePageView();
} else {
requestSummary();
}
}
function handleSpaPageView() {
ensurePanel();
applyPendingSummaryIfPossible();
requestAnimationFrame(function () {
setTimeout(function () {
handlePageView();
}, 80);
});
}
function applySummary(summary, options) {
updatePanel(summary, options || { animateGain: false });
}
window.ProgressSystemWebUi = {
boot: bootProgressSystem,
requestSummary: requestSummary,
applySummary: applySummary,
handlePageView: handlePageView,
handleSpaPageView: handleSpaPageView,
ensurePanel: ensurePanel
};
$(function () {
bindVisibilitySync();
bootProgressSystem('documentReady');
});
mw.hook('wikipage.content').add(function () {
ensurePanel();
applyPendingSummaryIfPossible();
});
})(mediaWiki, jQuery);