rework admin conviction menu

This commit is contained in:
Echo
2025-06-26 13:05:40 -04:00
parent cfae3443cb
commit b9a2649545
3 changed files with 166 additions and 143 deletions

View File

@@ -136,7 +136,7 @@ class Admin::TimelineController < Admin::BaseController
# For Stimulus: provide initial selected users with details
@initial_selected_user_objects = User.where(id: @selected_user_ids)
.select(:id, :username, :slack_username, :github_username, :slack_avatar_url, :github_avatar_url)
.map { |u| { id: u.id, display_name: "#{u.display_name} (ID: #{u.id})", avatar_url: u.avatar_url } }
.map { |u| { id: u.id, display_name: "#{u.display_name}", avatar_url: u.avatar_url } }
.sort_by { |u_obj| @selected_user_ids.index(u_obj[:id]) || Float::INFINITY } # Preserve order
@primary_user = @users_with_timeline_data.first&.[](:user) || current_user
@@ -181,7 +181,7 @@ class Admin::TimelineController < Admin::BaseController
if user_id_match
results = [ {
id: user_id_match.id,
display_name: "#{user_id_match.display_name} (ID: #{user_id_match.id})",
display_name: "#{user_id_match.display_name}",
avatar_url: user_id_match.avatar_url
} ]
else
@@ -193,7 +193,7 @@ class Admin::TimelineController < Admin::BaseController
results = users.map do |user|
{
id: user.id,
display_name: "#{user.display_name} (ID: #{user.id})",
display_name: "#{user.display_name}",
avatar_url: user.avatar_url
}
end
@@ -224,7 +224,7 @@ class Admin::TimelineController < Admin::BaseController
final_user_objects = []
# Add admin first
if admin_data = users_data[current_user.id]
final_user_objects << { id: admin_data.id, display_name: "#{admin_data.display_name} (ID: #{admin_data.id})", avatar_url: admin_data.avatar_url }
final_user_objects << { id: admin_data.id, display_name: "#{admin_data.display_name}", avatar_url: admin_data.avatar_url }
end
# Add leaderboard users, ensuring no duplicates and respecting limit
@@ -233,7 +233,7 @@ class Admin::TimelineController < Admin::BaseController
next if uid == current_user.id
if user_data = users_data[uid]
final_user_objects << { id: user_data.id, display_name: "#{user_data.display_name} (ID: #{user_data.id})", avatar_url: user_data.avatar_url }
final_user_objects << { id: user_data.id, display_name: "#{user_data.display_name}", avatar_url: user_data.avatar_url }
end
end

View File

