Show separate light/dark logo and favicon croppers in a compact grid, support favicon saves, and keep super-admin overflow dashboard links in English. Co-authored-by: Cursor <cursoragent@cursor.com>
205 lines
5.3 KiB
TypeScript
205 lines
5.3 KiB
TypeScript
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
|
import { createPortal } from 'react-dom'
|
|
import {
|
|
Building2,
|
|
ExternalLink,
|
|
Globe,
|
|
MoreHorizontal,
|
|
Trash2,
|
|
UserRound,
|
|
} from 'lucide-react'
|
|
import { fitFixedPopover } from '../utils/fitFixedPopover'
|
|
import styles from './BusinessActionsOverflowMenu.module.css'
|
|
|
|
type PopoverPosition = {
|
|
top: number
|
|
left: number
|
|
}
|
|
|
|
interface DomainLinksOverflowMenuProps {
|
|
host: string
|
|
onRemove: () => void
|
|
}
|
|
|
|
function apexHost(host: string) {
|
|
return host.trim().replace(/^www\./i, '')
|
|
}
|
|
|
|
function siteProtocol(host: string) {
|
|
return /\.local$/i.test(host) ? 'http:' : 'https:'
|
|
}
|
|
|
|
function siteUrl(host: string) {
|
|
return `${siteProtocol(host)}//${host}`
|
|
}
|
|
|
|
function openHost(host: string) {
|
|
window.open(siteUrl(host), '_blank', 'noopener,noreferrer')
|
|
}
|
|
|
|
export function DomainLinksOverflowMenu({ host, onRemove }: DomainLinksOverflowMenuProps) {
|
|
const [open, setOpen] = useState(false)
|
|
const [popoverPos, setPopoverPos] = useState<PopoverPosition | null>(null)
|
|
const wrapRef = useRef<HTMLDivElement>(null)
|
|
const popoverRef = useRef<HTMLDivElement>(null)
|
|
const triggerRef = useRef<HTMLButtonElement>(null)
|
|
|
|
const apex = apexHost(host)
|
|
const websiteHost = apex
|
|
const businessHost = `business.${apex}`
|
|
const customerHost = `customer.${apex}`
|
|
|
|
function updatePopoverPosition() {
|
|
const rect = triggerRef.current?.getBoundingClientRect()
|
|
if (!rect) return
|
|
|
|
const size = popoverRef.current
|
|
? { width: popoverRef.current.offsetWidth, height: popoverRef.current.offsetHeight }
|
|
: { width: 220, height: 220 }
|
|
|
|
setPopoverPos(fitFixedPopover(rect, size, 'end'))
|
|
}
|
|
|
|
function toggleOpen() {
|
|
if (open) {
|
|
setOpen(false)
|
|
return
|
|
}
|
|
|
|
updatePopoverPosition()
|
|
setOpen(true)
|
|
}
|
|
|
|
useLayoutEffect(() => {
|
|
if (!open) return
|
|
updatePopoverPosition()
|
|
}, [open])
|
|
|
|
useEffect(() => {
|
|
if (!open) return
|
|
|
|
function handleClickOutside(e: MouseEvent) {
|
|
const target = e.target as Node
|
|
if (wrapRef.current?.contains(target)) return
|
|
if (popoverRef.current?.contains(target)) return
|
|
setOpen(false)
|
|
}
|
|
|
|
function handleEscape(e: KeyboardEvent) {
|
|
if (e.key === 'Escape') setOpen(false)
|
|
}
|
|
|
|
function handleReposition() {
|
|
updatePopoverPosition()
|
|
}
|
|
|
|
document.addEventListener('mousedown', handleClickOutside)
|
|
document.addEventListener('keydown', handleEscape)
|
|
window.addEventListener('resize', handleReposition)
|
|
window.addEventListener('scroll', handleReposition, true)
|
|
|
|
return () => {
|
|
document.removeEventListener('mousedown', handleClickOutside)
|
|
document.removeEventListener('keydown', handleEscape)
|
|
window.removeEventListener('resize', handleReposition)
|
|
window.removeEventListener('scroll', handleReposition, true)
|
|
}
|
|
}, [open])
|
|
|
|
const items = [
|
|
{
|
|
key: 'website',
|
|
label: 'Open website',
|
|
detail: websiteHost,
|
|
icon: Globe,
|
|
onClick: () => openHost(websiteHost),
|
|
},
|
|
{
|
|
key: 'business',
|
|
label: 'Business dashboard',
|
|
detail: businessHost,
|
|
icon: Building2,
|
|
onClick: () => openHost(businessHost),
|
|
},
|
|
{
|
|
key: 'customer',
|
|
label: 'Customer dashboard',
|
|
detail: customerHost,
|
|
icon: UserRound,
|
|
onClick: () => openHost(customerHost),
|
|
},
|
|
] as const
|
|
|
|
return (
|
|
<div className={styles.wrap} ref={wrapRef}>
|
|
<button
|
|
ref={triggerRef}
|
|
type="button"
|
|
className={`${styles.trigger} ${open ? styles.triggerOpen : ''}`}
|
|
onClick={(e) => {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
toggleOpen()
|
|
}}
|
|
title="More actions"
|
|
aria-label={`More actions for ${apex}`}
|
|
aria-haspopup="menu"
|
|
aria-expanded={open}
|
|
>
|
|
<MoreHorizontal size={16} />
|
|
</button>
|
|
|
|
{open &&
|
|
popoverPos &&
|
|
createPortal(
|
|
<div
|
|
ref={popoverRef}
|
|
className={styles.menu}
|
|
role="menu"
|
|
aria-label={`More actions for ${apex}`}
|
|
style={{
|
|
top: popoverPos.top,
|
|
left: popoverPos.left,
|
|
}}
|
|
>
|
|
{items.map(({ key, label, detail, icon: Icon, onClick }) => (
|
|
<button
|
|
key={key}
|
|
type="button"
|
|
className={styles.item}
|
|
role="menuitem"
|
|
onClick={() => {
|
|
setOpen(false)
|
|
onClick()
|
|
}}
|
|
>
|
|
<Icon size={16} />
|
|
<span className={styles.itemText}>
|
|
<span>{label}</span>
|
|
<span className={styles.itemDetail} dir="ltr">
|
|
{detail}
|
|
<ExternalLink size={12} aria-hidden="true" />
|
|
</span>
|
|
</span>
|
|
</button>
|
|
))}
|
|
<div className={styles.menuDivider} role="separator" />
|
|
<button
|
|
type="button"
|
|
className={`${styles.item} ${styles.itemDanger}`}
|
|
role="menuitem"
|
|
onClick={() => {
|
|
setOpen(false)
|
|
onRemove()
|
|
}}
|
|
>
|
|
<Trash2 size={16} />
|
|
<span>Remove</span>
|
|
</button>
|
|
</div>,
|
|
document.body,
|
|
)}
|
|
</div>
|
|
)
|
|
}
|