Backloggery Plus

Show lists on user profiles, including counts.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(У мене вже є менеджер скриптів, дайте мені встановити його!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         Backloggery Plus
// @namespace    https://github.com/oniietzschan/backloggery-plus
// @version      0.1.0
// @description  Show lists on user profiles, including counts.
// @author       shru
// @match        https://backloggery.com/*
// @match        https://www.backloggery.com/*
// @grant        GM_addStyle
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_listValues
// @grant        GM_deleteValue
// @license Open Sores
// ==/UserScript==

(function () {
  'use strict'

  // ---------------------------------------------------------------------------
  // Helpers
  // ---------------------------------------------------------------------------

  const getVueApp = () => document.querySelector('#app')?.__vue__
  const getStore = () => getVueApp()?.$store
  const getRouter = () => getVueApp()?.$router

  // Initialize the script once Vue is ready.
  function onAppReady(fn) {
    if (getVueApp()) {
      fn()
      return
    }
    const observer = new MutationObserver(() => {
      if (getVueApp()) {
        observer.disconnect()
        fn()
      }
    })
    observer.observe(document.documentElement, { childList: true, subtree: true })
  }

  // Also run whenever the route changes.
  function onRouteChange(fn) {
    const router = getRouter()
    if (router) {
      fn(router.currentRoute)
      router.afterEach((to) => fn(to))
    }
  }

  // Create an <a> that navigates via Vue Router on click.
  function routerLink(path) {
    const a = document.createElement('a')
    a.href = path
    a.addEventListener('click', (e) => {
      e.preventDefault()
      getRouter()?.push(path)
    })
    return a
  }

  // <h1> is a good heuristic to know when the page has finished loading.
  function waitForHeading(text) {
    return new Promise((resolve, reject) => {
      const settle = (fn, arg) => {
        clearTimeout(timer)
        observer.disconnect()
        fn(arg)
      }
      const check = () => {
        const h = [...document.querySelectorAll('h1')].find(el => el.textContent.includes(text))
        if (h) settle(resolve, h)
      }
      const observer = new MutationObserver(check)
      const timer = setTimeout(() => settle(reject, new Error(`heading not found: ${text}`)), 10000)
      observer.observe(document.querySelector('#app'), { childList: true, subtree: true })
      check()
    })
  }

  // ---------------------------------------------------------------------------
  // Helpers: caching
  // ---------------------------------------------------------------------------

  const CACHE_TTL = 3600 * 1000

  // Memoize with cache expiry.
  async function memoize(key, resolveFn) {
    const cacheKey = `cache_${key}`
    const entry = GM_getValue(cacheKey, null)
    if (entry && typeof entry.expires === 'number' && entry.expires > Date.now()) {
      return entry.value
    }
    const value = await resolveFn()
    GM_setValue(cacheKey, { value, expires: Date.now() + CACHE_TTL })
    return value
  }

  // Manually prune expired cache entries.
  function pruneCache() {
    const now = Date.now()
    for (const key of GM_listValues()) {
      if (!key.startsWith('cache_')) continue
      const entry = GM_getValue(key, null)
      if (!entry || typeof entry.expires !== 'number' || entry.expires <= now) {
        GM_deleteValue(key)
      }
    }
  }

  // ---------------------------------------------------------------------------
  // Lists on Profile
  // ---------------------------------------------------------------------------

  const CSS = `
    .bgp-lists {
      margin-bottom: 0.5em;
    }
    .bgp-lists-grid {
      display: flex;
      flex-direction: column;
      gap: 4px;
    }
    .bgp-list-card {
      display: flex;
      border: 2px solid rgba(0, 0, 0, .5);
      border-radius: 6px;
      background: hsla(0, 0%, 100%, .05);
      color: var(--active-text, #fff);
      text-decoration: none;
      cursor: pointer;
      font-weight: 700;
      width: 100%;
    }
    .bgp-list-card .bgp-list-icon {
      display: flex;
      align-items: center;
      justify-content: center;
      background: var(--active-secondary, #272c34);
      border-radius: 4px 0 0 4px;
      padding: 5px 8px;
      min-width: 40px;
    }
    .bgp-list-card .bgp-list-icon svg {
      width: 1.5rem;
      height: 1.5rem;
      fill: var(--active-accent-75, #f58d47) !important;
      stroke: #000 !important;
    }
    .bgp-list-card .bgp-list-content {
      display: flex;
      flex-direction: column;
      flex: 1;
      min-width: 0;
    }
    .bgp-list-card .bgp-list-title {
      padding: 5px 10px;
      font-size: 15px;
      overflow: hidden;
      white-space: nowrap;
      text-overflow: ellipsis;
    }
    .bgp-list-card .bgp-list-desc {
      padding: 0 10px 5px;
      font-size: 0.85em;
      font-weight: 400;
      opacity: 0.75;
    }
    .bgp-list-card .bgp-list-progress {
      display: flex;
      align-items: center;
      justify-content: space-between;
      padding: 4px 10px 6px;
      gap: 8px;
    }
    .bgp-list-card .bgp-list-stats {
      display: flex;
      flex-wrap: wrap;
      align-items: center;
      gap: 4px;
    }
    .bgp-list-card .bgp-list-chip {
      display: inline-flex;
      align-items: center;
      padding: 1px 6px;
      border-radius: 3px;
      font-size: 12px;
    }
    .bgp-list-card .bgp-list-total {
      font-size: 14px;
      white-space: nowrap;
      opacity: 0.75;
    }
  `

  // The order here determines the display order.
  const STATUSES = [
    { id: 10, key: 'up', cls: 'unplayed',   label: 'UP' },
    { id: 20, key: 'uf', cls: 'unfinished', label: 'UF' },
    { id: 30, key: 'b',  cls: 'beaten',     label: 'B'  },
    { id: 40, key: 'c',  cls: 'completed',  label: 'C'  },
    { id: 60, key: 'e',  cls: 'endless',    label: 'E'  },
    { id: 80, key: 'n',  cls: 'none',       label: 'N'  },
  ]
  const STATUS_BY_ID = new Map(STATUSES.map(s => [s.id, s]))

  // Find the SVG sprite URL dynamically from an existing <use> on the page.
  function getSvgSpriteUrl() {
    const use = document.querySelector('use[href*="icons."]')
             || document.querySelector('use[xlink\\:href*="icons."]')
    const href = use && (use.getAttribute('href') || use.getAttribute('xlink:href'))
    return href ? href.split('#')[0] : '/img/icons.e56d98ff.svg'
  }

  // Tally game statuses into { up, uf, b, c, e, n, t }.
  function tallyStatuses(games) {
    const counts = { t: games.length }
    for (const s of STATUSES) counts[s.key] = 0
    for (const game of games) {
      const s = STATUS_BY_ID.get(Number(game.status))
      if (s) counts[s.key]++
    }
    return counts
  }

  async function postJson(url, body) {
    const resp = await fetch(url, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(body),
      credentials: 'same-origin',
    })
    return resp.json()
  }

  async function injectBacklogLists(route) {
    const username = route.params.url_name

    let breakdownH1
    try {
      breakdownH1 = await waitForHeading('Backlog Breakdown')
    } catch {
      return
    }

    // Guard against duplicate injection.
    if (breakdownH1.parentNode.querySelector('.bgp-lists')) return

    const store = getStore()
    const pageUser = store && store.getters.page_user
    if (!pageUser) return

    let lists
    try {
      const data = await postJson('/api/fetch_lists.php', { user_id: pageUser.user_id })
      if (!data.status || !Array.isArray(data.payload)) return
      lists = data.payload
    } catch {
      return
    }

    if (store.getters.relationship_with_page !== 'owner') {
      lists = lists.filter(l => !l.private)
    }

    if (lists.length === 0) return

    // Memoized per list_id so a refresh doesn't re-hit the API for every list
    // (which gets rate limited quickly). Each list is fetched at most once/hour.
    const gameResults = await Promise.all(lists.map(async (list) => {
      try {
        return await memoize(`list_games_${list.list_id}`, async () => {
          const data = await postJson('/api/fetch_list_games.php', { list_id: list.list_id })
          return data.status && Array.isArray(data.payload) ? data.payload : []
        })
      } catch {
        return []
      }
    }))

    const svgUrl = getSvgSpriteUrl()

    const container = document.createElement('div')
    container.className = 'bgp-lists'

    const heading = document.createElement('h1')
    heading.textContent = 'Lists'
    container.appendChild(heading)

    const grid = document.createElement('div')
    grid.className = 'bgp-lists-grid'

    lists.forEach((list, i) => {
      const counts = tallyStatuses(gameResults[i])

      const card = routerLink(`/${username}/lists/${list.list_id}`)
      card.className = 'bgp-list-card'

      const iconArea = document.createElement('div')
      iconArea.className = 'bgp-list-icon'
      const iconId = list.ranked ? 'rank' : 'list'
      iconArea.innerHTML = `<svg class="icon"><use href="${svgUrl}#${iconId}"></use></svg>`
      card.appendChild(iconArea)

      const content = document.createElement('div')
      content.className = 'bgp-list-content'

      const title = document.createElement('div')
      title.className = 'bgp-list-title'
      title.textContent = list.title
      if (list.private) title.textContent += ' \u{1F512}'
      content.appendChild(title)

      if (list.description) {
        const desc = document.createElement('div')
        desc.className = 'bgp-list-desc'
        desc.textContent = list.description
        content.appendChild(desc)
      }

      const progress = document.createElement('div')
      progress.className = 'bgp-list-progress'

      const stats = document.createElement('div')
      stats.className = 'bgp-list-stats'

      for (const s of STATUSES) {
        const count = counts[s.key]
        if (count === 0) continue
        const chip = document.createElement('span')
        chip.className = `bgp-list-chip ${s.cls}`
        chip.textContent = `${count} ${s.label}`
        stats.appendChild(chip)
      }
      progress.appendChild(stats)

      const total = document.createElement('div')
      total.className = 'bgp-list-total'
      total.textContent = `${counts.t} Total`
      progress.appendChild(total)

      content.appendChild(progress)
      card.appendChild(content)

      grid.appendChild(card)
    })

    container.appendChild(grid)

    breakdownH1.parentNode.insertBefore(container, breakdownH1)
  }

  // ---------------------------------------------------------------------------
  // Bootstrap
  // ---------------------------------------------------------------------------

  function init() {
    console.log('[Backloggery Plus] loaded')

    pruneCache()

    GM_addStyle(CSS)

    onAppReady(() => {
      onRouteChange((route) => {
        if (route.name !== 'backlog') return
        try {
          injectBacklogLists(route)
        } catch (err) {
          console.error('[Backloggery Plus] backlog-lists error:', err)
        }
      })
    })
  }

  init()
})()