Show lists on user profiles, including counts.
// ==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()
})()