diff --git a/.env.example b/.env.example index 22ab8ed..9a4da8b 100644 --- a/.env.example +++ b/.env.example @@ -1 +1,3 @@ -NEXT_PUBLIC_API_ENDPOINT=https://api.pulseapp.cc/v1 \ No newline at end of file +NEXT_PUBLIC_API_ENDPOINT=http://localhost:7500/v1 +NEXT_PUBLIC_CDN_ENDPOINT=https://cdn.pulseapp.cc +NEXT_PUBLIC_CAPTCHA_SITE_KEY=CHANGE_ME \ No newline at end of file diff --git a/bun.lockb b/bun.lockb index 2574444..d6784b2 100644 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/next.config.mjs b/next.config.mjs index 608f1be..ffc3dcb 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -1,6 +1,14 @@ /** @type {import('next').NextConfig} */ const nextConfig = { output: "standalone", + images: { + remotePatterns: [ + { + protocol: "https", + hostname: "cdn.pulseapp.cc", + }, + ], + }, }; export default nextConfig; diff --git a/package.json b/package.json index ec66efd..86ee8d7 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "react": "^18", "react-dom": "^18", "react-hook-form": "^7.53.0", + "react-initials-avatar": "^1.1.2", "react-turnstile": "^1.1.3", "sharp": "^0.33.5", "sonner": "^1.5.0", diff --git a/src/app/(pages)/dashboard/layout.tsx b/src/app/(pages)/dashboard/layout.tsx index 2892eef..330bc52 100644 --- a/src/app/(pages)/dashboard/layout.tsx +++ b/src/app/(pages)/dashboard/layout.tsx @@ -1,5 +1,7 @@ import { ReactElement, ReactNode } from "react"; import UserProvider from "@/app/provider/user-provider"; +import Sidebar from "@/components/dashboard/sidebar/sidebar"; +import OrganizationProvider from "@/app/provider/organization-provider"; /** * The layout for the dashboard pages. @@ -11,5 +13,14 @@ const DashboardLayout = ({ children, }: Readonly<{ children: ReactNode; -}>): ReactElement => {children}; +}>): ReactElement => ( + + +
+ + {children} +
+
+
+); export default DashboardLayout; diff --git a/src/app/(pages)/dashboard/onboarding/page.tsx b/src/app/(pages)/dashboard/onboarding/page.tsx index b0dcdbf..51aa9a2 100644 --- a/src/app/(pages)/dashboard/onboarding/page.tsx +++ b/src/app/(pages)/dashboard/onboarding/page.tsx @@ -3,7 +3,7 @@ import { ReactElement } from "react"; import OnboardingForm from "@/components/dashboard/onboarding/onboarding-form"; import { useUserContext } from "@/app/provider/user-provider"; -import { UserState } from "@/app/store/user-store-props"; +import { UserState } from "@/app/store/user-store"; import { User } from "@/app/types/user/user"; import { hasFlag } from "@/lib/user"; import { UserFlag } from "@/app/types/user/user-flag"; diff --git a/src/app/(pages)/dashboard/page.tsx b/src/app/(pages)/dashboard/page.tsx index 4f203f6..dedd503 100644 --- a/src/app/(pages)/dashboard/page.tsx +++ b/src/app/(pages)/dashboard/page.tsx @@ -1,17 +1,23 @@ "use client"; import { ReactElement } from "react"; -import { UserState } from "@/app/store/user-store-props"; +import { UserState } from "@/app/store/user-store"; import { User } from "@/app/types/user/user"; import { useUserContext } from "@/app/provider/user-provider"; +import { useOrganizationContext } from "@/app/provider/organization-provider"; +import { OrganizationState } from "@/app/store/organization-store"; const DashboardPage = (): ReactElement => { const user: User | undefined = useUserContext( (state: UserState) => state.user ); + const selectedOrganization: bigint | undefined = useOrganizationContext( + (state: OrganizationState) => state.selected + ); return ( -
- PulseApp Dashboard, hello {user?.email} +
+ PulseApp Dashboard, hello {user?.email}, selected org:{" "} + {selectedOrganization}
); }; diff --git a/src/app/(pages)/layout.tsx b/src/app/(pages)/layout.tsx index f550700..4faaf8f 100644 --- a/src/app/(pages)/layout.tsx +++ b/src/app/(pages)/layout.tsx @@ -7,6 +7,7 @@ import { NextFont } from "next/dist/compiled/@next/font"; import { ThemeProvider } from "@/components/theme-provider"; import { CookiesProvider } from "next-client-cookies/server"; import { Toaster } from "@/components/ui/sonner"; +import { TooltipProvider } from "@/components/ui/tooltip"; const inter: NextFont = Inter({ subsets: ["latin"] }); @@ -55,7 +56,9 @@ const RootLayout = ({ }} > - {children} + + {children} + diff --git a/src/app/provider/organization-provider.tsx b/src/app/provider/organization-provider.tsx new file mode 100644 index 0000000..c6d5ea9 --- /dev/null +++ b/src/app/provider/organization-provider.tsx @@ -0,0 +1,84 @@ +"use client"; + +import { ReactNode, useCallback, useContext, useEffect, useRef } from "react"; +import { UserState } from "@/app/store/user-store"; +import { User } from "@/app/types/user/user"; +import { StoreApi, useStore } from "zustand"; +import createOrganizationStore, { + OrganizationContext, + OrganizationState, + OrganizationStore, +} from "@/app/store/organization-store"; +import { apiRequest } from "@/lib/api"; +import { Organization } from "@/app/types/org/organization"; +import { useUserContext } from "@/app/provider/user-provider"; +import { Session } from "@/app/types/user/session"; + +/** + * The provider that will provide organization context to children. + * + * @param children the children to provide context to + * @return the provider + */ +const OrganizationProvider = ({ children }: { children: ReactNode }) => { + const session: Session | undefined = useUserContext( + (state: UserState) => state.session + ); + const user: User | undefined = useUserContext( + (state: UserState) => state.user + ); + + const storeRef = useRef(); + if (!storeRef.current) { + storeRef.current = createOrganizationStore(); + } + + /** + * Fetch the organizations for the logged in user. + */ + const fetchOrganizations = useCallback(async () => { + let selectedOrganization: string | null = localStorage.getItem( + "selected-organization" + ); + const { data, error } = await apiRequest({ + endpoint: "/organization/@me", + session, + }); + const organizations: Organization[] = data as Organization[]; + if (!selectedOrganization && organizations.length > 0) { + selectedOrganization = organizations[0].snowflake; + } + storeRef.current + ?.getState() + .update(selectedOrganization || undefined, organizations); + }, [session]); + + useEffect(() => { + fetchOrganizations(); + }, [fetchOrganizations]); + + return ( + + {children} + + ); +}; + +/** + * Use the organization context. + * + * @param selector the state selector to use + * @return the value returned by the selector + */ +export function useOrganizationContext( + selector: (state: OrganizationState) => T +): T { + const store: StoreApi | null = + useContext(OrganizationContext); + if (!store) { + throw new Error("Missing OrganizationContext.Provider in the tree"); + } + return useStore(store, selector); +} + +export default OrganizationProvider; diff --git a/src/app/provider/user-provider.tsx b/src/app/provider/user-provider.tsx index d6c75f5..6bdd4e5 100644 --- a/src/app/provider/user-provider.tsx +++ b/src/app/provider/user-provider.tsx @@ -12,7 +12,7 @@ import createUserStore, { UserContext, UserState, UserStore, -} from "@/app/store/user-store-props"; +} from "@/app/store/user-store"; import { User } from "@/app/types/user/user"; import { Cookies, useCookies } from "next-client-cookies"; import { Session } from "@/app/types/user/session"; diff --git a/src/app/store/organization-store.ts b/src/app/store/organization-store.ts new file mode 100644 index 0000000..3443c13 --- /dev/null +++ b/src/app/store/organization-store.ts @@ -0,0 +1,71 @@ +import { createStore } from "zustand"; +import { createContext } from "react"; +import { Organization } from "@/app/types/org/organization"; + +/** + * The context to provide this store. + */ +export const OrganizationContext = createContext( + null +); + +/** + * The props in this store. + */ +export type OrganizationStoreProps = { + /** + * The currently selected organization. + */ + selected: string | undefined; + + /** + * The organization's the user has. + */ + organizations: Organization[]; +}; + +/** + * The organization store state. + */ +export type OrganizationState = OrganizationStoreProps & { + /** + * Update the state. + * + * @param selected the selected organization + * @param organizations the user's organizations + */ + update: ( + selected: string | undefined, + organizations: Organization[] + ) => void; + + /** + * Set the selected organization. + * + * @param selected the selected organization + */ + setSelected: (selected: string | undefined) => void; +}; + +/** + * The type representing the organization store. + */ +export type OrganizationStore = ReturnType; + +/** + * Create a new user store. + */ +const createOrganizationStore = () => { + const defaultProps: OrganizationStoreProps = { + selected: undefined, + organizations: [], + }; + return createStore()((set) => ({ + ...defaultProps, + update: (selected: string | undefined, organizations: Organization[]) => + set(() => ({ selected, organizations })), + setSelected: (selected: string | undefined) => + set(() => ({ selected })), + })); +}; +export default createOrganizationStore; diff --git a/src/app/store/user-store-props.ts b/src/app/store/user-store.ts similarity index 94% rename from src/app/store/user-store-props.ts rename to src/app/store/user-store.ts index 08d284f..d572534 100644 --- a/src/app/store/user-store-props.ts +++ b/src/app/store/user-store.ts @@ -3,10 +3,13 @@ import { User } from "@/app/types/user/user"; import { createContext } from "react"; import { Session } from "@/app/types/user/session"; +/** + * The context to provide this store. + */ export const UserContext = createContext(null); /** - * The props in the store. + * The props in this store. */ export type UserStoreProps = { /** diff --git a/src/app/types/org/organization.ts b/src/app/types/org/organization.ts new file mode 100644 index 0000000..d13b923 --- /dev/null +++ b/src/app/types/org/organization.ts @@ -0,0 +1,37 @@ +import { StatusPage } from "@/app/types/page/status-page"; + +/** + * An organization owned by a {@link User}. + */ +export type Organization = { + /** + * The snowflake id of this organization. + */ + snowflake: string; + + /** + * The name of this organization. + */ + name: string; + + /** + * The slug of this organization. + */ + slug: string; + + /** + * The hash to the logo of this organization, if any. + */ + logo: string; + + /** + * The snowflake of the {@link User} + * that owns this organization. + */ + ownerSnowflake: number; + + /** + * The status pages owned by this organization. + */ + statusPages: StatusPage[]; +}; diff --git a/src/app/types/page/status-page.ts b/src/app/types/page/status-page.ts new file mode 100644 index 0000000..5f07811 --- /dev/null +++ b/src/app/types/page/status-page.ts @@ -0,0 +1,50 @@ +/** + * A status page owned by an {@link Organization}. + */ +export type StatusPage = { + /** + * The snowflake id of this status page. + */ + snowflake: bigint; + + /** + * The name of this status page. + */ + name: string; + + /** + * The slug of this status page. + */ + slug: string; + + /** + * The description of this status page, if any. + */ + description: string | undefined; + + /** + * The hash to the logo of this status page, if any. + */ + logo: string | undefined; + + /** + * The hash to the banner of this status page, if any. + */ + banner: string | undefined; + + /** + * The theme of this status page. + */ + theme: "AUTO" | "DARK" | "LIGHT"; + + /** + * Whether this status page is visible in search engines. + */ + visibleInSearchEngines: boolean; + + /** + * The snowflake of the {@link Organization} + * that owns this status page. + */ + orgSnowflake: boolean; +}; diff --git a/src/app/types/sidebar-link.ts b/src/app/types/sidebar-link.ts new file mode 100644 index 0000000..1635be8 --- /dev/null +++ b/src/app/types/sidebar-link.ts @@ -0,0 +1,21 @@ +import { ReactElement } from "react"; + +/** + * A link on the dashboard sidebar. + */ +export type SidebarLink = { + /** + * The name of this link. + */ + name: string; + + /** + * The icon for this link. + */ + icon: ReactElement; + + /** + * The href for this link. + */ + href: string; +}; diff --git a/src/app/types/user/session.ts b/src/app/types/user/session.ts index 958b82d..8beb7fa 100644 --- a/src/app/types/user/session.ts +++ b/src/app/types/user/session.ts @@ -15,5 +15,5 @@ export type Session = { /** * The unix time this session expires. */ - expires: number; + expires: bigint; }; diff --git a/src/app/types/user/user.ts b/src/app/types/user/user.ts index a9d9a2d..301162b 100644 --- a/src/app/types/user/user.ts +++ b/src/app/types/user/user.ts @@ -2,7 +2,7 @@ export type User = { /** * The snowflake id of this user. */ - snowflake: `${bigint}`; + snowflake: bigint; /** * This user's email. diff --git a/src/components/branding.tsx b/src/components/branding.tsx index 7e02e68..7e777dc 100644 --- a/src/components/branding.tsx +++ b/src/components/branding.tsx @@ -4,10 +4,11 @@ import { cva } from "class-variance-authority"; import { cn } from "@/lib/utils"; const brandingVariants = cva( - "relative hover:opacity-75 select-none transition-all transform-gpu", + "relative group-hover:opacity-75 hover:opacity-75 select-none transition-all transform-gpu", { variants: { size: { + xs: "w-10 h-10", sm: "w-16 h-16", default: "w-24 h-24", lg: "w-32 h-32", @@ -23,10 +24,15 @@ const brandingVariants = cva( * The props for this component. */ type BrandingProps = { + /** + * The href to go to when clicked. + */ + href?: string; + /** * The size of the branding. */ - size?: "sm" | "default" | "lg"; + size?: "xs" | "sm" | "default" | "lg"; /** * The optional class name to apply to the branding. @@ -34,8 +40,11 @@ type BrandingProps = { className?: string; }; -const Branding = ({ size, className }: BrandingProps) => ( - +const Branding = ({ href, size, className }: BrandingProps) => ( + PulseApp Logo ); diff --git a/src/components/dashboard/onboarding/onboarding-form.tsx b/src/components/dashboard/onboarding/onboarding-form.tsx index 3d18ba9..d11def9 100644 --- a/src/components/dashboard/onboarding/onboarding-form.tsx +++ b/src/components/dashboard/onboarding/onboarding-form.tsx @@ -14,7 +14,7 @@ import { z } from "zod"; import { motion } from "framer-motion"; import { apiRequest } from "@/lib/api"; import { useUserContext } from "@/app/provider/user-provider"; -import { UserState } from "@/app/store/user-store-props"; +import { UserState } from "@/app/store/user-store"; import { Session } from "@/app/types/user/session"; import { AppRouterInstance } from "next/dist/shared/lib/app-router-context.shared-runtime"; import { useRouter } from "next/navigation"; diff --git a/src/components/dashboard/sidebar/links.tsx b/src/components/dashboard/sidebar/links.tsx new file mode 100644 index 0000000..e1c62f3 --- /dev/null +++ b/src/components/dashboard/sidebar/links.tsx @@ -0,0 +1,82 @@ +"use client"; + +import { ReactElement } from "react"; +import { SidebarLink } from "@/app/types/sidebar-link"; +import SimpleTooltip from "@/components/simple-tooltip"; +import Link from "next/link"; +import { cn } from "@/lib/utils"; +import { + ChartBarSquareIcon, + ClipboardIcon, + Cog6ToothIcon, + FireIcon, + WrenchIcon, +} from "@heroicons/react/24/outline"; +import { useOrganizationContext } from "@/app/provider/organization-provider"; +import { OrganizationState } from "@/app/store/organization-store"; + +const links: SidebarLink[] = [ + { + name: "Status Pages", + icon: , + href: "/status-pages", + }, + { + name: "Automations", + icon: , + href: "/automations", + }, + { + name: "Incidents", + icon: , + href: "/incidents", + }, + { + name: "Insights", + icon: , + href: "/insights", + }, + { + name: "Settings", + icon: , + href: "/settings", + }, +]; + +/** + * The links to display on + * the dashboard sidebar. + * + * @return the links jsx + */ +const Links = (): ReactElement => { + const selectedOrganization: string | undefined = useOrganizationContext( + (state: OrganizationState) => state.selected + ); + return ( +
+ {links.map((link: SidebarLink, index: number) => { + const active: boolean = index === 0; + return ( + + +
{link.icon}
+ {link.name} + +
+ ); + })} +
+ ); +}; +export default Links; diff --git a/src/components/dashboard/sidebar/organization-selector.tsx b/src/components/dashboard/sidebar/organization-selector.tsx new file mode 100644 index 0000000..adf7fc9 --- /dev/null +++ b/src/components/dashboard/sidebar/organization-selector.tsx @@ -0,0 +1,133 @@ +"use client"; + +import * as React from "react"; +import { ReactElement, useEffect, useState } from "react"; +import { ChevronsUpDownIcon } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import InitialsAvatar from "react-initials-avatar"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { useOrganizationContext } from "@/app/provider/organization-provider"; +import { OrganizationState } from "@/app/store/organization-store"; +import { Organization } from "@/app/types/org/organization"; +import { CheckIcon } from "@heroicons/react/24/outline"; +import Image from "next/image"; + +/** + * The organization selector. + * + * @return the selector jsx + */ +const OrganizationSelector = (): ReactElement => { + const selectedOrganization: string | undefined = useOrganizationContext( + (state: OrganizationState) => state.selected + ); + const setSelectedOrganization = useOrganizationContext( + (state) => state.setSelected + ); + const organizations: Organization[] = useOrganizationContext( + (state: OrganizationState) => state.organizations + ); + const [open, setOpen] = useState(false); + const [selected, setSelected] = useState(); + + // Set the selected organization + useEffect(() => { + setSelected( + organizations.find((organization: Organization) => { + return organization.snowflake === selectedOrganization; + }) + ); + }, [organizations, selectedOrganization]); + + /** + * Handle selecting an organization. + * + * @param organization the selected organization + */ + const selectOrganization = (organization: Organization) => { + setOpen(false); + setSelected(organization); + setSelectedOrganization(organization.snowflake); + localStorage.setItem("selected-organization", organization.snowflake); + }; + + return ( + + + + + + + + + No organizations found. + + {organizations.map( + (organization: Organization, index: number) => ( + + selectOrganization( + organizations.find( + (organization) => + organization.name === + currentValue + ) as Organization + ) + } + > + {selected?.snowflake === + selectedOrganization && ( + + )} + {organization.name} + + ) + )} + + + + + + ); +}; +export default OrganizationSelector; diff --git a/src/components/dashboard/sidebar/sidebar.tsx b/src/components/dashboard/sidebar/sidebar.tsx new file mode 100644 index 0000000..9a40409 --- /dev/null +++ b/src/components/dashboard/sidebar/sidebar.tsx @@ -0,0 +1,26 @@ +import { ReactElement } from "react"; +import Branding from "@/components/branding"; +import { Separator } from "@/components/ui/separator"; +import Link from "next/link"; +import OrganizationSelector from "@/components/dashboard/sidebar/organization-selector"; +import Links from "@/components/dashboard/sidebar/links"; + +const Sidebar = (): ReactElement => ( + +); + +export default Sidebar; diff --git a/src/components/simple-tooltip.tsx b/src/components/simple-tooltip.tsx new file mode 100644 index 0000000..1f33a10 --- /dev/null +++ b/src/components/simple-tooltip.tsx @@ -0,0 +1,47 @@ +import { ReactElement, ReactNode } from "react"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { SIDE_OPTIONS } from "@radix-ui/react-popper"; + +/** + * The props for a simple tooltip. + */ +type SimpleTooltipProps = { + /** + * The content to display in the tooltip. + */ + content: string | ReactElement; + + /** + * The side to display the tooltip on. + */ + side?: (typeof SIDE_OPTIONS)[number]; + + /** + * The children to render in this tooltip. + */ + children: ReactNode; +}; + +/** + * A simple tooltip, this is wrapping the + * shadcn tooltip to make it easier to use. + * + * @return the tooltip jsx + */ +const SimpleTooltip = ({ + content, + side, + children, +}: SimpleTooltipProps): ReactElement => ( + + {children} + + {content} + + +); +export default SimpleTooltip; diff --git a/src/components/ui/command.tsx b/src/components/ui/command.tsx new file mode 100644 index 0000000..dda4c34 --- /dev/null +++ b/src/components/ui/command.tsx @@ -0,0 +1,158 @@ +"use client"; + +import * as React from "react"; +import { type DialogProps } from "@radix-ui/react-dialog"; +import { MagnifyingGlassIcon } from "@radix-ui/react-icons"; +import { Command as CommandPrimitive } from "cmdk"; + +import { cn } from "@/lib/utils"; +import { Dialog, DialogContent } from "@/components/ui/dialog"; + +const Command = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +Command.displayName = CommandPrimitive.displayName; + +interface CommandDialogProps extends DialogProps {} + +const CommandDialog = ({ children, ...props }: CommandDialogProps) => { + return ( + + + + {children} + + + + ); +}; + +const CommandInput = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( +
+ + +
+)); + +CommandInput.displayName = CommandPrimitive.Input.displayName; + +const CommandList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); + +CommandList.displayName = CommandPrimitive.List.displayName; + +const CommandEmpty = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>((props, ref) => ( + +)); + +CommandEmpty.displayName = CommandPrimitive.Empty.displayName; + +const CommandGroup = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); + +CommandGroup.displayName = CommandPrimitive.Group.displayName; + +const CommandSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +CommandSeparator.displayName = CommandPrimitive.Separator.displayName; + +const CommandItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); + +CommandItem.displayName = CommandPrimitive.Item.displayName; + +const CommandShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ); +}; +CommandShortcut.displayName = "CommandShortcut"; + +export { + Command, + CommandDialog, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, + CommandShortcut, + CommandSeparator, +}; diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx new file mode 100644 index 0000000..a33c04d --- /dev/null +++ b/src/components/ui/dialog.tsx @@ -0,0 +1,122 @@ +"use client"; + +import * as React from "react"; +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { Cross2Icon } from "@radix-ui/react-icons"; + +import { cn } from "@/lib/utils"; + +const Dialog = DialogPrimitive.Root; + +const DialogTrigger = DialogPrimitive.Trigger; + +const DialogPortal = DialogPrimitive.Portal; + +const DialogClose = DialogPrimitive.Close; + +const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)); +DialogContent.displayName = DialogPrimitive.Content.displayName; + +const DialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +DialogHeader.displayName = "DialogHeader"; + +const DialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +DialogFooter.displayName = "DialogFooter"; + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogTitle.displayName = DialogPrimitive.Title.displayName; + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogDescription.displayName = DialogPrimitive.Description.displayName; + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogTrigger, + DialogClose, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +}; diff --git a/src/components/ui/popover.tsx b/src/components/ui/popover.tsx new file mode 100644 index 0000000..1c1d1c5 --- /dev/null +++ b/src/components/ui/popover.tsx @@ -0,0 +1,33 @@ +"use client"; + +import * as React from "react"; +import * as PopoverPrimitive from "@radix-ui/react-popover"; + +import { cn } from "@/lib/utils"; + +const Popover = PopoverPrimitive.Root; + +const PopoverTrigger = PopoverPrimitive.Trigger; + +const PopoverAnchor = PopoverPrimitive.Anchor; + +const PopoverContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( + + + +)); +PopoverContent.displayName = PopoverPrimitive.Content.displayName; + +export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }; diff --git a/src/components/ui/tooltip.tsx b/src/components/ui/tooltip.tsx new file mode 100644 index 0000000..9427e9c --- /dev/null +++ b/src/components/ui/tooltip.tsx @@ -0,0 +1,30 @@ +"use client"; + +import * as React from "react"; +import * as TooltipPrimitive from "@radix-ui/react-tooltip"; + +import { cn } from "@/lib/utils"; + +const TooltipProvider = TooltipPrimitive.Provider; + +const Tooltip = TooltipPrimitive.Root; + +const TooltipTrigger = TooltipPrimitive.Trigger; + +const TooltipContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + +)); +TooltipContent.displayName = TooltipPrimitive.Content.displayName; + +export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }; diff --git a/src/lib/api.ts b/src/lib/api.ts index 0102bf2..a811df8 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -42,6 +42,18 @@ export const apiRequest = async ({ data: T | undefined; error: ApiError | undefined; }> => { + // Build the request headers + let headers: HeadersInit = { + "Content-Type": `application/${method === "POST" ? "x-www-form-urlencoded" : "json"}`, + }; + if (session) { + headers = { + ...headers, + Authorization: `Bearer ${session.accessToken}`, + }; + } + + // Send the request const response: Response = await fetch( `${process.env.NEXT_PUBLIC_API_ENDPOINT}${endpoint}`, { @@ -50,17 +62,12 @@ export const apiRequest = async ({ method === "POST" && body ? new URLSearchParams(body) : undefined, - headers: { - "Content-Type": `application/${method === "POST" ? "x-www-form-urlencoded" : "json"}`, - ...(session - ? { - Authorization: `Bearer ${session.accessToken}`, - } - : {}), - }, + headers, } ); - const json: any = parseJson(await response.text()); // Parse the Json response from the API + + // Parse the Json response from the API + const json: any = parseJson(await response.text()); if (response.status !== 200) { return { data: undefined, error: json as ApiError }; }