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;