add nice statistics

This commit is contained in:
2025-06-21 09:38:22 +02:00
parent 99b0aaa72e
commit e79626972f
43 changed files with 1359 additions and 1 deletions

View File

@@ -0,0 +1,16 @@
<h2>Resend confirmation instructions</h2>
<%= form_for(resource, as: resource_name, url: confirmation_path(resource_name), html: { method: :post }) do |f| %>
<%= render "devise/shared/error_messages", resource: resource %>
<div class="field">
<%= f.label :email %><br />
<%= f.email_field :email, autofocus: true, autocomplete: "email", value: (resource.pending_reconfirmation? ? resource.unconfirmed_email : resource.email) %>
</div>
<div class="actions">
<%= f.submit "Resend confirmation instructions" %>
</div>
<% end %>
<%= render "devise/shared/links" %>

View File

@@ -0,0 +1,5 @@
<p>Welcome <%= @email %>!</p>
<p>You can confirm your account email through the link below:</p>
<p><%= link_to 'Confirm my account', confirmation_url(@resource, confirmation_token: @token) %></p>

View File

@@ -0,0 +1,7 @@
<p>Hello <%= @email %>!</p>
<% if @resource.try(:unconfirmed_email?) %>
<p>We're contacting you to notify you that your email is being changed to <%= @resource.unconfirmed_email %>.</p>
<% else %>
<p>We're contacting you to notify you that your email has been changed to <%= @resource.email %>.</p>
<% end %>

View File

@@ -0,0 +1,3 @@
<p>Hello <%= @resource.email %>!</p>
<p>We're contacting you to notify you that your password has been changed.</p>

View File

@@ -0,0 +1,8 @@
<p>Hello <%= @resource.email %>!</p>
<p>Someone has requested a link to change your password. You can do this through the link below.</p>
<p><%= link_to 'Change my password', edit_password_url(@resource, reset_password_token: @token) %></p>
<p>If you didn't request this, please ignore this email.</p>
<p>Your password won't change until you access the link above and create a new one.</p>

View File

@@ -0,0 +1,7 @@
<p>Hello <%= @resource.email %>!</p>
<p>Your account has been locked due to an excessive number of unsuccessful sign in attempts.</p>
<p>Click the link below to unlock your account:</p>
<p><%= link_to 'Unlock my account', unlock_url(@resource, unlock_token: @token) %></p>

View File

@@ -0,0 +1,25 @@
<h2>Change your password</h2>
<%= form_for(resource, as: resource_name, url: password_path(resource_name), html: { method: :put }) do |f| %>
<%= render "devise/shared/error_messages", resource: resource %>
<%= f.hidden_field :reset_password_token %>
<div class="field">
<%= f.label :password, "New password" %><br />
<% if @minimum_password_length %>
<em>(<%= @minimum_password_length %> characters minimum)</em><br />
<% end %>
<%= f.password_field :password, autofocus: true, autocomplete: "new-password" %>
</div>
<div class="field">
<%= f.label :password_confirmation, "Confirm new password" %><br />
<%= f.password_field :password_confirmation, autocomplete: "new-password" %>
</div>
<div class="actions">
<%= f.submit "Change my password" %>
</div>
<% end %>
<%= render "devise/shared/links" %>

View File

@@ -0,0 +1,16 @@
<h2>Forgot your password?</h2>
<%= form_for(resource, as: resource_name, url: password_path(resource_name), html: { method: :post }) do |f| %>
<%= render "devise/shared/error_messages", resource: resource %>
<div class="field">
<%= f.label :email %><br />
<%= f.email_field :email, autofocus: true, autocomplete: "email" %>
</div>
<div class="actions">
<%= f.submit "Send me reset password instructions" %>
</div>
<% end %>
<%= render "devise/shared/links" %>

View File

