Soyjak Themer

Select and apply themes from GitHub repositories to soyjak.st

  1. // ==UserScript==
  2. // @name Soyjak Themer
  3. // @namespace http://tampermonkey.net/
  4. // @version 1.3.1
  5. // @description Select and apply themes from GitHub repositories to soyjak.st
  6. // @author ReignOfTea
  7. // @match https://www.soyjak.party/*
  8. // @match https://soyjak.party/*
  9. // @match https://www.soyjak.st/*
  10. // @match https://soyjak.st/*
  11. // @icon https://www.google.com/s2/favicons?sz=64&domain=www.soyjak.st
  12. // @license AGPL-3.0
  13. // @grant none
  14. // ==/UserScript==
  15.  
  16. (function () {
  17. "use strict";
  18.  
  19. const STORAGE_KEYS = {
  20. THEME: "soyjak_selected_theme",
  21. USER_CSS: "soyjak_user_css",
  22. THEMES_CACHE: "soyjak_themes_cache",
  23. CACHE_TIMESTAMP: "soyjak_themes_cache_timestamp",
  24. FAVORITES: "soyjak_favorite_themes",
  25. REPOS: "soyjak_theme_repos"
  26. };
  27.  
  28. const CACHE_DURATION = 7 * 24 * 60 * 60 * 1000; // 7 Days
  29.  
  30. const DEFAULT_REPOS = [
  31. {
  32. name: "tchan",
  33. apiUrl: "https://api.github.com/repos/ReignOfTea/tchan/contents/?ref=master",
  34. basePath: "https://raw.githubusercontent.com/ReignOfTea/tchan/master/",
  35. isDefault: true,
  36. },
  37. {
  38. name: "lainchan",
  39. apiUrl: "https://api.github.com/repos/lainchan/lainchan/contents/stylesheets?ref=php7.4",
  40. basePath: "https://raw.githubusercontent.com/lainchan/lainchan/php7.4/stylesheets/",
  41. isDefault: true,
  42. },
  43. {
  44. name: "vichan",
  45. apiUrl: "https://api.github.com/repos/vichan-devel/vichan/contents/stylesheets?ref=master",
  46. basePath: "https://raw.githubusercontent.com/vichan-devel/vichan/master/stylesheets/",
  47. isDefault: true,
  48. },
  49. ];
  50.  
  51. let state = {
  52. initialThemeApplied: false,
  53. originalCSS: null,
  54. previewingTheme: false
  55. };
  56.  
  57. const repoManager = {
  58. getAll() {
  59. try {
  60. const repos = JSON.parse(
  61. localStorage.getItem(STORAGE_KEYS.REPOS) || "null"
  62. );
  63.  
  64. if (!repos) {
  65. localStorage.setItem(STORAGE_KEYS.REPOS, JSON.stringify(DEFAULT_REPOS));
  66. return DEFAULT_REPOS;
  67. }
  68.  
  69. return repos;
  70. } catch (error) {
  71. console.error("Error getting repositories:", error);
  72. localStorage.setItem(STORAGE_KEYS.REPOS, JSON.stringify(DEFAULT_REPOS));
  73. return DEFAULT_REPOS;
  74. }
  75. },
  76.  
  77. add(name, apiUrl, basePath) {
  78. try {
  79. const repos = this.getAll();
  80.  
  81. const existingIndex = repos.findIndex((repo) => repo.name === name);
  82. if (existingIndex >= 0) {
  83. if (repos[existingIndex].isDefault) {
  84. uiHelper.showToast(
  85. `Cannot modify default repository "${name}"`,
  86. "error"
  87. );
  88. return false;
  89. }
  90.  
  91. repos[existingIndex] = { name, apiUrl, basePath };
  92. uiHelper.showToast(`Updated repository "${name}"`, "info");
  93. } else {
  94. repos.push({ name, apiUrl, basePath });
  95. uiHelper.showToast(`Added repository "${name}"`, "info");
  96. }
  97.  
  98. localStorage.setItem(STORAGE_KEYS.REPOS, JSON.stringify(repos));
  99. uiHelper.updateReposList();
  100. return true;
  101. } catch (error) {
  102. console.error("Error adding repository:", error);
  103. uiHelper.showToast("Error adding repository", "error");
  104. return false;
  105. }
  106. },
  107.  
  108. remove(name) {
  109. try {
  110. const repos = this.getAll();
  111. const repo = repos.find(r => r.name === name);
  112.  
  113. if (!repo) return false;
  114.  
  115. if (repo.isDefault && !confirm("This is a default repository. Are you sure you want to remove it?")) {
  116. return false;
  117. }
  118.  
  119. const filteredRepos = repos.filter((repo) => repo.name !== name);
  120.  
  121. if (filteredRepos.length < repos.length) {
  122. localStorage.setItem(
  123. STORAGE_KEYS.REPOS,
  124. JSON.stringify(filteredRepos)
  125. );
  126. uiHelper.showToast(`Removed repository "${name}"`, "info");
  127. uiHelper.updateReposList();
  128. return true;
  129. }
  130.  
  131. return false;
  132. } catch (error) {
  133. console.error("Error removing repository:", error);
  134. uiHelper.showToast("Error removing repository", "error");
  135. return false;
  136. }
  137. },
  138.  
  139. resetToDefault() {
  140. try {
  141. if (
  142. confirm(
  143. "Reset all repositories to default? This will remove any custom repositories you have added."
  144. )
  145. ) {
  146. localStorage.setItem(
  147. STORAGE_KEYS.REPOS,
  148. JSON.stringify(DEFAULT_REPOS)
  149. );
  150. uiHelper.showToast("Repositories reset to default", "info");
  151. uiHelper.updateReposList();
  152. return true;
  153. }
  154. return false;
  155. } catch (error) {
  156. console.error("Error resetting repositories:", error);
  157. uiHelper.showToast("Error resetting repositories", "error");
  158. return false;
  159. }
  160. }
  161. };
  162.  
  163. const themeManager = {
  164. async fetchRepoContents(repo) {
  165. try {
  166. const response = await fetch(repo.apiUrl);
  167. if (!response.ok) {
  168. throw new Error(`HTTP error! status: ${response.status}`);
  169. }
  170.  
  171. const data = await response.json();
  172.  
  173. return data
  174. .filter((item) => item.name.endsWith(".css"))
  175. .map((item) => {
  176. return {
  177. name: item.name.replace(".css", ""),
  178. url: repo.basePath + item.name,
  179. repo: repo.name,
  180. };
  181. });
  182. } catch (error) {
  183. console.error(`Error fetching themes from ${repo.name}:`, error);
  184. uiHelper.showToast(`Error fetching themes from ${repo.name}`, "error");
  185. return [];
  186. }
  187. },
  188.  
  189. async fetchAllThemes() {
  190. const statusElement = document.getElementById("theme-status");
  191. if (statusElement) {
  192. statusElement.textContent = "Fetching available themes...";
  193. }
  194.  
  195. try {
  196. const allRepos = repoManager.getAll();
  197. const allThemesPromises = allRepos.map((repo) =>
  198. this.fetchRepoContents(repo)
  199. );
  200. const allThemesArrays = await Promise.all(allThemesPromises);
  201. const allThemes = allThemesArrays.flat();
  202.  
  203. const themesObject = {};
  204. allThemes.forEach((theme) => {
  205. themesObject[theme.name] = theme;
  206. });
  207.  
  208. localStorage.setItem(STORAGE_KEYS.THEMES_CACHE, JSON.stringify(themesObject));
  209. localStorage.setItem(
  210. STORAGE_KEYS.CACHE_TIMESTAMP,
  211. Date.now().toString()
  212. );
  213.  
  214. if (statusElement) {
  215. statusElement.textContent = `Found ${allThemes.length} themes from ${allRepos.length} repositories.`;
  216. }
  217.  
  218. return themesObject;
  219. } catch (error) {
  220. console.error("Error fetching all themes:", error);
  221. if (statusElement) {
  222. statusElement.textContent =
  223. "Error fetching themes. Check console for details.";
  224. }
  225. uiHelper.showToast("Error fetching themes", "error");
  226. return {};
  227. }
  228. },
  229.  
  230. async getThemes(forceRefresh = false) {
  231. try {
  232. const cachedThemes = localStorage.getItem(STORAGE_KEYS.THEMES_CACHE);
  233. const cacheTimestamp = localStorage.getItem(STORAGE_KEYS.CACHE_TIMESTAMP);
  234.  
  235. const now = Date.now();
  236. const cacheAge = cacheTimestamp
  237. ? now - parseInt(cacheTimestamp)
  238. : Infinity;
  239.  
  240. if (!forceRefresh && cachedThemes && cacheAge < CACHE_DURATION) {
  241. return JSON.parse(cachedThemes);
  242. }
  243.  
  244. return await this.fetchAllThemes();
  245. } catch (error) {
  246. console.error("Error getting themes:", error);
  247. uiHelper.showToast("Error getting themes", "error");
  248. return {};
  249. }
  250. },
  251.  
  252. async applyTheme(themeName, skipConfirmation = false, initialLoad = false) {
  253. if (
  254. !skipConfirmation &&
  255. !initialLoad &&
  256. !confirm(`Apply theme: ${themeName}?`)
  257. ) {
  258. return;
  259. }
  260.  
  261. try {
  262. const themes = await this.getThemes();
  263. if (!themes[themeName]) {
  264. uiHelper.showToast(`Theme "${themeName}" not found`, "error");
  265. return false;
  266. }
  267.  
  268. const response = await fetch(themes[themeName].url);
  269. if (!response.ok) {
  270. throw new Error(`HTTP error! status: ${response.status}`);
  271. }
  272.  
  273. let css = await response.text();
  274.  
  275. css = this.processCSS(css);
  276.  
  277. if (!state.originalCSS && !initialLoad) {
  278. const existingStyle = document.getElementById(
  279. "applied-theme-style"
  280. );
  281. if (existingStyle) {
  282. state.originalCSS = existingStyle.textContent;
  283. }
  284. }
  285.  
  286. let styleElement = document.getElementById("applied-theme-style");
  287. if (!styleElement) {
  288. styleElement = document.createElement("style");
  289. styleElement.id = "applied-theme-style";
  290. document.head.appendChild(styleElement);
  291. }
  292.  
  293. styleElement.textContent = css;
  294.  
  295. localStorage.setItem(STORAGE_KEYS.THEME, themeName);
  296. localStorage.setItem(STORAGE_KEYS.USER_CSS, css);
  297.  
  298. if (!initialLoad) {
  299. uiHelper.showToast(`Theme "${themeName}" applied`, "success");
  300. }
  301.  
  302. state.previewingTheme = false;
  303.  
  304. const cancelPreviewButton =
  305. document.getElementById("cancel-preview");
  306. if (cancelPreviewButton) {
  307. cancelPreviewButton.style.display = "none";
  308. }
  309.  
  310. return true;
  311. } catch (error) {
  312. console.error("Error applying theme:", error);
  313. uiHelper.showToast(`Error applying theme: ${error.message}`, "error");
  314. return false;
  315. }
  316. },
  317.  
  318. async previewTheme(themeName) {
  319. try {
  320. const themes = await this.getThemes();
  321. if (!themes[themeName]) {
  322. uiHelper.showToast(`Theme "${themeName}" not found`, "error");
  323. return false;
  324. }
  325.  
  326. const response = await fetch(themes[themeName].url);
  327. if (!response.ok) {
  328. throw new Error(`HTTP error! status: ${response.status}`);
  329. }
  330.  
  331. let css = await response.text();
  332.  
  333. css = this.processCSS(css);
  334.  
  335. const styleElement = document.getElementById("applied-theme-style");
  336. if (styleElement) {
  337. state.originalCSS = styleElement.textContent;
  338. } else {
  339. state.originalCSS = null;
  340. }
  341.  
  342. let previewStyleElement = document.getElementById("applied-theme-style");
  343. if (!previewStyleElement) {
  344. previewStyleElement = document.createElement("style");
  345. previewStyleElement.id = "applied-theme-style";
  346. document.head.appendChild(previewStyleElement);
  347. }
  348.  
  349. previewStyleElement.textContent = css;
  350. state.previewingTheme = true;
  351. uiHelper.showToast(`Previewing "${themeName}"`, "info");
  352.  
  353. return true;
  354. } catch (error) {
  355. console.error("Error previewing theme:", error);
  356. uiHelper.showToast(`Error previewing theme: ${error.message}`, "error");
  357. return false;
  358. }
  359. },
  360.  
  361. processCSS(css) {
  362. let processedCSS = "/* Processed by Soyjak Theme Selector to ensure style overrides */\n";
  363.  
  364. const cssRules = css.split('}');
  365.  
  366. for (let i = 0; i < cssRules.length; i++) {
  367. if (cssRules[i].trim() === '') continue;
  368.  
  369. const openBracePos = cssRules[i].indexOf('{');
  370. if (openBracePos === -1) {
  371. processedCSS += cssRules[i] + '}\n';
  372. continue;
  373. }
  374.  
  375. const selector = cssRules[i].substring(0, openBracePos).trim();
  376. let declarations = cssRules[i].substring(openBracePos + 1).trim();
  377.  
  378. if (selector.startsWith('@')) {
  379. processedCSS += cssRules[i] + '}\n';
  380. continue;
  381. }
  382.  
  383. const declarationParts = declarations.split(';');
  384. let processedDeclarations = '';
  385.  
  386. for (let j = 0; j < declarationParts.length; j++) {
  387. const declaration = declarationParts[j].trim();
  388. if (declaration === '') continue;
  389.  
  390. if (declaration.includes('!important')) {
  391. processedDeclarations += declaration + '; ';
  392. } else {
  393. processedDeclarations += declaration + ' !important; ';
  394. }
  395. }
  396.  
  397. processedCSS += selector + ' { ' + processedDeclarations + '}\n';
  398. }
  399.  
  400. return processedCSS;
  401. },
  402.  
  403. cancelPreview() {
  404. if (!state.previewingTheme) {
  405. return;
  406. }
  407.  
  408. const styleElement = document.getElementById("applied-theme-style");
  409.  
  410. if (state.originalCSS === null) {
  411. if (styleElement) {
  412. styleElement.remove();
  413. }
  414. } else {
  415. if (styleElement) {
  416. styleElement.textContent = state.originalCSS;
  417. } else {
  418. const newStyleElement = document.createElement("style");
  419. newStyleElement.id = "applied-theme-style";
  420. newStyleElement.textContent = state.originalCSS;
  421. document.head.appendChild(newStyleElement);
  422. }
  423. }
  424.  
  425. state.previewingTheme = false;
  426. uiHelper.showToast("Preview canceled", "info");
  427.  
  428. const cancelPreviewButton = document.getElementById("cancel-preview");
  429. if (cancelPreviewButton) {
  430. cancelPreviewButton.style.display = "none";
  431. }
  432. },
  433. resetTheme() {
  434. try {
  435. if (!confirm("Reset to default theme?")) {
  436. return false;
  437. }
  438.  
  439. localStorage.removeItem(STORAGE_KEYS.THEME);
  440. localStorage.removeItem(STORAGE_KEYS.USER_CSS);
  441.  
  442. const styleElement = document.getElementById("applied-theme-style");
  443. if (styleElement) {
  444. styleElement.remove();
  445. }
  446.  
  447. const select = document.getElementById("external-theme-select");
  448. if (select) {
  449. select.value = "";
  450. }
  451.  
  452. const themeInfo = document.getElementById("theme-info");
  453. if (themeInfo) {
  454. themeInfo.style.display = "none";
  455. }
  456.  
  457. const statusElement = document.getElementById("theme-status");
  458. if (statusElement) {
  459. statusElement.textContent = "Theme reset to default.";
  460. }
  461.  
  462. state.originalCSS = null;
  463. state.previewingTheme = false;
  464.  
  465. uiHelper.showToast("Reset to default theme", "info");
  466. return true;
  467. } catch (error) {
  468. console.error("Error resetting theme:", error);
  469. uiHelper.showToast("Error resetting theme", "error");
  470. return false;
  471. }
  472. }
  473. };
  474.  
  475. const favoritesManager = {
  476. getFavorites() {
  477. try {
  478. return JSON.parse(localStorage.getItem(STORAGE_KEYS.FAVORITES) || "[]");
  479. } catch (error) {
  480. console.error("Error getting favorites:", error);
  481. return [];
  482. }
  483. },
  484.  
  485. toggleFavorite(themeName) {
  486. try {
  487. const favorites = this.getFavorites();
  488. const index = favorites.indexOf(themeName);
  489.  
  490. if (index === -1) {
  491. favorites.push(themeName);
  492. uiHelper.showToast(`Added "${themeName}" to favorites`, "success");
  493. } else {
  494. favorites.splice(index, 1);
  495. uiHelper.showToast(`Removed "${themeName}" from favorites`, "info");
  496. }
  497.  
  498. localStorage.setItem(STORAGE_KEYS.FAVORITES, JSON.stringify(favorites));
  499. return index === -1;
  500. } catch (error) {
  501. console.error("Error toggling favorite:", error);
  502. uiHelper.showToast("Error updating favorites", "error");
  503. return false;
  504. }
  505. }
  506. };
  507.  
  508. const settingsManager = {
  509. exportSettings() {
  510. try {
  511. const settings = {
  512. selectedTheme: localStorage.getItem(STORAGE_KEYS.THEME),
  513. favorites: favoritesManager.getFavorites(),
  514. repositories: repoManager.getAll(),
  515. };
  516.  
  517. const blob = new Blob([JSON.stringify(settings, null, 2)], {
  518. type: "application/json",
  519. });
  520. const url = URL.createObjectURL(blob);
  521. const a = document.createElement("a");
  522. a.href = url;
  523. a.download = "soyjak-theme-settings.json";
  524. a.click();
  525.  
  526. URL.revokeObjectURL(url);
  527. uiHelper.showToast("Settings exported successfully", "info");
  528. return true;
  529. } catch (error) {
  530. console.error("Error exporting settings:", error);
  531. uiHelper.showToast("Error exporting settings", "error");
  532. return false;
  533. }
  534. },
  535.  
  536. importSettings(file) {
  537. const reader = new FileReader();
  538. reader.onload = (e) => {
  539. try {
  540. const settings = JSON.parse(e.target.result);
  541.  
  542. if (settings.repositories && Array.isArray(settings.repositories)) {
  543. localStorage.setItem(
  544. STORAGE_KEYS.REPOS,
  545. JSON.stringify(settings.repositories)
  546. );
  547. }
  548.  
  549. if (settings.favorites && Array.isArray(settings.favorites)) {
  550. localStorage.setItem(
  551. STORAGE_KEYS.FAVORITES,
  552. JSON.stringify(settings.favorites)
  553. );
  554. }
  555.  
  556. if (settings.selectedTheme) {
  557. localStorage.setItem(STORAGE_KEYS.THEME, settings.selectedTheme);
  558. themeManager.applyTheme(settings.selectedTheme, true);
  559. }
  560.  
  561. uiHelper.updateReposList();
  562. uiHelper.populateThemeSelect();
  563. uiHelper.updateCacheInfo();
  564. uiHelper.showToast("Settings imported successfully", "success");
  565. return true;
  566. } catch (error) {
  567. console.error("Error importing settings:", error);
  568. uiHelper.showToast("Error importing settings", "error");
  569. return false;
  570. }
  571. };
  572. reader.readAsText(file);
  573. }
  574. };
  575.  
  576. const uiHelper = {
  577. showToast(message, type = "info") {
  578. const toast = document.createElement("div");
  579. toast.className = `theme-toast ${type}`;
  580. toast.textContent = message;
  581. document.body.appendChild(toast);
  582.  
  583. setTimeout(() => {
  584. toast.classList.add("show");
  585. }, 10);
  586.  
  587. setTimeout(() => {
  588. toast.classList.remove("show");
  589. setTimeout(() => {
  590. if (document.body.contains(toast)) {
  591. document.body.removeChild(toast);
  592. }
  593. }, 500);
  594. }, 3000);
  595. },
  596.  
  597. addStyles() {
  598. const styleElement = document.createElement("style");
  599. styleElement.textContent = `
  600. .theme-selector-container {
  601. padding: 15px;
  602. font-family: Arial, sans-serif;
  603. max-width: 800px;
  604. margin: 0 auto;
  605. }
  606.  
  607. .theme-selector-header {
  608. margin-bottom: 20px;
  609. text-align: center;
  610. }
  611.  
  612. .theme-selector-tabs {
  613. display: flex;
  614. border-bottom: 1px solid #ccc;
  615. margin-bottom: 15px;
  616. }
  617.  
  618. .theme-selector-tab {
  619. padding: 8px 15px;
  620. cursor: pointer;
  621. border: 1px solid transparent;
  622. border-bottom: none;
  623. margin-right: 5px;
  624. border-radius: 5px 5px 0 0;
  625. }
  626.  
  627. .theme-selector-tab.active {
  628. border-color: #ccc;
  629. background-color: #f9f9f9;
  630. margin-bottom: -1px;
  631. padding-bottom: 9px;
  632. }
  633.  
  634. .theme-selector-tab-content {
  635. display: none;
  636. padding: 15px;
  637. border: 1px solid #ccc;
  638. border-top: none;
  639. background-color: #f9f9f9;
  640. }
  641.  
  642. .theme-selector-tab-content.active {
  643. display: block;
  644. }
  645.  
  646. .theme-selector-search {
  647. width: 100%;
  648. padding: 8px;
  649. margin-bottom: 10px;
  650. box-sizing: border-box;
  651. }
  652.  
  653. .theme-selector-row {
  654. display: flex;
  655. justify-content: space-between;
  656. margin-bottom: 10px;
  657. }
  658.  
  659. .theme-selector-button {
  660. padding: 8px 15px;
  661. cursor: pointer;
  662. background-color: #f0f0f0;
  663. border: 1px solid #ccc;
  664. border-radius: 3px;
  665. flex: 1;
  666. margin: 0 5px;
  667. }
  668.  
  669. .theme-selector-button:first-child {
  670. margin-left: 0;
  671. }
  672.  
  673. .theme-selector-button:last-child {
  674. margin-right: 0;
  675. }
  676.  
  677. .theme-selector-button:hover {
  678. background-color: #e0e0e0;
  679. }
  680.  
  681. .theme-selector-full-button {
  682. width: 100%;
  683. padding: 8px 15px;
  684. cursor: pointer;
  685. background-color: #f0f0f0;
  686. border: 1px solid #ccc;
  687. border-radius: 3px;
  688. margin-bottom: 10px;
  689. }
  690.  
  691. .theme-selector-full-button:hover {
  692. background-color: #e0e0e0;
  693. }
  694.  
  695. .theme-selector-info {
  696. margin-top: 15px;
  697. padding: 10px;
  698. border: 1px solid #ddd;
  699. background-color: #f9f9f9;
  700. border-radius: 3px;
  701. }
  702.  
  703. .theme-selector-status {
  704. margin-top: 15px;
  705. font-style: italic;
  706. color: #666;
  707. }
  708.  
  709. .theme-toast {
  710. position: fixed;
  711. bottom: 20px;
  712. right: 20px;
  713. padding: 10px 20px;
  714. background-color: #333;
  715. color: white;
  716. border-radius: 5px;
  717. z-index: 10000;
  718. opacity: 0;
  719. transition: opacity 0.5s;
  720. max-width: 300px;
  721. }
  722.  
  723. .theme-toast.show {
  724. opacity: 1;
  725. }
  726.  
  727. .theme-toast.info {
  728. background-color: #2196F3;
  729. }
  730.  
  731. .theme-toast.success {
  732. background-color: #4CAF50;
  733. }
  734.  
  735. .theme-toast.warning {
  736. background-color: #FF9800;
  737. }
  738.  
  739. .theme-toast.error {
  740. background-color: #F44336;
  741. }
  742.  
  743. .favorite-star {
  744. cursor: pointer;
  745. margin-left: 5px;
  746. color: #ccc;
  747. }
  748.  
  749. .favorite-star.active {
  750. color: gold;
  751. }
  752.  
  753. .repo-item {
  754. margin-bottom: 5px;
  755. }
  756.  
  757. .remove-repo-btn {
  758. background-color: #f44336;
  759. color: white;
  760. border: none;
  761. padding: 3px 8px;
  762. border-radius: 3px;
  763. cursor: pointer;
  764. }
  765.  
  766. .remove-repo-btn:hover {
  767. background-color: #d32f2f;
  768. }
  769. `;
  770. document.head.appendChild(styleElement);
  771. },
  772.  
  773. async populateThemeSelect() {
  774. const select = document.getElementById("external-theme-select");
  775. if (!select) return;
  776.  
  777. const themes = await themeManager.getThemes();
  778. const favorites = favoritesManager.getFavorites();
  779.  
  780. select.innerHTML = "";
  781.  
  782. const placeholderOption = document.createElement("option");
  783. placeholderOption.value = "";
  784. placeholderOption.textContent = "-- Select a theme --";
  785. select.appendChild(placeholderOption);
  786.  
  787. const sortedThemeNames = Object.keys(themes).sort((a, b) => {
  788. const aIsFavorite = favorites.includes(a);
  789. const bIsFavorite = favorites.includes(b);
  790.  
  791. if (aIsFavorite && !bIsFavorite) return -1;
  792. if (!aIsFavorite && bIsFavorite) return 1;
  793.  
  794. return a.localeCompare(b);
  795. });
  796.  
  797. const favoritesGroup = document.createElement("optgroup");
  798. favoritesGroup.label = "Favorites";
  799.  
  800. const repoGroups = {};
  801.  
  802. sortedThemeNames.forEach((themeName) => {
  803. const theme = themes[themeName];
  804. const isFavorite = favorites.includes(themeName);
  805.  
  806. const option = document.createElement("option");
  807. option.value = themeName;
  808. option.textContent = themeName;
  809. option.dataset.repo = theme.repo;
  810.  
  811. if (isFavorite) {
  812. option.dataset.favorite = "true";
  813. favoritesGroup.appendChild(option);
  814. } else {
  815. if (!repoGroups[theme.repo]) {
  816. repoGroups[theme.repo] = document.createElement("optgroup");
  817. repoGroups[theme.repo].label = theme.repo;
  818. }
  819.  
  820. repoGroups[theme.repo].appendChild(option);
  821. }
  822. });
  823.  
  824. if (favoritesGroup.children.length > 0) {
  825. select.appendChild(favoritesGroup);
  826. }
  827.  
  828. Object.values(repoGroups).forEach((group) => {
  829. if (group.children.length > 0) {
  830. select.appendChild(group);
  831. }
  832. });
  833.  
  834. const savedTheme = localStorage.getItem(STORAGE_KEYS.THEME);
  835. if (savedTheme && themes[savedTheme]) {
  836. select.value = savedTheme;
  837. }
  838.  
  839. this.updateThemeInfo();
  840. },
  841.  
  842. filterThemes(searchText) {
  843. const select = document.getElementById("external-theme-select");
  844. if (!select) return;
  845.  
  846. const options = select.querySelectorAll("option");
  847. const optgroups = select.querySelectorAll("optgroup");
  848.  
  849. searchText = searchText.toLowerCase();
  850.  
  851. optgroups.forEach((group) => {
  852. group.style.display = "";
  853. });
  854.  
  855. options.forEach((option) => {
  856. if (option.value === "") return;
  857. const themeName = option.textContent.toLowerCase();
  858. const matches = themeName.includes(searchText);
  859.  
  860. option.style.display = matches ? "" : "none";
  861. });
  862.  
  863. optgroups.forEach((group) => {
  864. const visibleOptions = Array.from(
  865. group.querySelectorAll("option")
  866. ).filter((opt) => opt.style.display !== "none");
  867. group.style.display = visibleOptions.length > 0 ? "" : "none";
  868. });
  869. },
  870.  
  871. async updateThemeInfo() {
  872. const select = document.getElementById("external-theme-select");
  873. const infoDiv = document.getElementById("theme-info");
  874.  
  875. if (!select || !infoDiv) return;
  876.  
  877. const selectedTheme = select.value;
  878.  
  879. if (!selectedTheme) {
  880. infoDiv.style.display = "none";
  881. return;
  882. }
  883.  
  884. const themes = await themeManager.getThemes();
  885. const theme = themes[selectedTheme];
  886.  
  887. if (!theme) {
  888. infoDiv.style.display = "none";
  889. return;
  890. }
  891.  
  892. const favorites = favoritesManager.getFavorites();
  893. const isFavorite = favorites.includes(selectedTheme);
  894.  
  895. infoDiv.innerHTML = `
  896. <h3>
  897. ${selectedTheme}
  898. <span class="favorite-star ${isFavorite ? "active" : ""
  899. }" data-theme="${selectedTheme}">★</span>
  900. </h3>
  901. <p><strong>Repository:</strong> ${theme.repo}</p>
  902. <p><strong>URL:</strong> <a href="${theme.url}" target="_blank">${theme.url
  903. }</a></p>
  904. `;
  905.  
  906. infoDiv.style.display = "block";
  907.  
  908. const favoriteStar = infoDiv.querySelector(".favorite-star");
  909. if (favoriteStar) {
  910. favoriteStar.addEventListener("click", (event) => {
  911. const themeName = event.target.dataset.theme;
  912. if (!themeName) return;
  913.  
  914. const isNowFavorite = favoritesManager.toggleFavorite(themeName);
  915. event.target.classList.toggle("active", isNowFavorite);
  916.  
  917. this.populateThemeSelect();
  918. });
  919. }
  920. },
  921.  
  922. updateReposList() {
  923. const reposList = document.getElementById("repos-list");
  924. if (!reposList) return;
  925.  
  926. const repos = repoManager.getAll();
  927. reposList.innerHTML = "";
  928.  
  929. if (repos.length === 0) {
  930. reposList.innerHTML = "<p>No repositories configured.</p>";
  931. return;
  932. }
  933.  
  934. repos.forEach((repo) => {
  935. const repoItem = document.createElement("div");
  936. repoItem.className = "repo-item";
  937. repoItem.innerHTML = `
  938. <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; padding: 5px; border-bottom: 1px solid #ddd;">
  939. <div>
  940. <strong>${repo.name}</strong> ${repo.isDefault
  941. ? '<span style="color: #666; font-size: 0.8em;">(Default)</span>'
  942. : ""
  943. }
  944. <div style="font-size: 0.8em; color: #666;">API: ${repo.apiUrl}</div>
  945. <div style="font-size: 0.8em; color: #666;">Base: ${repo.basePath}</div>
  946. </div>
  947. <button class="remove-repo-btn" data-repo="${repo.name}">Remove</button>
  948. </div>
  949. `;
  950. reposList.appendChild(repoItem);
  951.  
  952. const removeBtn = repoItem.querySelector(".remove-repo-btn");
  953. if (removeBtn) {
  954. removeBtn.addEventListener("click", () => {
  955. repoManager.remove(repo.name);
  956. });
  957. }
  958. });
  959. },
  960.  
  961. updateCacheInfo() {
  962. const cacheInfoDiv = document.getElementById("cache-info");
  963. if (!cacheInfoDiv) return;
  964.  
  965. const cacheTimestamp = localStorage.getItem(STORAGE_KEYS.CACHE_TIMESTAMP);
  966. const themesCache = localStorage.getItem(STORAGE_KEYS.THEMES_CACHE);
  967. const repos = repoManager.getAll();
  968. const defaultRepoCount = repos.filter((repo) => repo.isDefault).length;
  969. const customRepoCount = repos.length - defaultRepoCount;
  970.  
  971. if (!cacheTimestamp || !themesCache) {
  972. cacheInfoDiv.innerHTML = "<p>No theme cache found.</p>";
  973. return;
  974. }
  975.  
  976. try {
  977. const themes = JSON.parse(themesCache);
  978. const themeCount = Object.keys(themes).length;
  979. const cacheDate = new Date(parseInt(cacheTimestamp));
  980. const now = new Date();
  981. const diffMs = now - cacheDate;
  982. const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
  983. const diffHours = Math.floor((diffMs % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
  984. const diffMins = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
  985.  
  986. cacheInfoDiv.innerHTML = `
  987. <p><strong>Themes in cache:</strong> ${themeCount}</p>
  988. <p><strong>Total repositories:</strong> ${repos.length}</p>
  989. <p><strong>Default repositories:</strong> ${defaultRepoCount}</p>
  990. <p><strong>Custom repositories:</strong> ${customRepoCount}</p>
  991. <p><strong>Last updated:</strong> ${cacheDate.toLocaleString()}</p>
  992. <p><strong>Cache age:</strong> ${diffDays}d ${diffHours}h ${diffMins}m</p>
  993. <p><strong>Cache expires:</strong> After 7 days</p>
  994. `;
  995. } catch (e) {
  996. cacheInfoDiv.innerHTML = "<p>Error reading cache information.</p>";
  997. console.error("Error parsing cache:", e);
  998. }
  999. },
  1000.  
  1001. switchTab(tabId) {
  1002. const tabContents = document.querySelectorAll(
  1003. ".theme-selector-tab-content"
  1004. );
  1005. tabContents.forEach((content) => {
  1006. content.classList.remove("active");
  1007. });
  1008.  
  1009. const tabs = document.querySelectorAll(".theme-selector-tab");
  1010. tabs.forEach((tab) => {
  1011. tab.classList.remove("active");
  1012. });
  1013.  
  1014. const selectedTab = document.getElementById(`tab-${tabId}`);
  1015. const selectedContent = document.getElementById(`tab-content-${tabId}`);
  1016.  
  1017. if (selectedTab) {
  1018. selectedTab.classList.add("active");
  1019. }
  1020.  
  1021. if (selectedContent) {
  1022. selectedContent.classList.add("active");
  1023. }
  1024. },
  1025.  
  1026. createThemeSelectorUI(options) {
  1027. const optionsHTML = `
  1028. <div class="theme-selector-container">
  1029. <div class="theme-selector-header">
  1030. <p>Select a theme from the dropdown and apply it to change the site's appearance.</p>
  1031. </div>
  1032.  
  1033. <div class="theme-selector-tabs">
  1034. <div id="tab-themes" class="theme-selector-tab active">Themes</div>
  1035. <div id="tab-settings" class="theme-selector-tab">Settings</div>
  1036. </div>
  1037.  
  1038. <div id="tab-content-themes" class="theme-selector-tab-content active">
  1039. <input type="text" id="theme-search" class="theme-selector-search" placeholder="Search themes...">
  1040. <select id="external-theme-select" style="width: 100%; margin-bottom: 5px;"></select>
  1041.  
  1042. <div class="theme-selector-row">
  1043. <button id="preview-external-theme" class="theme-selector-button">Preview</button>
  1044. <button id="apply-external-theme" class="theme-selector-button">Apply Theme</button>
  1045. </div>
  1046.  
  1047. <button id="cancel-preview" class="theme-selector-full-button" style="display: none;">Cancel Preview</button>
  1048. <button id="reset-external-theme" class="theme-selector-full-button">Reset to Default</button>
  1049.  
  1050. <div id="theme-info" class="theme-selector-info" style="display: none;"></div>
  1051. </div>
  1052.  
  1053. <div id="tab-content-settings" class="theme-selector-tab-content">
  1054. <h3>Theme Settings</h3>
  1055.  
  1056. <div class="theme-selector-row">
  1057. <button id="refresh-themes" class="theme-selector-full-button">Refresh Theme List</button>
  1058. </div>
  1059.  
  1060. <div class="theme-selector-row">
  1061. <button id="export-theme-settings" class="theme-selector-button">Export Settings</button>
  1062. <button id="import-theme-settings" class="theme-selector-button">Import Settings</button>
  1063. </div>
  1064.  
  1065. <input type="file" id="import-file" style="display: none;" accept=".json">
  1066.  
  1067. <div style="margin-top: 20px;">
  1068. <h4>Theme Repositories</h4>
  1069. <p>Manage repositories to fetch themes from.</p>
  1070.  
  1071. <div style="margin-bottom: 10px;">
  1072. <input type="text" id="repo-name" placeholder="Repository Name" style="width: 100%; margin-bottom: 5px; padding: 5px;">
  1073. <input type="text" id="repo-api-url" placeholder="GitHub API URL (e.g., https://api.github.com/repos/user/repo/contents/path)" style="width: 100%; margin-bottom: 5px; padding: 5px;">
  1074. <input type="text" id="repo-base-path" placeholder="Base Path (e.g., https://raw.githubusercontent.com/user/repo/branch/path/)" style="width: 100%; margin-bottom: 5px; padding: 5px;">
  1075. <button id="add-repo-btn" class="theme-selector-full-button">Add Repository</button>
  1076. </div>
  1077.  
  1078. <div id="repos-list" style="margin-top: 10px; max-height: 300px; overflow-y: auto; border: 1px solid #ddd; padding: 5px;"></div>
  1079.  
  1080. <button id="reset-repos-btn" class="theme-selector-full-button" style="margin-top: 10px;">Reset to Default Repositories</button>
  1081. </div>
  1082.  
  1083. <div style="margin-top: 20px;">
  1084. <h4>Cache Information</h4>
  1085. <div id="cache-info"></div>
  1086. </div>
  1087. </div>
  1088.  
  1089. <div id="theme-status" class="theme-selector-status"></div>
  1090. </div>
  1091. `;
  1092.  
  1093. options.insertAdjacentHTML("beforeend", optionsHTML);
  1094. this.updateCacheInfo();
  1095. this.updateReposList();
  1096. this.populateThemeSelect();
  1097.  
  1098. this.setupEventListeners();
  1099. },
  1100.  
  1101. setupEventListeners() {
  1102. const select = document.getElementById("external-theme-select");
  1103. const searchInput = document.getElementById("theme-search");
  1104. const previewButton = document.getElementById("preview-external-theme");
  1105. const cancelPreviewButton = document.getElementById("cancel-preview");
  1106. const applyButton = document.getElementById("apply-external-theme");
  1107. const resetButton = document.getElementById("reset-external-theme");
  1108. const refreshButton = document.getElementById("refresh-themes");
  1109. const exportButton = document.getElementById("export-theme-settings");
  1110. const importButton = document.getElementById("import-theme-settings");
  1111. const importFile = document.getElementById("import-file");
  1112. const tabElements = document.querySelectorAll(".theme-selector-tab");
  1113. const addRepoBtn = document.getElementById("add-repo-btn");
  1114. const resetReposBtn = document.getElementById("reset-repos-btn");
  1115.  
  1116. if (select) {
  1117. select.addEventListener("change", () => {
  1118. this.updateThemeInfo();
  1119. });
  1120. }
  1121.  
  1122. if (searchInput) {
  1123. searchInput.addEventListener("input", (e) => {
  1124. this.filterThemes(e.target.value);
  1125. });
  1126. }
  1127.  
  1128. if (previewButton) {
  1129. previewButton.addEventListener("click", () => {
  1130. const selectedTheme = select?.value;
  1131. if (selectedTheme) {
  1132. themeManager.previewTheme(selectedTheme);
  1133. if (cancelPreviewButton) {
  1134. cancelPreviewButton.style.display = "block";
  1135. }
  1136. } else {
  1137. this.showToast("Please select a theme first", "warning");
  1138. }
  1139. });
  1140. }
  1141.  
  1142. if (cancelPreviewButton) {
  1143. cancelPreviewButton.addEventListener("click", () => {
  1144. themeManager.cancelPreview();
  1145. cancelPreviewButton.style.display = "none";
  1146. });
  1147. }
  1148.  
  1149. if (applyButton) {
  1150. applyButton.addEventListener("click", () => {
  1151. const selectedTheme = select?.value;
  1152. if (selectedTheme) {
  1153. themeManager.applyTheme(selectedTheme);
  1154. if (cancelPreviewButton) {
  1155. cancelPreviewButton.style.display = "none";
  1156. }
  1157. } else {
  1158. this.showToast("Please select a theme first", "warning");
  1159. }
  1160. });
  1161. }
  1162.  
  1163. if (resetButton) {
  1164. resetButton.addEventListener("click", () => {
  1165. themeManager.resetTheme();
  1166. if (cancelPreviewButton) {
  1167. cancelPreviewButton.style.display = "none";
  1168. }
  1169. });
  1170. }
  1171.  
  1172. if (refreshButton) {
  1173. refreshButton.addEventListener("click", async () => {
  1174. const statusElement = document.getElementById("theme-status");
  1175. if (statusElement) {
  1176. statusElement.textContent = "Refreshing theme list...";
  1177. }
  1178.  
  1179. await themeManager.getThemes(true);
  1180. await this.populateThemeSelect();
  1181. this.updateCacheInfo();
  1182.  
  1183. if (statusElement) {
  1184. statusElement.textContent = "Theme list refreshed.";
  1185. }
  1186.  
  1187. this.showToast("Theme list refreshed", "info");
  1188. });
  1189. }
  1190.  
  1191. if (exportButton) {
  1192. exportButton.addEventListener("click", settingsManager.exportSettings);
  1193. }
  1194.  
  1195. if (importButton && importFile) {
  1196. importButton.addEventListener("click", () => {
  1197. importFile.click();
  1198. });
  1199.  
  1200. importFile.addEventListener("change", (e) => {
  1201. if (e.target.files && e.target.files.length > 0) {
  1202. settingsManager.importSettings(e.target.files[0]);
  1203. e.target.value = "";
  1204. }
  1205. });
  1206. }
  1207.  
  1208. if (addRepoBtn) {
  1209. addRepoBtn.addEventListener("click", () => {
  1210. const nameInput = document.getElementById("repo-name");
  1211. const apiUrlInput = document.getElementById("repo-api-url");
  1212. const basePathInput = document.getElementById("repo-base-path");
  1213.  
  1214. if (!nameInput || !apiUrlInput || !basePathInput) return;
  1215.  
  1216. const name = nameInput.value.trim();
  1217. const apiUrl = apiUrlInput.value.trim();
  1218. const basePath = basePathInput.value.trim();
  1219.  
  1220. if (!name || !apiUrl || !basePath) {
  1221. this.showToast("Please fill in all repository fields", "error");
  1222. return;
  1223. }
  1224.  
  1225. if (repoManager.add(name, apiUrl, basePath)) {
  1226. nameInput.value = "";
  1227. apiUrlInput.value = "";
  1228. basePathInput.value = "";
  1229.  
  1230. const refreshThemesBtn = document.getElementById("refresh-themes");
  1231. if (refreshThemesBtn) {
  1232. refreshThemesBtn.click();
  1233. }
  1234. }
  1235. });
  1236. }
  1237.  
  1238. if (resetReposBtn) {
  1239. resetReposBtn.addEventListener("click", () => {
  1240. if (repoManager.resetToDefault()) {
  1241. const refreshThemesBtn = document.getElementById("refresh-themes");
  1242. if (refreshThemesBtn) {
  1243. refreshThemesBtn.click();
  1244. }
  1245. }
  1246. });
  1247. }
  1248.  
  1249. tabElements.forEach((tab) => {
  1250. tab.addEventListener("click", () => {
  1251. const tabId = tab.id.replace("tab-", "");
  1252. this.switchTab(tabId);
  1253. });
  1254. });
  1255. }
  1256. };
  1257.  
  1258. async function init() {
  1259. console.log("Initializing Soyjak Theme Selector");
  1260.  
  1261. uiHelper.addStyles();
  1262.  
  1263. try {
  1264. const themes = await themeManager.getThemes();
  1265. console.log(`Loaded ${Object.keys(themes).length} themes`);
  1266.  
  1267. const savedTheme = localStorage.getItem(STORAGE_KEYS.THEME);
  1268. const savedCss = localStorage.getItem(STORAGE_KEYS.USER_CSS);
  1269.  
  1270. if (savedCss && !state.initialThemeApplied) {
  1271. const styleElement = document.createElement("style");
  1272. styleElement.id = "applied-theme-style";
  1273. styleElement.textContent = savedCss;
  1274. document.head.appendChild(styleElement);
  1275. state.initialThemeApplied = true;
  1276. console.log("Applied saved CSS from localStorage");
  1277. } else if (savedTheme && themes[savedTheme] && !state.initialThemeApplied) {
  1278. console.log(`Found saved theme: ${savedTheme}`);
  1279. state.initialThemeApplied = true;
  1280. await themeManager.applyTheme(savedTheme, true, true);
  1281. }
  1282. } catch (error) {
  1283. console.error("Error during initialization:", error);
  1284. }
  1285.  
  1286. let checkAttempts = 0;
  1287. const maxAttempts = 100;
  1288. const checkInterval = setInterval(() => {
  1289. checkAttempts++;
  1290.  
  1291. if (typeof Options !== "undefined") {
  1292. clearInterval(checkInterval);
  1293. console.log("Options object found, adding theme selector tab");
  1294.  
  1295. try {
  1296. let options = Options.add_tab(
  1297. "external-themes",
  1298. "css3",
  1299. "External Themes"
  1300. ).content[0];
  1301. uiHelper.createThemeSelectorUI(options);
  1302. } catch (error) {
  1303. console.error("Error creating theme selector UI:", error);
  1304. }
  1305. }
  1306.  
  1307. if (checkAttempts >= maxAttempts) {
  1308. clearInterval(checkInterval);
  1309. console.log("Stopped checking for Options object (timeout)");
  1310. }
  1311. }, 100);
  1312. }
  1313.  
  1314. if (document.readyState === "loading") {
  1315. document.addEventListener("DOMContentLoaded", init);
  1316. } else {
  1317. init();
  1318. }
  1319. })();