Browse Source

bug-fixes

imwald
Silberengel 1 month ago
parent
commit
91e3cca452
  1. 32
      src/PageManager.tsx
  2. 7
      src/components/Favicon/index.tsx
  3. 87
      src/components/PostEditor/PostContent.tsx
  4. 5
      src/components/PostEditor/PostTextarea/index.tsx
  5. 6
      src/components/ScheduleVideoCallDialog/CalendarEventPreview.tsx
  6. 12
      src/components/ScheduleVideoCallDialog/ScheduleInPersonMeetingDialog.tsx
  7. 12
      src/components/ScheduleVideoCallDialog/ScheduleInPersonMeetingSingleDialog.tsx
  8. 12
      src/components/ScheduleVideoCallDialog/ScheduleVideoCallDialog.tsx
  9. 12
      src/components/ScheduleVideoCallDialog/ScheduleVideoCallSingleDialog.tsx
  10. 95
      src/components/ui/TimePicker.tsx

32
src/PageManager.tsx

@ -109,6 +109,8 @@ const PrimaryNoteViewContext = createContext<{ @@ -109,6 +109,8 @@ const PrimaryNoteViewContext = createContext<{
setPrimaryNoteView: (view: ReactNode | null, type?: 'note' | 'settings' | 'settings-sub' | 'profile' | 'hashtag' | 'relay' | 'following' | 'mute' | 'others-relay-settings') => void
primaryViewType: 'note' | 'settings' | 'settings-sub' | 'profile' | 'hashtag' | 'relay' | 'following' | 'mute' | 'others-relay-settings' | null
getNavigationCounter: () => number
/** Top URL in the secondary stack (right panel), or undefined if empty. Used so settings sub-pages open in the panel instead of behind it. */
getTopSecondaryUrl: () => string | undefined
} | undefined>(undefined)
const NoteDrawerContext = createContext<{
@ -398,12 +400,22 @@ export function useSmartOthersRelaySettingsNavigation() { @@ -398,12 +400,22 @@ export function useSmartOthersRelaySettingsNavigation() {
return { navigateToOthersRelaySettings }
}
// Fixed: Settings navigation now uses primary note view since secondary panel is disabled
// Fixed: Settings navigation uses primary note view when settings is in main area; when settings list is in the right panel (Sheet), push sub-pages so they open in the panel instead of behind it.
export function useSmartSettingsNavigation() {
const { setPrimaryNoteView } = usePrimaryNoteView()
const { setPrimaryNoteView, getTopSecondaryUrl } = usePrimaryNoteView()
const { push: pushSecondaryPage } = useSecondaryPage()
const navigateToSettings = (url: string) => {
// Use primary note view to show settings since secondary panel is disabled
const topUrl = getTopSecondaryUrl?.()
const settingsInRightPanel = topUrl === '/settings'
// When the right panel is showing the settings list, push the sub-page so it opens in the panel instead of in the main area (behind the panel).
if (settingsInRightPanel && url !== '/settings') {
pushSecondaryPage(url)
return
}
// Otherwise use primary note view (main content area)
if (url === '/settings') {
window.history.pushState(null, '', url)
setPrimaryNoteView(<SettingsPage key="settings" index={0} hideTitlebar={true} />, 'settings')
@ -1374,6 +1386,16 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -1374,6 +1386,16 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
}))
currentTabStateRef.current.set('search', tab)
}
} else if (secondaryStack.length > 1) {
// Pop to previous page (e.g. from /settings/general back to /settings) so Back/Close return to the list instead of closing the panel
setSecondaryStack((prevStack) => {
const newStack = prevStack.slice(0, -1)
const topItem = newStack[newStack.length - 1]
if (topItem) {
window.history.replaceState({ index: topItem.index, url: topItem.url }, '', topItem.url)
}
return newStack
})
} else {
window.history.go(-1)
}
@ -1410,7 +1432,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -1410,7 +1432,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
>
<CurrentRelaysProvider>
<NotificationProvider>
<PrimaryNoteViewContext.Provider value={{ setPrimaryNoteView, primaryViewType, getNavigationCounter: () => navigationCounterRef.current }}>
<PrimaryNoteViewContext.Provider value={{ setPrimaryNoteView, primaryViewType, getNavigationCounter: () => navigationCounterRef.current, getTopSecondaryUrl: () => secondaryStack.length > 0 ? secondaryStack[secondaryStack.length - 1].url : undefined }}>
<NoteDrawerContext.Provider value={{ openDrawer, closeDrawer, isDrawerOpen: drawerOpen, drawerNoteId }}>
{primaryNoteView ? (
// Show primary note view with back button on mobile
@ -1527,7 +1549,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -1527,7 +1549,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
>
<CurrentRelaysProvider>
<NotificationProvider>
<PrimaryNoteViewContext.Provider value={{ setPrimaryNoteView, primaryViewType, getNavigationCounter: () => navigationCounterRef.current }}>
<PrimaryNoteViewContext.Provider value={{ setPrimaryNoteView, primaryViewType, getNavigationCounter: () => navigationCounterRef.current, getTopSecondaryUrl: () => secondaryStack.length > 0 ? secondaryStack[secondaryStack.length - 1].url : undefined }}>
<NoteDrawerContext.Provider value={{ openDrawer, closeDrawer, isDrawerOpen: drawerOpen, drawerNoteId }}>
<div className="flex flex-col items-center bg-surface-background">
<div

7
src/components/Favicon/index.tsx

@ -12,14 +12,15 @@ export function Favicon({ @@ -12,14 +12,15 @@ export function Favicon({
}) {
const [loading, setLoading] = useState(true)
const [error, setError] = useState(false)
if (error) return fallback
const trimmed = domain?.trim() ?? ''
if (error || !trimmed) return fallback
return (
<div className={cn('relative', className)}>
{loading && <div className={cn('absolute inset-0', className)}>{fallback}</div>}
<img
src={`https://${domain}/favicon.ico`}
alt={domain}
src={`https://${trimmed}/favicon.ico`}
alt={trimmed}
className={cn('absolute inset-0', loading && 'opacity-0', className)}
onError={() => setError(true)}
onLoad={() => setLoading(false)}

87
src/components/PostEditor/PostContent.tsx

@ -324,15 +324,7 @@ export default function PostContent({ @@ -324,15 +324,7 @@ export default function PostContent({
}
// For voice comments in replies, check mediaNoteKind even if mediaUrl is not set yet (for preview)
// Debug logging
console.log('🔍 getDeterminedKind: checking', {
parentEvent: !!parentEvent,
mediaNoteKind,
VOICE_COMMENT: ExtendedKind.VOICE_COMMENT,
match: parentEvent && mediaNoteKind === ExtendedKind.VOICE_COMMENT
})
if (parentEvent && mediaNoteKind === ExtendedKind.VOICE_COMMENT) {
console.log('✅ getDeterminedKind: returning VOICE_COMMENT')
return ExtendedKind.VOICE_COMMENT
} else if (mediaNoteKind !== null && mediaUrl) {
return mediaNoteKind
@ -357,12 +349,6 @@ export default function PostContent({ @@ -357,12 +349,6 @@ export default function PostContent({
} else if (isPoll) {
return ExtendedKind.POLL
} else if (parentEvent && parentEvent.kind !== kinds.ShortTextNote) {
console.log('⚠ getDeterminedKind: falling through to COMMENT', {
parentEvent: !!parentEvent,
parentEventKind: parentEvent?.kind,
mediaNoteKind,
mediaUrl
})
return ExtendedKind.COMMENT
} else {
return kinds.ShortTextNote
@ -623,9 +609,6 @@ export default function PostContent({ @@ -623,9 +609,6 @@ export default function PostContent({
summary: citationSummary.trim() || undefined
}
// Debug: Log what we're passing to the function
console.log('Creating hardcopy citation with options:', hardcopyOptions)
return createCitationHardcopyDraftEvent(cleanedText, hardcopyOptions)
} else if (isCitationPrompt) {
return createCitationPromptDraftEvent(cleanedText, {
@ -962,11 +945,6 @@ export default function PostContent({ @@ -962,11 +945,6 @@ export default function PostContent({
}
const handleUploadStart = (file: File, cancel: () => void) => {
console.log('🔍 handleUploadStart called', {
fileName: file.name,
fileType: file.type,
parentEvent: !!parentEvent
})
setUploadProgresses((prev) => [...prev, { file, progress: 0, cancel }])
// Track file for media upload
if (file.type.startsWith('image/') || file.type.startsWith('audio/') || file.type.startsWith('video/')) {
@ -993,56 +971,25 @@ export default function PostContent({ @@ -993,56 +971,25 @@ export default function PostContent({
// m4a files are always audio, even if MIME type is wrong
const isAudio = isAudioMime || isAudioExt || isM4aFile || isMp4Audio || isWebmFile || isOggFile || isMp3File
console.log('🔍 handleUploadStart: audio detection', {
fileType,
fileName,
isAudioMime,
isAudioExt,
isMp4Audio,
isWebmFile,
isOggFile,
isMp3File,
isAudio
})
if (isAudio) {
// For PM replies, don't set mediaNoteKind - let PM reply handle it with imeta tags
if (parentEvent && parentEvent.kind === ExtendedKind.PUBLIC_MESSAGE) {
console.log('✅ handleUploadStart: PM reply with audio - will use imeta tags, not setting mediaNoteKind')
// Don't set mediaNoteKind - PM replies stay as kind 24 with imeta tags
} else if (parentEvent) {
console.log('✅ handleUploadStart: setting VOICE_COMMENT for reply', {
mediaNoteKind: ExtendedKind.VOICE_COMMENT,
fileType,
fileName
})
setMediaNoteKind(ExtendedKind.VOICE_COMMENT)
} else if (isPublicMessage) {
console.log('✅ handleUploadStart: setting VOICE for PM', {
mediaNoteKind: ExtendedKind.VOICE,
fileType,
fileName
})
setMediaNoteKind(ExtendedKind.VOICE)
}
// Note: URL will be inserted when upload completes in handleMediaUploadSuccess
} else {
console.log('❌ handleUploadStart: file is not audio, not setting media note kind')
}
} else {
// For new posts, detect the kind from the file (async)
getMediaKindFromFile(file, false)
.then((kind) => {
console.log('✅ handleUploadStart: detected kind for new post', { kind, fileName: file.name })
setMediaNoteKind(kind)
})
.then((kind) => setMediaNoteKind(kind))
.catch((error) => {
console.error('❌ Error detecting media kind in handleUploadStart', { error, file: file.name })
logger.error('Error detecting media kind in handleUploadStart', { error, file: file.name })
})
}
} else {
console.log('❌ handleUploadStart: file is not media type', { fileType: file.type })
}
}
@ -1290,37 +1237,16 @@ export default function PostContent({ @@ -1290,37 +1237,16 @@ export default function PostContent({
// m4a files are always audio, even if MIME type is wrong
const isAudio = isAudioMime || isAudioExt || isM4aFile || isMp4Audio || isWebmFile || isOggFile || isMp3File
console.log('🔍 handleMediaUploadSuccess: audio detection', {
fileType,
fileName,
isAudioMime,
isAudioExt,
isMp4Audio,
isWebmFile,
isOggFile,
isMp3File,
isAudio
})
if (isAudio) {
// For PM replies, don't set mediaNoteKind - let PM reply handle it with imeta tags
if (parentEvent && parentEvent.kind === ExtendedKind.PUBLIC_MESSAGE) {
console.log('✅ handleMediaUploadSuccess: PM reply with audio - will use imeta tags, not setting mediaNoteKind')
// Don't set mediaNoteKind - PM replies stay as kind 24 with imeta tags
// Just set the URL and imeta tags
} else if (parentEvent) {
// For regular replies, always create voice comments (kind 1244), regardless of duration
console.log('✅ handleMediaUploadSuccess: setting VOICE_COMMENT for reply', {
mediaNoteKind: ExtendedKind.VOICE_COMMENT,
url
})
setMediaNoteKind(ExtendedKind.VOICE_COMMENT)
} else if (isPublicMessage) {
// For new PMs, create voice notes (kind 1222)
console.log('✅ handleMediaUploadSuccess: setting VOICE for PM', {
mediaNoteKind: ExtendedKind.VOICE,
url
})
setMediaNoteKind(ExtendedKind.VOICE)
}
setMediaUrl(url)
@ -1365,11 +1291,6 @@ export default function PostContent({ @@ -1365,11 +1291,6 @@ export default function PostContent({
} else {
// Non-audio media in replies/PMs - don't set mediaNoteKind, will be handled as regular comment/PM
// Clear any existing media note kind
console.log('❌ handleMediaUploadSuccess: file is not audio, clearing mediaNoteKind', {
fileType,
fileName,
isAudio
})
setMediaNoteKind(null)
setMediaUrl('')
setMediaImetaTags([])
@ -2032,11 +1953,7 @@ export default function PostContent({ @@ -2032,11 +1953,7 @@ export default function PostContent({
onUploadStart={handleUploadStart}
onUploadProgress={handleUploadProgress}
onUploadEnd={handleUploadEnd}
kind={(() => {
const kind = getDeterminedKind
console.log('🔍 PostTextarea kind prop:', { kind, mediaNoteKind, parentEvent: !!parentEvent })
return kind
})()}
kind={getDeterminedKind}
highlightData={isHighlight ? highlightData : undefined}
pollCreateData={isPoll ? pollCreateData : undefined}
getDraftEventJson={getDraftEventJson}

5
src/components/PostEditor/PostTextarea/index.tsx

@ -87,10 +87,7 @@ const PostTextarea = forwardRef< @@ -87,10 +87,7 @@ const PostTextarea = forwardRef<
const [draftEventJson, setDraftEventJson] = useState<string>('')
const [isLoadingJson, setIsLoadingJson] = useState(false)
const kindDescription = useMemo(() => {
console.log('🔍 kindDescription: recalculating', { kind })
return getKindDescription(kind)
}, [kind])
const kindDescription = useMemo(() => getKindDescription(kind), [kind])
useEffect(() => {
if (activeTab === 'json' && getDraftEventJson) {

6
src/components/ScheduleVideoCallDialog/CalendarEventPreview.tsx

@ -36,19 +36,19 @@ export function CalendarEventPreview({ @@ -36,19 +36,19 @@ export function CalendarEventPreview({
)
return (
<div className={cn('space-y-2', className)}>
<div className={cn('w-full', className)}>
<Tabs defaultValue="rendered" className="w-full">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="rendered">{t('Rendered')}</TabsTrigger>
<TabsTrigger value="json">{t('JSON')}</TabsTrigger>
</TabsList>
<TabsContent value="rendered" className="mt-2">
<div className="rounded-md border bg-muted/20 p-2">
<div className="max-h-[200px] overflow-auto rounded-md border bg-muted/20 p-2">
<EmbeddedCalendarEvent event={previewEvent} />
</div>
</TabsContent>
<TabsContent value="json" className="mt-2">
<pre className="max-h-[240px] overflow-auto rounded-md border bg-muted/20 p-3 text-xs">
<pre className="max-h-[200px] overflow-auto rounded-md border bg-muted/20 p-3 text-xs">
{jsonString}
</pre>
</TabsContent>

12
src/components/ScheduleVideoCallDialog/ScheduleInPersonMeetingDialog.tsx

@ -234,8 +234,8 @@ export function ScheduleInPersonMeetingDialog({ @@ -234,8 +234,8 @@ export function ScheduleInPersonMeetingDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogContent className="sm:max-w-md max-h-[90vh] flex flex-col p-6">
<DialogHeader className="shrink-0">
<DialogTitle className="flex items-center gap-2">
<MapPin className="size-5" />
{t('Schedule in-person meeting')}
@ -244,7 +244,8 @@ export function ScheduleInPersonMeetingDialog({ @@ -244,7 +244,8 @@ export function ScheduleInPersonMeetingDialog({
{t('Required: start (or start date), invitees. Optional: title, end, location, summary, topics, image.')}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<form onSubmit={handleSubmit} className="flex flex-col min-h-0 flex-1">
<div className="flex-1 min-h-0 overflow-y-auto space-y-4 pr-1">
<div>
<Label>{t('Event type')}</Label>
<RadioGroup
@ -382,12 +383,13 @@ export function ScheduleInPersonMeetingDialog({ @@ -382,12 +383,13 @@ export function ScheduleInPersonMeetingDialog({
/>
</div>
{formValid && previewDraft && (
<div>
<div className="min-h-0 shrink-0">
<Label className="mb-1 block">{t('Preview')}</Label>
<CalendarEventPreview draft={previewDraft} />
</div>
)}
<DialogFooter>
</div>
<DialogFooter className="shrink-0 pt-2 border-t mt-2">
<Button
type="button"
variant="outline"

12
src/components/ScheduleVideoCallDialog/ScheduleInPersonMeetingSingleDialog.tsx

@ -219,8 +219,8 @@ export function ScheduleInPersonMeetingSingleDialog({ @@ -219,8 +219,8 @@ export function ScheduleInPersonMeetingSingleDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogContent className="sm:max-w-md max-h-[90vh] flex flex-col p-6">
<DialogHeader className="shrink-0">
<DialogTitle className="flex items-center gap-2">
<MapPin className="size-5" />
{t('Schedule in-person meeting')}
@ -229,7 +229,8 @@ export function ScheduleInPersonMeetingSingleDialog({ @@ -229,7 +229,8 @@ export function ScheduleInPersonMeetingSingleDialog({
{t('Required: start time or start date. Optional: title, end, location, summary, topics, image.')}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<form onSubmit={handleSubmit} className="flex flex-col min-h-0 flex-1">
<div className="flex-1 min-h-0 overflow-y-auto space-y-4 pr-1">
<div>
<Label>{t('Event type')}</Label>
<RadioGroup
@ -356,12 +357,13 @@ export function ScheduleInPersonMeetingSingleDialog({ @@ -356,12 +357,13 @@ export function ScheduleInPersonMeetingSingleDialog({
/>
</div>
{formValid && previewDraft && (
<div>
<div className="min-h-0 shrink-0">
<Label className="mb-1 block">{t('Preview')}</Label>
<CalendarEventPreview draft={previewDraft} />
</div>
)}
<DialogFooter>
</div>
<DialogFooter className="shrink-0 pt-2 border-t mt-2">
<Button
type="button"
variant="outline"

12
src/components/ScheduleVideoCallDialog/ScheduleVideoCallDialog.tsx

@ -191,8 +191,8 @@ export function ScheduleVideoCallDialog({ @@ -191,8 +191,8 @@ export function ScheduleVideoCallDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogContent className="sm:max-w-md max-h-[90vh] flex flex-col p-6">
<DialogHeader className="shrink-0">
<DialogTitle className="flex items-center gap-2">
<Calendar className="size-5" />
{t('Schedule a video call')}
@ -201,7 +201,8 @@ export function ScheduleVideoCallDialog({ @@ -201,7 +201,8 @@ export function ScheduleVideoCallDialog({
{t('Required: start time, invitees. Join link defaults to HiveTalk. Optional: title, end, summary, topics, image.')}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<form onSubmit={handleSubmit} className="flex flex-col min-h-0 flex-1">
<div className="flex-1 min-h-0 overflow-y-auto space-y-4 pr-1">
<div>
<Label htmlFor="own-call-title">
{t('Title')} <span className="text-muted-foreground font-normal">({t('optional')})</span>
@ -293,12 +294,13 @@ export function ScheduleVideoCallDialog({ @@ -293,12 +294,13 @@ export function ScheduleVideoCallDialog({
/>
</div>
{formValid && previewDraft && (
<div>
<div className="min-h-0 shrink-0">
<Label className="mb-1 block">{t('Preview')}</Label>
<CalendarEventPreview draft={previewDraft} />
</div>
)}
<DialogFooter>
</div>
<DialogFooter className="shrink-0 pt-2 border-t mt-2">
<Button
type="button"
variant="outline"

12
src/components/ScheduleVideoCallDialog/ScheduleVideoCallSingleDialog.tsx

@ -176,8 +176,8 @@ export function ScheduleVideoCallSingleDialog({ @@ -176,8 +176,8 @@ export function ScheduleVideoCallSingleDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogContent className="sm:max-w-md max-h-[90vh] flex flex-col p-6">
<DialogHeader className="shrink-0">
<DialogTitle className="flex items-center gap-2">
<Calendar className="size-5" />
{t('Schedule video call')}
@ -186,7 +186,8 @@ export function ScheduleVideoCallSingleDialog({ @@ -186,7 +186,8 @@ export function ScheduleVideoCallSingleDialog({
{t('Required: start time. Join link defaults to HiveTalk. Optional: title, end, summary, topics, image.')}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<form onSubmit={handleSubmit} className="flex flex-col min-h-0 flex-1">
<div className="flex-1 min-h-0 overflow-y-auto space-y-4 pr-1">
<div>
<Label htmlFor="schedule-call-title">
{t('Title')} <span className="text-muted-foreground font-normal">({t('optional')})</span>
@ -267,12 +268,13 @@ export function ScheduleVideoCallSingleDialog({ @@ -267,12 +268,13 @@ export function ScheduleVideoCallSingleDialog({
/>
</div>
{formValid && previewDraft && (
<div>
<div className="min-h-0 shrink-0">
<Label className="mb-1 block">{t('Preview')}</Label>
<CalendarEventPreview draft={previewDraft} />
</div>
)}
<DialogFooter>
</div>
<DialogFooter className="shrink-0 pt-2 border-t mt-2">
<Button
type="button"
variant="outline"

95
src/components/ui/TimePicker.tsx

@ -6,6 +6,7 @@ import { @@ -6,6 +6,7 @@ import {
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import { Input } from '@/components/ui/input'
import { cn } from '@/lib/utils'
import { useTranslation } from 'react-i18next'
@ -49,10 +50,6 @@ function to24h(displayHour: number, pm: boolean): number { @@ -49,10 +50,6 @@ function to24h(displayHour: number, pm: boolean): number {
return displayHour === 12 ? 0 : displayHour
}
const MINUTES = Array.from({ length: 60 }, (_, i) => i)
const HOURS_24 = Array.from({ length: 24 }, (_, i) => i)
const HOURS_12 = Array.from({ length: 12 }, (_, i) => i + 1)
/** Default: use 12h for en-US, 24h otherwise */
function defaultHour12(): boolean {
try {
@ -86,73 +83,65 @@ export function TimePicker({ @@ -86,73 +83,65 @@ export function TimePicker({
const { hour: hour24, minute } = parseHHmm(value)
const { displayHour: hour12Val, pm } = to12h(hour24)
const handleMinuteChange = React.useCallback(
(m: number) => {
onChange(toHHmm(hour24, m))
},
[hour24, onChange]
)
const displayHour = hour12 ? hour12Val : hour24
const hourMin = hour12 ? 1 : 0
const hourMax = hour12 ? 12 : 23
const handleHourChange = React.useCallback(
(newHour: number) => {
const handleHourChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const v = e.target.value
if (v === '') return
const num = parseInt(v, 10)
if (Number.isNaN(num)) return
const clamped = Math.min(hourMax, Math.max(hourMin, num))
if (hour12) {
const new24 = to24h(newHour, pm)
const new24 = to24h(clamped, pm)
onChange(toHHmm(new24, minute))
} else {
onChange(toHHmm(newHour, minute))
onChange(toHHmm(clamped, minute))
}
}
},
[hour12, minute, pm, onChange]
)
const handleAmPmChange = React.useCallback(
(newPm: boolean) => {
const handleMinuteChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const v = e.target.value
if (v === '') return
const num = parseInt(v, 10)
if (Number.isNaN(num)) return
const clamped = Math.min(59, Math.max(0, num))
onChange(toHHmm(hour24, clamped))
}
const handleAmPmChange = (newPm: boolean) => {
const new24 = to24h(hour12Val, newPm)
onChange(toHHmm(new24, minute))
},
[hour12Val, minute, onChange]
)
}
return (
<div className={cn('flex flex-wrap items-center gap-2', className)}>
<div className="flex items-center gap-1">
<Select
value={hour12 ? String(hour12Val) : String(hour24)}
onValueChange={(v) => handleHourChange(parseInt(v, 10))}
<Input
id={id}
type="number"
min={hourMin}
max={hourMax}
value={displayHour}
onChange={handleHourChange}
disabled={disabled}
>
<SelectTrigger id={id} className="w-[72px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{(hour12 ? HOURS_12 : HOURS_24).map((h) => (
<SelectItem key={h} value={String(h)}>
{hour12 ? h : String(h).padStart(2, '0')}
</SelectItem>
))}
</SelectContent>
</Select>
className="h-9 w-14 px-2 text-center tabular-nums"
/>
<span className="text-muted-foreground">:</span>
<Select
value={String(minute)}
onValueChange={(v) => handleMinuteChange(parseInt(v, 10))}
<Input
type="number"
min={0}
max={59}
value={minute}
onChange={handleMinuteChange}
disabled={disabled}
>
<SelectTrigger className="w-[72px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{MINUTES.map((m) => (
<SelectItem key={m} value={String(m)}>
{String(m).padStart(2, '0')}
</SelectItem>
))}
</SelectContent>
</Select>
className="h-9 w-14 px-2 text-center tabular-nums"
/>
</div>
{hour12 && (
<Select value={pm ? 'pm' : 'am'} onValueChange={(v) => handleAmPmChange(v === 'pm')} disabled={disabled}>
<SelectTrigger className="w-[72px]">
<SelectTrigger className="w-[72px] h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>

Loading…
Cancel
Save