Skip to content
Open
Show file tree
Hide file tree
Changes from all 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": "project-card-stats",
"versionAdded": "v4.3.0"
},
{
"version": 2,
"id": "real-date",
Expand Down
30 changes: 30 additions & 0 deletions features/project-card-stats/data.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
{
"title": "Project Card Stats",
"description": "Displays project statistics on project cards in Explore and Search.",
"credits": [
{ "username": "Masaabu-YT", "url": "https://scratch.mit.edu/users/Masaabu-YT/" }
],
"type": ["Website"],
"tags": ["New", "Featured"],
"dynamic": true,
"options": [
{ "id": "show-love", "name": "Show love count", "type": 1 },
{ "id": "show-fav", "name": "Show favorite count", "type": 1 },
{ "id": "show-view", "name": "Show view count", "type": 1, "default": true },
{ "id": "show-published", "name": "Show published date", "type": 1, "default": true }
],
"scripts": [
{ "file": "script.js", "runOn": "/explore/projects/*" },
{ "file": "script.js", "runOn": "/search/projects/*" }
],
"styles": [
{ "file": "style.css", "runOn": "/explore/projects/*" },
{ "file": "style.css", "runOn": "/search/projects/*" }
],
"resources": [
{ "name": "love-icon", "path": "/resources/love.svg" },
{ "name": "fav-icon", "path": "/resources/fav.svg" },
{ "name": "views-icon", "path": "/resources/views.svg" },
{ "name": "published-icon", "path": "/resources/published.svg" }
]
}
3 changes: 3 additions & 0 deletions features/project-card-stats/resources/fav.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions features/project-card-stats/resources/love.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions features/project-card-stats/resources/published.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 6 additions & 0 deletions features/project-card-stats/resources/views.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
180 changes: 180 additions & 0 deletions features/project-card-stats/script.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
export default async function ({ feature }) {
const STATE_KEY = "__steProjectCardStatsState";
const ITEM_DEFINITIONS = [
{ settingId: "show-love", dataKey: "loves", label: "Love", iconName: "love-icon", type: "number" },
{ settingId: "show-fav", dataKey: "favorites", label: "Favorite", iconName: "fav-icon", type: "number" },
{ settingId: "show-view", dataKey: "views", label: "View", iconName: "views-icon", type: "number" },
{ settingId: "show-published", dataKey: "published", label: "Published", iconName: "published-icon", type: "date" },
];

const iconUrls = ITEM_DEFINITIONS.reduce((all, item) => {
all[item.settingId] = feature.self.getResource(item.iconName);
return all;
}, {});

if (!window[STATE_KEY]) window[STATE_KEY] = { optionVisibility: new Map(), observedFeatures: new WeakSet(), projectStats: new Map(), statsRequests: new Map(), rerender: null, };
const state = window[STATE_KEY];

function initializeOptionVisibility() {
for (const item of ITEM_DEFINITIONS) {
state.optionVisibility.set(item.settingId, feature.settings.get(item.settingId) || false);
}
}

function parseProjectStats(projectData) {
if (!projectData || typeof projectData !== "object") return null;
const projectId = projectData.id != null ? String(projectData.id) : null;
if (!projectId) return null;

function parseCount(value) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}

const publishedRaw = projectData.history && typeof projectData.history.shared === "string" ? projectData.history.shared : null;

return {
id: projectId,
loves: parseCount(projectData.stats && projectData.stats.loves),
favorites: parseCount(projectData.stats && projectData.stats.favorites),
views: parseCount(projectData.stats && projectData.stats.views),
published: publishedRaw ? publishedRaw.split("T")[0] : null,
};
}

async function fetchProjectStats(projectId) {
if (!projectId) return null;
if (state.projectStats.has(projectId)) return state.projectStats.get(projectId);
if (state.statsRequests.has(projectId)) return state.statsRequests.get(projectId);

const request = (async () => {
try {
const response = await fetch(`https://api.scratch.mit.edu/projects/${projectId}`);
if (!response.ok) {
state.projectStats.set(projectId, null);
return null;
}

const parsed = parseProjectStats(await response.json());
state.projectStats.set(projectId, parsed);
return parsed;
} catch {
state.projectStats.set(projectId, null);
return null;
} finally {
state.statsRequests.delete(projectId);
}
})();

state.statsRequests.set(projectId, request);
return request;
}

function getProjectIdFromCard(card) {
const link = card.querySelector("a.thumbnail-image, a.thumbnail-title, a[href*='/projects/']");
const href = link && typeof link.href === "string" ? link.href : "";
const match = href.match(/\/projects\/(\d+)/);
return match ? match[1] : null;
}

function formatNumber(value) {
if (!Number.isFinite(value)) return "-";
return value.toLocaleString();
}

function formatPublishedDate(value) {
if (!value || typeof value !== "string") return "-";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleDateString();
}

function ensureCardStatsRow(card) {
const creator = card.querySelector("div.thumbnail-creator");
if (!creator) return null;

let row = card.querySelector(".ste-project-card-stats-row");
if (!row) {
row = document.createElement("div");
row.className = "ste-project-card-stats-row";
creator.insertAdjacentElement("afterend", row);
}
return row;
}

function renderCardStats(card) {
const row = ensureCardStatsRow(card);
if (!row) return;

const projectId = getProjectIdFromCard(card);
const enabledItems = ITEM_DEFINITIONS.filter((item) => { return state.optionVisibility.get(item.settingId) });
const hasStats = projectId ? state.projectStats.has(projectId) : false;
const stats = hasStats ? state.projectStats.get(projectId) : null;

if (enabledItems.length === 0) {
row.replaceChildren();
row.style.display = "none";
return;
}

row.style.display = "flex";
row.replaceChildren();

for (const item of enabledItems) {
const statItem = document.createElement("span");
statItem.className = "ste-project-card-stat";

const icon = document.createElement("span");
icon.className = "ste-project-card-stat-icon";
icon.style.setProperty("--ste-project-card-icon-url", `url(\"${iconUrls[item.settingId]}\")`);

const value = document.createElement("span");
value.className = "ste-project-card-stat-value";
value.textContent = item.type === "date" ? formatPublishedDate(stats ? stats[item.dataKey] : null) : formatNumber(stats ? stats[item.dataKey] : NaN);

statItem.title = `${item.label}: ${value.textContent}`;
statItem.appendChild(icon);
statItem.appendChild(value);
row.appendChild(statItem);
}

if (projectId && !hasStats && !state.statsRequests.has(projectId)) {
fetchProjectStats(projectId).then(() => {
if (state.projectStats.has(projectId)) {
const rerender = state.rerender;
if (typeof rerender === "function") rerender();
}
});
}
}

function renderVisibleCards() {
if (state.optionVisibility.size === 0) initializeOptionVisibility();
document.querySelectorAll("#projectBox div.thumbnail.project").forEach(renderCardStats);
}

state.rerender = renderVisibleCards;
initializeOptionVisibility();

if (!state.observedFeatures.has(feature)) {
state.observedFeatures.add(feature);
feature.settings.addEventListener("changed", (event) => {
if (!event || typeof event !== "object") {
initializeOptionVisibility();
renderVisibleCards();
return;
}

const item = ITEM_DEFINITIONS.find((definition) => definition.settingId === event.key);
if (!item) return;

state.optionVisibility.set(event.key, event.value);
renderVisibleCards();
});
}

ScratchTools.waitForElements("#projectBox div.thumbnail.project", renderCardStats);
renderVisibleCards();

feature.addEventListener("enabled", () => renderVisibleCards());
}
38 changes: 38 additions & 0 deletions features/project-card-stats/style.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
.grid .thumbnail.project {
height: auto !important;
min-height: 208px;
}

#projectBox .grid .flex-row {
align-items: flex-start;
}

.ste-project-card-stats-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px 8px;
max-width: 100%;
margin-top: 2px;
font-size: 11px;
line-height: 1.3;
color: var(--color);
}

.ste-project-card-stat {
display: inline-flex;
align-items: center;
gap: 2px;
white-space: nowrap;
font-weight: 500;
line-height: normal;
}

.ste-project-card-stat-icon {
width: 0.82rem;
height: 0.82rem;
display: inline-block;
background-color: currentColor;
mask: var(--ste-project-card-icon-url) no-repeat center / contain;
flex-shrink: 0;
}