mirror of
https://github.com/System-End/hackatime.git
synced 2026-04-19 19:55:16 +00:00
Profiles (#719)
* new username logic * lint * change up settings for username * user profiles
This commit is contained in:
parent
ec16e73fc4
commit
335cc42654
9 changed files with 228 additions and 7 deletions
76
app/controllers/profiles_controller.rb
Normal file
76
app/controllers/profiles_controller.rb
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
class ProfilesController < ApplicationController
|
||||
def show
|
||||
@user = find(params[:username])
|
||||
|
||||
if @user.nil?
|
||||
render :not_found, status: :not_found
|
||||
return
|
||||
end
|
||||
|
||||
@is_own_profile = current_user && current_user.id == @user.id
|
||||
@profile_visible = @user.allow_public_stats_lookup || @is_own_profile
|
||||
|
||||
return unless @profile_visible
|
||||
|
||||
load
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def find(username)
|
||||
User.find_by(username: username)
|
||||
end
|
||||
|
||||
def load
|
||||
Time.use_zone(@user.timezone) do
|
||||
@total_time_today = @user.heartbeats.today.duration_seconds
|
||||
@total_time_week = @user.heartbeats.this_week.duration_seconds
|
||||
@total_time_all = @user.heartbeats.duration_seconds
|
||||
|
||||
@top_languages = @user.heartbeats
|
||||
.where.not(language: [ nil, "" ])
|
||||
.group(:language)
|
||||
.duration_seconds
|
||||
.sort_by { |_, v| -v }
|
||||
.first(5)
|
||||
.to_h
|
||||
|
||||
@top_projects = @user.heartbeats
|
||||
.group(:project)
|
||||
.duration_seconds
|
||||
.sort_by { |_, v| -v }
|
||||
.first(5)
|
||||
.to_h
|
||||
|
||||
project_repo_mappings = @user.project_repo_mappings.index_by(&:project_name)
|
||||
|
||||
@top_projects_month = @user.heartbeats
|
||||
.where("time >= ?", 1.month.ago.to_f)
|
||||
.group(:project)
|
||||
.duration_seconds
|
||||
.sort_by { |_, v| -v }
|
||||
.first(6)
|
||||
.map do |project, duration|
|
||||
mapping = project_repo_mappings[project]
|
||||
{ project: project, duration: duration, repo_url: mapping&.repo_url }
|
||||
end
|
||||
|
||||
@top_editors = @user.heartbeats
|
||||
.where.not(editor: [ nil, "" ])
|
||||
.group(:editor)
|
||||
.duration_seconds
|
||||
.each_with_object(Hash.new(0)) do |(editor, duration), acc|
|
||||
normalized = ApplicationController.helpers.display_editor_name(editor)
|
||||
acc[normalized] += duration
|
||||
end
|
||||
.sort_by { |_, v| -v }
|
||||
.first(3)
|
||||
.to_h
|
||||
|
||||
@daily_durations = @user.heartbeats.daily_durations(user_timezone: @user.timezone).to_h
|
||||
|
||||
@streak_days = @user.streak_days
|
||||
@cool = @user.trust_level == 2
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -17,6 +17,7 @@ class User < ApplicationRecord
|
|||
validates :username,
|
||||
length: { maximum: USERNAME_MAX_LENGTH },
|
||||
format: { with: /\A[A-Za-z0-9_-]+\z/, message: "may only include letters, numbers, '-', and '_'" },
|
||||
uniqueness: { case_sensitive: false, message: "has already been taken" },
|
||||
allow_nil: true
|
||||
validate :username_must_be_visible
|
||||
|
||||
|
|
@ -566,7 +567,7 @@ class User < ApplicationRecord
|
|||
end
|
||||
|
||||
def display_name
|
||||
name = username || slack_username || github_username
|
||||
name = slack_username || github_username
|
||||
return name if name.present?
|
||||
|
||||
# "zach@hackclub.com" -> "zach (email sign-up)"
|
||||
|
|
|
|||
33
app/views/profiles/_project_cards.html.erb
Normal file
33
app/views/profiles/_project_cards.html.erb
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
<% if projects.present? && projects.size > 0 %>
|
||||
<%
|
||||
max = projects.map { |p| p[:duration] }.max || 1
|
||||
%>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<% projects.each do |project| %>
|
||||
<div class="border border-primary rounded-xl p-5 transition-all duration-300 flex flex-col gap-3">
|
||||
<div class="flex justify-between items-start gap-2">
|
||||
<h3 class="text-lg font-semibold text-white truncate flex-1" title="<%= h(project[:project]) %>">
|
||||
<%= h(project[:project].presence || "Unknown") %>
|
||||
</h3>
|
||||
<% if project[:repo_url].present? %>
|
||||
<%= link_to project[:repo_url], target: "_blank", title: "View repository", class: "p-2 rounded-lg bg-white/5 hover:bg-white/10 transition-colors duration-200 shrink-0" do %>
|
||||
<svg class="w-4 h-4 text-white/70" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 2A10 10 0 0 0 2 12c0 4.42 2.87 8.17 6.84 9.5c.5.08.66-.23.66-.5v-1.69c-2.77.6-3.36-1.34-3.36-1.34c-.46-1.16-1.11-1.47-1.11-1.47c-.91-.62.07-.6.07-.6c1 .07 1.53 1.03 1.53 1.03c.87 1.52 2.34 1.07 2.91.83c.09-.65.35-1.09.63-1.34c-2.22-.25-4.55-1.11-4.55-4.92c0-1.11.38-2 1.03-2.71c-.1-.25-.45-1.29.1-2.64c0 0 .84-.27 2.75 1.02c.79-.22 1.65-.33 2.5-.33s1.71.11 2.5.33c1.91-1.29 2.75-1.02 2.75-1.02c.55 1.35.2 2.39.1 2.64c.65.71 1.03 1.6 1.03 2.71c0 3.82-2.34 4.66-4.57 4.91c.36.31.69.92.69 1.85V21c0 .27.16.59.67.5C19.14 20.16 22 16.42 22 12A10 10 0 0 0 12 2" />
|
||||
</svg>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-2xl font-bold text-primary"><%= ApplicationController.helpers.short_time_simple(project[:duration]) %></span>
|
||||
</div>
|
||||
|
||||
<div class="w-full h-2 bg-darkless rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full bg-primary rounded-full"
|
||||
style="width: <%= [project[:duration].to_f / max.to_f * 100, 100].min %>%;"></div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
7
app/views/profiles/not_found.html.erb
Normal file
7
app/views/profiles/not_found.html.erb
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<div class="container">
|
||||
<div class="text-center py-16">
|
||||
<h1 class="text-3xl font-bold mb-2">User not found</h1>
|
||||
<p class="text-gray-400 mb-6">We couldn't find a user with that username... Usernames are case-sensitive if that helps.</p>
|
||||
<%= link_to "Go back home", root_path, class: "inline-block px-6 py-3 bg-primary text-white rounded font-bold hover:bg-primary/80 transition-colors" %>
|
||||
</div>
|
||||
</div>
|
||||
103
app/views/profiles/show.html.erb
Normal file
103
app/views/profiles/show.html.erb
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
<div class="container">
|
||||
<div class="flex items-center gap-4 mb-6">
|
||||
<img src="<%= @user.avatar_url %>" alt="<%= @user.display_name %>" class="w-20 h-20 rounded-full border-2 border-primary">
|
||||
<div>
|
||||
<div class="flex items-center gap-3">
|
||||
<h1 class="text-4xl font-bold m-0"><%= @user.display_name %></h1>
|
||||
<% if @user.trust_level == "green" %>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-8 h-8" viewBox="0 0 256 256" fill="#EC3750" title="Verified"><path d="M225.86 102.82c-3.77-3.94-7.67-8-9.14-11.57c-1.36-3.27-1.44-8.69-1.52-13.94c-.15-9.76-.31-20.82-8-28.51s-18.75-7.85-28.51-8c-5.25-.08-10.67-.16-13.94-1.52c-3.56-1.47-7.63-5.37-11.57-9.14C146.28 23.51 138.44 16 128 16s-18.27 7.51-25.18 14.14c-3.94 3.77-8 7.67-11.57 9.14c-3.25 1.36-8.69 1.44-13.94 1.52c-9.76.15-20.82.31-28.51 8s-7.8 18.75-8 28.51c-.08 5.25-.16 10.67-1.52 13.94c-1.47 3.56-5.37 7.63-9.14 11.57C23.51 109.72 16 117.56 16 128s7.51 18.27 14.14 25.18c3.77 3.94 7.67 8 9.14 11.57c1.36 3.27 1.44 8.69 1.52 13.94c.15 9.76.31 20.82 8 28.51s18.75 7.85 28.51 8c5.25.08 10.67.16 13.94 1.52c3.56 1.47 7.63 5.37 11.57 9.14c6.9 6.63 14.74 14.14 25.18 14.14s18.27-7.51 25.18-14.14c3.94-3.77 8-7.67 11.57-9.14c3.27-1.36 8.69-1.44 13.94-1.52c9.76-.15 20.82-.31 28.51-8s7.85-18.75 8-28.51c.08-5.25.16-10.67 1.52-13.94c1.47-3.56 5.37-7.63 9.14-11.57c6.63-6.9 14.14-14.74 14.14-25.18s-7.51-18.27-14.14-25.18m-52.2 6.84l-56 56a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L112 148.69l50.34-50.35a8 8 0 0 1 11.32 11.32"/></svg>
|
||||
<% end %>
|
||||
<% if @profile_visible && @streak_days && @streak_days > 0 %>
|
||||
<%= render "static_pages/streak", user: @user, streak_count: @streak_days, turbo_frame: false, icon_size: 20, show_text: true %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% if @user.username.present? %>
|
||||
<p class="text-gray-400 m-0">@<%= @user.username %></p>
|
||||
<% end %>
|
||||
<% if @user.github_username.present? %>
|
||||
<a href="<%= @user.github_profile_url %>" target="_blank" class="text-gray-400 hover:text-primary inline-flex items-center gap-1 mt-1">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" viewBox="0 0 24 24"><path fill="currentColor" d="M12 2A10 10 0 0 0 2 12c0 4.42 2.87 8.17 6.84 9.5c.5.08.66-.23.66-.5v-1.69c-2.77.6-3.36-1.34-3.36-1.34c-.46-1.16-1.11-1.47-1.11-1.47c-.91-.62.07-.6.07-.6c1 .07 1.53 1.03 1.53 1.03c.87 1.52 2.34 1.07 2.91.83c.09-.65.35-1.09.63-1.34c-2.22-.25-4.55-1.11-4.55-4.92c0-1.11.38-2 1.03-2.71c-.1-.25-.45-1.29.1-2.64c0 0 .84-.27 2.75 1.02c.79-.22 1.65-.33 2.5-.33s1.71.11 2.5.33c1.91-1.29 2.75-1.02 2.75-1.02c.55 1.35.2 2.39.1 2.64c.65.71 1.03 1.6 1.03 2.71c0 3.82-2.34 4.66-4.57 4.91c.36.31.69.92.69 1.85V21c0 .27.16.59.67.5C19.14 20.16 22 16.42 22 12A10 10 0 0 0 12 2" /></svg>
|
||||
<%= @user.github_username %>
|
||||
</a>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<% if @profile_visible %>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-6">
|
||||
<div class="border border-primary rounded-xl p-5 text-center">
|
||||
<div class="text-sm text-gray-400 uppercase tracking-wide mb-1">Today</div>
|
||||
<div class="text-2xl font-bold text-primary"><%= ApplicationController.helpers.short_time_simple(@total_time_today) %></div>
|
||||
</div>
|
||||
<div class="border border-primary rounded-xl p-5 text-center">
|
||||
<div class="text-sm text-gray-400 uppercase tracking-wide mb-1">This Week</div>
|
||||
<div class="text-2xl font-bold text-primary"><%= ApplicationController.helpers.short_time_simple(@total_time_week) %></div>
|
||||
</div>
|
||||
<div class="border border-primary rounded-xl p-5 text-center">
|
||||
<div class="text-sm text-gray-400 uppercase tracking-wide mb-1">All Time</div>
|
||||
<div class="text-2xl font-bold text-primary"><%= ApplicationController.helpers.short_time_simple(@total_time_all) %></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<% if @top_projects_month.present? && @top_projects_month.size > 0 %>
|
||||
<div class="mb-6">
|
||||
<h2 class="text-xl font-bold mb-4">Top Projects <span class="text-gray-400 font-normal text-base">(Past Month)</span></h2>
|
||||
<%= render "profiles/project_cards", projects: @top_projects_month %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
|
||||
<% if @top_languages.present? && @top_languages.size > 0 %>
|
||||
<div class="border border-primary rounded-xl p-5">
|
||||
<h2 class="text-xl font-bold mb-4">Top Languages</h2>
|
||||
<div class="flex flex-col gap-3">
|
||||
<% max_duration = @top_languages.values.max %>
|
||||
<% @top_languages.each do |language, duration| %>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-24 text-sm text-gray-300 truncate"><%= ApplicationController.helpers.display_language_name(language) || "Unknown" %></div>
|
||||
<div class="flex-1 h-6 bg-darkless rounded-full overflow-hidden relative">
|
||||
<div class="h-full bg-primary rounded-full flex items-center justify-end pr-2" style="width: <%= [duration.to_f / max_duration * 100, 100].min.round %>%">
|
||||
<span class="text-xs font-medium text-white"><%= ApplicationController.helpers.short_time_simple(duration) %></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% if @top_editors.present? && @top_editors.size > 0 %>
|
||||
<div class="border border-primary rounded-xl p-5">
|
||||
<h2 class="text-xl font-bold mb-4">Favorite Editors</h2>
|
||||
<div class="flex flex-col gap-3">
|
||||
<% max_duration = @top_editors.values.max %>
|
||||
<% @top_editors.each do |editor, duration| %>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-24 text-sm text-gray-300 truncate"><%= editor %></div>
|
||||
<div class="flex-1 h-6 bg-darkless rounded-full overflow-hidden relative">
|
||||
<div class="h-full bg-primary rounded-full flex items-center justify-end pr-2" style="width: <%= [duration.to_f / max_duration * 100, 100].min.round %>%">
|
||||
<span class="text-xs font-medium text-white"><%= ApplicationController.helpers.short_time_simple(duration) %></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<% if @daily_durations.present? %>
|
||||
<div class="mt-6">
|
||||
<h2 class="text-xl font-bold mb-4">Activity</h2>
|
||||
<%= render "static_pages/activity_graph", daily_durations: @daily_durations, length_of_busiest_day: 8.hours.to_i %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<div class="text-center py-16">
|
||||
<div class="text-6xl mb-4">🔒</div>
|
||||
<h2 class="text-2xl font-bold mb-2">This user has their stats private</h2>
|
||||
<p class="text-gray-400">They've chosen not to share their coding statistics publicly.</p>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
|
@ -45,7 +45,7 @@
|
|||
</div>
|
||||
<% end %>
|
||||
<%= link_to slack_auth_path, class: "flex items-center justify-center px-4 py-3 rounded text-white cursor-pointer border border-darkless bg-dark hover:bg-gray-600 transition-colors w-1/4 gap-2" do %>
|
||||
<span><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="w-6 h-6"><path fill="currentColor" d="M6 15a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2h2zm1 0a2 2 0 0 1 2-2a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2a2 2 0 0 1-2-2zm2-8a2 2 0 0 1-2-2a2 2 0 0 1 2-2a2 2 0 0 1 2 2v2zm0 1a2 2 0 0 1 2 2a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2a2 2 0 0 1 2-2zm8 2a2 2 0 0 1 2-2a2 2 0 0 1 2 2a2 2 0 0 1-2 2h-2zm-1 0a2 2 0 0 1-2 2a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2a2 2 0 0 1 2 2zm-2 8a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2v-2zm0-1a2 2 0 0 1-2-2a2 2 0 0 1 2-2h5a2 2 0 0 1 2 2a2 2 0 0 1-2 2z"/></svg></span>
|
||||
<span><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="w-6 h-6"><path fill="currentColor" d="M6 15a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2h2zm1 0a2 2 0 0 1 2-2a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2a2 2 0 0 1-2-2zm2-8a2 2 0 0 1-2-2a2 2 0 0 1 2-2a2 2 0 0 1 2 2v2zm0 1a2 2 0 0 1 2 2a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2a2 2 0 0 1 2-2zm8 2a2 2 0 0 1 2-2a2 2 0 0 1 2 2a2 2 0 0 1-2 2h-2zm-1 0a2 2 0 0 1-2 2a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2a2 2 0 0 1 2 2zm-2 8a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2v-2zm0-1a2 2 0 0 1-2-2a2 2 0 0 1 2-2h5a2 2 0 0 1 2 2a2 2 0 0 1-2 2z" /></svg></span>
|
||||
<span class="hidden xl:inline">Slack Sign In</span>
|
||||
<% end %>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -73,14 +73,14 @@
|
|||
<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_username">Display Name</h2>
|
||||
<h2 class="text-xl font-semibold text-white" id="user_username">Username</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>
|
||||
<%= f.label :username, "Custom display name", class: "block text-sm font-medium text-gray-200 mb-2" %>
|
||||
<%= f.label :username, "Custom username", class: "block text-sm font-medium text-gray-200 mb-2" %>
|
||||
<%= f.text_field :username,
|
||||
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",
|
||||
placeholder: "HackClubber",
|
||||
|
|
@ -90,7 +90,7 @@
|
|||
<% end %>
|
||||
</div>
|
||||
<p class="text-xs text-gray-400">
|
||||
Choose a name to use in Hackatime. This will take priority over Slack or GitHub names when possible. Letters, numbers, "-" and "_" only, max <%= User::USERNAME_MAX_LENGTH %> chars.
|
||||
Choose a name to use in Hackatime. This will take priority over Slack or GitHub names when possible. You will be able to use your username in your profile URL, hackatime.hackclub.com/@username. Letters, numbers, "-" and "_" only, max <%= User::USERNAME_MAX_LENGTH %> chars.
|
||||
</p>
|
||||
<%= f.submit "Save Settings", class: "w-full px-4 py-2 bg-primary text-white font-medium rounded transition-colors duration-200 cursor-pointer" %>
|
||||
<% end %>
|
||||
|
|
@ -628,7 +628,6 @@
|
|||
custom: '<div class="w-full mb-4"><div class="bg-darker rounded-lg p-3"><code id="new-api-key-display" class="text-sm text-white break-all" data-token=""></code></div></div>' %>
|
||||
</div>
|
||||
|
||||
|
||||
<% if @user.can_request_deletion? %>
|
||||
<div data-controller="account-deletion">
|
||||
<%= render "shared/modal",
|
||||
|
|
|
|||
|
|
@ -178,7 +178,7 @@
|
|||
const now = new Date();
|
||||
const secondsAgo = (now - heartbeatTime) / 1000;
|
||||
const recentThreshold = 30; // 30 seconds
|
||||
|
||||
|
||||
if (secondsAgo <= recentThreshold) {
|
||||
window.location.href = '<%= my_wakatime_setup_step_2_path %>';
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -216,6 +216,8 @@ Rails.application.routes.draw do
|
|||
end
|
||||
end
|
||||
|
||||
get "/@:username", to: "profiles#show", as: :profile, constraints: { username: /[A-Za-z0-9_-]+/ }
|
||||
|
||||
# SEO routes
|
||||
get "/sitemap.xml", to: "sitemap#sitemap", defaults: { format: "xml" }
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue