feat: Converting space app to pages dir (#2052)

Co-authored-by: Bavisetti Narayan <72156168+NarayanBavisetti@users.noreply.github.com>
Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
Co-authored-by: Bavisetti Narayan <narayan@Bavisettis-MacBook-Pro.local>
Co-authored-by: Nikhil <118773738+pablohashescobar@users.noreply.github.com>
Co-authored-by: M. Palanikannan <73993394+Palanikannan1437@users.noreply.github.com>
Co-authored-by: Lakhan Baheti <94619783+1akhanBaheti@users.noreply.github.com>
Co-authored-by: Dakshesh Jain <65905942+dakshesh14@users.noreply.github.com>
Co-authored-by: Aaryan Khandelwal <65252264+aaryan610@users.noreply.github.com>
This commit is contained in:
sriram veeraghanta 2023-09-01 16:42:30 +05:30 committed by GitHub
parent c03550656a
commit 8a95a41100
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
133 changed files with 7993 additions and 1521 deletions

View File

@ -1,66 +0,0 @@
// next imports
import Link from "next/link";
import Image from "next/image";
import { Metadata, ResolvingMetadata } from "next";
// components
import IssueNavbar from "components/issues/navbar";
import IssueFilter from "components/issues/filters-render";
// service
import ProjectService from "services/project.service";
import { redirect } from "next/navigation";
type LayoutProps = {
params: { workspace_slug: string; project_slug: string };
};
export async function generateMetadata({ params }: LayoutProps): Promise<Metadata> {
// read route params
const { workspace_slug, project_slug } = params;
const projectServiceInstance = new ProjectService();
try {
const project = await projectServiceInstance?.getProjectSettingsAsync(workspace_slug, project_slug);
return {
title: `${project?.project_details?.name} | ${workspace_slug}`,
description: `${
project?.project_details?.description || `${project?.project_details?.name} | ${workspace_slug}`
}`,
icons: `data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>${
typeof project?.project_details?.emoji != "object"
? String.fromCodePoint(parseInt(project?.project_details?.emoji))
: "✈️"
}</text></svg>`,
};
} catch (error: any) {
if (error?.data?.error) {
redirect(`/project-not-published`);
}
return {};
}
}
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">
<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>
<div className="absolute z-[99999] bottom-[10px] right-[10px] bg-white rounded-sm shadow-lg border border-gray-100">
<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">
Powered by <b>Plane Deploy</b>
</div>
</Link>
</div>
</div>
);
export default RootLayout;

View File

@ -1,113 +0,0 @@
"use client";
import { useEffect } from "react";
// next imports
import { useRouter, useParams, useSearchParams } from "next/navigation";
// mobx
import { observer } from "mobx-react-lite";
// components
import { IssueListView } from "components/issues/board-views/list";
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";
// mobx store
import { RootStore } from "store/root";
import { useMobxStore } from "lib/mobx/store-provider";
// types
import { TIssueBoardKeys } from "store/types";
const WorkspaceProjectPage = observer(() => {
const store: RootStore = useMobxStore();
const router = useRouter();
const routerParams = useParams();
const routerSearchparams = useSearchParams();
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 | "");
// updating default board view when we are in the issues page
useEffect(() => {
if (workspace_slug && project_slug && store?.project?.workspaceProjectSettings) {
const workspacePRojectSettingViews = store?.project?.workspaceProjectSettings?.views;
const userAccessViews: TIssueBoardKeys[] = [];
Object.keys(workspacePRojectSettingViews).filter((_key) => {
if (_key === "list" && workspacePRojectSettingViews.list === true) userAccessViews.push(_key);
if (_key === "kanban" && workspacePRojectSettingViews.kanban === true) userAccessViews.push(_key);
if (_key === "calendar" && workspacePRojectSettingViews.calendar === true) userAccessViews.push(_key);
if (_key === "spreadsheet" && workspacePRojectSettingViews.spreadsheet === true) userAccessViews.push(_key);
if (_key === "gantt" && workspacePRojectSettingViews.gantt === true) userAccessViews.push(_key);
});
if (userAccessViews && userAccessViews.length > 0) {
if (!board) {
store.issue.setCurrentIssueBoardView(userAccessViews[0]);
router.replace(`/${workspace_slug}/${project_slug}?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 {
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]}`);
}
}
}
}
}, [workspace_slug, project_slug, board, router, store?.issue, store?.project?.workspaceProjectSettings]);
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]);
return (
<div className="relative w-full h-full overflow-hidden">
{store?.issue?.loader && !store.issue.issues ? (
<div className="text-sm text-center py-10 text-gray-500">Loading...</div>
) : (
<>
{store?.issue?.error ? (
<div className="text-sm text-center py-10 text-gray-500">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">
<IssueListView />
</div>
</div>
)}
{store?.issue?.currentIssueBoardView === "kanban" && (
<div className="relative w-full h-full mx-auto px-5">
<IssueKanbanView />
</div>
)}
{store?.issue?.currentIssueBoardView === "calendar" && <IssueCalendarView />}
{store?.issue?.currentIssueBoardView === "spreadsheet" && <IssueSpreadsheetView />}
{store?.issue?.currentIssueBoardView === "gantt" && <IssueGanttView />}
</>
)
)}
</>
)}
</div>
);
});
export default WorkspaceProjectPage;

View File

@ -1,20 +0,0 @@
"use client";
// root styles
import "styles/globals.css";
// 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">
<MobxStoreProvider>
<MobxStoreInit />
<main>{children}</main>
</MobxStoreProvider>
</body>
</html>
);
export default RootLayout;

View File

@ -1,9 +0,0 @@
"use client";
import React from "react";
const HomePage = () => (
<div className="relative w-screen h-screen flex justify-center items-center text-5xl">Plane Deploy</div>
);
export default HomePage;

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,55 @@
import { useEffect, useState, FC } from "react";
import Link from "next/link";
import Image from "next/image";
import { useRouter } from "next/router";
// 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 router = useRouter();
const { code } = router.query;
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

@ -0,0 +1,6 @@
export * from "./email-code-form";
export * from "./email-password-form";
export * from "./email-reset-password-form";
export * from "./github-login-button";
export * from "./google-login";
export * from "./onboarding-form";

View File

@ -0,0 +1,187 @@
import { useEffect, Fragment } from "react";
import { useRouter } from "next/router";
// 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 OnBoardingForm: React.FC<Props> = observer(({ user }) => {
const { setToastAlert } = useToast();
const router = useRouter();
const { next_path } = router.query;
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);
router.push(next_path?.toString() || "/");
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,10 @@
"use client";
export const IssueBlockDownVotes = ({ number }: { number: number }) => (
<div className="h-6 rounded flex px-1.5 pl-1 py-1 items-center border-[0.5px] border-custom-border-300 text-custom-text-300 text-xs">
<span className="material-symbols-rounded text-base !p-0 !m-0 rotate-180 text-custom-text-300">
arrow_upward_alt
</span>
{number}
</div>
);

View File

@ -1,32 +1,60 @@
"use client";
// helpers
import { renderDateFormat } from "constants/helpers";
import { renderFullDate } from "constants/helpers";
export const findHowManyDaysLeft = (date: string | Date) => {
const today = new Date();
const eventDate = new Date(date);
const timeDiff = Math.abs(eventDate.getTime() - today.getTime());
return Math.ceil(timeDiff / (1000 * 3600 * 24));
};
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 dueDateIcon = (
date: string,
stateGroup: string
): {
iconName: string;
className: string;
} => {
let iconName = "calendar_today";
let className = "";
if (!date || ["completed", "cancelled"].includes(stateGroup)) {
iconName = "calendar_today";
className = "";
} 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`;
if (dueDate < today) {
iconName = "event_busy";
className = "text-red-500";
} else if (dueDate > today) {
iconName = "calendar_today";
className = "";
} else {
iconName = "today";
className = "text-red-500";
}
}
return {
iconName,
className,
};
};
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
${validDate(due_date, state)}`}
>
{renderDateFormat(due_date)}
</div>
);
export const IssueBlockDueDate = ({ due_date, group }: { due_date: string; group: string }) => {
const iconDetails = dueDateIcon(due_date, group);
return (
<div className="rounded flex px-2.5 py-1 items-center border-[0.5px] border-custom-border-300 gap-1 text-custom-text-100 text-xs">
<span className={`material-symbols-rounded text-sm -my-0.5 ${iconDetails.className}`}>
{iconDetails.iconName}
</span>
{renderFullDate(due_date)}
</div>
);
};

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

