Files
hapi/web/src/components/SessionActionMenu.tsx
T

434 lines
14 KiB
TypeScript

import {
useCallback,
useEffect,
useId,
useLayoutEffect,
useRef,
useState,
type CSSProperties
} from 'react'
import { useTranslation } from '@/lib/use-translation'
import { HoverTooltip } from '@/components/HoverTooltip'
import { safeCopyToClipboard } from '@/lib/clipboard'
import { buildSessionReferenceText } from '@/lib/sessionReference'
import { usePlatform } from '@/hooks/usePlatform'
import { CopyIcon } from '@/components/icons'
type SessionActionMenuProps = {
isOpen: boolean
onClose: () => void
sessionId: string
sessionTitle: string
sessionActive: boolean
onRename: () => void
onExport?: () => void
onSyncCodex?: () => void
onArchive: () => void
onReopen?: () => void
reopenDisabledReason?: string
onDelete: () => void
anchorPoint: { x: number; y: number }
menuId?: string
}
function EditIcon(props: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={props.className}
>
<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" />
<path d="m15 5 4 4" />
</svg>
)
}
function ArchiveIcon(props: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={props.className}
>
<rect width="20" height="5" x="2" y="3" rx="1" />
<path d="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8" />
<path d="M10 12h4" />
</svg>
)
}
function DownloadIcon(props: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={props.className}
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" x2="12" y1="15" y2="3" />
</svg>
)
}
function ReopenIcon(props: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={props.className}
>
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
<path d="M3 3v5h5" />
</svg>
)
}
function SyncIcon(props: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={props.className}
>
<path d="M3 12a9 9 0 0 1 15-6.7L21 8" />
<path d="M21 3v5h-5" />
<path d="M21 12a9 9 0 0 1-15 6.7L3 16" />
<path d="M3 21v-5h5" />
</svg>
)
}
function TrashIcon(props: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={props.className}
>
<path d="M3 6h18" />
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
<line x1="10" x2="10" y1="11" y2="17" />
<line x1="14" x2="14" y1="11" y2="17" />
</svg>
)
}
type MenuPosition = {
top: number
left: number
transformOrigin: string
}
export function SessionActionMenu(props: SessionActionMenuProps) {
const { t } = useTranslation()
const { haptic } = usePlatform()
const {
isOpen,
onClose,
sessionId,
sessionTitle,
sessionActive,
onRename,
onExport,
onSyncCodex,
onArchive,
onReopen,
reopenDisabledReason,
onDelete,
anchorPoint,
menuId
} = props
const menuRef = useRef<HTMLDivElement | null>(null)
const [menuPosition, setMenuPosition] = useState<MenuPosition | null>(null)
const internalId = useId()
const resolvedMenuId = menuId ?? `session-action-menu-${internalId}`
const headingId = `${resolvedMenuId}-heading`
const handleRename = () => {
onClose()
onRename()
}
const handleCopyReference = async () => {
onClose()
try {
await safeCopyToClipboard(buildSessionReferenceText(sessionTitle, sessionId))
haptic.notification('success')
} catch {
haptic.notification('error')
}
}
const handleArchive = () => {
onClose()
onArchive()
}
const handleReopen = () => {
onClose()
onReopen?.()
}
const handleExport = () => {
onClose()
onExport?.()
}
const handleSyncCodex = () => {
onClose()
onSyncCodex?.()
}
const handleDelete = () => {
onClose()
onDelete()
}
const updatePosition = useCallback(() => {
const menuEl = menuRef.current
if (!menuEl) return
const menuRect = menuEl.getBoundingClientRect()
const viewportWidth = window.innerWidth
const viewportHeight = window.innerHeight
const padding = 8
const gap = 8
const spaceBelow = viewportHeight - anchorPoint.y
const spaceAbove = anchorPoint.y
const openAbove = spaceBelow < menuRect.height + gap && spaceAbove > spaceBelow
let top = openAbove ? anchorPoint.y - menuRect.height - gap : anchorPoint.y + gap
let left = anchorPoint.x - menuRect.width / 2
const transformOrigin = openAbove ? 'bottom center' : 'top center'
top = Math.min(Math.max(top, padding), viewportHeight - menuRect.height - padding)
left = Math.min(Math.max(left, padding), viewportWidth - menuRect.width - padding)
setMenuPosition({ top, left, transformOrigin })
}, [anchorPoint])
useLayoutEffect(() => {
if (!isOpen) return
updatePosition()
}, [isOpen, updatePosition])
useEffect(() => {
if (!isOpen) {
setMenuPosition(null)
return
}
const handlePointerDown = (event: PointerEvent) => {
const target = event.target as Node
if (menuRef.current?.contains(target)) return
onClose()
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose()
}
}
const handleReflow = () => {
updatePosition()
}
document.addEventListener('pointerdown', handlePointerDown)
document.addEventListener('keydown', handleKeyDown)
window.addEventListener('resize', handleReflow)
window.addEventListener('scroll', handleReflow, true)
return () => {
document.removeEventListener('pointerdown', handlePointerDown)
document.removeEventListener('keydown', handleKeyDown)
window.removeEventListener('resize', handleReflow)
window.removeEventListener('scroll', handleReflow, true)
}
}, [isOpen, onClose, updatePosition])
useEffect(() => {
if (!isOpen) return
const frame = window.requestAnimationFrame(() => {
const firstItem = menuRef.current?.querySelector<HTMLElement>('[role="menuitem"]')
firstItem?.focus()
})
return () => window.cancelAnimationFrame(frame)
}, [isOpen])
if (!isOpen) return null
const menuStyle: CSSProperties | undefined = menuPosition
? {
top: `max(${menuPosition.top}px, calc(env(safe-area-inset-top) + 8px))`,
left: menuPosition.left,
transformOrigin: menuPosition.transformOrigin
}
: undefined
const baseItemClassName =
'flex w-full items-center gap-3 rounded-md px-3 py-2 text-left text-base transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--app-link)]'
return (
<div
ref={menuRef}
className="fixed z-50 min-w-[200px] rounded-lg border border-[var(--app-border)] bg-[var(--app-bg)] p-1 shadow-lg animate-menu-pop"
style={menuStyle}
>
<div
id={headingId}
className="px-3 py-1.5 text-[10px] font-semibold uppercase tracking-wide text-[var(--app-hint)]"
>
{t('session.more')}
</div>
<div
id={resolvedMenuId}
role="menu"
aria-labelledby={headingId}
className="flex flex-col gap-1"
>
<button
type="button"
role="menuitem"
className={`${baseItemClassName} hover:bg-[var(--app-subtle-bg)]`}
onClick={handleRename}
>
<EditIcon className="text-[var(--app-hint)]" />
{t('session.action.rename')}
</button>
<button
type="button"
role="menuitem"
className={`${baseItemClassName} hover:bg-[var(--app-subtle-bg)]`}
onClick={() => void handleCopyReference()}
>
<CopyIcon className="h-[18px] w-[18px] text-[var(--app-hint)]" />
{t('session.action.copyReference')}
</button>
{onExport ? (
<button
type="button"
role="menuitem"
className={`${baseItemClassName} hover:bg-[var(--app-subtle-bg)]`}
onClick={handleExport}
>
<DownloadIcon className="text-[var(--app-hint)]" />
{t('session.action.export')}
</button>
) : null}
{onSyncCodex ? (
<button
type="button"
role="menuitem"
className={`${baseItemClassName} hover:bg-[var(--app-subtle-bg)]`}
onClick={handleSyncCodex}
>
<SyncIcon className="text-[var(--app-hint)]" />
{t('session.action.syncCodex')}
</button>
) : null}
{sessionActive ? (
<button
type="button"
role="menuitem"
className={`${baseItemClassName} text-red-500 hover:bg-red-500/10`}
onClick={handleArchive}
>
<ArchiveIcon className="text-red-500" />
{t('session.action.archive')}
</button>
) : (
<>
{onReopen || reopenDisabledReason ? (
<HoverTooltip
id={`${resolvedMenuId}-reopen-tooltip`}
className="w-full [&>span:first-child]:w-full"
align="start"
revealOnParentFocusClass="group-focus-within:opacity-100 group-focus-within:visible"
target={(
<button
type="button"
role="menuitem"
aria-disabled={reopenDisabledReason ? true : undefined}
aria-describedby={reopenDisabledReason ? `${resolvedMenuId}-reopen-tooltip` : undefined}
className={`${baseItemClassName} ${reopenDisabledReason
? 'cursor-not-allowed opacity-50'
: 'hover:bg-[var(--app-subtle-bg)]'}`}
onClick={reopenDisabledReason ? undefined : handleReopen}
>
<ReopenIcon className="text-[var(--app-hint)]" />
{t('session.action.reopen')}
</button>
)}
>
{reopenDisabledReason ?? t('session.action.reopen')}
</HoverTooltip>
) : null}
<button
type="button"
role="menuitem"
className={`${baseItemClassName} text-red-500 hover:bg-red-500/10`}
onClick={handleDelete}
>
<TrashIcon className="text-red-500" />
{t('session.action.delete')}
</button>
</>
)}
</div>
</div>
)
}