* refactor(auth): replace dashboard sessions with stateless tokens * feat(auth): harden session issuance and distributed enforcement * fix(proxy): preserve trusted proxy compatibility defaults * refactor: address dashboard auth review feedback * refactor: remove classic frontend and flatten web app
198 lines
5.8 KiB
TypeScript
Vendored
198 lines
5.8 KiB
TypeScript
Vendored
/*
|
|
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 { Minus, Plus } from 'lucide-react'
|
|
import { useState, useEffect, useRef } from 'react'
|
|
|
|
import { Label } from '@/components/ui/label'
|
|
import { cn } from '@/lib/utils'
|
|
|
|
interface NumericSpinnerInputProps {
|
|
value: number | null | undefined
|
|
onChange: (value: number) => void
|
|
min?: number
|
|
max?: number
|
|
step?: number
|
|
disabled?: boolean
|
|
className?: string
|
|
label?: string
|
|
}
|
|
|
|
export function NumericSpinnerInput({
|
|
value,
|
|
onChange,
|
|
min = 0,
|
|
max,
|
|
step = 1,
|
|
disabled = false,
|
|
className,
|
|
label,
|
|
}: NumericSpinnerInputProps) {
|
|
const [localValue, setLocalValue] = useState(String(value ?? 0))
|
|
const [editing, setEditing] = useState(false)
|
|
const inputRef = useRef<HTMLInputElement>(null)
|
|
|
|
useEffect(() => {
|
|
if (!editing) {
|
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
setLocalValue(String(value ?? 0))
|
|
}
|
|
}, [value, editing])
|
|
|
|
const clamp = (v: number) => {
|
|
let result = v
|
|
if (min !== undefined) result = Math.max(min, result)
|
|
if (max !== undefined) result = Math.min(max, result)
|
|
return result
|
|
}
|
|
|
|
const handleIncrement = (e: React.MouseEvent) => {
|
|
e.stopPropagation()
|
|
if (disabled) return
|
|
const next = clamp((Number(localValue) || 0) + step)
|
|
setLocalValue(String(next))
|
|
onChange(next)
|
|
}
|
|
|
|
const handleDecrement = (e: React.MouseEvent) => {
|
|
e.stopPropagation()
|
|
if (disabled) return
|
|
const next = clamp((Number(localValue) || 0) - step)
|
|
setLocalValue(String(next))
|
|
onChange(next)
|
|
}
|
|
|
|
const handleStartEdit = () => {
|
|
if (disabled) return
|
|
setEditing(true)
|
|
requestAnimationFrame(() => inputRef.current?.select())
|
|
}
|
|
|
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const raw = e.target.value
|
|
if (raw === '' || raw === '-') {
|
|
setLocalValue(raw)
|
|
return
|
|
}
|
|
if (!/^-?\d+$/.test(raw)) return
|
|
setLocalValue(raw)
|
|
}
|
|
|
|
const commitValue = () => {
|
|
setEditing(false)
|
|
const num = Number(localValue)
|
|
if (isNaN(num) || localValue === '' || localValue === '-') {
|
|
setLocalValue(String(value ?? 0))
|
|
return
|
|
}
|
|
const clamped = clamp(num)
|
|
setLocalValue(String(clamped))
|
|
if (clamped !== (value ?? 0)) {
|
|
onChange(clamped)
|
|
}
|
|
}
|
|
|
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault()
|
|
commitValue()
|
|
} else if (e.key === 'Escape') {
|
|
setEditing(false)
|
|
setLocalValue(String(value ?? 0))
|
|
}
|
|
}
|
|
|
|
const atMin = min !== undefined && Number(localValue) <= min
|
|
const atMax = max !== undefined && Number(localValue) >= max
|
|
|
|
return (
|
|
<div className={cn('inline-flex items-center', className)}>
|
|
{label && (
|
|
<Label className='text-muted-foreground mr-1.5 text-xs'>{label}</Label>
|
|
)}
|
|
<div
|
|
className={cn(
|
|
'group/spinner border-input inline-flex h-7 items-center gap-0 rounded-md border transition-colors',
|
|
!disabled && 'hover:bg-muted/60',
|
|
editing && 'bg-muted/60 ring-primary/30 ring-1'
|
|
)}
|
|
>
|
|
<button
|
|
type='button'
|
|
tabIndex={-1}
|
|
aria-label='Decrement'
|
|
onClick={handleDecrement}
|
|
disabled={disabled || atMin}
|
|
className={cn(
|
|
'text-muted-foreground/0 group-hover/spinner:text-muted-foreground flex h-7 w-6 shrink-0 items-center justify-center rounded-l-md transition-colors',
|
|
!disabled &&
|
|
!atMin &&
|
|
'group-hover/spinner:hover:text-foreground group-hover/spinner:hover:bg-muted',
|
|
(disabled || atMin) && 'group-hover/spinner:opacity-30'
|
|
)}
|
|
>
|
|
<Minus className='size-3' />
|
|
</button>
|
|
|
|
{editing ? (
|
|
<input
|
|
ref={inputRef}
|
|
type='text'
|
|
value={localValue}
|
|
onChange={handleInputChange}
|
|
onBlur={commitValue}
|
|
onKeyDown={handleKeyDown}
|
|
className='h-7 w-10 bg-transparent text-center font-mono text-sm outline-none'
|
|
autoFocus
|
|
/>
|
|
) : (
|
|
<button
|
|
type='button'
|
|
onClick={handleStartEdit}
|
|
disabled={disabled}
|
|
title={localValue}
|
|
className={cn(
|
|
'h-7 min-w-8 max-w-16 cursor-text truncate px-1 text-center font-mono text-sm tabular-nums',
|
|
disabled && 'cursor-default opacity-50'
|
|
)}
|
|
>
|
|
{localValue}
|
|
</button>
|
|
)}
|
|
|
|
<button
|
|
type='button'
|
|
tabIndex={-1}
|
|
aria-label='Increment'
|
|
onClick={handleIncrement}
|
|
disabled={disabled || atMax}
|
|
className={cn(
|
|
'text-muted-foreground/0 group-hover/spinner:text-muted-foreground flex h-7 w-6 shrink-0 items-center justify-center rounded-r-md transition-colors',
|
|
!disabled &&
|
|
!atMax &&
|
|
'group-hover/spinner:hover:text-foreground group-hover/spinner:hover:bg-muted',
|
|
(disabled || atMax) && 'group-hover/spinner:opacity-30'
|
|
)}
|
|
>
|
|
<Plus className='size-3' />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|