mirror of
https://github.com/SrIzan10/hc-harbor.git
synced 2026-05-01 10:45:21 +00:00
Make users on calendar selectable
This commit is contained in:
@@ -12,29 +12,31 @@ class Admin::TimelineController < Admin::BaseController
|
|||||||
@next_date = @date + 1.day
|
@next_date = @date + 1.day
|
||||||
@prev_date = @date - 1.day
|
@prev_date = @date - 1.day
|
||||||
|
|
||||||
# Step 1: Consolidate User Loading
|
# User selection logic
|
||||||
# Note: current_user in an admin controller is the admin user.
|
raw_user_ids = params[:user_ids].present? ? params[:user_ids].split(',').map(&:to_i).uniq : []
|
||||||
# The original list of user_ids_to_fetch includes some hardcoded IDs.
|
|
||||||
# This logic is preserved but might need review for an admin-specific timeline.
|
|
||||||
user_ids_to_fetch = [
|
|
||||||
current_user&.id, # Admin's own data (if they are also a tracked user)
|
|
||||||
1, # Example: User.find(1) if it's relevant
|
|
||||||
10, 1792, 69, 1476, 805, 2003, 2011 # Original hardcoded IDs
|
|
||||||
].compact.uniq
|
|
||||||
|
|
||||||
|
# Always include current_user (admin)
|
||||||
|
@selected_user_ids = [current_user.id] + raw_user_ids
|
||||||
|
@selected_user_ids.uniq!
|
||||||
|
|
||||||
|
user_ids_to_fetch = @selected_user_ids
|
||||||
|
|
||||||
|
# Fetch all valid users in one go
|
||||||
users_by_id = User.where(id: user_ids_to_fetch).index_by(&:id)
|
users_by_id = User.where(id: user_ids_to_fetch).index_by(&:id)
|
||||||
|
# Ensure we only use IDs of users that actually exist
|
||||||
|
valid_user_ids_to_fetch = users_by_id.keys
|
||||||
|
|
||||||
mappings_by_user_project = ProjectRepoMapping.where(user_id: users_by_id.keys)
|
mappings_by_user_project = ProjectRepoMapping.where(user_id: valid_user_ids_to_fetch)
|
||||||
.group_by(&:user_id)
|
.group_by(&:user_id)
|
||||||
.transform_values { |mappings| mappings.index_by(&:project_name) }
|
.transform_values { |mappings| mappings.index_by(&:project_name) }
|
||||||
|
|
||||||
users_to_process = user_ids_to_fetch.map { |id| users_by_id[id] }.compact
|
users_to_process = valid_user_ids_to_fetch.map { |id| users_by_id[id] }.compact
|
||||||
|
|
||||||
start_of_day_timestamp = @date.beginning_of_day.to_f
|
start_of_day_timestamp = @date.beginning_of_day.to_f
|
||||||
end_of_day_timestamp = @date.end_of_day.to_f
|
end_of_day_timestamp = @date.end_of_day.to_f
|
||||||
|
|
||||||
all_heartbeats = Heartbeat
|
all_heartbeats = Heartbeat
|
||||||
.where(user_id: user_ids_to_fetch, deleted_at: nil)
|
.where(user_id: valid_user_ids_to_fetch, deleted_at: nil)
|
||||||
.where('time >= ? AND time <= ?', start_of_day_timestamp, end_of_day_timestamp)
|
.where('time >= ? AND time <= ?', start_of_day_timestamp, end_of_day_timestamp)
|
||||||
.select(:id, :user_id, :time, :entity, :project, :editor, :language)
|
.select(:id, :user_id, :time, :entity, :project, :editor, :language)
|
||||||
.order(:user_id, :time)
|
.order(:user_id, :time)
|
||||||
@@ -42,7 +44,7 @@ class Admin::TimelineController < Admin::BaseController
|
|||||||
|
|
||||||
heartbeats_by_user_id = all_heartbeats.group_by(&:user_id)
|
heartbeats_by_user_id = all_heartbeats.group_by(&:user_id)
|
||||||
|
|
||||||
@users_with_timeline_data = []
|
@users_with_timeline_data_unordered = []
|
||||||
|
|
||||||
users_to_process.each do |user|
|
users_to_process.each do |user|
|
||||||
user_daily_heartbeats_relation = Heartbeat.where(user_id: user.id, deleted_at: nil)
|
user_daily_heartbeats_relation = Heartbeat.where(user_id: user.id, deleted_at: nil)
|
||||||
@@ -96,17 +98,85 @@ class Admin::TimelineController < Admin::BaseController
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
if calculated_spans_with_details.any? || total_coded_time_seconds > 0
|
# Add user data, even if no spans/time, if they were explicitly selected
|
||||||
@users_with_timeline_data << {
|
@users_with_timeline_data_unordered << {
|
||||||
user: user,
|
user: user,
|
||||||
spans: calculated_spans_with_details,
|
spans: calculated_spans_with_details,
|
||||||
total_coded_time: total_coded_time_seconds # Actual coded time for the user for the day
|
total_coded_time: total_coded_time_seconds # Actual coded time for the user for the day
|
||||||
}
|
}
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
@primary_user = users_to_process.first || current_user # current_user is the admin
|
# Order @users_with_timeline_data according to @selected_user_ids
|
||||||
|
# This ensures that if a user was explicitly selected they appear in the timeline
|
||||||
|
# even if they have no heartbeats for the day.
|
||||||
|
data_map_for_ordering = @users_with_timeline_data_unordered.index_by { |data| data[:user].id }
|
||||||
|
@users_with_timeline_data = @selected_user_ids.map do |id|
|
||||||
|
data_map_for_ordering[id] || (users_by_id[id] ? { user: users_by_id[id], spans: [], total_coded_time: 0 } : nil)
|
||||||
|
end.compact
|
||||||
|
|
||||||
|
# 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, 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
|
||||||
|
|
||||||
render :show # Renders app/views/admin/timeline/show.html.erb
|
render :show # Renders app/views/admin/timeline/show.html.erb
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def search_users
|
||||||
|
query_term = params[:query].to_s.downcase
|
||||||
|
users = User.where("LOWER(username) LIKE :query OR LOWER(slack_username) LIKE :query OR EXISTS (SELECT 1 FROM email_addresses WHERE email_addresses.user_id = users.id AND LOWER(email_addresses.email) LIKE :query)", query: "%#{query_term}%")
|
||||||
|
.order(Arel.sql("CASE WHEN LOWER(username) = #{ActiveRecord::Base.connection.quote(query_term)} THEN 0 ELSE 1 END, username ASC")) # Prioritize exact match
|
||||||
|
.limit(20)
|
||||||
|
.select(:id, :username, :slack_username, :github_username, :slack_avatar_url, :github_avatar_url)
|
||||||
|
|
||||||
|
results = users.map do |user|
|
||||||
|
{
|
||||||
|
id: user.id,
|
||||||
|
display_name: user.display_name,
|
||||||
|
avatar_url: user.avatar_url
|
||||||
|
}
|
||||||
|
end
|
||||||
|
render json: results
|
||||||
|
end
|
||||||
|
|
||||||
|
def leaderboard_users
|
||||||
|
period = params[:period]
|
||||||
|
limit = 25
|
||||||
|
|
||||||
|
leaderboard_period_type = (period == 'last_7_days') ? :last_7_days : :daily
|
||||||
|
start_date = Date.current # Leaderboard job for :last_7_days uses Date.current as start_date
|
||||||
|
|
||||||
|
leaderboard = Leaderboard.where.not(finished_generating_at: nil)
|
||||||
|
.find_by(start_date: start_date, period_type: leaderboard_period_type, deleted_at: nil)
|
||||||
|
|
||||||
|
user_ids_from_leaderboard = leaderboard ? leaderboard.entries.order(total_seconds: :desc).limit(limit).pluck(:user_id) : []
|
||||||
|
|
||||||
|
all_ids_to_fetch = user_ids_from_leaderboard.dup
|
||||||
|
all_ids_to_fetch.unshift(current_user.id).uniq!
|
||||||
|
|
||||||
|
users_data = User.where(id: all_ids_to_fetch)
|
||||||
|
.select(:id, :username, :slack_username, :github_username, :slack_avatar_url, :github_avatar_url)
|
||||||
|
.index_by(&:id)
|
||||||
|
|
||||||
|
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, avatar_url: admin_data.avatar_url }
|
||||||
|
end
|
||||||
|
|
||||||
|
# Add leaderboard users, ensuring no duplicates and respecting limit
|
||||||
|
user_ids_from_leaderboard.each do |uid|
|
||||||
|
break if final_user_objects.size >= limit
|
||||||
|
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, avatar_url: user_data.avatar_url }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
render json: { users: final_user_objects }
|
||||||
|
end
|
||||||
end
|
end
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
import { Controller } from "@hotwired/stimulus"
|
||||||
|
import { FetchRequest } from '@rails/request.js'
|
||||||
|
|
||||||
|
// Helper for debouncing
|
||||||
|
function debounce(func, wait) {
|
||||||
|
let timeout;
|
||||||
|
return function executedFunction(...args) {
|
||||||
|
const later = () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
func(...args);
|
||||||
|
};
|
||||||
|
clearTimeout(timeout);
|
||||||
|
timeout = setTimeout(later, wait);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class extends Controller {
|
||||||
|
static targets = ["searchInput", "searchResults", "selectedUsersContainer", "userIdsInput", "dateInput"]
|
||||||
|
|
||||||
|
static values = {
|
||||||
|
currentUserJson: Object,
|
||||||
|
initialSelectedUsersJson: Array,
|
||||||
|
searchUrl: String,
|
||||||
|
leaderboardUsersUrl: String
|
||||||
|
}
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
this.selectedUsers = new Map(); // Stores selected users {id: userObject}
|
||||||
|
this._debouncedSearchImpl = debounce(this.search.bind(this), 300);
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log("Initializing timeline user selector");
|
||||||
|
|
||||||
|
// Parse the JSON if it's a string
|
||||||
|
let adminUser = this.currentUserJsonValue;
|
||||||
|
if (typeof adminUser === 'string') {
|
||||||
|
adminUser = JSON.parse(adminUser);
|
||||||
|
console.log("Parsed admin user:", adminUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (adminUser && adminUser.id) {
|
||||||
|
// Ensure ID is a number
|
||||||
|
adminUser.id = parseInt(adminUser.id, 10);
|
||||||
|
this.addUserToSelection(adminUser, true); // true = isPillForAdmin
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the JSON if it's a string
|
||||||
|
let initialUsers = this.initialSelectedUsersJsonValue;
|
||||||
|
if (typeof initialUsers === 'string') {
|
||||||
|
initialUsers = JSON.parse(initialUsers);
|
||||||
|
console.log("Parsed initial users:", initialUsers);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make sure it's an array
|
||||||
|
if (Array.isArray(initialUsers)) {
|
||||||
|
initialUsers.forEach(user => {
|
||||||
|
// Ensure ID is a number
|
||||||
|
if (user && user.id) {
|
||||||
|
user.id = parseInt(user.id, 10);
|
||||||
|
if (!adminUser || user.id !== adminUser.id) {
|
||||||
|
this.addUserToSelection(user, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateHiddenInput();
|
||||||
|
this.updateDateLinks();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error initializing user selector:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async search() {
|
||||||
|
const query = this.searchInputTarget.value;
|
||||||
|
if (query.length < 2) {
|
||||||
|
this.searchResultsTarget.innerHTML = "";
|
||||||
|
this.searchResultsTarget.classList.remove('active');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = new FetchRequest('get', `${this.searchUrlValue}?query=${encodeURIComponent(query)}`, { responseKind: 'json' })
|
||||||
|
const response = await request.perform()
|
||||||
|
|
||||||
|
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='list-group-item list-group-item-light disabled'>Error searching users</li>";
|
||||||
|
this.searchResultsTarget.classList.add('active');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderSearchResults(users) {
|
||||||
|
if (users.length === 0) {
|
||||||
|
this.searchResultsTarget.innerHTML = "<li class='list-group-item list-group-item-light disabled'>No users found</li>";
|
||||||
|
} else {
|
||||||
|
this.searchResultsTarget.innerHTML = users.map(user => `
|
||||||
|
<li class="list-group-item list-group-item-action list-group-item-dark"
|
||||||
|
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="avatar-xs me-2 rounded-circle">${this.escapeHTML(user.display_name)}
|
||||||
|
</li>
|
||||||
|
`).join("");
|
||||||
|
}
|
||||||
|
// Make sure the result list is shown
|
||||||
|
this.searchResultsTarget.style.display = 'block';
|
||||||
|
this.searchResultsTarget.classList.add('active');
|
||||||
|
console.log("Search results rendered and active class added");
|
||||||
|
}
|
||||||
|
|
||||||
|
selectUser(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
// Get values directly from data attributes
|
||||||
|
const element = event.currentTarget;
|
||||||
|
const userId = parseInt(element.getAttribute(`data-${this.identifier}-user-id-value`), 10);
|
||||||
|
const displayName = element.getAttribute(`data-${this.identifier}-user-display-name-value`);
|
||||||
|
const avatarUrl = element.getAttribute(`data-${this.identifier}-user-avatar-url-value`);
|
||||||
|
|
||||||
|
console.log("Selected user data:", { userId, displayName, avatarUrl });
|
||||||
|
|
||||||
|
if (isNaN(userId)) {
|
||||||
|
console.error("Invalid user ID");
|
||||||
|
this.clearSearch();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.selectedUsers.has(userId)) {
|
||||||
|
this.clearSearch();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = {
|
||||||
|
id: userId,
|
||||||
|
display_name: displayName,
|
||||||
|
avatar_url: avatarUrl
|
||||||
|
};
|
||||||
|
|
||||||
|
this.addUserToSelection(user, false);
|
||||||
|
|
||||||
|
this.clearSearch();
|
||||||
|
this.updateHiddenInput();
|
||||||
|
this.updateDateLinks();
|
||||||
|
}
|
||||||
|
|
||||||
|
addUserToSelection(user, isPillForAdmin = false) {
|
||||||
|
// Make sure we have a valid user ID
|
||||||
|
if (!user || !user.id || isNaN(parseInt(user.id, 10))) {
|
||||||
|
console.error("Invalid user object or ID:", user);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert ID to number just to be safe
|
||||||
|
const userId = parseInt(user.id, 10);
|
||||||
|
user.id = userId;
|
||||||
|
|
||||||
|
if (this.selectedUsers.has(userId)) return;
|
||||||
|
|
||||||
|
console.log("Adding user to selection:", user);
|
||||||
|
this.selectedUsers.set(userId, user);
|
||||||
|
|
||||||
|
const pill = document.createElement("span");
|
||||||
|
// Using Pico.css friendly classes if available, or simple badges
|
||||||
|
pill.classList.add("badge", "secondary", "me-1", "mb-1", "d-inline-flex", "align-items-center", "user-pill");
|
||||||
|
pill.style.backgroundColor = isPillForAdmin ? 'var(--pico-primary-focus)' : 'var(--pico-muted-border-color)';
|
||||||
|
pill.style.color = isPillForAdmin ? 'var(--pico-primary-inverse)' : 'var(--pico-color)';
|
||||||
|
pill.style.padding = '0.3em 0.6em';
|
||||||
|
pill.style.borderRadius = 'var(--pico-border-radius)';
|
||||||
|
|
||||||
|
pill.dataset.userId = userId;
|
||||||
|
|
||||||
|
let avatarImg = '';
|
||||||
|
if (user.avatar_url) {
|
||||||
|
avatarImg = `<img src="${user.avatar_url}" alt="${this.escapeHTML(user.display_name)}" class="avatar-xxs me-1 rounded-circle">`;
|
||||||
|
}
|
||||||
|
|
||||||
|
pill.innerHTML = `${avatarImg} ${this.escapeHTML(user.display_name)}`;
|
||||||
|
|
||||||
|
if (!isPillForAdmin) {
|
||||||
|
const removeButton = document.createElement("button");
|
||||||
|
removeButton.type = "button";
|
||||||
|
removeButton.classList.add("btn-close-custom", "ms-1"); // Custom class for styling
|
||||||
|
removeButton.innerHTML = "×"; // Simple 'x'
|
||||||
|
removeButton.dataset.action = `click->${this.identifier}#removeUser`;
|
||||||
|
removeButton.setAttribute("aria-label", "Remove");
|
||||||
|
pill.appendChild(removeButton);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.selectedUsersContainerTarget.appendChild(pill);
|
||||||
|
}
|
||||||
|
|
||||||
|
removeUser(event) {
|
||||||
|
const pill = event.target.closest(".user-pill");
|
||||||
|
const userId = parseInt(pill.dataset.userId, 10);
|
||||||
|
|
||||||
|
console.log("Removing user with ID:", userId);
|
||||||
|
|
||||||
|
// Don't allow removing if it's the current user or an invalid ID
|
||||||
|
if (isNaN(userId) || (this.currentUserJsonValue && userId === this.currentUserJsonValue.id)) return;
|
||||||
|
|
||||||
|
this.selectedUsers.delete(userId);
|
||||||
|
pill.remove();
|
||||||
|
this.updateHiddenInput();
|
||||||
|
this.updateDateLinks();
|
||||||
|
}
|
||||||
|
|
||||||
|
async applyPreset(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
const period = event.currentTarget.dataset.period;
|
||||||
|
|
||||||
|
const request = new FetchRequest('get', `${this.leaderboardUsersUrlValue}?period=${period}`, { responseKind: 'json' })
|
||||||
|
const response = await request.perform()
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json;
|
||||||
|
const presetUsers = data.users;
|
||||||
|
|
||||||
|
this.selectedUsersContainerTarget.querySelectorAll('.user-pill').forEach(pill => {
|
||||||
|
const uid = parseInt(pill.dataset.userId);
|
||||||
|
if (uid !== this.currentUserJsonValue.id) {
|
||||||
|
pill.remove();
|
||||||
|
this.selectedUsers.delete(uid);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
presetUsers.forEach(user => {
|
||||||
|
if (user.id !== this.currentUserJsonValue.id) {
|
||||||
|
this.addUserToSelection(user, false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.updateHiddenInput();
|
||||||
|
this.updateDateLinks();
|
||||||
|
} else {
|
||||||
|
console.error("Failed to load preset users");
|
||||||
|
alert("Could not load preset users. Please try again.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateHiddenInput() {
|
||||||
|
this.userIdsInputTarget.value = Array.from(this.selectedUsers.keys()).join(',');
|
||||||
|
}
|
||||||
|
|
||||||
|
updateDateLinks() {
|
||||||
|
const selectedIds = Array.from(this.selectedUsers.keys()).join(',');
|
||||||
|
|
||||||
|
document.querySelectorAll('a[data-date-nav-link="true"]').forEach(link => {
|
||||||
|
const url = new URL(link.href, window.location.origin);
|
||||||
|
if (selectedIds) {
|
||||||
|
url.searchParams.set('user_ids', selectedIds);
|
||||||
|
} else {
|
||||||
|
url.searchParams.delete('user_ids');
|
||||||
|
}
|
||||||
|
link.href = url.toString();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
submitForm() {
|
||||||
|
const form = this.element.querySelector('form#timeline-filter-form');
|
||||||
|
if (form) {
|
||||||
|
if(this.hasDateInputTarget && form.elements.date) {
|
||||||
|
form.elements.date.value = this.dateInputTarget.value;
|
||||||
|
}
|
||||||
|
form.requestSubmit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleKeydown(event) {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
this.clearSearch();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clearSearch() {
|
||||||
|
this.searchInputTarget.value = "";
|
||||||
|
this.searchResultsTarget.innerHTML = "";
|
||||||
|
this.searchResultsTarget.classList.remove('active');
|
||||||
|
this.searchResultsTarget.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
hideResultsDelayed() {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!this.searchResultsTarget.matches(':hover')) {
|
||||||
|
this.clearSearch();
|
||||||
|
}
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
escapeHTML(str) {
|
||||||
|
if (str === null || str === undefined) return '';
|
||||||
|
return str.toString().replace(/[&<>"']/g, function (match) {
|
||||||
|
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[match];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// This is just a wrapper to satisfy Stimulus data-action format
|
||||||
|
// The actual debounced search implementation is stored in _debouncedSearchImpl
|
||||||
|
debouncedSearch() {
|
||||||
|
this._debouncedSearchImpl();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,21 +43,77 @@
|
|||||||
# Add fixed spacers for the header:
|
# Add fixed spacers for the header:
|
||||||
total_min_scroll_width_px = (activity_col_area_start_rem * 16) + min_header_content_width_px + (activity_col_area_end_rem * 16)
|
total_min_scroll_width_px = (activity_col_area_start_rem * 16) + min_header_content_width_px + (activity_col_area_end_rem * 16)
|
||||||
|
|
||||||
# No longer using flex-based width calculation for headers
|
# Current admin user and selected users for Stimulus
|
||||||
# css_header_item_width = "calc((100% - #{(num_users > 1 ? (num_users - 1) * gutter_rem : 0)}rem) / #{num_users})"
|
current_admin_user = {
|
||||||
|
id: current_user.id,
|
||||||
|
display_name: current_user.display_name,
|
||||||
|
avatar_url: current_user.avatar_url
|
||||||
|
}
|
||||||
|
current_admin_user_json = current_admin_user.to_json
|
||||||
|
|
||||||
|
initial_selected_users_json = @initial_selected_user_objects.to_json
|
||||||
|
current_date_for_form = @date.to_s
|
||||||
%>
|
%>
|
||||||
|
|
||||||
<div style="background-color: #1F2937; color: #FFFFFF; padding: 1rem; border-radius: 0.5rem; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; display: flex; flex-direction: column; height: calc(100vh - 8rem);">
|
<div style="background-color: #1F2937; color: #FFFFFF; padding: 1rem; border-radius: 0.5rem; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; display: flex; flex-direction: column; height: calc(100vh - 8rem);">
|
||||||
|
|
||||||
|
<!-- User Selector UI -->
|
||||||
|
<div
|
||||||
|
data-controller="admin-timeline-user-selector"
|
||||||
|
data-admin-timeline-user-selector-current-user-json-value='<%= current_admin_user_json %>'
|
||||||
|
data-admin-timeline-user-selector-initial-selected-users-json-value='<%= initial_selected_users_json %>'
|
||||||
|
data-admin-timeline-user-selector-search-url-value="<%= admin_timeline_search_users_path %>"
|
||||||
|
data-admin-timeline-user-selector-leaderboard-users-url-value="<%= admin_timeline_leaderboard_users_path %>"
|
||||||
|
style="margin-bottom: 1rem; padding: 0.75rem; background-color: #2D3748; border-radius: 0.375rem; flex-shrink: 0;"
|
||||||
|
class="user-selector-compact"
|
||||||
|
>
|
||||||
|
<form id="timeline-filter-form" action="<%= admin_timeline_path %>" method="get" data-turbo-frame="_top">
|
||||||
|
<input type="hidden" name="user_ids" data-admin-timeline-user-selector-target="userIdsInput">
|
||||||
|
<input type="hidden" name="date" value="<%= current_date_for_form %>" data-admin-timeline-user-selector-target="dateInput">
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
<div style="grid-column: span 7;">
|
||||||
|
<label for="user-search-input" class="visually-hidden">Add User</label>
|
||||||
|
<div style="position: relative;">
|
||||||
|
<input type="text"
|
||||||
|
id="user-search-input"
|
||||||
|
placeholder="Add user by name/email..."
|
||||||
|
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"
|
||||||
|
style="font-size: 0.875rem; padding: 0.35rem 0.5rem; margin-bottom: 0; width: 100%;">
|
||||||
|
<ul class="list-group position-absolute w-100"
|
||||||
|
data-admin-timeline-user-selector-target="searchResults"
|
||||||
|
style="z-index: 1050; max-height: 200px; overflow-y: auto; list-style-type: none; padding-left: 0; margin-top: 0; width: 100%; background-color: #1A202C; border: 1px solid #4A5568; border-top: none; border-radius: 0 0 0.25rem 0.25rem; display: none;">
|
||||||
|
<%# Search results will appear here %>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="grid-column: span 5; display: flex; align-items: flex-end; justify-content: flex-end; gap: 0.5rem;">
|
||||||
|
<div class="btn-group" role="group" style="display:contents;">
|
||||||
|
<button type="button" class="secondary outline" data-action="admin-timeline-user-selector#applyPreset" data-period="today" style="font-size: 0.8rem; padding: 0.35rem 0.5rem; margin-bottom: 0;">Top Today</button>
|
||||||
|
<button type="button" class="secondary outline" data-action="admin-timeline-user-selector#applyPreset" data-period="last_7_days" style="font-size: 0.8rem; padding: 0.35rem 0.5rem; margin-bottom: 0;">Top 7 Days</button>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="primary" style="font-size: 0.8rem; padding: 0.35rem 0.75rem; margin-bottom: 0;">View</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-2" data-admin-timeline-user-selector-target="selectedUsersContainer" style="margin-top: 0.5rem; min-height: 28px;">
|
||||||
|
<%# Selected user pills will appear here %>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Date Navigation (remains the same) -->
|
<!-- Date Navigation (remains the same) -->
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; flex-shrink: 0;">
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; flex-shrink: 0;">
|
||||||
<div style="font-size: 1.125rem; line-height: 1.75rem; font-weight: 600;">
|
<div style="font-size: 1.125rem; line-height: 1.75rem; font-weight: 600;">
|
||||||
<%= @date.in_time_zone(primary_user_tz).strftime("%A, %B %-d, %Y") %>
|
<%= @date.in_time_zone(primary_user_tz).strftime("%A, %B %-d, %Y") %>
|
||||||
</div>
|
</div>
|
||||||
<div style="display: flex; gap: 0.5rem;">
|
<div style="display: flex; gap: 0.5rem;">
|
||||||
<%= link_to "← Prev", admin_timeline_path(date: @prev_date.to_s), class: "button button-outline" %>
|
<%= link_to "← Prev", admin_timeline_path(date: @prev_date.to_s), class: "button button-outline", data: { "date-nav-link": "true" } %>
|
||||||
<%= link_to "Today", admin_timeline_path(date: Time.current.to_date.to_s), class: "button button-outline" %>
|
<%= link_to "Today", admin_timeline_path(date: Time.current.to_date.to_s), class: "button button-outline", data: { "date-nav-link": "true" } %>
|
||||||
<%= link_to "Next →", admin_timeline_path(date: @next_date.to_s), class: "button button-outline" %>
|
<%= link_to "Next →", admin_timeline_path(date: @next_date.to_s), class: "button button-outline", data: { "date-nav-link": "true" } %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -238,3 +294,52 @@
|
|||||||
</div> <!-- End .admin-timeline-content-sizer -->
|
</div> <!-- End .admin-timeline-content-sizer -->
|
||||||
</div> <!-- End .admin-timeline-view-wrapper -->
|
</div> <!-- End .admin-timeline-view-wrapper -->
|
||||||
</div> <!-- End Main Page Container -->
|
</div> <!-- End Main Page Container -->
|
||||||
|
|
||||||
|
<% content_for :head do %>
|
||||||
|
<style>
|
||||||
|
.user-selector-compact .form-label-sm { font-size: 0.75rem; margin-bottom: 0.25rem; }
|
||||||
|
.user-selector-compact input[type="text"] { font-size: 0.875rem; padding: 0.35rem 0.5rem; margin-bottom: 0; }
|
||||||
|
.user-selector-compact button { font-size: 0.8rem; padding: 0.35rem 0.5rem; margin-bottom: 0; }
|
||||||
|
|
||||||
|
.user-selector-compact .list-group-item {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
padding: 0.4rem 0.75rem; /* Increased padding for easier clicking */
|
||||||
|
cursor: pointer;
|
||||||
|
background-color: #1A202C; /* Dark background for items */
|
||||||
|
color: #E2E8F0; /* Light text color */
|
||||||
|
border-bottom: 1px solid #2D3748; /* Separator */
|
||||||
|
}
|
||||||
|
.user-selector-compact .list-group.position-absolute {
|
||||||
|
top: 100%; left: 0;
|
||||||
|
border: 1px solid #4A5568; border-top: none;
|
||||||
|
border-radius: 0 0 0.25rem 0.25rem;
|
||||||
|
display: none !important; /* Hidden by default */
|
||||||
|
}
|
||||||
|
.user-selector-compact .list-group.active {
|
||||||
|
display: block !important;
|
||||||
|
}
|
||||||
|
.user-selector-compact .list-group-item:hover { background-color: #4A5568; }
|
||||||
|
.user-selector-compact .list-group-item.disabled { color: #A0AEC0; background-color: #2D3748; }
|
||||||
|
|
||||||
|
.user-selector-compact .avatar-xs { width: 20px; height: 20px; border-radius: 50%; vertical-align: middle; }
|
||||||
|
.user-selector-compact .avatar-xxs { width: 16px; height: 16px; border-radius: 50%; vertical-align: middle; }
|
||||||
|
|
||||||
|
.user-selector-compact .user-pill {
|
||||||
|
padding: 0.3em 0.6em; font-size: 0.8rem;
|
||||||
|
display: inline-flex; align-items: center;
|
||||||
|
border-radius: var(--pico-border-radius);
|
||||||
|
margin-right: 0.25rem; margin-bottom: 0.25rem; /* Ensure pills wrap nicely */
|
||||||
|
}
|
||||||
|
.user-selector-compact .user-pill .btn-close-custom {
|
||||||
|
background: none; border: none; color: inherit;
|
||||||
|
padding: 0 0.25rem; margin-left: 0.4rem;
|
||||||
|
font-size: 1rem; line-height: 1;
|
||||||
|
cursor: pointer; opacity: 0.7;
|
||||||
|
}
|
||||||
|
.user-selector-compact .user-pill .btn-close-custom:hover { opacity: 1; }
|
||||||
|
.visually-hidden {
|
||||||
|
border: 0; clip: rect(0 0 0 0); height: 1px; margin: -1px;
|
||||||
|
overflow: hidden; padding: 0; position: absolute; width: 1px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<% end %>
|
||||||
@@ -5,3 +5,4 @@ pin "@hotwired/turbo-rails", to: "turbo.min.js"
|
|||||||
pin "@hotwired/stimulus", to: "stimulus.min.js"
|
pin "@hotwired/stimulus", to: "stimulus.min.js"
|
||||||
pin "@hotwired/stimulus-loading", to: "stimulus-loading.js"
|
pin "@hotwired/stimulus-loading", to: "stimulus-loading.js"
|
||||||
pin_all_from "app/javascript/controllers", under: "controllers"
|
pin_all_from "app/javascript/controllers", under: "controllers"
|
||||||
|
pin "@rails/request.js", to: "https://ga.jspm.io/npm:@rails/request.js@0.0.8/src/index.js"
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ Rails.application.routes.draw do
|
|||||||
|
|
||||||
namespace :admin do
|
namespace :admin do
|
||||||
get 'timeline', to: 'timeline#show', as: :timeline
|
get 'timeline', to: 'timeline#show', as: :timeline
|
||||||
|
get 'timeline/search_users', to: 'timeline#search_users'
|
||||||
|
get 'timeline/leaderboard_users', to: 'timeline#leaderboard_users'
|
||||||
end
|
end
|
||||||
|
|
||||||
if Rails.env.development?
|
if Rails.env.development?
|
||||||
|
|||||||
4
vendor/javascript/@rails--request.js.js
vendored
Normal file
4
vendor/javascript/@rails--request.js.js
vendored
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user