Fullchan X

8chan features script

Από την 20/04/2025. Δείτε την τελευταία έκδοση.

  1. // ==UserScript==
  2. // @name Fullchan X
  3. // @namespace Violentmonkey Scripts
  4. // @match https://8chan.moe/*/res/*
  5. // @match https://8chan.se/*/res/*
  6. // @match https://8chan.moe/*/catalog*
  7. // @match https://8chan.se/*/catalog*
  8. // @run-at document-end
  9. // @grant none
  10. // @version 1.9.0
  11. // @author vfyxe
  12. // @description 8chan features script
  13. // ==/UserScript==
  14.  
  15. class fullChanX extends HTMLElement {
  16. constructor() {
  17. super();
  18. this.settingsEl = document.querySelector('fullchan-x-settings');
  19. this.settings = this.settingsEl.settings;
  20. this.isThread = !!document.querySelector('.opCell');
  21. this.isDisclaimer = window.location.href.includes('disclaimer');
  22. Object.keys(this.settings).forEach(key => {
  23. this[key] = this.settings[key]?.value;
  24. });
  25. }
  26.  
  27. init() {
  28. this.settingsButton = this.querySelector('#fcx-settings-btn');
  29. this.settingsButton.addEventListener('click', () => this.settingsEl.toggle());
  30. this.handleBoardLinks();
  31. if (!this.isThread) return;
  32.  
  33. this.quickReply = document.querySelector('#quick-reply');
  34. this.qrbody = document.querySelector('#qrbody');
  35. this.threadParent = document.querySelector('#divThreads');
  36. this.threadId = this.threadParent.querySelector('.opCell').id;
  37. this.thread = this.threadParent.querySelector('.divPosts');
  38. this.posts = [...this.thread.querySelectorAll('.postCell')];
  39. this.postOrder = 'default';
  40. this.postOrderSelect = this.querySelector('#thread-sort');
  41. this.myYousLabel = this.querySelector('.my-yous__label');
  42. this.yousContainer = this.querySelector('#my-yous');
  43.  
  44. this.gallery = document.querySelector('fullchan-x-gallery');
  45. this.galleryButton = this.querySelector('#fcx-gallery-btn');
  46.  
  47. this.updateYous();
  48. this.observers();
  49.  
  50. if (this.enableFileExtentions) this.handleTruncatedFilenames();
  51. }
  52.  
  53. styleUI () {
  54. this.style.setProperty('--top', this.uiTopPosition);
  55. this.style.setProperty('--right', this.uiRightPosition);
  56. this.classList.toggle('fcx--dim', this.uiDimWhenInactive);
  57. this.classList.toggle('page-thread', this.isThread);
  58. const style = document.createElement('style');
  59.  
  60. if (this.hideDefaultBoards !== '') {
  61. style.textContent += '#navTopBoardsSpan{display:block!important;}'
  62. }
  63. document.body.appendChild(style);
  64. }
  65.  
  66. handleBoardLinks () {
  67. const navBoards = document.querySelector('#navTopBoardsSpan');
  68. const customBoardLinks = this.customBoardLinks?.toLowerCase().replace(/\s/g,'').split(',');
  69. let hideDefaultBoards = this.hideDefaultBoards?.toLowerCase().replace(/\s/g,'') || '';
  70. const urlCatalog = this.catalogBoardLinks ? '/catalog.html' : '';
  71.  
  72.  
  73. if (hideDefaultBoards === 'all') {
  74. navBoards.classList.add('hidden');
  75. } else {
  76. const waitForNavBoards = setInterval(() => {
  77. const navBoards = document.querySelector('#navTopBoardsSpan');
  78. if (!navBoards || !navBoards.querySelector('a')) return;
  79.  
  80. clearInterval(waitForNavBoards);
  81.  
  82. hideDefaultBoards = hideDefaultBoards.split(',');
  83. const defaultLinks = [...navBoards.querySelectorAll('a')];
  84. defaultLinks.forEach(link => {
  85. link.href += urlCatalog;
  86. const linkText = link.textContent;
  87. const shouldHide = hideDefaultBoards.includes(linkText) || customBoardLinks.includes(linkText);
  88. link.classList.toggle('hidden', shouldHide);
  89. });
  90. }, 50);
  91. }
  92.  
  93. if (this.customBoardLinks.length > 0) {
  94. const customNav = document.createElement('span');
  95. customNav.classList = 'nav-boards nav-boards--custom';
  96. customNav.innerHTML = '<span>[</span>';
  97.  
  98. customBoardLinks.forEach((board, index) => {
  99. const link = document.createElement('a');
  100. link.href = '/' + board + urlCatalog;
  101. link.textContent = board;
  102. customNav.appendChild(link);
  103. if (index < customBoardLinks.length - 1) customNav.innerHTML += '<span>/</span>';
  104. });
  105.  
  106. customNav.innerHTML += '<span>]</span>';
  107. navBoards.parentNode.insertBefore(customNav, navBoards);
  108. }
  109. }
  110.  
  111. observers () {
  112. this.postOrderSelect.addEventListener('change', (event) => {
  113. this.postOrder = event.target.value;
  114. this.assignPostOrder();
  115. });
  116.  
  117. const observerCallback = (mutationsList, observer) => {
  118. for (const mutation of mutationsList) {
  119. if (mutation.type === 'childList') {
  120. this.posts = [...this.thread.querySelectorAll('.postCell')];
  121. if (this.postOrder !== 'default') this.assignPostOrder();
  122. this.updateYous();
  123. this.gallery.updateGalleryImages();
  124. if (this.settings.enableFileExtentions) this.handleTruncatedFilenames();
  125. }
  126. }
  127. };
  128.  
  129. const threadObserver = new MutationObserver(observerCallback);
  130. threadObserver.observe(this.thread, { childList: true, subtree: false });
  131.  
  132. if (this.enableNestedQuotes) {
  133. this.thread.addEventListener('click', event => {
  134. this.handleClick(event);
  135. });
  136. }
  137.  
  138. this.galleryButton.addEventListener('click', () => this.gallery.open());
  139. this.myYousLabel.addEventListener('click', (event) => {
  140. if (this.myYousLabel.classList.contains('unseen')) {
  141. this.yousContainer.querySelector('.unseen').click();
  142. }
  143. });
  144. }
  145.  
  146. handleClick (event) {
  147. const clicked = event.target;
  148.  
  149. const post = clicked.closest('.innerPost') || clicked.closest('.innerOP');
  150. if (!post) return;
  151.  
  152. const isNested = !!post.closest('.innerNested');
  153. const nestQuote = clicked.closest('.quoteLink') || clicked.closest('.panelBacklinks a');
  154. const postMedia = clicked.closest('a[data-filemime]');
  155. const postId = clicked.closest('.linkQuote');
  156. const anonId = clicked.closest('.labelId');
  157.  
  158. if (nestQuote) {
  159. if (event.closest('.fcx-prevent-nesting')) return;
  160. event.preventDefault();
  161. this.nestQuote(nestQuote, post);
  162. } else if (postMedia && isNested) {
  163. this.handleMediaClick(event, postMedia);
  164. } else if (postId && isNested) {
  165. this.handleIdClick(postId);
  166. } else if (anonId) {
  167. this.handleAnonIdClick(anonId, event);
  168. }
  169. }
  170.  
  171. handleAnonIdClick (anonId, event) {
  172. this.anonIdPosts?.remove();
  173. if (anonId === this.anonId) {
  174. this.anonId = null;
  175. return;
  176. }
  177.  
  178. this.anonId = anonId;
  179. const anonIdText = anonId.textContent.split(' ')[0];
  180. this.anonIdPosts = document.createElement('div');
  181. this.anonIdPosts.classList = 'fcx-id-posts fcx-prevent-nesting';
  182.  
  183. const match = window.location.pathname.match(/^\/[^/]+\/res\/\d+\.html/);
  184. const prepend = match ? `${match[0]}#` : '';
  185.  
  186. const selector = `.postInfo:has(.labelId[style="background-color: #${anonIdText}"]) .linkQuote`;
  187. const postIds = [...this.threadParent.querySelectorAll(selector)].map(link => {
  188. const postId = link.getAttribute('href').replace('#q', '');
  189. const newLink = document.createElement('a');
  190. newLink.className = 'quoteLink';
  191. newLink.href = prepend + postId;
  192. newLink.textContent = `>>${postId}`;
  193. return newLink;
  194. });
  195.  
  196. postIds.forEach(postId => this.anonIdPosts.appendChild(postId));
  197. anonId.insertAdjacentElement('afterend', this.anonIdPosts);
  198.  
  199. this.setPostListeners(this.anonIdPosts);
  200. }
  201.  
  202.  
  203. handleMediaClick (event, postMedia) {
  204. if (postMedia.dataset.filemime === "video/webm") return;
  205. event.preventDefault();
  206. const imageSrc = `${postMedia.href}`;
  207. const imageEl = postMedia.querySelector('img');
  208. if (!postMedia.dataset.thumbSrc) postMedia.dataset.thumbSrc = `${imageEl.src}`;
  209.  
  210. const isExpanding = imageEl.src !== imageSrc;
  211.  
  212. if (isExpanding) {
  213. imageEl.src = imageSrc;
  214. imageEl.classList
  215. }
  216. imageEl.src = isExpanding ? imageSrc : postMedia.dataset.thumbSrc;
  217. imageEl.classList.toggle('imgExpanded', isExpanding);
  218. }
  219.  
  220. handleIdClick (postId) {
  221. const idNumber = '>>' + postId.textContent;
  222. this.quickReply.style.display = 'block';
  223. this.qrbody.value += idNumber + '\n';
  224. }
  225.  
  226. handleTruncatedFilenames () {
  227. this.postFileNames = [...this.threadParent.querySelectorAll('.originalNameLink[download]:not([data-file-ext])')];
  228. this.postFileNames.forEach(fileName => {
  229. const strings = fileName.textContent.split('.');
  230. fileName.textContent = strings[0];
  231. fileName.dataset.fileExt = `.${strings[1]}`;
  232. const typeEl = document.createElement('a');
  233. typeEl.textContent = `.${strings[1]}`;
  234. typeEl.classList = ('file-ext originalNameLink');
  235. fileName.parentNode.insertBefore(typeEl, fileName.nextSibling);
  236. });
  237. }
  238.  
  239. assignPostOrder () {
  240. const postOrderReplies = (post) => {
  241. const replyCount = post.querySelectorAll('.panelBacklinks a').length;
  242. post.style.order = 100 - replyCount;
  243. }
  244.  
  245. const postOrderCatbox = (post) => {
  246. const postContent = post.querySelector('.divMessage').textContent;
  247. const matches = postContent.match(/catbox\.moe/g);
  248. const catboxCount = matches ? matches.length : 0;
  249. post.style.order = 100 - catboxCount;
  250. }
  251.  
  252. if (this.postOrder === 'default') {
  253. this.thread.style.display = 'block';
  254. return;
  255. }
  256.  
  257. this.thread.style.display = 'flex';
  258.  
  259. if (this.postOrder === 'replies') {
  260. this.posts.forEach(post => postOrderReplies(post));
  261. } else if (this.postOrder === 'catbox') {
  262. this.posts.forEach(post => postOrderCatbox(post));
  263. }
  264. }
  265.  
  266. updateYous () {
  267. this.yous = this.posts.filter(post => post.querySelector('.quoteLink.you'));
  268. this.yousLinks = this.yous.map(you => {
  269. const youLink = document.createElement('a');
  270. youLink.textContent = '>>' + you.id;
  271. youLink.href = '#' + you.id;
  272. return youLink;
  273. })
  274.  
  275. let hasUnseenYous = false;
  276. this.setUnseenYous();
  277.  
  278. this.yousContainer.innerHTML = '';
  279. this.yousLinks.forEach(you => {
  280. const youId = you.textContent.replace('>>', '');
  281. if (!this.seenYous.includes(youId)) {
  282. you.classList.add('unseen');
  283. hasUnseenYous = true
  284. }
  285. this.yousContainer.appendChild(you)
  286. });
  287.  
  288. this.myYousLabel.classList.toggle('unseen', hasUnseenYous);
  289.  
  290. if (this.replyTabIcon === '') return;
  291. const icon = this.replyTabIcon;
  292. document.title = hasUnseenYous
  293. ? document.title.startsWith(`${icon} `)
  294. ? document.title
  295. : `${icon} ${document.title}`
  296. : document.title.replace(new RegExp(`^${icon} `), '');
  297. }
  298.  
  299. observeUnseenYou(you) {
  300. you.classList.add('observe-you');
  301.  
  302. const observer = new IntersectionObserver((entries, observer) => {
  303. entries.forEach(entry => {
  304. if (entry.isIntersecting) {
  305. const id = you.id;
  306. you.classList.remove('observe-you');
  307.  
  308. if (!this.seenYous.includes(id)) {
  309. this.seenYous.push(id);
  310. localStorage.setItem(this.seenKey, JSON.stringify(this.seenYous));
  311. }
  312.  
  313. observer.unobserve(you);
  314. this.updateYous();
  315.  
  316. }
  317. });
  318. }, { rootMargin: '0px', threshold: 0.1 });
  319.  
  320. observer.observe(you);
  321. }
  322.  
  323. setUnseenYous() {
  324. this.seenKey = `${this.threadId}-seen-yous`;
  325. this.seenYous = JSON.parse(localStorage.getItem(this.seenKey));
  326.  
  327. if (!this.seenYous) {
  328. this.seenYous = [];
  329. localStorage.setItem(this.seenKey, JSON.stringify(this.seenYous));
  330. }
  331.  
  332. this.unseenYous = this.yous.filter(you => !this.seenYous.includes(you.id));
  333.  
  334. this.unseenYous.forEach(you => {
  335. if (!you.classList.contains('observe-you')) {
  336. this.observeUnseenYou(you);
  337. }
  338. });
  339. }
  340.  
  341. nestQuote(quoteLink, parentPost) {
  342. const parentPostMessage = parentPost.querySelector('.divMessage');
  343. const quoteId = quoteLink.href.split('#')[1];
  344. const quotePost = document.getElementById(quoteId);
  345. if (!quotePost) return;
  346.  
  347. const quotePostContent = quotePost.querySelector('.innerOP') || quotePost.querySelector('.innerPost');
  348. if (!quotePostContent) return;
  349.  
  350. const existing = parentPost.querySelector(`.nestedPost[data-quote-id="${quoteId}"]`);
  351. if (existing) {
  352. existing.remove();
  353. return;
  354. }
  355.  
  356. const isReply = !quoteLink.classList.contains('quoteLink');
  357.  
  358. const wrapper = document.createElement('div');
  359. wrapper.classList.add('nestedPost');
  360. wrapper.setAttribute('data-quote-id', quoteId);
  361.  
  362. const clone = quotePostContent.cloneNode(true);
  363. clone.style.whiteSpace = 'unset';
  364. clone.classList.add('innerNested');
  365. wrapper.appendChild(clone);
  366.  
  367. if (isReply) {
  368. parentPostMessage.insertBefore(wrapper, parentPostMessage.firstChild);
  369. } else {
  370. quoteLink.insertAdjacentElement('afterend', wrapper);
  371. }
  372.  
  373. this.setPostListeners(wrapper);
  374. }
  375.  
  376. setPostListeners(parentPost) {
  377. const postLinks = [
  378. ...parentPost.querySelectorAll('.quoteLink'),
  379. ...parentPost.querySelectorAll('.panelBacklinks a')
  380. ];
  381.  
  382. const hoverPost = (event, link) => {
  383. const quoteId = link.href.split('#')[1];
  384.  
  385. let existingPost = document.querySelector(`.nestedPost[data-quote-id="${quoteId}"]`)
  386. || link.closest(`.postCell[id="${quoteId}"]`);
  387.  
  388. if (existingPost) {
  389. this.markedPost = existingPost.querySelector('.innerPost') || existingPost.querySelector('.innerOP');
  390. this.markedPost?.classList.add('markedPost');
  391. return;
  392. }
  393.  
  394. const quotePost = document.getElementById(quoteId);
  395.  
  396. tooltips.removeIfExists();
  397.  
  398. const tooltip = document.createElement('div');
  399. tooltip.className = 'quoteTooltip';
  400. document.body.appendChild(tooltip);
  401.  
  402. const rect = link.getBoundingClientRect();
  403. if (!api.mobile) {
  404. if (rect.left > window.innerWidth / 2) {
  405. const right = window.innerWidth - rect.left - window.scrollX;
  406. tooltip.style.right = `${right}px`;
  407. } else {
  408. const left = rect.right + 10 + window.scrollX;
  409. tooltip.style.left = `${left}px`;
  410. }
  411. }
  412.  
  413. tooltip.style.top = `${rect.top + window.scrollY}px`;
  414. tooltip.style.display = 'inline';
  415.  
  416. tooltips.loadTooltip(tooltip, link.href, quoteId);
  417. tooltips.currentTooltip = tooltip;
  418. }
  419.  
  420. const unHoverPost = (event, link) => {
  421. if (!tooltips.currentTooltip) {
  422. this.markedPost?.classList.remove('markedPost');
  423. return false;
  424. }
  425.  
  426. if (tooltips.unmarkReply) {
  427. tooltips.currentTooltip.classList.remove('markedPost');
  428. Array.from(tooltips.currentTooltip.getElementsByClassName('replyUnderline'))
  429. .forEach((a) => a.classList.remove('replyUnderline'))
  430. tooltips.unmarkReply = false;
  431. } else {
  432. tooltips.currentTooltip.remove();
  433. }
  434.  
  435. tooltips.currentTooltip = null;
  436. }
  437.  
  438. const addHoverPost = (link => {
  439. link.addEventListener('mouseenter', (event) => hoverPost(event, link));
  440. link.addEventListener('mouseleave', (event) => unHoverPost(event, link));
  441. });
  442.  
  443. postLinks.forEach(link => addHoverPost(link));
  444. }
  445. };
  446.  
  447. window.customElements.define('fullchan-x', fullChanX);
  448.  
  449.  
  450. class fullChanXGallery extends HTMLElement {
  451. constructor() {
  452. super();
  453. }
  454.  
  455. init() {
  456. this.fullchanX = document.querySelector('fullchan-x');
  457. this.imageContainer = this.querySelector('.gallery__images');
  458. this.mainImageContainer = this.querySelector('.gallery__main-image');
  459. this.mainImage = this.mainImageContainer.querySelector('img');
  460. this.sizeButtons = [...this.querySelectorAll('.gallery__scale-options .scale-option')];
  461. this.closeButton = this.querySelector('.gallery__close');
  462. this.listeners();
  463. this.addGalleryImages();
  464. this.initalized = true;
  465. }
  466.  
  467. addGalleryImages () {
  468. this.thumbs = [...this.fullchanX.threadParent.querySelectorAll('.imgLink')].map(thumb => {
  469. return thumb.cloneNode(true);
  470. });
  471.  
  472. this.thumbs.forEach(thumb => {
  473. this.imageContainer.appendChild(thumb);
  474. });
  475. }
  476.  
  477. updateGalleryImages () {
  478. if (!this.initalized) return;
  479.  
  480. const newThumbs = [...this.fullchanX.threadParent.querySelectorAll('.imgLink')].filter(thumb => {
  481. return !this.thumbs.find(thisThumb.href === thumb.href);
  482. }).map(thumb => {
  483. return thumb.cloneNode(true);
  484. });
  485.  
  486. newThumbs.forEach(thumb => {
  487. this.thumbs.push(thumb);
  488. this.imageContainer.appendChild(thumb);
  489. });
  490. }
  491.  
  492. listeners () {
  493. this.addEventListener('click', event => {
  494. const clicked = event.target;
  495.  
  496. let imgLink = clicked.closest('.imgLink');
  497. if (imgLink?.dataset.filemime === 'video/webm') return;
  498.  
  499. if (imgLink) {
  500. event.preventDefault();
  501. this.mainImage.src = imgLink.href;
  502. }
  503.  
  504. this.mainImageContainer.classList.toggle('active', !!imgLink);
  505.  
  506. const scaleButton = clicked.closest('.scale-option');
  507. if (scaleButton) {
  508. const scale = parseFloat(getComputedStyle(this.imageContainer).getPropertyValue('--scale')) || 1;
  509. const delta = scaleButton.id === 'fcxg-smaller' ? -0.1 : 0.1;
  510. const newScale = Math.max(0.1, scale + delta);
  511. this.imageContainer.style.setProperty('--scale', newScale.toFixed(2));
  512. }
  513.  
  514. if (clicked.closest('.gallery__close')) this.close();
  515. });
  516. }
  517.  
  518. open () {
  519. if (!this.initalized) this.init();
  520. this.classList.add('open');
  521. document.body.classList.add('fct-gallery-open');
  522. }
  523.  
  524. close () {
  525. this.classList.remove('open');
  526. document.body.classList.remove('fct-gallery-open');
  527. }
  528. }
  529.  
  530. window.customElements.define('fullchan-x-gallery', fullChanXGallery);
  531.  
  532.  
  533.  
  534. class fullChanXSettings extends HTMLElement {
  535. constructor() {
  536. super();
  537. this.settingsKey = 'fullchan-x-settings';
  538. this.inputs = [];
  539. this.settings = {};
  540. this.settingsTemplate = {
  541. enableNestedQuotes: {
  542. info: 'Nest posts when clicking backlinks.',
  543. type: 'checkbox',
  544. value: true
  545. },
  546. enableFileExtentions: {
  547. info: 'Always show filetype on shortened file names.',
  548. type: 'checkbox',
  549. value: true
  550. },
  551. customBoardLinks: {
  552. info: 'List of custom boards in nav (seperate by comma)',
  553. type: 'input',
  554. value: 'v,a,b'
  555. },
  556. hideDefaultBoards: {
  557. info: 'List of boards to remove from nav (seperate by comma). Set as "all" to remove all.',
  558. type: 'input',
  559. value: 'interracial,mlp'
  560. },
  561. catalogBoardLinks: {
  562. info: 'Redirect nav board links to catalog pages.',
  563. type: 'checkbox',
  564. value: true
  565. },
  566. uiTopPosition: {
  567. info: 'Position from top of screen e.g. 100px',
  568. type: 'input',
  569. value: '50px'
  570. },
  571. uiRightPosition: {
  572. info: 'Position from right of screen e.g. 100px',
  573. type: 'input',
  574. value: '25px'
  575. },
  576. uiDimWhenInactive: {
  577. info: 'Dim UI when not hovering with mouse.',
  578. type: 'checkbox',
  579. value: true
  580. },
  581. replyTabIcon: {
  582. info: 'Set the icon/text added to tab title when you get a new (You).',
  583. type: 'input',
  584. value: '❗'
  585. },
  586. };
  587. }
  588.  
  589. init() {
  590. this.settingsContainer = this.querySelector('.fcx-settings__settings');
  591. this.getSavedSettings();
  592. this.buildSettingsOptions();
  593. this.listeners();
  594. this.querySelector('.fcx-settings__close').addEventListener('click', () => this.close());
  595. }
  596.  
  597. setSavedSettings (updated) {
  598. localStorage.setItem(this.settingsKey, JSON.stringify(this.settings));
  599. if (updated) this.classList.add('fcxs-updated');
  600. }
  601.  
  602. getSavedSettings() {
  603. const saved = JSON.parse(localStorage.getItem(this.settingsKey));
  604. if (saved) this.settings = saved;
  605. }
  606.  
  607. listeners() {
  608. this.inputs.forEach(input => {
  609. input.addEventListener('change', () => {
  610. const key = input.name;
  611. const value = input.type === 'checkbox' ? input.checked : input.value;
  612. this.settings[key].value = value;
  613. this.setSavedSettings(true);
  614. });
  615. });
  616. }
  617.  
  618. buildSettingsOptions() {
  619. Object.entries(this.settingsTemplate).forEach(([key, config]) => {
  620. const wrapper = document.createElement('div');
  621. const infoWrapper = document.createElement('div');
  622. wrapper.classList.add('fcx-setting');
  623. infoWrapper.classList.add('fcx-setting__info');
  624. wrapper.appendChild(infoWrapper);
  625.  
  626. const label = document.createElement('label');
  627. label.textContent = key
  628. .replace(/([A-Z])/g, ' $1')
  629. .replace(/^./, str => str.toUpperCase());
  630. label.setAttribute('for', key);
  631. infoWrapper.appendChild(label);
  632.  
  633. if (config.info) {
  634. const info = document.createElement('p');
  635. info.textContent = config.info;
  636. infoWrapper.appendChild(info);
  637. }
  638.  
  639. const savedValue = this.settings[key]?.value ?? config.value;
  640.  
  641. let input;
  642.  
  643. if (config.type === 'checkbox') {
  644. input = document.createElement('input');
  645. input.type = 'checkbox';
  646. input.checked = savedValue;
  647. } else if (config.type === 'input') {
  648. input = document.createElement('input');
  649. input.type = 'text';
  650. input.value = savedValue;
  651. } else if (config.type === 'select') {
  652. input = document.createElement('select');
  653. const options = config.options.split(',');
  654. options.forEach(opt => {
  655. const option = document.createElement('option');
  656. option.value = opt;
  657. option.textContent = opt;
  658. if (opt === savedValue) option.selected = true;
  659. input.appendChild(option);
  660. });
  661. }
  662.  
  663. if (input) {
  664. input.id = key;
  665. input.name = key;
  666. wrapper.appendChild(input);
  667. this.inputs.push(input);
  668. this.settings[key] = { value: input.type === 'checkbox' ? input.checked : input.value };
  669. }
  670.  
  671. this.settingsContainer.appendChild(wrapper);
  672. });
  673.  
  674. this.setSavedSettings();
  675. }
  676.  
  677. open() {
  678. this.classList.add('open');
  679. }
  680.  
  681. close() {
  682. this.classList.remove('open');
  683. }
  684.  
  685. toggle() {
  686. this.classList.toggle('open');
  687. }
  688. }
  689.  
  690. window.customElements.define('fullchan-x-settings', fullChanXSettings);
  691.  
  692.  
  693.  
  694. // Create fullchan-x settings
  695. const fcxs = document.createElement('fullchan-x-settings');
  696. fcxs.innerHTML = `
  697. <div class="fcxs fcx-settings">
  698. <header>
  699. <span class="fcx-settings__title">
  700. Fullchan-X Settings
  701. </span>
  702. <button class="fcx-settings__close fullchan-x__option">Close</button>
  703. </header>
  704.  
  705. <main>
  706. <div class="fcxs__updated-message">
  707. <p>Settings updated, refresh page to apply</p>
  708. <button class="fullchan-x__option" onClick="location.reload()">Reload page</button>
  709. </div>
  710. <div class="fcx-settings__settings"></div>
  711. </main>
  712.  
  713. <footer>
  714. </footer>
  715. </div>
  716. `;
  717. document.body.appendChild(fcxs);
  718. fcxs.init();
  719.  
  720.  
  721. // Create fullchan-x gallery
  722. const fcxg = document.createElement('fullchan-x-gallery');
  723. fcxg.innerHTML = `
  724. <div class="fcxg gallery">
  725. <button id="fcxg-close" class="gallery__close fullchan-x__option">Close</button>
  726. <div class="gallery__scale-options">
  727. <button id="fcxg-smaller" class="scale-option fullchan-x__option">-</button>
  728. <button id="fcxg-larger" class="scale-option fullchan-x__option">+</button>
  729. </div>
  730. <div id="fcxg-images" class="gallery__images" style="--scale:1.0"></div>
  731. <div id="fcxg-main-image" class="gallery__main-image">
  732. <img src="" />
  733. </div>
  734. </div>
  735. `;
  736. document.body.appendChild(fcxg);
  737.  
  738.  
  739.  
  740. // Create fullchan-x element
  741. const fcx = document.createElement('fullchan-x');
  742. fcx.innerHTML = `
  743. <div class="fcx__controls">
  744. <button id="fcx-settings-btn" class="fullchan-x__option fcx-settings-toggle">
  745. ⚙️<span>Settings</span>
  746. </button>
  747.  
  748. <div class="fullchan-x__option thread-only">
  749. <select id="thread-sort">
  750. <option value="default">Default</option>
  751. <option value="replies">Replies</option>
  752. <option value="catbox">Catbox</option>
  753. </select>
  754. </div>
  755.  
  756. <button id="fcx-gallery-btn" class="gallery__toggle fullchan-x__option thread-only">
  757. 🖼️<span>Gallery</span>
  758. </button>
  759.  
  760. <div class="fcx__my-yous thread-only">
  761. <p class="my-yous__label fullchan-x__option">💬<span>My (You)s</span></p>
  762. <div class="my-yous__yous fcx-prevent-nesting" id="my-yous"></div>
  763. </div>
  764. </div>
  765. `;
  766. document.body.appendChild(fcx);
  767. fcx.styleUI()
  768. onload = (event) => fcx.init();
  769.  
  770.  
  771. // Styles
  772. const style = document.createElement('style');
  773. style.innerHTML = `
  774. fullchan-x {
  775. --top: 50px;
  776. --right: 25px;
  777. top: var(--top);
  778. right: var(--right);
  779. display: block;
  780. position: fixed;
  781. padding: 10px;
  782. background: var(--background-color);
  783. border: 1px solid var(--navbar-text-color);
  784. color: var(--link-color);
  785. font-size: 14px;
  786. z-index: 1000;
  787. }
  788.  
  789. fullchan-x:not(.page-thread) .thread-only,
  790. fullchan-x:not(.page-catalog) .catalog-only{
  791. display: none!important;
  792. }
  793.  
  794. fullchan-x:not(:hover):not(:has(select:focus)) {
  795. z-index: 3;
  796. }
  797.  
  798. fullchan-x.fcx--dim:not(:hover) {
  799. opacity: 0.6;
  800. }
  801.  
  802. .divPosts {
  803. flex-direction: column;
  804. }
  805.  
  806. .fcx__controls {
  807. display: flex;
  808. flex-direction: column;
  809. gap: 6px;
  810. }
  811.  
  812. fullchan-x:not(:hover):not(:has(select:focus)) span,
  813. fullchan-x:not(:hover):not(:has(select:focus)) select {
  814. display: none;
  815. margin-left: 5px;
  816. z-index:3;
  817. }
  818.  
  819. .fcx__controls span,
  820. .fcx__controls select {
  821. margin-left: 5px;
  822. }
  823.  
  824. #thread-sort {
  825. border: none;
  826. background: none;
  827. }
  828.  
  829. .my-yous__yous {
  830. display: none;
  831. flex-direction: column;
  832. padding-top: 10px;
  833. max-height: calc(100vh - 220px - var(--top));
  834. overflow: auto;
  835. }
  836.  
  837. .fcx__my-yous:hover .my-yous__yous {
  838. display: flex;
  839. }
  840.  
  841. .fullchan-x__option {
  842. display: flex;
  843. padding: 6px 8px;
  844. background: white;
  845. border: none !important;
  846. border-radius: 0.2rem;
  847. transition: all ease 150ms;
  848. cursor: pointer;
  849. margin: 0;
  850. text-align: left;
  851. min-width: 18px;
  852. min-height: 18px;
  853. align-items: center;
  854. }
  855.  
  856. .fullchan-x__option,
  857. .fullchan-x__option select {
  858. font-size: 12px;
  859. font-weight: 400;
  860. color: #374369;
  861. }
  862.  
  863. fullchan-x:not(:hover):not(:has(select:focus)) .fullchan-x__option {
  864. display: flex;
  865. justify-content: center;
  866. }
  867.  
  868. #thread-sort {
  869. padding-right: 0;
  870. }
  871.  
  872. #thread-sort:hover {
  873. display: block;
  874. }
  875.  
  876. .innerPost:has(.quoteLink.you) {
  877. border-left: solid #dd003e 6px;
  878. }
  879.  
  880. .innerPost:has(.youName) {
  881. border-left: solid #68b723 6px;
  882. }
  883.  
  884. /* --- Nested quotes --- */
  885. .divMessage .nestedPost {
  886. display: inline-block;
  887. width: 100%;
  888. margin-bottom: 14px;
  889. white-space: normal!important;
  890. overflow-wrap: anywhere;
  891. margin-top: 0.5em;
  892. border: 1px solid var(--navbar-text-color);
  893. }
  894.  
  895. .nestedPost .innerPost,
  896. .nestedPost .innerOP {
  897. width: 100%;
  898. }
  899.  
  900. .nestedPost .imgLink .imgExpanded {
  901. width: auto!important;
  902. height: auto!important;
  903. }
  904.  
  905. .my-yous__label.unseen {
  906. background: var(--link-hover-color);
  907. color: white;
  908. }
  909.  
  910. .my-yous__yous .unseen {
  911. font-weight: 900;
  912. color: var(--link-hover-color);
  913. }
  914.  
  915.  
  916.  
  917. /*--- Settings --- */
  918. .fcx-settings {
  919. display: block;
  920. position: fixed;
  921. top: 50vh;
  922. left: 50vw;
  923. translate: -50% -50%;
  924. padding: 20px 0;
  925. background: var(--background-color);
  926. border: 1px solid var(--navbar-text-color);
  927. color: var(--link-color);
  928. font-size: 14px;
  929. max-width: 480px;
  930. max-height: 80vh;
  931. overflow: scroll;
  932. }
  933.  
  934. fullchan-x-settings:not(.open) {
  935. display: none;
  936. }
  937.  
  938. .fcx-settings > * {
  939. padding: 0 20px;
  940. }
  941.  
  942. .fcx-settings header {
  943. display: flex;
  944. align-items: center;
  945. justify-content: space-between;
  946. margin: 0 0 15px;
  947. padding-bottom: 20px;
  948. border-bottom: 1px solid var(--navbar-text-color);
  949. }
  950.  
  951. .fcx-settings__title {
  952. font-size: 24px;
  953. font-size: 24px;
  954. letter-spacing: 0.04em;
  955. }
  956.  
  957. fullchan-x-settings:not(.fcxs-updated) .fcxs__updated-message {
  958. display: none;
  959. }
  960.  
  961. .fcx-setting {
  962. display: flex;
  963. justify-content: space-between;
  964. align-items: center;
  965. padding: 12px 0;
  966. }
  967.  
  968. .fcx-setting__info {
  969. max-width: 60%;
  970. }
  971.  
  972. .fcx-setting input[type="text"],
  973. .fcx-setting select {
  974. padding: 4px 6px;
  975. min-width: 35%;
  976. }
  977.  
  978. .fcx-setting label {
  979. font-weight: 600;
  980. }
  981.  
  982. .fcx-setting p {
  983. margin: 6px 0 0;
  984. font-size: 12px;
  985. }
  986.  
  987. .fcx-setting + .fcx-setting {
  988. border-top: 1px solid var(--navbar-text-color);
  989. }
  990.  
  991. .fcxs__updated-message {
  992. margin: 10px 0;
  993. text-align: center;
  994. }
  995.  
  996. .fcxs__updated-message p {
  997. font-size: 14px;
  998. color: var(--error);
  999. }
  1000.  
  1001. .fcxs__updated-message button {
  1002. margin: 14px auto 0;
  1003. }
  1004.  
  1005. /* --- Gallery --- */
  1006. .fct-gallery-open,
  1007. body.fct-gallery-open,
  1008. body.fct-gallery-open #mainPanel {
  1009. overflow: hidden!important;
  1010. }
  1011.  
  1012. body.fct-gallery-open fullchan-x,
  1013. body.fct-gallery-open #quick-reply {
  1014. display: none!important;
  1015. }
  1016.  
  1017. fullchan-x-gallery {
  1018. position: fixed;
  1019. top: 0;
  1020. left: 0;
  1021. width: 100%;
  1022. background: rgba(0,0,0,0.9);
  1023. display: none;
  1024. height: 100%;
  1025. overflow: auto;
  1026. }
  1027.  
  1028. fullchan-x-gallery.open {
  1029. display: block;
  1030. }
  1031.  
  1032. fullchan-x-gallery .gallery {
  1033. padding: 50px 10px 0
  1034. }
  1035.  
  1036. fullchan-x-gallery .gallery__images {
  1037. --scale: 1.0;
  1038. display: flex;
  1039. width: 100%;
  1040. height: 100%;
  1041. justify-content: center;
  1042. align-content: flex-start;
  1043. gap: 4px 8px;
  1044. flex-wrap: wrap;
  1045. }
  1046.  
  1047. fullchan-x-gallery .imgLink {
  1048. float: unset;
  1049. display: block;
  1050. zoom: var(--scale);
  1051. }
  1052.  
  1053. fullchan-x-gallery .imgLink img {
  1054. border: solid white 1px;
  1055. }
  1056.  
  1057. fullchan-x-gallery .imgLink[data-filemime="video/webm"] img {
  1058. border: solid #68b723 4px;
  1059. }
  1060.  
  1061. fullchan-x-gallery .gallery__close {
  1062. border: solid 1px var(--background-color)!important;
  1063. position: fixed;
  1064. top: 60px;
  1065. right: 35px;
  1066. padding: 6px 14px;
  1067. min-height: 30px;
  1068. z-index: 10;
  1069. }
  1070.  
  1071. .fcxg .gallery__scale-options {
  1072. position: fixed;
  1073. bottom: 30px;
  1074. right: 35px;
  1075. display: flex;
  1076. gap: 14px;
  1077. z-index: 10;
  1078. }
  1079.  
  1080. .fcxg .gallery__scale-options .fullchan-x__option {
  1081. border: solid 1px var(--background-color)!important;
  1082. width: 35px;
  1083. height: 35px;
  1084. font-size: 18px;
  1085. display: flex;
  1086. justify-content: center;
  1087. }
  1088.  
  1089. .gallery__main-image {
  1090. display: none;
  1091. position: fixed;
  1092. top: 0;
  1093. left: 0;
  1094. width: 100%;
  1095. height: 100%;
  1096. justify-content: center;
  1097. align-content: center;
  1098. background: rgba(0,0,0,0.5);
  1099. }
  1100.  
  1101. .gallery__main-image img {
  1102. padding: 40px 10px 15px;
  1103. height: auto;
  1104. max-width: calc(100% - 20px);
  1105. object-fit: contain;
  1106. }
  1107.  
  1108. .gallery__main-image.active {
  1109. display: flex;
  1110. }
  1111.  
  1112. /*-- Truncated file extentions --*/
  1113. .originalNameLink[data-file-ext] {
  1114. max-width: 65px;
  1115. }
  1116.  
  1117. a[data-file-ext]:hover:after {
  1118. content: attr(data-file-ext);
  1119. }
  1120.  
  1121. a[data-file-ext] + .file-ext {
  1122. pointer-events: none;
  1123. }
  1124.  
  1125. a[data-file-ext]:hover + .file-ext {
  1126. display: none;
  1127. }
  1128.  
  1129. /*-- Nav Board Links --*/
  1130. .nav-boards--custom {
  1131. display: flex;
  1132. gap: 3px;
  1133. }
  1134.  
  1135. #navTopBoardsSpan.hidden ~ #navBoardsSpan,
  1136. #navTopBoardsSpan.hidden ~ .nav-fade,
  1137. #navTopBoardsSpan a.hidden + span {
  1138. display: none;
  1139. }
  1140.  
  1141. /*-- Anon Unique ID posts --*/
  1142. .postInfo .spanId {
  1143. position: relative;
  1144. }
  1145.  
  1146. .fcx-id-posts {
  1147. position: absolute;
  1148. top: 0;
  1149. left: 20px;
  1150. translate: 0 calc(-100% - 5px);
  1151. display: flex;
  1152. flex-direction: column;
  1153. padding: 10px;
  1154. background: var(--background-color);
  1155. border: 1px solid var(--navbar-text-color);
  1156. width: max-content;
  1157. max-width: 500px;
  1158. max-height: 500px;
  1159. overflow: auto;
  1160. z-index: 1000;
  1161. }
  1162.  
  1163. .fcx-id-posts .nestedPost {
  1164. pointer-events: none;
  1165. width: auto;
  1166. }
  1167. `;
  1168.  
  1169. document.head.appendChild(style);
  1170.  
  1171.  
  1172. // Asuka and Eris (fantasy Asuka) are best girls