839 lines
34 KiB
JavaScript
839 lines
34 KiB
JavaScript
|
/// <reference types="react" />
|
||
|
/// <reference types="react-dom" />
|
||
|
/// <reference path="../spicetify-cli/globals.d.ts" />
|
||
|
/// <reference path="../spicetify-cli/jsHelper/spicetifyWrapper.js" />
|
||
|
/// <reference path="src/Card.js" />
|
||
|
/// <reference path="src/Icons.js" />
|
||
|
/// <reference path="src/Settings.js" />
|
||
|
/// <reference path="src/SortBox.js" />
|
||
|
/// <reference path="src/TabBar.js" />
|
||
|
/// <reference path="src/ReadmePage.js" />
|
||
|
/// <reference path="src/Utils.js" />
|
||
|
|
||
|
/* eslint-disable no-redeclare, no-unused-vars */
|
||
|
/** @type {React} */
|
||
|
const react = Spicetify.React;
|
||
|
/** @type {import("react-dom")} */
|
||
|
const reactDOM = Spicetify.ReactDOM;
|
||
|
const {
|
||
|
URI,
|
||
|
React: { useState, useEffect, useCallback },
|
||
|
// @ts-ignore
|
||
|
Platform: { History },
|
||
|
} = Spicetify;
|
||
|
/* eslint-enable no-redeclare, no-unused-vars */
|
||
|
|
||
|
// eslint-disable-next-line no-redeclare
|
||
|
const LOCALSTORAGE_KEYS = {
|
||
|
"installedExtensions": "marketplace:installed-extensions",
|
||
|
"installedSnippets": "marketplace:installed-snippets",
|
||
|
"installedThemes": "marketplace:installed-themes",
|
||
|
"activeTab": "marketplace:active-tab",
|
||
|
"tabs": "marketplace:tabs",
|
||
|
"sortBy": "marketplace:sort-by",
|
||
|
// Theme installed store the localsorage key of the theme (e.g. marketplace:installed:NYRI4/Comfy-spicetify/user.css)
|
||
|
"themeInstalled": "marketplace:theme-installed",
|
||
|
};
|
||
|
|
||
|
// Define a function called "render" to specify app entry point
|
||
|
// This function will be used to mount app to main view.
|
||
|
// eslint-disable-next-line no-unused-vars
|
||
|
function render() {
|
||
|
const { location } = Spicetify.Platform.History;
|
||
|
|
||
|
// If page state set to display readme, render it
|
||
|
// (This location state data comes from Card.openReadme())
|
||
|
if (location.pathname === "/spicetify-marketplace/readme") {
|
||
|
return react.createElement(ReadmePage, {
|
||
|
title: "Spicetify Marketplace - Readme",
|
||
|
data: location.state.data,
|
||
|
});
|
||
|
} // Otherwise, render the main Grid
|
||
|
else {
|
||
|
return react.createElement(Grid, { title: "Spicetify Marketplace" });
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// Initalize topbar tabs
|
||
|
// Data initalized in TabBar.js
|
||
|
// eslint-disable-next-line no-redeclare
|
||
|
const ALL_TABS = [
|
||
|
{ name: "Extensions", enabled: true },
|
||
|
{ name: "Themes", enabled: true },
|
||
|
{ name: "Snippets", enabled: true },
|
||
|
{ name: "Installed", enabled: true },
|
||
|
];
|
||
|
let tabsString = localStorage.getItem(LOCALSTORAGE_KEYS.tabs);
|
||
|
let tabs = [];
|
||
|
try {
|
||
|
tabs = JSON.parse(tabsString);
|
||
|
if (!Array.isArray(tabs)) {
|
||
|
throw new Error("Could not parse marketplace tabs key");
|
||
|
} else if (tabs.length === 0) {
|
||
|
throw new Error("Empty marketplace tabs key");
|
||
|
} else if (tabs.filter(tab => !tab).length > 0) {
|
||
|
throw new Error("Falsey marketplace tabs key");
|
||
|
}
|
||
|
} catch {
|
||
|
tabs = ALL_TABS;
|
||
|
localStorage.setItem(LOCALSTORAGE_KEYS.tabs, JSON.stringify(tabs));
|
||
|
}
|
||
|
|
||
|
// Get active theme
|
||
|
let schemes = [];
|
||
|
let activeScheme = null;
|
||
|
try {
|
||
|
const installedThemeKey = localStorage.getItem(LOCALSTORAGE_KEYS.themeInstalled);
|
||
|
if (installedThemeKey) {
|
||
|
const installedThemeDataStr = localStorage.getItem(installedThemeKey);
|
||
|
if (!installedThemeDataStr) throw new Error("No installed theme data");
|
||
|
|
||
|
const installedTheme = JSON.parse(installedThemeDataStr);
|
||
|
schemes = installedTheme.schemes;
|
||
|
activeScheme = installedTheme.activeScheme;
|
||
|
} else {
|
||
|
console.log("No theme set as installed");
|
||
|
}
|
||
|
} catch (err) {
|
||
|
console.error(err);
|
||
|
}
|
||
|
|
||
|
// eslint-disable-next-line no-redeclare
|
||
|
const CONFIG = {
|
||
|
// Fetch the settings and set defaults. Used in Settings.js
|
||
|
visual: {
|
||
|
stars: JSON.parse(localStorage.getItem("marketplace:stars")) ?? true,
|
||
|
tags: JSON.parse(localStorage.getItem("marketplace:tags")) ?? true,
|
||
|
hideInstalled: JSON.parse(localStorage.getItem("marketplace:hideInstalled")) ?? false,
|
||
|
colorShift: JSON.parse(localStorage.getItem("marketplace:colorShift")) ?? false,
|
||
|
// Legacy from reddit app...
|
||
|
type: JSON.parse(localStorage.getItem("marketplace:type")) ?? false,
|
||
|
// I was considering adding watchers as "followers" but it looks like the value is a duplicate
|
||
|
// of stargazers, and the subscribers_count isn't returned in the main API call we make
|
||
|
// https://github.community/t/bug-watchers-count-is-the-duplicate-of-stargazers-count/140865/4
|
||
|
followers: JSON.parse(localStorage.getItem("marketplace:followers")) ?? false,
|
||
|
},
|
||
|
tabs,
|
||
|
activeTab: localStorage.getItem(LOCALSTORAGE_KEYS.activeTab),
|
||
|
theme: {
|
||
|
activeThemeKey: localStorage.getItem(LOCALSTORAGE_KEYS.themeInstalled) || null,
|
||
|
schemes,
|
||
|
activeScheme,
|
||
|
},
|
||
|
};
|
||
|
|
||
|
if (!CONFIG.activeTab || !CONFIG.tabs.filter(tab => tab.name === CONFIG.activeTab).length) {
|
||
|
CONFIG.activeTab = CONFIG.tabs[0].name;
|
||
|
}
|
||
|
|
||
|
// Fetches the sorting options, fetched from SortBox.js
|
||
|
// eslint-disable-next-line no-redeclare
|
||
|
let sortConfig = {
|
||
|
by: localStorage.getItem(LOCALSTORAGE_KEYS.sortBy) || "top",
|
||
|
};
|
||
|
let cardList = [];
|
||
|
let endOfList = false;
|
||
|
let lastScroll = 0;
|
||
|
let requestQueue = [];
|
||
|
let requestPage = null;
|
||
|
// Max GitHub API items per page
|
||
|
// https://docs.github.com/en/rest/reference/search#search-repositories
|
||
|
const ITEMS_PER_REQUEST = 100;
|
||
|
|
||
|
let BLACKLIST = [];
|
||
|
|
||
|
// eslint-disable-next-line no-redeclare, no-unused-vars
|
||
|
let gridUpdateTabs, gridUpdatePostsVisual;
|
||
|
|
||
|
class Grid extends react.Component {
|
||
|
constructor(props) {
|
||
|
super(props);
|
||
|
Object.assign(this, props);
|
||
|
this.state = {
|
||
|
cards: [],
|
||
|
tabs: CONFIG.tabs,
|
||
|
rest: true,
|
||
|
endOfList: endOfList,
|
||
|
schemes: CONFIG.theme.schemes,
|
||
|
activeScheme: CONFIG.theme.activeScheme,
|
||
|
activeThemeKey: CONFIG.theme.activeThemeKey,
|
||
|
};
|
||
|
}
|
||
|
|
||
|
// TODO: should I put this in Grid state?
|
||
|
getInstalledTheme() {
|
||
|
const installedThemeKey = localStorage.getItem(LOCALSTORAGE_KEYS.themeInstalled);
|
||
|
if (!installedThemeKey) return null;
|
||
|
|
||
|
const installedThemeDataStr = localStorage.getItem(installedThemeKey);
|
||
|
if (!installedThemeDataStr) return null;
|
||
|
|
||
|
const installedTheme = JSON.parse(installedThemeDataStr);
|
||
|
return installedTheme;
|
||
|
}
|
||
|
|
||
|
newRequest(amount) {
|
||
|
cardList = [];
|
||
|
const queue = [];
|
||
|
requestQueue.unshift(queue);
|
||
|
this.loadAmount(queue, amount);
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* @param {Object} item
|
||
|
* @param {"extension" | "theme" | "snippet"} type The type of card
|
||
|
*/
|
||
|
appendCard(item, type) {
|
||
|
item.visual = CONFIG.visual;
|
||
|
// Set key prop so items don't get stuck when switching tabs
|
||
|
item.key = `${CONFIG.activeTab}:${item.title}`;
|
||
|
item.type = type;
|
||
|
// Pass along the functions to update Grid state on apply
|
||
|
item.updateColourSchemes = this.updateColourSchemes.bind(this);
|
||
|
item.updateActiveTheme = this.setActiveTheme.bind(this);
|
||
|
// This isn't used other than to trigger a re-render
|
||
|
item.activeThemeKey = this.state.activeThemeKey;
|
||
|
cardList.push(react.createElement(Card, item));
|
||
|
this.setState({ cards: cardList });
|
||
|
}
|
||
|
|
||
|
// TODO: this isn't currently used, but it will be used for sorting (based on the SortBox component)
|
||
|
updateSort(sortByValue) {
|
||
|
if (sortByValue) {
|
||
|
sortConfig.by = sortByValue;
|
||
|
localStorage.setItem(LOCALSTORAGE_KEYS.sortBy, sortByValue);
|
||
|
}
|
||
|
|
||
|
requestPage = null;
|
||
|
cardList = [];
|
||
|
this.setState({
|
||
|
cards: [],
|
||
|
rest: false,
|
||
|
endOfList: false,
|
||
|
});
|
||
|
endOfList = false;
|
||
|
|
||
|
this.newRequest(ITEMS_PER_REQUEST);
|
||
|
}
|
||
|
|
||
|
updateTabs() {
|
||
|
this.setState({
|
||
|
tabs: [...CONFIG.tabs],
|
||
|
});
|
||
|
}
|
||
|
|
||
|
updatePostsVisual() {
|
||
|
cardList = cardList.map(card => {
|
||
|
return react.createElement(Card, card.props);
|
||
|
});
|
||
|
this.setState({ cards: [...cardList] });
|
||
|
}
|
||
|
|
||
|
switchTo(value) {
|
||
|
CONFIG.activeTab = value;
|
||
|
localStorage.setItem(LOCALSTORAGE_KEYS.activeTab, value);
|
||
|
cardList = [];
|
||
|
requestPage = null;
|
||
|
this.setState({
|
||
|
cards: [],
|
||
|
rest: false,
|
||
|
endOfList: false,
|
||
|
});
|
||
|
endOfList = false;
|
||
|
|
||
|
this.newRequest(ITEMS_PER_REQUEST);
|
||
|
}
|
||
|
|
||
|
// This is called from loadAmount in a loop until it has the requested amount of cards or runs out of results
|
||
|
// Returns the next page number to fetch, or null if at end
|
||
|
// TODO: maybe we should rename `loadPage()`, since it's slightly confusing when we have github pages as well
|
||
|
async loadPage(queue) {
|
||
|
if (CONFIG.activeTab === "Extensions") {
|
||
|
let pageOfRepos = await getExtensionRepos(requestPage);
|
||
|
for (const repo of pageOfRepos.items) {
|
||
|
let extensions = await fetchExtensionManifest(repo.contents_url, repo.default_branch, repo.stargazers_count);
|
||
|
|
||
|
// I believe this stops the requests when switching tabs?
|
||
|
if (requestQueue.length > 1 && queue !== requestQueue[0]) {
|
||
|
// Stop this queue from continuing to fetch and append to cards list
|
||
|
return -1;
|
||
|
}
|
||
|
|
||
|
if (extensions && extensions.length) {
|
||
|
// console.log(`${repo.name} has ${extensions.length} extensions:`, extensions);
|
||
|
extensions.forEach((extension) => this.appendCard(extension, "extension"));
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// First result is null or -1 so it coerces to 1
|
||
|
const currentPage = requestPage > -1 && requestPage ? requestPage : 1;
|
||
|
// Sets the amount of items that have thus been fetched
|
||
|
const soFarResults = ITEMS_PER_REQUEST * (currentPage - 1) + pageOfRepos.page_count;
|
||
|
const remainingResults = pageOfRepos.total_count - soFarResults;
|
||
|
|
||
|
// If still have more results, return next page number to fetch
|
||
|
console.log(`Parsed ${soFarResults}/${pageOfRepos.total_count} extensions`);
|
||
|
if (remainingResults > 0) return currentPage + 1;
|
||
|
else console.log("No more extension results");
|
||
|
} else if (CONFIG.activeTab === "Installed") {
|
||
|
const installedStuff = {
|
||
|
theme: getLocalStorageDataFromKey(LOCALSTORAGE_KEYS.installedThemes, []),
|
||
|
extension: getLocalStorageDataFromKey(LOCALSTORAGE_KEYS.installedExtensions, []),
|
||
|
snippet: getLocalStorageDataFromKey(LOCALSTORAGE_KEYS.installedSnippets, []),
|
||
|
};
|
||
|
|
||
|
for (const type in installedStuff) {
|
||
|
if (installedStuff[type].length) {
|
||
|
installedStuff[type].forEach(async (itemKey) => {
|
||
|
// TODO: err handling
|
||
|
const extension = JSON.parse(localStorage.getItem(itemKey));
|
||
|
// I believe this stops the requests when switching tabs?
|
||
|
if (requestQueue.length > 1 && queue !== requestQueue[0]) {
|
||
|
// Stop this queue from continuing to fetch and append to cards list
|
||
|
return -1;
|
||
|
}
|
||
|
|
||
|
// @ts-ignore
|
||
|
this.appendCard(extension, type);
|
||
|
});
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// Don't need to return a page number because
|
||
|
// installed extension do them all in one go, since it's local
|
||
|
} else if (CONFIG.activeTab == "Themes") {
|
||
|
let pageOfRepos = await getThemeRepos(requestPage);
|
||
|
for (const repo of pageOfRepos.items) {
|
||
|
|
||
|
let themes = await fetchThemeManifest(repo.contents_url, repo.default_branch, repo.stargazers_count);
|
||
|
// I believe this stops the requests when switching tabs?
|
||
|
if (requestQueue.length > 1 && queue !== requestQueue[0]) {
|
||
|
// Stop this queue from continuing to fetch and append to cards list
|
||
|
return -1;
|
||
|
}
|
||
|
|
||
|
if (themes && themes.length) {
|
||
|
themes.forEach((theme) => this.appendCard(theme, "theme"));
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// First request is null, so coerces to 1
|
||
|
const currentPage = requestPage > -1 && requestPage ? requestPage : 1;
|
||
|
// -1 because the page number is 1-indexed
|
||
|
const soFarResults = ITEMS_PER_REQUEST * (currentPage - 1) + pageOfRepos.page_count;
|
||
|
const remainingResults = pageOfRepos.total_count - soFarResults;
|
||
|
|
||
|
console.log(`Parsed ${soFarResults}/${pageOfRepos.total_count} themes`);
|
||
|
if (remainingResults > 0) return currentPage + 1;
|
||
|
else console.log("No more theme results");
|
||
|
} else if (CONFIG.activeTab == "Snippets") {
|
||
|
let snippets = await fetchCssSnippets();
|
||
|
|
||
|
if (requestQueue.length > 1 && queue !== requestQueue[0]) {
|
||
|
// Stop this queue from continuing to fetch and append to cards list
|
||
|
return -1;
|
||
|
}
|
||
|
if (snippets && snippets.length) {
|
||
|
snippets.forEach((snippet) => this.appendCard(snippet, "snippet"));
|
||
|
}
|
||
|
|
||
|
}
|
||
|
|
||
|
this.setState({ rest: true, endOfList: true });
|
||
|
endOfList = true;
|
||
|
return null;
|
||
|
}
|
||
|
/**
|
||
|
* Load a new set of extensions
|
||
|
* @param {any} queue An array of the extensions to be loaded
|
||
|
* @param {number} [quantity] Amount of extensions to be loaded per page. (Defaults to ITEMS_PER_REQUEST constant)
|
||
|
*/
|
||
|
async loadAmount(queue, quantity = ITEMS_PER_REQUEST) {
|
||
|
this.setState({ rest: false });
|
||
|
quantity += cardList.length;
|
||
|
|
||
|
requestPage = await this.loadPage(queue);
|
||
|
|
||
|
while (
|
||
|
requestPage &&
|
||
|
requestPage !== -1 &&
|
||
|
cardList.length < quantity &&
|
||
|
!this.state.endOfList
|
||
|
) {
|
||
|
requestPage = await this.loadPage(queue);
|
||
|
}
|
||
|
|
||
|
if (requestPage === -1) {
|
||
|
requestQueue = requestQueue.filter(a => a !== queue);
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
// Remove this queue from queue list
|
||
|
requestQueue.shift();
|
||
|
this.setState({ rest: true });
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Load more items if there are more items to load.
|
||
|
* @returns {void}
|
||
|
*/
|
||
|
loadMore() {
|
||
|
if (this.state.rest && !endOfList) {
|
||
|
this.loadAmount(requestQueue[0], ITEMS_PER_REQUEST);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Update the colour schemes in the state + dropdown, and inject the active one
|
||
|
* @param {any} schemes Object with the colour schemes
|
||
|
* @param {string} activeScheme The name of the active colour scheme (a key in the schemes object)
|
||
|
*/
|
||
|
updateColourSchemes(schemes, activeScheme) {
|
||
|
console.log("updateColourSchemes", schemes, activeScheme);
|
||
|
CONFIG.theme.schemes = schemes;
|
||
|
CONFIG.theme.activeScheme = activeScheme;
|
||
|
|
||
|
if (schemes && schemes[activeScheme]) {
|
||
|
this.injectColourScheme(CONFIG.theme.schemes[activeScheme]);
|
||
|
} else {
|
||
|
// Reset schemes if none sent
|
||
|
this.injectColourScheme(null);
|
||
|
}
|
||
|
|
||
|
// Save to localstorage
|
||
|
const installedThemeKey = localStorage.getItem(LOCALSTORAGE_KEYS.themeInstalled);
|
||
|
const installedThemeDataStr = localStorage.getItem(installedThemeKey);
|
||
|
const installedThemeData = JSON.parse(installedThemeDataStr);
|
||
|
installedThemeData.activeScheme = activeScheme;
|
||
|
localStorage.setItem(installedThemeKey, JSON.stringify(installedThemeData));
|
||
|
|
||
|
this.setState({
|
||
|
schemes,
|
||
|
activeScheme,
|
||
|
});
|
||
|
}
|
||
|
|
||
|
// NOTE: Keep in sync with extension.js
|
||
|
injectColourScheme (scheme) {
|
||
|
// Remove any existing Spicetify scheme
|
||
|
const existingColorsCSS = document.querySelector("link[href='colors.css']");
|
||
|
if (existingColorsCSS) existingColorsCSS.remove();
|
||
|
|
||
|
// Remove any existing marketplace scheme
|
||
|
const existingMarketplaceSchemeCSS = document.querySelector("style.marketplaceCSS.marketplaceScheme");
|
||
|
if (existingMarketplaceSchemeCSS) existingMarketplaceSchemeCSS.remove();
|
||
|
|
||
|
if (scheme) {
|
||
|
// Add new marketplace scheme
|
||
|
const schemeTag = document.createElement("style");
|
||
|
schemeTag.classList.add("marketplaceCSS");
|
||
|
schemeTag.classList.add("marketplaceScheme");
|
||
|
|
||
|
let injectStr = ":root {";
|
||
|
const themeIniKeys = Object.keys(scheme);
|
||
|
themeIniKeys.forEach((key) => {
|
||
|
injectStr += `--spice-${key}: #${scheme[key]};`;
|
||
|
injectStr += `--spice-rgb-${key}: ${hexToRGB(scheme[key])};`;
|
||
|
});
|
||
|
injectStr += "}";
|
||
|
schemeTag.innerHTML = injectStr;
|
||
|
document.head.appendChild(schemeTag);
|
||
|
} else {
|
||
|
// Re-add default user.css
|
||
|
let originalColorsCSS = document.createElement("link");
|
||
|
originalColorsCSS.setAttribute("rel", "stylesheet");
|
||
|
originalColorsCSS.setAttribute("href", "colors.css");
|
||
|
originalColorsCSS.classList.add("userCSS");
|
||
|
document.head.appendChild(originalColorsCSS);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* The componentDidMount() method is called when the component is first loaded.
|
||
|
* It checks if the cardList is already loaded. If it is, it checks if the lastScroll value is
|
||
|
greater than 0.
|
||
|
* If it is, it scrolls to the lastScroll value. If it isn't, it scrolls to the top of the page.
|
||
|
* If the cardList isn't loaded, it loads the cardList.
|
||
|
*/
|
||
|
async componentDidMount() {
|
||
|
gridUpdateTabs = this.updateTabs.bind(this);
|
||
|
gridUpdatePostsVisual = this.updatePostsVisual.bind(this);
|
||
|
|
||
|
const viewPort = document.querySelector(".os-viewport");
|
||
|
this.checkScroll = this.isScrolledBottom.bind(this);
|
||
|
viewPort.addEventListener("scroll", this.checkScroll);
|
||
|
|
||
|
if (cardList.length) { // Already loaded
|
||
|
if (lastScroll > 0) {
|
||
|
viewPort.scrollTo(0, lastScroll);
|
||
|
}
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
// Load blacklist
|
||
|
BLACKLIST = await getBlacklist();
|
||
|
this.newRequest(ITEMS_PER_REQUEST);
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* When the component is unmounted, remove the scroll event listener.
|
||
|
* @returns {void}
|
||
|
*/
|
||
|
componentWillUnmount() {
|
||
|
gridUpdateTabs = gridUpdatePostsVisual = null;
|
||
|
const viewPort = document.querySelector(".os-viewport");
|
||
|
lastScroll = viewPort.scrollTop;
|
||
|
viewPort.removeEventListener("scroll", this.checkScroll);
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* If the user has scrolled to the bottom of the page, load more posts.
|
||
|
* @param event - The event object that is passed to the callback function.
|
||
|
* @returns {void}
|
||
|
*/
|
||
|
isScrolledBottom(event) {
|
||
|
const viewPort = event.target;
|
||
|
if ((viewPort.scrollTop + viewPort.clientHeight) >= viewPort.scrollHeight) {
|
||
|
// At bottom, load more posts
|
||
|
this.loadMore();
|
||
|
}
|
||
|
}
|
||
|
|
||
|
setActiveTheme(themeKey) {
|
||
|
CONFIG.theme.activeThemeKey = themeKey;
|
||
|
this.setState({ activeThemeKey: themeKey });
|
||
|
}
|
||
|
|
||
|
// TODO: clean this up. It worked when I was using state, but state seems like pointless overhead.
|
||
|
getActiveScheme() {
|
||
|
return this.state.activeScheme;
|
||
|
}
|
||
|
|
||
|
render() {
|
||
|
return react.createElement("section", {
|
||
|
className: "contentSpacing",
|
||
|
},
|
||
|
react.createElement("div", {
|
||
|
className: "marketplace-header",
|
||
|
}, react.createElement("h1", null, this.props.title),
|
||
|
// Start of marketplace-header__right
|
||
|
react.createElement("div", {
|
||
|
className: "marketplace-header__right",
|
||
|
},
|
||
|
// Show colour scheme dropdown if there is a theme with schemes installed
|
||
|
this.state.activeScheme ? react.createElement(SortBox, {
|
||
|
onChange: (value) => this.updateColourSchemes(this.state.schemes, value),
|
||
|
// TODO: Make this compatible with the changes to the theme install process: need to create a method to update the scheme options without a full reload.
|
||
|
sortBoxOptions: generateSchemesOptions(this.state.schemes),
|
||
|
// It doesn't work when I directly use CONFIG.theme.activeScheme in the sortBySelectedFn
|
||
|
// because it hardcodes the value into the fn
|
||
|
sortBySelectedFn: (a) => a.key === this.getActiveScheme(),
|
||
|
}) : null,
|
||
|
react.createElement("button", {
|
||
|
className: "marketplace-settings-button",
|
||
|
id: "marketplace-settings-button",
|
||
|
|
||
|
onClick: openConfig,
|
||
|
}, SETTINGS_ICON),
|
||
|
// End of marketplace-header__right
|
||
|
),
|
||
|
// TODO: Add search bar and sort functionality
|
||
|
// react.createElement("div", {
|
||
|
// className: "searchbar--bar__wrapper",
|
||
|
// }, react.createElement("input", {
|
||
|
// className: "searchbar-bar",
|
||
|
// type: "text",
|
||
|
// placeholder: "Search for Extensions?",
|
||
|
// })),
|
||
|
),
|
||
|
[ // Add a header and grid for each card type if it has any cards
|
||
|
{ handle: "extension", name: "Extensions" },
|
||
|
{ handle: "theme", name: "Themes" },
|
||
|
{ handle: "snippet", name: "Snippets" },
|
||
|
].map((cardType) => {
|
||
|
const cardsOfType = cardList.filter((card) => card.props.type === cardType.handle)
|
||
|
.map((card) => {
|
||
|
// Clone the cards and update the prop to trigger re-render
|
||
|
// TODO: is it possible to only re-render the theme cards whose status have changed?
|
||
|
const cardElement = react.cloneElement(card, {
|
||
|
activeThemeKey: this.state.activeThemeKey,
|
||
|
});
|
||
|
return cardElement;
|
||
|
});
|
||
|
|
||
|
if (cardsOfType.length) {
|
||
|
return [
|
||
|
// Add a header for the card type
|
||
|
react.createElement("h2",
|
||
|
{ className: "marketplace-card-type-heading" },
|
||
|
cardType.name),
|
||
|
// Add the grid and cards
|
||
|
react.createElement("div", {
|
||
|
className: "marketplace-grid main-gridContainer-gridContainer main-gridContainer-fixedWidth",
|
||
|
"data-tab": CONFIG.activeTab,
|
||
|
style: {
|
||
|
"--minimumColumnWidth": "180px",
|
||
|
"--column-width": "minmax(var(--minimumColumnWidth),1fr)",
|
||
|
"--column-count": "auto-fill",
|
||
|
"--grid-gap": "24px",
|
||
|
},
|
||
|
}, cardsOfType)];
|
||
|
} else {
|
||
|
return null;
|
||
|
}
|
||
|
}), react.createElement("footer", {
|
||
|
style: {
|
||
|
margin: "auto",
|
||
|
textAlign: "center",
|
||
|
},
|
||
|
}, !this.state.endOfList && (this.state.rest ? react.createElement(LoadMoreIcon, { onClick: this.loadMore.bind(this) }) : react.createElement(LoadingIcon)),
|
||
|
// Add snippets button if on snippets tab
|
||
|
CONFIG.activeTab === "Snippets" ? react.createElement("button", {
|
||
|
className: "marketplace-add-snippet-btn main-buttons-button main-button-secondary",
|
||
|
onClick: openAddSnippetModal,
|
||
|
}, "+Add CSS") : null,
|
||
|
), react.createElement(TopBarContent, {
|
||
|
switchCallback: this.switchTo.bind(this),
|
||
|
links: CONFIG.tabs,
|
||
|
activeLink: CONFIG.activeTab,
|
||
|
}));
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// TODO: add sort type, order, etc?
|
||
|
// https://docs.github.com/en/github/searching-for-information-on-github/searching-on-github/searching-for-repositories#search-by-topic
|
||
|
// https://docs.github.com/en/rest/reference/search#search-repositories
|
||
|
/**
|
||
|
* Query GitHub for all repos with the "spicetify-extensions" topic
|
||
|
* @param {number} page The query page number
|
||
|
* @returns Array of search results (filtered through the blacklist)
|
||
|
*/
|
||
|
async function getExtensionRepos(page = 1) {
|
||
|
// www is needed or it will block with "cross-origin" error.
|
||
|
let url = `https://api.github.com/search/repositories?q=${encodeURIComponent("topic:spicetify-extensions")}&per_page=${ITEMS_PER_REQUEST}`;
|
||
|
|
||
|
// We can test multiple pages with this URL (58 results), as well as broken iamges etc.
|
||
|
// let url = `https://api.github.com/search/repositories?q=${encodeURIComponent("topic:spicetify")}`;
|
||
|
if (page) url += `&page=${page}`;
|
||
|
// Sorting params (not implemented for Marketplace yet)
|
||
|
// if (sortConfig.by.match(/top|controversial/) && sortConfig.time) {
|
||
|
// url += `&t=${sortConfig.time}`
|
||
|
const allRepos = await fetch(url).then(res => res.json()).catch(() => []);
|
||
|
if (!allRepos.items) {
|
||
|
Spicetify.showNotification("Too Many Requests, Cool Down.");
|
||
|
}
|
||
|
const filteredResults = {
|
||
|
...allRepos,
|
||
|
// Include count of all items on the page, since we're filtering the blacklist below,
|
||
|
// which can mess up the paging logic
|
||
|
page_count: allRepos.items.length,
|
||
|
items: allRepos.items.filter(item => !BLACKLIST.includes(item.html_url)),
|
||
|
};
|
||
|
|
||
|
return filteredResults;
|
||
|
}
|
||
|
|
||
|
// TODO: add try/catch here?
|
||
|
// TODO: can we add a return type here?
|
||
|
/**
|
||
|
* Get the manifest object for a repo
|
||
|
* @param {string} user Owner username
|
||
|
* @param {string} repo Repo name
|
||
|
* @param {string} branch Default branch name (e.g. main or master)
|
||
|
* @returns The manifest object
|
||
|
*/
|
||
|
async function getRepoManifest(user, repo, branch) {
|
||
|
const sessionStorageItem = window.sessionStorage.getItem(`${user}-${repo}`);
|
||
|
const failedSessionStorageItems = window.sessionStorage.getItem("noManifests");
|
||
|
if (sessionStorageItem) {
|
||
|
return JSON.parse(sessionStorageItem);
|
||
|
}
|
||
|
const url = `https://raw.githubusercontent.com/${user}/${repo}/${branch}/manifest.json`;
|
||
|
if (failedSessionStorageItems.includes(url)) {
|
||
|
return null;
|
||
|
}
|
||
|
const manifest = await fetch(url).then(res => res.json()).catch(() => addToSessionStorage([url], "noManifests"));
|
||
|
if (manifest) {
|
||
|
window.sessionStorage.setItem(`${user}-${repo}`, JSON.stringify(manifest));
|
||
|
}
|
||
|
return manifest;
|
||
|
}
|
||
|
|
||
|
// TODO: can we add a return type here?
|
||
|
/**
|
||
|
* Fetch extensions from a repo and format data for generating cards
|
||
|
* @param {string} contents_url The repo's GitHub API contents_url (e.g. "https://api.github.com/repos/theRealPadster/spicetify-hide-podcasts/contents/{+path}")
|
||
|
* @param {string} branch The repo's default branch (e.g. main or master)
|
||
|
* @param {number} stars The number of stars the repo has
|
||
|
* @returns Extension info for card (or null)
|
||
|
*/
|
||
|
async function fetchExtensionManifest(contents_url, branch, stars) {
|
||
|
try {
|
||
|
// TODO: use the original search full_name ("theRealPadster/spicetify-hide-podcasts") or something to get the url better?
|
||
|
let manifests;
|
||
|
const regex_result = contents_url.match(/https:\/\/api\.github\.com\/repos\/(?<user>.+)\/(?<repo>.+)\/contents/);
|
||
|
// TODO: err handling?
|
||
|
if (!regex_result || !regex_result.groups) return null;
|
||
|
const { user, repo } = regex_result.groups;
|
||
|
|
||
|
manifests = await getRepoManifest(user, repo, branch);
|
||
|
|
||
|
// If the manifest returned is not an array, initialize it as one
|
||
|
if (!Array.isArray(manifests)) manifests = [manifests];
|
||
|
|
||
|
// Manifest is initially parsed
|
||
|
const parsedManifests = manifests.reduce((accum, manifest) => {
|
||
|
const selectedBranch = manifest.branch || branch;
|
||
|
const item = {
|
||
|
manifest,
|
||
|
title: manifest.name,
|
||
|
subtitle: manifest.description,
|
||
|
authors: processAuthors(manifest.authors, user),
|
||
|
user,
|
||
|
repo,
|
||
|
branch: selectedBranch,
|
||
|
|
||
|
imageURL: manifest.preview && manifest.preview.startsWith("http")
|
||
|
? manifest.preview
|
||
|
: `https://raw.githubusercontent.com/${user}/${repo}/${selectedBranch}/${manifest.preview}`,
|
||
|
extensionURL: manifest.main.startsWith("http")
|
||
|
? manifest.main
|
||
|
: `https://raw.githubusercontent.com/${user}/${repo}/${selectedBranch}/${manifest.main}`,
|
||
|
readmeURL: manifest.readme && manifest.readme.startsWith("http")
|
||
|
? manifest.readme
|
||
|
: `https://raw.githubusercontent.com/${user}/${repo}/${selectedBranch}/${manifest.readme}`,
|
||
|
stars,
|
||
|
tags: manifest.tags,
|
||
|
};
|
||
|
|
||
|
// If manifest is valid, add it to the list
|
||
|
if (manifest && manifest.name && manifest.description && manifest.main
|
||
|
) {
|
||
|
// Add to list unless we're hiding installed items and it's installed
|
||
|
if (!(CONFIG.visual.hideInstalled
|
||
|
&& localStorage.getItem("marketplace:installed:" + `${user}/${repo}/${manifest.main}`))
|
||
|
) {
|
||
|
accum.push(item);
|
||
|
}
|
||
|
}
|
||
|
// else {
|
||
|
// console.error("Invalid manifest:", manifest);
|
||
|
// }
|
||
|
|
||
|
return accum;
|
||
|
}, []);
|
||
|
|
||
|
return parsedManifests;
|
||
|
}
|
||
|
catch (err) {
|
||
|
// console.warn(contents_url, err);
|
||
|
return null;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// TODO: can we add a return type here?
|
||
|
/**
|
||
|
* Fetch themes from a repo and format data for generating cards
|
||
|
* @param {string} contents_url The repo's GitHub API contents_url (e.g. "https://api.github.com/repos/theRealPadster/spicetify-hide-podcasts/contents/{+path}")
|
||
|
* @param {string} branch The repo's default branch (e.g. main or master)
|
||
|
* @param {number} stars The number of stars the repo has
|
||
|
* @returns Extension info for card (or null)
|
||
|
*/
|
||
|
async function fetchThemeManifest(contents_url, branch, stars) {
|
||
|
try {
|
||
|
let manifests;
|
||
|
const regex_result = contents_url.match(/https:\/\/api\.github\.com\/repos\/(?<user>.+)\/(?<repo>.+)\/contents/);
|
||
|
// TODO: err handling?
|
||
|
if (!regex_result || !regex_result.groups) return null;
|
||
|
let { user, repo } = regex_result.groups;
|
||
|
|
||
|
manifests = await getRepoManifest(user, repo, branch);
|
||
|
|
||
|
// If the manifest returned is not an array, initialize it as one
|
||
|
if (!Array.isArray(manifests)) manifests = [manifests];
|
||
|
|
||
|
// Manifest is initially parsed
|
||
|
const parsedManifests = manifests.reduce((accum, manifest) => {
|
||
|
const selectedBranch = manifest.branch || branch;
|
||
|
const item = {
|
||
|
manifest,
|
||
|
title: manifest.name,
|
||
|
subtitle: manifest.description,
|
||
|
authors: processAuthors(manifest.authors, user),
|
||
|
user,
|
||
|
repo,
|
||
|
branch: selectedBranch,
|
||
|
imageURL: manifest.preview && manifest.preview.startsWith("http")
|
||
|
? manifest.preview
|
||
|
: `https://raw.githubusercontent.com/${user}/${repo}/${selectedBranch}/${manifest.preview}`,
|
||
|
readmeURL: manifest.readme && manifest.readme.startsWith("http")
|
||
|
? manifest.readme
|
||
|
: `https://raw.githubusercontent.com/${user}/${repo}/${selectedBranch}/${manifest.readme}`,
|
||
|
stars,
|
||
|
tags: manifest.tags,
|
||
|
// theme stuff
|
||
|
cssURL: manifest.usercss.startsWith("http")
|
||
|
? manifest.usercss
|
||
|
: `https://raw.githubusercontent.com/${user}/${repo}/${selectedBranch}/${manifest.usercss}`,
|
||
|
// TODO: clean up indentation etc
|
||
|
schemesURL: manifest.schemes
|
||
|
? (
|
||
|
manifest.schemes.startsWith("http") ? manifest.schemes : `https://raw.githubusercontent.com/${user}/${repo}/${selectedBranch}/${manifest.schemes}`
|
||
|
)
|
||
|
: null,
|
||
|
include: manifest.include,
|
||
|
};
|
||
|
// If manifest is valid, add it to the list
|
||
|
if (manifest && manifest.name && manifest.usercss && manifest.description) {
|
||
|
accum.push(item);
|
||
|
}
|
||
|
return accum;
|
||
|
}, []);
|
||
|
return parsedManifests;
|
||
|
}
|
||
|
catch (err) {
|
||
|
// console.warn(contents_url, err);
|
||
|
return null;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Query the GitHub API for a page of theme repos (having "spicetify-themes" topic)
|
||
|
* @param {number} page The page to get (defaults to 1)
|
||
|
* @returns Array of GitHub API results, filtered through the blacklist
|
||
|
*/
|
||
|
async function getThemeRepos(page = 1) {
|
||
|
let url = `https://api.github.com/search/repositories?q=${encodeURIComponent("topic:spicetify-themes")}&per_page=${ITEMS_PER_REQUEST}`;
|
||
|
|
||
|
// We can test multiple pages with this URL (58 results), as well as broken iamges etc.
|
||
|
// let url = `https://api.github.com/search/repositories?q=${encodeURIComponent("topic:spicetify")}`;
|
||
|
if (page) url += `&page=${page}`;
|
||
|
// Sorting params (not implemented for Marketplace yet)
|
||
|
// if (sortConfig.by.match(/top|controversial/) && sortConfig.time) {
|
||
|
// url += `&t=${sortConfig.time}`
|
||
|
const allThemes = await fetch(url).then(res => res.json()).catch(() => []);
|
||
|
if (!allThemes.items) {
|
||
|
Spicetify.showNotification("Too Many Requests, Cool Down.");
|
||
|
}
|
||
|
const filteredResults = {
|
||
|
...allThemes,
|
||
|
// Include count of all items on the page, since we're filtering the blacklist below,
|
||
|
// which can mess up the paging logic
|
||
|
page_count: allThemes.items.length,
|
||
|
items: allThemes.items.filter(item => !BLACKLIST.includes(item.html_url)),
|
||
|
};
|
||
|
|
||
|
return filteredResults;
|
||
|
}
|
||
|
function addToSessionStorage(items, key) {
|
||
|
if (!items || items == null) return;
|
||
|
items.forEach(item => {
|
||
|
if (!key) key = `${items.user}-${items.repo}`;
|
||
|
// If the key already exists, it will append to it instead of overwriting it
|
||
|
const existing = window.sessionStorage.getItem(key);
|
||
|
const parsed = existing ? JSON.parse(existing) : [];
|
||
|
parsed.push(item);
|
||
|
window.sessionStorage.setItem(key, JSON.stringify(parsed));
|
||
|
});
|
||
|
}
|