@ -1,7 +1,7 @@
"use client";
// types
import { TIssuePriorityKey } from "store/types/issue";
import { TIssuePriorityKey } from "types/issue";
// constants
import { issuePriorityFilter } from "constants/data";
@ -9,9 +9,10 @@ export const IssueBlockPriority = ({ priority }: { priority: TIssuePriorityKey |
const priority_detail = priority != null ? issuePriorityFilter(priority) : null;
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 grid place-items-center border-[0.5px] ${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 shadow-sm border-[0.5px] 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-1.5 text-custom-text-200">
<stateGroup.icon />
<div className="text-xs">{state?.name}</div>
</div>
</div>
);
};

View File

@ -0,0 +1,8 @@
"use client";
export const IssueBlockUpVotes = ({ number }: { number: number }) => (
<div className="h-6 rounded flex px-1.5 pl-1 py-1 items-center border-[0.5px] border-custom-border-300 text-custom-text-300 text-xs">
<span className="material-symbols-rounded text-base !p-0 !m-0 text-custom-text-300">arrow_upward_alt</span>
{number}
</div>
);

View File

@ -2,32 +2,55 @@
// 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 { IIssue } from "types/issue";
import { RootStore } from "store/root";
import { useRouter } from "next/router";
export const IssueListBlock = ({ issue }: { issue: IIssue }) => {
const store: RootStore = useMobxStore();
export const IssueListBlock = observer(({ issue }: { issue: IIssue }) => {
const { project: projectStore, issueDetails: issueDetailStore }: RootStore = useMobxStore();
// router
const router = useRouter();
const { workspace_slug, project_slug, board } = router.query;
const handleBlockClick = () => {
issueDetailStore.setPeekId(issue.id);
router.replace(
{
pathname: `/${workspace_slug?.toString()}/${project_slug}`,
query: {
board: board?.toString(),
peekId: issue.id,
},
},
undefined,
{ shallow: true }
);
// router.push(`/${workspace_slug?.toString()}/${project_slug}?board=${board?.toString()}&peekId=${issue.id}`);
};
return (
<div className="p-2 px-3 bg-white space-y-2 rounded-sm shadow">
<div className="py-3 px-4 h-[118px] flex flex-col gap-1.5 bg-custom-background-100 rounded shadow-custom-shadow-sm border-[0.5px] border-custom-border-200">
{/* id */}
<div className="flex-shrink-0 text-sm text-gray-600 w-[60px]">
{store?.project?.project?.identifier}-{issue?.sequence_id}
<div className="text-xs text-custom-text-300 break-words">
{projectStore?.project?.identifier}-{issue?.sequence_id}
</div>
{/* name */}
<div className="font-medium text-gray-800 h-full line-clamp-2">{issue.name}</div>
<h6 onClick={handleBlockClick} className="text-sm font-medium break-words line-clamp-2 cursor-pointer">
{issue.name}
</h6>
{/* priority */}
<div className="relative flex flex-wrap items-center gap-2 w-full">
<div className="relative flex-grow flex items-end gap-2 w-full overflow-x-scroll hide-horizontal-scrollbar">
{/* priority */}
{issue?.priority && (
<div className="flex-shrink-0">
<IssueBlockPriority priority={issue?.priority} />
@ -39,12 +62,6 @@ export const IssueListBlock = ({ issue }: { issue: IIssue }) => {
<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>
)}
{/* due date */}
{issue?.target_date && (
<div className="flex-shrink-0">
@ -54,4 +71,4 @@ export const IssueListBlock = ({ issue }: { issue: IIssue }) => {
</div>
</div>
);
};
});

View File

@ -3,7 +3,7 @@
// mobx react lite
import { observer } from "mobx-react-lite";
// interfaces
import { IIssueState } from "store/types/issue";
import { IIssueState } from "types/issue";
// constants
import { issueGroupFilter } from "constants/data";
// mobx hook
@ -18,14 +18,14 @@ export const IssueListHeader = observer(({ state }: { state: IIssueState }) => {
if (stateGroup === null) return <></>;
return (
<div className="py-2 flex items-center gap-2">
<div className="w-[28px] h-[28px] flex justify-center items-center">
<div className="pb-2 px-2 flex items-center">
<div className="w-4 h-4 flex justify-center items-center flex-shrink-0">
<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-custom-text-200 capitalize ml-2 mr-3 truncate">{state?.name}</div>
<span className="text-custom-text-300 rounded-full flex-shrink-0">
{store.issue.getCountOfIssuesByState(state.id)}
</div>
</span>
</div>
);
});

View File

@ -6,7 +6,7 @@ import { observer } from "mobx-react-lite";
import { IssueListHeader } from "components/issues/board-views/kanban/header";
import { IssueListBlock } from "components/issues/board-views/kanban/block";
// interfaces
import { IIssueState, IIssue } from "store/types/issue";
import { IIssueState, IIssue } from "types/issue";
// mobx hook
import { useMobxStore } from "lib/mobx/store-provider";
import { RootStore } from "store/root";
@ -19,20 +19,20 @@ 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>
<div className="w-full h-full overflow-hidden overflow-y-auto">
<div className="w-full h-full overflow-hidden overflow-y-auto hide-vertical-scrollbar">
{store.issue.getFilteredIssuesByState(_state.id) &&
store.issue.getFilteredIssuesByState(_state.id).length > 0 ? (
<div className="space-y-3 pb-2">
<div className="space-y-3 pb-2 px-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

@ -1,59 +1,102 @@
"use client";
// mobx react lite
import { FC } from "react";
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
// 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";
import { IssueBlockUpVotes } from "components/issues/board-views/block-upvotes";
import { IssueBlockDownVotes } from "components/issues/board-views/block-downvotes";
// mobx hook
import { useMobxStore } from "lib/mobx/store-provider";
// interfaces
import { IIssue } from "store/types/issue";
import { IIssue } from "types/issue";
// store
import { RootStore } from "store/root";
export const IssueListBlock = ({ issue }: { issue: IIssue }) => {
const store: RootStore = useMobxStore();
export const IssueListBlock: FC<{ issue: IIssue }> = observer((props) => {
const { issue } = props;
// store
const { project: projectStore, issueDetails: issueDetailStore }: RootStore = useMobxStore();
// router
const router = useRouter();
const { workspace_slug, project_slug, board } = router.query;
const handleBlockClick = () => {
issueDetailStore.setPeekId(issue.id);
router.replace(
{
pathname: `/${workspace_slug?.toString()}/${project_slug}`,
query: {
board: board?.toString(),
peekId: issue.id,
},
},
undefined,
{ shallow: true }
);
// router.push(`/${workspace_slug?.toString()}/${project_slug}?board=${board?.toString()}&peekId=${issue.id}`);
};
const totalUpVotes = issue.votes.filter((v) => v.vote === 1);
const totalDownVotes = issue.votes.filter((v) => v.vote === -1);
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-6 py-3.5 relative gap-10 bg-custom-background-100">
<div className="relative flex items-center gap-5 w-full flex-grow overflow-hidden">
{/* id */}
<div className="flex-shrink-0 text-sm text-gray-600 w-[60px]">
{store?.project?.project?.identifier}-{issue?.sequence_id}
<div className="flex-shrink-0 text-sm text-custom-text-300">
{projectStore?.project?.identifier}-{issue?.sequence_id}
</div>
{/* name */}
<div className="font-medium text-gray-800 h-full line-clamp-1">{issue.name}</div>
<div onClick={handleBlockClick} className="font-medium text-sm truncate flex-grow cursor-pointer">
{issue.name}
</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">
{projectStore.deploySettings?.votes && (
<>
{/* upvotes */}
<div className="flex-shrink-0">
<IssueBlockUpVotes number={totalUpVotes.length} />
</div>
{/* downotes */}
<div className="flex-shrink-0">
<IssueBlockDownVotes number={totalDownVotes.length} />
</div>
</>
)}
{/* state */}
{issue?.state_detail && (
<div className="flex-shrink-0">
<IssueBlockState state={issue?.state_detail} />
</div>
)}
{/* priority */}
{issue?.priority && (
<div className="flex-shrink-0">
<IssueBlockPriority priority={issue?.priority} />
</div>
)}
{/* labels */}
{issue?.label_details && issue?.label_details.length > 0 && (
<div className="flex-shrink-0">
<IssueBlockLabels labels={issue?.label_details} />
</div>
)}
{/* state */}
{issue?.state_detail && (
<div className="flex-shrink-0">
<IssueBlockState state={issue?.state_detail} />
</div>
)}
{/* due date */}
{issue?.target_date && (
<div className="flex-shrink-0">
<IssueBlockDueDate due_date={issue?.target_date} group={issue?.state_detail?.group} />
</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>
)}
</div>
</div>
);
};
});

View File

@ -3,7 +3,7 @@
// mobx react lite
import { observer } from "mobx-react-lite";
// interfaces
import { IIssueState } from "store/types/issue";
import { IIssueState } from "types/issue";
// constants
import { issueGroupFilter } from "constants/data";
// mobx hook
@ -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="w-[28px] h-[28px] flex justify-center items-center">
<div className="px-6 py-2 flex items-center">
<div className="w-4 h-4 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 capitalize ml-2 mr-3">{state?.name}</div>
<div className="text-custom-text-200">{store.issue.getCountOfIssuesByState(state.id)}</div>
</div>
);
});

View File

@ -1,35 +1,37 @@
"use client";
// mobx react lite
import { useEffect } from "react";
import { observer } from "mobx-react-lite";
// components
import { IssueListHeader } from "components/issues/board-views/list/header";
import { IssueListBlock } from "components/issues/board-views/list/block";
// interfaces
import { IIssueState, IIssue } from "store/types/issue";
import { IIssueState, IIssue } from "types/issue";
// mobx hook
import { useMobxStore } from "lib/mobx/store-provider";
// store
import { RootStore } from "store/root";
import { useRouter } from "next/router";
export const IssueListView = observer(() => {
const store: RootStore = useMobxStore();
const { issue: issueStore }: RootStore = useMobxStore();
return (
<>
{store?.issue?.states &&
store?.issue?.states.length > 0 &&
store?.issue?.states.map((_state: IIssueState) => (
<div className="relative w-full">
{issueStore?.states &&
issueStore?.states.length > 0 &&
issueStore?.states.map((_state: IIssueState) => (
<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">
{store.issue.getFilteredIssuesByState(_state.id).map((_issue: IIssue) => (
<IssueListBlock issue={_issue} />
{issueStore.getFilteredIssuesByState(_state.id) &&
issueStore.getFilteredIssuesByState(_state.id).length > 0 ? (
<div className="divide-y divide-custom-border-200">
{issueStore.getFilteredIssuesByState(_state.id).map((_issue: IIssue) => (
<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-6 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,10 @@
"use client";
import { useRouter } from "next/router";
// 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 +12,39 @@ import { RootStore } from "store/root";
const IssueFilter = observer(() => {
const store: RootStore = useMobxStore();
const clearAllFilters = () => {};
const router = useRouter();
const { workspace_slug, project_slug } = router.query 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,42 @@
"use client";
import { useRouter } from "next/router";
// 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";
import { IIssueLabel } from "types/issue";
export const RenderIssueLabel = observer(({ label }: { label: IIssueLabel }) => {
const store = useMobxStore();
const removeLabelFromFilter = () => {};
const router = useRouter();
const { workspace_slug, project_slug } = router.query 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,11 +1,10 @@
"use client";
import { useRouter } from "next/router";
// mobx react lite
import { observer } from "mobx-react-lite";
// components
import { RenderIssueLabel } from "./filter-label-block";
// interfaces
import { IIssueLabel } from "store/types/issue";
import { IIssueLabel } from "types/issue";
// mobx hook
import { useMobxStore } from "lib/mobx/store-provider";
import { RootStore } from "store/root";
@ -13,21 +12,36 @@ import { RootStore } from "store/root";
const IssueLabelFilter = observer(() => {
const store: RootStore = useMobxStore();
const clearLabelFilters = () => {};
const router = useRouter();
const { workspace_slug, project_slug } = router.query 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 &&
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,32 +1,41 @@
"use client";
import { useRouter } from "next/router";
// mobx react lite
import { observer } from "mobx-react-lite";
// mobx hook
import { useMobxStore } from "lib/mobx/store-provider";
// interfaces
import { IIssuePriorityFilters } from "store/types/issue";
import { IIssuePriorityFilters } from "types/issue";
export const RenderIssuePriority = observer(({ priority }: { priority: IIssuePriorityFilters }) => {
const store = useMobxStore();
const removePriorityFromFilter = () => {};
const router = useRouter();
const { workspace_slug, project_slug } = router.query 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,4 @@
"use client";
import { useRouter } from "next/router";
// mobx react lite
import { observer } from "mobx-react-lite";
// mobx hook
@ -7,28 +6,46 @@ import { useMobxStore } from "lib/mobx/store-provider";
// components
import { RenderIssuePriority } from "./filter-priority-block";
// interfaces
import { IIssuePriorityFilters } from "store/types/issue";
import { IIssuePriorityFilters } from "types/issue";
// constants
import { issuePriorityFilters } from "constants/data";
const IssuePriorityFilter = observer(() => {
const store = useMobxStore();
const router = useRouter();
const { workspace_slug, project_slug } = router.query 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,37 +1,42 @@
"use client";
import { useRouter } from "next/router";
// mobx react lite
import { observer } from "mobx-react-lite";
// mobx hook
import { useMobxStore } from "lib/mobx/store-provider";
// interfaces
import { IIssueState } from "store/types/issue";
import { IIssueState } from "types/issue";
// constants
import { issueGroupFilter } from "constants/data";
export const RenderIssueState = observer(({ state }: { state: IIssueState }) => {
const store = useMobxStore();
const router = useRouter();
const { workspace_slug, project_slug } = router.query 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,11 +1,10 @@
"use client";
import { useRouter } from "next/router";
// mobx react lite
import { observer } from "mobx-react-lite";
// components
import { RenderIssueState } from "./filter-state-block";
// interfaces
import { IIssueState } from "store/types/issue";
import { IIssueState } from "types/issue";
// mobx hook
import { useMobxStore } from "lib/mobx/store-provider";
import { RootStore } from "store/root";
@ -13,21 +12,36 @@ import { RootStore } from "store/root";
const IssueStateFilter = observer(() => {
const store: RootStore = useMobxStore();
const clearStateFilters = () => {};
const router = useRouter();
const { workspace_slug, project_slug } = router.query 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 &&
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

@ -1,17 +1,15 @@
"use client";
// next imports
import { useEffect } from "react";
import Image from "next/image";
import { observer } from "mobx-react-lite";
import { useRouter } from "next/router";
// components
import { NavbarSearch } from "./search";
import { NavbarIssueBoardView } from "./issue-board-view";
import { NavbarIssueFilter } from "./issue-filter";
import { NavbarIssueView } from "./issue-view";
import { NavbarTheme } from "./theme";
// mobx react lite
import { observer } from "mobx-react-lite";
// mobx
// lib
import { useMobxStore } from "lib/mobx/store-provider";
// store
import { RootStore } from "store/root";
const renderEmoji = (emoji: string | { name: string; color: string }) => {
@ -27,21 +25,41 @@ const renderEmoji = (emoji: string | { name: string; color: string }) => {
};
const IssueNavbar = observer(() => {
const store: RootStore = useMobxStore();
const { project: projectStore }: RootStore = useMobxStore();
// router
const router = useRouter();
const { workspace_slug, project_slug, board } = router.query;
useEffect(() => {
if (workspace_slug && project_slug) {
projectStore.fetchProjectSettings(workspace_slug.toString(), project_slug.toString());
}
}, [projectStore, workspace_slug, project_slug]);
useEffect(() => {
if (workspace_slug && projectStore) {
if (board) {
projectStore.setActiveBoard(board.toString());
} else {
router.push(`/${workspace_slug}/${project_slug}?board=list`);
projectStore.setActiveBoard("list");
}
}
}, [board, router, projectStore, workspace_slug, project_slug]);
return (
<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]">
{store?.project?.project && store?.project?.project?.emoji ? (
renderEmoji(store?.project?.project?.emoji)
<div className="w-4 h-4 flex justify-center items-center">
{projectStore?.project && projectStore?.project?.emoji ? (
renderEmoji(projectStore?.project?.emoji)
) : (
<Image src="/plane-logo.webp" alt="plane logo" className="w-[24px] h-[24px]" height="24" width="24" />
)}
</div>
<div className="font-medium text-lg max-w-[300px] line-clamp-1 overflow-hidden">
{store?.project?.project?.name || `...`}
{projectStore?.project?.name || `...`}
</div>
</div>
@ -55,16 +73,10 @@ const IssueNavbar = observer(() => {
<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,54 +1,63 @@
"use client";
// next imports
import { useRouter, useParams } from "next/navigation";
// mobx react lite
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
// constants
import { issueViews } from "constants/data";
// interfaces
import { TIssueBoardKeys } from "store/types";
// mobx
import { useMobxStore } from "lib/mobx/store-provider";
import { RootStore } from "store/root";
export const NavbarIssueBoardView = observer(() => {
const store: RootStore = useMobxStore();
const { project: projectStore, issue: issueStore }: RootStore = useMobxStore();
const router = useRouter();
const routerParams = useParams();
const { workspace_slug, project_slug } = router.query as { workspace_slug: string; project_slug: string };
const { workspace_slug, project_slug } = routerParams as { workspace_slug: string; project_slug: string };
const handleCurrentBoardView = (boardView: TIssueBoardKeys) => {
store?.issue?.setCurrentIssueBoardView(boardView);
router.replace(`/${workspace_slug}/${project_slug}?board=${boardView}`);
const handleCurrentBoardView = (boardView: string) => {
projectStore.setActiveBoard(boardView);
router.push(
`/${workspace_slug}/${project_slug}?board=${boardView}${
issueStore?.filteredLabels && issueStore?.filteredLabels.length > 0
? `&labels=${issueStore?.filteredLabels.join(",")}`
: ""
}${
issueStore?.filteredPriorities && issueStore?.filteredPriorities.length > 0
? `&priorities=${issueStore?.filteredPriorities.join(",")}`
: ""
}${
issueStore?.filteredStates && issueStore?.filteredStates.length > 0
? `&states=${issueStore?.filteredStates.join(",")}`
: ""
}`
);
};
return (
<>
{store?.project?.workspaceProjectSettings &&
issueViews &&
issueViews.length > 0 &&
issueViews.map(
(_view) =>
store?.project?.workspaceProjectSettings?.views[_view?.key] && (
{projectStore?.viewOptions &&
Object.keys(projectStore?.viewOptions).map((viewKey: string) => {
if (projectStore?.viewOptions[viewKey]) {
return (
<div
key={_view?.key}
className={`w-[28px] h-[28px] flex justify-center items-center rounded-sm cursor-pointer text-gray-500 ${
_view?.key === store?.issue?.currentIssueBoardView
? `bg-gray-200/60 text-gray-800`
: `hover:bg-gray-200/60 text-gray-600`
key={viewKey}
className={`w-[28px] h-[28px] flex justify-center items-center rounded-sm cursor-pointer ${
viewKey === projectStore?.activeBoard
? `bg-custom-background-80 text-custom-text-200`
: `hover:bg-custom-background-80 text-custom-text-300`
}`}
onClick={() => handleCurrentBoardView(_view?.key)}
title={_view?.title}
onClick={() => handleCurrentBoardView(viewKey)}
title={viewKey}
>
<span className={`material-symbols-rounded text-[18px] ${_view?.className ? _view?.className : ``}`}>
{_view?.icon}
<span
className={`material-symbols-rounded text-[18px] ${
issueViews[viewKey]?.className ? issueViews[viewKey]?.className : ``
}`}
>
{issueViews[viewKey]?.icon}
</span>
</div>
)
)}
);
}
})}
</>
);
});

View File

@ -1,13 +1,111 @@
"use client";
// mobx react lite
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
// icons
import { ChevronDownIcon } from "@heroicons/react/20/solid";
// 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 = router.asPath;
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.filteredPriorities.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.filteredStates.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.filteredLabels.includes(label.id),
})),
},
]}
/>
);
});

View File

@ -1,28 +1,25 @@
"use client";
// next theme
import { useTheme } from "next-themes";
// mobx react lite
import { observer } from "mobx-react-lite";
// mobx
import { useMobxStore } from "lib/mobx/store-provider";
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");
};
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"
<button
type="button"
onClick={handleTheme}
className="relative w-7 h-7 grid place-items-center bg-custom-background-100 hover:bg-custom-background-80 text-custom-text-100 rounded"
>
{store?.theme?.theme === "light" ? (
<span className={`material-symbols-rounded text-[18px]`}>dark_mode</span>
) : (
<span className={`material-symbols-rounded text-[18px]`}>light_mode</span>
)}
</div>
<span className="material-symbols-rounded text-sm">{theme === "light" ? "dark_mode" : "light_mode"}</span>
</button>
);
});

View File

@ -0,0 +1,104 @@
import React, { useRef } from "react";
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
import { useForm, Controller } from "react-hook-form";
// lib
import { useMobxStore } from "lib/mobx/store-provider";
// hooks
import useToast from "hooks/use-toast";
// ui
import { SecondaryButton } from "components/ui";
// types
import { Comment } from "types/issue";
// components
import { TipTapEditor } from "components/tiptap";
const defaultValues: Partial<Comment> = {
comment_html: "",
};
type Props = {
disabled?: boolean;
};
export const AddComment: React.FC<Props> = observer((props) => {
const { disabled = false } = props;
const {
handleSubmit,
control,
setValue,
watch,
formState: { isSubmitting },
reset,
} = useForm<Comment>({ defaultValues });
const router = useRouter();
const { workspace_slug, project_slug } = router.query as { workspace_slug: string; project_slug: string };
const { user: userStore, issueDetails: issueDetailStore } = useMobxStore();
const issueId = issueDetailStore.peekId;
const editorRef = useRef<any>(null);
const { setToastAlert } = useToast();
const onSubmit = async (formData: Comment) => {
if (!workspace_slug || !project_slug || !issueId || isSubmitting || !formData.comment_html) return;
await issueDetailStore
.addIssueComment(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);
}}
/>
)}
/>
<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,190 @@
import React, { useEffect, useState, useRef } from "react";
import { useForm, Controller } from "react-hook-form";
import { observer } from "mobx-react-lite";
import { Menu, Transition } from "@headlessui/react";
// lib
import { useMobxStore } from "lib/mobx/store-provider";
// icons
import { ChatBubbleLeftEllipsisIcon, CheckIcon, XMarkIcon, EllipsisVerticalIcon } from "@heroicons/react/24/outline";
// helpers
import { timeAgo } from "helpers/date-time.helper";
// types
import { Comment } from "types/issue";
// components
import { TipTapEditor } from "components/tiptap";
type Props = {
workspaceSlug: string;
comment: Comment;
};
export const CommentCard: React.FC<Props> = observer((props) => {
const { comment, workspaceSlug } = props;
// store
const { user: userStore, issueDetails: issueDetailStore } = useMobxStore();
// states
const [isEditing, setIsEditing] = useState(false);
const {
formState: { isSubmitting },
handleSubmit,
control,
} = useForm<any>({
defaultValues: { comment_html: comment.comment_html },
});
const handleDelete = () => {
if (!workspaceSlug || !issueDetailStore.peekId) return;
issueDetailStore.deleteIssueComment(workspaceSlug, comment.project, issueDetailStore.peekId, comment.id);
};
const handleCommentUpdate = async (formData: Comment) => {
if (!workspaceSlug || !issueDetailStore.peekId) return;
issueDetailStore.updateIssueComment(workspaceSlug, comment.project, issueDetailStore.peekId, comment.id, formData);
setIsEditing(false);
};
return (
<div className="relative flex items-start space-x-3">
<div className="relative px-1">
{comment.actor_detail.avatar && comment.actor_detail.avatar !== "" ? (
// eslint-disable-next-line @next/next/no-img-element
<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>
<Controller
control={control}
name="comment_html"
render={({ field: { onChange, value } }) => (
<TipTapEditor
workspaceSlug={workspaceSlug as string}
value={value}
debouncedUpdatesEnabled={false}
customClassName="min-h-[50px] p-3 shadow-sm"
onChange={(comment_json: Object, comment_html: string) => {
onChange(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.toString()}
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,71 @@
import { useEffect } from "react";
import { observer } from "mobx-react-lite";
// lib
import { useMobxStore } from "lib/mobx/store-provider";
// components
import {
PeekOverviewHeader,
PeekOverviewIssueActivity,
PeekOverviewIssueDetails,
PeekOverviewIssueProperties,
} from "components/issues/peek-overview";
// types
import { Loader } from "components/ui/loader";
import { IIssue } from "types/issue";
type Props = {
handleClose: () => void;
issueDetails: IIssue | undefined;
};
export const FullScreenPeekView: React.FC<Props> = observer((props) => {
const { handleClose, issueDetails } = props;
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} issueDetails={issueDetails} />
</div>
{issueDetails ? (
<div className="h-full w-full px-6 overflow-y-auto">
{/* issue title and description */}
<div className="w-full">
<PeekOverviewIssueDetails issueDetails={issueDetails} />
</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 issueDetails={issueDetails} />
</div>
</div>
) : (
<Loader className="px-6">
<Loader.Item height="30px" />
<div className="space-y-2 mt-3">
<Loader.Item height="20px" width="70%" />
<Loader.Item height="20px" width="60%" />
<Loader.Item height="20px" width="60%" />
</div>
</Loader>
)}
</div>
<div className="col-span-3 h-full w-full overflow-y-auto">
{/* issue properties */}
<div className="w-full px-6 py-5">
{issueDetails ? (
<PeekOverviewIssueProperties issueDetails={issueDetails} />
) : (
<Loader className="mt-11 space-y-4">
<Loader.Item height="30px" />
<Loader.Item height="30px" />
<Loader.Item height="30px" />
<Loader.Item height="30px" />
</Loader>
)}
</div>
</div>
</div>
);
});

View File

@ -0,0 +1,145 @@
import React from "react";
import { useRouter } from "next/router";
// headless ui
import { Listbox, Transition } from "@headlessui/react";
// hooks
import useToast from "hooks/use-toast";
// ui
import { Icon } from "components/ui";
// icons
import { East } from "@mui/icons-material";
// helpers
import { copyTextToClipboard } from "helpers/string.helper";
// store
import { IPeekMode } from "store/issue_details";
import { RootStore } from "store/root";
// lib
import { useMobxStore } from "lib/mobx/store-provider";
// types
import { IIssue } from "types/issue";
type Props = {
handleClose: () => void;
issueDetails: IIssue | undefined;
};
const peekModes: {
key: IPeekMode;
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> = (props) => {
const { handleClose, issueDetails } = props;
const { issueDetails: issueDetailStore }: RootStore = useMobxStore();
const router = useRouter();
const { workspace_slug } = router.query;
const { setToastAlert } = useToast();
const handleCopyLink = () => {
const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
copyTextToClipboard(`${originURL}/${workspace_slug}/projects/${issueDetails?.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">
{issueDetailStore.peekMode === "side" && (
<button type="button" onClick={handleClose} autoFocus={false}>
<East
sx={{
fontSize: "14px",
}}
/>
</button>
)}
<Listbox
as="div"
value={issueDetailStore.peekMode}
onChange={(val) => issueDetailStore.setPeekMode(val)}
className="relative flex-shrink-0 text-left"
>
<Listbox.Button
className={`grid place-items-center ${issueDetailStore.peekMode === "full" ? "rotate-45" : ""}`}
>
<Icon iconName={peekModes.find((m) => m.key === issueDetailStore.peekMode)?.icon ?? ""} />
</Listbox.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"
>
<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 left-0 origin-top-left min-w-[8rem] whitespace-nowrap">
<div className="space-y-1 p-2">
{peekModes.map((mode) => (
<Listbox.Option
key={mode.key}
value={mode.key}
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">
<div className="flex items-center gap-1.5">
<Icon
iconName={mode.icon}
className={`!text-base flex-shrink-0 -my-1 ${mode.key === "full" ? "rotate-45" : ""}`}
/>
{mode.label}
</div>
</div>
{selected && <Icon iconName="done" />}
</div>
)}
</Listbox.Option>
))}
</div>
</Listbox.Options>
</Transition>
</Listbox>
</div>
{(issueDetailStore.peekMode === "side" || issueDetailStore.peekMode === "modal") && (
<div className="flex items-center gap-2 flex-shrink-0">
<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,44 @@
import React, { useEffect } from "react";
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
// lib
import { useMobxStore } from "lib/mobx/store-provider";
// components
import { CommentCard, AddComment } from "components/issues/peek-overview";
// types
import { IIssue } from "types/issue";
type Props = {
issueDetails: IIssue;
};
export const PeekOverviewIssueActivity: React.FC<Props> = observer((props) => {
const router = useRouter();
const { workspace_slug } = router.query;
const { issueDetails: issueDetailStore, project: projectStore, user: userStore } = useMobxStore();
const comments = issueDetailStore.details[issueDetailStore.peekId || ""]?.comments || [];
console.log("issueDetailStore", issueDetailStore);
return (
<div>
<h4 className="font-medium">Activity</h4>
{workspace_slug && (
<div className="mt-4">
<div className="space-y-4">
{comments.map((comment: any) => (
<CommentCard key={comment.id} comment={comment} workspaceSlug={workspace_slug?.toString()} />
))}
</div>
{projectStore.deploySettings?.comments && (
<div className="mt-4">
<AddComment disabled={!userStore.currentUser} />
</div>
)}
</div>
)}
</div>
);
});

View File

@ -0,0 +1,40 @@
import { IssueReactions } from "components/issues/peek-overview";
import { TipTapEditor } from "components/tiptap";
import { useRouter } from "next/router";
// types
import { IIssue } from "types/issue";
type Props = {
issueDetails: IIssue;
};
export const PeekOverviewIssueDetails: React.FC<Props> = ({ issueDetails }) => {
const router = useRouter();
const { workspace_slug } = router.query;
return (
<div className="space-y-2">
<h6 className="font-medium text-custom-text-200">
{issueDetails.project_detail.identifier}-{issueDetails.sequence_id}
</h6>
<h4 className="break-words text-2xl font-semibold">{issueDetails.name}</h4>
{issueDetails.description_html !== "" && issueDetails.description_html !== "<p></p>" && (
<TipTapEditor
workspaceSlug={workspace_slug as string}
value={
!issueDetails.description_html ||
issueDetails.description_html === "" ||
(typeof issueDetails.description_html === "object" &&
Object.keys(issueDetails.description_html).length === 0)
? "<p></p>"
: issueDetails.description_html
}
customClassName="p-3 min-h-[50px] shadow-sm"
debouncedUpdatesEnabled={false}
editable={false}
/>
)}
<IssueReactions />
</div>
);
};

View File

@ -0,0 +1,101 @@
import { useEffect } from "react";
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
// lib
import { useMobxStore } from "lib/mobx/store-provider";
// helpers
import { groupReactions, renderEmoji } from "helpers/emoji.helper";
// components
import { ReactionSelector, Tooltip } from "components/ui";
export const IssueEmojiReactions: React.FC = observer(() => {
// router
const router = useRouter();
const { workspace_slug, project_slug } = router.query;
// store
const { user: userStore, issueDetails: issueDetailsStore } = useMobxStore();
const user = userStore?.currentUser;
const issueId = issueDetailsStore.peekId;
const reactions = issueId ? issueDetailsStore.details[issueId]?.reactions || [] : [];
const groupedReactions = groupReactions(reactions, "reaction");
const handleReactionSelectClick = (reactionHex: string) => {
if (!workspace_slug || !project_slug || !issueId) return;
const userReaction = reactions?.find((r) => r.actor_detail.id === user?.id && r.reaction === reactionHex);
if (userReaction) return;
issueDetailsStore.addIssueReaction(workspace_slug.toString(), project_slug.toString(), issueId, {
reaction: reactionHex,
});
};
const handleReactionClick = (reactionHex: string) => {
if (!workspace_slug || !project_slug || !issueId) return;
issueDetailsStore.removeIssueReaction(workspace_slug.toString(), project_slug.toString(), issueId, reactionHex);
};
useEffect(() => {
if (user) return;
userStore.fetchCurrentUser();
}, [user, userStore]);
return (
<>
<ReactionSelector
onSelect={(value) => {
userStore.requiredLogin(() => {
handleReactionSelectClick(value);
});
}}
size="md"
/>
<div className="flex items-center gap-2 flex-wrap">
{Object.keys(groupedReactions || {}).map((reaction) => {
const reactions = groupedReactions?.[reaction] ?? [];
const REACTIONS_LIMIT = 1000;
if (reactions.length > 0)
return (
<Tooltip
tooltipContent={
<div>
{reactions
.map((r) => r.actor_detail.display_name)
.splice(0, REACTIONS_LIMIT)
.join(", ")}
{reactions.length > REACTIONS_LIMIT && " and " + (reactions.length - REACTIONS_LIMIT) + " more"}
</div>
}
>
<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 ${
reactions?.some((r) => r.actor_detail.id === user?.id && r.reaction === reaction)
? "bg-custom-primary-100/10"
: "bg-custom-background-80"
}`}
>
<span>{renderEmoji(reaction)}</span>
<span
className={
reactions?.some((r) => r.actor_detail.id === user?.id && r.reaction === reaction)
? "text-custom-primary-100"
: ""
}
>
{groupedReactions?.[reaction].length}{" "}
</span>
</button>
</Tooltip>
);
})}
</div>
</>
);
});

View File

@ -0,0 +1,155 @@
// headless ui
import { Disclosure } from "@headlessui/react";
// import { getStateGroupIcon } from "components/icons";
// hooks
import useToast from "hooks/use-toast";
// icons
import { Icon } from "components/ui";
import { copyTextToClipboard, addSpaceIfCamelCase } from "helpers/string.helper";
// types
import { IIssue } from "types/issue";
// constants
import { issueGroupFilter, issuePriorityFilter } from "constants/data";
import { useEffect } from "react";
import { renderDateFormat } from "constants/helpers";
import { IPeekMode } from "store/issue_details";
import { useRouter } from "next/router";
import { RootStore } from "store/root";
import { useMobxStore } from "lib/mobx/store-provider";
type Props = {
issueDetails: IIssue;
mode?: IPeekMode;
};
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> = ({ issueDetails, mode }) => {
const { setToastAlert } = useToast();
const { issueDetails: issueDetailStore }: RootStore = useMobxStore();
const router = useRouter();
const { workspaceSlug } = router.query;
const startDate = issueDetails.start_date;
const targetDate = issueDetails.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 = issueDetails.state_detail;
const stateGroup = issueGroupFilter(state.group);
const priority = issueDetails.priority ? issuePriorityFilter(issueDetails.priority) : null;
const handleCopyLink = () => {
const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
copyTextToClipboard(
`${originURL}/${workspaceSlug}/projects/${issueDetails.project}/issues/${issueDetails.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)} */}
{issueDetails.project_detail.identifier}-{issueDetails.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>
{issueDetails.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(issueDetails.target_date, state)}`}
>
{renderDateFormat(issueDetails.target_date)}
</div>
) : (
<span className="text-custom-text-200">Empty</span>
)}
</div>
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,24 @@
import { IssueEmojiReactions, IssueVotes } from "components/issues/peek-overview";
import { useMobxStore } from "lib/mobx/store-provider";
export const IssueReactions: React.FC = () => {
const { project: projectStore } = useMobxStore();
return (
<div className="flex gap-3 items-center mt-4">
{projectStore?.deploySettings?.votes && (
<>
<div className="flex gap-2 items-center">
<IssueVotes />
</div>
<div className="w-0.5 h-8 bg-custom-background-200" />
</>
)}
{projectStore?.deploySettings?.reactions && (
<div className="flex gap-2 items-center">
<IssueEmojiReactions />
</div>
)}
</div>
);
};

View File

@ -0,0 +1,129 @@
import { useState, useEffect } from "react";
import { useRouter } from "next/router";
// mobx
import { observer } from "mobx-react-lite";
// lib
import { useMobxStore } from "lib/mobx/store-provider";
import { Tooltip } from "components/ui";
export const IssueVotes: React.FC = observer(() => {
const [isSubmitting, setIsSubmitting] = useState(false);
const router = useRouter();
const { workspace_slug, project_slug } = router.query;
const { user: userStore, issueDetails: issueDetailsStore } = useMobxStore();
const user = userStore?.currentUser;
const issueId = issueDetailsStore.peekId;
const votes = issueId ? issueDetailsStore.details[issueId]?.votes : [];
const allUpVotes = votes?.filter((vote) => vote.vote === 1);
const allDownVotes = votes?.filter((vote) => vote.vote === -1);
const isUpVotedByUser = allUpVotes?.some((vote) => vote.actor === user?.id);
const isDownVotedByUser = allDownVotes?.some((vote) => vote.actor === user?.id);
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 issueDetailsStore.removeIssueVote(workspace_slug.toString(), project_slug.toString(), issueId);
else
await issueDetailsStore.addIssueVote(workspace_slug.toString(), project_slug.toString(), issueId, {
vote: voteValue,
});
setIsSubmitting(false);
};
useEffect(() => {
if (user) return;
userStore.fetchCurrentUser();
}, [user, userStore]);
const VOTES_LIMIT = 1000;
return (
<div className="flex gap-2 items-center">
{/* upvote button 👇 */}
<Tooltip
tooltipContent={
<div>
{allUpVotes.length > 0 ? (
<>
{allUpVotes
.map((r) => r.actor_detail.display_name)
.splice(0, VOTES_LIMIT)
.join(", ")}
{allUpVotes.length > VOTES_LIMIT && " and " + (allUpVotes.length - VOTES_LIMIT) + " more"}
</>
) : (
"No upvotes yet"
)}
</div>
}
>
<button
type="button"
disabled={isSubmitting}
onClick={(e) => {
userStore.requiredLogin(() => {
handleVote(e, 1);
});
}}
className={`flex items-center justify-center overflow-hidden px-2 gap-x-1 border rounded focus:outline-none ${
isUpVotedByUser ? "border-custom-primary-200 text-custom-primary-200" : "border-custom-border-300"
}`}
>
<span className="material-symbols-rounded text-base !p-0 !m-0">arrow_upward_alt</span>
<span className="text-sm font-normal transition-opacity ease-in-out">{allUpVotes.length}</span>
</button>
</Tooltip>
{/* downvote button 👇 */}
<Tooltip
tooltipContent={
<div>
{allDownVotes.length > 0 ? (
<>
{allDownVotes
.map((r) => r.actor_detail.display_name)
.splice(0, VOTES_LIMIT)
.join(", ")}
{allDownVotes.length > VOTES_LIMIT && " and " + (allDownVotes.length - VOTES_LIMIT) + " more"}
</>
) : (
"No downvotes yet"
)}
</div>
}
>
<button
type="button"
disabled={isSubmitting}
onClick={(e) => {
userStore.requiredLogin(() => {
handleVote(e, -1);
});
}}
className={`flex items-center justify-center overflow-hidden px-2 gap-x-1 border rounded focus:outline-none ${
isDownVotedByUser ? "border-red-600 text-red-600" : "border-custom-border-300"
}`}
>
<span className="material-symbols-rounded text-base !p-0 !m-0">arrow_downward_alt</span>
<span className="text-sm font-normal transition-opacity ease-in-out">{allDownVotes.length}</span>
</button>
</Tooltip>
</div>
);
});

View File

@ -0,0 +1,127 @@
import React, { useEffect, useState } from "react";
import { useRouter } from "next/router";
// mobx
import { observer } from "mobx-react-lite";
// headless ui
import { Dialog, Transition } from "@headlessui/react";
// components
import { FullScreenPeekView, SidePeekView } from "components/issues/peek-overview";
// lib
import { useMobxStore } from "lib/mobx/store-provider";
type Props = {};
export const IssuePeekOverview: React.FC<Props> = observer((props) => {
const [isSidePeekOpen, setIsSidePeekOpen] = useState(false);
const [isModalPeekOpen, setIsModalPeekOpen] = useState(false);
// router
const router = useRouter();
const { workspace_slug, project_slug, peekId, board } = router.query;
// store
const { issueDetails: issueDetailStore, issue: issueStore } = useMobxStore();
const issueDetails = issueDetailStore.peekId && peekId ? issueDetailStore.details[peekId.toString()] : undefined;
useEffect(() => {
if (workspace_slug && project_slug && peekId && issueStore.issues && issueStore.issues.length > 0) {
if (!issueDetails) {
issueDetailStore.fetchIssueDetails(workspace_slug.toString(), project_slug.toString(), peekId.toString());
}
}
}, [workspace_slug, project_slug, issueDetailStore, issueDetails, peekId, issueStore.issues]);
const handleClose = () => {
issueDetailStore.setPeekId(null);
router.replace(
{
pathname: `/${workspace_slug?.toString()}/${project_slug}`,
query: {
board,
},
},
undefined,
{ shallow: true }
);
};
useEffect(() => {
if (peekId) {
if (issueDetailStore.peekMode === "side") {
setIsSidePeekOpen(true);
setIsModalPeekOpen(false);
} else {
setIsModalPeekOpen(true);
setIsSidePeekOpen(false);
}
} else {
setIsSidePeekOpen(false);
setIsModalPeekOpen(false);
}
}, [peekId, issueDetailStore.peekMode]);
return (
<>
<Transition.Root appear show={isSidePeekOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-20" onClose={handleClose}>
<div className="fixed inset-0 z-20 h-full w-full overflow-y-auto">
<Transition.Child
as={React.Fragment}
enter="transition-transform duration-300"
enterFrom="translate-x-full"
enterTo="translate-x-0"
leave="transition-transform duration-200"
leaveFrom="translate-x-0"
leaveTo="translate-x-full"
>
<Dialog.Panel className="fixed z-20 bg-custom-background-100 top-0 right-0 h-full w-1/2 shadow-custom-shadow-sm">
<SidePeekView handleClose={handleClose} issueDetails={issueDetails} />
</Dialog.Panel>
</Transition.Child>
</div>
</Dialog>
</Transition.Root>
<Transition.Root appear show={isModalPeekOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-20" onClose={handleClose}>
<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 h-full w-full overflow-y-auto">
<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"
>
<Dialog.Panel
className={`fixed z-20 bg-custom-background-100 top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 rounded-lg shadow-custom-shadow-xl transition-all duration-300 ${
issueDetailStore.peekMode === "modal" ? "h-[70%] w-3/5" : "h-[95%] w-[95%]"
}`}
>
{issueDetailStore.peekMode === "modal" && (
<SidePeekView handleClose={handleClose} issueDetails={issueDetails} />
)}
{issueDetailStore.peekMode === "full" && (
<FullScreenPeekView handleClose={handleClose} issueDetails={issueDetails} />
)}
</Dialog.Panel>
</Transition.Child>
</div>
</Dialog>
</Transition.Root>
</>
);
});

View File

@ -0,0 +1,55 @@
import { observer } from "mobx-react-lite";
// components
import {
PeekOverviewHeader,
PeekOverviewIssueActivity,
PeekOverviewIssueDetails,
PeekOverviewIssueProperties,
} from "components/issues/peek-overview";
import { Loader } from "components/ui/loader";
import { IIssue } from "types/issue";
type Props = {
handleClose: () => void;
issueDetails: IIssue | undefined;
};
export const SidePeekView: React.FC<Props> = observer((props) => {
const { handleClose, issueDetails } = props;
return (
<div className="h-full w-full flex flex-col overflow-hidden">
<div className="w-full p-5">
<PeekOverviewHeader handleClose={handleClose} issueDetails={issueDetails} />
</div>
{issueDetails ? (
<div className="h-full w-full px-6 overflow-y-auto">
{/* issue title and description */}
<div className="w-full">
<PeekOverviewIssueDetails issueDetails={issueDetails} />
</div>
{/* issue properties */}
<div className="w-full mt-6">
<PeekOverviewIssueProperties issueDetails={issueDetails} />
</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 issueDetails={issueDetails} />
</div>
</div>
) : (
<Loader className="px-6">
<Loader.Item height="30px" />
<div className="space-y-2 mt-3">
<Loader.Item height="20px" width="70%" />
<Loader.Item height="20px" width="60%" />
<Loader.Item height="20px" width="60%" />
</div>
</Loader>
)}
</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,113 @@
import { useImperativeHandle, useRef, forwardRef, useEffect } from "react";
import { useEditor, EditorContent, Editor } from "@tiptap/react";
import { useDebouncedCallback } from "use-debounce";
// components
import { EditorBubbleMenu } from "./bubble-menu";
import { TiptapExtensions } from "./extensions";
import { TiptapEditorProps } from "./props";
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());
}
},
});
useEffect(() => {
if (editor) {
editor.commands.setContent(value);
}
}, [value]);
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-full 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>
);
};
const TipTapEditor = forwardRef<ITipTapRichTextEditor, ITipTapRichTextEditor>((props, ref) => (
<Tiptap {...props} forwardedRef={ref} />
));
TipTapEditor.displayName = "TipTapEditor";
export { TipTapEditor };

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,8 @@
export * from "./dropdown";
export * from "./input";
export * from "./loader";
export * from "./primary-button";
export * from "./secondary-button";
export * from "./icon";
export * from "./reaction-selector";
export * from "./tooltip";

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,25 @@
import React from "react";
type Props = {
children: React.ReactNode;
className?: string;
};
const Loader = ({ children, className = "" }: Props) => (
<div className={`${className} animate-pulse`} role="status">
{children}
</div>
);
type ItemProps = {
height?: string;
width?: string;
};
const Item: React.FC<ItemProps> = ({ height = "auto", width = "auto" }) => (
<div className="rounded-md bg-custom-background-80" style={{ height: height, width: width }} />
);
Loader.Item = Item;
export { Loader };

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

@ -0,0 +1,71 @@
import React from "react";
// next-themes
import { useTheme } from "next-themes";
// tooltip2
import { Tooltip2 } from "@blueprintjs/popover2";
type Props = {
tooltipHeading?: string;
tooltipContent: string | React.ReactNode;
position?:
| "top"
| "right"
| "bottom"
| "left"
| "auto"
| "auto-end"
| "auto-start"
| "bottom-left"
| "bottom-right"
| "left-bottom"
| "left-top"
| "right-bottom"
| "right-top"
| "top-left"
| "top-right";
children: JSX.Element;
disabled?: boolean;
className?: string;
openDelay?: number;
closeDelay?: number;
};
export const Tooltip: React.FC<Props> = ({
tooltipHeading,
tooltipContent,
position = "top",
children,
disabled = false,
className = "",
openDelay = 200,
closeDelay,
}) => {
const { theme } = useTheme();
return (
<Tooltip2
disabled={disabled}
hoverOpenDelay={openDelay}
hoverCloseDelay={closeDelay}
content={
<div
className={`relative z-50 max-w-xs gap-1 rounded-md p-2 text-xs shadow-md border border-custom-border-200 ${
theme === "custom" ? "bg-custom-background-100 text-custom-text-200" : "bg-black text-gray-400"
} break-words overflow-hidden ${className}`}
>
{tooltipHeading && (
<h5 className={`font-medium ${theme === "custom" ? "text-custom-text-100" : "text-white"}`}>
{tooltipHeading}
</h5>
)}
{tooltipContent}
</div>
}
position={position}
renderTarget={({ isOpen: isTooltipOpen, ref: eleReference, ...tooltipProps }) =>
React.cloneElement(children, { ref: eleReference, ...tooltipProps, ...children.props })
}
/>
);
};

View File

@ -0,0 +1,84 @@
import { useEffect } from "react";
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
// components
import { IssueListView } from "components/issues/board-views/list";
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";
export const ProjectDetailsView = observer(() => {
const router = useRouter();
const { workspace_slug, project_slug, states, labels, priorities, board, peekId } = router.query;
const {
issue: issueStore,
project: projectStore,
issueDetails: issueDetailStore,
user: userStore,
}: RootStore = useMobxStore();
useEffect(() => {
if (!userStore.currentUser) {
userStore.fetchCurrentUser();
}
}, [userStore]);
useEffect(() => {
if (workspace_slug && project_slug) {
const params = {
state: states || null,
labels: labels || null,
priority: priorities || null,
};
issueStore.fetchPublicIssues(workspace_slug?.toString(), project_slug.toString(), params);
}
}, [workspace_slug, project_slug, issueStore, states, labels, priorities]);
useEffect(() => {
if (peekId && workspace_slug && project_slug) {
issueDetailStore.setPeekId(peekId.toString());
}
}, [peekId, issueDetailStore, project_slug, workspace_slug]);
return (
<div className="relative w-full h-full overflow-hidden">
{workspace_slug && <IssuePeekOverview />}
{issueStore?.loader && !issueStore.issues ? (
<div className="text-sm text-center py-10 text-custom-text-100">Loading...</div>
) : (
<>
{issueStore?.error ? (
<div className="text-sm text-center py-10 bg-custom-background-200 text-custom-text-100">
Something went wrong.
</div>
) : (
projectStore?.activeBoard && (
<>
{projectStore?.activeBoard === "list" && (
<div className="relative w-full h-full overflow-y-auto">
<IssueListView />
</div>
)}
{projectStore?.activeBoard === "kanban" && (
<div className="relative w-full h-full mx-auto px-9 py-5">
<IssueKanbanView />
</div>
)}
{projectStore?.activeBoard === "calendar" && <IssueCalendarView />}
{projectStore?.activeBoard === "spreadsheet" && <IssueSpreadsheetView />}
{projectStore?.activeBoard === "gantt" && <IssueGanttView />}
</>
)
)}
</>
)}
</div>
);
});

View File

@ -7,7 +7,7 @@ import {
TIssueGroupKey,
IIssuePriorityFilters,
IIssueGroup,
} from "store/types/issue";
} from "types/issue";
// icons
import {
BacklogStateIcon,
@ -18,69 +18,49 @@ import {
} from "components/icons";
// all issue views
export const issueViews: IIssueBoardViews[] = [
{
key: "list",
export const issueViews: any = {
list: {
title: "List View",
icon: "format_list_bulleted",
className: "",
},
{
key: "kanban",
kanban: {
title: "Board View",
icon: "grid_view",
className: "",
},
// {
// key: "calendar",
// title: "Calendar View",
// icon: "calendar_month",
// className: "",
// },
// {
// key: "spreadsheet",
// title: "Spreadsheet View",
// icon: "table_chart",
// className: "",
// },
// {
// key: "gantt",
// title: "Gantt Chart View",
// icon: "waterfall_chart",
// className: "rotate-90",
// },
];
};
// issue priority filters
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 border-red-500 text-white",
icon: "error",
},
{
key: "high",
title: "High",
className: "border border-orange-500/50 bg-orange-500/20 text-orange-500",
className: "text-orange-500 border-custom-border-300",
icon: "signal_cellular_alt",
},
{
key: "medium",
title: "Medium",
className: "border border-yellow-500/50 bg-yellow-500/20 text-yellow-500",
className: "text-yellow-500 border-custom-border-300",
icon: "signal_cellular_alt_2_bar",
},
{
key: "low",
title: "Low",
className: "border border-green-500/50 bg-green-500/20 text-green-500",
className: "text-green-500 border-custom-border-300",
icon: "signal_cellular_alt_1_bar",
},
{
key: "none",
title: "None",
className: "border border-gray-500/50 bg-gray-500/20 text-gray-500",
className: "text-gray-500 border-custom-border-300",
icon: "block",
},
];
@ -111,35 +91,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

@ -11,3 +11,26 @@ export const renderDateFormat = (date: string | Date | null) => {
return [year, month, day].join("-");
};
/**
* @description Returns date and month, if date is of the current year
* @description Returns date, month adn year, if date is of a different year than current
* @param {string} date
* @example renderFullDate("2023-01-01") // 1 Jan
* @example renderFullDate("2021-01-01") // 1 Jan, 2021
*/
export const renderFullDate = (date: string): string => {
if (!date) return "";
const months: string[] = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
const currentDate: Date = new Date();
const [year, month, day]: number[] = date.split("-").map(Number);
const formattedMonth: string = months[month - 1];
const formattedDay: string = day < 10 ? `0${day}` : day.toString();
if (currentDate.getFullYear() === year) return `${formattedDay} ${formattedMonth}`;
else return `${formattedDay} ${formattedMonth}, ${year}`;
};

View File

@ -0,0 +1,7 @@
export const SITE_NAME = "Plane Deploy | Make your Plane boards and roadmaps pubic with just one-click. ";
export const SITE_TITLE = "Plane Deploy | Make your Plane boards public with one-click";
export const SITE_DESCRIPTION = "Plane Deploy is a customer feedback management tool built on top of plane.so";
export const SITE_KEYWORDS =
"software development, customer feedback, software, accelerate, code management, release management, project management, issue tracking, agile, scrum, kanban, collaboration";
export const SITE_URL = "https://app.plane.so/";
export const TWITTER_USER_NAME = "planepowers";

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

@ -0,0 +1,30 @@
import Link from "next/link";
import Image from "next/image";
// mobx
import { observer } from "mobx-react-lite";
// components
import IssueNavbar from "components/issues/navbar";
const ProjectLayout = ({ 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-custom-border-300 relative flex items-center bg-custom-sidebar-background-100 select-none">
<IssueNavbar />
</div>
<div className="w-full h-full relative bg-custom-background-90 overflow-hidden">{children}</div>
<div className="absolute z-[99999] bottom-[10px] right-[10px] bg-custom-background-100 rounded-sm shadow-lg border border-custom-border-300">
<Link href="https://plane.so">
<a 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">
Powered by <b>Plane Deploy</b>
</div>
</a>
</Link>
</div>
</div>
);
export default observer(ProjectLayout);

View File

@ -1,6 +1,8 @@
"use client";
import { useEffect } from "react";
// next imports
import { useRouter } from "next/router";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
import { RootStore } from "store/root";
@ -8,12 +10,14 @@ import { RootStore } from "store/root";
const MobxStoreInit = () => {
const store: RootStore = useMobxStore();
useEffect(() => {
// theme
const _theme = localStorage && localStorage.getItem("app_theme") ? localStorage.getItem("app_theme") : "light";
if (_theme && store?.theme?.theme != _theme) store.theme.setTheme(_theme);
else localStorage.setItem("app_theme", _theme && _theme != "light" ? "dark" : "light");
}, [store?.theme]);
const router = useRouter();
const { states, labels, priorities } = router.query as { states: string[]; labels: string[]; priorities: string[] };
// useEffect(() => {
// store.issue.userSelectedLabels = labels || [];
// store.issue.userSelectedPriorities = priorities || [];
// store.issue.userSelectedStates = states || [];
// }, [store.issue]);
return <></>;
};

View File

@ -6,7 +6,6 @@ const nextConfig = {
swcMinify: true,
experimental: {
outputFileTracingRoot: path.join(__dirname, "../../"),
appDir: true,
},
output: "standalone",
};

View File

@ -10,27 +10,52 @@
},
"dependencies": {
"@headlessui/react": "^1.7.13",
"@types/node": "18.14.1",
"@types/nprogress": "^0.2.0",
"@types/react": "18.0.28",
"@types/react-dom": "18.0.11",
"@mui/icons-material": "^5.14.7",
"@tiptap-pro/extension-unique-id": "^2.1.0",
"@tiptap/extension-code-block-lowlight": "^2.0.4",
"@tiptap/extension-color": "^2.0.4",
"@tiptap/extension-gapcursor": "^2.1.7",
"@tiptap/extension-highlight": "^2.0.4",
"@tiptap/extension-horizontal-rule": "^2.0.4",
"@tiptap/extension-image": "^2.0.4",
"@tiptap/extension-link": "^2.0.4",
"@tiptap/extension-placeholder": "^2.0.4",
"@tiptap/extension-table": "^2.1.6",
"@tiptap/extension-table-cell": "^2.1.6",
"@tiptap/extension-table-header": "^2.1.6",
"@tiptap/extension-table-row": "^2.1.6",
"@tiptap/extension-task-item": "^2.0.4",
"@tiptap/extension-task-list": "^2.0.4",
"@tiptap/extension-text-style": "^2.0.4",
"@tiptap/extension-underline": "^2.0.4",
"@tiptap/pm": "^2.0.4",
"@tiptap/react": "^2.0.4",
"@tiptap/starter-kit": "^2.0.4",
"@tiptap/suggestion": "^2.0.4",
"axios": "^1.3.4",
"eslint": "8.34.0",
"eslint-config-next": "13.2.1",
"js-cookie": "^3.0.1",
"mobx": "^6.10.0",
"mobx-react-lite": "^4.0.3",
"next": "^13.4.16",
"next": "12.3.2",
"next-theme": "^0.1.5",
"nprogress": "^0.2.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"swr": "^2.2.2",
"typescript": "4.9.5",
"uuid": "^9.0.0"
},
"devDependencies": {
"@tailwindcss/typography": "^0.5.9",
"@types/js-cookie": "^3.0.3",
"@types/node": "18.14.1",
"@types/nprogress": "^0.2.0",
"@types/react": "18.0.28",
"@types/react-dom": "18.0.11",
"@types/uuid": "^9.0.1",
"autoprefixer": "^10.4.13",
"eslint": "8.34.0",
"eslint-config-next": "13.2.1",
"postcss": "^8.4.21",
"tailwindcss": "^3.2.7"
}

View File

@ -0,0 +1,50 @@
import useSWR from "swr";
import type { GetServerSideProps } from "next";
import { useRouter } from "next/router";
import Head from "next/head";
/// layouts
import ProjectLayout from "layouts/project-layout";
// components
import { ProjectDetailsView } from "components/views/project-details";
// lib
import { useMobxStore } from "lib/mobx/store-provider";
const WorkspaceProjectPage = (props: any) => {
const SITE_TITLE = props?.project_settings?.project_details?.name || "Plane | Deploy";
const router = useRouter();
const { workspace_slug, project_slug, states, labels, priorities } = router.query;
const { project: projectStore, issue: issueStore } = useMobxStore();
useSWR("REVALIDATE_ALL", () => {
if (workspace_slug && project_slug) {
projectStore.fetchProjectSettings(workspace_slug.toString(), project_slug.toString());
const params = {
state: states || null,
labels: labels || null,
priority: priorities || null,
};
issueStore.fetchPublicIssues(workspace_slug.toString(), project_slug.toString(), params);
}
});
return (
<ProjectLayout>
<Head>
<title>{SITE_TITLE}</title>
</Head>
<ProjectDetailsView />
</ProjectLayout>
);
};
// export const getServerSideProps: GetServerSideProps<any> = async ({ query: { workspace_slug, project_slug } }) => {
// const res = await fetch(
// `${process.env.NEXT_PUBLIC_API_BASE_URL}/api/public/workspaces/${workspace_slug}/project-boards/${project_slug}/settings/`
// );
// const project_settings = await res.json();
// return { props: { project_settings } };
// };
export default WorkspaceProjectPage;

View File

@ -1,10 +1,43 @@
import Head from "next/head";
import type { AppProps } from "next/app";
import { ThemeProvider } from "next-themes";
// styles
import "styles/globals.css";
// types
import type { AppProps } from "next/app";
import "styles/editor.css";
// contexts
import { ToastContextProvider } from "contexts/toast.context";
// mobx store provider
import { MobxStoreProvider } from "lib/mobx/store-provider";
import MobxStoreInit from "lib/mobx/store-init";
// constants
import { SITE_NAME, SITE_DESCRIPTION, SITE_URL, TWITTER_USER_NAME, SITE_KEYWORDS, SITE_TITLE } from "constants/seo";
function MyApp({ Component, pageProps }: AppProps) {
return <Component {...pageProps} />;
return (
<MobxStoreProvider>
<MobxStoreInit />
<Head>
<title>{SITE_TITLE}</title>
<meta property="og:site_name" content={SITE_NAME} />
<meta property="og:title" content={SITE_TITLE} />
<meta property="og:url" content={SITE_URL} />
<meta name="description" content={SITE_DESCRIPTION} />
<meta property="og:description" content={SITE_DESCRIPTION} />
<meta name="keywords" content={SITE_KEYWORDS} />
<meta name="twitter:site" content={`@${TWITTER_USER_NAME}`} />
<link rel="apple-touch-icon" sizes="180x180" href="/favicon/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon/favicon-16x16.png" />
<link rel="manifest" href="/site.webmanifest.json" />
<link rel="shortcut icon" href="/favicon/favicon.ico" />
</Head>
<ToastContextProvider>
<ThemeProvider themes={["light", "dark"]} defaultTheme="system" enableSystem>
<Component {...pageProps} />
</ThemeProvider>
</ToastContextProvider>
</MobxStoreProvider>
);
}
export default MyApp;

View File

@ -5,7 +5,7 @@ class MyDocument extends Document {
return (
<Html>
<Head />
<body>
<body className="antialiased bg-custom-background-90 w-100">
<Main />
<NextScript />
</body>

156
apps/space/pages/index.tsx Normal file
View File

@ -0,0 +1,156 @@
import React, { useEffect } from "react";
import Image from "next/image";
import { useRouter } from "next/router";
// 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, GithubLoginButton, GoogleLoginButton, EmailCodeForm } from "components/accounts";
const HomePage = () => {
const { user: userStore } = useMobxStore();
const router = useRouter();
const { next_path = "/" } = router.query;
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;
userStore.setCurrentUser(response?.user);
if (!isOnboarded) {
router.push(`/onboarding?next_path=${next_path}`);
return;
}
router.push(next_path.toString());
};
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 HomePage;

View File

@ -0,0 +1,53 @@
import React, { useEffect } from "react";
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 { OnBoardingForm } from "components/accounts/onboarding-form";
const OnBoardingPage = () => {
const { user: userStore } = useMobxStore();
const user = userStore?.currentUser;
const { setToastAlert } = useToast();
useEffect(() => {
const user = userStore?.currentUser;
if (!user) {
userStore.fetchCurrentUser();
}
}, [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">
<OnBoardingForm user={user} />
</div>
</div>
</div>
);
};
export default observer(OnBoardingPage);

View File

@ -1,5 +1,6 @@
module.exports = {
plugins: {
"tailwindcss/nesting": {},
tailwindcss: {},
autoprefixer: {},
},

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Some files were not shown because too many files have changed in this diff Show More