feat: replace native select elements with custom Select component for improved styling and functionality in replays and settings pages
This commit is contained in:
@@ -7,6 +7,7 @@ import { listReplays, formatDuration, type ReplayListItem, type ReplayFilters }
|
|||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { LockClosedIcon } from '@radix-ui/react-icons'
|
import { LockClosedIcon } from '@radix-ui/react-icons'
|
||||||
import LoadingOverlay from '@/components/LoadingOverlay'
|
import LoadingOverlay from '@/components/LoadingOverlay'
|
||||||
|
import Select from '@/components/ui/Select'
|
||||||
|
|
||||||
function formatDate(dateString: string) {
|
function formatDate(dateString: string) {
|
||||||
const date = new Date(dateString)
|
const date = new Date(dateString)
|
||||||
@@ -128,28 +129,28 @@ export default function ReplaysPage() {
|
|||||||
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<div className="mb-6 flex gap-4 flex-wrap">
|
<div className="mb-6 flex gap-4 flex-wrap">
|
||||||
<select
|
<Select
|
||||||
className="px-3 py-2 border border-neutral-200 dark:border-neutral-700 rounded-xl bg-white dark:bg-neutral-800 text-sm"
|
value={filters.device_type ?? ''}
|
||||||
value={filters.device_type || ''}
|
onChange={(v) => setFilters((prev) => ({ ...prev, device_type: v || undefined, offset: 0 }))}
|
||||||
onChange={(e) => setFilters(prev => ({ ...prev, device_type: e.target.value || undefined, offset: 0 }))}
|
options={[
|
||||||
>
|
{ value: '', label: 'All Devices' },
|
||||||
<option value="">All Devices</option>
|
{ value: 'desktop', label: 'Desktop' },
|
||||||
<option value="desktop">Desktop</option>
|
{ value: 'mobile', label: 'Mobile' },
|
||||||
<option value="mobile">Mobile</option>
|
{ value: 'tablet', label: 'Tablet' },
|
||||||
<option value="tablet">Tablet</option>
|
]}
|
||||||
</select>
|
/>
|
||||||
|
|
||||||
<select
|
<Select
|
||||||
className="px-3 py-2 border border-neutral-200 dark:border-neutral-700 rounded-xl bg-white dark:bg-neutral-800 text-sm"
|
value={filters.min_duration ? String(filters.min_duration) : ''}
|
||||||
value={filters.min_duration || ''}
|
onChange={(v) => setFilters((prev) => ({ ...prev, min_duration: v ? parseInt(v, 10) : undefined, offset: 0 }))}
|
||||||
onChange={(e) => setFilters(prev => ({ ...prev, min_duration: e.target.value ? parseInt(e.target.value) : undefined, offset: 0 }))}
|
options={[
|
||||||
>
|
{ value: '', label: 'Any Duration' },
|
||||||
<option value="">Any Duration</option>
|
{ value: '5000', label: '5s+' },
|
||||||
<option value="5000">5s+</option>
|
{ value: '30000', label: '30s+' },
|
||||||
<option value="30000">30s+</option>
|
{ value: '60000', label: '1m+' },
|
||||||
<option value="60000">1m+</option>
|
{ value: '300000', label: '5m+' },
|
||||||
<option value="300000">5m+</option>
|
]}
|
||||||
</select>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Replays List */}
|
{/* Replays List */}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { toast } from 'sonner'
|
|||||||
import LoadingOverlay from '@/components/LoadingOverlay'
|
import LoadingOverlay from '@/components/LoadingOverlay'
|
||||||
import VerificationModal from '@/components/sites/VerificationModal'
|
import VerificationModal from '@/components/sites/VerificationModal'
|
||||||
import PasswordInput from '@/components/PasswordInput'
|
import PasswordInput from '@/components/PasswordInput'
|
||||||
|
import Select from '@/components/ui/Select'
|
||||||
import { APP_URL, API_URL } from '@/lib/api/client'
|
import { APP_URL, API_URL } from '@/lib/api/client'
|
||||||
import { motion, AnimatePresence } from 'framer-motion'
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
import {
|
import {
|
||||||
@@ -305,26 +306,15 @@ export default function SiteSettingsPage() {
|
|||||||
<label htmlFor="timezone" className="block text-sm font-medium text-neutral-700 dark:text-neutral-300">
|
<label htmlFor="timezone" className="block text-sm font-medium text-neutral-700 dark:text-neutral-300">
|
||||||
Timezone
|
Timezone
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<Select
|
||||||
<select
|
id="timezone"
|
||||||
id="timezone"
|
value={formData.timezone}
|
||||||
value={formData.timezone}
|
onChange={(v) => setFormData({ ...formData, timezone: v })}
|
||||||
onChange={(e) => setFormData({ ...formData, timezone: e.target.value })}
|
options={TIMEZONES.map((tz) => ({ value: tz, label: tz }))}
|
||||||
className="w-full px-4 py-2 border border-neutral-200 dark:border-neutral-800 rounded-xl bg-neutral-50/50 dark:bg-neutral-900/50 focus:bg-white dark:focus:bg-neutral-900
|
variant="input"
|
||||||
focus:border-brand-orange focus:ring-4 focus:ring-brand-orange/10 outline-none transition-all duration-200 dark:text-white appearance-none"
|
fullWidth
|
||||||
>
|
align="left"
|
||||||
{TIMEZONES.map((tz) => (
|
/>
|
||||||
<option key={tz} value={tz}>
|
|
||||||
{tz}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<div className="absolute inset-y-0 right-0 flex items-center px-4 pointer-events-none">
|
|
||||||
<svg className="w-4 h-4 text-neutral-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
@@ -649,22 +639,18 @@ export default function SiteSettingsPage() {
|
|||||||
Control location tracking granularity
|
Control location tracking granularity
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative">
|
<Select
|
||||||
<select
|
value={formData.collect_geo_data}
|
||||||
value={formData.collect_geo_data}
|
onChange={(v) => setFormData({ ...formData, collect_geo_data: v as GeoDataLevel })}
|
||||||
onChange={(e) => setFormData({ ...formData, collect_geo_data: e.target.value as GeoDataLevel })}
|
options={[
|
||||||
className="px-4 py-2 border border-neutral-200 dark:border-neutral-700 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-white text-sm font-medium appearance-none pr-10 focus:outline-none focus:ring-2 focus:ring-brand-orange/20 focus:border-brand-orange"
|
{ value: 'full', label: 'Full (country, region, city)' },
|
||||||
>
|
{ value: 'country', label: 'Country only' },
|
||||||
<option value="full">Full (country, region, city)</option>
|
{ value: 'none', label: 'None' },
|
||||||
<option value="country">Country only</option>
|
]}
|
||||||
<option value="none">None</option>
|
variant="input"
|
||||||
</select>
|
align="right"
|
||||||
<div className="absolute inset-y-0 right-0 flex items-center px-3 pointer-events-none">
|
className="min-w-[200px]"
|
||||||
<svg className="w-4 h-4 text-neutral-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
/>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -850,24 +836,20 @@ export default function SiteSettingsPage() {
|
|||||||
How long to keep recordings
|
How long to keep recordings
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative">
|
<Select
|
||||||
<select
|
value={String(formData.replay_retention_days)}
|
||||||
value={formData.replay_retention_days}
|
onChange={(v) => setFormData({ ...formData, replay_retention_days: parseInt(v, 10) })}
|
||||||
onChange={(e) => setFormData({ ...formData, replay_retention_days: parseInt(e.target.value) })}
|
options={[
|
||||||
className="px-4 py-2 border border-neutral-200 dark:border-neutral-700 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-white text-sm font-medium appearance-none pr-10 focus:outline-none focus:ring-2 focus:ring-brand-orange/20 focus:border-brand-orange"
|
{ value: '7', label: '7 days' },
|
||||||
>
|
{ value: '14', label: '14 days' },
|
||||||
<option value={7}>7 days</option>
|
{ value: '30', label: '30 days' },
|
||||||
<option value={14}>14 days</option>
|
{ value: '60', label: '60 days' },
|
||||||
<option value={30}>30 days</option>
|
{ value: '90', label: '90 days' },
|
||||||
<option value={60}>60 days</option>
|
]}
|
||||||
<option value={90}>90 days</option>
|
variant="input"
|
||||||
</select>
|
align="right"
|
||||||
<div className="absolute inset-y-0 right-0 flex items-center px-3 pointer-events-none">
|
className="min-w-[120px]"
|
||||||
<svg className="w-4 h-4 text-neutral-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
/>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -12,9 +12,29 @@ interface SelectProps {
|
|||||||
onChange: (value: string) => void
|
onChange: (value: string) => void
|
||||||
options: Option[]
|
options: Option[]
|
||||||
className?: string
|
className?: string
|
||||||
|
/** Form-field style (input-like). Default: toolbar style (btn-secondary). */
|
||||||
|
variant?: 'default' | 'input'
|
||||||
|
/** Shown when value is empty or does not match any option. */
|
||||||
|
placeholder?: string
|
||||||
|
/** Full-width trigger and panel. Use in form layouts. */
|
||||||
|
fullWidth?: boolean
|
||||||
|
/** Id for the trigger (e.g. for label htmlFor). */
|
||||||
|
id?: string
|
||||||
|
/** Alignment of the dropdown panel. */
|
||||||
|
align?: 'left' | 'right'
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Select({ value, onChange, options, className = '' }: SelectProps) {
|
export default function Select({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
options,
|
||||||
|
className = '',
|
||||||
|
variant = 'default',
|
||||||
|
placeholder,
|
||||||
|
fullWidth = false,
|
||||||
|
id,
|
||||||
|
align = 'right',
|
||||||
|
}: SelectProps) {
|
||||||
const [isOpen, setIsOpen] = useState(false)
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
const ref = useRef<HTMLDivElement>(null)
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
@@ -28,18 +48,35 @@ export default function Select({ value, onChange, options, className = '' }: Sel
|
|||||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const selectedOption = options.find(o => o.value === value) || options.find(o => o.value === 'custom')
|
const selectedOption = options.find((o) => o.value === value)
|
||||||
|
const displayLabel = selectedOption?.label ?? placeholder ?? value ?? ''
|
||||||
|
|
||||||
|
const triggerBase =
|
||||||
|
variant === 'input'
|
||||||
|
? 'px-4 py-2 border border-neutral-200 dark:border-neutral-800 rounded-xl bg-neutral-50/50 dark:bg-neutral-900/50 text-neutral-900 dark:text-white text-left text-sm ' +
|
||||||
|
'focus:outline-none focus:border-brand-orange focus:ring-4 focus:ring-brand-orange/10 transition-all duration-200 ' +
|
||||||
|
(isOpen ? 'ring-4 ring-brand-orange/10 border-brand-orange' : '')
|
||||||
|
: 'btn-secondary min-w-[140px]'
|
||||||
|
|
||||||
|
const triggerLayout = fullWidth ? 'w-full ' : ''
|
||||||
|
const alignClass = align === 'left' ? 'left-0' : 'right-0'
|
||||||
|
const panelMinW = fullWidth ? 'w-full' : 'min-w-[140px] w-full'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`relative ${className}`} ref={ref}>
|
<div className={`relative ${fullWidth ? 'w-full' : ''} ${className}`} ref={ref}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
id={id}
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
className="btn-secondary min-w-[140px] flex items-center justify-between gap-2"
|
className={`${triggerLayout}${triggerBase} flex items-center justify-between gap-2`}
|
||||||
|
aria-haspopup="listbox"
|
||||||
|
aria-expanded={isOpen}
|
||||||
>
|
>
|
||||||
<span>{selectedOption?.label || value}</span>
|
<span className={!selectedOption && placeholder ? 'text-neutral-500 dark:text-neutral-400' : ''}>
|
||||||
|
{displayLabel}
|
||||||
|
</span>
|
||||||
<svg
|
<svg
|
||||||
className={`w-4 h-4 text-neutral-500 transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`}
|
className={`w-4 h-4 text-neutral-500 flex-shrink-0 transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`}
|
||||||
fill="none"
|
fill="none"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
@@ -49,24 +86,29 @@ export default function Select({ value, onChange, options, className = '' }: Sel
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<div className="absolute right-0 mt-2 w-full min-w-[140px] bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-xl shadow-lg z-50 overflow-hidden py-1">
|
<div
|
||||||
|
className={`absolute ${alignClass} mt-2 ${panelMinW} bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-xl shadow-lg z-50 overflow-hidden py-1 max-h-60 overflow-y-auto`}
|
||||||
|
role="listbox"
|
||||||
|
>
|
||||||
{options.map((option) => (
|
{options.map((option) => (
|
||||||
<button
|
<button
|
||||||
key={option.value}
|
key={option.value}
|
||||||
|
role="option"
|
||||||
|
aria-selected={value === option.value}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onChange(option.value)
|
onChange(option.value)
|
||||||
setIsOpen(false)
|
setIsOpen(false)
|
||||||
}}
|
}}
|
||||||
className={`w-full text-left px-4 py-2.5 text-sm transition-colors flex items-center justify-between
|
className={`w-full text-left px-4 py-2.5 text-sm transition-colors duration-200 flex items-center justify-between
|
||||||
${value === option.value
|
${value === option.value
|
||||||
? 'bg-neutral-50 dark:bg-neutral-800 text-brand-orange font-medium'
|
? 'bg-neutral-50 dark:bg-neutral-800 text-brand-orange font-medium'
|
||||||
: 'text-neutral-700 dark:text-neutral-300 hover:bg-neutral-50 dark:hover:bg-neutral-800'
|
: 'text-neutral-700 dark:text-neutral-300 hover:bg-neutral-50 dark:hover:bg-neutral-800'
|
||||||
}
|
}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
{option.label}
|
{option.label}
|
||||||
{value === option.value && (
|
{value === option.value && (
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg className="w-4 h-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||||
</svg>
|
</svg>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user