- 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
342 lines
12 KiB
JavaScript
342 lines
12 KiB
JavaScript
import React, {
|
|
useCallback,
|
|
useEffect,
|
|
useLayoutEffect,
|
|
useRef,
|
|
useState,
|
|
} from 'react'
|
|
import { createPortal } from 'react-dom'
|
|
|
|
/* ─── Date helpers ────────────────────────────────────────────── */
|
|
|
|
const MONTH_NAMES = [
|
|
'January','February','March','April','May','June',
|
|
'July','August','September','October','November','December',
|
|
]
|
|
const DAY_ABBR = ['Mo','Tu','We','Th','Fr','Sa','Su']
|
|
|
|
function daysInMonth(year, month) {
|
|
return new Date(year, month + 1, 0).getDate()
|
|
}
|
|
|
|
/** Returns 0=Mon … 6=Sun for first day of month */
|
|
function firstWeekday(year, month) {
|
|
const d = new Date(year, month, 1).getDay() // 0=Sun
|
|
return (d + 6) % 7 // shift so 0=Mon
|
|
}
|
|
|
|
function toISO(date) {
|
|
const y = date.getFullYear()
|
|
const m = String(date.getMonth() + 1).padStart(2, '0')
|
|
const d = String(date.getDate()).padStart(2, '0')
|
|
return `${y}-${m}-${d}`
|
|
}
|
|
|
|
function fromISO(str) {
|
|
if (!str) return null
|
|
const [y, m, d] = str.split('-').map(Number)
|
|
return new Date(y, m - 1, d)
|
|
}
|
|
|
|
function formatDisplay(isoStr) {
|
|
if (!isoStr) return ''
|
|
const d = fromISO(isoStr)
|
|
if (!d) return ''
|
|
return `${MONTH_NAMES[d.getMonth()].slice(0, 3)} ${d.getDate()}, ${d.getFullYear()}`
|
|
}
|
|
|
|
function isSameDay(a, b) {
|
|
return a?.getFullYear() === b?.getFullYear() &&
|
|
a?.getMonth() === b?.getMonth() &&
|
|
a?.getDate() === b?.getDate()
|
|
}
|
|
|
|
/* ─── Calendar Grid ───────────────────────────────────────────── */
|
|
|
|
function CalendarGrid({ year, month, selected, onSelect, minDate, maxDate }) {
|
|
const numDays = daysInMonth(year, month)
|
|
const startWd = firstWeekday(year, month)
|
|
const prevDays = daysInMonth(year, month - 1 < 0 ? 11 : month - 1)
|
|
const cells = []
|
|
|
|
// Filler from previous month
|
|
for (let i = startWd - 1; i >= 0; i--) {
|
|
cells.push({ day: prevDays - i, current: false, date: new Date(year, month - 1, prevDays - i) })
|
|
}
|
|
// Current month
|
|
for (let d = 1; d <= numDays; d++) {
|
|
cells.push({ day: d, current: true, date: new Date(year, month, d) })
|
|
}
|
|
// Next month filler to fill 6 rows
|
|
let next = 1
|
|
while (cells.length % 7 !== 0) {
|
|
cells.push({ day: next++, current: false, date: new Date(year, month + 1, next - 1) })
|
|
}
|
|
|
|
const selectedDate = fromISO(selected)
|
|
const today = new Date()
|
|
today.setHours(0, 0, 0, 0)
|
|
|
|
return (
|
|
<div className="p-3">
|
|
{/* Day-of-week headers */}
|
|
<div className="grid grid-cols-7 mb-1">
|
|
{DAY_ABBR.map((d) => (
|
|
<div key={d} className="text-center text-[10px] font-semibold text-slate-500 py-1">{d}</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Day cells */}
|
|
<div className="grid grid-cols-7 gap-y-0.5">
|
|
{cells.map((cell, i) => {
|
|
const iso = toISO(cell.date)
|
|
const isSelected = isSameDay(cell.date, selectedDate)
|
|
const isToday = isSameDay(cell.date, today)
|
|
const disabled =
|
|
(minDate && iso < minDate) ||
|
|
(maxDate && iso > maxDate)
|
|
|
|
return (
|
|
<button
|
|
key={i}
|
|
type="button"
|
|
disabled={disabled}
|
|
onClick={() => onSelect(iso)}
|
|
className={[
|
|
'relative flex items-center justify-center w-8 h-8 mx-auto rounded-lg text-sm transition-all',
|
|
!cell.current ? 'text-slate-600' : '',
|
|
cell.current && !isSelected && !disabled ? 'hover:bg-white/10 text-white' : '',
|
|
isSelected ? 'bg-accent text-white font-semibold shadow shadow-accent/30' : '',
|
|
isToday && !isSelected ? 'ring-1 ring-accent/50 text-accent' : '',
|
|
disabled ? 'opacity-30 cursor-not-allowed' : 'cursor-pointer',
|
|
].join(' ')}
|
|
>
|
|
{cell.day}
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/* ─── DatePicker ──────────────────────────────────────────────── */
|
|
|
|
/**
|
|
* Nova DatePicker
|
|
*
|
|
* @prop {string} value - ISO date string 'YYYY-MM-DD' or ''
|
|
* @prop {function} onChange - called with ISO string
|
|
* @prop {string} label
|
|
* @prop {string} placeholder
|
|
* @prop {string} error
|
|
* @prop {string} hint
|
|
* @prop {boolean} required
|
|
* @prop {boolean} clearable
|
|
* @prop {string} minDate - ISO string lower bound
|
|
* @prop {string} maxDate - ISO string upper bound
|
|
*/
|
|
export default function DatePicker({
|
|
value = '',
|
|
onChange,
|
|
label,
|
|
placeholder = 'Pick a date',
|
|
error,
|
|
hint,
|
|
required = false,
|
|
clearable = false,
|
|
minDate,
|
|
maxDate,
|
|
id,
|
|
disabled = false,
|
|
className = '',
|
|
}) {
|
|
const [open, setOpen] = useState(false)
|
|
const [dropPos, setPos] = useState({ top: 0, left: 0, width: 240, openUp: false })
|
|
|
|
const today = new Date()
|
|
const initVal = value ? fromISO(value) : today
|
|
const [viewYear, setYear] = useState(initVal?.getFullYear() ?? today.getFullYear())
|
|
const [viewMonth, setMonth] = useState(initVal?.getMonth() ?? today.getMonth())
|
|
|
|
const triggerRef = useRef(null)
|
|
const inputId = id ?? (label ? `dp-${label.toLowerCase().replace(/\s+/g, '-')}` : 'date-picker')
|
|
|
|
const measure = useCallback(() => {
|
|
if (!triggerRef.current) return
|
|
const rect = triggerRef.current.getBoundingClientRect()
|
|
const height = 320
|
|
const openUp = window.innerHeight - rect.bottom < height + 8 && rect.top > height + 8
|
|
setPos({
|
|
top: openUp ? rect.top - height - 4 : rect.bottom + 4,
|
|
left: rect.left,
|
|
width: Math.max(rect.width, 280),
|
|
openUp,
|
|
})
|
|
}, [])
|
|
|
|
const openPicker = () => {
|
|
if (disabled) return
|
|
if (value) {
|
|
const d = fromISO(value)
|
|
if (d) { setYear(d.getFullYear()); setMonth(d.getMonth()) }
|
|
}
|
|
measure()
|
|
setOpen(true)
|
|
}
|
|
|
|
// close on outside click
|
|
useEffect(() => {
|
|
if (!open) return
|
|
const handler = (e) => {
|
|
if (
|
|
!triggerRef.current?.contains(e.target) &&
|
|
!document.getElementById(`dp-panel-${inputId}`)?.contains(e.target)
|
|
) setOpen(false)
|
|
}
|
|
document.addEventListener('mousedown', handler)
|
|
return () => document.removeEventListener('mousedown', handler)
|
|
}, [open, inputId])
|
|
|
|
useEffect(() => {
|
|
if (!open) return
|
|
const onScroll = (e) => {
|
|
if (document.getElementById(`dp-panel-${inputId}`)?.contains(e.target)) return
|
|
setOpen(false)
|
|
}
|
|
const onResize = () => setOpen(false)
|
|
window.addEventListener('scroll', onScroll, true)
|
|
window.addEventListener('resize', onResize)
|
|
return () => {
|
|
window.removeEventListener('scroll', onScroll, true)
|
|
window.removeEventListener('resize', onResize)
|
|
}
|
|
}, [open, inputId])
|
|
|
|
const prevMonth = () => {
|
|
if (viewMonth === 0) { setMonth(11); setYear((y) => y - 1) }
|
|
else setMonth((m) => m - 1)
|
|
}
|
|
const nextMonth = () => {
|
|
if (viewMonth === 11) { setMonth(0); setYear((y) => y + 1) }
|
|
else setMonth((m) => m + 1)
|
|
}
|
|
|
|
const handleSelect = (iso) => {
|
|
onChange?.(iso)
|
|
setOpen(false)
|
|
}
|
|
|
|
const clearValue = (e) => { e.stopPropagation(); onChange?.('') }
|
|
|
|
const triggerClass = [
|
|
'relative flex items-center h-[42px] rounded-xl border px-3.5 gap-2 cursor-pointer w-full',
|
|
'bg-white/[0.06] text-sm transition-all duration-150',
|
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-0',
|
|
error
|
|
? 'border-red-500/60 focus-visible:ring-red-500/40'
|
|
: open
|
|
? 'border-accent/50 ring-2 ring-accent/40'
|
|
: 'border-white/12 hover:border-white/22',
|
|
disabled ? 'opacity-50 cursor-not-allowed pointer-events-none' : '',
|
|
className,
|
|
].join(' ')
|
|
|
|
return (
|
|
<div className="flex flex-col gap-1.5">
|
|
{label && (
|
|
<label htmlFor={inputId} className="text-sm font-medium text-white/85 select-none">
|
|
{label}{required && <span className="text-red-400 ml-1">*</span>}
|
|
</label>
|
|
)}
|
|
|
|
<div ref={triggerRef} className={triggerClass} tabIndex={disabled ? -1 : 0} onClick={openPicker}
|
|
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openPicker() } }}
|
|
role="button" aria-label={label ?? placeholder} id={inputId}
|
|
>
|
|
{/* Calendar icon */}
|
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" className="text-slate-500 shrink-0" aria-hidden="true">
|
|
<rect x="1" y="2.5" width="12" height="10.5" rx="1.5" stroke="currentColor" strokeWidth="1.3" />
|
|
<path d="M1 6h12" stroke="currentColor" strokeWidth="1.3" />
|
|
<path d="M4 1v3M10 1v3" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" />
|
|
<circle cx="4.5" cy="9" r="0.75" fill="currentColor" />
|
|
<circle cx="7" cy="9" r="0.75" fill="currentColor" />
|
|
<circle cx="9.5" cy="9" r="0.75" fill="currentColor" />
|
|
</svg>
|
|
|
|
<span className={`flex-1 truncate ${value ? 'text-white' : 'text-slate-500'}`}>
|
|
{value ? formatDisplay(value) : placeholder}
|
|
</span>
|
|
|
|
{clearable && value && (
|
|
<button type="button" tabIndex={-1} onClick={clearValue}
|
|
className="w-5 h-5 flex items-center justify-center rounded text-slate-500 hover:text-white transition-colors"
|
|
aria-label="Clear date"
|
|
>
|
|
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" aria-hidden="true">
|
|
<path d="M1 1l8 8M9 1L1 9" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
|
</svg>
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{error && <p role="alert" className="text-xs text-red-400">{error}</p>}
|
|
{!error && hint && <p className="text-xs text-slate-500">{hint}</p>}
|
|
|
|
{open && createPortal(
|
|
<div
|
|
id={`dp-panel-${inputId}`}
|
|
className="fixed z-[500] rounded-2xl border border-white/12 bg-nova-900 shadow-2xl shadow-black/50 overflow-hidden"
|
|
style={{ top: dropPos.top, left: dropPos.left, width: dropPos.width }}
|
|
>
|
|
{/* Month nav header */}
|
|
<div className="flex items-center justify-between px-3 pt-3">
|
|
<button type="button" onClick={prevMonth}
|
|
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="Previous month"
|
|
>
|
|
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" aria-hidden="true">
|
|
<path d="M7 1L3 5l4 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
|
</svg>
|
|
</button>
|
|
|
|
<span className="text-sm font-semibold text-white">
|
|
{MONTH_NAMES[viewMonth]} {viewYear}
|
|
</span>
|
|
|
|
<button type="button" onClick={nextMonth}
|
|
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="Next month"
|
|
>
|
|
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" aria-hidden="true">
|
|
<path d="M3 1l4 4-4 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
<CalendarGrid
|
|
year={viewYear}
|
|
month={viewMonth}
|
|
selected={value}
|
|
onSelect={handleSelect}
|
|
minDate={minDate}
|
|
maxDate={maxDate}
|
|
/>
|
|
|
|
{/* Today shortcut */}
|
|
<div className="border-t border-white/8 px-3 py-2 flex justify-end">
|
|
<button
|
|
type="button"
|
|
onClick={() => handleSelect(toISO(new Date()))}
|
|
className="text-xs text-accent hover:text-accent/80 font-medium transition-colors"
|
|
>
|
|
Today
|
|
</button>
|
|
</div>
|
|
</div>,
|
|
document.body,
|
|
)}
|
|
</div>
|
|
)
|
|
}
|