mirror of
https://github.com/lockfale/OSINT-Framework.git
synced 2026-08-17 19:35:41 +02:00
Add half-star and 0-star community ratings (THE-122)
Replace integer-only 1-5 stars with 0-5 scale in 0.5 increments. Each star has left/right click zones for half/full values, plus a dedicated 0-star button. Backend validation updated to accept the new range. Half-filled stars rendered via CSS linear-gradient on background-clip: text. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
+77
-7
@@ -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);
|
||||
|
||||
+26
-5
@@ -83,11 +83,32 @@
|
||||
<span class="panel-section-label">Community Rating</span>
|
||||
<div id="panel-rating-row">
|
||||
<div id="star-rating">
|
||||
<button class="star-btn" data-star="1" aria-label="Rate 1 star">★</button>
|
||||
<button class="star-btn" data-star="2" aria-label="Rate 2 stars">★</button>
|
||||
<button class="star-btn" data-star="3" aria-label="Rate 3 stars">★</button>
|
||||
<button class="star-btn" data-star="4" aria-label="Rate 4 stars">★</button>
|
||||
<button class="star-btn" data-star="5" aria-label="Rate 5 stars">★</button>
|
||||
<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>
|
||||
|
||||
+60
-29
@@ -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 */ });
|
||||
|
||||
+12
-9
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user