feat: integrate Combobox component for timezone selection in CreateTaskDialog and AccountPage
Some checks failed
Frontend CI / lint (push) Failing after 19s
Frontend CI / build (push) Has been skipped

This commit is contained in:
JSC
2025-08-29 03:33:38 +02:00
parent 70de6ad919
commit 851738f04f
3 changed files with 215 additions and 216 deletions

View File

@@ -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">

View 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>
)
}