(
+ a: T,
+ key: K
+): P extends SVGAnimatedString ? string : P {
+ if (typeof key === "string" && key === "data-disable-nprogress") {
+ const dataKey = key.substring(5) as keyof DOMStringMap;
+ return a.dataset[dataKey] as any;
+ }
+
+ const prop = a[key];
+
+ if (prop instanceof SVGAnimatedString) {
+ const value = prop.baseVal as unknown;
+
+ if (key === "href") {
+ return addPathPrefix(value as string, location.origin) as any;
+ }
+
+ return value as any;
+ }
+
+ return prop as any;
+}
+
+// Utility function to check for attribute in parent elements
+export const hasPreventProgressAttribute = (element: Element | null): boolean => {
+ while (element) {
+ if (element?.getAttribute("data-prevent-nprogress") === "true") {
+ return true;
+ }
+ element = element?.parentElement;
+ }
+ return false;
+};
diff --git a/dev-wiki/core/lib/n-progress/utils/sameURL.ts b/dev-wiki/core/lib/n-progress/utils/sameURL.ts
new file mode 100644
index 0000000000..14c689f756
--- /dev/null
+++ b/dev-wiki/core/lib/n-progress/utils/sameURL.ts
@@ -0,0 +1,16 @@
+export function isSameURL(target: URL, current: URL) {
+ const cleanTarget =
+ target.protocol + '//' + target.host + target.pathname + target.search;
+ const cleanCurrent =
+ current.protocol + '//' + current.host + current.pathname + current.search;
+
+ return cleanTarget === cleanCurrent;
+}
+
+export function isSameURLWithoutSearch(target: URL, current: URL) {
+ const cleanTarget = target.protocol + '//' + target.host + target.pathname;
+ const cleanCurrent =
+ current.protocol + '//' + current.host + current.pathname;
+
+ return cleanTarget === cleanCurrent;
+}
diff --git a/dev-wiki/core/lib/n-progress/withSuspense.tsx b/dev-wiki/core/lib/n-progress/withSuspense.tsx
new file mode 100644
index 0000000000..2b36e9444a
--- /dev/null
+++ b/dev-wiki/core/lib/n-progress/withSuspense.tsx
@@ -0,0 +1,11 @@
+import React, { ComponentType, Suspense } from "react";
+
+export default function withSuspense(Component: ComponentType
) {
+ return function WithSuspenseComponent(props: P) {
+ return (
+
+
+
+ );
+ };
+}
diff --git a/dev-wiki/core/lib/polyfills/index.ts b/dev-wiki/core/lib/polyfills/index.ts
new file mode 100644
index 0000000000..20b3b476c0
--- /dev/null
+++ b/dev-wiki/core/lib/polyfills/index.ts
@@ -0,0 +1 @@
+export * from "./requestIdleCallback";
diff --git a/dev-wiki/core/lib/polyfills/requestIdleCallback.ts b/dev-wiki/core/lib/polyfills/requestIdleCallback.ts
new file mode 100644
index 0000000000..86d1655b69
--- /dev/null
+++ b/dev-wiki/core/lib/polyfills/requestIdleCallback.ts
@@ -0,0 +1,24 @@
+if (typeof window !== "undefined" && window) {
+ // Add request callback polyfill to browser incase 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 {};
diff --git a/dev-wiki/core/lib/store-context.tsx b/dev-wiki/core/lib/store-context.tsx
new file mode 100644
index 0000000000..f06dfb4d83
--- /dev/null
+++ b/dev-wiki/core/lib/store-context.tsx
@@ -0,0 +1,20 @@
+"use client";
+
+import { ReactElement, createContext } from "react";
+// plane web store
+import { RootStore } from "@/plane-web/store/root.store";
+
+export let rootStore = new RootStore();
+
+export const StoreContext = createContext(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 }) => {children};
diff --git a/dev-wiki/core/lib/wrappers/authentication-wrapper.tsx b/dev-wiki/core/lib/wrappers/authentication-wrapper.tsx
new file mode 100644
index 0000000000..3c235adc74
--- /dev/null
+++ b/dev-wiki/core/lib/wrappers/authentication-wrapper.tsx
@@ -0,0 +1,139 @@
+"use client";
+
+import { 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";
+// helpers
+import { EPageTypes } from "@/helpers/authentication.helper";
+// hooks
+import { useUser, useUserProfile, useUserSettings, useWorkspace } from "@/hooks/store";
+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 = 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 (
+
+
+
+ );
+
+ 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}>;
+});
diff --git a/dev-wiki/core/lib/wrappers/index.ts b/dev-wiki/core/lib/wrappers/index.ts
new file mode 100644
index 0000000000..6dfc75b23a
--- /dev/null
+++ b/dev-wiki/core/lib/wrappers/index.ts
@@ -0,0 +1,2 @@
+export * from "./instance-wrapper";
+export * from "./authentication-wrapper";
diff --git a/dev-wiki/core/lib/wrappers/instance-wrapper.tsx b/dev-wiki/core/lib/wrappers/instance-wrapper.tsx
new file mode 100644
index 0000000000..c14cbd7564
--- /dev/null
+++ b/dev-wiki/core/lib/wrappers/instance-wrapper.tsx
@@ -0,0 +1,42 @@
+import { FC, ReactNode } from "react";
+import { observer } from "mobx-react";
+import useSWR from "swr";
+// components
+import { LogoSpinner } from "@/components/common";
+import { MaintenanceView, InstanceNotReady } from "@/components/instance";
+// hooks
+import { useInstance } from "@/hooks/store";
+
+type TInstanceWrapper = {
+ children: ReactNode;
+};
+
+export const InstanceWrapper: FC = 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 (
+
+
+
+ );
+
+ if (instanceSWRError) return ;
+
+ // 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 ;
+
+ return <>{children}>;
+});
diff --git a/dev-wiki/core/lib/wrappers/store-wrapper.tsx b/dev-wiki/core/lib/wrappers/store-wrapper.tsx
new file mode 100644
index 0000000000..0243c34f48
--- /dev/null
+++ b/dev-wiki/core/lib/wrappers/store-wrapper.tsx
@@ -0,0 +1,67 @@
+import { ReactNode, useEffect, FC } from "react";
+import { observer } from "mobx-react";
+import { useParams } from "next/navigation";
+import { useTheme } from "next-themes";
+import { useTranslation, TLanguage } from "@plane/i18n";
+// helpers
+import { applyTheme, unsetCustomCssVariables } from "@/helpers/theme.helper";
+// hooks
+import { useRouterParams, useAppTheme, useUserProfile } from "@/hooks/store";
+
+type TStoreWrapper = {
+ children: ReactNode;
+};
+
+const StoreWrapper: FC = 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;
diff --git a/dev-wiki/core/services/ai.service.ts b/dev-wiki/core/services/ai.service.ts
new file mode 100644
index 0000000000..bb0241e06d
--- /dev/null
+++ b/dev-wiki/core/services/ai.service.ts
@@ -0,0 +1,44 @@
+// helpers
+import { API_BASE_URL } from "@/helpers/common.helper";
+// plane web constants
+import { AI_EDITOR_TASKS } from "@/plane-web/constants/ai";
+// services
+import { APIService } from "@/services/api.service";
+// types
+// FIXME:
+// import { IGptResponse } from "@plane/types";
+// helpers
+
+export type TTaskPayload = {
+ casual_score?: number;
+ formal_score?: number;
+ task: AI_EDITOR_TASKS;
+ text_input: string;
+};
+
+export class AIService extends APIService {
+ constructor() {
+ super(API_BASE_URL);
+ }
+
+ async createGptTask(workspaceSlug: string, data: { prompt: string; task: string }): Promise {
+ return this.post(`/api/workspaces/${workspaceSlug}/ai-assistant/`, data)
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response;
+ });
+ }
+
+ async performEditorTask(
+ workspaceSlug: string,
+ data: TTaskPayload
+ ): Promise<{
+ response: string;
+ }> {
+ return this.post(`/api/workspaces/${workspaceSlug}/rephrase-grammar/`, data)
+ .then((res) => res?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+}
diff --git a/dev-wiki/core/services/api.service.ts b/dev-wiki/core/services/api.service.ts
new file mode 100644
index 0000000000..944990a05c
--- /dev/null
+++ b/dev-wiki/core/services/api.service.ts
@@ -0,0 +1,57 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+import axios, { AxiosInstance, AxiosRequestConfig } from "axios";
+
+export abstract class APIService {
+ protected baseURL: string;
+ private axiosInstance: AxiosInstance;
+
+ constructor(baseURL: string) {
+ this.baseURL = baseURL;
+ this.axiosInstance = axios.create({
+ baseURL,
+ withCredentials: true,
+ });
+
+ this.setupInterceptors();
+ }
+
+ private setupInterceptors() {
+ this.axiosInstance.interceptors.response.use(
+ (response) => response,
+ (error) => {
+ if (error.response && error.response.status === 401) {
+ const currentPath = window.location.pathname;
+ window.location.replace(`/${currentPath ? `?next_path=${currentPath}` : ``}`);
+ }
+ return Promise.reject(error);
+ }
+ );
+ }
+
+ get(url: string, params = {}, config: AxiosRequestConfig = {}) {
+ return this.axiosInstance.get(url, {
+ ...params,
+ ...config,
+ });
+ }
+
+ post(url: string, data = {}, config: AxiosRequestConfig = {}) {
+ return this.axiosInstance.post(url, data, config);
+ }
+
+ put(url: string, data = {}, config: AxiosRequestConfig = {}) {
+ return this.axiosInstance.put(url, data, config);
+ }
+
+ patch(url: string, data = {}, config: AxiosRequestConfig = {}) {
+ return this.axiosInstance.patch(url, data, config);
+ }
+
+ delete(url: string, data?: any, config: AxiosRequestConfig = {}) {
+ return this.axiosInstance.delete(url, { data, ...config });
+ }
+
+ request(config = {}) {
+ return this.axiosInstance(config);
+ }
+}
diff --git a/dev-wiki/core/services/api_token.service.ts b/dev-wiki/core/services/api_token.service.ts
new file mode 100644
index 0000000000..51078396cd
--- /dev/null
+++ b/dev-wiki/core/services/api_token.service.ts
@@ -0,0 +1,41 @@
+import { IApiToken } from "@plane/types";
+import { API_BASE_URL } from "@/helpers/common.helper";
+import { APIService } from "./api.service";
+
+export class APITokenService extends APIService {
+ constructor() {
+ super(API_BASE_URL);
+ }
+
+ async getApiTokens(workspaceSlug: string): Promise {
+ return this.get(`/api/workspaces/${workspaceSlug}/api-tokens/`)
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ async retrieveApiToken(workspaceSlug: string, tokenId: string): Promise {
+ return this.get(`/api/workspaces/${workspaceSlug}/api-tokens/${tokenId}`)
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ async createApiToken(workspaceSlug: string, data: Partial): Promise {
+ return this.post(`/api/workspaces/${workspaceSlug}/api-tokens/`, data)
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ async deleteApiToken(workspaceSlug: string, tokenId: string): Promise {
+ return this.delete(`/api/workspaces/${workspaceSlug}/api-tokens/${tokenId}`)
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+}
diff --git a/dev-wiki/core/services/auth.service.ts b/dev-wiki/core/services/auth.service.ts
new file mode 100644
index 0000000000..7663afde86
--- /dev/null
+++ b/dev-wiki/core/services/auth.service.ts
@@ -0,0 +1,78 @@
+// types
+import { ICsrfTokenData, IEmailCheckData, IEmailCheckResponse } from "@plane/types";
+// helpers
+import { API_BASE_URL } from "@/helpers/common.helper";
+// services
+import { APIService } from "@/services/api.service";
+
+export class AuthService extends APIService {
+ constructor() {
+ super(API_BASE_URL);
+ }
+
+ async requestCSRFToken(): Promise {
+ return this.get("/auth/get-csrf-token/")
+ .then((response) => response.data)
+ .catch((error) => {
+ throw error;
+ });
+ }
+
+ emailCheck = async (data: IEmailCheckData): Promise =>
+ this.post("/auth/email-check/", data, { headers: {} })
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+
+ async sendResetPasswordLink(data: { email: string }): Promise {
+ return this.post(`/auth/forgot-password/`, data)
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response;
+ });
+ }
+
+ async setPassword(token: string, data: { password: string }): Promise {
+ return this.post(`/auth/set-password/`, data, {
+ headers: {
+ "X-CSRFTOKEN": token,
+ },
+ })
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ async generateUniqueCode(data: { email: string }): Promise {
+ return this.post("/auth/magic-generate/", data, { headers: {} })
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ async signOut(baseUrl: string): Promise {
+ await this.requestCSRFToken().then((data) => {
+ const csrfToken = data?.csrf_token;
+
+ if (!csrfToken) throw Error("CSRF token not found");
+
+ const form = document.createElement("form");
+ const element1 = document.createElement("input");
+
+ form.method = "POST";
+ form.action = `${baseUrl}/auth/sign-out/`;
+
+ element1.value = csrfToken;
+ element1.name = "csrfmiddlewaretoken";
+ element1.type = "hidden";
+ form.appendChild(element1);
+
+ document.body.appendChild(form);
+
+ form.submit();
+ });
+ }
+}
diff --git a/dev-wiki/core/services/favorite/favorite.service.ts b/dev-wiki/core/services/favorite/favorite.service.ts
new file mode 100644
index 0000000000..8a4963cdf3
--- /dev/null
+++ b/dev-wiki/core/services/favorite/favorite.service.ts
@@ -0,0 +1,56 @@
+import type { IFavorite } from "@plane/types";
+// helpers
+import { API_BASE_URL } from "@/helpers/common.helper";
+// services
+import { APIService } from "@/services/api.service";
+// types
+
+export class FavoriteService extends APIService {
+ constructor() {
+ super(API_BASE_URL);
+ }
+
+ async addFavorite(workspaceSlug: string, data: Partial): Promise {
+ return this.post(`/api/workspaces/${workspaceSlug}/user-favorites/`, data)
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response;
+ });
+ }
+
+ async updateFavorite(workspaceSlug: string, favoriteId: string, data: Partial): Promise {
+ return this.patch(`/api/workspaces/${workspaceSlug}/user-favorites/${favoriteId}/`, data)
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response;
+ });
+ }
+
+ async deleteFavorite(workspaceSlug: string, favoriteId: string): Promise {
+ return this.delete(`/api/workspaces/${workspaceSlug}/user-favorites/${favoriteId}/`)
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response;
+ });
+ }
+
+ async getFavorites(workspaceSlug: string): Promise {
+ return this.get(`/api/workspaces/${workspaceSlug}/user-favorites/`, {
+ params: {
+ all: true,
+ },
+ })
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ async getGroupedFavorites(workspaceSlug: string, favoriteId: string): Promise {
+ return this.get(`/api/workspaces/${workspaceSlug}/user-favorites/${favoriteId}/group/`)
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+}
diff --git a/dev-wiki/core/services/favorite/index.ts b/dev-wiki/core/services/favorite/index.ts
new file mode 100644
index 0000000000..41df23a178
--- /dev/null
+++ b/dev-wiki/core/services/favorite/index.ts
@@ -0,0 +1 @@
+export * from "./favorite.service";
diff --git a/dev-wiki/core/services/file-upload.service.ts b/dev-wiki/core/services/file-upload.service.ts
new file mode 100644
index 0000000000..c6421872a1
--- /dev/null
+++ b/dev-wiki/core/services/file-upload.service.ts
@@ -0,0 +1,39 @@
+import axios, { AxiosRequestConfig } from "axios";
+// services
+import { APIService } from "@/services/api.service";
+
+export class FileUploadService extends APIService {
+ private cancelSource: any;
+
+ constructor() {
+ super("");
+ }
+
+ async uploadFile(
+ url: string,
+ data: FormData,
+ uploadProgressHandler?: AxiosRequestConfig["onUploadProgress"]
+ ): Promise {
+ this.cancelSource = axios.CancelToken.source();
+ return this.post(url, data, {
+ headers: {
+ "Content-Type": "multipart/form-data",
+ },
+ cancelToken: this.cancelSource.token,
+ withCredentials: false,
+ onUploadProgress: uploadProgressHandler,
+ })
+ .then((response) => response?.data)
+ .catch((error) => {
+ if (axios.isCancel(error)) {
+ console.log(error.message);
+ } else {
+ throw error?.response?.data;
+ }
+ });
+ }
+
+ cancelUpload() {
+ this.cancelSource.cancel("Upload canceled");
+ }
+}
diff --git a/dev-wiki/core/services/file.service.ts b/dev-wiki/core/services/file.service.ts
new file mode 100644
index 0000000000..5f69fb04fb
--- /dev/null
+++ b/dev-wiki/core/services/file.service.ts
@@ -0,0 +1,342 @@
+import { AxiosRequestConfig } from "axios";
+// plane types
+import { TFileEntityInfo, TFileSignedURLResponse } from "@plane/types";
+// helpers
+import { EFileAssetType } from "@plane/types/src/enums";
+import { API_BASE_URL } from "@/helpers/common.helper";
+import { generateFileUploadPayload, getAssetIdFromUrl, getFileMetaDataForUpload } from "@/helpers/file.helper";
+// services
+import { APIService } from "@/services/api.service";
+import { FileUploadService } from "@/services/file-upload.service";
+
+export interface UnSplashImage {
+ id: string;
+ created_at: Date;
+ updated_at: Date;
+ promoted_at: Date;
+ width: number;
+ height: number;
+ color: string;
+ blur_hash: string;
+ description: null;
+ alt_description: string;
+ urls: UnSplashImageUrls;
+ [key: string]: any;
+}
+
+export interface UnSplashImageUrls {
+ raw: string;
+ full: string;
+ regular: string;
+ small: string;
+ thumb: string;
+ small_s3: string;
+}
+
+export enum TFileAssetType {
+ COMMENT_DESCRIPTION = "COMMENT_DESCRIPTION",
+ ISSUE_ATTACHMENT = "ISSUE_ATTACHMENT",
+ ISSUE_DESCRIPTION = "ISSUE_DESCRIPTION",
+ PAGE_DESCRIPTION = "PAGE_DESCRIPTION",
+ PROJECT_COVER = "PROJECT_COVER",
+ USER_AVATAR = "USER_AVATAR",
+ USER_COVER = "USER_COVER",
+ WORKSPACE_LOGO = "WORKSPACE_LOGO",
+}
+
+export class FileService extends APIService {
+ private cancelSource: any;
+ private fileUploadService: FileUploadService;
+
+ constructor() {
+ super(API_BASE_URL);
+ this.cancelUpload = this.cancelUpload.bind(this);
+ // upload service
+ this.fileUploadService = new FileUploadService();
+ }
+
+ private async updateWorkspaceAssetUploadStatus(workspaceSlug: string, assetId: string): Promise {
+ return this.patch(`/api/assets/v2/workspaces/${workspaceSlug}/${assetId}/`)
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ async uploadWorkspaceAsset(
+ workspaceSlug: string,
+ data: TFileEntityInfo,
+ file: File,
+ uploadProgressHandler?: AxiosRequestConfig["onUploadProgress"]
+ ): Promise {
+ const fileMetaData = getFileMetaDataForUpload(file);
+ return this.post(`/api/assets/v2/workspaces/${workspaceSlug}/`, {
+ ...data,
+ ...fileMetaData,
+ })
+ .then(async (response) => {
+ const signedURLResponse: TFileSignedURLResponse = response?.data;
+ const fileUploadPayload = generateFileUploadPayload(signedURLResponse, file);
+ await this.fileUploadService.uploadFile(
+ signedURLResponse.upload_data.url,
+ fileUploadPayload,
+ uploadProgressHandler
+ );
+ await this.updateWorkspaceAssetUploadStatus(workspaceSlug.toString(), signedURLResponse.asset_id);
+ return signedURLResponse;
+ })
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ async deleteWorkspaceAsset(workspaceSlug: string, assetId: string): Promise {
+ return this.delete(`/api/assets/v2/workspaces/${workspaceSlug}/${assetId}/`)
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ private async updateProjectAssetUploadStatus(
+ workspaceSlug: string,
+ projectId: string,
+ assetId: string
+ ): Promise {
+ return this.patch(`/api/assets/v2/workspaces/${workspaceSlug}/projects/${projectId}/${assetId}/`)
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ async updateBulkWorkspaceAssetsUploadStatus(
+ workspaceSlug: string,
+ entityId: string,
+ data: {
+ asset_ids: string[];
+ }
+ ): Promise {
+ return this.post(`/api/assets/v2/workspaces/${workspaceSlug}/${entityId}/bulk/`, data)
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ async updateBulkProjectAssetsUploadStatus(
+ workspaceSlug: string,
+ projectId: string,
+ entityId: string,
+ data: {
+ asset_ids: string[];
+ }
+ ): Promise {
+ return this.post(`/api/assets/v2/workspaces/${workspaceSlug}/projects/${projectId}/${entityId}/bulk/`, data)
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ async updateBulkInitiativeAssetsUploadStatus(
+ workspaceSlug: string,
+ initiativeId: string,
+ data: {
+ asset_ids: string[];
+ }
+ ): Promise {
+ return this.post(`/api/assets/v2/workspaces/${workspaceSlug}/initiatives/${initiativeId}/attachments/`, data)
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ async updateBulkInitiativeCommentAssetsUploadStatus(
+ workspaceSlug: string,
+ initiativeId: string,
+ commentId: string,
+ data: {
+ asset_ids: string[];
+ }
+ ): Promise {
+ return this.post(
+ `/api/assets/v2/workspaces/${workspaceSlug}/initiatives/${initiativeId}/comments/${commentId}/attachments/`,
+ data
+ )
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ async uploadProjectAsset(
+ workspaceSlug: string,
+ projectId: string,
+ data: TFileEntityInfo,
+ file: File,
+ uploadProgressHandler?: AxiosRequestConfig["onUploadProgress"]
+ ): Promise {
+ const fileMetaData = getFileMetaDataForUpload(file);
+ return this.post(`/api/assets/v2/workspaces/${workspaceSlug}/projects/${projectId}/`, {
+ ...data,
+ ...fileMetaData,
+ })
+ .then(async (response) => {
+ const signedURLResponse: TFileSignedURLResponse = response?.data;
+ const fileUploadPayload = generateFileUploadPayload(signedURLResponse, file);
+ await this.fileUploadService.uploadFile(
+ signedURLResponse.upload_data.url,
+ fileUploadPayload,
+ uploadProgressHandler
+ );
+ await this.updateProjectAssetUploadStatus(workspaceSlug, projectId, signedURLResponse.asset_id);
+ return signedURLResponse;
+ })
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ private async updateUserAssetUploadStatus(assetId: string): Promise {
+ return this.patch(`/api/assets/v2/user-assets/${assetId}/`)
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ async uploadUserAsset(data: TFileEntityInfo, file: File): Promise {
+ const fileMetaData = getFileMetaDataForUpload(file);
+ return this.post(`/api/assets/v2/user-assets/`, {
+ ...data,
+ ...fileMetaData,
+ })
+ .then(async (response) => {
+ const signedURLResponse: TFileSignedURLResponse = response?.data;
+ const fileUploadPayload = generateFileUploadPayload(signedURLResponse, file);
+ await this.fileUploadService.uploadFile(signedURLResponse.upload_data.url, fileUploadPayload);
+ await this.updateUserAssetUploadStatus(signedURLResponse.asset_id);
+ return signedURLResponse;
+ })
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ async deleteUserAsset(assetId: string): Promise {
+ return this.delete(`/api/assets/v2/user-assets/${assetId}/`)
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ async deleteNewAsset(assetPath: string): Promise {
+ return this.delete(assetPath)
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ async deleteOldWorkspaceAsset(workspaceId: string, src: string): Promise {
+ const assetKey = getAssetIdFromUrl(src);
+ return this.delete(`/api/workspaces/file-assets/${workspaceId}/${assetKey}/`)
+ .then((response) => response?.status)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ async deleteOldWorkspaceAssetV2(workspaceSlug: string, src: string): Promise {
+ const assetKey = getAssetIdFromUrl(src);
+ return this.delete(`/api/assets/v2/workspaces/${workspaceSlug}/${assetKey}/`)
+ .then((response) => response?.status)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ async deleteOldUserAsset(src: string): Promise {
+ const assetKey = getAssetIdFromUrl(src);
+ return this.delete(`/api/users/file-assets/${assetKey}/`)
+ .then((response) => response?.status)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ async restoreNewAsset(workspaceSlug: string, src: string): Promise {
+ // remove the last slash and get the asset id
+ const assetId = getAssetIdFromUrl(src);
+ return this.post(`/api/assets/v2/workspaces/${workspaceSlug}/restore/${assetId}/`)
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ async checkIfAssetExists(
+ workspaceSlug: string,
+ assetId: string
+ ): Promise<{
+ exists: boolean;
+ }> {
+ return this.get(`/api/assets/v2/workspaces/${workspaceSlug}/check/${assetId}/`)
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ async restoreOldEditorAsset(workspaceId: string, src: string): Promise {
+ const assetKey = getAssetIdFromUrl(src);
+ return this.post(`/api/workspaces/file-assets/${workspaceId}/${assetKey}/restore/`)
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ async duplicateAssets(
+ workspaceSlug: string,
+ data: {
+ entity_id: string;
+ entity_type: EFileAssetType;
+ project_id?: string;
+ asset_ids: string[];
+ }
+ ): Promise> {
+ return this.post(`/api/assets/v2/workspaces/${workspaceSlug}/duplicate-assets/`, data)
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ cancelUpload() {
+ this.cancelSource.cancel("Upload canceled");
+ }
+
+ async getUnsplashImages(query?: string): Promise {
+ return this.get(`/api/unsplash/`, {
+ params: {
+ query,
+ },
+ })
+ .then((res) => res?.data?.results ?? res?.data)
+ .catch((err) => {
+ throw err?.response?.data;
+ });
+ }
+
+ async getProjectCoverImages(): Promise