diff --git a/public/css/panel.css b/public/css/panel.css
index 1db8a02..bf76e56 100644
--- a/public/css/panel.css
+++ b/public/css/panel.css
@@ -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);
diff --git a/public/index.html b/public/index.html
index baaf730..c566702 100644
--- a/public/index.html
+++ b/public/index.html
@@ -83,11 +83,32 @@
Community Rating
-
-
-
-
-
+
+
+ ★
+
+
+
+
+ ★
+
+
+
+
+ ★
+
+
+
+
+ ★
+
+
+
+
+ ★
+
+
+
…
diff --git a/public/js/arf.js b/public/js/arf.js
index 7a2dca3..4b08769 100644
--- a/public/js/arf.js
+++ b/public/js/arf.js
@@ -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 */ });
diff --git a/src/worker.js b/src/worker.js
index bd724ee..ac2d93a 100644
--- a/src/worker.js
+++ b/src/worker.js
@@ -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;