feat: public board (#2017)
* feat: filters in plane deploy implemented multi-level dropdown for plane deploy * style: spacing and fonts * feat: plane deploy implemented authentication/theming, created/modified all the required store & services * devL reactions, voting, comments and theme
216
apps/space/app/(auth)/components/email-code-form.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
116
apps/space/app/(auth)/components/email-password-form.tsx
Normal 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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
57
apps/space/app/(auth)/components/github-login-button.tsx
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import { useEffect, useState, FC } from "react";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { useSearchParams } from "next/navigation";
|
||||||
|
|
||||||
|
// next-themes
|
||||||
|
import { useTheme } from "next-themes";
|
||||||
|
// images
|
||||||
|
import githubBlackImage from "/public/logos/github-black.png";
|
||||||
|
import githubWhiteImage from "/public/logos/github-white.png";
|
||||||
|
|
||||||
|
export interface GithubLoginButtonProps {
|
||||||
|
handleSignIn: React.Dispatch<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GithubLoginButton: FC<GithubLoginButtonProps> = ({ handleSignIn }) => {
|
||||||
|
const [loginCallBackURL, setLoginCallBackURL] = useState(undefined);
|
||||||
|
const [gitCode, setGitCode] = useState<null | string>(null);
|
||||||
|
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
const code = searchParams?.get("code");
|
||||||
|
|
||||||
|
const { theme } = useTheme();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (code && !gitCode) {
|
||||||
|
setGitCode(code.toString());
|
||||||
|
handleSignIn(code.toString());
|
||||||
|
}
|
||||||
|
}, [code, gitCode, handleSignIn]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const origin = typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
|
||||||
|
setLoginCallBackURL(`${origin}/` as any);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full flex justify-center items-center">
|
||||||
|
<Link
|
||||||
|
className="w-full"
|
||||||
|
href={`https://github.com/login/oauth/authorize?client_id=${process.env.NEXT_PUBLIC_GITHUB_ID}&redirect_uri=${loginCallBackURL}&scope=read:user,user:email`}
|
||||||
|
>
|
||||||
|
<button className="flex w-full items-center justify-center gap-2 rounded border border-custom-border-300 p-2 text-sm font-medium text-custom-text-100 duration-300 hover:bg-custom-background-80 h-[46px]">
|
||||||
|
<Image
|
||||||
|
src={theme === "dark" ? githubWhiteImage : githubBlackImage}
|
||||||
|
height={20}
|
||||||
|
width={20}
|
||||||
|
alt="GitHub Logo"
|
||||||
|
/>
|
||||||
|
<span>Sign in with GitHub</span>
|
||||||
|
</button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
59
apps/space/app/(auth)/components/google-login.tsx
Normal 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} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@ -42,20 +42,18 @@ export async function generateMetadata({ params }: LayoutProps): Promise<Metadat
|
|||||||
|
|
||||||
const RootLayout = ({ children }: { children: React.ReactNode }) => (
|
const RootLayout = ({ children }: { children: React.ReactNode }) => (
|
||||||
<div className="relative w-screen min-h-[500px] h-screen overflow-hidden flex flex-col">
|
<div className="relative w-screen min-h-[500px] h-screen overflow-hidden flex flex-col">
|
||||||
<div className="flex-shrink-0 h-[60px] border-b border-gray-300 relative flex items-center bg-white select-none">
|
<div className="flex-shrink-0 h-[60px] border-b border-custom-border-300 relative flex items-center bga-white select-none">
|
||||||
<IssueNavbar />
|
<IssueNavbar />
|
||||||
</div>
|
</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 />
|
<IssueFilter />
|
||||||
</div> */}
|
<div className="w-full h-full relative overflow-hidden">{children}</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">
|
<div className="absolute z-[99999] bottom-[10px] right-[10px] bg-custom-background-100 rounded-sm shadow-lg border border-custom-border-200">
|
||||||
<Link href="https://plane.so" className="p-1 px-2 flex items-center gap-1" target="_blank">
|
<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">
|
<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" />
|
<Image src="/plane-logo.webp" alt="plane logo" className="w-[24px] h-[24px]" height="24" width="24" />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs">
|
<div className="text-xs text-custom-text-200">
|
||||||
Powered by <b>Plane Deploy</b>
|
Powered by <b>Plane Deploy</b>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect } from "react";
|
import { useEffect, useState } from "react";
|
||||||
// next imports
|
// next imports
|
||||||
import { useRouter, useParams, useSearchParams } from "next/navigation";
|
import { useRouter, useParams, useSearchParams } from "next/navigation";
|
||||||
// mobx
|
// mobx
|
||||||
@ -11,25 +11,33 @@ import { IssueKanbanView } from "components/issues/board-views/kanban";
|
|||||||
import { IssueCalendarView } from "components/issues/board-views/calendar";
|
import { IssueCalendarView } from "components/issues/board-views/calendar";
|
||||||
import { IssueSpreadsheetView } from "components/issues/board-views/spreadsheet";
|
import { IssueSpreadsheetView } from "components/issues/board-views/spreadsheet";
|
||||||
import { IssueGanttView } from "components/issues/board-views/gantt";
|
import { IssueGanttView } from "components/issues/board-views/gantt";
|
||||||
|
import { IssuePeekOverview } from "components/issues/peek-overview";
|
||||||
// mobx store
|
// mobx store
|
||||||
import { RootStore } from "store/root";
|
import { RootStore } from "store/root";
|
||||||
import { useMobxStore } from "lib/mobx/store-provider";
|
import { useMobxStore } from "lib/mobx/store-provider";
|
||||||
// types
|
// types
|
||||||
import { TIssueBoardKeys } from "store/types";
|
import { TIssueBoardKeys } from "store/types";
|
||||||
|
|
||||||
const WorkspaceProjectPage = observer(() => {
|
const WorkspaceProjectPage = () => {
|
||||||
const store: RootStore = useMobxStore();
|
const store: RootStore = useMobxStore();
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const routerParams = useParams();
|
const routerParams = useParams();
|
||||||
const routerSearchparams = useSearchParams();
|
const routerSearchparams = useSearchParams();
|
||||||
|
|
||||||
|
const activeIssueId = store.issue.activePeekOverviewIssueId;
|
||||||
|
|
||||||
const { workspace_slug, project_slug } = routerParams as { workspace_slug: string; project_slug: string };
|
const { workspace_slug, project_slug } = routerParams as { workspace_slug: string; project_slug: string };
|
||||||
const board =
|
const board =
|
||||||
routerSearchparams &&
|
routerSearchparams &&
|
||||||
routerSearchparams.get("board") != null &&
|
routerSearchparams.get("board") != null &&
|
||||||
(routerSearchparams.get("board") as TIssueBoardKeys | "");
|
(routerSearchparams.get("board") as TIssueBoardKeys | "");
|
||||||
|
|
||||||
|
const states = routerSearchparams && routerSearchparams.get("states") != null && routerSearchparams.get("states");
|
||||||
|
const labels = routerSearchparams && routerSearchparams.get("labels") != null && routerSearchparams.get("labels");
|
||||||
|
const priorities =
|
||||||
|
routerSearchparams && routerSearchparams.get("priorities") != null && routerSearchparams.get("priorities");
|
||||||
|
|
||||||
// updating default board view when we are in the issues page
|
// updating default board view when we are in the issues page
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (workspace_slug && project_slug && store?.project?.workspaceProjectSettings) {
|
if (workspace_slug && project_slug && store?.project?.workspaceProjectSettings) {
|
||||||
@ -44,57 +52,96 @@ const WorkspaceProjectPage = observer(() => {
|
|||||||
if (_key === "gantt" && workspacePRojectSettingViews.gantt === true) userAccessViews.push(_key);
|
if (_key === "gantt" && workspacePRojectSettingViews.gantt === true) userAccessViews.push(_key);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let url = `/${workspace_slug}/${project_slug}`;
|
||||||
|
let _board = board;
|
||||||
|
|
||||||
if (userAccessViews && userAccessViews.length > 0) {
|
if (userAccessViews && userAccessViews.length > 0) {
|
||||||
if (!board) {
|
if (!board) {
|
||||||
store.issue.setCurrentIssueBoardView(userAccessViews[0]);
|
store.issue.setCurrentIssueBoardView(userAccessViews[0]);
|
||||||
router.replace(`/${workspace_slug}/${project_slug}?board=${userAccessViews[0]}`);
|
_board = userAccessViews[0];
|
||||||
} else {
|
} else {
|
||||||
if (userAccessViews.includes(board)) {
|
if (userAccessViews.includes(board)) {
|
||||||
if (store.issue.currentIssueBoardView === null) store.issue.setCurrentIssueBoardView(board);
|
if (store.issue.currentIssueBoardView === null) store.issue.setCurrentIssueBoardView(board);
|
||||||
else {
|
else {
|
||||||
if (board === store.issue.currentIssueBoardView)
|
if (board === store.issue.currentIssueBoardView) {
|
||||||
router.replace(`/${workspace_slug}/${project_slug}?board=${board}`);
|
_board = board;
|
||||||
else {
|
} else {
|
||||||
|
_board = board;
|
||||||
store.issue.setCurrentIssueBoardView(board);
|
store.issue.setCurrentIssueBoardView(board);
|
||||||
router.replace(`/${workspace_slug}/${project_slug}?board=${board}`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
store.issue.setCurrentIssueBoardView(userAccessViews[0]);
|
store.issue.setCurrentIssueBoardView(userAccessViews[0]);
|
||||||
router.replace(`/${workspace_slug}/${project_slug}?board=${userAccessViews[0]}`);
|
_board = userAccessViews[0];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_board = _board || "list";
|
||||||
|
url = `${url}?board=${_board}`;
|
||||||
|
|
||||||
|
if (states) url = `${url}&states=${states}`;
|
||||||
|
if (labels) url = `${url}&labels=${labels}`;
|
||||||
|
if (priorities) url = `${url}&priorities=${priorities}`;
|
||||||
|
|
||||||
|
url = decodeURIComponent(url);
|
||||||
|
|
||||||
|
router.replace(url);
|
||||||
}
|
}
|
||||||
}, [workspace_slug, project_slug, board, router, store?.issue, store?.project?.workspaceProjectSettings]);
|
}, [
|
||||||
|
workspace_slug,
|
||||||
|
project_slug,
|
||||||
|
board,
|
||||||
|
router,
|
||||||
|
store?.issue,
|
||||||
|
store?.project?.workspaceProjectSettings,
|
||||||
|
states,
|
||||||
|
labels,
|
||||||
|
priorities,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (workspace_slug && project_slug) {
|
if (!workspace_slug || !project_slug) return;
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
state: states || null,
|
||||||
|
labels: labels || null,
|
||||||
|
priority: priorities || null,
|
||||||
|
};
|
||||||
|
|
||||||
store?.project?.getProjectSettingsAsync(workspace_slug, project_slug);
|
store?.project?.getProjectSettingsAsync(workspace_slug, project_slug);
|
||||||
store?.issue?.getIssuesAsync(workspace_slug, project_slug);
|
store?.issue?.getIssuesAsync(workspace_slug, project_slug, params);
|
||||||
}
|
}, [workspace_slug, project_slug, store?.project, store?.issue, states, labels, priorities]);
|
||||||
}, [workspace_slug, project_slug, store?.project, store?.issue]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative w-full h-full overflow-hidden">
|
<div className="relative w-full h-full overflow-hidden">
|
||||||
|
<IssuePeekOverview
|
||||||
|
isOpen={Boolean(activeIssueId)}
|
||||||
|
onClose={() => store.issue.setActivePeekOverviewIssueId(null)}
|
||||||
|
issue={store?.issue?.issues?.find((_issue) => _issue.id === activeIssueId) || null}
|
||||||
|
workspaceSlug={workspace_slug}
|
||||||
|
/>
|
||||||
|
|
||||||
{store?.issue?.loader && !store.issue.issues ? (
|
{store?.issue?.loader && !store.issue.issues ? (
|
||||||
<div className="text-sm text-center py-10 text-gray-500">Loading...</div>
|
<div className="text-sm text-center py-10 text-custom-text-100">Loading...</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{store?.issue?.error ? (
|
{store?.issue?.error ? (
|
||||||
<div className="text-sm text-center py-10 text-gray-500">Something went wrong.</div>
|
<div className="text-sm text-center py-10 bg-custom-background-200 text-custom-text-100">
|
||||||
|
Something went wrong.
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
store?.issue?.currentIssueBoardView && (
|
store?.issue?.currentIssueBoardView && (
|
||||||
<>
|
<>
|
||||||
{store?.issue?.currentIssueBoardView === "list" && (
|
{store?.issue?.currentIssueBoardView === "list" && (
|
||||||
<div className="relative w-full h-full overflow-y-auto">
|
<div className="relative w-full h-full overflow-y-auto">
|
||||||
<div className="container mx-auto px-5 py-3">
|
<div className="mx-auto px-4">
|
||||||
<IssueListView />
|
<IssueListView />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{store?.issue?.currentIssueBoardView === "kanban" && (
|
{store?.issue?.currentIssueBoardView === "kanban" && (
|
||||||
<div className="relative w-full h-full mx-auto px-5">
|
<div className="relative w-full h-full mx-auto px-9 py-5">
|
||||||
<IssueKanbanView />
|
<IssueKanbanView />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -108,6 +155,6 @@ const WorkspaceProjectPage = observer(() => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
};
|
||||||
|
|
||||||
export default WorkspaceProjectPage;
|
export default observer(WorkspaceProjectPage);
|
||||||
|
@ -2,16 +2,27 @@
|
|||||||
|
|
||||||
// root styles
|
// root styles
|
||||||
import "styles/globals.css";
|
import "styles/globals.css";
|
||||||
|
|
||||||
|
// next theme
|
||||||
|
import { ThemeProvider } from "next-themes";
|
||||||
|
|
||||||
|
// toast alert
|
||||||
|
import { ToastContextProvider } from "contexts/toast.context";
|
||||||
|
|
||||||
// mobx store provider
|
// mobx store provider
|
||||||
import { MobxStoreProvider } from "lib/mobx/store-provider";
|
import { MobxStoreProvider } from "lib/mobx/store-provider";
|
||||||
import MobxStoreInit from "lib/mobx/store-init";
|
import MobxStoreInit from "lib/mobx/store-init";
|
||||||
|
|
||||||
const RootLayout = ({ children }: { children: React.ReactNode }) => (
|
const RootLayout = ({ children }: { children: React.ReactNode }) => (
|
||||||
<html lang="en">
|
<html lang="en" suppressHydrationWarning>
|
||||||
<body className="antialiased w-100">
|
<body className="antialiased bg-custom-background-90 w-100">
|
||||||
<MobxStoreProvider>
|
<MobxStoreProvider>
|
||||||
<MobxStoreInit />
|
<MobxStoreInit />
|
||||||
|
<ToastContextProvider>
|
||||||
|
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||||
<main>{children}</main>
|
<main>{children}</main>
|
||||||
|
</ThemeProvider>
|
||||||
|
</ToastContextProvider>
|
||||||
</MobxStoreProvider>
|
</MobxStoreProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
200
apps/space/app/onboarding/components/user-details.tsx
Normal file
@ -0,0 +1,200 @@
|
|||||||
|
import { useEffect, Fragment } from "react";
|
||||||
|
|
||||||
|
// next
|
||||||
|
import { useSearchParams, useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
// react-hook-form
|
||||||
|
import { Controller, useForm } from "react-hook-form";
|
||||||
|
|
||||||
|
// mobx
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import { useMobxStore } from "lib/mobx/store-provider";
|
||||||
|
|
||||||
|
// headless ui
|
||||||
|
import { Listbox, Transition } from "@headlessui/react";
|
||||||
|
|
||||||
|
// icons
|
||||||
|
import { ChevronDownIcon, CheckIcon } from "@heroicons/react/20/solid";
|
||||||
|
|
||||||
|
// constants
|
||||||
|
import { USER_ROLES } from "constants/workspace";
|
||||||
|
|
||||||
|
// hooks
|
||||||
|
import useToast from "hooks/use-toast";
|
||||||
|
|
||||||
|
// services
|
||||||
|
import UserService from "services/user.service";
|
||||||
|
|
||||||
|
// ui
|
||||||
|
import { Input, PrimaryButton } from "components/ui";
|
||||||
|
|
||||||
|
const defaultValues = {
|
||||||
|
first_name: "",
|
||||||
|
last_name: "",
|
||||||
|
role: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
user?: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const UserDetails: React.FC<Props> = observer(({ user }) => {
|
||||||
|
const { setToastAlert } = useToast();
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
const { user: userStore } = useMobxStore();
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
control,
|
||||||
|
reset,
|
||||||
|
formState: { errors, isSubmitting, isValid },
|
||||||
|
} = useForm({
|
||||||
|
defaultValues,
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = async (formData: any) => {
|
||||||
|
const payload = {
|
||||||
|
...formData,
|
||||||
|
onboarding_step: {
|
||||||
|
...user.onboarding_step,
|
||||||
|
profile_complete: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const userService = new UserService();
|
||||||
|
|
||||||
|
await userService
|
||||||
|
.updateMe(payload)
|
||||||
|
.then((response) => {
|
||||||
|
userStore.setCurrentUser(response);
|
||||||
|
|
||||||
|
const nextPath = searchParams?.get("next_path") || "/";
|
||||||
|
router.push(nextPath);
|
||||||
|
|
||||||
|
setToastAlert({
|
||||||
|
type: "success",
|
||||||
|
title: "Success!",
|
||||||
|
message: "Details updated successfully.",
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((err) => {});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user) {
|
||||||
|
reset({
|
||||||
|
first_name: user.first_name,
|
||||||
|
last_name: user.last_name,
|
||||||
|
role: user.role,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [user, reset]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
className="h-full w-full space-y-7 sm:space-y-10 overflow-y-auto sm:flex sm:flex-col sm:items-start sm:justify-center"
|
||||||
|
onSubmit={handleSubmit(onSubmit)}
|
||||||
|
>
|
||||||
|
<div className="relative sm:text-lg">
|
||||||
|
<div className="text-gray-800 absolute -top-1 -left-3">{'"'}</div>
|
||||||
|
<h5>Hey there 👋🏻</h5>
|
||||||
|
<h5 className="mt-5 mb-6">Let{"'"}s get you onboard!</h5>
|
||||||
|
<h4 className="text-xl sm:text-2xl font-semibold">Set up your Plane profile.</h4>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-7 sm:w-3/4 md:w-2/5">
|
||||||
|
<div className="space-y-1 text-sm">
|
||||||
|
<label htmlFor="firstName">First Name</label>
|
||||||
|
<Input
|
||||||
|
id="firstName"
|
||||||
|
autoComplete="off"
|
||||||
|
placeholder="Enter your first name..."
|
||||||
|
{...register("first_name", {
|
||||||
|
required: "First name is required",
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
{errors.first_name && <div className="text-sm text-red-500">{errors.first_name.message}</div>}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1 text-sm">
|
||||||
|
<label htmlFor="lastName">Last Name</label>
|
||||||
|
<Input
|
||||||
|
id="lastName"
|
||||||
|
autoComplete="off"
|
||||||
|
placeholder="Enter your last name..."
|
||||||
|
{...register("last_name", {
|
||||||
|
required: "Last name is required",
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
{errors.last_name && <div className="text-sm text-red-500">{errors.last_name.message}</div>}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1 text-sm">
|
||||||
|
<span>What{"'"}s your role?</span>
|
||||||
|
<div className="w-full">
|
||||||
|
<Controller
|
||||||
|
name="role"
|
||||||
|
control={control}
|
||||||
|
rules={{ required: "This field is required" }}
|
||||||
|
render={({ field: { value, onChange } }) => (
|
||||||
|
<Listbox as="div" value={value} onChange={onChange} className="relative flex-shrink-0 text-left">
|
||||||
|
<Listbox.Button
|
||||||
|
type="button"
|
||||||
|
className={`flex items-center justify-between gap-1 w-full rounded-md border border-custom-border-300 shadow-sm duration-300 focus:outline-none px-3 py-2 text-sm`}
|
||||||
|
>
|
||||||
|
<span className="text-custom-text-400">{value || "Select your role..."}</span>
|
||||||
|
<ChevronDownIcon className="h-3 w-3" aria-hidden="true" />
|
||||||
|
</Listbox.Button>
|
||||||
|
|
||||||
|
<Transition
|
||||||
|
as={Fragment}
|
||||||
|
enter="transition ease-out duration-100"
|
||||||
|
enterFrom="transform opacity-0 scale-95"
|
||||||
|
enterTo="transform opacity-100 scale-100"
|
||||||
|
leave="transition ease-in duration-75"
|
||||||
|
leaveFrom="transform opacity-100 scale-100"
|
||||||
|
leaveTo="transform opacity-0 scale-95"
|
||||||
|
>
|
||||||
|
<Listbox.Options
|
||||||
|
className={`absolute z-10 border border-custom-border-300 mt-1 overflow-y-auto rounded-md bg-custom-background-90 text-xs shadow-lg focus:outline-none w-full max-h-36 left-0 origin-top-left`}
|
||||||
|
>
|
||||||
|
<div className="space-y-1 p-2">
|
||||||
|
{USER_ROLES.map((role) => (
|
||||||
|
<Listbox.Option
|
||||||
|
key={role.value}
|
||||||
|
value={role.value}
|
||||||
|
className={({ active, selected }) =>
|
||||||
|
`cursor-pointer select-none truncate rounded px-1 py-1.5 ${
|
||||||
|
active || selected ? "bg-custom-background-80" : ""
|
||||||
|
} ${selected ? "text-custom-text-100" : "text-custom-text-200"}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{({ selected }) => (
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span>{role.label}</span>
|
||||||
|
</div>
|
||||||
|
{selected && <CheckIcon className="h-4 w-4 flex-shrink-0" />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Listbox.Option>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Listbox.Options>
|
||||||
|
</Transition>
|
||||||
|
</Listbox>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{errors.role && <span className="text-sm text-red-500">{errors.role.message}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PrimaryButton type="submit" size="md" disabled={!isValid} loading={isSubmitting}>
|
||||||
|
{isSubmitting ? "Updating..." : "Continue"}
|
||||||
|
</PrimaryButton>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
});
|
62
apps/space/app/onboarding/page.tsx
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useEffect } from "react";
|
||||||
|
|
||||||
|
// next
|
||||||
|
import Image from "next/image";
|
||||||
|
|
||||||
|
// assets
|
||||||
|
import BluePlaneLogoWithoutText from "public/plane-logos/blue-without-text.png";
|
||||||
|
import BlackHorizontalLogo from "public/plane-logos/black-horizontal-with-blue-logo.svg";
|
||||||
|
|
||||||
|
// mobx
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import { useMobxStore } from "lib/mobx/store-provider";
|
||||||
|
|
||||||
|
// services
|
||||||
|
import authenticationService from "services/authentication.service";
|
||||||
|
|
||||||
|
// hooks
|
||||||
|
import useToast from "hooks/use-toast";
|
||||||
|
|
||||||
|
// components
|
||||||
|
import { UserDetails } from "./components/user-details";
|
||||||
|
|
||||||
|
const HomePage = () => {
|
||||||
|
const { user: userStore } = useMobxStore();
|
||||||
|
|
||||||
|
const user = userStore?.currentUser;
|
||||||
|
|
||||||
|
const { setToastAlert } = useToast();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const user = userStore?.currentUser;
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
userStore.getUserAsync();
|
||||||
|
}
|
||||||
|
}, [userStore]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-screen w-full overflow-hidden bg-custom-background-100">
|
||||||
|
<div className="flex h-full w-full flex-col gap-y-2 sm:gap-y-0 sm:flex-row overflow-hidden">
|
||||||
|
<div className="relative h-1/6 flex-shrink-0 sm:w-2/12 md:w-3/12 lg:w-1/5">
|
||||||
|
<div className="absolute border-b-[0.5px] sm:border-r-[0.5px] border-custom-border-200 h-[0.5px] w-full top-1/2 left-0 -translate-y-1/2 sm:h-screen sm:w-[0.5px] sm:top-0 sm:left-1/2 md:left-1/3 sm:-translate-x-1/2 sm:translate-y-0 z-10" />
|
||||||
|
<div className="absolute grid place-items-center bg-custom-background-100 px-3 sm:px-0 py-5 left-2 sm:left-1/2 md:left-1/3 sm:-translate-x-1/2 top-1/2 -translate-y-1/2 sm:translate-y-0 sm:top-12 z-10">
|
||||||
|
<div className="h-[30px] w-[30px]">
|
||||||
|
<Image src={BluePlaneLogoWithoutText} alt="Plane logo" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="absolute sm:fixed text-custom-text-100 text-sm font-medium right-4 top-1/4 sm:top-12 -translate-y-1/2 sm:translate-y-0 sm:right-16 sm:py-5">
|
||||||
|
{user?.email}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="relative flex justify-center sm:items-center h-full px-8 pb-0 sm:px-0 sm:py-12 sm:pr-[8.33%] sm:w-10/12 md:w-9/12 lg:w-4/5 overflow-hidden">
|
||||||
|
<UserDetails user={user} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default observer(HomePage);
|
@ -1,9 +1,171 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React from "react";
|
import React, { useEffect } from "react";
|
||||||
|
|
||||||
const HomePage = () => (
|
// next
|
||||||
<div className="relative w-screen h-screen flex justify-center items-center text-5xl">Plane Deploy</div>
|
import Image from "next/image";
|
||||||
|
import Router from "next/router";
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
|
||||||
|
// assets
|
||||||
|
import BluePlaneLogoWithoutText from "public/plane-logos/blue-without-text.png";
|
||||||
|
|
||||||
|
// mobx
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import { useMobxStore } from "lib/mobx/store-provider";
|
||||||
|
|
||||||
|
// services
|
||||||
|
import authenticationService from "services/authentication.service";
|
||||||
|
|
||||||
|
// hooks
|
||||||
|
import useToast from "hooks/use-toast";
|
||||||
|
|
||||||
|
// components
|
||||||
|
import { EmailPasswordForm } from "./(auth)/components/email-password-form";
|
||||||
|
import { GithubLoginButton } from "./(auth)/components/github-login-button";
|
||||||
|
import { GoogleLoginButton } from "./(auth)/components/google-login";
|
||||||
|
import { EmailCodeForm } from "./(auth)/components/email-code-form";
|
||||||
|
|
||||||
|
const HomePage = () => {
|
||||||
|
const { user: userStore } = useMobxStore();
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
const { setToastAlert } = useToast();
|
||||||
|
|
||||||
|
const onSignInError = (error: any) => {
|
||||||
|
setToastAlert({
|
||||||
|
title: "Error signing in!",
|
||||||
|
type: "error",
|
||||||
|
message: error?.error || "Something went wrong. Please try again later or contact the support team.",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSignInSuccess = (response: any) => {
|
||||||
|
const isOnboarded = response?.user?.onboarding_step?.profile_complete || false;
|
||||||
|
const nextPath = searchParams?.get("next_path") || "/";
|
||||||
|
|
||||||
|
userStore.setCurrentUser(response?.user);
|
||||||
|
|
||||||
|
if (!isOnboarded) {
|
||||||
|
router.push(`/onboarding?next_path=${nextPath}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.push(nextPath);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGoogleSignIn = async ({ clientId, credential }: any) => {
|
||||||
|
try {
|
||||||
|
if (clientId && credential) {
|
||||||
|
const socialAuthPayload = {
|
||||||
|
medium: "google",
|
||||||
|
credential,
|
||||||
|
clientId,
|
||||||
|
};
|
||||||
|
const response = await authenticationService.socialAuth(socialAuthPayload);
|
||||||
|
|
||||||
|
onSignInSuccess(response);
|
||||||
|
} else {
|
||||||
|
throw Error("Cant find credentials");
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
onSignInError(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGitHubSignIn = async (credential: string) => {
|
||||||
|
try {
|
||||||
|
if (process.env.NEXT_PUBLIC_GITHUB_ID && credential) {
|
||||||
|
const socialAuthPayload = {
|
||||||
|
medium: "github",
|
||||||
|
credential,
|
||||||
|
clientId: process.env.NEXT_PUBLIC_GITHUB_ID,
|
||||||
|
};
|
||||||
|
const response = await authenticationService.socialAuth(socialAuthPayload);
|
||||||
|
onSignInSuccess(response);
|
||||||
|
} else {
|
||||||
|
throw Error("Cant find credentials");
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
onSignInError(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePasswordSignIn = async (formData: any) => {
|
||||||
|
await authenticationService
|
||||||
|
.emailLogin(formData)
|
||||||
|
.then((response) => {
|
||||||
|
try {
|
||||||
|
if (response) {
|
||||||
|
onSignInSuccess(response);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
onSignInError(err);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => onSignInError(err));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEmailCodeSignIn = async (response: any) => {
|
||||||
|
try {
|
||||||
|
if (response) {
|
||||||
|
onSignInSuccess(response);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
onSignInError(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-screen w-full overflow-hidden">
|
||||||
|
<div className="hidden sm:block sm:fixed border-r-[0.5px] border-custom-border-200 h-screen w-[0.5px] top-0 left-20 lg:left-32" />
|
||||||
|
<div className="fixed grid place-items-center bg-custom-background-100 sm:py-5 top-11 sm:top-12 left-7 sm:left-16 lg:left-28">
|
||||||
|
<div className="grid place-items-center bg-custom-background-100">
|
||||||
|
<div className="h-[30px] w-[30px]">
|
||||||
|
<Image src={BluePlaneLogoWithoutText} alt="Plane Logo" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid place-items-center h-full overflow-y-auto py-5 px-7">
|
||||||
|
<div>
|
||||||
|
{parseInt(process.env.NEXT_PUBLIC_ENABLE_OAUTH || "0") ? (
|
||||||
|
<>
|
||||||
|
<h1 className="text-center text-2xl sm:text-2.5xl font-semibold text-custom-text-100">
|
||||||
|
Sign in to Plane
|
||||||
|
</h1>
|
||||||
|
<div className="flex flex-col divide-y divide-custom-border-200">
|
||||||
|
<div className="pb-7">
|
||||||
|
<EmailCodeForm handleSignIn={handleEmailCodeSignIn} />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center justify-center gap-4 pt-7 sm:w-[360px] mx-auto overflow-hidden">
|
||||||
|
<GoogleLoginButton handleSignIn={handleGoogleSignIn} />
|
||||||
|
<GithubLoginButton handleSignIn={handleGitHubSignIn} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<EmailPasswordForm onSubmit={handlePasswordSignIn} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{parseInt(process.env.NEXT_PUBLIC_ENABLE_OAUTH || "0") ? (
|
||||||
|
<p className="pt-16 text-custom-text-200 text-sm text-center">
|
||||||
|
By signing up, you agree to the{" "}
|
||||||
|
<a
|
||||||
|
href="https://plane.so/terms-and-conditions"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="font-medium underline"
|
||||||
|
>
|
||||||
|
Terms & Conditions
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default HomePage;
|
export default observer(HomePage);
|
||||||
|
@ -24,7 +24,7 @@ const validDate = (date: any, state: string): string => {
|
|||||||
|
|
||||||
export const IssueBlockDueDate = ({ due_date, state }: any) => (
|
export const IssueBlockDueDate = ({ due_date, state }: any) => (
|
||||||
<div
|
<div
|
||||||
className={`h-[24px] rounded-sm flex px-2 items-center border border-gray-300 gap-1 text-gray-700 text-xs font-medium
|
className={`h-[24px] rounded-md flex px-2.5 py-1 items-center border border-custom-border-100 gap-1 text-custom-text-100 text-xs font-medium
|
||||||
${validDate(due_date, state)}`}
|
${validDate(due_date, state)}`}
|
||||||
>
|
>
|
||||||
{renderDateFormat(due_date)}
|
{renderDateFormat(due_date)}
|
||||||
|
@ -6,11 +6,13 @@ export const IssueBlockLabels = ({ labels }: any) => (
|
|||||||
labels.length > 0 &&
|
labels.length > 0 &&
|
||||||
labels.map((_label: any) => (
|
labels.map((_label: any) => (
|
||||||
<div
|
<div
|
||||||
className={`h-[24px] rounded-sm flex px-1 items-center border gap-1 !bg-transparent !text-gray-700`}
|
key={_label?.id}
|
||||||
style={{ backgroundColor: `${_label?.color}10`, borderColor: `${_label?.color}50` }}
|
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="flex items-center gap-1.5 text-custom-text-200">
|
||||||
<div className="text-sm">{_label?.name}</div>
|
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: `${_label?.color}` }} />
|
||||||
|
<div className="text-xs">{_label?.name}</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
@ -10,8 +10,8 @@ export const IssueBlockPriority = ({ priority }: { priority: TIssuePriorityKey |
|
|||||||
|
|
||||||
if (priority_detail === null) return <></>;
|
if (priority_detail === null) return <></>;
|
||||||
return (
|
return (
|
||||||
<div className={`w-[24px] h-[24px] rounded-sm flex justify-center items-center ${priority_detail?.className}`}>
|
<div className={`h-6 w-6 rounded flex justify-center items-center ${priority_detail?.className}`}>
|
||||||
<span className="material-symbols-rounded text-[16px]">{priority_detail?.icon}</span>
|
<span className="material-symbols-rounded text-sm">{priority_detail?.icon}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -8,11 +8,11 @@ export const IssueBlockState = ({ state }: any) => {
|
|||||||
|
|
||||||
if (stateGroup === null) return <></>;
|
if (stateGroup === null) return <></>;
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="flex items-center justify-between gap-1 w-full rounded-md shadow-sm border border-custom-border-300 duration-300 focus:outline-none px-2.5 py-1 text-xs cursor-pointer hover:bg-custom-background-80">
|
||||||
className={`h-[24px] rounded-sm flex px-1 items-center border ${stateGroup?.className} gap-1 !bg-transparent !text-gray-700`}
|
<div className="flex items-center cursor-pointer w-full gap-2 text-custom-text-200">
|
||||||
>
|
|
||||||
<stateGroup.icon />
|
<stateGroup.icon />
|
||||||
<div className="text-sm">{state?.name}</div>
|
<div className="text-xs">{state?.name}</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -2,29 +2,36 @@
|
|||||||
|
|
||||||
// mobx react lite
|
// mobx react lite
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
|
import { useMobxStore } from "lib/mobx/store-provider";
|
||||||
|
|
||||||
// components
|
// components
|
||||||
import { IssueBlockPriority } from "components/issues/board-views/block-priority";
|
import { IssueBlockPriority } from "components/issues/board-views/block-priority";
|
||||||
import { IssueBlockState } from "components/issues/board-views/block-state";
|
import { IssueBlockState } from "components/issues/board-views/block-state";
|
||||||
import { IssueBlockLabels } from "components/issues/board-views/block-labels";
|
import { IssueBlockLabels } from "components/issues/board-views/block-labels";
|
||||||
import { IssueBlockDueDate } from "components/issues/board-views/block-due-date";
|
import { IssueBlockDueDate } from "components/issues/board-views/block-due-date";
|
||||||
// mobx hook
|
|
||||||
import { useMobxStore } from "lib/mobx/store-provider";
|
|
||||||
// interfaces
|
// interfaces
|
||||||
import { IIssue } from "store/types/issue";
|
import { IIssue } from "store/types/issue";
|
||||||
import { RootStore } from "store/root";
|
import { RootStore } from "store/root";
|
||||||
|
|
||||||
export const IssueListBlock = ({ issue }: { issue: IIssue }) => {
|
export const IssueListBlock = observer(({ issue }: { issue: IIssue }) => {
|
||||||
const store: RootStore = useMobxStore();
|
const store: RootStore = useMobxStore();
|
||||||
|
|
||||||
|
const { issue: issueStore } = store;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-2 px-3 bg-white space-y-2 rounded-sm shadow">
|
<div className="p-3.5 h-[118px] flex flex-col justify-between bg-custom-background-100 space-y-2 rounded shadow">
|
||||||
{/* id */}
|
{/* id */}
|
||||||
<div className="flex-shrink-0 text-sm text-gray-600 w-[60px]">
|
<div className="flex-shrink-0 text-xs font-medium text-custom-text-200 w-[60px]">
|
||||||
{store?.project?.project?.identifier}-{issue?.sequence_id}
|
{store?.project?.project?.identifier}-{issue?.sequence_id}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* name */}
|
{/* name */}
|
||||||
<div className="font-medium text-gray-800 h-full line-clamp-2">{issue.name}</div>
|
<div
|
||||||
|
onClick={() => issueStore?.setActivePeekOverviewIssueId(issue?.id)}
|
||||||
|
className="text-custom-text-100 text-sm font-medium h-full break-words line-clamp-2 cursor-pointer"
|
||||||
|
>
|
||||||
|
{issue.name}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* priority */}
|
{/* priority */}
|
||||||
<div className="relative flex flex-wrap items-center gap-2 w-full">
|
<div className="relative flex flex-wrap items-center gap-2 w-full">
|
||||||
@ -54,4 +61,4 @@ export const IssueListBlock = ({ issue }: { issue: IIssue }) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
@ -18,12 +18,12 @@ export const IssueListHeader = observer(({ state }: { state: IIssueState }) => {
|
|||||||
if (stateGroup === null) return <></>;
|
if (stateGroup === null) return <></>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="py-2 flex items-center gap-2">
|
<div className="pb-3 flex items-center">
|
||||||
<div className="w-[28px] h-[28px] flex justify-center items-center">
|
<div className="w-[28px] h-[28px] flex justify-center items-center">
|
||||||
<stateGroup.icon />
|
<stateGroup.icon />
|
||||||
</div>
|
</div>
|
||||||
<div className="font-medium capitalize">{state?.name}</div>
|
<div className="font-semibold text-base capitalize ml-2 mr-3">{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="text-gray-700 w-full max-w-[26px] h-[20px] flex justify-center items-center rounded-full">
|
||||||
{store.issue.getCountOfIssuesByState(state.id)}
|
{store.issue.getCountOfIssuesByState(state.id)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -19,7 +19,7 @@ export const IssueKanbanView = observer(() => {
|
|||||||
{store?.issue?.states &&
|
{store?.issue?.states &&
|
||||||
store?.issue?.states.length > 0 &&
|
store?.issue?.states.length > 0 &&
|
||||||
store?.issue?.states.map((_state: IIssueState) => (
|
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">
|
<div className="flex-shrink-0">
|
||||||
<IssueListHeader state={_state} />
|
<IssueListHeader state={_state} />
|
||||||
</div>
|
</div>
|
||||||
@ -28,11 +28,11 @@ export const IssueKanbanView = observer(() => {
|
|||||||
store.issue.getFilteredIssuesByState(_state.id).length > 0 ? (
|
store.issue.getFilteredIssuesByState(_state.id).length > 0 ? (
|
||||||
<div className="space-y-3 pb-2">
|
<div className="space-y-3 pb-2">
|
||||||
{store.issue.getFilteredIssuesByState(_state.id).map((_issue: IIssue) => (
|
{store.issue.getFilteredIssuesByState(_state.id).map((_issue: IIssue) => (
|
||||||
<IssueListBlock issue={_issue} />
|
<IssueListBlock key={_issue.id} issue={_issue} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</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.
|
No Issues are available.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
@ -13,20 +13,30 @@ import { useMobxStore } from "lib/mobx/store-provider";
|
|||||||
import { IIssue } from "store/types/issue";
|
import { IIssue } from "store/types/issue";
|
||||||
import { RootStore } from "store/root";
|
import { RootStore } from "store/root";
|
||||||
|
|
||||||
export const IssueListBlock = ({ issue }: { issue: IIssue }) => {
|
export const IssueListBlock = observer(({ issue }: { issue: IIssue }) => {
|
||||||
const store: RootStore = useMobxStore();
|
const store: RootStore = useMobxStore();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-2 px-3 relative flex items-center gap-3">
|
<div className="flex items-center px-9 py-3.5 relative gap-10 border-b border-custom-border-200 bg-custom-background-100 last:border-b-0">
|
||||||
<div className="relative flex items-center gap-3 w-full">
|
<div className="relative flex items-center gap-6 w-full flex-grow overflow-hidden">
|
||||||
{/* id */}
|
{/* id */}
|
||||||
<div className="flex-shrink-0 text-sm text-gray-600 w-[60px]">
|
<div className="flex-shrink-0 text-sm w-[60px] text-custom-text-200">
|
||||||
{store?.project?.project?.identifier}-{issue?.sequence_id}
|
{store?.project?.project?.identifier}-{issue?.sequence_id}
|
||||||
</div>
|
</div>
|
||||||
{/* name */}
|
{/* name */}
|
||||||
<div className="font-medium text-gray-800 h-full line-clamp-1">{issue.name}</div>
|
<div className="h-full line-clamp-1 w-full overflow-ellipsis cursor-pointer">
|
||||||
|
<p
|
||||||
|
onClick={() => {
|
||||||
|
store.issue.setActivePeekOverviewIssueId(issue.id);
|
||||||
|
}}
|
||||||
|
className="text-[0.825rem] font-medium text-sm truncate text-custom-text-100"
|
||||||
|
>
|
||||||
|
{issue.name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="inline-flex flex-shrink-0 items-center gap-2 text-xs">
|
||||||
{/* priority */}
|
{/* priority */}
|
||||||
{issue?.priority && (
|
{issue?.priority && (
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
@ -55,5 +65,6 @@ export const IssueListBlock = ({ issue }: { issue: IIssue }) => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
@ -18,14 +18,12 @@ export const IssueListHeader = observer(({ state }: { state: IIssueState }) => {
|
|||||||
if (stateGroup === null) return <></>;
|
if (stateGroup === null) return <></>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="py-2 px-3 flex items-center gap-2">
|
<div className="py-2 px-3 flex items-center">
|
||||||
<div className="w-[28px] h-[28px] flex justify-center items-center">
|
<div className="w-[28px] h-[28px] flex justify-center items-center">
|
||||||
<stateGroup.icon />
|
<stateGroup.icon />
|
||||||
</div>
|
</div>
|
||||||
<div className="font-medium capitalize">{state?.name}</div>
|
<div className="font-semibold leading-6 text-base capitalize ml-2 mr-3">{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="text-gray-700 font-normal text-base">{store.issue.getCountOfIssuesByState(state.id)}</div>
|
||||||
{store.issue.getCountOfIssuesByState(state.id)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
@ -19,17 +19,19 @@ export const IssueListView = observer(() => {
|
|||||||
{store?.issue?.states &&
|
{store?.issue?.states &&
|
||||||
store?.issue?.states.length > 0 &&
|
store?.issue?.states.length > 0 &&
|
||||||
store?.issue?.states.map((_state: IIssueState) => (
|
store?.issue?.states.map((_state: IIssueState) => (
|
||||||
<div className="relative w-full">
|
<div key={_state.id} className="relative w-full">
|
||||||
<IssueListHeader state={_state} />
|
<IssueListHeader state={_state} />
|
||||||
{store.issue.getFilteredIssuesByState(_state.id) &&
|
{store.issue.getFilteredIssuesByState(_state.id) &&
|
||||||
store.issue.getFilteredIssuesByState(_state.id).length > 0 ? (
|
store.issue.getFilteredIssuesByState(_state.id).length > 0 ? (
|
||||||
<div className="bg-white divide-y">
|
<div className="divide-y">
|
||||||
{store.issue.getFilteredIssuesByState(_state.id).map((_issue: IIssue) => (
|
{store.issue.getFilteredIssuesByState(_state.id).map((_issue: IIssue) => (
|
||||||
<IssueListBlock issue={_issue} />
|
<IssueListBlock key={_issue.id} issue={_issue} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="bg-white p-5 text-sm text-gray-600">No Issues are available.</div>
|
<div className="px-9 py-3.5 text-sm text-custom-text-200 bg-custom-background-100">
|
||||||
|
No Issues are available.
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
@ -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;
|
|
@ -1,12 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter, useParams } from "next/navigation";
|
||||||
|
|
||||||
// mobx react lite
|
// mobx react lite
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
// components
|
// components
|
||||||
import IssueStateFilter from "./state";
|
import IssueStateFilter from "./state";
|
||||||
import IssueLabelFilter from "./label";
|
import IssueLabelFilter from "./label";
|
||||||
import IssuePriorityFilter from "./priority";
|
import IssuePriorityFilter from "./priority";
|
||||||
import IssueDateFilter from "./date";
|
|
||||||
// mobx hook
|
// mobx hook
|
||||||
import { useMobxStore } from "lib/mobx/store-provider";
|
import { useMobxStore } from "lib/mobx/store-provider";
|
||||||
import { RootStore } from "store/root";
|
import { RootStore } from "store/root";
|
||||||
@ -14,24 +15,41 @@ import { RootStore } from "store/root";
|
|||||||
const IssueFilter = observer(() => {
|
const IssueFilter = observer(() => {
|
||||||
const store: RootStore = useMobxStore();
|
const store: RootStore = useMobxStore();
|
||||||
|
|
||||||
const clearAllFilters = () => {};
|
const router = useRouter();
|
||||||
|
const routerParams = useParams();
|
||||||
|
|
||||||
|
const { workspace_slug, project_slug } = routerParams as { workspace_slug: string; project_slug: string };
|
||||||
|
|
||||||
|
const clearAllFilters = () => {
|
||||||
|
router.replace(
|
||||||
|
store.issue.getURLDefinition(workspace_slug, project_slug, {
|
||||||
|
key: "all",
|
||||||
|
removeAll: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (store.issue.getIfFiltersIsEmpty()) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto px-5 flex justify-start items-center flex-wrap gap-2 text-sm">
|
<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 */}
|
{/* state */}
|
||||||
{store?.issue?.states && <IssueStateFilter />}
|
{store.issue.checkIfFilterExistsForKey("state") && <IssueStateFilter />}
|
||||||
{/* labels */}
|
{/* labels */}
|
||||||
{store?.issue?.labels && <IssueLabelFilter />}
|
{store.issue.checkIfFilterExistsForKey("label") && <IssueLabelFilter />}
|
||||||
{/* priority */}
|
{/* priority */}
|
||||||
<IssuePriorityFilter />
|
{store.issue.checkIfFilterExistsForKey("priority") && <IssuePriorityFilter />}
|
||||||
{/* due date */}
|
|
||||||
<IssueDateFilter />
|
|
||||||
{/* clear all filters */}
|
{/* clear all filters */}
|
||||||
<div
|
<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"
|
className="flex items-center gap-2 border border-custom-border-200 px-2 py-1 cursor-pointer text-xs rounded-full"
|
||||||
onClick={clearAllFilters}
|
onClick={clearAllFilters}
|
||||||
>
|
>
|
||||||
<div>Clear all filters</div>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -1,33 +1,47 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter, useParams } from "next/navigation";
|
||||||
|
|
||||||
// mobx react lite
|
// mobx react lite
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
// mobx hook
|
// mobx hook
|
||||||
import { useMobxStore } from "lib/mobx/store-provider";
|
import { useMobxStore } from "lib/mobx/store-provider";
|
||||||
// interfaces
|
// interfaces
|
||||||
import { IIssueLabel } from "store/types/issue";
|
import { IIssueLabel } from "store/types/issue";
|
||||||
// constants
|
|
||||||
import { issueGroupFilter } from "constants/data";
|
|
||||||
|
|
||||||
export const RenderIssueLabel = observer(({ label }: { label: IIssueLabel }) => {
|
export const RenderIssueLabel = observer(({ label }: { label: IIssueLabel }) => {
|
||||||
const store = useMobxStore();
|
const store = useMobxStore();
|
||||||
|
|
||||||
const removeLabelFromFilter = () => {};
|
const router = useRouter();
|
||||||
|
const routerParams = useParams();
|
||||||
|
|
||||||
|
const { workspace_slug, project_slug } = routerParams as { workspace_slug: string; project_slug: string };
|
||||||
|
|
||||||
|
const removeLabelFromFilter = () => {
|
||||||
|
router.replace(
|
||||||
|
store.issue.getURLDefinition(workspace_slug, project_slug, {
|
||||||
|
key: "label",
|
||||||
|
value: label?.id,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<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 rounded-full select-none"
|
||||||
style={{ color: label?.color, backgroundColor: `${label?.color}10`, borderColor: `${label?.color}50` }}
|
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
|
<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}
|
onClick={removeLabelFromFilter}
|
||||||
>
|
>
|
||||||
<span className="material-symbols-rounded text-[14px]">close</span>
|
<span className="material-symbols-rounded text-xs">close</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter, useParams } from "next/navigation";
|
||||||
|
|
||||||
// mobx react lite
|
// mobx react lite
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
// components
|
// components
|
||||||
@ -13,21 +15,38 @@ import { RootStore } from "store/root";
|
|||||||
const IssueLabelFilter = observer(() => {
|
const IssueLabelFilter = observer(() => {
|
||||||
const store: RootStore = useMobxStore();
|
const store: RootStore = useMobxStore();
|
||||||
|
|
||||||
const clearLabelFilters = () => {};
|
const router = useRouter();
|
||||||
|
const routerParams = useParams();
|
||||||
|
|
||||||
|
const { workspace_slug, project_slug } = routerParams as { workspace_slug: string; project_slug: string };
|
||||||
|
|
||||||
|
const clearLabelFilters = () => {
|
||||||
|
router.replace(
|
||||||
|
store.issue.getURLDefinition(workspace_slug, project_slug, {
|
||||||
|
key: "label",
|
||||||
|
removeAll: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center gap-2 border border-gray-300 px-2 py-1 pr-1 rounded">
|
<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 font-medium">Labels</div>
|
<div className="flex-shrink-0 text-custom-text-200">Labels</div>
|
||||||
<div className="relative flex flex-wrap items-center gap-1">
|
<div className="relative flex flex-wrap items-center gap-1">
|
||||||
{store?.issue?.labels &&
|
{store?.issue?.labels &&
|
||||||
store?.issue?.labels.map((_label: IIssueLabel, _index: number) => <RenderIssueLabel label={_label} />)}
|
store?.issue?.labels.map(
|
||||||
|
(_label: IIssueLabel, _index: number) =>
|
||||||
|
store.issue.getUserSelectedFilter("label", _label.id) && (
|
||||||
|
<RenderIssueLabel key={_label.id} label={_label} />
|
||||||
|
)
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<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}
|
onClick={clearLabelFilters}
|
||||||
>
|
>
|
||||||
<span className="material-symbols-rounded text-[16px]">close</span>
|
<span className="material-symbols-rounded text-[12px]">close</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter, useParams } from "next/navigation";
|
||||||
|
|
||||||
// mobx react lite
|
// mobx react lite
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
// mobx hook
|
// mobx hook
|
||||||
@ -10,23 +12,36 @@ import { IIssuePriorityFilters } from "store/types/issue";
|
|||||||
export const RenderIssuePriority = observer(({ priority }: { priority: IIssuePriorityFilters }) => {
|
export const RenderIssuePriority = observer(({ priority }: { priority: IIssuePriorityFilters }) => {
|
||||||
const store = useMobxStore();
|
const store = useMobxStore();
|
||||||
|
|
||||||
const removePriorityFromFilter = () => {};
|
const router = useRouter();
|
||||||
|
|
||||||
|
const routerParams = useParams();
|
||||||
|
|
||||||
|
const { workspace_slug, project_slug } = routerParams as { workspace_slug: string; project_slug: string };
|
||||||
|
|
||||||
|
const removePriorityFromFilter = () => {
|
||||||
|
router.replace(
|
||||||
|
store.issue.getURLDefinition(workspace_slug, project_slug, {
|
||||||
|
key: "priority",
|
||||||
|
value: priority?.key,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<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 || ``
|
priority.className || ``
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex-shrink-0 w-[20px] h-[20px] flex justify-center items-center overflow-hidden rounded-full">
|
<div className="flex-shrink-0 flex justify-center items-center overflow-hidden rounded-full">
|
||||||
<span className="material-symbols-rounded text-[14px]">{priority?.icon}</span>
|
<span className="material-symbols-rounded text-xs">{priority?.icon}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm font-medium whitespace-nowrap">{priority?.title}</div>
|
<div className="whitespace-nowrap">{priority?.title}</div>
|
||||||
<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}
|
onClick={removePriorityFromFilter}
|
||||||
>
|
>
|
||||||
<span className="material-symbols-rounded text-[14px]">close</span>
|
<span className="material-symbols-rounded text-xs">close</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter, useParams } from "next/navigation";
|
||||||
|
|
||||||
// mobx react lite
|
// mobx react lite
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
// mobx hook
|
// mobx hook
|
||||||
@ -14,21 +16,41 @@ import { issuePriorityFilters } from "constants/data";
|
|||||||
const IssuePriorityFilter = observer(() => {
|
const IssuePriorityFilter = observer(() => {
|
||||||
const store = useMobxStore();
|
const store = useMobxStore();
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const routerParams = useParams();
|
||||||
|
|
||||||
|
const { workspace_slug, project_slug } = routerParams as { workspace_slug: string; project_slug: string };
|
||||||
|
|
||||||
|
const clearPriorityFilters = () => {
|
||||||
|
router.replace(
|
||||||
|
store.issue.getURLDefinition(workspace_slug, project_slug, {
|
||||||
|
key: "priority",
|
||||||
|
removeAll: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center gap-2 border border-gray-300 px-2 py-1 pr-1 rounded">
|
<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 font-medium">Priority</div>
|
<div className="flex-shrink-0 text-custom-text-200">Priority</div>
|
||||||
<div className="relative flex flex-wrap items-center gap-1">
|
<div className="relative flex flex-wrap items-center gap-1">
|
||||||
{issuePriorityFilters.map((_priority: IIssuePriorityFilters, _index: number) => (
|
{issuePriorityFilters.map(
|
||||||
<RenderIssuePriority priority={_priority} />
|
(_priority: IIssuePriorityFilters, _index: number) =>
|
||||||
))}
|
store.issue.getUserSelectedFilter("priority", _priority.key) && (
|
||||||
|
<RenderIssuePriority key={_priority.key} priority={_priority} />
|
||||||
|
)
|
||||||
|
)}
|
||||||
</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`}
|
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>
|
||||||
</div>{" "}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter, useParams } from "next/navigation";
|
||||||
|
|
||||||
// mobx react lite
|
// mobx react lite
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
// mobx hook
|
// mobx hook
|
||||||
@ -12,26 +14,34 @@ import { issueGroupFilter } from "constants/data";
|
|||||||
export const RenderIssueState = observer(({ state }: { state: IIssueState }) => {
|
export const RenderIssueState = observer(({ state }: { state: IIssueState }) => {
|
||||||
const store = useMobxStore();
|
const store = useMobxStore();
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const routerParams = useParams();
|
||||||
|
|
||||||
|
const { workspace_slug, project_slug } = routerParams as { workspace_slug: string; project_slug: string };
|
||||||
|
|
||||||
const stateGroup = issueGroupFilter(state.group);
|
const 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 <></>;
|
if (stateGroup === null) return <></>;
|
||||||
return (
|
return (
|
||||||
<div
|
<div className={`inline-flex items-center gap-x-1 rounded-full px-2 py-0.5 ${stateGroup.className || ``}`}>
|
||||||
className={`flex-shrink-0 relative flex items-center flex-wrap gap-1 border px-[2px] py-0.5 rounded-full select-none ${
|
<div className="flex-shrink-0 w-3 h-3 flex justify-center items-center overflow-hidden rounded-full">
|
||||||
stateGroup.className || ``
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex-shrink-0 w-[20px] h-[20px] flex justify-center items-center overflow-hidden rounded-full">
|
|
||||||
<stateGroup.icon />
|
<stateGroup.icon />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm font-medium whitespace-nowrap">{state?.name}</div>
|
<div className="text-xs font-medium whitespace-nowrap">{state?.name}</div>
|
||||||
<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}
|
onClick={removeStateFromFilter}
|
||||||
>
|
>
|
||||||
<span className="material-symbols-rounded text-[14px]">close</span>
|
<span className="material-symbols-rounded text-xs">close</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter, useParams } from "next/navigation";
|
||||||
|
|
||||||
// mobx react lite
|
// mobx react lite
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
// components
|
// components
|
||||||
@ -13,21 +15,38 @@ import { RootStore } from "store/root";
|
|||||||
const IssueStateFilter = observer(() => {
|
const IssueStateFilter = observer(() => {
|
||||||
const store: RootStore = useMobxStore();
|
const store: RootStore = useMobxStore();
|
||||||
|
|
||||||
const clearStateFilters = () => {};
|
const router = useRouter();
|
||||||
|
const routerParams = useParams();
|
||||||
|
|
||||||
|
const { workspace_slug, project_slug } = routerParams as { workspace_slug: string; project_slug: string };
|
||||||
|
|
||||||
|
const clearStateFilters = () => {
|
||||||
|
router.replace(
|
||||||
|
store.issue.getURLDefinition(workspace_slug, project_slug, {
|
||||||
|
key: "state",
|
||||||
|
removeAll: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center gap-2 border border-gray-300 px-2 py-1 pr-1 rounded">
|
<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 font-medium">State</div>
|
<div className="flex-shrink-0 text-custom-text-200">State</div>
|
||||||
<div className="relative flex flex-wrap items-center gap-1">
|
<div className="relative flex flex-wrap items-center gap-1">
|
||||||
{store?.issue?.states &&
|
{store?.issue?.states &&
|
||||||
store?.issue?.states.map((_state: IIssueState, _index: number) => <RenderIssueState state={_state} />)}
|
store?.issue?.states.map(
|
||||||
|
(_state: IIssueState, _index: number) =>
|
||||||
|
store.issue.getUserSelectedFilter("state", _state.id) && (
|
||||||
|
<RenderIssueState key={_state.id} state={_state} />
|
||||||
|
)
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<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}
|
onClick={clearStateFilters}
|
||||||
>
|
>
|
||||||
<span className="material-symbols-rounded text-[16px]">close</span>
|
<span className="material-symbols-rounded text-[12px]">close</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
@ -33,7 +33,7 @@ const IssueNavbar = observer(() => {
|
|||||||
<div className="px-5 relative w-full flex items-center gap-4">
|
<div className="px-5 relative w-full flex items-center gap-4">
|
||||||
{/* project detail */}
|
{/* project detail */}
|
||||||
<div className="flex-shrink-0 flex items-center gap-2">
|
<div className="flex-shrink-0 flex items-center gap-2">
|
||||||
<div className="w-[32px] h-[32px] rounded-sm flex justify-center items-center bg-gray-100 text-[24px]">
|
<div className="w-[32px] h-[32px] rounded-sm flex justify-center items-center text-[24px]">
|
||||||
{store?.project?.project && store?.project?.project?.emoji ? (
|
{store?.project?.project && store?.project?.project?.emoji ? (
|
||||||
renderEmoji(store?.project?.project?.emoji)
|
renderEmoji(store?.project?.project?.emoji)
|
||||||
) : (
|
) : (
|
||||||
@ -50,21 +50,21 @@ const IssueNavbar = observer(() => {
|
|||||||
<NavbarSearch />
|
<NavbarSearch />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* issue filters */}
|
||||||
|
<div className="flex-shrink-0 relative flex items-center gap-2">
|
||||||
|
<NavbarIssueFilter />
|
||||||
|
{/* <NavbarIssueView /> */}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* issue views */}
|
{/* issue views */}
|
||||||
<div className="flex-shrink-0 relative flex items-center gap-1 transition-all ease-in-out delay-150">
|
<div className="flex-shrink-0 relative flex items-center gap-1 transition-all ease-in-out delay-150">
|
||||||
<NavbarIssueBoardView />
|
<NavbarIssueBoardView />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* issue filters */}
|
|
||||||
{/* <div className="flex-shrink-0 relative flex items-center gap-2">
|
|
||||||
<NavbarIssueFilter />
|
|
||||||
<NavbarIssueView />
|
|
||||||
</div> */}
|
|
||||||
|
|
||||||
{/* theming */}
|
{/* theming */}
|
||||||
{/* <div className="flex-shrink-0 relative">
|
<div className="flex-shrink-0 relative">
|
||||||
<NavbarTheme />
|
<NavbarTheme />
|
||||||
</div> */}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
// next imports
|
// next imports
|
||||||
import { useRouter, useParams } from "next/navigation";
|
import { useRouter, useParams, useSearchParams } from "next/navigation";
|
||||||
// mobx react lite
|
// mobx react lite
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
// constants
|
// constants
|
||||||
@ -22,7 +22,21 @@ export const NavbarIssueBoardView = observer(() => {
|
|||||||
|
|
||||||
const handleCurrentBoardView = (boardView: TIssueBoardKeys) => {
|
const handleCurrentBoardView = (boardView: TIssueBoardKeys) => {
|
||||||
store?.issue?.setCurrentIssueBoardView(boardView);
|
store?.issue?.setCurrentIssueBoardView(boardView);
|
||||||
router.replace(`/${workspace_slug}/${project_slug}?board=${boardView}`);
|
router.replace(
|
||||||
|
`/${workspace_slug}/${project_slug}?board=${boardView}${
|
||||||
|
store?.issue?.userSelectedLabels && store?.issue?.userSelectedLabels.length > 0
|
||||||
|
? `&labels=${store?.issue?.userSelectedLabels.join(",")}`
|
||||||
|
: ""
|
||||||
|
}${
|
||||||
|
store?.issue?.userSelectedPriorities && store?.issue?.userSelectedPriorities.length > 0
|
||||||
|
? `&priorities=${store?.issue?.userSelectedPriorities.join(",")}`
|
||||||
|
: ""
|
||||||
|
}${
|
||||||
|
store?.issue?.userSelectedStates && store?.issue?.userSelectedStates.length > 0
|
||||||
|
? `&states=${store?.issue?.userSelectedStates.join(",")}`
|
||||||
|
: ""
|
||||||
|
}`
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -35,10 +49,10 @@ export const NavbarIssueBoardView = observer(() => {
|
|||||||
store?.project?.workspaceProjectSettings?.views[_view?.key] && (
|
store?.project?.workspaceProjectSettings?.views[_view?.key] && (
|
||||||
<div
|
<div
|
||||||
key={_view?.key}
|
key={_view?.key}
|
||||||
className={`w-[28px] h-[28px] flex justify-center items-center rounded-sm cursor-pointer text-gray-500 ${
|
className={`w-[28px] h-[28px] flex justify-center items-center rounded-sm cursor-pointer ${
|
||||||
_view?.key === store?.issue?.currentIssueBoardView
|
_view?.key === store?.issue?.currentIssueBoardView
|
||||||
? `bg-gray-200/60 text-gray-800`
|
? `bg-custom-background-200 text-custom-text-200`
|
||||||
: `hover:bg-gray-200/60 text-gray-600`
|
: `hover:bg-custom-background-200 text-custom-text-300`
|
||||||
}`}
|
}`}
|
||||||
onClick={() => handleCurrentBoardView(_view?.key)}
|
onClick={() => handleCurrentBoardView(_view?.key)}
|
||||||
title={_view?.title}
|
title={_view?.title}
|
||||||
|
@ -1,13 +1,119 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter, usePathname } from "next/navigation";
|
||||||
|
|
||||||
|
import { ChevronDownIcon } from "@heroicons/react/20/solid";
|
||||||
|
|
||||||
// mobx react lite
|
// mobx react lite
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
// mobx
|
// mobx
|
||||||
import { useMobxStore } from "lib/mobx/store-provider";
|
import { useMobxStore } from "lib/mobx/store-provider";
|
||||||
import { RootStore } from "store/root";
|
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(() => {
|
export const NavbarIssueFilter = observer(() => {
|
||||||
const store: RootStore = useMobxStore();
|
const store: RootStore = useMobxStore();
|
||||||
|
|
||||||
return <div>Filter</div>;
|
const router = useRouter();
|
||||||
|
const pathName = usePathname();
|
||||||
|
|
||||||
|
const handleOnSelect = (key: "states" | "labels" | "priorities", value: string) => {
|
||||||
|
if (key === "states") {
|
||||||
|
store.issue.userSelectedStates = store.issue.userSelectedStates.includes(value)
|
||||||
|
? store.issue.userSelectedStates.filter((s) => s !== value)
|
||||||
|
: [...store.issue.userSelectedStates, value];
|
||||||
|
} else if (key === "labels") {
|
||||||
|
store.issue.userSelectedLabels = store.issue.userSelectedLabels.includes(value)
|
||||||
|
? store.issue.userSelectedLabels.filter((l) => l !== value)
|
||||||
|
: [...store.issue.userSelectedLabels, value];
|
||||||
|
} else if (key === "priorities") {
|
||||||
|
store.issue.userSelectedPriorities = store.issue.userSelectedPriorities.includes(value)
|
||||||
|
? store.issue.userSelectedPriorities.filter((p) => p !== value)
|
||||||
|
: [...store.issue.userSelectedPriorities, value];
|
||||||
|
}
|
||||||
|
|
||||||
|
const paramsCommaSeparated = `${`board=${store.issue.currentIssueBoardView || "list"}`}${
|
||||||
|
store.issue.userSelectedPriorities.length > 0 ? `&priorities=${store.issue.userSelectedPriorities.join(",")}` : ""
|
||||||
|
}${store.issue.userSelectedStates.length > 0 ? `&states=${store.issue.userSelectedStates.join(",")}` : ""}${
|
||||||
|
store.issue.userSelectedLabels.length > 0 ? `&labels=${store.issue.userSelectedLabels.join(",")}` : ""
|
||||||
|
}`;
|
||||||
|
|
||||||
|
router.replace(`${pathName}?${paramsCommaSeparated}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dropdown
|
||||||
|
button={
|
||||||
|
<>
|
||||||
|
<span>Filters</span>
|
||||||
|
<ChevronDownIcon className="h-3 w-3" aria-hidden="true" />
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
display: "Priority",
|
||||||
|
children: PRIORITIES.map((priority) => ({
|
||||||
|
display: (
|
||||||
|
<span className="capitalize flex items-center gap-x-2">
|
||||||
|
<span className="material-symbols-rounded text-[14px]">
|
||||||
|
{priority === "urgent"
|
||||||
|
? "error"
|
||||||
|
: priority === "high"
|
||||||
|
? "signal_cellular_alt"
|
||||||
|
: priority === "medium"
|
||||||
|
? "signal_cellular_alt_2_bar"
|
||||||
|
: "signal_cellular_alt_1_bar"}
|
||||||
|
</span>
|
||||||
|
{priority}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
onClick: () => handleOnSelect("priorities", priority),
|
||||||
|
isSelected: store.issue.userSelectedPriorities.includes(priority),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
display: "State",
|
||||||
|
children: (store.issue.states || []).map((state) => {
|
||||||
|
const stateGroup = issueGroupFilter(state.group);
|
||||||
|
|
||||||
|
return {
|
||||||
|
display: (
|
||||||
|
<span className="capitalize flex items-center gap-x-2">
|
||||||
|
{stateGroup && <stateGroup.icon />}
|
||||||
|
{state.name}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
onClick: () => handleOnSelect("states", state.id),
|
||||||
|
isSelected: store.issue.userSelectedStates.includes(state.id),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
display: "Labels",
|
||||||
|
children: [...(store.issue.labels || [])].map((label) => ({
|
||||||
|
display: (
|
||||||
|
<span className="capitalize flex items-center gap-x-2">
|
||||||
|
<span
|
||||||
|
className="w-3 h-3 rounded-full"
|
||||||
|
style={{
|
||||||
|
backgroundColor: label.color || "#000",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{label.name}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
onClick: () => handleOnSelect("labels", label.id),
|
||||||
|
isSelected: store.issue.userSelectedLabels.includes(label.id),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
@ -1,5 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
// react
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
// next theme
|
||||||
|
import { useTheme } from "next-themes";
|
||||||
|
|
||||||
// mobx react lite
|
// mobx react lite
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
// mobx
|
// mobx
|
||||||
@ -9,16 +15,24 @@ import { RootStore } from "store/root";
|
|||||||
export const NavbarTheme = observer(() => {
|
export const NavbarTheme = observer(() => {
|
||||||
const store: RootStore = useMobxStore();
|
const store: RootStore = useMobxStore();
|
||||||
|
|
||||||
|
const { setTheme, theme } = useTheme();
|
||||||
|
|
||||||
const handleTheme = () => {
|
const handleTheme = () => {
|
||||||
store?.theme?.setTheme(store?.theme?.theme === "light" ? "dark" : "light");
|
store?.theme?.setTheme(store?.theme?.theme === "light" ? "dark" : "light");
|
||||||
|
setTheme(theme === "light" ? "dark" : "light");
|
||||||
|
document?.documentElement.setAttribute("data-theme", theme ?? store?.theme?.theme);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document?.documentElement.setAttribute("data-theme", theme ?? store?.theme?.theme);
|
||||||
|
}, [theme, store]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="relative w-[28px] h-[28px] flex justify-center items-center rounded-sm cursor-pointer bg-gray-100 hover:bg-gray-200 hover:bg-gray-200/60 text-gray-600 transition-all"
|
className="relative w-[28px] h-[28px] flex justify-center rounded-md items-center rounded-sm cursor-pointer bg-custom-background-100 hover:bg-custom-background-200 hover:bg-custom-background-200/60 text-custom-text-100 transition-all"
|
||||||
onClick={handleTheme}
|
onClick={handleTheme}
|
||||||
>
|
>
|
||||||
{store?.theme?.theme === "light" ? (
|
{theme === "light" ? (
|
||||||
<span className={`material-symbols-rounded text-[18px]`}>dark_mode</span>
|
<span className={`material-symbols-rounded text-[18px]`}>dark_mode</span>
|
||||||
) : (
|
) : (
|
||||||
<span className={`material-symbols-rounded text-[18px]`}>light_mode</span>
|
<span className={`material-symbols-rounded text-[18px]`}>light_mode</span>
|
||||||
|
126
apps/space/components/issues/peek-overview/add-comment.tsx
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
import React, { useRef } from "react";
|
||||||
|
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import { useMobxStore } from "lib/mobx/store-provider";
|
||||||
|
|
||||||
|
// react-hook-form
|
||||||
|
import { useForm, Controller } from "react-hook-form";
|
||||||
|
|
||||||
|
// hooks
|
||||||
|
import useToast from "hooks/use-toast";
|
||||||
|
|
||||||
|
// ui
|
||||||
|
import { SecondaryButton } from "components/ui";
|
||||||
|
|
||||||
|
// types
|
||||||
|
import { Comment } from "store/types";
|
||||||
|
|
||||||
|
// components
|
||||||
|
import Tiptap, { ITiptapRichTextEditor } from "components/tiptap";
|
||||||
|
|
||||||
|
const TiptapEditor = React.forwardRef<ITiptapRichTextEditor, ITiptapRichTextEditor>((props, ref) => (
|
||||||
|
<Tiptap {...props} forwardedRef={ref} />
|
||||||
|
));
|
||||||
|
|
||||||
|
TiptapEditor.displayName = "TiptapEditor";
|
||||||
|
|
||||||
|
const defaultValues: Partial<Comment> = {
|
||||||
|
comment_json: "",
|
||||||
|
comment_html: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
issueId: string | null;
|
||||||
|
disabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AddComment: React.FC<Props> = observer((props) => {
|
||||||
|
const { issueId, disabled = false } = props;
|
||||||
|
|
||||||
|
const {
|
||||||
|
handleSubmit,
|
||||||
|
control,
|
||||||
|
setValue,
|
||||||
|
watch,
|
||||||
|
formState: { isSubmitting },
|
||||||
|
reset,
|
||||||
|
} = useForm<Comment>({ defaultValues });
|
||||||
|
|
||||||
|
const routerParams = useParams();
|
||||||
|
const { workspace_slug, project_slug } = routerParams as { workspace_slug: string; project_slug: string };
|
||||||
|
|
||||||
|
const { issue: issueStore, user: userStore } = useMobxStore();
|
||||||
|
|
||||||
|
const editorRef = useRef<any>(null);
|
||||||
|
|
||||||
|
const { setToastAlert } = useToast();
|
||||||
|
|
||||||
|
const onSubmit = async (formData: Comment) => {
|
||||||
|
if (
|
||||||
|
!workspace_slug ||
|
||||||
|
!project_slug ||
|
||||||
|
!issueId ||
|
||||||
|
isSubmitting ||
|
||||||
|
!formData.comment_html ||
|
||||||
|
!formData.comment_json
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
|
||||||
|
await issueStore
|
||||||
|
.createIssueCommentAsync(workspace_slug, project_slug, issueId, formData)
|
||||||
|
.then(() => {
|
||||||
|
reset(defaultValues);
|
||||||
|
editorRef.current?.clearEditor();
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setToastAlert({
|
||||||
|
type: "error",
|
||||||
|
title: "Error!",
|
||||||
|
message: "Comment could not be posted. Please try again.",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="issue-comments-section">
|
||||||
|
<Controller
|
||||||
|
name="comment_html"
|
||||||
|
control={control}
|
||||||
|
render={({ field: { value, onChange } }) => (
|
||||||
|
<TiptapEditor
|
||||||
|
workspaceSlug={workspace_slug as string}
|
||||||
|
ref={editorRef}
|
||||||
|
value={
|
||||||
|
!value || value === "" || (typeof value === "object" && Object.keys(value).length === 0)
|
||||||
|
? watch("comment_html")
|
||||||
|
: value
|
||||||
|
}
|
||||||
|
customClassName="p-3 min-h-[50px] shadow-sm"
|
||||||
|
debouncedUpdatesEnabled={false}
|
||||||
|
onChange={(comment_json: Object, comment_html: string) => {
|
||||||
|
onChange(comment_html);
|
||||||
|
setValue("comment_json", comment_json);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SecondaryButton
|
||||||
|
onClick={(e) => {
|
||||||
|
userStore.requiredLogin(() => {
|
||||||
|
handleSubmit(onSubmit)(e);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting || disabled}
|
||||||
|
className="mt-2"
|
||||||
|
>
|
||||||
|
{isSubmitting ? "Adding..." : "Comment"}
|
||||||
|
</SecondaryButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
@ -0,0 +1,228 @@
|
|||||||
|
import React, { useEffect, useState, useRef } from "react";
|
||||||
|
|
||||||
|
// react-hook-form
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
|
||||||
|
// mobx
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import { useMobxStore } from "lib/mobx/store-provider";
|
||||||
|
|
||||||
|
// headless ui
|
||||||
|
import { Menu, Transition } from "@headlessui/react";
|
||||||
|
|
||||||
|
// icons
|
||||||
|
import { ChatBubbleLeftEllipsisIcon, CheckIcon, XMarkIcon, EllipsisVerticalIcon } from "@heroicons/react/24/outline";
|
||||||
|
|
||||||
|
// helpers
|
||||||
|
import { timeAgo } from "helpers/date-time.helper";
|
||||||
|
// types
|
||||||
|
import { Comment } from "store/types";
|
||||||
|
import Tiptap, { ITiptapRichTextEditor } from "components/tiptap";
|
||||||
|
|
||||||
|
const TiptapEditor = React.forwardRef<ITiptapRichTextEditor, ITiptapRichTextEditor>((props, ref) => (
|
||||||
|
<Tiptap {...props} forwardedRef={ref} />
|
||||||
|
));
|
||||||
|
|
||||||
|
TiptapEditor.displayName = "TiptapEditor";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
workspaceSlug: string;
|
||||||
|
comment: Comment;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CommentCard: React.FC<Props> = observer((props) => {
|
||||||
|
const { comment, workspaceSlug } = props;
|
||||||
|
|
||||||
|
const { user: userStore, issue: issueStore } = useMobxStore();
|
||||||
|
|
||||||
|
const editorRef = useRef<any>(null);
|
||||||
|
const showEditorRef = useRef<any>(null);
|
||||||
|
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
|
||||||
|
const {
|
||||||
|
formState: { isSubmitting },
|
||||||
|
handleSubmit,
|
||||||
|
setFocus,
|
||||||
|
watch,
|
||||||
|
setValue,
|
||||||
|
} = useForm<Comment>({
|
||||||
|
defaultValues: comment,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!workspaceSlug || !issueStore.activePeekOverviewIssueId) return;
|
||||||
|
|
||||||
|
await issueStore.deleteIssueCommentAsync(
|
||||||
|
workspaceSlug,
|
||||||
|
comment.project,
|
||||||
|
issueStore.activePeekOverviewIssueId,
|
||||||
|
comment.id
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCommentUpdate = async (formData: Comment) => {
|
||||||
|
if (!workspaceSlug || !issueStore.activePeekOverviewIssueId) return;
|
||||||
|
|
||||||
|
const response = await issueStore.updateIssueCommentAsync(
|
||||||
|
workspaceSlug,
|
||||||
|
comment.project,
|
||||||
|
issueStore.activePeekOverviewIssueId,
|
||||||
|
comment.id,
|
||||||
|
formData
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response) {
|
||||||
|
editorRef.current?.setEditorValue(response.comment_html);
|
||||||
|
showEditorRef.current?.setEditorValue(response.comment_html);
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsEditing(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
isEditing && setFocus("comment_html");
|
||||||
|
}, [isEditing, setFocus]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex items-start space-x-3">
|
||||||
|
<div className="relative px-1">
|
||||||
|
{comment.actor_detail.avatar && comment.actor_detail.avatar !== "" ? (
|
||||||
|
<img
|
||||||
|
src={comment.actor_detail.avatar}
|
||||||
|
alt={
|
||||||
|
comment.actor_detail.is_bot ? comment.actor_detail.first_name + " Bot" : comment.actor_detail.display_name
|
||||||
|
}
|
||||||
|
height={30}
|
||||||
|
width={30}
|
||||||
|
className="grid h-7 w-7 place-items-center rounded-full border-2 border-custom-border-200"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className={`grid h-7 w-7 place-items-center rounded-full border-2 border-white bg-gray-500 text-white`}>
|
||||||
|
{comment.actor_detail.is_bot
|
||||||
|
? comment.actor_detail.first_name.charAt(0)
|
||||||
|
: comment.actor_detail.display_name.charAt(0)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<span className="absolute -bottom-0.5 -right-1 rounded-tl bg-custom-background-80 px-0.5 py-px">
|
||||||
|
<ChatBubbleLeftEllipsisIcon className="h-3.5 w-3.5 text-custom-text-200" aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs">
|
||||||
|
{comment.actor_detail.is_bot ? comment.actor_detail.first_name + " Bot" : comment.actor_detail.display_name}
|
||||||
|
</div>
|
||||||
|
<p className="mt-0.5 text-xs text-custom-text-200">
|
||||||
|
<>Commented {timeAgo(comment.created_at)}</>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="issue-comments-section p-0">
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit(handleCommentUpdate)}
|
||||||
|
className={`flex-col gap-2 ${isEditing ? "flex" : "hidden"}`}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<TiptapEditor
|
||||||
|
workspaceSlug={workspaceSlug as string}
|
||||||
|
ref={editorRef}
|
||||||
|
value={watch("comment_html")}
|
||||||
|
debouncedUpdatesEnabled={false}
|
||||||
|
customClassName="min-h-[50px] p-3 shadow-sm"
|
||||||
|
onChange={(comment_json: Object, comment_html: string) => {
|
||||||
|
setValue("comment_json", comment_json);
|
||||||
|
setValue("comment_html", comment_html);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1 self-end">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="group rounded border border-green-500 bg-green-500/20 p-2 shadow-md duration-300 hover:bg-green-500"
|
||||||
|
>
|
||||||
|
<CheckIcon className="h-3 w-3 text-green-500 duration-300 group-hover:text-white" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="group rounded border border-red-500 bg-red-500/20 p-2 shadow-md duration-300 hover:bg-red-500"
|
||||||
|
onClick={() => setIsEditing(false)}
|
||||||
|
>
|
||||||
|
<XMarkIcon className="h-3 w-3 text-red-500 duration-300 group-hover:text-white" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<div className={`${isEditing ? "hidden" : ""}`}>
|
||||||
|
<TiptapEditor
|
||||||
|
workspaceSlug={workspaceSlug as string}
|
||||||
|
ref={showEditorRef}
|
||||||
|
value={comment.comment_html}
|
||||||
|
editable={false}
|
||||||
|
customClassName="text-xs border border-custom-border-200 bg-custom-background-100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{userStore?.currentUser?.id === comment?.actor_detail?.id && (
|
||||||
|
<Menu as="div" className="relative w-min text-left">
|
||||||
|
<Menu.Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {}}
|
||||||
|
className="relative grid place-items-center rounded p-1 text-custom-text-200 hover:text-custom-text-100 outline-none cursor-pointer hover:bg-custom-background-80"
|
||||||
|
>
|
||||||
|
<EllipsisVerticalIcon className="h-5 w-5 text-custom-text-200 duration-300" />
|
||||||
|
</Menu.Button>
|
||||||
|
|
||||||
|
<Transition
|
||||||
|
as={React.Fragment}
|
||||||
|
enter="transition ease-out duration-100"
|
||||||
|
enterFrom="transform opacity-0 scale-95"
|
||||||
|
enterTo="transform opacity-100 scale-100"
|
||||||
|
leave="transition ease-in duration-75"
|
||||||
|
leaveFrom="transform opacity-100 scale-100"
|
||||||
|
leaveTo="transform opacity-0 scale-95"
|
||||||
|
>
|
||||||
|
<Menu.Items className="absolute z-10 overflow-y-scroll whitespace-nowrap rounded-md max-h-36 border right-0 origin-top-right mt-1 overflow-auto min-w-[8rem] border-custom-border-300 p-1 text-xs shadow-lg focus:outline-none bg-custom-background-90">
|
||||||
|
<Menu.Item>
|
||||||
|
{({ active }) => (
|
||||||
|
<div className="py-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setIsEditing(true);
|
||||||
|
}}
|
||||||
|
className={`w-full select-none truncate rounded px-1 py-1.5 text-left text-custom-text-200 hover:bg-custom-background-80 ${
|
||||||
|
active ? "bg-custom-background-80" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Item>
|
||||||
|
{({ active }) => (
|
||||||
|
<div className="py-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
handleDelete();
|
||||||
|
}}
|
||||||
|
className={`w-full select-none truncate rounded px-1 py-1.5 text-left text-custom-text-200 hover:bg-custom-background-80 ${
|
||||||
|
active ? "bg-custom-background-80" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Menu.Item>
|
||||||
|
</Menu.Items>
|
||||||
|
</Transition>
|
||||||
|
</Menu>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
@ -0,0 +1,70 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
// mobx
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import { useMobxStore } from "lib/mobx/store-provider";
|
||||||
|
|
||||||
|
import {
|
||||||
|
PeekOverviewHeader,
|
||||||
|
PeekOverviewIssueActivity,
|
||||||
|
PeekOverviewIssueDetails,
|
||||||
|
PeekOverviewIssueProperties,
|
||||||
|
TPeekOverviewModes,
|
||||||
|
} from "components/issues/peek-overview";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
issueId: string;
|
||||||
|
workspaceSlug: string;
|
||||||
|
projectId: string;
|
||||||
|
handleClose: () => void;
|
||||||
|
mode: TPeekOverviewModes;
|
||||||
|
setMode: (mode: TPeekOverviewModes) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FullScreenPeekView: React.FC<Props> = observer((props) => {
|
||||||
|
const { handleClose, issueId, mode, setMode, workspaceSlug , projectId } = props;
|
||||||
|
|
||||||
|
const { issue: issueStore } = useMobxStore();
|
||||||
|
|
||||||
|
const issue = issueStore.issue_detail[issueId]?.issue;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!workspaceSlug || !projectId || !issueId) return;
|
||||||
|
|
||||||
|
issueStore.getIssueByIdAsync(workspaceSlug, projectId, issueId);
|
||||||
|
}, [workspaceSlug, projectId, issueId, issueStore]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full w-full grid grid-cols-10 divide-x divide-custom-border-200 overflow-hidden">
|
||||||
|
<div className="h-full w-full flex flex-col col-span-7 overflow-hidden">
|
||||||
|
<div className="w-full p-5">
|
||||||
|
<PeekOverviewHeader
|
||||||
|
handleClose={handleClose}
|
||||||
|
issue={issue}
|
||||||
|
mode={mode}
|
||||||
|
setMode={setMode}
|
||||||
|
workspaceSlug={workspaceSlug}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="h-full w-full px-6 overflow-y-auto">
|
||||||
|
{/* issue title and description */}
|
||||||
|
<div className="w-full">
|
||||||
|
<PeekOverviewIssueDetails issue={issue} />
|
||||||
|
</div>
|
||||||
|
{/* divider */}
|
||||||
|
<div className="h-[1] w-full border-t border-custom-border-200 my-5" />
|
||||||
|
{/* issue activity/comments */}
|
||||||
|
<div className="w-full">
|
||||||
|
<PeekOverviewIssueActivity workspaceSlug={workspaceSlug} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-3 h-full w-full overflow-y-auto">
|
||||||
|
{/* issue properties */}
|
||||||
|
<div className="w-full px-6 py-5">
|
||||||
|
<PeekOverviewIssueProperties issue={issue} mode="full" workspaceSlug={workspaceSlug} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
99
apps/space/components/issues/peek-overview/header.tsx
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
// hooks
|
||||||
|
import useToast from "hooks/use-toast";
|
||||||
|
// ui
|
||||||
|
import { Icon } from "components/ui";
|
||||||
|
// helpers
|
||||||
|
import { copyTextToClipboard } from "helpers/string.helper";
|
||||||
|
// types
|
||||||
|
import { TPeekOverviewModes } from "./layout";
|
||||||
|
import { ArrowRightAlt, CloseFullscreen, East, OpenInFull } from "@mui/icons-material";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
handleClose: () => void;
|
||||||
|
issue: any;
|
||||||
|
mode: TPeekOverviewModes;
|
||||||
|
setMode: (mode: TPeekOverviewModes) => void;
|
||||||
|
workspaceSlug: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const peekModes: {
|
||||||
|
key: TPeekOverviewModes;
|
||||||
|
icon: string;
|
||||||
|
label: string;
|
||||||
|
}[] = [
|
||||||
|
{ key: "side", icon: "side_navigation", label: "Side Peek" },
|
||||||
|
{
|
||||||
|
key: "modal",
|
||||||
|
icon: "dialogs",
|
||||||
|
label: "Modal Peek",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "full",
|
||||||
|
icon: "nearby",
|
||||||
|
label: "Full Screen Peek",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const PeekOverviewHeader: React.FC<Props> = ({ issue, handleClose, mode, setMode, workspaceSlug }) => {
|
||||||
|
const { setToastAlert } = useToast();
|
||||||
|
|
||||||
|
const handleCopyLink = () => {
|
||||||
|
const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
|
||||||
|
|
||||||
|
copyTextToClipboard(`${originURL}/${workspaceSlug}/projects/${issue.project}/`).then(() => {
|
||||||
|
setToastAlert({
|
||||||
|
type: "success",
|
||||||
|
title: "Link copied!",
|
||||||
|
message: "Issue link copied to clipboard",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{mode === "side" && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
handleClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<East
|
||||||
|
sx={{
|
||||||
|
fontSize: "14px",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{mode === "modal" || mode === "full" ? (
|
||||||
|
<button type="button" onClick={() => setMode("side")}>
|
||||||
|
<CloseFullscreen
|
||||||
|
sx={{
|
||||||
|
fontSize: "14px",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button type="button" onClick={() => setMode("modal")}>
|
||||||
|
<OpenInFull
|
||||||
|
sx={{
|
||||||
|
fontSize: "14px",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button type="button" className={`grid place-items-center ${mode === "full" ? "rotate-45" : ""}`}>
|
||||||
|
<Icon iconName={peekModes.find((m) => m.key === mode)?.icon ?? ""} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{(mode === "side" || mode === "modal") && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button type="button" onClick={handleCopyLink} className="-rotate-45">
|
||||||
|
<Icon iconName="link" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
12
apps/space/components/issues/peek-overview/index.ts
Normal 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";
|
@ -0,0 +1,42 @@
|
|||||||
|
import React, { useEffect } from "react";
|
||||||
|
// mobx
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import { useMobxStore } from "lib/mobx/store-provider";
|
||||||
|
|
||||||
|
import { CommentCard, AddComment } from "components/issues/peek-overview";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
workspaceSlug: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PeekOverviewIssueActivity: React.FC<Props> = observer((props) => {
|
||||||
|
const { workspaceSlug } = props;
|
||||||
|
|
||||||
|
const { issue: issueStore, user: userStore } = useMobxStore();
|
||||||
|
|
||||||
|
const issueId = issueStore?.activePeekOverviewIssueId;
|
||||||
|
const comments = issueStore?.issue_detail[issueId ?? ""]?.comments ?? [];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (userStore.currentUser) return;
|
||||||
|
|
||||||
|
userStore.getUserAsync();
|
||||||
|
}, [userStore]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium">Activity</h4>
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<div className="space-y-4">
|
||||||
|
{comments.map((comment) => (
|
||||||
|
<CommentCard comment={comment} workspaceSlug={workspaceSlug} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-4">
|
||||||
|
<AddComment disabled={!userStore.currentUser} issueId={issueId} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
18
apps/space/components/issues/peek-overview/issue-details.tsx
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
// components
|
||||||
|
import { IssueReactions } from "components/issues/peek-overview";
|
||||||
|
// types
|
||||||
|
import { IIssue } from "store/types";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
issue: IIssue;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PeekOverviewIssueDetails: React.FC<Props> = ({ issue }) => (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h6 className="font-medium text-custom-text-200">
|
||||||
|
{issue.project_detail.identifier}-{issue.sequence_id}
|
||||||
|
</h6>
|
||||||
|
<h4 className="break-words text-2xl font-semibold">{issue.name}</h4>
|
||||||
|
<IssueReactions />
|
||||||
|
</div>
|
||||||
|
);
|
@ -0,0 +1,94 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// react
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
// next
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
|
||||||
|
// mobx
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import { useMobxStore } from "lib/mobx/store-provider";
|
||||||
|
|
||||||
|
// helpers
|
||||||
|
import { groupReactions, renderEmoji } from "helpers/emoji.helper";
|
||||||
|
|
||||||
|
// ui
|
||||||
|
import { ReactionSelector } from "components/ui";
|
||||||
|
|
||||||
|
export const IssueEmojiReactions: React.FC = observer(() => {
|
||||||
|
const routerParams = useParams();
|
||||||
|
|
||||||
|
const { workspace_slug, project_slug } = routerParams as { workspace_slug: string; project_slug: string };
|
||||||
|
|
||||||
|
const { user: userStore, issue: issueStore } = useMobxStore();
|
||||||
|
|
||||||
|
const user = userStore?.currentUser;
|
||||||
|
const issueId = issueStore.activePeekOverviewIssueId;
|
||||||
|
|
||||||
|
const reactions = issueId ? issueStore.issue_detail[issueId]?.reactions || [] : [];
|
||||||
|
const groupedReactions = groupReactions(reactions, "reaction");
|
||||||
|
|
||||||
|
const handleReactionClick = (reactionHexa: string) => {
|
||||||
|
if (!workspace_slug || !project_slug || !issueId) return;
|
||||||
|
|
||||||
|
const userReaction = reactions?.find((r) => r.created_by === user?.id && r.reaction === reactionHexa);
|
||||||
|
|
||||||
|
if (userReaction)
|
||||||
|
issueStore.deleteIssueReactionAsync(workspace_slug, userReaction.project, userReaction.issue, reactionHexa);
|
||||||
|
else
|
||||||
|
issueStore.createIssueReactionAsync(workspace_slug, project_slug, issueId, {
|
||||||
|
reaction: reactionHexa,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user) return;
|
||||||
|
|
||||||
|
userStore.getUserAsync();
|
||||||
|
}, [user, userStore]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ReactionSelector
|
||||||
|
onSelect={(value) => {
|
||||||
|
userStore.requiredLogin(() => {
|
||||||
|
handleReactionClick(value);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{Object.keys(groupedReactions || {}).map(
|
||||||
|
(reaction) =>
|
||||||
|
groupedReactions?.[reaction]?.length &&
|
||||||
|
groupedReactions[reaction].length > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
userStore.requiredLogin(() => {
|
||||||
|
handleReactionClick(reaction);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
key={reaction}
|
||||||
|
className={`flex items-center gap-1 text-custom-text-100 text-sm h-full px-2 py-1 rounded-md border ${
|
||||||
|
reactions?.some((r) => r.actor === user?.id && r.reaction === reaction)
|
||||||
|
? "bg-custom-primary-100/10 border-custom-primary-100"
|
||||||
|
: "bg-custom-background-80 border-transparent"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span>{renderEmoji(reaction)}</span>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
reactions?.some((r) => r.actor === user?.id && r.reaction === reaction)
|
||||||
|
? "text-custom-primary-100"
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{groupedReactions?.[reaction].length}{" "}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
149
apps/space/components/issues/peek-overview/issue-properties.tsx
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
// headless ui
|
||||||
|
import { Disclosure } from "@headlessui/react";
|
||||||
|
// import { getStateGroupIcon } from "components/icons";
|
||||||
|
// hooks
|
||||||
|
import useToast from "hooks/use-toast";
|
||||||
|
// components
|
||||||
|
import { TPeekOverviewModes } from "components/issues/peek-overview";
|
||||||
|
// icons
|
||||||
|
import { Icon } from "components/ui";
|
||||||
|
import { copyTextToClipboard, addSpaceIfCamelCase } from "helpers/string.helper";
|
||||||
|
|
||||||
|
// types
|
||||||
|
import { IIssue } from "store/types";
|
||||||
|
|
||||||
|
// constants
|
||||||
|
import { issueGroupFilter, issuePriorityFilter } from "constants/data";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { renderDateFormat } from "constants/helpers";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
issue: IIssue;
|
||||||
|
mode: TPeekOverviewModes;
|
||||||
|
workspaceSlug: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const validDate = (date: any, state: string): string => {
|
||||||
|
if (date === null || ["backlog", "unstarted", "cancelled"].includes(state))
|
||||||
|
return `bg-gray-500/10 text-gray-500 border-gray-500/50`;
|
||||||
|
else {
|
||||||
|
const today = new Date();
|
||||||
|
const dueDate = new Date(date);
|
||||||
|
|
||||||
|
if (dueDate < today) return `bg-red-500/10 text-red-500 border-red-500/50`;
|
||||||
|
else return `bg-green-500/10 text-green-500 border-green-500/50`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PeekOverviewIssueProperties: React.FC<Props> = ({ issue, mode, workspaceSlug }) => {
|
||||||
|
const { setToastAlert } = useToast();
|
||||||
|
|
||||||
|
const startDate = issue.start_date;
|
||||||
|
const targetDate = issue.target_date;
|
||||||
|
|
||||||
|
const minDate = startDate ? new Date(startDate) : null;
|
||||||
|
minDate?.setDate(minDate.getDate());
|
||||||
|
|
||||||
|
const maxDate = targetDate ? new Date(targetDate) : null;
|
||||||
|
maxDate?.setDate(maxDate.getDate());
|
||||||
|
|
||||||
|
const state = issue.state_detail;
|
||||||
|
const stateGroup = issueGroupFilter(state.group);
|
||||||
|
|
||||||
|
const priority = issue.priority ? issuePriorityFilter(issue.priority) : null;
|
||||||
|
|
||||||
|
const handleCopyLink = () => {
|
||||||
|
const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
|
||||||
|
|
||||||
|
copyTextToClipboard(`${originURL}/${workspaceSlug}/projects/${issue.project}/issues/${issue.id}`).then(() => {
|
||||||
|
setToastAlert({
|
||||||
|
type: "success",
|
||||||
|
title: "Link copied!",
|
||||||
|
message: "Issue link copied to clipboard",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={mode === "full" ? "divide-y divide-custom-border-200" : ""}>
|
||||||
|
{mode === "full" && (
|
||||||
|
<div className="flex justify-between gap-2 pb-3">
|
||||||
|
<h6 className="flex items-center gap-2 font-medium">
|
||||||
|
{/* {getStateGroupIcon(issue.state_detail.group, "16", "16", issue.state_detail.color)} */}
|
||||||
|
{issue.project_detail.identifier}-{issue.sequence_id}
|
||||||
|
</h6>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button type="button" onClick={handleCopyLink} className="-rotate-45">
|
||||||
|
<Icon iconName="link" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={`space-y-4 ${mode === "full" ? "pt-3" : ""}`}>
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<div className="flex-shrink-0 w-1/4 flex items-center gap-2 font-medium">
|
||||||
|
<Icon iconName="radio_button_checked" className="!text-base flex-shrink-0" />
|
||||||
|
<span className="flex-grow truncate">State</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-3/4">
|
||||||
|
{stateGroup && (
|
||||||
|
<div className="inline-flex bg-custom-background-80 text-sm rounded px-2.5 py-0.5">
|
||||||
|
<div className="flex items-center gap-1.5 text-left text-custom-text-100">
|
||||||
|
<stateGroup.icon />
|
||||||
|
{addSpaceIfCamelCase(state?.name ?? "")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<div className="flex-shrink-0 w-1/4 flex items-center gap-2 font-medium">
|
||||||
|
<Icon iconName="signal_cellular_alt" className="!text-base flex-shrink-0" />
|
||||||
|
<span className="flex-grow truncate">Priority</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-3/4">
|
||||||
|
<div
|
||||||
|
className={`inline-flex items-center gap-1.5 text-left text-sm capitalize rounded px-2.5 py-0.5 ${
|
||||||
|
priority?.key === "urgent"
|
||||||
|
? "border-red-500/20 bg-red-500/20 text-red-500"
|
||||||
|
: priority?.key === "high"
|
||||||
|
? "border-orange-500/20 bg-orange-500/20 text-orange-500"
|
||||||
|
: priority?.key === "medium"
|
||||||
|
? "border-yellow-500/20 bg-yellow-500/20 text-yellow-500"
|
||||||
|
: priority?.key === "low"
|
||||||
|
? "border-green-500/20 bg-green-500/20 text-green-500"
|
||||||
|
: "bg-custom-background-80 border-custom-border-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{priority && (
|
||||||
|
<span className="grid place-items-center -my-1">
|
||||||
|
<Icon iconName={priority?.icon!} />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span>{priority?.title ?? "None"}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<div className="flex-shrink-0 w-1/4 flex items-center gap-2 font-medium">
|
||||||
|
<Icon iconName="calendar_today" className="!text-base flex-shrink-0" />
|
||||||
|
<span className="flex-grow truncate">Due date</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{issue.target_date ? (
|
||||||
|
<div
|
||||||
|
className={`h-[24px] rounded-md flex px-2.5 py-1 items-center border border-custom-border-100 gap-1 text-custom-text-100 text-xs font-medium
|
||||||
|
${validDate(issue.target_date, state)}`}
|
||||||
|
>
|
||||||
|
{renderDateFormat(issue.target_date)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-custom-text-200">Empty</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,18 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// ui
|
||||||
|
import { IssueEmojiReactions, IssueVotes } from "components/issues/peek-overview";
|
||||||
|
|
||||||
|
export const IssueReactions: React.FC = () => (
|
||||||
|
<div className="flex gap-3 items-center mt-4">
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
<IssueVotes />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-0.5 h-8 bg-custom-background-200" />
|
||||||
|
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
<IssueEmojiReactions />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
@ -0,0 +1,102 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// react
|
||||||
|
import { useState, useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
// next
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
|
||||||
|
// mobx
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import { useMobxStore } from "lib/mobx/store-provider";
|
||||||
|
|
||||||
|
export const IssueVotes: React.FC = observer(() => {
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const routerParams = useParams();
|
||||||
|
|
||||||
|
const { workspace_slug, project_slug } = routerParams as { workspace_slug: string; project_slug: string };
|
||||||
|
|
||||||
|
const { user: userStore, issue: issueStore } = useMobxStore();
|
||||||
|
|
||||||
|
const user = userStore?.currentUser;
|
||||||
|
const issueId = issueStore.activePeekOverviewIssueId;
|
||||||
|
|
||||||
|
const votes = issueId ? issueStore.issue_detail[issueId]?.votes : [];
|
||||||
|
|
||||||
|
const upVoteCount = votes?.filter((vote) => vote.vote === 1).length || 0;
|
||||||
|
const downVoteCount = votes?.filter((vote) => vote.vote === -1).length || 0;
|
||||||
|
|
||||||
|
const isUpVotedByUser = votes?.some((vote) => vote.actor === user?.id && vote.vote === 1);
|
||||||
|
const isDownVotedByUser = votes?.some((vote) => vote.actor === user?.id && vote.vote === -1);
|
||||||
|
|
||||||
|
const handleVote = async (e: any, voteValue: 1 | -1) => {
|
||||||
|
if (!workspace_slug || !project_slug || !issueId) return;
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
|
||||||
|
const actionPerformed = votes?.find((vote) => vote.actor === user?.id && vote.vote === voteValue);
|
||||||
|
|
||||||
|
if (actionPerformed) await issueStore.deleteIssueVoteAsync(workspace_slug, project_slug, issueId);
|
||||||
|
else
|
||||||
|
await issueStore.createIssueVoteAsync(workspace_slug, project_slug, issueId, {
|
||||||
|
vote: voteValue,
|
||||||
|
});
|
||||||
|
|
||||||
|
setIsSubmitting(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user) return;
|
||||||
|
|
||||||
|
userStore.getUserAsync();
|
||||||
|
}, [user, userStore]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
{/* upvote button 👇 */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
onClick={(e) => {
|
||||||
|
userStore.requiredLogin(() => {
|
||||||
|
handleVote(e, 1);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className={`flex items-center justify-center overflow-hidden px-2 py-1 gap-x-1 border rounded focus:outline-none ${
|
||||||
|
isUpVotedByUser ? "border-custom-primary-200 text-custom-primary-200" : "border-custom-border-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<svg className="w-2.5" viewBox="0 0 8 10" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path
|
||||||
|
d="M3.44122 2.35988L0.952108 4.84898C0.848405 4.95268 0.718049 5.00574 0.561041 5.00815C0.404044 5.01054 0.271292 4.95749 0.162783 4.84898C0.0542613 4.74047 0 4.60891 0 4.45431C0 4.2997 0.0542613 4.16814 0.162783 4.05963L3.52908 0.693333C3.66448 0.557934 3.82245 0.490234 4.00297 0.490234C4.1835 0.490234 4.34147 0.557934 4.47687 0.693333L7.84316 4.05963C7.94688 4.16335 7.99994 4.2937 8.00233 4.4507C8.00474 4.60771 7.95169 4.74047 7.84316 4.84898C7.73466 4.95749 7.6031 5.01174 7.4485 5.01174C7.2939 5.01174 7.16235 4.95749 7.05384 4.84898L4.56473 2.35988V8.94848C4.56473 9.10787 4.51095 9.24135 4.4034 9.34891C4.29586 9.45646 4.16238 9.51023 4.00297 9.51023C3.84357 9.51023 3.71009 9.45646 3.60254 9.34891C3.495 9.24135 3.44122 9.10787 3.44122 8.94848V2.35988Z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span className="text-sm font-normal transition-opacity ease-in-out">{upVoteCount}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* downvote button 👇 */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
onClick={(e) => {
|
||||||
|
userStore.requiredLogin(() => {
|
||||||
|
handleVote(e, -1);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className={`flex items-center justify-center overflow-hidden px-2 py-1 gap-x-1 border rounded focus:outline-none ${
|
||||||
|
isDownVotedByUser ? "border-red-600 text-red-600" : "border-custom-border-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<svg className="w-2.5" viewBox="0 0 8 10" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path
|
||||||
|
d="M4.55878 7.6406L7.04789 5.15151C7.15159 5.04781 7.28195 4.99475 7.43896 4.99234C7.59596 4.98994 7.72871 5.043 7.83722 5.15151C7.94574 5.26002 8 5.39157 8 5.54617C8 5.70079 7.94574 5.83235 7.83722 5.94085L4.47092 9.30715C4.33552 9.44255 4.17755 9.51025 3.99703 9.51025C3.8165 9.51025 3.65853 9.44255 3.52313 9.30715L0.156836 5.94085C0.0531201 5.83714 6.34193e-05 5.70678 -0.00233364 5.54979C-0.00474262 5.39278 0.0483136 5.26002 0.156836 5.15151C0.265345 5.043 0.396898 4.98875 0.551497 4.98875C0.706097 4.98875 0.837651 5.043 0.946161 5.15151L3.43527 7.6406L3.43527 1.05201C3.43527 0.892613 3.48905 0.759136 3.5966 0.651576C3.70414 0.544028 3.83762 0.490253 3.99703 0.490253C4.15643 0.490253 4.28991 0.544028 4.39746 0.651576C4.505 0.759136 4.55878 0.892613 4.55878 1.05201L4.55878 7.6406Z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span className="text-sm font-normal transition-opacity ease-in-out">{downVoteCount}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
98
apps/space/components/issues/peek-overview/layout.tsx
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
|
||||||
|
// headless ui
|
||||||
|
import { Dialog, Transition } from "@headlessui/react";
|
||||||
|
import { FullScreenPeekView, SidePeekView } from "components/issues/peek-overview";
|
||||||
|
|
||||||
|
// types
|
||||||
|
import type { IIssue } from "store/types";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
issue: IIssue | null;
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
workspaceSlug: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TPeekOverviewModes = "side" | "modal" | "full";
|
||||||
|
|
||||||
|
export const IssuePeekOverview: React.FC<Props> = ({
|
||||||
|
issue,
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
workspaceSlug,
|
||||||
|
}) => {
|
||||||
|
const [peekOverviewMode, setPeekOverviewMode] = useState<TPeekOverviewModes>("side");
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
onClose();
|
||||||
|
setPeekOverviewMode("side");
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!issue || !isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Transition.Root show={isOpen} as={React.Fragment}>
|
||||||
|
<Dialog as="div" className="relative z-20" onClose={handleClose}>
|
||||||
|
{/* add backdrop conditionally */}
|
||||||
|
{(peekOverviewMode === "modal" || peekOverviewMode === "full") && (
|
||||||
|
<Transition.Child
|
||||||
|
as={React.Fragment}
|
||||||
|
enter="ease-out duration-300"
|
||||||
|
enterFrom="opacity-0"
|
||||||
|
enterTo="opacity-100"
|
||||||
|
leave="ease-in duration-200"
|
||||||
|
leaveFrom="opacity-100"
|
||||||
|
leaveTo="opacity-0"
|
||||||
|
>
|
||||||
|
<div className="fixed inset-0 bg-custom-backdrop bg-opacity-50 transition-opacity" />
|
||||||
|
</Transition.Child>
|
||||||
|
)}
|
||||||
|
<div className="fixed inset-0 z-20 overflow-y-auto">
|
||||||
|
<div className="relative h-full w-full">
|
||||||
|
<Transition.Child
|
||||||
|
as={React.Fragment}
|
||||||
|
enter="ease-out duration-300"
|
||||||
|
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||||
|
enterTo="opacity-100 translate-y-0 sm:scale-100"
|
||||||
|
leave="ease-in duration-200"
|
||||||
|
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||||
|
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||||
|
>
|
||||||
|
<Dialog.Panel
|
||||||
|
className={`absolute z-20 bg-custom-background-100 ${
|
||||||
|
peekOverviewMode === "side"
|
||||||
|
? "top-0 right-0 h-full w-1/2 shadow-custom-shadow-md"
|
||||||
|
: peekOverviewMode === "modal"
|
||||||
|
? "top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 h-[70%] w-3/5 rounded-lg shadow-custom-shadow-xl"
|
||||||
|
: "top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 h-[95%] w-[95%] rounded-lg shadow-custom-shadow-xl"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{(peekOverviewMode === "side" || peekOverviewMode === "modal") && (
|
||||||
|
<SidePeekView
|
||||||
|
handleClose={handleClose}
|
||||||
|
issueId={issue.id}
|
||||||
|
projectId={issue.project}
|
||||||
|
mode={peekOverviewMode}
|
||||||
|
setMode={(mode) => setPeekOverviewMode(mode)}
|
||||||
|
workspaceSlug={workspaceSlug}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{peekOverviewMode === "full" && (
|
||||||
|
<FullScreenPeekView
|
||||||
|
issueId={issue.id}
|
||||||
|
workspaceSlug={workspaceSlug}
|
||||||
|
projectId={issue.project}
|
||||||
|
handleClose={handleClose}
|
||||||
|
mode={peekOverviewMode}
|
||||||
|
setMode={(mode) => setPeekOverviewMode(mode)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Dialog.Panel>
|
||||||
|
</Transition.Child>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
</Transition.Root>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,67 @@
|
|||||||
|
// mobx
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import { useMobxStore } from "lib/mobx/store-provider";
|
||||||
|
|
||||||
|
import {
|
||||||
|
PeekOverviewHeader,
|
||||||
|
PeekOverviewIssueActivity,
|
||||||
|
PeekOverviewIssueDetails,
|
||||||
|
PeekOverviewIssueProperties,
|
||||||
|
TPeekOverviewModes,
|
||||||
|
} from "components/issues/peek-overview";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
issueId: string;
|
||||||
|
projectId: string;
|
||||||
|
workspaceSlug: string;
|
||||||
|
handleClose: () => void;
|
||||||
|
mode: TPeekOverviewModes;
|
||||||
|
setMode: (mode: TPeekOverviewModes) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SidePeekView: React.FC<Props> = observer((props) => {
|
||||||
|
const { handleClose, issueId, mode, setMode, workspaceSlug, projectId } = props;
|
||||||
|
|
||||||
|
const { issue: issueStore } = useMobxStore();
|
||||||
|
|
||||||
|
const issue = issueStore.issue_detail[issueId]?.issue;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!workspaceSlug || !projectId || !issueId) return;
|
||||||
|
|
||||||
|
issueStore.getIssueByIdAsync(workspaceSlug, projectId, issueId);
|
||||||
|
}, [workspaceSlug, projectId, issueId, issueStore]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full w-full flex flex-col overflow-hidden">
|
||||||
|
<div className="w-full p-5">
|
||||||
|
<PeekOverviewHeader
|
||||||
|
handleClose={handleClose}
|
||||||
|
issue={issue}
|
||||||
|
mode={mode}
|
||||||
|
setMode={setMode}
|
||||||
|
workspaceSlug={workspaceSlug}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{issue && (
|
||||||
|
<div className="h-full w-full px-6 overflow-y-auto">
|
||||||
|
{/* issue title and description */}
|
||||||
|
<div className="w-full">
|
||||||
|
<PeekOverviewIssueDetails issue={issue} />
|
||||||
|
</div>
|
||||||
|
{/* issue properties */}
|
||||||
|
<div className="w-full mt-10">
|
||||||
|
<PeekOverviewIssueProperties issue={issue} mode={mode} workspaceSlug={workspaceSlug} />
|
||||||
|
</div>
|
||||||
|
{/* divider */}
|
||||||
|
<div className="h-[1] w-full border-t border-custom-border-200 my-5" />
|
||||||
|
{/* issue activity/comments */}
|
||||||
|
<div className="w-full pb-5">
|
||||||
|
<PeekOverviewIssueActivity workspaceSlug={workspaceSlug} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
119
apps/space/components/tiptap/bubble-menu/index.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
90
apps/space/components/tiptap/bubble-menu/link-selector.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
130
apps/space/components/tiptap/bubble-menu/node-selector.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
@ -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:";
|
||||||
|
}
|
||||||
|
|
57
apps/space/components/tiptap/extensions/image-resize.tsx
Normal 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;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
137
apps/space/components/tiptap/extensions/index.tsx
Normal 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,
|
||||||
|
}),
|
||||||
|
];
|
22
apps/space/components/tiptap/extensions/updated-image.tsx
Normal 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;
|
100
apps/space/components/tiptap/index.tsx
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
import { useEditor, EditorContent, Editor } from "@tiptap/react";
|
||||||
|
import { useDebouncedCallback } from "use-debounce";
|
||||||
|
import { EditorBubbleMenu } from "./bubble-menu";
|
||||||
|
import { TiptapExtensions } from "./extensions";
|
||||||
|
import { TiptapEditorProps } from "./props";
|
||||||
|
import { useImperativeHandle, useRef } from "react";
|
||||||
|
import { ImageResizer } from "./extensions/image-resize";
|
||||||
|
|
||||||
|
export interface ITiptapRichTextEditor {
|
||||||
|
value: string;
|
||||||
|
noBorder?: boolean;
|
||||||
|
borderOnFocus?: boolean;
|
||||||
|
customClassName?: string;
|
||||||
|
editorContentCustomClassNames?: string;
|
||||||
|
onChange?: (json: any, html: string) => void;
|
||||||
|
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void;
|
||||||
|
setShouldShowAlert?: (showAlert: boolean) => void;
|
||||||
|
workspaceSlug: string;
|
||||||
|
editable?: boolean;
|
||||||
|
forwardedRef?: any;
|
||||||
|
debouncedUpdatesEnabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Tiptap = (props: ITiptapRichTextEditor) => {
|
||||||
|
const {
|
||||||
|
onChange,
|
||||||
|
debouncedUpdatesEnabled,
|
||||||
|
forwardedRef,
|
||||||
|
editable,
|
||||||
|
setIsSubmitting,
|
||||||
|
setShouldShowAlert,
|
||||||
|
editorContentCustomClassNames,
|
||||||
|
value,
|
||||||
|
noBorder,
|
||||||
|
workspaceSlug,
|
||||||
|
borderOnFocus,
|
||||||
|
customClassName,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const editor = useEditor({
|
||||||
|
editable: editable ?? true,
|
||||||
|
editorProps: TiptapEditorProps(workspaceSlug, setIsSubmitting),
|
||||||
|
extensions: TiptapExtensions(workspaceSlug, setIsSubmitting),
|
||||||
|
content: value,
|
||||||
|
onUpdate: async ({ editor }) => {
|
||||||
|
// for instant feedback loop
|
||||||
|
setIsSubmitting?.("submitting");
|
||||||
|
setShouldShowAlert?.(true);
|
||||||
|
if (debouncedUpdatesEnabled) {
|
||||||
|
debouncedUpdates({ onChange, editor });
|
||||||
|
} else {
|
||||||
|
onChange?.(editor.getJSON(), editor.getHTML());
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const editorRef: React.MutableRefObject<Editor | null> = useRef(null);
|
||||||
|
|
||||||
|
useImperativeHandle(forwardedRef, () => ({
|
||||||
|
clearEditor: () => {
|
||||||
|
editorRef.current?.commands.clearContent();
|
||||||
|
},
|
||||||
|
setEditorValue: (content: string) => {
|
||||||
|
editorRef.current?.commands.setContent(content);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const debouncedUpdates = useDebouncedCallback(async ({ onChange, editor }) => {
|
||||||
|
setTimeout(async () => {
|
||||||
|
if (onChange) {
|
||||||
|
onChange(editor.getJSON(), editor.getHTML());
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
const editorClassNames = `relative w-full max-w-screen-lg sm:rounded-lg mt-2 p-3 relative focus:outline-none rounded-md
|
||||||
|
${noBorder ? "" : "border border-custom-border-200"} ${borderOnFocus ? "focus:border border-custom-border-300" : "focus:border-0"
|
||||||
|
} ${customClassName}`;
|
||||||
|
|
||||||
|
if (!editor) return null;
|
||||||
|
editorRef.current = editor;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
id="tiptap-container"
|
||||||
|
onClick={() => {
|
||||||
|
editor?.chain().focus().run();
|
||||||
|
}}
|
||||||
|
className={`tiptap-editor-container cursor-text ${editorClassNames}`}
|
||||||
|
>
|
||||||
|
{editor && <EditorBubbleMenu editor={editor} />}
|
||||||
|
<div className={`${editorContentCustomClassNames}`}>
|
||||||
|
<EditorContent editor={editor} />
|
||||||
|
{editor?.isActive("image") && <ImageResizer editor={editor} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Tiptap;
|
56
apps/space/components/tiptap/plugins/delete-image.tsx
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
127
apps/space/components/tiptap/plugins/upload-image.tsx
Normal 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);
|
||||||
|
}
|
||||||
|
};
|
56
apps/space/components/tiptap/props.tsx
Normal 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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
339
apps/space/components/tiptap/slash-command/index.tsx
Normal 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;
|
6
apps/space/components/tiptap/utils.ts
Normal 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));
|
||||||
|
}
|
149
apps/space/components/ui/dropdown.tsx
Normal 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 };
|
10
apps/space/components/ui/icon.tsx
Normal 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>
|
||||||
|
);
|
6
apps/space/components/ui/index.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export * from "./dropdown";
|
||||||
|
export * from "./input";
|
||||||
|
export * from "./primary-button";
|
||||||
|
export * from "./secondary-button";
|
||||||
|
export * from "./icon";
|
||||||
|
export * from "./reaction-selector";
|
37
apps/space/components/ui/input.tsx
Normal 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;
|
35
apps/space/components/ui/primary-button.tsx
Normal 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>
|
||||||
|
);
|
77
apps/space/components/ui/reaction-selector.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
35
apps/space/components/ui/secondary-button.tsx
Normal 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>
|
||||||
|
);
|
67
apps/space/components/ui/toast-alert.tsx
Normal 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;
|
@ -56,31 +56,31 @@ export const issuePriorityFilters: IIssuePriorityFilters[] = [
|
|||||||
{
|
{
|
||||||
key: "urgent",
|
key: "urgent",
|
||||||
title: "Urgent",
|
title: "Urgent",
|
||||||
className: "border border-red-500/50 bg-red-500/20 text-red-500",
|
className: "bg-red-500/10 text-red-500",
|
||||||
icon: "error",
|
icon: "error",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "high",
|
key: "high",
|
||||||
title: "High",
|
title: "High",
|
||||||
className: "border border-orange-500/50 bg-orange-500/20 text-orange-500",
|
className: "bg-orange-500/10 text-orange-500",
|
||||||
icon: "signal_cellular_alt",
|
icon: "signal_cellular_alt",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "medium",
|
key: "medium",
|
||||||
title: "Medium",
|
title: "Medium",
|
||||||
className: "border border-yellow-500/50 bg-yellow-500/20 text-yellow-500",
|
className: "bg-yellow-500/10 text-yellow-500",
|
||||||
icon: "signal_cellular_alt_2_bar",
|
icon: "signal_cellular_alt_2_bar",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "low",
|
key: "low",
|
||||||
title: "Low",
|
title: "Low",
|
||||||
className: "border border-green-500/50 bg-green-500/20 text-green-500",
|
className: "bg-green-500/10 text-green-500",
|
||||||
icon: "signal_cellular_alt_1_bar",
|
icon: "signal_cellular_alt_1_bar",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "none",
|
key: "none",
|
||||||
title: "None",
|
title: "None",
|
||||||
className: "border border-gray-500/50 bg-gray-500/20 text-gray-500",
|
className: "bg-gray-500/10 text-gray-500",
|
||||||
icon: "block",
|
icon: "block",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@ -111,35 +111,35 @@ export const issueGroups: IIssueGroup[] = [
|
|||||||
key: "backlog",
|
key: "backlog",
|
||||||
title: "Backlog",
|
title: "Backlog",
|
||||||
color: "#d9d9d9",
|
color: "#d9d9d9",
|
||||||
className: `border-[#d9d9d9]/50 text-[#d9d9d9] bg-[#d9d9d9]/10`,
|
className: `text-[#d9d9d9] bg-[#d9d9d9]/10`,
|
||||||
icon: BacklogStateIcon,
|
icon: BacklogStateIcon,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "unstarted",
|
key: "unstarted",
|
||||||
title: "Unstarted",
|
title: "Unstarted",
|
||||||
color: "#3f76ff",
|
color: "#3f76ff",
|
||||||
className: `border-[#3f76ff]/50 text-[#3f76ff] bg-[#3f76ff]/10`,
|
className: `text-[#3f76ff] bg-[#3f76ff]/10`,
|
||||||
icon: UnstartedStateIcon,
|
icon: UnstartedStateIcon,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "started",
|
key: "started",
|
||||||
title: "Started",
|
title: "Started",
|
||||||
color: "#f59e0b",
|
color: "#f59e0b",
|
||||||
className: `border-[#f59e0b]/50 text-[#f59e0b] bg-[#f59e0b]/10`,
|
className: `text-[#f59e0b] bg-[#f59e0b]/10`,
|
||||||
icon: StartedStateIcon,
|
icon: StartedStateIcon,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "completed",
|
key: "completed",
|
||||||
title: "Completed",
|
title: "Completed",
|
||||||
color: "#16a34a",
|
color: "#16a34a",
|
||||||
className: `border-[#16a34a]/50 text-[#16a34a] bg-[#16a34a]/10`,
|
className: `text-[#16a34a] bg-[#16a34a]/10`,
|
||||||
icon: CompletedStateIcon,
|
icon: CompletedStateIcon,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "cancelled",
|
key: "cancelled",
|
||||||
title: "Cancelled",
|
title: "Cancelled",
|
||||||
color: "#dc2626",
|
color: "#dc2626",
|
||||||
className: `border-[#dc2626]/50 text-[#dc2626] bg-[#dc2626]/10`,
|
className: `text-[#dc2626] bg-[#dc2626]/10`,
|
||||||
icon: CancelledStateIcon,
|
icon: CancelledStateIcon,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
12
apps/space/constants/workspace.ts
Normal 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" },
|
||||||
|
];
|
97
apps/space/contexts/toast.context.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
14
apps/space/helpers/date-time.helper.ts
Normal 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();
|
||||||
|
}
|
||||||
|
};
|
56
apps/space/helpers/emoji.helper.tsx
Normal 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;
|
||||||
|
};
|
31
apps/space/helpers/string.helper.ts
Normal 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);
|
||||||
|
};
|
21
apps/space/hooks/use-outside-click.tsx
Normal 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;
|
19
apps/space/hooks/use-timer.tsx
Normal 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;
|
9
apps/space/hooks/use-toast.tsx
Normal 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;
|
@ -1,6 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
// next imports
|
||||||
|
import { useSearchParams } from "next/navigation";
|
||||||
// mobx store
|
// mobx store
|
||||||
import { useMobxStore } from "lib/mobx/store-provider";
|
import { useMobxStore } from "lib/mobx/store-provider";
|
||||||
import { RootStore } from "store/root";
|
import { RootStore } from "store/root";
|
||||||
@ -8,6 +10,9 @@ import { RootStore } from "store/root";
|
|||||||
const MobxStoreInit = () => {
|
const MobxStoreInit = () => {
|
||||||
const store: RootStore = useMobxStore();
|
const store: RootStore = useMobxStore();
|
||||||
|
|
||||||
|
// search params
|
||||||
|
const routerSearchparams = useSearchParams();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// theme
|
// theme
|
||||||
const _theme = localStorage && localStorage.getItem("app_theme") ? localStorage.getItem("app_theme") : "light";
|
const _theme = localStorage && localStorage.getItem("app_theme") ? localStorage.getItem("app_theme") : "light";
|
||||||
@ -15,6 +20,18 @@ const MobxStoreInit = () => {
|
|||||||
else localStorage.setItem("app_theme", _theme && _theme != "light" ? "dark" : "light");
|
else localStorage.setItem("app_theme", _theme && _theme != "light" ? "dark" : "light");
|
||||||
}, [store?.theme]);
|
}, [store?.theme]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!routerSearchparams) return;
|
||||||
|
|
||||||
|
const states = routerSearchparams.get("states");
|
||||||
|
const labels = routerSearchparams.get("labels");
|
||||||
|
const priorities = routerSearchparams.get("priorities");
|
||||||
|
|
||||||
|
store.issue.userSelectedLabels = labels?.split(",") || [];
|
||||||
|
store.issue.userSelectedPriorities = priorities?.split(",") || [];
|
||||||
|
store.issue.userSelectedStates = states?.split(",") || [];
|
||||||
|
}, [routerSearchparams, store.issue]);
|
||||||
|
|
||||||
return <></>;
|
return <></>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -21,6 +21,7 @@
|
|||||||
"mobx": "^6.10.0",
|
"mobx": "^6.10.0",
|
||||||
"mobx-react-lite": "^4.0.3",
|
"mobx-react-lite": "^4.0.3",
|
||||||
"next": "^13.4.16",
|
"next": "^13.4.16",
|
||||||
|
"next-theme": "^0.1.5",
|
||||||
"nprogress": "^0.2.0",
|
"nprogress": "^0.2.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
@ -28,6 +29,7 @@
|
|||||||
"uuid": "^9.0.0"
|
"uuid": "^9.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@tailwindcss/typography": "^0.5.9",
|
||||||
"@types/js-cookie": "^3.0.3",
|
"@types/js-cookie": "^3.0.3",
|
||||||
"@types/uuid": "^9.0.1",
|
"@types/uuid": "^9.0.1",
|
||||||
"autoprefixer": "^10.4.13",
|
"autoprefixer": "^10.4.13",
|
||||||
|
BIN
apps/space/public/logos/github-black.png
Normal file
After Width: | Height: | Size: 14 KiB |
BIN
apps/space/public/logos/github-square.png
Normal file
After Width: | Height: | Size: 2.3 KiB |
BIN
apps/space/public/logos/github-white.png
Normal file
After Width: | Height: | Size: 16 KiB |
@ -0,0 +1,17 @@
|
|||||||
|
<svg width="133" height="30" viewBox="0 0 133 30" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g clip-path="url(#clip0_372_3489)">
|
||||||
|
<path d="M35.7598 0.00235398H44.7151C46.4036 -0.0301787 48.0819 0.274777 49.6538 0.899752C51.0897 1.48694 52.3232 2.48876 53.2005 3.7804C54.1414 5.27171 54.6058 7.02009 54.5305 8.78788C54.6025 10.5948 54.1394 12.3823 53.2005 13.921C52.338 15.271 51.1056 16.3375 49.6538 16.9901C48.0968 17.6881 46.409 18.0371 44.7062 18.0131H40.3882V30.0024H35.7598V0.00235398ZM40.3882 14.1812H43.4472C44.5022 14.1924 45.5515 14.0225 46.5505 13.6787C47.4425 13.3684 48.2171 12.7851 48.7672 12.0095C49.3447 11.1092 49.6299 10.0487 49.5829 8.97633C49.6547 7.88162 49.368 6.79348 48.7672 5.88031C48.2009 5.1354 47.4202 4.58669 46.5328 4.30986C45.5354 3.98896 44.4937 3.83143 43.4472 3.84322H40.3882V14.1812Z" fill="#262626"/>
|
||||||
|
<path d="M63.6538 30.0023H58.9102V0.00234985H63.6538V30.0023Z" fill="#262626"/>
|
||||||
|
<path d="M83.1961 30.0023V26.0717C82.9541 26.623 82.6146 27.1249 82.1941 27.5524C81.5462 28.2456 80.7841 28.8194 79.942 29.2485C78.8993 29.7731 77.7457 30.0319 76.5816 30.0023C74.9896 30.0315 73.4209 29.6134 72.0497 28.7943C70.6785 27.9753 69.5585 26.7874 68.8144 25.3628C68.0116 23.8079 67.6091 22.0734 67.644 20.3194C67.6069 18.5595 68.0094 16.8186 68.8144 15.2581C69.5577 13.8315 70.677 12.6411 72.048 11.819C73.419 10.9969 74.9882 10.5752 76.5816 10.6006C77.7134 10.57 78.8375 10.7969 79.8711 11.2647C80.698 11.6453 81.4539 12.1675 82.1055 12.8082C82.5675 13.2374 82.9256 13.7687 83.1518 14.3607V11.139H87.8333V29.9844L83.1961 30.0023ZM72.299 20.3194C72.2745 21.3974 72.5412 22.4617 73.0704 23.3975C73.548 24.2312 74.2333 24.9235 75.0579 25.4053C75.8825 25.8872 76.8175 26.1417 77.7697 26.1435C78.7342 26.1735 79.6872 25.9254 80.5177 25.4281C81.3482 24.9308 82.0218 24.2047 82.4602 23.3347C82.9374 22.3947 83.1751 21.349 83.1518 20.2925C83.1763 19.233 82.9385 18.1841 82.4602 17.2413C81.9964 16.419 81.3267 15.7349 80.5183 15.2581C79.6888 14.7549 78.7367 14.4969 77.7697 14.5133C76.8353 14.5149 75.9179 14.7656 75.1097 15.2401C74.2725 15.73 73.5757 16.4321 73.0881 17.2772C72.5606 18.2015 72.288 19.2521 72.299 20.3194Z" fill="#262626"/>
|
||||||
|
<path d="M103.536 10.6096C104.757 10.611 105.964 10.8677 107.082 11.3634C108.238 11.8795 109.223 12.7205 109.92 13.7864C110.714 15.0781 111.104 16.5829 111.037 18.1029V30.0023H106.293V18.9733C106.348 18.3184 106.273 17.6591 106.074 17.0337C105.874 16.4082 105.554 15.8291 105.132 15.3299C104.739 14.9381 104.272 14.6317 103.758 14.4296C103.244 14.2274 102.695 14.1337 102.144 14.1543C101.323 14.1599 100.521 14.3994 99.8295 14.8453C99.0977 15.3167 98.4893 15.9602 98.0562 16.7209C97.5907 17.539 97.3518 18.469 97.3646 19.4131V30.0023H92.6387V11.157H97.3646V14.244C97.5688 13.6234 97.9368 13.0711 98.4286 12.6467C99.081 12.0457 99.8309 11.563 100.645 11.2198C101.557 10.8175 102.541 10.6097 103.536 10.6096Z" fill="#262626"/>
|
||||||
|
<path d="M118.973 21.4322C118.987 22.346 119.221 23.2426 119.656 24.0436C120.095 24.8189 120.753 25.4438 121.545 25.8384C122.494 26.3044 123.541 26.5293 124.595 26.4935C125.5 26.5093 126.4 26.3635 127.255 26.0628C127.932 25.8094 128.569 25.4556 129.143 25.0128C129.56 24.6821 129.93 24.2957 130.243 23.8642L132.335 26.7448C131.854 27.3909 131.273 27.9545 130.615 28.414C129.834 28.9511 128.963 29.3402 128.044 29.5626C126.808 29.8643 125.538 30.0031 124.267 29.9754C122.476 30.0226 120.705 29.599 119.124 28.746C117.69 27.949 116.513 26.7492 115.737 25.291C114.909 23.68 114.498 21.8834 114.54 20.0682C114.532 18.3948 114.924 16.7444 115.684 15.2581C116.41 13.8364 117.524 12.6558 118.894 11.857C120.412 11.0607 122.093 10.6338 123.803 10.6105C125.513 10.5871 127.205 10.9678 128.744 11.7224C130.081 12.4518 131.174 13.5669 131.883 14.9261C132.667 16.483 133.051 18.2141 133 19.9605C133 20.0771 133 20.3284 133 20.6963C133.005 20.9431 132.984 21.1897 132.938 21.4322H118.973ZM128.567 17.9503C128.53 17.3776 128.37 16.8201 128.097 16.317C127.762 15.6679 127.264 15.12 126.652 14.7287C125.854 14.2334 124.926 13.9955 123.992 14.0466C123.03 14.0014 122.076 14.2282 121.234 14.7017C120.661 15.0464 120.172 15.5175 119.804 16.08C119.435 16.6424 119.197 17.2817 119.106 17.9503H128.567Z" fill="#262626"/>
|
||||||
|
<path d="M29.127 0.271576H9.70898V10.0981H19.418V19.9246H29.127V0.271576Z" fill="#3F76FF"/>
|
||||||
|
<path d="M9.709 10.0981H0V19.9066H9.709V10.0981Z" fill="#3F76FF"/>
|
||||||
|
<path d="M19.4266 19.9246H9.73535V29.7511H19.4266V19.9246Z" fill="#3F76FF"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_372_3489">
|
||||||
|
<rect width="133" height="30" fill="white"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 4.4 KiB |
BIN
apps/space/public/plane-logos/blue-without-text.png
Normal file
After Width: | Height: | Size: 2.4 KiB |
@ -0,0 +1,17 @@
|
|||||||
|
<svg width="133" height="30" viewBox="0 0 133 30" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g clip-path="url(#clip0_372_3489)">
|
||||||
|
<path d="M35.7598 0.00235398H44.7151C46.4036 -0.0301787 48.0819 0.274777 49.6538 0.899752C51.0897 1.48694 52.3232 2.48876 53.2005 3.7804C54.1414 5.27171 54.6058 7.02009 54.5305 8.78788C54.6025 10.5948 54.1394 12.3823 53.2005 13.921C52.338 15.271 51.1056 16.3375 49.6538 16.9901C48.0968 17.6881 46.409 18.0371 44.7062 18.0131H40.3882V30.0024H35.7598V0.00235398ZM40.3882 14.1812H43.4472C44.5022 14.1924 45.5515 14.0225 46.5505 13.6787C47.4425 13.3684 48.2171 12.7851 48.7672 12.0095C49.3447 11.1092 49.6299 10.0487 49.5829 8.97633C49.6547 7.88162 49.368 6.79348 48.7672 5.88031C48.2009 5.1354 47.4202 4.58669 46.5328 4.30986C45.5354 3.98896 44.4937 3.83143 43.4472 3.84322H40.3882V14.1812Z" fill="#ffffff"/>
|
||||||
|
<path d="M63.6538 30.0023H58.9102V0.00234985H63.6538V30.0023Z" fill="#ffffff"/>
|
||||||
|
<path d="M83.1961 30.0023V26.0717C82.9541 26.623 82.6146 27.1249 82.1941 27.5524C81.5462 28.2456 80.7841 28.8194 79.942 29.2485C78.8993 29.7731 77.7457 30.0319 76.5816 30.0023C74.9896 30.0315 73.4209 29.6134 72.0497 28.7943C70.6785 27.9753 69.5585 26.7874 68.8144 25.3628C68.0116 23.8079 67.6091 22.0734 67.644 20.3194C67.6069 18.5595 68.0094 16.8186 68.8144 15.2581C69.5577 13.8315 70.677 12.6411 72.048 11.819C73.419 10.9969 74.9882 10.5752 76.5816 10.6006C77.7134 10.57 78.8375 10.7969 79.8711 11.2647C80.698 11.6453 81.4539 12.1675 82.1055 12.8082C82.5675 13.2374 82.9256 13.7687 83.1518 14.3607V11.139H87.8333V29.9844L83.1961 30.0023ZM72.299 20.3194C72.2745 21.3974 72.5412 22.4617 73.0704 23.3975C73.548 24.2312 74.2333 24.9235 75.0579 25.4053C75.8825 25.8872 76.8175 26.1417 77.7697 26.1435C78.7342 26.1735 79.6872 25.9254 80.5177 25.4281C81.3482 24.9308 82.0218 24.2047 82.4602 23.3347C82.9374 22.3947 83.1751 21.349 83.1518 20.2925C83.1763 19.233 82.9385 18.1841 82.4602 17.2413C81.9964 16.419 81.3267 15.7349 80.5183 15.2581C79.6888 14.7549 78.7367 14.4969 77.7697 14.5133C76.8353 14.5149 75.9179 14.7656 75.1097 15.2401C74.2725 15.73 73.5757 16.4321 73.0881 17.2772C72.5606 18.2015 72.288 19.2521 72.299 20.3194Z" fill="#ffffff"/>
|
||||||
|
<path d="M103.536 10.6096C104.757 10.611 105.964 10.8677 107.082 11.3634C108.238 11.8795 109.223 12.7205 109.92 13.7864C110.714 15.0781 111.104 16.5829 111.037 18.1029V30.0023H106.293V18.9733C106.348 18.3184 106.273 17.6591 106.074 17.0337C105.874 16.4082 105.554 15.8291 105.132 15.3299C104.739 14.9381 104.272 14.6317 103.758 14.4296C103.244 14.2274 102.695 14.1337 102.144 14.1543C101.323 14.1599 100.521 14.3994 99.8295 14.8453C99.0977 15.3167 98.4893 15.9602 98.0562 16.7209C97.5907 17.539 97.3518 18.469 97.3646 19.4131V30.0023H92.6387V11.157H97.3646V14.244C97.5688 13.6234 97.9368 13.0711 98.4286 12.6467C99.081 12.0457 99.8309 11.563 100.645 11.2198C101.557 10.8175 102.541 10.6097 103.536 10.6096Z" fill="#ffffff"/>
|
||||||
|
<path d="M118.973 21.4322C118.987 22.346 119.221 23.2426 119.656 24.0436C120.095 24.8189 120.753 25.4438 121.545 25.8384C122.494 26.3044 123.541 26.5293 124.595 26.4935C125.5 26.5093 126.4 26.3635 127.255 26.0628C127.932 25.8094 128.569 25.4556 129.143 25.0128C129.56 24.6821 129.93 24.2957 130.243 23.8642L132.335 26.7448C131.854 27.3909 131.273 27.9545 130.615 28.414C129.834 28.9511 128.963 29.3402 128.044 29.5626C126.808 29.8643 125.538 30.0031 124.267 29.9754C122.476 30.0226 120.705 29.599 119.124 28.746C117.69 27.949 116.513 26.7492 115.737 25.291C114.909 23.68 114.498 21.8834 114.54 20.0682C114.532 18.3948 114.924 16.7444 115.684 15.2581C116.41 13.8364 117.524 12.6558 118.894 11.857C120.412 11.0607 122.093 10.6338 123.803 10.6105C125.513 10.5871 127.205 10.9678 128.744 11.7224C130.081 12.4518 131.174 13.5669 131.883 14.9261C132.667 16.483 133.051 18.2141 133 19.9605C133 20.0771 133 20.3284 133 20.6963C133.005 20.9431 132.984 21.1897 132.938 21.4322H118.973ZM128.567 17.9503C128.53 17.3776 128.37 16.8201 128.097 16.317C127.762 15.6679 127.264 15.12 126.652 14.7287C125.854 14.2334 124.926 13.9955 123.992 14.0466C123.03 14.0014 122.076 14.2282 121.234 14.7017C120.661 15.0464 120.172 15.5175 119.804 16.08C119.435 16.6424 119.197 17.2817 119.106 17.9503H128.567Z" fill="#ffffff"/>
|
||||||
|
<path d="M29.127 0.271576H9.70898V10.0981H19.418V19.9246H29.127V0.271576Z" fill="#3F76FF"/>
|
||||||
|
<path d="M9.709 10.0981H0V19.9066H9.709V10.0981Z" fill="#3F76FF"/>
|
||||||
|
<path d="M19.4266 19.9246H9.73535V29.7511H19.4266V19.9246Z" fill="#3F76FF"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_372_3489">
|
||||||
|
<rect width="133" height="30" fill="white"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 4.4 KiB |
17
apps/space/public/plane-logos/white-horizontal.svg
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
<svg width="178" height="40" viewBox="0 0 178 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g clip-path="url(#clip0_435_3747)">
|
||||||
|
<path d="M47.8574 -4.66756e-06H59.8428C62.1026 -0.0433815 64.3487 0.363226 66.4525 1.19653C68.3742 1.97944 70.025 3.3152 71.1992 5.03739C72.4584 7.02581 73.0799 9.35697 72.9792 11.714C73.0756 14.1233 72.4557 16.5067 71.1992 18.5582C70.0448 20.3582 68.3954 21.7802 66.4525 22.6503C64.3686 23.581 62.1098 24.0463 59.8309 24.0144H54.0518V40H47.8574V-4.66756e-06ZM54.0518 18.9052H58.1458C59.5579 18.9201 60.9622 18.6935 62.2992 18.2351C63.493 17.8214 64.5297 17.0436 65.2658 16.0096C66.0388 14.8091 66.4205 13.3952 66.3576 11.9653C66.4536 10.5057 66.0699 9.05483 65.2658 7.83727C64.508 6.84406 63.4631 6.11244 62.2754 5.74334C60.9405 5.31547 59.5464 5.10542 58.1458 5.12114H54.0518V18.9052Z" fill="#F2F2F2"/>
|
||||||
|
<path d="M85.1905 40H78.8418V0H85.1905V40Z" fill="#F2F2F2"/>
|
||||||
|
<path d="M111.345 40V34.7592C111.021 35.4943 110.566 36.1634 110.004 36.7335C109.136 37.6576 108.117 38.4228 106.99 38.9949C105.594 39.6944 104.05 40.0394 102.492 40C100.362 40.0389 98.262 39.4814 96.4269 38.3893C94.5917 37.2972 93.0929 35.7134 92.0969 33.8139C91.0225 31.7408 90.4838 29.4281 90.5305 27.0894C90.4808 24.7429 91.0196 22.4217 92.0969 20.341C93.0918 18.4388 94.5897 16.8517 96.4246 15.7555C98.2595 14.6594 100.36 14.0971 102.492 14.131C104.007 14.0902 105.511 14.3928 106.895 15.0165C108.001 15.524 109.013 16.2202 109.885 17.0745C110.503 17.6468 110.983 18.3551 111.285 19.1445V14.8489H117.551V39.9761L111.345 40ZM96.7605 27.0894C96.7277 28.5267 97.0847 29.9458 97.7929 31.1935C98.4321 32.3051 99.3493 33.2282 100.453 33.8706C101.556 34.5131 102.808 34.8525 104.082 34.8549C105.373 34.8949 106.649 34.5641 107.76 33.901C108.871 33.2379 109.773 32.2699 110.36 31.1098C110.998 29.8565 111.317 28.4623 111.285 27.0536C111.318 25.6409 111 24.2424 110.36 22.9853C109.739 21.8888 108.843 20.9767 107.761 20.341C106.651 19.6701 105.376 19.3261 104.082 19.3479C102.832 19.3501 101.604 19.6844 100.522 20.3171C99.4017 20.9702 98.4693 21.9063 97.8166 23.0332C97.1106 24.2655 96.7459 25.6664 96.7605 27.0894Z" fill="#F2F2F2"/>
|
||||||
|
<path d="M138.567 14.143C140.201 14.1449 141.816 14.4871 143.313 15.1481C144.86 15.8362 146.178 16.9575 147.111 18.3787C148.174 20.101 148.695 22.1074 148.606 24.134V40H142.257V25.2946C142.33 24.4215 142.23 23.5424 141.963 22.7085C141.696 21.8745 141.268 21.1023 140.703 20.4367C140.177 19.9144 139.551 19.5058 138.864 19.2363C138.176 18.9667 137.441 18.8418 136.703 18.8693C135.606 18.8767 134.532 19.196 133.606 19.7906C132.627 20.4192 131.813 21.2771 131.233 22.2914C130.61 23.3822 130.29 24.6223 130.307 25.8809V40H123.982V14.8729H130.307V18.9889C130.581 18.1614 131.073 17.425 131.731 16.8591C132.605 16.0578 133.608 15.4143 134.698 14.9566C135.918 14.4202 137.235 14.1432 138.567 14.143Z" fill="#F2F2F2"/>
|
||||||
|
<path d="M159.226 28.5731C159.245 29.7916 159.558 30.987 160.14 32.055C160.728 33.0887 161.608 33.922 162.668 34.4481C163.938 35.0694 165.339 35.3692 166.75 35.3216C167.961 35.3426 169.166 35.1482 170.31 34.7472C171.217 34.4094 172.068 33.9376 172.837 33.3473C173.394 32.9063 173.889 32.3911 174.309 31.8157L177.109 35.6566C176.465 36.5181 175.688 37.2695 174.807 37.8821C173.761 38.5983 172.596 39.1172 171.366 39.4137C169.711 39.8159 168.012 40.0009 166.311 39.9641C163.915 40.027 161.543 39.4622 159.428 38.3249C157.508 37.2622 155.934 35.6624 154.895 33.7182C153.787 31.5703 153.236 29.1747 153.293 26.7544C153.282 24.5233 153.807 22.3227 154.824 20.341C155.795 18.4454 157.286 16.8713 159.12 15.8062C161.152 14.7444 163.402 14.1753 165.69 14.1442C167.979 14.113 170.243 14.6206 172.303 15.6267C174.093 16.5992 175.555 18.086 176.504 19.8983C177.552 21.9742 178.067 24.2823 177.999 26.6108C177.999 26.7664 177.999 27.1014 177.999 27.592C178.005 27.921 177.977 28.2498 177.916 28.5731H159.226ZM172.066 23.9306C172.017 23.167 171.802 22.4237 171.437 21.7529C170.989 20.8874 170.322 20.1568 169.503 19.6351C168.435 18.9747 167.194 18.6575 165.943 18.7257C164.656 18.6653 163.378 18.9678 162.252 19.5992C161.485 20.0587 160.831 20.6868 160.338 21.4368C159.844 22.1867 159.526 23.0391 159.404 23.9306H172.066Z" fill="#F2F2F2"/>
|
||||||
|
<path d="M38.9821 0.358963H12.9941V13.461H25.9881V26.563H38.9821V0.358963Z" fill="#F2F2F2"/>
|
||||||
|
<path d="M12.994 13.461H0V26.539H12.994V13.461Z" fill="#F2F2F2"/>
|
||||||
|
<path d="M25.9996 26.563H13.0293V39.665H25.9996V26.563Z" fill="#F2F2F2"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_435_3747">
|
||||||
|
<rect width="178" height="40" fill="white"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 4.4 KiB |
@ -90,6 +90,16 @@ abstract class APIService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mediaUpload(url: string, data = {}, config = {}): Promise<any> {
|
||||||
|
return axios({
|
||||||
|
method: "post",
|
||||||
|
url: this.baseURL + url,
|
||||||
|
data,
|
||||||
|
headers: this.getAccessToken() ? { ...this.getHeaders(), "Content-Type": "multipart/form-data" } : {},
|
||||||
|
...config,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
request(config = {}) {
|
request(config = {}) {
|
||||||
return axios(config);
|
return axios(config);
|
||||||
}
|
}
|
||||||
|
92
apps/space/services/authentication.service.ts
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
// services
|
||||||
|
import APIService from "services/api.service";
|
||||||
|
|
||||||
|
class AuthService extends APIService {
|
||||||
|
constructor() {
|
||||||
|
super(process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000");
|
||||||
|
}
|
||||||
|
|
||||||
|
async emailLogin(data: any) {
|
||||||
|
return this.post("/api/sign-in/", data, { headers: {} })
|
||||||
|
.then((response) => {
|
||||||
|
this.setAccessToken(response?.data?.access_token);
|
||||||
|
this.setRefreshToken(response?.data?.refresh_token);
|
||||||
|
return response?.data;
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
throw error?.response?.data;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async emailSignUp(data: { email: string; password: string }) {
|
||||||
|
return this.post("/api/sign-up/", data, { headers: {} })
|
||||||
|
.then((response) => {
|
||||||
|
this.setAccessToken(response?.data?.access_token);
|
||||||
|
this.setRefreshToken(response?.data?.refresh_token);
|
||||||
|
return response?.data;
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
throw error?.response?.data;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async socialAuth(data: any): Promise<{
|
||||||
|
access_token: string;
|
||||||
|
refresh_toke: string;
|
||||||
|
user: any;
|
||||||
|
}> {
|
||||||
|
return this.post("/api/social-auth/", data, { headers: {} })
|
||||||
|
.then((response) => {
|
||||||
|
this.setAccessToken(response?.data?.access_token);
|
||||||
|
this.setRefreshToken(response?.data?.refresh_token);
|
||||||
|
return response?.data;
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
throw error?.response?.data;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async emailCode(data: any) {
|
||||||
|
return this.post("/api/magic-generate/", data, { headers: {} })
|
||||||
|
.then((response) => response?.data)
|
||||||
|
.catch((error) => {
|
||||||
|
throw error?.response?.data;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async forgotPassword(data: { email: string }): Promise<any> {
|
||||||
|
return this.post(`/api/forgot-password/`, data)
|
||||||
|
.then((response) => response?.data)
|
||||||
|
.catch((error) => {
|
||||||
|
throw error?.response;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async magicSignIn(data: any) {
|
||||||
|
const response = await this.post("/api/magic-sign-in/", data, { headers: {} });
|
||||||
|
if (response?.status === 200) {
|
||||||
|
this.setAccessToken(response?.data?.access_token);
|
||||||
|
this.setRefreshToken(response?.data?.refresh_token);
|
||||||
|
return response?.data;
|
||||||
|
}
|
||||||
|
throw response.response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async signOut() {
|
||||||
|
return this.post("/api/sign-out/", { refresh_token: this.getRefreshToken() })
|
||||||
|
.then((response) => {
|
||||||
|
this.purgeAccessToken();
|
||||||
|
this.purgeRefreshToken();
|
||||||
|
return response?.data;
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
this.purgeAccessToken();
|
||||||
|
this.purgeRefreshToken();
|
||||||
|
throw error?.response?.data;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const authService = new AuthService();
|
||||||
|
|
||||||
|
export default authService;
|
101
apps/space/services/file.service.ts
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
// services
|
||||||
|
import APIService from "services/api.service";
|
||||||
|
|
||||||
|
const { NEXT_PUBLIC_API_BASE_URL } = process.env;
|
||||||
|
|
||||||
|
interface UnSplashImage {
|
||||||
|
id: string;
|
||||||
|
created_at: Date;
|
||||||
|
updated_at: Date;
|
||||||
|
promoted_at: Date;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
color: string;
|
||||||
|
blur_hash: string;
|
||||||
|
description: null;
|
||||||
|
alt_description: string;
|
||||||
|
urls: UnSplashImageUrls;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UnSplashImageUrls {
|
||||||
|
raw: string;
|
||||||
|
full: string;
|
||||||
|
regular: string;
|
||||||
|
small: string;
|
||||||
|
thumb: string;
|
||||||
|
small_s3: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class FileServices extends APIService {
|
||||||
|
constructor() {
|
||||||
|
super(NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000");
|
||||||
|
}
|
||||||
|
|
||||||
|
async uploadFile(workspaceSlug: string, file: FormData): Promise<any> {
|
||||||
|
return this.mediaUpload(`/api/workspaces/${workspaceSlug}/file-assets/`, file)
|
||||||
|
.then((response) => response?.data)
|
||||||
|
.catch((error) => {
|
||||||
|
throw error?.response?.data;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteImage(assetUrlWithWorkspaceId: string): Promise<any> {
|
||||||
|
return this.delete(`/api/workspaces/file-assets/${assetUrlWithWorkspaceId}/`)
|
||||||
|
.then((response) => response?.status)
|
||||||
|
.catch((error) => {
|
||||||
|
throw error?.response?.data;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteFile(workspaceId: string, assetUrl: string): Promise<any> {
|
||||||
|
const lastIndex = assetUrl.lastIndexOf("/");
|
||||||
|
const assetId = assetUrl.substring(lastIndex + 1);
|
||||||
|
|
||||||
|
return this.delete(`/api/workspaces/file-assets/${workspaceId}/${assetId}/`)
|
||||||
|
.then((response) => response?.data)
|
||||||
|
.catch((error) => {
|
||||||
|
throw error?.response?.data;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async uploadUserFile(file: FormData): Promise<any> {
|
||||||
|
return this.mediaUpload(`/api/users/file-assets/`, file)
|
||||||
|
.then((response) => response?.data)
|
||||||
|
.catch((error) => {
|
||||||
|
throw error?.response?.data;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteUserFile(assetUrl: string): Promise<any> {
|
||||||
|
const lastIndex = assetUrl.lastIndexOf("/");
|
||||||
|
const assetId = assetUrl.substring(lastIndex + 1);
|
||||||
|
|
||||||
|
return this.delete(`/api/users/file-assets/${assetId}`)
|
||||||
|
.then((response) => response?.data)
|
||||||
|
.catch((error) => {
|
||||||
|
throw error?.response?.data;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUnsplashImages(page: number = 1, query?: string): Promise<UnSplashImage[]> {
|
||||||
|
const url = "/api/unsplash";
|
||||||
|
|
||||||
|
return this.request({
|
||||||
|
method: "get",
|
||||||
|
url,
|
||||||
|
params: {
|
||||||
|
page,
|
||||||
|
per_page: 20,
|
||||||
|
query,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then((response) => response?.data?.results ?? response?.data)
|
||||||
|
.catch((error) => {
|
||||||
|
throw error?.response?.data;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileServices = new FileServices();
|
||||||
|
|
||||||
|
export default fileServices;
|
@ -6,8 +6,135 @@ class IssueService extends APIService {
|
|||||||
super(process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000");
|
super(process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000");
|
||||||
}
|
}
|
||||||
|
|
||||||
async getPublicIssues(workspace_slug: string, project_slug: string): Promise<any> {
|
async getPublicIssues(workspace_slug: string, project_slug: string, params: any): Promise<any> {
|
||||||
return this.get(`/api/public/workspaces/${workspace_slug}/project-boards/${project_slug}/issues/`)
|
return this.get(`/api/public/workspaces/${workspace_slug}/project-boards/${project_slug}/issues/`, {
|
||||||
|
params,
|
||||||
|
})
|
||||||
|
.then((response) => response?.data)
|
||||||
|
.catch((error) => {
|
||||||
|
throw error?.response;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getIssueById(workspaceSlug: string, projectId: string, issueId: string): Promise<any> {
|
||||||
|
return this.get(`/api/public/workspaces/${workspaceSlug}/project-boards/${projectId}/issues/${issueId}/`)
|
||||||
|
.then((response) => response?.data)
|
||||||
|
.catch((error) => {
|
||||||
|
throw error?.response;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getIssueVotes(workspaceSlug: string, projectId: string, issueId: string): Promise<any> {
|
||||||
|
return this.get(`/api/public/workspaces/${workspaceSlug}/project-boards/${projectId}/issues/${issueId}/votes/`)
|
||||||
|
.then((response) => response?.data)
|
||||||
|
.catch((error) => {
|
||||||
|
throw error?.response;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async createIssueVote(workspaceSlug: string, projectId: string, issueId: string, data: any): Promise<any> {
|
||||||
|
return this.post(
|
||||||
|
`/api/public/workspaces/${workspaceSlug}/project-boards/${projectId}/issues/${issueId}/votes/`,
|
||||||
|
data
|
||||||
|
)
|
||||||
|
.then((response) => response?.data)
|
||||||
|
.catch((error) => {
|
||||||
|
throw error?.response;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteIssueVote(workspaceSlug: string, projectId: string, issueId: string): Promise<any> {
|
||||||
|
return this.delete(`/api/public/workspaces/${workspaceSlug}/project-boards/${projectId}/issues/${issueId}/votes/`)
|
||||||
|
.then((response) => response?.data)
|
||||||
|
.catch((error) => {
|
||||||
|
throw error?.response;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getIssueReactions(workspaceSlug: string, projectId: string, issueId: string): Promise<any> {
|
||||||
|
return this.get(`/api/public/workspaces/${workspaceSlug}/project-boards/${projectId}/issues/${issueId}/reactions/`)
|
||||||
|
.then((response) => response?.data)
|
||||||
|
.catch((error) => {
|
||||||
|
throw error?.response;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async createIssueReaction(workspaceSlug: string, projectId: string, issueId: string, data: any): Promise<any> {
|
||||||
|
return this.post(
|
||||||
|
`/api/public/workspaces/${workspaceSlug}/project-boards/${projectId}/issues/${issueId}/reactions/`,
|
||||||
|
data
|
||||||
|
)
|
||||||
|
.then((response) => response?.data)
|
||||||
|
.catch((error) => {
|
||||||
|
throw error?.response;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteIssueReaction(
|
||||||
|
workspaceSlug: string,
|
||||||
|
projectId: string,
|
||||||
|
issueId: string,
|
||||||
|
reactionId: string
|
||||||
|
): Promise<any> {
|
||||||
|
return this.delete(
|
||||||
|
`/api/public/workspaces/${workspaceSlug}/project-boards/${projectId}/issues/${issueId}/reactions/${reactionId}/`
|
||||||
|
)
|
||||||
|
.then((response) => response?.data)
|
||||||
|
.catch((error) => {
|
||||||
|
throw error?.response;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getIssueComments(workspaceSlug: string, projectId: string, issueId: string): Promise<any> {
|
||||||
|
return this.get(`/api/public/workspaces/${workspaceSlug}/project-boards/${projectId}/issues/${issueId}/comments/`)
|
||||||
|
.then((response) => response?.data)
|
||||||
|
.catch((error) => {
|
||||||
|
throw error?.response;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCommentsReactions(workspaceSlug: string, projectId: string, commentId: string): Promise<any> {
|
||||||
|
return this.get(
|
||||||
|
`/api/public/workspaces/${workspaceSlug}/project-boards/${projectId}/comments/${commentId}/reactions/`
|
||||||
|
)
|
||||||
|
.then((response) => response?.data)
|
||||||
|
.catch((error) => {
|
||||||
|
throw error?.response;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async createIssueComment(workspaceSlug: string, projectId: string, issueId: string, data: any): Promise<any> {
|
||||||
|
return this.post(
|
||||||
|
`/api/public/workspaces/${workspaceSlug}/project-boards/${projectId}/issues/${issueId}/comments/`,
|
||||||
|
data
|
||||||
|
)
|
||||||
|
.then((response) => response?.data)
|
||||||
|
.catch((error) => {
|
||||||
|
throw error?.response;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateIssueComment(
|
||||||
|
workspaceSlug: string,
|
||||||
|
projectId: string,
|
||||||
|
issueId: string,
|
||||||
|
commentId: string,
|
||||||
|
data: any
|
||||||
|
): Promise<any> {
|
||||||
|
return this.patch(
|
||||||
|
`/api/public/workspaces/${workspaceSlug}/project-boards/${projectId}/issues/${issueId}/comments/${commentId}/`,
|
||||||
|
data
|
||||||
|
)
|
||||||
|
.then((response) => response?.data)
|
||||||
|
.catch((error) => {
|
||||||
|
throw error?.response;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteIssueComment(workspaceSlug: string, projectId: string, issueId: string, commentId: string): Promise<any> {
|
||||||
|
return this.delete(
|
||||||
|
`/api/public/workspaces/${workspaceSlug}/project-boards/${projectId}/issues/${issueId}/comments/${commentId}/`
|
||||||
|
)
|
||||||
.then((response) => response?.data)
|
.then((response) => response?.data)
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
throw error?.response;
|
throw error?.response;
|
||||||
|
@ -13,6 +13,14 @@ class UserService extends APIService {
|
|||||||
throw error?.response;
|
throw error?.response;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateMe(data: any): Promise<any> {
|
||||||
|
return this.patch("/api/users/me/", data)
|
||||||
|
.then((response) => response?.data)
|
||||||
|
.catch((error) => {
|
||||||
|
throw error?.response?.data;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default UserService;
|
export default UserService;
|
||||||
|
@ -1,12 +1,13 @@
|
|||||||
// mobx
|
// mobx
|
||||||
import { observable, action, computed, makeObservable, runInAction } from "mobx";
|
import { observable, action, computed, makeObservable, runInAction, reaction } from "mobx";
|
||||||
// service
|
// service
|
||||||
import IssueService from "services/issue.service";
|
import IssueService from "services/issue.service";
|
||||||
// types
|
// types
|
||||||
import { TIssueBoardKeys } from "store/types/issue";
|
import { IssueDetailType, TIssueBoardKeys } from "store/types/issue";
|
||||||
import { IIssueStore, IIssue, IIssueState, IIssueLabel } from "./types";
|
import { IIssueStore, IIssue, IIssueState, IIssueLabel } from "./types";
|
||||||
|
|
||||||
class IssueStore implements IIssueStore {
|
// class IssueStore implements IIssueStore {
|
||||||
|
class IssueStore {
|
||||||
currentIssueBoardView: TIssueBoardKeys | null = null;
|
currentIssueBoardView: TIssueBoardKeys | null = null;
|
||||||
|
|
||||||
loader: boolean = false;
|
loader: boolean = false;
|
||||||
@ -16,8 +17,13 @@ class IssueStore implements IIssueStore {
|
|||||||
labels: IIssueLabel[] | null = null;
|
labels: IIssueLabel[] | null = null;
|
||||||
issues: IIssue[] | null = null;
|
issues: IIssue[] | null = null;
|
||||||
|
|
||||||
|
issue_detail: IssueDetailType = {};
|
||||||
|
|
||||||
|
activePeekOverviewIssueId: string | null = null;
|
||||||
|
|
||||||
userSelectedStates: string[] = [];
|
userSelectedStates: string[] = [];
|
||||||
userSelectedLabels: string[] = [];
|
userSelectedLabels: string[] = [];
|
||||||
|
userSelectedPriorities: string[] = [];
|
||||||
// root store
|
// root store
|
||||||
rootStore;
|
rootStore;
|
||||||
// service
|
// service
|
||||||
@ -34,9 +40,13 @@ class IssueStore implements IIssueStore {
|
|||||||
states: observable.ref,
|
states: observable.ref,
|
||||||
labels: observable.ref,
|
labels: observable.ref,
|
||||||
issues: observable.ref,
|
issues: observable.ref,
|
||||||
|
issue_detail: observable.ref,
|
||||||
|
|
||||||
userSelectedStates: observable,
|
activePeekOverviewIssueId: observable.ref,
|
||||||
userSelectedLabels: observable,
|
|
||||||
|
userSelectedStates: observable.ref,
|
||||||
|
userSelectedLabels: observable.ref,
|
||||||
|
userSelectedPriorities: observable.ref,
|
||||||
// action
|
// action
|
||||||
setCurrentIssueBoardView: action,
|
setCurrentIssueBoardView: action,
|
||||||
getIssuesAsync: action,
|
getIssuesAsync: action,
|
||||||
@ -56,17 +66,113 @@ class IssueStore implements IIssueStore {
|
|||||||
return this.issues?.filter((issue) => issue.state == state_id) || [];
|
return this.issues?.filter((issue) => issue.state == state_id) || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setActivePeekOverviewIssueId = (issueId: string | null) => (this.activePeekOverviewIssueId = issueId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param key Is the key of the filter, i.e. state, label, priority
|
||||||
|
* @param value Is the value of the filter, i.e. state_id, label_id, priority
|
||||||
|
* @returns boolean
|
||||||
|
*/
|
||||||
|
|
||||||
|
getUserSelectedFilter(key: "state" | "priority" | "label", value: string): boolean {
|
||||||
|
if (key == "state") {
|
||||||
|
return this.userSelectedStates.includes(value);
|
||||||
|
} else if (key == "label") {
|
||||||
|
return this.userSelectedLabels.includes(value);
|
||||||
|
} else if (key == "priority") {
|
||||||
|
return this.userSelectedPriorities.includes(value);
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
checkIfFilterExistsForKey: (key: "state" | "priority" | "label") => boolean = (key) => {
|
||||||
|
if (key == "state") {
|
||||||
|
return this.userSelectedStates.length > 0;
|
||||||
|
} else if (key == "label") {
|
||||||
|
return this.userSelectedLabels.length > 0;
|
||||||
|
} else if (key == "priority") {
|
||||||
|
return this.userSelectedPriorities.length > 0;
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
clearUserSelectedFilter(key: "state" | "priority" | "label" | "all") {
|
||||||
|
if (key == "state") {
|
||||||
|
this.userSelectedStates = [];
|
||||||
|
} else if (key == "label") {
|
||||||
|
this.userSelectedLabels = [];
|
||||||
|
} else if (key == "priority") {
|
||||||
|
this.userSelectedPriorities = [];
|
||||||
|
} else if (key == "all") {
|
||||||
|
this.userSelectedStates = [];
|
||||||
|
this.userSelectedLabels = [];
|
||||||
|
this.userSelectedPriorities = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getIfFiltersIsEmpty: () => boolean = () =>
|
||||||
|
this.userSelectedStates.length === 0 &&
|
||||||
|
this.userSelectedLabels.length === 0 &&
|
||||||
|
this.userSelectedPriorities.length === 0;
|
||||||
|
|
||||||
|
getURLDefinition = (
|
||||||
|
workspaceSlug: string,
|
||||||
|
projectId: string,
|
||||||
|
action?: {
|
||||||
|
key: "state" | "priority" | "label" | "all";
|
||||||
|
value?: string;
|
||||||
|
removeAll?: boolean;
|
||||||
|
}
|
||||||
|
) => {
|
||||||
|
let url = `/${workspaceSlug}/${projectId}?board=${this.currentIssueBoardView}`;
|
||||||
|
|
||||||
|
if (action) {
|
||||||
|
if (action.key === "state")
|
||||||
|
this.userSelectedStates = action.removeAll
|
||||||
|
? []
|
||||||
|
: [...this.userSelectedStates].filter((state) => state !== action.value);
|
||||||
|
if (action.key === "label")
|
||||||
|
this.userSelectedLabels = action.removeAll
|
||||||
|
? []
|
||||||
|
: [...this.userSelectedLabels].filter((label) => label !== action.value);
|
||||||
|
if (action.key === "priority")
|
||||||
|
this.userSelectedPriorities = action.removeAll
|
||||||
|
? []
|
||||||
|
: [...this.userSelectedPriorities].filter((priority) => priority !== action.value);
|
||||||
|
if (action.key === "all") {
|
||||||
|
this.userSelectedStates = [];
|
||||||
|
this.userSelectedLabels = [];
|
||||||
|
this.userSelectedPriorities = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.checkIfFilterExistsForKey("state")) {
|
||||||
|
url += `&states=${this.userSelectedStates.join(",")}`;
|
||||||
|
}
|
||||||
|
if (this.checkIfFilterExistsForKey("label")) {
|
||||||
|
url += `&labels=${this.userSelectedLabels.join(",")}`;
|
||||||
|
}
|
||||||
|
if (this.checkIfFilterExistsForKey("priority")) {
|
||||||
|
url += `&priorities=${this.userSelectedPriorities.join(",")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return url;
|
||||||
|
};
|
||||||
|
|
||||||
// action
|
// action
|
||||||
setCurrentIssueBoardView = async (view: TIssueBoardKeys) => {
|
setCurrentIssueBoardView = async (view: TIssueBoardKeys) => {
|
||||||
this.currentIssueBoardView = view;
|
this.currentIssueBoardView = view;
|
||||||
};
|
};
|
||||||
|
|
||||||
getIssuesAsync = async (workspace_slug: string, project_slug: string) => {
|
getIssuesAsync = async (workspaceSlug: string, projectId: string, params: any) => {
|
||||||
try {
|
try {
|
||||||
this.loader = true;
|
this.loader = true;
|
||||||
this.error = null;
|
this.error = null;
|
||||||
|
|
||||||
const response = await this.issueService.getPublicIssues(workspace_slug, project_slug);
|
const response = await this.issueService.getPublicIssues(workspaceSlug, projectId, params);
|
||||||
|
|
||||||
if (response) {
|
if (response) {
|
||||||
const _states: IIssueState[] = [...response?.states];
|
const _states: IIssueState[] = [...response?.states];
|
||||||
@ -86,6 +192,344 @@ class IssueStore implements IIssueStore {
|
|||||||
return error;
|
return error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
getIssueByIdAsync = async (workspaceSlug: string, projectId: string, issueId: string): Promise<IssueDetailType> => {
|
||||||
|
try {
|
||||||
|
const response = this.issues?.find((issue) => issue.id === issueId);
|
||||||
|
|
||||||
|
if (response) {
|
||||||
|
const _issue_detail = {
|
||||||
|
...this.issue_detail,
|
||||||
|
[issueId]: {
|
||||||
|
issue: response,
|
||||||
|
comments: [],
|
||||||
|
reactions: [],
|
||||||
|
votes: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
runInAction(() => {
|
||||||
|
this.issue_detail = _issue_detail;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.getIssueReactionsAsync(workspaceSlug, projectId, issueId);
|
||||||
|
this.getIssueVotesAsync(workspaceSlug, projectId, issueId);
|
||||||
|
this.getIssueCommentsAsync(workspaceSlug, projectId, issueId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.issue_detail[issueId] as any;
|
||||||
|
} catch (error) {
|
||||||
|
this.loader = false;
|
||||||
|
this.error = error;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
getIssueVotesAsync = async (workspaceSlug: string, projectId: string, issueId: string) => {
|
||||||
|
try {
|
||||||
|
const response = await this.issueService.getIssueVotes(workspaceSlug, projectId, issueId);
|
||||||
|
|
||||||
|
if (response) {
|
||||||
|
const _issue_detail = {
|
||||||
|
...this.issue_detail,
|
||||||
|
[issueId]: {
|
||||||
|
...this.issue_detail[issueId],
|
||||||
|
votes: response,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
runInAction(() => {
|
||||||
|
this.issue_detail = _issue_detail;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
this.loader = false;
|
||||||
|
this.error = error;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
createIssueVoteAsync = async (
|
||||||
|
workspaceSlug: string,
|
||||||
|
projectId: string,
|
||||||
|
issueId: string,
|
||||||
|
data: {
|
||||||
|
vote: 1 | -1;
|
||||||
|
}
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const response = await this.issueService.createIssueVote(workspaceSlug, projectId, issueId, data);
|
||||||
|
|
||||||
|
if (response) {
|
||||||
|
const _issue_detail = {
|
||||||
|
...this.issue_detail,
|
||||||
|
[issueId]: {
|
||||||
|
...this.issue_detail[issueId],
|
||||||
|
votes: [
|
||||||
|
...{ ...this.issue_detail }[issueId].votes.filter(
|
||||||
|
(vote) => vote.actor !== this.rootStore?.user?.currentUser?.id
|
||||||
|
),
|
||||||
|
response,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
runInAction(() => {
|
||||||
|
this.issue_detail = _issue_detail;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
this.loader = false;
|
||||||
|
this.error = error;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
deleteIssueVoteAsync = async (workspaceSlug: string, projectId: string, issueId: string) => {
|
||||||
|
try {
|
||||||
|
const _votes = (this.issue_detail[issueId].votes = this.issue_detail[issueId].votes.filter(
|
||||||
|
(vote) => vote.actor !== this.rootStore?.user?.user?.id
|
||||||
|
));
|
||||||
|
|
||||||
|
runInAction(() => {
|
||||||
|
this.issue_detail[issueId].votes = _votes;
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await this.issueService.deleteIssueVote(workspaceSlug, projectId, issueId);
|
||||||
|
|
||||||
|
const votesAfterCall = await this.issueService.getIssueVotes(workspaceSlug, projectId, issueId);
|
||||||
|
|
||||||
|
if (votesAfterCall)
|
||||||
|
runInAction(() => {
|
||||||
|
this.issue_detail[issueId].votes = votesAfterCall;
|
||||||
|
});
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
this.loader = false;
|
||||||
|
this.error = error;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
getIssueReactionsAsync = async (workspaceSlug: string, projectId: string, issueId: string) => {
|
||||||
|
try {
|
||||||
|
const response = await this.issueService.getIssueReactions(workspaceSlug, projectId, issueId);
|
||||||
|
|
||||||
|
if (response) {
|
||||||
|
const _issue_detail = {
|
||||||
|
...this.issue_detail,
|
||||||
|
[issueId]: {
|
||||||
|
...this.issue_detail[issueId],
|
||||||
|
reactions: response,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
runInAction(() => {
|
||||||
|
this.issue_detail = _issue_detail;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
this.loader = false;
|
||||||
|
this.error = error;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
createIssueReactionAsync = async (workspaceSlug: string, projectId: string, issueId: string, data: any) => {
|
||||||
|
try {
|
||||||
|
const response = await this.issueService.createIssueReaction(workspaceSlug, projectId, issueId, data);
|
||||||
|
|
||||||
|
if (response) {
|
||||||
|
const _issue_detail = {
|
||||||
|
...this.issue_detail,
|
||||||
|
[issueId]: {
|
||||||
|
...this.issue_detail[issueId],
|
||||||
|
reactions: [...this.issue_detail[issueId].reactions, response],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
runInAction(() => {
|
||||||
|
this.issue_detail = _issue_detail;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
this.loader = false;
|
||||||
|
this.error = error;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
deleteIssueReactionAsync = async (workspaceSlug: string, projectId: string, issueId: string, reactionHex: string) => {
|
||||||
|
try {
|
||||||
|
const newReactionsList = this.issue_detail[issueId].reactions.filter(
|
||||||
|
(reaction) => reaction.reaction !== reactionHex
|
||||||
|
);
|
||||||
|
|
||||||
|
const _issue_detail = {
|
||||||
|
...this.issue_detail,
|
||||||
|
[issueId]: {
|
||||||
|
...this.issue_detail[issueId],
|
||||||
|
reactions: newReactionsList,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
runInAction(() => {
|
||||||
|
this.issue_detail = _issue_detail;
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await this.issueService.deleteIssueReaction(workspaceSlug, projectId, issueId, reactionHex);
|
||||||
|
|
||||||
|
const reactionsAfterCall = await this.issueService.getIssueReactions(workspaceSlug, projectId, issueId);
|
||||||
|
|
||||||
|
if (reactionsAfterCall) {
|
||||||
|
const _issue_detail = {
|
||||||
|
...this.issue_detail,
|
||||||
|
[issueId]: {
|
||||||
|
...this.issue_detail[issueId],
|
||||||
|
reactions: reactionsAfterCall,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
runInAction(() => {
|
||||||
|
this.issue_detail = _issue_detail;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
this.loader = false;
|
||||||
|
this.error = error;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
getIssueCommentsAsync = async (workspaceSlug: string, projectId: string, issueId: string) => {
|
||||||
|
try {
|
||||||
|
const response = await this.issueService.getIssueComments(workspaceSlug, projectId, issueId);
|
||||||
|
|
||||||
|
if (response) {
|
||||||
|
const _issue_detail = {
|
||||||
|
...this.issue_detail,
|
||||||
|
[issueId]: {
|
||||||
|
...this.issue_detail[issueId],
|
||||||
|
comments: response,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
runInAction(() => {
|
||||||
|
this.issue_detail = _issue_detail;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
this.loader = false;
|
||||||
|
this.error = error;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
createIssueCommentAsync = async (workspaceSlug: string, projectId: string, issueId: string, data: any) => {
|
||||||
|
try {
|
||||||
|
const response = await this.issueService.createIssueComment(workspaceSlug, projectId, issueId, data);
|
||||||
|
|
||||||
|
if (response) {
|
||||||
|
const _issue_detail = {
|
||||||
|
...this.issue_detail,
|
||||||
|
[issueId]: {
|
||||||
|
...this.issue_detail[issueId],
|
||||||
|
comments: [...this.issue_detail[issueId].comments, response],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
runInAction(() => {
|
||||||
|
this.issue_detail = _issue_detail;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
this.loader = false;
|
||||||
|
this.error = error;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
updateIssueCommentAsync = async (
|
||||||
|
workspaceSlug: string,
|
||||||
|
projectId: string,
|
||||||
|
issueId: string,
|
||||||
|
commentId: string,
|
||||||
|
data: any
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const response = await this.issueService.updateIssueComment(workspaceSlug, projectId, issueId, commentId, data);
|
||||||
|
|
||||||
|
if (response) {
|
||||||
|
const _issue_detail = {
|
||||||
|
...this.issue_detail,
|
||||||
|
[issueId]: {
|
||||||
|
...this.issue_detail[issueId],
|
||||||
|
comments: [
|
||||||
|
...this.issue_detail[issueId].comments.filter((comment) => comment.id !== response.id),
|
||||||
|
response,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
runInAction(() => {
|
||||||
|
this.issue_detail = _issue_detail;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
this.loader = false;
|
||||||
|
this.error = error;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
deleteIssueCommentAsync = async (workspaceSlug: string, projectId: string, issueId: string, commentId: string) => {
|
||||||
|
try {
|
||||||
|
const newCommentsList = this.issue_detail[issueId].comments.filter((comment) => comment.id !== commentId);
|
||||||
|
|
||||||
|
const _issue_detail = {
|
||||||
|
...this.issue_detail,
|
||||||
|
[issueId]: {
|
||||||
|
...this.issue_detail[issueId],
|
||||||
|
comments: newCommentsList,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
runInAction(() => {
|
||||||
|
this.issue_detail = _issue_detail;
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await this.issueService.deleteIssueComment(workspaceSlug, projectId, issueId, commentId);
|
||||||
|
|
||||||
|
const commentsAfterCall = await this.issueService.getIssueComments(workspaceSlug, projectId, issueId);
|
||||||
|
|
||||||
|
if (commentsAfterCall) {
|
||||||
|
const _issue_detail = {
|
||||||
|
...this.issue_detail,
|
||||||
|
[issueId]: {
|
||||||
|
...this.issue_detail[issueId],
|
||||||
|
comments: commentsAfterCall,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
runInAction(() => {
|
||||||
|
this.issue_detail = _issue_detail;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
this.loader = false;
|
||||||
|
this.error = error;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default IssueStore;
|
export default IssueStore;
|
||||||
|
@ -6,14 +6,14 @@ import ThemeStore from "./theme";
|
|||||||
import IssueStore from "./issue";
|
import IssueStore from "./issue";
|
||||||
import ProjectStore from "./project";
|
import ProjectStore from "./project";
|
||||||
// types
|
// types
|
||||||
import { IIssueStore, IProjectStore, IThemeStore, IUserStore } from "./types";
|
import { IIssueStore, IProjectStore, IThemeStore } from "./types";
|
||||||
|
|
||||||
enableStaticRendering(typeof window === "undefined");
|
enableStaticRendering(typeof window === "undefined");
|
||||||
|
|
||||||
export class RootStore {
|
export class RootStore {
|
||||||
user: IUserStore;
|
user: UserStore;
|
||||||
theme: IThemeStore;
|
theme: IThemeStore;
|
||||||
issue: IIssueStore;
|
issue: IssueStore;
|
||||||
project: IProjectStore;
|
project: IProjectStore;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
|
@ -32,11 +32,14 @@ export interface IIssue {
|
|||||||
sequence_id: number;
|
sequence_id: number;
|
||||||
name: string;
|
name: string;
|
||||||
description_html: string;
|
description_html: string;
|
||||||
|
project: string;
|
||||||
|
project_detail: any;
|
||||||
priority: TIssuePriorityKey | null;
|
priority: TIssuePriorityKey | null;
|
||||||
state: string;
|
state: string;
|
||||||
state_detail: any;
|
state_detail: any;
|
||||||
label_details: any;
|
label_details: any;
|
||||||
target_date: any;
|
target_date: any;
|
||||||
|
start_date: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IIssueState {
|
export interface IIssueState {
|
||||||
@ -52,6 +55,95 @@ export interface IIssueLabel {
|
|||||||
color: string;
|
color: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Comment {
|
||||||
|
id: string;
|
||||||
|
actor_detail: ActorDetail;
|
||||||
|
issue_detail: IssueDetail;
|
||||||
|
project_detail: ProjectDetail;
|
||||||
|
workspace_detail: WorkspaceDetail;
|
||||||
|
comment_reactions: any[];
|
||||||
|
is_member: boolean;
|
||||||
|
created_at: Date;
|
||||||
|
updated_at: Date;
|
||||||
|
comment_stripped: string;
|
||||||
|
comment_json: any;
|
||||||
|
comment_html: string;
|
||||||
|
attachments: any[];
|
||||||
|
access: string;
|
||||||
|
created_by: string;
|
||||||
|
updated_by: string;
|
||||||
|
project: string;
|
||||||
|
workspace: string;
|
||||||
|
issue: string;
|
||||||
|
actor: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActorDetail {
|
||||||
|
id: string;
|
||||||
|
first_name: string;
|
||||||
|
last_name: string;
|
||||||
|
avatar: string;
|
||||||
|
is_bot: boolean;
|
||||||
|
display_name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IssueDetail {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: Description;
|
||||||
|
description_html: string;
|
||||||
|
priority: string;
|
||||||
|
start_date: null;
|
||||||
|
target_date: null;
|
||||||
|
sequence_id: number;
|
||||||
|
sort_order: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Description {
|
||||||
|
type: string;
|
||||||
|
content: DescriptionContent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DescriptionContent {
|
||||||
|
type: string;
|
||||||
|
attrs?: Attrs;
|
||||||
|
content: ContentContent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Attrs {
|
||||||
|
level: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContentContent {
|
||||||
|
text: string;
|
||||||
|
type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectDetail {
|
||||||
|
id: string;
|
||||||
|
identifier: string;
|
||||||
|
name: string;
|
||||||
|
cover_image: string;
|
||||||
|
icon_prop: null;
|
||||||
|
emoji: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkspaceDetail {
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IssueDetailType {
|
||||||
|
[issueId: string]: {
|
||||||
|
issue: IIssue;
|
||||||
|
comments: Comment[];
|
||||||
|
reactions: any[];
|
||||||
|
votes: any[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export interface IIssueStore {
|
export interface IIssueStore {
|
||||||
currentIssueBoardView: TIssueBoardKeys | null;
|
currentIssueBoardView: TIssueBoardKeys | null;
|
||||||
loader: boolean;
|
loader: boolean;
|
||||||
@ -61,12 +153,35 @@ export interface IIssueStore {
|
|||||||
labels: IIssueLabel[] | null;
|
labels: IIssueLabel[] | null;
|
||||||
issues: IIssue[] | null;
|
issues: IIssue[] | null;
|
||||||
|
|
||||||
|
issue_detail: IssueDetailType;
|
||||||
|
|
||||||
userSelectedStates: string[];
|
userSelectedStates: string[];
|
||||||
userSelectedLabels: string[];
|
userSelectedLabels: string[];
|
||||||
|
userSelectedPriorities: string[];
|
||||||
|
|
||||||
getCountOfIssuesByState: (state: string) => number;
|
getCountOfIssuesByState: (state: string) => number;
|
||||||
getFilteredIssuesByState: (state: string) => IIssue[];
|
getFilteredIssuesByState: (state: string) => IIssue[];
|
||||||
|
|
||||||
setCurrentIssueBoardView: (view: TIssueBoardKeys) => void;
|
getUserSelectedFilter: (key: "state" | "priority" | "label", value: string) => boolean;
|
||||||
getIssuesAsync: (workspace_slug: string, project_slug: string) => Promise<void>;
|
|
||||||
|
checkIfFilterExistsForKey: (key: "state" | "priority" | "label") => boolean;
|
||||||
|
|
||||||
|
clearUserSelectedFilter: (key: "state" | "priority" | "label" | "all") => void;
|
||||||
|
|
||||||
|
getIfFiltersIsEmpty: () => boolean;
|
||||||
|
|
||||||
|
getURLDefinition: (
|
||||||
|
workspaceSlug: string,
|
||||||
|
projectId: string,
|
||||||
|
action?: {
|
||||||
|
key: "state" | "priority" | "label" | "all";
|
||||||
|
value?: string;
|
||||||
|
removeAll?: boolean;
|
||||||
|
}
|
||||||
|
) => string;
|
||||||
|
|
||||||
|
setCurrentIssueBoardView: (view: TIssueBoardKeys) => void;
|
||||||
|
getIssuesAsync: (workspaceSlug: string, projectId: string, params: any) => Promise<void>;
|
||||||
|
|
||||||
|
getIssueByIdAsync: (workspaceSlug: string, projectId: string, issueId: string) => Promise<IssueDetailType>;
|
||||||
}
|
}
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
// mobx
|
// mobx
|
||||||
import { observable, action, computed, makeObservable, runInAction } from "mobx";
|
import { observable, action, computed, makeObservable, runInAction } from "mobx";
|
||||||
// service
|
// service
|
||||||
@ -17,15 +19,47 @@ class UserStore implements IUserStore {
|
|||||||
// observable
|
// observable
|
||||||
currentUser: observable,
|
currentUser: observable,
|
||||||
// actions
|
// actions
|
||||||
|
setCurrentUser: action,
|
||||||
// computed
|
// computed
|
||||||
});
|
});
|
||||||
this.rootStore = _rootStore;
|
this.rootStore = _rootStore;
|
||||||
this.userService = new UserService();
|
this.userService = new UserService();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setCurrentUser = (user: any) => {
|
||||||
|
// TODO: destructure user object
|
||||||
|
this.currentUser = user;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param callback
|
||||||
|
* @description A wrapper function to check user authentication; it redirects to the login page if not authenticated, otherwise, it executes a callback.
|
||||||
|
* @example this.requiredLogin(() => { // do something });
|
||||||
|
*/
|
||||||
|
|
||||||
|
requiredLogin = (callback: () => void) => {
|
||||||
|
if (this.currentUser) {
|
||||||
|
callback();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.getUserAsync()
|
||||||
|
.then(() => {
|
||||||
|
if (!this.currentUser) {
|
||||||
|
const currentPath = window.location.pathname;
|
||||||
|
window.location.href = `/?next_path=${currentPath}`;
|
||||||
|
} else callback();
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
const currentPath = window.location.pathname;
|
||||||
|
window.location.href = `/?next_path=${currentPath}`;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
getUserAsync = async () => {
|
getUserAsync = async () => {
|
||||||
try {
|
try {
|
||||||
const response = this.userService.currentUser();
|
const response = await this.userService.currentUser();
|
||||||
if (response) {
|
if (response) {
|
||||||
runInAction(() => {
|
runInAction(() => {
|
||||||
this.currentUser = response;
|
this.currentUser = response;
|
||||||
|
@ -4,3 +4,148 @@
|
|||||||
@tailwind base;
|
@tailwind base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
html {
|
||||||
|
font-family: "Inter", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
color-scheme: light !important;
|
||||||
|
|
||||||
|
--color-primary-10: 236, 241, 255;
|
||||||
|
--color-primary-20: 217, 228, 255;
|
||||||
|
--color-primary-30: 197, 214, 255;
|
||||||
|
--color-primary-40: 178, 200, 255;
|
||||||
|
--color-primary-50: 159, 187, 255;
|
||||||
|
--color-primary-60: 140, 173, 255;
|
||||||
|
--color-primary-70: 121, 159, 255;
|
||||||
|
--color-primary-80: 101, 145, 255;
|
||||||
|
--color-primary-90: 82, 132, 255;
|
||||||
|
--color-primary-100: 63, 118, 255;
|
||||||
|
--color-primary-200: 57, 106, 230;
|
||||||
|
--color-primary-300: 50, 94, 204;
|
||||||
|
--color-primary-400: 44, 83, 179;
|
||||||
|
--color-primary-500: 38, 71, 153;
|
||||||
|
--color-primary-600: 32, 59, 128;
|
||||||
|
--color-primary-700: 25, 47, 102;
|
||||||
|
--color-primary-800: 19, 35, 76;
|
||||||
|
--color-primary-900: 13, 24, 51;
|
||||||
|
|
||||||
|
--color-background-100: 255, 255, 255; /* primary bg */
|
||||||
|
--color-background-90: 250, 250, 250; /* secondary bg */
|
||||||
|
--color-background-80: 245, 245, 245; /* tertiary bg */
|
||||||
|
|
||||||
|
--color-text-100: 23, 23, 23; /* primary text */
|
||||||
|
--color-text-200: 58, 58, 58; /* secondary text */
|
||||||
|
--color-text-300: 82, 82, 82; /* tertiary text */
|
||||||
|
--color-text-400: 163, 163, 163; /* placeholder text */
|
||||||
|
|
||||||
|
--color-border-100: 245, 245, 245; /* subtle border= 1 */
|
||||||
|
--color-border-200: 229, 229, 229; /* subtle border- 2 */
|
||||||
|
--color-border-300: 212, 212, 212; /* strong border- 1 */
|
||||||
|
--color-border-400: 185, 185, 185; /* strong border- 2 */
|
||||||
|
|
||||||
|
--color-sidebar-background-100: var(--color-background-100); /* primary sidebar bg */
|
||||||
|
--color-sidebar-background-90: var(--color-background-90); /* secondary sidebar bg */
|
||||||
|
--color-sidebar-background-80: var(--color-background-80); /* tertiary sidebar bg */
|
||||||
|
|
||||||
|
--color-sidebar-text-100: var(--color-text-100); /* primary sidebar text */
|
||||||
|
--color-sidebar-text-200: var(--color-text-200); /* secondary sidebar text */
|
||||||
|
--color-sidebar-text-300: var(--color-text-300); /* tertiary sidebar text */
|
||||||
|
--color-sidebar-text-400: var(--color-text-400); /* sidebar placeholder text */
|
||||||
|
|
||||||
|
--color-sidebar-border-100: var(--color-border-100); /* subtle sidebar border= 1 */
|
||||||
|
--color-sidebar-border-200: var(--color-border-100); /* subtle sidebar border- 2 */
|
||||||
|
--color-sidebar-border-300: var(--color-border-100); /* strong sidebar border- 1 */
|
||||||
|
--color-sidebar-border-400: var(--color-border-100); /* strong sidebar border- 2 */
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="light"] {
|
||||||
|
color-scheme: light !important;
|
||||||
|
|
||||||
|
--color-background-100: 255, 255, 255; /* primary bg */
|
||||||
|
--color-background-90: 250, 250, 250; /* secondary bg */
|
||||||
|
--color-background-80: 245, 245, 245; /* tertiary bg */
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="light"] {
|
||||||
|
--color-text-100: 23, 23, 23; /* primary text */
|
||||||
|
--color-text-200: 58, 58, 58; /* secondary text */
|
||||||
|
--color-text-300: 82, 82, 82; /* tertiary text */
|
||||||
|
--color-text-400: 163, 163, 163; /* placeholder text */
|
||||||
|
|
||||||
|
--color-border-100: 245, 245, 245; /* subtle border= 1 */
|
||||||
|
--color-border-200: 229, 229, 229; /* subtle border- 2 */
|
||||||
|
--color-border-300: 212, 212, 212; /* strong border- 1 */
|
||||||
|
--color-border-400: 185, 185, 185; /* strong border- 2 */
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="light-contrast"] {
|
||||||
|
--color-text-100: 11, 11, 11; /* primary text */
|
||||||
|
--color-text-200: 38, 38, 38; /* secondary text */
|
||||||
|
--color-text-300: 58, 58, 58; /* tertiary text */
|
||||||
|
--color-text-400: 115, 115, 115; /* placeholder text */
|
||||||
|
|
||||||
|
--color-border-100: 34, 34, 34; /* subtle border= 1 */
|
||||||
|
--color-border-200: 38, 38, 38; /* subtle border- 2 */
|
||||||
|
--color-border-300: 46, 46, 46; /* strong border- 1 */
|
||||||
|
--color-border-400: 58, 58, 58; /* strong border- 2 */
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] {
|
||||||
|
color-scheme: dark !important;
|
||||||
|
|
||||||
|
--color-background-100: 7, 7, 7; /* primary bg */
|
||||||
|
--color-background-90: 11, 11, 11; /* secondary bg */
|
||||||
|
--color-background-80: 23, 23, 23; /* tertiary bg */
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] {
|
||||||
|
--color-text-100: 229, 229, 229; /* primary text */
|
||||||
|
--color-text-200: 163, 163, 163; /* secondary text */
|
||||||
|
--color-text-300: 115, 115, 115; /* tertiary text */
|
||||||
|
--color-text-400: 82, 82, 82; /* placeholder text */
|
||||||
|
|
||||||
|
--color-border-100: 34, 34, 34; /* subtle border= 1 */
|
||||||
|
--color-border-200: 38, 38, 38; /* subtle border- 2 */
|
||||||
|
--color-border-300: 46, 46, 46; /* strong border- 1 */
|
||||||
|
--color-border-400: 58, 58, 58; /* strong border- 2 */
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="light"],
|
||||||
|
[data-theme="dark"] {
|
||||||
|
--color-primary-10: 236, 241, 255;
|
||||||
|
--color-primary-20: 217, 228, 255;
|
||||||
|
--color-primary-30: 197, 214, 255;
|
||||||
|
--color-primary-40: 178, 200, 255;
|
||||||
|
--color-primary-50: 159, 187, 255;
|
||||||
|
--color-primary-60: 140, 173, 255;
|
||||||
|
--color-primary-70: 121, 159, 255;
|
||||||
|
--color-primary-80: 101, 145, 255;
|
||||||
|
--color-primary-90: 82, 132, 255;
|
||||||
|
--color-primary-100: 63, 118, 255;
|
||||||
|
--color-primary-200: 57, 106, 230;
|
||||||
|
--color-primary-300: 50, 94, 204;
|
||||||
|
--color-primary-400: 44, 83, 179;
|
||||||
|
--color-primary-500: 38, 71, 153;
|
||||||
|
--color-primary-600: 32, 59, 128;
|
||||||
|
--color-primary-700: 25, 47, 102;
|
||||||
|
--color-primary-800: 19, 35, 76;
|
||||||
|
--color-primary-900: 13, 24, 51;
|
||||||
|
|
||||||
|
--color-sidebar-background-100: var(--color-background-100); /* primary sidebar bg */
|
||||||
|
--color-sidebar-background-90: var(--color-background-90); /* secondary sidebar bg */
|
||||||
|
--color-sidebar-background-80: var(--color-background-80); /* tertiary sidebar bg */
|
||||||
|
|
||||||
|
--color-sidebar-text-100: var(--color-text-100); /* primary sidebar text */
|
||||||
|
--color-sidebar-text-200: var(--color-text-200); /* secondary sidebar text */
|
||||||
|
--color-sidebar-text-300: var(--color-text-300); /* tertiary sidebar text */
|
||||||
|
--color-sidebar-text-400: var(--color-text-400); /* sidebar placeholder text */
|
||||||
|
|
||||||
|
--color-sidebar-border-100: var(--color-border-100); /* subtle sidebar border= 1 */
|
||||||
|
--color-sidebar-border-200: var(--color-border-100); /* subtle sidebar border- 2 */
|
||||||
|
--color-sidebar-border-300: var(--color-border-100); /* strong sidebar border- 1 */
|
||||||
|
--color-sidebar-border-400: var(--color-border-100); /* strong sidebar border- 2 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
|
||||||
|
const convertToRGB = (variableName) => `rgba(var(${variableName}))`;
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
content: [
|
content: [
|
||||||
"./app/**/*.{js,ts,jsx,tsx}",
|
"./app/**/*.{js,ts,jsx,tsx}",
|
||||||
@ -10,8 +12,174 @@ module.exports = {
|
|||||||
],
|
],
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
colors: {},
|
colors: {
|
||||||
|
custom: {
|
||||||
|
primary: {
|
||||||
|
0: "rgb(255, 255, 255)",
|
||||||
|
10: convertToRGB("--color-primary-10"),
|
||||||
|
20: convertToRGB("--color-primary-20"),
|
||||||
|
30: convertToRGB("--color-primary-30"),
|
||||||
|
40: convertToRGB("--color-primary-40"),
|
||||||
|
50: convertToRGB("--color-primary-50"),
|
||||||
|
60: convertToRGB("--color-primary-60"),
|
||||||
|
70: convertToRGB("--color-primary-70"),
|
||||||
|
80: convertToRGB("--color-primary-80"),
|
||||||
|
90: convertToRGB("--color-primary-90"),
|
||||||
|
100: convertToRGB("--color-primary-100"),
|
||||||
|
200: convertToRGB("--color-primary-200"),
|
||||||
|
300: convertToRGB("--color-primary-300"),
|
||||||
|
400: convertToRGB("--color-primary-400"),
|
||||||
|
500: convertToRGB("--color-primary-500"),
|
||||||
|
600: convertToRGB("--color-primary-600"),
|
||||||
|
700: convertToRGB("--color-primary-700"),
|
||||||
|
800: convertToRGB("--color-primary-800"),
|
||||||
|
900: convertToRGB("--color-primary-900"),
|
||||||
|
1000: "rgb(0, 0, 0)",
|
||||||
|
DEFAULT: convertToRGB("--color-primary-100"),
|
||||||
|
},
|
||||||
|
background: {
|
||||||
|
0: "rgb(255, 255, 255)",
|
||||||
|
10: convertToRGB("--color-background-10"),
|
||||||
|
20: convertToRGB("--color-background-20"),
|
||||||
|
30: convertToRGB("--color-background-30"),
|
||||||
|
40: convertToRGB("--color-background-40"),
|
||||||
|
50: convertToRGB("--color-background-50"),
|
||||||
|
60: convertToRGB("--color-background-60"),
|
||||||
|
70: convertToRGB("--color-background-70"),
|
||||||
|
80: convertToRGB("--color-background-80"),
|
||||||
|
90: convertToRGB("--color-background-90"),
|
||||||
|
100: convertToRGB("--color-background-100"),
|
||||||
|
200: convertToRGB("--color-background-200"),
|
||||||
|
300: convertToRGB("--color-background-300"),
|
||||||
|
400: convertToRGB("--color-background-400"),
|
||||||
|
500: convertToRGB("--color-background-500"),
|
||||||
|
600: convertToRGB("--color-background-600"),
|
||||||
|
700: convertToRGB("--color-background-700"),
|
||||||
|
800: convertToRGB("--color-background-800"),
|
||||||
|
900: convertToRGB("--color-background-900"),
|
||||||
|
1000: "rgb(0, 0, 0)",
|
||||||
|
DEFAULT: convertToRGB("--color-background-100"),
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
0: "rgb(255, 255, 255)",
|
||||||
|
10: convertToRGB("--color-text-10"),
|
||||||
|
20: convertToRGB("--color-text-20"),
|
||||||
|
30: convertToRGB("--color-text-30"),
|
||||||
|
40: convertToRGB("--color-text-40"),
|
||||||
|
50: convertToRGB("--color-text-50"),
|
||||||
|
60: convertToRGB("--color-text-60"),
|
||||||
|
70: convertToRGB("--color-text-70"),
|
||||||
|
80: convertToRGB("--color-text-80"),
|
||||||
|
90: convertToRGB("--color-text-90"),
|
||||||
|
100: convertToRGB("--color-text-100"),
|
||||||
|
200: convertToRGB("--color-text-200"),
|
||||||
|
300: convertToRGB("--color-text-300"),
|
||||||
|
400: convertToRGB("--color-text-400"),
|
||||||
|
500: convertToRGB("--color-text-500"),
|
||||||
|
600: convertToRGB("--color-text-600"),
|
||||||
|
700: convertToRGB("--color-text-700"),
|
||||||
|
800: convertToRGB("--color-text-800"),
|
||||||
|
900: convertToRGB("--color-text-900"),
|
||||||
|
1000: "rgb(0, 0, 0)",
|
||||||
|
DEFAULT: convertToRGB("--color-text-100"),
|
||||||
|
},
|
||||||
|
border: {
|
||||||
|
0: "rgb(255, 255, 255)",
|
||||||
|
100: convertToRGB("--color-border-100"),
|
||||||
|
200: convertToRGB("--color-border-200"),
|
||||||
|
300: convertToRGB("--color-border-300"),
|
||||||
|
400: convertToRGB("--color-border-400"),
|
||||||
|
1000: "rgb(0, 0, 0)",
|
||||||
|
DEFAULT: convertToRGB("--color-border-200"),
|
||||||
|
},
|
||||||
|
sidebar: {
|
||||||
|
background: {
|
||||||
|
0: "rgb(255, 255, 255)",
|
||||||
|
10: convertToRGB("--color-sidebar-background-10"),
|
||||||
|
20: convertToRGB("--color-sidebar-background-20"),
|
||||||
|
30: convertToRGB("--color-sidebar-background-30"),
|
||||||
|
40: convertToRGB("--color-sidebar-background-40"),
|
||||||
|
50: convertToRGB("--color-sidebar-background-50"),
|
||||||
|
60: convertToRGB("--color-sidebar-background-60"),
|
||||||
|
70: convertToRGB("--color-sidebar-background-70"),
|
||||||
|
80: convertToRGB("--color-sidebar-background-80"),
|
||||||
|
90: convertToRGB("--color-sidebar-background-90"),
|
||||||
|
100: convertToRGB("--color-sidebar-background-100"),
|
||||||
|
200: convertToRGB("--color-sidebar-background-200"),
|
||||||
|
300: convertToRGB("--color-sidebar-background-300"),
|
||||||
|
400: convertToRGB("--color-sidebar-background-400"),
|
||||||
|
500: convertToRGB("--color-sidebar-background-500"),
|
||||||
|
600: convertToRGB("--color-sidebar-background-600"),
|
||||||
|
700: convertToRGB("--color-sidebar-background-700"),
|
||||||
|
800: convertToRGB("--color-sidebar-background-800"),
|
||||||
|
900: convertToRGB("--color-sidebar-background-900"),
|
||||||
|
1000: "rgb(0, 0, 0)",
|
||||||
|
DEFAULT: convertToRGB("--color-sidebar-background-100"),
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
0: "rgb(255, 255, 255)",
|
||||||
|
10: convertToRGB("--color-sidebar-text-10"),
|
||||||
|
20: convertToRGB("--color-sidebar-text-20"),
|
||||||
|
30: convertToRGB("--color-sidebar-text-30"),
|
||||||
|
40: convertToRGB("--color-sidebar-text-40"),
|
||||||
|
50: convertToRGB("--color-sidebar-text-50"),
|
||||||
|
60: convertToRGB("--color-sidebar-text-60"),
|
||||||
|
70: convertToRGB("--color-sidebar-text-70"),
|
||||||
|
80: convertToRGB("--color-sidebar-text-80"),
|
||||||
|
90: convertToRGB("--color-sidebar-text-90"),
|
||||||
|
100: convertToRGB("--color-sidebar-text-100"),
|
||||||
|
200: convertToRGB("--color-sidebar-text-200"),
|
||||||
|
300: convertToRGB("--color-sidebar-text-300"),
|
||||||
|
400: convertToRGB("--color-sidebar-text-400"),
|
||||||
|
500: convertToRGB("--color-sidebar-text-500"),
|
||||||
|
600: convertToRGB("--color-sidebar-text-600"),
|
||||||
|
700: convertToRGB("--color-sidebar-text-700"),
|
||||||
|
800: convertToRGB("--color-sidebar-text-800"),
|
||||||
|
900: convertToRGB("--color-sidebar-text-900"),
|
||||||
|
1000: "rgb(0, 0, 0)",
|
||||||
|
DEFAULT: convertToRGB("--color-sidebar-text-100"),
|
||||||
|
},
|
||||||
|
border: {
|
||||||
|
0: "rgb(255, 255, 255)",
|
||||||
|
100: convertToRGB("--color-sidebar-border-100"),
|
||||||
|
200: convertToRGB("--color-sidebar-border-200"),
|
||||||
|
300: convertToRGB("--color-sidebar-border-300"),
|
||||||
|
400: convertToRGB("--color-sidebar-border-400"),
|
||||||
|
1000: "rgb(0, 0, 0)",
|
||||||
|
DEFAULT: convertToRGB("--color-sidebar-border-200"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [],
|
backdrop: "#131313",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
typography: ({ theme }) => ({
|
||||||
|
brand: {
|
||||||
|
css: {
|
||||||
|
"--tw-prose-body": convertToRGB("--color-text-100"),
|
||||||
|
"--tw-prose-p": convertToRGB("--color-text-100"),
|
||||||
|
"--tw-prose-headings": convertToRGB("--color-text-100"),
|
||||||
|
"--tw-prose-lead": convertToRGB("--color-text-100"),
|
||||||
|
"--tw-prose-links": convertToRGB("--color-primary-100"),
|
||||||
|
"--tw-prose-bold": convertToRGB("--color-text-100"),
|
||||||
|
"--tw-prose-counters": convertToRGB("--color-text-100"),
|
||||||
|
"--tw-prose-bullets": convertToRGB("--color-text-100"),
|
||||||
|
"--tw-prose-hr": convertToRGB("--color-text-100"),
|
||||||
|
"--tw-prose-quotes": convertToRGB("--color-text-100"),
|
||||||
|
"--tw-prose-quote-borders": convertToRGB("--color-border"),
|
||||||
|
"--tw-prose-code": convertToRGB("--color-text-100"),
|
||||||
|
"--tw-prose-pre-code": convertToRGB("--color-text-100"),
|
||||||
|
"--tw-prose-pre-bg": convertToRGB("--color-background-100"),
|
||||||
|
"--tw-prose-th-borders": convertToRGB("--color-border"),
|
||||||
|
"--tw-prose-td-borders": convertToRGB("--color-border"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
custom: ["Inter", "sans-serif"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
require("@tailwindcss/typography"),
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|