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

@@ -1,4 +1,5 @@
import React, { useState } from 'react'
import NovaSelect from '../ui/NovaSelect'
const actions = [
{ value: 'publish', label: 'Publish', icon: 'fa-eye', danger: false },
@@ -37,18 +38,15 @@ export default function BulkActionsBar({ count, onExecute, onClearSelection }) {
</div>
<div className="flex items-center gap-2">
<select
value={action}
onChange={(e) => setAction(e.target.value)}
className="px-3 py-2 rounded-xl bg-white/5 border border-white/10 text-white text-sm focus:outline-none focus:ring-2 focus:ring-accent/50 min-w-[180px]"
>
<option value="" className="bg-nova-900">Choose action</option>
{actions.map((a) => (
<option key={a.value} value={a.value} className="bg-nova-900">
{a.label}
</option>
))}
</select>
<div className="min-w-[180px]">
<NovaSelect
options={actions.map((a) => ({ value: a.value, label: a.label }))}
value={action || null}
onChange={(val) => setAction(val ?? '')}
placeholder="Choose action…"
searchable={false}
/>
</div>
<button
onClick={handleExecute}

View File

@@ -0,0 +1,5 @@
/**
* @deprecated Use the unified Checkbox from Components/ui instead.
* This shim exists only for backward compatibility.
*/
export { default } from '../ui/Checkbox'

View File

@@ -1,17 +1,17 @@
import React from 'react'
import React, { useMemo } from 'react'
import DateRangePicker from '../ui/DateRangePicker'
import NovaSelect from '../ui/NovaSelect'
const statusOptions = [
{ value: '', label: 'All statuses' },
{ value: 'published', label: 'Published' },
{ value: 'draft', label: 'Draft' },
{ value: 'archived', label: 'Archived' },
{ value: 'draft', label: 'Draft' },
{ value: 'archived', label: 'Archived' },
]
const performanceOptions = [
{ value: '', label: 'All performance' },
{ value: 'rising', label: 'Rising (hot)' },
{ value: 'top', label: 'Top performers' },
{ value: 'low', label: 'Low performers' },
{ value: 'top', label: 'Top performers' },
{ value: 'low', label: 'Low performers' },
]
export default function StudioFilters({
@@ -27,13 +27,26 @@ export default function StudioFilters({
onFilterChange({ ...filters, [key]: value })
}
const categoryOptions = useMemo(() => {
const opts = []
categories.forEach((ct) => {
ct.categories?.forEach((cat) => {
opts.push({ value: cat.slug, label: cat.name, group: ct.name })
cat.children?.forEach((ch) => {
opts.push({ value: ch.slug, label: ch.name, group: ct.name })
})
})
})
return opts
}, [categories])
return (
<>
{/* Mobile backdrop */}
<div className="lg:hidden fixed inset-0 z-40 bg-black/50 backdrop-blur-sm" onClick={onClose} />
{/* Filter panel */}
<div className="fixed lg:relative inset-y-0 left-0 z-50 lg:z-auto w-72 lg:w-64 bg-nova-900 lg:bg-nova-900/40 border-r lg:border border-white/10 lg:rounded-2xl p-5 space-y-5 overflow-y-auto lg:static lg:mb-4">
<div className="fixed lg:relative inset-y-0 left-0 z-50 lg:z-auto w-72 lg:w-64 bg-nova-900 lg:bg-nova-900/40 border-r lg:border border-white/10 lg:rounded-2xl p-5 space-y-5 overflow-y-auto nova-scrollbar lg:static lg:mb-4">
<div className="flex items-center justify-between lg:hidden">
<h3 className="text-base font-semibold text-white">Filters</h3>
<button onClick={onClose} className="text-slate-400 hover:text-white" aria-label="Close filters">
@@ -44,75 +57,48 @@ export default function StudioFilters({
<h3 className="hidden lg:block text-sm font-semibold text-slate-400 uppercase tracking-wider">Filters</h3>
{/* Status */}
<div>
<label className="text-xs font-medium text-slate-400 mb-1.5 block">Status</label>
<select
value={filters.status || ''}
onChange={(e) => handleChange('status', e.target.value)}
className="w-full px-3 py-2 rounded-lg bg-white/5 border border-white/10 text-white text-sm focus:outline-none focus:ring-2 focus:ring-accent/50"
>
{statusOptions.map((o) => (
<option key={o.value} value={o.value} className="bg-nova-900">{o.label}</option>
))}
</select>
</div>
<NovaSelect
label="Status"
options={statusOptions}
value={filters.status || null}
onChange={(val) => handleChange('status', val ?? '')}
placeholder="All statuses"
searchable={false}
clearable
/>
{/* Category */}
<div>
<label className="text-xs font-medium text-slate-400 mb-1.5 block">Category</label>
<select
value={filters.category || ''}
onChange={(e) => handleChange('category', e.target.value)}
className="w-full px-3 py-2 rounded-lg bg-white/5 border border-white/10 text-white text-sm focus:outline-none focus:ring-2 focus:ring-accent/50"
>
<option value="" className="bg-nova-900">All categories</option>
{categories.map((ct) => (
<optgroup key={ct.id} label={ct.name}>
{ct.categories?.map((cat) => (
<React.Fragment key={cat.id}>
<option value={cat.slug} className="bg-nova-900">{cat.name}</option>
{cat.children?.map((ch) => (
<option key={ch.id} value={ch.slug} className="bg-nova-900">&nbsp;&nbsp;{ch.name}</option>
))}
</React.Fragment>
))}
</optgroup>
))}
</select>
</div>
<NovaSelect
label="Category"
options={categoryOptions}
value={filters.category || null}
onChange={(val) => handleChange('category', val ?? '')}
placeholder="All categories"
clearable
/>
{/* Performance */}
<div>
<label className="text-xs font-medium text-slate-400 mb-1.5 block">Performance</label>
<select
value={filters.performance || ''}
onChange={(e) => handleChange('performance', e.target.value)}
className="w-full px-3 py-2 rounded-lg bg-white/5 border border-white/10 text-white text-sm focus:outline-none focus:ring-2 focus:ring-accent/50"
>
{performanceOptions.map((o) => (
<option key={o.value} value={o.value} className="bg-nova-900">{o.label}</option>
))}
</select>
</div>
<NovaSelect
label="Performance"
options={performanceOptions}
value={filters.performance || null}
onChange={(val) => handleChange('performance', val ?? '')}
placeholder="All performance"
searchable={false}
clearable
/>
{/* Date range */}
<div>
<label className="text-xs font-medium text-slate-400 mb-1.5 block">Date range</label>
<div className="grid grid-cols-2 gap-2">
<input
type="date"
value={filters.date_from || ''}
onChange={(e) => handleChange('date_from', e.target.value)}
className="px-2 py-2 rounded-lg bg-white/5 border border-white/10 text-white text-xs focus:outline-none focus:ring-2 focus:ring-accent/50"
/>
<input
type="date"
value={filters.date_to || ''}
onChange={(e) => handleChange('date_to', e.target.value)}
className="px-2 py-2 rounded-lg bg-white/5 border border-white/10 text-white text-xs focus:outline-none focus:ring-2 focus:ring-accent/50"
/>
</div>
</div>
<DateRangePicker
label="Date range"
start={filters.date_from || ''}
end={filters.date_to || ''}
onChange={({ start, end }) => {
onFilterChange({ ...filters, date_from: start, date_to: end })
}}
clearable
placeholder="Any date"
/>
{/* Clear */}
<button

View File

@@ -1,6 +1,7 @@
import React from 'react'
import StatusBadge from '../Badges/StatusBadge'
import RisingBadge from '../Badges/RisingBadge'
import Checkbox from '../ui/Checkbox'
function getStatus(art) {
if (art.deleted_at) return 'archived'
@@ -27,14 +28,13 @@ export default function StudioGridCard({ artwork, selected, onSelect, onAction }
}`}
>
{/* Selection checkbox */}
<label className="absolute top-3 left-3 z-10 cursor-pointer">
<input
type="checkbox"
<div className="absolute top-3 left-3 z-10">
<Checkbox
checked={selected}
onChange={() => onSelect(artwork.id)}
className="w-4 h-4 rounded-sm bg-transparent border border-white/20 accent-accent focus:ring-accent/50 cursor-pointer"
aria-label={`Select ${artwork.title}`}
/>
</label>
</div>
{/* Thumbnail */}
<div className="relative aspect-[4/3] bg-nova-800 overflow-hidden">

View File

@@ -1,6 +1,7 @@
import React from 'react'
import StatusBadge from '../Badges/StatusBadge'
import RisingBadge from '../Badges/RisingBadge'
import Checkbox from '../ui/Checkbox'
function getStatus(art) {
if (art.deleted_at) return 'archived'
@@ -46,11 +47,10 @@ export default function StudioTable({ artworks, selectedIds, onSelect, onSelectA
<thead className="sticky top-0 z-10 bg-nova-900/90 backdrop-blur-sm border-b border-white/10">
<tr>
<th className="p-3 w-10">
<input
type="checkbox"
<Checkbox
checked={allSelected}
onChange={onSelectAll}
className="w-4 h-4 rounded-sm bg-transparent border border-white/20 accent-accent focus:ring-accent/50 cursor-pointer"
aria-label="Select all artworks"
/>
</th>
<th className="p-3 w-12"></th>
@@ -74,11 +74,10 @@ export default function StudioTable({ artworks, selectedIds, onSelect, onSelectA
className={`transition-colors ${selectedIds.includes(art.id) ? 'bg-accent/5' : 'hover:bg-white/[0.02]'}`}
>
<td className="p-3">
<input
type="checkbox"
<Checkbox
checked={selectedIds.includes(art.id)}
onChange={() => onSelect(art.id)}
className="w-4 h-4 rounded-sm bg-transparent border border-white/20 accent-accent focus:ring-accent/50 cursor-pointer"
aria-label={`Select ${art.title}`}
/>
</td>
<td className="p-3">

View File

@@ -1,4 +1,5 @@
import React from 'react'
import NovaSelect from '../ui/NovaSelect'
const sortOptions = [
{ value: 'created_at:desc', label: 'Latest' },
@@ -37,17 +38,14 @@ export default function StudioToolbar({
</div>
{/* Sort */}
<select
value={sort}
onChange={(e) => onSortChange(e.target.value)}
className="px-3 py-2.5 rounded-xl bg-nova-900/60 border border-white/10 text-white text-sm focus:outline-none focus:ring-2 focus:ring-accent/50 appearance-none cursor-pointer min-w-[160px]"
>
{sortOptions.map((opt) => (
<option key={opt.value} value={opt.value} className="bg-nova-900 text-white">
{opt.label}
</option>
))}
</select>
<div className="min-w-[160px]">
<NovaSelect
options={sortOptions}
value={sort}
onChange={onSortChange}
searchable={false}
/>
</div>
{/* Filter toggle */}
<button