feat: integrate Combobox component for timezone selection in CreateTaskDialog and AccountPage
This commit is contained in:
@@ -1,12 +1,5 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command'
|
||||
import { Combobox, type ComboboxOption } from '@/components/ui/combobox'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -15,11 +8,6 @@ import {
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -39,7 +27,7 @@ import { soundsService } from '@/lib/api/services/sounds'
|
||||
import { playlistsService } from '@/lib/api/services/playlists'
|
||||
import { getSupportedTimezones } from '@/utils/locale'
|
||||
import { useLocale } from '@/hooks/use-locale'
|
||||
import { CalendarPlus, Check, ChevronsUpDown, Loader2, Music, PlayCircle } from 'lucide-react'
|
||||
import { CalendarPlus, Loader2, Music, PlayCircle } from 'lucide-react'
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
interface CreateTaskDialogProps {
|
||||
@@ -62,10 +50,35 @@ export function CreateTaskDialog({
|
||||
}: CreateTaskDialogProps) {
|
||||
const { timezone } = useLocale()
|
||||
|
||||
const getDefaultScheduledTime = () => {
|
||||
const now = new Date()
|
||||
now.setMinutes(now.getMinutes() + 10) // Default to 10 minutes from now
|
||||
|
||||
// Format the time in the user's timezone
|
||||
const formatter = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: timezone,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
})
|
||||
|
||||
const parts = formatter.formatToParts(now)
|
||||
const partsObj = parts.reduce((acc, part) => {
|
||||
acc[part.type] = part.value
|
||||
return acc
|
||||
}, {} as Record<string, string>)
|
||||
|
||||
// Return in datetime-local format (YYYY-MM-DDTHH:MM)
|
||||
return `${partsObj.year}-${partsObj.month}-${partsObj.day}T${partsObj.hour}:${partsObj.minute}`
|
||||
}
|
||||
|
||||
const [formData, setFormData] = useState<CreateScheduledTaskRequest>({
|
||||
name: '',
|
||||
task_type: 'play_sound',
|
||||
scheduled_at: '',
|
||||
scheduled_at: getDefaultScheduledTime(),
|
||||
timezone: timezone,
|
||||
parameters: {},
|
||||
recurrence_type: 'none',
|
||||
@@ -89,10 +102,6 @@ export function CreateTaskDialog({
|
||||
const [loadingSounds, setLoadingSounds] = useState(false)
|
||||
const [loadingPlaylists, setLoadingPlaylists] = useState(false)
|
||||
|
||||
// Combobox state
|
||||
const [soundComboOpen, setSoundComboOpen] = useState(false)
|
||||
const [playlistComboOpen, setPlaylistComboOpen] = useState(false)
|
||||
const [timezoneComboOpen, setTimezoneComboOpen] = useState(false)
|
||||
|
||||
// Load sounds and playlists when dialog opens
|
||||
useEffect(() => {
|
||||
@@ -128,6 +137,30 @@ export function CreateTaskDialog({
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare options for comboboxes
|
||||
const soundOptions: ComboboxOption[] = sounds.map((sound) => ({
|
||||
value: sound.id.toString(),
|
||||
label: sound.name || sound.filename,
|
||||
icon: <Music className="h-4 w-4" />,
|
||||
searchValue: `${sound.id}-${sound.name || sound.filename}`
|
||||
}))
|
||||
|
||||
const playlistOptions: ComboboxOption[] = playlists.map((playlist) => ({
|
||||
value: playlist.id.toString(),
|
||||
label: playlist.name,
|
||||
icon: <PlayCircle className="h-4 w-4" />,
|
||||
searchValue: `${playlist.id}-${playlist.name}`
|
||||
}))
|
||||
|
||||
const timezoneOptions: ComboboxOption[] = [
|
||||
{ value: 'UTC', label: 'UTC' },
|
||||
...getSupportedTimezones().map((tz) => ({
|
||||
value: tz,
|
||||
label: tz.replace('_', ' '),
|
||||
searchValue: tz.replace('_', ' ')
|
||||
}))
|
||||
]
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
@@ -195,7 +228,7 @@ export function CreateTaskDialog({
|
||||
setFormData({
|
||||
name: '',
|
||||
task_type: 'play_sound',
|
||||
scheduled_at: '',
|
||||
scheduled_at: getDefaultScheduledTime(),
|
||||
timezone: timezone,
|
||||
parameters: {},
|
||||
recurrence_type: 'none',
|
||||
@@ -209,37 +242,9 @@ export function CreateTaskDialog({
|
||||
setSelectedPlaylistId('')
|
||||
setPlayMode('continuous')
|
||||
setShuffle(false)
|
||||
setSoundComboOpen(false)
|
||||
setPlaylistComboOpen(false)
|
||||
setTimezoneComboOpen(false)
|
||||
onCancel()
|
||||
}
|
||||
|
||||
const getDefaultScheduledTime = () => {
|
||||
const now = new Date()
|
||||
now.setMinutes(now.getMinutes() + 10) // Default to 10 minutes from now
|
||||
|
||||
// Format the time in the user's timezone
|
||||
const formatter = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: timezone,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
})
|
||||
|
||||
const parts = formatter.formatToParts(now)
|
||||
const partsObj = parts.reduce((acc, part) => {
|
||||
acc[part.type] = part.value
|
||||
return acc
|
||||
}, {} as Record<string, string>)
|
||||
|
||||
// Return in datetime-local format (YYYY-MM-DDTHH:MM)
|
||||
return `${partsObj.year}-${partsObj.month}-${partsObj.day}T${partsObj.hour}:${partsObj.minute}`
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
@@ -289,7 +294,7 @@ export function CreateTaskDialog({
|
||||
<Input
|
||||
id="scheduled_at"
|
||||
type="datetime-local"
|
||||
value={formData.scheduled_at || getDefaultScheduledTime()}
|
||||
value={formData.scheduled_at}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, scheduled_at: e.target.value }))}
|
||||
required
|
||||
/>
|
||||
@@ -297,61 +302,14 @@ export function CreateTaskDialog({
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="timezone">Timezone</Label>
|
||||
<Popover open={timezoneComboOpen} onOpenChange={setTimezoneComboOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={timezoneComboOpen}
|
||||
className="w-full justify-between"
|
||||
>
|
||||
{formData.timezone?.replace('_', ' ') || 'UTC'}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-full p-0" style={{ WebkitOverflowScrolling: 'touch' } as React.CSSProperties}>
|
||||
<Command onWheel={(e) => e.stopPropagation()}>
|
||||
<CommandInput placeholder="Search timezone..." />
|
||||
<CommandList className="max-h-[200px] overflow-y-auto overscroll-contain" style={{ touchAction: 'pan-y' }}>
|
||||
<CommandEmpty>No timezone found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
key="UTC"
|
||||
value="UTC"
|
||||
onSelect={() => {
|
||||
setFormData(prev => ({ ...prev, timezone: 'UTC' }))
|
||||
setTimezoneComboOpen(false)
|
||||
}}
|
||||
>
|
||||
<span>UTC</span>
|
||||
<Check
|
||||
className={`ml-auto h-4 w-4 ${
|
||||
formData.timezone === 'UTC' ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
/>
|
||||
</CommandItem>
|
||||
{getSupportedTimezones().map((tz) => (
|
||||
<CommandItem
|
||||
key={tz}
|
||||
value={tz.replace('_', ' ')}
|
||||
onSelect={() => {
|
||||
setFormData(prev => ({ ...prev, timezone: tz }))
|
||||
setTimezoneComboOpen(false)
|
||||
}}
|
||||
>
|
||||
<span>{tz.replace('_', ' ')}</span>
|
||||
<Check
|
||||
className={`ml-auto h-4 w-4 ${
|
||||
formData.timezone === tz ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
/>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Combobox
|
||||
value={formData.timezone}
|
||||
onValueChange={(value) => setFormData(prev => ({ ...prev, timezone: value }))}
|
||||
options={timezoneOptions}
|
||||
placeholder="Select timezone..."
|
||||
searchPlaceholder="Search timezone..."
|
||||
emptyMessage="No timezone found."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -418,56 +376,16 @@ export function CreateTaskDialog({
|
||||
{formData.task_type === 'play_sound' && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="sound">Sound to Play</Label>
|
||||
<Popover open={soundComboOpen} onOpenChange={setSoundComboOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={soundComboOpen}
|
||||
className="w-full justify-between"
|
||||
disabled={loadingSounds}
|
||||
>
|
||||
{selectedSoundId
|
||||
? sounds.find((sound) => sound.id.toString() === selectedSoundId)?.name ||
|
||||
sounds.find((sound) => sound.id.toString() === selectedSoundId)?.filename ||
|
||||
"Selected sound"
|
||||
: loadingSounds
|
||||
? "Loading sounds..."
|
||||
: "Select a sound"}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-full p-0" style={{ WebkitOverflowScrolling: 'touch' } as React.CSSProperties}>
|
||||
<Command onWheel={(e) => e.stopPropagation()}>
|
||||
<CommandInput placeholder="Search sounds..." />
|
||||
<CommandList className="max-h-[200px] overflow-y-auto overscroll-contain" style={{ touchAction: 'pan-y' }}>
|
||||
<CommandEmpty>No sound found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{sounds.map((sound) => (
|
||||
<CommandItem
|
||||
key={sound.id}
|
||||
value={`${sound.id}-${sound.name || sound.filename}`}
|
||||
onSelect={() => {
|
||||
setSelectedSoundId(sound.id.toString())
|
||||
setSoundComboOpen(false)
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Music className="h-4 w-4" />
|
||||
<span>{sound.name || sound.filename}</span>
|
||||
</div>
|
||||
<Check
|
||||
className={`ml-auto h-4 w-4 ${
|
||||
selectedSoundId === sound.id.toString() ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
/>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Combobox
|
||||
value={selectedSoundId}
|
||||
onValueChange={setSelectedSoundId}
|
||||
options={soundOptions}
|
||||
placeholder="Select a sound"
|
||||
searchPlaceholder="Search sounds..."
|
||||
emptyMessage="No sound found."
|
||||
loading={loadingSounds}
|
||||
loadingMessage="Loading sounds..."
|
||||
/>
|
||||
{parametersError && (
|
||||
<p className="text-sm text-destructive">{parametersError}</p>
|
||||
)}
|
||||
@@ -478,55 +396,16 @@ export function CreateTaskDialog({
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="playlist">Playlist to Play</Label>
|
||||
<Popover open={playlistComboOpen} onOpenChange={setPlaylistComboOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={playlistComboOpen}
|
||||
className="w-full justify-between"
|
||||
disabled={loadingPlaylists}
|
||||
>
|
||||
{selectedPlaylistId
|
||||
? playlists.find((playlist) => playlist.id.toString() === selectedPlaylistId)?.name ||
|
||||
"Selected playlist"
|
||||
: loadingPlaylists
|
||||
? "Loading playlists..."
|
||||
: "Select a playlist"}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-full p-0" style={{ WebkitOverflowScrolling: 'touch' } as React.CSSProperties}>
|
||||
<Command onWheel={(e) => e.stopPropagation()}>
|
||||
<CommandInput placeholder="Search playlists..." />
|
||||
<CommandList className="max-h-[200px] overflow-y-auto overscroll-contain" style={{ touchAction: 'pan-y' }}>
|
||||
<CommandEmpty>No playlist found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{playlists.map((playlist) => (
|
||||
<CommandItem
|
||||
key={playlist.id}
|
||||
value={`${playlist.id}-${playlist.name}`}
|
||||
onSelect={() => {
|
||||
setSelectedPlaylistId(playlist.id.toString())
|
||||
setPlaylistComboOpen(false)
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<PlayCircle className="h-4 w-4" />
|
||||
<span>{playlist.name}</span>
|
||||
</div>
|
||||
<Check
|
||||
className={`ml-auto h-4 w-4 ${
|
||||
selectedPlaylistId === playlist.id.toString() ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
/>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Combobox
|
||||
value={selectedPlaylistId}
|
||||
onValueChange={setSelectedPlaylistId}
|
||||
options={playlistOptions}
|
||||
placeholder="Select a playlist"
|
||||
searchPlaceholder="Search playlists..."
|
||||
emptyMessage="No playlist found."
|
||||
loading={loadingPlaylists}
|
||||
loadingMessage="Loading playlists..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
|
||||
119
src/components/ui/combobox.tsx
Normal file
119
src/components/ui/combobox.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover"
|
||||
import { Check, ChevronsUpDown } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export interface ComboboxOption {
|
||||
value: string
|
||||
label: string
|
||||
icon?: React.ReactNode
|
||||
searchValue?: string
|
||||
}
|
||||
|
||||
interface ComboboxProps {
|
||||
value?: string
|
||||
onValueChange: (value: string) => void
|
||||
options: ComboboxOption[]
|
||||
placeholder?: string
|
||||
searchPlaceholder?: string
|
||||
emptyMessage?: string
|
||||
disabled?: boolean
|
||||
loading?: boolean
|
||||
loadingMessage?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function Combobox({
|
||||
value,
|
||||
onValueChange,
|
||||
options,
|
||||
placeholder = "Select option...",
|
||||
searchPlaceholder = "Search options...",
|
||||
emptyMessage = "No option found.",
|
||||
disabled = false,
|
||||
loading = false,
|
||||
loadingMessage = "Loading...",
|
||||
className,
|
||||
}: ComboboxProps) {
|
||||
const [open, setOpen] = React.useState(false)
|
||||
|
||||
const selectedOption = options.find((option) => option.value === value)
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className={cn("w-full justify-between", className)}
|
||||
disabled={disabled || loading}
|
||||
>
|
||||
{selectedOption ? (
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedOption.icon}
|
||||
<span>{selectedOption.label}</span>
|
||||
</div>
|
||||
) : loading ? (
|
||||
loadingMessage
|
||||
) : (
|
||||
placeholder
|
||||
)}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-full p-0"
|
||||
style={{ WebkitOverflowScrolling: 'touch' } as React.CSSProperties}
|
||||
>
|
||||
<Command onWheel={(e) => e.stopPropagation()}>
|
||||
<CommandInput placeholder={searchPlaceholder} />
|
||||
<CommandList
|
||||
className="max-h-[200px] overflow-y-auto overscroll-contain"
|
||||
style={{ touchAction: 'pan-y' }}
|
||||
>
|
||||
<CommandEmpty>{emptyMessage}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{options.map((option) => (
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
value={option.searchValue || `${option.value}-${option.label}`}
|
||||
onSelect={() => {
|
||||
onValueChange(option.value)
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{option.icon}
|
||||
<span>{option.label}</span>
|
||||
</div>
|
||||
<Check
|
||||
className={cn(
|
||||
"ml-auto h-4 w-4",
|
||||
value === option.value ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { AppLayout } from '@/components/AppLayout'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Combobox, type ComboboxOption } from '@/components/ui/combobox'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -75,6 +76,13 @@ export function AccountPage() {
|
||||
const [providers, setProviders] = useState<UserProvider[]>([])
|
||||
const [providersLoading, setProvidersLoading] = useState(true)
|
||||
|
||||
// Prepare timezone options for combobox
|
||||
const timezoneOptions: ComboboxOption[] = getSupportedTimezones().map((tz) => ({
|
||||
value: tz,
|
||||
label: tz.replace('_', ' '),
|
||||
searchValue: tz.replace('_', ' ')
|
||||
}))
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
setProfileName(user.name)
|
||||
@@ -390,21 +398,14 @@ export function AccountPage() {
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Timezone</Label>
|
||||
<Select
|
||||
<Combobox
|
||||
value={timezone}
|
||||
onValueChange={setTimezone}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{getSupportedTimezones().map((tz) => (
|
||||
<SelectItem key={tz} value={tz}>
|
||||
{tz.replace('_', ' ')}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
options={timezoneOptions}
|
||||
placeholder="Select timezone..."
|
||||
searchPlaceholder="Search timezone..."
|
||||
emptyMessage="No timezone found."
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Choose your timezone for date and time display
|
||||
</p>
|
||||
|
||||
Reference in New Issue
Block a user