From 59ea28273ede29aab76bc61b307acceeaf283c57 Mon Sep 17 00:00:00 2001
From: Teal Bauer <teal@starsong.eu>
Date: Tue, 11 Mar 2025 11:13:10 +0100
Subject: [PATCH] Add dark mode toggle button

---
 app/controllers/users_controller.rb    | 20 ++++++
 app/views/layouts/application.html.erb | 91 ++++++++++++++++++++++++--
 config/routes.rb                       |  2 +
 3 files changed, 108 insertions(+), 5 deletions(-)

diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb
index 2a1503d..dee25aa 100644
--- a/app/controllers/users_controller.rb
+++ b/app/controllers/users_controller.rb
@@ -1,5 +1,25 @@
 class UsersController < ApplicationController
+  before_action :authenticate_user!, only: [ :update_theme ]
+
   def leaderboard
     @workload_data = User.leaderboard
   end
+
+  def update_theme
+    if current_user.present?
+      # Ensure darkmode parameter is valid (light, dark)
+      theme = params[:darkmode].to_s
+      if [ "light", "dark" ].include?(theme)
+        if current_user.update(darkmode: theme)
+          render json: { success: true, theme: theme }, status: :ok
+        else
+          render json: { success: false, errors: current_user.errors.full_messages }, status: :unprocessable_entity
+        end
+      else
+        render json: { success: false, error: "Invalid theme value" }, status: :bad_request
+      end
+    else
+      render json: { success: false, error: "User not authenticated" }, status: :unauthorized
+    end
+  end
 end
diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb
index e2deef9..561b985 100644
--- a/app/views/layouts/application.html.erb
+++ b/app/views/layouts/application.html.erb
@@ -45,7 +45,7 @@
           </div>
         </div>
 
-        <nav class="flex items-center">
+        <nav class="flex items-center space-x-3">
           <% if user_signed_in? %>
             <!-- Desktop user navigation -->
             <div class="hidden md:flex md:items-center md:space-x-4">
@@ -66,6 +66,16 @@
             <div class="flex items-center ml-4">
               <%= render partial: "application/user_avatar", locals: { user: current_user } %>
             </div>
+
+            <!-- Dark mode toggle button -->
+            <button id="theme-toggle" class="text-gray-600 hover:text-gray-900 dark:text-gray-300 dark:hover:text-white" aria-label="Toggle dark mode">
+              <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 hidden dark:block" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+                <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
+              </svg>
+              <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 block dark:hidden" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+                <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
+              </svg>
+            </button>
             
             <!-- Mobile hamburger menu -->
             <div class="md:hidden relative ml-4">
@@ -100,10 +110,19 @@
           <% else %>
             <!-- Not logged in state -->
             <div class="flex items-center space-x-4">
-              <span class="px-2 text-gray-600 dark:text-gray-300 hidden md:inline">not logged in</span>
               <%= link_to "Assignments", assignments_path, class: "text-gray-600 hover:text-gray-900 dark:text-gray-300 dark:hover:text-white" %>
               <%= link_to "Log in", new_user_session_path, class: "text-gray-600 hover:text-gray-900 dark:text-gray-300 dark:hover:text-white" %>
             </div>
+
+            <!-- Dark mode toggle button -->
+            <button id="theme-toggle" class="text-gray-600 hover:text-gray-900 dark:text-gray-300 dark:hover:text-white block" aria-label="Toggle dark mode">
+              <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 hidden dark:block" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+                <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
+              </svg>
+              <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 block dark:hidden" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+                <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
+              </svg>
+            </button>
             
             <!-- Mobile hamburger menu for non-logged in users -->
             <div class="md:hidden relative ml-4">
@@ -131,6 +150,56 @@
     </header>
 
     <script>
+      function initializeThemeToggle() {
+        const themeToggle = document.getElementById('theme-toggle');
+        if (!themeToggle) return;
+        
+        // Remove any existing event listeners to prevent duplicates
+        const newThemeToggle = themeToggle.cloneNode(true);
+        themeToggle.parentNode.replaceChild(newThemeToggle, themeToggle);
+        
+        newThemeToggle.addEventListener('click', function() {
+          const htmlElement = document.documentElement;
+          const isDark = htmlElement.classList.contains('dark');
+          const newMode = isDark ? 'light' : 'dark';
+          
+          // Remove all theme classes
+          htmlElement.classList.remove('dark', 'light', 'auto');
+          
+          // Add the new theme class
+          htmlElement.classList.add(newMode);
+          
+          // For logged-in users, save preference via AJAX
+          if (window.userSignedIn) {
+            fetch('/users/update_theme', {
+              method: 'PATCH',
+              headers: {
+                'Content-Type': 'application/json',
+                'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content
+              },
+              body: JSON.stringify({ darkmode: newMode })
+            }).catch(error => console.error('Error updating theme preference:', error));
+          } 
+          // For logged-out users, save preference in localStorage
+          else {
+            localStorage.setItem('theme', newMode);
+          }
+        });
+        
+        // If logged out and we have a theme in localStorage, apply it
+        if (!window.userSignedIn && localStorage.getItem('theme')) {
+          const savedTheme = localStorage.getItem('theme');
+          if (savedTheme === 'dark' || savedTheme === 'light') {
+            const htmlElement = document.documentElement;
+            // Only apply if we're in 'auto' mode (default for logged-out users)
+            if (htmlElement.classList.contains('auto')) {
+              htmlElement.classList.remove('auto', 'dark', 'light');
+              htmlElement.classList.add(savedTheme);
+            }
+          }
+        }
+      }
+      
       function initializeNavMenus() {
         // Initialize main menu for logged in users
         const menuToggle = document.getElementById('main-menu-toggle');
@@ -174,12 +243,24 @@
         });
       }
       
+      // Set user signed in state for JS
+      window.userSignedIn = <%= user_signed_in? %>;
+      
       // Initialize on DOMContentLoaded
-      document.addEventListener('DOMContentLoaded', initializeNavMenus);
+      document.addEventListener('DOMContentLoaded', () => {
+        initializeThemeToggle();
+        initializeNavMenus();
+      });
       
       // Re-initialize on Turbo navigation
-      document.addEventListener('turbo:load', initializeNavMenus);
-      document.addEventListener('turbo:render', initializeNavMenus);
+      document.addEventListener('turbo:load', () => {
+        initializeThemeToggle();
+        initializeNavMenus();
+      });
+      document.addEventListener('turbo:render', () => {
+        initializeThemeToggle();
+        initializeNavMenus();
+      });
     </script>
 
     <main>
diff --git a/config/routes.rb b/config/routes.rb
index d1ba652..9879211 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -24,7 +24,9 @@ Rails.application.routes.draw do
   end
 
   get "speakers/show"
+
   get "users/leaderboard"
+  patch "users/update_theme", to: "users#update_theme"
 
   # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500.
   # Can be used by load balancers and uptime monitors to verify that the app is live.
-- 
GitLab