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",