feat: init
This commit is contained in:
6
apps/web/core/lib/b-progress/AppProgressBar.tsx
Normal file
6
apps/web/core/lib/b-progress/AppProgressBar.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { useRouter as useBProgressRouter } from "@bprogress/next";
|
||||
|
||||
export function useRouter() {
|
||||
const router = useBProgressRouter();
|
||||
return router;
|
||||
}
|
||||
1
apps/web/core/lib/b-progress/index.tsx
Normal file
1
apps/web/core/lib/b-progress/index.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./AppProgressBar";
|
||||
47
apps/web/core/lib/intercom-provider.tsx
Normal file
47
apps/web/core/lib/intercom-provider.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import React, { useEffect } from "react";
|
||||
import { Intercom, show, hide, onHide } from "@intercom/messenger-js-sdk";
|
||||
import { observer } from "mobx-react";
|
||||
// store hooks
|
||||
import { useInstance } from "@/hooks/store/use-instance";
|
||||
import { useTransient } from "@/hooks/store/use-transient";
|
||||
import { useUser } from "@/hooks/store/user";
|
||||
|
||||
export type IntercomProviderProps = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
const IntercomProvider: FC<IntercomProviderProps> = observer((props) => {
|
||||
const { children } = props;
|
||||
// hooks
|
||||
const { data: user } = useUser();
|
||||
const { config } = useInstance();
|
||||
const { isIntercomToggle, toggleIntercom } = useTransient();
|
||||
|
||||
useEffect(() => {
|
||||
if (isIntercomToggle) show();
|
||||
else hide();
|
||||
}, [isIntercomToggle]);
|
||||
|
||||
onHide(() => {
|
||||
toggleIntercom(false);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (user && config?.is_intercom_enabled && config.intercom_app_id) {
|
||||
Intercom({
|
||||
app_id: config.intercom_app_id || "",
|
||||
user_id: user.id,
|
||||
name: `${user.first_name} ${user.last_name}`,
|
||||
email: user.email,
|
||||
hide_default_launcher: true,
|
||||
});
|
||||
}
|
||||
}, [user, config, toggleIntercom]);
|
||||
|
||||
return <>{children}</>;
|
||||
});
|
||||
|
||||
export default IntercomProvider;
|
||||
27
apps/web/core/lib/local-storage.ts
Normal file
27
apps/web/core/lib/local-storage.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { isEmpty } from "lodash-es";
|
||||
|
||||
export const storage = {
|
||||
set: (key: string, value: object | string | boolean): void => {
|
||||
if (typeof window === undefined || typeof window === "undefined" || !key || !value) return undefined;
|
||||
const tempValue: string | undefined = value
|
||||
? ["string", "boolean"].includes(typeof value)
|
||||
? value.toString()
|
||||
: isEmpty(value)
|
||||
? undefined
|
||||
: JSON.stringify(value)
|
||||
: undefined;
|
||||
if (!tempValue) return undefined;
|
||||
window.localStorage.setItem(key, tempValue);
|
||||
},
|
||||
|
||||
get: (key: string): string | undefined => {
|
||||
if (typeof window === undefined || typeof window === "undefined") return undefined;
|
||||
const item = window.localStorage.getItem(key);
|
||||
return item ? item : undefined;
|
||||
},
|
||||
|
||||
remove: (key: string): void => {
|
||||
if (typeof window === undefined || typeof window === "undefined" || !key) return undefined;
|
||||
window.localStorage.removeItem(key);
|
||||
},
|
||||
};
|
||||
1
apps/web/core/lib/polyfills/index.ts
Normal file
1
apps/web/core/lib/polyfills/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./requestIdleCallback";
|
||||
24
apps/web/core/lib/polyfills/requestIdleCallback.ts
Normal file
24
apps/web/core/lib/polyfills/requestIdleCallback.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
if (typeof window !== "undefined" && window) {
|
||||
// Add request callback polyfill to browser in case it does not exist
|
||||
window.requestIdleCallback =
|
||||
window.requestIdleCallback ??
|
||||
function (cb) {
|
||||
const start = Date.now();
|
||||
return setTimeout(function () {
|
||||
cb({
|
||||
didTimeout: false,
|
||||
timeRemaining: function () {
|
||||
return Math.max(0, 50 - (Date.now() - start));
|
||||
},
|
||||
});
|
||||
}, 1);
|
||||
};
|
||||
|
||||
window.cancelIdleCallback =
|
||||
window.cancelIdleCallback ??
|
||||
function (id) {
|
||||
clearTimeout(id);
|
||||
};
|
||||
}
|
||||
|
||||
export {};
|
||||
110
apps/web/core/lib/posthog-provider.tsx
Normal file
110
apps/web/core/lib/posthog-provider.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
|
||||
import type { FC, ReactNode } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useParams } from "next/navigation";
|
||||
import posthog from "posthog-js";
|
||||
import { PostHogProvider as PHProvider } from "posthog-js/react";
|
||||
// constants
|
||||
import { GROUP_WORKSPACE_TRACKER_EVENT } from "@plane/constants";
|
||||
// helpers
|
||||
import { getUserRole } from "@plane/utils";
|
||||
// hooks
|
||||
import { captureClick, joinEventGroup } from "@/helpers/event-tracker.helper";
|
||||
import { useInstance } from "@/hooks/store/use-instance";
|
||||
import { useWorkspace } from "@/hooks/store/use-workspace";
|
||||
import { useUser, useUserPermissions } from "@/hooks/store/user";
|
||||
// dynamic imports
|
||||
const PostHogPageView = dynamic(() => import("@/lib/posthog-view"), { ssr: false });
|
||||
|
||||
export interface IPosthogWrapper {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const PostHogProvider: FC<IPosthogWrapper> = observer((props) => {
|
||||
const { children } = props;
|
||||
const { data: user } = useUser();
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const { instance } = useInstance();
|
||||
const { workspaceSlug, projectId } = useParams();
|
||||
const { getWorkspaceRoleByWorkspaceSlug, getProjectRoleByWorkspaceSlugAndProjectId } = useUserPermissions();
|
||||
|
||||
const currentProjectRole = getProjectRoleByWorkspaceSlugAndProjectId(
|
||||
workspaceSlug?.toString(),
|
||||
projectId?.toString()
|
||||
);
|
||||
const currentWorkspaceRole = getWorkspaceRoleByWorkspaceSlug(workspaceSlug?.toString());
|
||||
const is_telemetry_enabled = instance?.is_telemetry_enabled || false;
|
||||
const is_posthog_enabled =
|
||||
process.env.NEXT_PUBLIC_POSTHOG_KEY && process.env.NEXT_PUBLIC_POSTHOG_HOST && is_telemetry_enabled;
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
// Identify sends an event, so you want may want to limit how often you call it
|
||||
posthog?.identify(user.email, {
|
||||
id: user.id,
|
||||
first_name: user.first_name,
|
||||
last_name: user.last_name,
|
||||
email: user.email,
|
||||
workspace_role: currentWorkspaceRole ? getUserRole(currentWorkspaceRole) : undefined,
|
||||
project_role: currentProjectRole ? getUserRole(currentProjectRole) : undefined,
|
||||
});
|
||||
if (currentWorkspace) {
|
||||
joinEventGroup(GROUP_WORKSPACE_TRACKER_EVENT, currentWorkspace?.id, {
|
||||
date: new Date().toDateString(),
|
||||
workspace_id: currentWorkspace?.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [user, currentProjectRole, currentWorkspaceRole, currentWorkspace]);
|
||||
|
||||
useEffect(() => {
|
||||
if (process.env.NEXT_PUBLIC_POSTHOG_KEY && process.env.NEXT_PUBLIC_POSTHOG_HOST) {
|
||||
posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY, {
|
||||
api_host: "/ingest",
|
||||
ui_host: process.env.NEXT_PUBLIC_POSTHOG_HOST,
|
||||
debug: process.env.NEXT_PUBLIC_POSTHOG_DEBUG === "1", // Debug mode based on the environment variable
|
||||
autocapture: false,
|
||||
capture_pageview: false, // Disable automatic pageview capture, as we capture manually
|
||||
capture_pageleave: true,
|
||||
disable_session_recording: true,
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const clickHandler = (event: MouseEvent) => {
|
||||
const target = event.target as HTMLElement;
|
||||
// Use closest to find the nearest parent element with data-ph-element attribute
|
||||
const elementWithAttribute = target.closest("[data-ph-element]") as HTMLElement;
|
||||
if (elementWithAttribute) {
|
||||
const element = elementWithAttribute.getAttribute("data-ph-element");
|
||||
if (element) {
|
||||
captureClick({ elementName: element });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (is_posthog_enabled) {
|
||||
document.addEventListener("click", clickHandler);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("click", clickHandler);
|
||||
};
|
||||
}, [is_posthog_enabled]);
|
||||
|
||||
if (is_posthog_enabled)
|
||||
return (
|
||||
<PHProvider client={posthog}>
|
||||
<PostHogPageView />
|
||||
{children}
|
||||
</PHProvider>
|
||||
);
|
||||
|
||||
return <>{children}</>;
|
||||
});
|
||||
|
||||
export default PostHogProvider;
|
||||
26
apps/web/core/lib/posthog-view.tsx
Normal file
26
apps/web/core/lib/posthog-view.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
// posthog
|
||||
import { usePostHog } from "posthog-js/react";
|
||||
|
||||
export default function PostHogPageView(): null {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const posthog = usePostHog();
|
||||
useEffect(() => {
|
||||
// Track pageviews
|
||||
if (pathname && posthog) {
|
||||
let url = window.origin + pathname;
|
||||
if (searchParams.toString()) {
|
||||
url = url + `?${searchParams.toString()}`;
|
||||
}
|
||||
posthog.capture("$pageview", {
|
||||
$current_url: url,
|
||||
});
|
||||
}
|
||||
}, [pathname, searchParams, posthog]);
|
||||
|
||||
return null;
|
||||
}
|
||||
23
apps/web/core/lib/store-context.tsx
Normal file
23
apps/web/core/lib/store-context.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactElement } from "react";
|
||||
import { createContext } from "react";
|
||||
// plane web store
|
||||
import { RootStore } from "@/plane-web/store/root.store";
|
||||
|
||||
export let rootStore = new RootStore();
|
||||
|
||||
export const StoreContext = createContext<RootStore>(rootStore);
|
||||
|
||||
const initializeStore = () => {
|
||||
const newRootStore = rootStore ?? new RootStore();
|
||||
if (typeof window === "undefined") return newRootStore;
|
||||
if (!rootStore) rootStore = newRootStore;
|
||||
return newRootStore;
|
||||
};
|
||||
|
||||
export const store = initializeStore();
|
||||
|
||||
export const StoreProvider = ({ children }: { children: ReactElement }) => (
|
||||
<StoreContext.Provider value={store}>{children}</StoreContext.Provider>
|
||||
);
|
||||
140
apps/web/core/lib/wrappers/authentication-wrapper.tsx
Normal file
140
apps/web/core/lib/wrappers/authentication-wrapper.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
"use client";
|
||||
|
||||
import type { FC, ReactNode } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useSearchParams, usePathname } from "next/navigation";
|
||||
import useSWR from "swr";
|
||||
// components
|
||||
import { LogoSpinner } from "@/components/common/logo-spinner";
|
||||
// helpers
|
||||
import { EPageTypes } from "@/helpers/authentication.helper";
|
||||
// hooks
|
||||
import { useWorkspace } from "@/hooks/store/use-workspace";
|
||||
import { useUser, useUserProfile, useUserSettings } from "@/hooks/store/user";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
|
||||
type TPageType = EPageTypes;
|
||||
|
||||
type TAuthenticationWrapper = {
|
||||
children: ReactNode;
|
||||
pageType?: TPageType;
|
||||
};
|
||||
|
||||
const isValidURL = (url: string): boolean => {
|
||||
const disallowedSchemes = /^(https?|ftp):\/\//i;
|
||||
return !disallowedSchemes.test(url);
|
||||
};
|
||||
|
||||
export const AuthenticationWrapper: FC<TAuthenticationWrapper> = observer((props) => {
|
||||
const pathname = usePathname();
|
||||
const router = useAppRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const nextPath = searchParams.get("next_path");
|
||||
// props
|
||||
const { children, pageType = EPageTypes.AUTHENTICATED } = props;
|
||||
// hooks
|
||||
const { isLoading: isUserLoading, data: currentUser, fetchCurrentUser } = useUser();
|
||||
const { data: currentUserProfile } = useUserProfile();
|
||||
const { data: currentUserSettings } = useUserSettings();
|
||||
const { loader: workspacesLoader, workspaces } = useWorkspace();
|
||||
|
||||
const { isLoading: isUserSWRLoading } = useSWR("USER_INFORMATION", async () => await fetchCurrentUser(), {
|
||||
revalidateOnFocus: false,
|
||||
shouldRetryOnError: false,
|
||||
});
|
||||
|
||||
const isUserOnboard =
|
||||
currentUserProfile?.is_onboarded ||
|
||||
(currentUserProfile?.onboarding_step?.profile_complete &&
|
||||
currentUserProfile?.onboarding_step?.workspace_create &&
|
||||
currentUserProfile?.onboarding_step?.workspace_invite &&
|
||||
currentUserProfile?.onboarding_step?.workspace_join) ||
|
||||
false;
|
||||
|
||||
const getWorkspaceRedirectionUrl = (): string => {
|
||||
let redirectionRoute = "/create-workspace";
|
||||
|
||||
// validating the nextPath from the router query
|
||||
if (nextPath && isValidURL(nextPath.toString())) {
|
||||
redirectionRoute = nextPath.toString();
|
||||
return redirectionRoute;
|
||||
}
|
||||
|
||||
// validate the last and fallback workspace_slug
|
||||
const currentWorkspaceSlug =
|
||||
currentUserSettings?.workspace?.last_workspace_slug || currentUserSettings?.workspace?.fallback_workspace_slug;
|
||||
|
||||
// validate the current workspace_slug is available in the user's workspace list
|
||||
const isCurrentWorkspaceValid = Object.values(workspaces || {}).findIndex(
|
||||
(workspace) => workspace.slug === currentWorkspaceSlug
|
||||
);
|
||||
|
||||
if (isCurrentWorkspaceValid >= 0) redirectionRoute = `/${currentWorkspaceSlug}`;
|
||||
|
||||
return redirectionRoute;
|
||||
};
|
||||
|
||||
if ((isUserSWRLoading || isUserLoading || workspacesLoader) && !currentUser?.id)
|
||||
return (
|
||||
<div className="relative flex h-screen w-full items-center justify-center">
|
||||
<LogoSpinner />
|
||||
</div>
|
||||
);
|
||||
|
||||
if (pageType === EPageTypes.PUBLIC) return <>{children}</>;
|
||||
|
||||
if (pageType === EPageTypes.NON_AUTHENTICATED) {
|
||||
if (!currentUser?.id) return <>{children}</>;
|
||||
else {
|
||||
if (currentUserProfile?.id && isUserOnboard) {
|
||||
const currentRedirectRoute = getWorkspaceRedirectionUrl();
|
||||
router.push(currentRedirectRoute);
|
||||
return <></>;
|
||||
} else {
|
||||
router.push("/onboarding");
|
||||
return <></>;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (pageType === EPageTypes.ONBOARDING) {
|
||||
if (!currentUser?.id) {
|
||||
router.push(`/${pathname ? `?next_path=${pathname}` : ``}`);
|
||||
return <></>;
|
||||
} else {
|
||||
if (currentUser && currentUserProfile?.id && isUserOnboard) {
|
||||
const currentRedirectRoute = getWorkspaceRedirectionUrl();
|
||||
router.replace(currentRedirectRoute);
|
||||
return <></>;
|
||||
} else return <>{children}</>;
|
||||
}
|
||||
}
|
||||
|
||||
if (pageType === EPageTypes.SET_PASSWORD) {
|
||||
if (!currentUser?.id) {
|
||||
router.push(`/${pathname ? `?next_path=${pathname}` : ``}`);
|
||||
return <></>;
|
||||
} else {
|
||||
if (currentUser && !currentUser?.is_password_autoset && currentUserProfile?.id && isUserOnboard) {
|
||||
const currentRedirectRoute = getWorkspaceRedirectionUrl();
|
||||
router.push(currentRedirectRoute);
|
||||
return <></>;
|
||||
} else return <>{children}</>;
|
||||
}
|
||||
}
|
||||
|
||||
if (pageType === EPageTypes.AUTHENTICATED) {
|
||||
if (currentUser?.id) {
|
||||
if (currentUserProfile && currentUserProfile?.id && isUserOnboard) return <>{children}</>;
|
||||
else {
|
||||
router.push(`/onboarding`);
|
||||
return <></>;
|
||||
}
|
||||
} else {
|
||||
router.push(`/${pathname ? `?next_path=${pathname}` : ``}`);
|
||||
return <></>;
|
||||
}
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
});
|
||||
42
apps/web/core/lib/wrappers/instance-wrapper.tsx
Normal file
42
apps/web/core/lib/wrappers/instance-wrapper.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { FC, ReactNode } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import useSWR from "swr";
|
||||
// components
|
||||
import { LogoSpinner } from "@/components/common/logo-spinner";
|
||||
import { InstanceNotReady, MaintenanceView } from "@/components/instance";
|
||||
// hooks
|
||||
import { useInstance } from "@/hooks/store/use-instance";
|
||||
|
||||
type TInstanceWrapper = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export const InstanceWrapper: FC<TInstanceWrapper> = observer((props) => {
|
||||
const { children } = props;
|
||||
// store
|
||||
const { isLoading, instance, error, fetchInstanceInfo } = useInstance();
|
||||
|
||||
const { isLoading: isInstanceSWRLoading, error: instanceSWRError } = useSWR(
|
||||
"INSTANCE_INFORMATION",
|
||||
async () => await fetchInstanceInfo(),
|
||||
{ revalidateOnFocus: false }
|
||||
);
|
||||
|
||||
// loading state
|
||||
if ((isLoading || isInstanceSWRLoading) && !instance)
|
||||
return (
|
||||
<div className="relative flex h-screen w-full items-center justify-center">
|
||||
<LogoSpinner />
|
||||
</div>
|
||||
);
|
||||
|
||||
if (instanceSWRError) return <MaintenanceView />;
|
||||
|
||||
// something went wrong while in the request
|
||||
if (error && error?.status === "error") return <>{children}</>;
|
||||
|
||||
// instance is not ready and setup is not done
|
||||
if (instance?.is_setup_done === false) return <InstanceNotReady />;
|
||||
|
||||
return <>{children}</>;
|
||||
});
|
||||
71
apps/web/core/lib/wrappers/store-wrapper.tsx
Normal file
71
apps/web/core/lib/wrappers/store-wrapper.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import type { ReactNode, FC } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useTheme } from "next-themes";
|
||||
import type { TLanguage } from "@plane/i18n";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// helpers
|
||||
import { applyTheme, unsetCustomCssVariables } from "@plane/utils";
|
||||
// hooks
|
||||
import { useAppTheme } from "@/hooks/store/use-app-theme";
|
||||
import { useRouterParams } from "@/hooks/store/use-router-params";
|
||||
import { useUserProfile } from "@/hooks/store/user";
|
||||
|
||||
type TStoreWrapper = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
const StoreWrapper: FC<TStoreWrapper> = observer((props) => {
|
||||
const { children } = props;
|
||||
// theme
|
||||
const { setTheme } = useTheme();
|
||||
// router
|
||||
const params = useParams();
|
||||
// store hooks
|
||||
const { setQuery } = useRouterParams();
|
||||
const { sidebarCollapsed, toggleSidebar } = useAppTheme();
|
||||
const { data: userProfile } = useUserProfile();
|
||||
const { changeLanguage } = useTranslation();
|
||||
|
||||
/**
|
||||
* Sidebar collapsed fetching from local storage
|
||||
*/
|
||||
useEffect(() => {
|
||||
const localValue = localStorage && localStorage.getItem("app_sidebar_collapsed");
|
||||
const localBoolValue = localValue ? (localValue === "true" ? true : false) : false;
|
||||
if (localValue && sidebarCollapsed === undefined) toggleSidebar(localBoolValue);
|
||||
}, [sidebarCollapsed, setTheme, toggleSidebar]);
|
||||
|
||||
/**
|
||||
* Setting up the theme of the user by fetching it from local storage
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!userProfile?.theme?.theme) return;
|
||||
const currentTheme = userProfile?.theme?.theme || "system";
|
||||
const currentThemePalette = userProfile?.theme?.palette;
|
||||
if (currentTheme) {
|
||||
setTheme(currentTheme);
|
||||
if (currentTheme === "custom" && currentThemePalette) {
|
||||
applyTheme(
|
||||
currentThemePalette !== ",,,," ? currentThemePalette : "#0d101b,#c5c5c5,#3f76ff,#0d101b,#c5c5c5",
|
||||
false
|
||||
);
|
||||
} else unsetCustomCssVariables();
|
||||
}
|
||||
}, [userProfile?.theme?.theme, userProfile?.theme?.palette, setTheme]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!userProfile?.language) return;
|
||||
changeLanguage(userProfile?.language as TLanguage);
|
||||
}, [userProfile?.language, changeLanguage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!params) return;
|
||||
setQuery(params);
|
||||
}, [params, setQuery]);
|
||||
|
||||
return <>{children}</>;
|
||||
});
|
||||
|
||||
export default StoreWrapper;
|
||||
Reference in New Issue
Block a user