diff --git a/Gemfile b/Gemfile
index 53d27b9..a520912 100644
--- a/Gemfile
+++ b/Gemfile
@@ -49,3 +49,5 @@ group :test do
gem "capybara"
gem "selenium-webdriver"
end
+
+gem "devise", "~> 4.9"
diff --git a/Gemfile.lock b/Gemfile.lock
index 50ad109..1f044da 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -76,6 +76,7 @@ GEM
public_suffix (>= 2.0.2, < 7.0)
ast (2.4.3)
base64 (0.3.0)
+ bcrypt (3.1.20)
benchmark (0.4.1)
bigdecimal (3.2.2)
bindex (0.8.1)
@@ -100,6 +101,12 @@ GEM
debug (1.11.0)
irb (~> 1.10)
reline (>= 0.3.8)
+ devise (4.9.4)
+ bcrypt (~> 3.0)
+ orm_adapter (~> 0.1)
+ railties (>= 4.1.0)
+ responders
+ warden (~> 1.2.3)
drb (2.2.3)
erb (5.0.1)
erubi (1.13.1)
@@ -162,6 +169,7 @@ GEM
racc (~> 1.4)
nokogiri (1.18.8-x86_64-linux-musl)
racc (~> 1.4)
+ orm_adapter (0.5.0)
parallel (1.27.0)
parser (3.3.8.0)
ast (~> 2.4.1)
@@ -228,6 +236,9 @@ GEM
regexp_parser (2.10.0)
reline (0.6.1)
io-console (~> 0.5)
+ responders (3.1.1)
+ actionpack (>= 5.2)
+ railties (>= 5.2)
rexml (3.4.1)
rubocop (1.77.0)
json (~> 2.3)
@@ -281,6 +292,8 @@ GEM
unicode-emoji (4.0.4)
uri (1.0.3)
useragent (0.16.11)
+ warden (1.2.9)
+ rack (>= 2.0.9)
web-console (4.2.1)
actionview (>= 6.0.0)
activemodel (>= 6.0.0)
@@ -312,6 +325,7 @@ DEPENDENCIES
brakeman
capybara
debug
+ devise (~> 4.9)
importmap-rails
jbuilder
pg (~> 1.1)
diff --git a/app/assets/stylesheets/app.css b/app/assets/stylesheets/app.css
new file mode 100644
index 0000000..550ae83
--- /dev/null
+++ b/app/assets/stylesheets/app.css
@@ -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;
+ }
+}
+
diff --git a/app/controllers/home_controller.rb b/app/controllers/home_controller.rb
new file mode 100644
index 0000000..877cc2e
--- /dev/null
+++ b/app/controllers/home_controller.rb
@@ -0,0 +1,8 @@
+class HomeController < ApplicationController
+ before_action :authenticate_user!
+
+ def index
+ @user = current_user
+ @logins = Login.where(user: @user)
+ end
+end
diff --git a/app/controllers/statistics_controller.rb b/app/controllers/statistics_controller.rb
new file mode 100644
index 0000000..3400644
--- /dev/null
+++ b/app/controllers/statistics_controller.rb
@@ -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
diff --git a/app/javascript/activity_heatmap.js b/app/javascript/activity_heatmap.js
new file mode 100644
index 0000000..a89f785
--- /dev/null
+++ b/app/javascript/activity_heatmap.js
@@ -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 = `${count} play${count === 1 ? '' : 's'}
${weekday}, ${iso}`;
+ };
+ 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;
diff --git a/app/models/activity.rb b/app/models/activity.rb
new file mode 100644
index 0000000..c3923da
--- /dev/null
+++ b/app/models/activity.rb
@@ -0,0 +1,3 @@
+class Activity < ApplicationRecord
+ belongs_to :user
+end
diff --git a/app/models/login.rb b/app/models/login.rb
new file mode 100644
index 0000000..728c384
--- /dev/null
+++ b/app/models/login.rb
@@ -0,0 +1,3 @@
+class Login < ApplicationRecord
+ belongs_to :user
+end
diff --git a/app/models/shakti.rb b/app/models/shakti.rb
new file mode 100644
index 0000000..004f101
--- /dev/null
+++ b/app/models/shakti.rb
@@ -0,0 +1,2 @@
+class Shakti < ApplicationRecord
+end
diff --git a/app/models/statistics.rb b/app/models/statistics.rb
new file mode 100644
index 0000000..f441adf
--- /dev/null
+++ b/app/models/statistics.rb
@@ -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
diff --git a/app/models/user.rb b/app/models/user.rb
new file mode 100644
index 0000000..4756799
--- /dev/null
+++ b/app/models/user.rb
@@ -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
diff --git a/app/views/devise/confirmations/new.html.erb b/app/views/devise/confirmations/new.html.erb
new file mode 100644
index 0000000..b12dd0c
--- /dev/null
+++ b/app/views/devise/confirmations/new.html.erb
@@ -0,0 +1,16 @@
+
Welcome <%= @email %>!
+ +You can confirm your account email through the link below:
+ +<%= link_to 'Confirm my account', confirmation_url(@resource, confirmation_token: @token) %>
diff --git a/app/views/devise/mailer/email_changed.html.erb b/app/views/devise/mailer/email_changed.html.erb new file mode 100644 index 0000000..32f4ba8 --- /dev/null +++ b/app/views/devise/mailer/email_changed.html.erb @@ -0,0 +1,7 @@ +Hello <%= @email %>!
+ +<% if @resource.try(:unconfirmed_email?) %> +We're contacting you to notify you that your email is being changed to <%= @resource.unconfirmed_email %>.
+<% else %> +We're contacting you to notify you that your email has been changed to <%= @resource.email %>.
+<% end %> diff --git a/app/views/devise/mailer/password_change.html.erb b/app/views/devise/mailer/password_change.html.erb new file mode 100644 index 0000000..b41daf4 --- /dev/null +++ b/app/views/devise/mailer/password_change.html.erb @@ -0,0 +1,3 @@ +Hello <%= @resource.email %>!
+ +We're contacting you to notify you that your password has been changed.
diff --git a/app/views/devise/mailer/reset_password_instructions.html.erb b/app/views/devise/mailer/reset_password_instructions.html.erb new file mode 100644 index 0000000..f667dc1 --- /dev/null +++ b/app/views/devise/mailer/reset_password_instructions.html.erb @@ -0,0 +1,8 @@ +Hello <%= @resource.email %>!
+ +Someone has requested a link to change your password. You can do this through the link below.
+ +<%= link_to 'Change my password', edit_password_url(@resource, reset_password_token: @token) %>
+ +If you didn't request this, please ignore this email.
+Your password won't change until you access the link above and create a new one.
diff --git a/app/views/devise/mailer/unlock_instructions.html.erb b/app/views/devise/mailer/unlock_instructions.html.erb new file mode 100644 index 0000000..41e148b --- /dev/null +++ b/app/views/devise/mailer/unlock_instructions.html.erb @@ -0,0 +1,7 @@ +Hello <%= @resource.email %>!
+ +Your account has been locked due to an excessive number of unsuccessful sign in attempts.
+ +Click the link below to unlock your account:
+ +<%= link_to 'Unlock my account', unlock_url(@resource, unlock_token: @token) %>
diff --git a/app/views/devise/passwords/edit.html.erb b/app/views/devise/passwords/edit.html.erb new file mode 100644 index 0000000..5fbb9ff --- /dev/null +++ b/app/views/devise/passwords/edit.html.erb @@ -0,0 +1,25 @@ +<%= notice %>
+ <% end %> + <% if alert %> +<%= alert %>
+ <% end %>