GGn Tag selector

Enhanced Tag selector for GGn

  1. // ==UserScript==
  2. // @name GGn Tag selector
  3. // @description Enhanced Tag selector for GGn
  4. // @namespace ggntagselector
  5. // @version 1.3
  6. // @match *://gazellegames.net/upload.php*
  7. // @match *://gazellegames.net/torrents.php?*action=advanced*
  8. // @match *://gazellegames.net/torrents.php*id=*
  9. // @exclude *://gazellegames.net/torrents.php*action=editgroup*
  10. // @match *://gazellegames.net/requests.php*
  11. // @match *://gazellegames.net/user.php*action=edit*
  12. // @grant GM.setValue
  13. // @grant GM.getValue
  14. // @grant GM_setValue
  15. // @grant GM_getValue
  16. // @grant GM_addStyle
  17. // @license MIT
  18. // @author tweembp, ingts
  19. // ==/UserScript==
  20. // noinspection CssUnresolvedCustomProperty,CssUnusedSymbol
  21.  
  22. const locationhref = location.href
  23. const isUploadPage = locationhref.includes('upload.php'),
  24. isGroupPage = locationhref.includes('torrents.php?id='),
  25. isSearchPage = locationhref.includes('action=advanced'),
  26. isRequestPage = locationhref.includes('requests.php') && !locationhref.includes('action=new'),
  27. isCreateRequestPage = locationhref.includes('action=new'),
  28. isUserPage = locationhref.includes('user.php')
  29.  
  30. const SEPERATOR = '|'
  31. const TAGSEPERATOR = ', '
  32. const defaultHotkeys = {
  33. 'favorite': [
  34. 'shift + digit1',
  35. 'shift + digit2',
  36. 'shift + digit3',
  37. 'shift + digit4',
  38. 'shift + digit5',
  39. 'shift + digit6',
  40. 'shift + digit7',
  41. 'shift + digit8',
  42. 'shift + digit9',
  43. ],
  44. 'preset': [
  45. 'alt + digit1',
  46. 'alt + digit2',
  47. 'alt + digit3',
  48. 'alt + digit4',
  49. 'alt + digit5',
  50. 'alt + digit6',
  51. 'alt + digit7',
  52. 'alt + digit8',
  53. 'alt + digit9',
  54. ],
  55. }
  56. const defaulthotkeyPrefixes = {
  57. 'show_indices': 'shift'
  58. }
  59. const modifiers = ["shift", "alt", "ctrl", "cmd"]
  60. const categoryDict = {
  61. "genre": [
  62. "4x",
  63. "action",
  64. "adventure",
  65. "aerial.combat",
  66. "agriculture",
  67. "arcade",
  68. "auto.battler",
  69. "beat.em.up",
  70. "board.game",
  71. "building",
  72. "bullet.hell",
  73. "card.game",
  74. "casual",
  75. "childrens",
  76. "city.building",
  77. "clicker",
  78. "creature.collector",
  79. "d10.system",
  80. "d20.system",
  81. "driving",
  82. "dungeon.crawler",
  83. "educational",
  84. "espionage",
  85. "exploration",
  86. "fighting",
  87. "fitness",
  88. "game.show",
  89. "grand.strategy",
  90. "hack.and.slash",
  91. "hidden.object",
  92. "horror",
  93. "hunting",
  94. "interactive.fiction",
  95. "jigsaw",
  96. "karaoke",
  97. "management",
  98. "match.3",
  99. "mini.game",
  100. "music",
  101. "open.world",
  102. "parody",
  103. "party",
  104. "pinball",
  105. "platform",
  106. "point.and.click",
  107. "puzzle",
  108. "quiz",
  109. "rhythm",
  110. "roguelike",
  111. "role.playing.game",
  112. "runner",
  113. "sandbox",
  114. "shoot.em.up",
  115. "shooter",
  116. "first.person.shooter",
  117. "third.person.shooter",
  118. "simulation",
  119. "solitaire",
  120. "space",
  121. "stealth",
  122. "strategy",
  123. "real.time.strategy",
  124. "turn.based.strategy",
  125. "stunts",
  126. "survival",
  127. "tabletop",
  128. "tactics",
  129. "text.adventure",
  130. "time.management",
  131. "tower.defense",
  132. "trivia",
  133. "typing",
  134. "vehicular.combat",
  135. "visual.novel",
  136. "wargame",
  137. "word.game",
  138. "word.construction",
  139. ],
  140. "theme": [
  141. "adult",
  142. "romance",
  143. "comedy",
  144. "crime",
  145. "drama",
  146. "fantasy",
  147. "historical",
  148. "mystery",
  149. "thriller",
  150. "science.fiction",
  151. ],
  152. "sports": [
  153. "american.football",
  154. "baseball",
  155. "basketball",
  156. "billiards",
  157. "blackjack",
  158. "bowling",
  159. "boxing",
  160. "chess",
  161. "cricket",
  162. "cycling",
  163. "extreme.sports",
  164. "fishing",
  165. "go",
  166. "golf",
  167. "hockey",
  168. "ice.skating",
  169. "mahjong",
  170. "pachinko",
  171. "pinball",
  172. "poker",
  173. "racing",
  174. "rugby",
  175. "skateboarding",
  176. "slots",
  177. "snowboarding",
  178. "soccer",
  179. "sports",
  180. "tennis",
  181. "wrestling",
  182. ],
  183. "simulation": [
  184. "business.simulation",
  185. "construction.simulation",
  186. "dating.simulation",
  187. "flight.simulation",
  188. "life.simulation",
  189. "space.simulation",
  190. "vehicle.simulation",
  191. "walking.simulation",
  192. ],
  193. "ost": [
  194. "acappella",
  195. "acid.house",
  196. "acid.jazz",
  197. "acoustic",
  198. "afrobeat",
  199. "alternative",
  200. "ambient",
  201. "arrangement",
  202. "ballad",
  203. "black.metal",
  204. "breakbeat",
  205. "breakcore",
  206. "chill.out",
  207. "chillwave",
  208. "chipbreak",
  209. "chiptune",
  210. "choral",
  211. "citypop",
  212. "classical",
  213. "country",
  214. "dance",
  215. "dark.ambient",
  216. "dark.electro",
  217. "dark.synth",
  218. "dark.wave",
  219. "downtempo",
  220. "dream.pop",
  221. "drum.and.bass",
  222. "dubstep",
  223. "electro",
  224. "electronic",
  225. "electronic.rock",
  226. "epic.metal",
  227. "euro.house",
  228. "experimental",
  229. "folk",
  230. "funk",
  231. "happy.hardcore",
  232. "hardcore",
  233. "heavy.metal",
  234. "hip.hop",
  235. "horrorcore",
  236. "house",
  237. "hymn",
  238. "indie.pop",
  239. "indie.rock",
  240. "industrial",
  241. "instrumental",
  242. "jazz",
  243. "lo.fi",
  244. "modern.classical",
  245. "new.age",
  246. "opera",
  247. "orchestral",
  248. "phonk",
  249. "piano",
  250. "pop",
  251. "rhythm.and.blues",
  252. "rock",
  253. "smooth.jazz",
  254. "sound.effects",
  255. "symphonic",
  256. "synth",
  257. "synth.pop",
  258. "synthwave",
  259. "traditional",
  260. "techno",
  261. "trance",
  262. "vaporwave",
  263. "violin",
  264. "vocal",
  265. ],
  266. "books": [
  267. "art.book",
  268. "collection",
  269. "comic.book",
  270. "fiction",
  271. "game.design",
  272. "game.programming",
  273. "psychology",
  274. "social.science",
  275. "gamebook",
  276. "graphic.novel",
  277. "guide",
  278. "magazine",
  279. "non.fiction",
  280. "novelization",
  281. "programming",
  282. "business",
  283. "reference",
  284. "study"
  285. ],
  286. "applications": [
  287. "apps.windows",
  288. "apps.linux",
  289. "apps.mac",
  290. "apps.android",
  291. "utility",
  292. "development",
  293. ],
  294. }
  295. // relevant keys for each upload category
  296. const categoryKeys = {
  297. 'Games': ["genre", "theme", "sports", "simulation"],
  298. 'E-Books': ['books'],
  299. 'Applications': ['applications'],
  300. 'OST': ['ost']
  301. }
  302. const specialTags = ['pack', 'collection']
  303.  
  304. // common functions
  305. function titlecase(s) {
  306. let out = s.split('.').map((e) => {
  307. if (!["and", "em"].includes(e)) {
  308. return e[0].toUpperCase() + e.slice(1)
  309. } else {
  310. return e
  311. }
  312. }).join(' ')
  313. return out[0].toUpperCase() + out.slice(1)
  314. }
  315.  
  316. function normalise_combo_string(s) {
  317. return s.trim().split('+').map((c) => c.trim().toLowerCase()).join(' + ')
  318. }
  319.  
  320. function observe_element(element, property, callback, delay = 0) {
  321. let elementPrototype = Object.getPrototypeOf(element)
  322. if (elementPrototype.hasOwnProperty(property)) {
  323. let descriptor = Object.getOwnPropertyDescriptor(elementPrototype, property)
  324. Object.defineProperty(element, property, {
  325. get: function () {
  326. return descriptor.get.apply(this, arguments)
  327. },
  328. set: function () {
  329. let oldValue = this[property]
  330. descriptor.set.apply(this, arguments)
  331. let newValue = this[property]
  332. if (typeof callback == "function") {
  333. setTimeout(callback.bind(this, oldValue, newValue), delay)
  334. }
  335. return newValue
  336. },
  337. configurable: true
  338. })
  339. }
  340. }
  341.  
  342.  
  343. function addTagFromSearch(event) {
  344. let tag = event.target.value.trim()
  345. tag = tag.replaceAll(' ', '.')
  346. if (tag.length > 0) {
  347. add_tag(tag)
  348. }
  349. }
  350.  
  351. if (!isUserPage) {
  352. if (isSearchPage) {
  353. const taglist = document.getElementById('taglist')
  354. taglist.style.display = 'none'
  355. taglist.nextElementSibling.style.display = 'none'
  356. }
  357. if (isGroupPage) {
  358. document.getElementById('tags_add_note').remove() // "To add multiple tags separate by comma" text
  359. }
  360. // load settings
  361. let currentFavoritesDict = (GM_getValue('gts_favorites')) || {}
  362. let currentPresetsDict = (GM_getValue('gts_presets')) || {}
  363. let hotkeys = (GM_getValue('gts_hotkeys')) || defaultHotkeys
  364. let hotkeyPrefixes = (GM_getValue('gts_hotkey_prefixes')) || defaulthotkeyPrefixes
  365.  
  366. let searchStringDict = {}
  367. for (const tags of Object.values(categoryDict)) {
  368. // map from tag => search title, string
  369. for (const tag of tags) {
  370. const title = titlecase(tag)
  371. searchStringDict[tag] = `${title.toLowerCase()}${SEPERATOR}${tag}`
  372. }
  373. }
  374.  
  375. let foundTags = -1
  376. let windowEvents = []
  377.  
  378. // language=CSS
  379. GM_addStyle(`
  380. .gts-selector *::-webkit-scrollbar {
  381. width: 3px;
  382. }
  383.  
  384. .gts-selector *::-webkit-scrollbar-track {
  385. background: transparent;
  386. }
  387.  
  388. .gts-selector *::-webkit-scrollbar-thumb {
  389. background-color: rgba(155, 155, 155, 0.5);
  390. border-radius: 20px;
  391. border: transparent;
  392. }
  393.  
  394. .gts-unlisted-tag {
  395. color: coral !important;
  396. }
  397.  
  398. .gts-remove-unlisted {
  399. margin-top: 15px;
  400. }
  401.  
  402. #genre_tags {
  403. display: none !important
  404. }
  405.  
  406. .gts-add-preset {
  407. display: none;
  408. }
  409. .gts-selector {
  410. display: none;
  411. position: absolute;
  412. background-color: rgb(27, 48, 63);
  413. box-sizing: border-box;
  414. padding: .5em 1em 1em 1em;
  415. border: 3px solid var(--rowb);
  416. box-shadow: -3px 3px 5px var(--black);
  417. z-index: 99999;
  418. grid-template-columns: auto fit-content(180px) fit-content(180px);
  419. column-gap: 1em;
  420. min-width: min-content !important;
  421. max-width: 1000px !important;
  422. font-size: 13px;
  423. }
  424.  
  425. .gts-selector h1 {
  426. margin: 0;
  427. font-weight: normal;
  428. padding-bottom: 0;
  429. }
  430.  
  431. .gts-tag {
  432. height: fit-content;
  433. font-family: inherit;
  434. font-size: inherit;
  435. opacity: 1 !important;
  436. background: none!important;
  437. border: none;
  438. padding: 0!important;
  439. color: var(--lightBlue);
  440. text-decoration: none;
  441. cursor: pointer;
  442. text-align: start;
  443. }
  444.  
  445. .gts-sidearea {
  446. min-width: 150px;
  447. box-sizing: border-box;
  448. border-left: 2px solid var(--grey);
  449. padding-left: 1em;
  450. }
  451.  
  452. .gts-selector .gts-sidearea h1 {
  453. font-size: 1.2em;
  454. margin-top: 1em;
  455. margin-bottom: 0.25em;
  456. }
  457.  
  458. .gts-sidearea h1:nth-child(2) {
  459. margin-top: 0;
  460. }
  461.  
  462. .gts-current-tags-inner {
  463. font-size: 0.9em;
  464. margin-top: 1em;
  465. overflow-y: auto;
  466. max-height: 320px;
  467. }
  468.  
  469. .gts-searchbar {
  470. display: flex;
  471. column-gap: 1em;
  472. margin-bottom: 1em;
  473. align-items: center;
  474. div {
  475. width: fit-content !important;
  476. }
  477. }
  478. #gts-search {
  479. flex: 1;
  480. }
  481.  
  482. .gts-categoryarea {
  483. display: grid;
  484. grid-template-columns: 1fr 1fr;
  485. column-gap: 10px;
  486.  
  487. .gts-right {
  488. display: grid;
  489. grid-template-columns: 1fr 1fr;
  490. height: 100%;
  491. column-gap: 1em;
  492. width: max-content;
  493. }
  494.  
  495. .gts-left {
  496. height: 100%;
  497. }
  498.  
  499. .gts-category-inner {
  500. display: grid;
  501. grid-template-columns: 1fr;
  502. column-gap: 1em;
  503. overflow-y: auto;
  504. max-height: 145px;
  505. }
  506. }
  507.  
  508. .gts-category .gts-category-inner, #gts-favoritearea, #gts-presetarea {
  509. font-size: .9em;
  510. margin-top: 0.5em;
  511. width: max-content !important;
  512. }
  513.  
  514. #gts-presetarea {
  515. max-height: 140px;
  516. overflow-y: auto;
  517. width: unset !important;
  518. }
  519.  
  520. #gts-favoritearea {
  521. max-height: 140px;
  522. overflow-y: auto;
  523. /*grid-template-columns: 1fr 1fr;*/
  524. /*display: grid;*/
  525. /*column-gap: .5em;*/
  526. }
  527.  
  528. .gts-category h1 {
  529. font-size: 1.1em;
  530. }
  531.  
  532. .gts-category-genre .gts-category-inner {
  533. grid-template-columns: auto auto;
  534. max-height: 320px;
  535. }
  536.  
  537. .gts-tag-idx {
  538. color: yellow;
  539. font-weight: bold;
  540. margin-left: 0.25em;
  541. }
  542.  
  543. .hide-idx .gts-tag-idx {
  544. visibility: hidden;;
  545. }
  546.  
  547. #gts-selector a {
  548. font-size: inherit !important;
  549. }
  550.  
  551. .gts-tag-link-wrapper {
  552. width: fit-content !important;
  553. max-width: 100px;
  554. scroll-snap-align: start;
  555. }
  556.  
  557. .gts-tag-wrapper {
  558. width: fit-content !important;
  559. max-width: 100px;
  560. }
  561.  
  562. .gts-category .gts-tag-wrapper {
  563. width: fit-content(120px);
  564. }
  565.  
  566. .gts-category-genre .gts-tag-wrapper {
  567. max-width: 120px;
  568. width: max-content !important;
  569. }
  570.  
  571. .gts-category .gts-tag-link-wrapper {
  572. width: fit-content(120px);
  573. }
  574.  
  575. .gts-category-genre .gts-tag-link-wrapper {
  576. max-width: 120px;
  577. width: max-content !important;
  578. }
  579.  
  580. .gts-category-simulation {
  581. grid-column: span 2;
  582.  
  583. .gts-tag-wrapper {
  584. max-width: unset;
  585. }
  586. .gts-category-inner {
  587. width: 100% !important;
  588. }
  589. }
  590.  
  591. /*region non-Games*/
  592. .gts-categoryarea-E-Books,
  593. .gts-categoryarea-E-Books .gts-right,
  594. .gts-categoryarea-Applications,
  595. .gts-categoryarea-Applications .gts-right,
  596. .gts-categoryarea-OST,
  597. .gts-categoryarea-OST .gts-right {
  598. grid-template-columns: 1fr;
  599. }
  600.  
  601. .gts-categoryarea-E-Books .gts-category .gts-category-inner,
  602. .gts-categoryarea-OST .gts-category .gts-category-inner,
  603. .gts-categoryarea-Applications .gts-category .gts-category-inner {
  604. max-height: 300px;
  605. grid-template-columns: repeat(6, fit-content(180px));
  606. row-gap: 0.3em;
  607. }
  608.  
  609. /*endregion*/
  610. `)
  611. // renderer functions
  612. let tagBox, searchBox, modal, presetButton, currentUploadCategory, showIndicess, removeUnlistedButton
  613. let allCurrentCategoryTags = []
  614.  
  615. function render_tag_links(tags, idx) {
  616. let html = ''
  617. for (const tag of tags) {
  618. html += `<div class="gts-tag-wrapper"><button type="button" class="gts-tag" data-tag-idx="${idx}" data-tag="${tag}">${titlecase(tag)}</button>`
  619. if (idx < 9) {
  620. html += `<span data-tag-idx="${idx}" class="gts-tag-idx">${idx + 1}</span>`
  621. }
  622. html += `</div>`
  623. idx += 1
  624. }
  625. return [html, idx]
  626. }
  627.  
  628. function filter_category_dict(query, categoryDict, currentUploadCategory = 'Games') {
  629. let filteredDict = {}
  630. foundTags = []
  631. for (const [category, tags] of Object.entries(categoryDict)) {
  632. if (!categoryKeys[currentUploadCategory].includes(category)) {
  633. continue
  634. }
  635. filteredDict[category] = []
  636. for (const tag of tags) {
  637. if (searchStringDict[tag].includes(query)) {
  638. filteredDict[category].push(tag)
  639. foundTags.push(tag)
  640. }
  641. }
  642. }
  643. return filteredDict
  644. }
  645.  
  646. function draw_currenttagsarea() {
  647. removeUnlistedButton.style.display = 'none'
  648. let html = `<h1>Current Tags</h1> (<small>Click to remove</small>)
  649. <div class="gts-current-tags-inner">`
  650. const tags = parse_text_to_tag_list(tagBox.value.trim())
  651.  
  652. const unlistedTags = tags.filter(tag => !allCurrentCategoryTags.includes(tag))
  653. for (const [idx, tag] of tags.entries()) {
  654. html += `<div>${idx + 1}. <button type="button" class="gts-tag ${unlistedTags.includes(tag) ? 'gts-unlisted-tag' : ''}" data-tag="${tag}">${titlecase(tag)}</button></div>`
  655. }
  656. html += `</div>`
  657. const tagArea = document.querySelector('#gts-currenttagsarea')
  658. tagArea.innerHTML = html
  659. for (const tagLink of tagArea.querySelectorAll('.gts-tag')) {
  660. tagLink.onclick = event => {
  661. event.preventDefault()
  662. const currentTags = parse_text_to_tag_list(tagBox.value.trim())
  663. const clickedTag = event.target.getAttribute('data-tag')
  664. tagBox.value = currentTags.filter(t => t !== clickedTag).join(TAGSEPERATOR)
  665. // draw_currenttagsarea()
  666. }
  667. }
  668. if (unlistedTags.length > 0) {
  669. removeUnlistedButton.style.display = 'block'
  670. removeUnlistedButton.onclick = () => {
  671. for (const unlistedTag of tagArea.querySelectorAll('.gts-unlisted-tag')) {
  672. unlistedTag.click()
  673. }
  674. }
  675. }
  676. }
  677.  
  678. function draw_categoryarea(query = SEPERATOR) {
  679. let categoryAreaHTML = ''
  680. let idx = 0
  681. let tagLinks
  682. const filteredDict = filter_category_dict(query, categoryDict, currentUploadCategory)
  683. if (currentUploadCategory === 'Games') {
  684. if (filteredDict['genre'].length > 0) {
  685. [tagLinks, idx] = render_tag_links(filteredDict['genre'], idx)
  686. categoryAreaHTML += `
  687. <div class="gts-left">
  688. <div class="gts-category gts-category-genre" tabindex="-1">
  689. <h1 class="gts-h1">Genre</h1>
  690. <div class="gts-category-inner" tabindex="-1">
  691. ${tagLinks}
  692. </div>
  693. </div>
  694. </div>`
  695. }
  696.  
  697. }
  698. categoryAreaHTML += `<div class="gts-right" tabindex="-1">`
  699. for (const [category, tags] of Object.entries(filteredDict)) {
  700. if ((currentUploadCategory === 'Games' && category === 'genre') || tags.length === 0) {
  701. continue
  702. }
  703. [tagLinks, idx] = render_tag_links(tags, idx)
  704. categoryAreaHTML += `<div class="gts-category gts-category-${category}" tabindex="-1">`
  705. if (categoryKeys[currentUploadCategory].length > 1) {
  706. categoryAreaHTML += `<h1>${titlecase(category)}</h1>`
  707. }
  708. categoryAreaHTML += `
  709. <div class="gts-category-inner" tabindex="-1">
  710. ${tagLinks}
  711. </div>
  712. </div>`
  713. }
  714. document.querySelector('#gts-categoryarea').innerHTML = categoryAreaHTML
  715. document.querySelectorAll('#gts-categoryarea .gts-tag').forEach((el) => {
  716. el.addEventListener('click', (event) => {
  717. event.preventDefault()
  718. const tag = event.target.getAttribute('data-tag').trim()
  719. const favoriteChecked = check_favorite()
  720. if (favoriteChecked) {
  721. add_favorite(tag).then(() => {
  722. draw_favoritearea()
  723. register_hotkeys('favorite')
  724. })
  725. } else {
  726. add_tag(tag)
  727. }
  728. })
  729. })
  730. }
  731.  
  732. function draw_presetarea() {
  733. let html = ''
  734. const currentPresets = currentPresetsDict[currentUploadCategory] || []
  735. for (const [idx, preset] of currentPresets.entries()) {
  736. html += `<div class="gts-preset">${idx + 1}.
  737. <button type="button" class="gts-preset-link gts-tag" data-preset="${preset}">
  738. ${preset.split(TAGSEPERATOR).map((tag) => titlecase(tag)).join(TAGSEPERATOR)}</button>
  739. </div>
  740. </div>`
  741. }
  742. document.querySelector('#gts-presetarea').innerHTML = html
  743. document.querySelectorAll('#gts-presetarea .gts-preset-link').forEach((el) => {
  744. el.addEventListener('click', (event) => {
  745. event.preventDefault()
  746. const preset = event.target.getAttribute('data-preset').trim()
  747. if (check_remove()) {
  748. remove_preset(preset).then(() => {
  749. draw_presetarea()
  750. })
  751. } else {
  752. tagBox.value = preset
  753. tagBox.focus()
  754. searchBox.value = ''
  755. searchBox.focus()
  756. }
  757. })
  758. })
  759. }
  760.  
  761. function draw_favoritearea() {
  762. let html = ''
  763. const currentFavorites = currentFavoritesDict[currentUploadCategory] || []
  764. for (const [idx, tag] of currentFavorites.entries()) {
  765. html += `<div class="gts-favorite">${idx + 1}. <button type="button" class="gts-tag" data-tag="${tag}">${titlecase(tag)}</button></div></div>`
  766. }
  767. document.querySelector('#gts-favoritearea').innerHTML = html
  768. document.querySelectorAll('#gts-favoritearea .gts-tag').forEach((el) => {
  769. el.addEventListener('click', (event) => {
  770. event.preventDefault()
  771. const tag = event.target.getAttribute('data-tag').trim()
  772. if (check_remove()) {
  773. remove_favorite(tag).then(() => {
  774. draw_favoritearea()
  775. register_hotkeys('favorite')
  776. })
  777. } else {
  778. add_tag(tag)
  779. }
  780. })
  781. })
  782. }
  783.  
  784. function insert_modal(addTagsToggle) {
  785. modal = document.createElement('div')
  786. const tagBoxStyle = tagBox.currentStyle || window.getComputedStyle(tagBox)
  787. const tdStyle = tagBox.parentElement.currentStyle || window.getComputedStyle(tagBox.parentElement)
  788. modal.style.top = (parseInt(tagBoxStyle.marginTop.replace('px', ''), 10) +
  789. parseInt(tagBoxStyle.marginBottom.replace('px', ''), 10) +
  790. tagBoxStyle.offsetHeight) + 'px'
  791. modal.style.left = (parseInt(tagBoxStyle.marginLeft.replace('px', ''), 10) + parseInt(tdStyle.paddingLeft.replace('px', ''), 10)) + 'px'
  792. modal.id = 'gts-selector'
  793. modal.classList.add('gts-selector')
  794. modal.setAttribute('tabindex', '-1')
  795. modal.innerHTML = `
  796. <div class="gts-selectarea">
  797. <div class="gts-searchbar">
  798. <input id="gts-search" type="text" placeholder="Search (Enter to add as-is${addTagsToggle ? ', ctrl+enter to submit' : ''})">
  799. <div class="gts-settings-wrapper" tabindex="-1">
  800. <a href="/user.php?action=edit#ggn-tag-selector" target="_blank" tabindex="-1">[Settings]</a>
  801. </div>
  802. ${isSearchPage ? `<div class="gts-checkbox-wrapper" style="text-align: right;">
  803. <input id="gts-exclude-checkbox" type="checkbox" tabindex="-1">
  804. <label class="gts-label" for="gts-exclude-checkbox">Exclude</label>
  805. </div>` : ''}
  806. <div class="gts-checkbox-wrapper" style="text-align: right;">
  807. <input id="gts-favorite-checkbox" type="checkbox" tabindex="-1">
  808. <label class="gts-label" for="gts-favorite-checkbox">Favorite</label>
  809. </div>
  810. </div>
  811. <div id="gts-categoryarea" class="hide-idx gts-categoryarea gts-categoryarea-${currentUploadCategory}">
  812. </div>
  813. </div>
  814. <div class="gts-sidearea">
  815. <div class="gts-sidetopbar" tabindex="-1" style="text-align: right !important;">
  816. <div class="gts-checkbox-wrapper" style="text-align: right !important;">
  817. <input id="gts-remove-checkbox" type="checkbox" tabindex="-1"><label class="gts-label" for="gts-remove-checkbox">Remove</label>
  818. </div>
  819. </div>
  820. <h1>Presets</h1>
  821. <div id="gts-presetarea" tabindex="-1">
  822. </div>
  823. <h1>Favorites</h1>
  824. <div id="gts-favoritearea" tabindex="-1">
  825. </div>
  826. </div>
  827. <div class="gts-sidearea" style="display:flex;flex-direction:column;justify-content: start">
  828. <div id="gts-currenttagsarea"></div>
  829. <button type="button" style="display:none;font-size: smaller;" class="gts-remove-unlisted">Remove Unlisted Tags</button>
  830. </div>
  831. `
  832.  
  833. tagBox.parentElement.style.position = 'relative'
  834. tagBox.parentElement.appendChild(modal)
  835. draw_categoryarea()
  836.  
  837. removeUnlistedButton = modal.querySelector('.gts-remove-unlisted')
  838. searchBox = document.querySelector('#gts-search')
  839. searchBox.addEventListener('keydown', (event) => {
  840. if (event.key === 'Enter' || (event.key === 'Tab' && foundTags.length === 1)) {
  841. event.preventDefault()
  842. event.stopPropagation()
  843. }
  844. })
  845. searchBox.addEventListener('keyup', (event) => {
  846. if (event.key === 'Tab' && foundTags.length === 1) {
  847. add_tag(foundTags[0])
  848. } else if (event.key === 'Enter') {
  849. addTagFromSearch(event)
  850. }
  851. let query = event.target.value.trim()
  852. if (query === '') {
  853. query = SEPERATOR
  854. }
  855. query = query.toLowerCase()
  856. draw_categoryarea(query)
  857.  
  858. if (event.code === 'Escape') {
  859. hide_gts()
  860. }
  861. })
  862.  
  863. draw_presetarea()
  864. draw_favoritearea()
  865. draw_currenttagsarea()
  866. }
  867.  
  868. function insert_preset_button() {
  869. presetButton = document.createElement('button')
  870. presetButton.id = 'gts-add-preset'
  871. presetButton.classList.add('gts-add-preset')
  872. presetButton.type = 'button'
  873. presetButton.setAttribute('tabindex', '-1')
  874. presetButton.textContent = 'Add Preset'
  875.  
  876. if (!isGroupPage) {
  877. tagBox.after(presetButton)
  878. if (!isUploadPage) presetButton.style.marginLeft = '5px'
  879. } else {
  880. const div = document.createElement('div')
  881. const submitButton = tagBox.nextElementSibling
  882. div.style.cssText = `
  883. display: flex;
  884. justify-content: end;
  885. align-items: center;
  886. `
  887. tagBox.after(div)
  888. div.append(presetButton, submitButton)
  889. }
  890. presetButton.addEventListener('click', () => {
  891. const preset = tagBox.value.trim()
  892. add_preset(preset).then(() => {
  893. draw_presetarea()
  894. })
  895. })
  896. }
  897.  
  898. // actions
  899. function add_tag(tag) {
  900. const currentValue = tagBox.value.trim()
  901. const excludeChecked = document.getElementById('gts-exclude-checkbox')?.checked
  902. const _tag = tag.trim().toLowerCase()
  903. tag = excludeChecked ? '!' + _tag : _tag
  904.  
  905. if (currentValue === "") {
  906. tagBox.value = tag
  907. } else {
  908. let tags = currentValue.split(TAGSEPERATOR)
  909. if (!tags.includes(tag)) {
  910. tags.push(tag)
  911. }
  912. tagBox.value = tags.join(TAGSEPERATOR)
  913. }
  914. tagBox.focus()
  915. tagBox.setSelectionRange(-1, -1)
  916. searchBox.focus()
  917. searchBox.value = ''
  918. draw_categoryarea()
  919. }
  920.  
  921. async function add_favorite(tag) {
  922. const currentFavorites = currentFavoritesDict[currentUploadCategory] || []
  923. if (currentFavorites.length < 9 && !currentFavorites.includes(tag)) {
  924. currentFavoritesDict[currentUploadCategory] = currentFavorites.concat(tag)
  925. return GM.setValue('gts_favorites', currentFavoritesDict)
  926. }
  927. }
  928.  
  929. async function remove_favorite(tag) {
  930. const currentFavorites = currentFavoritesDict[currentUploadCategory] || []
  931. let _temp = []
  932. for (const fav of currentFavorites) {
  933. if (fav !== tag) {
  934. _temp.push(fav)
  935. }
  936. }
  937. currentFavoritesDict[currentUploadCategory] = _temp
  938. return GM.setValue('gts_favorites', currentFavoritesDict)
  939. }
  940.  
  941. function parse_text_to_tag_list(text) {
  942. let tagList = []
  943. for (let tag of text.split(TAGSEPERATOR.trim())) {
  944. tag = tag.trim()
  945. if (tag !== '') {
  946. tagList.push(tag)
  947. }
  948. }
  949. return tagList
  950. }
  951.  
  952. async function add_preset(rawPreset) {
  953. let preset = parse_text_to_tag_list(rawPreset)
  954. const currentPresets = currentPresetsDict[currentUploadCategory] || []
  955. preset = preset.join(TAGSEPERATOR)
  956. if (!currentPresets.includes(preset)) {
  957. currentPresetsDict[currentUploadCategory] = currentPresets.concat(preset)
  958. return GM.setValue('gts_presets', currentPresetsDict)
  959. }
  960. }
  961.  
  962. async function remove_preset(preset) {
  963. let _temp = []
  964. const currentPresets = currentPresetsDict[currentUploadCategory] || []
  965. for (const pres of currentPresets) {
  966. if (pres !== preset) {
  967. _temp.push(pres)
  968. }
  969. }
  970. currentPresetsDict[currentUploadCategory] = _temp
  971. return GM.setValue('gts_presets', currentPresetsDict)
  972. }
  973.  
  974. function check_favorite() {
  975. return document.querySelector('#gts-favorite-checkbox').checked
  976. }
  977.  
  978. function check_remove() {
  979. return document.querySelector('#gts-remove-checkbox').checked
  980. }
  981.  
  982. function check_gts_element(element) {
  983. if (typeof element === 'undefined' || !(element instanceof HTMLElement)) {
  984. return false
  985. }
  986. const _id = element.id || ''
  987. const _class = element.getAttribute('class') || ''
  988. return (_id === 'tags' ||
  989. _id.includes('gts-') ||
  990. _class.includes('gts-'))
  991. }
  992.  
  993. function hide_gts() {
  994. modal.style.display = 'none'
  995. presetButton.style.display = 'none'
  996. }
  997.  
  998. function show_gts() {
  999. if (!check_gts_active()) {
  1000. modal.style.display = 'grid'
  1001. presetButton.style.display = 'inline'
  1002. searchBox.focus()
  1003. draw_currenttagsarea()
  1004. }
  1005. }
  1006.  
  1007. function hide_indices() {
  1008. document.querySelector('#gts-categoryarea').classList.add('hide-idx')
  1009. showIndicess = false
  1010. }
  1011.  
  1012. function show_indices() {
  1013. document.querySelector('#gts-categoryarea').classList.remove('hide-idx')
  1014. showIndicess = true
  1015. }
  1016.  
  1017. function check_gts_active() {
  1018. return modal.style.display === 'grid'
  1019. }
  1020.  
  1021. function check_query_exists() {
  1022. // returns true if there is query
  1023. return searchBox.value.trim() !== ''
  1024. }
  1025.  
  1026. function get_index_from_code(code) {
  1027. if (code.indexOf('Digit') === 0) {
  1028. return parseInt(code.replaceAll('Digit', ''), 10) - 1
  1029. }
  1030. return null
  1031. }
  1032.  
  1033. function get_current_upload_category(defaultCategory = 'Games') {
  1034. if (isSearchPage || isRequestPage) {
  1035. const list = document.querySelectorAll('input[type=checkbox][name^=filter_cat]:checked')
  1036. if (list.length < 1) return defaultCategory
  1037. const lastChecked = list[list.length - 1]
  1038.  
  1039. return {
  1040. 1: "Games",
  1041. 2: "Applications",
  1042. 3: "E-Books",
  1043. 4: "OST",
  1044. }[/\d/.exec(lastChecked.id)[0]]
  1045. }
  1046. let categoryElement = document.querySelector('#categories')
  1047. if (categoryElement) {
  1048. return categoryElement.value
  1049. }
  1050. categoryElement = document.querySelector('#group_nofo_bigdiv .head:first-child')
  1051. const s = categoryElement.innerText.trim()
  1052. if (s.indexOf('Application') !== -1) {
  1053. return 'Applications'
  1054. } else if (s.indexOf('OST') !== -1) {
  1055. return 'OST'
  1056. } else if (s.indexOf('Book') !== -1) {
  1057. return 'E-Books'
  1058. } else if (s.indexOf('Game') !== -1) {
  1059. return 'Games'
  1060. }
  1061. return defaultCategory
  1062. }
  1063.  
  1064. function check_hotkey_prefix(event, type) {
  1065. let eventModifiers = [event.shiftKey, event.altKey, event.ctrlKey, event.metaKey]
  1066. const targetKeys = hotkeyPrefixes[type].split(' + ').map((key) => key.trim().toLowerCase())
  1067. for (let i = 0; i < modifiers.length; i++) {
  1068. if (targetKeys.includes(modifiers[i]) !== eventModifiers[i]) {
  1069. return false
  1070. }
  1071. }
  1072. return true
  1073. }
  1074.  
  1075. function get_hotkey_target(event, type) {
  1076. for (const [idx, hotkey] of Object.entries(hotkeys[type])) {
  1077. let normalKeys = []
  1078. const targetKeys = hotkey.split('+').map((s) => {
  1079. const key = s.toLowerCase().trim()
  1080. if (!modifiers.includes(key)) {
  1081. normalKeys.push(key)
  1082. }
  1083. return key
  1084. })
  1085. let modifierMismatch = false
  1086. let eventModifiers = [event.shiftKey, event.altKey, event.ctrlKey, event.metaKey]
  1087. for (let i = 0; i < modifiers.length; i++) {
  1088. if (targetKeys.includes(modifiers[i]) !== eventModifiers[i]) {
  1089. modifierMismatch = true
  1090. break
  1091. }
  1092. }
  1093. if (modifierMismatch) {
  1094. continue
  1095. }
  1096. if (normalKeys.length > 0 && (
  1097. !(normalKeys.includes(event.key.toLowerCase()) || normalKeys.includes(event.code.toLowerCase()))
  1098. )) {
  1099. continue
  1100. }
  1101. return idx
  1102. }
  1103. return null
  1104. }
  1105.  
  1106. function register_hotkeys(type) {
  1107. if (['favorite', 'preset'].includes(type) && !windowEvents.includes(`hotkey-${type}`)) {
  1108. window.addEventListener('keydown', (event) => {
  1109. if (!check_gts_active()) {
  1110. return
  1111. }
  1112. const target = get_hotkey_target(event, type)
  1113. let currentList
  1114. if (type === 'favorite') {
  1115. if (check_query_exists()) {
  1116. return // return early if query is active
  1117. }
  1118. currentList = currentFavoritesDict[currentUploadCategory] || []
  1119. } else if (type === 'preset') {
  1120. // if we're working with presets,
  1121. // we proceed anyway
  1122. currentList = currentPresetsDict[currentUploadCategory] || []
  1123. }
  1124. if (target !== null) {
  1125. if (target < currentList.length) {
  1126. event.preventDefault()
  1127. if (type === 'favorite') {
  1128. add_tag(currentList[target])
  1129. } else if (type === 'preset') {
  1130. tagBox.value = currentList[target]
  1131. tagBox.focus()
  1132. }
  1133. searchBox.focus()
  1134. }
  1135. }
  1136. }, true)
  1137. } else if (type === 'show_indices' && !windowEvents.includes(!`hotkey-${type}`)) {
  1138. window.addEventListener('keydown', (event) => {
  1139. if (!check_gts_active() || !check_query_exists()) {
  1140. return
  1141. }
  1142. if (check_hotkey_prefix(event, type)) {
  1143. show_indices()
  1144. const idx = get_index_from_code(event.code)
  1145. if (idx !== null) {
  1146. document.querySelector(`button.gts-tag[data-tag-idx="${idx}"]`).click()
  1147. event.preventDefault()
  1148. }
  1149. }
  1150. }, true)
  1151. window.addEventListener('keyup', () => {
  1152. if (showIndicess) {
  1153. hide_indices()
  1154. }
  1155. }, true)
  1156. }
  1157. windowEvents.push(`hotkey-${type}`)
  1158. }
  1159.  
  1160. // initialiser
  1161. function init() {
  1162. const modal = document.querySelector('#gts-selector')
  1163. if (modal) {
  1164. modal.remove()
  1165. }
  1166. currentUploadCategory = get_current_upload_category()
  1167. allCurrentCategoryTags = categoryKeys[currentUploadCategory].flatMap(c => categoryDict[c])
  1168.  
  1169. tagBox = document.getElementById('tags') || document.querySelector('input[name=tags]')
  1170. tagBox.setAttribute('onfocus', 'this.value = this.value')
  1171. const addTagsToggle = document.getElementById('tags_add_toggle')
  1172. insert_modal(addTagsToggle)
  1173. insert_preset_button()
  1174.  
  1175. if (addTagsToggle) { // on group page and has tagging priviledge (maybe)
  1176. const groupTagEls = Array.from(document.querySelectorAll("a[href^='torrents.php?taglist=']"))
  1177. const unlistedTagEls = groupTagEls.filter(tag => !allCurrentCategoryTags.includes(tag.textContent) && !specialTags.includes(tag.textContent))
  1178.  
  1179. for (const groupTagEl of groupTagEls) {
  1180. if (unlistedTagEls.includes(groupTagEl)) {
  1181. groupTagEl.classList.add('gts-unlisted-tag')
  1182. }
  1183. }
  1184. addTagsToggle.style.display = 'none'
  1185. const addTagForm = document.getElementById('tag_add_form')
  1186. addTagForm.style.display = 'block'
  1187. // add/remove tags replaces the whole list
  1188. new MutationObserver(() => {
  1189. for (const el of document.querySelectorAll("a[href^='torrents.php?taglist=']")) {
  1190. if (unlistedTagEls.some(t => t.href === el.href)) {
  1191. el.classList.add('gts-unlisted-tag')
  1192. }
  1193. }
  1194. }).observe(document.getElementById('tagslist'), {childList: true})
  1195.  
  1196. tagBox.placeholder = 'Add tags'
  1197. GM_addStyle(`#gts-search::placeholder {font-size: 0.9em;}`)
  1198.  
  1199. searchBox.addEventListener('keydown', e => {
  1200. if (e.ctrlKey && e.key === 'Enter') {
  1201. addTagFromSearch(e)
  1202. addTagForm.querySelector('input[type=submit]').click()
  1203. hide_gts()
  1204. }
  1205. })
  1206. }
  1207.  
  1208. if (!windowEvents.includes('click')) {
  1209. window.addEventListener('click', (event) => {
  1210. if (!check_gts_element(event.target)) {
  1211. setTimeout(() => {
  1212. if (!check_gts_element(document.activeElement)) {
  1213. hide_gts()
  1214. }
  1215. }, 50)
  1216. }
  1217. }, true)
  1218. windowEvents.push('click')
  1219. }
  1220. if (!windowEvents.includes('esc')) {
  1221. window.addEventListener('keyup', (event) => {
  1222. if (event.code === 'Escape') {
  1223. if (check_gts_active()) {
  1224. hide_gts()
  1225. }
  1226. }
  1227. }, true)
  1228. windowEvents.push('esc')
  1229. }
  1230. tagBox.addEventListener('focus', show_gts)
  1231. tagBox.addEventListener('click', show_gts)
  1232. tagBox.addEventListener('keyup', (event) => {
  1233. if (event.code !== 'Escape') {
  1234. draw_currenttagsarea()
  1235. }
  1236. })
  1237. register_hotkeys('favorite')
  1238. register_hotkeys('preset')
  1239. register_hotkeys('show_indices')
  1240. draw_currenttagsarea()
  1241. // watch for value change in the tagBox
  1242. observe_element(tagBox, 'value', (_) => {
  1243. draw_currenttagsarea()
  1244. })
  1245. }
  1246.  
  1247. if (isUploadPage) {
  1248. const observerTarget = document.querySelector('#dynamic_form')
  1249. let observer = new MutationObserver(init)
  1250. const observerConfig = {childList: true, attributes: false, subtree: false}
  1251. if (document.readyState === "loading") {
  1252. document.addEventListener("DOMContentLoaded", init)
  1253. observer.observe(observerTarget, observerConfig)
  1254. } else {
  1255. init()
  1256. observer.observe(observerTarget, observerConfig)
  1257. }
  1258. } else {
  1259. init()
  1260. if (isSearchPage || isRequestPage) {
  1261. document.querySelector('.cat_list').addEventListener('change', e => {
  1262. if (e.target.checked) {
  1263. init()
  1264. }
  1265. })
  1266. }
  1267. else if (isCreateRequestPage) { // it doesn't use dynamic form
  1268. init()
  1269. document.getElementById('categories').addEventListener('change', () => {
  1270. init()
  1271. })
  1272. }
  1273. }
  1274. } else {
  1275. let hotkeys = (GM_getValue('gts_hotkeys')) || defaultHotkeys
  1276. let hotkeyPrefixes = (GM_getValue('gts_hotkey_prefixes')) || defaulthotkeyPrefixes
  1277.  
  1278. GM_addStyle(`
  1279. #gts-save-settings {
  1280. min-width: 200px;
  1281. }
  1282. .gts-hotkey-grid {
  1283. display: grid;
  1284. column-gap: 1em;
  1285. grid-template-columns: repeat(2, fit-content(400px)) 1fr;
  1286. }
  1287. .gts-hotkey-grid h1 {
  1288. font-size: 1.1em;
  1289. }
  1290. .gts-hotkey-col div {
  1291. margin-bottom: 0.25em;
  1292. }
  1293. `)
  1294.  
  1295. async function init() {
  1296. let colhead = document.createElement('tr')
  1297. colhead.classList.add('colhead_dark')
  1298. colhead.innerHTML = '<td colspan="2" id="ggn-tag-selector"><strong>GGn Tag Selector</strong></span>'
  1299. const lastTr = document.querySelector('#userform > table > tbody > tr:last-child')
  1300. lastTr.before(colhead)
  1301. let hotkeyTr = document.createElement('tr')
  1302. let html = `
  1303. <td class="label"><strong>Hotkeys</strong></td>
  1304. <td class="gts-hotkey-grid">
  1305. `
  1306. for (const [type, cHotkeys] of Object.entries(hotkeys)) {
  1307. html += `<div class="gts-hotkey-col"><h1>${titlecase(type)}</h1>`
  1308. for (const [idx, hotkey] of cHotkeys.entries()) {
  1309. html += `<div>${idx + 1}. <input class="gts-settings" data-gts-settings="gts_hotkeys:${type}-${idx}" value="${hotkey}"></div>`
  1310. }
  1311. html += `</div>`
  1312. }
  1313. html += `<div class="gts-hotkey-col">
  1314. <h1>Index peeker</h1>
  1315. Hold <input type="text" style="width: 5em" class="gts-settings" data-gts-settings="gts_hotkey_prefixes:show_indices" value="${hotkeyPrefixes['show_indices']}"> to display indices of the filtered results (modifier keys/their combinations only).
  1316. Use the key along with a digit (1-9) to add the tag according to the index.
  1317. Note that peeking/adding by index will not work if the filter query is empty.
  1318. <h1>How to set combos/keys</h1>
  1319. To set a combo, use the keys joined by the plus sign. For example, Ctrl + Shift + 1 is <span style="font-family: monospace">ctrl + shift + digit1</span>
  1320. <ul>
  1321. <li>Modifier keys: shift, alt, ctrl, cmd</li>
  1322. <li>Numbers: digit1, digit2, digit3, digit4, digit5, digit6, digit7, digit8, digit9</li>
  1323. <li>Alphabet: a, b, c, d, (etc.)</li>
  1324. </ul>
  1325. <div style="margin-top: 1em;">
  1326. Other keys should also work. If not, use the <span style="font-family: monospace">event.code</span> value from <a target="_blank" href="https://www.toptal.com/developers/keycode">the keycode tool</a>.
  1327. </div>
  1328. </div>`
  1329. html += `</td>`
  1330. html += `<div style="margin-left: 1em;"><input type="button" id="gts-save-settings" value="Save GGn Tag Selector settings">
  1331. <input type="button" id="gts-restore-settings" value="Restore Defaults"></div>`
  1332. hotkeyTr.innerHTML = html
  1333. colhead.after(hotkeyTr)
  1334. document.querySelector('#gts-save-settings').addEventListener('click', (event) => {
  1335. const originalText = event.target.value
  1336. let newData = {
  1337. 'gts_hotkeys': hotkeys,
  1338. 'gts_hotkey_prefixes': hotkeyPrefixes
  1339. }
  1340. event.target.value = 'Saving ...'
  1341. document.querySelectorAll('.gts-settings').forEach((el) => {
  1342. const meta = el.getAttribute('data-gts-settings')
  1343. const rawValue = el.value
  1344. const [settingKey, settingSubKey] = meta.split(':')
  1345. if (settingKey === 'gts_hotkey_prefixes') {
  1346. newData[settingKey][settingSubKey] = normalise_combo_string(rawValue)
  1347. } else if (settingKey === 'gts_hotkeys') {
  1348. const [type, idx] = settingSubKey.split('-')
  1349. // normalise the value
  1350. newData[settingKey][type][idx] = normalise_combo_string(rawValue)
  1351. }
  1352. })
  1353. let promises = []
  1354. for (const [key, value] of Object.entries(newData)) {
  1355. promises.push(GM.setValue(key, value))
  1356. }
  1357. Promise.all(promises).then(() => {
  1358. event.target.value = 'Saved!'
  1359. setTimeout(() => {
  1360. event.target.value = originalText
  1361. }, 500)
  1362. })
  1363. })
  1364. document.querySelector('#gts-restore-settings').addEventListener('click', () => {
  1365. let defaults = {
  1366. 'gts_hotkeys': defaultHotkeys,
  1367. 'gts_hotkey_prefixes': hotkeyPrefixes
  1368. }
  1369. document.querySelectorAll('.gts-settings').forEach((el) => {
  1370. const meta = el.getAttribute('data-gts-settings')
  1371. const [settingKey, settingSubKey] = meta.split(':')
  1372. if (settingKey === 'gts_hotkey_prefixes') {
  1373. el.value = defaults[settingKey][settingSubKey]
  1374. } else if (settingKey === 'gts_hotkeys') {
  1375. const [type, idx] = settingSubKey.split('-')
  1376. el.value = defaults[settingKey][type][idx]
  1377. }
  1378. })
  1379. })
  1380.  
  1381. if (window.location.hash.substring(1) === 'ggn-tag-selector') {
  1382. document.querySelector('#ggn-tag-selector').scrollIntoView()
  1383. }
  1384. }
  1385.  
  1386. if (document.readyState === "loading") {
  1387. document.addEventListener("DOMContentLoaded", init)
  1388. } else {
  1389. init()
  1390. }
  1391. }