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:
@@ -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}
|
||||
|
||||
5
resources/js/components/Studio/StudioCheckbox.jsx
Normal file
5
resources/js/components/Studio/StudioCheckbox.jsx
Normal 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'
|
||||
@@ -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"> {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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user