mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
434 lines
14 KiB
TypeScript
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>
|
|
)
|
|
}
|