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; +}