Wire admin studio SSR and search infrastructure

This commit is contained in:
2026-05-01 11:46:06 +02:00
parent 257b0dbef6
commit 18cea8b0f0
329 changed files with 197465 additions and 2741 deletions

View File

@@ -58,6 +58,34 @@ function mergeDateTime(date, time) {
return `${date}T${time || '00:00'}`
}
function maxDateValue(a, b) {
if (!a) return b || ''
if (!b) return a || ''
return a > b ? a : b
}
function minDateValue(a, b) {
if (!a) return b || ''
if (!b) return a || ''
return a < b ? a : b
}
function clampTimeToBounds(date, time, minDateTime, maxDateTime) {
const nextTime = time || '00:00'
const minParts = splitDateTime(minDateTime)
const maxParts = splitDateTime(maxDateTime)
if (date && minParts.date === date && minParts.time && nextTime < minParts.time) {
return minParts.time
}
if (date && maxParts.date === date && maxParts.time && nextTime > maxParts.time) {
return maxParts.time
}
return nextTime
}
function formatDisplay(value) {
if (!value) return ''
@@ -147,15 +175,18 @@ export default function DateTimePicker({
value = '',
onChange,
label,
placeholder = 'Pick a date and time',
placeholder,
error,
hint,
required = false,
clearable = false,
id,
disabled = false,
mode = 'datetime',
minDate,
maxDate,
minDateTime,
maxDateTime,
className = '',
}) {
const today = new Date()
@@ -168,6 +199,7 @@ export default function DateTimePicker({
const [viewMonth, setViewMonth] = useState(initialDate.getMonth())
const [draftDate, setDraftDate] = useState(initial.date)
const [draftTime, setDraftTime] = useState(initial.time || '12:00')
const effectivePlaceholder = placeholder || (mode === 'date' ? 'Pick a date' : 'Pick a date and time')
const triggerRef = useRef(null)
const inputId = id ?? (label ? `dtp-${label.toLowerCase().replace(/\s+/g, '-')}` : 'date-time-picker')
@@ -239,16 +271,23 @@ export default function DateTimePicker({
}, [open, panelId])
const applyValue = useCallback((date, time) => {
onChange?.(date ? mergeDateTime(date, time) : '')
}, [onChange])
if (!date) {
onChange?.('')
return
}
onChange?.(mode === 'date' ? date : mergeDateTime(date, time))
}, [mode, onChange])
const handleDateSelect = (nextDate) => {
const nextTime = clampTimeToBounds(nextDate, draftTime, minDateTime, maxDateTime)
setDraftDate(nextDate)
applyValue(nextDate, draftTime)
setDraftTime(nextTime)
applyValue(nextDate, nextTime)
}
const handleTimeChange = (event) => {
const nextTime = event.target.value
const nextTime = clampTimeToBounds(draftDate, event.target.value, minDateTime, maxDateTime)
setDraftTime(nextTime)
applyValue(draftDate, nextTime)
}
@@ -293,6 +332,12 @@ export default function DateTimePicker({
].join(' ')
const selectedDate = parseDatePart(draftDate)
const minDateTimeParts = splitDateTime(minDateTime)
const maxDateTimeParts = splitDateTime(maxDateTime)
const effectiveMinDate = maxDateValue(minDate, minDateTimeParts.date)
const effectiveMaxDate = minDateValue(maxDate, maxDateTimeParts.date)
const minTime = draftDate && draftDate === minDateTimeParts.date ? minDateTimeParts.time || undefined : undefined
const maxTime = draftDate && draftDate === maxDateTimeParts.date ? maxDateTimeParts.time || undefined : undefined
return (
<div className="flex flex-col gap-1.5">
@@ -308,7 +353,7 @@ export default function DateTimePicker({
id={inputId}
role="button"
tabIndex={disabled ? -1 : 0}
aria-label={label ?? placeholder}
aria-label={label ?? effectivePlaceholder}
className={triggerClass}
onClick={openPicker}
onKeyDown={(event) => {
@@ -328,7 +373,7 @@ export default function DateTimePicker({
</svg>
<span className={`flex-1 truncate ${value ? 'text-white' : 'text-slate-500'}`}>
{value ? formatDisplay(value) : placeholder}
{value ? formatDisplay(value) : effectivePlaceholder}
</span>
{clearable && value && (
@@ -386,28 +431,32 @@ export default function DateTimePicker({
month={viewMonth}
selectedDate={selectedDate}
onSelect={handleDateSelect}
minDate={minDate}
maxDate={maxDate}
minDate={effectiveMinDate}
maxDate={effectiveMaxDate}
/>
<div className="border-t border-white/8 px-4 py-3">
<div className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_7rem] sm:items-end">
<div className={`grid gap-3 ${mode === 'date' ? '' : 'sm:grid-cols-[minmax(0,1fr)_7rem] sm:items-end'}`}>
<div>
<div className="mb-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Selected date</div>
<div className="rounded-xl border border-white/10 bg-white/[0.04] px-3 py-2 text-sm text-white">
{draftDate ? formatDisplay(mergeDateTime(draftDate, draftTime)).replace(` at ${draftTime}`, '') : 'Pick a day'}
{draftDate ? formatDisplay(draftDate) : 'Pick a day'}
</div>
</div>
<label className="grid gap-1.5 text-sm text-slate-300">
<span className="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Time</span>
<input
type="time"
value={draftTime}
onChange={handleTimeChange}
className="rounded-xl border border-white/10 bg-white/[0.04] px-3 py-2 text-white outline-none transition focus:border-accent/50 focus:ring-2 focus:ring-accent/40"
/>
</label>
{mode !== 'date' ? (
<label className="grid gap-1.5 text-sm text-slate-300">
<span className="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Time</span>
<input
type="time"
value={draftTime}
onChange={handleTimeChange}
min={minTime}
max={maxTime}
className="rounded-xl border border-white/10 bg-white/[0.04] px-3 py-2 text-white outline-none transition focus:border-accent/50 focus:ring-2 focus:ring-accent/40"
/>
</label>
) : null}
</div>
<div className="mt-3 flex items-center justify-between">

View File

@@ -26,6 +26,8 @@ import { createPortal } from 'react-dom'
* @prop {boolean} required - asterisk on label
* @prop {boolean} disabled
* @prop {function} renderOption - custom render fn: (option) => ReactNode
* @prop {function} renderValue - custom render fn for single-value trigger: (option) => ReactNode
* @prop {string} searchPlaceholder - placeholder shown in the dropdown search input
*/
export default function NovaSelect({
options = [],
@@ -41,8 +43,10 @@ export default function NovaSelect({
required = false,
disabled = false,
renderOption,
renderValue,
id,
className = '',
searchPlaceholder = 'Search…',
}) {
const [open, setOpen] = useState(false)
const [search, setSearch] = useState('')
@@ -211,9 +215,10 @@ export default function NovaSelect({
}, [open, filtered, highlighted, search, multi, selected, selectOption, closeDropdown, openDropdown, onChange])
// Build display label(s)
const optionMap = useMemo(() => Object.fromEntries(options.map((o) => [String(o.value), o])), [options])
const labelMap = useMemo(() => Object.fromEntries(options.map((o) => [String(o.value), o.label])), [options])
const hasValue = selected.length > 0
const selectedOption = !multi && hasValue ? optionMap[String(selected[0])] ?? null : null
// Trigger appearance
const triggerClass = [
@@ -273,7 +278,9 @@ export default function NovaSelect({
))}
{!multi && hasValue && (
<span className="truncate text-white">{labelMap[String(selected[0])] ?? selected[0]}</span>
renderValue && selectedOption
? renderValue(selectedOption)
: <span className="truncate text-white">{labelMap[String(selected[0])] ?? selected[0]}</span>
)}
{!hasValue && (
@@ -339,7 +346,7 @@ export default function NovaSelect({
value={search}
onChange={(e) => { setSearch(e.target.value); setHigh(0) }}
onKeyDown={handleKeyDown}
placeholder="Search…"
placeholder={searchPlaceholder}
className="w-full pl-3 pr-7 py-1.5 rounded-lg bg-white/5 border border-white/8 text-white text-xs placeholder:text-slate-500 focus:outline-none focus:ring-1 focus:ring-accent/50"
autoComplete="off"
/>