| 1 | import { useEffect, useId, useState } from "react"; |
| 2 | import { create } from "zustand"; |
| 3 | import { |
| 4 | localSnoozeDate, |
| 5 | localSnoozeTime, |
| 6 | resolveCustomSnooze, |
| 7 | type CustomSnoozeInput, |
| 8 | } from "@t3tools/client-runtime/state/thread-settled"; |
| 9 | import { Button } from "./ui/button"; |
| 10 | import { CalendarIcon } from "lucide-react"; |
| 11 | import { Calendar } from "./ui/calendar"; |
| 12 | import { weekStartsOn } from "../timestampFormat"; |
| 13 | import { Popover, PopoverTrigger, PopoverPopup } from "./ui/popover"; |
| 14 | import { Input } from "./ui/input"; |
| 15 | import { Label } from "./ui/label"; |
| 16 | import { Toggle, ToggleGroup } from "./ui/toggle-group"; |
| 17 | import { Select, SelectTrigger, SelectValue, SelectPopup, SelectItem } from "./ui/select"; |
| 18 | import { |
| 19 | NumberField, |
| 20 | NumberFieldGroup, |
| 21 | NumberFieldInput, |
| 22 | NumberFieldDecrement, |
| 23 | NumberFieldIncrement, |
| 24 | } from "./ui/number-field"; |
| 25 | import { |
| 26 | Dialog, |
| 27 | DialogPopup, |
| 28 | DialogHeader, |
| 29 | DialogTitle, |
| 30 | DialogDescription, |
| 31 | DialogPanel, |
| 32 | DialogFooter, |
| 33 | } from "./ui/dialog"; |
| 34 | |
| 35 | type SnoozeChoice = { readonly snoozedUntil: string }; |
| 36 | type Request = { readonly resolve: (choice: SnoozeChoice | null) => void }; |
| 37 | const useRequest = create<{ request: Request | null }>(() => ({ request: null })); |
| 38 | |
| 39 | export function requestCustomSnooze(): Promise<SnoozeChoice | null> { |
| 40 | useRequest.getState().request?.resolve(null); |
| 41 | return new Promise((resolve) => useRequest.setState({ request: { resolve } })); |
| 42 | } |
| 43 | |
| 44 | function finish(choice: SnoozeChoice | null) { |
| 45 | const request = useRequest.getState().request; |
| 46 | useRequest.setState({ request: null }); |
| 47 | request?.resolve(choice); |
| 48 | } |
| 49 | |
| 50 | export function CustomSnoozeDialogHost() { |
| 51 | const request = useRequest((state) => state.request); |
| 52 | useEffect(() => () => finish(null), []); |
| 53 | return request ? <CustomSnoozeDialog /> : null; |
| 54 | } |
| 55 | |
| 56 | function CustomSnoozeDialog() { |
| 57 | const id = useId(); |
| 58 | const [initial] = useState(() => new Date(Date.now() + 3_600_000)); |
| 59 | const [mode, setMode] = useState<CustomSnoozeInput["mode"]>("date"); |
| 60 | const [date, setDate] = useState(initial); |
| 61 | const [calendarOpen, setCalendarOpen] = useState(false); |
| 62 | const [time, setTime] = useState(localSnoozeTime(initial)); |
| 63 | const [amount, setAmount] = useState("2"); |
| 64 | const [unit, setUnit] = useState<"minutes" | "hours" | "days">("hours"); |
| 65 | const [error, setError] = useState<string | null>(null); |
| 66 | const input: CustomSnoozeInput = |
| 67 | mode === "date" ? { mode, date: localSnoozeDate(date), time } : { mode, amount, unit }; |
| 68 | return ( |
| 69 | <Dialog |
| 70 | open |
| 71 | onOpenChange={(open) => { |
| 72 | if (!open) finish(null); |
| 73 | }} |
| 74 | > |
| 75 | <DialogPopup className="sm:max-w-sm"> |
| 76 | <form |
| 77 | className="flex min-h-0 flex-col" |
| 78 | onSubmit={(event) => { |
| 79 | event.preventDefault(); |
| 80 | const snoozedUntil = resolveCustomSnooze(input, new Date()); |
| 81 | if (!snoozedUntil) { |
| 82 | setError( |
| 83 | mode === "date" |
| 84 | ? "Choose a valid date and time in the future." |
| 85 | : "Enter a positive duration.", |
| 86 | ); |
| 87 | return; |
| 88 | } |
| 89 | finish({ snoozedUntil }); |
| 90 | }} |
| 91 | > |
| 92 | <DialogHeader> |
| 93 | <DialogTitle>Custom snooze</DialogTitle> |
| 94 | <DialogDescription>Choose when snoozed threads return to your inbox.</DialogDescription> |
| 95 | </DialogHeader> |
| 96 | <DialogPanel> |
| 97 | <div className="flex flex-col gap-4"> |
| 98 | <ToggleGroup |
| 99 | aria-label="Schedule type" |
| 100 | className="w-full *:flex-1" |
| 101 | value={[mode]} |
| 102 | onValueChange={(next) => { |
| 103 | const value = next[0]; |
| 104 | if (value === "date" || value === "duration") setMode(value); |
| 105 | setError(null); |
| 106 | }} |
| 107 | > |
| 108 | <Toggle value="date">Date and time</Toggle> |
| 109 | <Toggle value="duration">Duration</Toggle> |
| 110 | </ToggleGroup> |
| 111 | <div className="flex flex-col gap-4"> |
| 112 | {mode === "date" ? ( |
| 113 | <div className="grid grid-cols-1 gap-3 sm:grid-cols-2"> |
| 114 | <div className="flex min-w-0 flex-col gap-1.5"> |
| 115 | <Label htmlFor={`${id}-date`}>Date</Label> |
| 116 | <Popover open={calendarOpen} onOpenChange={setCalendarOpen}> |
| 117 | <PopoverTrigger |
| 118 | render={ |
| 119 | <Button |
| 120 | id={`${id}-date`} |
| 121 | variant="outline" |
| 122 | className="w-full justify-between" |
| 123 | /> |
| 124 | } |
| 125 | > |
| 126 | {date.toLocaleDateString(undefined, { |
| 127 | month: "short", |
| 128 | day: "numeric", |
| 129 | year: "numeric", |
| 130 | })} |
| 131 | <CalendarIcon className="size-4 text-muted-foreground" /> |
| 132 | </PopoverTrigger> |
| 133 | <PopoverPopup align="start" aria-label="Choose snooze date"> |
| 134 | <Calendar |
| 135 | mode="single" |
| 136 | required |
| 137 | selected={date} |
| 138 | defaultMonth={date} |
| 139 | {...(weekStartsOn === undefined ? {} : { weekStartsOn })} |
| 140 | disabled={{ before: new Date(new Date().setHours(0, 0, 0, 0)) }} |
| 141 | onSelect={(selected) => { |
| 142 | setDate(selected); |
| 143 | setCalendarOpen(false); |
| 144 | setError(null); |
| 145 | }} |
| 146 | /> |
| 147 | </PopoverPopup> |
| 148 | </Popover> |
| 149 | </div> |
| 150 | <Label className="flex min-w-0 flex-col items-stretch" htmlFor={`${id}-time`}> |
| 151 | Time |
| 152 | <Input |
| 153 | nativeInput |
| 154 | id={`${id}-time`} |
| 155 | className="h-9 sm:h-8" |
| 156 | type="time" |
| 157 | required |
| 158 | value={time} |
| 159 | onChange={(event) => { |
| 160 | setTime(event.target.value); |
| 161 | setError(null); |
| 162 | }} |
| 163 | /> |
| 164 | </Label> |
| 165 | </div> |
| 166 | ) : ( |
| 167 | <div className="grid grid-cols-1 gap-3 sm:grid-cols-2"> |
| 168 | <NumberField |
| 169 | id={`${id}-amount`} |
| 170 | min={0} |
| 171 | step="any" |
| 172 | value={amount === "" ? null : Number(amount)} |
| 173 | onValueChange={(value) => { |
| 174 | setAmount(value === null ? "" : String(value)); |
| 175 | setError(null); |
| 176 | }} |
| 177 | > |
| 178 | <Label htmlFor={`${id}-amount`}>Snooze for</Label> |
| 179 | <NumberFieldGroup> |
| 180 | <NumberFieldDecrement aria-label="Decrease duration" /> |
| 181 | <NumberFieldInput required /> |
| 182 | <NumberFieldIncrement aria-label="Increase duration" /> |
| 183 | </NumberFieldGroup> |
| 184 | </NumberField> |
| 185 | <Label className="flex min-w-0 flex-col items-stretch" htmlFor={`${id}-unit`}> |
| 186 | Unit |
| 187 | <Select |
| 188 | value={unit} |
| 189 | items={{ minutes: "Minutes", hours: "Hours", days: "Days" }} |
| 190 | onValueChange={(value) => { |
| 191 | if (value === "minutes" || value === "hours" || value === "days") |
| 192 | setUnit(value); |
| 193 | setError(null); |
| 194 | }} |
| 195 | > |
| 196 | <SelectTrigger id={`${id}-unit`} className="min-w-0"> |
| 197 | <SelectValue /> |
| 198 | </SelectTrigger> |
| 199 | <SelectPopup> |
| 200 | <SelectItem value="minutes">Minutes</SelectItem> |
| 201 | <SelectItem value="hours">Hours</SelectItem> |
| 202 | <SelectItem value="days">Days</SelectItem> |
| 203 | </SelectPopup> |
| 204 | </Select> |
| 205 | </Label> |
| 206 | </div> |
| 207 | )} |
| 208 | </div> |
| 209 | </div> |
| 210 | {error && ( |
| 211 | <p role="alert" className="text-destructive"> |
| 212 | {error} |
| 213 | </p> |
| 214 | )} |
| 215 | </DialogPanel> |
| 216 | <DialogFooter> |
| 217 | <Button type="button" variant="outline" onClick={() => finish(null)}> |
| 218 | Cancel |
| 219 | </Button> |
| 220 | <Button type="submit">Snooze</Button> |
| 221 | </DialogFooter> |
| 222 | </form> |
| 223 | </DialogPopup> |
| 224 | </Dialog> |
| 225 | ); |
| 226 | } |