Chzzk_L&V: Dal.wiki & WWME Viewer

치지직에서 Dal.wiki 일정 확인 및 추가기능 + WWME 뷰어 기능 추가 (다크모드 지원)

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         Chzzk_L&V: Dal.wiki & WWME Viewer
// @namespace    Chzzk_L&V: Third Party Iframe Viewer
// @version      1.1.2
// @description  치지직에서 Dal.wiki 일정 확인 및 추가기능 + WWME 뷰어 기능 추가 (다크모드 지원)
// @author       DOGJIP
// @match        *://chzzk.naver.com/*
// @match        https://dal.wiki/*
// @grant        none
// @run-at       document-idle
// @license      MIT
// @icon         https://www.google.com/s2/favicons?sz=64&domain=chzzk.naver.com
// ==/UserScript==

(function () {
  'use strict';

  const isChzzk = location.hostname.includes('chzzk.naver.com');
  const isDalWiki = location.hostname.includes('dal.wiki');

  // ========================= CHZZK: 일정 뷰어 =========================
  if (isChzzk && window.top === window.self && !location.pathname.includes('/chat')) {
    let buttonContainer, MainBtn, streamerBtn, dayBtn, wwmeBtn, darkModeBtn;  // darkModeBtn 추가
    let iframe, closeBtn, opacitySlider, rememberOpacityCheckbox, checkbox;
    let viewMode = 'daily'; // 'streamer' | 'daily' | 'wwme'
    let currentOpacity = 1.0;
    let isDarkMode = false;  // 다크모드 상태 변수 추가

    const STORAGE_KEY = 'dalwiki_opacity';
    const DARKMODE_KEY = 'dalwiki_darkmode';  // 다크모드 저장 키 추가
    const fixedPosition = { top: 16, left: 150 };
    const iframeDefaultWidth = 1000;
    const iframeDefaultHeight = 800;

    function applyButtonPosition() {
      Object.assign(buttonContainer.style, {
        position: 'fixed',
        top: `${fixedPosition.top}px`,
        left: `${fixedPosition.left}px`,
        zIndex: 2147483647,
        display: 'flex',
        gap: '4px'
      });
    }

    function centerIframe() {
      const maxW = window.innerWidth * 0.9;
      const maxH = window.innerHeight * 0.9;
      const w = Math.min(iframeDefaultWidth, maxW);
      const h = Math.min(iframeDefaultHeight, maxH);
      const top = (window.innerHeight - h) / 2;
      const left = (window.innerWidth - w) / 2;

      Object.assign(iframe.style, {
        width: `${w}px`,
        height: `${h}px`,
        top: `${top}px`,
        left: `${left}px`,
        display: 'block',
        position: 'fixed',
        border: '2px solid #ccc',
        borderRadius: '8px',
        boxShadow: '0 0 12px rgba(0,0,0,0.4)',
        background: 'white',
        zIndex: 2147483646
      });

      Object.assign(closeBtn.style, {
        position: 'fixed',
        top: `${top - 30}px`,
        left: `${left + w - 30}px`,
        display: 'block',
        background: 'crimson',
        color: 'white',
        border: 'none',
        padding: '4px 8px',
        borderRadius: '50%',
        cursor: 'pointer',
        fontSize: '12px',
        zIndex: 2147483647
      });

      // 다크모드 버튼 위치 추가
      const darkModeLeft = parseFloat(closeBtn.style.left) - 40;
      darkModeBtn.style.top = closeBtn.style.top;
      darkModeBtn.style.left = `${darkModeLeft}px`;
      darkModeBtn.style.display = 'block';

      const sliderRight = darkModeLeft - 110;  // closeBtn.style.left 대신 darkModeLeft 사용
      opacitySlider.style.top = closeBtn.style.top;
      opacitySlider.style.left = `${sliderRight}px`;

      const checkboxLeft = sliderRight - 100 - 8;
      rememberOpacityCheckbox.style.top = closeBtn.style.top;
      rememberOpacityCheckbox.style.left = `${checkboxLeft}px`;

      streamerBtn.style.top = closeBtn.style.top;
      streamerBtn.style.left = iframe.style.left;
      streamerBtn.style.display = 'block';

      dayBtn.style.top = closeBtn.style.top;
      const streamerWidth = streamerBtn.offsetWidth;
      dayBtn.style.left = `${parseFloat(iframe.style.left) + streamerWidth + 8}px`;
      dayBtn.style.display = 'block';

      wwmeBtn.style.top = closeBtn.style.top;
      wwmeBtn.style.left = `${parseFloat(dayBtn.style.left) + dayBtn.offsetWidth + 4}px`;
      wwmeBtn.style.display = 'block';
    }

    // 다크모드 토글 함수 추가
    function toggleDarkMode() {
      isDarkMode = !isDarkMode;
      localStorage.setItem(DARKMODE_KEY, isDarkMode ? '1' : '0');
      applyDarkMode();
      darkModeBtn.textContent = isDarkMode ? '☀️' : '🌙';
    }

    // 다크모드 적용 함수 추가
    function applyDarkMode() {
      if (isDarkMode) {
        iframe.style.filter = 'invert(0.9) hue-rotate(180deg)';
      } else {
        iframe.style.filter = 'none';
      }
    }

    function updateMainBtnText(mode) {
      switch (mode) {
        case 'streamer':
          MainBtn.textContent = '🎥 스트리머 일정';
          break;
        case 'daily':
          MainBtn.textContent = '📅 치지직 일간 일정';
          break;
        case 'wwme':
          MainBtn.textContent = '🌐 WWME';
          break;
      }
    }

    function createUI() {
      buttonContainer = document.createElement('div');
      document.body.appendChild(buttonContainer);

      MainBtn = document.createElement('button');
      Object.assign(MainBtn.style, {
        padding: '6px 8px',
        zIndex: 2147483647,
        background: '#333',
        color: 'white',
        borderRadius: '6px',
        border: 'none',
        fontSize: '12px'
      });
      buttonContainer.appendChild(MainBtn);

      streamerBtn = document.createElement('button');
      streamerBtn.textContent = '🎥 스트리머 일정';
      dayBtn = document.createElement('button');
      [streamerBtn, dayBtn].forEach(btn => {
        btn.style.display = 'none';
        btn.style.position = 'fixed';
        btn.style.zIndex = '2147483647';
        btn.style.padding = '4px 8px';
        btn.style.background = '#555';
        btn.style.color = 'white';
        btn.style.borderRadius = '4px';
        btn.style.border = 'none';
        btn.style.cursor = 'pointer';
        btn.style.fontSize = '12px';
        document.body.appendChild(btn);
      });
      dayBtn.textContent = '📅 치지직 일간 일정';

      iframe = document.createElement('iframe');
      Object.assign(iframe.style, { display: 'none' });
      document.body.appendChild(iframe);

      closeBtn = document.createElement('button');
      closeBtn.textContent = '✖';
      Object.assign(closeBtn.style, { display: 'none' });
      document.body.appendChild(closeBtn);

      // 다크모드 버튼 생성 추가
      darkModeBtn = document.createElement('button');
      darkModeBtn.textContent = isDarkMode ? '☀️' : '🌙';
      darkModeBtn.title = '다크모드 토글';
      Object.assign(darkModeBtn.style, {
        display: 'none',
        position: 'fixed',
        zIndex: '2147483647',
        background: '#555',
        color: 'white',
        border: 'none',
        padding: '4px 8px',
        borderRadius: '50%',
        cursor: 'pointer',
        fontSize: '14px'
      });
      document.body.appendChild(darkModeBtn);

      opacitySlider = document.createElement('input');
      opacitySlider.type = 'range';
      opacitySlider.min = '0.3';
      opacitySlider.max = '1';
      opacitySlider.step = '0.01';
      opacitySlider.value = '1';
      Object.assign(opacitySlider.style, {
        display: 'none',
        position: 'fixed',
        zIndex: 2147483647,
        width: '100px'
      });
      document.body.appendChild(opacitySlider);

      rememberOpacityCheckbox = document.createElement('label');
      checkbox = document.createElement('input');
      checkbox.type = 'checkbox';
      checkbox.style.marginRight = '4px';
      rememberOpacityCheckbox.appendChild(checkbox);
      // WWME 버튼 생성
      wwmeBtn = document.createElement('button');
      wwmeBtn.textContent = '🌐 WWME';
      Object.assign(wwmeBtn.style, {
        display: 'none',
        position: 'fixed',
        zIndex: '2147483647',
        padding: '4px 8px',
        background: '#555',
        color: 'white',
        borderRadius: '4px',
        border: 'none',
        cursor: 'pointer',
        fontSize: '12px'
      });
      document.body.appendChild(wwmeBtn);

      applyButtonPosition();
    }

    function openIframe(mode = viewMode) {
      if (mode === 'wwme') {
        // WWME 모드: 사전 로딩된 iframe이 아니더라도 URL 설정
        iframe.src = 'https://wwme.kr/';
        centerIframe();
        // 투명도 기억 적용
        iframe.style.opacity = currentOpacity;
        opacitySlider.value = currentOpacity.toString();
        checkbox.checked = !!localStorage.getItem(STORAGE_KEY);

        // 표시
        iframe.style.display = 'block';
        closeBtn.style.display = 'block';
        opacitySlider.style.display = 'block';
        rememberOpacityCheckbox.style.display = 'block';

        // 버튼 위치
        streamerBtn.style.top = closeBtn.style.top;
        streamerBtn.style.left = iframe.style.left;
        dayBtn.style.top = closeBtn.style.top;
        dayBtn.style.left = `${parseFloat(streamerBtn.style.left) + streamerBtn.offsetWidth + 8}px`;
        wwmeBtn.style.top = closeBtn.style.top;
        wwmeBtn.style.left = `${parseFloat(dayBtn.style.left) + dayBtn.offsetWidth + 4}px`;

        applyDarkMode();  // 다크모드 적용 추가
        return;
      }

      const dt = new Date();
      const yyyy = dt.getFullYear();
      const mm = String(dt.getMonth() + 1).padStart(2, '0');
      const dd = String(dt.getDate()).padStart(2, '0');

      let topicPath = '';
      let page = '';
      switch (mode) {
        case 'daily':
          topicPath = '/topic/%EC%B9%98%EC%A7%80%EC%A7%81%20%ED%95%A9%EB%B0%A9%2F%EB%8C%80%ED%9A%8C%2F%EC%BD%98%ED%85%90%EC%B8%A0%20%EC%9D%BC%EC%A0%95';
          page = 'agenda';
          break;
        case 'streamer':
          topicPath = '/topic/%EC%B9%98%EC%A7%80%EC%A7%81%20%EC%8A%A4%ED%8A%B8%EB%A6%AC%EB%A8%B8%20%EC%9D%BC%EC%A0%95';
          page = 'agenda';
          break;
        default:
          return;
      }

      iframe.src = `https://dal.wiki${topicPath}/${page}?date=${yyyy}-${mm}-${dd}`;
      centerIframe();
      // 투명도 기억 적용
      iframe.style.opacity = currentOpacity;
      opacitySlider.value = currentOpacity.toString();
      checkbox.checked = !!localStorage.getItem(STORAGE_KEY);

      // 표시
      iframe.style.display = 'block';
      closeBtn.style.display = 'block';
      opacitySlider.style.display = 'block';
      rememberOpacityCheckbox.style.display = 'block';

      applyDarkMode();  // 다크모드 적용 추가
    }

    function bindEvents() {
      MainBtn.onclick = () => {
        if (iframe.style.display === 'block') {
          iframe.style.display = closeBtn.style.display = darkModeBtn.style.display = opacitySlider.style.display = rememberOpacityCheckbox.style.display = 'none';  // darkModeBtn 추가
          streamerBtn.style.display = dayBtn.style.display = wwmeBtn.style.display = 'none';
        } else {
          openIframe();
        }
      };
      closeBtn.onclick = () => {
        iframe.style.display = closeBtn.style.display = darkModeBtn.style.display = opacitySlider.style.display = rememberOpacityCheckbox.style.display = 'none';  // darkModeBtn 추가
        streamerBtn.style.display = dayBtn.style.display = wwmeBtn.style.display = 'none';
      };
      darkModeBtn.onclick = toggleDarkMode;  // 다크모드 버튼 이벤트 추가
      window.addEventListener('resize', () => iframe.style.display === 'block' && centerIframe());
      opacitySlider.oninput = () => {
        currentOpacity = parseFloat(opacitySlider.value);
        iframe.style.opacity = currentOpacity;
        if (checkbox.checked) localStorage.setItem(STORAGE_KEY, currentOpacity.toString());
      };
      checkbox.onchange = () => {
        if (checkbox.checked) localStorage.setItem(STORAGE_KEY, currentOpacity.toString());
        else localStorage.removeItem(STORAGE_KEY);
      };
      streamerBtn.onclick = () => {
        viewMode = 'streamer';
        openIframe(viewMode);
      };
      dayBtn.onclick = () => {
        viewMode = 'daily';
        openIframe(viewMode);
      };
      wwmeBtn.onclick = () => {
        viewMode = 'wwme';
        openIframe(viewMode);
      };
    }

    function observeBodyStyle() {
      const checkBodyStyle = () => {
        const bodyStyle = document.body.getAttribute('style') || '';
        const computedStyle = window.getComputedStyle(document.body);

        // body에 overflow: hidden과 position: fixed가 동시에 있는지 확인
        const isTheaterMode = (
          (bodyStyle.includes('overflow: hidden') || computedStyle.overflow === 'hidden') &&
          (bodyStyle.includes('position: fixed') || computedStyle.position === 'fixed')
        );

        buttonContainer.style.display = isTheaterMode ? 'none' : 'flex';
      };

      // MutationObserver로 body의 style 속성 변화 감지
      const observer = new MutationObserver((mutations) => {
        mutations.forEach((mutation) => {
          if (mutation.type === 'attributes' && mutation.attributeName === 'style') {
            checkBodyStyle();
          }
        });
      });

      observer.observe(document.body, {
        attributes: true,
        attributeFilter: ['style'],
        attributeOldValue: true
      });

      // 초기 체크
      checkBodyStyle();

      // 추가 안전장치: 특정 이벤트 발생 시 체크
      // 넓은 화면 모드 관련 단축키 및 상호작용
      const eventsToCheck = ['keydown', 'click'];

      eventsToCheck.forEach(eventType => {
        document.addEventListener(eventType, (e) => {
          // 키보드 이벤트인 경우
          if (e.type === 'keydown') {
            const key = e.key.toLowerCase();
            // t(넓은화면), f(전체화면), F12(개발자도구) 키만 체크
            if (key === 't' || key === 'f' || e.keyCode === 123) {
              setTimeout(checkBodyStyle, 100); // 약간의 지연 후 체크
            }
          }
          // 클릭 이벤트인 경우 (넓은화면/전체화면 버튼 클릭)
          else if (e.type === 'click') {
            // 버튼 클릭으로 인한 변화를 감지하기 위해 짧은 지연
            setTimeout(checkBodyStyle, 100);
          }
        }, { passive: true });
      });
    }

    function init() {
      const saved = localStorage.getItem(STORAGE_KEY);
      if (saved) currentOpacity = parseFloat(saved);

      // 다크모드 설정 불러오기 추가
      const savedDarkMode = localStorage.getItem(DARKMODE_KEY);
      isDarkMode = savedDarkMode === '1';

      createUI();
      updateMainBtnText(viewMode);
      bindEvents();
      observeBodyStyle();
    }

    if (document.body) init();
    else new MutationObserver((obs) => document.body && (obs.disconnect(), init())).observe(document.documentElement, { childList: true });
  }

  // ========================= DAL.WIKI 내 고정 레이아웃 =========================
  if (isDalWiki) {
    const observer = new MutationObserver(() => {
      const aside = document.querySelector('aside.md\\:w-\\[176px\\]');
      if (!aside || aside.dataset.stickyApplied === 'true') return;

      const addButtonAnchor = aside.querySelector('a[href*=\"/editor\"]');
      const scrollWrapper = aside.querySelector('[style*=\"--radix-scroll-area-corner-width\"]');
      const viewport = scrollWrapper?.querySelector('[data-radix-scroll-area-viewport]');
      if (!addButtonAnchor || !scrollWrapper || !viewport) return;

      aside.dataset.stickyApplied = 'true';
      viewport.style.minHeight = viewport.offsetHeight + 'px';
      scrollWrapper.style.maxHeight = '50vh';
      scrollWrapper.style.overflowY = 'auto';

      const stickyContainer = document.createElement('div');
      stickyContainer.style.position = 'sticky';
      stickyContainer.style.top = '0';
      stickyContainer.style.background = 'white';
      stickyContainer.style.zIndex = '50';
      stickyContainer.style.paddingBottom = '12px';

      const config = { attributes: true, childList: false, subtree: false };
      observer.disconnect();

      stickyContainer.appendChild(addButtonAnchor);
      stickyContainer.appendChild(scrollWrapper);
      aside.insertBefore(stickyContainer, aside.firstChild);

      observer.observe(document.body, config);
    });
    observer.observe(document.body, { childList: true, subtree: true });
  }
})();