Ignore SimpCity Threads

Hide selected threads from forum listings and manage them from the forum navigation.

이 스크립트를 설치하려면 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         Ignore SimpCity Threads
// @namespace    lovetransgaping
// @author       lovetransgaping
// @license      MIT
// @version      0.1.0
// @description  Hide selected threads from forum listings and manage them from the forum navigation.
// @match        https://simpcity.cr/*
// @match        https://www.simpcity.cr/*
// @grant        GM_addStyle
// @grant        GM_addValueChangeListener
// @grant        GM_getValue
// @grant        GM_registerMenuCommand
// @grant        GM_setValue
// @run-at       document-idle
// ==/UserScript==

(function () {
  'use strict'

  const STORAGE_KEY = 'simpcityIgnoredThreads.v1'
  const ROW_SELECTOR = '.js-threadList > .structItem--thread'
  const THREAD_PATH_PATTERN = /^\/threads\/([^/]+?)(?:\/|$)/
  const THREAD_ID_PATTERN = /(?:^|\.)(\d+)$/
  const UNDO_DURATION_MS = 5000

  let ignoredThreads = normalizeStoredThreads(GM_getValue(STORAGE_KEY, {}))
  let showIgnoredRows = false
  let undoTimer = 0
  let pendingUndo = null
  let managerFilter = ''
  let listRefreshQueued = false

  GM_addStyle(`
    .scti-row-title {
      min-height: 1.6em;
      padding-right: calc(5.2em + var(--scti-status-offset, 0px));
      position: relative;
    }

    .scti-row-toggle {
      background: hsla(var(--xf-buttonBg));
      border: 1px solid hsla(var(--xf-borderColor));
      border-radius: 3px;
      color: hsla(var(--xf-buttonColor));
      cursor: pointer;
      font: inherit;
      font-size: .78em;
      height: 1.65em;
      line-height: 1;
      padding: 0 .55em;
      position: absolute;
      right: var(--scti-status-offset, 0px);
      top: 0;
      white-space: nowrap;
    }

    .scti-row-toggle:hover,
    .scti-row-toggle:focus-visible {
      filter: brightness(1.15);
    }

    .scti-hidden {
      display: none !important;
    }

    .scti-ignored-revealed {
      opacity: .55;
    }

    .scti-ignored-revealed:hover,
    .scti-ignored-revealed:focus-within {
      opacity: .8;
    }

    .scti-all-hidden {
      align-items: center;
      background: hsla(var(--xf-contentAltBg));
      border-top: 1px solid hsla(var(--xf-borderColorLight));
      color: hsla(var(--xf-textColorMuted));
      display: flex;
      gap: 12px;
      justify-content: center;
      min-height: 72px;
      padding: 12px;
      text-align: center;
    }

    .scti-thread-toggle {
      appearance: none;
      background: transparent;
      border: 0;
      cursor: pointer;
      font: inherit;
    }

    .scti-thread-title-toggle {
      margin-left: 10px;
      vertical-align: middle;
    }

    .scti-manager-backdrop {
      align-items: flex-start;
      background: rgba(0, 0, 0, .68);
      display: flex;
      inset: 0;
      justify-content: center;
      overflow: auto;
      padding: 8vh 20px 40px;
      position: fixed;
      z-index: 100000;
    }

    .scti-manager-backdrop[hidden] {
      display: none;
    }

    .scti-manager {
      background: hsla(var(--xf-contentBg));
      border: 1px solid hsla(var(--xf-borderColor));
      border-radius: 6px;
      box-shadow: 0 12px 40px rgba(0, 0, 0, .5);
      color: hsla(var(--xf-textColor));
      max-width: 780px;
      width: 100%;
    }

    .scti-manager-header {
      align-items: center;
      border-bottom: 1px solid hsla(var(--xf-borderColorLight));
      display: flex;
      justify-content: space-between;
      padding: 14px 16px;
    }

    .scti-manager-title {
      font-size: 20px;
      font-weight: 600;
      margin: 0;
    }

    .scti-manager-close {
      appearance: none;
      background: transparent;
      border: 0;
      color: inherit;
      cursor: pointer;
      font-size: 26px;
      line-height: 1;
      padding: 0 4px;
    }

    .scti-manager-body {
      padding: 16px;
    }

    .scti-manager-filter {
      background: hsla(var(--xf-inputBg));
      border: 1px solid hsla(var(--xf-borderColor));
      border-radius: 4px;
      color: hsla(var(--xf-inputColor));
      box-sizing: border-box;
      font: inherit;
      margin-bottom: 12px;
      padding: 9px 10px;
      width: 100%;
    }

    .scti-manager-list {
      max-height: 60vh;
      overflow: auto;
    }

    .scti-manager-item {
      align-items: center;
      border-top: 1px solid hsla(var(--xf-borderColorLight));
      display: grid;
      gap: 6px 14px;
      grid-template-columns: minmax(0, 1fr) auto;
      padding: 12px 2px;
    }

    .scti-manager-item:first-child {
      border-top: 0;
    }

    .scti-manager-link {
      font-weight: 600;
      min-width: 0;
      overflow-wrap: anywhere;
    }

    .scti-manager-meta {
      color: hsla(var(--xf-textColorMuted));
      font-size: 12px;
      grid-column: 1;
    }

    .scti-manager-unignore {
      grid-column: 2;
      grid-row: 1 / span 2;
    }

    .scti-manager-empty {
      color: hsla(var(--xf-textColorMuted));
      padding: 28px 10px;
      text-align: center;
    }

    .scti-toast {
      align-items: center;
      background: #202428;
      border: 1px solid rgba(255, 255, 255, .2);
      border-radius: 5px;
      bottom: 24px;
      box-shadow: 0 4px 18px rgba(0, 0, 0, .45);
      color: #fff;
      display: flex;
      gap: 16px;
      max-width: min(440px, calc(100vw - 32px));
      padding: 10px 12px;
      position: fixed;
      right: 24px;
      z-index: 100001;
    }

    .scti-toast[hidden] {
      display: none;
    }

    .scti-toast button {
      background: transparent;
      border: 0;
      color: #8fc7ff;
      cursor: pointer;
      font: inherit;
      font-weight: 600;
      padding: 2px;
    }
  `)

  function normalizeStoredThreads(rawValue) {
    const normalized = {}
    const candidates = Array.isArray(rawValue)
      ? rawValue.map((id) => [String(id), { id: String(id) }])
      : Object.entries(rawValue && typeof rawValue === 'object' ? rawValue : {})

    for (const [key, value] of candidates) {
      const id = String(value && value.id ? value.id : key).match(/^\d+$/)?.[0]
      if (!id) continue

      normalized[id] = {
        id,
        title: cleanText(value && value.title) || `Thread #${id}`,
        url: canonicalThreadUrl(value && value.url, id),
        ignoredAt: Number(value && value.ignoredAt) || Date.now()
      }
    }

    return normalized
  }

  function cleanText(value) {
    return typeof value === 'string' ? value.replace(/\s+/g, ' ').trim() : ''
  }

  function threadIdFromUrl(value) {
    try {
      const url = new URL(value, location.origin)
      const segment = url.pathname.match(THREAD_PATH_PATTERN)?.[1] || ''
      return segment.match(THREAD_ID_PATTERN)?.[1] || ''
    } catch {
      return ''
    }
  }

  function canonicalThreadUrl(value, expectedId = '') {
    try {
      const url = new URL(value || `/threads/${expectedId}/`, location.origin)
      const segment = url.pathname.match(THREAD_PATH_PATTERN)?.[1] || ''
      const id = segment.match(THREAD_ID_PATTERN)?.[1] || expectedId
      if (!id) return ''

      const canonicalSegment = segment && threadIdFromUrl(url.href) === id ? segment : id
      return `${url.origin}/threads/${canonicalSegment}/`
    } catch {
      return expectedId ? `${location.origin}/threads/${expectedId}/` : ''
    }
  }

  function cloneThreads(value = ignoredThreads) {
    return Object.fromEntries(
      Object.entries(value).map(([id, record]) => [id, { ...record }])
    )
  }

  function saveThreads(nextThreads) {
    ignoredThreads = normalizeStoredThreads(nextThreads)
    GM_setValue(STORAGE_KEY, cloneThreads())
    refreshAllUi()
  }

  function ignoreThread(record, message = 'Thread ignored') {
    const id = String(record.id)
    if (ignoredThreads[id]) return

    const nextThreads = cloneThreads()
    nextThreads[id] = {
      id,
      title: cleanText(record.title) || `Thread #${id}`,
      url: canonicalThreadUrl(record.url, id),
      ignoredAt: Date.now()
    }
    saveThreads(nextThreads)
    showUndo(message, { type: 'ignore', record: nextThreads[id] })
  }

  function unignoreThread(id, message = 'Thread restored') {
    const record = ignoredThreads[id]
    if (!record) return

    const nextThreads = cloneThreads()
    delete nextThreads[id]
    saveThreads(nextThreads)
    showUndo(message, { type: 'unignore', record })
  }

  function refreshKnownRecord(record) {
    const current = ignoredThreads[record.id]
    if (!current) return false

    const title = cleanText(record.title) || current.title
    const url = canonicalThreadUrl(record.url, record.id) || current.url
    if (current.title === title && current.url === url) return false

    ignoredThreads[record.id] = { ...current, title, url }
    return true
  }

  function recordFromRow(row) {
    const classId = Array.from(row.classList)
      .map((className) => className.match(/^js-threadListItem-(\d+)$/)?.[1])
      .find(Boolean)
    const titleContainer = row.querySelector('.structItem-title')
    const titleLink = titleContainer?.querySelector('a[data-tp-primary], a[href*="/threads/"]')
    const id = classId || threadIdFromUrl(titleLink?.href)
    if (!id) return null

    return {
      id,
      title: cleanText(titleLink?.textContent) || `Thread #${id}`,
      url: canonicalThreadUrl(titleLink?.href, id)
    }
  }

  function recordFromThreadPage() {
    const contentKey = document.documentElement.dataset.contentKey || ''
    const id = contentKey.match(/^thread-(\d+)$/)?.[1] || threadIdFromUrl(location.href)
    if (!id) return null

    const heading = document.querySelector('.p-title-value')
    const headingCopy = heading?.cloneNode(true)
    headingCopy?.querySelectorAll('.labelLink, .label-append, .scti-thread-toggle').forEach((node) => node.remove())
    const canonicalLink = document.querySelector('link[rel="canonical"]')?.href

    return {
      id,
      title: cleanText(headingCopy?.textContent) || `Thread #${id}`,
      url: canonicalThreadUrl(canonicalLink || location.href, id)
    }
  }

  function makeButton(text, className, onClick) {
    const button = document.createElement('button')
    button.type = 'button'
    button.className = className
    button.textContent = text
    button.addEventListener('pointerdown', (event) => event.stopPropagation())
    button.addEventListener('click', (event) => {
      event.preventDefault()
      event.stopPropagation()
      onClick(event)
    })
    return button
  }

  function processForumRows() {
    const rows = Array.from(document.querySelectorAll(ROW_SELECTOR))
    let metadataChanged = false

    for (const row of rows) {
      const record = recordFromRow(row)
      if (!record) continue

      row.dataset.sctiThreadId = record.id
      metadataChanged = refreshKnownRecord(record) || metadataChanged

      const titleContainer = row.querySelector('.structItem-title')
      if (!titleContainer) continue

      titleContainer.classList.add('scti-row-title')
      const statusWidth = Math.ceil(
        row.querySelector('.structItem-statuses')?.getBoundingClientRect().width || 0
      )
      const statusOffset = statusWidth > 0 ? statusWidth + 6 : 0
      titleContainer.style.setProperty('--scti-status-offset', `${statusOffset}px`)
      let button = titleContainer.querySelector('.scti-row-toggle')
      if (!button) {
        button = makeButton('Ignore', 'scti-row-toggle', () => {
          if (ignoredThreads[record.id]) {
            unignoreThread(record.id)
          } else {
            ignoreThread(record)
          }
        })
        button.dataset.threadId = record.id
        titleContainer.append(button)
      }
    }

    if (metadataChanged) GM_setValue(STORAGE_KEY, cloneThreads())
    renderForumRows(rows)
  }

  function renderForumRows(rows = Array.from(document.querySelectorAll(ROW_SELECTOR))) {
    let ignoredOnPage = 0
    let visibleRows = 0

    for (const row of rows) {
      const record = recordFromRow(row)
      if (!record) continue

      const isIgnored = Boolean(ignoredThreads[record.id])
      const button = row.querySelector('.scti-row-toggle')
      row.classList.toggle('scti-hidden', isIgnored && !showIgnoredRows)
      row.classList.toggle('scti-ignored-revealed', isIgnored && showIgnoredRows)
      if (button) button.textContent = isIgnored ? 'Unignore' : 'Ignore'
      if (isIgnored) ignoredOnPage += 1
      if (!isIgnored || showIgnoredRows) visibleRows += 1
    }

    if (ignoredOnPage === 0) showIgnoredRows = false

    renderShowIgnoredControl(ignoredOnPage)
    renderAllHiddenMessage(rows.length > 0 && visibleRows === 0, ignoredOnPage)
  }

  function findMarkReadButton() {
    return Array.from(document.querySelectorAll('.block-outer .buttonGroup > a, .block-outer .buttonGroup > button'))
      .find((element) => cleanText(element.textContent).toLowerCase() === 'mark read')
  }

  function renderShowIgnoredControl(ignoredOnPage) {
    let control = document.querySelector('.scti-show-toggle')
    const markRead = findMarkReadButton()

    if (!markRead || ignoredOnPage === 0) {
      control?.remove()
      return
    }

    if (!control) {
      control = makeButton('', 'button button--link scti-show-toggle', () => {
        showIgnoredRows = !showIgnoredRows
        renderForumRows()
      })
      const text = document.createElement('span')
      text.className = 'button-text'
      control.append(text)
      markRead.before(control)
    }

    control.querySelector('.button-text').textContent = `${showIgnoredRows ? 'Hide' : 'Show'} ignored (${ignoredOnPage})`
    control.setAttribute('aria-pressed', String(showIgnoredRows))
  }

  function renderAllHiddenMessage(isAllHidden, ignoredOnPage) {
    const list = document.querySelector('.js-threadList')
    let message = document.querySelector('.scti-all-hidden')
    if (!list || !isAllHidden) {
      message?.remove()
      return
    }

    if (!message) {
      message = document.createElement('div')
      message.className = 'scti-all-hidden'
      const label = document.createElement('span')
      label.textContent = 'All threads on this page are ignored.'
      const showButton = makeButton('', 'button button--link', () => {
        showIgnoredRows = true
        renderForumRows()
      })
      const buttonText = document.createElement('span')
      buttonText.className = 'button-text'
      showButton.append(buttonText)
      message.append(label, showButton)
      list.after(message)
    }

    message.querySelector('.button-text').textContent = `Show ignored (${ignoredOnPage})`
  }

  function installThreadPageControl() {
    const record = recordFromThreadPage()
    if (!record) return

    if (refreshKnownRecord(record)) GM_setValue(STORAGE_KEY, cloneThreads())
    let control = document.querySelector('.scti-thread-toggle')
    if (!control) {
      control = makeButton('', 'tabs-tab scti-thread-toggle', () => {
        if (ignoredThreads[record.id]) {
          unignoreThread(record.id)
        } else {
          ignoreThread(record)
        }
      })
      control.dataset.threadId = record.id

      const sortByDate = Array.from(document.querySelectorAll('.tabs-tab'))
        .find((tab) => cleanText(tab.textContent).toLowerCase() === 'sort by date')
      if (sortByDate) {
        sortByDate.before(control)
      } else {
        control.className = 'button button--link scti-thread-toggle scti-thread-title-toggle'
        document.querySelector('.p-title-value')?.append(control)
      }
    }

    renderThreadPageControl()
  }

  function renderThreadPageControl() {
    const control = document.querySelector('.scti-thread-toggle')
    if (!control) return
    control.textContent = ignoredThreads[control.dataset.threadId] ? 'Unignore thread' : 'Ignore thread'
  }

  function installManagerNavigation() {
    const searchLink = document.querySelector('.p-sectionLinks-list > li > .p-navEl > a[data-nav-id="searchForums"]')
    if (!searchLink) return

    let link = document.querySelector('.scti-manager-nav-link')
    if (!link) {
      const listItem = document.createElement('li')
      const navElement = document.createElement('div')
      navElement.className = 'p-navEl'
      link = document.createElement('a')
      link.href = '#'
      link.className = 'p-navEl-link scti-manager-nav-link'
      link.addEventListener('click', (event) => {
        event.preventDefault()
        openManager()
      })
      navElement.append(link)
      listItem.append(navElement)
      searchLink.closest('li').before(listItem)
    }

    renderManagerNavigation()
  }

  function renderManagerNavigation() {
    const link = document.querySelector('.scti-manager-nav-link')
    if (link) link.textContent = `Ignored threads (${Object.keys(ignoredThreads).length})`
  }

  function ensureManager() {
    let backdrop = document.querySelector('.scti-manager-backdrop')
    if (backdrop) return backdrop

    backdrop = document.createElement('div')
    backdrop.className = 'scti-manager-backdrop'
    backdrop.hidden = true
    backdrop.innerHTML = `
      <section class="scti-manager" role="dialog" aria-modal="true" aria-labelledby="scti-manager-title">
        <header class="scti-manager-header">
          <h2 class="scti-manager-title" id="scti-manager-title">Ignored threads</h2>
          <button type="button" class="scti-manager-close" aria-label="Close ignored threads manager">x</button>
        </header>
        <div class="scti-manager-body">
          <input class="scti-manager-filter" type="search" placeholder="Filter by title or thread ID" aria-label="Filter ignored threads">
          <div class="scti-manager-list"></div>
        </div>
      </section>
    `
    backdrop.addEventListener('click', (event) => {
      if (event.target === backdrop) closeManager()
    })
    backdrop.querySelector('.scti-manager-close').addEventListener('click', closeManager)
    backdrop.querySelector('.scti-manager-filter').addEventListener('input', (event) => {
      managerFilter = event.target.value
      renderManager()
    })
    document.addEventListener('keydown', (event) => {
      if (event.key === 'Escape' && !backdrop.hidden) closeManager()
    })
    document.body.append(backdrop)
    return backdrop
  }

  function openManager() {
    const backdrop = ensureManager()
    backdrop.hidden = false
    renderManager()
    backdrop.querySelector('.scti-manager-filter').focus()
  }

  function closeManager() {
    const backdrop = document.querySelector('.scti-manager-backdrop')
    if (backdrop) backdrop.hidden = true
  }

  function renderManager() {
    const backdrop = document.querySelector('.scti-manager-backdrop')
    if (!backdrop) return

    const list = backdrop.querySelector('.scti-manager-list')
    const query = cleanText(managerFilter).toLowerCase()
    const records = Object.values(ignoredThreads)
      .filter((record) => !query || record.id.includes(query) || record.title.toLowerCase().includes(query))
      .sort((left, right) => right.ignoredAt - left.ignoredAt)

    list.replaceChildren()
    if (records.length === 0) {
      const empty = document.createElement('div')
      empty.className = 'scti-manager-empty'
      empty.textContent = Object.keys(ignoredThreads).length === 0
        ? 'No threads are ignored.'
        : 'No ignored threads match this filter.'
      list.append(empty)
      return
    }

    for (const record of records) {
      const item = document.createElement('div')
      item.className = 'scti-manager-item'

      const link = document.createElement('a')
      link.className = 'scti-manager-link'
      link.href = record.url
      link.target = '_blank'
      link.rel = 'noopener noreferrer'
      link.textContent = record.title

      const meta = document.createElement('div')
      meta.className = 'scti-manager-meta'
      meta.textContent = `Thread #${record.id} | Ignored ${formatDate(record.ignoredAt)}`

      const button = makeButton('Unignore', 'button button--link scti-manager-unignore', () => {
        unignoreThread(record.id)
      })
      item.append(link, meta, button)
      list.append(item)
    }
  }

  function formatDate(timestamp) {
    return new Intl.DateTimeFormat(undefined, {
      dateStyle: 'medium',
      timeStyle: 'short'
    }).format(new Date(timestamp))
  }

  function ensureToast() {
    let toast = document.querySelector('.scti-toast')
    if (toast) return toast

    toast = document.createElement('div')
    toast.className = 'scti-toast'
    toast.hidden = true
    toast.setAttribute('role', 'status')
    const message = document.createElement('span')
    message.className = 'scti-toast-message'
    const undoButton = makeButton('Undo', '', performUndo)
    toast.append(message, undoButton)
    document.body.append(toast)
    return toast
  }

  function showUndo(message, action) {
    pendingUndo = action
    const toast = ensureToast()
    toast.querySelector('.scti-toast-message').textContent = message
    toast.hidden = false
    window.clearTimeout(undoTimer)
    undoTimer = window.setTimeout(hideUndo, UNDO_DURATION_MS)
  }

  function hideUndo() {
    pendingUndo = null
    const toast = document.querySelector('.scti-toast')
    if (toast) toast.hidden = true
  }

  function performUndo() {
    if (!pendingUndo) return

    const action = pendingUndo
    hideUndo()
    const nextThreads = cloneThreads()
    if (action.type === 'ignore') {
      delete nextThreads[action.record.id]
    } else {
      nextThreads[action.record.id] = action.record
    }
    saveThreads(nextThreads)
  }

  function refreshAllUi() {
    processForumRows()
    renderThreadPageControl()
    renderManagerNavigation()
    renderManager()
  }

  function queueListRefresh() {
    if (listRefreshQueued) return
    listRefreshQueued = true
    queueMicrotask(() => {
      listRefreshQueued = false
      processForumRows()
    })
  }

  function observeThreadList() {
    const list = document.querySelector('.js-threadList')
    if (!list) return
    new MutationObserver(queueListRefresh).observe(list, { childList: true })
  }

  installManagerNavigation()
  processForumRows()
  installThreadPageControl()
  observeThreadList()
  ensureManager()
  ensureToast()

  if (typeof GM_registerMenuCommand === 'function') {
    GM_registerMenuCommand('Manage ignored threads', openManager)
  }

  if (typeof GM_addValueChangeListener === 'function') {
    GM_addValueChangeListener(STORAGE_KEY, (_name, _oldValue, newValue) => {
      ignoredThreads = normalizeStoredThreads(newValue)
      refreshAllUi()
    })
  }
})()