미디어위키:Common.js: 두 판 사이의 차이

편집 요약 없음
편집 요약 없음
2,243번째 줄: 2,243번째 줄:


/* =========================================
/* =========================================
   CRT WebGL Renderer — cool-retro-term-webgl port
   CRT WebGL Renderer — cool-retro-term IBM DOS style
  원본 ShaderLibrary.qml + ShaderTerminal.qml 기반
   ========================================= */
   ========================================= */
(function () {
(function () {
     'use strict';
     'use strict';


    // 512x512 RGBA 노이즈 텍스처를 JS로 생성
     function createNoiseTexture(gl) {
     function createNoiseTexture(gl) {
         var size = 512;
         var size = 512;
         var data = new Uint8Array(size * size * 4);
         var data = new Uint8Array(size * size * 4);
        // 시드 기반 의사난수 (재현 가능)
         var s = 12345;
         var s = 12345;
         function rand() {
         function rand() {
2,286번째 줄: 2,283번째 줄:
         'uniform sampler2D u_noise;',
         'uniform sampler2D u_noise;',
         'uniform vec2 u_res;',
         'uniform vec2 u_res;',
        'uniform vec2 u_imgSize;',
         'uniform float u_time;',
         'uniform float u_time;',
         'uniform vec2 u_noiseScale;',
         'uniform vec2 u_noiseScale;',
         'varying vec2 v_uv;',
         'varying vec2 v_uv;',


         // ── Utilities (ShaderLibrary.qml) ──
         // Utilities
         'float sum2(vec2 v) { return v.x + v.y; }',
         'float sum2(vec2 v) { return v.x + v.y; }',
         'float min2(vec2 v) { return min(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)); }',
         'float rgb2grey(vec3 v) { return dot(v, vec3(0.21, 0.72, 0.04)); }',


         // ── Barrel distortion (원본 QML 공식) ──
         // Cover UV — 좌우 맞추고 세로 클리핑 (object-fit: cover 방식)
        'vec2 coverUV(vec2 uv) {',
        '  float imgAR = u_imgSize.x / u_imgSize.y;',
        '  float scrAR = u_res.x / u_res.y;',
        '  vec2 scale = vec2(1.0);',
        '  if (imgAR > scrAR) scale.y = imgAR / scrAR;',
        '  else scale.x = scrAR / imgAR;',
        '  vec2 result = (uv - 0.5) * scale + 0.5;',
        '  result = clamp(result, 0.0, 1.0);',
        '  return result;',
        '}',
 
        // Barrel distortion — QML 원본 공식 + 종횡비 보정
         'vec2 barrel(vec2 v, vec2 cc, float k) {',
         'vec2 barrel(vec2 v, vec2 cc, float k) {',
         '  float ar = u_res.x / u_res.y;',
         '  float ar = u_res.x / u_res.y;',
2,304번째 줄: 2,314번째 줄:
         '}',
         '}',


         // ── Noise samplers ──
         // Noise samplers
         'vec4 sampleInitialNoise(float t) {',
         'vec4 sampleInitialNoise(float t) {',
         '  return texture2D(u_noise, vec2(fract(t/2048.0), fract(t/1048576.0)));',
         '  return texture2D(u_noise, vec2(fract(t/2048.0), fract(t/1048576.0)));',
         '}',
         '}',
         'vec4 sampleScreenNoise(vec2 uv, float t) {',
        // 고정 노이즈 — time offset 없음
        '  vec2 off = vec2(fract(t/51.0), fract(t/237.0));',
         'vec4 sampleScreenNoise(vec2 uv) {',
         '  return texture2D(u_noise, u_noiseScale * uv + off);',
         '  return texture2D(u_noise, u_noiseScale * uv);',
         '}',
         '}',


         // ── RGB shift (원본 QML 비대칭 가중치) ──
         // RGB shift QML 비대칭 가중치
         'vec3 applyRgbShift(vec2 uv, float shift) {',
         'vec3 applyRgbShift(vec2 uv, float shift) {',
         '  vec2 d = vec2(12.0, 0.0) * shift / u_res.x;',
         '  vec2 d = vec2(shift, 0.0);',
         '  vec3 r = texture2D(u_tex, uv + d).rgb;',
         '  vec3 r = texture2D(u_tex, coverUV(uv + d)).rgb;',
         '  vec3 c = texture2D(u_tex, uv).rgb;',
         '  vec3 c = texture2D(u_tex, coverUV(uv)).rgb;',
         '  vec3 l = texture2D(u_tex, uv - d).rgb;',
         '  vec3 l = texture2D(u_tex, coverUV(uv - d)).rgb;',
         '  return vec3(',
         '  return vec3(',
         '    l.r*0.10 + r.r*0.30 + c.r*0.60,',
         '    l.r*0.10 + r.r*0.30 + c.r*0.60,',
2,326번째 줄: 2,336번째 줄:
         '}',
         '}',


         // ── Bloom (포스포 글로우) ──
         // Bloom 포스포 글로우
         'vec3 applyBloom(vec2 uv, float strength) {',
         'vec3 applyBloom(vec2 uv, float strength) {',
         '  vec2 px = 1.0 / u_res;',
         '  vec2 px = 2.0 / u_res;',
         '  vec3 acc = vec3(0.0);',
         '  vec3 acc = vec3(0.0);',
         '  acc += texture2D(u_tex, uv + vec2( px.x,  0.0)).rgb;',
         '  acc += texture2D(u_tex, coverUV(uv + vec2( px.x,  0.0))).rgb;',
         '  acc += texture2D(u_tex, uv + vec2(-px.x,  0.0)).rgb;',
         '  acc += texture2D(u_tex, coverUV(uv + vec2(-px.x,  0.0))).rgb;',
         '  acc += texture2D(u_tex, uv + vec2( 0.0,  px.y)).rgb;',
         '  acc += texture2D(u_tex, coverUV(uv + vec2( 0.0,  px.y))).rgb;',
         '  acc += texture2D(u_tex, uv + vec2( 0.0, -px.y)).rgb;',
         '  acc += texture2D(u_tex, coverUV(uv + vec2( 0.0, -px.y))).rgb;',
         '  acc += texture2D(u_tex, uv + vec2( px.x*2.00.0)).rgb * 0.5;',
         '  acc += texture2D(u_tex, coverUV(uv + vec2( px.x*1.5px.y*1.5))).rgb * 0.5;',
         '  acc += texture2D(u_tex, uv + vec2(-px.x*2.00.0)).rgb * 0.5;',
         '  acc += texture2D(u_tex, coverUV(uv + vec2(-px.x*1.5px.y*1.5))).rgb * 0.5;',
         '  acc += texture2D(u_tex, uv + vec2( 0.0, px.y*2.0)).rgb * 0.5;',
         '  acc += texture2D(u_tex, coverUV(uv + vec2( px.x*1.5, -px.y*1.5))).rgb * 0.5;',
         '  acc += texture2D(u_tex, uv + vec2( 0.0, -px.y*2.0)).rgb * 0.5;',
         '  acc += texture2D(u_tex, coverUV(uv + vec2(-px.x*1.5, -px.y*1.5))).rgb * 0.5;',
         '  return acc / 6.0 * strength;',
         '  return acc / 6.0 * strength;',
         '}',
         '}',


         // ── Scanline rasterization (원본 QML 방식) ──
         // Scanlines — QML 방식
         'vec3 applyScanlines(vec2 uv, vec3 col) {',
         'vec3 applyScanlines(vec2 uv, vec3 col) {',
         '  float line = mod(uv.y * u_res.y, 2.0);',
         '  float line = mod(uv.y * u_res.y, 2.0);',
2,349번째 줄: 2,359번째 줄:
         '}',
         '}',


         // ── Glowing line (위→아래 스캔빔, 원본 QML randomPass) ──
         // Glowing line 위→아래 스캔빔
         'float glowingLine(vec2 uv, float t) {',
         'float glowingLine(vec2 uv, float t) {',
         '  float pos = fract(t * 0.00015 * 60.0);',
         '  float pos = fract(t * 0.22);',
        '  float lineY = pos * (u_res.y + 120.0) - 120.0;',
         '  float y = uv.y * u_res.y;',
         '  float y = uv.y * u_res.y;',
        '  float total = u_res.y;',
        '  float lineY = pos * (total + 120.0) - 120.0;',
         '  return fract(smoothstep(-120.0, 0.0, y - lineY));',
         '  return fract(smoothstep(-120.0, 0.0, y - lineY));',
         '}',
         '}',


         // ── Horizontal sync ──
         // Horizontal sync
         'vec2 applyHSync(vec2 uv, vec4 noise, float strength) {',
         'vec2 applyHSync(vec2 uv, vec4 noise, float strength) {',
         '  if (strength <= 0.0) return uv;',
         '  if (strength <= 0.0) return uv;',
2,366번째 줄: 2,375번째 줄:
         '  uv.x += sin((uv.y + u_time * 0.001) * freq) * scale;',
         '  uv.x += sin((uv.y + u_time * 0.001) * freq) * scale;',
         '  return uv;',
         '  return uv;',
        '}',
        // ── Vignette ──
        'float vignette(vec2 uv) {',
        '  vec2 v = uv * (1.0 - uv);',
        '  return pow(v.x * v.y * 15.0, 0.25);',
         '}',
         '}',


2,378번째 줄: 2,381번째 줄:


         // 배럴 왜곡
         // 배럴 왜곡
         '  float curvature = 0.15;',
         '  float curvature = 0.28;',
         '  vec2 uv = barrel(v_uv, cc, curvature);',
         '  vec2 uv = barrel(v_uv, cc, curvature);',


         // 화면 밖 검정
         // 화면 밖 검정
         '  float inScreen = min2(step(vec2(0.0), uv) - step(vec2(1.0), uv));',
         '  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; }',
         '  if (inScreen < 0.5) { gl_FragColor = vec4(0.0, 0.0, 0.0, 1.0); return; }',


         // 노이즈 샘플
         // 노이즈
         '  vec4 initNoise = sampleInitialNoise(u_time);',
         '  vec4 initNoise = sampleInitialNoise(u_time);',
         '  vec4 screenNoise = sampleScreenNoise(uv, u_time);',
         '  vec4 screenNoise = sampleScreenNoise(uv);',


         // Horizontal sync (약하게)
         // Horizontal sync
         '  uv = applyHSync(uv, initNoise, 0.008);',
         '  uv = applyHSync(uv, initNoise, 0.006);',


         // Jitter (미세 흔들림)
         // Jitter
         '  uv += (vec2(screenNoise.b, screenNoise.a) - 0.5) * 0.0008;',
         '  uv += (vec2(screenNoise.b, screenNoise.a) - 0.5) * 0.0006;',


         // RGB shift + 베이스 컬러
         // 베이스 컬러 (coverUV 적용)
         '  vec3 col = applyRgbShift(uv, 0.002);',
         '  vec3 col = applyRgbShift(uv, 0.003);',


         // Bloom
         // Bloom
         '  col += applyBloom(uv, 0.18);',
         '  col += applyBloom(uv, 0.22);',


         // 스캔라인
         // 뿌연 블러 (IBM DOS 스타일)
        '  vec2 bpx = 1.5 / u_res;',
        '  vec3 blurCol = vec3(0.0);',
        '  blurCol += texture2D(u_tex, coverUV(uv + vec2(-bpx.x, -bpx.y))).rgb;',
        '  blurCol += texture2D(u_tex, coverUV(uv + vec2( 0.0,  -bpx.y))).rgb;',
        '  blurCol += texture2D(u_tex, coverUV(uv + vec2( bpx.x, -bpx.y))).rgb;',
        '  blurCol += texture2D(u_tex, coverUV(uv + vec2(-bpx.x,  0.0  ))).rgb;',
        '  blurCol += texture2D(u_tex, coverUV(uv + vec2( bpx.x,  0.0  ))).rgb;',
        '  blurCol += texture2D(u_tex, coverUV(uv + vec2(-bpx.x,  bpx.y))).rgb;',
        '  blurCol += texture2D(u_tex, coverUV(uv + vec2( 0.0,    bpx.y))).rgb;',
        '  blurCol += texture2D(u_tex, coverUV(uv + vec2( bpx.x,  bpx.y))).rgb;',
        '  col = mix(col, blurCol / 8.0, 0.40);',
 
        // Scanlines
         '  col = applyScanlines(uv, col);',
         '  col = applyScanlines(uv, col);',


         // Glowing line
         // Glowing line
         '  float glow = glowingLine(uv, u_time);',
         '  float glow = glowingLine(uv, u_time);',
         '  col += glow * 0.5 * vec3(0.85, 0.95, 1.0);',
         '  col += glow * 0.55 * vec3(0.85, 0.95, 1.0);',


         // Static noise (grain)
         // Static noise
         '  float dist = length(cc);',
         '  float dist = length(cc);',
         '  col += screenNoise.a * 0.08 * (1.0 - dist * 1.3);',
         '  col += screenNoise.a * 0.07 * (1.0 - dist * 1.3);',
 
        // IBM DOS 그린 포스포 틴트
        '  float grey = rgb2grey(col);',
        '  vec3 phosphor = vec3(0.20, 0.95, 0.35);',
        '  col = mix(col, grey * phosphor, 0.50);',


         // Vignette
         // Vignette
         '  col *= vignette(v_uv);',
        '  vec2 vig = v_uv * (1.0 - v_uv);',
         '  col *= pow(vig.x * vig.y * 15.0, 0.25);',


         // Flickering (원본 QML brightness 방식)
         // Flickering
         '  float flicker = 1.0 + (initNoise.g - 0.5) * 0.06;',
         '  col *= 1.0 + (initNoise.g - 0.5) * 0.06;',
        '  col *= flicker;',


         // Ambient light (모서리 약간 밝게)
         // Ambient light
         '  col += vec3(0.015) * (1.0 - dist) * (1.0 - dist);',
         '  col += vec3(0.012) * (1.0 - dist) * (1.0 - dist);',


         // 감마
         // 감마
2,431번째 줄: 2,452번째 줄:
     function initCRTCanvas(screen, imgEl) {
     function initCRTCanvas(screen, imgEl) {
         var existing = screen.querySelector('.crt-webgl-canvas');
         var existing = screen.querySelector('.crt-webgl-canvas');
         if (existing) { existing.remove(); }
         if (existing) existing.remove();


         var canvas = document.createElement('canvas');
         var canvas = document.createElement('canvas');
2,446번째 줄: 2,467번째 줄:
             gl.compileShader(s);
             gl.compileShader(s);
             if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) {
             if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) {
                 console.error('[CRT]', gl.getShaderInfoLog(s));
                 console.error('[CRT shader]', gl.getShaderInfoLog(s));
             }
             }
             return s;
             return s;
2,457번째 줄: 2,478번째 줄:
         gl.useProgram(prog);
         gl.useProgram(prog);


        // 풀스크린 쿼드
         var buf = gl.createBuffer();
         var buf = gl.createBuffer();
         gl.bindBuffer(gl.ARRAY_BUFFER, buf);
         gl.bindBuffer(gl.ARRAY_BUFFER, buf);
2,465번째 줄: 2,485번째 줄:
         gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0);
         gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0);


         var uTex     = gl.getUniformLocation(prog, 'u_tex');
         var uTex     = gl.getUniformLocation(prog, 'u_tex');
         var uNoise   = gl.getUniformLocation(prog, 'u_noise');
         var uNoise   = gl.getUniformLocation(prog, 'u_noise');
         var uRes     = gl.getUniformLocation(prog, 'u_res');
         var uRes     = gl.getUniformLocation(prog, 'u_res');
         var uTime     = gl.getUniformLocation(prog, 'u_time');
        var uImgSize = gl.getUniformLocation(prog, 'u_imgSize');
         var uNoiseSc = gl.getUniformLocation(prog, 'u_noiseScale');
         var uTime   = gl.getUniformLocation(prog, 'u_time');
         var uNoiseSc = gl.getUniformLocation(prog, 'u_noiseScale');


         // 이미지 텍스처 (unit 0)
         // 이미지 텍스처 (unit 0)
2,482번째 줄: 2,503번째 줄:
         // 노이즈 텍스처 (unit 1)
         // 노이즈 텍스처 (unit 1)
         gl.activeTexture(gl.TEXTURE1);
         gl.activeTexture(gl.TEXTURE1);
         var noiseTex = createNoiseTexture(gl);
         createNoiseTexture(gl);


         var texReady = false;
         var texReady = false;
2,492번째 줄: 2,513번째 줄:
                 gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, imgEl);
                 gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, imgEl);
                 texReady = true;
                 texReady = true;
             } catch(e) { console.error('[CRT] tex upload:', e); }
             } catch(e) { console.error('[CRT] tex:', e); }
         }
         }