@@ -15,7 +15,7 @@ function debounce(func, wait) {
}
export default class extends Controller {
static targets = ["searchInput", "searchResults", "selectedUsersContainer", "userIdsInput", "dateInput"]
static targets = ["searchInput", "searchResults", "selectedUsersContainer", "userIdsInput", "dateInput", "searchIcon", "searchSpinner"]
static values = {
currentUserJson: Object,
@@ -76,36 +76,39 @@ export default class extends Controller {
if (query.length < 2) {
this.searchResultsTarget.innerHTML = "";
this.searchResultsTarget.classList.remove('active');
this.hideSpinner();
return;
}
this.showSpinner();
const request = new FetchRequest('get', `${this.searchUrlValue}?query=${encodeURIComponent(query)}`, { responseKind: 'json' })
const response = await request.perform()
this.hideSpinner();
if (response.ok) {
const users = await response.json;
console.log(`Found ${users.length} users in search results`);
this.renderSearchResults(users);
} else {
this.searchResultsTarget.innerHTML = "<li class='px-4 py-2 text-red-400 text-sm'>Error searching users</li>";
this.searchResultsTarget.innerHTML = "<div class='px-4 py-2 text-red-400 text-sm'>Error searching users</div>";
this.searchResultsTarget.classList.add('active');
}
}
renderSearchResults(users) {
if (users.length === 0) {
this.searchResultsTarget.innerHTML = "<li class='px-4 py-2 text-gray-400 text-sm'>No users found</li>";
this.searchResultsTarget.innerHTML = "<div class='px-4 py-2 text-gray-400 text-sm'>No users found</div>";
} else {
this.searchResultsTarget.innerHTML = users.map(user => `
<li class="px-4 py-2 hover:bg-gray-700 cursor-pointer text-white text-sm flex items-center transition-colors"
<div class="mx-2 my-1 px-3 py-2 hover:bg-darkless cursor-pointer text-white text-sm flex items-center transition-colors rounded-lg border border-transparent hover:border-gray-600"
data-action="click->${this.identifier}#selectUser"
data-${this.identifier}-user-id-value="${user.id}"
data-${this.identifier}-user-display-name-value="${this.escapeHTML(user.display_name)}"
data-${this.identifier}-user-avatar-url-value="${user.avatar_url || ''}">
<img src="${user.avatar_url || 'https://via.placeholder.com/20'}" alt="${this.escapeHTML(user.display_name)}" class="w-5 h-5 rounded-full mr-3">
<span>${this.escapeHTML(user.display_name)}</span>
<span class="ml-auto text-xs text-gray-400">#${user.id}</span>
</li>
<span class="ml-auto text-xs text-gray-400 bg-gray-700 px-2 py-1 rounded-full">#${user.id}</span>
</div>
`).join("");
}
// Make sure the result list is shown
@@ -277,9 +280,65 @@ export default class extends Controller {
}
}
handleKeydown(event) {
async handleKeydown(event) {
if (event.key === "Escape") {
this.clearSearch();
} else if (event.key === "Enter") {
event.preventDefault();
await this.handle();
}
}
async handle() {
const query = this.searchInputTarget.value.trim();
if (/^\d+$/.test(query)) {
this.showSpinner();
try {
const request = new FetchRequest('get', `${this.searchUrlValue}?query=${encodeURIComponent(query)}&limit=1`, { responseKind: 'json' })
const response = await request.perform()
if (response.ok) {
const users = await response.json;
if (users.length > 0) {
const user = users[0];
if (!this.selectedUsers.has(parseInt(user.id, 10))) {
this.addUserToSelection(user, false);
this.updateHiddenInput();
this.updateDateLinks();
}
this.clearSearch();
this.hideSpinner();
return;
}
}
} catch (error) {
console.error(error);
}
this.hideSpinner();
}
if (query.length >= 2) {
this.showSpinner();
try {
const request = new FetchRequest('get', `${this.searchUrlValue}?query=${encodeURIComponent(query)}&limit=1`, { responseKind: 'json' })
const response = await request.perform()
if (response.ok) {
const users = await response.json;
if (users.length > 0) {
const user = users[0];
if (!this.selectedUsers.has(parseInt(user.id, 10))) {
this.addUserToSelection(user, false);
this.updateHiddenInput();
this.updateDateLinks();
}
this.clearSearch();
}
}
} catch (error) {
console.error(error);
}
this.hideSpinner();
}
}
@@ -298,6 +357,16 @@ export default class extends Controller {
}, 200);
}
showSpinner() {
if (this.hasSearchIconTarget) this.searchIconTarget.classList.add('hidden');
if (this.hasSearchSpinnerTarget) this.searchSpinnerTarget.classList.remove('hidden');
}
hideSpinner() {
if (this.hasSearchSpinnerTarget) this.searchSpinnerTarget.classList.add('hidden');
if (this.hasSearchIconTarget) this.searchIconTarget.classList.remove('hidden');
}
escapeHTML(str) {
if (str === null || str === undefined) return '';
return str.toString().replace(/[&<>"']/g, function (match) {

View File

@@ -30,6 +30,11 @@
current_date_for_form = @date.to_s
%>
<style>
@keyframes spin {to {transform: rotate(360deg);}}
.spinny {animation: spin 0.2s linear infinite;}
</style>
<div class=" text-white flex flex-col font-sans min-h-screen">
<div
data-controller="admin-timeline-user-selector"
@@ -45,17 +50,28 @@
<div class="flex items-center gap-3">
<div class="flex-1 relative">
<input type="text"
id="user-search-input"
placeholder="Add user by name/email/id..."
data-admin-timeline-user-selector-target="searchInput"
data-action="input->admin-timeline-user-selector#debouncedSearch keydown->admin-timeline-user-selector#handleKeydown focus->admin-timeline-user-selector#search blur->admin-timeline-user-selector#hideResultsDelayed"
autocomplete="off"
class="w-full px-3 py-2 bg-darker rounded-md text-white placeholder-gray-300 focus:outline-none focus:border-transparent text-sm">
<ul class="absolute top-full left-0 w-full bg-dark border border-gray-600 rounded-b-md z-50 max-h-48 overflow-y-auto hidden"
<div class="relative">
<div class="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400">
<svg data-admin-timeline-user-selector-target="searchIcon" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
</svg>
<svg data-admin-timeline-user-selector-target="searchSpinner" class="w-4 h-4 spinny hidden" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</div>
<input type="text"
id="user-search-input"
placeholder="Add user by name/email/id..."
data-admin-timeline-user-selector-target="searchInput"
data-action="input->admin-timeline-user-selector#debouncedSearch keydown->admin-timeline-user-selector#handleKeydown focus->admin-timeline-user-selector#search blur->admin-timeline-user-selector#hideResultsDelayed"
autocomplete="off"
class="w-full pl-10 pr-3 py-2 bg-darker rounded-md text-white placeholder-gray-300 focus:outline-none focus:border-transparent text-sm">
</div>
<div class="absolute top-full left-0 w-full bg-dark border border-gray-700 rounded-lg mt-1 z-50 max-h-48 overflow-y-auto hidden shadow-lg"
data-admin-timeline-user-selector-target="searchResults">
<%# Search results will appear here %>
</ul>
</div>
</div>
<button type="button"
class="px-3 py-2 bg-darker rounded-md text-sm text-white transition-colors"
@@ -141,7 +157,7 @@
end
%>
<div class="absolute border-r border-gray-700"
style="left: <%= column_left %>px; width: <%= min_column_width_px %>px; top: 120px; bottom: 0;">
style="left: <%= column_left %>px; width: <%= min_column_width_px %>px; top: 120px; bottom: 0; padding-bottom: 0;">
</div>
<div class="absolute top-0 p-3 rounded-lg shadow-lg <%= trust_level_bg %>"
data-user-id="<%= user.id %>"
@@ -170,8 +186,7 @@
</div>
<% if current_user && current_user.admin? && user != current_user %>
<div>
<button data-action="openModal"
data-user-id="<%= user.id %>"
<button onclick="setTrust(<%= user.id %>)"
class="text-xs text-gray-300 hover:text-white"
title="Set trust level">🔨</button>
</div>
@@ -323,129 +338,68 @@
<% end %>
</div>
<div id="trust-level-modal" class="fixed inset-0 items-center justify-center hidden z-50">
<div class="bg-darker rounded-lg shadow-xl max-w-sm w-full mx-4">
<div class="flex justify-between items-center p-4 border-b border-gray-800">
<h3 class="text-base font-semibold text-gray-100">Set Trust Level</h3>
<button id="close-modal" class="text-gray-400 hover:text-white text-xl">×</button>
</div>
<div class="p-5">
<div id="trust-level-status" class="hidden mb-4 p-3 rounded text-sm"></div>
<ul class="space-y-2">
<li>
<button class="trust-option w-full text-left px-3 py-2 rounded bg-darkless text-gray-100" data-level="green">
🟢 Green - Trusted
</button>
</li>
<li>
<button class="trust-option w-full text-left px-3 py-2 rounded bg-darkless text-gray-100" data-level="yellow">
🟡 Yellow - Suspected
</button>
</li>
<li>
<button class="trust-option w-full text-left px-3 py-2 rounded bg-darkless text-gray-100" data-level="red">
🔴 Red - Convicted (banned)
</button>
</li>
<li>
<button class="trust-option w-full text-left px-3 py-2 rounded bg-darkless text-gray-100" data-level="blue">
🔵 Blue - Unscored
</button>
</li>
</ul>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const modal = document.getElementById('trust-level-modal');
const closeModal = document.getElementById('close-modal');
const sdiv = document.getElementById('trust-level-status');
let currentUserId = null;
function report(message, type = 'i') {
sdiv.className = `mb-4 p-3 rounded-lg text-sm ${type === 's' ? 'bg-green-500/20 border border-green-500 text-green-300' :
type === 'e' ? 'bg-red-500/20 border border-red-500 text-red-300' :
'bg-blue-500/20 border border-blue-500 text-blue-300'}`;
sdiv.textContent = message;
sdiv.classList.remove('hidden');
}
function hide() {
sdiv.classList.add('hidden');
}
function pause() {
document.querySelectorAll('.trust-option').forEach(btn => btn.disabled = true);
}
function resume() {
document.querySelectorAll('.trust-option').forEach(btn => btn.disabled = false);
}
document.addEventListener('click', function(e) {
if (e.target.closest('[data-action="openModal"]')) {
e.preventDefault();
currentUserId = e.target.dataset.userId;
hide();
resume();
modal.classList.remove('hidden');
modal.style.display = 'flex';
}
});
function close() {
modal.classList.add('hidden');
modal.style.display = 'none';
hide();
resume();
}
closeModal.addEventListener('click', close);
modal.addEventListener('click', function(e) {
if (e.target === modal) {
close();
}
});
document.querySelectorAll('.trust-option').forEach(button => {
button.addEventListener('click', async function() {
const level = this.dataset.level;
if (!currentUserId || !level) return;
pause();
report('Updating trust level...', 'i');
try {
console.log("set trust", currentUserId, "to:", level);
const url = new URL(
`/users/${currentUserId}/update_trust_level`,
window.location.origin
);
const response = await fetch(url, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content
},
body: JSON.stringify({ trust_level: level })
});
const trusts = {
'green': { emoji: '🟢', name: 'Green - Trusted', bg: 'bg-green-500/20' },
'yellow': { emoji: '🟡', name: 'Yellow - Suspected', bg: 'bg-yellow-500/20' },
'red': { emoji: '🔴', name: 'Red - Convicted (banned)', bg: 'bg-red-500/20' },
'blue': { emoji: '🔵', name: 'Blue - Unscored', bg: 'bg-blue-500/20' },
'1': { emoji: '🟢', name: 'Green - Trusted', bg: 'bg-green-500/20', level: 'green' },
'2': { emoji: '🟡', name: 'Yellow - Suspected', bg: 'bg-yellow-500/20', level: 'yellow' },
'3': { emoji: '🔴', name: 'Red - Convicted (banned)', bg: 'bg-red-500/20', level: 'red' },
'4': { emoji: '🔵', name: 'Blue - Unscored', bg: 'bg-blue-500/20', level: 'blue' }
};
if (!response.ok) {
throw new Error(
`${response.status} ${response.statusText}`
);
}
report('done, reloading...', 's');
close();
location.reload();
} catch (error) {
console.error('you done did it', error);
report('check console', 'e');
resume();
}
window.setTrust = function(userId) {
const options = '🟢 Green (1) - Trusted\n🟡 Yellow (2) - Suspected\n🔴 Red (3) - Convicted (banned)\n🔵 Blue (4) - Unscored';
const input = prompt(`set the trust for ${userId}\n\n${options}\n\nenter number or color`);
if (!input) return;
const normalizedInput = input.toLowerCase().trim();
const trust = trusts[normalizedInput];
if (!trust) {
alert('read the popup idiot');
return;
}
const levelForAPI = trust.level || normalizedInput;
fetch(`/users/${userId}/update_trust_level`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content
},
body: JSON.stringify({ trust_level: levelForAPI })
})
.then(response => {
if (!response.ok) {
throw new Error(`${response.status} ${response.statusText}`);
}
return response.json();
})
.then(data => {
const card = Array.from(document.querySelectorAll('[data-user-id]')).find(card => {
const title = card.getAttribute('title') || '';
return title.match(new RegExp(`User ID:\\s*${userId}\\b`));
// what the fuck is this
});
if (card) {
card.classList.remove('bg-red-500/20', 'bg-green-500/20', 'bg-yellow-500/20', 'bg-blue-500/20', 'bg-gray-500/20');
card.classList.add(trust.bg);
const span = card.querySelector('span.text-sm');
if (span) {
span.textContent = trust.emoji;
}
}
alert(`set trust to ${trust.name}`);
})
.catch(error => {
console.error(error);
alert('shit ' + error.message);
});
});
};
</script>