fix: code review — security, dead code, performance, consistency
Critical fixes: - XSS: escapeHtml() on all user-supplied text in email notifications - Budget PATCH: added mutex lock + availability validation (prevents corruption) - batchResolveNames: fixed wrong signature for budget request earmark names Dead code cleanup: - Deleted 8 unused PostComposition* files (replaced by PostDetail full page) Performance: - budget-helpers: single-fetch with computeFromEntries(), optional prefetch param - post-composition: parallelized text + thumbnail fetches with Promise.all Consistency: - PostDetail.jsx: native <select> → PortalSelect (matches all panels) - Finance.jsx: 11 hardcoded English table headers → t() with i18n keys - PostCalendar.jsx: day names, Month/Week labels → t() with i18n keys - App.jsx Suspense: "Loading..." → brand spinner (can't use i18n in fallback) - UploadZone: proper useRef pattern, no vanilla JS document.createElement - All file inputs: className="hidden" → absolute w-0 h-0 opacity-0 - ArtefactDetailPanel: removed campaign/project selects (inherited from post) - TranslationDetailPanel: removed brand/linked post selects (inherited from post) - ApproverMultiSelect: portal-based dropdown (fixes clipping in modals) - Thumbnail fix: post-composition constructs URL from filename (was undefined) - Upload fix: UploadZone with drag-and-drop for design + video artefacts Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { ChevronDown, Check } from 'lucide-react'
|
||||
|
||||
/**
|
||||
* Portal-based select dropdown that renders options outside any overflow/stacking context.
|
||||
* Drop-in replacement for <select> inside SlidePanel/TabbedModal/Modal.
|
||||
*
|
||||
* Props:
|
||||
* value - current value
|
||||
* onChange - (value) => void
|
||||
* options - [{ value, label }] or children-based (fallback to native if no options)
|
||||
* placeholder - text when no value selected
|
||||
* className - additional classes on the trigger button
|
||||
* disabled - boolean
|
||||
*/
|
||||
export default function PortalSelect({ value, onChange, options = [], placeholder = '—', className = '', disabled = false }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const triggerRef = useRef(null)
|
||||
const dropdownRef = useRef(null)
|
||||
const [pos, setPos] = useState({ top: 0, left: 0, width: 0 })
|
||||
|
||||
const selectedOption = options.find(o => String(o.value) === String(value))
|
||||
const displayText = selectedOption?.label || placeholder
|
||||
|
||||
const updatePosition = useCallback(() => {
|
||||
if (!triggerRef.current) return
|
||||
const rect = triggerRef.current.getBoundingClientRect()
|
||||
const spaceBelow = window.innerHeight - rect.bottom
|
||||
const dropdownHeight = Math.min(options.length * 32 + 8, 240)
|
||||
const showAbove = spaceBelow < dropdownHeight && rect.top > dropdownHeight
|
||||
|
||||
setPos({
|
||||
top: showAbove ? rect.top - dropdownHeight - 4 : rect.bottom + 4,
|
||||
left: rect.left,
|
||||
width: Math.max(rect.width, 160),
|
||||
})
|
||||
}, [options.length])
|
||||
|
||||
const handleOpen = () => {
|
||||
if (disabled) return
|
||||
updatePosition()
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
const handleSelect = (val) => {
|
||||
onChange(val)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const handleClick = (e) => {
|
||||
if (triggerRef.current?.contains(e.target)) return
|
||||
if (dropdownRef.current?.contains(e.target)) return
|
||||
setOpen(false)
|
||||
}
|
||||
const handleEsc = (e) => { if (e.key === 'Escape') setOpen(false) }
|
||||
const handleScroll = () => updatePosition()
|
||||
|
||||
document.addEventListener('mousedown', handleClick)
|
||||
document.addEventListener('keydown', handleEsc)
|
||||
window.addEventListener('scroll', handleScroll, true)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClick)
|
||||
document.removeEventListener('keydown', handleEsc)
|
||||
window.removeEventListener('scroll', handleScroll, true)
|
||||
}
|
||||
}, [open, updatePosition])
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
onClick={handleOpen}
|
||||
disabled={disabled}
|
||||
className={`flex items-center justify-between gap-1 text-start ${className} ${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`}
|
||||
>
|
||||
<span className={`truncate ${selectedOption ? '' : 'text-text-tertiary'}`}>{displayText}</span>
|
||||
<ChevronDown className={`w-3 h-3 shrink-0 text-text-tertiary transition-transform ${open ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{open && createPortal(
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className="fixed z-[99999] bg-surface border border-border rounded-lg shadow-lg overflow-y-auto animate-scale-in"
|
||||
style={{ top: pos.top, left: pos.left, width: pos.width, maxHeight: 240 }}
|
||||
>
|
||||
{options.map(opt => {
|
||||
const isSelected = String(opt.value) === String(value)
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => handleSelect(opt.value)}
|
||||
className={`w-full flex items-center gap-2 px-3 py-1.5 text-xs text-start transition-colors ${
|
||||
isSelected
|
||||
? 'bg-brand-primary/10 text-brand-primary font-medium'
|
||||
: 'text-text-primary hover:bg-surface-secondary'
|
||||
}`}
|
||||
>
|
||||
<span className="flex-1 truncate">{opt.label}</span>
|
||||
{isSelected && <Check className="w-3 h-3 shrink-0" />}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{options.length === 0 && (
|
||||
<div className="px-3 py-2 text-xs text-text-tertiary text-center">—</div>
|
||||
)}
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user