2,515번째 줄: 2,536번째 줄:
             gl.uniform1i(uNoise, 1);
             gl.uniform1i(uNoise, 1);
             gl.uniform2f(uRes, canvas.width, canvas.height);
             gl.uniform2f(uRes, canvas.width, canvas.height);
            gl.uniform2f(uImgSize, imgEl.naturalWidth, imgEl.naturalHeight);
             gl.uniform1f(uTime, t);
             gl.uniform1f(uTime, t);
             gl.uniform2f(uNoiseSc, canvas.width / 512.0, canvas.height / 512.0);
             gl.uniform2f(uNoiseSc, canvas.width / 512.0, canvas.height / 512.0);
2,544번째 줄: 2,566번째 줄:
                     if (!img) return;
                     if (!img) return;
                     obs.disconnect();
                     obs.disconnect();
                     img.complete && img.naturalWidth ? initCRTCanvas(screen, img) : img.addEventListener('load', function () { initCRTCanvas(screen, img); });
                     if (img.complete && img.naturalWidth) {
                        initCRTCanvas(screen, img);
                    } else {
                        img.addEventListener('load', function () { initCRTCanvas(screen, img); });
                    }
                 });
                 });
                 obs.observe(frame, { childList: true, subtree: true });
                 obs.observe(frame, { childList: true, subtree: true });

2026년 5월 10일 (일) 22:29 판

mw.loader.load('https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.5.13/cropper.min.js');

function loadLangScript(done) {
    $.getScript('/index.php?title=미디어위키:Lang.js&action=raw&ctype=text/javascript')
        .done(function() {
            if (typeof done === 'function') done();
        })
        .fail(function(a, b, c) {
            console.error('Lang.js load failed:', b, c);
            if (typeof done === 'function') done();
        });
}

$(function() {
    $('body').prepend(
        '<div class="WW-bg" style="position:fixed;top:0;left:0;width:100%;height:100vh;"></div>'
    );
});

// 페이지 전환 사운드
var transitionSound = new Audio('/index.php?title=특수:Redirect/file/Sfx-ui-001.mp3');

(function() {
    var master = parseFloat(localStorage.getItem('clbi-audio-master') || 80) / 100;
    var sfx = parseFloat(localStorage.getItem('clbi-audio-sfx') || 60) / 100;
    var sfxOn = localStorage.getItem('clbi-audio-sfxOn') !== 'false';
    transitionSound.volume = sfxOn ? master * sfx : 0;
})();

function playStaticSound() {
    var master = parseFloat(localStorage.getItem('clbi-audio-master') || 80) / 100;
    var sfx = parseFloat(localStorage.getItem('clbi-audio-sfx') || 60) / 100;
    var sfxOn = localStorage.getItem('clbi-audio-sfxOn') !== 'false';

    if (!sfxOn) return;

    transitionSound.volume = master * sfx;
    transitionSound.currentTime = 0;
    transitionSound.play();
}

// 현재 언어 감지
function getCurrentLang() {
    var langData = document.getElementById('clbi-lang-data');
    return langData ? (langData.getAttribute('data-lang') || 'ko') : 'ko';
}

