feat: public board (#2017)

* feat: filters in plane deploy

implemented multi-level dropdown for plane deploy

* style: spacing and fonts

* feat: plane deploy

implemented authentication/theming, created/modified all the required store & services

* devL reactions, voting, comments and theme
This commit is contained in:
Dakshesh Jain 2023-08-30 12:49:15 +05:30 committed by GitHub
parent c65bbf865d
commit 96399c7112
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
97 changed files with 6578 additions and 804 deletions

View File

@ -0,0 +1,216 @@
import React, { useEffect, useState, useCallback } from "react";
// react hook form
import { useForm } from "react-hook-form";
// services
import authenticationService from "services/authentication.service";
// hooks
import useToast from "hooks/use-toast";
import useTimer from "hooks/use-timer";
// ui
import { Input, PrimaryButton } from "components/ui";
// types
type EmailCodeFormValues = {
email: string;
key?: string;
token?: string;
};
export const EmailCodeForm = ({ handleSignIn }: any) => {
const [codeSent, setCodeSent] = useState(false);
const [codeResent, setCodeResent] = useState(false);
const [isCodeResending, setIsCodeResending] = useState(false);
const [errorResendingCode, setErrorResendingCode] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const { setToastAlert } = useToast();
const { timer: resendCodeTimer, setTimer: setResendCodeTimer } = useTimer();
const {
register,
handleSubmit,
setError,
setValue,
getValues,
watch,
formState: { errors, isSubmitting, isValid, isDirty },
} = useForm<EmailCodeFormValues>({
defaultValues: {
email: "",
key: "",
token: "",
},
mode: "onChange",
reValidateMode: "onChange",
});
const isResendDisabled = resendCodeTimer > 0 || isCodeResending || isSubmitting || errorResendingCode;
const onSubmit = useCallback(
async ({ email }: EmailCodeFormValues) => {
setErrorResendingCode(false);
await authenticationService
.emailCode({ email })
.then((res) => {
setValue("key", res.key);
setCodeSent(true);
})
.catch((err) => {
setErrorResendingCode(true);
setToastAlert({
title: "Oops!",
type: "error",
message: err?.error,
});
});
},
[setToastAlert, setValue]
);
const handleSignin = async (formData: EmailCodeFormValues) => {
setIsLoading(true);
await authenticationService
.magicSignIn(formData)
.then((response) => {
setIsLoading(false);
handleSignIn(response);
})
.catch((error) => {
setIsLoading(false);
setToastAlert({
title: "Oops!",
type: "error",
message: error?.response?.data?.error ?? "Enter the correct code to sign in",
});
setError("token" as keyof EmailCodeFormValues, {
type: "manual",
message: error?.error,
});
});
};
const emailOld = getValues("email");
useEffect(() => {
setErrorResendingCode(false);
}, [emailOld]);
useEffect(() => {
const submitForm = (e: KeyboardEvent) => {
if (!codeSent && e.key === "Enter") {
e.preventDefault();
handleSubmit(onSubmit)().then(() => {
setResendCodeTimer(30);
});
}
};
if (!codeSent) {
window.addEventListener("keydown", submitForm);
}
return () => {
window.removeEventListener("keydown", submitForm);
};
}, [handleSubmit, codeSent, onSubmit, setResendCodeTimer]);
return (
<>
{(codeSent || codeResent) && (
<p className="text-center mt-4">
We have sent the sign in code.
<br />
Please check your inbox at <span className="font-medium">{watch("email")}</span>
</p>
)}
<form className="space-y-4 mt-10 sm:w-[360px] mx-auto">
<div className="space-y-1">
<Input
id="email"
type="email"
placeholder="Enter your email address..."
className="border-custom-border-300 h-[46px]"
{...register("email", {
required: "Email address is required",
validate: (value) =>
/^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test(
value
) || "Email address is not valid",
})}
/>
{errors.email && <div className="text-sm text-red-500">{errors.email.message}</div>}
</div>
{codeSent && (
<>
<Input
id="token"
type="token"
{...register("token", {
required: "Code is required",
})}
placeholder="Enter code..."
className="border-custom-border-300 h-[46px]"
/>
{errors.token && <div className="text-sm text-red-500">{errors.token.message}</div>}
<button
type="button"
className={`flex w-full justify-end text-xs outline-none ${
isResendDisabled ? "cursor-default text-custom-text-200" : "cursor-pointer text-custom-primary-100"
} `}
onClick={() => {
setIsCodeResending(true);
onSubmit({ email: getValues("email") }).then(() => {
setCodeResent(true);
setIsCodeResending(false);
setResendCodeTimer(30);
});
}}
disabled={isResendDisabled}
>
{resendCodeTimer > 0 ? (
<span className="text-right">Request new code in {resendCodeTimer} seconds</span>
) : isCodeResending ? (
"Sending new code..."
) : errorResendingCode ? (
"Please try again later"
) : (
<span className="font-medium">Resend code</span>
)}
</button>
</>
)}
{codeSent ? (
<PrimaryButton
type="submit"
className="w-full text-center h-[46px]"
size="md"
onClick={handleSubmit(handleSignin)}
disabled={!isValid && isDirty}
loading={isLoading}
>
{isLoading ? "Signing in..." : "Sign in"}
</PrimaryButton>
) : (
<PrimaryButton
className="w-full text-center h-[46px]"
size="md"
onClick={() => {
handleSubmit(onSubmit)().then(() => {
setResendCodeTimer(30);
});
}}
disabled={!isValid && isDirty}
loading={isSubmitting}
>
{isSubmitting ? "Sending code..." : "Send sign in code"}
</PrimaryButton>
)}
</form>
</>
);
};

View File

@ -0,0 +1,116 @@
import React, { useState } from "react";
import { useRouter } from "next/router";
import Link from "next/link";
// react hook form
import { useForm } from "react-hook-form";
// components
import { EmailResetPasswordForm } from "./email-reset-password-form";
// ui
import { Input, PrimaryButton } from "components/ui";
// types
type EmailPasswordFormValues = {
email: string;
password?: string;
medium?: string;
};
type Props = {
onSubmit: (formData: EmailPasswordFormValues) => Promise<void>;
};
export const EmailPasswordForm: React.FC<Props> = ({ onSubmit }) => {
const [isResettingPassword, setIsResettingPassword] = useState(false);
const router = useRouter();
const isSignUpPage = router.pathname === "/sign-up";
const {
register,
handleSubmit,
formState: { errors, isSubmitting, isValid, isDirty },
} = useForm<EmailPasswordFormValues>({
defaultValues: {
email: "",
password: "",
medium: "email",
},
mode: "onChange",
reValidateMode: "onChange",
});
return (
<>
<h1 className="text-center text-2xl sm:text-2.5xl font-semibold text-custom-text-100">
{isResettingPassword ? "Reset your password" : isSignUpPage ? "Sign up on Plane" : "Sign in to Plane"}
</h1>
{isResettingPassword ? (
<EmailResetPasswordForm setIsResettingPassword={setIsResettingPassword} />
) : (
<form className="space-y-4 mt-10 w-full sm:w-[360px] mx-auto" onSubmit={handleSubmit(onSubmit)}>
<div className="space-y-1">
<Input
id="email"
type="email"
{...register("email", {
required: "Email address is required",
validate: (value) =>
/^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test(
value
) || "Email address is not valid",
})}
placeholder="Enter your email address..."
className="border-custom-border-300 h-[46px]"
/>
{errors.email && <div className="text-sm text-red-500">{errors.email.message}</div>}
</div>
<div className="space-y-1">
<Input
id="password"
type="password"
{...register("password", {
required: "Password is required",
})}
placeholder="Enter your password..."
className="border-custom-border-300 h-[46px]"
/>
{errors.password && <div className="text-sm text-red-500">{errors.password.message}</div>}
</div>
<div className="text-right text-xs">
{isSignUpPage ? (
<Link href="/">
<a className="text-custom-text-200 hover:text-custom-primary-100">Already have an account? Sign in.</a>
</Link>
) : (
<button
type="button"
onClick={() => setIsResettingPassword(true)}
className="text-custom-text-200 hover:text-custom-primary-100"
>
Forgot your password?
</button>
)}
</div>
<div>
<PrimaryButton
type="submit"
className="w-full text-center h-[46px]"
disabled={!isValid && isDirty}
loading={isSubmitting}
>
{isSignUpPage ? (isSubmitting ? "Signing up..." : "Sign up") : isSubmitting ? "Signing in..." : "Sign in"}
</PrimaryButton>
{!isSignUpPage && (
<Link href="/sign-up">
<a className="block text-custom-text-200 hover:text-custom-primary-100 text-xs mt-4">
Don{"'"}t have an account? Sign up.
</a>
</Link>
)}
</div>
</form>
)}
</>
);
};

View File

@ -0,0 +1,89 @@
import React from "react";
// react hook form
import { useForm } from "react-hook-form";
// services
import userService from "services/user.service";
// hooks
// import useToast from "hooks/use-toast";
// ui
import { Input, PrimaryButton, SecondaryButton } from "components/ui";
// types
type Props = {
setIsResettingPassword: React.Dispatch<React.SetStateAction<boolean>>;
};
export const EmailResetPasswordForm: React.FC<Props> = ({ setIsResettingPassword }) => {
// const { setToastAlert } = useToast();
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm({
defaultValues: {
email: "",
},
mode: "onChange",
reValidateMode: "onChange",
});
const forgotPassword = async (formData: any) => {
const payload = {
email: formData.email,
};
// await userService
// .forgotPassword(payload)
// .then(() =>
// setToastAlert({
// type: "success",
// title: "Success!",
// message: "Password reset link has been sent to your email address.",
// })
// )
// .catch((err) => {
// if (err.status === 400)
// setToastAlert({
// type: "error",
// title: "Error!",
// message: "Please check the Email ID entered.",
// });
// else
// setToastAlert({
// type: "error",
// title: "Error!",
// message: "Something went wrong. Please try again.",
// });
// });
};
return (
<form className="space-y-4 mt-10 w-full sm:w-[360px] mx-auto" onSubmit={handleSubmit(forgotPassword)}>
<div className="space-y-1">
<Input
id="email"
type="email"
{...register("email", {
required: "Email address is required",
validate: (value) =>
/^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test(
value
) || "Email address is not valid",
})}
placeholder="Enter registered email address.."
className="border-custom-border-300 h-[46px]"
/>
{errors.email && <div className="text-sm text-red-500">{errors.email.message}</div>}
</div>
<div className="mt-5 flex flex-col-reverse sm:flex-row items-center gap-2">
<SecondaryButton className="w-full text-center h-[46px]" onClick={() => setIsResettingPassword(false)}>
Go Back
</SecondaryButton>
<PrimaryButton type="submit" className="w-full text-center h-[46px]" loading={isSubmitting}>
{isSubmitting ? "Sending link..." : "Send reset link"}
</PrimaryButton>
</div>
</form>
);
};

View File

@ -0,0 +1,57 @@
import { useEffect, useState, FC } from "react";
import Link from "next/link";
import Image from "next/image";
import { useSearchParams } from "next/navigation";
// next-themes
import { useTheme } from "next-themes";
// images
import githubBlackImage from "/public/logos/github-black.png";
import githubWhiteImage from "/public/logos/github-white.png";
export interface GithubLoginButtonProps {
handleSignIn: React.Dispatch<string>;
}
export const GithubLoginButton: FC<GithubLoginButtonProps> = ({ handleSignIn }) => {
const [loginCallBackURL, setLoginCallBackURL] = useState(undefined);
const [gitCode, setGitCode] = useState<null | string>(null);
const searchParams = useSearchParams();
const code = searchParams?.get("code");
const { theme } = useTheme();
useEffect(() => {
if (code && !gitCode) {
setGitCode(code.toString());
handleSignIn(code.toString());
}
}, [code, gitCode, handleSignIn]);
useEffect(() => {
const origin = typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
setLoginCallBackURL(`${origin}/` as any);
}, []);
return (
<div className="w-full flex justify-center items-center">
<Link
className="w-full"
href={`https://github.com/login/oauth/authorize?client_id=${process.env.NEXT_PUBLIC_GITHUB_ID}&redirect_uri=${loginCallBackURL}&scope=read:user,user:email`}
>
<button className="flex w-full items-center justify-center gap-2 rounded border border-custom-border-300 p-2 text-sm font-medium text-custom-text-100 duration-300 hover:bg-custom-background-80 h-[46px]">
<Image
src={theme === "dark" ? githubWhiteImage : githubBlackImage}
height={20}
width={20}
alt="GitHub Logo"
/>
<span>Sign in with GitHub</span>
</button>
</Link>
</div>
);
};

View File

@ -0,0 +1,59 @@
import { FC, CSSProperties, useEffect, useRef, useCallback, useState } from "react";
import Script from "next/script";
export interface IGoogleLoginButton {
text?: string;
handleSignIn: React.Dispatch<any>;
styles?: CSSProperties;
}
export const GoogleLoginButton: FC<IGoogleLoginButton> = ({ handleSignIn }) => {
const googleSignInButton = useRef<HTMLDivElement>(null);
const [gsiScriptLoaded, setGsiScriptLoaded] = useState(false);
const loadScript = useCallback(() => {
if (!googleSignInButton.current || gsiScriptLoaded) return;
(window as any)?.google?.accounts.id.initialize({
client_id: process.env.NEXT_PUBLIC_GOOGLE_CLIENTID || "",
callback: handleSignIn,
});
try {
(window as any)?.google?.accounts.id.renderButton(
googleSignInButton.current,
{
type: "standard",
theme: "outline",
size: "large",
logo_alignment: "center",
width: 360,
text: "signin_with",
} as any // customization attributes
);
} catch (err) {
console.log(err);
}
(window as any)?.google?.accounts.id.prompt(); // also display the One Tap dialog
setGsiScriptLoaded(true);
}, [handleSignIn, gsiScriptLoaded]);
useEffect(() => {
if ((window as any)?.google?.accounts?.id) {
loadScript();
}
return () => {
(window as any)?.google?.accounts.id.cancel();
};
}, [loadScript]);
return (
<>
<Script src="https://accounts.google.com/gsi/client" async defer onLoad={loadScript} />
<div className="overflow-hidden rounded w-full" id="googleSignInButton" ref={googleSignInButton} />
</>
);
};

View File

@ -42,20 +42,18 @@ export async function generateMetadata({ params }: LayoutProps): Promise<Metadat
const RootLayout = ({ children }: { children: React.ReactNode }) => (
<div className="relative w-screen min-h-[500px] h-screen overflow-hidden flex flex-col">
<div className="flex-shrink-0 h-[60px] border-b border-gray-300 relative flex items-center bg-white select-none">
<div className="flex-shrink-0 h-[60px] border-b border-custom-border-300 relative flex items-center bga-white select-none">
<IssueNavbar />
</div>
{/* <div className="flex-shrink-0 min-h-[50px] h-auto py-1.5 border-b border-gray-300 relative flex items-center shadow-md bg-white select-none">
<IssueFilter />
</div> */}
<div className="w-full h-full relative bg-gray-100/50 overflow-hidden">{children}</div>
<IssueFilter />
<div className="w-full h-full relative overflow-hidden">{children}</div>
<div className="absolute z-[99999] bottom-[10px] right-[10px] bg-white rounded-sm shadow-lg border border-gray-100">
<div className="absolute z-[99999] bottom-[10px] right-[10px] bg-custom-background-100 rounded-sm shadow-lg border border-custom-border-200">
<Link href="https://plane.so" className="p-1 px-2 flex items-center gap-1" target="_blank">
<div className="w-[24px] h-[24px] relative flex justify-center items-center">
<Image src="/plane-logo.webp" alt="plane logo" className="w-[24px] h-[24px]" height="24" width="24" />
</div>
<div className="text-xs">
<div className="text-xs text-custom-text-200">
Powered by <b>Plane Deploy</b>
</div>
</Link>

View File

@ -1,6 +1,6 @@
"use client";
import { useEffect } from "react";
import { useEffect, useState } from "react";
// next imports
import { useRouter, useParams, useSearchParams } from "next/navigation";
// mobx
@ -11,25 +11,33 @@ import { IssueKanbanView } from "components/issues/board-views/kanban";
import { IssueCalendarView } from "components/issues/board-views/calendar";
import { IssueSpreadsheetView } from "components/issues/board-views/spreadsheet";
import { IssueGanttView } from "components/issues/board-views/gantt";
import { IssuePeekOverview } from "components/issues/peek-overview";
// mobx store
import { RootStore } from "store/root";
import { useMobxStore } from "lib/mobx/store-provider";
// types
import { TIssueBoardKeys } from "store/types";
const WorkspaceProjectPage = observer(() => {
const WorkspaceProjectPage = () => {
const store: RootStore = useMobxStore();
const router = useRouter();
const routerParams = useParams();
const routerSearchparams = useSearchParams();
const activeIssueId = store.issue.activePeekOverviewIssueId;
const { workspace_slug, project_slug } = routerParams as { workspace_slug: string; project_slug: string };
const board =
routerSearchparams &&
routerSearchparams.get("board") != null &&
(routerSearchparams.get("board") as TIssueBoardKeys | "");
const states = routerSearchparams && routerSearchparams.get("states") != null && routerSearchparams.get("states");
const labels = routerSearchparams && routerSearchparams.get("labels") != null && routerSearchparams.get("labels");
const priorities =
routerSearchparams && routerSearchparams.get("priorities") != null && routerSearchparams.get("priorities");
// updating default board view when we are in the issues page
useEffect(() => {
if (workspace_slug && project_slug && store?.project?.workspaceProjectSettings) {
@ -44,57 +52,96 @@ const WorkspaceProjectPage = observer(() => {
if (_key === "gantt" && workspacePRojectSettingViews.gantt === true) userAccessViews.push(_key);
});
let url = `/${workspace_slug}/${project_slug}`;
let _board = board;
if (userAccessViews && userAccessViews.length > 0) {
if (!board) {
store.issue.setCurrentIssueBoardView(userAccessViews[0]);
router.replace(`/${workspace_slug}/${project_slug}?board=${userAccessViews[0]}`);
_board = userAccessViews[0];
} else {
if (userAccessViews.includes(board)) {
if (store.issue.currentIssueBoardView === null) store.issue.setCurrentIssueBoardView(board);
else {
if (board === store.issue.currentIssueBoardView)
router.replace(`/${workspace_slug}/${project_slug}?board=${board}`);
else {
if (board === store.issue.currentIssueBoardView) {
_board = board;
} else {
_board = board;
store.issue.setCurrentIssueBoardView(board);
router.replace(`/${workspace_slug}/${project_slug}?board=${board}`);
}
}
} else {
store.issue.setCurrentIssueBoardView(userAccessViews[0]);
router.replace(`/${workspace_slug}/${project_slug}?board=${userAccessViews[0]}`);
_board = userAccessViews[0];
}
}
}
_board = _board || "list";
url = `${url}?board=${_board}`;
if (states) url = `${url}&states=${states}`;
if (labels) url = `${url}&labels=${labels}`;
if (priorities) url = `${url}&priorities=${priorities}`;
url = decodeURIComponent(url);
router.replace(url);
}
}, [workspace_slug, project_slug, board, router, store?.issue, store?.project?.workspaceProjectSettings]);
}, [
workspace_slug,
project_slug,
board,
router,
store?.issue,
store?.project?.workspaceProjectSettings,
states,
labels,
priorities,
]);
useEffect(() => {
if (workspace_slug && project_slug) {
store?.project?.getProjectSettingsAsync(workspace_slug, project_slug);
store?.issue?.getIssuesAsync(workspace_slug, project_slug);
}
}, [workspace_slug, project_slug, store?.project, store?.issue]);
if (!workspace_slug || !project_slug) return;
const params = {
state: states || null,
labels: labels || null,
priority: priorities || null,
};
store?.project?.getProjectSettingsAsync(workspace_slug, project_slug);
store?.issue?.getIssuesAsync(workspace_slug, project_slug, params);
}, [workspace_slug, project_slug, store?.project, store?.issue, states, labels, priorities]);
return (
<div className="relative w-full h-full overflow-hidden">
<IssuePeekOverview
isOpen={Boolean(activeIssueId)}
onClose={() => store.issue.setActivePeekOverviewIssueId(null)}
issue={store?.issue?.issues?.find((_issue) => _issue.id === activeIssueId) || null}
workspaceSlug={workspace_slug}
/>
{store?.issue?.loader && !store.issue.issues ? (
<div className="text-sm text-center py-10 text-gray-500">Loading...</div>
<div className="text-sm text-center py-10 text-custom-text-100">Loading...</div>
) : (
<>
{store?.issue?.error ? (
<div className="text-sm text-center py-10 text-gray-500">Something went wrong.</div>
<div className="text-sm text-center py-10 bg-custom-background-200 text-custom-text-100">
Something went wrong.
</div>
) : (
store?.issue?.currentIssueBoardView && (
<>
{store?.issue?.currentIssueBoardView === "list" && (
<div className="relative w-full h-full overflow-y-auto">
<div className="container mx-auto px-5 py-3">
<div className="mx-auto px-4">
<IssueListView />
</div>
</div>
)}
{store?.issue?.currentIssueBoardView === "kanban" && (
<div className="relative w-full h-full mx-auto px-5">
<div className="relative w-full h-full mx-auto px-9 py-5">
<IssueKanbanView />
</div>
)}
@ -108,6 +155,6 @@ const WorkspaceProjectPage = observer(() => {
)}
</div>
);
});
};
export default WorkspaceProjectPage;
export default observer(WorkspaceProjectPage);

View File

@ -2,16 +2,27 @@
// root styles
import "styles/globals.css";
// next theme
import { ThemeProvider } from "next-themes";
// toast alert
import { ToastContextProvider } from "contexts/toast.context";
// mobx store provider
import { MobxStoreProvider } from "lib/mobx/store-provider";
import MobxStoreInit from "lib/mobx/store-init";
const RootLayout = ({ children }: { children: React.ReactNode }) => (
<html lang="en">
<body className="antialiased w-100">
<html lang="en" suppressHydrationWarning>
<body className="antialiased bg-custom-background-90 w-100">
<MobxStoreProvider>
<MobxStoreInit />
<main>{children}</main>
<ToastContextProvider>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<main>{children}</main>
</ThemeProvider>
</ToastContextProvider>
</MobxStoreProvider>
</body>
</html>

View File

@ -0,0 +1,200 @@
import { useEffect, Fragment } from "react";
// next
import { useSearchParams, useRouter } from "next/navigation";
// react-hook-form
import { Controller, useForm } from "react-hook-form";
// mobx
import { observer } from "mobx-react-lite";
import { useMobxStore } from "lib/mobx/store-provider";
// headless ui
import { Listbox, Transition } from "@headlessui/react";
// icons
import { ChevronDownIcon, CheckIcon } from "@heroicons/react/20/solid";
// constants
import { USER_ROLES } from "constants/workspace";
// hooks
import useToast from "hooks/use-toast";
// services
import UserService from "services/user.service";
// ui
import { Input, PrimaryButton } from "components/ui";
const defaultValues = {
first_name: "",
last_name: "",
role: "",
};
type Props = {
user?: any;
};
export const UserDetails: React.FC<Props> = observer(({ user }) => {
const { setToastAlert } = useToast();
const router = useRouter();
const searchParams = useSearchParams();
const { user: userStore } = useMobxStore();
const {
register,
handleSubmit,
control,
reset,
formState: { errors, isSubmitting, isValid },
} = useForm({
defaultValues,
});
const onSubmit = async (formData: any) => {
const payload = {
...formData,
onboarding_step: {
...user.onboarding_step,
profile_complete: true,
},
};
const userService = new UserService();
await userService
.updateMe(payload)
.then((response) => {
userStore.setCurrentUser(response);
const nextPath = searchParams?.get("next_path") || "/";
router.push(nextPath);
setToastAlert({
type: "success",
title: "Success!",
message: "Details updated successfully.",
});
})
.catch((err) => {});
};
useEffect(() => {
if (user) {
reset({
first_name: user.first_name,
last_name: user.last_name,
role: user.role,
});
}
}, [user, reset]);
return (
<form
className="h-full w-full space-y-7 sm:space-y-10 overflow-y-auto sm:flex sm:flex-col sm:items-start sm:justify-center"
onSubmit={handleSubmit(onSubmit)}
>
<div className="relative sm:text-lg">
<div className="text-gray-800 absolute -top-1 -left-3">{'"'}</div>
<h5>Hey there 👋🏻</h5>
<h5 className="mt-5 mb-6">Let{"'"}s get you onboard!</h5>
<h4 className="text-xl sm:text-2xl font-semibold">Set up your Plane profile.</h4>
</div>
<div className="space-y-7 sm:w-3/4 md:w-2/5">
<div className="space-y-1 text-sm">
<label htmlFor="firstName">First Name</label>
<Input
id="firstName"
autoComplete="off"
placeholder="Enter your first name..."
{...register("first_name", {
required: "First name is required",
})}
/>
{errors.first_name && <div className="text-sm text-red-500">{errors.first_name.message}</div>}
</div>
<div className="space-y-1 text-sm">
<label htmlFor="lastName">Last Name</label>
<Input
id="lastName"
autoComplete="off"
placeholder="Enter your last name..."
{...register("last_name", {
required: "Last name is required",
})}
/>
{errors.last_name && <div className="text-sm text-red-500">{errors.last_name.message}</div>}
</div>
<div className="space-y-1 text-sm">
<span>What{"'"}s your role?</span>
<div className="w-full">
<Controller
name="role"
control={control}
rules={{ required: "This field is required" }}
render={({ field: { value, onChange } }) => (
<Listbox as="div" value={value} onChange={onChange} className="relative flex-shrink-0 text-left">
<Listbox.Button
type="button"
className={`flex items-center justify-between gap-1 w-full rounded-md border border-custom-border-300 shadow-sm duration-300 focus:outline-none px-3 py-2 text-sm`}
>
<span className="text-custom-text-400">{value || "Select your role..."}</span>
<ChevronDownIcon className="h-3 w-3" aria-hidden="true" />
</Listbox.Button>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Listbox.Options
className={`absolute z-10 border border-custom-border-300 mt-1 overflow-y-auto rounded-md bg-custom-background-90 text-xs shadow-lg focus:outline-none w-full max-h-36 left-0 origin-top-left`}
>
<div className="space-y-1 p-2">
{USER_ROLES.map((role) => (
<Listbox.Option
key={role.value}
value={role.value}
className={({ active, selected }) =>
`cursor-pointer select-none truncate rounded px-1 py-1.5 ${
active || selected ? "bg-custom-background-80" : ""
} ${selected ? "text-custom-text-100" : "text-custom-text-200"}`
}
>
{({ selected }) => (
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<span>{role.label}</span>
</div>
{selected && <CheckIcon className="h-4 w-4 flex-shrink-0" />}
</div>
)}
</Listbox.Option>
))}
</div>
</Listbox.Options>
</Transition>
</Listbox>
)}
/>
{errors.role && <span className="text-sm text-red-500">{errors.role.message}</span>}
</div>
</div>
</div>
<PrimaryButton type="submit" size="md" disabled={!isValid} loading={isSubmitting}>
{isSubmitting ? "Updating..." : "Continue"}
</PrimaryButton>
</form>
);
});

View File

@ -0,0 +1,62 @@
"use client";
import React, { useEffect } from "react";
// next
import Image from "next/image";
// assets
import BluePlaneLogoWithoutText from "public/plane-logos/blue-without-text.png";
import BlackHorizontalLogo from "public/plane-logos/black-horizontal-with-blue-logo.svg";
// mobx
import { observer } from "mobx-react-lite";
import { useMobxStore } from "lib/mobx/store-provider";
// services
import authenticationService from "services/authentication.service";
// hooks
import useToast from "hooks/use-toast";
// components
import { UserDetails } from "./components/user-details";
const HomePage = () => {
const { user: userStore } = useMobxStore();
const user = userStore?.currentUser;
const { setToastAlert } = useToast();
useEffect(() => {
const user = userStore?.currentUser;
if (!user) {
userStore.getUserAsync();
}
}, [userStore]);
return (
<div className="h-screen w-full overflow-hidden bg-custom-background-100">
<div className="flex h-full w-full flex-col gap-y-2 sm:gap-y-0 sm:flex-row overflow-hidden">
<div className="relative h-1/6 flex-shrink-0 sm:w-2/12 md:w-3/12 lg:w-1/5">
<div className="absolute border-b-[0.5px] sm:border-r-[0.5px] border-custom-border-200 h-[0.5px] w-full top-1/2 left-0 -translate-y-1/2 sm:h-screen sm:w-[0.5px] sm:top-0 sm:left-1/2 md:left-1/3 sm:-translate-x-1/2 sm:translate-y-0 z-10" />
<div className="absolute grid place-items-center bg-custom-background-100 px-3 sm:px-0 py-5 left-2 sm:left-1/2 md:left-1/3 sm:-translate-x-1/2 top-1/2 -translate-y-1/2 sm:translate-y-0 sm:top-12 z-10">
<div className="h-[30px] w-[30px]">
<Image src={BluePlaneLogoWithoutText} alt="Plane logo" />
</div>
</div>
<div className="absolute sm:fixed text-custom-text-100 text-sm font-medium right-4 top-1/4 sm:top-12 -translate-y-1/2 sm:translate-y-0 sm:right-16 sm:py-5">
{user?.email}
</div>
</div>
<div className="relative flex justify-center sm:items-center h-full px-8 pb-0 sm:px-0 sm:py-12 sm:pr-[8.33%] sm:w-10/12 md:w-9/12 lg:w-4/5 overflow-hidden">
<UserDetails user={user} />
</div>
</div>
</div>
);
};
export default observer(HomePage);

View File

@ -1,9 +1,171 @@
"use client";
import React from "react";
import React, { useEffect } from "react";
const HomePage = () => (
<div className="relative w-screen h-screen flex justify-center items-center text-5xl">Plane Deploy</div>
);
// next
import Image from "next/image";
import Router from "next/router";
import { useRouter, useSearchParams } from "next/navigation";
export default HomePage;
// assets
import BluePlaneLogoWithoutText from "public/plane-logos/blue-without-text.png";
// mobx
import { observer } from "mobx-react-lite";
import { useMobxStore } from "lib/mobx/store-provider";
// services
import authenticationService from "services/authentication.service";
// hooks
import useToast from "hooks/use-toast";
// components
import { EmailPasswordForm } from "./(auth)/components/email-password-form";
import { GithubLoginButton } from "./(auth)/components/github-login-button";
import { GoogleLoginButton } from "./(auth)/components/google-login";
import { EmailCodeForm } from "./(auth)/components/email-code-form";
const HomePage = () => {
const { user: userStore } = useMobxStore();
const router = useRouter();
const searchParams = useSearchParams();
const { setToastAlert } = useToast();
const onSignInError = (error: any) => {
setToastAlert({
title: "Error signing in!",
type: "error",
message: error?.error || "Something went wrong. Please try again later or contact the support team.",
});
};
const onSignInSuccess = (response: any) => {
const isOnboarded = response?.user?.onboarding_step?.profile_complete || false;
const nextPath = searchParams?.get("next_path") || "/";
userStore.setCurrentUser(response?.user);
if (!isOnboarded) {
router.push(`/onboarding?next_path=${nextPath}`);
return;
}
router.push(nextPath);
};
const handleGoogleSignIn = async ({ clientId, credential }: any) => {
try {
if (clientId && credential) {
const socialAuthPayload = {
medium: "google",
credential,
clientId,
};
const response = await authenticationService.socialAuth(socialAuthPayload);
onSignInSuccess(response);
} else {
throw Error("Cant find credentials");
}
} catch (err: any) {
onSignInError(err);
}
};
const handleGitHubSignIn = async (credential: string) => {
try {
if (process.env.NEXT_PUBLIC_GITHUB_ID && credential) {
const socialAuthPayload = {
medium: "github",
credential,
clientId: process.env.NEXT_PUBLIC_GITHUB_ID,
};
const response = await authenticationService.socialAuth(socialAuthPayload);
onSignInSuccess(response);
} else {
throw Error("Cant find credentials");
}
} catch (err: any) {
onSignInError(err);
}
};
const handlePasswordSignIn = async (formData: any) => {
await authenticationService
.emailLogin(formData)
.then((response) => {
try {
if (response) {
onSignInSuccess(response);
}
} catch (err: any) {
onSignInError(err);
}
})
.catch((err) => onSignInError(err));
};
const handleEmailCodeSignIn = async (response: any) => {
try {
if (response) {
onSignInSuccess(response);
}
} catch (err: any) {
onSignInError(err);
}
};
return (
<div className="h-screen w-full overflow-hidden">
<div className="hidden sm:block sm:fixed border-r-[0.5px] border-custom-border-200 h-screen w-[0.5px] top-0 left-20 lg:left-32" />
<div className="fixed grid place-items-center bg-custom-background-100 sm:py-5 top-11 sm:top-12 left-7 sm:left-16 lg:left-28">
<div className="grid place-items-center bg-custom-background-100">
<div className="h-[30px] w-[30px]">
<Image src={BluePlaneLogoWithoutText} alt="Plane Logo" />
</div>
</div>
</div>
<div className="grid place-items-center h-full overflow-y-auto py-5 px-7">
<div>
{parseInt(process.env.NEXT_PUBLIC_ENABLE_OAUTH || "0") ? (
<>
<h1 className="text-center text-2xl sm:text-2.5xl font-semibold text-custom-text-100">
Sign in to Plane
</h1>
<div className="flex flex-col divide-y divide-custom-border-200">
<div className="pb-7">
<EmailCodeForm handleSignIn={handleEmailCodeSignIn} />
</div>
<div className="flex flex-col items-center justify-center gap-4 pt-7 sm:w-[360px] mx-auto overflow-hidden">
<GoogleLoginButton handleSignIn={handleGoogleSignIn} />
<GithubLoginButton handleSignIn={handleGitHubSignIn} />
</div>
</div>
</>
) : (
<EmailPasswordForm onSubmit={handlePasswordSignIn} />
)}
{parseInt(process.env.NEXT_PUBLIC_ENABLE_OAUTH || "0") ? (
<p className="pt-16 text-custom-text-200 text-sm text-center">
By signing up, you agree to the{" "}
<a
href="https://plane.so/terms-and-conditions"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline"
>
Terms & Conditions
</a>
</p>
) : null}
</div>
</div>
</div>
);
};
export default observer(HomePage);

View File

@ -24,7 +24,7 @@ const validDate = (date: any, state: string): string => {
export const IssueBlockDueDate = ({ due_date, state }: any) => (
<div
className={`h-[24px] rounded-sm flex px-2 items-center border border-gray-300 gap-1 text-gray-700 text-xs font-medium
className={`h-[24px] rounded-md flex px-2.5 py-1 items-center border border-custom-border-100 gap-1 text-custom-text-100 text-xs font-medium
${validDate(due_date, state)}`}
>
{renderDateFormat(due_date)}

View File

@ -6,11 +6,13 @@ export const IssueBlockLabels = ({ labels }: any) => (
labels.length > 0 &&
labels.map((_label: any) => (
<div
className={`h-[24px] rounded-sm flex px-1 items-center border gap-1 !bg-transparent !text-gray-700`}
style={{ backgroundColor: `${_label?.color}10`, borderColor: `${_label?.color}50` }}
key={_label?.id}
className="flex cursor-default items-center flex-shrink-0 rounded-md border border-custom-border-300 px-2.5 py-1 text-xs shadow-sm"
>
<div className="w-[10px] h-[10px] rounded-full" style={{ backgroundColor: `${_label?.color}` }} />
<div className="text-sm">{_label?.name}</div>
<div className="flex items-center gap-1.5 text-custom-text-200">
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: `${_label?.color}` }} />
<div className="text-xs">{_label?.name}</div>
</div>
</div>
))}
</div>

View File

@ -10,8 +10,8 @@ export const IssueBlockPriority = ({ priority }: { priority: TIssuePriorityKey |
if (priority_detail === null) return <></>;
return (
<div className={`w-[24px] h-[24px] rounded-sm flex justify-center items-center ${priority_detail?.className}`}>
<span className="material-symbols-rounded text-[16px]">{priority_detail?.icon}</span>
<div className={`h-6 w-6 rounded flex justify-center items-center ${priority_detail?.className}`}>
<span className="material-symbols-rounded text-sm">{priority_detail?.icon}</span>
</div>
);
};

View File

@ -8,11 +8,11 @@ export const IssueBlockState = ({ state }: any) => {
if (stateGroup === null) return <></>;
return (
<div
className={`h-[24px] rounded-sm flex px-1 items-center border ${stateGroup?.className} gap-1 !bg-transparent !text-gray-700`}
>
<stateGroup.icon />
<div className="text-sm">{state?.name}</div>
<div className="flex items-center justify-between gap-1 w-full rounded-md shadow-sm border border-custom-border-300 duration-300 focus:outline-none px-2.5 py-1 text-xs cursor-pointer hover:bg-custom-background-80">
<div className="flex items-center cursor-pointer w-full gap-2 text-custom-text-200">
<stateGroup.icon />
<div className="text-xs">{state?.name}</div>
</div>
</div>
);
};

View File

@ -2,29 +2,36 @@
// mobx react lite
import { observer } from "mobx-react-lite";
import { useMobxStore } from "lib/mobx/store-provider";
// components
import { IssueBlockPriority } from "components/issues/board-views/block-priority";
import { IssueBlockState } from "components/issues/board-views/block-state";
import { IssueBlockLabels } from "components/issues/board-views/block-labels";
import { IssueBlockDueDate } from "components/issues/board-views/block-due-date";
// mobx hook
import { useMobxStore } from "lib/mobx/store-provider";
// interfaces
import { IIssue } from "store/types/issue";
import { RootStore } from "store/root";
export const IssueListBlock = ({ issue }: { issue: IIssue }) => {
export const IssueListBlock = observer(({ issue }: { issue: IIssue }) => {
const store: RootStore = useMobxStore();
const { issue: issueStore } = store;
return (
<div className="p-2 px-3 bg-white space-y-2 rounded-sm shadow">
<div className="p-3.5 h-[118px] flex flex-col justify-between bg-custom-background-100 space-y-2 rounded shadow">
{/* id */}
<div className="flex-shrink-0 text-sm text-gray-600 w-[60px]">
<div className="flex-shrink-0 text-xs font-medium text-custom-text-200 w-[60px]">
{store?.project?.project?.identifier}-{issue?.sequence_id}
</div>
{/* name */}
<div className="font-medium text-gray-800 h-full line-clamp-2">{issue.name}</div>
<div
onClick={() => issueStore?.setActivePeekOverviewIssueId(issue?.id)}
className="text-custom-text-100 text-sm font-medium h-full break-words line-clamp-2 cursor-pointer"
>
{issue.name}
</div>
{/* priority */}
<div className="relative flex flex-wrap items-center gap-2 w-full">
@ -54,4 +61,4 @@ export const IssueListBlock = ({ issue }: { issue: IIssue }) => {
</div>
</div>
);
};
});

View File

@ -18,12 +18,12 @@ export const IssueListHeader = observer(({ state }: { state: IIssueState }) => {
if (stateGroup === null) return <></>;
return (
<div className="py-2 flex items-center gap-2">
<div className="pb-3 flex items-center">
<div className="w-[28px] h-[28px] flex justify-center items-center">
<stateGroup.icon />
</div>
<div className="font-medium capitalize">{state?.name}</div>
<div className="bg-gray-200/50 text-gray-700 font-medium text-xs w-full max-w-[26px] h-[20px] flex justify-center items-center rounded-full">
<div className="font-semibold text-base capitalize ml-2 mr-3">{state?.name}</div>
<div className="text-gray-700 w-full max-w-[26px] h-[20px] flex justify-center items-center rounded-full">
{store.issue.getCountOfIssuesByState(state.id)}
</div>
</div>

View File

@ -19,7 +19,7 @@ export const IssueKanbanView = observer(() => {
{store?.issue?.states &&
store?.issue?.states.length > 0 &&
store?.issue?.states.map((_state: IIssueState) => (
<div className="flex-shrink-0 relative w-[340px] h-full flex flex-col">
<div key={_state.id} className="flex-shrink-0 relative w-[340px] h-full flex flex-col">
<div className="flex-shrink-0">
<IssueListHeader state={_state} />
</div>
@ -28,11 +28,11 @@ export const IssueKanbanView = observer(() => {
store.issue.getFilteredIssuesByState(_state.id).length > 0 ? (
<div className="space-y-3 pb-2">
{store.issue.getFilteredIssuesByState(_state.id).map((_issue: IIssue) => (
<IssueListBlock issue={_issue} />
<IssueListBlock key={_issue.id} issue={_issue} />
))}
</div>
) : (
<div className="relative w-full h-full flex justify-center items-center p-10 text-center text-sm text-gray-600">
<div className="relative w-full h-full flex justify-center items-center p-10 text-center text-sm text-custom-text-200">
No Issues are available.
</div>
)}

View File

@ -13,47 +13,58 @@ import { useMobxStore } from "lib/mobx/store-provider";
import { IIssue } from "store/types/issue";
import { RootStore } from "store/root";
export const IssueListBlock = ({ issue }: { issue: IIssue }) => {
export const IssueListBlock = observer(({ issue }: { issue: IIssue }) => {
const store: RootStore = useMobxStore();
return (
<div className="p-2 px-3 relative flex items-center gap-3">
<div className="relative flex items-center gap-3 w-full">
<div className="flex items-center px-9 py-3.5 relative gap-10 border-b border-custom-border-200 bg-custom-background-100 last:border-b-0">
<div className="relative flex items-center gap-6 w-full flex-grow overflow-hidden">
{/* id */}
<div className="flex-shrink-0 text-sm text-gray-600 w-[60px]">
<div className="flex-shrink-0 text-sm w-[60px] text-custom-text-200">
{store?.project?.project?.identifier}-{issue?.sequence_id}
</div>
{/* name */}
<div className="font-medium text-gray-800 h-full line-clamp-1">{issue.name}</div>
<div className="h-full line-clamp-1 w-full overflow-ellipsis cursor-pointer">
<p
onClick={() => {
store.issue.setActivePeekOverviewIssueId(issue.id);
}}
className="text-[0.825rem] font-medium text-sm truncate text-custom-text-100"
>
{issue.name}
</p>
</div>
</div>
{/* priority */}
{issue?.priority && (
<div className="flex-shrink-0">
<IssueBlockPriority priority={issue?.priority} />
</div>
)}
<div className="inline-flex flex-shrink-0 items-center gap-2 text-xs">
{/* priority */}
{issue?.priority && (
<div className="flex-shrink-0">
<IssueBlockPriority priority={issue?.priority} />
</div>
)}
{/* state */}
{issue?.state_detail && (
<div className="flex-shrink-0">
<IssueBlockState state={issue?.state_detail} />
</div>
)}
{/* state */}
{issue?.state_detail && (
<div className="flex-shrink-0">
<IssueBlockState state={issue?.state_detail} />
</div>
)}
{/* labels */}
{issue?.label_details && issue?.label_details.length > 0 && (
<div className="flex-shrink-0">
<IssueBlockLabels labels={issue?.label_details} />
</div>
)}
{/* labels */}
{issue?.label_details && issue?.label_details.length > 0 && (
<div className="flex-shrink-0">
<IssueBlockLabels labels={issue?.label_details} />
</div>
)}
{/* due date */}
{issue?.target_date && (
<div className="flex-shrink-0">
<IssueBlockDueDate due_date={issue?.target_date} group={issue?.state_detail?.group} />
</div>
)}
{/* due date */}
{issue?.target_date && (
<div className="flex-shrink-0">
<IssueBlockDueDate due_date={issue?.target_date} group={issue?.state_detail?.group} />
</div>
)}
</div>
</div>
);
};
});

View File

@ -18,14 +18,12 @@ export const IssueListHeader = observer(({ state }: { state: IIssueState }) => {
if (stateGroup === null) return <></>;
return (
<div className="py-2 px-3 flex items-center gap-2">
<div className="py-2 px-3 flex items-center">
<div className="w-[28px] h-[28px] flex justify-center items-center">
<stateGroup.icon />
</div>
<div className="font-medium capitalize">{state?.name}</div>
<div className="bg-gray-200/50 text-gray-700 font-medium text-xs w-full max-w-[26px] h-[20px] flex justify-center items-center rounded-full">
{store.issue.getCountOfIssuesByState(state.id)}
</div>
<div className="font-semibold leading-6 text-base capitalize ml-2 mr-3">{state?.name}</div>
<div className="text-gray-700 font-normal text-base">{store.issue.getCountOfIssuesByState(state.id)}</div>
</div>
);
});

View File

@ -19,17 +19,19 @@ export const IssueListView = observer(() => {
{store?.issue?.states &&
store?.issue?.states.length > 0 &&
store?.issue?.states.map((_state: IIssueState) => (
<div className="relative w-full">
<div key={_state.id} className="relative w-full">
<IssueListHeader state={_state} />
{store.issue.getFilteredIssuesByState(_state.id) &&
store.issue.getFilteredIssuesByState(_state.id).length > 0 ? (
<div className="bg-white divide-y">
<div className="divide-y">
{store.issue.getFilteredIssuesByState(_state.id).map((_issue: IIssue) => (
<IssueListBlock issue={_issue} />
<IssueListBlock key={_issue.id} issue={_issue} />
))}
</div>
) : (
<div className="bg-white p-5 text-sm text-gray-600">No Issues are available.</div>
<div className="px-9 py-3.5 text-sm text-custom-text-200 bg-custom-background-100">
No Issues are available.
</div>
)}
</div>
))}

View File

@ -1,38 +0,0 @@
"use client";
// mobx react lite
import { observer } from "mobx-react-lite";
// mobx hook
import { useMobxStore } from "lib/mobx/store-provider";
const IssueDateFilter = observer(() => {
const store = useMobxStore();
return (
<>
<div className="flex items-center gap-2 border border-gray-300 px-2 py-1 pr-1 rounded">
<div className="flex-shrink-0 font-medium">Due Date</div>
<div className="relative flex flex-wrap items-center gap-1">
{/* <div className="flex items-center gap-1 border border-gray-300 px-[2px] py-0.5 rounded-full">
<div className="w-[18px] h-[18px] cursor-pointer flex justify-center items-center overflow-hidden rounded-full border border-gray-300">
<span className={`material-symbols-rounded text-[16px]`}>close</span>
</div>
<div>Backlog</div>
<div
className={`w-[18px] h-[18px] cursor-pointer flex justify-center items-center overflow-hidden rounded-full text-gray-500 hover:bg-gray-200/60 hover:text-gray-600`}
>
<span className={`material-symbols-rounded text-[16px]`}>close</span>
</div>
</div> */}
</div>
<div
className={`w-[20px] h-[20px] cursor-pointer flex justify-center items-center overflow-hidden rounded-sm text-gray-500 hover:bg-gray-200/60 hover:text-gray-600`}
>
<span className={`material-symbols-rounded text-[16px]`}>close</span>
</div>
</div>
</>
);
});
export default IssueDateFilter;

View File

@ -1,12 +1,13 @@
"use client";
import { useRouter, useParams } from "next/navigation";
// mobx react lite
import { observer } from "mobx-react-lite";
// components
import IssueStateFilter from "./state";
import IssueLabelFilter from "./label";
import IssuePriorityFilter from "./priority";
import IssueDateFilter from "./date";
// mobx hook
import { useMobxStore } from "lib/mobx/store-provider";
import { RootStore } from "store/root";
@ -14,24 +15,41 @@ import { RootStore } from "store/root";
const IssueFilter = observer(() => {
const store: RootStore = useMobxStore();
const clearAllFilters = () => {};
const router = useRouter();
const routerParams = useParams();
const { workspace_slug, project_slug } = routerParams as { workspace_slug: string; project_slug: string };
const clearAllFilters = () => {
router.replace(
store.issue.getURLDefinition(workspace_slug, project_slug, {
key: "all",
removeAll: true,
})
);
};
if (store.issue.getIfFiltersIsEmpty()) return null;
return (
<div className="container mx-auto px-5 flex justify-start items-center flex-wrap gap-2 text-sm">
{/* state */}
{store?.issue?.states && <IssueStateFilter />}
{/* labels */}
{store?.issue?.labels && <IssueLabelFilter />}
{/* priority */}
<IssuePriorityFilter />
{/* due date */}
<IssueDateFilter />
{/* clear all filters */}
<div
className="flex items-center gap-2 border border-gray-300 px-2 py-1 pr-1 rounded cursor-pointer hover:bg-gray-200/60"
onClick={clearAllFilters}
>
<div>Clear all filters</div>
<div className="flex-shrink-0 min-h-[50px] h-auto py-1.5 border-b border-custom-border-200 relative flex items-center shadow-md bg-whiate select-none">
<div className="px-5 flex justify-start items-center flex-wrap gap-2 text-sm">
{/* state */}
{store.issue.checkIfFilterExistsForKey("state") && <IssueStateFilter />}
{/* labels */}
{store.issue.checkIfFilterExistsForKey("label") && <IssueLabelFilter />}
{/* priority */}
{store.issue.checkIfFilterExistsForKey("priority") && <IssuePriorityFilter />}
{/* clear all filters */}
<div
className="flex items-center gap-2 border border-custom-border-200 px-2 py-1 cursor-pointer text-xs rounded-full"
onClick={clearAllFilters}
>
<div>Clear all filters</div>
<div className="flex-shrink-0 w-3 h-3 cursor-pointer flex justify-center items-center overflow-hidden rounded-sm">
<span className="material-symbols-rounded text-[12px]">close</span>
</div>
</div>
</div>
</div>
);

View File

@ -1,33 +1,47 @@
"use client";
import { useRouter, useParams } from "next/navigation";
// mobx react lite
import { observer } from "mobx-react-lite";
// mobx hook
import { useMobxStore } from "lib/mobx/store-provider";
// interfaces
import { IIssueLabel } from "store/types/issue";
// constants
import { issueGroupFilter } from "constants/data";
export const RenderIssueLabel = observer(({ label }: { label: IIssueLabel }) => {
const store = useMobxStore();
const removeLabelFromFilter = () => {};
const router = useRouter();
const routerParams = useParams();
const { workspace_slug, project_slug } = routerParams as { workspace_slug: string; project_slug: string };
const removeLabelFromFilter = () => {
router.replace(
store.issue.getURLDefinition(workspace_slug, project_slug, {
key: "label",
value: label?.id,
})
);
};
return (
<div
className="flex-shrink-0 relative flex items-center flex-wrap gap-1 border px-[2px] py-0.5 rounded-full select-none"
style={{ color: label?.color, backgroundColor: `${label?.color}10`, borderColor: `${label?.color}50` }}
className="flex-shrink-0 relative flex items-center flex-wrap gap-1 px-2 py-0.5 rounded-full select-none"
style={{ color: label?.color, backgroundColor: `${label?.color}10` }}
>
<div className="flex-shrink-0 w-[20px] h-[20px] flex justify-center items-center overflow-hidden rounded-full">
<div className="w-[10px] h-[10px] rounded-full" style={{ backgroundColor: `${label?.color}` }} />
</div>
<div className="text-sm font-medium whitespace-nowrap">{label?.name}</div>
<div
className="flex-shrink-0 w-[20px] h-[20px] cursor-pointer flex justify-center items-center overflow-hidden rounded-full text-gray-500 hover:bg-gray-200/60 hover:text-gray-600"
className="flex-shrink-0 w-1.5 h-1.5 flex justify-center items-center overflow-hidden rounded-full"
style={{ backgroundColor: `${label?.color}` }}
/>
<div className="font-medium whitespace-nowrap text-xs">{label?.name}</div>
<div
className="flex-shrink-0 w-3 h-3 cursor-pointer flex justify-center items-center overflow-hidden rounded-full"
onClick={removeLabelFromFilter}
>
<span className="material-symbols-rounded text-[14px]">close</span>
<span className="material-symbols-rounded text-xs">close</span>
</div>
</div>
);

View File

@ -1,5 +1,7 @@
"use client";
import { useRouter, useParams } from "next/navigation";
// mobx react lite
import { observer } from "mobx-react-lite";
// components
@ -13,21 +15,38 @@ import { RootStore } from "store/root";
const IssueLabelFilter = observer(() => {
const store: RootStore = useMobxStore();
const clearLabelFilters = () => {};
const router = useRouter();
const routerParams = useParams();
const { workspace_slug, project_slug } = routerParams as { workspace_slug: string; project_slug: string };
const clearLabelFilters = () => {
router.replace(
store.issue.getURLDefinition(workspace_slug, project_slug, {
key: "label",
removeAll: true,
})
);
};
return (
<>
<div className="flex items-center gap-2 border border-gray-300 px-2 py-1 pr-1 rounded">
<div className="flex-shrink-0 font-medium">Labels</div>
<div className="flex items-center gap-2 border border-custom-border-300 px-2 py-1 rounded-full text-xs">
<div className="flex-shrink-0 text-custom-text-200">Labels</div>
<div className="relative flex flex-wrap items-center gap-1">
{store?.issue?.labels &&
store?.issue?.labels.map((_label: IIssueLabel, _index: number) => <RenderIssueLabel label={_label} />)}
store?.issue?.labels.map(
(_label: IIssueLabel, _index: number) =>
store.issue.getUserSelectedFilter("label", _label.id) && (
<RenderIssueLabel key={_label.id} label={_label} />
)
)}
</div>
<div
className="flex-shrink-0 w-[20px] h-[20px] cursor-pointer flex justify-center items-center overflow-hidden rounded-sm text-gray-500 hover:bg-gray-200/60 hover:text-gray-600"
className="flex-shrink-0 w-3 h-3 cursor-pointer flex justify-center items-center overflow-hidden rounded-sm"
onClick={clearLabelFilters}
>
<span className="material-symbols-rounded text-[16px]">close</span>
<span className="material-symbols-rounded text-[12px]">close</span>
</div>
</div>
</>

View File

@ -1,5 +1,7 @@
"use client";
import { useRouter, useParams } from "next/navigation";
// mobx react lite
import { observer } from "mobx-react-lite";
// mobx hook
@ -10,23 +12,36 @@ import { IIssuePriorityFilters } from "store/types/issue";
export const RenderIssuePriority = observer(({ priority }: { priority: IIssuePriorityFilters }) => {
const store = useMobxStore();
const removePriorityFromFilter = () => {};
const router = useRouter();
const routerParams = useParams();
const { workspace_slug, project_slug } = routerParams as { workspace_slug: string; project_slug: string };
const removePriorityFromFilter = () => {
router.replace(
store.issue.getURLDefinition(workspace_slug, project_slug, {
key: "priority",
value: priority?.key,
})
);
};
return (
<div
className={`flex-shrink-0 relative flex items-center flex-wrap gap-1 border px-[2px] py-0.5 rounded-full select-none ${
className={`flex-shrink-0 relative flex items-center flex-wrap gap-1 px-2 py-0.5 text-xs rounded-full select-none ${
priority.className || ``
}`}
>
<div className="flex-shrink-0 w-[20px] h-[20px] flex justify-center items-center overflow-hidden rounded-full">
<span className="material-symbols-rounded text-[14px]">{priority?.icon}</span>
<div className="flex-shrink-0 flex justify-center items-center overflow-hidden rounded-full">
<span className="material-symbols-rounded text-xs">{priority?.icon}</span>
</div>
<div className="text-sm font-medium whitespace-nowrap">{priority?.title}</div>
<div className="whitespace-nowrap">{priority?.title}</div>
<div
className="flex-shrink-0 w-[20px] h-[20px] cursor-pointer flex justify-center items-center overflow-hidden rounded-full text-gray-500 hover:bg-gray-200/60 hover:text-gray-600"
className="flex-shrink-0 w-3 h-3 cursor-pointer flex justify-center items-center overflow-hidden rounded-full"
onClick={removePriorityFromFilter}
>
<span className="material-symbols-rounded text-[14px]">close</span>
<span className="material-symbols-rounded text-xs">close</span>
</div>
</div>
);

View File

@ -1,5 +1,7 @@
"use client";
import { useRouter, useParams } from "next/navigation";
// mobx react lite
import { observer } from "mobx-react-lite";
// mobx hook
@ -14,21 +16,41 @@ import { issuePriorityFilters } from "constants/data";
const IssuePriorityFilter = observer(() => {
const store = useMobxStore();
const router = useRouter();
const routerParams = useParams();
const { workspace_slug, project_slug } = routerParams as { workspace_slug: string; project_slug: string };
const clearPriorityFilters = () => {
router.replace(
store.issue.getURLDefinition(workspace_slug, project_slug, {
key: "priority",
removeAll: true,
})
);
};
return (
<>
<div className="flex items-center gap-2 border border-gray-300 px-2 py-1 pr-1 rounded">
<div className="flex-shrink-0 font-medium">Priority</div>
<div className="flex items-center gap-2 border border-custom-border-300 px-2 py-1 rounded-full text-xs">
<div className="flex-shrink-0 text-custom-text-200">Priority</div>
<div className="relative flex flex-wrap items-center gap-1">
{issuePriorityFilters.map((_priority: IIssuePriorityFilters, _index: number) => (
<RenderIssuePriority priority={_priority} />
))}
{issuePriorityFilters.map(
(_priority: IIssuePriorityFilters, _index: number) =>
store.issue.getUserSelectedFilter("priority", _priority.key) && (
<RenderIssuePriority key={_priority.key} priority={_priority} />
)
)}
</div>
<div
className={`w-[20px] h-[20px] cursor-pointer flex justify-center items-center overflow-hidden rounded-sm text-gray-500 hover:bg-gray-200/60 hover:text-gray-600`}
className="flex-shrink-0 w-3 h-3 cursor-pointer flex justify-center items-center overflow-hidden rounded-sm"
onClick={() => {
clearPriorityFilters();
}}
>
<span className={`material-symbols-rounded text-[16px]`}>close</span>
<span className="material-symbols-rounded text-[12px]">close</span>
</div>
</div>{" "}
</div>
</>
);
});

View File

@ -1,5 +1,7 @@
"use client";
import { useRouter, useParams } from "next/navigation";
// mobx react lite
import { observer } from "mobx-react-lite";
// mobx hook
@ -12,26 +14,34 @@ import { issueGroupFilter } from "constants/data";
export const RenderIssueState = observer(({ state }: { state: IIssueState }) => {
const store = useMobxStore();
const router = useRouter();
const routerParams = useParams();
const { workspace_slug, project_slug } = routerParams as { workspace_slug: string; project_slug: string };
const stateGroup = issueGroupFilter(state.group);
const removeStateFromFilter = () => {};
const removeStateFromFilter = () => {
router.replace(
store.issue.getURLDefinition(workspace_slug, project_slug, {
key: "state",
value: state?.id,
})
);
};
if (stateGroup === null) return <></>;
return (
<div
className={`flex-shrink-0 relative flex items-center flex-wrap gap-1 border px-[2px] py-0.5 rounded-full select-none ${
stateGroup.className || ``
}`}
>
<div className="flex-shrink-0 w-[20px] h-[20px] flex justify-center items-center overflow-hidden rounded-full">
<div className={`inline-flex items-center gap-x-1 rounded-full px-2 py-0.5 ${stateGroup.className || ``}`}>
<div className="flex-shrink-0 w-3 h-3 flex justify-center items-center overflow-hidden rounded-full">
<stateGroup.icon />
</div>
<div className="text-sm font-medium whitespace-nowrap">{state?.name}</div>
<div className="text-xs font-medium whitespace-nowrap">{state?.name}</div>
<div
className="flex-shrink-0 w-[20px] h-[20px] cursor-pointer flex justify-center items-center overflow-hidden rounded-full text-gray-500 hover:bg-gray-200/60 hover:text-gray-600"
className="flex-shrink-0 w-3 h-3 cursor-pointer flex justify-center items-center overflow-hidden rounded-full"
onClick={removeStateFromFilter}
>
<span className="material-symbols-rounded text-[14px]">close</span>
<span className="material-symbols-rounded text-xs">close</span>
</div>
</div>
);

View File

@ -1,5 +1,7 @@
"use client";
import { useRouter, useParams } from "next/navigation";
// mobx react lite
import { observer } from "mobx-react-lite";
// components
@ -13,21 +15,38 @@ import { RootStore } from "store/root";
const IssueStateFilter = observer(() => {
const store: RootStore = useMobxStore();
const clearStateFilters = () => {};
const router = useRouter();
const routerParams = useParams();
const { workspace_slug, project_slug } = routerParams as { workspace_slug: string; project_slug: string };
const clearStateFilters = () => {
router.replace(
store.issue.getURLDefinition(workspace_slug, project_slug, {
key: "state",
removeAll: true,
})
);
};
return (
<>
<div className="flex items-center gap-2 border border-gray-300 px-2 py-1 pr-1 rounded">
<div className="flex-shrink-0 font-medium">State</div>
<div className="flex items-center gap-2 border border-custom-border-300 px-2 py-1 rounded-full text-xs">
<div className="flex-shrink-0 text-custom-text-200">State</div>
<div className="relative flex flex-wrap items-center gap-1">
{store?.issue?.states &&
store?.issue?.states.map((_state: IIssueState, _index: number) => <RenderIssueState state={_state} />)}
store?.issue?.states.map(
(_state: IIssueState, _index: number) =>
store.issue.getUserSelectedFilter("state", _state.id) && (
<RenderIssueState key={_state.id} state={_state} />
)
)}
</div>
<div
className="flex-shrink-0 w-[20px] h-[20px] cursor-pointer flex justify-center items-center overflow-hidden rounded-sm text-gray-500 hover:bg-gray-200/60 hover:text-gray-600"
className="flex-shrink-0 w-3 h-3 cursor-pointer flex justify-center items-center overflow-hidden rounded-sm"
onClick={clearStateFilters}
>
<span className="material-symbols-rounded text-[16px]">close</span>
<span className="material-symbols-rounded text-[12px]">close</span>
</div>
</div>
</>

View File

@ -33,7 +33,7 @@ const IssueNavbar = observer(() => {
<div className="px-5 relative w-full flex items-center gap-4">
{/* project detail */}
<div className="flex-shrink-0 flex items-center gap-2">
<div className="w-[32px] h-[32px] rounded-sm flex justify-center items-center bg-gray-100 text-[24px]">
<div className="w-[32px] h-[32px] rounded-sm flex justify-center items-center text-[24px]">
{store?.project?.project && store?.project?.project?.emoji ? (
renderEmoji(store?.project?.project?.emoji)
) : (
@ -50,21 +50,21 @@ const IssueNavbar = observer(() => {
<NavbarSearch />
</div>
{/* issue filters */}
<div className="flex-shrink-0 relative flex items-center gap-2">
<NavbarIssueFilter />
{/* <NavbarIssueView /> */}
</div>
{/* issue views */}
<div className="flex-shrink-0 relative flex items-center gap-1 transition-all ease-in-out delay-150">
<NavbarIssueBoardView />
</div>
{/* issue filters */}
{/* <div className="flex-shrink-0 relative flex items-center gap-2">
<NavbarIssueFilter />
<NavbarIssueView />
</div> */}
{/* theming */}
{/* <div className="flex-shrink-0 relative">
<div className="flex-shrink-0 relative">
<NavbarTheme />
</div> */}
</div>
</div>
);
});

View File

@ -1,7 +1,7 @@
"use client";
// next imports
import { useRouter, useParams } from "next/navigation";
import { useRouter, useParams, useSearchParams } from "next/navigation";
// mobx react lite
import { observer } from "mobx-react-lite";
// constants
@ -22,7 +22,21 @@ export const NavbarIssueBoardView = observer(() => {
const handleCurrentBoardView = (boardView: TIssueBoardKeys) => {
store?.issue?.setCurrentIssueBoardView(boardView);
router.replace(`/${workspace_slug}/${project_slug}?board=${boardView}`);
router.replace(
`/${workspace_slug}/${project_slug}?board=${boardView}${
store?.issue?.userSelectedLabels && store?.issue?.userSelectedLabels.length > 0
? `&labels=${store?.issue?.userSelectedLabels.join(",")}`
: ""
}${
store?.issue?.userSelectedPriorities && store?.issue?.userSelectedPriorities.length > 0
? `&priorities=${store?.issue?.userSelectedPriorities.join(",")}`
: ""
}${
store?.issue?.userSelectedStates && store?.issue?.userSelectedStates.length > 0
? `&states=${store?.issue?.userSelectedStates.join(",")}`
: ""
}`
);
};
return (
@ -35,10 +49,10 @@ export const NavbarIssueBoardView = observer(() => {
store?.project?.workspaceProjectSettings?.views[_view?.key] && (
<div
key={_view?.key}
className={`w-[28px] h-[28px] flex justify-center items-center rounded-sm cursor-pointer text-gray-500 ${
className={`w-[28px] h-[28px] flex justify-center items-center rounded-sm cursor-pointer ${
_view?.key === store?.issue?.currentIssueBoardView
? `bg-gray-200/60 text-gray-800`
: `hover:bg-gray-200/60 text-gray-600`
? `bg-custom-background-200 text-custom-text-200`
: `hover:bg-custom-background-200 text-custom-text-300`
}`}
onClick={() => handleCurrentBoardView(_view?.key)}
title={_view?.title}

View File

@ -1,13 +1,119 @@
"use client";
import { useRouter, usePathname } from "next/navigation";
import { ChevronDownIcon } from "@heroicons/react/20/solid";
// mobx react lite
import { observer } from "mobx-react-lite";
// mobx
import { useMobxStore } from "lib/mobx/store-provider";
import { RootStore } from "store/root";
// components
import { Dropdown } from "components/ui/dropdown";
// constants
import { issueGroupFilter } from "constants/data";
const PRIORITIES = ["urgent", "high", "medium", "low"];
export const NavbarIssueFilter = observer(() => {
const store: RootStore = useMobxStore();
return <div>Filter</div>;
const router = useRouter();
const pathName = usePathname();
const handleOnSelect = (key: "states" | "labels" | "priorities", value: string) => {
if (key === "states") {
store.issue.userSelectedStates = store.issue.userSelectedStates.includes(value)
? store.issue.userSelectedStates.filter((s) => s !== value)
: [...store.issue.userSelectedStates, value];
} else if (key === "labels") {
store.issue.userSelectedLabels = store.issue.userSelectedLabels.includes(value)
? store.issue.userSelectedLabels.filter((l) => l !== value)
: [...store.issue.userSelectedLabels, value];
} else if (key === "priorities") {
store.issue.userSelectedPriorities = store.issue.userSelectedPriorities.includes(value)
? store.issue.userSelectedPriorities.filter((p) => p !== value)
: [...store.issue.userSelectedPriorities, value];
}
const paramsCommaSeparated = `${`board=${store.issue.currentIssueBoardView || "list"}`}${
store.issue.userSelectedPriorities.length > 0 ? `&priorities=${store.issue.userSelectedPriorities.join(",")}` : ""
}${store.issue.userSelectedStates.length > 0 ? `&states=${store.issue.userSelectedStates.join(",")}` : ""}${
store.issue.userSelectedLabels.length > 0 ? `&labels=${store.issue.userSelectedLabels.join(",")}` : ""
}`;
router.replace(`${pathName}?${paramsCommaSeparated}`);
};
return (
<Dropdown
button={
<>
<span>Filters</span>
<ChevronDownIcon className="h-3 w-3" aria-hidden="true" />
</>
}
items={[
{
display: "Priority",
children: PRIORITIES.map((priority) => ({
display: (
<span className="capitalize flex items-center gap-x-2">
<span className="material-symbols-rounded text-[14px]">
{priority === "urgent"
? "error"
: priority === "high"
? "signal_cellular_alt"
: priority === "medium"
? "signal_cellular_alt_2_bar"
: "signal_cellular_alt_1_bar"}
</span>
{priority}
</span>
),
onClick: () => handleOnSelect("priorities", priority),
isSelected: store.issue.userSelectedPriorities.includes(priority),
})),
},
{
display: "State",
children: (store.issue.states || []).map((state) => {
const stateGroup = issueGroupFilter(state.group);
return {
display: (
<span className="capitalize flex items-center gap-x-2">
{stateGroup && <stateGroup.icon />}
{state.name}
</span>
),
onClick: () => handleOnSelect("states", state.id),
isSelected: store.issue.userSelectedStates.includes(state.id),
};
}),
},
{
display: "Labels",
children: [...(store.issue.labels || [])].map((label) => ({
display: (
<span className="capitalize flex items-center gap-x-2">
<span
className="w-3 h-3 rounded-full"
style={{
backgroundColor: label.color || "#000",
}}
/>
{label.name}
</span>
),
onClick: () => handleOnSelect("labels", label.id),
isSelected: store.issue.userSelectedLabels.includes(label.id),
})),
},
]}
/>
);
});

View File

@ -1,5 +1,11 @@
"use client";
// react
import { useEffect } from "react";
// next theme
import { useTheme } from "next-themes";
// mobx react lite
import { observer } from "mobx-react-lite";
// mobx
@ -9,16 +15,24 @@ import { RootStore } from "store/root";
export const NavbarTheme = observer(() => {
const store: RootStore = useMobxStore();
const { setTheme, theme } = useTheme();
const handleTheme = () => {
store?.theme?.setTheme(store?.theme?.theme === "light" ? "dark" : "light");
setTheme(theme === "light" ? "dark" : "light");
document?.documentElement.setAttribute("data-theme", theme ?? store?.theme?.theme);
};
useEffect(() => {
document?.documentElement.setAttribute("data-theme", theme ?? store?.theme?.theme);
}, [theme, store]);
return (
<div
className="relative w-[28px] h-[28px] flex justify-center items-center rounded-sm cursor-pointer bg-gray-100 hover:bg-gray-200 hover:bg-gray-200/60 text-gray-600 transition-all"
className="relative w-[28px] h-[28px] flex justify-center rounded-md items-center rounded-sm cursor-pointer bg-custom-background-100 hover:bg-custom-background-200 hover:bg-custom-background-200/60 text-custom-text-100 transition-all"
onClick={handleTheme}
>
{store?.theme?.theme === "light" ? (
{theme === "light" ? (
<span className={`material-symbols-rounded text-[18px]`}>dark_mode</span>
) : (
<span className={`material-symbols-rounded text-[18px]`}>light_mode</span>

View File

@ -0,0 +1,126 @@
import React, { useRef } from "react";
import { useParams } from "next/navigation";
import { observer } from "mobx-react-lite";
import { useMobxStore } from "lib/mobx/store-provider";
// react-hook-form
import { useForm, Controller } from "react-hook-form";
// hooks
import useToast from "hooks/use-toast";
// ui
import { SecondaryButton } from "components/ui";
// types
import { Comment } from "store/types";
// components
import Tiptap, { ITiptapRichTextEditor } from "components/tiptap";
const TiptapEditor = React.forwardRef<ITiptapRichTextEditor, ITiptapRichTextEditor>((props, ref) => (
<Tiptap {...props} forwardedRef={ref} />
));
TiptapEditor.displayName = "TiptapEditor";
const defaultValues: Partial<Comment> = {
comment_json: "",
comment_html: "",
};
type Props = {
issueId: string | null;
disabled?: boolean;
};
export const AddComment: React.FC<Props> = observer((props) => {
const { issueId, disabled = false } = props;
const {
handleSubmit,
control,
setValue,
watch,
formState: { isSubmitting },
reset,
} = useForm<Comment>({ defaultValues });
const routerParams = useParams();
const { workspace_slug, project_slug } = routerParams as { workspace_slug: string; project_slug: string };
const { issue: issueStore, user: userStore } = useMobxStore();
const editorRef = useRef<any>(null);
const { setToastAlert } = useToast();
const onSubmit = async (formData: Comment) => {
if (
!workspace_slug ||
!project_slug ||
!issueId ||
isSubmitting ||
!formData.comment_html ||
!formData.comment_json
)
return;
await issueStore
.createIssueCommentAsync(workspace_slug, project_slug, issueId, formData)
.then(() => {
reset(defaultValues);
editorRef.current?.clearEditor();
})
.catch(() => {
setToastAlert({
type: "error",
title: "Error!",
message: "Comment could not be posted. Please try again.",
});
});
};
return (
<div>
<div className="issue-comments-section">
<Controller
name="comment_html"
control={control}
render={({ field: { value, onChange } }) => (
<TiptapEditor
workspaceSlug={workspace_slug as string}
ref={editorRef}
value={
!value || value === "" || (typeof value === "object" && Object.keys(value).length === 0)
? watch("comment_html")
: value
}
customClassName="p-3 min-h-[50px] shadow-sm"
debouncedUpdatesEnabled={false}
onChange={(comment_json: Object, comment_html: string) => {
onChange(comment_html);
setValue("comment_json", comment_json);
}}
/>
)}
/>
<SecondaryButton
onClick={(e) => {
userStore.requiredLogin(() => {
handleSubmit(onSubmit)(e);
});
}}
type="submit"
disabled={isSubmitting || disabled}
className="mt-2"
>
{isSubmitting ? "Adding..." : "Comment"}
</SecondaryButton>
</div>
</div>
);
});

View File

@ -0,0 +1,228 @@
import React, { useEffect, useState, useRef } from "react";
// react-hook-form
import { useForm } from "react-hook-form";
// mobx
import { observer } from "mobx-react-lite";
import { useMobxStore } from "lib/mobx/store-provider";
// headless ui
import { Menu, Transition } from "@headlessui/react";
// icons
import { ChatBubbleLeftEllipsisIcon, CheckIcon, XMarkIcon, EllipsisVerticalIcon } from "@heroicons/react/24/outline";
// helpers
import { timeAgo } from "helpers/date-time.helper";
// types
import { Comment } from "store/types";
import Tiptap, { ITiptapRichTextEditor } from "components/tiptap";
const TiptapEditor = React.forwardRef<ITiptapRichTextEditor, ITiptapRichTextEditor>((props, ref) => (
<Tiptap {...props} forwardedRef={ref} />
));
TiptapEditor.displayName = "TiptapEditor";
type Props = {
workspaceSlug: string;
comment: Comment;
};
export const CommentCard: React.FC<Props> = observer((props) => {
const { comment, workspaceSlug } = props;
const { user: userStore, issue: issueStore } = useMobxStore();
const editorRef = useRef<any>(null);
const showEditorRef = useRef<any>(null);
const [isEditing, setIsEditing] = useState(false);
const {
formState: { isSubmitting },
handleSubmit,
setFocus,
watch,
setValue,
} = useForm<Comment>({
defaultValues: comment,
});
const handleDelete = async () => {
if (!workspaceSlug || !issueStore.activePeekOverviewIssueId) return;
await issueStore.deleteIssueCommentAsync(
workspaceSlug,
comment.project,
issueStore.activePeekOverviewIssueId,
comment.id
);
};
const handleCommentUpdate = async (formData: Comment) => {
if (!workspaceSlug || !issueStore.activePeekOverviewIssueId) return;
const response = await issueStore.updateIssueCommentAsync(
workspaceSlug,
comment.project,
issueStore.activePeekOverviewIssueId,
comment.id,
formData
);
if (response) {
editorRef.current?.setEditorValue(response.comment_html);
showEditorRef.current?.setEditorValue(response.comment_html);
}
setIsEditing(false);
};
useEffect(() => {
isEditing && setFocus("comment_html");
}, [isEditing, setFocus]);
return (
<div className="relative flex items-start space-x-3">
<div className="relative px-1">
{comment.actor_detail.avatar && comment.actor_detail.avatar !== "" ? (
<img
src={comment.actor_detail.avatar}
alt={
comment.actor_detail.is_bot ? comment.actor_detail.first_name + " Bot" : comment.actor_detail.display_name
}
height={30}
width={30}
className="grid h-7 w-7 place-items-center rounded-full border-2 border-custom-border-200"
/>
) : (
<div className={`grid h-7 w-7 place-items-center rounded-full border-2 border-white bg-gray-500 text-white`}>
{comment.actor_detail.is_bot
? comment.actor_detail.first_name.charAt(0)
: comment.actor_detail.display_name.charAt(0)}
</div>
)}
<span className="absolute -bottom-0.5 -right-1 rounded-tl bg-custom-background-80 px-0.5 py-px">
<ChatBubbleLeftEllipsisIcon className="h-3.5 w-3.5 text-custom-text-200" aria-hidden="true" />
</span>
</div>
<div className="min-w-0 flex-1">
<div>
<div className="text-xs">
{comment.actor_detail.is_bot ? comment.actor_detail.first_name + " Bot" : comment.actor_detail.display_name}
</div>
<p className="mt-0.5 text-xs text-custom-text-200">
<>Commented {timeAgo(comment.created_at)}</>
</p>
</div>
<div className="issue-comments-section p-0">
<form
onSubmit={handleSubmit(handleCommentUpdate)}
className={`flex-col gap-2 ${isEditing ? "flex" : "hidden"}`}
>
<div>
<TiptapEditor
workspaceSlug={workspaceSlug as string}
ref={editorRef}
value={watch("comment_html")}
debouncedUpdatesEnabled={false}
customClassName="min-h-[50px] p-3 shadow-sm"
onChange={(comment_json: Object, comment_html: string) => {
setValue("comment_json", comment_json);
setValue("comment_html", comment_html);
}}
/>
</div>
<div className="flex gap-1 self-end">
<button
type="submit"
disabled={isSubmitting}
className="group rounded border border-green-500 bg-green-500/20 p-2 shadow-md duration-300 hover:bg-green-500"
>
<CheckIcon className="h-3 w-3 text-green-500 duration-300 group-hover:text-white" />
</button>
<button
type="button"
className="group rounded border border-red-500 bg-red-500/20 p-2 shadow-md duration-300 hover:bg-red-500"
onClick={() => setIsEditing(false)}
>
<XMarkIcon className="h-3 w-3 text-red-500 duration-300 group-hover:text-white" />
</button>
</div>
</form>
<div className={`${isEditing ? "hidden" : ""}`}>
<TiptapEditor
workspaceSlug={workspaceSlug as string}
ref={showEditorRef}
value={comment.comment_html}
editable={false}
customClassName="text-xs border border-custom-border-200 bg-custom-background-100"
/>
</div>
</div>
</div>
{userStore?.currentUser?.id === comment?.actor_detail?.id && (
<Menu as="div" className="relative w-min text-left">
<Menu.Button
type="button"
onClick={() => {}}
className="relative grid place-items-center rounded p-1 text-custom-text-200 hover:text-custom-text-100 outline-none cursor-pointer hover:bg-custom-background-80"
>
<EllipsisVerticalIcon className="h-5 w-5 text-custom-text-200 duration-300" />
</Menu.Button>
<Transition
as={React.Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items className="absolute z-10 overflow-y-scroll whitespace-nowrap rounded-md max-h-36 border right-0 origin-top-right mt-1 overflow-auto min-w-[8rem] border-custom-border-300 p-1 text-xs shadow-lg focus:outline-none bg-custom-background-90">
<Menu.Item>
{({ active }) => (
<div className="py-1">
<button
type="button"
onClick={() => {
setIsEditing(true);
}}
className={`w-full select-none truncate rounded px-1 py-1.5 text-left text-custom-text-200 hover:bg-custom-background-80 ${
active ? "bg-custom-background-80" : ""
}`}
>
Edit
</button>
</div>
)}
</Menu.Item>
<Menu.Item>
{({ active }) => (
<div className="py-1">
<button
type="button"
onClick={() => {
handleDelete();
}}
className={`w-full select-none truncate rounded px-1 py-1.5 text-left text-custom-text-200 hover:bg-custom-background-80 ${
active ? "bg-custom-background-80" : ""
}`}
>
Delete
</button>
</div>
)}
</Menu.Item>
</Menu.Items>
</Transition>
</Menu>
)}
</div>
);
});

View File

@ -0,0 +1,70 @@
import { useEffect } from "react";
// mobx
import { observer } from "mobx-react-lite";
import { useMobxStore } from "lib/mobx/store-provider";
import {
PeekOverviewHeader,
PeekOverviewIssueActivity,
PeekOverviewIssueDetails,
PeekOverviewIssueProperties,
TPeekOverviewModes,
} from "components/issues/peek-overview";
type Props = {
issueId: string;
workspaceSlug: string;
projectId: string;
handleClose: () => void;
mode: TPeekOverviewModes;
setMode: (mode: TPeekOverviewModes) => void;
};
export const FullScreenPeekView: React.FC<Props> = observer((props) => {
const { handleClose, issueId, mode, setMode, workspaceSlug , projectId } = props;
const { issue: issueStore } = useMobxStore();
const issue = issueStore.issue_detail[issueId]?.issue;
useEffect(() => {
if (!workspaceSlug || !projectId || !issueId) return;
issueStore.getIssueByIdAsync(workspaceSlug, projectId, issueId);
}, [workspaceSlug, projectId, issueId, issueStore]);
return (
<div className="h-full w-full grid grid-cols-10 divide-x divide-custom-border-200 overflow-hidden">
<div className="h-full w-full flex flex-col col-span-7 overflow-hidden">
<div className="w-full p-5">
<PeekOverviewHeader
handleClose={handleClose}
issue={issue}
mode={mode}
setMode={setMode}
workspaceSlug={workspaceSlug}
/>
</div>
<div className="h-full w-full px-6 overflow-y-auto">
{/* issue title and description */}
<div className="w-full">
<PeekOverviewIssueDetails issue={issue} />
</div>
{/* divider */}
<div className="h-[1] w-full border-t border-custom-border-200 my-5" />
{/* issue activity/comments */}
<div className="w-full">
<PeekOverviewIssueActivity workspaceSlug={workspaceSlug} />
</div>
</div>
</div>
<div className="col-span-3 h-full w-full overflow-y-auto">
{/* issue properties */}
<div className="w-full px-6 py-5">
<PeekOverviewIssueProperties issue={issue} mode="full" workspaceSlug={workspaceSlug} />
</div>
</div>
</div>
)
})

View File

@ -0,0 +1,99 @@
// hooks
import useToast from "hooks/use-toast";
// ui
import { Icon } from "components/ui";
// helpers
import { copyTextToClipboard } from "helpers/string.helper";
// types
import { TPeekOverviewModes } from "./layout";
import { ArrowRightAlt, CloseFullscreen, East, OpenInFull } from "@mui/icons-material";
type Props = {
handleClose: () => void;
issue: any;
mode: TPeekOverviewModes;
setMode: (mode: TPeekOverviewModes) => void;
workspaceSlug: string;
};
const peekModes: {
key: TPeekOverviewModes;
icon: string;
label: string;
}[] = [
{ key: "side", icon: "side_navigation", label: "Side Peek" },
{
key: "modal",
icon: "dialogs",
label: "Modal Peek",
},
{
key: "full",
icon: "nearby",
label: "Full Screen Peek",
},
];
export const PeekOverviewHeader: React.FC<Props> = ({ issue, handleClose, mode, setMode, workspaceSlug }) => {
const { setToastAlert } = useToast();
const handleCopyLink = () => {
const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
copyTextToClipboard(`${originURL}/${workspaceSlug}/projects/${issue.project}/`).then(() => {
setToastAlert({
type: "success",
title: "Link copied!",
message: "Issue link copied to clipboard",
});
});
};
return (
<div className="flex justify-between items-center">
<div className="flex items-center gap-4">
{mode === "side" && (
<button
type="button"
onClick={() => {
handleClose();
}}
>
<East
sx={{
fontSize: "14px",
}}
/>
</button>
)}
{mode === "modal" || mode === "full" ? (
<button type="button" onClick={() => setMode("side")}>
<CloseFullscreen
sx={{
fontSize: "14px",
}}
/>
</button>
) : (
<button type="button" onClick={() => setMode("modal")}>
<OpenInFull
sx={{
fontSize: "14px",
}}
/>
</button>
)}
<button type="button" className={`grid place-items-center ${mode === "full" ? "rotate-45" : ""}`}>
<Icon iconName={peekModes.find((m) => m.key === mode)?.icon ?? ""} />
</button>
</div>
{(mode === "side" || mode === "modal") && (
<div className="flex items-center gap-2">
<button type="button" onClick={handleCopyLink} className="-rotate-45">
<Icon iconName="link" />
</button>
</div>
)}
</div>
);
};

View File

@ -0,0 +1,12 @@
export * from "./full-screen-peek-view";
export * from "./header";
export * from "./issue-activity";
export * from "./issue-details";
export * from "./issue-properties";
export * from "./layout";
export * from "./side-peek-view";
export * from "./issue-reaction";
export * from "./issue-vote-reactions";
export * from "./issue-emoji-reactions";
export * from "./comment-detail-card";
export * from "./add-comment";

View File

@ -0,0 +1,42 @@
import React, { useEffect } from "react";
// mobx
import { observer } from "mobx-react-lite";
import { useMobxStore } from "lib/mobx/store-provider";
import { CommentCard, AddComment } from "components/issues/peek-overview";
type Props = {
workspaceSlug: string;
};
export const PeekOverviewIssueActivity: React.FC<Props> = observer((props) => {
const { workspaceSlug } = props;
const { issue: issueStore, user: userStore } = useMobxStore();
const issueId = issueStore?.activePeekOverviewIssueId;
const comments = issueStore?.issue_detail[issueId ?? ""]?.comments ?? [];
useEffect(() => {
if (userStore.currentUser) return;
userStore.getUserAsync();
}, [userStore]);
return (
<div>
<h4 className="font-medium">Activity</h4>
<div className="mt-4">
<div className="space-y-4">
{comments.map((comment) => (
<CommentCard comment={comment} workspaceSlug={workspaceSlug} />
))}
</div>
<div className="mt-4">
<AddComment disabled={!userStore.currentUser} issueId={issueId} />
</div>
</div>
</div>
);
});

View File

@ -0,0 +1,18 @@
// components
import { IssueReactions } from "components/issues/peek-overview";
// types
import { IIssue } from "store/types";
type Props = {
issue: IIssue;
};
export const PeekOverviewIssueDetails: React.FC<Props> = ({ issue }) => (
<div className="space-y-2">
<h6 className="font-medium text-custom-text-200">
{issue.project_detail.identifier}-{issue.sequence_id}
</h6>
<h4 className="break-words text-2xl font-semibold">{issue.name}</h4>
<IssueReactions />
</div>
);

View File

@ -0,0 +1,94 @@
"use client";
// react
import { useEffect } from "react";
// next
import { useParams } from "next/navigation";
// mobx
import { observer } from "mobx-react-lite";
import { useMobxStore } from "lib/mobx/store-provider";
// helpers
import { groupReactions, renderEmoji } from "helpers/emoji.helper";
// ui
import { ReactionSelector } from "components/ui";
export const IssueEmojiReactions: React.FC = observer(() => {
const routerParams = useParams();
const { workspace_slug, project_slug } = routerParams as { workspace_slug: string; project_slug: string };
const { user: userStore, issue: issueStore } = useMobxStore();
const user = userStore?.currentUser;
const issueId = issueStore.activePeekOverviewIssueId;
const reactions = issueId ? issueStore.issue_detail[issueId]?.reactions || [] : [];
const groupedReactions = groupReactions(reactions, "reaction");
const handleReactionClick = (reactionHexa: string) => {
if (!workspace_slug || !project_slug || !issueId) return;
const userReaction = reactions?.find((r) => r.created_by === user?.id && r.reaction === reactionHexa);
if (userReaction)
issueStore.deleteIssueReactionAsync(workspace_slug, userReaction.project, userReaction.issue, reactionHexa);
else
issueStore.createIssueReactionAsync(workspace_slug, project_slug, issueId, {
reaction: reactionHexa,
});
};
useEffect(() => {
if (user) return;
userStore.getUserAsync();
}, [user, userStore]);
return (
<>
<ReactionSelector
onSelect={(value) => {
userStore.requiredLogin(() => {
handleReactionClick(value);
});
}}
/>
{Object.keys(groupedReactions || {}).map(
(reaction) =>
groupedReactions?.[reaction]?.length &&
groupedReactions[reaction].length > 0 && (
<button
type="button"
onClick={() => {
userStore.requiredLogin(() => {
handleReactionClick(reaction);
});
}}
key={reaction}
className={`flex items-center gap-1 text-custom-text-100 text-sm h-full px-2 py-1 rounded-md border ${
reactions?.some((r) => r.actor === user?.id && r.reaction === reaction)
? "bg-custom-primary-100/10 border-custom-primary-100"
: "bg-custom-background-80 border-transparent"
}`}
>
<span>{renderEmoji(reaction)}</span>
<span
className={
reactions?.some((r) => r.actor === user?.id && r.reaction === reaction)
? "text-custom-primary-100"
: ""
}
>
{groupedReactions?.[reaction].length}{" "}
</span>
</button>
)
)}
</>
);
});

View File

@ -0,0 +1,149 @@
// headless ui
import { Disclosure } from "@headlessui/react";
// import { getStateGroupIcon } from "components/icons";
// hooks
import useToast from "hooks/use-toast";
// components
import { TPeekOverviewModes } from "components/issues/peek-overview";
// icons
import { Icon } from "components/ui";
import { copyTextToClipboard, addSpaceIfCamelCase } from "helpers/string.helper";
// types
import { IIssue } from "store/types";
// constants
import { issueGroupFilter, issuePriorityFilter } from "constants/data";
import { useEffect } from "react";
import { renderDateFormat } from "constants/helpers";
type Props = {
issue: IIssue;
mode: TPeekOverviewModes;
workspaceSlug: string;
};
const validDate = (date: any, state: string): string => {
if (date === null || ["backlog", "unstarted", "cancelled"].includes(state))
return `bg-gray-500/10 text-gray-500 border-gray-500/50`;
else {
const today = new Date();
const dueDate = new Date(date);
if (dueDate < today) return `bg-red-500/10 text-red-500 border-red-500/50`;
else return `bg-green-500/10 text-green-500 border-green-500/50`;
}
};
export const PeekOverviewIssueProperties: React.FC<Props> = ({ issue, mode, workspaceSlug }) => {
const { setToastAlert } = useToast();
const startDate = issue.start_date;
const targetDate = issue.target_date;
const minDate = startDate ? new Date(startDate) : null;
minDate?.setDate(minDate.getDate());
const maxDate = targetDate ? new Date(targetDate) : null;
maxDate?.setDate(maxDate.getDate());
const state = issue.state_detail;
const stateGroup = issueGroupFilter(state.group);
const priority = issue.priority ? issuePriorityFilter(issue.priority) : null;
const handleCopyLink = () => {
const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
copyTextToClipboard(`${originURL}/${workspaceSlug}/projects/${issue.project}/issues/${issue.id}`).then(() => {
setToastAlert({
type: "success",
title: "Link copied!",
message: "Issue link copied to clipboard",
});
});
};
return (
<div className={mode === "full" ? "divide-y divide-custom-border-200" : ""}>
{mode === "full" && (
<div className="flex justify-between gap-2 pb-3">
<h6 className="flex items-center gap-2 font-medium">
{/* {getStateGroupIcon(issue.state_detail.group, "16", "16", issue.state_detail.color)} */}
{issue.project_detail.identifier}-{issue.sequence_id}
</h6>
<div className="flex items-center gap-2">
<button type="button" onClick={handleCopyLink} className="-rotate-45">
<Icon iconName="link" />
</button>
</div>
</div>
)}
<div className={`space-y-4 ${mode === "full" ? "pt-3" : ""}`}>
<div className="flex items-center gap-2 text-sm">
<div className="flex-shrink-0 w-1/4 flex items-center gap-2 font-medium">
<Icon iconName="radio_button_checked" className="!text-base flex-shrink-0" />
<span className="flex-grow truncate">State</span>
</div>
<div className="w-3/4">
{stateGroup && (
<div className="inline-flex bg-custom-background-80 text-sm rounded px-2.5 py-0.5">
<div className="flex items-center gap-1.5 text-left text-custom-text-100">
<stateGroup.icon />
{addSpaceIfCamelCase(state?.name ?? "")}
</div>
</div>
)}
</div>
</div>
<div className="flex items-center gap-2 text-sm">
<div className="flex-shrink-0 w-1/4 flex items-center gap-2 font-medium">
<Icon iconName="signal_cellular_alt" className="!text-base flex-shrink-0" />
<span className="flex-grow truncate">Priority</span>
</div>
<div className="w-3/4">
<div
className={`inline-flex items-center gap-1.5 text-left text-sm capitalize rounded px-2.5 py-0.5 ${
priority?.key === "urgent"
? "border-red-500/20 bg-red-500/20 text-red-500"
: priority?.key === "high"
? "border-orange-500/20 bg-orange-500/20 text-orange-500"
: priority?.key === "medium"
? "border-yellow-500/20 bg-yellow-500/20 text-yellow-500"
: priority?.key === "low"
? "border-green-500/20 bg-green-500/20 text-green-500"
: "bg-custom-background-80 border-custom-border-200"
}`}
>
{priority && (
<span className="grid place-items-center -my-1">
<Icon iconName={priority?.icon!} />
</span>
)}
<span>{priority?.title ?? "None"}</span>
</div>
</div>
</div>
<div className="flex items-center gap-2 text-sm">
<div className="flex-shrink-0 w-1/4 flex items-center gap-2 font-medium">
<Icon iconName="calendar_today" className="!text-base flex-shrink-0" />
<span className="flex-grow truncate">Due date</span>
</div>
<div>
{issue.target_date ? (
<div
className={`h-[24px] rounded-md flex px-2.5 py-1 items-center border border-custom-border-100 gap-1 text-custom-text-100 text-xs font-medium
${validDate(issue.target_date, state)}`}
>
{renderDateFormat(issue.target_date)}
</div>
) : (
<span className="text-custom-text-200">Empty</span>
)}
</div>
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,18 @@
"use client";
// ui
import { IssueEmojiReactions, IssueVotes } from "components/issues/peek-overview";
export const IssueReactions: React.FC = () => (
<div className="flex gap-3 items-center mt-4">
<div className="flex gap-2 items-center">
<IssueVotes />
</div>
<div className="w-0.5 h-8 bg-custom-background-200" />
<div className="flex gap-2 items-center">
<IssueEmojiReactions />
</div>
</div>
);

View File

@ -0,0 +1,102 @@
"use client";
// react
import { useState, useEffect, useRef } from "react";
// next
import { useParams } from "next/navigation";
// mobx
import { observer } from "mobx-react-lite";
import { useMobxStore } from "lib/mobx/store-provider";
export const IssueVotes: React.FC = observer(() => {
const [isSubmitting, setIsSubmitting] = useState(false);
const routerParams = useParams();
const { workspace_slug, project_slug } = routerParams as { workspace_slug: string; project_slug: string };
const { user: userStore, issue: issueStore } = useMobxStore();
const user = userStore?.currentUser;
const issueId = issueStore.activePeekOverviewIssueId;
const votes = issueId ? issueStore.issue_detail[issueId]?.votes : [];
const upVoteCount = votes?.filter((vote) => vote.vote === 1).length || 0;
const downVoteCount = votes?.filter((vote) => vote.vote === -1).length || 0;
const isUpVotedByUser = votes?.some((vote) => vote.actor === user?.id && vote.vote === 1);
const isDownVotedByUser = votes?.some((vote) => vote.actor === user?.id && vote.vote === -1);
const handleVote = async (e: any, voteValue: 1 | -1) => {
if (!workspace_slug || !project_slug || !issueId) return;
setIsSubmitting(true);
const actionPerformed = votes?.find((vote) => vote.actor === user?.id && vote.vote === voteValue);
if (actionPerformed) await issueStore.deleteIssueVoteAsync(workspace_slug, project_slug, issueId);
else
await issueStore.createIssueVoteAsync(workspace_slug, project_slug, issueId, {
vote: voteValue,
});
setIsSubmitting(false);
};
useEffect(() => {
if (user) return;
userStore.getUserAsync();
}, [user, userStore]);
return (
<div className="flex gap-2 items-center">
{/* upvote button 👇 */}
<button
type="button"
disabled={isSubmitting}
onClick={(e) => {
userStore.requiredLogin(() => {
handleVote(e, 1);
});
}}
className={`flex items-center justify-center overflow-hidden px-2 py-1 gap-x-1 border rounded focus:outline-none ${
isUpVotedByUser ? "border-custom-primary-200 text-custom-primary-200" : "border-custom-border-300"
}`}
>
<svg className="w-2.5" viewBox="0 0 8 10" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M3.44122 2.35988L0.952108 4.84898C0.848405 4.95268 0.718049 5.00574 0.561041 5.00815C0.404044 5.01054 0.271292 4.95749 0.162783 4.84898C0.0542613 4.74047 0 4.60891 0 4.45431C0 4.2997 0.0542613 4.16814 0.162783 4.05963L3.52908 0.693333C3.66448 0.557934 3.82245 0.490234 4.00297 0.490234C4.1835 0.490234 4.34147 0.557934 4.47687 0.693333L7.84316 4.05963C7.94688 4.16335 7.99994 4.2937 8.00233 4.4507C8.00474 4.60771 7.95169 4.74047 7.84316 4.84898C7.73466 4.95749 7.6031 5.01174 7.4485 5.01174C7.2939 5.01174 7.16235 4.95749 7.05384 4.84898L4.56473 2.35988V8.94848C4.56473 9.10787 4.51095 9.24135 4.4034 9.34891C4.29586 9.45646 4.16238 9.51023 4.00297 9.51023C3.84357 9.51023 3.71009 9.45646 3.60254 9.34891C3.495 9.24135 3.44122 9.10787 3.44122 8.94848V2.35988Z"
fill="currentColor"
/>
</svg>
<span className="text-sm font-normal transition-opacity ease-in-out">{upVoteCount}</span>
</button>
{/* downvote button 👇 */}
<button
type="button"
disabled={isSubmitting}
onClick={(e) => {
userStore.requiredLogin(() => {
handleVote(e, -1);
});
}}
className={`flex items-center justify-center overflow-hidden px-2 py-1 gap-x-1 border rounded focus:outline-none ${
isDownVotedByUser ? "border-red-600 text-red-600" : "border-custom-border-300"
}`}
>
<svg className="w-2.5" viewBox="0 0 8 10" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M4.55878 7.6406L7.04789 5.15151C7.15159 5.04781 7.28195 4.99475 7.43896 4.99234C7.59596 4.98994 7.72871 5.043 7.83722 5.15151C7.94574 5.26002 8 5.39157 8 5.54617C8 5.70079 7.94574 5.83235 7.83722 5.94085L4.47092 9.30715C4.33552 9.44255 4.17755 9.51025 3.99703 9.51025C3.8165 9.51025 3.65853 9.44255 3.52313 9.30715L0.156836 5.94085C0.0531201 5.83714 6.34193e-05 5.70678 -0.00233364 5.54979C-0.00474262 5.39278 0.0483136 5.26002 0.156836 5.15151C0.265345 5.043 0.396898 4.98875 0.551497 4.98875C0.706097 4.98875 0.837651 5.043 0.946161 5.15151L3.43527 7.6406L3.43527 1.05201C3.43527 0.892613 3.48905 0.759136 3.5966 0.651576C3.70414 0.544028 3.83762 0.490253 3.99703 0.490253C4.15643 0.490253 4.28991 0.544028 4.39746 0.651576C4.505 0.759136 4.55878 0.892613 4.55878 1.05201L4.55878 7.6406Z"
fill="currentColor"
/>
</svg>
<span className="text-sm font-normal transition-opacity ease-in-out">{downVoteCount}</span>
</button>
</div>
);
});

View File

@ -0,0 +1,98 @@
import React, { useState } from "react";
// headless ui
import { Dialog, Transition } from "@headlessui/react";
import { FullScreenPeekView, SidePeekView } from "components/issues/peek-overview";
// types
import type { IIssue } from "store/types";
type Props = {
issue: IIssue | null;
isOpen: boolean;
onClose: () => void;
workspaceSlug: string;
};
export type TPeekOverviewModes = "side" | "modal" | "full";
export const IssuePeekOverview: React.FC<Props> = ({
issue,
isOpen,
onClose,
workspaceSlug,
}) => {
const [peekOverviewMode, setPeekOverviewMode] = useState<TPeekOverviewModes>("side");
const handleClose = () => {
onClose();
setPeekOverviewMode("side");
};
if (!issue || !isOpen) return null;
return (
<Transition.Root show={isOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-20" onClose={handleClose}>
{/* add backdrop conditionally */}
{(peekOverviewMode === "modal" || peekOverviewMode === "full") && (
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-custom-backdrop bg-opacity-50 transition-opacity" />
</Transition.Child>
)}
<div className="fixed inset-0 z-20 overflow-y-auto">
<div className="relative h-full w-full">
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel
className={`absolute z-20 bg-custom-background-100 ${
peekOverviewMode === "side"
? "top-0 right-0 h-full w-1/2 shadow-custom-shadow-md"
: peekOverviewMode === "modal"
? "top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 h-[70%] w-3/5 rounded-lg shadow-custom-shadow-xl"
: "top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 h-[95%] w-[95%] rounded-lg shadow-custom-shadow-xl"
}`}
>
{(peekOverviewMode === "side" || peekOverviewMode === "modal") && (
<SidePeekView
handleClose={handleClose}
issueId={issue.id}
projectId={issue.project}
mode={peekOverviewMode}
setMode={(mode) => setPeekOverviewMode(mode)}
workspaceSlug={workspaceSlug}
/>
)}
{peekOverviewMode === "full" && (
<FullScreenPeekView
issueId={issue.id}
workspaceSlug={workspaceSlug}
projectId={issue.project}
handleClose={handleClose}
mode={peekOverviewMode}
setMode={(mode) => setPeekOverviewMode(mode)}
/>
)}
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
};

View File

@ -0,0 +1,67 @@
// mobx
import { observer } from "mobx-react-lite";
import { useMobxStore } from "lib/mobx/store-provider";
import {
PeekOverviewHeader,
PeekOverviewIssueActivity,
PeekOverviewIssueDetails,
PeekOverviewIssueProperties,
TPeekOverviewModes,
} from "components/issues/peek-overview";
import { useEffect } from "react";
type Props = {
issueId: string;
projectId: string;
workspaceSlug: string;
handleClose: () => void;
mode: TPeekOverviewModes;
setMode: (mode: TPeekOverviewModes) => void;
};
export const SidePeekView: React.FC<Props> = observer((props) => {
const { handleClose, issueId, mode, setMode, workspaceSlug, projectId } = props;
const { issue: issueStore } = useMobxStore();
const issue = issueStore.issue_detail[issueId]?.issue;
useEffect(() => {
if (!workspaceSlug || !projectId || !issueId) return;
issueStore.getIssueByIdAsync(workspaceSlug, projectId, issueId);
}, [workspaceSlug, projectId, issueId, issueStore]);
return (
<div className="h-full w-full flex flex-col overflow-hidden">
<div className="w-full p-5">
<PeekOverviewHeader
handleClose={handleClose}
issue={issue}
mode={mode}
setMode={setMode}
workspaceSlug={workspaceSlug}
/>
</div>
{issue && (
<div className="h-full w-full px-6 overflow-y-auto">
{/* issue title and description */}
<div className="w-full">
<PeekOverviewIssueDetails issue={issue} />
</div>
{/* issue properties */}
<div className="w-full mt-10">
<PeekOverviewIssueProperties issue={issue} mode={mode} workspaceSlug={workspaceSlug} />
</div>
{/* divider */}
<div className="h-[1] w-full border-t border-custom-border-200 my-5" />
{/* issue activity/comments */}
<div className="w-full pb-5">
<PeekOverviewIssueActivity workspaceSlug={workspaceSlug} />
</div>
</div>
)}
</div>
);
});

View File

@ -0,0 +1,119 @@
import { BubbleMenu, BubbleMenuProps } from "@tiptap/react";
import { FC, useState } from "react";
import { BoldIcon, ItalicIcon, UnderlineIcon, StrikethroughIcon, CodeIcon } from "lucide-react";
import { NodeSelector } from "./node-selector";
import { LinkSelector } from "./link-selector";
import { cn } from "../utils";
export interface BubbleMenuItem {
name: string;
isActive: () => boolean;
command: () => void;
icon: typeof BoldIcon;
}
type EditorBubbleMenuProps = Omit<BubbleMenuProps, "children">;
export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: any) => {
const items: BubbleMenuItem[] = [
{
name: "bold",
isActive: () => props.editor?.isActive("bold"),
command: () => props.editor?.chain().focus().toggleBold().run(),
icon: BoldIcon,
},
{
name: "italic",
isActive: () => props.editor?.isActive("italic"),
command: () => props.editor?.chain().focus().toggleItalic().run(),
icon: ItalicIcon,
},
{
name: "underline",
isActive: () => props.editor?.isActive("underline"),
command: () => props.editor?.chain().focus().toggleUnderline().run(),
icon: UnderlineIcon,
},
{
name: "strike",
isActive: () => props.editor?.isActive("strike"),
command: () => props.editor?.chain().focus().toggleStrike().run(),
icon: StrikethroughIcon,
},
{
name: "code",
isActive: () => props.editor?.isActive("code"),
command: () => props.editor?.chain().focus().toggleCode().run(),
icon: CodeIcon,
},
];
const bubbleMenuProps: EditorBubbleMenuProps = {
...props,
shouldShow: ({ editor }) => {
if (!editor.isEditable) {
return false;
}
if (editor.isActive("image")) {
return false;
}
return editor.view.state.selection.content().size > 0;
},
tippyOptions: {
moveTransition: "transform 0.15s ease-out",
onHidden: () => {
setIsNodeSelectorOpen(false);
setIsLinkSelectorOpen(false);
},
},
};
const [isNodeSelectorOpen, setIsNodeSelectorOpen] = useState(false);
const [isLinkSelectorOpen, setIsLinkSelectorOpen] = useState(false);
return (
<BubbleMenu
{...bubbleMenuProps}
className="flex w-fit divide-x divide-custom-border-300 rounded border border-custom-border-300 bg-custom-background-100 shadow-xl"
>
<NodeSelector
editor={props.editor!}
isOpen={isNodeSelectorOpen}
setIsOpen={() => {
setIsNodeSelectorOpen(!isNodeSelectorOpen);
setIsLinkSelectorOpen(false);
}}
/>
<LinkSelector
editor={props.editor!!}
isOpen={isLinkSelectorOpen}
setIsOpen={() => {
setIsLinkSelectorOpen(!isLinkSelectorOpen);
setIsNodeSelectorOpen(false);
}}
/>
<div className="flex">
{items.map((item, index) => (
<button
key={index}
type="button"
onClick={item.command}
className={cn(
"p-2 text-custom-text-300 hover:bg-custom-primary-100/5 active:bg-custom-primary-100/5 transition-colors",
{
"text-custom-text-100 bg-custom-primary-100/5": item.isActive(),
}
)}
>
<item.icon
className={cn("h-4 w-4", {
"text-custom-text-100": item.isActive(),
})}
/>
</button>
))}
</div>
</BubbleMenu>
);
};

View File

@ -0,0 +1,90 @@
import { Editor } from "@tiptap/core";
import { Check, Trash } from "lucide-react";
import { Dispatch, FC, SetStateAction, useCallback, useEffect, useRef } from "react";
import { cn } from "../utils";
import isValidHttpUrl from "./utils/link-validator";
interface LinkSelectorProps {
editor: Editor;
isOpen: boolean;
setIsOpen: Dispatch<SetStateAction<boolean>>;
}
export const LinkSelector: FC<LinkSelectorProps> = ({ editor, isOpen, setIsOpen }) => {
const inputRef = useRef<HTMLInputElement>(null);
const onLinkSubmit = useCallback(() => {
const input = inputRef.current;
const url = input?.value;
if (url && isValidHttpUrl(url)) {
editor.chain().focus().setLink({ href: url }).run();
setIsOpen(false);
}
}, [editor, inputRef, setIsOpen]);
useEffect(() => {
inputRef.current && inputRef.current?.focus();
});
return (
<div className="relative">
<button
type="button"
className={cn(
"flex h-full items-center space-x-2 px-3 py-1.5 text-sm font-medium text-custom-text-300 hover:bg-custom-background-100 active:bg-custom-background-100",
{ "bg-custom-background-100": isOpen }
)}
onClick={() => {
setIsOpen(!isOpen);
}}
>
<p className="text-base"></p>
<p
className={cn("underline underline-offset-4", {
"text-custom-text-100": editor.isActive("link"),
})}
>
Link
</p>
</button>
{isOpen && (
<div
className="fixed top-full z-[99999] mt-1 flex w-60 overflow-hidden rounded border border-custom-border-300 bg-custom-background-100 dow-xl animate-in fade-in slide-in-from-top-1"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault(); onLinkSubmit();
}
}}
>
<input
ref={inputRef}
type="url"
placeholder="Paste a link"
className="flex-1 bg-custom-background-100 border-r border-custom-border-300 p-1 text-sm outline-none placeholder:text-custom-text-400"
defaultValue={editor.getAttributes("link").href || ""}
/>
{editor.getAttributes("link").href ? (
<button
type="button"
className="flex items-center rounded-sm p-1 text-red-600 transition-all hover:bg-red-100 dark:hover:bg-red-800"
onClick={() => {
editor.chain().focus().unsetLink().run();
setIsOpen(false);
}}
>
<Trash className="h-4 w-4" />
</button>
) : (
<button className="flex items-center rounded-sm p-1 text-custom-text-300 transition-all hover:bg-custom-background-90" type="button"
onClick={() => {
onLinkSubmit();
}}
>
<Check className="h-4 w-4" />
</button>
)}
</div>
)}
</div>
);
};

View File

@ -0,0 +1,130 @@
import { Editor } from "@tiptap/core";
import {
Check,
ChevronDown,
Heading1,
Heading2,
Heading3,
TextQuote,
ListOrdered,
TextIcon,
Code,
CheckSquare,
} from "lucide-react";
import { Dispatch, FC, SetStateAction } from "react";
import { BubbleMenuItem } from "../bubble-menu";
import { cn } from "../utils";
interface NodeSelectorProps {
editor: Editor;
isOpen: boolean;
setIsOpen: Dispatch<SetStateAction<boolean>>;
}
export const NodeSelector: FC<NodeSelectorProps> = ({ editor, isOpen, setIsOpen }) => {
const items: BubbleMenuItem[] = [
{
name: "Text",
icon: TextIcon,
command: () => editor.chain().focus().toggleNode("paragraph", "paragraph").run(),
isActive: () =>
editor.isActive("paragraph") &&
!editor.isActive("bulletList") &&
!editor.isActive("orderedList"),
},
{
name: "H1",
icon: Heading1,
command: () => editor.chain().focus().toggleHeading({ level: 1 }).run(),
isActive: () => editor.isActive("heading", { level: 1 }),
},
{
name: "H2",
icon: Heading2,
command: () => editor.chain().focus().toggleHeading({ level: 2 }).run(),
isActive: () => editor.isActive("heading", { level: 2 }),
},
{
name: "H3",
icon: Heading3,
command: () => editor.chain().focus().toggleHeading({ level: 3 }).run(),
isActive: () => editor.isActive("heading", { level: 3 }),
},
{
name: "To-do List",
icon: CheckSquare,
command: () => editor.chain().focus().toggleTaskList().run(),
isActive: () => editor.isActive("taskItem"),
},
{
name: "Bullet List",
icon: ListOrdered,
command: () => editor.chain().focus().toggleBulletList().run(),
isActive: () => editor.isActive("bulletList"),
},
{
name: "Numbered List",
icon: ListOrdered,
command: () => editor.chain().focus().toggleOrderedList().run(),
isActive: () => editor.isActive("orderedList"),
},
{
name: "Quote",
icon: TextQuote,
command: () =>
editor.chain().focus().toggleNode("paragraph", "paragraph").toggleBlockquote().run(),
isActive: () => editor.isActive("blockquote"),
},
{
name: "Code",
icon: Code,
command: () => editor.chain().focus().toggleCodeBlock().run(),
isActive: () => editor.isActive("codeBlock"),
},
];
const activeItem = items.filter((item) => item.isActive()).pop() ?? {
name: "Multiple",
};
return (
<div className="relative h-full">
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
className="flex h-full items-center gap-1 whitespace-nowrap p-2 text-sm font-medium text-custom-text-300 hover:bg-custom-primary-100/5 active:bg-custom-primary-100/5"
>
<span>{activeItem?.name}</span>
<ChevronDown className="h-4 w-4" />
</button>
{isOpen && (
<section className="fixed top-full z-[99999] mt-1 flex w-48 flex-col overflow-hidden rounded border border-custom-border-300 bg-custom-background-100 p-1 shadow-xl animate-in fade-in slide-in-from-top-1">
{items.map((item, index) => (
<button
key={index}
type="button"
onClick={() => {
item.command();
setIsOpen(false);
}}
className={cn(
"flex items-center justify-between rounded-sm px-2 py-1 text-sm text-custom-text-200 hover:bg-custom-primary-100/5 hover:text-custom-text-100",
{ "bg-custom-primary-100/5 text-custom-text-100": activeItem.name === item.name }
)}
>
<div className="flex items-center space-x-2">
<div className="rounded-sm border border-custom-border-300 p-1">
<item.icon className="h-3 w-3" />
</div>
<span>{item.name}</span>
</div>
{activeItem.name === item.name && <Check className="h-4 w-4" />}
</button>
))}
</section>
)}
</div>
);
};

View File

@ -0,0 +1,12 @@
export default function isValidHttpUrl(string: string): boolean {
let url;
try {
url = new URL(string);
} catch (_) {
return false;
}
return url.protocol === "http:" || url.protocol === "https:";
}

View File

@ -0,0 +1,57 @@
import { Editor } from "@tiptap/react";
import Moveable from "react-moveable";
export const ImageResizer = ({ editor }: { editor: Editor }) => {
const updateMediaSize = () => {
const imageInfo = document.querySelector(
".ProseMirror-selectednode",
) as HTMLImageElement;
if (imageInfo) {
const selection = editor.state.selection;
editor.commands.setImage({
src: imageInfo.src,
width: Number(imageInfo.style.width.replace("px", "")),
height: Number(imageInfo.style.height.replace("px", "")),
} as any);
editor.commands.setNodeSelection(selection.from);
}
};
return (
<>
<Moveable
target={document.querySelector(".ProseMirror-selectednode") as any}
container={null}
origin={false}
edge={false}
throttleDrag={0}
keepRatio={true}
resizable={true}
throttleResize={0}
onResize={({
target,
width,
height,
delta,
}:
any) => {
delta[0] && (target!.style.width = `${width}px`);
delta[1] && (target!.style.height = `${height}px`);
}}
onResizeEnd={() => {
updateMediaSize();
}}
scalable={true}
renderDirections={["w", "e"]}
onScale={({
target,
transform,
}:
any) => {
target!.style.transform = transform;
}}
/>
</>
);
};

View File

@ -0,0 +1,137 @@
import StarterKit from "@tiptap/starter-kit";
import HorizontalRule from "@tiptap/extension-horizontal-rule";
import TiptapLink from "@tiptap/extension-link";
import Placeholder from "@tiptap/extension-placeholder";
import TiptapUnderline from "@tiptap/extension-underline";
import TextStyle from "@tiptap/extension-text-style";
import { Color } from "@tiptap/extension-color";
import TaskItem from "@tiptap/extension-task-item";
import TaskList from "@tiptap/extension-task-list";
import { Markdown } from "tiptap-markdown";
import Highlight from "@tiptap/extension-highlight";
import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight";
import { lowlight } from "lowlight/lib/core";
import SlashCommand from "../slash-command";
import { InputRule } from "@tiptap/core";
import ts from "highlight.js/lib/languages/typescript";
import "highlight.js/styles/github-dark.css";
import UniqueID from "@tiptap-pro/extension-unique-id";
import UpdatedImage from "./updated-image";
import isValidHttpUrl from "../bubble-menu/utils/link-validator";
lowlight.registerLanguage("ts", ts);
export const TiptapExtensions = (workspaceSlug: string, setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void) => [
StarterKit.configure({
bulletList: {
HTMLAttributes: {
class: "list-disc list-outside leading-3 -mt-2",
},
},
orderedList: {
HTMLAttributes: {
class: "list-decimal list-outside leading-3 -mt-2",
},
},
listItem: {
HTMLAttributes: {
class: "leading-normal -mb-2",
},
},
blockquote: {
HTMLAttributes: {
class: "border-l-4 border-custom-border-300",
},
},
code: {
HTMLAttributes: {
class:
"rounded-md bg-custom-primary-30 mx-1 px-1 py-1 font-mono font-medium text-custom-text-1000",
spellcheck: "false",
},
},
codeBlock: false,
horizontalRule: false,
dropcursor: {
color: "#DBEAFE",
width: 2,
},
gapcursor: false,
}),
CodeBlockLowlight.configure({
lowlight,
}),
HorizontalRule.extend({
addInputRules() {
return [
new InputRule({
find: /^(?:---|—-|___\s|\*\*\*\s)$/,
handler: ({ state, range, commands }) => {
commands.splitBlock();
const attributes = {};
const { tr } = state;
const start = range.from;
const end = range.to;
// @ts-ignore
tr.replaceWith(start - 1, end, this.type.create(attributes));
},
}),
];
},
}).configure({
HTMLAttributes: {
class: "mb-6 border-t border-custom-border-300",
},
}),
TiptapLink.configure({
protocols: ["http", "https"],
validate: (url) => isValidHttpUrl(url),
HTMLAttributes: {
class:
"text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer",
},
}),
UpdatedImage.configure({
HTMLAttributes: {
class: "rounded-lg border border-custom-border-300",
},
}),
Placeholder.configure({
placeholder: ({ node }) => {
if (node.type.name === "heading") {
return `Heading ${node.attrs.level}`;
}
return "Press '/' for commands...";
},
includeChildren: true,
}),
UniqueID.configure({
types: ["image"],
}),
SlashCommand(workspaceSlug, setIsSubmitting),
TiptapUnderline,
TextStyle,
Color,
Highlight.configure({
multicolor: true,
}),
TaskList.configure({
HTMLAttributes: {
class: "not-prose pl-2",
},
}),
TaskItem.configure({
HTMLAttributes: {
class: "flex items-start my-4",
},
nested: true,
}),
Markdown.configure({
html: true,
transformCopiedText: true,
}),
];

View File

@ -0,0 +1,22 @@
import Image from "@tiptap/extension-image";
import TrackImageDeletionPlugin from "../plugins/delete-image";
import UploadImagesPlugin from "../plugins/upload-image";
const UpdatedImage = Image.extend({
addProseMirrorPlugins() {
return [UploadImagesPlugin(), TrackImageDeletionPlugin()];
},
addAttributes() {
return {
...this.parent?.(),
width: {
default: '35%',
},
height: {
default: null,
},
};
},
});
export default UpdatedImage;

View File

@ -0,0 +1,100 @@
import { useEditor, EditorContent, Editor } from "@tiptap/react";
import { useDebouncedCallback } from "use-debounce";
import { EditorBubbleMenu } from "./bubble-menu";
import { TiptapExtensions } from "./extensions";
import { TiptapEditorProps } from "./props";
import { useImperativeHandle, useRef } from "react";
import { ImageResizer } from "./extensions/image-resize";
export interface ITiptapRichTextEditor {
value: string;
noBorder?: boolean;
borderOnFocus?: boolean;
customClassName?: string;
editorContentCustomClassNames?: string;
onChange?: (json: any, html: string) => void;
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void;
setShouldShowAlert?: (showAlert: boolean) => void;
workspaceSlug: string;
editable?: boolean;
forwardedRef?: any;
debouncedUpdatesEnabled?: boolean;
}
const Tiptap = (props: ITiptapRichTextEditor) => {
const {
onChange,
debouncedUpdatesEnabled,
forwardedRef,
editable,
setIsSubmitting,
setShouldShowAlert,
editorContentCustomClassNames,
value,
noBorder,
workspaceSlug,
borderOnFocus,
customClassName,
} = props;
const editor = useEditor({
editable: editable ?? true,
editorProps: TiptapEditorProps(workspaceSlug, setIsSubmitting),
extensions: TiptapExtensions(workspaceSlug, setIsSubmitting),
content: value,
onUpdate: async ({ editor }) => {
// for instant feedback loop
setIsSubmitting?.("submitting");
setShouldShowAlert?.(true);
if (debouncedUpdatesEnabled) {
debouncedUpdates({ onChange, editor });
} else {
onChange?.(editor.getJSON(), editor.getHTML());
}
},
});
const editorRef: React.MutableRefObject<Editor | null> = useRef(null);
useImperativeHandle(forwardedRef, () => ({
clearEditor: () => {
editorRef.current?.commands.clearContent();
},
setEditorValue: (content: string) => {
editorRef.current?.commands.setContent(content);
},
}));
const debouncedUpdates = useDebouncedCallback(async ({ onChange, editor }) => {
setTimeout(async () => {
if (onChange) {
onChange(editor.getJSON(), editor.getHTML());
}
}, 500);
}, 1000);
const editorClassNames = `relative w-full max-w-screen-lg sm:rounded-lg mt-2 p-3 relative focus:outline-none rounded-md
${noBorder ? "" : "border border-custom-border-200"} ${borderOnFocus ? "focus:border border-custom-border-300" : "focus:border-0"
} ${customClassName}`;
if (!editor) return null;
editorRef.current = editor;
return (
<div
id="tiptap-container"
onClick={() => {
editor?.chain().focus().run();
}}
className={`tiptap-editor-container cursor-text ${editorClassNames}`}
>
{editor && <EditorBubbleMenu editor={editor} />}
<div className={`${editorContentCustomClassNames}`}>
<EditorContent editor={editor} />
{editor?.isActive("image") && <ImageResizer editor={editor} />}
</div>
</div>
);
};
export default Tiptap;

View File

@ -0,0 +1,56 @@
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { Node as ProseMirrorNode } from '@tiptap/pm/model';
import fileService from "services/file.service";
const deleteKey = new PluginKey("delete-image");
const TrackImageDeletionPlugin = () =>
new Plugin({
key: deleteKey,
appendTransaction: (transactions, oldState, newState) => {
transactions.forEach((transaction) => {
if (!transaction.docChanged) return;
const removedImages: ProseMirrorNode[] = [];
oldState.doc.descendants((oldNode, oldPos) => {
if (oldNode.type.name !== 'image') return;
if (!newState.doc.resolve(oldPos).parent) return;
const newNode = newState.doc.nodeAt(oldPos);
// Check if the node has been deleted or replaced
if (!newNode || newNode.type.name !== 'image') {
// Check if the node still exists elsewhere in the document
let nodeExists = false;
newState.doc.descendants((node) => {
if (node.attrs.id === oldNode.attrs.id) {
nodeExists = true;
}
});
if (!nodeExists) {
removedImages.push(oldNode as ProseMirrorNode);
}
}
});
removedImages.forEach((node) => {
const src = node.attrs.src;
onNodeDeleted(src);
});
});
return null;
},
});
export default TrackImageDeletionPlugin;
async function onNodeDeleted(src: string) {
const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1);
const resStatus = await fileService.deleteImage(assetUrlWithWorkspaceId);
if (resStatus === 204) {
console.log("Image deleted successfully");
}
}

View File

@ -0,0 +1,127 @@
// @ts-nocheck
import { EditorState, Plugin, PluginKey } from "@tiptap/pm/state";
import { Decoration, DecorationSet, EditorView } from "@tiptap/pm/view";
import fileService from "services/file.service";
const uploadKey = new PluginKey("upload-image");
const UploadImagesPlugin = () =>
new Plugin({
key: uploadKey,
state: {
init() {
return DecorationSet.empty;
},
apply(tr, set) {
set = set.map(tr.mapping, tr.doc);
// See if the transaction adds or removes any placeholders
const action = tr.getMeta(uploadKey);
if (action && action.add) {
const { id, pos, src } = action.add;
const placeholder = document.createElement("div");
placeholder.setAttribute("class", "img-placeholder");
const image = document.createElement("img");
image.setAttribute(
"class",
"opacity-10 rounded-lg border border-custom-border-300",
);
image.src = src;
placeholder.appendChild(image);
const deco = Decoration.widget(pos + 1, placeholder, {
id,
});
set = set.add(tr.doc, [deco]);
} else if (action && action.remove) {
set = set.remove(set.find(undefined, undefined, (spec) => spec.id == action.remove.id));
}
return set;
},
},
props: {
decorations(state) {
return this.getState(state);
},
},
});
export default UploadImagesPlugin;
function findPlaceholder(state: EditorState, id: {}) {
const decos = uploadKey.getState(state);
const found = decos.find(
undefined,
undefined,
(spec: { id: number | undefined }) => spec.id == id
);
return found.length ? found[0].from : null;
}
export async function startImageUpload(file: File, view: EditorView, pos: number, workspaceSlug: string, setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void) {
if (!file.type.includes("image/")) {
return;
} else if (file.size / 1024 / 1024 > 20) {
return;
}
const id = {};
const tr = view.state.tr;
if (!tr.selection.empty) tr.deleteSelection();
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => {
tr.setMeta(uploadKey, {
add: {
id,
pos,
src: reader.result,
},
});
view.dispatch(tr);
};
if (!workspaceSlug) {
return;
}
setIsSubmitting?.("submitting")
const src = await UploadImageHandler(file, workspaceSlug);
const { schema } = view.state;
pos = findPlaceholder(view.state, id);
if (pos == null) return;
const imageSrc = typeof src === "object" ? reader.result : src;
const node = schema.nodes.image.create({ src: imageSrc });
const transaction = view.state.tr
.replaceWith(pos, pos, node)
.setMeta(uploadKey, { remove: { id } });
view.dispatch(transaction);
}
const UploadImageHandler = (file: File, workspaceSlug: string): Promise<string> => {
if (!workspaceSlug) {
return Promise.reject("Workspace slug is missing");
}
try {
const formData = new FormData();
formData.append("asset", file);
formData.append("attributes", JSON.stringify({}));
return new Promise(async (resolve, reject) => {
const imageUrl = await fileService
.uploadFile(workspaceSlug, formData)
.then((response) => response.asset);
const image = new Image();
image.src = imageUrl;
image.onload = () => {
resolve(imageUrl);
};
});
} catch (error) {
console.log(error);
return Promise.reject(error);
}
};

View File

@ -0,0 +1,56 @@
import { EditorProps } from "@tiptap/pm/view";
import { startImageUpload } from "./plugins/upload-image";
export function TiptapEditorProps(workspaceSlug: string, setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void): EditorProps {
return {
attributes: {
class: `prose prose-brand max-w-full prose-headings:font-display font-default focus:outline-none`,
},
handleDOMEvents: {
keydown: (_view, event) => {
// prevent default event listeners from firing when slash command is active
if (["ArrowUp", "ArrowDown", "Enter"].includes(event.key)) {
const slashCommand = document.querySelector("#slash-command");
if (slashCommand) {
return true;
}
}
},
},
handlePaste: (view, event) => {
if (
event.clipboardData &&
event.clipboardData.files &&
event.clipboardData.files[0]
) {
event.preventDefault();
const file = event.clipboardData.files[0];
const pos = view.state.selection.from;
startImageUpload(file, view, pos, workspaceSlug, setIsSubmitting);
return true;
}
return false;
},
handleDrop: (view, event, _slice, moved) => {
if (
!moved &&
event.dataTransfer &&
event.dataTransfer.files &&
event.dataTransfer.files[0]
) {
event.preventDefault();
const file = event.dataTransfer.files[0];
const coordinates = view.posAtCoords({
left: event.clientX,
top: event.clientY,
});
// here we deduct 1 from the pos or else the image will create an extra node
if (coordinates) {
startImageUpload(file, view, coordinates.pos - 1, workspaceSlug, setIsSubmitting);
}
return true;
}
return false;
},
};
}

View File

@ -0,0 +1,339 @@
import React, { useState, useEffect, useCallback, ReactNode, useRef, useLayoutEffect } from "react";
import { Editor, Range, Extension } from "@tiptap/core";
import Suggestion from "@tiptap/suggestion";
import { ReactRenderer } from "@tiptap/react";
import tippy from "tippy.js";
import {
Heading1,
Heading2,
Heading3,
List,
ListOrdered,
Text,
TextQuote,
Code,
MinusSquare,
CheckSquare,
ImageIcon,
} from "lucide-react";
import { startImageUpload } from "../plugins/upload-image";
import { cn } from "../utils";
interface CommandItemProps {
title: string;
description: string;
icon: ReactNode;
}
interface CommandProps {
editor: Editor;
range: Range;
}
const Command = Extension.create({
name: "slash-command",
addOptions() {
return {
suggestion: {
char: "/",
command: ({ editor, range, props }: { editor: Editor; range: Range; props: any }) => {
props.command({ editor, range });
},
},
};
},
addProseMirrorPlugins() {
return [
Suggestion({
editor: this.editor,
...this.options.suggestion,
}),
];
},
});
const getSuggestionItems = (workspaceSlug: string, setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void) => ({ query }: { query: string }) =>
[
{
title: "Text",
description: "Just start typing with plain text.",
searchTerms: ["p", "paragraph"],
icon: <Text size={18} />,
command: ({ editor, range }: CommandProps) => {
editor.chain().focus().deleteRange(range).toggleNode("paragraph", "paragraph").run();
},
},
{
title: "Heading 1",
description: "Big section heading.",
searchTerms: ["title", "big", "large"],
icon: <Heading1 size={18} />,
command: ({ editor, range }: CommandProps) => {
editor.chain().focus().deleteRange(range).setNode("heading", { level: 1 }).run();
},
},
{
title: "Heading 2",
description: "Medium section heading.",
searchTerms: ["subtitle", "medium"],
icon: <Heading2 size={18} />,
command: ({ editor, range }: CommandProps) => {
editor.chain().focus().deleteRange(range).setNode("heading", { level: 2 }).run();
},
},
{
title: "Heading 3",
description: "Small section heading.",
searchTerms: ["subtitle", "small"],
icon: <Heading3 size={18} />,
command: ({ editor, range }: CommandProps) => {
editor.chain().focus().deleteRange(range).setNode("heading", { level: 3 }).run();
},
},
{
title: "To-do List",
description: "Track tasks with a to-do list.",
searchTerms: ["todo", "task", "list", "check", "checkbox"],
icon: <CheckSquare size={18} />,
command: ({ editor, range }: CommandProps) => {
editor.chain().focus().deleteRange(range).toggleTaskList().run();
},
},
{
title: "Bullet List",
description: "Create a simple bullet list.",
searchTerms: ["unordered", "point"],
icon: <List size={18} />,
command: ({ editor, range }: CommandProps) => {
editor.chain().focus().deleteRange(range).toggleBulletList().run();
},
},
{
title: "Divider",
description: "Visually divide blocks",
searchTerms: ["line", "divider", "horizontal", "rule", "separate"],
icon: <MinusSquare size={18} />,
command: ({ editor, range }: CommandProps) => {
editor.chain().focus().deleteRange(range).setHorizontalRule().run();
},
},
{
title: "Numbered List",
description: "Create a list with numbering.",
searchTerms: ["ordered"],
icon: <ListOrdered size={18} />,
command: ({ editor, range }: CommandProps) => {
editor.chain().focus().deleteRange(range).toggleOrderedList().run();
},
},
{
title: "Quote",
description: "Capture a quote.",
searchTerms: ["blockquote"],
icon: <TextQuote size={18} />,
command: ({ editor, range }: CommandProps) =>
editor
.chain()
.focus()
.deleteRange(range)
.toggleNode("paragraph", "paragraph")
.toggleBlockquote()
.run(),
},
{
title: "Code",
description: "Capture a code snippet.",
searchTerms: ["codeblock"],
icon: <Code size={18} />,
command: ({ editor, range }: CommandProps) =>
editor.chain().focus().deleteRange(range).toggleCodeBlock().run(),
},
{
title: "Image",
description: "Upload an image from your computer.",
searchTerms: ["photo", "picture", "media"],
icon: <ImageIcon size={18} />,
command: ({ editor, range }: CommandProps) => {
editor.chain().focus().deleteRange(range).run();
// upload image
const input = document.createElement("input");
input.type = "file";
input.accept = "image/*";
input.onchange = async () => {
if (input.files?.length) {
const file = input.files[0];
const pos = editor.view.state.selection.from;
startImageUpload(file, editor.view, pos, workspaceSlug, setIsSubmitting);
}
};
input.click();
},
},
].filter((item) => {
if (typeof query === "string" && query.length > 0) {
const search = query.toLowerCase();
return (
item.title.toLowerCase().includes(search) ||
item.description.toLowerCase().includes(search) ||
(item.searchTerms && item.searchTerms.some((term: string) => term.includes(search)))
);
}
return true;
});
export const updateScrollView = (container: HTMLElement, item: HTMLElement) => {
const containerHeight = container.offsetHeight;
const itemHeight = item ? item.offsetHeight : 0;
const top = item.offsetTop;
const bottom = top + itemHeight;
if (top < container.scrollTop) {
container.scrollTop -= container.scrollTop - top + 5;
} else if (bottom > containerHeight + container.scrollTop) {
container.scrollTop += bottom - containerHeight - container.scrollTop + 5;
}
};
const CommandList = ({
items,
command,
}: {
items: CommandItemProps[];
command: any;
editor: any;
range: any;
}) => {
const [selectedIndex, setSelectedIndex] = useState(0);
const selectItem = useCallback(
(index: number) => {
const item = items[index];
if (item) {
command(item);
}
},
[command, items]
);
useEffect(() => {
const navigationKeys = ["ArrowUp", "ArrowDown", "Enter"];
const onKeyDown = (e: KeyboardEvent) => {
if (navigationKeys.includes(e.key)) {
e.preventDefault();
if (e.key === "ArrowUp") {
setSelectedIndex((selectedIndex + items.length - 1) % items.length);
return true;
}
if (e.key === "ArrowDown") {
setSelectedIndex((selectedIndex + 1) % items.length);
return true;
}
if (e.key === "Enter") {
selectItem(selectedIndex);
return true;
}
return false;
}
};
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("keydown", onKeyDown);
};
}, [items, selectedIndex, setSelectedIndex, selectItem]);
useEffect(() => {
setSelectedIndex(0);
}, [items]);
const commandListContainer = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
const container = commandListContainer?.current;
const item = container?.children[selectedIndex] as HTMLElement;
if (item && container) updateScrollView(container, item);
}, [selectedIndex]);
return items.length > 0 ? (
<div
id="slash-command"
ref={commandListContainer}
className="z-50 fixed h-auto max-h-[330px] w-72 overflow-y-auto rounded-md border border-custom-border-300 bg-custom-background-100 px-1 py-2 shadow-md transition-all"
>
{items.map((item: CommandItemProps, index: number) => (
<button
className={cn(
`flex w-full items-center space-x-2 rounded-md px-2 py-1 text-left text-sm text-custom-text-200 hover:bg-custom-primary-100/5 hover:text-custom-text-100`,
{ "bg-custom-primary-100/5 text-custom-text-100": index === selectedIndex }
)}
key={index}
onClick={() => selectItem(index)}
>
<div>
<p className="font-medium">{item.title}</p>
<p className="text-xs text-custom-text-300">{item.description}</p>
</div>
</button>
))}
</div>
) : null;
};
const renderItems = () => {
let component: ReactRenderer | null = null;
let popup: any | null = null;
return {
onStart: (props: { editor: Editor; clientRect: DOMRect }) => {
component = new ReactRenderer(CommandList, {
props,
editor: props.editor,
});
// @ts-ignore
popup = tippy("body", {
getReferenceClientRect: props.clientRect,
appendTo: () => document.querySelector("#tiptap-container"),
content: component.element,
showOnCreate: true,
interactive: true,
trigger: "manual",
placement: "bottom-start",
});
},
onUpdate: (props: { editor: Editor; clientRect: DOMRect }) => {
component?.updateProps(props);
popup &&
popup[0].setProps({
getReferenceClientRect: props.clientRect,
});
},
onKeyDown: (props: { event: KeyboardEvent }) => {
if (props.event.key === "Escape") {
popup?.[0].hide();
return true;
}
// @ts-ignore
return component?.ref?.onKeyDown(props);
},
onExit: () => {
popup?.[0].destroy();
component?.destroy();
},
};
};
export const SlashCommand = (workspaceSlug: string, setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void) =>
Command.configure({
suggestion: {
items: getSuggestionItems(workspaceSlug, setIsSubmitting),
render: renderItems,
},
});
export default SlashCommand;

View File

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View File

@ -0,0 +1,149 @@
"use client";
import { Fragment, useState, useRef } from "react";
// next
import Link from "next/link";
// headless
import { Popover, Transition } from "@headlessui/react";
import { ChevronLeftIcon, CheckIcon } from "@heroicons/react/20/solid";
// hooks
import useOutSideClick from "hooks/use-outside-click";
type ItemOptionType = {
display: React.ReactNode;
as?: "button" | "link" | "div";
href?: string;
isSelected?: boolean;
onClick?: () => void;
children?: ItemOptionType[] | null;
};
type DropdownItemProps = {
item: ItemOptionType;
};
type DropDownListProps = {
open: boolean;
handleClose?: () => void;
items: ItemOptionType[];
};
type DropdownProps = {
button: React.ReactNode | (() => React.ReactNode);
items: ItemOptionType[];
};
const DropdownList: React.FC<DropDownListProps> = (props) => {
const { open, items, handleClose } = props;
const ref = useRef(null);
useOutSideClick(ref, () => {
if (handleClose) handleClose();
});
return (
<Popover className="absolute -left-1">
<Transition
show={open}
as={Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel
ref={ref}
className="absolute left-1/2 -translate-x-full z-10 mt-1 max-w-[9rem] origin-top-right select-none rounded-md bg-custom-background-90 border border-custom-border-300 text-xs shadow-lg focus:outline-none"
>
<div className="w-full text-sm rounded-md shadow-lg">
{items.map((item, index) => (
<DropdownItem key={index} item={item} />
))}
</div>
</Popover.Panel>
</Transition>
</Popover>
);
};
const DropdownItem: React.FC<DropdownItemProps> = (props) => {
const { item } = props;
const { display, children, as: as_, href, onClick, isSelected } = item;
const [open, setOpen] = useState(false);
return (
<div className="w-full group relative flex gap-x-6 rounded-lg p-1">
{(!as_ || as_ === "button" || as_ === "div") && (
<button
type="button"
onClick={() => {
if (!children) {
if (onClick) onClick();
return;
}
setOpen((prev) => !prev);
}}
className={`w-full flex items-center gap-1 rounded px-1 py-1.5 text-custom-text-200 hover:bg-custom-background-80 ${
isSelected ? "bg-custom-background-80" : ""
}`}
>
{children && <ChevronLeftIcon className="h-5 w-5 transition-transform transform" />}
{!children && <span />}
<span className="truncate text-xs">{display}</span>
<CheckIcon className={`h-3.5 w-3.5 opacity-0 ${isSelected ? "opacity-100" : ""}`} />
</button>
)}
{as_ === "link" && <Link href={href || "#"}>{display}</Link>}
{children && <DropdownList open={open} handleClose={() => setOpen(false)} items={children} />}
</div>
);
};
const Dropdown: React.FC<DropdownProps> = (props) => {
const { button, items } = props;
return (
<Popover className="relative">
{({ open }) => (
<>
<Popover.Button
className={`group flex items-center justify-between gap-2 rounded-md border border-custom-border-200 px-3 py-1.5 text-xs shadow-sm duration-300 focus:outline-none hover:text-custom-text-100 hover:bg-custom-background-90 ${
open ? "bg-custom-background-90 text-custom-text-100" : "text-custom-text-200"
}`}
>
{typeof button === "function" ? button() : button}
</Popover.Button>
<Transition
as={Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel className="absolute left-full -translate-x-full z-10 mt-1 w-36 origin-top-right select-none rounded-md bg-custom-background-90 border border-custom-border-300 text-xs shadow-lg focus:outline-none">
<div className="w-full">
{items.map((item, index) => (
<DropdownItem key={index} item={item} />
))}
</div>
</Popover.Panel>
</Transition>
</>
)}
</Popover>
);
};
export { Dropdown };

View File

@ -0,0 +1,10 @@
import React from "react";
type Props = {
iconName: string;
className?: string;
};
export const Icon: React.FC<Props> = ({ iconName, className = "" }) => (
<span className={`material-symbols-rounded text-sm leading-5 font-light ${className}`}>{iconName}</span>
);

View File

@ -0,0 +1,6 @@
export * from "./dropdown";
export * from "./input";
export * from "./primary-button";
export * from "./secondary-button";
export * from "./icon";
export * from "./reaction-selector";

View File

@ -0,0 +1,37 @@
import React, { forwardRef, Ref } from "react";
// types
interface Props extends React.InputHTMLAttributes<HTMLInputElement> {
mode?: "primary" | "transparent" | "trueTransparent";
error?: boolean;
inputSize?: "rg" | "lg";
fullWidth?: boolean;
}
export const Input = forwardRef((props: Props, ref: Ref<HTMLInputElement>) => {
const { mode = "primary", error, className = "", type, fullWidth = true, id, inputSize = "rg", ...rest } = props;
return (
<input
id={id}
ref={ref}
type={type}
className={`block rounded-md bg-transparent text-sm focus:outline-none placeholder-custom-text-400 ${
mode === "primary"
? "rounded-md border border-custom-border-200"
: mode === "transparent"
? "rounded border-none bg-transparent ring-0 transition-all focus:ring-1 focus:ring-custom-primary"
: mode === "trueTransparent"
? "rounded border-none bg-transparent ring-0"
: ""
} ${error ? "border-red-500" : ""} ${error && mode === "primary" ? "bg-red-500/20" : ""} ${
fullWidth ? "w-full" : ""
} ${inputSize === "rg" ? "px-3 py-2" : inputSize === "lg" ? "p-3" : ""} ${className}`}
{...rest}
/>
);
});
Input.displayName = "Input";
export default Input;

View File

@ -0,0 +1,35 @@
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
size?: "sm" | "md" | "lg";
outline?: boolean;
loading?: boolean;
}
export const PrimaryButton: React.FC<ButtonProps> = ({
children,
className = "",
onClick,
type = "button",
disabled = false,
loading = false,
size = "sm",
outline = false,
}) => (
<button
type={type}
className={`${className} border border-custom-primary font-medium duration-300 ${
size === "sm"
? "rounded px-3 py-2 text-xs"
: size === "md"
? "rounded-md px-3.5 py-2 text-sm"
: "rounded-lg px-4 py-2 text-base"
} ${disabled ? "cursor-not-allowed opacity-70 hover:opacity-70" : ""} ${
outline
? "bg-transparent text-custom-primary hover:bg-custom-primary hover:text-white"
: "text-white bg-custom-primary hover:border-opacity-90 hover:bg-opacity-90"
} ${loading ? "cursor-wait" : ""}`}
onClick={onClick}
disabled={disabled || loading}
>
{children}
</button>
);

View File

@ -0,0 +1,77 @@
import { Fragment } from "react";
// headless ui
import { Popover, Transition } from "@headlessui/react";
// helper
import { renderEmoji } from "helpers/emoji.helper";
// icons
import { Icon } from "components/ui";
const reactionEmojis = ["128077", "128078", "128516", "128165", "128533", "129505", "9992", "128064"];
interface Props {
size?: "sm" | "md" | "lg";
position?: "top" | "bottom";
onSelect: (emoji: string) => void;
}
export const ReactionSelector: React.FC<Props> = (props) => {
const { onSelect, position, size } = props;
return (
<Popover className="relative">
{({ open, close: closePopover }) => (
<>
<Popover.Button
className={`${
open ? "" : "text-opacity-90"
} group inline-flex items-center rounded-md bg-custom-background-80 focus:outline-none`}
>
<span
className={`flex justify-center items-center rounded-md px-2 ${
size === "sm" ? "w-6 h-6" : size === "md" ? "w-7 h-7" : "w-8 h-8"
}`}
>
<Icon iconName="add_reaction" className="text-custom-text-100 scale-125" />
</span>
</Popover.Button>
<Transition
as={Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel
className={`bg-custom-sidebar-background-100 absolute -left-2 z-10 ${
position === "top" ? "-top-12" : "-bottom-12"
}`}
>
<div className="bg-custom-sidebar-background-100 border border-custom-border-200 rounded-md p-1">
<div className="flex gap-x-1">
{reactionEmojis.map((emoji) => (
<button
key={emoji}
type="button"
onClick={() => {
onSelect(emoji);
closePopover();
}}
className="flex select-none items-center justify-between rounded-md text-sm p-1 hover:bg-custom-sidebar-background-90"
>
{renderEmoji(emoji)}
</button>
))}
</div>
</div>
</Popover.Panel>
</Transition>
</>
)}
</Popover>
);
};

View File

@ -0,0 +1,35 @@
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
size?: "sm" | "md" | "lg";
outline?: boolean;
loading?: boolean;
}
export const SecondaryButton: React.FC<ButtonProps> = ({
children,
className = "",
onClick,
type = "button",
disabled = false,
loading = false,
size = "sm",
outline = false,
}) => (
<button
type={type}
className={`${className} border border-custom-border-200 font-medium duration-300 ${
size === "sm"
? "rounded px-3 py-2 text-xs"
: size === "md"
? "rounded-md px-3.5 py-2 text-sm"
: "rounded-lg px-4 py-2 text-base"
} ${disabled ? "cursor-not-allowed border-custom-border-200 bg-custom-background-90" : ""} ${
outline
? "bg-transparent hover:bg-custom-background-80"
: "bg-custom-background-100 hover:border-opacity-70 hover:bg-opacity-70"
} ${loading ? "cursor-wait" : ""}`}
onClick={onClick}
disabled={disabled || loading}
>
{children}
</button>
);

View File

@ -0,0 +1,67 @@
import React from "react";
// hooks
import {
CheckCircleIcon,
ExclamationTriangleIcon,
InformationCircleIcon,
XCircleIcon,
XMarkIcon,
} from "@heroicons/react/24/outline";
import useToast from "hooks/use-toast";
// icons
const ToastAlerts = () => {
const { alerts, removeAlert } = useToast();
if (!alerts) return null;
return (
<div className="pointer-events-none fixed top-5 right-5 z-50 h-full w-80 space-y-5 overflow-hidden">
{alerts.map((alert) => (
<div className="relative overflow-hidden rounded-md text-white" key={alert.id}>
<div className="absolute top-1 right-1">
<button
type="button"
className="pointer-events-auto inline-flex rounded-md p-1.5 focus:outline-none focus:ring-2 focus:ring-offset-2"
onClick={() => removeAlert(alert.id)}
>
<span className="sr-only">Dismiss</span>
<XMarkIcon className="h-5 w-5" aria-hidden="true" />
</button>
</div>
<div
className={`px-2 py-4 ${
alert.type === "success"
? "bg-[#06d6a0]"
: alert.type === "error"
? "bg-[#ef476f]"
: alert.type === "warning"
? "bg-[#e98601]"
: "bg-[#1B9aaa]"
}`}
>
<div className="flex items-center gap-x-3">
<div className="flex-shrink-0">
{alert.type === "success" ? (
<CheckCircleIcon className="h-8 w-8" aria-hidden="true" />
) : alert.type === "error" ? (
<XCircleIcon className="h-8 w-8" />
) : alert.type === "warning" ? (
<ExclamationTriangleIcon className="h-8 w-8" aria-hidden="true" />
) : (
<InformationCircleIcon className="h-8 w-8" />
)}
</div>
<div>
<p className="font-semibold">{alert.title}</p>
{alert.message && <p className="mt-1 text-xs">{alert.message}</p>}
</div>
</div>
</div>
</div>
))}
</div>
);
};
export default ToastAlerts;

View File

@ -56,31 +56,31 @@ export const issuePriorityFilters: IIssuePriorityFilters[] = [
{
key: "urgent",
title: "Urgent",
className: "border border-red-500/50 bg-red-500/20 text-red-500",
className: "bg-red-500/10 text-red-500",
icon: "error",
},
{
key: "high",
title: "High",
className: "border border-orange-500/50 bg-orange-500/20 text-orange-500",
className: "bg-orange-500/10 text-orange-500",
icon: "signal_cellular_alt",
},
{
key: "medium",
title: "Medium",
className: "border border-yellow-500/50 bg-yellow-500/20 text-yellow-500",
className: "bg-yellow-500/10 text-yellow-500",
icon: "signal_cellular_alt_2_bar",
},
{
key: "low",
title: "Low",
className: "border border-green-500/50 bg-green-500/20 text-green-500",
className: "bg-green-500/10 text-green-500",
icon: "signal_cellular_alt_1_bar",
},
{
key: "none",
title: "None",
className: "border border-gray-500/50 bg-gray-500/20 text-gray-500",
className: "bg-gray-500/10 text-gray-500",
icon: "block",
},
];
@ -111,35 +111,35 @@ export const issueGroups: IIssueGroup[] = [
key: "backlog",
title: "Backlog",
color: "#d9d9d9",
className: `border-[#d9d9d9]/50 text-[#d9d9d9] bg-[#d9d9d9]/10`,
className: `text-[#d9d9d9] bg-[#d9d9d9]/10`,
icon: BacklogStateIcon,
},
{
key: "unstarted",
title: "Unstarted",
color: "#3f76ff",
className: `border-[#3f76ff]/50 text-[#3f76ff] bg-[#3f76ff]/10`,
className: `text-[#3f76ff] bg-[#3f76ff]/10`,
icon: UnstartedStateIcon,
},
{
key: "started",
title: "Started",
color: "#f59e0b",
className: `border-[#f59e0b]/50 text-[#f59e0b] bg-[#f59e0b]/10`,
className: `text-[#f59e0b] bg-[#f59e0b]/10`,
icon: StartedStateIcon,
},
{
key: "completed",
title: "Completed",
color: "#16a34a",
className: `border-[#16a34a]/50 text-[#16a34a] bg-[#16a34a]/10`,
className: `text-[#16a34a] bg-[#16a34a]/10`,
icon: CompletedStateIcon,
},
{
key: "cancelled",
title: "Cancelled",
color: "#dc2626",
className: `border-[#dc2626]/50 text-[#dc2626] bg-[#dc2626]/10`,
className: `text-[#dc2626] bg-[#dc2626]/10`,
icon: CancelledStateIcon,
},
];

View File

@ -0,0 +1,12 @@
export const USER_ROLES = [
{ value: "Product / Project Manager", label: "Product / Project Manager" },
{ value: "Development / Engineering", label: "Development / Engineering" },
{ value: "Founder / Executive", label: "Founder / Executive" },
{ value: "Freelancer / Consultant", label: "Freelancer / Consultant" },
{ value: "Marketing / Growth", label: "Marketing / Growth" },
{ value: "Sales / Business Development", label: "Sales / Business Development" },
{ value: "Support / Operations", label: "Support / Operations" },
{ value: "Student / Professor", label: "Student / Professor" },
{ value: "Human Resources", label: "Human Resources" },
{ value: "Other", label: "Other" },
];

View File

@ -0,0 +1,97 @@
import React, { createContext, useCallback, useReducer } from "react";
// uuid
import { v4 as uuid } from "uuid";
// components
import ToastAlert from "components/ui/toast-alert";
export const toastContext = createContext<ContextType>({} as ContextType);
// types
type ToastAlert = {
id: string;
title: string;
message?: string;
type: "success" | "error" | "warning" | "info";
};
type ReducerActionType = {
type: "SET_TOAST_ALERT" | "REMOVE_TOAST_ALERT";
payload: ToastAlert;
};
type ContextType = {
alerts?: ToastAlert[];
removeAlert: (id: string) => void;
setToastAlert: (data: {
title: string;
type?: "success" | "error" | "warning" | "info" | undefined;
message?: string | undefined;
}) => void;
};
type StateType = {
toastAlerts?: ToastAlert[];
};
type ReducerFunctionType = (state: StateType, action: ReducerActionType) => StateType;
export const initialState: StateType = {
toastAlerts: [],
};
export const reducer: ReducerFunctionType = (state, action) => {
const { type, payload } = action;
switch (type) {
case "SET_TOAST_ALERT":
return {
...state,
toastAlerts: [...(state.toastAlerts ?? []), payload],
};
case "REMOVE_TOAST_ALERT":
return {
...state,
toastAlerts: state.toastAlerts?.filter((toastAlert) => toastAlert.id !== payload.id),
};
default: {
return state;
}
}
};
export const ToastContextProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [state, dispatch] = useReducer(reducer, initialState);
const removeAlert = useCallback((id: string) => {
dispatch({
type: "REMOVE_TOAST_ALERT",
payload: { id, title: "", message: "", type: "success" },
});
}, []);
const setToastAlert = useCallback(
(data: { title: string; type?: "success" | "error" | "warning" | "info"; message?: string }) => {
const id = uuid();
const { title, type, message } = data;
dispatch({
type: "SET_TOAST_ALERT",
payload: { id, title, message, type: type ?? "success" },
});
const timer = setTimeout(() => {
removeAlert(id);
clearTimeout(timer);
}, 3000);
},
[removeAlert]
);
return (
<toastContext.Provider value={{ setToastAlert, removeAlert, alerts: state.toastAlerts }}>
<ToastAlert />
{children}
</toastContext.Provider>
);
};

View File

@ -0,0 +1,14 @@
export const timeAgo = (time: any) => {
switch (typeof time) {
case "number":
break;
case "string":
time = +new Date(time);
break;
case "object":
if (time.constructor === Date) time = time.getTime();
break;
default:
time = +new Date();
}
};

View File

@ -0,0 +1,56 @@
export const getRandomEmoji = () => {
const emojis = [
"8986",
"9200",
"128204",
"127773",
"127891",
"127947",
"128076",
"128077",
"128187",
"128188",
"128512",
"128522",
"128578",
];
return emojis[Math.floor(Math.random() * emojis.length)];
};
export const renderEmoji = (
emoji:
| string
| {
name: string;
color: string;
}
) => {
if (!emoji) return;
if (typeof emoji === "object")
return (
<span style={{ color: emoji.color }} className="material-symbols-rounded text-lg">
{emoji.name}
</span>
);
else return isNaN(parseInt(emoji)) ? emoji : String.fromCodePoint(parseInt(emoji));
};
export const groupReactions: (reactions: any[], key: string) => { [key: string]: any[] } = (
reactions: any,
key: string
) => {
const groupedReactions = reactions.reduce(
(acc: any, reaction: any) => {
if (!acc[reaction[key]]) {
acc[reaction[key]] = [];
}
acc[reaction[key]].push(reaction);
return acc;
},
{} as { [key: string]: any[] }
);
return groupedReactions;
};

View File

@ -0,0 +1,31 @@
export const addSpaceIfCamelCase = (str: string) => str.replace(/([a-z])([A-Z])/g, "$1 $2");
const fallbackCopyTextToClipboard = (text: string) => {
var textArea = document.createElement("textarea");
textArea.value = text;
// Avoid scrolling to bottom
textArea.style.top = "0";
textArea.style.left = "0";
textArea.style.position = "fixed";
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
// FIXME: Even though we are using this as a fallback, execCommand is deprecated 👎. We should find a better way to do this.
// https://developer.mozilla.org/en-US/docs/Web/API/Document/execCommand
var successful = document.execCommand("copy");
} catch (err) {}
document.body.removeChild(textArea);
};
export const copyTextToClipboard = async (text: string) => {
if (!navigator.clipboard) {
fallbackCopyTextToClipboard(text);
return;
}
await navigator.clipboard.writeText(text);
};

View File

@ -0,0 +1,21 @@
"use client";
import { useEffect } from "react";
const useOutSideClick = (ref: any, callback: any) => {
const handleClick = (e: any) => {
if (ref.current && !ref.current.contains(e.target)) {
callback();
}
};
useEffect(() => {
document.addEventListener("click", handleClick);
return () => {
document.removeEventListener("click", handleClick);
};
});
};
export default useOutSideClick;

View File

@ -0,0 +1,19 @@
import { useState, useEffect } from "react";
const TIMER = 30;
const useTimer = (initialValue: number = TIMER) => {
const [timer, setTimer] = useState(initialValue);
useEffect(() => {
const interval = setInterval(() => {
setTimer((prev) => prev - 1);
}, 1000);
return () => clearInterval(interval);
}, []);
return { timer, setTimer };
};
export default useTimer;

View File

@ -0,0 +1,9 @@
import { useContext } from "react";
import { toastContext } from "contexts/toast.context";
const useToast = () => {
const toastContextData = useContext(toastContext);
return toastContextData;
};
export default useToast;

View File

@ -1,6 +1,8 @@
"use client";
import { useEffect } from "react";
// next imports
import { useSearchParams } from "next/navigation";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
import { RootStore } from "store/root";
@ -8,6 +10,9 @@ import { RootStore } from "store/root";
const MobxStoreInit = () => {
const store: RootStore = useMobxStore();
// search params
const routerSearchparams = useSearchParams();
useEffect(() => {
// theme
const _theme = localStorage && localStorage.getItem("app_theme") ? localStorage.getItem("app_theme") : "light";
@ -15,6 +20,18 @@ const MobxStoreInit = () => {
else localStorage.setItem("app_theme", _theme && _theme != "light" ? "dark" : "light");
}, [store?.theme]);
useEffect(() => {
if (!routerSearchparams) return;
const states = routerSearchparams.get("states");
const labels = routerSearchparams.get("labels");
const priorities = routerSearchparams.get("priorities");
store.issue.userSelectedLabels = labels?.split(",") || [];
store.issue.userSelectedPriorities = priorities?.split(",") || [];
store.issue.userSelectedStates = states?.split(",") || [];
}, [routerSearchparams, store.issue]);
return <></>;
};

View File

@ -21,6 +21,7 @@
"mobx": "^6.10.0",
"mobx-react-lite": "^4.0.3",
"next": "^13.4.16",
"next-theme": "^0.1.5",
"nprogress": "^0.2.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
@ -28,6 +29,7 @@
"uuid": "^9.0.0"
},
"devDependencies": {
"@tailwindcss/typography": "^0.5.9",
"@types/js-cookie": "^3.0.3",
"@types/uuid": "^9.0.1",
"autoprefixer": "^10.4.13",

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@ -0,0 +1,17 @@
<svg width="133" height="30" viewBox="0 0 133 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_372_3489)">
<path d="M35.7598 0.00235398H44.7151C46.4036 -0.0301787 48.0819 0.274777 49.6538 0.899752C51.0897 1.48694 52.3232 2.48876 53.2005 3.7804C54.1414 5.27171 54.6058 7.02009 54.5305 8.78788C54.6025 10.5948 54.1394 12.3823 53.2005 13.921C52.338 15.271 51.1056 16.3375 49.6538 16.9901C48.0968 17.6881 46.409 18.0371 44.7062 18.0131H40.3882V30.0024H35.7598V0.00235398ZM40.3882 14.1812H43.4472C44.5022 14.1924 45.5515 14.0225 46.5505 13.6787C47.4425 13.3684 48.2171 12.7851 48.7672 12.0095C49.3447 11.1092 49.6299 10.0487 49.5829 8.97633C49.6547 7.88162 49.368 6.79348 48.7672 5.88031C48.2009 5.1354 47.4202 4.58669 46.5328 4.30986C45.5354 3.98896 44.4937 3.83143 43.4472 3.84322H40.3882V14.1812Z" fill="#262626"/>
<path d="M63.6538 30.0023H58.9102V0.00234985H63.6538V30.0023Z" fill="#262626"/>
<path d="M83.1961 30.0023V26.0717C82.9541 26.623 82.6146 27.1249 82.1941 27.5524C81.5462 28.2456 80.7841 28.8194 79.942 29.2485C78.8993 29.7731 77.7457 30.0319 76.5816 30.0023C74.9896 30.0315 73.4209 29.6134 72.0497 28.7943C70.6785 27.9753 69.5585 26.7874 68.8144 25.3628C68.0116 23.8079 67.6091 22.0734 67.644 20.3194C67.6069 18.5595 68.0094 16.8186 68.8144 15.2581C69.5577 13.8315 70.677 12.6411 72.048 11.819C73.419 10.9969 74.9882 10.5752 76.5816 10.6006C77.7134 10.57 78.8375 10.7969 79.8711 11.2647C80.698 11.6453 81.4539 12.1675 82.1055 12.8082C82.5675 13.2374 82.9256 13.7687 83.1518 14.3607V11.139H87.8333V29.9844L83.1961 30.0023ZM72.299 20.3194C72.2745 21.3974 72.5412 22.4617 73.0704 23.3975C73.548 24.2312 74.2333 24.9235 75.0579 25.4053C75.8825 25.8872 76.8175 26.1417 77.7697 26.1435C78.7342 26.1735 79.6872 25.9254 80.5177 25.4281C81.3482 24.9308 82.0218 24.2047 82.4602 23.3347C82.9374 22.3947 83.1751 21.349 83.1518 20.2925C83.1763 19.233 82.9385 18.1841 82.4602 17.2413C81.9964 16.419 81.3267 15.7349 80.5183 15.2581C79.6888 14.7549 78.7367 14.4969 77.7697 14.5133C76.8353 14.5149 75.9179 14.7656 75.1097 15.2401C74.2725 15.73 73.5757 16.4321 73.0881 17.2772C72.5606 18.2015 72.288 19.2521 72.299 20.3194Z" fill="#262626"/>
<path d="M103.536 10.6096C104.757 10.611 105.964 10.8677 107.082 11.3634C108.238 11.8795 109.223 12.7205 109.92 13.7864C110.714 15.0781 111.104 16.5829 111.037 18.1029V30.0023H106.293V18.9733C106.348 18.3184 106.273 17.6591 106.074 17.0337C105.874 16.4082 105.554 15.8291 105.132 15.3299C104.739 14.9381 104.272 14.6317 103.758 14.4296C103.244 14.2274 102.695 14.1337 102.144 14.1543C101.323 14.1599 100.521 14.3994 99.8295 14.8453C99.0977 15.3167 98.4893 15.9602 98.0562 16.7209C97.5907 17.539 97.3518 18.469 97.3646 19.4131V30.0023H92.6387V11.157H97.3646V14.244C97.5688 13.6234 97.9368 13.0711 98.4286 12.6467C99.081 12.0457 99.8309 11.563 100.645 11.2198C101.557 10.8175 102.541 10.6097 103.536 10.6096Z" fill="#262626"/>
<path d="M118.973 21.4322C118.987 22.346 119.221 23.2426 119.656 24.0436C120.095 24.8189 120.753 25.4438 121.545 25.8384C122.494 26.3044 123.541 26.5293 124.595 26.4935C125.5 26.5093 126.4 26.3635 127.255 26.0628C127.932 25.8094 128.569 25.4556 129.143 25.0128C129.56 24.6821 129.93 24.2957 130.243 23.8642L132.335 26.7448C131.854 27.3909 131.273 27.9545 130.615 28.414C129.834 28.9511 128.963 29.3402 128.044 29.5626C126.808 29.8643 125.538 30.0031 124.267 29.9754C122.476 30.0226 120.705 29.599 119.124 28.746C117.69 27.949 116.513 26.7492 115.737 25.291C114.909 23.68 114.498 21.8834 114.54 20.0682C114.532 18.3948 114.924 16.7444 115.684 15.2581C116.41 13.8364 117.524 12.6558 118.894 11.857C120.412 11.0607 122.093 10.6338 123.803 10.6105C125.513 10.5871 127.205 10.9678 128.744 11.7224C130.081 12.4518 131.174 13.5669 131.883 14.9261C132.667 16.483 133.051 18.2141 133 19.9605C133 20.0771 133 20.3284 133 20.6963C133.005 20.9431 132.984 21.1897 132.938 21.4322H118.973ZM128.567 17.9503C128.53 17.3776 128.37 16.8201 128.097 16.317C127.762 15.6679 127.264 15.12 126.652 14.7287C125.854 14.2334 124.926 13.9955 123.992 14.0466C123.03 14.0014 122.076 14.2282 121.234 14.7017C120.661 15.0464 120.172 15.5175 119.804 16.08C119.435 16.6424 119.197 17.2817 119.106 17.9503H128.567Z" fill="#262626"/>
<path d="M29.127 0.271576H9.70898V10.0981H19.418V19.9246H29.127V0.271576Z" fill="#3F76FF"/>
<path d="M9.709 10.0981H0V19.9066H9.709V10.0981Z" fill="#3F76FF"/>
<path d="M19.4266 19.9246H9.73535V29.7511H19.4266V19.9246Z" fill="#3F76FF"/>
</g>
<defs>
<clipPath id="clip0_372_3489">
<rect width="133" height="30" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@ -0,0 +1,17 @@
<svg width="133" height="30" viewBox="0 0 133 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_372_3489)">
<path d="M35.7598 0.00235398H44.7151C46.4036 -0.0301787 48.0819 0.274777 49.6538 0.899752C51.0897 1.48694 52.3232 2.48876 53.2005 3.7804C54.1414 5.27171 54.6058 7.02009 54.5305 8.78788C54.6025 10.5948 54.1394 12.3823 53.2005 13.921C52.338 15.271 51.1056 16.3375 49.6538 16.9901C48.0968 17.6881 46.409 18.0371 44.7062 18.0131H40.3882V30.0024H35.7598V0.00235398ZM40.3882 14.1812H43.4472C44.5022 14.1924 45.5515 14.0225 46.5505 13.6787C47.4425 13.3684 48.2171 12.7851 48.7672 12.0095C49.3447 11.1092 49.6299 10.0487 49.5829 8.97633C49.6547 7.88162 49.368 6.79348 48.7672 5.88031C48.2009 5.1354 47.4202 4.58669 46.5328 4.30986C45.5354 3.98896 44.4937 3.83143 43.4472 3.84322H40.3882V14.1812Z" fill="#ffffff"/>
<path d="M63.6538 30.0023H58.9102V0.00234985H63.6538V30.0023Z" fill="#ffffff"/>
<path d="M83.1961 30.0023V26.0717C82.9541 26.623 82.6146 27.1249 82.1941 27.5524C81.5462 28.2456 80.7841 28.8194 79.942 29.2485C78.8993 29.7731 77.7457 30.0319 76.5816 30.0023C74.9896 30.0315 73.4209 29.6134 72.0497 28.7943C70.6785 27.9753 69.5585 26.7874 68.8144 25.3628C68.0116 23.8079 67.6091 22.0734 67.644 20.3194C67.6069 18.5595 68.0094 16.8186 68.8144 15.2581C69.5577 13.8315 70.677 12.6411 72.048 11.819C73.419 10.9969 74.9882 10.5752 76.5816 10.6006C77.7134 10.57 78.8375 10.7969 79.8711 11.2647C80.698 11.6453 81.4539 12.1675 82.1055 12.8082C82.5675 13.2374 82.9256 13.7687 83.1518 14.3607V11.139H87.8333V29.9844L83.1961 30.0023ZM72.299 20.3194C72.2745 21.3974 72.5412 22.4617 73.0704 23.3975C73.548 24.2312 74.2333 24.9235 75.0579 25.4053C75.8825 25.8872 76.8175 26.1417 77.7697 26.1435C78.7342 26.1735 79.6872 25.9254 80.5177 25.4281C81.3482 24.9308 82.0218 24.2047 82.4602 23.3347C82.9374 22.3947 83.1751 21.349 83.1518 20.2925C83.1763 19.233 82.9385 18.1841 82.4602 17.2413C81.9964 16.419 81.3267 15.7349 80.5183 15.2581C79.6888 14.7549 78.7367 14.4969 77.7697 14.5133C76.8353 14.5149 75.9179 14.7656 75.1097 15.2401C74.2725 15.73 73.5757 16.4321 73.0881 17.2772C72.5606 18.2015 72.288 19.2521 72.299 20.3194Z" fill="#ffffff"/>
<path d="M103.536 10.6096C104.757 10.611 105.964 10.8677 107.082 11.3634C108.238 11.8795 109.223 12.7205 109.92 13.7864C110.714 15.0781 111.104 16.5829 111.037 18.1029V30.0023H106.293V18.9733C106.348 18.3184 106.273 17.6591 106.074 17.0337C105.874 16.4082 105.554 15.8291 105.132 15.3299C104.739 14.9381 104.272 14.6317 103.758 14.4296C103.244 14.2274 102.695 14.1337 102.144 14.1543C101.323 14.1599 100.521 14.3994 99.8295 14.8453C99.0977 15.3167 98.4893 15.9602 98.0562 16.7209C97.5907 17.539 97.3518 18.469 97.3646 19.4131V30.0023H92.6387V11.157H97.3646V14.244C97.5688 13.6234 97.9368 13.0711 98.4286 12.6467C99.081 12.0457 99.8309 11.563 100.645 11.2198C101.557 10.8175 102.541 10.6097 103.536 10.6096Z" fill="#ffffff"/>
<path d="M118.973 21.4322C118.987 22.346 119.221 23.2426 119.656 24.0436C120.095 24.8189 120.753 25.4438 121.545 25.8384C122.494 26.3044 123.541 26.5293 124.595 26.4935C125.5 26.5093 126.4 26.3635 127.255 26.0628C127.932 25.8094 128.569 25.4556 129.143 25.0128C129.56 24.6821 129.93 24.2957 130.243 23.8642L132.335 26.7448C131.854 27.3909 131.273 27.9545 130.615 28.414C129.834 28.9511 128.963 29.3402 128.044 29.5626C126.808 29.8643 125.538 30.0031 124.267 29.9754C122.476 30.0226 120.705 29.599 119.124 28.746C117.69 27.949 116.513 26.7492 115.737 25.291C114.909 23.68 114.498 21.8834 114.54 20.0682C114.532 18.3948 114.924 16.7444 115.684 15.2581C116.41 13.8364 117.524 12.6558 118.894 11.857C120.412 11.0607 122.093 10.6338 123.803 10.6105C125.513 10.5871 127.205 10.9678 128.744 11.7224C130.081 12.4518 131.174 13.5669 131.883 14.9261C132.667 16.483 133.051 18.2141 133 19.9605C133 20.0771 133 20.3284 133 20.6963C133.005 20.9431 132.984 21.1897 132.938 21.4322H118.973ZM128.567 17.9503C128.53 17.3776 128.37 16.8201 128.097 16.317C127.762 15.6679 127.264 15.12 126.652 14.7287C125.854 14.2334 124.926 13.9955 123.992 14.0466C123.03 14.0014 122.076 14.2282 121.234 14.7017C120.661 15.0464 120.172 15.5175 119.804 16.08C119.435 16.6424 119.197 17.2817 119.106 17.9503H128.567Z" fill="#ffffff"/>
<path d="M29.127 0.271576H9.70898V10.0981H19.418V19.9246H29.127V0.271576Z" fill="#3F76FF"/>
<path d="M9.709 10.0981H0V19.9066H9.709V10.0981Z" fill="#3F76FF"/>
<path d="M19.4266 19.9246H9.73535V29.7511H19.4266V19.9246Z" fill="#3F76FF"/>
</g>
<defs>
<clipPath id="clip0_372_3489">
<rect width="133" height="30" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 4.4 KiB

View File

@ -0,0 +1,17 @@
<svg width="178" height="40" viewBox="0 0 178 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_435_3747)">
<path d="M47.8574 -4.66756e-06H59.8428C62.1026 -0.0433815 64.3487 0.363226 66.4525 1.19653C68.3742 1.97944 70.025 3.3152 71.1992 5.03739C72.4584 7.02581 73.0799 9.35697 72.9792 11.714C73.0756 14.1233 72.4557 16.5067 71.1992 18.5582C70.0448 20.3582 68.3954 21.7802 66.4525 22.6503C64.3686 23.581 62.1098 24.0463 59.8309 24.0144H54.0518V40H47.8574V-4.66756e-06ZM54.0518 18.9052H58.1458C59.5579 18.9201 60.9622 18.6935 62.2992 18.2351C63.493 17.8214 64.5297 17.0436 65.2658 16.0096C66.0388 14.8091 66.4205 13.3952 66.3576 11.9653C66.4536 10.5057 66.0699 9.05483 65.2658 7.83727C64.508 6.84406 63.4631 6.11244 62.2754 5.74334C60.9405 5.31547 59.5464 5.10542 58.1458 5.12114H54.0518V18.9052Z" fill="#F2F2F2"/>
<path d="M85.1905 40H78.8418V0H85.1905V40Z" fill="#F2F2F2"/>
<path d="M111.345 40V34.7592C111.021 35.4943 110.566 36.1634 110.004 36.7335C109.136 37.6576 108.117 38.4228 106.99 38.9949C105.594 39.6944 104.05 40.0394 102.492 40C100.362 40.0389 98.262 39.4814 96.4269 38.3893C94.5917 37.2972 93.0929 35.7134 92.0969 33.8139C91.0225 31.7408 90.4838 29.4281 90.5305 27.0894C90.4808 24.7429 91.0196 22.4217 92.0969 20.341C93.0918 18.4388 94.5897 16.8517 96.4246 15.7555C98.2595 14.6594 100.36 14.0971 102.492 14.131C104.007 14.0902 105.511 14.3928 106.895 15.0165C108.001 15.524 109.013 16.2202 109.885 17.0745C110.503 17.6468 110.983 18.3551 111.285 19.1445V14.8489H117.551V39.9761L111.345 40ZM96.7605 27.0894C96.7277 28.5267 97.0847 29.9458 97.7929 31.1935C98.4321 32.3051 99.3493 33.2282 100.453 33.8706C101.556 34.5131 102.808 34.8525 104.082 34.8549C105.373 34.8949 106.649 34.5641 107.76 33.901C108.871 33.2379 109.773 32.2699 110.36 31.1098C110.998 29.8565 111.317 28.4623 111.285 27.0536C111.318 25.6409 111 24.2424 110.36 22.9853C109.739 21.8888 108.843 20.9767 107.761 20.341C106.651 19.6701 105.376 19.3261 104.082 19.3479C102.832 19.3501 101.604 19.6844 100.522 20.3171C99.4017 20.9702 98.4693 21.9063 97.8166 23.0332C97.1106 24.2655 96.7459 25.6664 96.7605 27.0894Z" fill="#F2F2F2"/>
<path d="M138.567 14.143C140.201 14.1449 141.816 14.4871 143.313 15.1481C144.86 15.8362 146.178 16.9575 147.111 18.3787C148.174 20.101 148.695 22.1074 148.606 24.134V40H142.257V25.2946C142.33 24.4215 142.23 23.5424 141.963 22.7085C141.696 21.8745 141.268 21.1023 140.703 20.4367C140.177 19.9144 139.551 19.5058 138.864 19.2363C138.176 18.9667 137.441 18.8418 136.703 18.8693C135.606 18.8767 134.532 19.196 133.606 19.7906C132.627 20.4192 131.813 21.2771 131.233 22.2914C130.61 23.3822 130.29 24.6223 130.307 25.8809V40H123.982V14.8729H130.307V18.9889C130.581 18.1614 131.073 17.425 131.731 16.8591C132.605 16.0578 133.608 15.4143 134.698 14.9566C135.918 14.4202 137.235 14.1432 138.567 14.143Z" fill="#F2F2F2"/>
<path d="M159.226 28.5731C159.245 29.7916 159.558 30.987 160.14 32.055C160.728 33.0887 161.608 33.922 162.668 34.4481C163.938 35.0694 165.339 35.3692 166.75 35.3216C167.961 35.3426 169.166 35.1482 170.31 34.7472C171.217 34.4094 172.068 33.9376 172.837 33.3473C173.394 32.9063 173.889 32.3911 174.309 31.8157L177.109 35.6566C176.465 36.5181 175.688 37.2695 174.807 37.8821C173.761 38.5983 172.596 39.1172 171.366 39.4137C169.711 39.8159 168.012 40.0009 166.311 39.9641C163.915 40.027 161.543 39.4622 159.428 38.3249C157.508 37.2622 155.934 35.6624 154.895 33.7182C153.787 31.5703 153.236 29.1747 153.293 26.7544C153.282 24.5233 153.807 22.3227 154.824 20.341C155.795 18.4454 157.286 16.8713 159.12 15.8062C161.152 14.7444 163.402 14.1753 165.69 14.1442C167.979 14.113 170.243 14.6206 172.303 15.6267C174.093 16.5992 175.555 18.086 176.504 19.8983C177.552 21.9742 178.067 24.2823 177.999 26.6108C177.999 26.7664 177.999 27.1014 177.999 27.592C178.005 27.921 177.977 28.2498 177.916 28.5731H159.226ZM172.066 23.9306C172.017 23.167 171.802 22.4237 171.437 21.7529C170.989 20.8874 170.322 20.1568 169.503 19.6351C168.435 18.9747 167.194 18.6575 165.943 18.7257C164.656 18.6653 163.378 18.9678 162.252 19.5992C161.485 20.0587 160.831 20.6868 160.338 21.4368C159.844 22.1867 159.526 23.0391 159.404 23.9306H172.066Z" fill="#F2F2F2"/>
<path d="M38.9821 0.358963H12.9941V13.461H25.9881V26.563H38.9821V0.358963Z" fill="#F2F2F2"/>
<path d="M12.994 13.461H0V26.539H12.994V13.461Z" fill="#F2F2F2"/>
<path d="M25.9996 26.563H13.0293V39.665H25.9996V26.563Z" fill="#F2F2F2"/>
</g>
<defs>
<clipPath id="clip0_435_3747">
<rect width="178" height="40" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 4.4 KiB

View File

@ -90,6 +90,16 @@ abstract class APIService {
});
}
mediaUpload(url: string, data = {}, config = {}): Promise<any> {
return axios({
method: "post",
url: this.baseURL + url,
data,
headers: this.getAccessToken() ? { ...this.getHeaders(), "Content-Type": "multipart/form-data" } : {},
...config,
});
}
request(config = {}) {
return axios(config);
}

View File

@ -0,0 +1,92 @@
// services
import APIService from "services/api.service";
class AuthService extends APIService {
constructor() {
super(process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000");
}
async emailLogin(data: any) {
return this.post("/api/sign-in/", data, { headers: {} })
.then((response) => {
this.setAccessToken(response?.data?.access_token);
this.setRefreshToken(response?.data?.refresh_token);
return response?.data;
})
.catch((error) => {
throw error?.response?.data;
});
}
async emailSignUp(data: { email: string; password: string }) {
return this.post("/api/sign-up/", data, { headers: {} })
.then((response) => {
this.setAccessToken(response?.data?.access_token);
this.setRefreshToken(response?.data?.refresh_token);
return response?.data;
})
.catch((error) => {
throw error?.response?.data;
});
}
async socialAuth(data: any): Promise<{
access_token: string;
refresh_toke: string;
user: any;
}> {
return this.post("/api/social-auth/", data, { headers: {} })
.then((response) => {
this.setAccessToken(response?.data?.access_token);
this.setRefreshToken(response?.data?.refresh_token);
return response?.data;
})
.catch((error) => {
throw error?.response?.data;
});
}
async emailCode(data: any) {
return this.post("/api/magic-generate/", data, { headers: {} })
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async forgotPassword(data: { email: string }): Promise<any> {
return this.post(`/api/forgot-password/`, data)
.then((response) => response?.data)
.catch((error) => {
throw error?.response;
});
}
async magicSignIn(data: any) {
const response = await this.post("/api/magic-sign-in/", data, { headers: {} });
if (response?.status === 200) {
this.setAccessToken(response?.data?.access_token);
this.setRefreshToken(response?.data?.refresh_token);
return response?.data;
}
throw response.response.data;
}
async signOut() {
return this.post("/api/sign-out/", { refresh_token: this.getRefreshToken() })
.then((response) => {
this.purgeAccessToken();
this.purgeRefreshToken();
return response?.data;
})
.catch((error) => {
this.purgeAccessToken();
this.purgeRefreshToken();
throw error?.response?.data;
});
}
}
const authService = new AuthService();
export default authService;

View File

@ -0,0 +1,101 @@
// services
import APIService from "services/api.service";
const { NEXT_PUBLIC_API_BASE_URL } = process.env;
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;
}
interface UnSplashImageUrls {
raw: string;
full: string;
regular: string;
small: string;
thumb: string;
small_s3: string;
}
class FileServices extends APIService {
constructor() {
super(NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000");
}
async uploadFile(workspaceSlug: string, file: FormData): Promise<any> {
return this.mediaUpload(`/api/workspaces/${workspaceSlug}/file-assets/`, file)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async deleteImage(assetUrlWithWorkspaceId: string): Promise<any> {
return this.delete(`/api/workspaces/file-assets/${assetUrlWithWorkspaceId}/`)
.then((response) => response?.status)
.catch((error) => {
throw error?.response?.data;
});
}
async deleteFile(workspaceId: string, assetUrl: string): Promise<any> {
const lastIndex = assetUrl.lastIndexOf("/");
const assetId = assetUrl.substring(lastIndex + 1);
return this.delete(`/api/workspaces/file-assets/${workspaceId}/${assetId}/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async uploadUserFile(file: FormData): Promise<any> {
return this.mediaUpload(`/api/users/file-assets/`, file)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async deleteUserFile(assetUrl: string): Promise<any> {
const lastIndex = assetUrl.lastIndexOf("/");
const assetId = assetUrl.substring(lastIndex + 1);
return this.delete(`/api/users/file-assets/${assetId}`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async getUnsplashImages(page: number = 1, query?: string): Promise<UnSplashImage[]> {
const url = "/api/unsplash";
return this.request({
method: "get",
url,
params: {
page,
per_page: 20,
query,
},
})
.then((response) => response?.data?.results ?? response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
}
const fileServices = new FileServices();
export default fileServices;

View File

@ -6,8 +6,135 @@ class IssueService extends APIService {
super(process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000");
}
async getPublicIssues(workspace_slug: string, project_slug: string): Promise<any> {
return this.get(`/api/public/workspaces/${workspace_slug}/project-boards/${project_slug}/issues/`)
async getPublicIssues(workspace_slug: string, project_slug: string, params: any): Promise<any> {
return this.get(`/api/public/workspaces/${workspace_slug}/project-boards/${project_slug}/issues/`, {
params,
})
.then((response) => response?.data)
.catch((error) => {
throw error?.response;
});
}
async getIssueById(workspaceSlug: string, projectId: string, issueId: string): Promise<any> {
return this.get(`/api/public/workspaces/${workspaceSlug}/project-boards/${projectId}/issues/${issueId}/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response;
});
}
async getIssueVotes(workspaceSlug: string, projectId: string, issueId: string): Promise<any> {
return this.get(`/api/public/workspaces/${workspaceSlug}/project-boards/${projectId}/issues/${issueId}/votes/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response;
});
}
async createIssueVote(workspaceSlug: string, projectId: string, issueId: string, data: any): Promise<any> {
return this.post(
`/api/public/workspaces/${workspaceSlug}/project-boards/${projectId}/issues/${issueId}/votes/`,
data
)
.then((response) => response?.data)
.catch((error) => {
throw error?.response;
});
}
async deleteIssueVote(workspaceSlug: string, projectId: string, issueId: string): Promise<any> {
return this.delete(`/api/public/workspaces/${workspaceSlug}/project-boards/${projectId}/issues/${issueId}/votes/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response;
});
}
async getIssueReactions(workspaceSlug: string, projectId: string, issueId: string): Promise<any> {
return this.get(`/api/public/workspaces/${workspaceSlug}/project-boards/${projectId}/issues/${issueId}/reactions/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response;
});
}
async createIssueReaction(workspaceSlug: string, projectId: string, issueId: string, data: any): Promise<any> {
return this.post(
`/api/public/workspaces/${workspaceSlug}/project-boards/${projectId}/issues/${issueId}/reactions/`,
data
)
.then((response) => response?.data)
.catch((error) => {
throw error?.response;
});
}
async deleteIssueReaction(
workspaceSlug: string,
projectId: string,
issueId: string,
reactionId: string
): Promise<any> {
return this.delete(
`/api/public/workspaces/${workspaceSlug}/project-boards/${projectId}/issues/${issueId}/reactions/${reactionId}/`
)
.then((response) => response?.data)
.catch((error) => {
throw error?.response;
});
}
async getIssueComments(workspaceSlug: string, projectId: string, issueId: string): Promise<any> {
return this.get(`/api/public/workspaces/${workspaceSlug}/project-boards/${projectId}/issues/${issueId}/comments/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response;
});
}
async getCommentsReactions(workspaceSlug: string, projectId: string, commentId: string): Promise<any> {
return this.get(
`/api/public/workspaces/${workspaceSlug}/project-boards/${projectId}/comments/${commentId}/reactions/`
)
.then((response) => response?.data)
.catch((error) => {
throw error?.response;
});
}
async createIssueComment(workspaceSlug: string, projectId: string, issueId: string, data: any): Promise<any> {
return this.post(
`/api/public/workspaces/${workspaceSlug}/project-boards/${projectId}/issues/${issueId}/comments/`,
data
)
.then((response) => response?.data)
.catch((error) => {
throw error?.response;
});
}
async updateIssueComment(
workspaceSlug: string,
projectId: string,
issueId: string,
commentId: string,
data: any
): Promise<any> {
return this.patch(
`/api/public/workspaces/${workspaceSlug}/project-boards/${projectId}/issues/${issueId}/comments/${commentId}/`,
data
)
.then((response) => response?.data)
.catch((error) => {
throw error?.response;
});
}
async deleteIssueComment(workspaceSlug: string, projectId: string, issueId: string, commentId: string): Promise<any> {
return this.delete(
`/api/public/workspaces/${workspaceSlug}/project-boards/${projectId}/issues/${issueId}/comments/${commentId}/`
)
.then((response) => response?.data)
.catch((error) => {
throw error?.response;

View File

@ -13,6 +13,14 @@ class UserService extends APIService {
throw error?.response;
});
}
async updateMe(data: any): Promise<any> {
return this.patch("/api/users/me/", data)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
}
export default UserService;

View File

@ -1,12 +1,13 @@
// mobx
import { observable, action, computed, makeObservable, runInAction } from "mobx";
import { observable, action, computed, makeObservable, runInAction, reaction } from "mobx";
// service
import IssueService from "services/issue.service";
// types
import { TIssueBoardKeys } from "store/types/issue";
import { IssueDetailType, TIssueBoardKeys } from "store/types/issue";
import { IIssueStore, IIssue, IIssueState, IIssueLabel } from "./types";
class IssueStore implements IIssueStore {
// class IssueStore implements IIssueStore {
class IssueStore {
currentIssueBoardView: TIssueBoardKeys | null = null;
loader: boolean = false;
@ -16,8 +17,13 @@ class IssueStore implements IIssueStore {
labels: IIssueLabel[] | null = null;
issues: IIssue[] | null = null;
issue_detail: IssueDetailType = {};
activePeekOverviewIssueId: string | null = null;
userSelectedStates: string[] = [];
userSelectedLabels: string[] = [];
userSelectedPriorities: string[] = [];
// root store
rootStore;
// service
@ -34,9 +40,13 @@ class IssueStore implements IIssueStore {
states: observable.ref,
labels: observable.ref,
issues: observable.ref,
issue_detail: observable.ref,
userSelectedStates: observable,
userSelectedLabels: observable,
activePeekOverviewIssueId: observable.ref,
userSelectedStates: observable.ref,
userSelectedLabels: observable.ref,
userSelectedPriorities: observable.ref,
// action
setCurrentIssueBoardView: action,
getIssuesAsync: action,
@ -56,17 +66,113 @@ class IssueStore implements IIssueStore {
return this.issues?.filter((issue) => issue.state == state_id) || [];
}
setActivePeekOverviewIssueId = (issueId: string | null) => (this.activePeekOverviewIssueId = issueId);
/**
*
* @param key Is the key of the filter, i.e. state, label, priority
* @param value Is the value of the filter, i.e. state_id, label_id, priority
* @returns boolean
*/
getUserSelectedFilter(key: "state" | "priority" | "label", value: string): boolean {
if (key == "state") {
return this.userSelectedStates.includes(value);
} else if (key == "label") {
return this.userSelectedLabels.includes(value);
} else if (key == "priority") {
return this.userSelectedPriorities.includes(value);
} else {
return false;
}
}
checkIfFilterExistsForKey: (key: "state" | "priority" | "label") => boolean = (key) => {
if (key == "state") {
return this.userSelectedStates.length > 0;
} else if (key == "label") {
return this.userSelectedLabels.length > 0;
} else if (key == "priority") {
return this.userSelectedPriorities.length > 0;
} else {
return false;
}
};
clearUserSelectedFilter(key: "state" | "priority" | "label" | "all") {
if (key == "state") {
this.userSelectedStates = [];
} else if (key == "label") {
this.userSelectedLabels = [];
} else if (key == "priority") {
this.userSelectedPriorities = [];
} else if (key == "all") {
this.userSelectedStates = [];
this.userSelectedLabels = [];
this.userSelectedPriorities = [];
}
}
getIfFiltersIsEmpty: () => boolean = () =>
this.userSelectedStates.length === 0 &&
this.userSelectedLabels.length === 0 &&
this.userSelectedPriorities.length === 0;
getURLDefinition = (
workspaceSlug: string,
projectId: string,
action?: {
key: "state" | "priority" | "label" | "all";
value?: string;
removeAll?: boolean;
}
) => {
let url = `/${workspaceSlug}/${projectId}?board=${this.currentIssueBoardView}`;
if (action) {
if (action.key === "state")
this.userSelectedStates = action.removeAll
? []
: [...this.userSelectedStates].filter((state) => state !== action.value);
if (action.key === "label")
this.userSelectedLabels = action.removeAll
? []
: [...this.userSelectedLabels].filter((label) => label !== action.value);
if (action.key === "priority")
this.userSelectedPriorities = action.removeAll
? []
: [...this.userSelectedPriorities].filter((priority) => priority !== action.value);
if (action.key === "all") {
this.userSelectedStates = [];
this.userSelectedLabels = [];
this.userSelectedPriorities = [];
}
}
if (this.checkIfFilterExistsForKey("state")) {
url += `&states=${this.userSelectedStates.join(",")}`;
}
if (this.checkIfFilterExistsForKey("label")) {
url += `&labels=${this.userSelectedLabels.join(",")}`;
}
if (this.checkIfFilterExistsForKey("priority")) {
url += `&priorities=${this.userSelectedPriorities.join(",")}`;
}
return url;
};
// action
setCurrentIssueBoardView = async (view: TIssueBoardKeys) => {
this.currentIssueBoardView = view;
};
getIssuesAsync = async (workspace_slug: string, project_slug: string) => {
getIssuesAsync = async (workspaceSlug: string, projectId: string, params: any) => {
try {
this.loader = true;
this.error = null;
const response = await this.issueService.getPublicIssues(workspace_slug, project_slug);
const response = await this.issueService.getPublicIssues(workspaceSlug, projectId, params);
if (response) {
const _states: IIssueState[] = [...response?.states];
@ -86,6 +192,344 @@ class IssueStore implements IIssueStore {
return error;
}
};
getIssueByIdAsync = async (workspaceSlug: string, projectId: string, issueId: string): Promise<IssueDetailType> => {
try {
const response = this.issues?.find((issue) => issue.id === issueId);
if (response) {
const _issue_detail = {
...this.issue_detail,
[issueId]: {
issue: response,
comments: [],
reactions: [],
votes: [],
},
};
runInAction(() => {
this.issue_detail = _issue_detail;
});
this.getIssueReactionsAsync(workspaceSlug, projectId, issueId);
this.getIssueVotesAsync(workspaceSlug, projectId, issueId);
this.getIssueCommentsAsync(workspaceSlug, projectId, issueId);
}
return this.issue_detail[issueId] as any;
} catch (error) {
this.loader = false;
this.error = error;
throw error;
}
};
getIssueVotesAsync = async (workspaceSlug: string, projectId: string, issueId: string) => {
try {
const response = await this.issueService.getIssueVotes(workspaceSlug, projectId, issueId);
if (response) {
const _issue_detail = {
...this.issue_detail,
[issueId]: {
...this.issue_detail[issueId],
votes: response,
},
};
runInAction(() => {
this.issue_detail = _issue_detail;
});
}
return response;
} catch (error) {
this.loader = false;
this.error = error;
throw error;
}
};
createIssueVoteAsync = async (
workspaceSlug: string,
projectId: string,
issueId: string,
data: {
vote: 1 | -1;
}
) => {
try {
const response = await this.issueService.createIssueVote(workspaceSlug, projectId, issueId, data);
if (response) {
const _issue_detail = {
...this.issue_detail,
[issueId]: {
...this.issue_detail[issueId],
votes: [
...{ ...this.issue_detail }[issueId].votes.filter(
(vote) => vote.actor !== this.rootStore?.user?.currentUser?.id
),
response,
],
},
};
runInAction(() => {
this.issue_detail = _issue_detail;
});
}
return response;
} catch (error) {
this.loader = false;
this.error = error;
throw error;
}
};
deleteIssueVoteAsync = async (workspaceSlug: string, projectId: string, issueId: string) => {
try {
const _votes = (this.issue_detail[issueId].votes = this.issue_detail[issueId].votes.filter(
(vote) => vote.actor !== this.rootStore?.user?.user?.id
));
runInAction(() => {
this.issue_detail[issueId].votes = _votes;
});
const response = await this.issueService.deleteIssueVote(workspaceSlug, projectId, issueId);
const votesAfterCall = await this.issueService.getIssueVotes(workspaceSlug, projectId, issueId);
if (votesAfterCall)
runInAction(() => {
this.issue_detail[issueId].votes = votesAfterCall;
});
return response;
} catch (error) {
this.loader = false;
this.error = error;
throw error;
}
};
getIssueReactionsAsync = async (workspaceSlug: string, projectId: string, issueId: string) => {
try {
const response = await this.issueService.getIssueReactions(workspaceSlug, projectId, issueId);
if (response) {
const _issue_detail = {
...this.issue_detail,
[issueId]: {
...this.issue_detail[issueId],
reactions: response,
},
};
runInAction(() => {
this.issue_detail = _issue_detail;
});
}
return response;
} catch (error) {
this.loader = false;
this.error = error;
throw error;
}
};
createIssueReactionAsync = async (workspaceSlug: string, projectId: string, issueId: string, data: any) => {
try {
const response = await this.issueService.createIssueReaction(workspaceSlug, projectId, issueId, data);
if (response) {
const _issue_detail = {
...this.issue_detail,
[issueId]: {
...this.issue_detail[issueId],
reactions: [...this.issue_detail[issueId].reactions, response],
},
};
runInAction(() => {
this.issue_detail = _issue_detail;
});
}
return response;
} catch (error) {
this.loader = false;
this.error = error;
throw error;
}
};
deleteIssueReactionAsync = async (workspaceSlug: string, projectId: string, issueId: string, reactionHex: string) => {
try {
const newReactionsList = this.issue_detail[issueId].reactions.filter(
(reaction) => reaction.reaction !== reactionHex
);
const _issue_detail = {
...this.issue_detail,
[issueId]: {
...this.issue_detail[issueId],
reactions: newReactionsList,
},
};
runInAction(() => {
this.issue_detail = _issue_detail;
});
const response = await this.issueService.deleteIssueReaction(workspaceSlug, projectId, issueId, reactionHex);
const reactionsAfterCall = await this.issueService.getIssueReactions(workspaceSlug, projectId, issueId);
if (reactionsAfterCall) {
const _issue_detail = {
...this.issue_detail,
[issueId]: {
...this.issue_detail[issueId],
reactions: reactionsAfterCall,
},
};
runInAction(() => {
this.issue_detail = _issue_detail;
});
}
return response;
} catch (error) {
this.loader = false;
this.error = error;
throw error;
}
};
getIssueCommentsAsync = async (workspaceSlug: string, projectId: string, issueId: string) => {
try {
const response = await this.issueService.getIssueComments(workspaceSlug, projectId, issueId);
if (response) {
const _issue_detail = {
...this.issue_detail,
[issueId]: {
...this.issue_detail[issueId],
comments: response,
},
};
runInAction(() => {
this.issue_detail = _issue_detail;
});
}
return response;
} catch (error) {
this.loader = false;
this.error = error;
throw error;
}
};
createIssueCommentAsync = async (workspaceSlug: string, projectId: string, issueId: string, data: any) => {
try {
const response = await this.issueService.createIssueComment(workspaceSlug, projectId, issueId, data);
if (response) {
const _issue_detail = {
...this.issue_detail,
[issueId]: {
...this.issue_detail[issueId],
comments: [...this.issue_detail[issueId].comments, response],
},
};
runInAction(() => {
this.issue_detail = _issue_detail;
});
}
return response;
} catch (error) {
this.loader = false;
this.error = error;
throw error;
}
};
updateIssueCommentAsync = async (
workspaceSlug: string,
projectId: string,
issueId: string,
commentId: string,
data: any
) => {
try {
const response = await this.issueService.updateIssueComment(workspaceSlug, projectId, issueId, commentId, data);
if (response) {
const _issue_detail = {
...this.issue_detail,
[issueId]: {
...this.issue_detail[issueId],
comments: [
...this.issue_detail[issueId].comments.filter((comment) => comment.id !== response.id),
response,
],
},
};
runInAction(() => {
this.issue_detail = _issue_detail;
});
}
return response;
} catch (error) {
this.loader = false;
this.error = error;
throw error;
}
};
deleteIssueCommentAsync = async (workspaceSlug: string, projectId: string, issueId: string, commentId: string) => {
try {
const newCommentsList = this.issue_detail[issueId].comments.filter((comment) => comment.id !== commentId);
const _issue_detail = {
...this.issue_detail,
[issueId]: {
...this.issue_detail[issueId],
comments: newCommentsList,
},
};
runInAction(() => {
this.issue_detail = _issue_detail;
});
const response = await this.issueService.deleteIssueComment(workspaceSlug, projectId, issueId, commentId);
const commentsAfterCall = await this.issueService.getIssueComments(workspaceSlug, projectId, issueId);
if (commentsAfterCall) {
const _issue_detail = {
...this.issue_detail,
[issueId]: {
...this.issue_detail[issueId],
comments: commentsAfterCall,
},
};
runInAction(() => {
this.issue_detail = _issue_detail;
});
}
return response;
} catch (error) {
this.loader = false;
this.error = error;
throw error;
}
};
}
export default IssueStore;

View File

@ -6,14 +6,14 @@ import ThemeStore from "./theme";
import IssueStore from "./issue";
import ProjectStore from "./project";
// types
import { IIssueStore, IProjectStore, IThemeStore, IUserStore } from "./types";
import { IIssueStore, IProjectStore, IThemeStore } from "./types";
enableStaticRendering(typeof window === "undefined");
export class RootStore {
user: IUserStore;
user: UserStore;
theme: IThemeStore;
issue: IIssueStore;
issue: IssueStore;
project: IProjectStore;
constructor() {

View File

@ -32,11 +32,14 @@ export interface IIssue {
sequence_id: number;
name: string;
description_html: string;
project: string;
project_detail: any;
priority: TIssuePriorityKey | null;
state: string;
state_detail: any;
label_details: any;
target_date: any;
start_date: any;
}
export interface IIssueState {
@ -52,6 +55,95 @@ export interface IIssueLabel {
color: string;
}
export interface Comment {
id: string;
actor_detail: ActorDetail;
issue_detail: IssueDetail;
project_detail: ProjectDetail;
workspace_detail: WorkspaceDetail;
comment_reactions: any[];
is_member: boolean;
created_at: Date;
updated_at: Date;
comment_stripped: string;
comment_json: any;
comment_html: string;
attachments: any[];
access: string;
created_by: string;
updated_by: string;
project: string;
workspace: string;
issue: string;
actor: string;
}
export interface ActorDetail {
id: string;
first_name: string;
last_name: string;
avatar: string;
is_bot: boolean;
display_name: string;
}
export interface IssueDetail {
id: string;
name: string;
description: Description;
description_html: string;
priority: string;
start_date: null;
target_date: null;
sequence_id: number;
sort_order: number;
}
export interface Description {
type: string;
content: DescriptionContent[];
}
export interface DescriptionContent {
type: string;
attrs?: Attrs;
content: ContentContent[];
}
export interface Attrs {
level: number;
}
export interface ContentContent {
text: string;
type: string;
}
export interface ProjectDetail {
id: string;
identifier: string;
name: string;
cover_image: string;
icon_prop: null;
emoji: string;
description: string;
}
export interface WorkspaceDetail {
name: string;
slug: string;
id: string;
}
export interface IssueDetailType {
[issueId: string]: {
issue: IIssue;
comments: Comment[];
reactions: any[];
votes: any[];
};
}
export interface IIssueStore {
currentIssueBoardView: TIssueBoardKeys | null;
loader: boolean;
@ -61,12 +153,35 @@ export interface IIssueStore {
labels: IIssueLabel[] | null;
issues: IIssue[] | null;
issue_detail: IssueDetailType;
userSelectedStates: string[];
userSelectedLabels: string[];
userSelectedPriorities: string[];
getCountOfIssuesByState: (state: string) => number;
getFilteredIssuesByState: (state: string) => IIssue[];
getUserSelectedFilter: (key: "state" | "priority" | "label", value: string) => boolean;
checkIfFilterExistsForKey: (key: "state" | "priority" | "label") => boolean;
clearUserSelectedFilter: (key: "state" | "priority" | "label" | "all") => void;
getIfFiltersIsEmpty: () => boolean;
getURLDefinition: (
workspaceSlug: string,
projectId: string,
action?: {
key: "state" | "priority" | "label" | "all";
value?: string;
removeAll?: boolean;
}
) => string;
setCurrentIssueBoardView: (view: TIssueBoardKeys) => void;
getIssuesAsync: (workspace_slug: string, project_slug: string) => Promise<void>;
getIssuesAsync: (workspaceSlug: string, projectId: string, params: any) => Promise<void>;
getIssueByIdAsync: (workspaceSlug: string, projectId: string, issueId: string) => Promise<IssueDetailType>;
}

View File

@ -1,3 +1,5 @@
import { redirect } from "next/navigation";
// mobx
import { observable, action, computed, makeObservable, runInAction } from "mobx";
// service
@ -17,15 +19,47 @@ class UserStore implements IUserStore {
// observable
currentUser: observable,
// actions
setCurrentUser: action,
// computed
});
this.rootStore = _rootStore;
this.userService = new UserService();
}
setCurrentUser = (user: any) => {
// TODO: destructure user object
this.currentUser = user;
};
/**
*
* @param callback
* @description A wrapper function to check user authentication; it redirects to the login page if not authenticated, otherwise, it executes a callback.
* @example this.requiredLogin(() => { // do something });
*/
requiredLogin = (callback: () => void) => {
if (this.currentUser) {
callback();
return;
}
this.getUserAsync()
.then(() => {
if (!this.currentUser) {
const currentPath = window.location.pathname;
window.location.href = `/?next_path=${currentPath}`;
} else callback();
})
.catch(() => {
const currentPath = window.location.pathname;
window.location.href = `/?next_path=${currentPath}`;
});
};
getUserAsync = async () => {
try {
const response = this.userService.currentUser();
const response = await this.userService.currentUser();
if (response) {
runInAction(() => {
this.currentUser = response;

View File

@ -4,3 +4,148 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
html {
font-family: "Inter", sans-serif;
}
:root {
color-scheme: light !important;
--color-primary-10: 236, 241, 255;
--color-primary-20: 217, 228, 255;
--color-primary-30: 197, 214, 255;
--color-primary-40: 178, 200, 255;
--color-primary-50: 159, 187, 255;
--color-primary-60: 140, 173, 255;
--color-primary-70: 121, 159, 255;
--color-primary-80: 101, 145, 255;
--color-primary-90: 82, 132, 255;
--color-primary-100: 63, 118, 255;
--color-primary-200: 57, 106, 230;
--color-primary-300: 50, 94, 204;
--color-primary-400: 44, 83, 179;
--color-primary-500: 38, 71, 153;
--color-primary-600: 32, 59, 128;
--color-primary-700: 25, 47, 102;
--color-primary-800: 19, 35, 76;
--color-primary-900: 13, 24, 51;
--color-background-100: 255, 255, 255; /* primary bg */
--color-background-90: 250, 250, 250; /* secondary bg */
--color-background-80: 245, 245, 245; /* tertiary bg */
--color-text-100: 23, 23, 23; /* primary text */
--color-text-200: 58, 58, 58; /* secondary text */
--color-text-300: 82, 82, 82; /* tertiary text */
--color-text-400: 163, 163, 163; /* placeholder text */
--color-border-100: 245, 245, 245; /* subtle border= 1 */
--color-border-200: 229, 229, 229; /* subtle border- 2 */
--color-border-300: 212, 212, 212; /* strong border- 1 */
--color-border-400: 185, 185, 185; /* strong border- 2 */
--color-sidebar-background-100: var(--color-background-100); /* primary sidebar bg */
--color-sidebar-background-90: var(--color-background-90); /* secondary sidebar bg */
--color-sidebar-background-80: var(--color-background-80); /* tertiary sidebar bg */
--color-sidebar-text-100: var(--color-text-100); /* primary sidebar text */
--color-sidebar-text-200: var(--color-text-200); /* secondary sidebar text */
--color-sidebar-text-300: var(--color-text-300); /* tertiary sidebar text */
--color-sidebar-text-400: var(--color-text-400); /* sidebar placeholder text */
--color-sidebar-border-100: var(--color-border-100); /* subtle sidebar border= 1 */
--color-sidebar-border-200: var(--color-border-100); /* subtle sidebar border- 2 */
--color-sidebar-border-300: var(--color-border-100); /* strong sidebar border- 1 */
--color-sidebar-border-400: var(--color-border-100); /* strong sidebar border- 2 */
}
[data-theme="light"] {
color-scheme: light !important;
--color-background-100: 255, 255, 255; /* primary bg */
--color-background-90: 250, 250, 250; /* secondary bg */
--color-background-80: 245, 245, 245; /* tertiary bg */
}
[data-theme="light"] {
--color-text-100: 23, 23, 23; /* primary text */
--color-text-200: 58, 58, 58; /* secondary text */
--color-text-300: 82, 82, 82; /* tertiary text */
--color-text-400: 163, 163, 163; /* placeholder text */
--color-border-100: 245, 245, 245; /* subtle border= 1 */
--color-border-200: 229, 229, 229; /* subtle border- 2 */
--color-border-300: 212, 212, 212; /* strong border- 1 */
--color-border-400: 185, 185, 185; /* strong border- 2 */
}
[data-theme="light-contrast"] {
--color-text-100: 11, 11, 11; /* primary text */
--color-text-200: 38, 38, 38; /* secondary text */
--color-text-300: 58, 58, 58; /* tertiary text */
--color-text-400: 115, 115, 115; /* placeholder text */
--color-border-100: 34, 34, 34; /* subtle border= 1 */
--color-border-200: 38, 38, 38; /* subtle border- 2 */
--color-border-300: 46, 46, 46; /* strong border- 1 */
--color-border-400: 58, 58, 58; /* strong border- 2 */
}
[data-theme="dark"] {
color-scheme: dark !important;
--color-background-100: 7, 7, 7; /* primary bg */
--color-background-90: 11, 11, 11; /* secondary bg */
--color-background-80: 23, 23, 23; /* tertiary bg */
}
[data-theme="dark"] {
--color-text-100: 229, 229, 229; /* primary text */
--color-text-200: 163, 163, 163; /* secondary text */
--color-text-300: 115, 115, 115; /* tertiary text */
--color-text-400: 82, 82, 82; /* placeholder text */
--color-border-100: 34, 34, 34; /* subtle border= 1 */
--color-border-200: 38, 38, 38; /* subtle border- 2 */
--color-border-300: 46, 46, 46; /* strong border- 1 */
--color-border-400: 58, 58, 58; /* strong border- 2 */
}
[data-theme="light"],
[data-theme="dark"] {
--color-primary-10: 236, 241, 255;
--color-primary-20: 217, 228, 255;
--color-primary-30: 197, 214, 255;
--color-primary-40: 178, 200, 255;
--color-primary-50: 159, 187, 255;
--color-primary-60: 140, 173, 255;
--color-primary-70: 121, 159, 255;
--color-primary-80: 101, 145, 255;
--color-primary-90: 82, 132, 255;
--color-primary-100: 63, 118, 255;
--color-primary-200: 57, 106, 230;
--color-primary-300: 50, 94, 204;
--color-primary-400: 44, 83, 179;
--color-primary-500: 38, 71, 153;
--color-primary-600: 32, 59, 128;
--color-primary-700: 25, 47, 102;
--color-primary-800: 19, 35, 76;
--color-primary-900: 13, 24, 51;
--color-sidebar-background-100: var(--color-background-100); /* primary sidebar bg */
--color-sidebar-background-90: var(--color-background-90); /* secondary sidebar bg */
--color-sidebar-background-80: var(--color-background-80); /* tertiary sidebar bg */
--color-sidebar-text-100: var(--color-text-100); /* primary sidebar text */
--color-sidebar-text-200: var(--color-text-200); /* secondary sidebar text */
--color-sidebar-text-300: var(--color-text-300); /* tertiary sidebar text */
--color-sidebar-text-400: var(--color-text-400); /* sidebar placeholder text */
--color-sidebar-border-100: var(--color-border-100); /* subtle sidebar border= 1 */
--color-sidebar-border-200: var(--color-border-100); /* subtle sidebar border- 2 */
--color-sidebar-border-300: var(--color-border-100); /* strong sidebar border- 1 */
--color-sidebar-border-400: var(--color-border-100); /* strong sidebar border- 2 */
}
}

View File

@ -1,5 +1,7 @@
/** @type {import('tailwindcss').Config} */
const convertToRGB = (variableName) => `rgba(var(${variableName}))`;
module.exports = {
content: [
"./app/**/*.{js,ts,jsx,tsx}",
@ -10,8 +12,174 @@ module.exports = {
],
theme: {
extend: {
colors: {},
colors: {
custom: {
primary: {
0: "rgb(255, 255, 255)",
10: convertToRGB("--color-primary-10"),
20: convertToRGB("--color-primary-20"),
30: convertToRGB("--color-primary-30"),
40: convertToRGB("--color-primary-40"),
50: convertToRGB("--color-primary-50"),
60: convertToRGB("--color-primary-60"),
70: convertToRGB("--color-primary-70"),
80: convertToRGB("--color-primary-80"),
90: convertToRGB("--color-primary-90"),
100: convertToRGB("--color-primary-100"),
200: convertToRGB("--color-primary-200"),
300: convertToRGB("--color-primary-300"),
400: convertToRGB("--color-primary-400"),
500: convertToRGB("--color-primary-500"),
600: convertToRGB("--color-primary-600"),
700: convertToRGB("--color-primary-700"),
800: convertToRGB("--color-primary-800"),
900: convertToRGB("--color-primary-900"),
1000: "rgb(0, 0, 0)",
DEFAULT: convertToRGB("--color-primary-100"),
},
background: {
0: "rgb(255, 255, 255)",
10: convertToRGB("--color-background-10"),
20: convertToRGB("--color-background-20"),
30: convertToRGB("--color-background-30"),
40: convertToRGB("--color-background-40"),
50: convertToRGB("--color-background-50"),
60: convertToRGB("--color-background-60"),
70: convertToRGB("--color-background-70"),
80: convertToRGB("--color-background-80"),
90: convertToRGB("--color-background-90"),
100: convertToRGB("--color-background-100"),
200: convertToRGB("--color-background-200"),
300: convertToRGB("--color-background-300"),
400: convertToRGB("--color-background-400"),
500: convertToRGB("--color-background-500"),
600: convertToRGB("--color-background-600"),
700: convertToRGB("--color-background-700"),
800: convertToRGB("--color-background-800"),
900: convertToRGB("--color-background-900"),
1000: "rgb(0, 0, 0)",
DEFAULT: convertToRGB("--color-background-100"),
},
text: {
0: "rgb(255, 255, 255)",
10: convertToRGB("--color-text-10"),
20: convertToRGB("--color-text-20"),
30: convertToRGB("--color-text-30"),
40: convertToRGB("--color-text-40"),
50: convertToRGB("--color-text-50"),
60: convertToRGB("--color-text-60"),
70: convertToRGB("--color-text-70"),
80: convertToRGB("--color-text-80"),
90: convertToRGB("--color-text-90"),
100: convertToRGB("--color-text-100"),
200: convertToRGB("--color-text-200"),
300: convertToRGB("--color-text-300"),
400: convertToRGB("--color-text-400"),
500: convertToRGB("--color-text-500"),
600: convertToRGB("--color-text-600"),
700: convertToRGB("--color-text-700"),
800: convertToRGB("--color-text-800"),
900: convertToRGB("--color-text-900"),
1000: "rgb(0, 0, 0)",
DEFAULT: convertToRGB("--color-text-100"),
},
border: {
0: "rgb(255, 255, 255)",
100: convertToRGB("--color-border-100"),
200: convertToRGB("--color-border-200"),
300: convertToRGB("--color-border-300"),
400: convertToRGB("--color-border-400"),
1000: "rgb(0, 0, 0)",
DEFAULT: convertToRGB("--color-border-200"),
},
sidebar: {
background: {
0: "rgb(255, 255, 255)",
10: convertToRGB("--color-sidebar-background-10"),
20: convertToRGB("--color-sidebar-background-20"),
30: convertToRGB("--color-sidebar-background-30"),
40: convertToRGB("--color-sidebar-background-40"),
50: convertToRGB("--color-sidebar-background-50"),
60: convertToRGB("--color-sidebar-background-60"),
70: convertToRGB("--color-sidebar-background-70"),
80: convertToRGB("--color-sidebar-background-80"),
90: convertToRGB("--color-sidebar-background-90"),
100: convertToRGB("--color-sidebar-background-100"),
200: convertToRGB("--color-sidebar-background-200"),
300: convertToRGB("--color-sidebar-background-300"),
400: convertToRGB("--color-sidebar-background-400"),
500: convertToRGB("--color-sidebar-background-500"),
600: convertToRGB("--color-sidebar-background-600"),
700: convertToRGB("--color-sidebar-background-700"),
800: convertToRGB("--color-sidebar-background-800"),
900: convertToRGB("--color-sidebar-background-900"),
1000: "rgb(0, 0, 0)",
DEFAULT: convertToRGB("--color-sidebar-background-100"),
},
text: {
0: "rgb(255, 255, 255)",
10: convertToRGB("--color-sidebar-text-10"),
20: convertToRGB("--color-sidebar-text-20"),
30: convertToRGB("--color-sidebar-text-30"),
40: convertToRGB("--color-sidebar-text-40"),
50: convertToRGB("--color-sidebar-text-50"),
60: convertToRGB("--color-sidebar-text-60"),
70: convertToRGB("--color-sidebar-text-70"),
80: convertToRGB("--color-sidebar-text-80"),
90: convertToRGB("--color-sidebar-text-90"),
100: convertToRGB("--color-sidebar-text-100"),
200: convertToRGB("--color-sidebar-text-200"),
300: convertToRGB("--color-sidebar-text-300"),
400: convertToRGB("--color-sidebar-text-400"),
500: convertToRGB("--color-sidebar-text-500"),
600: convertToRGB("--color-sidebar-text-600"),
700: convertToRGB("--color-sidebar-text-700"),
800: convertToRGB("--color-sidebar-text-800"),
900: convertToRGB("--color-sidebar-text-900"),
1000: "rgb(0, 0, 0)",
DEFAULT: convertToRGB("--color-sidebar-text-100"),
},
border: {
0: "rgb(255, 255, 255)",
100: convertToRGB("--color-sidebar-border-100"),
200: convertToRGB("--color-sidebar-border-200"),
300: convertToRGB("--color-sidebar-border-300"),
400: convertToRGB("--color-sidebar-border-400"),
1000: "rgb(0, 0, 0)",
DEFAULT: convertToRGB("--color-sidebar-border-200"),
},
},
backdrop: "#131313",
},
},
typography: ({ theme }) => ({
brand: {
css: {
"--tw-prose-body": convertToRGB("--color-text-100"),
"--tw-prose-p": convertToRGB("--color-text-100"),
"--tw-prose-headings": convertToRGB("--color-text-100"),
"--tw-prose-lead": convertToRGB("--color-text-100"),
"--tw-prose-links": convertToRGB("--color-primary-100"),
"--tw-prose-bold": convertToRGB("--color-text-100"),
"--tw-prose-counters": convertToRGB("--color-text-100"),
"--tw-prose-bullets": convertToRGB("--color-text-100"),
"--tw-prose-hr": convertToRGB("--color-text-100"),
"--tw-prose-quotes": convertToRGB("--color-text-100"),
"--tw-prose-quote-borders": convertToRGB("--color-border"),
"--tw-prose-code": convertToRGB("--color-text-100"),
"--tw-prose-pre-code": convertToRGB("--color-text-100"),
"--tw-prose-pre-bg": convertToRGB("--color-background-100"),
"--tw-prose-th-borders": convertToRGB("--color-border"),
"--tw-prose-td-borders": convertToRGB("--color-border"),
},
},
}),
},
fontFamily: {
custom: ["Inter", "sans-serif"],
},
},
plugins: [],
plugins: [
require("@tailwindcss/typography"),
],
};

1279
yarn.lock

File diff suppressed because it is too large Load Diff