Backloggery Plus

Show lists on user profiles, including counts.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==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()
})()