Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions features/features.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
[
{
"version": 2,
"id": "search-in-studio",
"versionAdded": "v4.0.0"
},
{
"version": 2,
"id": "video-recorder",
Expand Down
39 changes: 39 additions & 0 deletions features/search-in-studio/data.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
{
"title": "Search in Studio",
"description": "Allows users to search for projects in studios.",
"credits": [
{
"username": "LoganMSM",
"url": "https://scratch.mit.edu/users/LoganMSM/"
},
{
"username": "MaterArc",
"url": "https://scratch.mit.edu/users/MaterArc/"
}
],
"type": [
"Website"
],
"tags": [
"New"
],
"dynamic": true,
"scripts": [
{
"file": "script.js",
"runOn": "/studios/*"
}
],
"styles": [
{
"file": "style.css",
"runOn": "/studios/*"
}
],
"components": [
{
"type": "info",
"content": "Searching can take a while to load depending on the studio size."
}
]
}
209 changes: 209 additions & 0 deletions features/search-in-studio/script.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
export default async function ({ feature, console }) {
let projects = [];
let projectDetailsMap = {};

function tokenize(text) {
return text
.toLowerCase()
.split(/\s+/)
.filter((word) => word.length > 0);
}

function computeExactPhraseScore(title, searchTokens) {
const titleTokens = tokenize(title);
const phrase = searchTokens.join(" ");
const titleString = titleTokens.join(" ");
if (titleString.includes(phrase)) {
const startIndex = titleString.indexOf(phrase);
return startIndex === 0 ? 2 : 1;
}
return 0;
}

function computeSingleWordScore(title, searchToken) {
const titleTokens = tokenize(title);
if (titleTokens[0] === searchToken) {
return 2;
} else if (titleTokens.includes(searchToken)) {
return 1;
}
return 0;
}

function searchProject(searchText) {
const searchTokens = tokenize(searchText.trim());
const exactMatchProjects = projects
.map((project) => ({
project,
score: computeExactPhraseScore(
projectDetailsMap[project.id].title.toLowerCase(),
searchTokens
),
}))
.filter(({ score }) => score > 0)
.sort((a, b) => b.score - a.score)
.map(({ project }) => project);

if (searchTokens.length === 1) {
const singleWordProjects = projects
.map((project) => ({
project,
score: computeSingleWordScore(
projectDetailsMap[project.id].title.toLowerCase(),
searchTokens[0]
),
}))
.filter(({ score }) => score > 0)
.sort((a, b) => b.score - a.score)
.map(({ project }) => project);

const combinedResults = [...exactMatchProjects, ...singleWordProjects];
updateProjectContainer(combinedResults);
} else {
updateProjectContainer(exactMatchProjects);
}
}

function injectSearchBar() {
const url = window.location.href;

if (!url.match(/^https:\/\/scratch\.mit\.edu\/studios\/\d+$/)) {
return;
}
ScratchTools.waitForElements(
".studio-header-container",
(headerContainer) => {
if (!headerContainer) return;

const searchContainer = document.createElement("div");
searchContainer.className = "search-container";

const searchInput = document.createElement("input");
searchInput.type = "text";
searchInput.className = "search-bar";
searchInput.id = "projectSearch";
searchInput.placeholder = "Search projects...";

searchContainer.appendChild(searchInput);
headerContainer.appendChild(searchContainer);

searchInput.addEventListener("input", () => {
const searchText = searchInput.value;
if (searchText.trim() === "") {
updateProjectContainer(projects);
} else {
searchProject(searchText);
}
});
}
);
}

async function fetchAllStudioProjects(studioId) {
let projects = [];
let offset = 0;
const limit = 40;

while (true) {
const response = await fetch(
`https://api.scratch.mit.edu/studios/${studioId}/projects?limit=${limit}&offset=${offset}`
);
const data = await response.json();

if (data.length === 0) break;

projects = projects.concat(data);
offset += limit;
}

return projects;
}

async function getProjectDetails(projectId) {
const response = await fetch(
`https://api.scratch.mit.edu/projects/${projectId}`
);
return response.json();
}

async function updateProjectContainer(filteredProjects) {
ScratchTools.waitForElements(".studio-projects-grid", async (container) => {
if (!container) return;

container.innerHTML = "";

if (filteredProjects.length === 0) return;

for (const project of filteredProjects) {
const projectDetails = await getProjectDetails(project.id);

const projectTile = document.createElement("div");
projectTile.className = "studio-project-tile";

const projectLink = document.createElement("a");
projectLink.href = `/projects/${project.id}/`;

const projectImage = document.createElement("img");
projectImage.className = "studio-project-image";
projectImage.src = projectDetails.image || "";

const projectBottom = document.createElement("div");
projectBottom.className = "studio-project-bottom";

const userLink = document.createElement("a");
userLink.href = `/users/${projectDetails.author.username}/`;

const userImage = document.createElement("img");
userImage.className = "studio-project-avatar";
userImage.src = `https://cdn2.scratch.mit.edu/get_image/user/${projectDetails.author.id}_90x90.png`;

const projectInfo = document.createElement("div");
projectInfo.className = "studio-project-info";

const projectTitle = document.createElement("a");
projectTitle.className = "studio-project-title";
projectTitle.href = `/projects/${project.id}/`;
projectTitle.textContent = projectDetails.title;

const projectUsername = document.createElement("div");
projectUsername.className = "studio-project-username";
projectUsername.textContent = projectDetails.author.username;

projectLink.appendChild(projectImage);
projectTile.appendChild(projectLink);

userLink.appendChild(userImage);
projectInfo.appendChild(projectTitle);
projectInfo.appendChild(projectUsername);
projectBottom.appendChild(userLink);
projectBottom.appendChild(projectInfo);
projectTile.appendChild(projectBottom);

container.appendChild(projectTile);
}
});
}

async function searchAndDisplayProjects() {
const studioId = getStudioIdFromUrl();
if (!studioId) return;

projects = await fetchAllStudioProjects(studioId);

projectDetailsMap = {};
for (const project of projects) {
projectDetailsMap[project.id] = await getProjectDetails(project.id);
}

injectSearchBar();
updateProjectContainer(projects);
}

function getStudioIdFromUrl() {
const url = window.location.href;
const matches = url.match(/studios\/(\d+)/);
return matches ? matches[1] : null;
}

searchAndDisplayProjects();
}
10 changes: 10 additions & 0 deletions features/search-in-studio/style.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
.search-container {
display: flex;
align-items: center;
margin-left: 10px;
}
.search-bar {
padding: 7px;
border: 1px solid #ccc;
border-radius: 4px;
}