- 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
352 lines
14 KiB
JavaScript
352 lines
14 KiB
JavaScript
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
||
import { createPortal } from 'react-dom'
|
||
|
||
/* ─── Date helpers (duplicated locally so component is self-contained) ─ */
|
||
|
||
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()
|
||
}
|
||
function firstWeekday(year, month) {
|
||
return (new Date(year, month, 1).getDay() + 6) % 7
|
||
}
|
||
function toISO(date) {
|
||
return `${date.getFullYear()}-${String(date.getMonth()+1).padStart(2,'0')}-${String(date.getDate()).padStart(2,'0')}`
|
||
}
|
||
function fromISO(str) {
|
||
if (!str) return null
|
||
const [y,m,d] = str.split('-').map(Number)
|
||
return new Date(y, m-1, d)
|
||
}
|
||
function fmt(isoStr) {
|
||
if (!isoStr) return ''
|
||
const d = fromISO(isoStr)
|
||
return d ? `${MONTH_NAMES[d.getMonth()].slice(0,3)} ${d.getDate()}, ${d.getFullYear()}` : ''
|
||
}
|
||
function isSameDay(a, b) {
|
||
return !!a && !!b &&
|
||
a.getFullYear()===b.getFullYear() && a.getMonth()===b.getMonth() && a.getDate()===b.getDate()
|
||
}
|
||
|
||
/* ─── Single month grid ─────────────────────────────────────────── */
|
||
|
||
function MonthGrid({ year, month, start, end, hover, onHover, 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 = []
|
||
|
||
for (let i = startWd - 1; i >= 0; i--)
|
||
cells.push({ day: prevDays - i, current: false, date: new Date(year, month - 1, prevDays - i) })
|
||
for (let d = 1; d <= numDays; d++)
|
||
cells.push({ day: d, current: true, date: new Date(year, month, d) })
|
||
let next = 1
|
||
while (cells.length % 7 !== 0)
|
||
cells.push({ day: next++, current: false, date: new Date(year, month + 1, next - 1) })
|
||
|
||
const startDate = fromISO(start)
|
||
const endDate = fromISO(end)
|
||
const hoverDate = hover ? fromISO(hover) : null
|
||
const today = new Date(); today.setHours(0,0,0,0)
|
||
|
||
// Range boundary to highlight (end could be hover while selecting)
|
||
const rangeEnd = endDate ?? hoverDate
|
||
|
||
return (
|
||
<div className="p-3 min-w-[224px]">
|
||
<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>
|
||
<div className="grid grid-cols-7 gap-y-0.5">
|
||
{cells.map((cell, i) => {
|
||
const iso = toISO(cell.date)
|
||
const isStart = isSameDay(cell.date, startDate)
|
||
const isEnd = isSameDay(cell.date, endDate)
|
||
const isToday = isSameDay(cell.date, today)
|
||
const disabled = (minDate && iso < minDate) || (maxDate && iso > maxDate)
|
||
|
||
// In range?
|
||
let inRange = false
|
||
if (startDate && rangeEnd) {
|
||
const lo = startDate <= rangeEnd ? startDate : rangeEnd
|
||
const hi = startDate <= rangeEnd ? rangeEnd : startDate
|
||
inRange = cell.date > lo && cell.date < hi
|
||
}
|
||
|
||
const isEdge = isStart || isEnd
|
||
|
||
return (
|
||
<button
|
||
key={i}
|
||
type="button"
|
||
disabled={disabled || !cell.current}
|
||
onClick={() => cell.current && !disabled && onSelect(iso)}
|
||
onMouseEnter={() => cell.current && !disabled && onHover(iso)}
|
||
className={[
|
||
'relative flex items-center justify-center w-8 h-8 mx-auto rounded-lg text-sm transition-all',
|
||
!cell.current ? 'opacity-0 pointer-events-none' : '',
|
||
isEdge ? 'bg-accent text-white font-semibold shadow shadow-accent/30 z-10' : '',
|
||
!isEdge && inRange ? 'bg-accent/20 text-white rounded-none' : '',
|
||
!isEdge && !inRange && !disabled ? 'hover:bg-white/10 text-white' : '',
|
||
isToday && !isEdge ? 'ring-1 ring-accent/50 text-accent' : '',
|
||
disabled ? 'opacity-30 cursor-not-allowed' : 'cursor-pointer',
|
||
].join(' ')}
|
||
>
|
||
{cell.day}
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/* ─── DateRangePicker ────────────────────────────────────────────── */
|
||
|
||
/**
|
||
* Nova DateRangePicker
|
||
*
|
||
* @prop {string} start - ISO 'YYYY-MM-DD'
|
||
* @prop {string} end - ISO 'YYYY-MM-DD'
|
||
* @prop {function} onChange - called with { start, end }
|
||
* @prop {string} label
|
||
* @prop {string} placeholder
|
||
* @prop {string} error
|
||
* @prop {string} hint
|
||
* @prop {boolean} required
|
||
* @prop {boolean} clearable
|
||
* @prop {string} minDate
|
||
* @prop {string} maxDate
|
||
*/
|
||
export default function DateRangePicker({
|
||
start = '',
|
||
end = '',
|
||
onChange,
|
||
label,
|
||
placeholder = 'Select date range',
|
||
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: 480 })
|
||
const [hover, setHover] = useState('')
|
||
// Selecting state: if we have a start but no end, next click sets end
|
||
const [picking, setPicking] = useState(null) // null | 'start' | 'end'
|
||
|
||
// View months: left and right panels
|
||
const today = new Date()
|
||
const [lYear, setLYear] = useState(today.getFullYear())
|
||
const [lMonth, setLMonth] = useState(today.getMonth() === 0 ? 11 : today.getMonth() - 1)
|
||
// right panel = left + 1
|
||
const rYear = lMonth === 11 ? lYear + 1 : lYear
|
||
const rMonth = lMonth === 11 ? 0 : lMonth + 1
|
||
|
||
const triggerRef = useRef(null)
|
||
const inputId = id ?? (label ? `drp-${label.toLowerCase().replace(/\s+/g, '-')}` : 'date-range')
|
||
|
||
const measure = useCallback(() => {
|
||
if (!triggerRef.current) return
|
||
const rect = triggerRef.current.getBoundingClientRect()
|
||
const panelW = Math.max(rect.width, 480)
|
||
const height = 340
|
||
const openUp = window.innerHeight - rect.bottom < height + 8 && rect.top > height + 8
|
||
setPos({
|
||
top: openUp ? rect.top - height - 4 : rect.bottom + 4,
|
||
left: Math.min(rect.left, window.innerWidth - panelW - 8),
|
||
width: panelW,
|
||
})
|
||
}, [])
|
||
|
||
const openPicker = () => { if (disabled) return; measure(); setOpen(true) }
|
||
|
||
useEffect(() => {
|
||
if (!open) return
|
||
const handler = (e) => {
|
||
if (
|
||
!triggerRef.current?.contains(e.target) &&
|
||
!document.getElementById(`drp-panel-${inputId}`)?.contains(e.target)
|
||
) { setOpen(false); setPicking(null) }
|
||
}
|
||
document.addEventListener('mousedown', handler)
|
||
return () => document.removeEventListener('mousedown', handler)
|
||
}, [open, inputId])
|
||
|
||
useEffect(() => {
|
||
if (!open) return
|
||
const onScroll = (e) => {
|
||
if (document.getElementById(`drp-panel-${inputId}`)?.contains(e.target)) return
|
||
setOpen(false); setPicking(null)
|
||
}
|
||
const onResize = () => { setOpen(false); setPicking(null) }
|
||
window.addEventListener('scroll', onScroll, true)
|
||
window.addEventListener('resize', onResize)
|
||
return () => { window.removeEventListener('scroll', onScroll, true); window.removeEventListener('resize', onResize) }
|
||
}, [open, inputId])
|
||
|
||
const handleSelect = (iso) => {
|
||
if (!start || picking === 'start' || (start && end)) {
|
||
// Start fresh
|
||
onChange?.({ start: iso, end: '' })
|
||
setPicking('end')
|
||
setHover('')
|
||
} else {
|
||
// We have start, picking end
|
||
const s = start < iso ? start : iso
|
||
const e = start < iso ? iso : start
|
||
onChange?.({ start: s, end: e })
|
||
setPicking(null)
|
||
setOpen(false)
|
||
}
|
||
}
|
||
|
||
const clearValue = (ev) => { ev.stopPropagation(); onChange?.({ start:'', end:'' }); setPicking(null) }
|
||
|
||
const prevLeft = () => {
|
||
if (lMonth === 0) { setLMonth(11); setLYear(y => y - 1) } else setLMonth(m => m - 1)
|
||
}
|
||
const nextLeft = () => {
|
||
if (lMonth === 11) { setLMonth(0); setLYear(y => y + 1) } else setLMonth(m => m + 1)
|
||
}
|
||
|
||
const displayText = start ? `${fmt(start)} – ${end ? fmt(end) : '…'}` : ''
|
||
|
||
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"/>
|
||
</svg>
|
||
|
||
<span className={`flex-1 truncate ${displayText ? 'text-white' : 'text-slate-500'}`}>
|
||
{displayText || placeholder}
|
||
</span>
|
||
|
||
{clearable && (start || end) && (
|
||
<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 range"
|
||
>
|
||
<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={`drp-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 }}
|
||
onMouseLeave={() => setHover('')}
|
||
>
|
||
{/* Header info */}
|
||
{picking === 'end' && (
|
||
<div className="px-4 pt-3 pb-1 text-xs text-accent/80 font-medium">
|
||
Now click an end date
|
||
</div>
|
||
)}
|
||
|
||
{/* Two calendars */}
|
||
<div className="flex items-start">
|
||
{/* Left month */}
|
||
<div className="flex-1 border-r border-white/8">
|
||
<div className="flex items-center justify-between px-3 pt-3">
|
||
<button type="button" onClick={prevLeft}
|
||
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[lMonth]} {lYear}</span>
|
||
<div className="w-8" />
|
||
</div>
|
||
<MonthGrid year={lYear} month={lMonth} start={start} end={end} hover={hover}
|
||
onHover={setHover} onSelect={handleSelect} minDate={minDate} maxDate={maxDate} />
|
||
</div>
|
||
|
||
{/* Right month */}
|
||
<div className="flex-1">
|
||
<div className="flex items-center justify-between px-3 pt-3">
|
||
<div className="w-8" />
|
||
<span className="text-sm font-semibold text-white">{MONTH_NAMES[rMonth]} {rYear}</span>
|
||
<button type="button" onClick={nextLeft}
|
||
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>
|
||
<MonthGrid year={rYear} month={rMonth} start={start} end={end} hover={hover}
|
||
onHover={setHover} onSelect={handleSelect} minDate={minDate} maxDate={maxDate} />
|
||
</div>
|
||
</div>
|
||
|
||
{/* Footer with preset shortcuts */}
|
||
<div className="border-t border-white/8 px-4 py-2.5 flex items-center gap-3 flex-wrap">
|
||
{[
|
||
{ label: 'Last 7 days', fn: () => { const e=toISO(new Date()); const s=toISO(new Date(Date.now()-6*864e5)); onChange?.({start:s,end:e}); setOpen(false) } },
|
||
{ label: 'Last 30 days', fn: () => { const e=toISO(new Date()); const s=toISO(new Date(Date.now()-29*864e5)); onChange?.({start:s,end:e}); setOpen(false) } },
|
||
{ label: 'This month', fn: () => { const n=new Date(); const s=toISO(new Date(n.getFullYear(),n.getMonth(),1)); const e=toISO(new Date(n.getFullYear(),n.getMonth()+1,0)); onChange?.({start:s,end:e}); setOpen(false) } },
|
||
].map((p) => (
|
||
<button key={p.label} type="button" onClick={p.fn}
|
||
className="text-xs text-slate-400 hover:text-accent transition-colors font-medium"
|
||
>
|
||
{p.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>,
|
||
document.body,
|
||
)}
|
||
</div>
|
||
)
|
||
}
|