mirror of
https://github.com/System-End/hackatime.git
synced 2026-04-19 16:38:23 +00:00
Make users on calendar selectable
This commit is contained in:
parent
6efdd05be9
commit
9d35bd3ad3
6 changed files with 513 additions and 27 deletions
|
|
@ -12,29 +12,31 @@ class Admin::TimelineController < Admin::BaseController
|
|||
@next_date = @date + 1.day
|
||||
@prev_date = @date - 1.day
|
||||
|
||||
# Step 1: Consolidate User Loading
|
||||
# Note: current_user in an admin controller is the admin user.
|
||||
# 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
|
||||
# User selection logic
|
||||
raw_user_ids = params[:user_ids].present? ? params[:user_ids].split(',').map(&:to_i).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)
|
||||
# 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)
|
||||
.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
|
||||
end_of_day_timestamp = @date.end_of_day.to_f
|
||||
|
||||
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)
|
||||
.select(:id, :user_id, :time, :entity, :project, :editor, :language)
|
||||
.order(:user_id, :time)
|
||||
|
|
@ -42,7 +44,7 @@ class Admin::TimelineController < Admin::BaseController
|
|||
|
||||
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|
|
||||
user_daily_heartbeats_relation = Heartbeat.where(user_id: user.id, deleted_at: nil)
|
||||
|
|
@ -96,17 +98,85 @@ class Admin::TimelineController < Admin::BaseController
|
|||
end
|
||||
end
|
||||
|
||||
if calculated_spans_with_details.any? || total_coded_time_seconds > 0
|
||||
@users_with_timeline_data << {
|
||||
user: user,
|
||||
spans: calculated_spans_with_details,
|
||||
total_coded_time: total_coded_time_seconds # Actual coded time for the user for the day
|
||||
}
|
||||
end
|
||||
# Add user data, even if no spans/time, if they were explicitly selected
|
||||
@users_with_timeline_data_unordered << {
|
||||
user: user,
|
||||
spans: calculated_spans_with_details,
|
||||
total_coded_time: total_coded_time_seconds # Actual coded time for the user for the day
|
||||
}
|
||||
end
|
||||
|
||||
# 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
|
||||
|
||||
@primary_user = users_to_process.first || current_user # current_user is the admin
|
||||
# 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
|
||||
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
|
||||
|
|
@ -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:
|
||||
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
|
||||
# css_header_item_width = "calc((100% - #{(num_users > 1 ? (num_users - 1) * gutter_rem : 0)}rem) / #{num_users})"
|
||||
# Current admin user and selected users for Stimulus
|
||||
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);">
|
||||
|
||||
<!-- 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) -->
|
||||
<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;">
|
||||
<%= @date.in_time_zone(primary_user_tz).strftime("%A, %B %-d, %Y") %>
|
||||
</div>
|
||||
<div style="display: flex; gap: 0.5rem;">
|
||||
<%= link_to "← Prev", admin_timeline_path(date: @prev_date.to_s), class: "button button-outline" %>
|
||||
<%= link_to "Today", admin_timeline_path(date: Time.current.to_date.to_s), class: "button button-outline" %>
|
||||
<%= link_to "Next →", admin_timeline_path(date: @next_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", data: { "date-nav-link": "true" } %>
|
||||
<%= link_to "Next →", admin_timeline_path(date: @next_date.to_s), class: "button button-outline", data: { "date-nav-link": "true" } %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -237,4 +293,53 @@
|
|||
</div> <!-- End #timeline-grid-scroll-container -->
|
||||
</div> <!-- End .admin-timeline-content-sizer -->
|
||||
</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-loading", to: "stimulus-loading.js"
|
||||
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
|
||||
get 'timeline', to: 'timeline#show', as: :timeline
|
||||
get 'timeline/search_users', to: 'timeline#search_users'
|
||||
get 'timeline/leaderboard_users', to: 'timeline#leaderboard_users'
|
||||
end
|
||||
|
||||
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
Loading…
Add table
Reference in a new issue