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
);