Publish Docker image (Multi-arch) / Build & push (amd64) (push) Failing after 1m40s
Release (Linux, macOS, Windows) / Linux Release (push) Failing after 42m16s
Publish Docker image (Multi-arch) / Build & push (arm64) (push) Canceled after 0s
Release (Linux, macOS, Windows) / macOS Release (push) Canceled after 0s
Release (Linux, macOS, Windows) / Windows Release (push) Canceled after 0s
Publish Docker image (Multi-arch) / Create multi-arch manifests (push) Canceled after 0s
Sync production source including routing, billing, Astra detection and public uptime. Update custom build instructions and license packaging.
478 lines
14 KiB
TypeScript
478 lines
14 KiB
TypeScript
/*
|
|
Copyright (C) 2023-2026 QuantumNous
|
|
|
|
This program is free software: you can redistribute it and/or modify
|
|
it under the terms of the GNU Affero General Public License as
|
|
published by the Free Software Foundation, either version 3 of the
|
|
License, or (at your option) any later version.
|
|
|
|
This program is distributed in the hope that it will be useful,
|
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
GNU Affero General Public License for more details.
|
|
|
|
You should have received a copy of the GNU Affero General Public License
|
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
|
|
For commercial licensing, please contact support@quantumnous.com
|
|
*/
|
|
import { ChevronsUpDown, GripHorizontal, X } from 'lucide-react'
|
|
import {
|
|
type PointerEvent as ReactPointerEvent,
|
|
type ReactNode,
|
|
useEffect,
|
|
useId,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from 'react'
|
|
import { useTranslation } from 'react-i18next'
|
|
|
|
import { Button } from '@/components/ui/button'
|
|
import { cn } from '@/lib/utils'
|
|
|
|
export type FloatingWindowPosition = {
|
|
x: number
|
|
y: number
|
|
}
|
|
|
|
type FloatingWindowSize = {
|
|
width: number
|
|
/** Unset until the user resizes vertically; content decides until then. */
|
|
height?: number
|
|
}
|
|
|
|
type FloatingWindowProps = {
|
|
title: ReactNode
|
|
children: ReactNode
|
|
onClose: () => void
|
|
/** Viewport coordinates used until the user moves the window. */
|
|
defaultPosition: FloatingWindowPosition
|
|
defaultWidth: number
|
|
defaultHeight?: number
|
|
minWidth?: number
|
|
minHeight?: number
|
|
/** Small element rendered beside the title, e.g. a count badge. */
|
|
badge?: ReactNode
|
|
footer?: ReactNode
|
|
/**
|
|
* Persists position, size and collapsed state in `localStorage` under this
|
|
* key so the window reopens where and how the user left it.
|
|
*/
|
|
storageKey?: string
|
|
className?: string
|
|
}
|
|
|
|
type FloatingWindowState = {
|
|
position: FloatingWindowPosition
|
|
size: FloatingWindowSize
|
|
collapsed: boolean
|
|
}
|
|
|
|
type ResizeDirection = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw'
|
|
|
|
const RESIZE_DIRECTIONS: ResizeDirection[] = [
|
|
'n',
|
|
's',
|
|
'e',
|
|
'w',
|
|
'ne',
|
|
'nw',
|
|
'se',
|
|
'sw',
|
|
]
|
|
const RESIZE_HANDLE_CLASS: Record<ResizeDirection, string> = {
|
|
n: 'top-0 left-2 right-2 h-1.5 cursor-ns-resize',
|
|
s: 'bottom-0 left-2 right-2 h-1.5 cursor-ns-resize',
|
|
e: 'top-2 bottom-2 right-0 w-1.5 cursor-ew-resize',
|
|
w: 'top-2 bottom-2 left-0 w-1.5 cursor-ew-resize',
|
|
ne: 'top-0 right-0 size-3 cursor-nesw-resize',
|
|
nw: 'top-0 left-0 size-3 cursor-nwse-resize',
|
|
se: 'bottom-0 right-0 size-3 cursor-nwse-resize',
|
|
sw: 'bottom-0 left-0 size-3 cursor-nesw-resize',
|
|
}
|
|
|
|
const VIEWPORT_MARGIN = 8
|
|
/** Keeps at least the header reachable when the window is dragged downward. */
|
|
const HEADER_HEIGHT = 48
|
|
const DEFAULT_MIN_WIDTH = 280
|
|
const DEFAULT_MIN_HEIGHT = 200
|
|
|
|
function storageKeyFor(key: string) {
|
|
return `floating-window:${key}`
|
|
}
|
|
|
|
function readStoredState(key?: string): FloatingWindowState | null {
|
|
if (!key) return null
|
|
try {
|
|
const raw = window.localStorage.getItem(storageKeyFor(key))
|
|
if (!raw) return null
|
|
const parsed: unknown = JSON.parse(raw)
|
|
if (!parsed || typeof parsed !== 'object') return null
|
|
const stored = parsed as Record<string, unknown>
|
|
if (typeof stored.x !== 'number' || typeof stored.y !== 'number') {
|
|
return null
|
|
}
|
|
return {
|
|
position: { x: stored.x, y: stored.y },
|
|
size: {
|
|
width: typeof stored.width === 'number' ? stored.width : Number.NaN,
|
|
height: typeof stored.height === 'number' ? stored.height : undefined,
|
|
},
|
|
collapsed: stored.collapsed === true,
|
|
}
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
function clampPosition(
|
|
position: FloatingWindowPosition,
|
|
width: number
|
|
): FloatingWindowPosition {
|
|
const maxX = Math.max(
|
|
VIEWPORT_MARGIN,
|
|
window.innerWidth - width - VIEWPORT_MARGIN
|
|
)
|
|
const maxY = Math.max(
|
|
VIEWPORT_MARGIN,
|
|
window.innerHeight - HEADER_HEIGHT - VIEWPORT_MARGIN
|
|
)
|
|
return {
|
|
x: Math.min(Math.max(VIEWPORT_MARGIN, position.x), maxX),
|
|
y: Math.min(Math.max(VIEWPORT_MARGIN, position.y), maxY),
|
|
}
|
|
}
|
|
|
|
function clampSize(
|
|
size: FloatingWindowSize,
|
|
limits: { minWidth: number; minHeight: number }
|
|
): FloatingWindowSize {
|
|
const maxWidth = Math.max(
|
|
limits.minWidth,
|
|
window.innerWidth - VIEWPORT_MARGIN * 2
|
|
)
|
|
const maxHeight = Math.max(
|
|
limits.minHeight,
|
|
window.innerHeight - VIEWPORT_MARGIN * 2
|
|
)
|
|
return {
|
|
width: Math.min(Math.max(limits.minWidth, size.width), maxWidth),
|
|
height:
|
|
size.height === undefined
|
|
? undefined
|
|
: Math.min(Math.max(limits.minHeight, size.height), maxHeight),
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Non-modal window that floats above the page: dragged by its header, resized
|
|
* from any edge or corner, collapsible to a pill. It renders where it is
|
|
* mounted (no portal) with `position: fixed`, so a parent modal keeps
|
|
* treating it as its own content.
|
|
*/
|
|
export function FloatingWindow(props: FloatingWindowProps) {
|
|
const { t } = useTranslation()
|
|
const id = useId()
|
|
const limits = useMemo(
|
|
() => ({
|
|
minWidth: props.minWidth ?? DEFAULT_MIN_WIDTH,
|
|
minHeight: props.minHeight ?? DEFAULT_MIN_HEIGHT,
|
|
}),
|
|
[props.minWidth, props.minHeight]
|
|
)
|
|
const [state, setState] = useState<FloatingWindowState>(() => {
|
|
const stored = readStoredState(props.storageKey)
|
|
const size = clampSize(
|
|
{
|
|
width:
|
|
stored && !Number.isNaN(stored.size.width)
|
|
? stored.size.width
|
|
: props.defaultWidth,
|
|
height: stored ? stored.size.height : props.defaultHeight,
|
|
},
|
|
limits
|
|
)
|
|
return {
|
|
position: clampPosition(
|
|
stored?.position ?? props.defaultPosition,
|
|
size.width
|
|
),
|
|
size,
|
|
collapsed: stored?.collapsed ?? false,
|
|
}
|
|
})
|
|
const windowRef = useRef<HTMLElement>(null)
|
|
const dragRef = useRef<{
|
|
pointerId: number
|
|
offsetX: number
|
|
offsetY: number
|
|
} | null>(null)
|
|
const resizeRef = useRef<{
|
|
pointerId: number
|
|
direction: ResizeDirection
|
|
startX: number
|
|
startY: number
|
|
position: FloatingWindowPosition
|
|
size: { width: number; height: number }
|
|
} | null>(null)
|
|
|
|
useEffect(() => {
|
|
if (!props.storageKey) return
|
|
try {
|
|
window.localStorage.setItem(
|
|
storageKeyFor(props.storageKey),
|
|
JSON.stringify({
|
|
x: state.position.x,
|
|
y: state.position.y,
|
|
width: state.size.width,
|
|
height: state.size.height,
|
|
collapsed: state.collapsed,
|
|
})
|
|
)
|
|
} catch {
|
|
// Storage may be unavailable; the window still works for this session.
|
|
}
|
|
}, [props.storageKey, state])
|
|
|
|
useEffect(() => {
|
|
const keepInViewport = () =>
|
|
setState((previous) => {
|
|
const size = clampSize(previous.size, limits)
|
|
return {
|
|
...previous,
|
|
size,
|
|
position: clampPosition(previous.position, size.width),
|
|
}
|
|
})
|
|
window.addEventListener('resize', keepInViewport)
|
|
return () => window.removeEventListener('resize', keepInViewport)
|
|
}, [limits])
|
|
|
|
const capturePointer = (
|
|
event: ReactPointerEvent<HTMLElement>,
|
|
capture: boolean
|
|
) => {
|
|
const method = capture ? 'setPointerCapture' : 'releasePointerCapture'
|
|
if (typeof event.currentTarget[method] === 'function') {
|
|
event.currentTarget[method](event.pointerId)
|
|
}
|
|
}
|
|
|
|
const handleDragStart = (event: ReactPointerEvent<HTMLElement>) => {
|
|
if (event.button !== 0) return
|
|
if (
|
|
event.target instanceof Element &&
|
|
event.target.closest('button, a, input, select, textarea')
|
|
) {
|
|
return
|
|
}
|
|
dragRef.current = {
|
|
pointerId: event.pointerId,
|
|
offsetX: event.clientX - state.position.x,
|
|
offsetY: event.clientY - state.position.y,
|
|
}
|
|
capturePointer(event, true)
|
|
}
|
|
|
|
const handleDragMove = (event: ReactPointerEvent<HTMLElement>) => {
|
|
const drag = dragRef.current
|
|
if (!drag || drag.pointerId !== event.pointerId) return
|
|
const next = clampPosition(
|
|
{ x: event.clientX - drag.offsetX, y: event.clientY - drag.offsetY },
|
|
state.size.width
|
|
)
|
|
setState((previous) => ({ ...previous, position: next }))
|
|
}
|
|
|
|
const handleDragEnd = (event: ReactPointerEvent<HTMLElement>) => {
|
|
if (dragRef.current?.pointerId !== event.pointerId) return
|
|
dragRef.current = null
|
|
capturePointer(event, false)
|
|
}
|
|
|
|
const handleResizeStart = (
|
|
event: ReactPointerEvent<HTMLElement>,
|
|
direction: ResizeDirection
|
|
) => {
|
|
if (event.button !== 0) return
|
|
event.preventDefault()
|
|
// Until the user has set a height the window is content-sized, so the
|
|
// rendered height becomes the starting point of a vertical resize.
|
|
const renderedHeight = windowRef.current?.getBoundingClientRect().height
|
|
resizeRef.current = {
|
|
pointerId: event.pointerId,
|
|
direction,
|
|
startX: event.clientX,
|
|
startY: event.clientY,
|
|
position: state.position,
|
|
size: {
|
|
width: state.size.width,
|
|
height:
|
|
state.size.height ?? Math.max(renderedHeight ?? 0, limits.minHeight),
|
|
},
|
|
}
|
|
capturePointer(event, true)
|
|
}
|
|
|
|
const handleResizeMove = (event: ReactPointerEvent<HTMLElement>) => {
|
|
const resize = resizeRef.current
|
|
if (!resize || resize.pointerId !== event.pointerId) return
|
|
const dx = event.clientX - resize.startX
|
|
const dy = event.clientY - resize.startY
|
|
let { width, height } = resize.size
|
|
let { x, y } = resize.position
|
|
if (resize.direction.includes('e')) width = resize.size.width + dx
|
|
if (resize.direction.includes('s')) height = resize.size.height + dy
|
|
if (resize.direction.includes('w')) width = resize.size.width - dx
|
|
if (resize.direction.includes('n')) height = resize.size.height - dy
|
|
const size = clampSize({ width, height }, limits)
|
|
if (resize.direction.includes('w')) {
|
|
x = resize.position.x + (resize.size.width - size.width)
|
|
}
|
|
if (resize.direction.includes('n')) {
|
|
y = resize.position.y + (resize.size.height - (size.height ?? height))
|
|
}
|
|
const position = clampPosition({ x, y }, size.width)
|
|
setState((previous) => ({ ...previous, size, position }))
|
|
}
|
|
|
|
const handleResizeEnd = (event: ReactPointerEvent<HTMLElement>) => {
|
|
if (resizeRef.current?.pointerId !== event.pointerId) return
|
|
resizeRef.current = null
|
|
capturePointer(event, false)
|
|
}
|
|
|
|
const dragHandleProps = {
|
|
title: t('Drag to move'),
|
|
onPointerDown: handleDragStart,
|
|
onPointerMove: handleDragMove,
|
|
onPointerUp: handleDragEnd,
|
|
onPointerCancel: handleDragEnd,
|
|
}
|
|
const style = {
|
|
position: 'fixed' as const,
|
|
left: state.position.x,
|
|
top: state.position.y,
|
|
}
|
|
|
|
if (state.collapsed) {
|
|
return (
|
|
<div
|
|
{...dragHandleProps}
|
|
style={style}
|
|
className={cn(
|
|
'bg-background border-border z-50 flex cursor-move touch-none items-center gap-2 rounded-full border py-1 pr-1 pl-3 shadow-lg select-none',
|
|
props.className
|
|
)}
|
|
>
|
|
<GripHorizontal
|
|
className='text-muted-foreground size-3.5 shrink-0'
|
|
aria-hidden='true'
|
|
/>
|
|
<span className='text-sm font-semibold'>{props.title}</span>
|
|
{props.badge}
|
|
<Button
|
|
type='button'
|
|
variant='ghost'
|
|
size='icon-xs'
|
|
aria-label={t('Expand panel')}
|
|
onClick={() =>
|
|
setState((previous) => ({ ...previous, collapsed: false }))
|
|
}
|
|
>
|
|
<ChevronsUpDown aria-hidden='true' />
|
|
</Button>
|
|
<Button
|
|
type='button'
|
|
variant='ghost'
|
|
size='icon-xs'
|
|
aria-label={t('Close panel')}
|
|
onClick={props.onClose}
|
|
>
|
|
<X aria-hidden='true' />
|
|
</Button>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<aside
|
|
ref={windowRef}
|
|
role='complementary'
|
|
aria-labelledby={`${id}-title`}
|
|
style={{
|
|
...style,
|
|
width: state.size.width,
|
|
height: state.size.height,
|
|
maxHeight: `calc(100vh - ${VIEWPORT_MARGIN * 2}px)`,
|
|
}}
|
|
className={cn(
|
|
'bg-background border-border z-50 flex flex-col overflow-hidden rounded-xl border shadow-xl',
|
|
props.className
|
|
)}
|
|
>
|
|
<header
|
|
{...dragHandleProps}
|
|
className='border-border/70 flex cursor-move touch-none items-center gap-2 border-b px-4 py-3 select-none'
|
|
>
|
|
<GripHorizontal
|
|
className='text-muted-foreground size-3.5 shrink-0'
|
|
aria-hidden='true'
|
|
/>
|
|
<h3 id={`${id}-title`} className='text-sm font-semibold'>
|
|
{props.title}
|
|
</h3>
|
|
{props.badge}
|
|
<div className='ml-auto flex items-center gap-1'>
|
|
<Button
|
|
type='button'
|
|
variant='ghost'
|
|
size='icon-xs'
|
|
aria-label={t('Collapse panel')}
|
|
onClick={() =>
|
|
setState((previous) => ({ ...previous, collapsed: true }))
|
|
}
|
|
>
|
|
<ChevronsUpDown aria-hidden='true' />
|
|
</Button>
|
|
<Button
|
|
type='button'
|
|
variant='ghost'
|
|
size='icon-xs'
|
|
aria-label={t('Close panel')}
|
|
onClick={props.onClose}
|
|
>
|
|
<X aria-hidden='true' />
|
|
</Button>
|
|
</div>
|
|
</header>
|
|
<div className='min-h-0 flex-1 overflow-y-auto px-4 py-3'>
|
|
{props.children}
|
|
</div>
|
|
{props.footer && (
|
|
<footer className='border-border/70 border-t px-4 py-3'>
|
|
{props.footer}
|
|
</footer>
|
|
)}
|
|
{RESIZE_DIRECTIONS.map((direction) => (
|
|
<div
|
|
key={direction}
|
|
data-slot='floating-window-resize'
|
|
data-direction={direction}
|
|
title={t('Drag to resize')}
|
|
aria-hidden='true'
|
|
className={cn(
|
|
'absolute z-10 touch-none',
|
|
RESIZE_HANDLE_CLASS[direction]
|
|
)}
|
|
onPointerDown={(event) => handleResizeStart(event, direction)}
|
|
onPointerMove={handleResizeMove}
|
|
onPointerUp={handleResizeEnd}
|
|
onPointerCancel={handleResizeEnd}
|
|
/>
|
|
))}
|
|
</aside>
|
|
)
|
|
}
|