Merge pull request #353 from hackclub/setting-refresh

settings redesign
This commit is contained in:
Echo
2025-06-24 16:26:36 -04:00
committed by GitHub
10 changed files with 654 additions and 1112 deletions

View File

@@ -1,4 +1,4 @@
# Hackatime!
# Hackatime
[![Ping](https://status.hackatime.hackclub.com/api/badge/1/ping)](https://status.hackatime.hackclub.com/status/hackatime)
[![Status](https://status.hackatime.hackclub.com/api/badge/1/status)](https://status.hackatime.hackclub.com/status/hackatime)
@@ -48,9 +48,9 @@ app# bin/rails c # start an interactive irb!
app# bin/rails db:migrate # migrate the database
```
You can now access the app at http://localhost:3000/
You can now access the app at <http://localhost:3000/>
Use email authentication from the homepage with `test@example.com` or create a new user (you can view outgoing emails at http://localhost:3000/letter_opener)!
Use email authentication from the homepage with `test@example.com` or create a new user (you can view outgoing emails at [http://localhost:3000/letter_opener](http://localhost:3000/letter_opener))!
Ever need to setup a new database?

View File

@@ -1,248 +0,0 @@
.admin-timeline-view-wrapper {
overflow-x: auto; /* Enables horizontal scrolling */
width: 100%; /* Takes full width of its parent in the main layout */
-webkit-overflow-scrolling: touch; /* Smooth scrolling on iOS */
flex-grow: 1; /* Allow it to take available vertical space in flex layouts */
display: flex; /* Needed for flex-grow to work properly with its child */
flex-direction: column;
}
.admin-timeline-content-sizer {
/* min-width is set inline dynamically via ERB based on number of users and min_column_width_px */
display: block; /* Or inline-block; block should be fine to ensure it respects min-width */
/* This element will be wider than its parent if content requires it, triggering scroll on admin-timeline-view-wrapper */
}
.admin-timeline-sticky-header {
/* Changed structure to use simple absolute positioning, no more flex layout */
position: sticky; /* Sticky to the top of admin-timeline-view-wrapper */
top: 0;
z-index: 20; /* Above other content */
background-color: #1F2937; /* Match page background to avoid transparency issues */
padding-bottom: 0.5rem;
padding-top: 0.25rem;
box-sizing: border-box;
/* No padding left/right as we use absolute positioning for the headers */
}
/* Removed header spacer and container styles as they're no longer used */
.admin-timeline-user-header-cell {
position: absolute; /* Positioned absolutely within the sticky header container */
/* left is set inline per instance - using the EXACT same calculation as activity spans */
top: 0; /* All headers start at the top of the container */
min-width: 186px; /* Minimum width for each header cell */
width: 186px; /* Fixed width set inline to match span columns exactly */
box-sizing: border-box; /* Include padding and border in width/height */
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
font-weight: 500;
color: #E5E7EB;
font-size: 0.875rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding: 0.1rem 0.25rem; /* Padding for content */
}
#timeline-grid-scroll-container { /* Original ID, holds hour rows and activity spans */
overflow-y: auto; /* Retains vertical scroll for hours */
position: relative; /* For absolute positioning of spans and current time line */
flex-grow: 1; /* Takes remaining vertical space within admin-timeline-content-sizer */
/* Width is implicitly 100% of admin-timeline-content-sizer, which can be very wide */
}
/* Span items representing user activity */
.admin-timeline-span-item {
position: absolute;
/* background-color is set by --span-color CSS variable, applied inline */
color: #FFFFFF;
border-radius: 0.25rem;
font-size: 0.75rem;
line-height: 1.1;
padding: 2px 4px;
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
z-index: 10; /* Above grid lines */
overflow: hidden; /* Clip content that overflows */
box-sizing: border-box;
min-width: 186px; /* Minimum width for each span "column" */
width: 186px; /* Fixed width set inline to match exactly with headers */
}
.admin-timeline-span-item a { /* Ensure links inside spans are visible and styled */
color: inherit; /* Inherit color from parent span */
text-decoration: underline;
}
.admin-timeline-span-item a:hover {
color: #FACC15; /* Example: yellow hover for links */
}
/* Styling for the hour rows that form the timeline background grid */
.admin-timeline-hour-row {
display: flex; /* To layout hour label container and gridline container */
align-items: center;
border-top: 1px solid #374151; /* Horizontal line for the hour */
position: relative; /* For the absolutely positioned detailed grid line */
box-sizing: border-box;
/* height for each hour row is set inline via ERB (pixels_per_hour) */
}
.admin-timeline-hour-label-container { /* Container for the hour text, e.g., "9:00 AM" */
/* width for this container is set inline via ERB (line_left_rem) */
flex-shrink: 0; /* Prevent shrinking */
font-size: 0.75rem;
line-height: 1rem;
color: #6B7280;
padding-right: 0.5rem;
text-align: right;
box-sizing: border-box;
height: 100%; /* Match parent row height */
display: flex; /* To vertically center text if needed, though align-items on parent might suffice */
align-items: flex-start; /* Vertically center text */
}
.admin-timeline-hour-gridline-container { /* Container that stretches across for the faint mid-hour line */
flex-grow: 1; /* Take remaining width */
height: 100%; /* Fill the row height */
position: relative; /* For the absolutely positioned line itself */
}
.admin-timeline-hour-gridline { /* The faint horizontal line in the middle of an hour block */
position: absolute;
/* left: 0; right: (calculated from line_right_rem) are applied inline */
top: 50%;
border-bottom: 1px solid #374151; /* Color of the grid line */
transform: translateY(-50%);
z-index: 1; /* Behind activity spans */
}
/* Current time indicator line */
.admin-timeline-now-marker {
position: absolute;
/* left, right, top positions are set inline via ERB */
height: 2px;
background-color: #F87171; /* Prominent color for "now" line */
z-index: 15; /* Above grid lines, potentially above spans if desired (adjust z-index accordingly) */
}
.admin-timeline-now-marker-text { /* For the "NOW" text label */
position: absolute;
left: -2.5rem; /* Adjust to position text to the left of the line_left_rem area */
top: -0.4rem; /* Adjust to vertically center with the line */
font-size: 0.65rem;
color: #F87171; /* Match line color */
background-color: #1F2937; /* Match page background to make it appear to "cut through" */
padding: 0 0.2rem;
white-space: nowrap;
}
.user-trust-red {
background-color: rgba(239, 68, 68, 0.15) !important;
border-left: 3px solid rgb(239, 68, 68) !important;
}
.user-trust-green {
background-color: rgba(16, 185, 129, 0.15) !important;
border-left: 3px solid rgb(16, 185, 129) !important;
}
.user-trust-yellow {
background-color: rgba(245, 158, 11, 0.15) !important;
border-left: 3px solid rgb(245, 158, 11) !important;
}
.user-trust-blue {
background-color: rgba(59, 130, 246, 0.1) !important;
border-left: 3px solid rgb(59, 130, 246) !important;
}
.user-trust-indicator {
display: inline-block;
margin-left: 4px;
font-size: 0.875rem;
}
.conviction-hammer {
cursor: pointer;
margin-left: 6px;
padding: 2px 4px;
border-radius: 4px;
transition: background-color 0.2s;
}
.conviction-hammer:hover {
background-color: rgba(255, 255, 255, 0.1);
}
.cm {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.cm-content {
background-color: #1F2937;
border-radius: 8px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
width: 90%;
max-width: 500px;
}
.cm-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
border-bottom: 1px solid #374151;
}
.cm-header h3 {
margin: 0;
color: #F3F4F6;
font-size: 1.25rem;
}
.cm-close {
background: none;
border: none;
color: #9CA3AF;
font-size: 1.5rem;
cursor: pointer;
}
.cm-body {
padding: 16px;
color: #D1D5DB;
}
.cos {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
margin-top: 16px;
}
.co {
background-color: #2D3748;
border: 1px solid #4B5563;
border-radius: 6px;
padding: 12px;
color: #E5E7EB;
font-size: 0.875rem;
cursor: pointer;
transition: background-color 0.2s;
text-align: center;
}
.co:hover {
background-color: #374151;
}

View File

@@ -10,7 +10,6 @@
*/
@import "https://uchu.style/color.css";
@import "https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css";
@import "settings.css";
/* Force dark mode for all elements */

View File

@@ -1,160 +0,0 @@
.settings-page .grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 400px), 1fr));
gap: 1rem;
margin-top: 2rem;
}
.settings-page article {
background: var(--pico-card-background-color);
border: var(--pico-border-width) solid var(--pico-card-border-color);
border-radius: var(--pico-border-radius);
box-shadow: var(--pico-card-box-shadow);
transition: box-shadow var(--pico-transition);
margin: 0;
}
.settings-page article header {
padding: 0.5rem;
border-bottom: 1px solid var(--pico-muted-border-color);
}
.settings-page article header h2 {
margin-bottom: 0;
font-size: 1.25rem;
font-weight: 600;
color: var(--pico-color);
}
.settings-page article header p {
margin-bottom: 0;
color: var(--pico-muted-color);
font-size: 0.875rem;
}
.settings-page article section {
margin-top: 1.5rem;
padding-top: 1rem;
border-top: 1px solid var(--pico-muted-border-color);
}
.settings-page article section:first-of-type {
margin-top: 0;
padding-top: 0;
border-top: none;
}
.settings-page article section h3 {
margin-bottom: 0.75rem;
font-size: 1rem;
font-weight: 500;
color: var(--pico-color);
}
.settings-page article .form-group {
margin-bottom: 1rem;
}
.settings-page article .form-group:last-child {
margin-bottom: 0;
}
.settings-page article button[role="button"],
.settings-page article input[type="submit"][role="button"] {
margin-top: 1rem;
}
.settings-page article .secondary {
background-color: var(--pico-secondary-background);
border-color: var(--pico-secondary-border);
color: var(--pico-secondary-color);
}
.settings-page article .secondary:hover {
background-color: var(--pico-secondary-hover-background);
border-color: var(--pico-secondary-hover-border);
}
.settings-page article .code-example {
margin: 1rem 0;
background: var(--pico-code-background-color);
border-radius: var(--pico-border-radius);
}
.settings-page article .code-example pre {
margin: 0;
background: transparent;
padding: 0;
}
.settings-page article img {
max-width: 100%;
height: auto;
margin: 1rem 0;
border-radius: var(--pico-border-radius);
}
.settings-page article pre:not(.code-example pre) {
background: var(--pico-code-background-color);
padding: 0.75rem;
border-radius: var(--pico-border-radius);
font-size: 0.875rem;
overflow-x: auto;
}
.settings-page .mirror {
padding: 1rem;
background: var(--pico-card-sectioning-background-color);
border-radius: var(--pico-border-radius);
margin-bottom: 1rem;
}
.settings-page .mirror:last-child {
margin-bottom: 0;
}
.settings-page .email-form {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--pico-muted-border-color);
}
.settings-page .email-form .field {
display: flex;
gap: 0.5rem;
align-items: end;
}
.settings-page .email-form input[type="email"] {
flex: 1;
margin-bottom: 0;
}
@media (max-width: 768px) {
.settings-page .grid {
grid-template-columns: 1fr;
gap: 1.5rem;
}
.settings-page article {
padding: 1rem;
}
.settings-page .email-form .field {
flex-direction: column;
align-items: stretch;
}
}
/* Dark mode styles (now default) */
.settings-page article {
background: var(--pico-card-background-color, #1e293b);
border-color: var(--pico-card-border-color, #334155);
}
.settings-page article:hover {
box-shadow: var(
--pico-card-box-shadow-hover,
0 0.125rem 1rem rgba(0, 0, 0, 0.3)
);
}

View File

@@ -52,6 +52,9 @@
.bg-dark {
background-color: var(--color-dark);
}
.bg-darkless {
background-color: var(--color-darkless);
}
.bg-sheet {
background-color: var(--color-sheet);
}

View File

@@ -28,8 +28,7 @@ export default class extends Controller {
method: "PATCH",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": document.querySelector('meta[name="csrf-token"]')
.content,
"X-CSRF-Token": document.querySelector('meta[name="csrf-token"]').content,
},
body: JSON.stringify({ trust_level: trustLevel }),
});
@@ -40,10 +39,7 @@ export default class extends Controller {
);
}
// Update the current trust level in the dataset
event.target.dataset.currentTrustLevel = trustLevel;
// Update the leaderboard entry's omitted class
const leaderboardEntry = event.target.closest(".leaderboard-entry");
if (leaderboardEntry) {
if (trustLevel === "red") {

View File

@@ -13,35 +13,10 @@
num_users = users_data_array.count
num_users = 1 if num_users == 0 # Ensure num_users is at least 1 for calculations
# Fixed REM values for layout (assuming 1rem = 16px for px calculations)
line_left_rem = 4.0
line_right_rem = 0.5
activity_col_area_start_rem = line_left_rem # Header spacer aligns with hour labels
activity_col_area_end_rem = line_right_rem # Header spacer aligns with right padding of grid
gutter_rem = 0.25
pixels_per_hour = 128 # Controls vertical scale
pixels_per_minute = pixels_per_hour / 60.0
min_column_width_px = 186 # Minimum width for each user column
gutter_px = gutter_rem * 16 # Gutter in pixels
# Calculate the total minimum width required for all user columns + gutters + fixed label/padding areas
# This width will be applied to the admin-timeline-content-sizer div
user_columns_total_min_width_px = (num_users * min_column_width_px)
gutters_total_width_px = (num_users > 1 ? (num_users - 1) * gutter_px : 0)
# Total min width for the content that scrolls (headers part)
min_header_content_width_px = user_columns_total_min_width_px + gutters_total_width_px
# Total min width for the grid content part (timeline-grid-scroll-container's content)
# This area includes the hour labels on the left, then the user activity columns, then right padding
min_grid_content_width_px = (line_left_rem * 16) + min_header_content_width_px + (line_right_rem * 16)
# The sizer div needs to be at least as wide as the widest of its direct children (header row or grid row)
# The header row's actual content part (user headers) spans min_header_content_width_px.
# 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)
gutter_px = 4
# Current admin user and selected users for Stimulus
current_admin_user = {
@@ -55,189 +30,179 @@
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;">
<!-- User Selector UI -->
<div class=" text-white flex flex-col font-sans min-h-screen">
<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"
class="mb-4 p-3 bg-dark rounded-md flex-shrink-0"
>
<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/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"
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 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"
data-admin-timeline-user-selector-target="searchResults">
<%# Search results will appear here %>
</ul>
</div>
<button type="button"
class="px-3 py-2 bg-darker rounded-md text-sm text-white transition-colors"
data-action="admin-timeline-user-selector#applyPreset"
data-period="today">
Top 15 Today
</button>
<button type="button"
class="px-3 py-2 bg-darker rounded-md text-sm text-white transition-colors"
data-action="admin-timeline-user-selector#applyPreset"
data-period="last_7_days">
Top 15 Week
</button>
<button type="submit"
class="px-4 py-2 bg-green-600 rounded-md text-sm text-white transition-colors font-medium">
View
</button>
</div>
<div class="mt-2" data-admin-timeline-user-selector-target="selectedUsersContainer" style="margin-top: 0.5rem; min-height: 28px;">
<div class="mt-2 min-h-7" data-admin-timeline-user-selector-target="selectedUsersContainer">
<%# 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;">
<div class="flex justify-between items-center mb-4 flex-shrink-0">
<div class="text-lg font-semibold">
<%= @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", 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 class="flex gap-2">
<%= link_to "← Prev", admin_timeline_path(date: @prev_date.to_s),
class: "px-3 py-1 bg-darker rounded text-sm transition-colors",
data: { "date-nav-link": "true" } %>
<%= link_to "Today", admin_timeline_path(date: Time.current.to_date.to_s),
class: "px-3 py-1 bg-darker rounded text-sm transition-colors",
data: { "date-nav-link": "true" } %>
<%= link_to "Next →", admin_timeline_path(date: @next_date.to_s),
class: "px-3 py-1 bg-darker rounded text-sm transition-colors",
data: { "date-nav-link": "true" } %>
</div>
</div>
<!-- Horizontal Scroll Wrapper -->
<div class="admin-timeline-view-wrapper">
<!-- Inner Content Sizer: This div gets the calculated min-width -->
<div class="admin-timeline-content-sizer" style="min-width: <%= total_min_scroll_width_px %>px;">
<div class="flex-1 overflow-x-auto overflow-y-auto">
<% if users_data_array.any? %>
<%
hour_label_width = 80
grid_width = hour_label_width + (users_data_array.count * (min_column_width_px + gutter_px))
grid_height = 120 + (24 * pixels_per_hour)
%>
<!-- User Headers Section (Sticky) - Using position: absolute for exact alignment -->
<% if users_data_array.any? %>
<div class="admin-timeline-sticky-header" style="padding-left: 0; padding-right: 0;">
<div style="position: relative; width: 100%; height: 5rem;">
<% users_data_array.each_with_index do |data, index| %>
<% user = data[:user] %>
<% total_coded_time_seconds = data[:total_coded_time] %>
<%
# Calculate the base offset for this column (without the hour label padding)
# This ensures evenly spaced columns considering gutters
base_offset_px = index * min_column_width_px + (index > 0 ? index * gutter_px : 0)
# Add the hour label padding to get the absolute left position - EXACT SAME CALCULATION AS SPANS
header_left_px = (line_left_rem * 16) + base_offset_px
%>
<%
trust_level_class = ""
if user.respond_to?(:trust_level)
case user.trust_level
when "red"
trust_level_class = "user-trust-red"
when "green"
trust_level_class = "user-trust-green"
when "yellow"
trust_level_class = "user-trust-yellow"
end
end
%>
<div class="admin-timeline-user-header-cell <%= trust_level_class %>"
data-user-id="<%= user.id %>"
data-controller="user-conviction"
style="width: <%= min_column_width_px %>px; position: absolute; left: <%= header_left_px %>px;"
title="User ID: <%= user.id %> - <%= user.respond_to?(:username) && user.username.present? ? user.username : user.email_addresses.first&.email %> | Total Coded: <%= total_coded_time_seconds && total_coded_time_seconds > 0 ? short_time_detailed(total_coded_time_seconds) : '0m' %> | TZ: <%= user.timezone %>">
<% github_url = user.respond_to?(:github_profile_url) ? user.github_profile_url : nil %>
<%= render "shared/user_mention", user: user %>
<div style="margin-top: 0.25rem; display: flex; gap: 0.5rem;">
<% if current_user && user != current_user && user.slack_uid.present? %>
<%= link_to "Slack", "slack://user?team=T0266FRGM&id=#{user.slack_uid}", target: "_blank", style: "font-size: 0.7rem; color: #9CA3AF; text-decoration: underline;" %>
<% end %>
<% if github_url.present? %>
<%= link_to "Git", github_url, target: "_blank", style: "font-size: 0.7rem; color: #9CA3AF; text-decoration: underline;" %>
<% end %>
<% if current_user && current_user.admin? && user != current_user %>
<a href="#"
data-action="click->user-conviction#convictUser"
data-user-id="<%= user.id %>"
class="conviction-hammer"
style="font-size: 0.7rem; color: #9CA3AF; text-decoration: none;"
title="Set trust level">🔨</a>
<% end %>
<% if user.respond_to?(:trust_level) %>
<span class="user-trust-indicator" data-user-conviction-target="trustIndicator" title="<%= user.trust_level.capitalize %>">
<% case user.trust_level %>
<% when "red" %>
🔴
<% when "green" %>
🟢
<% when "yellow" %>
<20>
<% when "blue" %>
🔵
<% end %>
</span>
<% else %>
<span class="user-trust-indicator" data-user-conviction-target="trustIndicator"></span>
<% end %>
</div>
<% if total_coded_time_seconds && total_coded_time_seconds > 0 %>
<div style="font-size: 0.7rem; color: #cbd5e0; margin-top: 0.1rem; line-height: 1;">
<%= short_time_simple(total_coded_time_seconds) %> coded
</div>
<% else %>
<div style="font-size: 0.7rem; color: #718096; margin-top: 0.1rem; line-height: 1;">
No time coded
</div>
<% end %>
<div style="font-size: 0.75rem; color: #9CA3AF; margin-top: 0.125rem; line-height: 1.1;"><%= user.timezone %></div>
</div>
<% end %>
</div>
</div>
<% end %>
<!-- Timeline Grid and Spans Section -->
<div id="timeline-grid-scroll-container" style="overflow: hidden;">
<%# Hour markers and lines (background grid) %>
<% (timeline_start_hour..timeline_end_hour).each do |hour| %>
<div class="admin-timeline-hour-row" style="height: <%= pixels_per_hour %>px;">
<div class="admin-timeline-hour-label-container" style="width: <%= line_left_rem %>rem;">
<%= Time.utc(2000,1,1, hour).strftime("%-l:00 %p") %>
</div>
<div class="admin-timeline-hour-gridline-container">
<%# The actual line is styled to span the container using absolute positioning from CSS %>
<div class="admin-timeline-hour-gridline" style="left:0; right: <%= line_right_rem %>rem;"></div>
<div class="relative" style="width: <%= grid_width %>px; height: <%= grid_height %>px;">
<% (0..23).each do |hour| %>
<%
hour_top = 120 + (hour * pixels_per_hour)
formatted_hour = Time.utc(2000,1,1, hour).strftime("%-l:00 %p")
%>
<div class="absolute left-0 w-full border-t border-gray-600"
style="top: <%= hour_top %>px; height: <%= pixels_per_hour %>px;">
<div class="absolute left-2 top-2 text-xs text-gray-300 font-mono px-1">
<%= formatted_hour %>
</div>
</div>
<% end %>
<%# Current Time Line Indicator %>
<% users_data_array.each_with_index do |data, index| %>
<%
user = data[:user]
total_coded_time_seconds = data[:total_coded_time]
column_left = hour_label_width + (index * (min_column_width_px + gutter_px))
trust_level_emoji = case user.respond_to?(:trust_level) ? user.trust_level : nil
when "red" then "🔴"
when "green" then "🟢"
when "yellow" then "🟡"
else "🔵"
end
trust_level_bg = case user.respond_to?(:trust_level) ? user.trust_level : nil
when "red" then "bg-red-500/20"
when "green" then "bg-green-500/20"
when "yellow" then "bg-yellow-500/20"
when "blue" then "bg-blue-500/20"
else "bg-gray-500/20"
end
%>
<div class="absolute border-r border-gray-700"
style="left: <%= column_left %>px; width: <%= min_column_width_px %>px; top: 120px; bottom: 0;">
</div>
<div class="absolute top-0 p-3 rounded-lg shadow-lg <%= trust_level_bg %>"
data-user-id="<%= user.id %>"
style="left: <%= column_left + 2 %>px; width: <%= min_column_width_px - 4 %>px;"
title="User ID: <%= user.id %> - <%= user.respond_to?(:username) && user.username.present? ? user.username : user.email_addresses.first&.email %> | Total Coded: <%= total_coded_time_seconds && total_coded_time_seconds > 0 ? short_time_detailed(total_coded_time_seconds) : '0m' %> | TZ: <%= user.timezone %>">
<div class="flex items-center space-x-1 mb-1">
<%= render "shared/user_mention", user: user %>
</div>
<div class="flex justify-center items-center gap-4 mb-1 text-center">
<% if current_user && user != current_user && user.slack_uid.present? %>
<div>
<%= link_to "Slack", "slack://user?team=T0266FRGM&id=#{user.slack_uid}",
target: "_blank",
class: "text-xs text-blue-400 hover:text-blue-300 underline" %>
</div>
<% end %>
<% if user.respond_to?(:github_profile_url) && user.github_profile_url.present? %>
<div>
<%= link_to "Git", user.github_profile_url,
target: "_blank",
class: "text-xs text-green-400 hover:text-green-300 underline" %>
</div>
<% end %>
<div>
<span class="text-sm"><%= trust_level_emoji %></span>
</div>
<% if current_user && current_user.admin? && user != current_user %>
<div>
<button data-action="openModal"
data-user-id="<%= user.id %>"
class="text-xs text-gray-300 hover:text-white"
title="Set trust level">🔨</button>
</div>
<% end %>
</div>
<div class="text-sm font-medium mb-1 <%= total_coded_time_seconds && total_coded_time_seconds > 0 ? 'text-green-300' : 'text-gray-400' %>">
<%= total_coded_time_seconds && total_coded_time_seconds > 0 ? "#{short_time_simple(total_coded_time_seconds)} coded" : "No time coded" %>
</div>
<div class="text-xs text-gray-300">
<%= user.timezone %>
</div>
</div>
<% end %>
<%
current_time_in_zone = Time.current.in_time_zone(primary_user_tz)
is_today = @date == Time.current.in_time_zone(primary_user_tz).to_date
show_current_time_line = is_today && current_time_in_zone.hour >= timeline_start_hour && current_time_in_zone.hour < (timeline_end_hour + 1)
if show_current_time_line
minutes_from_timeline_display_start_for_now = (current_time_in_zone.hour - timeline_start_hour) * 60 + current_time_in_zone.min
current_time_line_top_px = (minutes_from_timeline_display_start_for_now * pixels_per_minute)
minutes_from_timeline_start = (current_time_in_zone.hour - timeline_start_hour) * 60 + current_time_in_zone.min
current_time_line_top_px = 120 + (minutes_from_timeline_start * pixels_per_minute)
end
%>
<% if show_current_time_line %>
<div class="admin-timeline-now-marker" style="left: <%= line_left_rem %>rem; right: <%= line_right_rem %>rem; top: <%= current_time_line_top_px %>px;">
<div class="admin-timeline-now-marker-text">NOW</div>
<div class="absolute w-full h-0.5 bg-red-500 z-300 flex items-center"
style="left: <%= hour_label_width %>px; right: 0; top: <%= current_time_line_top_px %>px;">
<div class="bg-red-500 text-white text-xs px-2 py-1 rounded -ml-16">NOW</div>
</div>
<% end %>
<%# Logic for calculating span properties (remains mostly the same) %>
<%
calculate_span_properties = lambda do |span_data, span_user_tz|
return nil unless span_data && span_data[:start_time] && span_data[:duration]
@@ -254,7 +219,7 @@
duration_seconds_in_view = effective_end_time - effective_start_time
height_px = (duration_seconds_in_view / 60.0) * pixels_per_minute
return nil if height_px <= 0.5
final_top_px = (minutes_from_view_start * pixels_per_minute).round
final_top_px = 120 + (minutes_from_view_start * pixels_per_minute).round
title_parts = []
title_parts << "Languages: #{span_data[:languages].join(', ')}" if span_data[:languages]&.any?
project_title_segments = (span_data[:projects_edited_details] || []).map do |proj_detail|
@@ -282,37 +247,31 @@
}
end
%>
<%# Activity Spans %>
<% users_data_array.each_with_index do |data, index| %>
<%
user = data[:user]
user_spans = data[:spans]
block_color = user_colors[index % user_colors.length]
# Calculate the base offset for this column (without the hour label padding)
# This ensures evenly spaced columns considering gutters
base_offset_px = index * min_column_width_px + (index > 0 ? index * gutter_px : 0)
# Add the hour label padding to get the absolute left position
current_span_column_left_px = (line_left_rem * 16) + base_offset_px
column_left = hour_label_width + (index * (min_column_width_px + gutter_px))
%>
<% Array(user_spans).each do |span_data| %>
<% props = calculate_span_properties.call(span_data, user.timezone || primary_user_tz) %>
<% next unless props %>
<div class="admin-timeline-span-item"
style="--span-color: <%= block_color %>;
background-color: var(--span-color);
left: <%= current_span_column_left_px %>px;
width: <%= min_column_width_px %>px;
<div class="absolute rounded-md p-2 text-white text-xs overflow-hidden z-10"
style="background-color: <%= block_color %>;
left: <%= column_left + 2 %>px;
width: <%= min_column_width_px - 4 %>px;
top: <%= props[:final_top_px] %>px;
height: <%= props[:height_px] %>px;"
title="<%= props[:title] %>">
<div style="font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">
<div class="font-medium truncate">
<% if props[:projects_to_display]&.any? %>
<% props[:projects_to_display].each_with_index do |project_detail, p_idx| %>
<% if project_detail[:repo_url].present? %>
<a href="<%= project_detail[:repo_url] %>" target="_blank" rel="noopener noreferrer" title="Open <%= project_detail[:name] %> on GitHub"><%= project_detail[:name].truncate(20) %></a>
<a href="<%= project_detail[:repo_url] %>" target="_blank" rel="noopener noreferrer"
class="text-white hover:text-gray-200 underline"
title="Open <%= project_detail[:name] %> on GitHub"><%= project_detail[:name].truncate(20) %></a>
<% else %>
<span title="<%= project_detail[:name] %> - No GitHub Repo Mapped"><%= project_detail[:name].truncate(20) %> 🚫</span>
<% end %>
@@ -324,16 +283,15 @@
<span>Coding Activity</span>
<% end %>
</div>
<div style="font-size: 0.7rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">
<div class="truncate opacity-90">
<%= props[:display_text_line2] %>
</div>
<div style="font-size: 0.7rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: #E5E7EB;">
<div class="truncate opacity-75">
<%= props[:display_text_line3] %>
</div>
</div>
<% end %>
<% end %>
<% @timeline_commit_markers.each do |commit| %>
<% user_index = users_data_array.find_index { |data| data[:user].id == commit[:user_id] } %>
<% next unless user_index %>
@@ -341,187 +299,153 @@
<% user_tz = user&.timezone || primary_user_tz %>
<% today_start_of_day_for_user = @date.in_time_zone(user_tz).beginning_of_day %>
<% view_start_datetime = today_start_of_day_for_user.advance(hours: timeline_start_hour) %>
<% base_offset_px = user_index * min_column_width_px + (user_index > 0 ? user_index * gutter_px : 0) %>
<% pill_left_px = (line_left_rem * 16) + base_offset_px + min_column_width_px - 60 %> <!-- right align, adjust as needed -->
<% column_left = hour_label_width + (user_index * (min_column_width_px + gutter_px)) %>
<% pill_left_px = column_left + 93 %>
<% commit_minutes_from_view_start = ((Time.at(commit[:timestamp]).in_time_zone(user_tz) - view_start_datetime) / 60.0).to_f %>
<% commit_top_px = (commit_minutes_from_view_start * pixels_per_minute).round %>
<% commit_top_px = 120 + (commit_minutes_from_view_start * pixels_per_minute).round %>
<a href="<%= commit[:github_url] %>" target="_blank" rel="noopener noreferrer"
title="<%= commit[:message] %>"
class="timeline-commit-pill"
style="position: absolute;
left: <%= pill_left_px %>px;
top: <%= commit_top_px %>px;
z-index: 20;
background: #222;
color: #fff;
border-radius: 12px;
padding: 0px 7px;
font-size: 0.72em;
border: 1px solid rgba(255,255,255,0.1);
text-decoration: none;
box-shadow: 0 1px 4px rgba(0,0,0,0.10);">
<span style="<%= commit[:additions].to_i > 0 ? 'color: #22c55e; font-weight: 600;' : '' %>">+<%= commit[:additions] %></span>
class="absolute z-20 bg-darker text-white rounded-full px-2 py-1 text-xs hover:bg-gray-800 transition-colors"
style="left: <%= pill_left_px %>px; top: <%= commit_top_px %>px; transform: translateX(-50%);">
<span class="<%= commit[:additions].to_i > 0 ? 'text-green-400 font-semibold' : '' %>">+<%= commit[:additions] %></span>
/
<span style="<%= commit[:deletions].to_i > 0 ? 'color: #ef4444; font-weight: 600;' : '' %>">-<%= commit[:deletions] %></span>
<span class="<%= commit[:deletions].to_i > 0 ? 'text-red-400 font-semibold' : '' %>">-<%= commit[:deletions] %></span>
</a>
<% end %>
</div> <!-- End #timeline-grid-scroll-container -->
</div> <!-- End .admin-timeline-content-sizer -->
</div> <!-- End .admin-timeline-view-wrapper -->
</div> <!-- End Main Page Container -->
</div>
<% else %>
<div class="flex items-center justify-center h-64 text-gray-400">
<div class="text-center">
<div class="text-lg mb-2">No users selected</div>
<div class="text-sm">Use the search bar above to add users to the timeline</div>
</div>
</div>
<% 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 })
});
<% 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;
}
.user-trust-red {
background-color: rgba(220, 38, 38, 0.15);
border-left: 3px solid #DC2626;
}
.user-trust-green {
background-color: rgba(16, 185, 129, 0.15);
border-left: 3px solid #10B981;
}
.user-trust-yellow {
background-color: rgba(245, 158, 11, 0.08);
border-left: 3px solid #F59E0B;
}
.user-trust-indicator {
font-size: 0.75rem;
margin-left: 0.25rem;
}
.cm {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.cm-content {
background-color: #2D3748;
border-radius: 0.5rem;
width: 95%;
max-width: 500px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
color: white;
}
.cm-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border-bottom: 1px solid #4A5568;
}
.cm-header h3 {
margin: 0;
font-size: 1.25rem;
}
.cm-close {
background: none;
border: none;
font-size: 1.5rem;
color: #CBD5E0;
cursor: pointer;
}
.cm-body {
padding: 1.5rem;
}
.cos {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-bottom: 1.5rem;
}
.co {
background-color: #4A5568;
border: none;
border-radius: 0.375rem;
padding: 0.75rem;
color: white;
text-align: left;
cursor: pointer;
transition: background-color 0.2s;
}
.tln {
position: fixed;
bottom: 20px;
right: 20px;
background-color: #38A169;
color: white;
padding: 1rem;
border-radius: 0.375rem;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
z-index: 1000;
animation: f 3s ease-in-out;
}
@keyframes f {
0% { opacity: 0; transform: translateY(20px); }
10% { opacity: 1; transform: translateY(0); }
90% { opacity: 1; transform: translateY(0); }
100% { opacity: 0; transform: translateY(-20px); }
}
</style>
<% end %>
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();
}
});
});
});
</script>

View File

@@ -1,11 +1,13 @@
<div id="timezone_leaderboard_toggle">
<p>
<strong>Regional & Timezone Leaderboards</strong><br>
<small>Access regional leaderboards that show users in your timezone region or specific timezone. Choose between timezone-specific, regional (UTC offset), or global competition modes.</small>
</p>
<%= form_with model: user, url: (@is_own_settings ? my_settings_path : settings_user_path(user)), method: :patch, local: false do |f| %>
<%= f.check_box :default_timezone_leaderboard, checked: user.default_timezone_leaderboard, id: "user_default_timezone_leaderboard" %>
<%= f.label :default_timezone_leaderboard, "Default to Timezone Leaderboard", for: "user_default_timezone_leaderboard" %>
<%= f.submit "Save", role: "button" %>
<% end %>
</div>
<%= form_with model: user, url: (@is_own_settings ? my_settings_path : settings_user_path(user)), method: :patch, local: false, class: "space-y-4" do |f| %>
<div class="flex items-center gap-3">
<%= f.check_box :default_timezone_leaderboard,
checked: user.default_timezone_leaderboard,
id: "user_default_timezone_leaderboard",
class: "w-4 h-4 text-primary border-gray-600 rounded focus:ring-primary bg-gray-800" %>
<%= f.label :default_timezone_leaderboard, "Default to Timezone Leaderboard",
for: "user_default_timezone_leaderboard",
class: "text-sm text-gray-200" %>
</div>
<p class="text-xs text-gray-400">Access regional leaderboards that show users in your timezone region or specific timezone. Choose between timezone-specific, regional (UTC offset), or global competition modes.</p>
<%= f.submit "Save", class: "w-full px-4 py-2 bg-primary text-white font-medium rounded-lg transition-colors duration-200" %>
<% end %>

View File

@@ -1,5 +1,5 @@
<% if @user.api_keys.any? %>
<pre>
<pre class="bg-gray-800 rounded-lg text-sm text-gray-200 whitespace-pre-wrap break-all">
# put this in your ~/.wakatime.cfg file
[settings]
@@ -7,10 +7,9 @@ api_url = https://<%= request.host_with_port %>/api/hackatime/v1
api_key = <%= @user.api_keys.last.token %>
heartbeat_rate_limit_seconds = 30
# any other wakatime configs you want to add: https://github.com/wakatime/wakatime-cli/blob/develop/USAGE.md#ini-config-file
</pre>
# any other wakatime configs you want to add: https://github.com/wakatime/wakatime-cli/blob/develop/USAGE.md#ini-config-file</pre>
<% else %>
<p>
No API keys found. Please migrate your keys from waka.hackclub.com below. New API key generation has yet to be implemented.
No API keys found. Please migrate your keys from waka.hackclub.com below. New API key generation has yet to be implemented.
</p>
<% end %>

View File

@@ -2,358 +2,385 @@
<%= @is_own_settings ? "My Settings" : "Settings | #{@user.username}" %>
<% end %>
<% content_for :body_class, "settings-page" %>
<main class="container">
<header>
<h1><%= @is_own_settings ? "My Settings" : "Settings for #{@user.username}" %></h1>
<p>Change your settings for Hackatime and Sailors Log.</p>
<div class="max-w-6xl mx-auto p-6 space-y-6">
<header class="text-center mb-8">
<h1 class="text-4xl font-bold text-white mb-2">
<%= @is_own_settings ? "My Settings" : "Settings for #{@user.username}" %>
</h1>
<p class="text-muted text-lg">Change your Hackatime experience and preferences</p>
</header>
<div class="grid">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="border border-primary rounded-xl p-6 hover:bg-gray-800/50 transition-all duration-200">
<div class="flex items-center gap-3 mb-4">
<div class="p-2 bg-red-600/10 rounded">
<span class="text-2xl">🚀</span>
</div>
<h2 class="text-xl font-semibold text-white">Time Tracking Wizard</h2>
</div>
<p class="text-gray-300 mb-4">Get started with tracking your coding time in just a few minutes.</p>
<%= link_to "Set up time tracking", my_wakatime_setup_path,
class: "inline-flex items-center gap-2 px-4 py-2 bg-primary text-white font-medium rounded transition-colors duration-200" %>
</div>
<article>
<header>
<h2>🚀 Time tracking wizard</h2>
</header>
<p>Get started with tracking your coding time in just a few minutes.</p>
<%= link_to "Set up time tracking", my_wakatime_setup_path, role: "button" %>
</article>
<article>
<header>
<h2 id="user_timezone">🌍 Timezone</h2>
</header>
<div class="border border-primary rounded-xl p-6 hover:bg-gray-800/50 transition-all duration-200">
<div class="flex items-center gap-3 mb-4">
<div class="p-2 bg-red-600/10 rounded">
<span class="text-2xl">🌍</span>
</div>
<h2 class="text-xl font-semibold text-white" id="user_timezone">Timezone</h2>
</div>
<%= form_with model: @user,
url: @is_own_settings ? my_settings_path : settings_user_path(@user),
method: :patch do |f| %>
<div class="form-group">
<%= f.label :timezone, "Your timezone" %>
<%= f.select :timezone,
method: :patch, local: false,
class: "space-y-4" do |f| %>
<div>
<%= f.label :timezone, "Your timezone", class: "block text-sm font-medium text-gray-200 mb-2" %>
<%= f.select :timezone,
TZInfo::Timezone.all.map(&:identifier).sort,
include_blank: @user.timezone.blank?, class: "form-select" %>
{ include_blank: @user.timezone.blank? },
{ class: "w-full px-3 py-2 bg-gray-800 border border-gray-600 rounded text-white focus:border-primary focus:ring-1 focus:ring-primary" } %>
</div>
<small>This affects how your activity graph and other time-based features are displayed.</small>
<%= f.submit "Save Settings", role: "button" %>
<p class="text-xs text-gray-400">This affects how your activity graph and other time-based features are displayed.</p>
<%= f.submit "Save Settings", class: "w-full px-4 py-2 bg-primary text-white font-medium rounded transition-colors duration-200" %>
<% end %>
</article>
</div>
<article>
<header>
<h2 id="user_slack_status">💬 Slack Integration</h2>
</header>
<section>
<h3>Status Updates</h3>
<p>When you're hacking on a project, Hackatime can update your Slack status so you can show it off!</p>
<% unless @can_enable_slack_status %>
<%= link_to "Re-authorize with Slack to give permission to update your status", slack_auth_path, role: "button", class: "secondary" %>
<% end %>
<%= form_with model: @user,
url: @is_own_settings ? my_settings_path : settings_user_path(@user),
method: :patch do |f| %>
<fieldset>
<label>
<%= f.check_box :uses_slack_status, id: "user_uses_slack_status" %>
<%= f.label :uses_slack_status, "Update my Slack status with my current project" %>
</label>
</fieldset>
<%= f.submit "Save Settings", role: "button" %>
<% end %>
</section>
<section>
<h3 id="user_slack_notifications">Channel Notifications</h3>
<% if @enabled_sailors_logs.any? %>
<p>You have notifications enabled for the following channels:</p>
<ul>
<% @enabled_sailors_logs.each do |sl| %>
<li>
<%= render "shared/slack_channel_mention", channel_id: sl.slack_channel_id %>
</li>
<% end %>
</ul>
<% else %>
<p>You have no notifications enabled.</p>
<% end %>
<p>
You can enable notifications for specific channels by running <code>/sailorslog on</code> in the Slack channel you want to enable notifications for.
</p>
</section>
</article>
<article>
<header>
<h2 id="user_github_account">🔗 Connected Accounts</h2>
</header>
<section>
<h3>GitHub Account</h3>
<p>This is used to show your active projects on the leaderboard &amp; current hacking activity on the dashboard.</p>
<% if @user.github_uid.present? %>
<p>✅ Your GitHub account is linked: <%= link_to "@#{@user.github_username}", "https://github.com/#{@user.github_username}", target: "_blank" %></p>
<% if @user.github_access_token.present? %>
<%= link_to "Relink GitHub Account", github_auth_path, data: { turbo: "false" }, role: "button" %>
<%= link_to "Unlink GitHub Account", github_unlink_path,
data: {
turbo_method: :delete,
confirm: "Are you sure? This will remove access to your GitHub data."
},
role: "button",
class: "outline" %>
<% else %>
<p>⚠️ Your GitHub token has expired. Please relink your account.</p>
<%= link_to "Relink GitHub Account", github_auth_path, data: { turbo: "false" }, role: "button" %>
<% end %>
<% else %>
<%= link_to "Link GitHub Account", github_auth_path, data: { turbo: "false" }, role: "button" %>
<% end %>
</section>
<section>
<h3 id="user_email_addresses">Email Addresses</h3>
<p>These are the email addresses associated with your account.</p>
<% if @user.email_addresses.any? %>
<ul>
<% @user.email_addresses.each do |email_address| %>
<li>
<%= email_address.email %>
<% if email_address.source.present? %>
<span class="super">
(from <%= email_address.source.humanize %>)
</span>
<% end %>
</li>
<% end %>
</ul>
<% else %>
<p>No email addresses found.</p>
<% end %>
<div class="email-form">
<%= form_tag add_email_auth_path, data: { turbo: false } do %>
<div class="field">
<%= email_field_tag :email, nil, placeholder: "Add another email address", required: true %>
</div>
<%= submit_tag "Add Email", role: "button", class: "secondary" %>
<% end %>
<div class="border border-primary rounded-xl p-6 hover:bg-gray-800/50 transition-all duration-200">
<div class="flex items-center gap-3 mb-4">
<div class="p-2 bg-red-600/10 rounded">
<span class="text-2xl">⚙️</span>
</div>
</section>
</article>
<article>
<header>
<h2 id="user_hackatime_extension">⚙️ Extension Settings</h2>
</header>
<h2 class="text-xl font-semibold text-white" id="user_hackatime_extension">Extension Settings</h2>
</div>
<%= form_with model: @user,
url: @is_own_settings ? my_settings_path : settings_user_path(@user),
method: :patch do |f| %>
<div class="form-group">
<%= f.label "Simple text" %>
method: :patch, local: false,
class: "space-y-4" do |f| %>
<div>
<%= f.label :hackatime_extension_text_type, "Status bar text style", class: "block text-sm font-medium text-gray-200 mb-2" %>
<%= f.select :hackatime_extension_text_type,
User.hackatime_extension_text_types.keys.map { |type| [type.humanize, type] },
selected: @user.hackatime_extension_text_type, class: "form-select" %>
User.hackatime_extension_text_types.keys.map { |key| [key.humanize, key] },
{},
{ class: "w-full px-3 py-2 bg-gray-800 border border-gray-600 rounded text-white focus:border-primary focus:ring-1 focus:ring-primary" } %>
</div>
<%= f.submit "Save Settings", role: "button" %>
<%= f.submit "Save Settings", class: "w-full px-4 py-2 bg-primary text-white font-medium rounded transition-colors duration-200" %>
<% end %>
</article>
</div>
<article>
<header>
<h2 id="user_stats_badges">📊 Stats Badges</h2>
</header>
<section>
<p>Show your coding stats on your GitHub profile with beautiful badges.</p>
<h3>General Stats Badge</h3>
<p>This badge shows your overall coding statistics.</p>
<select name="theme" id="theme-select" onchange="updateBadgeTheme(this.value)" class="form-select">
<% GithubReadmeStats.themes.each do |theme| %>
<option value="<%= theme %>"><%= theme.humanize %></option>
<div class="border border-primary rounded-xl p-6 hover:bg-gray-800/50 transition-all duration-200">
<div class="flex items-center gap-3 mb-4">
<div class="p-2 bg-red-600/10 rounded">
<span class="text-2xl">💬</span>
</div>
<h2 class="text-xl font-semibold text-white" id="user_slack_status">Slack Integration</h2>
</div>
<div class="space-y-4">
<div>
<h3 class="text-lg font-medium text-white mb-2">Status Updates</h3>
<p class="text-gray-300 text-sm mb-3">When you're hacking on a project, Hackatime can update your Slack status so you can show it off!</p>
<% unless @can_enable_slack_status %>
<%= link_to "Re-authorize with Slack", slack_auth_path,
class: "inline-flex items-center gap-2 px-3 py-2 bg-gray-700 hover:bg-gray-600 text-gray-200 text-sm font-medium rounded transition-colors duration-200 mb-3" %>
<% end %>
</select>
<% gh_badge = GithubReadmeStats.new(current_user.id, "darcula") %>
<img id="badge-preview" src="<%= gh_badge.generate_badge_url %>" data-url="<%= gh_badge.generate_badge_url %>">
<pre id="badge-url"><%= gh_badge.generate_badge_url %></pre>
</section>
<%= form_with model: @user,
url: @is_own_settings ? my_settings_path : settings_user_path(@user),
method: :patch, local: false do |f| %>
<div class="flex items-center gap-3">
<%= f.check_box :uses_slack_status,
class: "w-4 h-4 text-primary border-gray-600 rounded focus:ring-primary bg-gray-800" %>
<%= f.label :uses_slack_status, "Update my Slack status automatically",
class: "text-sm text-gray-200" %>
</div>
<%= f.submit "Save", class: "mt-3 px-4 py-2 bg-primary text-white font-medium rounded transition-colors duration-200" %>
<% end %>
</div>
<% if @projects.any? && @user.slack_uid.present? %>
<section>
<h3>Project Stats Badge</h3>
<p>This badge shows individual project statistics.</p>
<p><small>See <a href="https://github.com/pbhak/hackatime-badge">the documentation</a> for more customization options!</small></p>
<select name="project-id" id="project-select" onchange="updateBadgeProject(this.value)" class="form-select">
<% @projects.each do |project_name| %>
<option value="<%= project_name %>"><%= project_name %></option>
<% end %>
</select>
<img id="work-time-badge-preview" src="<%= @work_time_stats_url %>" data-url="<%= @work_time_stats_url %>">
<pre id="work-time-badge-url"><%= @work_time_stats_url %></pre>
</section>
<div class="border-t border-gray-700 pt-4">
<h3 class="text-lg font-medium text-white mb-2" id="user_slack_notifications">Channel Notifications</h3>
<% if @enabled_sailors_logs.any? %>
<p class="text-gray-300 text-sm mb-2">You have notifications enabled for the following channels:</p>
<ul class="space-y-1 mb-3">
<% @enabled_sailors_logs.each do |sl| %>
<li class="text-xs text-gray-300 px-2 py-1 bg-gray-800 rounded">
<%= render "shared/slack_channel_mention", channel_id: sl.slack_channel_id %>
</li>
<% end %>
</ul>
<% else %>
<p class="text-gray-300 text-sm mb-3">You have no notifications enabled.</p>
<% end %>
<p class="text-xs text-gray-400">
You can enable notifications for specific channels by running <code class="px-1 py-0.5 bg-gray-800 rounded text-gray-200">/sailorslog on</code> in the Slack channel.
</p>
</div>
</div>
</div>
<div class="border border-primary rounded-xl p-6 hover:bg-gray-800/50 transition-all duration-200">
<div class="flex items-center gap-3 mb-4">
<div class="p-2 bg-red-600/10 rounded">
<span class="text-2xl">🔒</span>
</div>
<h2 class="text-xl font-semibold text-white" id="user_privacy">Privacy Settings</h2>
</div>
<%= form_with model: @user,
url: @is_own_settings ? my_settings_path : settings_user_path(@user),
method: :patch, local: false,
class: "space-y-4" do |f| %>
<div class="flex items-center gap-3">
<%= f.check_box :allow_public_stats_lookup,
class: "w-4 h-4 text-primary border-gray-600 rounded focus:ring-primary bg-gray-800" %>
<%= f.label :allow_public_stats_lookup, "Allow public stats lookup",
class: "text-sm text-gray-200" %>
</div>
<p class="text-xs text-gray-400">When enabled, others can view your coding statistics through public APIs.</p>
<%= f.submit "Save Settings", class: "w-full px-4 py-2 bg-primary text-white font-medium rounded transition-colors duration-200" %>
<% end %>
</div>
<div class="border border-primary rounded-xl p-6 hover:bg-gray-800/50 transition-all duration-200">
<div class="flex items-center gap-3 mb-4">
<div class="p-2 bg-red-600/10 rounded">
<span class="text-2xl">🏆</span>
</div>
<h2 class="text-xl font-semibold text-white" id="user_beta_features">Leaderboard Settings</h2>
</div>
<p class="text-gray-300 text-sm mb-4">Customize how you see the leaderboard</p>
<%= render "timezone_leaderboard_toggle", user: @user %>
</div>
<div class="border border-primary rounded-xl p-6 hover:bg-gray-800/50 transition-all duration-200 md:col-span-2">
<div class="flex items-center gap-3 mb-4">
<div class="p-2 bg-red-600/10 rounded">
<span class="text-2xl">🔗</span>
</div>
<h2 class="text-xl font-semibold text-white" id="user_github_account">Connected Accounts</h2>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="space-y-3">
<h3 class="text-lg font-medium text-white">GitHub Account</h3>
<p class="text-gray-300 text-sm">This is used to show your active projects on the leaderboard & current hacking activity on the dashboard.</p>
<% if @user.github_uid.present? %>
<div class="flex items-center gap-2 p-3 bg-gray-800 border border-gray-600 rounded">
<span class="text-green-400">✅</span>
<span class="text-gray-200 text-sm">Connected: <%= link_to "@#{@user.github_username}", "https://github.com/#{@user.github_username}", target: "_blank", class: "text-primary hover:text-primary/80 underline" %></span>
</div>
<% unless @user.github_access_token.present? %>
<%= link_to "Relink GitHub Account", github_auth_path, data: { turbo: "false" },
class: "inline-flex items-center gap-2 px-3 py-2 bg-primary text-white text-sm font-medium rounded transition-colors duration-200" %>
<% end %>
<% else %>
<%= link_to "Link GitHub Account", github_auth_path, data: { turbo: "false" },
class: "inline-flex items-center gap-2 px-4 py-2 bg-primary text-white font-medium rounded transition-colors duration-200" %>
<% end %>
</div>
<div class="space-y-3" id="user_email_addresses">
<h3 class="text-lg font-medium text-white">Email Addresses</h3>
<p class="text-gray-300 text-sm">These are the email addresses associated with your account.</p>
<% if @user.email_addresses.any? %>
<div class="space-y-2">
<% @user.email_addresses.each do |email| %>
<div class="flex items-center gap-2 p-2 bg-gray-800 border border-gray-600 rounded">
<span class="text-gray-300 text-sm"><%= email.email %></span>
<span class="text-xs px-2 py-1 bg-gray-700 text-gray-200 rounded">
<%= email.source&.humanize || "Unknown" %>
</span>
</div>
<% end %>
</div>
<% else %>
<p class="text-gray-400 text-sm">No email addresses found.</p>
<% end %>
<%= form_tag add_email_auth_path, data: { turbo: false }, class: "space-y-2" do %>
<%= email_field_tag :email, nil,
placeholder: "Add another email address",
required: true,
class: "w-full px-3 py-2 bg-gray-800 border border-gray-600 rounded text-white focus:border-primary focus:ring-1 focus:ring-primary text-sm" %>
<%= submit_tag "Add Email", class: "w-full px-3 py-2 bg-primary hover:bg-primary/80 text-white text-sm font-medium rounded transition-colors duration-200" %>
<% end %>
</div>
</div>
</div>
<div class="border border-primary rounded-xl p-6 hover:bg-gray-800/50 transition-all duration-200">
<div class="flex items-center gap-3 mb-4">
<div class="p-2 bg-red-600/10 rounded">
<span class="text-2xl">📊</span>
</div>
<h2 class="text-xl font-semibold text-white" id="user_stats_badges">Stats Badges</h2>
</div>
<div class="space-y-6">
<div>
<h3 class="text-lg font-medium text-white mb-2">General Stats Badge</h3>
<p class="text-gray-300 text-sm mb-4">Show your coding stats on your GitHub profile with beautiful badges.</p>
<div class="space-y-3">
<div>
<label class="block text-sm font-medium text-gray-200 mb-2">Theme</label>
<select name="theme" id="theme-select" onchange="up1(this.value)"
class="w-full px-3 py-2 bg-gray-800 border border-gray-600 rounded text-white focus:border-primary focus:ring-1 focus:ring-primary">
<% GithubReadmeStats.themes.each do |theme| %>
<option value="<%= theme %>"><%= theme.humanize %></option>
<% end %>
</select>
</div>
<% gh_badge = GithubReadmeStats.new(current_user.id, "darcula") %>
<div class="p-4 bg-gray-800 border border-gray-600 rounded">
<img id="badge-preview" src="<%= gh_badge.generate_badge_url %>" data-url="<%= gh_badge.generate_badge_url %>" class="mb-3 rounded">
<pre id="badge-url" class="text-xs text-gray-300 bg-gray-900 p-2 rounded overflow-x-auto"><%= gh_badge.generate_badge_url %></pre>
</div>
</div>
</div>
<% if @projects.any? && @user.slack_uid.present? %>
<div class="border-t border-gray-700 pt-4">
<h3 class="text-lg font-medium text-white mb-2">Project Stats Badge</h3>
<div class="space-y-2">
<label class="block text-sm font-medium text-gray-200">Project</label>
<select name="project" id="project-select" onchange="up2(this.value)"
class="w-full px-3 py-2 bg-gray-800 border border-gray-600 rounded text-white focus:border-primary focus:ring-1 focus:ring-primary">
<% @projects.each do |project| %>
<option value="<%= project %>"><%= project %></option>
<% end %>
</select>
<div class="mt-3 p-4 bg-gray-800 border border-gray-600 rounded">
<img id="project-badge-preview" src="<%= @work_time_stats_url %>" class="mb-3 rounded">
<pre id="project-badge-url" class="text-xs text-gray-300 bg-gray-900 p-2 rounded overflow-x-auto"><%= @work_time_stats_url %></pre>
</div>
</div>
</div>
<% end %>
</div>
<script>
function updateBadgeTheme(theme) {
const originalUrl = document.getElementById('badge-preview').dataset.url;
const [baseUrl, queryString] = originalUrl.split('?');
const params = queryString.split('&').map(param => {
const [key, value] = param.split('=');
return key === 'theme' ? `theme=${theme}` : param;
});
const newUrl = `${baseUrl}?${params.join('&')}`;
document.getElementById('badge-preview').src = newUrl;
document.getElementById('badge-url').textContent = newUrl;
function up1(theme) {
const preview = document.getElementById('badge-preview');
const url = document.getElementById('badge-url');
const baseUrl = preview.dataset.url.replace(/theme=[^&]*/, '');
const newUrl = baseUrl + (baseUrl.includes('?') ? '&' : '?') + 'theme=' + theme;
preview.src = newUrl;
url.textContent = newUrl;
}
function updateBadgeProject(project) {
const originalUrl = document.getElementById('work-time-badge-preview').dataset.url;
let splitUrl = originalUrl.split('/');
splitUrl[splitUrl.length - 1] = project;
const newUrl = splitUrl.join('/');
document.getElementById('work-time-badge-preview').src = newUrl;
document.getElementById('work-time-badge-url').textContent = newUrl;
function up2(project) {
const preview = document.getElementById('project-badge-preview');
const url = document.getElementById('project-badge-url');
const baseUrl = '<%= @work_time_stats_url.gsub(@projects.first || 'example', '') %>';
const newUrl = baseUrl + project;
preview.src = newUrl;
url.textContent = newUrl;
}
</script>
</article>
</div>
<article>
<header>
<h2 id="user_markscribe">📝 Markscribe Templates</h2>
<p>Use markscribe to create beautiful GitHub profile READMEs with your coding stats.</p>
</header>
<div class="code-example">
<pre><code>{{ wakatimeDoubleCategoryBar "💾 Languages:" wakatimeData.Languages "💼 Projects:" wakatimeData.Projects 5 }}</code></pre>
</div>
<p>Add this to your GitHub profile README template to display your top languages and projects.</p>
<p><small>See the <a href="https://github.com/taciturnaxolotl/markscribe#your-wakatime-languages-formated-as-a-bar" target="_blank">markscribe documentation</a> for more template options.</small></p>
<img src="https://cdn.fluff.pw/slackcdn/524e293aa09bc5f9115c0c29c18fb4bc.png" alt="Example of markscribe output showing coding language and project statistics" width="100%"/>
</article>
<article>
<header>
<h2 id="user_config_file">📄 Config File</h2>
<p>
<% if current_user.most_recent_direct_entry_heartbeat %>
Your last heartbeat was <%= time_ago_in_words current_user.most_recent_direct_entry_heartbeat.created_at %> ago.
<% else %>
You haven't sent any heartbeats yet directly to this platform.
<% end %>
<div class="border border-primary rounded-xl p-6 hover:bg-gray-800/50 transition-all duration-200 space-y-6">
<div>
<div class="flex items-center gap-3 mb-4">
<div class="p-2 bg-red-600/10 rounded">
<span class="text-2xl">📄</span>
</div>
<h2 class="text-xl font-semibold text-white" id="user_config_file">Config File</h2>
</div>
<p class="text-gray-300 text-sm mb-4">Your Wakatime configuration file for tracking coding time.</p>
<div class="bg-gray-800 border border-gray-600 rounded p-4 overflow-x-auto">
<%= render "wakatime_config_display" %>
</div>
<p class="text-xs text-gray-400 mt-2">
This configuration file is automatically generated and updated when you make changes to your settings.
</p>
</header>
</div>
<div class="border-t border-gray-700 pt-6">
<div class="flex items-center gap-3 mb-4">
<div class="p-2 bg-red-600/10 rounded">
<span class="text-2xl">🚚</span>
</div>
<h2 class="text-xl font-semibold text-white" id="user_migration_assistant">Migration Assistant</h2>
</div>
<p class="text-gray-300 text-sm mb-4">This will migrate your heartbeats from waka.hackclub.com to this platform.</p>
<%= button_to "Migrate heartbeats", my_settings_migrate_heartbeats_path, method: :post,
class: "w-full px-4 py-2 bg-primary text-white font-medium rounded transition-colors duration-200" %>
<% if @heartbeats_migration_jobs.any? %>
<div class="mt-4 space-y-2">
<h3 class="text-sm font-medium text-white">Migration Status</h3>
<% @heartbeats_migration_jobs.each do |job| %>
<div class="p-2 bg-gray-800 border border-gray-600 rounded text-xs text-gray-300">
Job ID: <%= job.id %> - Status: <%= job.status %>
</div>
<% end %>
</div>
<% end %>
</div>
</div>
<div class="border border-primary rounded-xl p-6 hover:bg-gray-800/50 transition-all duration-200 md:col-span-2">
<div class="flex items-center gap-3 mb-4">
<div class="p-2 bg-red-600/10 rounded">
<span class="text-2xl">📝</span>
</div>
<h2 class="text-xl font-semibold text-white" id="user_markscribe">Markscribe Templates</h2>
</div>
<p class="text-gray-300 text-sm mb-4">Use markscribe to create beautiful GitHub profile READMEs with your coding stats.</p>
<%= render "wakatime_config_display" %>
<p>
<small>
This file is located in <code>~/.wakatime.cfg</code> on your computer.
You can configure it with <a href="https://github.com/wakatime/wakatime-cli/blob/develop/USAGE.md#ini-config-file">other settings</a> as well.
</small>
</p>
</article>
<article>
<header>
<h2 id="user_privacy">🔒 Privacy Settings</h2>
</header>
<%= form_with model: @user,
url: @is_own_settings ? my_settings_path : settings_user_path(@user),
method: :patch do |f| %>
<fieldset>
<label for="user_allow_public_stats_lookup">
<%= f.check_box :allow_public_stats_lookup, id: "user_allow_public_stats_lookup" %>
<%= f.label :allow_public_stats_lookup, "Allow others to look up my public coding stats via the API" %>
</label>
</fieldset>
<%= f.submit "Save Settings", role: "button" %>
<% end %>
</article>
<article>
<header>
<h2 id="user_beta_features">🏆 Leaderboard settings</h2>
<p>Customize how you see the leaderboard</p>
</header>
<%= render "timezone_leaderboard_toggle", user: @user %>
</article>
<%#
<article>
<header>
<h2 id="user_beta_features">🧪 Beta Features</h2>
<p>Enable experimental features and help us test new functionality.</p>
</header>
</article>
%>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div>
<div class="p-4 bg-gray-800 border border-gray-600 rounded mb-4 overflow-x-auto">
<pre class="text-sm text-gray-200 whitespace-pre-wrap break-all"><code>{{ wakatimeDoubleCategoryBar "💾 Languages:" wakatimeData.Languages "💼 Projects:" wakatimeData.Projects 5 }}</code></pre>
</div>
<p class="text-gray-300 text-sm mb-2">Add this to your GitHub profile README template to display your top languages and projects.</p>
<p class="text-xs text-gray-400">See the <a href="https://github.com/taciturnaxolotl/markscribe#your-wakatime-languages-formated-as-a-bar" target="_blank" class="text-primary hover:text-primary/80 underline">markscribe documentation</a> for more template options.</p>
</div>
<div>
<img src="https://cdn.fluff.pw/slackcdn/524e293aa09bc5f9115c0c29c18fb4bc.png"
alt="Example of markscribe output showing coding language and project statistics"
class="w-full rounded border border-gray-600"/>
</div>
</div>
</div>
<% admin_tool do %>
<article>
<header>
<h2 id="wakatime_mirror">🔄 WakaTime Mirror</h2>
<p>Mirror your coding activity to WakaTime.</p>
</header>
<div class="p-6 md:col-span-2">
<div class="flex items-center gap-3 mb-4">
<div class="p-2 bg-red-600/10 rounded">
<span class="text-2xl">🔧</span>
</div>
<h2 class="text-xl font-semibold text-white">WakaTime Mirrors</h2>
</div>
<% if current_user.wakatime_mirrors.any? %>
<div class="mirrors-list">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<% current_user.wakatime_mirrors.each do |mirror| %>
<div class="mirror">
<p>
<strong>Endpoint:</strong> <%= mirror.endpoint_url %><br>
<strong>Last synced:</strong> <%= mirror.last_synced_at ? time_ago_in_words(mirror.last_synced_at) + " ago" : "Never" %>
</p>
<%= button_to "Delete", user_wakatime_mirror_path(current_user, mirror), method: :delete, role: "button", class: "secondary", data: { confirm: "Are you sure?" } %>
<div class="p-4 bg-gray-800 border border-gray-600 rounded">
<h3 class="text-white font-medium"><%= mirror.name %></h3>
<p class="text-gray-400 text-sm"><%= mirror.endpoint_url %></p>
</div>
<% end %>
</div>
<% end %>
<%= form_with(model: [current_user, WakatimeMirror.new], local: true) do |f| %>
<div class="form-group">
<%= f.label :endpoint_url, "WakaTime API Endpoint" %>
<%= f.text_field :endpoint_url, value: "https://wakatime.com/api/v1", placeholder: "https://wakatime.com/api/v1", class: "form-control" %>
<%= form_with(model: [current_user, WakatimeMirror.new], local: true, class: "space-y-4") do |f| %>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<%= f.label :name, class: "block text-sm font-medium text-gray-200 mb-2" %>
<%= f.text_field :name, class: "w-full px-3 py-2 bg-gray-800 border border-gray-600 rounded text-white focus:border-primary focus:ring-1 focus:ring-primary" %>
</div>
<div>
<%= f.label :endpoint_url, class: "block text-sm font-medium text-gray-200 mb-2" %>
<%= f.url_field :endpoint_url, class: "w-full px-3 py-2 bg-gray-800 border border-gray-600 rounded text-white focus:border-primary focus:ring-1 focus:ring-primary" %>
</div>
</div>
<div class="form-group">
<%= f.label :encrypted_api_key, "WakaTime API Key" %>
<%= f.text_field :encrypted_api_key, placeholder: "Enter your WakaTime API key", class: "form-control" %>
</div>
<%= f.submit "Add Mirror", role: "button" %>
<%= f.submit "Add Mirror", class: "px-4 py-2 bg-primary text-white font-medium rounded transition-colors duration-200" %>
<% end %>
</article>
</div>
<% end %>
<article>
<header>
<h2 id="user_migration_assistant">🚚 Migration Assistant</h2>
<p>This will migrate your heartbeats from waka.hackclub.com to this platform.</p>
</header>
<%= button_to "Migrate heartbeats", my_settings_migrate_heartbeats_path, method: :post, role: "button" %>
<% if @heartbeats_migration_jobs.any? %>
<section>
<h3>Migration Jobs</h3>
<ul>
<% @heartbeats_migration_jobs.each do |job| %>
<li>
<% if job.finished_at && !job.error %>
<% elsif job.finished_at && job.error %>
<% else %>
<% end %>
Job started at <%= job.created_at.strftime("%Y-%m-%d %H:%M:%S") %>
<% if job.finished_at %>
(and finished after <%= distance_of_time_in_words(job.finished_at - job.created_at) %>)
<% end %>
<% admin_tool('', 'span') do %>
<%= link_to "View job", GoodJob::Engine.routes.url_helpers.job_path(job.id) %>
<% end %>
</li>
<% end %>
</ul>
</section>
<% end %>
</article>
</div>
</main>
</div>