starting to come together (:
Some checks failed
Deploy / deploy (ubuntu-latest, 2.44.0) (push) Failing after 1m17s
Some checks failed
Deploy / deploy (ubuntu-latest, 2.44.0) (push) Failing after 1m17s
This commit is contained in:
parent
c8eae3330a
commit
30f5e779de
@ -1 +1,3 @@
|
||||
NEXT_PUBLIC_API_ENDPOINT=https://api.pulseapp.cc/v1
|
||||
NEXT_PUBLIC_API_ENDPOINT=http://localhost:7500/v1
|
||||
NEXT_PUBLIC_CDN_ENDPOINT=https://cdn.pulseapp.cc
|
||||
NEXT_PUBLIC_CAPTCHA_SITE_KEY=CHANGE_ME
|
@ -1,6 +1,14 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
output: "standalone",
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "cdn.pulseapp.cc",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
@ -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",
|
||||
|
@ -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 => <UserProvider>{children}</UserProvider>;
|
||||
}>): ReactElement => (
|
||||
<UserProvider>
|
||||
<OrganizationProvider>
|
||||
<div className="min-h-screen flex">
|
||||
<Sidebar />
|
||||
{children}
|
||||
</div>
|
||||
</OrganizationProvider>
|
||||
</UserProvider>
|
||||
);
|
||||
export default DashboardLayout;
|
||||
|
@ -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";
|
||||
|
@ -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 (
|
||||
<main className="min-h-screen">
|
||||
PulseApp Dashboard, hello {user?.email}
|
||||
<main>
|
||||
PulseApp Dashboard, hello {user?.email}, selected org:{" "}
|
||||
{selectedOrganization}
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
@ -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 = ({
|
||||
}}
|
||||
>
|
||||
<CookiesProvider>
|
||||
{children}
|
||||
<TooltipProvider delayDuration={100}>
|
||||
{children}
|
||||
</TooltipProvider>
|
||||
<Toaster />
|
||||
</CookiesProvider>
|
||||
</div>
|
||||
|
84
src/app/provider/organization-provider.tsx
Normal file
84
src/app/provider/organization-provider.tsx
Normal file
@ -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<OrganizationStore>();
|
||||
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<Organization[]>({
|
||||
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 (
|
||||
<OrganizationContext.Provider value={storeRef.current}>
|
||||
{children}
|
||||
</OrganizationContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Use the organization context.
|
||||
*
|
||||
* @param selector the state selector to use
|
||||
* @return the value returned by the selector
|
||||
*/
|
||||
export function useOrganizationContext<T>(
|
||||
selector: (state: OrganizationState) => T
|
||||
): T {
|
||||
const store: StoreApi<OrganizationState> | null =
|
||||
useContext(OrganizationContext);
|
||||
if (!store) {
|
||||
throw new Error("Missing OrganizationContext.Provider in the tree");
|
||||
}
|
||||
return useStore(store, selector);
|
||||
}
|
||||
|
||||
export default OrganizationProvider;
|
@ -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";
|
||||
|
71
src/app/store/organization-store.ts
Normal file
71
src/app/store/organization-store.ts
Normal file
@ -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<OrganizationStore | null>(
|
||||
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<typeof createOrganizationStore>;
|
||||
|
||||
/**
|
||||
* Create a new user store.
|
||||
*/
|
||||
const createOrganizationStore = () => {
|
||||
const defaultProps: OrganizationStoreProps = {
|
||||
selected: undefined,
|
||||
organizations: [],
|
||||
};
|
||||
return createStore<OrganizationState>()((set) => ({
|
||||
...defaultProps,
|
||||
update: (selected: string | undefined, organizations: Organization[]) =>
|
||||
set(() => ({ selected, organizations })),
|
||||
setSelected: (selected: string | undefined) =>
|
||||
set(() => ({ selected })),
|
||||
}));
|
||||
};
|
||||
export default createOrganizationStore;
|
@ -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<UserStore | null>(null);
|
||||
|
||||
/**
|
||||
* The props in the store.
|
||||
* The props in this store.
|
||||
*/
|
||||
export type UserStoreProps = {
|
||||
/**
|
37
src/app/types/org/organization.ts
Normal file
37
src/app/types/org/organization.ts
Normal file
@ -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[];
|
||||
};
|
50
src/app/types/page/status-page.ts
Normal file
50
src/app/types/page/status-page.ts
Normal file
@ -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;
|
||||
};
|
21
src/app/types/sidebar-link.ts
Normal file
21
src/app/types/sidebar-link.ts
Normal file
@ -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;
|
||||
};
|
@ -15,5 +15,5 @@ export type Session = {
|
||||
/**
|
||||
* The unix time this session expires.
|
||||
*/
|
||||
expires: number;
|
||||
expires: bigint;
|
||||
};
|
||||
|
@ -2,7 +2,7 @@ export type User = {
|
||||
/**
|
||||
* The snowflake id of this user.
|
||||
*/
|
||||
snowflake: `${bigint}`;
|
||||
snowflake: bigint;
|
||||
|
||||
/**
|
||||
* This user's email.
|
||||
|
@ -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) => (
|
||||
<Link className={cn(brandingVariants({ size, className }))} href="/">
|
||||
const Branding = ({ href, size, className }: BrandingProps) => (
|
||||
<Link
|
||||
className={cn(brandingVariants({ size, className }))}
|
||||
href={href ?? "/"}
|
||||
>
|
||||
<Image src="/media/logo.png" alt="PulseApp Logo" fill />
|
||||
</Link>
|
||||
);
|
||||
|
@ -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";
|
||||
|
82
src/components/dashboard/sidebar/links.tsx
Normal file
82
src/components/dashboard/sidebar/links.tsx
Normal file
@ -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: <ClipboardIcon />,
|
||||
href: "/status-pages",
|
||||
},
|
||||
{
|
||||
name: "Automations",
|
||||
icon: <WrenchIcon />,
|
||||
href: "/automations",
|
||||
},
|
||||
{
|
||||
name: "Incidents",
|
||||
icon: <FireIcon />,
|
||||
href: "/incidents",
|
||||
},
|
||||
{
|
||||
name: "Insights",
|
||||
icon: <ChartBarSquareIcon />,
|
||||
href: "/insights",
|
||||
},
|
||||
{
|
||||
name: "Settings",
|
||||
icon: <Cog6ToothIcon />,
|
||||
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 (
|
||||
<div className="mt-3.5 w-full flex flex-col gap-0.5">
|
||||
{links.map((link: SidebarLink, index: number) => {
|
||||
const active: boolean = index === 0;
|
||||
return (
|
||||
<SimpleTooltip
|
||||
key={index}
|
||||
content={`Visit ${link.name}`}
|
||||
side="right"
|
||||
>
|
||||
<Link
|
||||
className={cn(
|
||||
"px-3 py-2 flex gap-2 items-center text-sm rounded-lg hover:bg-zinc-800 transition-all transform-gpu",
|
||||
active && "font-medium bg-zinc-800"
|
||||
)}
|
||||
href={`/dashboard/org/${selectedOrganization}${link.href}`}
|
||||
>
|
||||
<div className="relative w-5 h-5">{link.icon}</div>
|
||||
{link.name}
|
||||
</Link>
|
||||
</SimpleTooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default Links;
|
133
src/components/dashboard/sidebar/organization-selector.tsx
Normal file
133
src/components/dashboard/sidebar/organization-selector.tsx
Normal file
@ -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<boolean>(false);
|
||||
const [selected, setSelected] = useState<Organization | undefined>();
|
||||
|
||||
// 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 (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
className="w-52 bg-background/30 justify-between"
|
||||
aria-expanded={open}
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
>
|
||||
{selected ? (
|
||||
<div className="flex gap-2.5 items-center">
|
||||
<div className="relative p-0.5 w-5 h-5 rounded-full">
|
||||
{selected.logo ? (
|
||||
<Image
|
||||
className="rounded-full"
|
||||
src={`${process.env.NEXT_PUBLIC_CDN_ENDPOINT}/organizations/${selected.logo}.webp`}
|
||||
alt={`${selected.name}'s Logo`}
|
||||
fill
|
||||
/>
|
||||
) : (
|
||||
<InitialsAvatar name={selected.name} />
|
||||
)}
|
||||
</div>
|
||||
{selected.name}
|
||||
</div>
|
||||
) : (
|
||||
"Select organization..."
|
||||
)}
|
||||
<ChevronsUpDownIcon className="ml-2 w-4 h-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0 w-52">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search organization..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No organizations found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{organizations.map(
|
||||
(organization: Organization, index: number) => (
|
||||
<CommandItem
|
||||
key={index}
|
||||
value={organization.name}
|
||||
onSelect={(currentValue: string) =>
|
||||
selectOrganization(
|
||||
organizations.find(
|
||||
(organization) =>
|
||||
organization.name ===
|
||||
currentValue
|
||||
) as Organization
|
||||
)
|
||||
}
|
||||
>
|
||||
{selected?.snowflake ===
|
||||
selectedOrganization && (
|
||||
<CheckIcon className="mr-2 w-4 h-4" />
|
||||
)}
|
||||
{organization.name}
|
||||
</CommandItem>
|
||||
)
|
||||
)}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
export default OrganizationSelector;
|
26
src/components/dashboard/sidebar/sidebar.tsx
Normal file
26
src/components/dashboard/sidebar/sidebar.tsx
Normal file
@ -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 => (
|
||||
<nav className="w-56 px-3 py-4 h-screen flex flex-col items-center bg-zinc-900 border-r">
|
||||
{/* Header */}
|
||||
<Link className="flex gap-3 items-center group" href="/dashboard">
|
||||
<Branding size="xs" />
|
||||
<h1 className="text-xl font-bold group-hover:opacity-75 transition-all transform-gpu">
|
||||
Pulse App
|
||||
</h1>
|
||||
</Link>
|
||||
<Separator className="w-32 my-3.5" />
|
||||
|
||||
{/* Content */}
|
||||
<OrganizationSelector />
|
||||
<Links />
|
||||
<div className="mt-auto">USER</div>
|
||||
</nav>
|
||||
);
|
||||
|
||||
export default Sidebar;
|
47
src/components/simple-tooltip.tsx
Normal file
47
src/components/simple-tooltip.tsx
Normal file
@ -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 => (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{children}</TooltipTrigger>
|
||||
<TooltipContent className="bg-muted text-white" side={side}>
|
||||
{content}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
export default SimpleTooltip;
|
158
src/components/ui/command.tsx
Normal file
158
src/components/ui/command.tsx
Normal file
@ -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<typeof CommandPrimitive>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Command.displayName = CommandPrimitive.displayName;
|
||||
|
||||
interface CommandDialogProps extends DialogProps {}
|
||||
|
||||
const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogContent className="overflow-hidden p-0">
|
||||
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
const CommandInput = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Input>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
|
||||
<MagnifyingGlassIcon className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
<CommandPrimitive.Input
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
));
|
||||
|
||||
CommandInput.displayName = CommandPrimitive.Input.displayName;
|
||||
|
||||
const CommandList = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"max-h-[300px] overflow-y-auto overflow-x-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
CommandList.displayName = CommandPrimitive.List.displayName;
|
||||
|
||||
const CommandEmpty = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Empty>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
|
||||
>((props, ref) => (
|
||||
<CommandPrimitive.Empty
|
||||
ref={ref}
|
||||
className="py-6 text-center text-sm"
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
|
||||
|
||||
const CommandGroup = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Group>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Group
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
CommandGroup.displayName = CommandPrimitive.Group.displayName;
|
||||
|
||||
const CommandSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
|
||||
|
||||
const CommandItem = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
CommandItem.displayName = CommandPrimitive.Item.displayName;
|
||||
|
||||
const CommandShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
CommandShortcut.displayName = "CommandShortcut";
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
};
|
122
src/components/ui/dialog.tsx
Normal file
122
src/components/ui/dialog.tsx
Normal file
@ -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<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"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",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<Cross2Icon className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
));
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DialogHeader.displayName = "DialogHeader";
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DialogFooter.displayName = "DialogFooter";
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogTrigger,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
};
|
33
src/components/ui/popover.tsx
Normal file
33
src/components/ui/popover.tsx
Normal file
@ -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<typeof PopoverPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none 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-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
));
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
|
30
src/components/ui/tooltip.tsx
Normal file
30
src/components/ui/tooltip.tsx
Normal file
@ -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<typeof TooltipPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-105 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
@ -42,6 +42,18 @@ export const apiRequest = async <T>({
|
||||
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 <T>({
|
||||
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 };
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user