\n >\n );\n\n return (\n \n );\n};\n\nexport default UserHoverPreview;\n","import * as React from 'react';\nimport { UserHoverPreview } from './UserHoverPreview';\nimport {\n anchorClassNamesForVisualStyle,\n iconClassNamesForSize,\n} from '../utils/hoverPreviewUtils';\n\ntype VisualStyle = 'sky-link' | 'description-section-link';\ntype IconSize = 'small' | 'large';\n\ninterface UserHoverPreviewWrapperProps {\n visualStyle: VisualStyle;\n iconSize: IconSize;\n linkText: string;\n userId: string;\n userName: string;\n userPath: string;\n userAvatarPath: string;\n userAvatarAlt: string;\n userDomainIcon?: string;\n userSmallAvatarPath?: string;\n userRegisteredAt?: string;\n userNumPosts?: number;\n userNumFollowedBy?: number;\n userNumFollowed?: number;\n}\n\nexport const UserHoverPreviewWrapper: React.FC<\n UserHoverPreviewWrapperProps\n> = ({\n visualStyle,\n iconSize,\n linkText,\n userName,\n userPath,\n userAvatarPath,\n userAvatarAlt,\n userDomainIcon,\n userSmallAvatarPath,\n userRegisteredAt,\n userNumPosts,\n userNumFollowedBy,\n userNumFollowed,\n}) => {\n return (\n \n \n {userSmallAvatarPath ? (\n \n ) : (\n \n )}\n {linkText}\n \n \n );\n};\n\nexport default UserHoverPreviewWrapper;\n","/**\n * Collapsible Section Controls\n *\n * This module contains functions to control collapsible sections with font size controls.\n * Features include:\n * - Toggling between expanded and collapsed view\n * - Increasing and decreasing font size\n */\n\n/**\n * Class representing a collapsible section with font size controls\n */\nclass CollapsibleSection {\n private readonly section: Element;\n private readonly content: HTMLElement;\n private readonly toggleTextElement: Element | null;\n private readonly toggleIconElement: HTMLElement | null;\n private readonly fontDisplayElement: Element | null;\n private readonly baseFontSize: number;\n private readonly initiallyCollapsed: boolean;\n\n /**\n * Create a new collapsible section\n * @param rootElement The root DOM element for this section\n */\n constructor(rootElement: Element) {\n this.section = rootElement;\n this.initiallyCollapsed = rootElement.hasAttribute(\n 'data-initially-collapsed',\n );\n\n // Find and cache all necessary DOM elements\n const contentElement = rootElement.querySelector(\n '[data-collapsable=\"content\"]',\n );\n if (!(contentElement instanceof HTMLElement)) {\n throw new Error(`Section is missing a content element`);\n }\n\n this.content = contentElement;\n\n // Get single elements instead of arrays\n this.toggleTextElement = rootElement.querySelector(\n '[data-collapsable=\"toggle-text\"]',\n );\n\n const iconElement = rootElement.querySelector(\n '[data-collapsable=\"toggle-icon\"]',\n );\n this.toggleIconElement =\n iconElement instanceof HTMLElement ? iconElement : null;\n\n this.fontDisplayElement = rootElement.querySelector(\n '[data-collapsable=\"font-display\"]',\n );\n\n // Initialize font size info\n this.baseFontSize = parseFloat(\n window.getComputedStyle(this.content).fontSize,\n );\n this.updateFontSizeDisplay();\n }\n\n /**\n * Initialize event listeners for this section\n */\n initEventListeners(): void {\n // Setup handlers for all interactive elements\n this.section\n .querySelectorAll('[data-collapsable=\"toggle\"]')\n .forEach((button) => {\n button.addEventListener('click', (e) => {\n e.preventDefault();\n this.toggle();\n });\n });\n\n this.section\n .querySelectorAll('[data-collapsable=\"decrease\"]')\n .forEach((button) => {\n button.addEventListener('click', (e) => {\n e.preventDefault();\n this.decreaseFontSize();\n });\n });\n\n this.section\n .querySelectorAll('[data-collapsable=\"increase\"]')\n .forEach((button) => {\n button.addEventListener('click', (e) => {\n e.preventDefault();\n this.increaseFontSize();\n });\n });\n }\n\n /**\n * Toggle between expanded and collapsed view\n */\n toggle(): void {\n // Determine current state from DOM\n const isCollapsed = this.content.classList.contains('line-clamp-6');\n\n // Toggle line-clamp class\n this.content.classList.toggle('line-clamp-6');\n\n // Update toggle text\n if (this.toggleTextElement) {\n this.toggleTextElement.textContent = isCollapsed\n ? 'Show Less'\n : 'Show More';\n }\n\n // Update toggle icon\n if (this.toggleIconElement) {\n this.toggleIconElement.style.transform = isCollapsed\n ? 'rotate(180deg)'\n : 'rotate(0deg)';\n }\n }\n\n /**\n * Adjust font size of content within min/max bounds\n */\n adjustFontSize(delta: number, min = 12, max = 22): void {\n const currentSize = parseFloat(\n window.getComputedStyle(this.content).fontSize,\n );\n const newSize = Math.min(Math.max(currentSize + delta, min), max);\n\n this.content.style.fontSize = `${newSize}px`;\n this.updateFontSizeDisplay();\n }\n\n /**\n * Increase font size (max 22px)\n */\n increaseFontSize(): void {\n this.adjustFontSize(2);\n }\n\n /**\n * Decrease font size (min 12px)\n */\n decreaseFontSize(): void {\n this.adjustFontSize(-2);\n }\n\n /**\n * Update the font size percentage display\n */\n updateFontSizeDisplay(): void {\n if (!this.fontDisplayElement) return;\n\n const currentSize = parseFloat(\n window.getComputedStyle(this.content).fontSize,\n );\n const percentage = Math.round((currentSize / this.baseFontSize) * 100);\n\n this.fontDisplayElement.textContent = `${percentage}%`;\n }\n}\n\n/**\n * Initialize all collapsible sections on the page\n */\nexport function initCollapsibleSections(): void {\n document\n .querySelectorAll('[data-collapsable=\"root\"]')\n .forEach((sectionElement) => {\n try {\n // Create a new section instance\n const section = new CollapsibleSection(sectionElement);\n\n // Set up event handlers\n section.initEventListeners();\n } catch (error) {\n console.error('Failed to initialize section:', error);\n }\n });\n}\n","export type ValidationResult = {\n isValid: boolean;\n message: string;\n type: 'ipv4' | 'ipv6' | 'cidr-v4' | 'cidr-v6' | 'none';\n};\n\n// Utility functions for IP address validation\nconst isIPv4Segment = (segment: string): boolean => {\n const num = Number(segment);\n return !isNaN(num) && num >= 0 && num <= 255 && segment === num.toString();\n};\n\nconst isIPv4Address = (address: string): boolean => {\n const segments = address.split('.');\n return segments.length === 4 && segments.every(isIPv4Segment);\n};\n\nconst isIPv6Segment = (segment: string): boolean => {\n return segment.length <= 4 && /^[0-9a-fA-F]*$/.test(segment);\n};\n\nconst isIPv6Address = (address: string): boolean => {\n // Handle the :: compression\n const parts = address.split('::');\n if (parts.length > 2) return false; // More than one :: is invalid\n\n if (parts.length === 2) {\n const [left, right] = parts;\n const leftSegments = left ? left.split(':') : [];\n const rightSegments = right ? right.split(':') : [];\n\n // Total segments should be 8 after decompression\n if (leftSegments.length + rightSegments.length > 7) return false;\n\n // Validate each segment\n return (\n leftSegments.every(isIPv6Segment) && rightSegments.every(isIPv6Segment)\n );\n }\n\n // No compression, should be exactly 8 segments\n const segments = address.split(':');\n return segments.length === 8 && segments.every(isIPv6Segment);\n};\n\nconst isCIDRPrefix = (prefix: string, isIPv6: boolean): boolean => {\n const num = Number(prefix);\n return !isNaN(num) && num >= 0 && num <= (isIPv6 ? 128 : 32);\n};\n\nconst validateCIDR = (input: string): ValidationResult | null => {\n const [address, prefix] = input.split('/');\n if (!prefix) return null;\n\n // Check if it's IPv6 CIDR\n if (address.includes(':')) {\n if (!isIPv6Address(address) || !isCIDRPrefix(prefix, true)) {\n return {\n isValid: false,\n message: 'Invalid IPv6 CIDR range format',\n type: 'none',\n };\n }\n return {\n isValid: true,\n message: 'Valid IPv6 CIDR range',\n type: 'cidr-v6',\n };\n }\n\n // Check if it's IPv4 CIDR\n if (!isIPv4Address(address) || !isCIDRPrefix(prefix, false)) {\n return {\n isValid: false,\n message: 'Invalid IPv4 CIDR range format',\n type: 'none',\n };\n }\n return {\n isValid: true,\n message: 'Valid IPv4 CIDR range',\n type: 'cidr-v4',\n };\n};\n\nexport const validateIpAddress = (input: string): ValidationResult => {\n if (!input) {\n return {\n isValid: false,\n message: 'IP address is required',\n type: 'none',\n };\n }\n\n if (input.includes('/')) {\n const cidrResult = validateCIDR(input);\n if (cidrResult) return cidrResult;\n\n return {\n isValid: false,\n message: 'Invalid CIDR range format',\n type: 'none',\n };\n }\n\n // Single IP address validation\n if (isIPv4Address(input)) {\n return {\n isValid: true,\n message: 'Valid IPv4 address',\n type: 'ipv4',\n };\n }\n\n if (isIPv6Address(input)) {\n return {\n isValid: true,\n message: 'Valid IPv6 address',\n type: 'ipv6',\n };\n }\n\n return {\n isValid: false,\n message: 'Invalid IP address format',\n type: 'none',\n };\n};\n","import * as React from 'react';\nimport { useState, useEffect } from 'react';\nimport {\n validateIpAddress,\n type ValidationResult,\n} from '../utils/ipValidation';\n\ninterface IpAddressInputProps {\n initialValue?: string;\n name: string;\n id?: string;\n placeholder?: string;\n onChange?: (value: string, isValid: boolean) => void;\n className?: string;\n}\n\nconst IpAddressInput: React.FC = ({\n initialValue = '',\n name = 'ip_address_role[ip_address]',\n id,\n placeholder = 'Example: 192.168.1.1, 2001:db8::1, or 10.0.0.0/24',\n onChange,\n className = '',\n}) => {\n const [value, setValue] = useState(initialValue);\n const [validation, setValidation] = useState({\n isValid: true,\n message: '',\n type: 'none',\n });\n const [isFocused, setIsFocused] = useState(false);\n\n // Update validation when value changes\n useEffect(() => {\n const result = validateIpAddress(value);\n setValidation(result);\n\n // Call onChange callback if provided\n if (onChange) {\n onChange(value, result.isValid);\n }\n }, [value, onChange]);\n\n // Get border color based on validation state\n const getBorderColorClass = () => {\n if (!isFocused) return 'border-slate-300';\n if (value === '') return 'border-sky-500';\n return validation.isValid ? 'border-emerald-500' : 'border-red-500';\n };\n\n return (\n
\n setValue(e.target.value)}\n onFocus={() => setIsFocused(true)}\n onBlur={() => setIsFocused(false)}\n placeholder={placeholder}\n className={`block w-full rounded-md font-mono shadow-sm focus:ring-sky-500 sm:text-sm ${getBorderColorClass()} ${className}`}\n id={id || 'ip_address_input'}\n />\n\n {/* This is a direct input that will be properly included in form submission */}\n \n\n {/* Validation feedback */}\n {value !== '' && (\n
\n )}\n\n {/* Additional helpful information */}\n
\n
\n {validation.type === 'cidr-v4' || validation.type === 'cidr-v6' ? (\n \n CIDR notation represents an IP range (e.g., 10.0.0.0/24 for IPv4\n or 2001:db8::/32 for IPv6)\n \n ) : (\n \n Enter a single IP address (IPv4: 192.168.1.1 or IPv6: 2001:db8::1)\n or an IP range in CIDR notation\n \n )}\n
\n
\n
\n );\n};\n\nexport default IpAddressInput;\n","import ReactOnRails from 'react-on-rails';\n\nimport UserSearchBar from '../bundles/Main/components/UserSearchBar';\nimport { UserMenu } from '../bundles/Main/components/UserMenu';\nimport { PostHoverPreviewWrapper } from '../bundles/Main/components/PostHoverPreviewWrapper';\nimport { UserHoverPreviewWrapper } from '../bundles/Main/components/UserHoverPreviewWrapper';\nimport { initCollapsibleSections } from '../bundles/UI/collapsibleSections';\nimport { IpAddressInput } from '../bundles/UI/components';\n\n// This is how react_on_rails can see the components in the browser.\nReactOnRails.register({\n UserSearchBar,\n UserMenu,\n PostHoverPreviewWrapper,\n UserHoverPreviewWrapper,\n IpAddressInput,\n});\n\n// Initialize collapsible sections\ndocument.addEventListener('DOMContentLoaded', function () {\n initCollapsibleSections();\n});\n","import * as React from 'react';\nimport { useRef, useEffect, useState } from 'react';\n\ninterface UserMenuProps {\n userEmail: string;\n userRole?: 'admin' | 'moderator';\n editProfilePath: string;\n signOutPath: string;\n csrfToken: string;\n globalStatesPath: string;\n goodJobPath: string;\n grafanaPath: string;\n prometheusPath: string;\n}\n\nexport const UserMenu: React.FC = ({\n userEmail,\n userRole,\n editProfilePath,\n signOutPath,\n csrfToken,\n globalStatesPath,\n goodJobPath,\n grafanaPath,\n prometheusPath,\n}) => {\n const [isOpen, setIsOpen] = useState(false);\n const menuRef = useRef(null);\n\n useEffect(() => {\n const handleClickOutside = (event: MouseEvent) => {\n if (menuRef.current && !menuRef.current.contains(event.target as Node)) {\n setIsOpen(false);\n }\n };\n\n document.addEventListener('mousedown', handleClickOutside);\n return () => document.removeEventListener('mousedown', handleClickOutside);\n }, []);\n\n const handleSignOut = (e: React.FormEvent) => {\n e.preventDefault();\n const form = document.createElement('form');\n form.method = 'POST';\n form.action = signOutPath;\n form.style.display = 'none';\n\n const methodInput = document.createElement('input');\n methodInput.type = 'hidden';\n methodInput.name = '_method';\n methodInput.value = 'delete';\n\n const csrfInput = document.createElement('input');\n csrfInput.type = 'hidden';\n csrfInput.name = 'authenticity_token';\n csrfInput.value = csrfToken;\n\n form.appendChild(methodInput);\n form.appendChild(csrfInput);\n document.body.appendChild(form);\n form.submit();\n };\n\n return (\n