@@ -0,0 +1,43 @@
<h2>Edit <%= resource_name.to_s.humanize %></h2>
<%= form_for(resource, as: resource_name, url: registration_path(resource_name), html: { method: :put }) do |f| %>
<%= render "devise/shared/error_messages", resource: resource %>
<div class="field">
<%= f.label :email %><br />
<%= f.email_field :email, autofocus: true, autocomplete: "email" %>
</div>
<% if devise_mapping.confirmable? && resource.pending_reconfirmation? %>
<div>Currently waiting confirmation for: <%= resource.unconfirmed_email %></div>
<% end %>
<div class="field">
<%= f.label :password %> <i>(leave blank if you don't want to change it)</i><br />
<%= f.password_field :password, autocomplete: "new-password" %>
<% if @minimum_password_length %>
<br />
<em><%= @minimum_password_length %> characters minimum</em>
<% end %>
</div>
<div class="field">
<%= f.label :password_confirmation %><br />
<%= f.password_field :password_confirmation, autocomplete: "new-password" %>
</div>
<div class="field">
<%= f.label :current_password %> <i>(we need your current password to confirm your changes)</i><br />
<%= f.password_field :current_password, autocomplete: "current-password" %>
</div>
<div class="actions">
<%= f.submit "Update" %>
</div>
<% end %>
<h3>Cancel my account</h3>
<div>Unhappy? <%= button_to "Cancel my account", registration_path(resource_name), data: { confirm: "Are you sure?", turbo_confirm: "Are you sure?" }, method: :delete %></div>
<%= link_to "Back", :back %>

View File

@@ -0,0 +1,29 @@
<h2>Sign up</h2>
<%= form_for(resource, as: resource_name, url: registration_path(resource_name)) do |f| %>
<%= render "devise/shared/error_messages", resource: resource %>
<div class="field">
<%= f.label :email %><br />
<%= f.email_field :email, autofocus: true, autocomplete: "email" %>
</div>
<div class="field">
<%= f.label :password %>
<% if @minimum_password_length %>
<em>(<%= @minimum_password_length %> characters minimum)</em>
<% end %><br />
<%= f.password_field :password, autocomplete: "new-password" %>
</div>
<div class="field">
<%= f.label :password_confirmation %><br />
<%= f.password_field :password_confirmation, autocomplete: "new-password" %>
</div>
<div class="actions">
<%= f.submit "Sign up" %>
</div>
<% end %>
<%= render "devise/shared/links" %>

View File

@@ -0,0 +1,26 @@
<h2>Log in</h2>
<%= form_for(resource, as: resource_name, url: session_path(resource_name)) do |f| %>
<div class="field">
<%= f.label :email %><br />
<%= f.email_field :email, autofocus: true, autocomplete: "email" %>
</div>
<div class="field">
<%= f.label :password %><br />
<%= f.password_field :password, autocomplete: "current-password" %>
</div>
<% if devise_mapping.rememberable? %>
<div class="field">
<%= f.check_box :remember_me %>
<%= f.label :remember_me %>
</div>
<% end %>
<div class="actions">
<%= f.submit "Log in" %>
</div>
<% end %>
<%= render "devise/shared/links" %>

View File

@@ -0,0 +1,15 @@
<% if resource.errors.any? %>
<div id="error_explanation" data-turbo-cache="false">
<h2>
<%= I18n.t("errors.messages.not_saved",
count: resource.errors.count,
resource: resource.class.model_name.human.downcase)
%>
</h2>
<ul>
<% resource.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
<% end %>

View File

@@ -0,0 +1,25 @@
<%- if controller_name != 'sessions' %>
<%= link_to "Log in", new_session_path(resource_name) %><br />
<% end %>
<%- if devise_mapping.registerable? && controller_name != 'registrations' %>
<%= link_to "Sign up", new_registration_path(resource_name) %><br />
<% end %>
<%- if devise_mapping.recoverable? && controller_name != 'passwords' && controller_name != 'registrations' %>
<%= link_to "Forgot your password?", new_password_path(resource_name) %><br />
<% end %>
<%- if devise_mapping.confirmable? && controller_name != 'confirmations' %>
<%= link_to "Didn't receive confirmation instructions?", new_confirmation_path(resource_name) %><br />
<% end %>
<%- if devise_mapping.lockable? && resource_class.unlock_strategy_enabled?(:email) && controller_name != 'unlocks' %>
<%= link_to "Didn't receive unlock instructions?", new_unlock_path(resource_name) %><br />
<% end %>
<%- if devise_mapping.omniauthable? %>
<%- resource_class.omniauth_providers.each do |provider| %>
<%= button_to "Sign in with #{OmniAuth::Utils.camelize(provider)}", omniauth_authorize_path(resource_name, provider), data: { turbo: false } %><br />
<% end %>
<% end %>

View File

@@ -0,0 +1,16 @@
<h2>Resend unlock instructions</h2>
<%= form_for(resource, as: resource_name, url: unlock_path(resource_name), html: { method: :post }) do |f| %>
<%= render "devise/shared/error_messages", resource: resource %>
<div class="field">
<%= f.label :email %><br />
<%= f.email_field :email, autofocus: true, autocomplete: "email" %>
</div>
<div class="actions">
<%= f.submit "Resend unlock instructions" %>
</div>
<% end %>
<%= render "devise/shared/links" %>

View File

@@ -0,0 +1,17 @@
<div style="display: flex; justify-content: flex-end; margin-bottom: 1.5em;">
<%= link_to 'Statistics Dashboard', statistics_path, style: 'background: #0071e3; color: #fff; padding: 0.6em 1.5em; border-radius: 10px; text-decoration: none; font-weight: 500; font-size: 1.05em; box-shadow: 0 1px 4px rgba(0,0,0,0.05); transition: background 0.2s;'
%>
</div>
<h1>Hello, <%= @user.email %>!</h1>
<h2>Your Logins</h2>
<ul>
<% @logins.each do |login| %>
<li>
Platform: <%= login.platform %> | Last refreshed: <%= login.last_refresh_at || 'Never' %>
</li>
<% end %>
</ul>
<button disabled style="margin-top: 20px;">Add New Login (Coming Soon)</button>

View File

@@ -24,5 +24,11 @@
<body>
<%= yield %>
<% if notice %>
<p class="notice"><%= notice %></p>
<% end %>
<% if alert %>
<p class="alert"><%= alert %></p>
<% end %>
</body>
</html>

View File

@@ -0,0 +1,11 @@
<div style="background: #fff; border-radius: 16px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); padding: 2em 2.5em; margin: 2em 0 2.5em 0; width: 100%; max-width: 1200px; margin-left: auto; margin-right: auto;">
<h2 style="margin-top:0; font-size: 1.4em; color: #0071e3;">Activity History</h2>
<div id="activity-heatmap" style="margin-top: 1em;"></div>
<script>
document.addEventListener('DOMContentLoaded', function() {
if (window.ActivityHeatmap) {
ActivityHeatmap.render('activity-heatmap', <%= raw @activity_by_day.to_json %>);
}
});
</script>
</div>

View File

@@ -0,0 +1,3 @@
<div style="background: #fff; border-radius: 16px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); padding: 2em 2.5em; color: #888; display: flex; align-items: center; justify-content: center; min-height: 100px;">
<span>(Placeholder widget)</span>
</div>

View File

@@ -0,0 +1,72 @@
<div style="background: #fff; border-radius: 16px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); padding: 2em 2.5em; min-width: 340px; max-width: 600px; flex: 2 1 340px; display: flex; flex-direction: column; align-items: center; min-height: 340px;">
<h2 style="margin-top:0; font-size: 1.4em; color: #0071e3;">Top 5 Songs</h2>
<div id="artist-tabs" style="display: flex; gap: 0.5em; margin: 1em 0 1.5em 0;">
<button class="artist-tab" data-tab="upcoming" style="background: #e8f0fe; color: #0071e3; border: none; border-radius: 8px; padding: 0.4em 1em; font-weight: 500; cursor: pointer;">Upcoming</button>
<button class="artist-tab" data-tab="year" style="background: #f3f3f3; color: #222; border: none; border-radius: 8px; padding: 0.4em 1em; font-weight: 500; cursor: pointer;">This Year</button>
<button class="artist-tab" data-tab="all" style="background: #f3f3f3; color: #222; border: none; border-radius: 8px; padding: 0.4em 1em; font-weight: 500; cursor: pointer;">All Time</button>
</div>
<div id="artists-all" style="display:none;">
<% if @top_artists_all_time.present? %>
<ol style="padding-left: 1.2em; margin: 0; width: 100%;">
<% @top_artists_all_time.each do |artist, count| %>
<li style="margin-bottom: 0.8em; display: flex; justify-content: space-between; align-items: center;">
<span><%= artist %></span>
<span style="color: #888; font-size: 1em;">Plays: <%= count %></span>
</li>
<% end %>
</ol>
<% else %>
<div style="color: #888;">No data</div>
<% end %>
</div>
<div id="artists-year" style="display:none;">
<% if @top_artists_year.present? %>
<ol style="padding-left: 1.2em; margin: 0; width: 100%;">
<% @top_artists_year.each do |artist, count| %>
<li style="margin-bottom: 0.8em; display: flex; justify-content: space-between; align-items: center;">
<span><%= artist %></span>
<span style="color: #888; font-size: 1em;">Plays: <%= count %></span>
</li>
<% end %>
</ol>
<% else %>
<div style="color: #888;">No data</div>
<% end %>
</div>
<div id="artists-upcoming" style="padding-left: 1.2em; margin: 0; width: 100%;">
<% if @top_artists_upcoming.present? %>
<ol style="padding-left: 1.2em; margin: 0; width: 100%;">
<% @top_artists_upcoming.each do |artist, count| %>
<li style="margin-bottom: 0.8em; display: flex; justify-content: space-between; align-items: center;">
<span><%= artist %></span>
<span style="color: #888; font-size: 1em;">Plays: <%= count %></span>
</li>
<% end %>
</ol>
<% else %>
<div style="color: #888;">No data</div>
<% end %>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const tabs = document.querySelectorAll('.artist-tab');
const views = {
all: document.getElementById('artists-all'),
year: document.getElementById('artists-year'),
upcoming: document.getElementById('artists-upcoming'),
};
tabs.forEach(tab => {
tab.addEventListener('click', function() {
tabs.forEach(t => {
t.style.background = '#f3f3f3';
t.style.color = '#222';
});
this.style.background = '#e8f0fe';
this.style.color = '#0071e3';
Object.keys(views).forEach(k => views[k].style.display = 'none');
views[this.dataset.tab].style.display = '';
});
});
});
</script>
</div>