function normalizePageName(value) {
    return String(value || '')
        .split('?')[0]
        .replace(/^\/index\.php\//, '')
        .replace(/_/g, ' ')
        .trim();
}

function buildWikiPath(title) {
    return '/index.php/' + encodeURI(String(title || '').replace(/ /g, '_'));
}

// 국가_및_조합 전용 왼쪽 사이드바 이미지
function updateLeftSidebarNationsImage() {
    var imageId = 'clbi-left-nations-image';

    // SPA 이동 시 이전 문서에서 삽입했던 이미지를 먼저 제거한다.
    // 이 처리가 없으면 국가_및_조합에서 다른 문서로 이동했을 때 이미지가 남을 수 있다.
    $('#' + imageId).remove();

    // 현재 문서명이 국가 및 조합일 때만 삽입한다.
    // wgPageName은 보통 국가_및_조합처럼 언더스코어를 포함하므로 normalizePageName으로 통일해서 비교한다.
    var pageName = normalizePageName(mw.config.get('wgPageName'));
    var targetPage = normalizePageName('국가_및_조합');

    if (pageName !== targetPage) {
        return;
    }

    var $leftSidebar = $('#clbi-left-sidebar');

    if (!$leftSidebar.length) {
        return;
    }

    // 왼쪽 사이드바 기본 자식 순서:
    // 0 = 언어 섹션, 1 = 카테고리 섹션.
    // 이미지는 카테고리 섹션 바로 아래에 삽입한다.
    var $categoryBox = $leftSidebar.children('.clbi-left-box').eq(1);

    if (!$categoryBox.length) {
        return;
    }

    // 컨테이너나 테두리를 추가하지 않고 img 요소만 넣는다.
    // 실제 파일을 바꾸려면 Redirect/file 뒤 파일명만 교체하면 된다.
    $categoryBox.after(
        '<img id="' + imageId + '" ' +
        'class="clbi-left-page-image" ' +
        'src="/index.php?title=특수:Redirect/file/img-nations-sidebar-001.png" ' +
        'alt="" loading="lazy">'
    );
}

// 사이드바 업데이트
function updateSidebar() {
    if (!window.LANG || !window.LANG_NAMES || !window.CAT_LINKS) {
        setTimeout(updateSidebar, 100);
        return;
    }

    var langData = document.getElementById('clbi-lang-data');
    var currentLang = getCurrentLang();
    var t = (window.LANG && window.LANG[currentLang]) ? window.LANG[currentLang] : window.LANG.ko;
    var names = (window.LANG_NAMES && window.LANG_NAMES[currentLang]) ? window.LANG_NAMES[currentLang] : window.LANG_NAMES.ko;
    var cl = (window.CAT_LINKS && window.CAT_LINKS[currentLang]) ? window.CAT_LINKS[currentLang] : window.CAT_LINKS.ko;

    var allLangs = langData ? {
        ko: langData.getAttribute('data-ko') || '',
        en: langData.getAttribute('data-en') || '',
        zh: langData.getAttribute('data-zh') || '',
        ja: langData.getAttribute('data-ja') || ''
    } : {
        ko: '',
        en: '',
        zh: '',
        ja: ''
    };

    $('#clbi-lang-current').text(names[currentLang]);

    var langHtml = '';
    $.each(['ko', 'en', 'zh', 'ja'], function(i, lang) {
        if (lang === currentLang) return;

        var target = allLangs[lang];
        if (target) {
            langHtml += '<div class="clbi-lang-link"><a href="' + buildWikiPath(target) + '?uselang=' + lang + '">' + names[lang] + '</a></div>';
        } else {
            langHtml += '<div class="clbi-lang-wip">' + names[lang] + '</div>';
        }
    });
    $('#clbi-lang-list').html(langHtml);

    $('#clbi-title-language').text(t.language);
    $('#clbi-title-categories').text(t.categories);
    $('#clbi-title-links').text(t.links);
    $('#clbi-title-search a').text(t.search);
    $('#clbi-search-input').attr('placeholder', t.search + '...');
    $('#clbi-title-recent a').text(t.recentChanges);
    $('#clbi-title-guide').text(t.guide);
    $('#clbi-guide-link').text(t.getStarted);
    $('#clbi-title-playlist').text(t.playlist);

    $('#clbi-cat-main a').attr('href', buildWikiPath(cl.main));
    $('#clbi-cat-main .clbi-cat-label').text(t.mainMenu);

    $('#clbi-cat-nations a').attr('href', buildWikiPath(cl.nations));
    $('#clbi-cat-nations .clbi-cat-label').text(t.nations);

    $('#clbi-cat-corporations a').attr('href', buildWikiPath(cl.corporations));
    $('#clbi-cat-corporations .clbi-cat-label').text(t.corporations);

    $('#clbi-cat-military a').attr('href', buildWikiPath(cl.military));
    $('#clbi-cat-military .clbi-cat-label').text(t.military);

    $('#clbi-cat-history a').attr('href', buildWikiPath(cl.history));
    $('#clbi-cat-history .clbi-cat-label').text(t.history);

    $('#clbi-cat-personnel a').attr('href', buildWikiPath(cl.personnel));
    $('#clbi-cat-personnel .clbi-cat-label').text(t.personnel);

    $('#clbi-btn-contribution').text(t.contribution);
    $('#clbi-btn-watchlist').text(t.watchlist);
    $('#clbi-btn-preferences').text(t.preferences);
    $('#clbi-btn-logout').text(t.logout);
    $('#clbi-btn-login').text(t.login);

    var pageName = normalizePageName(mw.config.get('wgPageName'));
    var specialPage = String(mw.config.get('wgCanonicalSpecialPageName') || '');

    $('.clbi-cat-btn').removeClass('clbi-cat-active');
    $.each(['main', 'nations', 'corporations', 'military', 'history', 'personnel'], function(i, key) {
        if (cl[key] && pageName === normalizePageName(cl[key])) {
            $('#clbi-cat-' + key).addClass('clbi-cat-active');
        }
    });

    $('.clbi-user-btn').removeClass('clbi-user-btn-active');

    if (
        specialPage === 'Contributions' ||
        specialPage === '기여' ||
        pageName.indexOf('특수:기여') === 0 ||
        pageName.indexOf('Special:Contributions') === 0
    ) {
        $('#clbi-btn-contribution').addClass('clbi-user-btn-active');
    }

    if (specialPage === 'Watchlist') {
        $('#clbi-btn-watchlist').addClass('clbi-user-btn-active');
    }

    if (
        specialPage === '설정' ||
        pageName === '특수:설정' ||
        pageName === 'Special:설정'
    ) {
        $('#clbi-btn-preferences').addClass('clbi-user-btn-active');
    }

    $('.toggleBtn').each(function() {
        var btn = $(this);
        if (!$('#' + btn.data('target')).hasClass('folding-open')) {
            btn.text(t.expand);
        } else {
            btn.text(t.collapse);
        }
    });

    // 왼쪽 사이드바의 문서별 전용 삽입물을 갱신한다.
    // 현재는 국가_및_조합 문서에서만 카테고리 섹션 아래 이미지를 삽입한다.
    updateLeftSidebarNationsImage();
}

function canShowContentTools() {
    // 비로그인 사용자는 편집/역사/공유 버튼을 숨김
    if (!mw.config.get('wgUserName')) {
        return false;
    }

    // MediaWiki가 현재 문서를 편집 가능하지 않다고 판단하면 숨김
    var isEditable = mw.config.get('wgIsProbablyEditable');
    if (isEditable === false) {
        return false;
    }

    var relevantEditable = mw.config.get('wgRelevantPageIsProbablyEditable');
    if (relevantEditable === false) {
        return false;
    }

    return true;
}

function moveCatlinksToBottom() {
    var main = $('.liberty-content-main');
    var parserOutput = $('.liberty-content-main .mw-parser-output').first();
    var catlinks = $('.catlinks');

    if (!main.length || !catlinks.length) return;

    catlinks.each(function () {
        var cat = $(this);

        if (parserOutput.length) {
            cat.appendTo(parserOutput);
        } else {
            cat.appendTo(main);
        }
    });
}

// 대문 스타일
function applyMainPageStyle() {
    var specialPage = mw.config.get('wgCanonicalSpecialPageName');
    if (specialPage === 'Preferences') return;

    var pageName = normalizePageName(mw.config.get('wgPageName'));
    var hideTools = (pageName === '대문' || !canShowContentTools());

    // 모든 문서에서 분류 바를 본문 컨테이너 아래로 이동
    moveCatlinksToBottom();

    if (pageName === '대문') {
        $('.liberty-content-header').css('display', 'none');
        $('.mw-page-title-main').addClass('clbi-hide');
        $('.catlinks').css('display', 'none');
        $('.liberty-content-main').css('border-radius', '5px 5px 0 0');

        if ($('#clbi-main-logo').length === 0) {
            $('.liberty-content').prepend(
                '<div id="clbi-main-logo" style="text-align:center;padding:10px 0;">' +
                '<img src="/index.php?title=특수:Redirect/file/Img-clbi-001.png" style="width:900px;height:auto;">' +
                '</div>'
            );
        }

        if ($('#clbi-main-crt-hero').length && $('#clbi-main-crt-hero-wrap').length === 0) {
            var heroWrap = $('<div id="clbi-main-crt-hero-wrap"></div>');

            if ($('#clbi-main-logo').length) {
                heroWrap.insertAfter('#clbi-main-logo');
            } else {
                heroWrap.insertBefore('.liberty-content-main');
            }

            $('#clbi-main-crt-hero').appendTo('#clbi-main-crt-hero-wrap');
        }
} else if ($('.screen-header').length > 0) {
        $('.liberty-content-header').css('display', 'none');
        $('.mw-page-title-main').addClass('clbi-hide');
        $('.catlinks').css('display', '');
        $('.liberty-content-main').css('border-radius', '5px');
        $('#clbi-main-logo').remove();
        $('#clbi-main-crt-hero-wrap').remove();

        if ($('#clbi-tools-box').length === 0 && canShowContentTools()) {
            var $toolsBox = $('<div id="clbi-tools-box" class="clbi-left-box"></div>');
            var $toolsTitle = $('<div class="clbi-left-title"><span class="clbi-icon" style="--icon:var(--ic-ui-003)"></span> 관리</div>');
            var $toolsContent = $('<div class="clbi-left-content"></div>');
            $toolsContent.append($('.content-tools .btn-group').clone(true));
            $toolsBox.append($toolsTitle).append($toolsContent);
            $('#clbi-left-sidebar').append($toolsBox);
        }

        $('.content-tools').css('display', 'none');
        $('.liberty-content').addClass('content-tools-hidden');
    } else {
        $('.liberty-content-header').css('display', '');
        $('.mw-page-title-main').removeClass('clbi-hide');
        $('.catlinks').css('display', '');
        $('.liberty-content-main').css('border-radius', '');
        $('#clbi-main-logo').remove();
        $('#clbi-main-crt-hero-wrap').remove();
        $('#clbi-tools-box').remove();
    }

    if (hideTools) {
        $('.content-tools').css('display', 'none');
        $('.liberty-content').addClass('content-tools-hidden');
    } else if (!$('.screen-header').length) {
        $('.content-tools').css('display', '');
        $('.liberty-content').removeClass('content-tools-hidden');
    }

    updateSidebar();
}

// 본문 기본 목차 제거
function removeNativeTocFromContent() {
    $('.liberty-content-main #toc, .liberty-content-main .toc').remove();
}

// 왼쪽 목차: MediaWiki 문단 ID 가져오기
function getHeadingId(heading) {
    if (heading.id) {
        return heading.id;
    }

    var headline = heading.querySelector('.mw-headline[id]');
    if (headline && headline.id) {
        return headline.id;
    }

    return '';
}

// 왼쪽 목차: MediaWiki 문단 제목 텍스트 가져오기
function getHeadingText(heading) {
    var headline = heading.querySelector('.mw-headline');
    var source = headline || heading;
    var clone = source.cloneNode(true);

    $(clone).find('.mw-editsection, .mw-editsection-bracket, .mw-editsection-divider').remove();

    return (clone.textContent || '')
        .replace(/\s+/g, ' ')
        .trim();
}

// 왼쪽 목차: 긴 제목에 자동 스크롤 적용
function initTocTitleScroll(root) {
    var $items = root
        ? $(root).find('.toc-scroll-text')
        : $('#side-toc-box .toc-scroll-text');

    $items.each(function () {
        var $text = $(this);
        var $wrap = $text.closest('.toc-scroll-wrap');

        if (!$wrap.length) return;

        var wrapW = Math.floor($wrap.width());
        var textW = Math.ceil(this.scrollWidth);

        // 왼쪽 목차: 레이아웃 계산이 끝나지 않았으면 이번 실행에서는 건드리지 않는다.
        if (!wrapW || !textW) return;

        if (textW <= wrapW + 12) {
            // 왼쪽 목차: 칸을 넘지 않는 제목은 전체 텍스트를 그대로 보여준다.
            $wrap.removeClass('is-scrolling');

            if ($text.data('toc-scroll-enabled')) {
                $text.css({
                    animation: '',
                    'animation-delay': '',
                    '--scroll-dist': ''
                });
                $text.removeData('toc-scroll-enabled');
                $text.removeData('toc-scroll-key');
            }

            return;
        }

        var scrollDist = '-' + (textW - wrapW + 10) + 'px';
        var duration = Math.max(7, textW / 38) * 1.25;
        var scrollKey = scrollDist + '|' + duration;

        // 왼쪽 목차: 긴 제목에는 오른쪽 페이드와 스크롤을 적용한다.
        $wrap.addClass('is-scrolling');

        // 왼쪽 목차: 같은 값으로 이미 적용된 애니메이션은 다시 초기화하지 않는다.
        if ($text.data('toc-scroll-key') === scrollKey) {
            return;
        }

        $text.data('toc-scroll-enabled', true);
        $text.data('toc-scroll-key', scrollKey);

        $text.css({
            // 왼쪽 목차: 페이지 진입 직후에는 잠시 읽을 시간을 준 뒤 흐르게 한다.
            animation: 'toc-scroll-blink-reset ' + duration + 's linear infinite',
            'animation-delay': '1s',
            '--scroll-dist': scrollDist
        });
    });
}

// 목차를 왼쪽 사이드바에 새로 생성
function moveTocToLeftSidebar() {
    // 왼쪽 목차: MediaWiki가 만든 원래 목차는 본문에서 제거한다.
    removeNativeTocFromContent();

    var leftSidebar = document.getElementById('clbi-left-sidebar');
    if (!leftSidebar) return;

    var content =
        document.querySelector('.liberty-content-main .mw-parser-output') ||
        document.querySelector('.liberty-content-main');

    if (!content) return;

    var headings = Array.prototype.slice.call(
        content.querySelectorAll('h2, h3')
    ).filter(function (heading) {
        if (heading.closest('#toc, .toc, #side-toc-box')) return false;

        var id = getHeadingId(heading);
        var text = getHeadingText(heading);

        if (!id || !text) return false;

        return true;
    });

    var tocKey = headings.map(function (heading) {
        return getHeadingId(heading) + '|' + getHeadingText(heading);
    }).join('||');

    var existingBox = document.getElementById('side-toc-box');

    // 왼쪽 목차: 같은 문서에서 같은 목차를 이미 만들었다면 다시 지우고 만들지 않는다.
    if (existingBox && existingBox.getAttribute('data-toc-key') === tocKey) {
        initTocTitleScroll(existingBox);
        return;
    }

    if (existingBox) {
        existingBox.remove();
    }

    if (!headings.length) return;

    var tocBox = document.createElement('div');
    tocBox.className = 'clbi-left-box';
    tocBox.id = 'side-toc-box';
    tocBox.setAttribute('data-toc-key', tocKey);

    var title = document.createElement('div');
    title.className = 'clbi-left-title';

    // 왼쪽 목차: 박스 제목은 Lang.js의 현재 UI 언어를 따른다.
    var currentLang = getCurrentLang();
    var t = (window.LANG && window.LANG[currentLang]) ? window.LANG[currentLang] : window.LANG.ko;
    var tocTitleText = (t && t.toc) ? t.toc : '목차';

    title.innerHTML =
        '<span class="clbi-icon" style="--icon:var(--ic-ui-002)"></span> ' + tocTitleText;

    var body = document.createElement('div');
    body.className = 'clbi-left-content toc-sidebar-content';

    var list = document.createElement('ul');
    list.className = 'generated-toc';

    headings.forEach(function (heading) {
        var id = getHeadingId(heading);
        var text = getHeadingText(heading);
        var level = heading.tagName.toLowerCase() === 'h3' ? 3 : 2;

        var item = document.createElement('li');
        item.className = 'toc-level-' + level;

        var link = document.createElement('a');
        link.setAttribute('href', '#' + id);

        // 왼쪽 목차: 긴 제목 스크롤을 위해 텍스트를 별도 span으로 감싼다.
        var textWrap = document.createElement('span');
        textWrap.className = 'toc-scroll-wrap';

        var textSpan = document.createElement('span');
        textSpan.className = 'toc-scroll-text';
        textSpan.textContent = text;

        textWrap.appendChild(textSpan);
        link.appendChild(textWrap);

        item.appendChild(link);
        list.appendChild(item);
    });

    body.appendChild(list);
    tocBox.appendChild(title);
    tocBox.appendChild(body);
    leftSidebar.appendChild(tocBox);

    // 왼쪽 목차: DOM 배치가 끝난 뒤 긴 제목 스크롤 여부를 계산한다.
    requestAnimationFrame(function () {
        initTocTitleScroll(tocBox);

        setTimeout(function () {
            initTocTitleScroll(tocBox);
        }, 120);
    });
}

// 초기화 함수
function initSidebars() {
    var header = $('.liberty-content-header');
    var content = $('.liberty-content');

    if (header.length && content.length) {
        header.prependTo(content);
    }

    if ($('#clbi-right-sidebar').length === 0) {
        var username = mw.config.get('wgUserName');
        var isLoggedIn = username !== null;
        var avatarSrc = isLoggedIn
            ? '/index.php?title=특수:Redirect/file/Pfp-' + username + '.png'
            : '/index.php?title=특수:Redirect/file/Pfp-default.png';

        var userBox;
        if (isLoggedIn) {
            userBox =
                '<div class="clbi-right-box">' +
                    '<div style="display:flex;flex-direction:column;align-items:center;padding:14px 14px 10px;background:linear-gradient(to bottom, #171114 0%, #0a0909 100%);">' +
                        '<img src="' + avatarSrc + '" onerror="this.src=\'/index.php?title=특수:Redirect/file/Pfp-default.png\'" style="width:64px;height:64px;border-radius:5px;object-fit:cover;border:2px solid #854369;margin-bottom:8px;">' +
                        '<div style="position:relative;width:100%;margin-top:2px;height:18px;line-height:18px;text-align:center;">' +
                            '<button type="button" id="clbi-playlist-toggle" aria-label="플레이리스트" style="position:absolute;top:0;left:10px;background:none;border:none;padding:0;width:18px;height:18px;color:#E2E2E2;cursor:pointer;display:flex;align-items:center;justify-content:center;">' +
                                '<span class="clbi-icon" style="--icon:var(--ic-ui-009);width:16px;height:16px;"></span>' +
                            '</button>' +
                            '<a href="/index.php/사용자:' + username + '" style="font-size:13px;font-weight:700;color:#E2E2E2 !important;text-decoration:none !important;line-height:18px;display:inline-block;vertical-align:middle;max-width:calc(100% - 58px);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">' + username + '</a>' +
                            '<button type="button" id="clbi-notification-toggle" aria-label="알림" style="position:absolute;top:0;right:10px;background:none;border:none;padding:0;width:18px;height:18px;color:#E2E2E2;cursor:pointer;display:flex;align-items:center;justify-content:center;">' +
                                '<span class="clbi-icon" style="--icon:var(--ic-ui-009);width:16px;height:16px;"></span>' +
                                '<span id="clbi-notification-badge" style="display:none;position:absolute;top:-6px;right:-8px;min-width:14px;height:14px;padding:0 3px;border-radius:999px;background:#854369;color:#fff;font-size:9px;line-height:14px;font-weight:700;text-align:center;"></span>' +
                            '</button>' +
                        '</div>' +
                    '</div>' +
                    '<div class="clbi-right-content" style="border-top:1px solid #2a2a2a;padding:8px;">' +
                        '<a href="/index.php/특수:기여/' + username + '" class="clbi-user-btn" id="clbi-btn-contribution">기여</a>' +
                        '<a href="/index.php/특수:주시문서목록" class="clbi-user-btn" id="clbi-btn-watchlist">주시문서 목록</a>' +
                        '<a href="/index.php/특수:설정" class="clbi-user-btn" id="clbi-btn-preferences">설정</a>' +
                        '<a href="/index.php?title=특수:로그아웃&returnto=대문" class="clbi-user-btn clbi-user-btn-logout" id="clbi-btn-logout">로그아웃</a>' +
                    '</div>' +
                '</div>';
        } else {
            userBox =
                '<div class="clbi-right-box">' +
                    '<div style="display:flex;flex-direction:column;align-items:center;padding:14px 14px 10px;background:linear-gradient(to bottom, #171114 0%, #0a0909 100%);">' +
                        '<img src="/index.php?title=특수:Redirect/file/Pfp-default.png" style="width:64px;height:64px;border-radius:5px;object-fit:cover;border:2px solid #854369;margin-bottom:8px;">' +
                        '<span style="font-size:13px;font-weight:700;color:#E2E2E2;">Guest</span>' +
                    '</div>' +
                    '<div class="clbi-right-content" style="border-top:1px solid #2a2a2a;padding:8px;">' +
                        '<a href="/index.php?title=특수:로그인&returnto=대문" class="clbi-user-btn" id="clbi-btn-login">로그인</a>' +
                    '</div>' +
                '</div>';
        }

        var recentBox =
            '<div class="clbi-right-box">' +
                '<div class="clbi-right-title" id="clbi-title-recent"><span class="clbi-icon" style="--icon:var(--ic-ui-006)"></span> <a href="/index.php/특수:최근바뀜" style="color:#E2E2E2 !important;text-decoration:none !important;">최근 변경</a></div>' +
                '<div class="clbi-right-content" id="clbi-recent-list">불러오는 중...</div>' +
            '</div>';

        var linkBox =
            '<div class="clbi-right-box">' +
                '<div class="clbi-right-title" id="clbi-title-links"><span class="clbi-icon" style="--icon:var(--ic-ui-003)"></span> 링크</div>' +
                '<div class="clbi-right-content clbi-link-box">' +
                    '<ul>' +
                        '<li><a href="https://discord.gg/ctaeJ9d3Q5" target="_blank">Discord</a></li>' +
                        '<li><a href="https://www.youtube.com/@nxdsxn" target="_blank">YouTube</a></li>' +
                        '<li><a href="https://x.com/nxd_sxn" target="_blank">X</a></li>' +
                    '</ul>' +
                '</div>' +
            '</div>';

        var sidebar =
            userBox +
            '<div class="clbi-right-box">' +
                '<div class="clbi-right-title" id="clbi-title-search"><span class="clbi-icon" style="--icon:var(--ic-ui-005)"></span> <a href="/index.php/특수:검색" style="color:#E2E2E2 !important;text-decoration:none !important;">검색</a></div>' +
                '<div class="clbi-right-content">' +
                    '<input id="clbi-search-input" type="text" placeholder="검색...">' +
                    '<button id="clbi-search-btn">GO</button>' +
                '</div>' +
            '</div>' +
            recentBox +
            '<div class="clbi-right-box">' +
                '<div class="clbi-right-title" id="clbi-title-guide"><span class="clbi-icon" style="--icon:var(--ic-ui-007)"></span> 가이드</div>' +
                '<div class="clbi-right-content">' +
                    '<a href="/index.php/CLBI_Wiki/KR_시작하기_(CLBI)" id="clbi-guide-link">시작하기</a>' +
                '</div>' +
            '</div>' +
            linkBox;

        $('.content-wrapper').append('<div id="clbi-right-sidebar">' + sidebar + '</div>');

        $('#clbi-search-btn').click(function() {
            var query = $('#clbi-search-input').val();
            if (query) {
                window.location.href = '/index.php?search=' + encodeURIComponent(query);
            }
        });

        $('#clbi-search-input').keypress(function(e) {
            if (e.which === 13) $('#clbi-search-btn').click();
        });

        $.getJSON(
            '/api.php?action=query&list=recentchanges&rclimit=5&rcprop=title|timestamp&format=json&rcnamespace=0&rctype=edit|new',
            function(data) {
                var items = data.query.recentchanges;
                var html = '';

                $.each(items, function(i, item) {
                    var label = timeAgo(item.timestamp);
                    html +=
                        '<div class="clbi-recent-item">' +
                            '<div class="clbi-recent-title-wrap">' +
                                '<a href="/index.php/' + encodeURIComponent(item.title) + '" class="clbi-recent-title">' + item.title + '</a>' +
                            '</div>' +
                            '<span class="clbi-recent-time">' + label + '</span>' +
                        '</div>';
                });

                $('#clbi-recent-list').html(html);

                $('#clbi-recent-list .clbi-recent-item').each(function() {
                    var wrap = $(this).find('.clbi-recent-title-wrap');
                    var title = $(this).find('.clbi-recent-title');
                    var wrapW = wrap.width();
                    var titleW = title[0].scrollWidth;

                    if (titleW > wrapW + 20) {
                        var duration = titleW / 40;
                        title.css({
                            animation: 'clbi-scroll ' + duration + 's linear infinite',
                            '--scroll-dist': '-' + (titleW - wrapW + 8) + 'px'
                        });
                    }
                });
            }
        ).fail(function() {
            $('#clbi-recent-list').html('불러오기 실패');
        });
    }

    if ($('#clbi-left-sidebar').length === 0) {
        var leftSidebar =
            '<div id="clbi-left-sidebar">' +
                '<div class="clbi-left-box">' +
                    '<div class="clbi-left-title"><span class="clbi-icon" style="--icon:var(--ic-ui-001)"></span> <span id="clbi-title-language">언어</span></div>' +
                    '<div class="clbi-left-content clbi-lang-box" id="clbi-lang-box">' +
                        '<div class="clbi-lang-current" id="clbi-lang-current">한국어</div>' +
                        '<div id="clbi-lang-list" style="display:none;"></div>' +
                    '</div>' +
                '</div>' +
                '<div class="clbi-left-box">' +
                    '<div class="clbi-left-title"><span class="clbi-icon" style="--icon:var(--ic-ui-002)"></span> <span id="clbi-title-categories">카테고리</span></div>' +
                    '<div class="clbi-left-content clbi-cat-box">' +
                        '<div class="clbi-cat-btn" id="clbi-cat-main"><a href="/index.php/대문"><div class="clbi-cat-text"><span class="clbi-cat-label">메인 메뉴</span><span class="clbi-cat-sub">MAIN MENU</span></div></a><div class="clbi-cat-arrow">▶</div></div>' +
                        '<div class="clbi-cat-btn" id="clbi-cat-nations"><a href="/index.php/국가_및_조합"><div class="clbi-cat-text"><span class="clbi-cat-label">국가 및 조합</span><span class="clbi-cat-sub">NATIONS & FACTIONS</span></div></a><div class="clbi-cat-arrow">▶</div></div>' +
                        '<div class="clbi-cat-btn" id="clbi-cat-corporations"><a href="/index.php/기업_및_공동체"><div class="clbi-cat-text"><span class="clbi-cat-label">기업 및 공동체</span><span class="clbi-cat-sub">CORPORATIONS & COMMUNITIES</span></div></a><div class="clbi-cat-arrow">▶</div></div>' +
                        '<div class="clbi-cat-btn" id="clbi-cat-military"><a href="/index.php/군_정치집단"><div class="clbi-cat-text"><span class="clbi-cat-label">군, 정치집단</span><span class="clbi-cat-sub">MILITARY & POLITICS</span></div></a><div class="clbi-cat-arrow">▶</div></div>' +
                        '<div class="clbi-cat-btn" id="clbi-cat-history"><a href="/index.php/역사적_사건"><div class="clbi-cat-text"><span class="clbi-cat-label">역사적 사건</span><span class="clbi-cat-sub">HISTORICAL EVENTS</span></div></a><div class="clbi-cat-arrow">▶</div></div>' +
                        '<div class="clbi-cat-btn" id="clbi-cat-personnel"><a href="/index.php/인물"><div class="clbi-cat-text"><span class="clbi-cat-label">인물</span><span class="clbi-cat-sub">KEY PERSONNEL</span></div></a><div class="clbi-cat-arrow">▶</div></div>' +
                    '</div>' +
                '</div>' +
            '</div>';

        $('.content-wrapper').prepend(leftSidebar);

        // 왼쪽 사이드바가 처음 생성된 직후에도 한 번 실행한다.
        // 이후 언어 갱신과 SPA 이동 시에는 updateSidebar()에서 다시 실행된다.
        updateLeftSidebarNationsImage();

        $(document).on('click', '#clbi-lang-current', function() {
            if ($('#clbi-lang-list').is(':hidden')) {
                $('#clbi-lang-current').css('margin-bottom', '8px');
                $('#clbi-lang-list').slideDown(200);
            } else {
                $('#clbi-lang-list').slideUp(200, function() {
                    $('#clbi-lang-current').css('margin-bottom', '0');
                });
            }
        });
    }

    applyMainPageStyle();
    $('#side-toc-box').remove();

    mw.loader.using(['mediawiki.api']).then(function() {
        setTimeout(function() {
            initNotifications();
            initPlaylistPopup();
            initProfile();
            moveTocToLeftSidebar();
        }, 300);

        setTimeout(moveTocToLeftSidebar, 800);
        setTimeout(moveTocToLeftSidebar, 1500);
    });
}

$(function() {
    loadLangScript(function() {
        setTimeout(function() {
            initSidebars();
        }, 100);
    });
});

// SPA 네비게이션
function shouldSkip(url) {
    return url.match(/action=edit|action=submit|action=history|action=delete|action=protect|action=purge|특수:로그인|특수:로그아웃|Special:UserLogin|Special:UserLogout|특수:사용자정보|특수:비밀번호바꾸기|uselang=/);
}

$(function() {
    if (window._spaInitialized) return;
    window._spaInitialized = true;

    function isInternal(url) {
        var a = document.createElement('a');
        a.href = url;
        return a.hostname === window.location.hostname;
    }

    function loadPage(url) {
        fetch(url)
            .then(function(res) {
                return res.text();
            })
            .then(function(html) {
                var parser = new DOMParser();
                var doc = parser.parseFromString(html, 'text/html');

                var scripts = doc.querySelectorAll('script');
                for (var i = 0; i < scripts.length; i++) {
                    var src = scripts[i].textContent;

                    if (src.indexOf('wgNamespaceNumber') !== -1) {
                        var match = src.match(/"wgNamespaceNumber":(-?\d+)/);
                        if (match) mw.config.set('wgNamespaceNumber', parseInt(match[1], 10));

                        var matchTitle = src.match(/"wgTitle":"([^"]+)"/);
                        if (matchTitle) mw.config.set('wgTitle', matchTitle[1]);

                        var matchPage = src.match(/"wgPageName":"([^"]+)"/);
                        if (matchPage) mw.config.set('wgPageName', matchPage[1]);

                        var matchSpecial = src.match(/"wgCanonicalSpecialPageName":"([^"]+)"/);
                        if (matchSpecial) {
                            mw.config.set('wgCanonicalSpecialPageName', matchSpecial[1]);
                        } else {
                            mw.config.set('wgCanonicalSpecialPageName', false);
                        }
                        break;
                    }
                }

                var newContent = doc.querySelector('.liberty-content-main');
                var newTitle = doc.querySelector('.mw-page-title-main');
                var newHead = doc.querySelector('title');
                var newHeader = doc.querySelector('.liberty-content-header');

                if (newContent) {
                    $('#side-toc-box').remove();
                    $('.liberty-content-main').html(newContent.innerHTML);
                    $('.profile-card').remove();
                    $('body').removeClass('page-loading');
                }

                if (newTitle) {
                    $('.mw-page-title-main').html(newTitle.innerHTML);
                }

                if (newHead) {
                    document.title = newHead.textContent;
                }

                if (newHeader) {
                    $('.liberty-content-header').html(newHeader.innerHTML);
                }

                window.scrollTo(0, 0);
                $('#clbi-lang-list').hide();
                $('#clbi-lang-current').css('margin-bottom', '0');

                mw.hook('wikipage.content').fire($('.liberty-content-main'));
                applyMainPageStyle();

                $('#side-toc-box').remove();
                setTimeout(moveTocToLeftSidebar, 100);
                setTimeout(moveTocToLeftSidebar, 500);
                setTimeout(moveTocToLeftSidebar, 1200);

                mw.loader.using(['mediawiki.api']).then(function() {
                    initProfile();
                    moveTocToLeftSidebar();
                });
            });
    }

