From 4e94a8911cb3b242a07887d721c60b6595540b34 Mon Sep 17 00:00:00 2001 From: Dylan Knutson Date: Wed, 30 Jul 2025 06:59:34 +0000 Subject: [PATCH] refactor user nav bar and log entry index --- .../bundles/Main/components/UserMenu.tsx | 144 ------------- .../bundles/Main/components/UserSearchBar.tsx | 2 +- app/javascript/bundles/UI/userMenu.ts | 165 ++++++++++++++ app/javascript/packs/application-bundle.js | 6 +- app/javascript/packs/server-bundle.js | 2 - app/views/layouts/application.html.erb | 204 ++++++++++++------ app/views/log_entries/index.html.erb | 68 +++--- config/initializers/mini_profiler.rb | 2 +- config/tailwind.config.js | 2 + 9 files changed, 352 insertions(+), 243 deletions(-) delete mode 100644 app/javascript/bundles/Main/components/UserMenu.tsx create mode 100644 app/javascript/bundles/UI/userMenu.ts diff --git a/app/javascript/bundles/Main/components/UserMenu.tsx b/app/javascript/bundles/Main/components/UserMenu.tsx deleted file mode 100644 index 6b450269..00000000 --- a/app/javascript/bundles/Main/components/UserMenu.tsx +++ /dev/null @@ -1,144 +0,0 @@ -import * as React from 'react'; -import { useRef, useEffect, useState } from 'react'; - -interface UserMenuProps { - userEmail: string; - userRole?: 'admin' | 'moderator'; - editProfilePath: string; - signOutPath: string; - csrfToken: string; - globalStatesPath: string; - goodJobPath: string; - grafanaPath: string; - prometheusPath: string; -} - -export const UserMenu: React.FC = ({ - userEmail, - userRole, - editProfilePath, - signOutPath, - csrfToken, - globalStatesPath, - goodJobPath, - grafanaPath, - prometheusPath, -}) => { - const [isOpen, setIsOpen] = useState(false); - const menuRef = useRef(null); - - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if (menuRef.current && !menuRef.current.contains(event.target as Node)) { - setIsOpen(false); - } - }; - - document.addEventListener('mousedown', handleClickOutside); - return () => document.removeEventListener('mousedown', handleClickOutside); - }, []); - - const handleSignOut = (e: React.FormEvent) => { - e.preventDefault(); - const form = document.createElement('form'); - form.method = 'POST'; - form.action = signOutPath; - form.style.display = 'none'; - - const methodInput = document.createElement('input'); - methodInput.type = 'hidden'; - methodInput.name = '_method'; - methodInput.value = 'delete'; - - const csrfInput = document.createElement('input'); - csrfInput.type = 'hidden'; - csrfInput.name = 'authenticity_token'; - csrfInput.value = csrfToken; - - form.appendChild(methodInput); - form.appendChild(csrfInput); - document.body.appendChild(form); - form.submit(); - }; - - return ( -
- - -
-
-
{userEmail}
- {userRole === 'admin' && ( - - Admin - - )} - {userRole === 'moderator' && ( - - Mod - - )} -
- - {userRole === 'admin' && ( - <> - - - Global State - - - - Jobs Queue - - - - Grafana - - - - Prometheus - - - )} - - - - Edit Profile - - - -
-
- ); -}; diff --git a/app/javascript/bundles/Main/components/UserSearchBar.tsx b/app/javascript/bundles/Main/components/UserSearchBar.tsx index 62c18a93..4fb7459a 100644 --- a/app/javascript/bundles/Main/components/UserSearchBar.tsx +++ b/app/javascript/bundles/Main/components/UserSearchBar.tsx @@ -296,7 +296,7 @@ export default function UserSearchBar({ isServerRendered }: PropTypes) { /> {anyShown && ( -
+
)} diff --git a/app/javascript/bundles/UI/userMenu.ts b/app/javascript/bundles/UI/userMenu.ts new file mode 100644 index 00000000..238bf1a6 --- /dev/null +++ b/app/javascript/bundles/UI/userMenu.ts @@ -0,0 +1,165 @@ +/** + * User Menu Handler + * Manages the responsive user menu that works for both desktop and mobile layouts + */ + +interface UserMenuElements { + button: HTMLButtonElement; + menu: HTMLDivElement; + menuIcon: SVGElement | null; + closeIcon: SVGElement | null; + chevronIcon: HTMLElement | null; +} + +class UserMenu { + private elements: UserMenuElements | null = null; + + constructor() { + this.initialize(); + } + + private initialize(): void { + const userMenuButton = document.querySelector( + '#user-menu-button', + ) as HTMLButtonElement; + const userMenu = document.querySelector('#user-menu') as HTMLDivElement; + const menuIcon = document.querySelector('.menu-icon') as SVGElement | null; + const closeIcon = document.querySelector( + '.close-icon', + ) as SVGElement | null; + const chevronIcon = document.querySelector( + '.chevron-icon', + ) as HTMLElement | null; + + if (!userMenuButton || !userMenu) { + return; // User menu not present (e.g., guest user) + } + + this.elements = { + button: userMenuButton, + menu: userMenu, + menuIcon, + closeIcon, + chevronIcon, + }; + + this.attachEventListeners(); + } + + private attachEventListeners(): void { + if (!this.elements) return; + + // Toggle menu on button click + this.elements.button.addEventListener( + 'click', + this.handleMenuToggle.bind(this), + ); + + // Close menu when clicking outside + document.addEventListener('click', this.handleClickOutside.bind(this)); + + // Close menu on escape key + document.addEventListener('keydown', this.handleEscapeKey.bind(this)); + } + + private handleMenuToggle(): void { + if (!this.elements) return; + + const isOpen = !this.elements.menu.classList.contains('hidden'); + + if (isOpen) { + this.closeMenu(); + } else { + this.openMenu(); + } + } + + private handleClickOutside(event: MouseEvent): void { + if (!this.elements) return; + + const target = event.target as Node; + + if ( + !this.elements.button.contains(target) && + !this.elements.menu.contains(target) + ) { + this.closeMenu(); + } + } + + private handleEscapeKey(event: KeyboardEvent): void { + if (!this.elements) return; + + if ( + event.key === 'Escape' && + !this.elements.menu.classList.contains('hidden') + ) { + this.closeMenu(); + } + } + + private openMenu(): void { + if (!this.elements) return; + + // Show menu but keep it in initial animation state + this.elements.menu.classList.remove('hidden'); + this.elements.button.setAttribute('aria-expanded', 'true'); + + // Ensure initial state is set + this.elements.menu.classList.add('opacity-0', '-translate-y-2'); + this.elements.menu.classList.remove('opacity-100', 'translate-y-0'); + + // Force a reflow to ensure the initial state is applied + this.elements.menu.offsetHeight; + + // Trigger animation by transitioning to visible state + requestAnimationFrame(() => { + this.elements!.menu.classList.remove('opacity-0', '-translate-y-2'); + this.elements!.menu.classList.add('opacity-100', 'translate-y-0'); + }); + + // Update mobile icons (hamburger -> close) + if (this.elements.menuIcon && this.elements.closeIcon) { + this.elements.menuIcon.classList.add('hidden'); + this.elements.closeIcon.classList.remove('hidden'); + } + + // Update desktop chevron (rotate down) + if (this.elements.chevronIcon) { + this.elements.chevronIcon.classList.add('rotate-180'); + } + } + + private closeMenu(): void { + if (!this.elements) return; + + // Animate out: fade and slide up + this.elements.menu.classList.remove('opacity-100', 'translate-y-0'); + this.elements.menu.classList.add('opacity-0', '-translate-y-2'); + + // Hide menu after CSS animation completes + setTimeout(() => { + if (this.elements) { + this.elements.menu.classList.add('hidden'); + } + }, 150); // Match the CSS transition duration + + this.elements.button.setAttribute('aria-expanded', 'false'); + + // Update mobile icons (close -> hamburger) + if (this.elements.menuIcon && this.elements.closeIcon) { + this.elements.menuIcon.classList.remove('hidden'); + this.elements.closeIcon.classList.add('hidden'); + } + + // Update desktop chevron (rotate back up) + if (this.elements.chevronIcon) { + this.elements.chevronIcon.classList.remove('rotate-180'); + } + } +} + +// Initialize user menu when DOM is loaded +export function initUserMenu(): void { + new UserMenu(); +} diff --git a/app/javascript/packs/application-bundle.js b/app/javascript/packs/application-bundle.js index 4dc54c9a..41c03dd5 100644 --- a/app/javascript/packs/application-bundle.js +++ b/app/javascript/packs/application-bundle.js @@ -1,18 +1,17 @@ import ReactOnRails from 'react-on-rails'; import UserSearchBar from '../bundles/Main/components/UserSearchBar'; -import { UserMenu } from '../bundles/Main/components/UserMenu'; import { PostHoverPreviewWrapper } from '../bundles/Main/components/PostHoverPreviewWrapper'; import { UserHoverPreviewWrapper } from '../bundles/Main/components/UserHoverPreviewWrapper'; import { TrackedObjectsChart } from '../bundles/Main/components/TrackedObjectsChart'; import { initCollapsibleSections } from '../bundles/UI/collapsibleSections'; import { IpAddressInput } from '../bundles/UI/components'; import { StatsPage } from '../bundles/Main/components/StatsPage'; +import { initUserMenu } from '../bundles/UI/userMenu'; // This is how react_on_rails can see the components in the browser. ReactOnRails.register({ UserSearchBar, - UserMenu, PostHoverPreviewWrapper, UserHoverPreviewWrapper, TrackedObjectsChart, @@ -20,7 +19,8 @@ ReactOnRails.register({ StatsPage, }); -// Initialize collapsible sections +// Initialize UI components document.addEventListener('DOMContentLoaded', function () { initCollapsibleSections(); + initUserMenu(); }); diff --git a/app/javascript/packs/server-bundle.js b/app/javascript/packs/server-bundle.js index 7dbd59a5..39ba5c9d 100644 --- a/app/javascript/packs/server-bundle.js +++ b/app/javascript/packs/server-bundle.js @@ -1,13 +1,11 @@ import ReactOnRails from 'react-on-rails'; import UserSearchBar from '../bundles/Main/components/UserSearchBarServer'; -import { UserMenu } from '../bundles/Main/components/UserMenu'; import { PostHoverPreviewWrapper } from '../bundles/Main/components/PostHoverPreviewWrapper'; import { UserHoverPreviewWrapper } from '../bundles/Main/components/UserHoverPreviewWrapper'; // This is how react_on_rails can see the UserSearchBar in the browser. ReactOnRails.register({ - UserMenu, UserSearchBar, PostHoverPreviewWrapper, UserHoverPreviewWrapper, diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 7135b762..d43dd89b 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -22,72 +22,154 @@ <%= yield :head %> -
-
- -

- <%= link_to root_path, class: "flex items-center" do %> - <%= image_tag asset_path("refurrer-logo-md.png"), class: "w-12 h-12 mr-2" %> - - ReFurrer - +
+
+
+ +
+ <%= link_to root_path, class: "flex items-center space-x-2 hover:opacity-80 transition-opacity" do %> + <%= image_tag asset_path("refurrer-logo-md.png"), class: "w-8 h-8 sm:w-10 sm:h-10", alt: "ReFurrer Logo" %> +
+ + ReFurrer + + +
<% end %> -

- <% if policy(IpAddressRole).view_debug_info? %> - - IP address - - <%= request.remote_ip %> - - - - - <%= link_to "IP Address Role", state_ip_address_roles_path, class: "text-blue-500 hover:text-blue-700" %> - - - <% if role = current_ip_address_role %> - <%= role.ip_address %> / <%= role.role %> - <% if role.description.present? %> - + +
+ <% if user_signed_in? %> + + + + <% else %> + +
+ <%= link_to new_user_session_path, class: "inline-flex items-center px-3 py-2 text-sm font-medium text-slate-600 hover:text-slate-900 hover:bg-slate-50 rounded-md transition-colors" do %> + + + Login + <% end %> + <%= link_to new_user_registration_path, class: "inline-flex items-center px-3 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md transition-colors" do %> + + + Join + <% end %> +
<% end %> - <%= link_to new_user_registration_path, class: "text-slate-600 hover:text-slate-900" do %> - - Sign Up - <% end %> - <% end %> - -

- Furry Swiss Army Knife -

+
+
+ + <% if policy(IpAddressRole).view_debug_info? %> +
+
+
+
+ IP: + 2001:0db8:85a3:0000:0000:8a2e:0370:7334 +
+
+ <%= link_to "Role", state_ip_address_roles_path, class: "text-blue-600 hover:text-blue-800 font-medium" %> + : + + <% if role = current_ip_address_role %> + <%= role.ip_address %> / <%= role.role %> + <% else %> + None + <% end %> + +
+
+
+
+ <% end %>
<% if notice %> diff --git a/app/views/log_entries/index.html.erb b/app/views/log_entries/index.html.erb index 09f97fbf..a6245c34 100644 --- a/app/views/log_entries/index.html.erb +++ b/app/views/log_entries/index.html.erb @@ -36,8 +36,8 @@ <% end %> <%= render partial: "shared/pagination_controls", locals: { collection: @log_entries } %> -
-
+
+ <% @log_entries.each do |hle| %> -
-
- <%= performed_by_to_short_code(hle.performed_by) %> +
+
+
+ <%= performed_by_to_short_code(hle.performed_by) %> +
+
+ <%= link_to hle.id, log_entry_path(hle.id), class: "text-blue-600 hover:text-blue-800 font-medium" %> +
+
+ <%= HexUtil.humansize(hle.response_size) %> +
-
- <%= link_to hle.id, log_entry_path(hle.id), class: "text-blue-600 hover:text-blue-800 font-medium" %> +
+
+ <%= time_ago_in_words(hle.created_at, include_seconds: true) %> +
+
+ + <%= hle.status_code %> + +
-
- <%= HexUtil.humansize(hle.response_size) %> -
-
- <%= time_ago_in_words(hle.created_at, include_seconds: true) %> -
-
- - <%= hle.status_code %> - -
-
+
<% iterative_parts = path_iterative_parts(hle.uri_path) %>
-
- <%= link_to hle.uri.to_s, class: "text-blue-600 hover:text-blue-800 transition-colors", target: "_blank", rel: "noreferrer" do %> - <%= render partial: "shared/icons/external_link", locals: { class_name: "w-4 h-4" } %> - <% end %> -
-
- - <%= hle.content_type.split(";")[0] %> - -
-
- <%= hle.response_time_ms %>ms +
+
+ <%= link_to hle.uri.to_s, class: "text-blue-600 hover:text-blue-800 transition-colors", target: "_blank", rel: "noreferrer" do %> + <%= render partial: "shared/icons/external_link", locals: { class_name: "w-4 h-4" } %> + <% end %> +
+
+ + <%= hle.content_type.split(";")[0] %> + +
+
+ <%= hle.response_time_ms %>ms +
<% end %> diff --git a/config/initializers/mini_profiler.rb b/config/initializers/mini_profiler.rb index fe5403ff..557b8d52 100644 --- a/config/initializers/mini_profiler.rb +++ b/config/initializers/mini_profiler.rb @@ -3,7 +3,7 @@ if Rails.env.development? || Rails.env.staging? # Rack::MiniProfiler.config.pre_authorize_cb = lambda { |env| true } Rack::MiniProfiler.config.authorization_mode = :allow_all Rack::MiniProfiler.config.enable_advanced_debugging_tools = true - Rack::MiniProfiler.config.position = "top-right" + Rack::MiniProfiler.config.position = "top-left" Rack::MiniProfiler.config.disable_caching = false Rack::MiniProfiler.config.skip_paths = [%r{/blobs/.+/contents.jpg$}] end diff --git a/config/tailwind.config.js b/config/tailwind.config.js index bcfc880b..c48b7835 100644 --- a/config/tailwind.config.js +++ b/config/tailwind.config.js @@ -25,7 +25,9 @@ module.exports = { experimental: { classRegex: [ /\\bclass:\s*'([^']*)'/, + /\\bclass=\s*'([^']*)'/, /\\bclass:\s*"([^"]*)"/, + /\\bclass=\s*"([^"]*)"/, /["'`]([^"'`]*).*?,?\s?/, ], },