Add filesystem scan trigger and version badge features

Implements optional filesystem scan triggering for Plex and Audiobookshelf after file organization, with new settings in the admin UI, setup wizard, and API. Updates documentation to reflect scan trigger options and improved file organization/cleanup logic. Refactors dropdown menus to use smart positioning and portals for better UX. Adds a version API route and a VersionBadge component to display build info in the header. Updates Docker build to inject version metadata.
This commit is contained in:
kikootwo
2026-01-09 17:15:00 -05:00
parent 288421012d
commit 384601014a
25 changed files with 1346 additions and 243 deletions
+135
View File
@@ -0,0 +1,135 @@
/**
* Hook: Smart Dropdown Positioning with Portal Support
*
* Automatically positions dropdown menus to avoid viewport overflow.
* Detects available space above/below the trigger element and positions
* the dropdown accordingly. Returns absolute coordinates for portal rendering.
*/
import { useRef, useState, useEffect, RefObject } from 'react';
interface UseSmartDropdownPositionReturn {
containerRef: RefObject<HTMLDivElement | null>;
dropdownRef: RefObject<HTMLDivElement | null>;
positionAbove: boolean;
style: {
position: 'fixed';
top?: number;
bottom?: number;
left: number;
right?: number;
minWidth: number;
} | null;
}
/**
* Custom hook for smart dropdown positioning with portal support
*
* @param isOpen - Whether the dropdown is currently open
* @returns Object containing refs, positioning state, and absolute coordinates for portal rendering
*
* @example
* const { containerRef, dropdownRef, positionAbove, style } = useSmartDropdownPosition(isOpen);
*
* <div ref={containerRef}>
* <button>Toggle</button>
* </div>
* {isOpen && createPortal(
* <div ref={dropdownRef} style={style}>Menu items</div>,
* document.body
* )}
*/
export function useSmartDropdownPosition(isOpen: boolean): UseSmartDropdownPositionReturn {
const containerRef = useRef<HTMLDivElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const [positionAbove, setPositionAbove] = useState(false);
const [style, setStyle] = useState<UseSmartDropdownPositionReturn['style']>(null);
useEffect(() => {
if (!isOpen) {
setStyle(null);
return;
}
const calculatePosition = () => {
if (!containerRef.current) {
return;
}
const buttonRect = containerRef.current.getBoundingClientRect();
// Get dropdown dimensions if available, otherwise estimate
const dropdownHeight = dropdownRef.current
? dropdownRef.current.getBoundingClientRect().height
: 300; // Reasonable default estimate
const dropdownWidth = dropdownRef.current
? dropdownRef.current.getBoundingClientRect().width
: 224; // Default width estimate (w-56)
// Calculate available space (with 16px buffer from viewport edges)
const spaceBelow = window.innerHeight - buttonRect.bottom - 16;
const spaceAbove = buttonRect.top - 16;
// Position above if not enough space below
const shouldPositionAbove = spaceBelow < dropdownHeight && spaceAbove >= dropdownHeight;
setPositionAbove(shouldPositionAbove);
// Calculate absolute position for portal rendering
// Align right edge of dropdown with right edge of button
const newStyle: UseSmartDropdownPositionReturn['style'] = {
position: 'fixed',
left: Math.max(8, buttonRect.right - dropdownWidth), // Keep 8px from left edge
minWidth: buttonRect.width,
};
if (shouldPositionAbove) {
// Position above the button
newStyle.bottom = window.innerHeight - buttonRect.top + 8; // 8px margin
} else {
// Position below the button
newStyle.top = buttonRect.bottom + 8; // 8px margin
}
setStyle(newStyle);
};
// Use requestAnimationFrame for immediate measurement after render
const rafId = requestAnimationFrame(() => {
calculatePosition();
});
// Recalculate on scroll/resize (debounced)
const debouncedCalculate = debounce(calculatePosition, 150);
// Use capture phase for scroll to catch scrolling in any parent
window.addEventListener('scroll', debouncedCalculate, true);
window.addEventListener('resize', debouncedCalculate);
return () => {
cancelAnimationFrame(rafId);
window.removeEventListener('scroll', debouncedCalculate, true);
window.removeEventListener('resize', debouncedCalculate);
};
}, [isOpen]);
return { containerRef, dropdownRef, positionAbove, style };
}
/**
* Debounce helper function
*/
function debounce<T extends (...args: any[]) => any>(
func: T,
wait: number
): (...args: Parameters<T>) => void {
let timeout: NodeJS.Timeout | null = null;
return (...args: Parameters<T>) => {
if (timeout) {
clearTimeout(timeout);
}
timeout = setTimeout(() => func(...args), wait);
};
}