diff --git a/features/features.json b/features/features.json
index f7ff9c44..0a7c456d 100644
--- a/features/features.json
+++ b/features/features.json
@@ -1,4 +1,9 @@
[
+ {
+ "version": 2,
+ "id": "project-card-stats",
+ "versionAdded": "v4.3.0"
+ },
{
"version": 2,
"id": "real-date",
diff --git a/features/project-card-stats/data.json b/features/project-card-stats/data.json
new file mode 100644
index 00000000..57a9cb47
--- /dev/null
+++ b/features/project-card-stats/data.json
@@ -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" }
+ ]
+}
diff --git a/features/project-card-stats/resources/fav.svg b/features/project-card-stats/resources/fav.svg
new file mode 100644
index 00000000..663cbdbd
--- /dev/null
+++ b/features/project-card-stats/resources/fav.svg
@@ -0,0 +1,3 @@
+
\ No newline at end of file
diff --git a/features/project-card-stats/resources/love.svg b/features/project-card-stats/resources/love.svg
new file mode 100644
index 00000000..d6411c96
--- /dev/null
+++ b/features/project-card-stats/resources/love.svg
@@ -0,0 +1,3 @@
+
\ No newline at end of file
diff --git a/features/project-card-stats/resources/published.svg b/features/project-card-stats/resources/published.svg
new file mode 100644
index 00000000..633e9030
--- /dev/null
+++ b/features/project-card-stats/resources/published.svg
@@ -0,0 +1,3 @@
+
diff --git a/features/project-card-stats/resources/views.svg b/features/project-card-stats/resources/views.svg
new file mode 100644
index 00000000..9380686e
--- /dev/null
+++ b/features/project-card-stats/resources/views.svg
@@ -0,0 +1,6 @@
+
\ No newline at end of file
diff --git a/features/project-card-stats/script.js b/features/project-card-stats/script.js
new file mode 100644
index 00000000..43eb88bf
--- /dev/null
+++ b/features/project-card-stats/script.js
@@ -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());
+}
diff --git a/features/project-card-stats/style.css b/features/project-card-stats/style.css
new file mode 100644
index 00000000..6c8c1483
--- /dev/null
+++ b/features/project-card-stats/style.css
@@ -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;
+}