// 목차 링크는 전용 처리
$(document).on('click', '#side-toc-box a, #toc a, .toc a', function(e) {
    var href = $(this).attr('href');
    if (!href || href.charAt(0) !== '#') return;

    var rawId = href.slice(1);
    if (!rawId) return;

    var decodedId = rawId;

    try {
        decodedId = decodeURIComponent(rawId);
    } catch (err) {
        decodedId = rawId;
    }

    var target = document.getElementById(decodedId);

    if (!target && window.CSS && CSS.escape) {
        target = document.querySelector('#' + CSS.escape(decodedId));
    }

    if (!target) return;

    e.preventDefault();
    e.stopPropagation();

    var scrollTarget = target.closest('h2, h3') || target;

    scrollTarget.scrollIntoView({
        behavior: 'auto',
        block: 'start'
    });

    history.replaceState(null, '', '#' + rawId);
});

    $(document).on('click', 'a', function(e) {
        var href = $(this).attr('href');
        if (!href) return;

        // 목차 링크는 별도 핸들러에서 처리
        if ($(this).closest('#side-toc-box, #toc, .toc').length) return;

        // 단순 해시 링크는 SPA 가로채기 제외
        if (href.startsWith('#')) return;

        var link = document.createElement('a');
        link.href = href;

        var samePath = decodeURIComponent(link.pathname) === decodeURIComponent(window.location.pathname);
        var sameSearch = (link.search || '') === (window.location.search || '');

        if (link.hash && samePath && sameSearch) return;

        var currentBase = window.location.href.split('#')[0];
        var targetBase = link.href.split('#')[0];

        if (link.hash && currentBase === targetBase) return;

        if (!isInternal(href)) return;
        if (shouldSkip(href)) return;

        e.preventDefault();
        playStaticSound();
        $('body').addClass('page-loading');
        history.pushState(null, '', href);
        loadPage(href);
    });

    window.addEventListener('popstate', function() {
        loadPage(window.location.href);
    });
});

// 시간 계산 함수
function timeAgo(timestamp) {
    var now = new Date();
    var date = new Date(timestamp);
    var diff = Math.floor((now - date) / 1000);

    if (diff < 60) return diff + '초 전';
    if (diff < 3600) return Math.floor(diff / 60) + '분 전';
    if (diff < 86400) return Math.floor(diff / 3600) + '시간 전';
    return Math.floor(diff / 86400) + '일 전';
}

// 펼접 토글
// 펼접 토글
function getFoldTexts() {
    var lang = getCurrentLang();
    return (window.LANG && window.LANG[lang])
        ? window.LANG[lang]
        : (window.LANG ? window.LANG.ko : { expand: '펼치기', collapse: '접기' });
}

function refreshOpenAncestors($start) {
    $start.parents('[id^="collapsible"]').each(function () {
        var $parent = $(this);
        if (!$parent.hasClass('folding-open')) return;

        // 이미 fully open 상태면 굳이 다시 잠그지 않음
        if ($parent.data('fold-state') === 'open') {
            return;
        }

        $parent.css('max-height', this.scrollHeight + 'px');
    });
}

