diff --git a/api/modal.css b/api/modal.css index 97e7cb6d..6bfdf408 100644 --- a/api/modal.css +++ b/api/modal.css @@ -12,14 +12,16 @@ .st-modal { position: fixed; - top: 15rem; + top: 50%; width: calc(40% - 4rem); padding: 2rem; left: 30%; background-color: #fafafa; border-radius: 0.5rem; - padding-top: 3rem; + padding-top: 1rem; z-index: 2147483647; + transform: translateY(-50%); + border-top: 1rem solid #ff9f00; } .st-modal h1 { diff --git a/api/modals.js b/api/modals.js index bd42fa17..9cf8e134 100644 --- a/api/modals.js +++ b/api/modals.js @@ -24,9 +24,6 @@ ScratchTools.modals = { p.textContent = data.description; modal.appendChild(p); - var orangeBar = document.createElement("div"); - orangeBar.className = "st-modal-header"; - data.components?.forEach(function (component) { if (component.type === "code") { var code = document.createElement("code"); @@ -46,7 +43,6 @@ ScratchTools.modals = { modal.appendChild(closeButton); div.appendChild(modal); - modal.prepend(orangeBar); document.body.appendChild(div); return { diff --git a/feature-locales/explore-filter/en.json b/feature-locales/explore-filter/en.json new file mode 100644 index 00000000..0bc2f832 --- /dev/null +++ b/feature-locales/explore-filter/en.json @@ -0,0 +1,13 @@ +{ + "filter": "filter", + "title": "Title", + "author": "Author", + "period": "Period", + "reset": "Reset", + "sharedDate": "Shared Date", + "updateDate": "Update Date", + "startDate": "Start date", + "endDate": "End date", + "including": "including", + "excluding": "excluding" +} \ No newline at end of file diff --git a/features/explore-filter/data.json b/features/explore-filter/data.json new file mode 100644 index 00000000..49d76022 --- /dev/null +++ b/features/explore-filter/data.json @@ -0,0 +1,51 @@ +{ + "title": "Explore Filter", + "description": "Customize project and studio search results with filters on the Search, Explore, and Studio pages.", + "credits": [ + { + "username": "Masaabu-YT", + "url": "https://scratch.mit.edu/users/Masaabu-YT/" + } + ], + "type": ["Website"], + "tags": ["New", "Featured"], + "dynamic": true, + "options": [ + { + "id": "filter-operation", + "name": "Filter operation", + "type": 4, + "options": [ + { + "name": "Blur", + "value": "blur" + }, + { + "name": "Hide", + "value": "hide" + } + ] + }, + { + "id": "keep-settings", + "name": "Keep filter setting even if you change pages", + "type": 1 + } + ], + "scripts": [ + { "file": "script.js", "runOn": "/explore/*" }, + { "file": "script.js", "runOn": "/search/*" }, + { "file": "script.js", "runOn": "/studios/*" } + ], + "styles": [ + { "file": "style.css", "runOn": "/explore/*" }, + { "file": "style.css", "runOn": "/search/*" }, + { "file": "style.css", "runOn": "/studios/*" } + ], + "resources": [ + { "name": "filter-icon", "path": "/resources/filter.svg" }, + { "name": "title-icon", "path": "/resources/title.svg" }, + { "name": "user-icon", "path": "/resources/user.svg" }, + { "name": "calendar-icon", "path": "/resources/calendar.svg" } + ] +} diff --git a/features/explore-filter/resources/calendar.svg b/features/explore-filter/resources/calendar.svg new file mode 100644 index 00000000..7381d9c5 --- /dev/null +++ b/features/explore-filter/resources/calendar.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/features/explore-filter/resources/filter.svg b/features/explore-filter/resources/filter.svg new file mode 100644 index 00000000..dec4964c --- /dev/null +++ b/features/explore-filter/resources/filter.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/features/explore-filter/resources/title.svg b/features/explore-filter/resources/title.svg new file mode 100644 index 00000000..2ac6bf23 --- /dev/null +++ b/features/explore-filter/resources/title.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/features/explore-filter/resources/user.svg b/features/explore-filter/resources/user.svg new file mode 100644 index 00000000..9cf90b10 --- /dev/null +++ b/features/explore-filter/resources/user.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/features/explore-filter/script.js b/features/explore-filter/script.js new file mode 100644 index 00000000..f5b19df4 --- /dev/null +++ b/features/explore-filter/script.js @@ -0,0 +1,488 @@ +export default async function ({ feature, console }) { + filterStyleSheet(feature.settings.get("filter-operation") || "blur"); + const filterDefault = `{ + "title": {}, + "author": {}, + "period": {} +}`; + let options = [ + { + icon: "title-icon", + id: "title", + }, + { + icon: "user-icon", + id: "author", + }, + { + icon: "calendar-icon", + id: "period", + }, + ]; + let page; + let filterData; + let apiCache = {}; + + async function filterProject(url, element) { + let data = apiCache[url]; + if (!data) { + data = await ( + await fetch(`${url.replace("scratch.mit.edu", "api.scratch.mit.edu")}`) + ).json(); + apiCache[url] = data; + } + if (data.code === "NotFound") { + element.classList.add("ste-filter-hide"); + return; + } + if ( + (filterData.period.shareStart && + filterData.period.shareStart > data.history.shared.split("T")[0]) || + (filterData.period.shareEnd && + filterData.period.shareEnd < data.history.shared.split("T")[0]) || + (filterData.period.updateStart && + filterData.period.updateStart > data.history.modified.split("T")[0]) || + (filterData.period.updateEnd && + filterData.period.updateEnd < data.history.modified.split("T")[0]) || + (filterData.title.including && + !filterData.title.including.every((text) => + data.title.includes(text) + )) || + (filterData.title.excluding && + filterData.title.excluding.some((text) => data.title.includes(text))) || + (filterData.author.including && + !filterData.author.including.some((text) => + data.author.username.includes(text) + )) || + (filterData.author.excluding && + filterData.author.excluding.some((text) => + data.author.username.includes(text) + )) + ) + element.classList.add("ste-filter-hide"); + else if (element.classList.contains("ste-filter-hide")) + element.classList.remove("ste-filter-hide"); + } + + async function filterStudio(url, element) { + let data = apiCache[url]; + if (!data) { + data = await ( + await fetch(`${url.replace("scratch.mit.edu", "api.scratch.mit.edu")}`) + ).json(); + apiCache[url] = data; + } + if ( + (filterData.period.shareStart && + filterData.period.shareStart > data.history.created.split("T")[0]) || + (filterData.period.shareEnd && + filterData.period.shareEnd < data.history.created.split("T")[0]) || + (filterData.period.updateStart && + filterData.period.updateStart > data.history.modified.split("T")[0]) || + (filterData.period.updateEnd && + filterData.period.updateEnd < data.history.modified.split("T")[0]) || + (filterData.title.including && + !filterData.title.including.every((text) => + data.title.includes(text) + )) || + (filterData.title.excluding && + filterData.title.excluding.some((text) => data.title.includes(text))) + ) + element.classList.add("ste-filter-hide"); + else if (element.classList.contains("ste-filter-hide")) + element.classList.remove("ste-filter-hide"); + } + + async function filter() { + switch (page[1]) { + case "search": + case "explore": { + if (page[2] === "projects") + document.querySelectorAll(".thumbnail.project").forEach((element) => { + let link = element.querySelector("a.thumbnail-image"); + filterProject(link.href, element); + }); + else if (page[2] === "studios") + document.querySelectorAll(".thumbnail.gallery").forEach((element) => { + let link = element.querySelector("a.thumbnail-image"); + filterStudio(link.href, element); + }); + break; + } + case "studios": { + document.querySelectorAll(".studio-project-tile").forEach((element) => { + let link = element.querySelector("a.studio-project-title"); + filterProject(link.href, element); + }); + break; + } + default: + break; + } + } + async function filterStyleSheet(filterType) { + let app = await ScratchTools.waitForElement("#app"); + + if (filterType === "hide") { + app.classList.add("ste-filter-mode-hide"); + app.classList.remove("ste-filter-mode-blur"); + } else if (filterType === "blur") { + app.classList.add("ste-filter-mode-blur"); + app.classList.remove("ste-filter-mode-hide"); + } + } + + feature.settings.addEventListener("changed", function ({ key, value }) { + if (key == "filter-operation") filterStyleSheet(value); + }); + + page = window.location.pathname.split("/"); + if (feature.settings.get("keep-settings") === true) + filterData = await ScratchTools.storage.get("project-filter"); + if (!filterData) filterData = JSON.parse(filterDefault); + else + switch (page[1]) { + case "search": + case "explore": { + if (page[2] === "projects") + ScratchTools.waitForElements(".thumbnail.project", (element) => { + let link = element.querySelector("a.thumbnail-image"); + filterProject(link.href, element); + }); + else if (page[2] === "studios") { + ScratchTools.waitForElements(".thumbnail.gallery", (element) => { + let link = element.querySelector("a.thumbnail-image"); + filterStudio(link.href, element); + }); + options = [ + { + icon: "title-icon", + id: "title", + }, + { + icon: "calendar-icon", + id: "period", + }, + ]; + } + break; + } + + case "studios": { + ScratchTools.waitForElements(".studio-project-tile", (element) => { + let link = element.querySelector("a.studio-project-title"); + filterProject(link.href, element); + }); + break; + } + + default: + break; + } + + const filterButton = document.createElement("div"); + filterButton.classList.add("ste-filter-button"); + const filterIcon = document.createElement("img"); + filterIcon.src = feature.self.getResource("filter-icon"); + const filterText = document.createElement("p"); + filterText.textContent = feature.msg("filter"); + filterButton.appendChild(filterIcon); + filterButton.appendChild(filterText); + + function optionButtonClick(id, button) { + function createDetails(label) { + const details = document.createElement("details"); + details.classList.add("ste-project-filter-details"); + details.setAttribute("open", "open"); + const summary = document.createElement("summary"); + summary.textContent = `${feature.msg(label)}`; + details.appendChild(summary); + return details; + } + + function createTextTag(id, type) { + const content = document.createElement("div"); + const tags = document.createElement("div"); + tags.style.margin = "0"; + function addTag(text) { + const tag = document.createElement("span"); + tag.classList.add("ste-filter-text"); + tag.textContent = text; + tag.addEventListener("click", function () { + filterData[id][type] = filterData[id][type].filter(function ( + tagText + ) { + return tagText !== text; + }); + tag.remove(); + if (filterData[id][type].length == 0) { + delete filterData[id][type]; + } + if (Object.keys(filterData[id]).length == 0) { + if (button.classList.contains("active")) + button.classList.remove("active"); + } + filter(); + }); + tags.appendChild(tag); + } + if (filterData[id][type]?.length >= 0) + filterData[id][type].forEach(addTag); + + const addButton = document.createElement("button"); + addButton.style.cssText = "min-width: 40px !important;"; + addButton.textContent = "+"; + addButton.addEventListener("click", function () { + if (!input.value) return; + if (!filterData[id][type]) filterData[id][type] = []; + filterData[id][type].push(input.value); + addTag(input.value); + input.value = ""; + filter(); + button.classList.add("active"); + }); + const input = document.createElement("input"); + + content.appendChild(tags); + content.appendChild(addButton); + content.appendChild(input); + return content; + } + + switch (id) { + case "reset": { + filterData = JSON.parse(filterDefault); + document + .querySelectorAll(".ste-filter-bar .ste-filter-button.active") + .forEach((element) => { + element.classList.remove("active"); + }); + filter(); + break; + } + + case "title": { + const includingDetails = createDetails("including"); + const includingTextTag = createTextTag("title", "including"); + includingDetails.appendChild(includingTextTag); + const excludingDetails = createDetails("excluding"); + const excludingTextTag = createTextTag("title", "excluding"); + excludingDetails.appendChild(excludingTextTag); + let modal = ScratchTools.modals.create({ + title: `${feature.msg("title")}`, + components: [ + { + type: "html", + content: includingDetails, + }, + { + type: "html", + content: excludingDetails, + }, + ], + }); + break; + } + + case "author": { + const includingDetails = createDetails("including"); + const includingTextTag = createTextTag("author", "including"); + includingDetails.appendChild(includingTextTag); + const excludingDetails = createDetails("excluding"); + const excludingTextTag = createTextTag("author", "excluding"); + excludingDetails.appendChild(excludingTextTag); + let modal = ScratchTools.modals.create({ + title: `${feature.msg("author")}`, + components: [ + { + type: "html", + content: includingDetails, + }, + { + type: "html", + content: excludingDetails, + }, + ], + }); + break; + } + + case "period": { + function createInput(id, label) { + const content = document.createElement("div"); + content.textContent = feature.msg(label); + const input = document.createElement("input"); + input.type = "date"; + input.style.margin = "0 10px"; + if (filterData.period[id]) input.value = filterData.period[id]; + input.addEventListener("change", function () { + if (input.value) { + filterData["period"][id] = input.value; + button.classList.add("active"); + } else if (filterData.period[id]) delete filterData.period[id]; + filter(); + }); + const resetButton = document.createElement("button"); + resetButton.textContent = feature.msg("reset"); + resetButton.addEventListener("click", function () { + input.value = ""; + if (filterData.period[id]) delete filterData.period[id]; + if (Object.keys(filterData["period"]).length == 0) { + if (button.classList.contains("active")) + button.classList.remove("active"); + } + filter(); + }); + content.appendChild(input); + content.appendChild(resetButton); + return content; + } + + const shareStart = createInput("shareStart", "startDate"); + const shareEnd = createInput("shareEnd", "endDate"); + const updateStart = createInput("updateStart", "startDate"); + const updateEnd = createInput("updateEnd", "endDate"); + + const shareDetails = createDetails("sharedDate"); + shareDetails.appendChild(shareStart); + shareDetails.appendChild(shareEnd); + + const updateDetails = createDetails("updateDate"); + updateDetails.appendChild(updateStart); + updateDetails.appendChild(updateEnd); + + ScratchTools.modals.create({ + title: `${feature.msg("period")}`, + components: [ + { + type: "html", + content: shareDetails, + }, + { + type: "html", + content: updateDetails, + }, + ], + }); + break; + } + + default: + break; + } + } + + const controlBar = document.createElement("div"); + controlBar.classList.add("ste-filter-bar"); + if (JSON.stringify(filterData) === filterDefault) + controlBar.style.display = "none"; + else filterButton.classList.add("active"); + const filterSettings = document.createElement("div"); + filterSettings.classList.add("ste-filter-settings"); + options.forEach((option) => { + const icon = document.createElement("img"); + icon.src = feature.self.getResource(option.icon); + + const text = document.createElement("p"); + text.textContent = feature.msg(option.id); + + const button = document.createElement("div"); + button.classList.add("ste-filter-button"); + if (filterData[option.id]) + if (Object.keys(filterData[option.id]).length !== 0) + button.classList.add("active"); + button.appendChild(icon); + button.appendChild(text); + + button.addEventListener("click", function () { + optionButtonClick(option.id, button); + }); + + filterSettings.appendChild(button); + }); + const resetButton = document.createElement("div"); + resetButton.style.marginLeft = "20px" + resetButton.classList.add('ste-filter-button'); + const resetButtonText = document.createElement("p"); + resetButtonText.textContent = feature.msg("reset"); + resetButtonText.classList.add('ste-reset'); + resetButton.appendChild(resetButtonText); + resetButton.addEventListener("click", function() { + optionButtonClick("reset", resetButton); + }) + filterSettings.appendChild(resetButton); + + controlBar.appendChild(filterSettings); + + filterButton.addEventListener("click", function () { + if (controlBar.style.display == "none") { + controlBar.style.display = "flex"; + filterButton.classList.add("active"); + } else { + controlBar.style.display = "none"; + if (filterButton.classList.contains("active")) + filterButton.classList.remove("active"); + } + }); + + switch (page[1]) { + case "search": + case "explore": { + const sortElement = await ScratchTools.waitForElement( + "div.sort-controls" + ); + const sortForm = await ScratchTools.waitForElement("form.sort-mode"); + controlBar.appendChild(sortForm); + sortElement.after(controlBar); + + sortElement.appendChild(filterButton); + break; + } + + case "studios": { + async function setFilterControl() { + const tabTitle = document.querySelector(".studio-header-container h2"); + const headerContainer = document.querySelector( + ".studio-header-container" + ); + headerContainer.after(controlBar); + tabTitle.after(filterButton); + } + await ScratchTools.waitForElement(".studio-project-tile"); + if ( + /^[0-9]+$/.test( + window.location.pathname.replace("/studios/", "").replaceAll("/", "") + ) + ) + setFilterControl(); + const buttons = document.querySelectorAll(".studio-tab-nav .nav_link"); + buttons.forEach((button) => { + if ( + /^[0-9]+$/.test( + button.href.replace("https://scratch.mit.edu/studios/", "") + ) + ) + button.addEventListener("click", async function () { + await ScratchTools.waitForElement(".studio-project-tile"); + setFilterControl(); + }); + }); + break; + } + + default: + break; + } + + window.addEventListener("beforeunload", async function (event) { + if ( + feature.settings.get("keep-settings") === true && + JSON.stringify(filterData) !== + JSON.stringify(await ScratchTools.storage.get("project-filter")) + ) + await ScratchTools.storage.set({ + key: "project-filter", + value: filterData, + }); + }); +} diff --git a/features/explore-filter/style.css b/features/explore-filter/style.css new file mode 100644 index 00000000..873663d2 --- /dev/null +++ b/features/explore-filter/style.css @@ -0,0 +1,110 @@ +.ste-filter-button { + margin: 5px 0; + padding: 1px 15px; + white-space: nowrap; + display: flex; + border: 1px solid #d9d9d9; + border-radius: 5px; +} +.ste-filter-button p:not(.ste-reset) { + margin: auto; + margin-left: 5px; + cursor: pointer; +} +.ste-reset { + margin: auto; + cursor: pointer; +} +.ste-filter-button img { + width: 1.2rem; +} + +.ste-filter-bar { + display: flex; + margin: 0 auto; + border-bottom: 1px solid #d9d9d9; + padding: 8px 0; + max-width: 58.75rem; + justify-content: space-between; +} +.ste-filter-bar img { + width: 1.5rem; + transform: scale(.6); + margin-left: -.35rem; +} +.ste-filter-bar .sort-mode { + margin: auto 0; + height: 32px; +} +.ste-filter-bar .control-label { + display: none; +} + +.ste-filter-settings { + display: flex; +} +.ste-filter-bar .ste-filter-button { + margin: 5px; +} + +.ste-filter-button.active { + border-color: #855cd6; + background-color: #855cd6; +} +.ste-filter-button.active p { + color: white; +} +.ste-filter-button.active img { + filter: brightness(0) invert(1); +} + +.ste-project-filter-details { + margin: 5px 0; + border-radius: 5px; + background-color: #79797929; +} +.ste-project-filter-details summary { + display: list-item; + padding: 6px; + background-color: #ffce7f; + border-radius: 5px; + cursor: pointer; +} +.ste-project-filter-details button { + background-color: white; + margin-top: 5px !important; +} +.ste-project-filter-details div { + margin-left: 20px; + padding-bottom: 5px; + +} +.ste-project-filter-details input { + background-color: white; + margin-bottom: 0; +} + +.ste-filter-text { + display: inline-block; + margin: .9em .2em 0; + padding: .45em .7em; + border: 2px solid #d68b5c; + border-radius: 10px; + background-color: #fff; + color: #d68b5c; + cursor: pointer; + transition: all 0.2s 0s ease; +} +.ste-filter-text:hover { + border: 2px solid #d13636; + color: #d13636; + background-color: #ffe1e1; +} + +#app.ste-filter-mode-blur .project.ste-filter-hide { + filter: opacity(.3) blur(.15rem); +} + +#app.ste-filter-mode-hide .project.ste-filter-hide { + display: none; +} diff --git a/features/features.json b/features/features.json index 38c747b0..fa38492f 100644 --- a/features/features.json +++ b/features/features.json @@ -1,4 +1,9 @@ [ + { + "version": 2, + "id": "explore-filter", + "versionAdded": "v4.0.0" + }, { "version": 2, "id": "better-featured-projects",