Merge pull request #605 from lockfale/feature/THE-122-community-rating

Replace thumbs up/down voting with 5-star community rating (THE-122)
This commit is contained in:
s0lray
2026-03-27 01:09:06 -04:00
committed by GitHub
4 changed files with 272 additions and 110 deletions
+94 -22
View File
@@ -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 {
+31 -1
View File
@@ -81,7 +81,37 @@
<div id="panel-rating-section" class="empty">
<span class="panel-section-label">Community Rating</span>
<span id="panel-rating"></span>
<div id="panel-rating-row">
<div id="star-rating">
<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>
</div>
</div>
+93 -41
View File
@@ -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) ===
+54 -46
View File
@@ -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=<name>
*
* 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
);