function bindInnerResizeUpdates($target) {
    // 이미지 늦게 로드될 때 높이 갱신
    $target.find('img').off('.foldimg').on('load.foldimg', function () {
        if ($target.hasClass('folding-open')) {
            if ($target.data('fold-state') !== 'open') {
                $target.css('max-height', $target[0].scrollHeight + 'px');
            }
            refreshOpenAncestors($target);
        }
    });
}

function openFold($target, $btn) {
    var t = getFoldTexts();

    $target.data('fold-state', 'opening');
    $target.addClass('folding-open');

    // 열린 뒤 자연 확장 가능하게 만들기 위해 먼저 px로 열기
    $target.css('max-height', '0px');
    $target[0].offsetHeight;
    $target.css('max-height', $target[0].scrollHeight + 'px');

    $btn.text(t.collapse);

    bindInnerResizeUpdates($target);

    // 바깥 펼접 즉시 갱신
    refreshOpenAncestors($target);

    // 전환 끝나면 none으로 풀어서 중첩 펼접/동적 내용 증가를 자연스럽게 허용
    $target.off('transitionend.foldopen').on('transitionend.foldopen', function (e) {
        if (e.target !== this) return;
        if (!$target.hasClass('folding-open')) return;

        $target.css('max-height', 'none');
        $target.data('fold-state', 'open');

        refreshOpenAncestors($target);
    });

    // 늦게 렌더되는 콘텐츠 대응
    requestAnimationFrame(function () {
        if ($target.hasClass('folding-open') && $target.data('fold-state') !== 'open') {
            $target.css('max-height', $target[0].scrollHeight + 'px');
            refreshOpenAncestors($target);
        }
    });

    setTimeout(function () {
        if ($target.hasClass('folding-open') && $target.data('fold-state') !== 'open') {
            $target.css('max-height', $target[0].scrollHeight + 'px');
            refreshOpenAncestors($target);
        }
    }, 80);

    setTimeout(function () {
        if ($target.hasClass('folding-open') && $target.data('fold-state') !== 'open') {
            $target.css('max-height', $target[0].scrollHeight + 'px');
            refreshOpenAncestors($target);
        }
    }, 220);
}

function closeFold($target, $btn) {
    var t = getFoldTexts();

    // none 상태에서 닫으면 transition이 안 되므로 실제 높이로 고정
    if ($target.css('max-height') === 'none' || $target.data('fold-state') === 'open') {
        $target.css('max-height', $target[0].scrollHeight + 'px');
    } else {
        $target.css('max-height', $target[0].scrollHeight + 'px');
    }

    $target.data('fold-state', 'closing');
    $target[0].offsetHeight;
    $target.css('max-height', '0px');
    $target.removeClass('folding-open');

    $btn.text(t.expand);

    refreshOpenAncestors($target);

    setTimeout(function () {
        refreshOpenAncestors($target);
        $target.data('fold-state', 'closed');
    }, 250);
}

$(function () {
    $(document)
        .off('click.clbiToggle')
        .on('click.clbiToggle', '.toggleBtn', function () {
            var $btn = $(this);
            var targetId = $btn.data('target');
            var $target = $('#' + targetId);
            if (!$target.length) return;

            var scrollY = window.scrollY;

            if ($target.hasClass('folding-open')) {
                closeFold($target, $btn);
            } else {
                openFold($target, $btn);
            }

            window.scrollTo(0, scrollY);
        });
});

// ========== 프로필 시스템 ==========
function initProfile() {
    $('.profile-card').remove();

    var ns = mw.config.get('wgNamespaceNumber');
    var title = mw.config.get('wgTitle');

    if (ns === 2) {
        var profileUser = title.split('/')[0];
        renderProfile(profileUser);
    }

    if (mw.config.get('wgCanonicalSpecialPageName') === '사용자정보') {
        initUserProfilePage();
    }
}

function renderProfile(username) {
    var api = new mw.Api();
    api.get({
        action: 'query',
        list: 'users',
        ususers: username,
        usprop: 'editcount'
    }).then(function(data) {
        var user = data.query.users[0];
        var contentEl = document.getElementById('mw-content-text');
        if (!contentEl) return;

        var pageContent = contentEl.querySelector('.mw-parser-output') || contentEl;
        injectProfileCard(username, user, pageContent);
    });
}

function injectProfileCard(username, userData, container) {
    var isOwnPage = mw.config.get('wgUserName') === username;
    var editCount = (userData && userData.editcount) ? userData.editcount : 0;
    var editBtn = isOwnPage
        ? '<a href="/index.php/특수:사용자정보" class="profile-edit-btn">프로필 수정</a>'
        : '';

    var card = document.createElement('div');
    card.className = 'profile-card';
    card.innerHTML =
        '<div class="profile-header">' +
            '<div class="profile-avatar">' +
                '<img src="/index.php?title=특수:Redirect/file/Pfp-' + username + '.png&width=120"' +
                ' onerror="this.src=\'/index.php?title=특수:Redirect/file/Pfp-default.png&width=120\'"' +
                ' alt="' + username + '">' +
            '</div>' +
            '<div class="profile-info">' +
                '<h2 class="profile-username">' + username + '</h2>' +
                '<div class="profile-name" data-field="name"></div>' +
                '<div class="profile-role" data-field="role"></div>' +
                '<div class="profile-discord" data-field="discord"></div>' +
                '<div class="profile-bio" data-field="bio"></div>' +
                '<div class="profile-badges" data-field="badges"></div>' +
            '</div>' +
            editBtn +
        '</div>' +
        '<div class="profile-stats">' +
            '<div class="profile-stat">' +
                '<span class="clbi-stat-value">' + editCount + '</span>' +
                '<span class="clbi-stat-label">수정 횟수</span>' +
            '</div>' +
        '</div>';

    $('.profile-card').remove();
    container.insertBefore(card, container.firstChild);
    loadProfileFields(username, card);
}

function loadProfileFields(username, card) {
    var api = new mw.Api();
    api.get({
        action: 'userprofile',
        user: username
    }).then(function(data) {
        var profile = data.userprofile;
        updateProfileFields(card, {
            name: profile.name || '',
            discord: profile.discord || '',
            role: profile.role || '',
            bio: profile.bio || '',
            badges: profile.badges || ''
        });
    }).fail(function() {
        updateProfileFields(card, {
            name: '',
            discord: '',
            role: '',
            bio: '',
            badges: ''
        });
    });
}

function updateProfileFields(card, data) {
    var nameEl = card.querySelector('[data-field="name"]');
    var roleEl = card.querySelector('[data-field="role"]');
    var discordEl = card.querySelector('[data-field="discord"]');
    var bioEl = card.querySelector('[data-field="bio"]');
    var badgesEl = card.querySelector('[data-field="badges"]');

    if (nameEl) nameEl.textContent = data.name || '';
    if (roleEl) roleEl.textContent = data.role || '';
    if (discordEl) discordEl.textContent = data.discord ? ('디스코드: ' + data.discord) : '';
    if (bioEl) bioEl.textContent = data.bio || '';

    if (badgesEl) {
        if (data.badges) {
            var badges = data.badges.split(',');
            var html = '';

            for (var i = 0; i < badges.length; i++) {
                html += '<span class="clbi-badge">' + badges[i].trim() + '</span>';
            }

            badgesEl.innerHTML = html;
        } else {
            badgesEl.innerHTML = '';
        }
    }
}
// ========== 프로필 시스템 끝 ==========

// ========== 알림 시스템 ==========
function ensureNotificationPopup() {
    if (document.getElementById('clbi-notification-popup')) return;

    var popup = document.createElement('div');
    popup.id = 'clbi-notification-popup';
    popup.style.cssText =
        'display:none;position:fixed;z-index:99999;width:320px;max-height:420px;' +
        'background:#0a0909;border:2px solid #854369;border-radius:10px;' +
        'box-shadow:0 0 0 1px #1a1a1a, 0 8px 24px rgba(0,0,0,0.55);overflow:hidden;';

    popup.innerHTML =
        '<div style="padding:10px 12px;border-bottom:2px solid #854369;background:linear-gradient(to bottom, #171114 0%, #0a0909 100%);color:#E2E2E2;font-size:13px;font-weight:700;display:flex;align-items:center;justify-content:space-between;gap:8px;">' +
            '<span>알림</span>' +
            '<button type="button" id="clbi-notification-readall" style="background:#171717;border:1px solid #854369;border-radius:6px;color:#E2E2E2;font-size:11px;font-weight:700;padding:4px 8px;cursor:pointer;">전체 읽음</button>' +
        '</div>' +
        '<div id="clbi-notification-list" style="max-height:320px;overflow-y:auto;padding:8px 0;color:#E2E2E2;font-size:12px;">불러오는 중...</div>' +
        '<div style="padding:8px;border-top:1px solid #2a2a2a;background:#111;">' +
            '<a href="/index.php?title=Special:Notifications" id="clbi-notification-more" style="display:block;width:100%;text-align:center;padding:8px 10px;border-radius:6px;background:#171717;border:1px solid #854369;color:#E2E2E2 !important;text-decoration:none !important;font-size:12px;font-weight:700;">더보기</a>' +
        '</div>';

    document.body.appendChild(popup);
}

function positionNotificationPopup() {
    var btn = document.getElementById('clbi-notification-toggle');
    var popup = document.getElementById('clbi-notification-popup');
    if (!btn || !popup) return;

    var rect = btn.getBoundingClientRect();
    var top = rect.bottom + 2;
    var left = rect.right - popup.offsetWidth;

    if (left < 8) left = 8;
    if (left + popup.offsetWidth > window.innerWidth - 8) {
        left = window.innerWidth - popup.offsetWidth - 8;
    }
    if (top + popup.offsetHeight > window.innerHeight - 8) {
        top = Math.max(8, window.innerHeight - popup.offsetHeight - 8);
    }

    popup.style.top = top + 'px';
    popup.style.left = left + 'px';
}

function parseNotificationItemsFromHtml(html) {
    var parser = new DOMParser();
    var doc = parser.parseFromString(html, 'text/html');

    var selectors = [
        '.mw-echo-ui-notificationItemWidget',
        '.mw-echo-ui-notificationsInboxWidgetRow',
        '.echo-ui-notificationItemWidget',
        'li[data-notification-id]',
        '.mw-echo-notifications-list li'
    ];

    var items = [];
    for (var i = 0; i < selectors.length; i++) {
        items = Array.prototype.slice.call(doc.querySelectorAll(selectors[i]));
        if (items.length) break;
    }

    return items.slice(0, 5).map(function(item) {
        var link = item.querySelector('a[href]');
        var href = link ? link.getAttribute('href') : '/index.php?title=Special:Notifications';
        var text = (item.textContent || '').replace(/\s+/g, ' ').trim();

        var notificationId =
            item.getAttribute('data-notification-id') ||
            item.getAttribute('data-id') ||
            item.getAttribute('data-notification') ||
            '';

        if (!notificationId) {
            var anyWithId = item.querySelector('[data-notification-id], [data-id], [data-notification]');
            if (anyWithId) {
                notificationId =
                    anyWithId.getAttribute('data-notification-id') ||
                    anyWithId.getAttribute('data-id') ||
                    anyWithId.getAttribute('data-notification') ||
                    '';
            }
        }

        if (href && href.indexOf('http') !== 0) {
            href = href.charAt(0) === '/'
                ? href
                : '/index.php' + (href.charAt(0) === '?' ? href : '/' + href);
        }

        return {
            id: notificationId,
            href: href,
            text: text || '알림'
        };
    });
}

function renderNotificationPopup(items) {
    var list = document.getElementById('clbi-notification-list');
    var badge = document.getElementById('clbi-notification-badge');
    if (!list) return;

    if (!items || !items.length) {
        list.innerHTML = '<div style="padding:14px 12px;color:#999;">표시할 알림이 없습니다.</div>';
        if (badge) badge.style.display = 'none';
        return;
    }

    var html = '';
    for (var i = 0; i < items.length; i++) {
        html +=
            '<a href="' + items[i].href + '" class="clbi-notification-item" data-notification-id="' + (items[i].id || '') + '" style="display:block;padding:10px 12px;color:#E2E2E2 !important;text-decoration:none !important;border-bottom:1px solid #1f1f1f;line-height:1.5;">' +
                items[i].text +
            '</a>';
    }
    list.innerHTML = html;

    if (badge) {
        badge.textContent = items.length;
        badge.style.display = 'block';
    }
}

function loadNotificationsIntoPopup() {
    var list = document.getElementById('clbi-notification-list');
    if (list) {
        list.innerHTML = '<div style="padding:14px 12px;color:#999;">불러오는 중...</div>';
    }

    fetch('/index.php?title=Special:Notifications', { credentials: 'same-origin' })
        .then(function(res) {
            return res.text();
        })
        .then(function(html) {
            var items = parseNotificationItemsFromHtml(html);
            renderNotificationPopup(items);
        })
        .catch(function(err) {
            console.error(err);
            if (list) {
                list.innerHTML = '<div style="padding:14px 12px;color:#999;">알림을 불러오지 못했습니다.</div>';
            }
        });
}

function markAllNotificationsRead() {
    return new mw.Api().postWithToken('csrf', {
        action: 'echomarkread',
        list: 'all'
    });
}

function markNotificationReadById(notificationId) {
    if (!notificationId) {
        return $.Deferred().resolve().promise();
    }

    return new mw.Api().postWithToken('csrf', {
        action: 'echomarkread',
        list: notificationId
    });
}

