From fc54de3d90bef761a6a4db44706af708007ad156 Mon Sep 17 00:00:00 2001 From: Ben Rexin Date: Mon, 15 Jun 2026 23:01:43 +0200 Subject: [PATCH 1/3] fix: small header menu item alignment fix --- app/assets/stylesheets/application.scss | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss index 9210f584..fda7ed55 100644 --- a/app/assets/stylesheets/application.scss +++ b/app/assets/stylesheets/application.scss @@ -40,6 +40,10 @@ body { display: none; } #nav { + .navbar-nav { + align-items: center; + } + img.label { max-height: 30px; } From 5bfc9590a6a48f914f6636847d913b5e639ad692 Mon Sep 17 00:00:00 2001 From: Ben Rexin Date: Mon, 15 Jun 2026 23:05:17 +0200 Subject: [PATCH 2/3] feat: set cookie with user data --- app/controllers/concerns/user_handling.rb | 28 +++++++++++++++++++++++ app/controllers/users_controller.rb | 1 + 2 files changed, 29 insertions(+) diff --git a/app/controllers/concerns/user_handling.rb b/app/controllers/concerns/user_handling.rb index f592a7e4..da08ea58 100644 --- a/app/controllers/concerns/user_handling.rb +++ b/app/controllers/concerns/user_handling.rb @@ -37,11 +37,39 @@ def sign_in(user) @current_user = user session[:user_id] = user.id cookies.permanent.signed[:remember_me] = [user.id, user.salt] + set_user_cookie(user) end def sign_out session.clear cookies.permanent.signed[:remember_me] = ['', ''] + clear_user_cookie + end + + def set_user_cookie(user) + data = { + slug: user.to_param, + name: user.name.to_s, + image_path: helpers.cache_image_path(user), + profile_path: user_path(user), + edit_path: edit_user_path(user), + logout_path: destroy_session_path, + hide_jobs: user.hide_jobs?, + missing_name: user.missing_name?, + is_admin: (true if user.admin?), + is_super_admin: (true if user.super_admin?) + }.compact + + cookies.permanent['_on_ruby_user'] = { + value: data.to_json, + domain: request.domain, + httponly: false, + same_site: :lax + } + end + + def clear_user_cookie + cookies.delete('_on_ruby_user', domain: request.domain) end def find_by_session_or_cookies diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 462ad33b..e3d24b8a 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -31,6 +31,7 @@ def calendar def update if current_user.update user_params + set_user_cookie(current_user) redirect_back(notice: t('user.saved_successful'), fallback_location: root_url) else redirect_back(alert: current_user.errors.full_messages.join(' '), fallback_location: root_url) From ae7ecf9c74ecce9d2ba7297a8a7bc250f8ce7d41 Mon Sep 17 00:00:00 2001 From: Ben Rexin Date: Mon, 15 Jun 2026 23:16:51 +0200 Subject: [PATCH 3/3] feat: replace serverside rendered user nav with client side rendered --- app/assets/javascripts/application.js | 6 ++- app/assets/javascripts/controllers.js | 10 ++++ .../javascripts/controllers/nav_controller.js | 36 +++++++++++++ app/controllers/concerns/user_handling.rb | 8 +-- app/controllers/users_controller.rb | 2 +- app/views/application/_nav.slim | 52 ++++++++++--------- 6 files changed, 82 insertions(+), 32 deletions(-) create mode 100644 app/assets/javascripts/controllers.js create mode 100644 app/assets/javascripts/controllers/nav_controller.js diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js index 62cf9e3f..97e4c510 100644 --- a/app/assets/javascripts/application.js +++ b/app/assets/javascripts/application.js @@ -11,11 +11,13 @@ //= require utility //= require custom //= require map +//= require controllers +//= require_tree ./controllers -$(function() { +$(function () { Utility.disable(); }); -$.fn.random = function() { +$.fn.random = function () { return this.eq(Math.floor(Math.random() * this.length)); }; diff --git a/app/assets/javascripts/controllers.js b/app/assets/javascripts/controllers.js new file mode 100644 index 00000000..f45fe40f --- /dev/null +++ b/app/assets/javascripts/controllers.js @@ -0,0 +1,10 @@ +// Minimal controller registry — mirrors Stimulus conventions without the dependency. +// Files in controllers/ assign to Controllers['name'] = class { connect(el) {} } +const Controllers = {}; + +document.addEventListener('DOMContentLoaded', function() { + document.querySelectorAll('[data-controller]').forEach(function(el) { + const name = el.dataset.controller; + if (Controllers[name]) new Controllers[name](el).connect(); + }); +}); diff --git a/app/assets/javascripts/controllers/nav_controller.js b/app/assets/javascripts/controllers/nav_controller.js new file mode 100644 index 00000000..5a7dc62f --- /dev/null +++ b/app/assets/javascripts/controllers/nav_controller.js @@ -0,0 +1,36 @@ +// Reads the _on_ruby_user data cookie (set by UserHandling#sign_in) and swaps the +// anonymous login button for the profile dropdown without a server round-trip. +Controllers['nav'] = class { + constructor(el) { this.el = el; } + + connect() { + const user = this.readUserCookie(); + if (user) this.renderUserNav(user); + } + + readUserCookie() { + const match = document.cookie.match(/(?:^|;\s*)_on_ruby_user=([^;]+)/); + if (!match) return null; + try { return JSON.parse(decodeURIComponent(match[1])); } + catch (e) { return null; } + } + + renderUserNav(user) { + const template = this.el.querySelector('template'); + if (!template) return; + + const frag = template.content.cloneNode(true); + const avatar = frag.querySelector('[data-avatar]'); + avatar.src = user.image_path; + avatar.alt = user.name; + frag.querySelector('[data-profile]').href = user.profile_path; + frag.querySelector('[data-edit]').href = user.edit_path; + frag.querySelector('[data-logout]').href = user.logout_path; + + const show = (sel) => { const node = frag.querySelector(sel); if (node) node.hidden = false; }; + if (user.is_admin) show('[data-admin]'); + if (user.is_super_admin) show('[data-super-admin]'); + + this.el.querySelector('[data-login]').replaceWith(frag); + } +}; diff --git a/app/controllers/concerns/user_handling.rb b/app/controllers/concerns/user_handling.rb index da08ea58..dc2716f3 100644 --- a/app/controllers/concerns/user_handling.rb +++ b/app/controllers/concerns/user_handling.rb @@ -37,7 +37,7 @@ def sign_in(user) @current_user = user session[:user_id] = user.id cookies.permanent.signed[:remember_me] = [user.id, user.salt] - set_user_cookie(user) + user_cookie(user) end def sign_out @@ -46,7 +46,7 @@ def sign_out clear_user_cookie end - def set_user_cookie(user) + def user_cookie(user) data = { slug: user.to_param, name: user.name.to_s, @@ -57,14 +57,14 @@ def set_user_cookie(user) hide_jobs: user.hide_jobs?, missing_name: user.missing_name?, is_admin: (true if user.admin?), - is_super_admin: (true if user.super_admin?) + is_super_admin: (true if user.super_admin?), }.compact cookies.permanent['_on_ruby_user'] = { value: data.to_json, domain: request.domain, httponly: false, - same_site: :lax + same_site: :lax, } end diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index e3d24b8a..4c169050 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -31,7 +31,7 @@ def calendar def update if current_user.update user_params - set_user_cookie(current_user) + user_cookie(current_user) redirect_back(notice: t('user.saved_successful'), fallback_location: root_url) else redirect_back(alert: current_user.errors.full_messages.join(' '), fallback_location: root_url) diff --git a/app/views/application/_nav.slim b/app/views/application/_nav.slim index 91f3d9cc..32fd638a 100644 --- a/app/views/application/_nav.slim +++ b/app/views/application/_nav.slim @@ -15,37 +15,39 @@ nav.navbar.sticky-top.navbar-expand-lg.navbar-light.bg-light#nav = fa_icon(fa_icon_map[section], class: 'fa-fw', text: t("main.#{section}")) ul.navbar-nav.ms-auto - li.nav-item.dropdown.pe-4 - - if signed_in? + li.nav-item.dropdown.pe-4(data-controller="nav") + / Anonymous default — nav_controller.js replaces with profile dropdown when cookie present + div(data-login="") + a.btn.btn-primary.dropdown-toggle(href="#" id="loginDropdown" role="button" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false") + = t('login.login') + .dropdown-menu.dropdown-menu-end(aria-labelledby="loginDropdown") + - login_providers.each do |provider| + = button_to(label_auth_url(provider), class: 'dropdown-item') do + = fa_icon(icon_for_provider(provider), class: 'fa-fw', text: t("login.#{provider}_login")) + + / Template for logged-in state — inert until cloned by nav_controller.js + template .dropdown a.btn.btn-light.dropdown-toggle(href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false") - = user_image(current_user) + img.user-image.user-image-small(src="" alt="" data-avatar="") = t('login.profile') - ul.dropdown-menu.dropdown-menu-end(aria-labelledby="loginDropdown") - li= link_to(user_path(current_user), class: 'dropdown-item') do - = fa_icon('eye', class: 'fa-fw', text: t("login.show_profile")) - - li= link_to(edit_user_path(current_user), class: 'dropdown-item') do - = fa_icon('edit', class: 'fa-fw', text: t("login.edit_profile")) - - li= link_to(destroy_session_path(current_user), class: 'dropdown-item') do - = fa_icon('times', class: 'fa-fw', text: t("login.logout")) - - - if current_user.admin? - li= link_to('/admin', class: 'dropdown-item') do + ul.dropdown-menu.dropdown-menu-end + li + a.dropdown-item(href="" data-profile="") + = fa_icon('eye', class: 'fa-fw', text: t("login.show_profile")) + li + a.dropdown-item(href="" data-edit="") + = fa_icon('edit', class: 'fa-fw', text: t("login.edit_profile")) + li + a.dropdown-item(href="" data-logout="") + = fa_icon('times', class: 'fa-fw', text: t("login.logout")) + li(hidden=true data-admin="") + a.dropdown-item(href="/admin") = fa_icon('lock', class: 'fa-fw', text: 'Community-Admin') - - if current_user.super_admin? - li= link_to('/super_admin', class: 'dropdown-item') do + li(hidden=true data-super-admin="") + a.dropdown-item(href="/super_admin") = fa_icon('lock', class: 'fa-fw', text: 'Super-Admin') - - else - a(class="btn btn-primary dropdown-toggle" href="#" id="loginDropdown" role="button" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false") - = t('login.login') - .dropdown-menu.dropdown-menu-end(aria-labelledby="loginDropdown") - - login_providers.each do |provider| - = button_to(label_auth_url(provider), class: 'dropdown-item') do - = fa_icon(icon_for_provider(provider), class: 'fa-fw', text: t("login.#{provider}_login")) - li.nav-item.dropdown.pe-4 a(class="nav-link btn btn-light dropdown-toggle" href="#" id="localeDropdown" role="button" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false")