Rule34.xxx User Notes

Add private notes to other members

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name            Rule34.xxx User Notes
// @namespace       861ddd094884eac5bea7a3b12e074f34
// @version         1.0.4
// @description     Add private notes to other members
// @author          Anonymous, Claude Fable 5 Low
// @match           https://rule34.xxx/index.php?page=comment&s=list*
// @match           https://rule34.xxx/index.php?page=account&s=profile*
// @match           https://rule34.xxx/index.php?page=post&s=view*
// @icon            https://external-content.duckduckgo.com/ip3/rule34.xxx.ico
// @grant           GM_getValue
// @grant           GM_setValue
// @grant           GM_registerMenuCommand
// @license         BSD-0
// ==/UserScript==

(function () {
  'use strict';

  const LOG = '[Rule34.xxx User Notes] ';
  const warn = (...a) => console.warn(LOG, ...a);

  // storage
  /////////////

  // One GM value holds the whole DB:
  //   { "<lowercase username>": { note: string, color: string|null, updated: "ISO date" } }

  function loadDb() {
    try {
      const raw = GM_getValue('user_notes', null);
      if (!raw) return {};
      const db = JSON.parse(raw);
      return db && typeof db === 'object' ? db : {};
    } catch (e) {
      warn('notes db read error', e);
      return {};
    }
  }

  function saveDb(db) {
    try {
      GM_setValue('user_notes', JSON.stringify(db));
    } catch (e) {
      warn('notes db write error', e);
    }
  }

  const keyFor = (username) => username.trim().toLowerCase();
  const getEntry = (username) => loadDb()[keyFor(username)] || null;

  function setEntry(username, note, color) {
    const db = loadDb();
    const key = keyFor(username);
    if (!note && !color) {
      delete db[key];
    } else {
      db[key] = { note, color: color || null, updated: new Date().toISOString() };
    }
    saveDb(db);
  }

  // style
  ///////////

  const PAGE_CSS = `
.r34un-icon {
  cursor: pointer;
  font-size: 12px;
  margin-left: 6px;
  text-decoration: none;
  user-select: none;
}
.r34un-icon.r34un-ghost {
  display: none;
  opacity: 0.45;
}
div.author:hover .r34un-ghost,
div.col1:hover .r34un-ghost {
  display: inline;
}
.r34un-tooltip {
  position: absolute;
  z-index: 2147483646;
  max-width: 340px;
  padding: 6px 9px;
  border: 1px solid #3a3c40;
  border-radius: 6px;
  background: #1f2023;
  color: #e6e6e6;
  font: 12px/1.4 system-ui, sans-serif;
  white-space: pre-wrap;
  overflow-wrap: break-word;
  pointer-events: none;
}
`;
  const STYLE_ELEMENT = document.createElement('style');
  STYLE_ELEMENT.textContent = PAGE_CSS;
  document.head.appendChild(STYLE_ELEMENT);

  // edit modal
  ////////////////

  // A native <dialog> in a Shadow root, mirroring the comments-filter settings
  // dialog: the shadow boundary keeps site CSS out, and showModal() provides
  // backdrop dimming, focus trapping and Esc-to-close (cancel) for free.

  const MODAL_CSS = `
:host { all: initial; }
* { box-sizing: border-box; font-family: system-ui, sans-serif; }
dialog.card {
  border: 1px solid #3a3c40; border-radius: 8px; color: #e6e6e6;
  background: #1f2023; width: min(480px, 90vw);
  padding: 14px 16px; margin: auto;
}
dialog.card::backdrop { background: rgba(0,0,0,.6); }
.row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; }
h1 { font-size: 15px; margin: 0; }
h1 .uname { color: #f08000; }
.x { background: none; border: none; color: #aaa; font-size: 18px; cursor: pointer; line-height: 1; }
.x:hover { color: #fff; }
textarea {
  width: 100%; min-height: 110px; resize: vertical;
  border: 1px solid #3a3c40; border-radius: 6px;
  background: #141517; color: #e6e6e6; font-size: 13px; padding: 6px 8px;
}
.colorrow { display: flex; align-items: center; gap: 8px; margin: 10px 0 12px; font-size: 12px; }
.colorrow input[type="color"] { width: 42px; height: 24px; padding: 0; border: 1px solid #3a3c40; background: #141517; }
.hint { color: #888; font-size: 11px; margin: 0 0 10px; }
.actions { display: flex; justify-content: flex-end; gap: 8px; }
.btn { font-size: 12px; padding: 5px 14px; border-radius: 6px; cursor: pointer;
  border: 1px solid #3a3c40; background: #2a2c30; color: #e6e6e6; }
.btn:hover { background: #34373c; }
.btn.primary { background: #3b6ea5; border-color: #3b6ea5; }
.btn.primary:hover { background: #4279b8; }
.btn.danger { background: #8a2f2f; border-color: #8a2f2f; margin-right: auto; }
.btn.danger:hover { background: #a53b3b; }
`;

  let modal = null; // { dialog, uname, textarea, colorInput, colorOn }

  function buildModal() {
    if (modal) return modal;
    const host = document.createElement('div');
    host.id = 'r34un-modal-host';
    const root = host.attachShadow({ mode: 'open' });
    const style = document.createElement('style');
    style.textContent = MODAL_CSS;
    root.appendChild(style);

    const dialog = document.createElement('dialog');
    dialog.className = 'card';
    dialog.innerHTML = `
      <div class="row">
        <h1>Note for <span class="uname"></span></h1>
        <button class="x" title="Cancel" type="button">&times;</button>
      </div>
      <textarea placeholder="Note text…"></textarea>
      <div class="colorrow">
        <label><input type="checkbox" class="coloron"> Highlight color</label>
        <input type="color" value="#f08000">
      </div>
      <p class="hint">Saving with an empty note and no color deletes the entry. Esc cancels.</p>
      <div class="actions">
        <button class="btn danger" type="button">Delete</button>
        <button class="btn primary" type="button">Save</button>
      </div>`;
    root.appendChild(dialog);
    document.body.appendChild(host);

    modal = {
      dialog,
      uname: dialog.querySelector('.uname'),
      textarea: dialog.querySelector('textarea'),
      colorInput: dialog.querySelector('input[type="color"]'),
      colorOn: dialog.querySelector('.coloron'),
      username: null,
    };

    dialog.querySelector('.x').addEventListener('click', () => dialog.close());
    dialog.querySelector('.btn.danger').addEventListener('click', () => {
      setEntry(modal.username, '', null);
      dialog.close();
      refreshAll();
    });
    modal.colorInput.addEventListener('input', () => { modal.colorOn.checked = true; });
    const save = () => {
      const note = modal.textarea.value.trim();
      const color = modal.colorOn.checked ? modal.colorInput.value : null;
      setEntry(modal.username, note, color);
      dialog.close();
      refreshAll();
    };
    dialog.querySelector('.btn.primary').addEventListener('click', save);
    // Capture-phase document listener so Ctrl+Enter is claimed before other
    // userscripts' document-level handlers (the keyboard-shortcuts script
    // exempts ctrl+enter from its input guard to do form submission).
    document.addEventListener(
      'keydown',
      (e) => {
        if (!dialog.open) return;
        if (e.key === 'Enter' && e.ctrlKey && !e.shiftKey && !e.altKey && !e.metaKey) {
          e.preventDefault();
          e.stopImmediatePropagation();
          save();
        }
      },
      true
    );
    return modal;
  }

  function openEditor(username) {
    const m = buildModal();
    m.username = username;
    m.uname.textContent = username;
    const entry = getEntry(username);
    m.textarea.value = entry ? entry.note : '';
    m.colorOn.checked = !!(entry && entry.color);
    if (entry && entry.color) m.colorInput.value = entry.color;
    m.dialog.showModal();
    m.textarea.focus();
  }

  // tooltip
  //////////

  let tooltip = null;

  function showTooltip(anchor, text) {
    hideTooltip();
    tooltip = document.createElement('div');
    tooltip.className = 'r34un-tooltip';
    tooltip.textContent = text;
    document.body.appendChild(tooltip);
    const r = anchor.getBoundingClientRect();
    tooltip.style.left = `${window.scrollX + r.left}px`;
    tooltip.style.top = `${window.scrollY + r.bottom + 4}px`;
  }

  function hideTooltip() {
    if (tooltip) tooltip.remove();
    tooltip = null;
  }

  // note icon
  ////////////

  function makeIcon(username) {
    const icon = document.createElement('span');
    icon.className = 'r34un-icon';
    icon.textContent = '📝';
    icon.addEventListener('mouseenter', () => {
      const entry = getEntry(username);
      if (entry && entry.note) showTooltip(icon, entry.note);
    });
    icon.addEventListener('mouseleave', hideTooltip);
    icon.addEventListener('click', (e) => {
      e.preventDefault();
      hideTooltip();
      openEditor(username);
    });
    return icon;
  }

  // comment lists
  ///////////////////

  // Global comments page blocks are div.post[id^="c"] with the author link in
  // div.author h6 a[href*="uname="]; post detail page blocks are bare
  // #comment-list > div[id^="c"] with the author link directly in .col1. The
  // icon goes right after the author link's container, and a chosen color
  // becomes an inline left border on the block — inline so it wins over the
  // comments-filter script's class-based pcf-today border.

  const COMMENT_SELECTOR = 'div.post[id^="c"], #comment-list > div[id^="c"]';
  const AUTHOR_LINK_SELECTOR =
    'div.author h6 a[href*="uname="], :scope > div.col1 > a[href*="uname="]';
  const UNAME_RE = /[?&]uname=([^&#]+)/;

  function commentUsername(post) {
    const a = post.querySelector(AUTHOR_LINK_SELECTOR);
    const m = a && a.getAttribute('href').match(UNAME_RE);
    return m ? decodeURIComponent(m[1].replace(/\+/g, ' ')) : null;
  }

  function decorateComment(post) {
    const username = commentUsername(post);
    if (!username) return;
    const entry = getEntry(username);

    const oldIcon = post.querySelector('.r34un-icon');
    if (oldIcon) oldIcon.remove();
    // Icon is always present; without a note it is a dimmed "add note"
    // affordance revealed only while the author block is hovered (CSS).
    const icon = makeIcon(username);
    icon.classList.toggle('r34un-ghost', !(entry && entry.note));
    const a = post.querySelector(AUTHOR_LINK_SELECTOR);
    const h6 = a.closest('h6');
    if (h6) h6.appendChild(icon);
    else a.after(icon);

    if (entry && entry.color) {
      post.style.setProperty('border-left', `3px solid ${entry.color}`, 'important');
      post.style.setProperty('border-radius', '4px');
      post.style.setProperty('padding-left', '8px');
    } else {
      post.style.removeProperty('border-left');
      post.style.removeProperty('border-radius');
      post.style.removeProperty('padding-left');
    }
  }

  const allComments = () => document.querySelectorAll(COMMENT_SELECTOR);

  function decorateComments() {
    allComments().forEach(decorateComment);
  }

  // Shift+N while hovering a comment opens the editor for its author. Track the
  // hovered comment via mouseover delegation rather than per-node listeners so
  // it survives the filter script re-rendering the list.
  let hoveredComment = null;
  document.addEventListener('mouseover', (e) => {
    hoveredComment = e.target.closest ? e.target.closest(COMMENT_SELECTOR) : null;
  });

  // profile page
  //////////////////

  function profileUsername() {
    const h2 = document.querySelector('#content h2');
    return h2 ? h2.textContent.trim() : null;
  }

  function decorateProfile() {
    const username = profileUsername();
    const table = document.querySelector('#content table.highlightable');
    if (!username || !table) return;

    let row = table.querySelector('tr[data-r34un]');
    if (!row) {
      row = table.insertRow();
      row.dataset.r34un = '1';
      row.innerHTML = '<td><strong>Note</strong></td><td></td>';
    }
    const cell = row.cells[1];
    cell.textContent = '';

    const entry = getEntry(username);
    if (entry && entry.note) {
      cell.appendChild(makeIcon(username));
      cell.appendChild(document.createTextNode(` ${entry.note}`));
    } else {
      const span = document.createElement('span');
      const text1 = document.createTextNode('(');
      const anchor = document.createElement('a');
      anchor.href = '#';
      anchor.textContent = 'add';
      anchor.addEventListener('click', (e) => {
        e.preventDefault();
        openEditor(username);
      });
      const text2 = document.createTextNode(')');
      span.appendChild(text1);
      span.appendChild(anchor);
      span.appendChild(text2);
      cell.appendChild(span);
    }
    if (entry && entry.color) {
      cell.style.setProperty('border-left', `3px solid ${entry.color}`);
      cell.style.setProperty('padding-left', '8px');
    } else {
      cell.style.removeProperty('border-left');
      cell.style.removeProperty('padding-left');
    }
  }

  // wiring
  ////////////

  const params = new URLSearchParams(location.search);
  const IS_COMMENTS = params.get('page') === 'comment' && params.get('s') === 'list';
  const IS_PROFILE = params.get('page') === 'account' && params.get('s') === 'profile';
  const IS_POST = params.get('page') === 'post' && params.get('s') === 'view';

  function refreshAll() {
    if (IS_COMMENTS || IS_POST) decorateComments();
    if (IS_PROFILE) decorateProfile();
  }

  // Shadow DOM retargets events, so document-level e.target is the modal's
  // host div rather than its textarea; resolve the real focus target through
  // nested shadow roots instead.
  function getDeepActiveElement() {
    let el = document.activeElement;
    while (el?.shadowRoot?.activeElement) el = el.shadowRoot.activeElement;
    return el;
  }

  document.addEventListener('keydown', (e) => {
    if (e.key !== 'N' || !e.shiftKey || e.ctrlKey || e.altKey || e.metaKey) return;
    if (modal && modal.dialog.open) return;
    // don't trigger when an input field has focus
    const active = getDeepActiveElement();
    if (
      active &&
      (active.isContentEditable ||
        ['INPUT', 'TEXTAREA', 'SELECT'].includes(active.tagName))
    )
      return;
    const username = IS_PROFILE
      ? profileUsername()
      : hoveredComment && commentUsername(hoveredComment);
    if (!username) return;
    e.preventDefault();
    openEditor(username);
  });

  refreshAll();

  // The comments-filter script rebuilds the comment list asynchronously;
  // re-decorate when comment blocks appear or change. Icon insertions retrigger
  // the observer, so skip runs while our own decoration mutates the DOM.
  if (IS_COMMENTS) {
    const content = document.getElementById('content');
    if (content) {
      let scheduled = false;
      new MutationObserver((muts) => {
        if (scheduled) return;
        if (!muts.some((m) => [...m.addedNodes].some((n) => n.nodeType === 1))) return;
        scheduled = true;
        setTimeout(() => {
          decorateComments();
          setTimeout(() => { scheduled = false; }, 0);
        }, 50);
      }).observe(content, { childList: true, subtree: true });
    }
  }

  // export/import
  ///////////////////

  if (typeof GM_registerMenuCommand === 'function') {
    GM_registerMenuCommand('Export user notes (JSON)', () => {
      const json = JSON.stringify(loadDb(), null, 2);
      const copy = navigator.clipboard
        ? navigator.clipboard.writeText(json)
        : Promise.reject();
      copy
        .then(() => alert('User notes JSON copied to clipboard.'))
        .catch(() => prompt('Copy the user notes JSON:', json));
    });
    GM_registerMenuCommand('Import user notes (JSON)', () => {
      const json = prompt('Paste user notes JSON (merged into existing notes):');
      if (!json) return;
      try {
        const incoming = JSON.parse(json);
        if (!incoming || typeof incoming !== 'object' || Array.isArray(incoming)) {
          throw new Error('not an object');
        }
        const db = loadDb();
        let count = 0;
        for (const [key, entry] of Object.entries(incoming)) {
          if (!entry || typeof entry.note !== 'string') continue;
          db[keyFor(key)] = {
            note: entry.note,
            color: typeof entry.color === 'string' ? entry.color : null,
            updated: entry.updated || new Date().toISOString(),
          };
          count++;
        }
        saveDb(db);
        refreshAll();
        alert(`Imported ${count} note(s).`);
      } catch (e) {
        alert('Import failed: invalid JSON. ' + e.message);
      }
    });
  }
})();