function initNotifications() {
    var btn = document.getElementById('clbi-notification-toggle');
    if (!btn) return;

    ensureNotificationPopup();
    loadNotificationsIntoPopup();

    $(document)
        .off('click.clbiNotificationToggle')
        .on('click.clbiNotificationToggle', '#clbi-notification-toggle', function(e) {
            e.preventDefault();
            e.stopPropagation();

            var popup = document.getElementById('clbi-notification-popup');
            var playlistPopup = document.getElementById('clbi-playlist-popup');
            if (!popup) return;

            if (playlistPopup) {
                playlistPopup.style.display = 'none';
            }

            if (popup.style.display === 'none' || popup.style.display === '') {
                popup.style.display = 'block';
                positionNotificationPopup();
                loadNotificationsIntoPopup();
            } else {
                popup.style.display = 'none';
            }
        });

    $(document)
        .off('click.clbiNotificationOutside')
        .on('click.clbiNotificationOutside', function(e) {
            var popup = document.getElementById('clbi-notification-popup');
            var toggle = document.getElementById('clbi-notification-toggle');
            if (!popup || !toggle) return;

            if (!popup.contains(e.target) && !toggle.contains(e.target)) {
                popup.style.display = 'none';
            }
        });

    $(document)
        .off('click.clbiNotificationReadAll')
        .on('click.clbiNotificationReadAll', '#clbi-notification-readall', function(e) {
            e.preventDefault();
            e.stopPropagation();

            var button = this;
            button.disabled = true;
            button.textContent = '처리 중...';

            markAllNotificationsRead()
                .then(function() {
                    loadNotificationsIntoPopup();
                })
                .always(function() {
                    button.disabled = false;
                    button.textContent = '전체 읽음';
                });
        });

    $(document)
        .off('click.clbiNotificationItem')
        .on('click.clbiNotificationItem', '.clbi-notification-item', function(e) {
            e.preventDefault();
            e.stopPropagation();

            var href = this.getAttribute('href');
            var notificationId = this.getAttribute('data-notification-id') || '';

            markNotificationReadById(notificationId).always(function() {
                loadNotificationsIntoPopup();
                if (href) {
                    window.location.href = href;
                }
            });
        });

    $(window)
        .off('resize.clbiNotification')
        .on('resize.clbiNotification', function() {
            var popup = document.getElementById('clbi-notification-popup');
            if (popup && popup.style.display === 'block') {
                positionNotificationPopup();
            }
        });
}
// ========== 알림 시스템 끝 ==========

// ========== 플레이리스트 팝업 시스템 ==========
function ensurePlaylistPopup() {
    if (document.getElementById('clbi-playlist-popup')) return;

    var popup = document.createElement('div');
    popup.id = 'clbi-playlist-popup';
    popup.style.cssText =
        'display:none;position:fixed;z-index:99999;width:690px;height:540px;' +
        'background:#0a0909;border:2px solid #854369;border-radius:10px;' +
        'box-shadow:0 0 0 1px #1a1a1a, 0 8px 24px rgba(0,0,0,0.55);overflow:hidden;';

    popup.innerHTML =
        '<div style="padding:10px 12px;border-bottom:2px solid #854369;background:linear-gradient(to bottom, #171114 0%, #0a0909 100%);color:#E2E2E2;font-size:13px;font-weight:700;">' +
            '<span>플레이리스트</span>' +
        '</div>' +
        '<div style="padding:0;background:#111;height:calc(100% - 42px);">' +
            '<iframe style="border:none;width:100%;height:100%;" src="https://open.spotify.com/embed/playlist/32l4ke6djdQn8LoBp1ipR9?utm_source=generator&theme=0" allowfullscreen allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture" loading="lazy"></iframe>' +
        '</div>';

    document.body.appendChild(popup);
}

function positionPlaylistPopup() {
    var btn = document.getElementById('clbi-playlist-toggle');
    var popup = document.getElementById('clbi-playlist-popup');
    if (!btn || !popup) return;

    var rect = btn.getBoundingClientRect();
    var top = rect.bottom + 2;
    var left = rect.right - popup.offsetWidth;

    if (left < 8) left = 8;
    if (left + popup.offsetWidth > window.innerWidth - 8) {
        left = window.innerWidth - popup.offsetWidth - 8;
    }
    if (top + popup.offsetHeight > window.innerHeight - 8) {
        top = Math.max(8, window.innerHeight - popup.offsetHeight - 8);
    }

    popup.style.top = top + 'px';
    popup.style.left = left + 'px';
}

function initPlaylistPopup() {
    var btn = document.getElementById('clbi-playlist-toggle');
    if (!btn) return;

    ensurePlaylistPopup();

    $(document)
        .off('click.clbiPlaylistToggle')
        .on('click.clbiPlaylistToggle', '#clbi-playlist-toggle', function(e) {
            e.preventDefault();
            e.stopPropagation();

            var popup = document.getElementById('clbi-playlist-popup');
            var notificationPopup = document.getElementById('clbi-notification-popup');
            if (!popup) return;

            if (notificationPopup) {
                notificationPopup.style.display = 'none';
            }

            if (popup.style.display === 'none' || popup.style.display === '') {
                popup.style.display = 'block';
                positionPlaylistPopup();
            } else {
                popup.style.display = 'none';
            }
        });

    $(document)
        .off('click.clbiPlaylistOutside')
        .on('click.clbiPlaylistOutside', function(e) {
            var popup = document.getElementById('clbi-playlist-popup');
            var toggle = document.getElementById('clbi-playlist-toggle');
            if (!popup || !toggle) return;

            if (!popup.contains(e.target) && !toggle.contains(e.target)) {
                popup.style.display = 'none';
            }
        });

    $(window)
        .off('resize.clbiPlaylist')
        .on('resize.clbiPlaylist', function() {
            var popup = document.getElementById('clbi-playlist-popup');
            if (popup && popup.style.display === 'block') {
                positionPlaylistPopup();
            }
        });
}
// ========== 플레이리스트 팝업 시스템 끝 ==========

function initUserProfilePage() {
    var saveBtn = document.getElementById('pref-save');
    if (!saveBtn) return;

    var adminEl = document.getElementById('pref-is-admin');
    var isAdmin = adminEl && adminEl.getAttribute('data-admin') === '1';
    var api = new mw.Api();
    var selectedFile = null;
    var cropper = null;

    if (!document.getElementById('clbi-gallery-modal')) {
        var gModal = document.createElement('div');
        gModal.id = 'clbi-gallery-modal';
        gModal.style.cssText =
            'display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.85);z-index:99999;align-items:center;justify-content:center;';

        gModal.innerHTML =
            '<div style="background:#1e1e1e;border:2px solid #854369;border-radius:12px;padding:24px;max-width:480px;width:90%;display:flex;flex-direction:column;gap:16px;">' +
                '<div style="display:flex;justify-content:space-between;align-items:center;">' +
                    '<span style="font-size:14px;font-weight:700;color:#e2e2e2;">프로필 사진 선택</span>' +
                    '<button type="button" id="clbi-gallery-close" style="background:none;border:none;color:#aaa;font-size:18px;cursor:pointer;">✕</button>' +
                '</div>' +
                '<button type="button" id="clbi-gallery-upload-btn" style="background:#2a2a2a;border:2px dashed #854369;border-radius:8px;padding:32px;color:#e2e2e2;cursor:pointer;display:flex;flex-direction:column;align-items:center;gap:8px;font-size:13px;width:100%;">' +
                    '<span style="font-size:32px;">🖼️</span>새 사진 업로드' +
                '</button>' +
                '<div id="clbi-gallery-history-section" style="display:none;">' +
                    '<div style="font-size:11px;color:#888;margin-bottom:8px;">이전 사진 — 클릭하면 바로 적용</div>' +
                    '<div id="clbi-gallery-history" style="display:flex;gap:8px;flex-wrap:wrap;"></div>' +
                '</div>' +
            '</div>';

        document.body.appendChild(gModal);
    }

    if (!document.getElementById('clbi-crop-modal')) {
        var cModal = document.createElement('div');
        cModal.id = 'clbi-crop-modal';
        cModal.style.cssText =
            'display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.85);z-index:99999;align-items:center;justify-content:center;';

        cModal.innerHTML =
            '<div style="background:#1e1e1e;border:2px solid #854369;border-radius:12px;padding:24px;max-width:500px;width:90%;display:flex;flex-direction:column;gap:16px;">' +
                '<div style="font-size:14px;font-weight:700;color:#e2e2e2;">사진 조정</div>' +
                '<div style="width:100%;max-height:380px;overflow:hidden;border-radius:8px;">' +
                    '<img id="clbi-crop-image" style="max-width:100%;">' +
                '</div>' +
                '<div style="display:flex;gap:8px;justify-content:flex-end;">' +
                    '<button type="button" id="clbi-crop-cancel" style="background:#2a2a2a;color:#e2e2e2;border:1px solid #444;padding:8px 16px;border-radius:6px;cursor:pointer;">취소</button>' +
                    '<button type="button" id="clbi-crop-confirm" style="background:#854369;color:#fff;border:none;padding:8px 16px;border-radius:6px;cursor:pointer;">확정</button>' +
                '</div>' +
            '</div>';

        document.body.appendChild(cModal);
    }

    var gModal = document.getElementById('clbi-gallery-modal');
    var cModal = document.getElementById('clbi-crop-modal');
    var cropImage = document.getElementById('clbi-crop-image');
    var pfpInput = document.getElementById('pref-pfp-input');

    function openGallery() {
        gModal.style.display = 'flex';

        var username = mw.config.get('wgUserName');
        api.get({
            action: 'query',
            titles: '파일:Pfp-' + username + '.png',
            prop: 'imageinfo',
            iiprop: 'url|timestamp',
            iilimit: 6
        }).then(function(data) {
            var pages = data.query.pages;
            var page = pages[Object.keys(pages)[0]];
            if (!page.imageinfo || page.imageinfo.length === 0) return;

            var historyEl = document.getElementById('clbi-gallery-history');
            var sectionEl = document.getElementById('clbi-gallery-history-section');
            historyEl.innerHTML = '';

            page.imageinfo.forEach(function(info, idx) {
                var wrap = document.createElement('div');
                wrap.style.cssText = 'position:relative;cursor:pointer;';

                var img = document.createElement('img');
                img.src = info.url;
                img.style.cssText =
                    'width:72px;height:72px;object-fit:cover;border-radius:8px;border:2px solid #444;flex-shrink:0;';

                if (idx === 0) {
                    img.style.borderColor = '#854369';
                    var badge = document.createElement('div');
                    badge.textContent = '현재';
                    badge.style.cssText =
                        'position:absolute;bottom:4px;left:50%;transform:translateX(-50%);background:#854369;color:#fff;font-size:9px;padding:1px 6px;border-radius:10px;';
                    wrap.appendChild(badge);
                }

                img.addEventListener('mouseenter', function() {
                    if (idx !== 0) img.style.borderColor = '#854369';
                });

                img.addEventListener('mouseleave', function() {
                    if (idx !== 0) img.style.borderColor = '#444';
                });

                img.addEventListener('click', function() {
                    fetch(info.url)
                        .then(function(r) {
                            return r.blob();
                        })
                        .then(function(blob) {
                            selectedFile = new File([blob], 'profile.png', { type: 'image/png' });
                            document.getElementById('pref-pfp-preview').src = URL.createObjectURL(blob);
                            gModal.style.display = 'none';
                            document.getElementById('pref-pfp-btn').textContent = '✓ 사진 선택됨';
                        });
                });

                wrap.appendChild(img);
                historyEl.appendChild(wrap);
            });

            sectionEl.style.display = 'block';
        });
    }

    function openCrop(src) {
        cropImage.src = src;
        cModal.style.display = 'flex';

        if (cropper) {
            cropper.destroy();
            cropper = null;
        }

        setTimeout(function() {
            cropper = new Cropper(cropImage, {
                aspectRatio: 1,
                viewMode: 1,
                dragMode: 'move',
                autoCropArea: 0.8,
                cropBoxResizable: true,
                cropBoxMovable: true
            });
        }, 150);
    }

    document.getElementById('pref-pfp-btn').addEventListener('click', function() {
        openGallery();
    });

    document.getElementById('clbi-gallery-upload-btn').addEventListener('click', function() {
        pfpInput.click();
    });

    document.getElementById('clbi-gallery-close').addEventListener('click', function() {
        gModal.style.display = 'none';
    });

    pfpInput.addEventListener('change', function() {
        var file = this.files[0];
        if (!file) return;

        gModal.style.display = 'none';

        var reader = new FileReader();
        reader.onload = function(e) {
            openCrop(e.target.result);
        };
        reader.readAsDataURL(file);
    });

    document.getElementById('clbi-crop-cancel').addEventListener('click', function() {
        cModal.style.display = 'none';
        if (cropper) {
            cropper.destroy();
            cropper = null;
        }
        pfpInput.value = '';
    });

    document.getElementById('clbi-crop-confirm').addEventListener('click', function() {
        if (!cropper) return;

        var canvas = cropper.getCroppedCanvas({ width: 256, height: 256 });
        if (!canvas) return;

        canvas.toBlob(function(blob) {
            selectedFile = new File([blob], 'profile.png', { type: 'image/png' });
            document.getElementById('pref-pfp-preview').src = URL.createObjectURL(blob);
            cModal.style.display = 'none';
            cropper.destroy();
            cropper = null;
            document.getElementById('pref-pfp-btn').textContent = '✓ 사진 선택됨';
        }, 'image/png');
    });

    var emailSaveBtn = document.getElementById('pref-email-save');
    if (emailSaveBtn) {
        emailSaveBtn.addEventListener('click', function() {
            var statusEl = document.getElementById('pref-email-status');
            var newEmail = document.getElementById('pref-new-email').value;
            var password = document.getElementById('pref-email-password').value;

            if (!newEmail || !password) {
                statusEl.textContent = '이메일과 비밀번호를 입력해주세요.';
                return;
            }

            statusEl.textContent = '변경 중...';

            api.postWithToken('csrf', {
                action: 'changeemail',
                email: newEmail,
                password: password
            }).then(function() {
                statusEl.textContent = '✓ 이메일 변경됨';
                document.getElementById('pref-new-email').value = '';
                document.getElementById('pref-email-password').value = '';

                setTimeout(function() {
                    statusEl.textContent = '';
                }, 3000);
            }).fail(function(code, data) {
                var msg = data && data.error && data.error.info ? data.error.info : '변경 실패';
                statusEl.textContent = msg;
            });
        });
    }

    saveBtn.addEventListener('click', function() {
        var statusEl = document.getElementById('pref-status');
        statusEl.textContent = '저장 중...';

        var promises = [];

        if (selectedFile) {
            var username = mw.config.get('wgUserName');
            promises.push(
                api.postWithToken('csrf', {
                    action: 'upload',
                    filename: 'Pfp-' + username + '.png',
                    ignorewarnings: true,
                    file: selectedFile,
                    format: 'json'
                }, {
                    contentType: 'multipart/form-data'
                })
            );
        }

        var fields = ['name', 'discord', 'role', 'bio'];
        if (isAdmin) fields.push('badges');

        for (var i = 0; i < fields.length; i++) {
            var el = document.getElementById('pref-' + fields[i]);
            if (!el) continue;

            promises.push(
                api.postWithToken('csrf', {
                    action: 'options',
                    optionname: 'profile-' + fields[i],
                    optionvalue: el.value
                })
            );
        }

        $.when.apply($, promises)
            .then(function() {
                statusEl.textContent = '✓ 저장됨';
                selectedFile = null;
                document.getElementById('pref-pfp-btn').textContent = '사진 선택';

                setTimeout(function() {
                    statusEl.textContent = '';
                }, 2000);
            })
            .fail(function() {
                statusEl.textContent = '저장 실패';
            });
    });
}

