atmo.rsvp
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

improve date selection, add timezones

Florian fc4a3a67 01f916fe

+400 -285
+31 -44
src/lib/components/DatePicker.svelte
··· 20 20 21 21 let isOpen = $state(false); 22 22 23 - const currentYear = new Date().getFullYear(); 23 + const now = new Date(); 24 + const currentYear = now.getFullYear(); 24 25 const yearRange = Array.from({ length: 7 }, (_, i) => currentYear - 1 + i); 25 26 const today = new Date(); 26 27 const todayDay = today.getDate(); ··· 61 62 parsed.day !== internalValue.day 62 63 ) { 63 64 internalValue = parsed; 65 + previousValue = parsed; 64 66 } 65 67 } else { 66 68 internalValue = undefined; ··· 68 70 }); 69 71 }); 70 72 73 + let previousValue: CalendarDate | undefined = $state(undefined); 74 + 71 75 function handleValueChange(newVal: DateValue | undefined) { 72 76 if (newVal && newVal instanceof CalendarDate) { 77 + previousValue = newVal; 73 78 internalValue = newVal; 74 79 value = formatDateStr(newVal); 80 + } else if (!newVal && previousValue) { 81 + // Prevent deselection — restore previous value 82 + internalValue = previousValue; 75 83 } 76 84 } 77 85 78 - function handleOpenChange(open: boolean) { 79 - isOpen = open; 80 - } 81 - 82 86 function handleOpenChangeComplete(open: boolean) { 83 87 if (!open && internalValue) { 84 88 onSelect?.(); 85 89 } 86 90 } 91 + 92 + let displayText = $derived.by(() => { 93 + if (!internalValue) return ''; 94 + const date = new Date(internalValue.year, internalValue.month - 1, internalValue.day); 95 + const opts: Intl.DateTimeFormatOptions = { weekday: 'short', month: 'short', day: 'numeric' }; 96 + if (internalValue.year !== currentYear) { 97 + opts.year = 'numeric'; 98 + } 99 + return date.toLocaleDateString(locale, opts); 100 + }); 87 101 </script> 88 102 89 103 <DatePicker.Root 90 104 bind:value={internalValue} 105 + bind:open={isOpen} 91 106 onValueChange={handleValueChange} 92 - onOpenChange={handleOpenChange} 93 107 onOpenChangeComplete={handleOpenChangeComplete} 94 108 minValue={internalMinValue} 95 109 granularity="day" ··· 98 112 {locale} 99 113 {required} 100 114 > 101 - <div 102 - class="border-base-300 bg-base-100 text-base-900 focus-within:border-accent-500 dark:border-base-700 dark:bg-base-800 dark:text-base-100 dark:focus-within:border-accent-400 flex items-center rounded-xl border px-2.5 py-1.5 text-sm transition-colors" 115 + <DatePicker.Trigger 116 + class="border-base-300 bg-base-100 text-base-900 focus-within:border-accent-500 dark:border-base-700 dark:bg-base-800 dark:text-base-100 dark:focus-within:border-accent-400 flex w-full min-w-[8.5rem] cursor-pointer items-center rounded-xl border px-2.5 py-1.5 text-sm transition-colors" 103 117 > 104 - <DatePicker.Input> 105 - {#snippet children({ segments })} 106 - {#each segments as segment, i (segment.part + i)} 107 - {#if segment.part === 'literal'} 108 - <span class="text-base-400 dark:text-base-500">{segment.value}</span> 109 - {:else} 110 - <DatePicker.Segment 111 - part={segment.part} 112 - class="hover:bg-base-200 focus:bg-base-200 dark:hover:bg-base-700 dark:focus:bg-base-700 rounded px-0.5 focus:outline-none" 113 - > 114 - {segment.value} 115 - </DatePicker.Segment> 116 - {/if} 117 - {/each} 118 - {/snippet} 119 - </DatePicker.Input> 120 - 121 - <DatePicker.Trigger 122 - class="text-base-400 hover:text-base-600 dark:text-base-500 dark:hover:text-base-300 ml-auto cursor-pointer pl-1.5" 123 - > 124 - <svg 125 - xmlns="http://www.w3.org/2000/svg" 126 - fill="none" 127 - viewBox="0 0 24 24" 128 - stroke-width="1.5" 129 - stroke="currentColor" 130 - class="size-4" 131 - > 132 - <path 133 - stroke-linecap="round" 134 - stroke-linejoin="round" 135 - d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 0 1 2.25-2.25h13.5A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5A2.25 2.25 0 0 1 21 11.25v7.5" 136 - /> 137 - </svg> 138 - </DatePicker.Trigger> 139 - </div> 118 + <span class="select-none"> 119 + {#if displayText} 120 + {displayText} 121 + {:else} 122 + <span class="text-base-400 dark:text-base-500">Select date</span> 123 + {/if} 124 + </span> 125 + </DatePicker.Trigger> 140 126 141 127 <DatePicker.Content 128 + sideOffset={8} 142 129 class="border-base-200 bg-base-50 dark:border-base-700 dark:bg-base-900 z-50 rounded-2xl border p-4 shadow-lg" 143 130 > 144 131 <DatePicker.Calendar>
+30 -2
src/lib/components/DateTimePicker.svelte
··· 8 8 let { 9 9 value = $bindable(''), 10 10 required = false, 11 - minValue = '' 11 + minValue = '', 12 + referenceTime = '' 12 13 }: { 13 14 value: string; 14 15 required?: boolean; 15 16 minValue?: string; 17 + referenceTime?: string; 16 18 } = $props(); 17 19 18 20 let datePart = $state(''); ··· 21 23 22 24 const locale = browser ? navigator.language || 'en' : 'en'; 23 25 let minDatePart = $derived(minValue ? minValue.split('T')[0] || '' : ''); 26 + let refTimePart = $derived.by(() => { 27 + if (!referenceTime) return ''; 28 + const [refDate, refTime] = referenceTime.split('T'); 29 + if (refDate && refDate === datePart && refTime) return refTime; 30 + return ''; 31 + }); 32 + 33 + // Default to current date/time rounded up to the next hour when no initial value 34 + if (browser && !value) { 35 + const now = new Date(); 36 + const rounded = new Date(now); 37 + rounded.setMinutes(0, 0, 0); 38 + rounded.setHours(rounded.getHours() + 1); 39 + 40 + const yyyy = rounded.getFullYear(); 41 + const mm = String(rounded.getMonth() + 1).padStart(2, '0'); 42 + const dd = String(rounded.getDate()).padStart(2, '0'); 43 + const hh = String(rounded.getHours()).padStart(2, '0'); 44 + const min = String(rounded.getMinutes()).padStart(2, '0'); 45 + 46 + const defaultDate = `${yyyy}-${mm}-${dd}`; 47 + const defaultTime = `${hh}:${min}`; 48 + datePart = defaultDate; 49 + timePart = defaultTime; 50 + value = `${defaultDate}T${defaultTime}`; 51 + } 24 52 25 53 // Sync external value -> date/time parts 26 54 $effect(() => { ··· 68 96 onSelect={focusTime} 69 97 /> 70 98 <div bind:this={timeEl}> 71 - <TimePicker bind:value={timePart} {locale} /> 99 + <TimePicker bind:value={timePart} {locale} referenceTime={refTimePart} /> 72 100 </div> 73 101 </div>
+78 -197
src/lib/components/EventEditor.svelte
··· 28 28 import { PlainTextEditor } from '@foxui/text'; 29 29 import Avatar from 'svelte-boring-avatars'; 30 30 import DateTimePicker from '$lib/components/DateTimePicker.svelte'; 31 + import TimezonePicker from '$lib/components/TimezonePicker.svelte'; 31 32 import type { FlatEventRecord } from '$lib/contrail'; 32 33 33 34 let { ··· 57 58 description: string; 58 59 startsAt: string; 59 60 endsAt: string; 61 + timezone?: string; 60 62 links: Array<{ uri: string; name: string }>; 61 63 mode?: EventMode; 62 64 thumbnailKey?: string; ··· 73 75 let description = $state(''); 74 76 let startsAt = $state(''); 75 77 let endsAt = $state(''); 78 + let timezone = $state(Intl.DateTimeFormat().resolvedOptions().timeZone); 76 79 let mode: EventMode = $state('inperson'); 77 80 let thumbnailFile: File | null = $state(null); 78 81 let thumbnailPreview: string | null = $state(null); ··· 92 95 let locationResult: { displayName: string; location: EventLocation } | null = $state(null); 93 96 94 97 let links: Array<{ uri: string; name: string }> = $state([]); 95 - let editingDates = $state(false); 96 98 let showLinkPopup = $state(false); 97 99 let newLinkUri = $state(''); 98 100 let newLinkName = $state(''); ··· 118 120 } 119 121 }); 120 122 123 + const pad = (n: number) => n.toString().padStart(2, '0'); 124 + 121 125 function isoToDatetimeLocal(iso: string): string { 122 126 const date = new Date(iso); 123 - const pad = (n: number) => n.toString().padStart(2, '0'); 124 127 return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`; 125 128 } 126 129 130 + function dateToDatetimeLocal(date: Date): string { 131 + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`; 132 + } 133 + 134 + /** 135 + * Convert a datetime-local string (e.g. "2026-04-09T15:00") to an ISO string 136 + * interpreting the date/time as being in the selected timezone. 137 + */ 138 + function datetimeLocalToISO(dt: string, tz: string): string { 139 + // Parse the datetime-local components 140 + const [datePart, timePart] = dt.split('T'); 141 + const [year, month, day] = datePart.split('-').map(Number); 142 + const [hour, minute] = timePart.split(':').map(Number); 143 + 144 + // Create a date and find the offset for the target timezone 145 + // by formatting a known date in that timezone and comparing 146 + const utcGuess = Date.UTC(year, month - 1, day, hour, minute); 147 + 148 + // Get what time it would be in the target timezone at our UTC guess 149 + const inTz = new Date(utcGuess).toLocaleString('en-US', { timeZone: tz }); 150 + const tzDate = new Date(inTz); 151 + 152 + // The difference tells us the timezone offset 153 + const offsetMs = tzDate.getTime() - utcGuess; 154 + 155 + // Subtract the offset to get the correct UTC time 156 + return new Date(utcGuess - offsetMs).toISOString(); 157 + } 158 + 127 159 function stripModePrefix(modeStr: string): EventMode { 128 160 const stripped = modeStr.replace('community.lexicon.calendar.event#', ''); 129 161 if (stripped === 'virtual' || stripped === 'hybrid' || stripped === 'inperson') return stripped; ··· 196 228 description = draft.description || ''; 197 229 startsAt = draft.startsAt || ''; 198 230 endsAt = draft.endsAt || ''; 231 + if (draft.timezone) timezone = draft.timezone; 199 232 links = draft.links || []; 200 233 mode = draft.mode || 'inperson'; 201 234 locationChanged = draft.locationChanged || false; ··· 227 260 populateFromEventData(); 228 261 } 229 262 draftLoaded = true; 230 - if (!startsAt) editingDates = true; 231 263 if (titleEditor) get(titleEditor)?.commands.focus(); 232 264 }); 233 265 ··· 242 274 description, 243 275 startsAt, 244 276 endsAt, 277 + timezone, 245 278 links, 246 279 mode, 247 280 thumbnailChanged, ··· 260 293 description, 261 294 startsAt, 262 295 endsAt, 296 + timezone, 263 297 mode, 264 298 JSON.stringify(links), 265 299 JSON.stringify(location) ··· 403 437 saveDraft(); 404 438 } 405 439 406 - function formatMonth(date: Date): string { 407 - return date.toLocaleDateString('en-US', { month: 'short' }).toUpperCase(); 408 - } 409 - 410 - function formatDay(date: Date): number { 411 - return date.getDate(); 412 - } 413 - 414 - function formatWeekday(date: Date): string { 415 - return date.toLocaleDateString('en-US', { weekday: 'long' }); 416 - } 417 - 418 - function formatFullDate(date: Date): string { 419 - const options: Intl.DateTimeFormatOptions = { month: 'long', day: 'numeric' }; 420 - if (date.getFullYear() !== new Date().getFullYear()) { 421 - options.year = 'numeric'; 440 + // Auto-set end date to 1 hour after start if empty 441 + $effect(() => { 442 + if (startsAt && !endsAt) { 443 + const s = new Date(startsAt); 444 + s.setHours(s.getHours() + 1); 445 + endsAt = isoToDatetimeLocal(s.toISOString()); 422 446 } 423 - return date.toLocaleDateString('en-US', options); 424 - } 425 - 426 - function formatTime(date: Date): string { 427 - return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }); 428 - } 429 - 430 - let startDate = $derived(startsAt ? new Date(startsAt) : null); 431 - let endDate = $derived(endsAt ? new Date(endsAt) : null); 432 - let isSameDay = $derived( 433 - startDate && 434 - endDate && 435 - startDate.getFullYear() === endDate.getFullYear() && 436 - startDate.getMonth() === endDate.getMonth() && 437 - startDate.getDate() === endDate.getDate() 438 - ); 447 + }); 439 448 440 449 // Auto-adjust end date if start moves past it 441 450 $effect(() => { ··· 500 509 } 501 510 if (!startsAt) { 502 511 error = 'Start date is required.'; 512 + return; 513 + } 514 + if (!endsAt) { 515 + error = 'End date is required.'; 503 516 return; 504 517 } 505 518 if (!user.isLoggedIn || !user.did) { ··· 558 571 name: name.trim(), 559 572 mode: `community.lexicon.calendar.event#${mode}`, 560 573 status: 'community.lexicon.calendar.event#scheduled', 561 - startsAt: new Date(startsAt).toISOString(), 574 + startsAt: datetimeLocalToISO(startsAt, timezone), 562 575 createdAt 563 576 }; 564 577 // Remove flattened fields that aren't part of the actual record ··· 582 595 } 583 596 } 584 597 if (endsAt) { 585 - record.endsAt = new Date(endsAt).toISOString(); 598 + record.endsAt = datetimeLocalToISO(endsAt, timezone); 586 599 } 587 600 if (media) { 588 601 record.media = media; ··· 659 672 } 660 673 } 661 674 662 - $inspect(name); 663 - 664 675 async function handleCreateRecurring() { 665 676 if (!name.trim() || !startsAt || !user.isLoggedIn || !user.did) return; 666 677 ··· 730 741 name: eventName, 731 742 mode: `community.lexicon.calendar.event#${mode}`, 732 743 status: 'community.lexicon.calendar.event#scheduled', 733 - startsAt: eventStart.toISOString(), 744 + startsAt: datetimeLocalToISO(dateToDatetimeLocal(eventStart), timezone), 734 745 createdAt: new Date().toISOString(), 735 746 recurringEventOf: parentUri 736 747 }; ··· 740 751 record.description = trimmedDescription; 741 752 } 742 753 if (eventEnd) { 743 - record.endsAt = eventEnd.toISOString(); 754 + record.endsAt = datetimeLocalToISO(dateToDatetimeLocal(eventEnd), timezone); 744 755 } 745 756 if (media) { 746 757 record.media = media; ··· 886 897 <Button 887 898 type="submit" 888 899 class="mt-3 w-full" 889 - disabled={submitting || !name.trim() || !startsAt} 900 + disabled={submitting || !name.trim() || !startsAt || !endsAt} 890 901 > 891 902 {submitting 892 903 ? isNew ··· 905 916 bind:editor={titleEditor} 906 917 placeholder="Event name" 907 918 onupdate={() => { 908 - if (titleEditor) name = get(titleEditor)?.getText() ?? ''; 919 + if (titleEditor) { 920 + const text = get(titleEditor)?.getText() ?? ''; 921 + if (text.includes('\n')) { 922 + const cleaned = text.replace(/\n/g, ' '); 923 + get(titleEditor)?.commands.setContent(cleaned); 924 + name = cleaned; 925 + } else { 926 + name = text; 927 + } 928 + } 909 929 }} 910 930 class="text-base-900 dark:text-base-50 placeholder:text-base-500 dark:placeholder:text-base-500 w-full text-3xl leading-tight font-bold focus:outline-none sm:text-4xl" 911 931 /> ··· 933 953 </div> 934 954 935 955 <!-- Date row --> 936 - <div class="mb-4 flex items-start gap-4"> 937 - <div 938 - class="border-base-200 dark:border-base-700 bg-base-100 dark:bg-base-950/30 flex size-12 shrink-0 flex-col items-center justify-center overflow-hidden rounded-xl border" 939 - > 940 - {#if startDate} 941 - <span 942 - class="text-base-500 dark:text-base-400 text-[9px] leading-none font-semibold" 943 - > 944 - {formatMonth(startDate)} 945 - </span> 946 - <span class="text-base-900 dark:text-base-50 text-lg leading-tight font-bold"> 947 - {formatDay(startDate)} 948 - </span> 949 - {:else} 950 - <svg 951 - xmlns="http://www.w3.org/2000/svg" 952 - fill="none" 953 - viewBox="0 0 24 24" 954 - stroke-width="1.5" 955 - stroke="currentColor" 956 - class="text-base-900 dark:text-base-200 size-5" 957 - > 958 - <path 959 - stroke-linecap="round" 960 - stroke-linejoin="round" 961 - d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 0 1 2.25-2.25h13.5A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5A2.25 2.25 0 0 1 21 11.25v7.5" 962 - /> 963 - </svg> 964 - {/if} 956 + <div class="mb-4 flex items-stretch gap-3"> 957 + <div class="flex flex-col gap-2"> 958 + <div class="flex items-center gap-2"> 959 + <span class="text-base-500 dark:text-base-400 w-9 text-sm">Start</span> 960 + <DateTimePicker bind:value={startsAt} required /> 961 + </div> 962 + <div class="flex items-center gap-2"> 963 + <span class="text-base-500 dark:text-base-400 w-9 text-sm">End</span> 964 + <DateTimePicker bind:value={endsAt} minValue={startsAt} referenceTime={startsAt} /> 965 + </div> 965 966 </div> 966 - <div class="flex-1"> 967 - {#if startDate && !editingDates} 968 - <!-- Display mode: show formatted date, click to edit --> 969 - <div class="flex items-start gap-2"> 970 - <button 971 - type="button" 972 - onclick={() => (editingDates = true)} 973 - class="cursor-pointer text-left" 974 - > 975 - <p class="text-base-900 dark:text-base-50 font-semibold"> 976 - {formatWeekday(startDate)}, {formatFullDate(startDate)} 977 - {#if endDate && !isSameDay} 978 - - {formatWeekday(endDate)}, {formatFullDate(endDate)} 979 - {/if} 980 - </p> 981 - <p class="text-base-500 dark:text-base-400 text-sm"> 982 - {formatTime(startDate)} 983 - {#if endDate && isSameDay} 984 - - {formatTime(endDate)} 985 - {/if} 986 - </p> 987 - </button> 988 - <Button variant="ghost" size="iconSm" onclick={() => (editingDates = true)}> 989 - <svg 990 - xmlns="http://www.w3.org/2000/svg" 991 - fill="none" 992 - viewBox="0 0 24 24" 993 - stroke-width="1.5" 994 - stroke="currentColor" 995 - class="size-3.5" 996 - > 997 - <path 998 - stroke-linecap="round" 999 - stroke-linejoin="round" 1000 - d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L6.832 19.82a4.5 4.5 0 0 1-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 0 1 1.13-1.897L16.863 4.487Zm0 0L19.5 7.125" 1001 - /> 1002 - </svg> 1003 - </Button> 1004 - </div> 1005 - {:else} 1006 - <!-- Edit mode: show pickers --> 1007 - <div class="flex flex-col gap-2"> 1008 - <div class="flex items-center gap-2"> 1009 - {#if endsAt} 1010 - <span class="text-base-500 dark:text-base-400 w-9 text-xs">Start</span> 1011 - {/if} 1012 - <DateTimePicker bind:value={startsAt} required /> 1013 - </div> 1014 - {#if endsAt} 1015 - <div class="flex items-center gap-2"> 1016 - <span class="text-base-500 dark:text-base-400 w-9 text-xs">End</span> 1017 - <DateTimePicker bind:value={endsAt} minValue={startsAt} /> 1018 - <Button variant="ghost" size="iconSm" onclick={() => (endsAt = '')}> 1019 - <svg 1020 - xmlns="http://www.w3.org/2000/svg" 1021 - fill="none" 1022 - viewBox="0 0 24 24" 1023 - stroke-width="1.5" 1024 - stroke="currentColor" 1025 - class="size-3.5" 1026 - > 1027 - <path 1028 - stroke-linecap="round" 1029 - stroke-linejoin="round" 1030 - d="M6 18 18 6M6 6l12 12" 1031 - /> 1032 - </svg> 1033 - </Button> 1034 - </div> 1035 - {:else} 1036 - <Button 1037 - variant="ghost" 1038 - size="sm" 1039 - class="w-fit" 1040 - onclick={() => { 1041 - if (startsAt) { 1042 - const d = new Date(startsAt); 1043 - d.setHours(d.getHours() + 1); 1044 - endsAt = isoToDatetimeLocal(d.toISOString()); 1045 - } else { 1046 - endsAt = ''; 1047 - } 1048 - }} 1049 - > 1050 - <svg 1051 - xmlns="http://www.w3.org/2000/svg" 1052 - fill="none" 1053 - viewBox="0 0 24 24" 1054 - stroke-width="1.5" 1055 - stroke="currentColor" 1056 - class="size-3.5" 1057 - > 1058 - <path 1059 - stroke-linecap="round" 1060 - stroke-linejoin="round" 1061 - d="M12 4.5v15m7.5-7.5h-15" 1062 - /> 1063 - </svg> 1064 - Add end date 1065 - </Button> 1066 - {/if} 1067 - {#if startDate} 1068 - <Button size="sm" onclick={() => (editingDates = false)} class="mt-1 w-fit"> 1069 - <svg 1070 - xmlns="http://www.w3.org/2000/svg" 1071 - fill="none" 1072 - viewBox="0 0 24 24" 1073 - stroke-width="2" 1074 - stroke="currentColor" 1075 - class="size-3.5" 1076 - > 1077 - <path 1078 - stroke-linecap="round" 1079 - stroke-linejoin="round" 1080 - d="m4.5 12.75 6 6 9-13.5" 1081 - /> 1082 - </svg> 1083 - Done 1084 - </Button> 1085 - {/if} 1086 - </div> 1087 - {/if} 967 + <div class="hidden sm:flex"> 968 + <TimezonePicker bind:value={timezone} /> 1088 969 </div> 1089 970 </div> 1090 971 ··· 1132 1013 </div> 1133 1014 {:else} 1134 1015 <div class="mb-6"> 1135 - <Button variant="ghost" onclick={() => (showLocationModal = true)}> 1016 + <Button variant="secondary" onclick={() => (showLocationModal = true)}> 1136 1017 <svg 1137 1018 xmlns="http://www.w3.org/2000/svg" 1138 1019 fill="none" ··· 1177 1058 <p class="mb-4 text-sm text-red-600 dark:text-red-400">{error}</p> 1178 1059 {/if} 1179 1060 1180 - <Button type="submit" disabled={submitting || !name.trim() || !startsAt}> 1061 + <Button type="submit" disabled={submitting || !name.trim() || !startsAt || !endsAt}> 1181 1062 {submitting 1182 1063 ? isNew 1183 1064 ? 'Publishing...' ··· 1190 1071 <Button 1191 1072 type="button" 1192 1073 variant="secondary" 1193 - disabled={submitting || !name.trim() || !startsAt} 1074 + disabled={submitting || !name.trim() || !startsAt || !endsAt} 1194 1075 onclick={() => { 1195 1076 recurringError = null; 1196 1077 recurringCreated = 0;
+129 -42
src/lib/components/TimePicker.svelte
··· 1 1 <script lang="ts"> 2 2 // @ts-nocheck 3 + import { Popover } from 'bits-ui'; 3 4 import { TimeField } from 'bits-ui'; 4 5 import { Time } from '@internationalized/date'; 5 - import { untrack } from 'svelte'; 6 + import { untrack, tick } from 'svelte'; 6 7 7 8 let { 8 9 value = $bindable(''), 9 10 required = false, 10 - locale = 'en' 11 + locale = 'en', 12 + referenceTime = '' 11 13 }: { 12 14 value: string; 13 15 required?: boolean; 14 16 locale?: string; 17 + referenceTime?: string; 15 18 } = $props(); 16 19 20 + let isOpen = $state(false); 21 + let listEl: HTMLDivElement | undefined = $state(undefined); 17 22 let internalValue: Time | undefined = $state(undefined); 18 23 19 24 function parseTimeStr(str: string): Time | undefined { ··· 54 59 value = formatTimeStr(newVal); 55 60 } 56 61 } 62 + 63 + // Generate 48 half-hour slots 64 + const slots = Array.from({ length: 48 }, (_, i) => { 65 + const h = Math.floor(i / 2); 66 + const m = i % 2 === 0 ? 0 : 30; 67 + const key = `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`; 68 + const date = new Date(2000, 0, 1, h, m); 69 + const label = date.toLocaleTimeString(locale, { hour: 'numeric', minute: '2-digit' }); 70 + return { key, label }; 71 + }); 72 + 73 + function durationLabel(slotKey: string): string { 74 + if (!referenceTime) return ''; 75 + const [rh, rm] = referenceTime.split(':').map(Number); 76 + const [sh, sm] = slotKey.split(':').map(Number); 77 + let diff = (sh * 60 + sm) - (rh * 60 + rm); 78 + if (diff <= 0) return ''; 79 + const hours = Math.floor(diff / 60); 80 + const mins = diff % 60; 81 + if (hours === 0) return `${mins}m`; 82 + if (mins === 0) return `${hours}h`; 83 + return `${hours}h ${mins}m`; 84 + } 85 + 86 + function selectSlot(key: string) { 87 + value = key; 88 + isOpen = false; 89 + } 90 + 91 + // Scroll to selected/closest slot when popover opens 92 + $effect(() => { 93 + if (isOpen && listEl) { 94 + tick().then(() => { 95 + if (!listEl) return; 96 + const selected = listEl.querySelector('[data-selected]'); 97 + if (selected) { 98 + selected.scrollIntoView({ block: 'center' }); 99 + } else if (value) { 100 + const [hStr, mStr] = value.split(':'); 101 + const totalMin = parseInt(hStr, 10) * 60 + parseInt(mStr, 10); 102 + const closestIdx = Math.min(Math.round(totalMin / 30), 47); 103 + const el = listEl.children[closestIdx]; 104 + if (el) el.scrollIntoView({ block: 'center' }); 105 + } 106 + }); 107 + } 108 + }); 57 109 </script> 58 110 59 - <TimeField.Root 60 - bind:value={internalValue} 61 - onValueChange={handleValueChange} 62 - granularity="minute" 63 - {locale} 64 - {required} 65 - > 66 - <div 67 - class="border-base-300 bg-base-100 text-base-900 focus-within:border-accent-500 dark:border-base-700 dark:bg-base-800 dark:text-base-100 dark:focus-within:border-accent-400 flex items-center rounded-xl border px-2.5 py-1.5 text-sm transition-colors" 111 + <div class="relative"> 112 + <TimeField.Root 113 + bind:value={internalValue} 114 + onValueChange={handleValueChange} 115 + granularity="minute" 116 + {locale} 117 + {required} 68 118 > 69 - <TimeField.Input> 70 - {#snippet children({ segments })} 71 - {#each segments as segment, i (segment.part + i)} 72 - {#if segment.part === 'literal'} 73 - <span class="text-base-400 dark:text-base-500">{segment.value}</span> 74 - {:else} 75 - <TimeField.Segment 76 - part={segment.part} 77 - class="hover:bg-base-200 focus:bg-base-200 dark:hover:bg-base-700 dark:focus:bg-base-700 rounded px-0.5 focus:outline-none" 78 - > 79 - {segment.value} 80 - </TimeField.Segment> 81 - {/if} 82 - {/each} 83 - {/snippet} 84 - </TimeField.Input> 119 + <!-- svelte-ignore a11y_click_events_have_key_events --> 120 + <!-- svelte-ignore a11y_no_static_element_interactions --> 121 + <div 122 + class="border-base-300 bg-base-100 text-base-900 focus-within:border-accent-500 dark:border-base-700 dark:bg-base-800 dark:text-base-100 dark:focus-within:border-accent-400 flex shrink-0 cursor-pointer items-center whitespace-nowrap rounded-xl border px-2.5 py-1.5 text-sm transition-colors" 123 + onfocusin={() => (isOpen = true)} 124 + > 125 + <TimeField.Input> 126 + {#snippet children({ segments })} 127 + {#each segments as segment, i (segment.part + i)} 128 + {#if segment.part === 'literal'} 129 + <span class="text-base-400 dark:text-base-500">{segment.value}</span> 130 + {:else} 131 + <TimeField.Segment 132 + part={segment.part} 133 + class="hover:bg-base-200 focus:bg-base-200 dark:hover:bg-base-700 dark:focus:bg-base-700 rounded px-0.5 focus:outline-none" 134 + > 135 + {segment.value} 136 + </TimeField.Segment> 137 + {/if} 138 + {/each} 139 + {/snippet} 140 + </TimeField.Input> 141 + 142 + <svg 143 + xmlns="http://www.w3.org/2000/svg" 144 + fill="none" 145 + viewBox="0 0 24 24" 146 + stroke-width="1.5" 147 + stroke="currentColor" 148 + class="text-base-400 dark:text-base-500 ml-auto size-4 pl-0.5" 149 + > 150 + <path 151 + stroke-linecap="round" 152 + stroke-linejoin="round" 153 + d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" 154 + /> 155 + </svg> 156 + </div> 157 + </TimeField.Root> 85 158 86 - <svg 87 - xmlns="http://www.w3.org/2000/svg" 88 - fill="none" 89 - viewBox="0 0 24 24" 90 - stroke-width="1.5" 91 - stroke="currentColor" 92 - class="text-base-400 dark:text-base-500 ml-auto size-4 pl-0.5" 159 + {#if isOpen} 160 + <!-- svelte-ignore a11y_no_static_element_interactions --> 161 + <div 162 + class="fixed inset-0 z-40" 163 + onclick={() => (isOpen = false)} 164 + onkeydown={(e) => { if (e.key === 'Escape') isOpen = false; }} 165 + ></div> 166 + <div 167 + bind:this={listEl} 168 + class="border-base-200 bg-base-50 dark:border-base-700 dark:bg-base-900 absolute left-0 z-50 mt-2 max-h-60 overflow-y-auto rounded-2xl border p-2 shadow-lg" 93 169 > 94 - <path 95 - stroke-linecap="round" 96 - stroke-linejoin="round" 97 - d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" 98 - /> 99 - </svg> 100 - </div> 101 - </TimeField.Root> 170 + {#each slots as slot (slot.key)} 171 + <button 172 + type="button" 173 + class="w-full rounded-lg px-4 py-1.5 text-left text-sm whitespace-nowrap transition-colors 174 + {value === slot.key 175 + ? 'bg-accent-100 dark:bg-accent-900 font-medium text-accent-900 dark:text-accent-100' 176 + : 'text-base-700 hover:bg-base-200 dark:text-base-300 dark:hover:bg-base-700'}" 177 + data-selected={value === slot.key ? '' : undefined} 178 + onclick={() => selectSlot(slot.key)} 179 + > 180 + {slot.label} 181 + {#if durationLabel(slot.key)} 182 + <span class="ml-2 opacity-50">{durationLabel(slot.key)}</span> 183 + {/if} 184 + </button> 185 + {/each} 186 + </div> 187 + {/if} 188 + </div>
+132
src/lib/components/TimezonePicker.svelte
··· 1 + <script lang="ts"> 2 + // @ts-nocheck 3 + import { tick } from 'svelte'; 4 + 5 + let { 6 + value = $bindable('') 7 + }: { 8 + value: string; 9 + } = $props(); 10 + 11 + let isOpen = $state(false); 12 + let search = $state(''); 13 + let listEl: HTMLDivElement | undefined = $state(undefined); 14 + let searchEl: HTMLInputElement | undefined = $state(undefined); 15 + 16 + // Get all IANA timezones 17 + const allTimezones = Intl.supportedValuesOf('timeZone'); 18 + 19 + function getOffset(tz: string): string { 20 + try { 21 + const fmt = new Intl.DateTimeFormat('en', { 22 + timeZone: tz, 23 + timeZoneName: 'shortOffset' 24 + }); 25 + const parts = fmt.formatToParts(new Date()); 26 + const tzPart = parts.find((p) => p.type === 'timeZoneName'); 27 + return tzPart?.value ?? ''; 28 + } catch { 29 + return ''; 30 + } 31 + } 32 + 33 + function getCityName(tz: string): string { 34 + const parts = tz.split('/'); 35 + return (parts[parts.length - 1] || tz).replace(/_/g, ' '); 36 + } 37 + 38 + let displayOffset = $derived(getOffset(value)); 39 + let displayCity = $derived(getCityName(value)); 40 + 41 + let filtered = $derived.by(() => { 42 + if (!search.trim()) return allTimezones; 43 + const q = search.toLowerCase(); 44 + return allTimezones.filter((tz) => { 45 + const city = getCityName(tz).toLowerCase(); 46 + const offset = getOffset(tz).toLowerCase(); 47 + return tz.toLowerCase().includes(q) || city.includes(q) || offset.includes(q); 48 + }); 49 + }); 50 + 51 + function selectTimezone(tz: string) { 52 + value = tz; 53 + isOpen = false; 54 + search = ''; 55 + } 56 + 57 + $effect(() => { 58 + if (isOpen) { 59 + tick().then(() => { 60 + searchEl?.focus(); 61 + if (listEl) { 62 + const selected = listEl.querySelector('[data-selected]'); 63 + if (selected) { 64 + selected.scrollIntoView({ block: 'center' }); 65 + } 66 + } 67 + }); 68 + } 69 + }); 70 + </script> 71 + 72 + <div class="relative"> 73 + <!-- svelte-ignore a11y_click_events_have_key_events --> 74 + <!-- svelte-ignore a11y_no_static_element_interactions --> 75 + <div 76 + class="border-base-300 bg-base-100 text-base-900 dark:border-base-700 dark:bg-base-800 dark:text-base-100 flex h-full shrink-0 cursor-pointer items-center gap-3 whitespace-nowrap rounded-xl border px-3 py-2 text-xs transition-colors" 77 + onclick={() => (isOpen = !isOpen)} 78 + > 79 + <svg 80 + xmlns="http://www.w3.org/2000/svg" 81 + fill="none" 82 + viewBox="0 0 24 24" 83 + stroke-width="1.5" 84 + stroke="currentColor" 85 + class="text-base-400 dark:text-base-500 size-4" 86 + > 87 + <path 88 + stroke-linecap="round" 89 + stroke-linejoin="round" 90 + d="M12 21a9.004 9.004 0 0 0 8.716-6.747M12 21a9.004 9.004 0 0 1-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 0 1 7.843 4.582M12 3a8.997 8.997 0 0 0-7.843 4.582m15.686 0A11.953 11.953 0 0 1 12 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0 1 21 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0 1 12 16.5a17.92 17.92 0 0 1-8.716-2.247m0 0A8.966 8.966 0 0 1 3 12c0-1.97.633-3.794 1.708-5.282" 91 + /> 92 + </svg> 93 + <div class="flex flex-col gap-0.5 leading-tight"> 94 + <span class="text-base-500 dark:text-base-400">{displayOffset}</span> 95 + <span>{displayCity}</span> 96 + </div> 97 + </div> 98 + 99 + {#if isOpen} 100 + <!-- svelte-ignore a11y_no_static_element_interactions --> 101 + <div class="fixed inset-0 z-40" onclick={() => { isOpen = false; search = ''; }} onkeydown={(e) => { if (e.key === 'Escape') { isOpen = false; search = ''; } }}></div> 102 + <div class="border-base-200 bg-base-50 dark:border-base-700 dark:bg-base-900 absolute right-0 z-50 mt-2 w-64 rounded-2xl border p-2 shadow-lg"> 103 + <input 104 + bind:this={searchEl} 105 + bind:value={search} 106 + type="text" 107 + placeholder="Search timezone..." 108 + class="border-base-300 bg-base-100 text-base-900 dark:border-base-700 dark:bg-base-800 dark:text-base-100 mb-2 w-full rounded-lg border px-3 py-1.5 text-sm outline-none focus:border-accent-500 dark:focus:border-accent-400" 109 + onkeydown={(e) => { if (e.key === 'Escape') { isOpen = false; search = ''; } }} 110 + /> 111 + <div bind:this={listEl} class="max-h-60 overflow-y-auto"> 112 + {#each filtered as tz (tz)} 113 + <button 114 + type="button" 115 + class="flex w-full items-center justify-between rounded-lg px-3 py-1.5 text-left text-sm transition-colors 116 + {value === tz 117 + ? 'bg-accent-100 dark:bg-accent-900 font-medium text-accent-900 dark:text-accent-100' 118 + : 'text-base-700 hover:bg-base-200 dark:text-base-300 dark:hover:bg-base-700'}" 119 + data-selected={value === tz ? '' : undefined} 120 + onclick={() => selectTimezone(tz)} 121 + > 122 + <span>{getCityName(tz)}</span> 123 + <span class="text-base-400 dark:text-base-500 text-xs">{getOffset(tz)}</span> 124 + </button> 125 + {/each} 126 + {#if filtered.length === 0} 127 + <p class="text-base-400 dark:text-base-500 px-3 py-2 text-sm">No results</p> 128 + {/if} 129 + </div> 130 + </div> 131 + {/if} 132 + </div>