Openstatus www.openstatus.dev

feat: split create update delete actions (#63)

* feat: split create update delete actions

* fix: edit form

* fix: typos

* chore: fix sitemap, update sitemap, add discord redirect

* chore: split status page into layout and page

* fix: schema

authored by

Maximilian Kaske and committed by
GitHub
787ae818 22361f3c

+598 -197
+1
apps/web/package.json
··· 17 17 "@openstatus/tinybird": "workspace:*", 18 18 "@openstatus/upstash": "workspace:*", 19 19 "@radix-ui/react-accordion": "^1.1.2", 20 + "@radix-ui/react-alert-dialog": "^1.0.4", 20 21 "@radix-ui/react-dialog": "^1.0.4", 21 22 "@radix-ui/react-dropdown-menu": "^2.0.5", 22 23 "@radix-ui/react-hover-card": "^1.0.6",
+4 -11
apps/web/src/app/_components/submit-button.tsx
··· 1 1 "use client"; 2 2 3 - import { Button } from "@/components/ui/button"; 4 3 import { experimental_useFormStatus as useFormStatus } from "react-dom"; 4 + 5 + import { LoadingAnimation } from "@/components/loading-animation"; 6 + import { Button } from "@/components/ui/button"; 5 7 6 8 export function SubmitButton() { 7 9 const { pending } = useFormStatus(); ··· 11 13 disabled={pending} 12 14 className="w-20 disabled:opacity-100" 13 15 > 14 - {pending ? ( 15 - // TODO: move into separate file `LoadingAnimation` 16 - <div className="flex items-center justify-center gap-1"> 17 - <div className="direction-alternate bg-primary-foreground h-1 w-1 animate-pulse rounded-full duration-700" /> 18 - <div className="direction-alternate bg-primary-foreground h-1 w-1 animate-pulse rounded-full delay-150 duration-700" /> 19 - <div className="direction-alternate bg-primary-foreground h-1 w-1 animate-pulse rounded-full delay-300 duration-700" /> 20 - </div> 21 - ) : ( 22 - "Join" 23 - )} 16 + {pending ? <LoadingAnimation /> : "Join"} 24 17 </Button> 25 18 ); 26 19 }
+148
apps/web/src/app/app/(dashboard)/[workspaceId]/monitors/_components/action-button.tsx
··· 1 + "use client"; 2 + 3 + import * as React from "react"; 4 + import { useRouter } from "next/navigation"; 5 + import { MoreVertical } from "lucide-react"; 6 + import type * as z from "zod"; 7 + 8 + import type { insertMonitorSchema } from "@openstatus/db/src/schema"; 9 + 10 + import { MonitorForm } from "@/components/forms/montitor-form"; 11 + import { LoadingAnimation } from "@/components/loading-animation"; 12 + import { 13 + AlertDialog, 14 + AlertDialogAction, 15 + AlertDialogCancel, 16 + AlertDialogContent, 17 + AlertDialogDescription, 18 + AlertDialogFooter, 19 + AlertDialogHeader, 20 + AlertDialogTitle, 21 + AlertDialogTrigger, 22 + } from "@/components/ui/alert-dialog"; 23 + import { Button } from "@/components/ui/button"; 24 + import { 25 + Dialog, 26 + DialogContent, 27 + DialogDescription, 28 + DialogFooter, 29 + DialogHeader, 30 + DialogTitle, 31 + DialogTrigger, 32 + } from "@/components/ui/dialog"; 33 + import { 34 + DropdownMenu, 35 + DropdownMenuContent, 36 + DropdownMenuItem, 37 + DropdownMenuTrigger, 38 + } from "@/components/ui/dropdown-menu"; 39 + import { wait } from "@/lib/utils"; 40 + 41 + type Schema = z.infer<typeof insertMonitorSchema>; 42 + 43 + interface Props { 44 + // TODO: use type instead! 45 + workspaceId: number; 46 + url: string; 47 + name: string; 48 + description: string; 49 + } 50 + 51 + // TODO: add correct types 52 + export function ActionButton({ workspaceId, ...props }: Props) { 53 + const router = useRouter(); 54 + const [dialogOpen, setDialogOpen] = React.useState(false); 55 + const [alertOpen, setAlertOpen] = React.useState(false); 56 + const [saving, setSaving] = React.useState(false); 57 + 58 + async function onUpdate(values: Schema) { 59 + setSaving(true); 60 + await wait(1000); // TODO: update monitor 61 + router.refresh(); 62 + setSaving(false); 63 + setDialogOpen(false); 64 + } 65 + 66 + async function onDelete() { 67 + setSaving(true); 68 + await wait(1000); // TODO: delete monitor 69 + setSaving(false); 70 + setAlertOpen(false); 71 + } 72 + 73 + return ( 74 + <Dialog open={dialogOpen} onOpenChange={(value) => setDialogOpen(value)}> 75 + <AlertDialog 76 + open={alertOpen} 77 + onOpenChange={(value) => setAlertOpen(value)} 78 + > 79 + <DropdownMenu> 80 + <DropdownMenuTrigger asChild> 81 + <Button 82 + variant="ghost" 83 + className="absolute right-6 top-6 h-8 w-8 p-0" 84 + > 85 + <span className="sr-only">Open menu</span> 86 + <MoreVertical className="h-4 w-4" /> 87 + </Button> 88 + </DropdownMenuTrigger> 89 + <DropdownMenuContent align="end"> 90 + <DialogTrigger asChild> 91 + <DropdownMenuItem>Edit</DropdownMenuItem> 92 + </DialogTrigger> 93 + <AlertDialogTrigger asChild> 94 + <DropdownMenuItem className="text-destructive focus:bg-destructive focus:text-background"> 95 + Delete 96 + </DropdownMenuItem> 97 + </AlertDialogTrigger> 98 + </DropdownMenuContent> 99 + </DropdownMenu> 100 + <AlertDialogContent> 101 + <AlertDialogHeader> 102 + <AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle> 103 + <AlertDialogDescription> 104 + This action cannot be undone. This will permanently delete the 105 + monitor. 106 + </AlertDialogDescription> 107 + </AlertDialogHeader> 108 + <AlertDialogFooter> 109 + <AlertDialogCancel>Cancel</AlertDialogCancel> 110 + <AlertDialogAction 111 + onClick={(e) => { 112 + e.preventDefault(); 113 + onDelete(); 114 + }} 115 + disabled={saving} 116 + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" 117 + > 118 + {!saving ? "Delete" : <LoadingAnimation />} 119 + </AlertDialogAction> 120 + </AlertDialogFooter> 121 + </AlertDialogContent> 122 + </AlertDialog> 123 + <DialogContent> 124 + <DialogHeader> 125 + <DialogTitle>Update Monitor</DialogTitle> 126 + <DialogDescription>Change your settings.</DialogDescription> 127 + </DialogHeader> 128 + <MonitorForm 129 + id="monitor-update" 130 + onSubmit={onUpdate} 131 + defaultValues={props} 132 + /> 133 + <DialogFooter> 134 + <Button 135 + type="submit" 136 + form="monitor-update" 137 + disabled={saving} 138 + onSubmit={(e) => { 139 + e.preventDefault(); 140 + }} 141 + > 142 + {!saving ? "Confirm" : <LoadingAnimation />} 143 + </Button> 144 + </DialogFooter> 145 + </DialogContent> 146 + </Dialog> 147 + ); 148 + }
+60
apps/web/src/app/app/(dashboard)/[workspaceId]/monitors/_components/create-form.tsx
··· 1 + "use client"; 2 + 3 + import * as React from "react"; 4 + import { useRouter } from "next/navigation"; 5 + import type * as z from "zod"; 6 + 7 + import type { insertMonitorSchema } from "@openstatus/db/src/schema"; 8 + 9 + import { MonitorForm } from "@/components/forms/montitor-form"; 10 + import { LoadingAnimation } from "@/components/loading-animation"; 11 + import { Button } from "@/components/ui/button"; 12 + import { 13 + Dialog, 14 + DialogContent, 15 + DialogDescription, 16 + DialogFooter, 17 + DialogHeader, 18 + DialogTitle, 19 + DialogTrigger, 20 + } from "@/components/ui/dialog"; 21 + import { api } from "@/trpc/client"; 22 + 23 + interface Props { 24 + workspaceId: number; 25 + } 26 + 27 + export function CreateForm({ workspaceId }: Props) { 28 + const router = useRouter(); 29 + const [open, setOpen] = React.useState(false); 30 + const [saving, setSaving] = React.useState(false); 31 + 32 + async function onCreate(values: z.infer<typeof insertMonitorSchema>) { 33 + setSaving(true); 34 + // await api.monitor.getMonitorsByWorkspace.revalidate(); 35 + await api.monitor.createMonitor.mutate({ ...values, workspaceId }); 36 + router.refresh(); 37 + setSaving(false); 38 + setOpen(false); 39 + } 40 + 41 + return ( 42 + <Dialog open={open} onOpenChange={(value) => setOpen(value)}> 43 + <DialogTrigger asChild> 44 + <Button>Create</Button> 45 + </DialogTrigger> 46 + <DialogContent> 47 + <DialogHeader> 48 + <DialogTitle>Create Monitor</DialogTitle> 49 + <DialogDescription>Choose the settings.</DialogDescription> 50 + </DialogHeader> 51 + <MonitorForm id="monitor-create" onSubmit={onCreate} /> 52 + <DialogFooter> 53 + <Button type="submit" form="monitor-create" disabled={saving}> 54 + {!saving ? "Confirm" : <LoadingAnimation />} 55 + </Button> 56 + </DialogFooter> 57 + </DialogContent> 58 + </Dialog> 59 + ); 60 + }
+6 -1
apps/web/src/app/app/(dashboard)/[workspaceId]/monitors/loading.tsx
··· 1 1 import { Container } from "@/components/dashboard/container"; 2 2 import { Header } from "@/components/dashboard/header"; 3 + import { Skeleton } from "@/components/ui/skeleton"; 3 4 4 5 export default function Loading() { 5 6 return ( 6 7 <div className="grid gap-6 md:grid-cols-2 md:gap-8"> 7 - <Header.Skeleton /> 8 + <div className="col-span-full flex w-full justify-between"> 9 + <Header.Skeleton> 10 + <Skeleton className="h-9 w-20" /> 11 + </Header.Skeleton> 12 + </div> 8 13 <Container.Skeleton /> 9 14 <Container.Skeleton /> 10 15 <Container.Skeleton />
+9 -9
apps/web/src/app/app/(dashboard)/[workspaceId]/monitors/page.tsx
··· 2 2 3 3 import { Container } from "@/components/dashboard/container"; 4 4 import { Header } from "@/components/dashboard/header"; 5 - import { MonitorCreateForm } from "@/components/forms/montitor-form"; 6 5 import { api } from "@/trpc/server"; 6 + import { ActionButton } from "./_components/action-button"; 7 + import { CreateForm } from "./_components/create-form"; 7 8 8 9 export default async function MonitorPage({ 9 10 params, 10 11 }: { 11 12 params: { workspaceId: string }; 12 13 }) { 14 + const workspaceId = Number(params.workspaceId); 13 15 const monitors = await api.monitor.getMonitorsByWorkspace.query({ 14 - workspaceId: Number(params.workspaceId), 16 + workspaceId, 15 17 }); 16 - // iterate over monitors 18 + 17 19 return ( 18 20 <div className="grid gap-6 md:grid-cols-2 md:gap-8"> 19 21 <Header title="Monitors" description="Overview of all your monitors."> 20 - <MonitorCreateForm /> 22 + <CreateForm {...{ workspaceId }} /> 21 23 </Header> 22 24 {monitors.map((monitor, index) => ( 23 - <Container 24 - key={index} 25 - title={monitor.url} 26 - description={monitor.name} 27 - ></Container> 25 + <Container key={index} title={monitor.name} description={monitor.url}> 26 + <ActionButton {...{ ...monitor, workspaceId }} /> 27 + </Container> 28 28 ))} 29 29 </div> 30 30 );
+6 -1
apps/web/src/app/app/(dashboard)/[workspaceId]/status-pages/loading.tsx
··· 1 1 import { Container } from "@/components/dashboard/container"; 2 2 import { Header } from "@/components/dashboard/header"; 3 + import { Skeleton } from "@/components/ui/skeleton"; 3 4 4 5 export default function Loading() { 5 6 return ( 6 7 <div className="grid gap-6 md:grid-cols-2 md:gap-8"> 7 - <Header.Skeleton /> 8 + <div className="col-span-full flex w-full justify-between"> 9 + <Header.Skeleton> 10 + <Skeleton className="h-9 w-20" /> 11 + </Header.Skeleton> 12 + </div> 8 13 <Container.Skeleton /> 9 14 <Container.Skeleton /> 10 15 <Container.Skeleton />
+5
apps/web/src/app/discord/page.tsx
··· 1 + import { redirect } from "next/navigation"; 2 + 3 + export default function DiscordRedirect() { 4 + return redirect("https://discord.gg/eGXPrdGe"); 5 + }
+16 -4
apps/web/src/app/not-found.tsx
··· 1 1 import Link from "next/link"; 2 2 3 + import { Header } from "@/components/dashboard/header"; 4 + import { Shell } from "@/components/dashboard/shell"; 5 + import { Button } from "@/components/ui/button"; 6 + 3 7 export default function NotFound() { 4 8 return ( 5 9 <main className="flex min-h-screen w-full flex-col space-y-6 p-4 md:p-8"> 6 10 <div className="flex flex-1 flex-col items-center justify-center gap-8"> 7 11 <div className="mx-auto max-w-xl text-center"> 8 - <div className="border-border rounded-lg border p-8 backdrop-blur-[2px]"> 9 - <h2>Not Found 😭</h2> 10 - <p>This page could not be found</p> 11 - </div> 12 + <Shell> 13 + <div className="flex flex-col gap-4 p-12"> 14 + <Header 15 + title="404" 16 + description="Sorry, this page could not be found." 17 + /> 18 + {/* could think of redirecting the user to somewhere else! */} 19 + <Button variant="link" asChild> 20 + <Link href="/">Homepage</Link> 21 + </Button> 22 + </div> 23 + </Shell> 12 24 </div> 13 25 </div> 14 26 </main>
+13
apps/web/src/app/status-page/[domain]/layout.tsx
··· 1 + export default function StatusPageLayout({ 2 + children, 3 + }: { 4 + children: React.ReactNode; 5 + }) { 6 + return ( 7 + <main className="flex min-h-screen w-full flex-col space-y-6 p-4 md:p-8"> 8 + <div className="flex flex-1 flex-col items-center justify-center gap-8"> 9 + <div className="mx-auto max-w-xl text-center">{children}</div> 10 + </div> 11 + </main> 12 + ); 13 + }
+7 -14
apps/web/src/app/status-page/[domain]/page.tsx
··· 1 1 import { notFound } from "next/navigation"; 2 2 3 + import { Header } from "@/components/dashboard/header"; 4 + import { Shell } from "@/components/dashboard/shell"; 3 5 import { MonitorList } from "@/components/status-page/monitor-list"; 4 6 import { api } from "@/trpc/server"; 5 7 ··· 13 15 } 14 16 15 17 return ( 16 - <main className="flex min-h-screen w-full flex-col space-y-6 p-4 md:p-8"> 17 - <div className="flex flex-1 flex-col items-center justify-center gap-8"> 18 - <div className="mx-auto max-w-xl text-center"> 19 - <div className="border-border rounded-lg border p-8 backdrop-blur-[2px]"> 20 - <h1 className="text-foreground font-cal mb-6 mt-2 text-3xl"> 21 - {page.title} 22 - </h1> 23 - <div> 24 - <p className="text-muted-foreground">{page.description}</p> 25 - </div> 26 - </div> 27 - <MonitorList monitors={page.monitors} /> 28 - </div> 18 + <Shell> 19 + <div className="grid gap-4"> 20 + <Header title={page.title} description={page.description} /> 21 + <MonitorList monitors={page.monitors} /> 29 22 </div> 30 - </main> 23 + </Shell> 31 24 ); 32 25 }
+4 -2
apps/web/src/components/dashboard/container.tsx
··· 16 16 17 17 function Container({ title, description, className, children }: CardProps) { 18 18 return ( 19 - <Card className={cn("border-border/50 w-full shadow-none", className)}> 20 - <CardHeader> 19 + <Card 20 + className={cn("border-border/50 relative w-full shadow-none", className)} 21 + > 22 + <CardHeader className="mr-12"> 21 23 <CardTitle className="text-lg font-medium tracking-normal"> 22 24 {title} 23 25 </CardTitle>
+1 -1
apps/web/src/components/dashboard/header.tsx
··· 13 13 return ( 14 14 <div 15 15 className={cn( 16 - "col-span-full mr-12 flex w-full justify-between md:mr-0", 16 + "col-span-full mr-12 flex justify-between md:mr-0", 17 17 className, 18 18 )} 19 19 >
+107 -145
apps/web/src/components/forms/montitor-form.tsx
··· 1 1 "use client"; 2 2 3 3 import * as React from "react"; 4 - import { useParams, useRouter } from "next/navigation"; 5 4 import { zodResolver } from "@hookform/resolvers/zod"; 6 - import { Loader2 } from "lucide-react"; 7 5 import { useForm } from "react-hook-form"; 8 6 import type * as z from "zod"; 9 7 ··· 12 10 periodicityEnum, 13 11 } from "@openstatus/db/src/schema"; 14 12 15 - import { Button } from "@/components/ui/button"; 16 - import { 17 - Dialog, 18 - DialogContent, 19 - DialogDescription, 20 - DialogFooter, 21 - DialogHeader, 22 - DialogTitle, 23 - DialogTrigger, 24 - } from "@/components/ui/dialog"; 25 13 import { 26 14 Form, 27 15 FormControl, ··· 32 20 FormMessage, 33 21 } from "@/components/ui/form"; 34 22 import { Input } from "@/components/ui/input"; 35 - import { api } from "@/trpc/client"; 36 23 import { 37 24 Select, 38 25 SelectContent, ··· 41 28 SelectValue, 42 29 } from "../ui/select"; 43 30 44 - // EXAMPLE 45 - export function MonitorCreateForm() { 46 - const [saving, setSaving] = React.useState(false); 47 - const [open, setOpen] = React.useState(false); 48 - const params = useParams(); 49 - const router = useRouter(); 31 + type Schema = z.infer<typeof insertMonitorSchema>; 50 32 51 - const form = useForm<z.infer<typeof insertMonitorSchema>>({ 52 - resolver: zodResolver(insertMonitorSchema), 33 + interface Props { 34 + id: string; 35 + defaultValues?: Schema; 36 + onSubmit: (values: Schema) => Promise<void>; 37 + } 38 + 39 + export function MonitorForm({ id, defaultValues, onSubmit }: Props) { 40 + const form = useForm<Schema>({ 41 + resolver: zodResolver(insertMonitorSchema), // too much - we should only validate the values we ask inside of the form! 53 42 defaultValues: { 54 - name: "", 55 - url: "", 56 - description: "", 57 - workspaceId: Number(params.workspaceId), 43 + url: defaultValues?.url || "", 44 + name: defaultValues?.name || "", 45 + description: defaultValues?.description || "", 58 46 }, 59 47 }); 60 - 61 - // either like that or with a user action 62 - async function onSubmit(values: z.infer<typeof insertMonitorSchema>) { 63 - setSaving(true); 64 - // await api.monitor.getMonitorsByWorkspace.revalidate(); 65 - await api.monitor.createMonitor.mutate(values); 66 - router.refresh(); 67 - setOpen(false); 68 - setSaving(false); 69 - } 70 48 71 49 return ( 72 - <Dialog open={open} onOpenChange={(value) => setOpen(value)}> 73 - <DialogTrigger asChild> 74 - <Button>Create</Button> 75 - </DialogTrigger> 76 - <DialogContent> 77 - <DialogHeader> 78 - <DialogTitle>Create Monitor</DialogTitle> 79 - <DialogDescription>Create a monitor</DialogDescription> 80 - </DialogHeader> 81 - <Form {...form}> 82 - <form onSubmit={form.handleSubmit(onSubmit)} id="monitor"> 83 - <div className="grid w-full items-center space-y-6"> 84 - <FormField 85 - control={form.control} 86 - name="url" 87 - render={({ field }) => ( 88 - <FormItem> 89 - <FormLabel>URL</FormLabel> 90 - <FormControl> 91 - <Input placeholder="" {...field} /> 92 - </FormControl> 93 - <FormDescription> 94 - This is url you want to monitor. 95 - </FormDescription> 96 - <FormMessage /> 97 - </FormItem> 98 - )} 99 - /> 100 - <FormField 101 - control={form.control} 102 - name="name" 103 - render={({ field }) => ( 104 - <FormItem> 105 - <FormLabel>Name</FormLabel> 106 - <FormControl> 107 - <Input placeholder="" {...field} /> 108 - </FormControl> 109 - <FormDescription> 110 - The name of the monitor that will be displayed. 111 - </FormDescription> 112 - <FormMessage /> 113 - </FormItem> 114 - )} 115 - /> 116 - <FormField 117 - control={form.control} 118 - name="description" 119 - render={({ field }) => ( 120 - <FormItem> 121 - <FormLabel>Description</FormLabel> 122 - <FormControl> 123 - <Input placeholder="" {...field} /> 124 - </FormControl> 125 - <FormDescription> 126 - Give your user some information about it. 127 - </FormDescription> 128 - <FormMessage /> 129 - </FormItem> 130 - )} 131 - /> 132 - <FormField 133 - control={form.control} 134 - name="periodicity" 135 - render={({ field }) => ( 136 - <FormItem> 137 - <FormLabel>Email</FormLabel> 138 - <Select 139 - onValueChange={(value) => 140 - field.onChange(periodicityEnum.parse(value)) 141 - } 142 - defaultValue={field.value} 143 - > 144 - <FormControl> 145 - <SelectTrigger> 146 - <SelectValue placeholder="How often it should check" /> 147 - </SelectTrigger> 148 - </FormControl> 149 - <SelectContent> 150 - <SelectItem value="1m" disabled> 151 - 1 minute 152 - </SelectItem> 153 - <SelectItem value="5m" disabled> 154 - 5 minutes 155 - </SelectItem> 156 - <SelectItem value="10m">10 minutes</SelectItem> 157 - <SelectItem value="30m" disabled> 158 - 30 minutes 159 - </SelectItem> 160 - <SelectItem value="1h" disabled> 161 - 1 hour 162 - </SelectItem> 163 - </SelectContent> 164 - </Select> 165 - <FormDescription> 166 - You can manage email addresses in your{" "} 167 - </FormDescription> 168 - <FormMessage /> 169 - </FormItem> 170 - )} 171 - /> 172 - </div> 173 - </form> 174 - </Form> 175 - <DialogFooter> 176 - <Button type="submit" form="monitor" disabled={saving}> 177 - {!saving ? "Confirm" : <Loader2 className="h-4 w-4 animate-spin" />} 178 - </Button> 179 - </DialogFooter> 180 - </DialogContent> 181 - </Dialog> 50 + <Form {...form}> 51 + <form onSubmit={form.handleSubmit(onSubmit)} id={id}> 52 + <div className="grid w-full items-center space-y-6"> 53 + <FormField 54 + control={form.control} 55 + name="url" 56 + render={({ field }) => ( 57 + <FormItem> 58 + <FormLabel>URL</FormLabel> 59 + <FormControl> 60 + <Input placeholder="" {...field} /> 61 + </FormControl> 62 + <FormDescription> 63 + This is url you want to monitor. 64 + </FormDescription> 65 + <FormMessage /> 66 + </FormItem> 67 + )} 68 + /> 69 + <FormField 70 + control={form.control} 71 + name="name" 72 + render={({ field }) => ( 73 + <FormItem> 74 + <FormLabel>Name</FormLabel> 75 + <FormControl> 76 + <Input placeholder="" {...field} /> 77 + </FormControl> 78 + <FormDescription> 79 + The name of the monitor that will be displayed. 80 + </FormDescription> 81 + <FormMessage /> 82 + </FormItem> 83 + )} 84 + /> 85 + <FormField 86 + control={form.control} 87 + name="description" 88 + render={({ field }) => ( 89 + <FormItem> 90 + <FormLabel>Description</FormLabel> 91 + <FormControl> 92 + <Input placeholder="" {...field} /> 93 + </FormControl> 94 + <FormDescription> 95 + Give your user some information about it. 96 + </FormDescription> 97 + <FormMessage /> 98 + </FormItem> 99 + )} 100 + /> 101 + <FormField 102 + control={form.control} 103 + name="periodicity" 104 + render={({ field }) => ( 105 + <FormItem> 106 + <FormLabel>Email</FormLabel> 107 + <Select 108 + onValueChange={(value) => 109 + field.onChange(periodicityEnum.parse(value)) 110 + } 111 + defaultValue={field.value} 112 + > 113 + <FormControl> 114 + <SelectTrigger> 115 + <SelectValue placeholder="How often it should check" /> 116 + </SelectTrigger> 117 + </FormControl> 118 + <SelectContent> 119 + <SelectItem value="1m" disabled> 120 + 1 minute 121 + </SelectItem> 122 + <SelectItem value="5m" disabled> 123 + 5 minutes 124 + </SelectItem> 125 + <SelectItem value="10m">10 minutes</SelectItem> 126 + <SelectItem value="30m" disabled> 127 + 30 minutes 128 + </SelectItem> 129 + <SelectItem value="1h" disabled> 130 + 1 hour 131 + </SelectItem> 132 + </SelectContent> 133 + </Select> 134 + <FormDescription> 135 + You can manage email addresses in your{" "} 136 + </FormDescription> 137 + <FormMessage /> 138 + </FormItem> 139 + )} 140 + /> 141 + </div> 142 + </form> 143 + </Form> 182 144 ); 183 145 }
+14 -3
apps/web/src/components/layout/app-header.tsx
··· 1 + "use client"; 2 + 1 3 import Link from "next/link"; 2 - import { UserButton } from "@clerk/nextjs"; 4 + import { UserButton, useUser } from "@clerk/nextjs"; 5 + 6 + import { Skeleton } from "../ui/skeleton"; 3 7 4 8 export function AppHeader() { 9 + const { isLoaded, isSignedIn } = useUser(); 10 + 5 11 return ( 6 - <header className="z-10 flex w-full items-center justify-between"> 12 + // use `h-8` to avoid header layout shift on load 13 + <header className="z-10 flex h-8 w-full items-center justify-between"> 7 14 <Link 8 15 href="/" 9 16 className="font-cal text-muted-foreground hover:text-foreground text-lg" 10 17 > 11 18 openstatus 12 19 </Link> 13 - <UserButton /> 20 + {!isLoaded && !isSignedIn ? ( 21 + <Skeleton className="h-8 w-8 rounded-full" /> 22 + ) : ( 23 + <UserButton /> 24 + )} 14 25 </header> 15 26 ); 16 27 }
+3 -2
apps/web/src/components/layout/app-sidebar.tsx
··· 12 12 const params = useParams(); 13 13 return ( 14 14 <ul className="grid gap-1"> 15 - {pagesConfig.map(({ title, href, icon }) => { 15 + {pagesConfig.map(({ title, href, icon, disabled }) => { 16 16 const Icon = Icons[icon]; 17 - const link = `/app/${params.workspaceId}${href}`; // TODO: add 17 + const link = `/app/${params.workspaceId}${href}`; 18 18 return ( 19 19 <li key={title} className="w-full"> 20 20 <Link ··· 23 23 "hover:bg-muted/50 hover:text-foreground text-muted-foreground group flex w-full min-w-[200px] items-center rounded-md border border-transparent px-3 py-1", 24 24 pathname === link && 25 25 "bg-muted/50 border-border text-foreground", 26 + disabled && "pointer-events-none opacity-60", 26 27 )} 27 28 > 28 29 <Icon className={cn("mr-2 h-4 w-4")} />
+16
apps/web/src/components/loading-animation.tsx
··· 1 + import { cn } from "@/lib/utils"; 2 + 3 + type Props = React.HTMLAttributes<HTMLDivElement>; 4 + 5 + export function LoadingAnimation({ className, ...props }: Props) { 6 + return ( 7 + <div 8 + className={cn("flex items-center justify-center gap-1", className)} 9 + {...props} 10 + > 11 + <div className="direction-alternate bg-primary-foreground h-1 w-1 animate-pulse rounded-full duration-700" /> 12 + <div className="direction-alternate bg-primary-foreground h-1 w-1 animate-pulse rounded-full delay-150 duration-700" /> 13 + <div className="direction-alternate bg-primary-foreground h-1 w-1 animate-pulse rounded-full delay-300 duration-700" /> 14 + </div> 15 + ); 16 + }
+145
apps/web/src/components/ui/alert-dialog.tsx
··· 1 + "use client" 2 + 3 + import * as React from "react" 4 + import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" 5 + 6 + import { cn } from "@/lib/utils" 7 + import { buttonVariants } from "@/components/ui/button" 8 + 9 + const AlertDialog = AlertDialogPrimitive.Root 10 + 11 + const AlertDialogTrigger = AlertDialogPrimitive.Trigger 12 + 13 + const AlertDialogPortal = ({ 14 + className, 15 + ...props 16 + }: AlertDialogPrimitive.AlertDialogPortalProps) => ( 17 + <AlertDialogPrimitive.Portal className={cn(className)} {...props} /> 18 + ) 19 + AlertDialogPortal.displayName = AlertDialogPrimitive.Portal.displayName 20 + 21 + const AlertDialogOverlay = React.forwardRef< 22 + React.ElementRef<typeof AlertDialogPrimitive.Overlay>, 23 + React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay> 24 + >(({ className, children, ...props }, ref) => ( 25 + <AlertDialogPrimitive.Overlay 26 + className={cn( 27 + "fixed inset-0 z-50 bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", 28 + className 29 + )} 30 + {...props} 31 + ref={ref} 32 + /> 33 + )) 34 + AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName 35 + 36 + const AlertDialogContent = React.forwardRef< 37 + React.ElementRef<typeof AlertDialogPrimitive.Content>, 38 + React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content> 39 + >(({ className, ...props }, ref) => ( 40 + <AlertDialogPortal> 41 + <AlertDialogOverlay /> 42 + <AlertDialogPrimitive.Content 43 + ref={ref} 44 + className={cn( 45 + "fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg md:w-full", 46 + className 47 + )} 48 + {...props} 49 + /> 50 + </AlertDialogPortal> 51 + )) 52 + AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName 53 + 54 + const AlertDialogHeader = ({ 55 + className, 56 + ...props 57 + }: React.HTMLAttributes<HTMLDivElement>) => ( 58 + <div 59 + className={cn( 60 + "flex flex-col space-y-2 text-center sm:text-left", 61 + className 62 + )} 63 + {...props} 64 + /> 65 + ) 66 + AlertDialogHeader.displayName = "AlertDialogHeader" 67 + 68 + const AlertDialogFooter = ({ 69 + className, 70 + ...props 71 + }: React.HTMLAttributes<HTMLDivElement>) => ( 72 + <div 73 + className={cn( 74 + "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", 75 + className 76 + )} 77 + {...props} 78 + /> 79 + ) 80 + AlertDialogFooter.displayName = "AlertDialogFooter" 81 + 82 + const AlertDialogTitle = React.forwardRef< 83 + React.ElementRef<typeof AlertDialogPrimitive.Title>, 84 + React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title> 85 + >(({ className, ...props }, ref) => ( 86 + <AlertDialogPrimitive.Title 87 + ref={ref} 88 + className={cn("text-lg font-semibold", className)} 89 + {...props} 90 + /> 91 + )) 92 + AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName 93 + 94 + const AlertDialogDescription = React.forwardRef< 95 + React.ElementRef<typeof AlertDialogPrimitive.Description>, 96 + React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description> 97 + >(({ className, ...props }, ref) => ( 98 + <AlertDialogPrimitive.Description 99 + ref={ref} 100 + className={cn("text-sm text-muted-foreground", className)} 101 + {...props} 102 + /> 103 + )) 104 + AlertDialogDescription.displayName = 105 + AlertDialogPrimitive.Description.displayName 106 + 107 + const AlertDialogAction = React.forwardRef< 108 + React.ElementRef<typeof AlertDialogPrimitive.Action>, 109 + React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action> 110 + >(({ className, ...props }, ref) => ( 111 + <AlertDialogPrimitive.Action 112 + ref={ref} 113 + className={cn(buttonVariants(), className)} 114 + {...props} 115 + /> 116 + )) 117 + AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName 118 + 119 + const AlertDialogCancel = React.forwardRef< 120 + React.ElementRef<typeof AlertDialogPrimitive.Cancel>, 121 + React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel> 122 + >(({ className, ...props }, ref) => ( 123 + <AlertDialogPrimitive.Cancel 124 + ref={ref} 125 + className={cn( 126 + buttonVariants({ variant: "outline" }), 127 + "mt-2 sm:mt-0", 128 + className 129 + )} 130 + {...props} 131 + /> 132 + )) 133 + AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName 134 + 135 + export { 136 + AlertDialog, 137 + AlertDialogTrigger, 138 + AlertDialogContent, 139 + AlertDialogHeader, 140 + AlertDialogFooter, 141 + AlertDialogTitle, 142 + AlertDialogDescription, 143 + AlertDialogAction, 144 + AlertDialogCancel, 145 + }
+2 -2
apps/web/src/config/pages.ts
··· 8 8 disabled?: boolean; 9 9 }; 10 10 11 - export const pagesConfig = [ 11 + export const pagesConfig: Page[] = [ 12 12 { 13 13 title: "Dashboard", 14 14 description: "Get an overview of what's hot.", ··· 35 35 icon: "siren", 36 36 }, 37 37 // ... 38 - ] satisfies Page[]; 38 + ];
+2 -2
apps/web/src/sitemap.ts apps/web/src/app/sitemap.ts
··· 13 13 lastModified: new Date(), 14 14 }, 15 15 { 16 - url: addPathToBaseURL("/sign-in"), 16 + url: addPathToBaseURL("/app/sign-in"), 17 17 lastModified: new Date(), 18 18 }, 19 19 { 20 - url: addPathToBaseURL("/sign-up"), 20 + url: addPathToBaseURL("/app/sign-up"), 21 21 lastModified: new Date(), 22 22 }, 23 23 {
+29
pnpm-lock.yaml
··· 59 59 '@radix-ui/react-accordion': 60 60 specifier: ^1.1.2 61 61 version: 1.1.2(@types/react-dom@18.2.5)(@types/react@18.2.12)(react-dom@18.2.0)(react@18.2.0) 62 + '@radix-ui/react-alert-dialog': 63 + specifier: ^1.0.4 64 + version: 1.0.4(@types/react-dom@18.2.5)(@types/react@18.2.12)(react-dom@18.2.0)(react@18.2.0) 62 65 '@radix-ui/react-dialog': 63 66 specifier: ^1.0.4 64 67 version: 1.0.4(@types/react-dom@18.2.5)(@types/react@18.2.12)(react-dom@18.2.0)(react@18.2.0) ··· 2048 2051 '@radix-ui/react-id': 1.0.1(@types/react@18.2.12)(react@18.2.0) 2049 2052 '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.5)(@types/react@18.2.12)(react-dom@18.2.0)(react@18.2.0) 2050 2053 '@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.2.12)(react@18.2.0) 2054 + '@types/react': 18.2.12 2055 + '@types/react-dom': 18.2.5 2056 + react: 18.2.0 2057 + react-dom: 18.2.0(react@18.2.0) 2058 + dev: false 2059 + 2060 + /@radix-ui/react-alert-dialog@1.0.4(@types/react-dom@18.2.5)(@types/react@18.2.12)(react-dom@18.2.0)(react@18.2.0): 2061 + resolution: {integrity: sha512-jbfBCRlKYlhbitueOAv7z74PXYeIQmWpKwm3jllsdkw7fGWNkxqP3v0nY9WmOzcPqpQuoorNtvViBgL46n5gVg==} 2062 + peerDependencies: 2063 + '@types/react': '*' 2064 + '@types/react-dom': '*' 2065 + react: ^16.8 || ^17.0 || ^18.0 2066 + react-dom: ^16.8 || ^17.0 || ^18.0 2067 + peerDependenciesMeta: 2068 + '@types/react': 2069 + optional: true 2070 + '@types/react-dom': 2071 + optional: true 2072 + dependencies: 2073 + '@babel/runtime': 7.22.5 2074 + '@radix-ui/primitive': 1.0.1 2075 + '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.12)(react@18.2.0) 2076 + '@radix-ui/react-context': 1.0.1(@types/react@18.2.12)(react@18.2.0) 2077 + '@radix-ui/react-dialog': 1.0.4(@types/react-dom@18.2.5)(@types/react@18.2.12)(react-dom@18.2.0)(react@18.2.0) 2078 + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.5)(@types/react@18.2.12)(react-dom@18.2.0)(react@18.2.0) 2079 + '@radix-ui/react-slot': 1.0.2(@types/react@18.2.12)(react@18.2.0) 2051 2080 '@types/react': 18.2.12 2052 2081 '@types/react-dom': 18.2.5 2053 2082 react: 18.2.0