/* =========================================
   Banner / CRT Page Monitor thumbnail slices
   - base 이미지는 틀에 들어간 파일 문법 그대로 사용
   - slice 레이어에는 300px MediaWiki 썸네일만 삽입
   ========================================= */

(function ($, mw) {
    var thumbCache = {};

    function parseSliceWidth(value) {
        var parsed = parseInt(value, 10);

        if (!isFinite(parsed) || parsed < 120) {
            return 300;
        }

        return parsed;
    }

    function getImageSrc(img) {
        return img ? (img.currentSrc || img.getAttribute('src') || img.src || '') : '';
    }

    function getFileNameFromSrc(src) {
        var a;
        var parts;
        var fileName;

        if (!src) return '';

        a = document.createElement('a');
        a.href = src;

        parts = (a.pathname || '').split('/').filter(function (part) {
            return !!part;
        });

        if (!parts.length) return '';

        fileName = parts.pop();

        /*
         * MediaWiki thumb URL 예시:
         * /images/thumb/a/ab/File.png/1000px-File.png
         * /images/thumb/a/ab/File.svg/1000px-File.svg.png
         *
         * 이 경우 실제 파일명은 마지막 조각이 아니라 그 앞 조각이다.
         */
        if (/^\d+px-/.test(fileName) && parts.length) {
            fileName = parts.pop();
        }

        fileName = fileName.replace(/^\d+px-/, '');

        try {
            fileName = decodeURIComponent(fileName);
        } catch (e) {}

        return fileName;
    }

    function resolveThumbUrl(img, width, callback) {
        var src = getImageSrc(img);
        var fileName = getFileNameFromSrc(src);
        var cacheKey;
        var entry;

        if (!src) return;

        if (!fileName || !mw || !mw.loader) {
            callback(src);
            return;
        }

        cacheKey = fileName + '|' + width;
        entry = thumbCache[cacheKey];

        if (entry) {
            if (entry.resolved) {
                callback(entry.url || src);
            } else {
                entry.callbacks.push(callback);
            }
            return;
        }

        entry = {
            resolved: false,
            url: '',
            callbacks: [callback]
        };

        thumbCache[cacheKey] = entry;

        function finish(url) {
            var callbacks = entry.callbacks.slice();
            var i;

            entry.resolved = true;
            entry.url = url || src;
            entry.callbacks = [];

            for (i = 0; i < callbacks.length; i++) {
                callbacks[i](entry.url);
            }
        }

        mw.loader.using('mediawiki.api').done(function () {
            var api = new mw.Api();

            api.get({
                action: 'query',
                titles: 'File:' + fileName,
                prop: 'imageinfo',
                iiprop: 'url',
                iiurlwidth: width,
                formatversion: 2
            }).done(function (data) {
                var page;
                var info;

                if (
                    data &&
                    data.query &&
                    data.query.pages &&
                    data.query.pages.length
                ) {
                    page = data.query.pages[0];

                    if (
                        page &&
                        page.imageinfo &&
                        page.imageinfo.length
                    ) {
                        info = page.imageinfo[0];
                    }
                }

                finish((info && (info.thumburl || info.url)) || src);
            }).fail(function () {
                finish(src);
            });
        }).fail(function () {
            finish(src);
        });
    }

    function applySliceImages(frame, thumbUrl) {
        var slices;
        var i;
        var img;

        if (!frame || !thumbUrl) return;

        slices = frame.querySelectorAll('.crt-page-monitor-slice');

        for (i = 0; i < slices.length; i++) {
            slices[i].innerHTML = '';

            img = document.createElement('img');
            img.className = 'crt-page-monitor-slice-img';
            img.src = thumbUrl;
            img.alt = '';
            img.decoding = 'async';
            img.loading = 'eager';
            img.setAttribute('aria-hidden', 'true');

            slices[i].appendChild(img);
        }

        frame.setAttribute('data-crt-slices-ready', '1');
    }

    function initBannerFrame(frame) {
        var baseImg;
        var width;

        if (!frame) return;
        if (frame.getAttribute('data-crt-slices-ready') === '1') return;

        baseImg = frame.querySelector('.crt-page-monitor-image-base img');

        if (!baseImg) return;

        width = parseSliceWidth(frame.getAttribute('data-crt-slice-width'));

        resolveThumbUrl(baseImg, width, function (thumbUrl) {
            if (!frame || !frame.parentNode) return;
            applySliceImages(frame, thumbUrl);
        });
    }

    function initBannerFrames(root) {
        var scope = root && root.querySelectorAll ? root : document;
        var frames = scope.querySelectorAll('.crt-page-monitor-frame');
        var i;

        for (i = 0; i < frames.length; i++) {
            initBannerFrame(frames[i]);
        }
    }

    $(function () {
        initBannerFrames(document);
    });

    if (mw && mw.hook) {
        mw.hook('wikipage.content').add(function ($content) {
            initBannerFrames($content && $content[0] ? $content[0] : document);
        });
    }
})(jQuery, window.mw);

/* =========================================
   Doc Tab System — Q/E 단축키 탭 전환
   글리치 플리커 + RGB split + 방향 슬라이드
   ========================================= */

(function () {
    'use strict';

var keydownBound = false;

    function initDocTabs() {
        var tabBars = document.querySelectorAll('.doc-tab-bar');
        if (!tabBars.length) return;

        tabBars.forEach(function (bar) {
            if (bar.getAttribute('data-tabs-init')) return;
            bar.setAttribute('data-tabs-init', '1');

            var tabs = Array.from(bar.querySelectorAll('.doc-tab'));
            if (!tabs.length) return;

            var panel = bar.closest('.doc-panel');
            var display = panel ? panel.querySelector('.doc-display') : null;
            if (!display) display = document.getElementById('doc-main-display');
            if (!display) return;

            tabs.forEach(function (tab, i) {
                tab.addEventListener('click', function () {
                    var currentIdx = tabs.findIndex(function (t) {
                        return t.classList.contains('active');
                    });
                    if (currentIdx === i) return;
                    switchTab(tabs, display, i, i > currentIdx ? 1 : -1);
                });
            });

            var initIdx = tabs.findIndex(function (t) { return t.classList.contains('active'); });
            if (initIdx !== -1) {
                var initRef = tabs[initIdx].dataset.ref;
                var initEl = initRef ? document.getElementById(initRef) : null;
                display.innerHTML = initEl ? initEl.innerHTML : (tabs[initIdx].dataset.content || '');
            }
        });

        if (!keydownBound) {
            keydownBound = true;
            document.addEventListener('keydown', function (e) {
                var tag = document.activeElement.tagName;
                if (tag === 'INPUT' || tag === 'TEXTAREA') return;

                var bar = document.querySelector('.doc-tab-bar');
                if (!bar) return;
                var tabs = Array.from(bar.querySelectorAll('.doc-tab'));

                var panel = bar.closest('.doc-panel');
                var display = panel ? panel.querySelector('.doc-display') : null;
                if (!display) display = document.getElementById('doc-main-display');
                if (!display) return;

                var activeIdx = tabs.findIndex(function (t) {
                    return t.classList.contains('active');
                });
                if (activeIdx === -1) return;

                if (e.key === 'q' || e.key === 'Q') {
                    e.preventDefault();
                    var prev = (activeIdx - 1 + tabs.length) % tabs.length;
                    if (prev !== activeIdx) switchTab(tabs, display, prev, -1);
                } else if (e.key === 'e' || e.key === 'E') {
                    e.preventDefault();
                    var next = (activeIdx + 1) % tabs.length;
                    if (next !== activeIdx) switchTab(tabs, display, next, 1);
                }
            });
        }
    }

    var isAnimating = false;

    function switchTab(tabs, display, nextIdx, dir) {
        if (isAnimating) return;
        isAnimating = true;

        tabs.forEach(function (t) { t.classList.remove('active'); });
        tabs[nextIdx].classList.add('active');

        var ref = tabs[nextIdx].dataset.ref;
        var nextContent;
        if (ref) {
            var refEl = document.getElementById(ref);
            nextContent = refEl ? refEl.innerHTML : '';
        } else {
            nextContent = tabs[nextIdx].dataset.content || '';
        }

        glitchOut(display, dir, function () {
            display.innerHTML = nextContent;
            glitchIn(display, dir, function () {
                isAnimating = false;
            });
        });
    }

    function glitchOut(el, dir, cb) {
        var duration = 160;
        var start = null;
        var slideX = dir * 16;

        function step(ts) {
            if (!start) start = ts;
            var p = Math.min((ts - start) / duration, 1);
            var ease = p * p;

            var tx = slideX * ease;
            var skew = dir * ease * 1.0;
            var opacity = 1 - ease;
            var rgb = ease * 5;

            el.style.transform = 'translateX(' + tx + 'px) skewX(' + skew + 'deg)';
            el.style.opacity = opacity;
            el.style.filter =
                'drop-shadow(' + (-rgb) + 'px 0 0 rgba(80,160,255,0.75)) ' +
                'drop-shadow(' + rgb + 'px 0 0 rgba(255,55,90,0.65)) ' +
                'brightness(' + (1 + ease * 0.25) + ')';

            if (p < 1) {
                requestAnimationFrame(step);
            } else {
                el.style.opacity = '0';
                cb();
            }
        }
        requestAnimationFrame(step);
    }

    function glitchIn(el, dir, cb) {
        var duration = 200;
        var start = null;
        var startX = -dir * 16;

        el.style.transform = 'translateX(' + startX + 'px) skewX(' + (-dir * 1.0) + 'deg)';
        el.style.opacity = '0';

        function step(ts) {
            if (!start) start = ts;
            var p = Math.min((ts - start) / duration, 1);
            var ease = 1 - Math.pow(1 - p, 3);

            var tx = startX * (1 - ease);
            var skew = -dir * 1.0 * (1 - ease);
            var opacity = ease;
            var rgb = (1 - ease) * 3;
            var brightness = 1 + (1 - ease) * 0.35;

            el.style.transform = 'translateX(' + tx + 'px) skewX(' + skew + 'deg)';
            el.style.opacity = opacity;
            el.style.filter =
                'drop-shadow(' + (-rgb) + 'px 0 0 rgba(80,160,255,0.65)) ' +
                'drop-shadow(' + rgb + 'px 0 0 rgba(255,55,90,0.55)) ' +
                'brightness(' + brightness + ')';

            if (p < 1) {
                requestAnimationFrame(step);
            } else {
                el.style.transform = '';
                el.style.opacity = '';
                el.style.filter = '';
                cb();
            }
        }
        requestAnimationFrame(step);
    }

if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', initDocTabs);
} else {
    initDocTabs();
}

if (typeof mw !== 'undefined' && mw.hook) {
    mw.hook('wikipage.content').add(function () {
        initDocTabs();
    });
}

})();

/* =========================================
   Doc Section Switch — 좌측 섹션 전환
   ========================================= */

$(document).on('click', '.doc-nav-item[data-section]', function () {
    var name = $(this).attr('data-section');
    var display = document.getElementById('doc-main-display');
    var titleEl = document.getElementById('doc-center-title');
    var tabBar = document.getElementById('doc-tab-bar-text');

    if (!display) return;

    $('.doc-nav-item[data-section]').removeClass('active');
    $('.doc-nav-item[data-section="' + name + '"]').addClass('active');

    if (name === 'text') {
        if (titleEl) titleEl.textContent = '개요';
        if (tabBar) $(tabBar).show();
        var activeTab = tabBar ? tabBar.querySelector('.doc-tab.active') : null;
        if (!activeTab && tabBar) activeTab = tabBar.querySelector('.doc-tab');
        if (activeTab) {
            var ref = activeTab.dataset.ref;
            var refEl = ref ? document.getElementById(ref) : null;
            display.innerHTML = refEl ? refEl.innerHTML : (activeTab.dataset.content || '');
        }
    } else {
        if (titleEl) titleEl.textContent = name === 'factions' ? '세력' : name === 'people' ? '인물' : name;
        if (tabBar) $(tabBar).hide();
        var refEl = document.getElementById('doc-content-' + name);
        display.innerHTML = refEl ? refEl.innerHTML : '';
    }
});

/* =========================================
   CRT WebGL Renderer — cool-retro-term IBM DOS style
   ========================================= */
