Fullchan X

8chan features script

As of 2025-04-20. See the latest version.

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