Add half-star and 0-star community ratings (THE-122)

Replace integer-only 1-5 stars with 0-5 scale in 0.5 increments.
Each star has left/right click zones for half/full values, plus a
dedicated 0-star button. Backend validation updated to accept the
new range. Half-filled stars rendered via CSS linear-gradient on
background-clip: text.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
s0lray
2026-03-26 23:01:21 -04:00
co-authored by Paperclip
parent 4b1510a8af
commit b50c831d1e
4 changed files with 175 additions and 50 deletions
+77 -7
View File
@@ -203,32 +203,102 @@
#star-rating {
display: flex;
align-items: center;
gap: 2px;
}
.star-btn {
.star-zero-btn {
background: none;
border: none;
padding: 2px;
font-size: 22px;
border: 1px solid var(--color-text-secondary);
border-radius: 50%;
width: 20px;
height: 20px;
font-size: 11px;
font-weight: 600;
cursor: pointer;
color: var(--color-text-secondary);
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
margin-right: 2px;
transition: color 100ms ease, border-color 100ms ease, transform 100ms ease;
}
.star-zero-btn:hover {
transform: scale(1.15);
color: #e8960f;
border-color: #e8960f;
}
.star-zero-btn.active {
color: #e8960f;
border-color: #e8960f;
background: rgba(232, 150, 15, 0.1);
}
.star-pos {
position: relative;
display: inline-block;
cursor: pointer;
}
.star-icon {
font-size: 22px;
line-height: 1;
color: var(--color-text-secondary);
pointer-events: none;
transition: color 100ms ease, transform 100ms ease;
}
.star-btn:hover {
.star-pos:hover .star-icon {
transform: scale(1.15);
}
.star-btn.filled {
.star-icon.filled {
color: #f5a623;
}
.star-btn.user-rated {
.star-icon.half-filled {
background: linear-gradient(to right, #f5a623 50%, var(--color-text-secondary) 50%);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
.star-icon.user-rated {
color: #e8960f;
}
.star-icon.user-rated.half-filled {
background: linear-gradient(to right, #e8960f 50%, var(--color-text-secondary) 50%);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
.star-click {
position: absolute;
top: 0;
height: 100%;
background: none;
border: none;
cursor: pointer;
opacity: 0;
padding: 0;
margin: 0;
}
.star-click.star-left {
left: 0;
width: 50%;
}
.star-click.star-right {
left: 50%;
width: 50%;
}
#rating-avg {
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
+26 -5
View File
@@ -83,11 +83,32 @@
<span class="panel-section-label">Community Rating</span>
<div id="panel-rating-row">
<div id="star-rating">
<button class="star-btn" data-star="1" aria-label="Rate 1 star">&#9733;</button>
<button class="star-btn" data-star="2" aria-label="Rate 2 stars">&#9733;</button>
<button class="star-btn" data-star="3" aria-label="Rate 3 stars">&#9733;</button>
<button class="star-btn" data-star="4" aria-label="Rate 4 stars">&#9733;</button>
<button class="star-btn" data-star="5" aria-label="Rate 5 stars">&#9733;</button>
<button class="star-zero-btn" data-value="0" aria-label="Rate 0 stars" title="0 stars">0</button>
<span class="star-pos" data-star="1">
<span class="star-icon">&#9733;</span>
<button class="star-click star-left" data-value="0.5" aria-label="Rate 0.5 stars"></button>
<button class="star-click star-right" data-value="1" aria-label="Rate 1 star"></button>
</span>
<span class="star-pos" data-star="2">
<span class="star-icon">&#9733;</span>
<button class="star-click star-left" data-value="1.5" aria-label="Rate 1.5 stars"></button>
<button class="star-click star-right" data-value="2" aria-label="Rate 2 stars"></button>
</span>
<span class="star-pos" data-star="3">
<span class="star-icon">&#9733;</span>
<button class="star-click star-left" data-value="2.5" aria-label="Rate 2.5 stars"></button>
<button class="star-click star-right" data-value="3" aria-label="Rate 3 stars"></button>
</span>
<span class="star-pos" data-star="4">
<span class="star-icon">&#9733;</span>
<button class="star-click star-left" data-value="3.5" aria-label="Rate 3.5 stars"></button>
<button class="star-click star-right" data-value="4" aria-label="Rate 4 stars"></button>
</span>
<span class="star-pos" data-star="5">
<span class="star-icon">&#9733;</span>
<button class="star-click star-left" data-value="4.5" aria-label="Rate 4.5 stars"></button>
<button class="star-click star-right" data-value="5" aria-label="Rate 5 stars"></button>
</span>
</div>
<span id="rating-avg">&#8230;</span>
</div>
+60 -29
View File
@@ -676,35 +676,51 @@ document.addEventListener("DOMContentLoaded", function() {
// === Community Rating (THE-122) ===
/**
* Render the 5-star rating UI for the given node.
* Reads cached rating from sessionStorage, then fetches live average from /api/tool-stats.
* Render the half-star rating UI for the given node.
* Supports 0 to 5 in 0.5 increments. Reads cached rating from sessionStorage,
* then fetches live average from /api/tool-stats.
*/
function _renderVoteUI(d) {
var toolId = parseName(d.data.name).cleanName;
var stars = document.querySelectorAll("#star-rating .star-btn");
var starPositions = document.querySelectorAll("#star-rating .star-pos");
var zeroBtn = document.querySelector("#star-rating .star-zero-btn");
var avgEl = document.getElementById("rating-avg");
var ratingSection = document.getElementById("panel-rating-section");
if (!stars.length || !avgEl) return;
if (!starPositions.length || !avgEl) return;
// Reset state
_applyStarFill(stars, 0, null);
_applyStarFill(starPositions, zeroBtn, 0, null);
avgEl.textContent = "\u2026";
if (ratingSection) ratingSection.classList.remove("empty");
// Read cached user rating from sessionStorage
var cached = sessionStorage.getItem("rating:" + toolId);
var userRating = cached ? parseInt(cached, 10) : null;
if (userRating) _applyStarFill(stars, userRating, userRating);
var userRating = cached !== null ? parseFloat(cached) : null;
if (userRating !== null) _applyStarFill(starPositions, zeroBtn, userRating, userRating);
// Wire hover and click events for this tool
Array.prototype.forEach.call(stars, function(btn) {
var n = parseInt(btn.getAttribute("data-star"), 10);
// Wire hover and click for zero button
if (zeroBtn) {
zeroBtn.onmouseenter = function() { _applyStarFill(starPositions, zeroBtn, 0, userRating); };
zeroBtn.onmouseleave = function() {
_applyStarFill(starPositions, zeroBtn, userRating !== null ? userRating : -1, userRating);
};
zeroBtn.onclick = function() {
userRating = _castRating(toolId, 0, userRating, starPositions, zeroBtn, avgEl);
};
}
btn.onmouseenter = function() { _applyStarFill(stars, n, userRating); };
btn.onmouseleave = function() { _applyStarFill(stars, userRating || 0, userRating); };
// Wire hover and click for each half-star click zone
var clickZones = document.querySelectorAll("#star-rating .star-click");
Array.prototype.forEach.call(clickZones, function(btn) {
var val = parseFloat(btn.getAttribute("data-value"));
btn.onmouseenter = function() { _applyStarFill(starPositions, zeroBtn, val, userRating); };
btn.onmouseleave = function() {
_applyStarFill(starPositions, zeroBtn, userRating !== null ? userRating : -1, userRating);
};
btn.onclick = function() {
userRating = _castRating(toolId, n, userRating, stars, avgEl);
userRating = _castRating(toolId, val, userRating, starPositions, zeroBtn, avgEl);
};
});
@@ -719,27 +735,41 @@ function _renderVoteUI(d) {
}
/**
* Apply filled/user-rated CSS classes to the star buttons.
* @param {NodeList} stars
* @param {number} fillUpTo - highlight stars 1 through fillUpTo
* @param {number|null} userRating - the user's current saved rating (shown distinctly)
* Apply filled/half-filled/user-rated classes to star icons.
* @param {NodeList} starPositions - .star-pos elements
* @param {Element|null} zeroBtn - the 0-star button
* @param {number} fillUpTo - rating value to highlight up to (0-5, supports 0.5 steps; -1 = nothing)
* @param {number|null} userRating - the user's saved rating (shown distinctly)
*/
function _applyStarFill(stars, fillUpTo, userRating) {
Array.prototype.forEach.call(stars, function(btn) {
var n = parseInt(btn.getAttribute("data-star"), 10);
btn.classList.toggle("filled", n <= fillUpTo);
btn.classList.toggle("user-rated", userRating !== null && n <= userRating && n <= fillUpTo);
function _applyStarFill(starPositions, zeroBtn, fillUpTo, userRating) {
if (zeroBtn) {
zeroBtn.classList.toggle("active", userRating !== null && userRating === 0 && fillUpTo === 0);
}
Array.prototype.forEach.call(starPositions, function(pos) {
var n = parseInt(pos.getAttribute("data-star"), 10);
var icon = pos.querySelector(".star-icon");
if (!icon) return;
var isUserStar = userRating !== null && fillUpTo === userRating;
icon.classList.remove("filled", "half-filled", "user-rated");
if (fillUpTo >= n) {
icon.classList.add("filled");
if (isUserStar) icon.classList.add("user-rated");
} else if (fillUpTo >= n - 0.5) {
icon.classList.add("half-filled");
if (isUserStar) icon.classList.add("user-rated");
}
});
}
/**
* Cast or toggle a star rating.
* Cast or toggle a rating (0-5, 0.5 increments).
* Returns the new userRating value (number or null).
*/
function _castRating(toolId, star, currentRating, stars, avgEl) {
function _castRating(toolId, value, currentRating, starPositions, zeroBtn, avgEl) {
var session = sessionStorage.getItem("osint-session") || "";
// Toggle off if clicking the same star
var newRating = (star === currentRating) ? null : star;
// Toggle off if clicking the same value
var newRating = (value === currentRating) ? null : value;
fetch("/api/vote", {
method: "POST",
@@ -749,13 +779,14 @@ function _castRating(toolId, star, currentRating, stars, avgEl) {
.then(function(r) { return r.ok ? r.json() : null; })
.then(function(data) {
if (!data || !data.ok) return;
var saved = data.userRating || null;
if (saved) {
var saved = data.userRating;
if (saved !== null && saved !== undefined) {
sessionStorage.setItem("rating:" + toolId, String(saved));
} else {
saved = null;
sessionStorage.removeItem("rating:" + toolId);
}
_applyStarFill(stars, saved || 0, saved);
_applyStarFill(starPositions, zeroBtn, saved !== null ? saved : -1, saved);
_updateRatingDisplay(avgEl, data.average, data.count);
})
.catch(function() { /* best effort */ });
+12 -9
View File
@@ -155,12 +155,15 @@ async function handleVote(request, env) {
return jsonResponse({ ok: false, error: validationError }, 400, origin);
}
if (rating !== null && (!Number.isInteger(rating) || rating < 1 || rating > 5)) {
return jsonResponse(
{ ok: false, error: "rating must be an integer 1-5 or null" },
400,
origin
);
if (rating !== null) {
var validRatings = [0, 0.5, 1, 1.5, 2, 2.5, 3, 3.5, 4, 4.5, 5];
if (typeof rating !== "number" || validRatings.indexOf(rating) === -1) {
return jsonResponse(
{ ok: false, error: "rating must be 0-5 in 0.5 increments, or null" },
400,
origin
);
}
}
if (await isRateLimited(env, session_hash)) {
@@ -178,8 +181,8 @@ async function handleVote(request, env) {
env.CLICK_DATA.get(countKey),
]);
const prevRating = prevRatingRaw ? parseInt(prevRatingRaw, 10) : null;
let sum = sumRaw ? parseInt(sumRaw, 10) : 0;
const prevRating = prevRatingRaw ? parseFloat(prevRatingRaw) : null;
let sum = sumRaw ? parseFloat(sumRaw) : 0;
let count = countRaw ? parseInt(countRaw, 10) : 0;
// Toggle off if same rating submitted again
@@ -361,7 +364,7 @@ async function handleStats(request, env) {
]);
const clicks = clicksRaw ? parseInt(clicksRaw, 10) : 0;
const sum = sumRaw ? parseInt(sumRaw, 10) : 0;
const sum = sumRaw ? parseFloat(sumRaw) : 0;
const count = countRaw ? parseInt(countRaw, 10) : 0;
const average = count > 0 ? Math.round((sum / count) * 10) / 10 : 0;