AO3 Karma

Track kudos & comments with score, icons, reset & stats

// ==UserScript==
// @name         AO3 Karma
// @namespace    http://lithiumdoll
// @version      0.1
// @description  Track kudos & comments with score, icons, reset & stats
// @match        https://archiveofourown.org/*
// @run-at       document-idle
// @grant        GM.getValue
// @grant        GM.setValue
// ==/UserScript==

/* jshint esversion: 11 */

(async function () {
  'use strict';

  const CONFIG = {
    BOX_ID: 'ao3-karma-box',
    KUDOS_POINTS: 1,
    COMMENT_POINTS: 10,
    STORAGE_KEYS: { SCORE: 'score', KUDOS: 'kudos', COMMENT: 'comment', POS: 'pos' },
    TIME_RANGES: {
      Day: 86400000,
      Week: 604800000,
      Month: 2592000000,
      Year: 31536000000
    }
  };

  // One time only
  if (window.__ao3_karma_initialized) {
    window.__ao3_karma_refresh?.();
    return;
  }

  const store = {
    get: (key, def) => GM.getValue(CONFIG.STORAGE_KEYS[key], def),
    set: (key, val) => GM.setValue(CONFIG.STORAGE_KEYS[key], val)
  };

  function injectStyles() {
    const css = `
      #${CONFIG.BOX_ID} {
        position: fixed; top: 10px; right: 10px;
        width: 160px; height: 160px;
        background: #e0e0e0; color: #000;
        font: bold 48px sans-serif;
        display: flex; align-items: center; justify-content: center;
        border-radius: 12px; z-index: 999999;
        box-shadow: 0 2px 6px rgba(0,0,0,.2);
        cursor: grab; user-select: none;
        transition: transform .2s;
      }
      #${CONFIG.BOX_ID} .karma-action {
        position: absolute; cursor: pointer; color: gray;
        transition: color .2s, transform .1s;
      }
      #${CONFIG.BOX_ID} .karma-action:hover { transform: scale(1.1); }
    `;
    document.head.appendChild(Object.assign(document.createElement('style'), { textContent: css }));
  }

  class AO3Karma {
    constructor() {
      this.score = 0;
      this.kudosLog = {};
      this.commentLog = {};
    }

    async init() {
      injectStyles();
      await this.load();
      this.createUI();
      this.bindEvents();
      this.updateUI();
      window.__ao3_karma_refresh = () => this.refresh();
      window.__ao3_karma_initialized = true;
    }

    async load() {
      this.score = await store.get('SCORE', 0);
      this.kudosLog = await store.get('KUDOS', {}) || {};
      this.commentLog = await store.get('COMMENT', {}) || {};
    }

    async save() {
      await Promise.all([
        store.set('SCORE', this.score),
        store.set('KUDOS', this.kudosLog),
        store.set('COMMENT', this.commentLog)
      ]);
    }

    createUI() {
      document.getElementById(CONFIG.BOX_ID)?.remove();
      this.box = Object.assign(document.createElement('div'), { id: CONFIG.BOX_ID });

      this.scoreLabel = Object.assign(document.createElement('div'), { style: 'pointer-events:none' });
      this.box.appendChild(this.scoreLabel);

      const btn = (txt, pos, handler) => {
        const b = Object.assign(document.createElement('div'), { className: 'karma-action', textContent: txt });
        b.style.cssText = pos;
        b.onclick = e => { e.stopPropagation(); handler(); };
        return b;
      };

      this.heart = btn('♡', 'top:6px;left:8px;font-size:24px', () =>
        document.querySelector('#kudo_submit')?.scrollIntoView({ behavior: 'smooth', block: 'center' })
      );
      this.comment = btn('💬', 'top:6px;right:8px;font-size:22px', () =>
        document.querySelector('input[id^="comment_submit_for_"]')?.scrollIntoView({ behavior: 'smooth', block: 'center' })
      );
      const reset = btn('🔄', 'bottom:6px;left:8px;font-size:18px', () => this.reset());
      const stats = btn('📊', 'bottom:6px;right:8px;font-size:18px', () => this.stats());

      [this.heart, this.comment, reset, stats].forEach(b => this.box.appendChild(b));
      document.body.appendChild(this.box);

      this.makeDraggable();
      this.restorePos();
    }

    makeDraggable() {
      let dragging = false, offsetX = 0, offsetY = 0;
      this.box.addEventListener('mousedown', e => {
        if (e.target.classList.contains('karma-action')) return;
        dragging = true;
        this.box.style.cursor = 'grabbing';
        const r = this.box.getBoundingClientRect();
        offsetX = e.clientX - r.left;
        offsetY = e.clientY - r.top;
      });
      document.addEventListener('mousemove', e => {
        if (!dragging) return;
        const x = Math.min(window.innerWidth - this.box.offsetWidth, Math.max(0, e.clientX - offsetX));
        const y = Math.min(window.innerHeight - this.box.offsetHeight, Math.max(0, e.clientY - offsetY));
        Object.assign(this.box.style, { left: x + 'px', top: y + 'px', right: 'auto' });
      });
      document.addEventListener('mouseup', async () => {
        if (!dragging) return;
        dragging = false;
        this.box.style.cursor = 'grab';
        await store.set('POS', { top: this.box.style.top, left: this.box.style.left });
      });
    }

    async restorePos() {
      const pos = await store.get('POS', null);
      if (pos) Object.assign(this.box.style, pos, { right: 'auto' });
    }

    bindEvents() {
      document.addEventListener('click', e => {
        if (e.target?.id === 'kudo_submit') this.add('kudos', CONFIG.KUDOS_POINTS);
        if (e.target?.id?.startsWith('comment_submit_for_')) this.add('comment', CONFIG.COMMENT_POINTS, true);
      }, true);
      document.addEventListener('submit', e => {
        if (e.target?.querySelector('#kudo_commentable_id')) this.add('kudos', CONFIG.KUDOS_POINTS);
        if (e.target?.querySelector('input[id^="comment_submit_for_"]')) this.add('comment', CONFIG.COMMENT_POINTS, true);
      }, true);
    }

    getWorkId() { return document.querySelector('#kudo_commentable_id')?.value || null; }

    async add(type, points, requireText = false) {
      const id = this.getWorkId();
      if (!id) return;
      if (requireText) {
        const txt = document.querySelector('textarea[id^="comment_content_for_"]')?.value.trim();
        if (!txt) return;
      }
      const log = this[`${type}Log`];
      if (log[id]) return;
      log[id] = new Date().toISOString();
      this.score += points;
      await this.save();
      this.updateUI();
    }

    updateUI() {
      this.scoreLabel.textContent = this.score;
      const id = this.getWorkId();
      this.heart.textContent = (id && this.kudosLog[id]) ? '❤️' : '♡';
      this.heart.style.color = (id && this.kudosLog[id]) ? 'red' : 'gray';
      this.comment.style.color = (id && this.commentLog[id]) ? 'green' : 'gray';
    }

    async reset() {
      if (!confirm('Reset karma?')) return;
      this.score = 0; this.kudosLog = {}; this.commentLog = {};
      await this.save();
      this.updateUI();
    }

    stats() {
      const now = Date.now();
      const count = (log, ms) => Object.values(log).filter(ts => now - Date.parse(ts) <= ms).length;
      let msg = '⭐ Stats ⭐\n\n' +
        Object.entries(CONFIG.TIME_RANGES).map(([p, ms]) => {
          const k = count(this.kudosLog, ms), c = count(this.commentLog, ms);
          return `${p}: ${(k + c * CONFIG.COMMENT_POINTS)} points (❤️ ${k}, 💬 ${c})`;
        }).join('\n');
      msg += `\n\nAll-time: ${this.score} points (❤️ ${Object.keys(this.kudosLog).length}, 💬 ${Object.keys(this.commentLog).length})`;
      alert(msg);
    }

    async refresh() { await this.load(); this.updateUI(); }
  }

  new AO3Karma().init();
})();