diff --git a/public/css/panel.css b/public/css/panel.css index 2de7f81..bf76e56 100644 --- a/public/css/panel.css +++ b/public/css/panel.css @@ -188,50 +188,122 @@ gap: var(--space-2); } -/* Community rating / vote */ +/* Community rating */ #panel-rating-section { display: flex; flex-direction: column; gap: var(--space-2); } -#panel-vote-row { +#panel-rating-row { display: flex; align-items: center; gap: var(--space-3); } -.vote-btn { +#star-rating { + display: flex; + align-items: center; + gap: 2px; +} + +.star-zero-btn { background: none; - border: 1px solid var(--color-border); - border-radius: var(--radius-sm); - padding: var(--space-1) var(--space-2); - font-size: 18px; + 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; - transition: background 150ms ease, border-color 150ms ease; + display: flex; + align-items: center; + justify-content: center; + margin-right: 2px; + transition: color 100ms ease, border-color 100ms ease, transform 100ms ease; } -.vote-btn:hover { - background: var(--color-bg); - border-color: var(--color-text-secondary); +.star-zero-btn:hover { + transform: scale(1.15); + color: #e8960f; + border-color: #e8960f; } -.vote-btn.active { - background: var(--color-accent); - border-color: var(--color-accent); +.star-zero-btn.active { + color: #e8960f; + border-color: #e8960f; + background: rgba(232, 150, 15, 0.1); } -.vote-score { - font-size: var(--font-size-lg); - font-weight: bold; - min-width: 2ch; - text-align: center; +.star-pos { + position: relative; + display: inline-block; + cursor: pointer; } -.vote-score.positive { color: #2d9e2d; } -.vote-score.negative { color: #c84040; } -.vote-score.zero { color: var(--color-text-secondary); } +.star-icon { + font-size: 22px; + line-height: 1; + color: var(--color-text-secondary); + pointer-events: none; + transition: color 100ms ease, transform 100ms ease; +} + +.star-pos:hover .star-icon { + transform: scale(1.15); +} + +.star-icon.filled { + color: #f5a623; +} + +.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); + white-space: nowrap; +} /* Footer (sticky, always visible) */ #panel-footer { diff --git a/public/index.html b/public/index.html index 9fc8790..c566702 100644 --- a/public/index.html +++ b/public/index.html @@ -81,7 +81,37 @@
Community Rating - +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
diff --git a/public/js/arf.js b/public/js/arf.js index a30e5a2..4b08769 100644 --- a/public/js/arf.js +++ b/public/js/arf.js @@ -673,81 +673,133 @@ document.addEventListener("DOMContentLoaded", function() { // Canvas click: close panel when clicking the SVG background (not a node) // This is wired after svgEl is created (see below in the zoom setup area). -// === Community Voting (THE-109) === +// === Community Rating (THE-122) === /** - * Render the vote UI for the given node. - * Reads cached vote state from sessionStorage to avoid a round-trip on reopen, - * then asynchronously fetches the live score 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; - // Reset button states - var upBtn = document.getElementById("vote-up"); - var downBtn = document.getElementById("vote-down"); - var scoreEl = document.getElementById("vote-score"); - if (!upBtn || !downBtn || !scoreEl) return; + 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 (!starPositions.length || !avgEl) return; - upBtn.classList.remove("active"); - downBtn.classList.remove("active"); - scoreEl.className = "vote-score zero"; - scoreEl.textContent = "…"; + // Reset state + _applyStarFill(starPositions, zeroBtn, 0, null); + avgEl.textContent = "\u2026"; + if (ratingSection) ratingSection.classList.remove("empty"); - // Read cached user vote from sessionStorage - var userVote = sessionStorage.getItem("vote:" + toolId) || null; - if (userVote === "up") upBtn.classList.add("active"); - if (userVote === "down") downBtn.classList.add("active"); + // Read cached user rating from sessionStorage + var cached = sessionStorage.getItem("rating:" + toolId); + var userRating = cached !== null ? parseFloat(cached) : null; + if (userRating !== null) _applyStarFill(starPositions, zeroBtn, userRating, userRating); - // Re-bind vote buttons for this tool - upBtn.onclick = function() { _castVote(toolId, "up", upBtn, downBtn, scoreEl); }; - downBtn.onclick = function() { _castVote(toolId, "down", upBtn, downBtn, scoreEl); }; + // 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); + }; + } - // Fetch live score asynchronously + // 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, val, userRating, starPositions, zeroBtn, avgEl); + }; + }); + + // Fetch live average asynchronously fetch("/api/tool-stats?tool_id=" + encodeURIComponent(toolId)) .then(function(r) { return r.ok ? r.json() : null; }) .then(function(data) { - if (!data || !data.votes) return; - _updateScoreDisplay(scoreEl, data.votes.score); + if (!data || !data.ratings) return; + _updateRatingDisplay(avgEl, data.ratings.average, data.ratings.count); }) .catch(function() { /* best effort */ }); } /** - * Cast or toggle a vote. + * 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 _castVote(toolId, direction, upBtn, downBtn, scoreEl) { - var session = sessionStorage.getItem("osint-session") || ""; - var currentVote = sessionStorage.getItem("vote:" + toolId) || null; +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; - // Toggle off if same direction - var newDirection = (direction === currentVote) ? null : direction; + 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 rating (0-5, 0.5 increments). + * Returns the new userRating value (number or null). + */ +function _castRating(toolId, value, currentRating, starPositions, zeroBtn, avgEl) { + var session = sessionStorage.getItem("osint-session") || ""; + // Toggle off if clicking the same value + var newRating = (value === currentRating) ? null : value; fetch("/api/vote", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ tool_id: toolId, direction: newDirection, session_hash: session }) + body: JSON.stringify({ tool_id: toolId, rating: newRating, session_hash: session }) }) .then(function(r) { return r.ok ? r.json() : null; }) .then(function(data) { if (!data || !data.ok) return; - // Update sessionStorage - if (data.userVote) { - sessionStorage.setItem("vote:" + toolId, data.userVote); + var saved = data.userRating; + if (saved !== null && saved !== undefined) { + sessionStorage.setItem("rating:" + toolId, String(saved)); } else { - sessionStorage.removeItem("vote:" + toolId); + saved = null; + sessionStorage.removeItem("rating:" + toolId); } - // Update button states - upBtn.classList.toggle("active", data.userVote === "up"); - downBtn.classList.toggle("active", data.userVote === "down"); - _updateScoreDisplay(scoreEl, data.score); + _applyStarFill(starPositions, zeroBtn, saved !== null ? saved : -1, saved); + _updateRatingDisplay(avgEl, data.average, data.count); }) .catch(function() { /* best effort */ }); + + return newRating; } -function _updateScoreDisplay(scoreEl, score) { - scoreEl.textContent = score > 0 ? "+" + score : String(score); - scoreEl.className = "vote-score " + (score > 0 ? "positive" : score < 0 ? "negative" : "zero"); +function _updateRatingDisplay(avgEl, average, count) { + if (!count || count === 0) { + avgEl.textContent = "No ratings yet"; + return; + } + avgEl.textContent = average.toFixed(1) + " (" + count + ")"; } // === Issue Reporting (THE-110) === diff --git a/src/worker.js b/src/worker.js index 0af9783..3d7d6b7 100644 --- a/src/worker.js +++ b/src/worker.js @@ -4,7 +4,7 @@ * API routes (all others fall through to static assets): * POST /api/track – fire-and-forget click tracking * GET /api/tool-stats – per-tool click + vote counts - * POST /api/vote – community upvote / downvote + * POST /api/vote – community 5-star rating * POST /api/report – flag dead link / paywalled / incorrect info * * KV binding: CLICK_DATA (configured in wrangler.jsonc + Pages dashboard) @@ -15,7 +15,7 @@ * - No cookies used or set * - session_hash is client-generated and ephemeral (sessionStorage) * - Rate-limit key TTL: 2 min (60 req/min ceiling per session_hash) - * - Vote dedup: permanent per session (sessionStorage already limits scope) + * - Rating dedup: permanent per session (sessionStorage already limits scope) * - Report dedup key TTL: 7 days (prevents same session from inflating counts) */ @@ -134,10 +134,10 @@ async function handleTrack(request, env) { /** * POST /api/vote - * Body: { tool_id: string, direction: "up" | "down" | null, session_hash: string } - * direction=null removes the current vote (toggle off) + * Body: { tool_id: string, rating: 0|0.5|1|...|5|null, session_hash: string } + * rating=null removes the current rating (toggle off) * - * Returns: { ok: true, score: number, userVote: "up" | "down" | null } + * Returns: { ok: true, average: number, count: number, userRating: number|null } */ async function handleVote(request, env) { const origin = request.headers.get("Origin") || ""; @@ -149,68 +149,75 @@ async function handleVote(request, env) { return jsonResponse({ ok: false, error: "invalid json" }, 400, origin); } - const { tool_id, direction, session_hash } = body; + const { tool_id, rating, session_hash } = body; const validationError = validateCommon(tool_id, session_hash); if (validationError) { return jsonResponse({ ok: false, error: validationError }, 400, origin); } - if (direction !== "up" && direction !== "down" && direction !== null) { - return jsonResponse( - { ok: false, error: "direction must be 'up', 'down', 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)) { return jsonResponse({ ok: false, error: "rate limited" }, 429, origin); } - const userVoteKey = `uservote:${session_hash}:${tool_id}`; - const upKey = `votes:up:${tool_id}`; - const downKey = `votes:down:${tool_id}`; + const userRatingKey = `rating:${session_hash}:${tool_id}`; + const sumKey = `ratings:sum:${tool_id}`; + const countKey = `ratings:count:${tool_id}`; // Read current state in parallel - const [prevVoteRaw, upRaw, downRaw] = await Promise.all([ - env.CLICK_DATA.get(userVoteKey), - env.CLICK_DATA.get(upKey), - env.CLICK_DATA.get(downKey), + const [prevRatingRaw, sumRaw, countRaw] = await Promise.all([ + env.CLICK_DATA.get(userRatingKey), + env.CLICK_DATA.get(sumKey), + env.CLICK_DATA.get(countKey), ]); - const prevVote = prevVoteRaw; // "up", "down", or null - let upCount = upRaw ? parseInt(upRaw, 10) : 0; - let downCount = downRaw ? parseInt(downRaw, 10) : 0; + const prevRating = prevRatingRaw ? parseFloat(prevRatingRaw) : null; + let sum = sumRaw ? parseFloat(sumRaw) : 0; + let count = countRaw ? parseInt(countRaw, 10) : 0; - // Determine the new vote: - // If same direction is sent again, treat as toggle-off (remove vote) - let newVote = direction; - if (direction !== null && direction === prevVote) { - newVote = null; // toggle off + // Toggle off if same rating submitted again + let newRating = rating; + if (rating !== null && rating === prevRating) { + newRating = null; } - // Undo previous vote - if (prevVote === "up") upCount = Math.max(0, upCount - 1); - if (prevVote === "down") downCount = Math.max(0, downCount - 1); + // Undo previous rating + if (prevRating !== null) { + sum = Math.max(0, sum - prevRating); + count = Math.max(0, count - 1); + } - // Apply new vote - if (newVote === "up") upCount++; - if (newVote === "down") downCount++; + // Apply new rating + if (newRating !== null) { + sum += newRating; + count++; + } // Persist const writes = [ - env.CLICK_DATA.put(upKey, String(upCount)), - env.CLICK_DATA.put(downKey, String(downCount)), + env.CLICK_DATA.put(sumKey, String(sum)), + env.CLICK_DATA.put(countKey, String(count)), ]; - if (newVote === null) { - writes.push(env.CLICK_DATA.delete(userVoteKey)); + if (newRating === null) { + writes.push(env.CLICK_DATA.delete(userRatingKey)); } else { - writes.push(env.CLICK_DATA.put(userVoteKey, newVote)); + writes.push(env.CLICK_DATA.put(userRatingKey, String(newRating))); } await Promise.all(writes); + const average = count > 0 ? Math.round((sum / count) * 10) / 10 : 0; return jsonResponse( - { ok: true, score: upCount - downCount, userVote: newVote }, + { ok: true, average, count, userRating: newRating }, 200, origin ); @@ -339,7 +346,7 @@ async function createGitHubIssue(env, tool_id, report_type, count) { /** * GET /api/tool-stats?tool_id= * - * Returns: { tool_id: string, clicks: number, votes: { up: number, down: number, score: number } } + * Returns: { tool_id: string, clicks: number, ratings: { average: number, count: number } } */ async function handleStats(request, env) { const origin = request.headers.get("Origin") || ""; @@ -350,18 +357,19 @@ async function handleStats(request, env) { return jsonResponse({ ok: false, error: "invalid tool_id" }, 400, origin); } - const [clicksRaw, upRaw, downRaw] = await Promise.all([ + const [clicksRaw, sumRaw, countRaw] = await Promise.all([ env.CLICK_DATA.get(`clicks:${tool_id}`), - env.CLICK_DATA.get(`votes:up:${tool_id}`), - env.CLICK_DATA.get(`votes:down:${tool_id}`), + env.CLICK_DATA.get(`ratings:sum:${tool_id}`), + env.CLICK_DATA.get(`ratings:count:${tool_id}`), ]); const clicks = clicksRaw ? parseInt(clicksRaw, 10) : 0; - const up = upRaw ? parseInt(upRaw, 10) : 0; - const down = downRaw ? parseInt(downRaw, 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; return jsonResponse( - { tool_id, clicks, votes: { up, down, score: up - down } }, + { tool_id, clicks, ratings: { average, count } }, 200, origin );