(function () {
    'use strict';

    function createNoiseTexture(gl) {
        var size = 512;
        var data = new Uint8Array(size * size * 4);
        var s = 12345;
        function rand() {
            s = (s * 1664525 + 1013904223) & 0xffffffff;
            return (s >>> 0) / 0xffffffff;
        }
        for (var i = 0; i < data.length; i++) {
            data[i] = (rand() * 255) | 0;
        }
        var tex = gl.createTexture();
        gl.bindTexture(gl.TEXTURE_2D, tex);
        gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, size, size, 0, gl.RGBA, gl.UNSIGNED_BYTE, data);
        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT);
        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT);
        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
        return tex;
    }

    var VERT = [
        'attribute vec2 a_pos;',
        'varying vec2 v_uv;',
        'void main() {',
        '  v_uv = vec2(a_pos.x * 0.5 + 0.5, 0.5 - a_pos.y * 0.5);',
        '  gl_Position = vec4(a_pos, 0.0, 1.0);',
        '}'
    ].join('\n');

    var FRAG = [
        'precision mediump float;',
        'uniform sampler2D u_tex;',
        'uniform sampler2D u_noise;',
        'uniform vec2 u_res;',
        'uniform vec2 u_imgSize;',
        'uniform float u_time;',
        'uniform vec2 u_noiseScale;',
        'varying vec2 v_uv;',

        // Utilities
        'float sum2(vec2 v) { return v.x + v.y; }',
        'float min2(vec2 v) { return min(v.x, v.y); }',
        'float rgb2grey(vec3 v) { return dot(v, vec3(0.21, 0.72, 0.04)); }',

        // Cover UV — 좌우 맞추고 세로 클리핑 (object-fit: cover 방식)
        'vec2 coverUV(vec2 uv) {',
        '  float imgAR = u_imgSize.x / u_imgSize.y;',
        '  float scrAR = u_res.x / u_res.y;',
        '  vec2 scale = vec2(1.0);',
        '  if (imgAR > scrAR) scale.y = imgAR / scrAR;',
        '  else scale.x = scrAR / imgAR;',
        '  vec2 result = (uv - 0.5) * scale + 0.5;',
        '  result = clamp(result, 0.0, 1.0);',
        '  return result;',
        '}',

        // Barrel distortion — QML 원본 공식 + 종횡비 보정
        'vec2 barrel(vec2 v, vec2 cc, float k) {',
        '  float ar = u_res.x / u_res.y;',
        '  vec2 c2 = cc;',
        '  if (ar > 1.0) c2.x /= ar; else c2.y *= ar;',
        '  float dist = dot(c2, c2) * k;',
        '  return v - cc * (1.0 + dist) * dist;',
        '}',

        // Noise samplers
        'vec4 sampleInitialNoise(float t) {',
        '  return texture2D(u_noise, vec2(fract(t/2048.0), fract(t/1048576.0)));',
        '}',
        // 고정 노이즈 — time offset 없음
        'vec4 sampleScreenNoise(vec2 uv) {',
        '  return texture2D(u_noise, u_noiseScale * uv);',
        '}',

        // RGB shift — QML 비대칭 가중치
        'vec3 applyRgbShift(vec2 uv, float shift) {',
        '  vec2 d = vec2(shift, 0.0);',
        '  vec3 r = texture2D(u_tex, coverUV(uv + d)).rgb;',
        '  vec3 c = texture2D(u_tex, coverUV(uv)).rgb;',
        '  vec3 l = texture2D(u_tex, coverUV(uv - d)).rgb;',
        '  return vec3(',
        '    l.r*0.10 + r.r*0.30 + c.r*0.60,',
        '    l.g*0.20 + r.g*0.20 + c.g*0.60,',
        '    l.b*0.30 + r.b*0.10 + c.b*0.60',
        '  );',
        '}',

        // Bloom — 포스포 글로우
        'vec3 applyBloom(vec2 uv, float strength) {',
        '  vec2 px = 2.0 / u_res;',
        '  vec3 acc = vec3(0.0);',
        '  acc += texture2D(u_tex, coverUV(uv + vec2( px.x,  0.0))).rgb;',
        '  acc += texture2D(u_tex, coverUV(uv + vec2(-px.x,  0.0))).rgb;',
        '  acc += texture2D(u_tex, coverUV(uv + vec2( 0.0,  px.y))).rgb;',
        '  acc += texture2D(u_tex, coverUV(uv + vec2( 0.0, -px.y))).rgb;',
        '  acc += texture2D(u_tex, coverUV(uv + vec2( px.x*1.5,  px.y*1.5))).rgb * 0.5;',
        '  acc += texture2D(u_tex, coverUV(uv + vec2(-px.x*1.5,  px.y*1.5))).rgb * 0.5;',
        '  acc += texture2D(u_tex, coverUV(uv + vec2( px.x*1.5, -px.y*1.5))).rgb * 0.5;',
        '  acc += texture2D(u_tex, coverUV(uv + vec2(-px.x*1.5, -px.y*1.5))).rgb * 0.5;',
        '  return acc / 6.0 * strength;',
        '}',

        // Scanlines — QML 방식
        'vec3 applyScanlines(vec2 uv, vec3 col) {',
        '  float line = mod(uv.y * u_res.y, 2.0);',
        '  vec3 hi = ((1.0 + 0.30) - 0.2 * col) * col;',
        '  vec3 lo = ((1.0 - 0.30) + 0.1 * col) * col;',
        '  return line < 1.0 ? lo : hi;',
        '}',

        // Glowing line — 위→아래 스캔빔
        'float glowingLine(vec2 uv, float t) {',
        '  float pos = fract(t * 0.22);',
        '  float lineY = pos * (u_res.y + 120.0) - 120.0;',
        '  float y = uv.y * u_res.y;',
        '  return fract(smoothstep(-120.0, 0.0, y - lineY));',
        '}',

        // Horizontal sync
        'vec2 applyHSync(vec2 uv, vec4 noise, float strength) {',
        '  if (strength <= 0.0) return uv;',
        '  float randval = strength - noise.r;',
        '  float scale = step(0.0, randval) * randval * strength;',
        '  float freq = mix(4.0, 40.0, noise.g);',
        '  uv.x += sin((uv.y + u_time * 0.001) * freq) * scale;',
        '  return uv;',
        '}',

        'void main() {',
        '  vec2 cc = vec2(0.5) - v_uv;',

        // 배럴 왜곡
        '  float curvature = 0.28;',
        '  vec2 uv = barrel(v_uv, cc, curvature);',

        // 화면 밖 검정
        '  float inScreen = min2(step(vec2(0.0), uv) - step(vec2(1.0), uv));',
        '  if (inScreen < 0.5) { gl_FragColor = vec4(0.0, 0.0, 0.0, 1.0); return; }',

        // 노이즈
        '  vec4 initNoise = sampleInitialNoise(u_time);',
        '  vec4 screenNoise = sampleScreenNoise(uv);',

        // Horizontal sync
        '  uv = applyHSync(uv, initNoise, 0.006);',

        // Jitter
        '  uv += (vec2(screenNoise.b, screenNoise.a) - 0.5) * 0.0006;',

        // 베이스 컬러 (coverUV 적용)
        '  vec3 col = applyRgbShift(uv, 0.003);',

        // Bloom
        '  col += applyBloom(uv, 0.22);',

        // 뿌연 블러 (IBM DOS 스타일)
        '  vec2 bpx = 1.5 / u_res;',
        '  vec3 blurCol = vec3(0.0);',
        '  blurCol += texture2D(u_tex, coverUV(uv + vec2(-bpx.x, -bpx.y))).rgb;',
        '  blurCol += texture2D(u_tex, coverUV(uv + vec2( 0.0,   -bpx.y))).rgb;',
        '  blurCol += texture2D(u_tex, coverUV(uv + vec2( bpx.x, -bpx.y))).rgb;',
        '  blurCol += texture2D(u_tex, coverUV(uv + vec2(-bpx.x,  0.0  ))).rgb;',
        '  blurCol += texture2D(u_tex, coverUV(uv + vec2( bpx.x,  0.0  ))).rgb;',
        '  blurCol += texture2D(u_tex, coverUV(uv + vec2(-bpx.x,  bpx.y))).rgb;',
        '  blurCol += texture2D(u_tex, coverUV(uv + vec2( 0.0,    bpx.y))).rgb;',
        '  blurCol += texture2D(u_tex, coverUV(uv + vec2( bpx.x,  bpx.y))).rgb;',
        '  col = mix(col, blurCol / 8.0, 0.40);',

        // Scanlines
        '  col = applyScanlines(uv, col);',

        // Glowing line
        '  float glow = glowingLine(uv, u_time);',
        '  col += glow * 0.55 * vec3(0.85, 0.95, 1.0);',

        // Static noise
        '  float dist = length(cc);',
        '  col += screenNoise.a * 0.07 * (1.0 - dist * 1.3);',

        // IBM DOS 그린 포스포 틴트
        '  float grey = rgb2grey(col);',
        '  vec3 phosphor = vec3(0.20, 0.95, 0.35);',
        '  col = mix(col, grey * phosphor, 0.50);',

        // Vignette
        '  vec2 vig = v_uv * (1.0 - v_uv);',
        '  col *= pow(vig.x * vig.y * 15.0, 0.25);',

        // Flickering
        '  col *= 1.0 + (initNoise.g - 0.5) * 0.06;',

        // Ambient light
        '  col += vec3(0.012) * (1.0 - dist) * (1.0 - dist);',

        // 감마
        '  col = pow(clamp(col, 0.0, 1.0), vec3(0.90));',

        '  gl_FragColor = vec4(col, 1.0);',
        '}'
    ].join('\n');

    function initCRTCanvas(screen, imgEl) {
        var existing = screen.querySelector('.crt-webgl-canvas');
        if (existing) existing.remove();

        var canvas = document.createElement('canvas');
        canvas.className = 'crt-webgl-canvas';
        canvas.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;z-index:19;pointer-events:none;display:block;';
        screen.appendChild(canvas);

        var gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl');
        if (!gl) return;

        function compile(type, src) {
            var s = gl.createShader(type);
            gl.shaderSource(s, src);
            gl.compileShader(s);
            if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) {
                console.error('[CRT shader]', gl.getShaderInfoLog(s));
            }
            return s;
        }

        var prog = gl.createProgram();
        gl.attachShader(prog, compile(gl.VERTEX_SHADER, VERT));
        gl.attachShader(prog, compile(gl.FRAGMENT_SHADER, FRAG));
        gl.linkProgram(prog);
        gl.useProgram(prog);

        var buf = gl.createBuffer();
        gl.bindBuffer(gl.ARRAY_BUFFER, buf);
        gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1,-1, 1,-1, -1,1, 1,1]), gl.STATIC_DRAW);
        var aPos = gl.getAttribLocation(prog, 'a_pos');
        gl.enableVertexAttribArray(aPos);
        gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0);

        var uTex     = gl.getUniformLocation(prog, 'u_tex');
        var uNoise   = gl.getUniformLocation(prog, 'u_noise');
        var uRes     = gl.getUniformLocation(prog, 'u_res');
        var uImgSize = gl.getUniformLocation(prog, 'u_imgSize');
        var uTime    = gl.getUniformLocation(prog, 'u_time');
        var uNoiseSc = gl.getUniformLocation(prog, 'u_noiseScale');

        // 이미지 텍스처 (unit 0)
        var imgTex = gl.createTexture();
        gl.activeTexture(gl.TEXTURE0);
        gl.bindTexture(gl.TEXTURE_2D, imgTex);
        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);

        // 노이즈 텍스처 (unit 1)
        gl.activeTexture(gl.TEXTURE1);
        createNoiseTexture(gl);

        var texReady = false;
        function uploadImg() {
            if (!imgEl || !imgEl.complete || !imgEl.naturalWidth) return;
            try {
                gl.activeTexture(gl.TEXTURE0);
                gl.bindTexture(gl.TEXTURE_2D, imgTex);
                gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, imgEl);
                texReady = true;
            } catch(e) { console.error('[CRT] tex:', e); }
        }

        var lastW = 0, lastH = 0;
        function resize() {
            var w = screen.offsetWidth, h = screen.offsetHeight;
            if (w === lastW && h === lastH) return;
            lastW = w; lastH = h;
            canvas.width = w; canvas.height = h;
            gl.viewport(0, 0, w, h);
        }

        var raf;
        var t0 = performance.now();

        function render() {
            raf = requestAnimationFrame(render);
            if (!texReady) { uploadImg(); return; }
            resize();
            var t = (performance.now() - t0) / 1000;
            gl.uniform1i(uTex, 0);
            gl.uniform1i(uNoise, 1);
            gl.uniform2f(uRes, canvas.width, canvas.height);
            gl.uniform2f(uImgSize, imgEl.naturalWidth, imgEl.naturalHeight);
            gl.uniform1f(uTime, t);
            gl.uniform2f(uNoiseSc, canvas.width / 512.0, canvas.height / 512.0);
            gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
        }

        if (imgEl.complete && imgEl.naturalWidth) { uploadImg(); }
        else { imgEl.addEventListener('load', uploadImg); }

        render();
        screen._crtCleanup = function () { cancelAnimationFrame(raf); };
    }

    function initAllCRTScreens(root) {
        var scope = root && root.querySelectorAll ? root : document;
        scope.querySelectorAll('.crt-page-monitor-screen').forEach(function (screen) {
            if (screen.getAttribute('data-crt-webgl') === '1') return;
            screen.setAttribute('data-crt-webgl', '1');
            var frame = screen.closest('.crt-page-monitor-frame');
            if (!frame) return;
            var imgEl = frame.querySelector('.crt-page-monitor-slice-img, .crt-page-monitor-image-base img');
            if (imgEl && imgEl.complete && imgEl.naturalWidth) {
                initCRTCanvas(screen, imgEl);
            } else if (imgEl) {
                imgEl.addEventListener('load', function () { initCRTCanvas(screen, imgEl); });
            } else {
                var obs = new MutationObserver(function () {
                    var img = frame.querySelector('.crt-page-monitor-slice-img');
                    if (!img) return;
                    obs.disconnect();
                    if (img.complete && img.naturalWidth) {
                        initCRTCanvas(screen, img);
                    } else {
                        img.addEventListener('load', function () { initCRTCanvas(screen, img); });
                    }
                });
                obs.observe(frame, { childList: true, subtree: true });
            }
        });
    }

    $(function () { initAllCRTScreens(document); });

    if (typeof mw !== 'undefined' && mw.hook) {
        mw.hook('wikipage.content').add(function ($c) {
            document.querySelectorAll('.crt-page-monitor-screen').forEach(function (s) {
                if (s._crtCleanup) s._crtCleanup();
                s.removeAttribute('data-crt-webgl');
            });
            initAllCRTScreens($c && $c[0] ? $c[0] : document);
        });
    }
})();