mirror of
https://github.com/lockfale/OSINT-Framework.git
synced 2026-09-07 10:17:45 +02:00
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:
+94
-22
@@ -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
@@ -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">★</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">★</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">★</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">★</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">★</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">…</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
+93
-41
@@ -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
@@ -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
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user