View File

@@ -0,0 +1,11 @@
<div style="background: #fff; border-radius: 16px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); padding: 2em 2.5em;">
<h2 style="margin-top:0; font-size: 1.4em; color: #0071e3;">Total Plays</h2>
<div style="font-size: 2.5em; font-weight: 700; margin-top: 0.5em; color: #222;">
<%= @total_plays %>
</div>
<% if defined?(@since_date) && @since_date.present? %>
<div style="margin-top: 0.8em; color: #888; font-size: 1em; font-weight: 400;">
since <%= @since_date.strftime('%b %-d, %Y') %>
</div>
<% end %>
</div>

View File

@@ -0,0 +1,20 @@
<main>
<h1>Statistics Dashboard</h1>
<%= javascript_include_tag 'activity_heatmap', 'data-turbo-track': 'reload' %>
<!-- Activity Heatmap (full width) -->
<%= render partial: 'statistics/activity_heatmap', locals: { activity_by_day: @activity_by_day } %>
<!-- Widgets row: Top 5 Artists (left, half), Total Plays + Placeholder (right, stacked) -->
<div style="display: flex; flex-wrap: wrap; gap: 2em; justify-content: center; align-items: flex-start; max-width: 1200px; margin-left: auto; margin-right: auto;">
<!-- Top 5 Artists Widget -->
<%= render partial: 'statistics/top_artists', locals: { top_artists_all_time: @top_artists_all_time, top_artists_year: @top_artists_year, top_artists_upcoming: @top_artists_upcoming } %>
<!-- Right column: Total Plays + Placeholder stacked -->
<div style="display: flex; flex-direction: column; gap: 2em; flex: 1 1 220px; min-width: 220px; max-width: 320px; align-self: flex-start;">
<!-- Total Plays Widget -->
<%= render partial: 'statistics/total_plays', locals: { total_plays: @total_plays } %>
<!-- Placeholder Widget -->
<%= render partial: 'statistics/placeholder' %>
</div>
</div>
</main>