diff --git a/src/app.tsx b/src/app.tsx index a892515b..2af30a86 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -137,6 +137,7 @@ class App extends React.Component<{ schemes, activeScheme, }, + sort: getLocalStorageDataFromKey(LOCALSTORAGE_KEYS.sort, "stars"), }; if (!this.CONFIG.activeTab || !this.CONFIG.tabs.filter(tab => tab.name === this.CONFIG.activeTab).length) { diff --git a/src/components/Grid.tsx b/src/components/Grid.tsx index 6ec97706..9d95ad11 100644 --- a/src/components/Grid.tsx +++ b/src/components/Grid.tsx @@ -5,7 +5,12 @@ import { Option } from "react-dropdown"; const Spicetify = window.Spicetify; import { CardItem, CardType, Config, SchemeIni, Snippet, TabItemConfig } from "../types/marketplace-types"; -import { getLocalStorageDataFromKey, generateSchemesOptions, injectColourScheme } from "../logic/Utils"; +import { getLocalStorageDataFromKey, + generateSchemesOptions, + injectColourScheme, + generateSortOptions, + sortCardItems, +} from "../logic/Utils"; import { LOCALSTORAGE_KEYS, ITEMS_PER_REQUEST, MARKETPLACE_VERSION, LATEST_RELEASE } from "../constants"; import { openModal } from "../logic/LaunchModals"; import { @@ -52,7 +57,7 @@ class Grid extends React.Component< // Fetches the sorting options, fetched from SortBox.js this.sortConfig = { - by: getLocalStorageDataFromKey(LOCALSTORAGE_KEYS.sortBy, "top"), + by: getLocalStorageDataFromKey(LOCALSTORAGE_KEYS.sort, "top"), }; this.state = { @@ -132,7 +137,7 @@ class Grid extends React.Component< updateSort(sortByValue) { if (sortByValue) { this.sortConfig.by = sortByValue; - localStorage.setItem(LOCALSTORAGE_KEYS.sortBy, sortByValue); + localStorage.setItem(LOCALSTORAGE_KEYS.sort, sortByValue); } // this.requestPage = null; @@ -187,8 +192,9 @@ class Grid extends React.Component< switch (activeTab) { case "Extensions": { const pageOfRepos = await getTaggedRepos("spicetify-extensions", this.requestPage, this.BLACKLIST); + const extensions: CardItem[] = []; for (const repo of pageOfRepos.items) { - const extensions = await fetchExtensionManifest( + const repoExtensions = await fetchExtensionManifest( repo.contents_url, repo.default_branch, repo.stargazers_count, @@ -201,14 +207,18 @@ class Grid extends React.Component< return -1; } - if (extensions && extensions.length) { - // console.log(`${repo.name} has ${extensions.length} extensions:`, extensions); - extensions.forEach((extension) => { - Object.assign(extension, { lastUpdated: repo.pushed_at }); - this.appendCard(extension, "extension", activeTab); - }); + if (repoExtensions && repoExtensions.length) { + extensions.push(...repoExtensions.map((extension) => ({ + ...extension, lastUpdated: repo.pushed_at, + }))); } } + + sortCardItems(extensions, localStorage.getItem("marketplace:sort") || "stars"); + + for (const extension of extensions) { + this.appendCard(extension, "extension", activeTab); + } this.setState({ cards: this.cardList }); // First result is null or -1 so it coerces to 1 @@ -231,17 +241,24 @@ class Grid extends React.Component< for (const type in installedStuff) { if (installedStuff[type].length) { + const installedOfType: CardItem[] = []; installedStuff[type].forEach(async (itemKey) => { // TODO: err handling - const extension = getLocalStorageDataFromKey(itemKey); + const installedItem = getLocalStorageDataFromKey(itemKey); // I believe this stops the requests when switching tabs? if (this.requestQueue.length > 1 && queue !== this.requestQueue[0]) { // Stop this queue from continuing to fetch and append to cards list return -1; } - this.appendCard(extension, type as CardType, activeTab); + installedOfType.push(installedItem); }); + + sortCardItems(installedOfType, localStorage.getItem("marketplace:sort") || "stars"); + + for (const item of installedOfType) { + this.appendCard(item, type as CardType, activeTab); + } } } this.setState({ cards: this.cardList }); @@ -251,28 +268,37 @@ class Grid extends React.Component< // installed extension do them all in one go, since it's local } case "Themes": { const pageOfRepos = await getTaggedRepos("spicetify-themes", this.requestPage, this.BLACKLIST); + const themes: CardItem[] = []; for (const repo of pageOfRepos.items) { - - const themes = await fetchThemeManifest( + const repoThemes = await fetchThemeManifest( repo.contents_url, repo.default_branch, repo.stargazers_count, ); + // I believe this stops the requests when switching tabs? if (this.requestQueue.length > 1 && queue !== this.requestQueue[0]) { // Stop this queue from continuing to fetch and append to cards list return -1; } - if (themes && themes.length) { - themes.forEach((theme) => { - Object.assign(theme, { lastUpdated: repo.pushed_at }); - this.appendCard(theme, "theme", activeTab); - }); + if (repoThemes && repoThemes.length) { + themes.push(...repoThemes.map( + (theme) => ({ + ...theme, + lastUpdated: repo.pushed_at, + }), + )); } } this.setState({ cards: this.cardList }); + sortCardItems(themes, localStorage.getItem("marketplace:sort") || "stars"); + + for (const theme of themes) { + this.appendCard(theme, "theme", activeTab); + } + // First request is null, so coerces to 1 const currentPage = this.requestPage > -1 && this.requestPage ? this.requestPage : 1; // -1 because the page number is 1-indexed @@ -283,11 +309,13 @@ class Grid extends React.Component< if (remainingResults > 0) return currentPage + 1; else console.debug("No more theme results"); break; - } case "Apps": { + } + case "Apps": { const pageOfRepos = await getTaggedRepos("spicetify-apps", this.requestPage, this.BLACKLIST); - for (const repo of pageOfRepos.items) { + const apps: CardItem[] = []; - const apps = await fetchAppManifest( + for (const repo of pageOfRepos.items) { + const repoApps = await fetchAppManifest( repo.contents_url, repo.default_branch, repo.stargazers_count, @@ -298,15 +326,21 @@ class Grid extends React.Component< return -1; } - if (apps && apps.length) { - apps.forEach((app) => { - Object.assign(app, { lastUpdated: repo.pushed_at }); - this.appendCard(app, "app", activeTab); - }); + if (repoApps && repoApps.length) { + apps.push(...repoApps.map((app) => ({ + ...app, + lastUpdated: repo.pushed_at, + }))); } } this.setState({ cards: this.cardList }); + sortCardItems(apps, localStorage.getItem("marketplace:sort") || "stars"); + + for (const app of apps) { + this.appendCard(app, "app", activeTab); + } + // First request is null, so coerces to 1 const currentPage = this.requestPage > -1 && this.requestPage ? this.requestPage : 1; // -1 because the page number is 1-indexed @@ -324,7 +358,9 @@ class Grid extends React.Component< // Stop this queue from continuing to fetch and append to cards list return -1; } + if (snippets && snippets.length) { + sortCardItems(snippets, localStorage.getItem("marketplace:sort") || "stars"); snippets.forEach((snippet) => this.appendCard(snippet, "snippet", activeTab)); this.setState({ cards: this.cardList }); } @@ -501,7 +537,6 @@ class Grid extends React.Component<
-

{this.props.title}

{this.state.newUpdate ? : null} + {/* Generate a new box for sorting options */} +

{t("grid.sort.label")}

+ this.updateSort(value)} + sortBoxOptions={generateSortOptions(t)} + sortBySelectedFn={(a) => a.key === this.CONFIG.sort} />
{/* Show theme developer tools button if themeDevTools is enabled */} diff --git a/src/constants.ts b/src/constants.ts index 0ce5a3ac..acedc827 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -9,7 +9,7 @@ export const LOCALSTORAGE_KEYS = { installedThemes: "marketplace:installed-themes", activeTab: "marketplace:active-tab", tabs: "marketplace:tabs", - sortBy: "marketplace:sort-by", + sort: "marketplace:sort", // Theme installed store the localsorage key of the theme (e.g. marketplace:installed:NYRI4/Comfy-spicetify/user.css) themeInstalled: "marketplace:theme-installed", albumArtBasedColor: "marketplace:albumArtBasedColors", diff --git a/src/logic/FetchRemotes.ts b/src/logic/FetchRemotes.ts index b55abb5f..5f156841 100644 --- a/src/logic/FetchRemotes.ts +++ b/src/logic/FetchRemotes.ts @@ -107,6 +107,7 @@ async function getRepoManifest(user: string, repo: string, branch: string) { * @param contents_url The repo's GitHub API contents_url (e.g. "https://api.github.com/repos/theRealPadster/spicetify-hide-podcasts/contents/{+path}") * @param branch The repo's default branch (e.g. main or master) * @param stars The number of stars the repo has +* @param hideInstalled Whether to hide installed items or not (defaults to `false`) * @returns Extension info for card (or null) */ export async function fetchExtensionManifest(contents_url: string, branch: string, stars: number, hideInstalled = false) { @@ -317,3 +318,4 @@ export const fetchCssSnippets = async () => { }, []); return snippets; }; + diff --git a/src/logic/Utils.ts b/src/logic/Utils.ts index 3c760659..a117d978 100644 --- a/src/logic/Utils.ts +++ b/src/logic/Utils.ts @@ -191,6 +191,26 @@ export const generateSchemesOptions = (schemes: SchemeIni) => { )); }; +/** + * Generate a list of options for the sort dropdown + * @param t The string translation function + * @returns The sort options for the sort dropdown + */ +export const generateSortOptions = (t: (key: string) => string) => { + // TODO: It would be great if I could disable the options that don't apply for snippets + // But it looks like that's not supported by the library + // https://github.com/fraserxu/react-dropdown/pull/176 + // TODO: I could also just remove the options for snippets, + // but then the sort resets when you switch tabs and it's disruptive + + return [ + { key: "stars", value: t("grid.sort.stars") }, + { key: "newest", value: t("grid.sort.newest") }, + { key: "oldest", value: t("grid.sort.oldest") }, + { key: "a-z", value: t("grid.sort.aToZ") }, + { key: "z-a", value: t("grid.sort.zToA") }, + ]; +}; /** * Reset Marketplace localStorage keys * @param categories The categories to reset. If none provided, reset everything. @@ -582,6 +602,51 @@ export const addExtensionToSpicetifyConfig = (main?: string) => { } }; +/** + * Compare two card items/snippets by name. + * This will use `title` for snippets and `manifest.name` for everything else. + */ +const compareNames = (a: CardItem | Snippet, b: CardItem | Snippet) => { + // Snippets have a title, but no manifest + const aName = a.title || a?.manifest?.name || ""; + const bName = b.title || b?.manifest?.name || ""; + return aName.localeCompare(bName); +}; + +/** + * Compare two card items/snippets by lastUpdated. + * This is skipped for snippets, since they don't have a lastUpdated property. + */ +const compareUpdated = (a: CardItem | Snippet, b: CardItem | Snippet) => { + // Abort compare if items are missing lastUpdated + if (a.lastUpdated === undefined || b.lastUpdated === undefined) return 0; + + const aDate = new Date(a.lastUpdated); + const bDate = new Date(b.lastUpdated); + return bDate.getTime() - aDate.getTime(); +}; + +export const sortCardItems = (cardItems: CardItem[] | Snippet[], sortMode: string) => { + switch (sortMode) { + case "a-z": + cardItems.sort((a, b) => compareNames(a, b)); + break; + case "z-a": + cardItems.sort((a, b) => compareNames(b, a)); + break; + case "newest": + cardItems.sort((a, b) => compareUpdated(a, b)); + break; + case "oldest": + cardItems.sort((a, b) => compareUpdated(b, a)); + break; + case "stars": + default: + cardItems.sort((a, b) => b.stars - a.stars); + break; + } +}; + // Make a ping to the jsdelivr CDN to check if the user has an internet connection export async function getAvailableTLD() { const tlds = ["net", "xyz"]; diff --git a/src/resources/locales/en.json b/src/resources/locales/en.json index c08ac075..ee3be9fa 100644 --- a/src/resources/locales/en.json +++ b/src/resources/locales/en.json @@ -81,7 +81,15 @@ "lastUpdated": "Last updated {{val, datetime}}", "externalJS": "external JS", "dark": "dark", - "light": "light" + "light": "light", + "sort": { + "label": "Sort by:", + "stars": "Stars", + "newest": "Newest", + "oldest": "Oldest", + "aToZ": "A-Z", + "zToA": "Z-A" + } }, "readmePage": { "title": "$t(grid.spicetifyMarketplace) - Readme", diff --git a/src/styles/components/_grid.scss b/src/styles/components/_grid.scss index 38eb3a6f..4d8d0763 100644 --- a/src/styles/components/_grid.scss +++ b/src/styles/components/_grid.scss @@ -1,10 +1,5 @@ @use "../constants.scss"; -// Compatibility with new Spotify layout -.Root__fixed-top-bar ~ .Root__main-view .marketplace-header { - padding-top: 64px; -} - .marketplace-header { -webkit-box-pack: justify; -webkit-box-align: center; @@ -18,7 +13,7 @@ // To position the settings button + colour schemes position: sticky; flex-direction: row-reverse; - // top: 80px; + top: 80px; z-index: 1; } @@ -35,6 +30,11 @@ left: 0; } +.marketplace-header__label { + display: inline-flex; + align-self: center; +} + .marketplace-grid { --minimumColumnWidth: 180px; --column-width: minmax(var(--minimumColumnWidth), 1fr); diff --git a/src/types/marketplace-types.d.ts b/src/types/marketplace-types.d.ts index b9f42dea..e1f31192 100644 --- a/src/types/marketplace-types.d.ts +++ b/src/types/marketplace-types.d.ts @@ -91,7 +91,9 @@ export type CardItem = { stars: number; tags: string[]; lastUpdated: string; - + name: string; + lastUpdated: string; + stargazers_count: number; // For themes only cssURL?: string; schemesURL?: string; @@ -151,6 +153,8 @@ export type SchemeIni = { [key: string]: ColourScheme; }; +export type SortMode = "a-z" | "z-a" | "newest" | "oldest" | "stars"; + export type Config = { // Fetch the settings and set defaults. Used in Settings.js visual: VisualConfig, @@ -161,4 +165,5 @@ export type Config = { schemes?: SchemeIni; activeScheme?: string | null; }, + sort: SortMode; };