add nice statistics
This commit is contained in:
129
app/assets/stylesheets/app.css
Normal file
129
app/assets/stylesheets/app.css
Normal file
@@ -0,0 +1,129 @@
|
||||
/* Spotify-inspired dark theme for Plays Hub */
|
||||
|
||||
body {
|
||||
background: #f5f6fa;
|
||||
color: #222;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
main {
|
||||
padding-left: 3vw;
|
||||
padding-right: 3vw;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
main {
|
||||
padding-left: 1vw;
|
||||
padding-right: 1vw;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
color: #222;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
margin-top: 2em;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2.2em;
|
||||
margin-top: 1.5em;
|
||||
}
|
||||
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0 0 2em 0;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
|
||||
li {
|
||||
background: #fff;
|
||||
margin-bottom: 1.5em;
|
||||
padding: 1.3em 1.5em;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 2px 12px rgba(0,0,0,0.06);
|
||||
border: 1px solid #e0e0e0;
|
||||
transition: box-shadow 0.2s, border 0.2s;
|
||||
font-size: 1.05em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
li:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
li:hover {
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.09);
|
||||
border: 1px solid #bdbdbd;
|
||||
}
|
||||
|
||||
|
||||
button {
|
||||
background: #0071e3;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
padding: 0.8em 2.2em;
|
||||
font-size: 1.05em;
|
||||
font-weight: 600;
|
||||
cursor: not-allowed;
|
||||
opacity: 0.7;
|
||||
margin-top: 2em;
|
||||
transition: background 0.2s, opacity 0.2s;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
button:active {
|
||||
background: #005bb5;
|
||||
}
|
||||
|
||||
|
||||
.notice {
|
||||
background: #e8f0fe;
|
||||
color: #0071e3;
|
||||
padding: 0.7em 1.2em;
|
||||
border-radius: 10px;
|
||||
margin: 1.5em 0 0 0;
|
||||
font-weight: 500;
|
||||
border: none;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.04);
|
||||
}
|
||||
|
||||
.alert {
|
||||
background: #fff0f0;
|
||||
color: #d7263d;
|
||||
padding: 0.7em 1.2em;
|
||||
border-radius: 10px;
|
||||
margin: 1.5em 0 0 0;
|
||||
font-weight: 500;
|
||||
border: none;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.04);
|
||||
}
|
||||
|
||||
|
||||
@media (max-width: 600px) {
|
||||
body {
|
||||
font-size: 1em;
|
||||
}
|
||||
ul {
|
||||
max-width: 100%;
|
||||
}
|
||||
li {
|
||||
padding: 1em 0.7em;
|
||||
font-size: 1em;
|
||||
}
|
||||
button {
|
||||
width: 100%;
|
||||
padding: 1em 0;
|
||||
}
|
||||
}
|
||||
|
||||
8
app/controllers/home_controller.rb
Normal file
8
app/controllers/home_controller.rb
Normal file
@@ -0,0 +1,8 @@
|
||||
class HomeController < ApplicationController
|
||||
before_action :authenticate_user!
|
||||
|
||||
def index
|
||||
@user = current_user
|
||||
@logins = Login.where(user: @user)
|
||||
end
|
||||
end
|
||||
13
app/controllers/statistics_controller.rb
Normal file
13
app/controllers/statistics_controller.rb
Normal file
@@ -0,0 +1,13 @@
|
||||
class StatisticsController < ApplicationController
|
||||
before_action :authenticate_user!
|
||||
|
||||
def index
|
||||
stats = Statistics.new(current_user)
|
||||
@total_plays = stats.total_plays
|
||||
@activity_by_day = stats.activity_by_day
|
||||
@top_artists_all_time = stats.top_artists_all_time
|
||||
@top_artists_year = stats.top_artists_year
|
||||
@top_artists_upcoming = stats.top_artists_upcoming
|
||||
@since_date = stats.since_date
|
||||
end
|
||||
end
|
||||
219
app/javascript/activity_heatmap.js
Normal file
219
app/javascript/activity_heatmap.js
Normal file
@@ -0,0 +1,219 @@
|
||||
// Renders a GitHub-like activity heatmap given a container and data
|
||||
// Usage: ActivityHeatmap.render(containerId, data)
|
||||
// data: { 'YYYY-MM-DD': count, ... }
|
||||
|
||||
const ActivityHeatmap = {
|
||||
githubColors: [
|
||||
'#ebedf0', // 0
|
||||
'#9be9a8', // 1
|
||||
'#40c463', // 2
|
||||
'#30a14e', // 3
|
||||
'#216e39' // 4+
|
||||
],
|
||||
|
||||
getColor(count) {
|
||||
if (count === 0) return this.githubColors[0];
|
||||
if (count === 1) return this.githubColors[1];
|
||||
if (count <= 3) return this.githubColors[2];
|
||||
if (count <= 6) return this.githubColors[3];
|
||||
return this.githubColors[4];
|
||||
},
|
||||
|
||||
render(containerId, data) {
|
||||
const container = document.getElementById(containerId);
|
||||
if (!container) return;
|
||||
container.innerHTML = '';
|
||||
|
||||
// Build an array of all days in the past year
|
||||
const today = new Date();
|
||||
const startDate = new Date(today);
|
||||
startDate.setFullYear(today.getFullYear() - 1);
|
||||
// Always start on Monday
|
||||
const dayOfWeek = startDate.getDay(); // 0=Sun, 1=Mon, ..., 6=Sat
|
||||
const daysToMonday = (dayOfWeek === 0) ? 1 : (dayOfWeek === 1 ? 0 : 8 - dayOfWeek);
|
||||
startDate.setDate(startDate.getDate() + daysToMonday - (dayOfWeek === 0 ? 7 : 0));
|
||||
|
||||
const days = [];
|
||||
let d = new Date(startDate);
|
||||
while (d <= today) {
|
||||
days.push(new Date(d));
|
||||
d.setDate(d.getDate() + 1);
|
||||
}
|
||||
// Pad at end so last week is always full (7 days)
|
||||
while (days.length % 7 !== 0) {
|
||||
days.push(null); // null means empty block
|
||||
}
|
||||
|
||||
// Group days into weeks
|
||||
const weeks = [];
|
||||
for (let i = 0; i < days.length; i += 7) {
|
||||
weeks.push(days.slice(i, i + 7));
|
||||
}
|
||||
|
||||
// Set cell size and gap before using them
|
||||
const cellSize = 14;
|
||||
const cellGap = 3;
|
||||
|
||||
// Prepare month labels (x-axis), skip the first detected month
|
||||
const monthLabels = [];
|
||||
let prevMonth = null;
|
||||
let isFirstMonth = true;
|
||||
weeks.forEach((week, x) => {
|
||||
const firstDay = week[0];
|
||||
if (firstDay) {
|
||||
const month = firstDay.getMonth();
|
||||
if (month !== prevMonth) {
|
||||
if (!isFirstMonth) {
|
||||
monthLabels.push({
|
||||
label: firstDay.toLocaleString('default', { month: 'short' }),
|
||||
x: x * (cellSize + cellGap)
|
||||
});
|
||||
} else {
|
||||
isFirstMonth = false;
|
||||
}
|
||||
prevMonth = month;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Prepare weekday labels (y-axis) - y=0 is Monday
|
||||
const weekdayLabels = [
|
||||
{ label: 'Mon', y: 0 * (cellSize + cellGap) },
|
||||
{ label: 'Wed', y: 2 * (cellSize + cellGap) },
|
||||
{ label: 'Fri', y: 4 * (cellSize + cellGap) }
|
||||
];
|
||||
|
||||
// Build SVG
|
||||
const leftPad = 32; // Space for weekday labels
|
||||
const topPad = 18; // Space for month labels
|
||||
const width = leftPad + weeks.length * (cellSize + cellGap);
|
||||
const height = topPad + 7 * (cellSize + cellGap);
|
||||
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
svg.setAttribute('width', width);
|
||||
svg.setAttribute('height', height);
|
||||
svg.setAttribute('viewBox', `0 0 ${width} ${height}`);
|
||||
svg.style.display = 'block';
|
||||
|
||||
// Month labels (top x-axis)
|
||||
monthLabels.forEach(({ label, x }) => {
|
||||
const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
||||
text.setAttribute('x', leftPad + x + cellSize / 2);
|
||||
text.setAttribute('y', 12);
|
||||
text.setAttribute('text-anchor', 'middle');
|
||||
text.setAttribute('font-size', '11');
|
||||
text.setAttribute('fill', '#888');
|
||||
text.textContent = label;
|
||||
svg.appendChild(text);
|
||||
});
|
||||
|
||||
// Weekday labels (left y-axis)
|
||||
weekdayLabels.forEach(({ label, y }) => {
|
||||
const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
||||
text.setAttribute('x', leftPad - 6);
|
||||
text.setAttribute('y', topPad + y + cellSize - 3);
|
||||
text.setAttribute('text-anchor', 'end');
|
||||
text.setAttribute('font-size', '11');
|
||||
text.setAttribute('fill', '#888');
|
||||
text.textContent = label;
|
||||
svg.appendChild(text);
|
||||
});
|
||||
|
||||
// Draw heatmap cells
|
||||
// Tooltip: only one in DOM
|
||||
let tooltip = document.getElementById('activity-heatmap-tooltip');
|
||||
if (!tooltip) {
|
||||
tooltip = document.createElement('div');
|
||||
tooltip.id = 'activity-heatmap-tooltip';
|
||||
Object.assign(tooltip.style, {
|
||||
position: 'fixed',
|
||||
pointerEvents: 'none',
|
||||
background: '#fff',
|
||||
color: '#222',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.10)',
|
||||
padding: '7px 14px',
|
||||
fontSize: '13px',
|
||||
fontFamily: 'system-ui, sans-serif',
|
||||
visibility: 'hidden',
|
||||
zIndex: 1000,
|
||||
opacity: 0,
|
||||
transition: 'opacity 0.15s',
|
||||
});
|
||||
document.body.appendChild(tooltip);
|
||||
}
|
||||
|
||||
// Batch SVG elements for performance
|
||||
const frag = document.createDocumentFragment();
|
||||
|
||||
weeks.forEach((week, x) => {
|
||||
week.forEach((date, y) => {
|
||||
if (!date) {
|
||||
// Draw empty block
|
||||
const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
|
||||
rect.setAttribute('x', leftPad + x * (cellSize + cellGap));
|
||||
rect.setAttribute('y', topPad + y * (cellSize + cellGap));
|
||||
rect.setAttribute('width', cellSize);
|
||||
rect.setAttribute('height', cellSize);
|
||||
rect.setAttribute('fill', '#fff');
|
||||
rect.setAttribute('rx', 3);
|
||||
rect.setAttribute('ry', 3);
|
||||
frag.appendChild(rect);
|
||||
return;
|
||||
}
|
||||
const iso = date.toISOString().slice(0, 10);
|
||||
const count = data[iso] || 0;
|
||||
const color = ActivityHeatmap.getColor(count);
|
||||
const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
|
||||
rect.setAttribute('x', leftPad + x * (cellSize + cellGap));
|
||||
rect.setAttribute('y', topPad + y * (cellSize + cellGap));
|
||||
rect.setAttribute('width', cellSize);
|
||||
rect.setAttribute('height', cellSize);
|
||||
rect.setAttribute('fill', color);
|
||||
rect.setAttribute('rx', 3);
|
||||
rect.setAttribute('ry', 3);
|
||||
rect.setAttribute('data-date', iso);
|
||||
rect.setAttribute('data-count', count);
|
||||
rect.setAttribute('tabindex', '0');
|
||||
rect.setAttribute('aria-label', `${count} play${count === 1 ? '' : 's'} on ${date.toLocaleDateString(undefined, {weekday:'long', year:'numeric', month:'short', day:'numeric'})}`);
|
||||
rect.style.cursor = 'pointer';
|
||||
rect.removeAttribute('title');
|
||||
|
||||
// Tooltip logic
|
||||
const showTooltip = (e) => {
|
||||
tooltip.style.visibility = 'visible';
|
||||
tooltip.style.opacity = '1';
|
||||
const weekday = date.toLocaleDateString(undefined, { weekday: 'short' });
|
||||
tooltip.innerHTML = `<strong>${count}</strong> play${count === 1 ? '' : 's'}<br><span style='color:#666;'>${weekday}, ${iso}</span>`;
|
||||
};
|
||||
let rafId;
|
||||
const moveTooltip = (e) => {
|
||||
if (rafId) cancelAnimationFrame(rafId);
|
||||
rafId = requestAnimationFrame(() => {
|
||||
const pad = 12;
|
||||
let x = e.clientX + pad;
|
||||
let y = e.clientY + pad;
|
||||
if (x + tooltip.offsetWidth > window.innerWidth) x = window.innerWidth - tooltip.offsetWidth - pad;
|
||||
if (y + tooltip.offsetHeight > window.innerHeight) y = window.innerHeight - tooltip.offsetHeight - pad;
|
||||
tooltip.style.left = x + 'px';
|
||||
tooltip.style.top = y + 'px';
|
||||
});
|
||||
};
|
||||
const hideTooltip = () => {
|
||||
tooltip.style.visibility = 'hidden';
|
||||
tooltip.style.opacity = '0';
|
||||
};
|
||||
rect.addEventListener('mouseenter', showTooltip);
|
||||
rect.addEventListener('mousemove', moveTooltip);
|
||||
rect.addEventListener('mouseleave', hideTooltip);
|
||||
// Keyboard accessibility
|
||||
rect.addEventListener('focus', showTooltip);
|
||||
rect.addEventListener('blur', hideTooltip);
|
||||
frag.appendChild(rect);
|
||||
});
|
||||
});
|
||||
svg.appendChild(frag);
|
||||
container.appendChild(svg);
|
||||
}
|
||||
};
|
||||
|
||||
window.ActivityHeatmap = ActivityHeatmap;
|
||||
3
app/models/activity.rb
Normal file
3
app/models/activity.rb
Normal file
@@ -0,0 +1,3 @@
|
||||
class Activity < ApplicationRecord
|
||||
belongs_to :user
|
||||
end
|
||||
3
app/models/login.rb
Normal file
3
app/models/login.rb
Normal file
@@ -0,0 +1,3 @@
|
||||
class Login < ApplicationRecord
|
||||
belongs_to :user
|
||||
end
|
||||
2
app/models/shakti.rb
Normal file
2
app/models/shakti.rb
Normal file
@@ -0,0 +1,2 @@
|
||||
class Shakti < ApplicationRecord
|
||||
end
|
||||
40
app/models/statistics.rb
Normal file
40
app/models/statistics.rb
Normal file
@@ -0,0 +1,40 @@
|
||||
class Statistics
|
||||
attr_reader :user
|
||||
|
||||
def initialize(user)
|
||||
@user = user
|
||||
end
|
||||
|
||||
def total_plays
|
||||
Activity.where(user: user).count
|
||||
end
|
||||
|
||||
def activity_by_day
|
||||
start_date = 1.year.ago.to_date
|
||||
end_date = Date.today
|
||||
activities = Activity.where(user: user, created_at: start_date.beginning_of_day..end_date.end_of_day)
|
||||
activities.group("DATE(created_at)").count.transform_keys { |d| d.to_s }
|
||||
end
|
||||
|
||||
def top_artists_all_time
|
||||
Activity.where(user: user)
|
||||
.group(:item_title).order("count_id DESC").limit(5).count(:id)
|
||||
end
|
||||
|
||||
def top_artists_year
|
||||
year_start = Date.today.beginning_of_year
|
||||
Activity.where(user: user, created_at: year_start..Date.today.end_of_day)
|
||||
.group(:item_title).order("count_id DESC").limit(5).count(:id)
|
||||
end
|
||||
|
||||
def top_artists_upcoming
|
||||
upcoming_start = 30.days.ago.to_date
|
||||
upcoming_end = Date.today
|
||||
Activity.where(user: user, started_at: upcoming_start.beginning_of_day..upcoming_end.end_of_day)
|
||||
.group(:item_title).order("count_id DESC").limit(5).count(:id)
|
||||
end
|
||||
|
||||
def since_date
|
||||
Activity.where(user: user).order(:created_at).limit(1).pick(:created_at)
|
||||
end
|
||||
end
|
||||
6
app/models/user.rb
Normal file
6
app/models/user.rb
Normal file
@@ -0,0 +1,6 @@
|
||||
class User < ApplicationRecord
|
||||
# Include default devise modules. Others available are:
|
||||
# :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
|
||||
devise :database_authenticatable, :registerable,
|
||||
:recoverable, :rememberable, :validatable
|
||||
end
|
||||
16
app/views/devise/confirmations/new.html.erb
Normal file
16
app/views/devise/confirmations/new.html.erb
Normal 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" %>
|
||||
@@ -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>
|
||||
7
app/views/devise/mailer/email_changed.html.erb
Normal file
7
app/views/devise/mailer/email_changed.html.erb
Normal 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 %>
|
||||
3
app/views/devise/mailer/password_change.html.erb
Normal file
3
app/views/devise/mailer/password_change.html.erb
Normal 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>
|
||||
@@ -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>
|
||||
7
app/views/devise/mailer/unlock_instructions.html.erb
Normal file
7
app/views/devise/mailer/unlock_instructions.html.erb
Normal 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>
|
||||
25
app/views/devise/passwords/edit.html.erb
Normal file
25
app/views/devise/passwords/edit.html.erb
Normal 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" %>
|
||||
16
app/views/devise/passwords/new.html.erb
Normal file
16
app/views/devise/passwords/new.html.erb
Normal 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" %>
|
||||
43
app/views/devise/registrations/edit.html.erb
Normal file
43
app/views/devise/registrations/edit.html.erb
Normal 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 %>
|
||||
29
app/views/devise/registrations/new.html.erb
Normal file
29
app/views/devise/registrations/new.html.erb
Normal 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" %>
|
||||
26
app/views/devise/sessions/new.html.erb
Normal file
26
app/views/devise/sessions/new.html.erb
Normal 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" %>
|
||||
15
app/views/devise/shared/_error_messages.html.erb
Normal file
15
app/views/devise/shared/_error_messages.html.erb
Normal 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 %>
|
||||
25
app/views/devise/shared/_links.html.erb
Normal file
25
app/views/devise/shared/_links.html.erb
Normal 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 %>
|
||||
16
app/views/devise/unlocks/new.html.erb
Normal file
16
app/views/devise/unlocks/new.html.erb
Normal 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" %>
|
||||
17
app/views/home/index.html.erb
Normal file
17
app/views/home/index.html.erb
Normal 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>
|
||||
@@ -24,5 +24,11 @@
|
||||
|
||||
<body>
|
||||
<%= yield %>
|
||||
<% if notice %>
|
||||
<p class="notice"><%= notice %></p>
|
||||
<% end %>
|
||||
<% if alert %>
|
||||
<p class="alert"><%= alert %></p>
|
||||
<% end %>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
11
app/views/statistics/_activity_heatmap.html.erb
Normal file
11
app/views/statistics/_activity_heatmap.html.erb
Normal 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>
|
||||
3
app/views/statistics/_placeholder.html.erb
Normal file
3
app/views/statistics/_placeholder.html.erb
Normal 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>
|
||||
72
app/views/statistics/_top_artists.html.erb
Normal file
72
app/views/statistics/_top_artists.html.erb
Normal 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>
|
||||
11
app/views/statistics/_total_plays.html.erb
Normal file
11
app/views/statistics/_total_plays.html.erb
Normal 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>
|
||||
20
app/views/statistics/index.html.erb
Normal file
20
app/views/statistics/index.html.erb
Normal 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>
|
||||
Reference in New Issue
Block a user