Openstatus www.openstatus.dev

chore: better toast, tracker fix and more (#238)

* chore: better toast, tracker fix and more

* fix: max-width on tracker

authored by

Maximilian Kaske and committed by
GitHub
6b6d087d 163662ad

+286 -133
+11 -4
apps/web/src/app/app/(dashboard)/[workspaceSlug]/incidents/_components/action-button.tsx
··· 30 30 DropdownMenuItem, 31 31 DropdownMenuTrigger, 32 32 } from "@/components/ui/dropdown-menu"; 33 + import { useToastAction } from "@/hooks/use-toast-action"; 33 34 import { api } from "@/trpc/client"; 34 35 35 36 const temporary = insertIncidentSchema.pick({ id: true, workspaceSlug: true }); ··· 38 39 39 40 export function ActionButton(props: Schema) { 40 41 const router = useRouter(); 42 + const { toast } = useToastAction(); 41 43 const [alertOpen, setAlertOpen] = React.useState(false); 42 44 const [isPending, startTransition] = React.useTransition(); 43 45 44 46 async function deleteIncident() { 45 47 startTransition(async () => { 46 - if (!props.id) return; 47 - await api.incident.deleteIncident.mutate({ id: props.id }); 48 - router.refresh(); 49 - setAlertOpen(false); 48 + try { 49 + if (!props.id) return; 50 + await api.incident.deleteIncident.mutate({ id: props.id }); 51 + toast("deleted"); 52 + router.refresh(); 53 + setAlertOpen(false); 54 + } catch { 55 + toast("error"); 56 + } 50 57 }); 51 58 } 52 59
+10 -3
apps/web/src/app/app/(dashboard)/[workspaceSlug]/incidents/_components/delete-incident-update.tsx
··· 17 17 AlertDialogTrigger, 18 18 } from "@/components/ui/alert-dialog"; 19 19 import { Button } from "@/components/ui/button"; 20 + import { useToastAction } from "@/hooks/use-toast-action"; 20 21 import { api } from "@/trpc/client"; 21 22 22 23 export function DeleteIncidentUpdateButtonIcon({ id }: { id: number }) { 23 24 const router = useRouter(); 25 + const { toast } = useToastAction(); 24 26 const [alertOpen, setAlertOpen] = React.useState(false); 25 27 const [isPending, startTransition] = React.useTransition(); 26 28 27 29 async function onDelete() { 28 30 startTransition(async () => { 29 - await api.incident.deleteIncidentUpdate.mutate({ id }); 30 - router.refresh(); 31 - setAlertOpen(false); 31 + try { 32 + await api.incident.deleteIncidentUpdate.mutate({ id }); 33 + toast("deleted"); 34 + router.refresh(); 35 + setAlertOpen(false); 36 + } catch { 37 + toast("error"); 38 + } 32 39 }); 33 40 } 34 41
+11 -4
apps/web/src/app/app/(dashboard)/[workspaceSlug]/monitors/_components/action-button.tsx
··· 27 27 DropdownMenuItem, 28 28 DropdownMenuTrigger, 29 29 } from "@/components/ui/dropdown-menu"; 30 + import { useToastAction } from "@/hooks/use-toast-action"; 30 31 import { api } from "@/trpc/client"; 31 32 32 33 type Schema = z.infer<typeof insertMonitorSchema>; 33 34 34 35 export function ActionButton(props: Schema & { workspaceSlug: string }) { 35 36 const router = useRouter(); 37 + const { toast } = useToastAction(); 36 38 const [alertOpen, setAlertOpen] = React.useState(false); 37 39 const [isPending, startTransition] = React.useTransition(); 38 40 39 41 async function onDelete() { 40 42 startTransition(async () => { 41 - if (!props.id) return; 42 - await api.monitor.deleteMonitor.mutate({ id: props.id }); 43 - router.refresh(); 44 - setAlertOpen(false); 43 + try { 44 + if (!props.id) return; 45 + await api.monitor.deleteMonitor.mutate({ id: props.id }); 46 + toast("deleted"); 47 + router.refresh(); 48 + setAlertOpen(false); 49 + } catch { 50 + toast("error"); 51 + } 45 52 }); 46 53 } 47 54
+11 -4
apps/web/src/app/app/(dashboard)/[workspaceSlug]/status-pages/_components/action-button.tsx
··· 27 27 DropdownMenuItem, 28 28 DropdownMenuTrigger, 29 29 } from "@/components/ui/dropdown-menu"; 30 + import { useToastAction } from "@/hooks/use-toast-action"; 30 31 import { api } from "@/trpc/client"; 31 32 32 33 type PageSchema = z.infer<typeof insertPageSchemaWithMonitors>; ··· 37 38 38 39 export function ActionButton({ page }: ActionButtonProps) { 39 40 const router = useRouter(); 41 + const { toast } = useToastAction(); 40 42 const [alertOpen, setAlertOpen] = React.useState(false); 41 43 const [isPending, startTransition] = React.useTransition(); 42 44 43 45 async function onDelete() { 44 46 startTransition(async () => { 45 - if (!page.id) return; 46 - await api.page.deletePage.mutate({ id: page.id }); 47 - router.refresh(); 48 - setAlertOpen(false); 47 + try { 48 + if (!page.id) return; 49 + await api.page.deletePage.mutate({ id: page.id }); 50 + toast("deleted"); 51 + router.refresh(); 52 + setAlertOpen(false); 53 + } catch { 54 + toast("error"); 55 + } 49 56 }); 50 57 } 51 58
+10 -8
apps/web/src/app/page.tsx
··· 50 50 <Button asChild variant="outline" className="rounded-full"> 51 51 <Link href="/play">Playground</Link> 52 52 </Button> 53 - {data && ( 54 - <Tracker 55 - data={data} 56 - id="openstatusPing" 57 - name="Ping" 58 - url="https://www.openstatus.dev/api/ping" 59 - /> 60 - )} 53 + <div className="mx-auto max-w-md"> 54 + {data && ( 55 + <Tracker 56 + data={data} 57 + id="openstatusPing" 58 + name="Ping" 59 + url="https://www.openstatus.dev/api/ping" 60 + /> 61 + )} 62 + </div> 61 63 </Shell> 62 64 <Cards /> 63 65 <Plans />
+10 -8
apps/web/src/app/play/page.tsx
··· 34 34 <p className="text-muted-foreground text-lg font-light"> 35 35 Build your own within seconds. 36 36 </p> 37 - {data && ( 38 - <Tracker 39 - data={data} 40 - id="openstatusPing" 41 - name="Ping" 42 - url="https://www.openstatus.dev/api/ping" 43 - /> 44 - )} 37 + <div className="mx-auto w-full max-w-md"> 38 + {data && ( 39 + <Tracker 40 + data={data} 41 + id="openstatusPing" 42 + name="Ping" 43 + url="https://www.openstatus.dev/api/ping" 44 + /> 45 + )} 46 + </div> 45 47 </div> 46 48 ); 47 49 }
+20 -15
apps/web/src/components/forms/advanced-monitor-form.tsx
··· 32 32 TooltipProvider, 33 33 TooltipTrigger, 34 34 } from "@/components/ui/tooltip"; 35 + import { useToastAction } from "@/hooks/use-toast-action"; 35 36 import { api } from "@/trpc/client"; 36 37 import { LoadingAnimation } from "../loading-animation"; 37 38 ··· 60 61 const form = useForm<AdvancedMonitorProps>({ 61 62 resolver: zodResolver(advancedSchema), 62 63 defaultValues: { 63 - headers: defaultValues?.headers ?? [{ key: "", value: "" }], 64 + headers: Boolean(defaultValues?.headers?.length) 65 + ? defaultValues?.headers 66 + : [{ key: "", value: "" }], 64 67 body: defaultValues?.body ?? "", 65 68 method: defaultValues?.method ?? "GET", 66 69 }, 67 70 }); 68 71 const router = useRouter(); 72 + const { toast } = useToastAction(); 69 73 const searchParams = useSearchParams(); 70 74 const monitorId = searchParams.get("id"); 71 - console.log(defaultValues); 72 75 const [isPending, startTransition] = React.useTransition(); 73 76 74 77 const { fields, append, remove } = useFieldArray({ ··· 76 79 control: form.control, 77 80 }); 78 81 79 - const onSubmit = ({ ...props }: AdvancedMonitorProps) => { 82 + const onSubmit = ({ headers, ...props }: AdvancedMonitorProps) => { 80 83 startTransition(async () => { 81 - console.log(props); 82 - if (!monitorId) return; 83 - if (validateJSON(props.body) === false) return; 84 - await api.monitor.updateMonitorAdvanced.mutate({ 85 - id: Number(monitorId), 86 - ...props, 87 - }); 88 - router.refresh(); 89 - // router.push("./"); // TODO: we need a better UX flow here. 84 + try { 85 + if (!monitorId) return; 86 + if (validateJSON(props.body) === false) return; 87 + await api.monitor.updateMonitorAdvanced.mutate({ 88 + id: Number(monitorId), 89 + headers: headers?.filter(({ key }) => key !== ""), // avoid saving empty key headers 90 + ...props, 91 + }); 92 + toast("saved"); 93 + router.refresh(); 94 + } catch (e) { 95 + toast("error"); 96 + } 90 97 }); 91 98 }; 92 99 ··· 113 120 } 114 121 }; 115 122 116 - console.log(form.formState.errors); 117 - 118 123 return ( 119 124 <Form {...form}> 120 125 <form ··· 122 127 className="grid w-full grid-cols-1 items-center gap-6 sm:grid-cols-6" 123 128 > 124 129 <div className="space-y-2 sm:col-span-full"> 125 - <FormLabel>Request Header</FormLabel> 126 130 {/* TODO: add FormDescription for latest key/value */} 131 + <FormLabel>Request Header</FormLabel> 127 132 {fields.map((field, index) => ( 128 133 <div key={field.id} className="grid grid-cols-6 gap-6"> 129 134 <FormField
+35 -28
apps/web/src/components/forms/custom-domain-form.tsx
··· 19 19 FormMessage, 20 20 } from "@/components/ui/form"; 21 21 import { useDomainStatus } from "@/hooks/use-domain-status"; 22 + import { useToastAction } from "@/hooks/use-toast-action"; 22 23 import { api } from "@/trpc/client"; 23 24 import DomainConfiguration from "../domains/domain-configuration"; 24 25 import DomainStatusIcon from "../domains/domain-status-icon"; ··· 39 40 }); 40 41 const router = useRouter(); 41 42 const [isPending, startTransition] = useTransition(); 43 + const { toast } = useToastAction(); 42 44 const domainStatus = useDomainStatus(defaultValues?.customDomain); 43 45 const { status } = domainStatus || {}; 44 46 45 47 async function onSubmit(data: Schema) { 46 48 startTransition(async () => { 47 - if (defaultValues.id) { 48 - await api.page.addCustomDomain.mutate({ 49 - customDomain: data.customDomain, 50 - pageId: defaultValues?.id, 51 - }); 49 + try { 50 + if (defaultValues.id) { 51 + await api.page.addCustomDomain.mutate({ 52 + customDomain: data.customDomain, 53 + pageId: defaultValues?.id, 54 + }); 55 + } 56 + if (data.customDomain && !defaultValues.customDomain) { 57 + await api.domain.addDomainToVercel.mutate({ 58 + domain: data.customDomain, 59 + }); 60 + // if changed, remove old domain and add new one 61 + } else if ( 62 + defaultValues.customDomain && 63 + data.customDomain !== defaultValues.customDomain 64 + ) { 65 + await api.domain.removeDomainFromVercelProject.mutate({ 66 + domain: defaultValues.customDomain, 67 + }); 68 + await api.domain.addDomainToVercel.mutate({ 69 + domain: data.customDomain, 70 + }); 71 + // if removed 72 + } else if (data.customDomain === "") { 73 + await api.domain.removeDomainFromVercelProject.mutate({ 74 + domain: defaultValues.customDomain, 75 + }); 76 + } 77 + toast("saved"); 78 + router.refresh(); 79 + } catch { 80 + toast("error"); 52 81 } 53 - if (data.customDomain && !defaultValues.customDomain) { 54 - await api.domain.addDomainToVercel.mutate({ 55 - domain: data.customDomain, 56 - }); 57 - // if changed, remove old domain and add new one 58 - } else if ( 59 - defaultValues.customDomain && 60 - data.customDomain !== defaultValues.customDomain 61 - ) { 62 - await api.domain.removeDomainFromVercelProject.mutate({ 63 - domain: defaultValues.customDomain, 64 - }); 65 - await api.domain.addDomainToVercel.mutate({ 66 - domain: data.customDomain, 67 - }); 68 - // if removed 69 - } else if (data.customDomain === "") { 70 - await api.domain.removeDomainFromVercelProject.mutate({ 71 - domain: defaultValues.customDomain, 72 - }); 73 - } 74 - router.refresh(); 75 82 }); 76 83 } 77 84 ··· 90 97 <FormControl> 91 98 <div className="flex items-center space-x-3"> 92 99 <InputWithAddons 93 - placeholder="acme.com" 100 + placeholder="status.documenso.com" 94 101 leading="https://" 95 102 {...field} 96 103 />
+4 -7
apps/web/src/components/forms/incident-form.tsx
··· 32 32 import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; 33 33 import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; 34 34 import { Textarea } from "@/components/ui/textarea"; 35 - import { useToast } from "@/components/ui/use-toast"; 36 35 import { statusDict } from "@/data/incidents-dictionary"; 36 + import { useToastAction } from "@/hooks/use-toast-action"; 37 37 import { api } from "@/trpc/client"; 38 38 39 39 // include update on creation ··· 71 71 }); 72 72 const router = useRouter(); 73 73 const [isPending, startTransition] = React.useTransition(); 74 - const { toast } = useToast(); 74 + const { toast } = useToastAction(); 75 75 76 76 const onSubmit = ({ ...props }: IncidentProps) => { 77 77 startTransition(async () => { ··· 97 97 }); 98 98 } 99 99 } 100 - router.push("./"); 100 + toast("saved"); 101 101 router.refresh(); 102 102 } catch { 103 - toast({ 104 - title: "Something went wrong.", 105 - description: "Please try again.", 106 - }); 103 + toast("error"); 107 104 } 108 105 }); 109 106 };
+4 -7
apps/web/src/components/forms/incident-update-form.tsx
··· 29 29 import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; 30 30 import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; 31 31 import { Textarea } from "@/components/ui/textarea"; 32 - import { useToast } from "@/components/ui/use-toast"; 33 32 import { statusDict } from "@/data/incidents-dictionary"; 33 + import { useToastAction } from "@/hooks/use-toast-action"; 34 34 import { api } from "@/trpc/client"; 35 35 36 36 // TODO: for UX, using the form inside of a Dialog feels more suitable ··· 61 61 }); 62 62 const router = useRouter(); 63 63 const [isPending, startTransition] = React.useTransition(); 64 - const { toast } = useToast(); 64 + const { toast } = useToastAction(); 65 65 66 66 const onSubmit = ({ ...props }: IncidentUpdateProps) => { 67 67 startTransition(async () => { ··· 71 71 } else { 72 72 await api.incident.createIncidentUpdate.mutate({ ...props }); 73 73 } 74 - router.push("../"); 74 + toast("saved"); 75 75 router.refresh(); 76 76 } catch { 77 - toast({ 78 - title: "Something went wrong.", 79 - description: "Please try again.", 80 - }); 77 + toast("error"); 81 78 } 82 79 }); 83 80 };
+16 -11
apps/web/src/components/forms/montitor-form.tsx
··· 45 45 } from "@/components/ui/select"; 46 46 import { Switch } from "@/components/ui/switch"; 47 47 import { regionsDict } from "@/data/regions-dictionary"; 48 + import { useToastAction } from "@/hooks/use-toast-action"; 48 49 import { cn } from "@/lib/utils"; 49 50 import { api } from "@/trpc/client"; 50 51 import { LoadingAnimation } from "../loading-animation"; 51 - import { useToast } from "../ui/use-toast"; 52 52 53 53 const cronJobs = [ 54 54 { value: "1m", label: "1 minute" }, ··· 85 85 }); 86 86 const router = useRouter(); 87 87 const [isPending, startTransition] = React.useTransition(); 88 - const { toast } = useToast(); 88 + const { toast } = useToastAction(); 89 89 90 90 const onSubmit = ({ ...props }: MonitorProps) => { 91 91 startTransition(async () => { ··· 94 94 if (defaultValues) { 95 95 await api.monitor.updateMonitor.mutate(props); 96 96 } else { 97 - await api.monitor.createMonitor.mutate({ 97 + const monitor = await api.monitor.createMonitor.mutate({ 98 98 data: props, 99 99 workspaceSlug, 100 100 }); 101 + router.replace(`./edit?id=${monitor?.id}`); // to stay on same page and enable 'Advanced' tab 101 102 } 102 - router.push("./"); 103 103 router.refresh(); 104 + toast("saved"); 104 105 } catch { 105 - toast({ 106 - title: "Something went wrong.", 107 - description: "If you are in the limits, please try again.", 108 - }); 106 + toast("error"); 109 107 } 110 108 }); 111 109 }; ··· 125 123 <FormItem className="sm:col-span-3"> 126 124 <FormLabel>Name</FormLabel> 127 125 <FormControl> 128 - <Input placeholder="" {...field} /> 126 + <Input placeholder="Documenso" {...field} /> 129 127 </FormControl> 130 128 <FormDescription> 131 129 The name of the monitor displayed on the status page. ··· 141 139 <FormItem className="sm:col-span-4"> 142 140 <FormLabel>URL</FormLabel> 143 141 <FormControl> 144 - <Input placeholder="" {...field} /> 142 + {/* Should we use `InputWithAddons here? */} 143 + <Input 144 + placeholder="https://documenso.com/api/health" 145 + {...field} 146 + /> 145 147 </FormControl> 146 148 <FormDescription> 147 149 Here is the URL you want to monitor.{" "} ··· 157 159 <FormItem className="sm:col-span-5"> 158 160 <FormLabel>Description</FormLabel> 159 161 <FormControl> 160 - <Input placeholder="" {...field} /> 162 + <Input 163 + placeholder="Determines the api health of our services." 164 + {...field} 165 + /> 161 166 </FormControl> 162 167 <FormDescription> 163 168 Provide your users with information about it.{" "}
+19 -17
apps/web/src/components/forms/status-page-form.tsx
··· 25 25 } from "@/components/ui/form"; 26 26 import { Input } from "@/components/ui/input"; 27 27 import { InputWithAddons } from "@/components/ui/input-with-addons"; 28 - import { useToast } from "@/components/ui/use-toast"; 29 28 import { useDebounce } from "@/hooks/use-debounce"; 29 + import { useToastAction } from "@/hooks/use-toast-action"; 30 30 import { slugify } from "@/lib/utils"; 31 31 import { api } from "@/trpc/client"; 32 32 import { LoadingAnimation } from "../loading-animation"; ··· 65 65 const [isPending, startTransition] = useTransition(); 66 66 const watchSlug = form.watch("slug"); 67 67 const debouncedSlug = useDebounce(watchSlug, 1000); // using debounce to not exhaust the server 68 - const { toast } = useToast(); 68 + const { toast } = useToastAction(); 69 69 const checkUniqueSlug = useCallback(async () => { 70 70 const isUnique = await api.page.getSlugUniqueness.query({ 71 71 slug: debouncedSlug, ··· 108 108 if (defaultValues) { 109 109 await api.page.updatePage.mutate(props); 110 110 } else { 111 - await api.page.createPage.mutate({ 111 + const page = await api.page.createPage.mutate({ 112 112 ...props, 113 113 workspaceSlug, 114 114 }); 115 + router.replace(`./edit?id=${page?.id}`); // to stay on same page and enable 'Advanced' tab 115 116 } 116 - router.push("./"); 117 - router.refresh(); // this will actually revalidate the page after submission 117 + toast("saved"); 118 + router.refresh(); 118 119 } catch { 119 - toast({ 120 - title: "Something went wrong.", 121 - description: "If you are in the limits, please try again.", 122 - }); 120 + toast("error"); 123 121 } 124 122 }); 125 123 }; ··· 148 146 const isUnique = await checkUniqueSlug(); 149 147 if (!isUnique) { 150 148 // the user will already have the "error" message - we include a toast as well 151 - toast({ 152 - title: "Slug is already taken.", 153 - description: "Please select another slug. Every slug is unique.", 154 - }); 149 + toast("unique-slug"); 155 150 } else { 156 151 if (onSubmit) { 157 152 void form.handleSubmit(onSubmit)(e); ··· 167 162 <FormItem className="sm:col-span-4"> 168 163 <FormLabel>Title</FormLabel> 169 164 <FormControl> 170 - <Input placeholder="" {...field} /> 165 + <Input placeholder="Documenso Status" {...field} /> 171 166 </FormControl> 172 167 <FormDescription>The title of your page.</FormDescription> 173 168 <FormMessage /> ··· 181 176 <FormItem className="sm:col-span-5"> 182 177 <FormLabel>Description</FormLabel> 183 178 <FormControl> 184 - <Input placeholder="" {...field} /> 179 + <Input 180 + placeholder="Stay informed about our api and website health." 181 + {...field} 182 + /> 185 183 </FormControl> 186 184 <FormDescription> 187 - Give your user some information about it. 185 + Provide your users informations about it. 188 186 </FormDescription> 189 187 <FormMessage /> 190 188 </FormItem> ··· 197 195 <FormItem className="sm:col-span-3"> 198 196 <FormLabel>Slug</FormLabel> 199 197 <FormControl> 200 - <InputWithAddons {...field} trailing={".openstatus.dev"} /> 198 + <InputWithAddons 199 + placeholder="documenso" 200 + trailing={".openstatus.dev"} 201 + {...field} 202 + /> 201 203 </FormControl> 202 204 <FormDescription> 203 205 The subdomain for your status page. At least 3 chars.
+8
apps/web/src/components/layout/app-header.tsx
··· 9 9 import { Button } from "../ui/button"; 10 10 import { Skeleton } from "../ui/skeleton"; 11 11 12 + /** 13 + * TODO: work on a better breadcrumb navigation like Vercel 14 + * [workspace/project/deploymenents/deployment] 15 + * This will allow us to 'only' save, and not redirect the user to the other pages 16 + * and therefore, can after saving the monitor/page go to the next tab! 17 + * Probably, we will need to use useSegements() from vercel, but once done properly, it could be really nice to share 18 + */ 19 + 12 20 export function AppHeader() { 13 21 const { isLoaded, isSignedIn } = useUser(); 14 22
+18
apps/web/src/components/layout/skeleton-tabs.tsx
··· 1 + import { Skeleton } from "@/components/ui/skeleton"; 2 + 3 + interface SkeletonTabsProps { 4 + children?: React.ReactNode; 5 + } 6 + 7 + export function SkeletonTabs({ children }: SkeletonTabsProps) { 8 + return ( 9 + <div className="w-full"> 10 + <div className="flex items-center border-b"> 11 + <Skeleton className="h-9 w-16 px-3 py-1.5" /> 12 + <Skeleton className="h-9 w-16 px-3 py-1.5" /> 13 + </div> 14 + {/* tbd: if children is empty, we could render a skeleton container */} 15 + <div className="mt-3">{children}</div> 16 + </div> 17 + ); 18 + }
-1
apps/web/src/components/status-page/monitor.tsx
··· 23 23 url={monitor.url} 24 24 description={monitor.description} 25 25 context="status-page" 26 - maxSize={40} 27 26 /> 28 27 ); 29 28 };
+14 -15
apps/web/src/components/tracker.tsx
··· 20 20 TooltipProvider, 21 21 TooltipTrigger, 22 22 } from "@/components/ui/tooltip"; 23 + import useWindowSize from "@/hooks/use-window-size"; 23 24 24 25 // What would be cool is tracker that turn from green to red depending on the number of errors 25 - const tracker = cva("h-10 w-1.5 sm:w-2 rounded-full md:w-2.5", { 26 + const tracker = cva("h-10 rounded-full flex-1", { 26 27 variants: { 27 28 variant: { 28 29 up: "bg-green-500 data-[state=open]:bg-green-600", ··· 42 43 id: string | number; 43 44 name: string; 44 45 description?: string; 45 - /** 46 - * Maximium length of the data array 47 - */ 48 - maxSize?: number; 49 46 context?: "play" | "status-page"; // TODO: we might need to extract those two different use cases - for now it's ok I'd say. 50 47 } 51 48 ··· 54 51 url, 55 52 id, 56 53 name, 57 - maxSize = 35, 58 54 context = "play", 59 55 description, 60 56 }: TrackerProps) { 57 + const { isMobile } = useWindowSize(); 58 + const maxSize = React.useMemo(() => (isMobile ? 35 : 45), [isMobile]); // TODO: it is better than how it is currently, but creates a small content shift on first render 61 59 const slicedData = data.slice(0, maxSize).reverse(); 62 60 const placeholderData: null[] = Array(maxSize).fill(null); 63 61 ··· 79 77 : ""; 80 78 81 79 return ( 82 - <div className="mx-auto max-w-max"> 83 - <div className="mb-1 flex justify-between text-sm sm:mb-2"> 80 + <div className="flex flex-col"> 81 + <div className="mb-2 flex justify-between text-sm"> 84 82 <div className="flex items-center gap-2"> 85 83 <p className="text-foreground font-semibold">{name}</p> 86 84 {description ? ( ··· 89 87 </div> 90 88 <p className="text-muted-foreground font-light">{uptime}</p> 91 89 </div> 92 - <div className="relative"> 93 - <div className="z-[-1] flex gap-0.5"> 94 - {placeholderData.map((_, i) => { 95 - return <div key={i} className={tracker({ variant: "empty" })} />; 96 - })} 97 - </div> 98 - <div className="absolute right-0 top-0 flex gap-0.5"> 90 + <div className="relative h-full w-full"> 91 + <div className="flex gap-0.5"> 92 + {Array(placeholderData.length - slicedData.length) 93 + .fill(null) 94 + .map((_, i) => { 95 + // TODO: use `Bar` component and `HoverCard` with empty state 96 + return <div key={i} className={tracker({ variant: "empty" })} />; 97 + })} 99 98 {slicedData.map((props) => { 100 99 return ( 101 100 <Bar key={props.cronTimestamp} context={context} {...props} />
+1 -1
apps/web/src/components/ui/use-toast.ts
··· 135 135 }); 136 136 } 137 137 138 - type Toast = Omit<ToasterToast, "id">; 138 + export type Toast = Omit<ToasterToast, "id">; 139 139 140 140 function toast({ ...props }: Toast) { 141 141 const id = genId();
+37
apps/web/src/hooks/use-toast-action.tsx
··· 1 + import { Button } from "@/components/ui/button"; 2 + import { useToast } from "@/components/ui/use-toast"; 3 + import type { Toast } from "@/components/ui/use-toast"; 4 + 5 + const config = { 6 + error: { 7 + title: "Something went wrong.", 8 + description: "Please try again.", 9 + variant: "destructive", 10 + action: ( 11 + <Button variant="outline" asChild className="text-foreground"> 12 + <a href="/discord" target="_blank" rel="noreferrer"> 13 + Discord 14 + </a> 15 + </Button> 16 + ), 17 + }, 18 + "unique-slug": { 19 + title: "Slug is already taken.", 20 + description: "Please select another slug. Every slug is unique.", 21 + }, 22 + success: { title: "Success" }, 23 + deleted: { title: "Deleted successfully." }, // TODO: we are not informing the user besides the visual changes when an entry has been deleted 24 + saved: { title: "Saved successfully." }, 25 + } as const satisfies Record<string, Toast>; 26 + 27 + type ToastAction = keyof typeof config; 28 + 29 + export function useToastAction() { 30 + const { toast: defaultToast } = useToast(); 31 + 32 + function toast(action: ToastAction) { 33 + return defaultToast(config[action]); 34 + } 35 + 36 + return { toast }; 37 + }
+43
apps/web/src/hooks/use-window-size.ts
··· 1 + // CREDITS: https://github.com/steven-tey/precedent/blob/main/lib/hooks/use-window-size.ts 2 + import { useEffect, useState } from "react"; 3 + 4 + export default function useWindowSize() { 5 + const [windowSize, setWindowSize] = useState<{ 6 + width: number | undefined; 7 + height: number | undefined; 8 + }>({ 9 + width: undefined, 10 + height: undefined, 11 + }); 12 + 13 + useEffect(() => { 14 + // Handler to call on window resize 15 + function handleResize() { 16 + // Set window width/height to state 17 + setWindowSize({ 18 + width: window.innerWidth, 19 + height: window.innerHeight, 20 + }); 21 + } 22 + 23 + // Add event listener 24 + window.addEventListener("resize", handleResize); 25 + 26 + // Call handler right away so state gets updated with initial window size 27 + handleResize(); 28 + 29 + // Remove event listener on cleanup 30 + return () => window.removeEventListener("resize", handleResize); 31 + }, []); // Empty array ensures that effect is only run on mount 32 + 33 + return { 34 + windowSize, 35 + isMobile: typeof windowSize?.width === "number" && windowSize?.width < 640, 36 + isTablet: 37 + typeof windowSize?.width === "number" && 38 + windowSize?.width >= 640 && 39 + windowSize?.width < 1024, 40 + isDesktop: 41 + typeof windowSize?.width === "number" && windowSize?.width >= 1024, 42 + }; 43 + }
+2
packages/api/src/router/monitor.ts
··· 70 70 .returning() 71 71 .get(); 72 72 73 + // TODO: check, do we have to await for the two calls? Will slow down user response for our analytics 73 74 await analytics.identify(result.user.id, { 74 75 userId: result.user.id, 75 76 }); ··· 78 79 url: newMonitor.url, 79 80 periodicity: newMonitor.periodicity, 80 81 }); 82 + return newMonitor; 81 83 }), 82 84 83 85 getMonitorByID: protectedProcedure
+2
packages/api/src/router/page.ts
··· 68 68 await opts.ctx.db.insert(monitorsToPages).values(values).run(); 69 69 } 70 70 71 + // TODO: check, do we have to await for the two calls? Will slow down user response for our analytics 71 72 await analytics.identify(data.user.id, { 72 73 userId: data.user.id, 73 74 }); ··· 75 76 event: "Page Created", 76 77 slug: newPage.slug, 77 78 }); 79 + return newPage; 78 80 }), 79 81 80 82 getPageByID: protectedProcedure