feat: Nova UI component library + Studio dropdown/picker polish

- Add Nova UI library: Button, TextInput, Textarea, FormField, Select,
  NovaSelect, Checkbox, Radio/RadioGroup, Toggle, DatePicker,
  DateRangePicker, Modal + barrel index.js
- Replace all native <select> in Studio with NovaSelect (StudioFilters,
  StudioToolbar, BulkActionsBar) including frosted-glass portal and
  category group headers
- Replace native checkboxes in StudioGridCard, StudioTable, UploadSidebar,
  UploadWizard, Upload/Index with custom Checkbox component
- Add nova-scrollbar CSS utility (thin 4px, semi-transparent)
- Fix portal position drift: use viewport-relative coords (no scrollY offset)
  for NovaSelect, DatePicker and DateRangePicker
- Close portals on external scroll instead of remeasuring
- Improve hover highlight visibility in NovaSelect (bg-white/[0.13])
- Move search icon to right side in NovaSelect dropdown
- Reduce Studio layout top spacing (py-6 -> pt-4 pb-8)
- Add StudioCheckbox and SquareCheckbox backward-compat shims
- Add sync.sh rsync deploy script
This commit is contained in:
2026-03-01 10:41:43 +01:00
parent e3ca845a6d
commit a875203482
26 changed files with 2087 additions and 132 deletions

View File

@@ -0,0 +1,121 @@
import React, { useEffect, useRef } from 'react'
import { createPortal } from 'react-dom'
/**
* Nova Modal accessible dialog rendered in a portal.
*
* @prop {boolean} open - controls visibility
* @prop {function} onClose - called on backdrop click / Escape
* @prop {string} title - dialog header title
* @prop {React.ReactNode} footer - rendered in footer area
* @prop {string} size - 'sm' | 'md' | 'lg' | 'xl' | 'full'
* @prop {boolean} closeOnBackdrop - close when clicking outside (default true)
* @prop {string} variant - 'default' | 'danger'
*/
const sizeClass = {
sm: 'max-w-sm',
md: 'max-w-md',
lg: 'max-w-lg',
xl: 'max-w-xl',
'2xl':'max-w-2xl',
full: 'max-w-full h-full rounded-none',
}
export default function Modal({
open,
onClose,
title,
footer,
size = 'md',
closeOnBackdrop = true,
variant = 'default',
children,
className = '',
}) {
const panelRef = useRef(null)
// Lock scroll when open
useEffect(() => {
if (!open) return
const prev = document.body.style.overflow
document.body.style.overflow = 'hidden'
return () => { document.body.style.overflow = prev }
}, [open])
// Trap focus + handle Escape
useEffect(() => {
if (!open) return
const handleKey = (e) => {
if (e.key === 'Escape') onClose?.()
}
window.addEventListener('keydown', handleKey)
// Focus first focusable element
const firstFocusable = panelRef.current?.querySelector(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
)
firstFocusable?.focus()
return () => window.removeEventListener('keydown', handleKey)
}, [open, onClose])
if (!open) return null
const borderClass = variant === 'danger' ? 'border-red-500/30' : 'border-white/10'
const sClass = sizeClass[size] ?? sizeClass.md
return createPortal(
<div
role="dialog"
aria-modal="true"
aria-label={title}
className="fixed inset-0 z-[200] flex items-center justify-center p-4"
>
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/70 backdrop-blur-sm"
onClick={closeOnBackdrop ? onClose : undefined}
aria-hidden="true"
/>
{/* Panel */}
<div
ref={panelRef}
className={[
'relative w-full bg-nova-900 rounded-2xl border shadow-2xl',
'flex flex-col max-h-[90vh]',
borderClass,
sClass,
className,
].join(' ')}
>
{/* Header */}
{title && (
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10 shrink-0">
<h2 className="text-base font-bold text-white">{title}</h2>
<button
onClick={onClose}
className="w-8 h-8 flex items-center justify-center rounded-lg text-slate-400 hover:text-white hover:bg-white/8 transition-all"
aria-label="Close dialog"
>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
<path d="M1 1l12 12M13 1L1 13" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
</button>
</div>
)}
{/* Body */}
<div className="flex-1 overflow-y-auto nova-scrollbar px-6 py-5">
{children}
</div>
{/* Footer */}
{footer && (
<div className="shrink-0 px-6 py-4 border-t border-white/10 flex items-center justify-end gap-2">
{footer}
</div>
)}
</div>
</div>,
document.body,
)
}