forked from github/plane
feat: Converting space app to pages dir (#2052)
Co-authored-by: Bavisetti Narayan <72156168+NarayanBavisetti@users.noreply.github.com> Co-authored-by: NarayanBavisetti <narayan3119@gmail.com> Co-authored-by: Bavisetti Narayan <narayan@Bavisettis-MacBook-Pro.local> Co-authored-by: Nikhil <118773738+pablohashescobar@users.noreply.github.com> Co-authored-by: M. Palanikannan <73993394+Palanikannan1437@users.noreply.github.com> Co-authored-by: Lakhan Baheti <94619783+1akhanBaheti@users.noreply.github.com> Co-authored-by: Dakshesh Jain <65905942+dakshesh14@users.noreply.github.com> Co-authored-by: Aaryan Khandelwal <65252264+aaryan610@users.noreply.github.com>
This commit is contained in:
parent
c03550656a
commit
8a95a41100
@ -1,66 +0,0 @@
|
||||
// next imports
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { Metadata, ResolvingMetadata } from "next";
|
||||
// components
|
||||
import IssueNavbar from "components/issues/navbar";
|
||||
import IssueFilter from "components/issues/filters-render";
|
||||
// service
|
||||
import ProjectService from "services/project.service";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
type LayoutProps = {
|
||||
params: { workspace_slug: string; project_slug: string };
|
||||
};
|
||||
|
||||
export async function generateMetadata({ params }: LayoutProps): Promise<Metadata> {
|
||||
// read route params
|
||||
const { workspace_slug, project_slug } = params;
|
||||
const projectServiceInstance = new ProjectService();
|
||||
|
||||
try {
|
||||
const project = await projectServiceInstance?.getProjectSettingsAsync(workspace_slug, project_slug);
|
||||
|
||||
return {
|
||||
title: `${project?.project_details?.name} | ${workspace_slug}`,
|
||||
description: `${
|
||||
project?.project_details?.description || `${project?.project_details?.name} | ${workspace_slug}`
|
||||
}`,
|
||||
icons: `data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>${
|
||||
typeof project?.project_details?.emoji != "object"
|
||||
? String.fromCodePoint(parseInt(project?.project_details?.emoji))
|
||||
: "✈️"
|
||||
}</text></svg>`,
|
||||
};
|
||||
} catch (error: any) {
|
||||
if (error?.data?.error) {
|
||||
redirect(`/project-not-published`);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
const RootLayout = ({ children }: { children: React.ReactNode }) => (
|
||||
<div className="relative w-screen min-h-[500px] h-screen overflow-hidden flex flex-col">
|
||||
<div className="flex-shrink-0 h-[60px] border-b border-gray-300 relative flex items-center bg-white select-none">
|
||||
<IssueNavbar />
|
||||
</div>
|
||||
{/* <div className="flex-shrink-0 min-h-[50px] h-auto py-1.5 border-b border-gray-300 relative flex items-center shadow-md bg-white select-none">
|
||||
<IssueFilter />
|
||||
</div> */}
|
||||
<div className="w-full h-full relative bg-gray-100/50 overflow-hidden">{children}</div>
|
||||
|
||||
<div className="absolute z-[99999] bottom-[10px] right-[10px] bg-white rounded-sm shadow-lg border border-gray-100">
|
||||
<Link href="https://plane.so" className="p-1 px-2 flex items-center gap-1" target="_blank">
|
||||
<div className="w-[24px] h-[24px] relative flex justify-center items-center">
|
||||
<Image src="/plane-logo.webp" alt="plane logo" className="w-[24px] h-[24px]" height="24" width="24" />
|
||||
</div>
|
||||
<div className="text-xs">
|
||||
Powered by <b>Plane Deploy</b>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default RootLayout;
|
@ -1,113 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
// next imports
|
||||
import { useRouter, useParams, useSearchParams } from "next/navigation";
|
||||
// mobx
|
||||
import { observer } from "mobx-react-lite";
|
||||
// components
|
||||
import { IssueListView } from "components/issues/board-views/list";
|
||||
import { IssueKanbanView } from "components/issues/board-views/kanban";
|
||||
import { IssueCalendarView } from "components/issues/board-views/calendar";
|
||||
import { IssueSpreadsheetView } from "components/issues/board-views/spreadsheet";
|
||||
import { IssueGanttView } from "components/issues/board-views/gantt";
|
||||
// mobx store
|
||||
import { RootStore } from "store/root";
|
||||
import { useMobxStore } from "lib/mobx/store-provider";
|
||||
// types
|
||||
import { TIssueBoardKeys } from "store/types";
|
||||
|
||||
const WorkspaceProjectPage = observer(() => {
|
||||
const store: RootStore = useMobxStore();
|
||||
|
||||
const router = useRouter();
|
||||
const routerParams = useParams();
|
||||
const routerSearchparams = useSearchParams();
|
||||
|
||||
const { workspace_slug, project_slug } = routerParams as { workspace_slug: string; project_slug: string };
|
||||
const board =
|
||||
routerSearchparams &&
|
||||
routerSearchparams.get("board") != null &&
|
||||
(routerSearchparams.get("board") as TIssueBoardKeys | "");
|
||||
|
||||
// updating default board view when we are in the issues page
|
||||
useEffect(() => {
|
||||
if (workspace_slug && project_slug && store?.project?.workspaceProjectSettings) {
|
||||
const workspacePRojectSettingViews = store?.project?.workspaceProjectSettings?.views;
|
||||
const userAccessViews: TIssueBoardKeys[] = [];
|
||||
|
||||
Object.keys(workspacePRojectSettingViews).filter((_key) => {
|
||||
if (_key === "list" && workspacePRojectSettingViews.list === true) userAccessViews.push(_key);
|
||||
if (_key === "kanban" && workspacePRojectSettingViews.kanban === true) userAccessViews.push(_key);
|
||||
if (_key === "calendar" && workspacePRojectSettingViews.calendar === true) userAccessViews.push(_key);
|
||||
if (_key === "spreadsheet" && workspacePRojectSettingViews.spreadsheet === true) userAccessViews.push(_key);
|
||||
if (_key === "gantt" && workspacePRojectSettingViews.gantt === true) userAccessViews.push(_key);
|
||||
});
|
||||
|
||||
if (userAccessViews && userAccessViews.length > 0) {
|
||||
if (!board) {
|
||||
store.issue.setCurrentIssueBoardView(userAccessViews[0]);
|
||||
router.replace(`/${workspace_slug}/${project_slug}?board=${userAccessViews[0]}`);
|
||||
} else {
|
||||
if (userAccessViews.includes(board)) {
|
||||
if (store.issue.currentIssueBoardView === null) store.issue.setCurrentIssueBoardView(board);
|
||||
else {
|
||||
if (board === store.issue.currentIssueBoardView)
|
||||
router.replace(`/${workspace_slug}/${project_slug}?board=${board}`);
|
||||
else {
|
||||
store.issue.setCurrentIssueBoardView(board);
|
||||
router.replace(`/${workspace_slug}/${project_slug}?board=${board}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
store.issue.setCurrentIssueBoardView(userAccessViews[0]);
|
||||
router.replace(`/${workspace_slug}/${project_slug}?board=${userAccessViews[0]}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [workspace_slug, project_slug, board, router, store?.issue, store?.project?.workspaceProjectSettings]);
|
||||
|
||||
useEffect(() => {
|
||||
if (workspace_slug && project_slug) {
|
||||
store?.project?.getProjectSettingsAsync(workspace_slug, project_slug);
|
||||
store?.issue?.getIssuesAsync(workspace_slug, project_slug);
|
||||
}
|
||||
}, [workspace_slug, project_slug, store?.project, store?.issue]);
|
||||
|
||||
return (
|
||||
<div className="relative w-full h-full overflow-hidden">
|
||||
{store?.issue?.loader && !store.issue.issues ? (
|
||||
<div className="text-sm text-center py-10 text-gray-500">Loading...</div>
|
||||
) : (
|
||||
<>
|
||||
{store?.issue?.error ? (
|
||||
<div className="text-sm text-center py-10 text-gray-500">Something went wrong.</div>
|
||||
) : (
|
||||
store?.issue?.currentIssueBoardView && (
|
||||
<>
|
||||
{store?.issue?.currentIssueBoardView === "list" && (
|
||||
<div className="relative w-full h-full overflow-y-auto">
|
||||
<div className="container mx-auto px-5 py-3">
|
||||
<IssueListView />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{store?.issue?.currentIssueBoardView === "kanban" && (
|
||||
<div className="relative w-full h-full mx-auto px-5">
|
||||
<IssueKanbanView />
|
||||
</div>
|
||||
)}
|
||||
{store?.issue?.currentIssueBoardView === "calendar" && <IssueCalendarView />}
|
||||
{store?.issue?.currentIssueBoardView === "spreadsheet" && <IssueSpreadsheetView />}
|
||||
{store?.issue?.currentIssueBoardView === "gantt" && <IssueGanttView />}
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default WorkspaceProjectPage;
|
@ -1,20 +0,0 @@
|
||||
"use client";
|
||||
|
||||
// root styles
|
||||
import "styles/globals.css";
|
||||
// mobx store provider
|
||||
import { MobxStoreProvider } from "lib/mobx/store-provider";
|
||||
import MobxStoreInit from "lib/mobx/store-init";
|
||||
|
||||
const RootLayout = ({ children }: { children: React.ReactNode }) => (
|
||||
<html lang="en">
|
||||
<body className="antialiased w-100">
|
||||
<MobxStoreProvider>
|
||||
<MobxStoreInit />
|
||||
<main>{children}</main>
|
||||
</MobxStoreProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
export default RootLayout;
|
@ -1,9 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
|
||||
const HomePage = () => (
|
||||
<div className="relative w-screen h-screen flex justify-center items-center text-5xl">Plane Deploy</div>
|
||||
);
|
||||
|
||||
export default HomePage;
|
216
apps/space/components/accounts/email-code-form.tsx
Normal file
216
apps/space/components/accounts/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/components/accounts/email-password-form.tsx
Normal file
116
apps/space/components/accounts/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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
89
apps/space/components/accounts/email-reset-password-form.tsx
Normal file
89
apps/space/components/accounts/email-reset-password-form.tsx
Normal file
@ -0,0 +1,89 @@
|
||||
import React from "react";
|
||||
|
||||
// react hook form
|
||||
import { useForm } from "react-hook-form";
|
||||
// services
|
||||
import userService from "services/user.service";
|
||||
// hooks
|
||||
// import useToast from "hooks/use-toast";
|
||||
// ui
|
||||
import { Input, PrimaryButton, SecondaryButton } from "components/ui";
|
||||
// types
|
||||
type Props = {
|
||||
setIsResettingPassword: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
};
|
||||
|
||||
export const EmailResetPasswordForm: React.FC<Props> = ({ setIsResettingPassword }) => {
|
||||
// const { setToastAlert } = useToast();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm({
|
||||
defaultValues: {
|
||||
email: "",
|
||||
},
|
||||
mode: "onChange",
|
||||
reValidateMode: "onChange",
|
||||
});
|
||||
|
||||
const forgotPassword = async (formData: any) => {
|
||||
const payload = {
|
||||
email: formData.email,
|
||||
};
|
||||
|
||||
// await userService
|
||||
// .forgotPassword(payload)
|
||||
// .then(() =>
|
||||
// setToastAlert({
|
||||
// type: "success",
|
||||
// title: "Success!",
|
||||
// message: "Password reset link has been sent to your email address.",
|
||||
// })
|
||||
// )
|
||||
// .catch((err) => {
|
||||
// if (err.status === 400)
|
||||
// setToastAlert({
|
||||
// type: "error",
|
||||
// title: "Error!",
|
||||
// message: "Please check the Email ID entered.",
|
||||
// });
|
||||
// else
|
||||
// setToastAlert({
|
||||
// type: "error",
|
||||
// title: "Error!",
|
||||
// message: "Something went wrong. Please try again.",
|
||||
// });
|
||||
// });
|
||||
};
|
||||
|
||||
return (
|
||||
<form className="space-y-4 mt-10 w-full sm:w-[360px] mx-auto" onSubmit={handleSubmit(forgotPassword)}>
|
||||
<div className="space-y-1">
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
{...register("email", {
|
||||
required: "Email address is required",
|
||||
validate: (value) =>
|
||||
/^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test(
|
||||
value
|
||||
) || "Email address is not valid",
|
||||
})}
|
||||
placeholder="Enter registered email address.."
|
||||
className="border-custom-border-300 h-[46px]"
|
||||
/>
|
||||
{errors.email && <div className="text-sm text-red-500">{errors.email.message}</div>}
|
||||
</div>
|
||||
<div className="mt-5 flex flex-col-reverse sm:flex-row items-center gap-2">
|
||||
<SecondaryButton className="w-full text-center h-[46px]" onClick={() => setIsResettingPassword(false)}>
|
||||
Go Back
|
||||
</SecondaryButton>
|
||||
<PrimaryButton type="submit" className="w-full text-center h-[46px]" loading={isSubmitting}>
|
||||
{isSubmitting ? "Sending link..." : "Send reset link"}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
55
apps/space/components/accounts/github-login-button.tsx
Normal file
55
apps/space/components/accounts/github-login-button.tsx
Normal file
@ -0,0 +1,55 @@
|
||||
import { useEffect, useState, FC } from "react";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/router";
|
||||
// next-themes
|
||||
import { useTheme } from "next-themes";
|
||||
// images
|
||||
import githubBlackImage from "/public/logos/github-black.png";
|
||||
import githubWhiteImage from "/public/logos/github-white.png";
|
||||
|
||||
export interface GithubLoginButtonProps {
|
||||
handleSignIn: React.Dispatch<string>;
|
||||
}
|
||||
|
||||
export const GithubLoginButton: FC<GithubLoginButtonProps> = ({ handleSignIn }) => {
|
||||
const [loginCallBackURL, setLoginCallBackURL] = useState(undefined);
|
||||
const [gitCode, setGitCode] = useState<null | string>(null);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const { code } = router.query;
|
||||
|
||||
const { theme } = useTheme();
|
||||
|
||||
useEffect(() => {
|
||||
if (code && !gitCode) {
|
||||
setGitCode(code.toString());
|
||||
handleSignIn(code.toString());
|
||||
}
|
||||
}, [code, gitCode, handleSignIn]);
|
||||
|
||||
useEffect(() => {
|
||||
const origin = typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
|
||||
setLoginCallBackURL(`${origin}/` as any);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="w-full flex justify-center items-center">
|
||||
<Link
|
||||
className="w-full"
|
||||
href={`https://github.com/login/oauth/authorize?client_id=${process.env.NEXT_PUBLIC_GITHUB_ID}&redirect_uri=${loginCallBackURL}&scope=read:user,user:email`}
|
||||
>
|
||||
<button className="flex w-full items-center justify-center gap-2 rounded border border-custom-border-300 p-2 text-sm font-medium text-custom-text-100 duration-300 hover:bg-custom-background-80 h-[46px]">
|
||||
<Image
|
||||
src={theme === "dark" ? githubWhiteImage : githubBlackImage}
|
||||
height={20}
|
||||
width={20}
|
||||
alt="GitHub Logo"
|
||||
/>
|
||||
<span>Sign in with GitHub</span>
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
};
|
59
apps/space/components/accounts/google-login.tsx
Normal file
59
apps/space/components/accounts/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} />
|
||||
</>
|
||||
);
|
||||
};
|
6
apps/space/components/accounts/index.ts
Normal file
6
apps/space/components/accounts/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export * from "./email-code-form";
|
||||
export * from "./email-password-form";
|
||||
export * from "./email-reset-password-form";
|
||||
export * from "./github-login-button";
|
||||
export * from "./google-login";
|
||||
export * from "./onboarding-form";
|
187
apps/space/components/accounts/onboarding-form.tsx
Normal file
187
apps/space/components/accounts/onboarding-form.tsx
Normal file
@ -0,0 +1,187 @@
|
||||
import { useEffect, Fragment } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
// react-hook-form
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
// mobx
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useMobxStore } from "lib/mobx/store-provider";
|
||||
// headless ui
|
||||
import { Listbox, Transition } from "@headlessui/react";
|
||||
// icons
|
||||
import { ChevronDownIcon, CheckIcon } from "@heroicons/react/20/solid";
|
||||
// constants
|
||||
import { USER_ROLES } from "constants/workspace";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
// services
|
||||
import UserService from "services/user.service";
|
||||
// ui
|
||||
import { Input, PrimaryButton } from "components/ui";
|
||||
|
||||
const defaultValues = {
|
||||
first_name: "",
|
||||
last_name: "",
|
||||
role: "",
|
||||
};
|
||||
|
||||
type Props = {
|
||||
user?: any;
|
||||
};
|
||||
|
||||
export const OnBoardingForm: React.FC<Props> = observer(({ user }) => {
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const router = useRouter();
|
||||
const { next_path } = router.query;
|
||||
|
||||
const { user: userStore } = useMobxStore();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
control,
|
||||
reset,
|
||||
formState: { errors, isSubmitting, isValid },
|
||||
} = useForm({
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
const onSubmit = async (formData: any) => {
|
||||
const payload = {
|
||||
...formData,
|
||||
onboarding_step: {
|
||||
...user.onboarding_step,
|
||||
profile_complete: true,
|
||||
},
|
||||
};
|
||||
|
||||
const userService = new UserService();
|
||||
|
||||
await userService
|
||||
.updateMe(payload)
|
||||
.then((response) => {
|
||||
userStore.setCurrentUser(response);
|
||||
router.push(next_path?.toString() || "/");
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Success!",
|
||||
message: "Details updated successfully.",
|
||||
});
|
||||
})
|
||||
.catch((err) => {});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
reset({
|
||||
first_name: user.first_name,
|
||||
last_name: user.last_name,
|
||||
role: user.role,
|
||||
});
|
||||
}
|
||||
}, [user, reset]);
|
||||
|
||||
return (
|
||||
<form
|
||||
className="h-full w-full space-y-7 sm:space-y-10 overflow-y-auto sm:flex sm:flex-col sm:items-start sm:justify-center"
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
>
|
||||
<div className="relative sm:text-lg">
|
||||
<div className="text-gray-800 absolute -top-1 -left-3">{'"'}</div>
|
||||
<h5>Hey there 👋🏻</h5>
|
||||
<h5 className="mt-5 mb-6">Let{"'"}s get you onboard!</h5>
|
||||
<h4 className="text-xl sm:text-2xl font-semibold">Set up your Plane profile.</h4>
|
||||
</div>
|
||||
|
||||
<div className="space-y-7 sm:w-3/4 md:w-2/5">
|
||||
<div className="space-y-1 text-sm">
|
||||
<label htmlFor="firstName">First Name</label>
|
||||
<Input
|
||||
id="firstName"
|
||||
autoComplete="off"
|
||||
placeholder="Enter your first name..."
|
||||
{...register("first_name", {
|
||||
required: "First name is required",
|
||||
})}
|
||||
/>
|
||||
{errors.first_name && <div className="text-sm text-red-500">{errors.first_name.message}</div>}
|
||||
</div>
|
||||
<div className="space-y-1 text-sm">
|
||||
<label htmlFor="lastName">Last Name</label>
|
||||
<Input
|
||||
id="lastName"
|
||||
autoComplete="off"
|
||||
placeholder="Enter your last name..."
|
||||
{...register("last_name", {
|
||||
required: "Last name is required",
|
||||
})}
|
||||
/>
|
||||
{errors.last_name && <div className="text-sm text-red-500">{errors.last_name.message}</div>}
|
||||
</div>
|
||||
<div className="space-y-1 text-sm">
|
||||
<span>What{"'"}s your role?</span>
|
||||
<div className="w-full">
|
||||
<Controller
|
||||
name="role"
|
||||
control={control}
|
||||
rules={{ required: "This field is required" }}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<Listbox as="div" value={value} onChange={onChange} className="relative flex-shrink-0 text-left">
|
||||
<Listbox.Button
|
||||
type="button"
|
||||
className={`flex items-center justify-between gap-1 w-full rounded-md border border-custom-border-300 shadow-sm duration-300 focus:outline-none px-3 py-2 text-sm`}
|
||||
>
|
||||
<span className="text-custom-text-400">{value || "Select your role..."}</span>
|
||||
<ChevronDownIcon className="h-3 w-3" aria-hidden="true" />
|
||||
</Listbox.Button>
|
||||
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<Listbox.Options
|
||||
className={`absolute z-10 border border-custom-border-300 mt-1 overflow-y-auto rounded-md bg-custom-background-90 text-xs shadow-lg focus:outline-none w-full max-h-36 left-0 origin-top-left`}
|
||||
>
|
||||
<div className="space-y-1 p-2">
|
||||
{USER_ROLES.map((role) => (
|
||||
<Listbox.Option
|
||||
key={role.value}
|
||||
value={role.value}
|
||||
className={({ active, selected }) =>
|
||||
`cursor-pointer select-none truncate rounded px-1 py-1.5 ${
|
||||
active || selected ? "bg-custom-background-80" : ""
|
||||
} ${selected ? "text-custom-text-100" : "text-custom-text-200"}`
|
||||
}
|
||||
>
|
||||
{({ selected }) => (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{role.label}</span>
|
||||
</div>
|
||||
{selected && <CheckIcon className="h-4 w-4 flex-shrink-0" />}
|
||||
</div>
|
||||
)}
|
||||
</Listbox.Option>
|
||||
))}
|
||||
</div>
|
||||
</Listbox.Options>
|
||||
</Transition>
|
||||
</Listbox>
|
||||
)}
|
||||
/>
|
||||
{errors.role && <span className="text-sm text-red-500">{errors.role.message}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PrimaryButton type="submit" size="md" disabled={!isValid} loading={isSubmitting}>
|
||||
{isSubmitting ? "Updating..." : "Continue"}
|
||||
</PrimaryButton>
|
||||
</form>
|
||||
);
|
||||
});
|
10
apps/space/components/issues/board-views/block-downvotes.tsx
Normal file
10
apps/space/components/issues/board-views/block-downvotes.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
"use client";
|
||||
|
||||
export const IssueBlockDownVotes = ({ number }: { number: number }) => (
|
||||
<div className="h-6 rounded flex px-1.5 pl-1 py-1 items-center border-[0.5px] border-custom-border-300 text-custom-text-300 text-xs">
|
||||
<span className="material-symbols-rounded text-base !p-0 !m-0 rotate-180 text-custom-text-300">
|
||||
arrow_upward_alt
|
||||
</span>
|
||||
{number}
|
||||
</div>
|
||||
);
|
@ -1,32 +1,60 @@
|
||||
"use client";
|
||||
|
||||
// helpers
|
||||
import { renderDateFormat } from "constants/helpers";
|
||||
import { renderFullDate } from "constants/helpers";
|
||||
|
||||
export const findHowManyDaysLeft = (date: string | Date) => {
|
||||
const today = new Date();
|
||||
const eventDate = new Date(date);
|
||||
const timeDiff = Math.abs(eventDate.getTime() - today.getTime());
|
||||
|
||||
return Math.ceil(timeDiff / (1000 * 3600 * 24));
|
||||
};
|
||||
|
||||
const validDate = (date: any, state: string): string => {
|
||||
if (date === null || ["backlog", "unstarted", "cancelled"].includes(state))
|
||||
return `bg-gray-500/10 text-gray-500 border-gray-500/50`;
|
||||
else {
|
||||
const dueDateIcon = (
|
||||
date: string,
|
||||
stateGroup: string
|
||||
): {
|
||||
iconName: string;
|
||||
className: string;
|
||||
} => {
|
||||
let iconName = "calendar_today";
|
||||
let className = "";
|
||||
|
||||
if (!date || ["completed", "cancelled"].includes(stateGroup)) {
|
||||
iconName = "calendar_today";
|
||||
className = "";
|
||||
} else {
|
||||
const today = new Date();
|
||||
const dueDate = new Date(date);
|
||||
|
||||
if (dueDate < today) return `bg-red-500/10 text-red-500 border-red-500/50`;
|
||||
else return `bg-green-500/10 text-green-500 border-green-500/50`;
|
||||
if (dueDate < today) {
|
||||
iconName = "event_busy";
|
||||
className = "text-red-500";
|
||||
} else if (dueDate > today) {
|
||||
iconName = "calendar_today";
|
||||
className = "";
|
||||
} else {
|
||||
iconName = "today";
|
||||
className = "text-red-500";
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
iconName,
|
||||
className,
|
||||
};
|
||||
};
|
||||
|
||||
export const IssueBlockDueDate = ({ due_date, state }: any) => (
|
||||
<div
|
||||
className={`h-[24px] rounded-sm flex px-2 items-center border border-gray-300 gap-1 text-gray-700 text-xs font-medium
|
||||
${validDate(due_date, state)}`}
|
||||
>
|
||||
{renderDateFormat(due_date)}
|
||||
</div>
|
||||
);
|
||||
export const IssueBlockDueDate = ({ due_date, group }: { due_date: string; group: string }) => {
|
||||
const iconDetails = dueDateIcon(due_date, group);
|
||||
|
||||
return (
|
||||
<div className="rounded flex px-2.5 py-1 items-center border-[0.5px] border-custom-border-300 gap-1 text-custom-text-100 text-xs">
|
||||
<span className={`material-symbols-rounded text-sm -my-0.5 ${iconDetails.className}`}>
|
||||
{iconDetails.iconName}
|
||||
</span>
|
||||
{renderFullDate(due_date)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -6,11 +6,13 @@ export const IssueBlockLabels = ({ labels }: any) => (
|
||||
labels.length > 0 &&
|
||||
labels.map((_label: any) => (
|
||||
<div
|
||||
className={`h-[24px] rounded-sm flex px-1 items-center border gap-1 !bg-transparent !text-gray-700`}
|
||||
style={{ backgroundColor: `${_label?.color}10`, borderColor: `${_label?.color}50` }}
|
||||
key={_label?.id}
|
||||
className="flex cursor-default items-center flex-shrink-0 rounded-md border border-custom-border-300 px-2.5 py-1 text-xs shadow-sm"
|
||||
>
|
||||
<div className="w-[10px] h-[10px] rounded-full" style={{ backgroundColor: `${_label?.color}` }} />
|
||||
<div className="text-sm">{_label?.name}</div>
|
||||
<div className="flex items-center gap-1.5 text-custom-text-200">
|
||||
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: `${_label?.color}` }} />
|
||||
<div className="text-xs">{_label?.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
// types
|
||||
import { TIssuePriorityKey } from "store/types/issue";
|
||||
import { TIssuePriorityKey } from "types/issue";
|
||||
// constants
|
||||
import { issuePriorityFilter } from "constants/data";
|
||||
|
||||
@ -9,9 +9,10 @@ export const IssueBlockPriority = ({ priority }: { priority: TIssuePriorityKey |
|
||||
const priority_detail = priority != null ? issuePriorityFilter(priority) : null;
|
||||
|
||||
if (priority_detail === null) return <></>;
|
||||
|
||||
return (
|
||||
<div className={`w-[24px] h-[24px] rounded-sm flex justify-center items-center ${priority_detail?.className}`}>
|
||||
<span className="material-symbols-rounded text-[16px]">{priority_detail?.icon}</span>
|
||||
<div className={`h-6 w-6 rounded grid place-items-center border-[0.5px] ${priority_detail?.className}`}>
|
||||
<span className="material-symbols-rounded text-sm">{priority_detail?.icon}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -8,11 +8,11 @@ export const IssueBlockState = ({ state }: any) => {
|
||||
|
||||
if (stateGroup === null) return <></>;
|
||||
return (
|
||||
<div
|
||||
className={`h-[24px] rounded-sm flex px-1 items-center border ${stateGroup?.className} gap-1 !bg-transparent !text-gray-700`}
|
||||
>
|
||||
<stateGroup.icon />
|
||||
<div className="text-sm">{state?.name}</div>
|
||||
<div className="flex items-center justify-between gap-1 w-full rounded shadow-sm border-[0.5px] border-custom-border-300 duration-300 focus:outline-none px-2.5 py-1 text-xs cursor-pointer hover:bg-custom-background-80">
|
||||
<div className="flex items-center cursor-pointer w-full gap-1.5 text-custom-text-200">
|
||||
<stateGroup.icon />
|
||||
<div className="text-xs">{state?.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -0,0 +1,8 @@
|
||||
"use client";
|
||||
|
||||
export const IssueBlockUpVotes = ({ number }: { number: number }) => (
|
||||
<div className="h-6 rounded flex px-1.5 pl-1 py-1 items-center border-[0.5px] border-custom-border-300 text-custom-text-300 text-xs">
|
||||
<span className="material-symbols-rounded text-base !p-0 !m-0 text-custom-text-300">arrow_upward_alt</span>
|
||||
{number}
|
||||
</div>
|
||||
);
|
@ -2,32 +2,55 @@
|
||||
|
||||
// mobx react lite
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useMobxStore } from "lib/mobx/store-provider";
|
||||
|
||||
// components
|
||||
import { IssueBlockPriority } from "components/issues/board-views/block-priority";
|
||||
import { IssueBlockState } from "components/issues/board-views/block-state";
|
||||
import { IssueBlockLabels } from "components/issues/board-views/block-labels";
|
||||
import { IssueBlockDueDate } from "components/issues/board-views/block-due-date";
|
||||
// mobx hook
|
||||
import { useMobxStore } from "lib/mobx/store-provider";
|
||||
// interfaces
|
||||
import { IIssue } from "store/types/issue";
|
||||
import { IIssue } from "types/issue";
|
||||
import { RootStore } from "store/root";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
export const IssueListBlock = ({ issue }: { issue: IIssue }) => {
|
||||
const store: RootStore = useMobxStore();
|
||||
export const IssueListBlock = observer(({ issue }: { issue: IIssue }) => {
|
||||
const { project: projectStore, issueDetails: issueDetailStore }: RootStore = useMobxStore();
|
||||
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspace_slug, project_slug, board } = router.query;
|
||||
|
||||
const handleBlockClick = () => {
|
||||
issueDetailStore.setPeekId(issue.id);
|
||||
router.replace(
|
||||
{
|
||||
pathname: `/${workspace_slug?.toString()}/${project_slug}`,
|
||||
query: {
|
||||
board: board?.toString(),
|
||||
peekId: issue.id,
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
{ shallow: true }
|
||||
);
|
||||
// router.push(`/${workspace_slug?.toString()}/${project_slug}?board=${board?.toString()}&peekId=${issue.id}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-2 px-3 bg-white space-y-2 rounded-sm shadow">
|
||||
<div className="py-3 px-4 h-[118px] flex flex-col gap-1.5 bg-custom-background-100 rounded shadow-custom-shadow-sm border-[0.5px] border-custom-border-200">
|
||||
{/* id */}
|
||||
<div className="flex-shrink-0 text-sm text-gray-600 w-[60px]">
|
||||
{store?.project?.project?.identifier}-{issue?.sequence_id}
|
||||
<div className="text-xs text-custom-text-300 break-words">
|
||||
{projectStore?.project?.identifier}-{issue?.sequence_id}
|
||||
</div>
|
||||
|
||||
{/* name */}
|
||||
<div className="font-medium text-gray-800 h-full line-clamp-2">{issue.name}</div>
|
||||
<h6 onClick={handleBlockClick} className="text-sm font-medium break-words line-clamp-2 cursor-pointer">
|
||||
{issue.name}
|
||||
</h6>
|
||||
|
||||
{/* priority */}
|
||||
<div className="relative flex flex-wrap items-center gap-2 w-full">
|
||||
<div className="relative flex-grow flex items-end gap-2 w-full overflow-x-scroll hide-horizontal-scrollbar">
|
||||
{/* priority */}
|
||||
{issue?.priority && (
|
||||
<div className="flex-shrink-0">
|
||||
<IssueBlockPriority priority={issue?.priority} />
|
||||
@ -39,12 +62,6 @@ export const IssueListBlock = ({ issue }: { issue: IIssue }) => {
|
||||
<IssueBlockState state={issue?.state_detail} />
|
||||
</div>
|
||||
)}
|
||||
{/* labels */}
|
||||
{issue?.label_details && issue?.label_details.length > 0 && (
|
||||
<div className="flex-shrink-0">
|
||||
<IssueBlockLabels labels={issue?.label_details} />
|
||||
</div>
|
||||
)}
|
||||
{/* due date */}
|
||||
{issue?.target_date && (
|
||||
<div className="flex-shrink-0">
|
||||
@ -54,4 +71,4 @@ export const IssueListBlock = ({ issue }: { issue: IIssue }) => {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
@ -3,7 +3,7 @@
|
||||
// mobx react lite
|
||||
import { observer } from "mobx-react-lite";
|
||||
// interfaces
|
||||
import { IIssueState } from "store/types/issue";
|
||||
import { IIssueState } from "types/issue";
|
||||
// constants
|
||||
import { issueGroupFilter } from "constants/data";
|
||||
// mobx hook
|
||||
@ -18,14 +18,14 @@ export const IssueListHeader = observer(({ state }: { state: IIssueState }) => {
|
||||
if (stateGroup === null) return <></>;
|
||||
|
||||
return (
|
||||
<div className="py-2 flex items-center gap-2">
|
||||
<div className="w-[28px] h-[28px] flex justify-center items-center">
|
||||
<div className="pb-2 px-2 flex items-center">
|
||||
<div className="w-4 h-4 flex justify-center items-center flex-shrink-0">
|
||||
<stateGroup.icon />
|
||||
</div>
|
||||
<div className="font-medium capitalize">{state?.name}</div>
|
||||
<div className="bg-gray-200/50 text-gray-700 font-medium text-xs w-full max-w-[26px] h-[20px] flex justify-center items-center rounded-full">
|
||||
<div className="font-semibold text-custom-text-200 capitalize ml-2 mr-3 truncate">{state?.name}</div>
|
||||
<span className="text-custom-text-300 rounded-full flex-shrink-0">
|
||||
{store.issue.getCountOfIssuesByState(state.id)}
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
@ -6,7 +6,7 @@ import { observer } from "mobx-react-lite";
|
||||
import { IssueListHeader } from "components/issues/board-views/kanban/header";
|
||||
import { IssueListBlock } from "components/issues/board-views/kanban/block";
|
||||
// interfaces
|
||||
import { IIssueState, IIssue } from "store/types/issue";
|
||||
import { IIssueState, IIssue } from "types/issue";
|
||||
// mobx hook
|
||||
import { useMobxStore } from "lib/mobx/store-provider";
|
||||
import { RootStore } from "store/root";
|
||||
@ -19,20 +19,20 @@ export const IssueKanbanView = observer(() => {
|
||||
{store?.issue?.states &&
|
||||
store?.issue?.states.length > 0 &&
|
||||
store?.issue?.states.map((_state: IIssueState) => (
|
||||
<div className="flex-shrink-0 relative w-[340px] h-full flex flex-col">
|
||||
<div key={_state.id} className="flex-shrink-0 relative w-[340px] h-full flex flex-col">
|
||||
<div className="flex-shrink-0">
|
||||
<IssueListHeader state={_state} />
|
||||
</div>
|
||||
<div className="w-full h-full overflow-hidden overflow-y-auto">
|
||||
<div className="w-full h-full overflow-hidden overflow-y-auto hide-vertical-scrollbar">
|
||||
{store.issue.getFilteredIssuesByState(_state.id) &&
|
||||
store.issue.getFilteredIssuesByState(_state.id).length > 0 ? (
|
||||
<div className="space-y-3 pb-2">
|
||||
<div className="space-y-3 pb-2 px-2">
|
||||
{store.issue.getFilteredIssuesByState(_state.id).map((_issue: IIssue) => (
|
||||
<IssueListBlock issue={_issue} />
|
||||
<IssueListBlock key={_issue.id} issue={_issue} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative w-full h-full flex justify-center items-center p-10 text-center text-sm text-gray-600">
|
||||
<div className="relative w-full h-full flex justify-center items-center p-10 text-center text-sm text-custom-text-200">
|
||||
No Issues are available.
|
||||
</div>
|
||||
)}
|
||||
|
@ -1,59 +1,102 @@
|
||||
"use client";
|
||||
|
||||
// mobx react lite
|
||||
import { FC } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import { observer } from "mobx-react-lite";
|
||||
// components
|
||||
import { IssueBlockPriority } from "components/issues/board-views/block-priority";
|
||||
import { IssueBlockState } from "components/issues/board-views/block-state";
|
||||
import { IssueBlockLabels } from "components/issues/board-views/block-labels";
|
||||
import { IssueBlockDueDate } from "components/issues/board-views/block-due-date";
|
||||
import { IssueBlockUpVotes } from "components/issues/board-views/block-upvotes";
|
||||
import { IssueBlockDownVotes } from "components/issues/board-views/block-downvotes";
|
||||
// mobx hook
|
||||
import { useMobxStore } from "lib/mobx/store-provider";
|
||||
// interfaces
|
||||
import { IIssue } from "store/types/issue";
|
||||
import { IIssue } from "types/issue";
|
||||
// store
|
||||
import { RootStore } from "store/root";
|
||||
|
||||
export const IssueListBlock = ({ issue }: { issue: IIssue }) => {
|
||||
const store: RootStore = useMobxStore();
|
||||
export const IssueListBlock: FC<{ issue: IIssue }> = observer((props) => {
|
||||
const { issue } = props;
|
||||
// store
|
||||
const { project: projectStore, issueDetails: issueDetailStore }: RootStore = useMobxStore();
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspace_slug, project_slug, board } = router.query;
|
||||
|
||||
const handleBlockClick = () => {
|
||||
issueDetailStore.setPeekId(issue.id);
|
||||
router.replace(
|
||||
{
|
||||
pathname: `/${workspace_slug?.toString()}/${project_slug}`,
|
||||
query: {
|
||||
board: board?.toString(),
|
||||
peekId: issue.id,
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
{ shallow: true }
|
||||
);
|
||||
// router.push(`/${workspace_slug?.toString()}/${project_slug}?board=${board?.toString()}&peekId=${issue.id}`);
|
||||
};
|
||||
|
||||
const totalUpVotes = issue.votes.filter((v) => v.vote === 1);
|
||||
const totalDownVotes = issue.votes.filter((v) => v.vote === -1);
|
||||
|
||||
return (
|
||||
<div className="p-2 px-3 relative flex items-center gap-3">
|
||||
<div className="relative flex items-center gap-3 w-full">
|
||||
<div className="flex items-center px-6 py-3.5 relative gap-10 bg-custom-background-100">
|
||||
<div className="relative flex items-center gap-5 w-full flex-grow overflow-hidden">
|
||||
{/* id */}
|
||||
<div className="flex-shrink-0 text-sm text-gray-600 w-[60px]">
|
||||
{store?.project?.project?.identifier}-{issue?.sequence_id}
|
||||
<div className="flex-shrink-0 text-sm text-custom-text-300">
|
||||
{projectStore?.project?.identifier}-{issue?.sequence_id}
|
||||
</div>
|
||||
{/* name */}
|
||||
<div className="font-medium text-gray-800 h-full line-clamp-1">{issue.name}</div>
|
||||
<div onClick={handleBlockClick} className="font-medium text-sm truncate flex-grow cursor-pointer">
|
||||
{issue.name}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* priority */}
|
||||
{issue?.priority && (
|
||||
<div className="flex-shrink-0">
|
||||
<IssueBlockPriority priority={issue?.priority} />
|
||||
</div>
|
||||
)}
|
||||
<div className="inline-flex flex-shrink-0 items-center gap-2 text-xs">
|
||||
{projectStore.deploySettings?.votes && (
|
||||
<>
|
||||
{/* upvotes */}
|
||||
<div className="flex-shrink-0">
|
||||
<IssueBlockUpVotes number={totalUpVotes.length} />
|
||||
</div>
|
||||
{/* downotes */}
|
||||
<div className="flex-shrink-0">
|
||||
<IssueBlockDownVotes number={totalDownVotes.length} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* state */}
|
||||
{issue?.state_detail && (
|
||||
<div className="flex-shrink-0">
|
||||
<IssueBlockState state={issue?.state_detail} />
|
||||
</div>
|
||||
)}
|
||||
{/* priority */}
|
||||
{issue?.priority && (
|
||||
<div className="flex-shrink-0">
|
||||
<IssueBlockPriority priority={issue?.priority} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* labels */}
|
||||
{issue?.label_details && issue?.label_details.length > 0 && (
|
||||
<div className="flex-shrink-0">
|
||||
<IssueBlockLabels labels={issue?.label_details} />
|
||||
</div>
|
||||
)}
|
||||
{/* state */}
|
||||
{issue?.state_detail && (
|
||||
<div className="flex-shrink-0">
|
||||
<IssueBlockState state={issue?.state_detail} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* due date */}
|
||||
{issue?.target_date && (
|
||||
<div className="flex-shrink-0">
|
||||
<IssueBlockDueDate due_date={issue?.target_date} group={issue?.state_detail?.group} />
|
||||
</div>
|
||||
)}
|
||||
{/* labels */}
|
||||
{issue?.label_details && issue?.label_details.length > 0 && (
|
||||
<div className="flex-shrink-0">
|
||||
<IssueBlockLabels labels={issue?.label_details} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* due date */}
|
||||
{issue?.target_date && (
|
||||
<div className="flex-shrink-0">
|
||||
<IssueBlockDueDate due_date={issue?.target_date} group={issue?.state_detail?.group} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
@ -3,7 +3,7 @@
|
||||
// mobx react lite
|
||||
import { observer } from "mobx-react-lite";
|
||||
// interfaces
|
||||
import { IIssueState } from "store/types/issue";
|
||||
import { IIssueState } from "types/issue";
|
||||
// constants
|
||||
import { issueGroupFilter } from "constants/data";
|
||||
// mobx hook
|
||||
@ -18,14 +18,12 @@ export const IssueListHeader = observer(({ state }: { state: IIssueState }) => {
|
||||
if (stateGroup === null) return <></>;
|
||||
|
||||
return (
|
||||
<div className="py-2 px-3 flex items-center gap-2">
|
||||
<div className="w-[28px] h-[28px] flex justify-center items-center">
|
||||
<div className="px-6 py-2 flex items-center">
|
||||
<div className="w-4 h-4 flex justify-center items-center">
|
||||
<stateGroup.icon />
|
||||
</div>
|
||||
<div className="font-medium capitalize">{state?.name}</div>
|
||||
<div className="bg-gray-200/50 text-gray-700 font-medium text-xs w-full max-w-[26px] h-[20px] flex justify-center items-center rounded-full">
|
||||
{store.issue.getCountOfIssuesByState(state.id)}
|
||||
</div>
|
||||
<div className="font-semibold capitalize ml-2 mr-3">{state?.name}</div>
|
||||
<div className="text-custom-text-200">{store.issue.getCountOfIssuesByState(state.id)}</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
@ -1,35 +1,37 @@
|
||||
"use client";
|
||||
|
||||
// mobx react lite
|
||||
import { useEffect } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
// components
|
||||
import { IssueListHeader } from "components/issues/board-views/list/header";
|
||||
import { IssueListBlock } from "components/issues/board-views/list/block";
|
||||
// interfaces
|
||||
import { IIssueState, IIssue } from "store/types/issue";
|
||||
import { IIssueState, IIssue } from "types/issue";
|
||||
// mobx hook
|
||||
import { useMobxStore } from "lib/mobx/store-provider";
|
||||
// store
|
||||
import { RootStore } from "store/root";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
export const IssueListView = observer(() => {
|
||||
const store: RootStore = useMobxStore();
|
||||
const { issue: issueStore }: RootStore = useMobxStore();
|
||||
|
||||
return (
|
||||
<>
|
||||
{store?.issue?.states &&
|
||||
store?.issue?.states.length > 0 &&
|
||||
store?.issue?.states.map((_state: IIssueState) => (
|
||||
<div className="relative w-full">
|
||||
{issueStore?.states &&
|
||||
issueStore?.states.length > 0 &&
|
||||
issueStore?.states.map((_state: IIssueState) => (
|
||||
<div key={_state.id} className="relative w-full">
|
||||
<IssueListHeader state={_state} />
|
||||
{store.issue.getFilteredIssuesByState(_state.id) &&
|
||||
store.issue.getFilteredIssuesByState(_state.id).length > 0 ? (
|
||||
<div className="bg-white divide-y">
|
||||
{store.issue.getFilteredIssuesByState(_state.id).map((_issue: IIssue) => (
|
||||
<IssueListBlock issue={_issue} />
|
||||
{issueStore.getFilteredIssuesByState(_state.id) &&
|
||||
issueStore.getFilteredIssuesByState(_state.id).length > 0 ? (
|
||||
<div className="divide-y divide-custom-border-200">
|
||||
{issueStore.getFilteredIssuesByState(_state.id).map((_issue: IIssue) => (
|
||||
<IssueListBlock key={_issue.id} issue={_issue} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white p-5 text-sm text-gray-600">No Issues are available.</div>
|
||||
<div className="px-6 py-3.5 text-sm text-custom-text-200 bg-custom-background-100">
|
||||
No Issues are available.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
@ -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,10 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
// mobx react lite
|
||||
import { observer } from "mobx-react-lite";
|
||||
// components
|
||||
import IssueStateFilter from "./state";
|
||||
import IssueLabelFilter from "./label";
|
||||
import IssuePriorityFilter from "./priority";
|
||||
import IssueDateFilter from "./date";
|
||||
// mobx hook
|
||||
import { useMobxStore } from "lib/mobx/store-provider";
|
||||
import { RootStore } from "store/root";
|
||||
@ -14,24 +12,39 @@ import { RootStore } from "store/root";
|
||||
const IssueFilter = observer(() => {
|
||||
const store: RootStore = useMobxStore();
|
||||
|
||||
const clearAllFilters = () => {};
|
||||
const router = useRouter();
|
||||
const { workspace_slug, project_slug } = router.query as { workspace_slug: string; project_slug: string };
|
||||
|
||||
const clearAllFilters = () => {
|
||||
// router.replace(
|
||||
// store.issue.getURLDefinition(workspace_slug, project_slug, {
|
||||
// key: "all",
|
||||
// removeAll: true,
|
||||
// })
|
||||
// );
|
||||
};
|
||||
|
||||
// if (store.issue.getIfFiltersIsEmpty()) return null;
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-5 flex justify-start items-center flex-wrap gap-2 text-sm">
|
||||
{/* state */}
|
||||
{store?.issue?.states && <IssueStateFilter />}
|
||||
{/* labels */}
|
||||
{store?.issue?.labels && <IssueLabelFilter />}
|
||||
{/* priority */}
|
||||
<IssuePriorityFilter />
|
||||
{/* due date */}
|
||||
<IssueDateFilter />
|
||||
{/* clear all filters */}
|
||||
<div
|
||||
className="flex items-center gap-2 border border-gray-300 px-2 py-1 pr-1 rounded cursor-pointer hover:bg-gray-200/60"
|
||||
onClick={clearAllFilters}
|
||||
>
|
||||
<div>Clear all filters</div>
|
||||
<div className="flex-shrink-0 min-h-[50px] h-auto py-1.5 border-b border-custom-border-200 relative flex items-center shadow-md bg-whiate select-none">
|
||||
<div className="px-5 flex justify-start items-center flex-wrap gap-2 text-sm">
|
||||
{/* state */}
|
||||
{/* {store.issue.checkIfFilterExistsForKey("state") && <IssueStateFilter />} */}
|
||||
{/* labels */}
|
||||
{/* {store.issue.checkIfFilterExistsForKey("label") && <IssueLabelFilter />} */}
|
||||
{/* priority */}
|
||||
{/* {store.issue.checkIfFilterExistsForKey("priority") && <IssuePriorityFilter />} */}
|
||||
{/* clear all filters */}
|
||||
<div
|
||||
className="flex items-center gap-2 border border-custom-border-200 px-2 py-1 cursor-pointer text-xs rounded-full"
|
||||
onClick={clearAllFilters}
|
||||
>
|
||||
<div>Clear all filters</div>
|
||||
<div className="flex-shrink-0 w-3 h-3 cursor-pointer flex justify-center items-center overflow-hidden rounded-sm">
|
||||
<span className="material-symbols-rounded text-[12px]">close</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@ -1,33 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
// mobx react lite
|
||||
import { observer } from "mobx-react-lite";
|
||||
// mobx hook
|
||||
import { useMobxStore } from "lib/mobx/store-provider";
|
||||
// interfaces
|
||||
import { IIssueLabel } from "store/types/issue";
|
||||
// constants
|
||||
import { issueGroupFilter } from "constants/data";
|
||||
import { IIssueLabel } from "types/issue";
|
||||
|
||||
export const RenderIssueLabel = observer(({ label }: { label: IIssueLabel }) => {
|
||||
const store = useMobxStore();
|
||||
|
||||
const removeLabelFromFilter = () => {};
|
||||
const router = useRouter();
|
||||
const { workspace_slug, project_slug } = router.query as { workspace_slug: string; project_slug: string };
|
||||
|
||||
const removeLabelFromFilter = () => {
|
||||
// router.replace(
|
||||
// store.issue.getURLDefinition(workspace_slug, project_slug, {
|
||||
// key: "label",
|
||||
// value: label?.id,
|
||||
// })
|
||||
// );
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex-shrink-0 relative flex items-center flex-wrap gap-1 border px-[2px] py-0.5 rounded-full select-none"
|
||||
style={{ color: label?.color, backgroundColor: `${label?.color}10`, borderColor: `${label?.color}50` }}
|
||||
className="flex-shrink-0 relative flex items-center flex-wrap gap-1 px-2 py-0.5 rounded-full select-none"
|
||||
style={{ color: label?.color, backgroundColor: `${label?.color}10` }}
|
||||
>
|
||||
<div className="flex-shrink-0 w-[20px] h-[20px] flex justify-center items-center overflow-hidden rounded-full">
|
||||
<div className="w-[10px] h-[10px] rounded-full" style={{ backgroundColor: `${label?.color}` }} />
|
||||
</div>
|
||||
<div className="text-sm font-medium whitespace-nowrap">{label?.name}</div>
|
||||
<div
|
||||
className="flex-shrink-0 w-[20px] h-[20px] cursor-pointer flex justify-center items-center overflow-hidden rounded-full text-gray-500 hover:bg-gray-200/60 hover:text-gray-600"
|
||||
className="flex-shrink-0 w-1.5 h-1.5 flex justify-center items-center overflow-hidden rounded-full"
|
||||
style={{ backgroundColor: `${label?.color}` }}
|
||||
/>
|
||||
|
||||
<div className="font-medium whitespace-nowrap text-xs">{label?.name}</div>
|
||||
<div
|
||||
className="flex-shrink-0 w-3 h-3 cursor-pointer flex justify-center items-center overflow-hidden rounded-full"
|
||||
onClick={removeLabelFromFilter}
|
||||
>
|
||||
<span className="material-symbols-rounded text-[14px]">close</span>
|
||||
<span className="material-symbols-rounded text-xs">close</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@ -1,11 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
// mobx react lite
|
||||
import { observer } from "mobx-react-lite";
|
||||
// components
|
||||
import { RenderIssueLabel } from "./filter-label-block";
|
||||
// interfaces
|
||||
import { IIssueLabel } from "store/types/issue";
|
||||
import { IIssueLabel } from "types/issue";
|
||||
// mobx hook
|
||||
import { useMobxStore } from "lib/mobx/store-provider";
|
||||
import { RootStore } from "store/root";
|
||||
@ -13,21 +12,36 @@ import { RootStore } from "store/root";
|
||||
const IssueLabelFilter = observer(() => {
|
||||
const store: RootStore = useMobxStore();
|
||||
|
||||
const clearLabelFilters = () => {};
|
||||
const router = useRouter();
|
||||
const { workspace_slug, project_slug } = router.query as { workspace_slug: string; project_slug: string };
|
||||
|
||||
const clearLabelFilters = () => {
|
||||
// router.replace(
|
||||
// store.issue.getURLDefinition(workspace_slug, project_slug, {
|
||||
// key: "label",
|
||||
// removeAll: true,
|
||||
// })
|
||||
// );
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-2 border border-gray-300 px-2 py-1 pr-1 rounded">
|
||||
<div className="flex-shrink-0 font-medium">Labels</div>
|
||||
<div className="flex items-center gap-2 border border-custom-border-300 px-2 py-1 rounded-full text-xs">
|
||||
<div className="flex-shrink-0 text-custom-text-200">Labels</div>
|
||||
<div className="relative flex flex-wrap items-center gap-1">
|
||||
{store?.issue?.labels &&
|
||||
store?.issue?.labels.map((_label: IIssueLabel, _index: number) => <RenderIssueLabel label={_label} />)}
|
||||
{/* {store?.issue?.labels &&
|
||||
store?.issue?.labels.map(
|
||||
(_label: IIssueLabel, _index: number) =>
|
||||
store.issue.getUserSelectedFilter("label", _label.id) && (
|
||||
<RenderIssueLabel key={_label.id} label={_label} />
|
||||
)
|
||||
)} */}
|
||||
</div>
|
||||
<div
|
||||
className="flex-shrink-0 w-[20px] h-[20px] cursor-pointer flex justify-center items-center overflow-hidden rounded-sm text-gray-500 hover:bg-gray-200/60 hover:text-gray-600"
|
||||
className="flex-shrink-0 w-3 h-3 cursor-pointer flex justify-center items-center overflow-hidden rounded-sm"
|
||||
onClick={clearLabelFilters}
|
||||
>
|
||||
<span className="material-symbols-rounded text-[16px]">close</span>
|
||||
<span className="material-symbols-rounded text-[12px]">close</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
@ -1,32 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
// mobx react lite
|
||||
import { observer } from "mobx-react-lite";
|
||||
// mobx hook
|
||||
import { useMobxStore } from "lib/mobx/store-provider";
|
||||
// interfaces
|
||||
import { IIssuePriorityFilters } from "store/types/issue";
|
||||
import { IIssuePriorityFilters } from "types/issue";
|
||||
|
||||
export const RenderIssuePriority = observer(({ priority }: { priority: IIssuePriorityFilters }) => {
|
||||
const store = useMobxStore();
|
||||
|
||||
const removePriorityFromFilter = () => {};
|
||||
const router = useRouter();
|
||||
const { workspace_slug, project_slug } = router.query as { workspace_slug: string; project_slug: string };
|
||||
|
||||
const removePriorityFromFilter = () => {
|
||||
// router.replace(
|
||||
// store.issue.getURLDefinition(workspace_slug, project_slug, {
|
||||
// key: "priority",
|
||||
// value: priority?.key,
|
||||
// })
|
||||
// );
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex-shrink-0 relative flex items-center flex-wrap gap-1 border px-[2px] py-0.5 rounded-full select-none ${
|
||||
className={`flex-shrink-0 relative flex items-center flex-wrap gap-1 px-2 py-0.5 text-xs rounded-full select-none ${
|
||||
priority.className || ``
|
||||
}`}
|
||||
>
|
||||
<div className="flex-shrink-0 w-[20px] h-[20px] flex justify-center items-center overflow-hidden rounded-full">
|
||||
<span className="material-symbols-rounded text-[14px]">{priority?.icon}</span>
|
||||
<div className="flex-shrink-0 flex justify-center items-center overflow-hidden rounded-full">
|
||||
<span className="material-symbols-rounded text-xs">{priority?.icon}</span>
|
||||
</div>
|
||||
<div className="text-sm font-medium whitespace-nowrap">{priority?.title}</div>
|
||||
<div className="whitespace-nowrap">{priority?.title}</div>
|
||||
<div
|
||||
className="flex-shrink-0 w-[20px] h-[20px] cursor-pointer flex justify-center items-center overflow-hidden rounded-full text-gray-500 hover:bg-gray-200/60 hover:text-gray-600"
|
||||
className="flex-shrink-0 w-3 h-3 cursor-pointer flex justify-center items-center overflow-hidden rounded-full"
|
||||
onClick={removePriorityFromFilter}
|
||||
>
|
||||
<span className="material-symbols-rounded text-[14px]">close</span>
|
||||
<span className="material-symbols-rounded text-xs">close</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@ -1,5 +1,4 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
// mobx react lite
|
||||
import { observer } from "mobx-react-lite";
|
||||
// mobx hook
|
||||
@ -7,28 +6,46 @@ import { useMobxStore } from "lib/mobx/store-provider";
|
||||
// components
|
||||
import { RenderIssuePriority } from "./filter-priority-block";
|
||||
// interfaces
|
||||
import { IIssuePriorityFilters } from "store/types/issue";
|
||||
import { IIssuePriorityFilters } from "types/issue";
|
||||
// constants
|
||||
import { issuePriorityFilters } from "constants/data";
|
||||
|
||||
const IssuePriorityFilter = observer(() => {
|
||||
const store = useMobxStore();
|
||||
|
||||
const router = useRouter();
|
||||
const { workspace_slug, project_slug } = router.query as { workspace_slug: string; project_slug: string };
|
||||
|
||||
const clearPriorityFilters = () => {
|
||||
// router.replace(
|
||||
// store.issue.getURLDefinition(workspace_slug, project_slug, {
|
||||
// key: "priority",
|
||||
// removeAll: true,
|
||||
// })
|
||||
// );
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-2 border border-gray-300 px-2 py-1 pr-1 rounded">
|
||||
<div className="flex-shrink-0 font-medium">Priority</div>
|
||||
<div className="flex items-center gap-2 border border-custom-border-300 px-2 py-1 rounded-full text-xs">
|
||||
<div className="flex-shrink-0 text-custom-text-200">Priority</div>
|
||||
<div className="relative flex flex-wrap items-center gap-1">
|
||||
{issuePriorityFilters.map((_priority: IIssuePriorityFilters, _index: number) => (
|
||||
<RenderIssuePriority priority={_priority} />
|
||||
))}
|
||||
{/* {issuePriorityFilters.map(
|
||||
(_priority: IIssuePriorityFilters, _index: number) =>
|
||||
store.issue.getUserSelectedFilter("priority", _priority.key) && (
|
||||
<RenderIssuePriority key={_priority.key} priority={_priority} />
|
||||
)
|
||||
)} */}
|
||||
</div>
|
||||
<div
|
||||
className={`w-[20px] h-[20px] cursor-pointer flex justify-center items-center overflow-hidden rounded-sm text-gray-500 hover:bg-gray-200/60 hover:text-gray-600`}
|
||||
className="flex-shrink-0 w-3 h-3 cursor-pointer flex justify-center items-center overflow-hidden rounded-sm"
|
||||
onClick={() => {
|
||||
clearPriorityFilters();
|
||||
}}
|
||||
>
|
||||
<span className={`material-symbols-rounded text-[16px]`}>close</span>
|
||||
<span className="material-symbols-rounded text-[12px]">close</span>
|
||||
</div>
|
||||
</div>{" "}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
@ -1,37 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
// mobx react lite
|
||||
import { observer } from "mobx-react-lite";
|
||||
// mobx hook
|
||||
import { useMobxStore } from "lib/mobx/store-provider";
|
||||
// interfaces
|
||||
import { IIssueState } from "store/types/issue";
|
||||
import { IIssueState } from "types/issue";
|
||||
// constants
|
||||
import { issueGroupFilter } from "constants/data";
|
||||
|
||||
export const RenderIssueState = observer(({ state }: { state: IIssueState }) => {
|
||||
const store = useMobxStore();
|
||||
|
||||
const router = useRouter();
|
||||
const { workspace_slug, project_slug } = router.query as { workspace_slug: string; project_slug: string };
|
||||
|
||||
const stateGroup = issueGroupFilter(state.group);
|
||||
|
||||
const removeStateFromFilter = () => {};
|
||||
const removeStateFromFilter = () => {
|
||||
// router.replace(
|
||||
// store.issue.getURLDefinition(workspace_slug, project_slug, {
|
||||
// key: "state",
|
||||
// value: state?.id,
|
||||
// })
|
||||
// );
|
||||
};
|
||||
|
||||
if (stateGroup === null) return <></>;
|
||||
return (
|
||||
<div
|
||||
className={`flex-shrink-0 relative flex items-center flex-wrap gap-1 border px-[2px] py-0.5 rounded-full select-none ${
|
||||
stateGroup.className || ``
|
||||
}`}
|
||||
>
|
||||
<div className="flex-shrink-0 w-[20px] h-[20px] flex justify-center items-center overflow-hidden rounded-full">
|
||||
<div className={`inline-flex items-center gap-x-1 rounded-full px-2 py-0.5 ${stateGroup.className || ``}`}>
|
||||
<div className="flex-shrink-0 w-3 h-3 flex justify-center items-center overflow-hidden rounded-full">
|
||||
<stateGroup.icon />
|
||||
</div>
|
||||
<div className="text-sm font-medium whitespace-nowrap">{state?.name}</div>
|
||||
<div className="text-xs font-medium whitespace-nowrap">{state?.name}</div>
|
||||
<div
|
||||
className="flex-shrink-0 w-[20px] h-[20px] cursor-pointer flex justify-center items-center overflow-hidden rounded-full text-gray-500 hover:bg-gray-200/60 hover:text-gray-600"
|
||||
className="flex-shrink-0 w-3 h-3 cursor-pointer flex justify-center items-center overflow-hidden rounded-full"
|
||||
onClick={removeStateFromFilter}
|
||||
>
|
||||
<span className="material-symbols-rounded text-[14px]">close</span>
|
||||
<span className="material-symbols-rounded text-xs">close</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@ -1,11 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
// mobx react lite
|
||||
import { observer } from "mobx-react-lite";
|
||||
// components
|
||||
import { RenderIssueState } from "./filter-state-block";
|
||||
// interfaces
|
||||
import { IIssueState } from "store/types/issue";
|
||||
import { IIssueState } from "types/issue";
|
||||
// mobx hook
|
||||
import { useMobxStore } from "lib/mobx/store-provider";
|
||||
import { RootStore } from "store/root";
|
||||
@ -13,21 +12,36 @@ import { RootStore } from "store/root";
|
||||
const IssueStateFilter = observer(() => {
|
||||
const store: RootStore = useMobxStore();
|
||||
|
||||
const clearStateFilters = () => {};
|
||||
const router = useRouter();
|
||||
const { workspace_slug, project_slug } = router.query as { workspace_slug: string; project_slug: string };
|
||||
|
||||
const clearStateFilters = () => {
|
||||
// router.replace(
|
||||
// store.issue.getURLDefinition(workspace_slug, project_slug, {
|
||||
// key: "state",
|
||||
// removeAll: true,
|
||||
// })
|
||||
// );
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-2 border border-gray-300 px-2 py-1 pr-1 rounded">
|
||||
<div className="flex-shrink-0 font-medium">State</div>
|
||||
<div className="flex items-center gap-2 border border-custom-border-300 px-2 py-1 rounded-full text-xs">
|
||||
<div className="flex-shrink-0 text-custom-text-200">State</div>
|
||||
<div className="relative flex flex-wrap items-center gap-1">
|
||||
{store?.issue?.states &&
|
||||
store?.issue?.states.map((_state: IIssueState, _index: number) => <RenderIssueState state={_state} />)}
|
||||
{/* {store?.issue?.states &&
|
||||
store?.issue?.states.map(
|
||||
(_state: IIssueState, _index: number) =>
|
||||
store.issue.getUserSelectedFilter("state", _state.id) && (
|
||||
<RenderIssueState key={_state.id} state={_state} />
|
||||
)
|
||||
)} */}
|
||||
</div>
|
||||
<div
|
||||
className="flex-shrink-0 w-[20px] h-[20px] cursor-pointer flex justify-center items-center overflow-hidden rounded-sm text-gray-500 hover:bg-gray-200/60 hover:text-gray-600"
|
||||
className="flex-shrink-0 w-3 h-3 cursor-pointer flex justify-center items-center overflow-hidden rounded-sm"
|
||||
onClick={clearStateFilters}
|
||||
>
|
||||
<span className="material-symbols-rounded text-[16px]">close</span>
|
||||
<span className="material-symbols-rounded text-[12px]">close</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
@ -1,17 +1,15 @@
|
||||
"use client";
|
||||
|
||||
// next imports
|
||||
import { useEffect } from "react";
|
||||
import Image from "next/image";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useRouter } from "next/router";
|
||||
// components
|
||||
import { NavbarSearch } from "./search";
|
||||
import { NavbarIssueBoardView } from "./issue-board-view";
|
||||
import { NavbarIssueFilter } from "./issue-filter";
|
||||
import { NavbarIssueView } from "./issue-view";
|
||||
import { NavbarTheme } from "./theme";
|
||||
// mobx react lite
|
||||
import { observer } from "mobx-react-lite";
|
||||
// mobx
|
||||
// lib
|
||||
import { useMobxStore } from "lib/mobx/store-provider";
|
||||
// store
|
||||
import { RootStore } from "store/root";
|
||||
|
||||
const renderEmoji = (emoji: string | { name: string; color: string }) => {
|
||||
@ -27,21 +25,41 @@ const renderEmoji = (emoji: string | { name: string; color: string }) => {
|
||||
};
|
||||
|
||||
const IssueNavbar = observer(() => {
|
||||
const store: RootStore = useMobxStore();
|
||||
const { project: projectStore }: RootStore = useMobxStore();
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspace_slug, project_slug, board } = router.query;
|
||||
|
||||
useEffect(() => {
|
||||
if (workspace_slug && project_slug) {
|
||||
projectStore.fetchProjectSettings(workspace_slug.toString(), project_slug.toString());
|
||||
}
|
||||
}, [projectStore, workspace_slug, project_slug]);
|
||||
|
||||
useEffect(() => {
|
||||
if (workspace_slug && projectStore) {
|
||||
if (board) {
|
||||
projectStore.setActiveBoard(board.toString());
|
||||
} else {
|
||||
router.push(`/${workspace_slug}/${project_slug}?board=list`);
|
||||
projectStore.setActiveBoard("list");
|
||||
}
|
||||
}
|
||||
}, [board, router, projectStore, workspace_slug, project_slug]);
|
||||
|
||||
return (
|
||||
<div className="px-5 relative w-full flex items-center gap-4">
|
||||
{/* project detail */}
|
||||
<div className="flex-shrink-0 flex items-center gap-2">
|
||||
<div className="w-[32px] h-[32px] rounded-sm flex justify-center items-center bg-gray-100 text-[24px]">
|
||||
{store?.project?.project && store?.project?.project?.emoji ? (
|
||||
renderEmoji(store?.project?.project?.emoji)
|
||||
<div className="w-4 h-4 flex justify-center items-center">
|
||||
{projectStore?.project && projectStore?.project?.emoji ? (
|
||||
renderEmoji(projectStore?.project?.emoji)
|
||||
) : (
|
||||
<Image src="/plane-logo.webp" alt="plane logo" className="w-[24px] h-[24px]" height="24" width="24" />
|
||||
)}
|
||||
</div>
|
||||
<div className="font-medium text-lg max-w-[300px] line-clamp-1 overflow-hidden">
|
||||
{store?.project?.project?.name || `...`}
|
||||
{projectStore?.project?.name || `...`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -55,16 +73,10 @@ const IssueNavbar = observer(() => {
|
||||
<NavbarIssueBoardView />
|
||||
</div>
|
||||
|
||||
{/* issue filters */}
|
||||
{/* <div className="flex-shrink-0 relative flex items-center gap-2">
|
||||
<NavbarIssueFilter />
|
||||
<NavbarIssueView />
|
||||
</div> */}
|
||||
|
||||
{/* theming */}
|
||||
{/* <div className="flex-shrink-0 relative">
|
||||
<div className="flex-shrink-0 relative">
|
||||
<NavbarTheme />
|
||||
</div> */}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
@ -1,54 +1,63 @@
|
||||
"use client";
|
||||
|
||||
// next imports
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
// mobx react lite
|
||||
import { useRouter } from "next/router";
|
||||
import { observer } from "mobx-react-lite";
|
||||
// constants
|
||||
import { issueViews } from "constants/data";
|
||||
// interfaces
|
||||
import { TIssueBoardKeys } from "store/types";
|
||||
// mobx
|
||||
import { useMobxStore } from "lib/mobx/store-provider";
|
||||
import { RootStore } from "store/root";
|
||||
|
||||
export const NavbarIssueBoardView = observer(() => {
|
||||
const store: RootStore = useMobxStore();
|
||||
const { project: projectStore, issue: issueStore }: RootStore = useMobxStore();
|
||||
|
||||
const router = useRouter();
|
||||
const routerParams = useParams();
|
||||
const { workspace_slug, project_slug } = router.query as { workspace_slug: string; project_slug: string };
|
||||
|
||||
const { workspace_slug, project_slug } = routerParams as { workspace_slug: string; project_slug: string };
|
||||
|
||||
const handleCurrentBoardView = (boardView: TIssueBoardKeys) => {
|
||||
store?.issue?.setCurrentIssueBoardView(boardView);
|
||||
router.replace(`/${workspace_slug}/${project_slug}?board=${boardView}`);
|
||||
const handleCurrentBoardView = (boardView: string) => {
|
||||
projectStore.setActiveBoard(boardView);
|
||||
router.push(
|
||||
`/${workspace_slug}/${project_slug}?board=${boardView}${
|
||||
issueStore?.filteredLabels && issueStore?.filteredLabels.length > 0
|
||||
? `&labels=${issueStore?.filteredLabels.join(",")}`
|
||||
: ""
|
||||
}${
|
||||
issueStore?.filteredPriorities && issueStore?.filteredPriorities.length > 0
|
||||
? `&priorities=${issueStore?.filteredPriorities.join(",")}`
|
||||
: ""
|
||||
}${
|
||||
issueStore?.filteredStates && issueStore?.filteredStates.length > 0
|
||||
? `&states=${issueStore?.filteredStates.join(",")}`
|
||||
: ""
|
||||
}`
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{store?.project?.workspaceProjectSettings &&
|
||||
issueViews &&
|
||||
issueViews.length > 0 &&
|
||||
issueViews.map(
|
||||
(_view) =>
|
||||
store?.project?.workspaceProjectSettings?.views[_view?.key] && (
|
||||
{projectStore?.viewOptions &&
|
||||
Object.keys(projectStore?.viewOptions).map((viewKey: string) => {
|
||||
if (projectStore?.viewOptions[viewKey]) {
|
||||
return (
|
||||
<div
|
||||
key={_view?.key}
|
||||
className={`w-[28px] h-[28px] flex justify-center items-center rounded-sm cursor-pointer text-gray-500 ${
|
||||
_view?.key === store?.issue?.currentIssueBoardView
|
||||
? `bg-gray-200/60 text-gray-800`
|
||||
: `hover:bg-gray-200/60 text-gray-600`
|
||||
key={viewKey}
|
||||
className={`w-[28px] h-[28px] flex justify-center items-center rounded-sm cursor-pointer ${
|
||||
viewKey === projectStore?.activeBoard
|
||||
? `bg-custom-background-80 text-custom-text-200`
|
||||
: `hover:bg-custom-background-80 text-custom-text-300`
|
||||
}`}
|
||||
onClick={() => handleCurrentBoardView(_view?.key)}
|
||||
title={_view?.title}
|
||||
onClick={() => handleCurrentBoardView(viewKey)}
|
||||
title={viewKey}
|
||||
>
|
||||
<span className={`material-symbols-rounded text-[18px] ${_view?.className ? _view?.className : ``}`}>
|
||||
{_view?.icon}
|
||||
<span
|
||||
className={`material-symbols-rounded text-[18px] ${
|
||||
issueViews[viewKey]?.className ? issueViews[viewKey]?.className : ``
|
||||
}`}
|
||||
>
|
||||
{issueViews[viewKey]?.icon}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
);
|
||||
}
|
||||
})}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
@ -1,13 +1,111 @@
|
||||
"use client";
|
||||
|
||||
// mobx react lite
|
||||
import { useRouter } from "next/router";
|
||||
import { observer } from "mobx-react-lite";
|
||||
// icons
|
||||
import { ChevronDownIcon } from "@heroicons/react/20/solid";
|
||||
// mobx
|
||||
import { useMobxStore } from "lib/mobx/store-provider";
|
||||
import { RootStore } from "store/root";
|
||||
// components
|
||||
import { Dropdown } from "components/ui/dropdown";
|
||||
// constants
|
||||
import { issueGroupFilter } from "constants/data";
|
||||
|
||||
const PRIORITIES = ["urgent", "high", "medium", "low"];
|
||||
|
||||
export const NavbarIssueFilter = observer(() => {
|
||||
const store: RootStore = useMobxStore();
|
||||
|
||||
return <div>Filter</div>;
|
||||
const router = useRouter();
|
||||
const pathName = router.asPath;
|
||||
|
||||
const handleOnSelect = (key: "states" | "labels" | "priorities", value: string) => {
|
||||
// if (key === "states") {
|
||||
// store.issue.userSelectedStates = store.issue.userSelectedStates.includes(value)
|
||||
// ? store.issue.userSelectedStates.filter((s) => s !== value)
|
||||
// : [...store.issue.userSelectedStates, value];
|
||||
// } else if (key === "labels") {
|
||||
// store.issue.userSelectedLabels = store.issue.userSelectedLabels.includes(value)
|
||||
// ? store.issue.userSelectedLabels.filter((l) => l !== value)
|
||||
// : [...store.issue.userSelectedLabels, value];
|
||||
// } else if (key === "priorities") {
|
||||
// store.issue.userSelectedPriorities = store.issue.userSelectedPriorities.includes(value)
|
||||
// ? store.issue.userSelectedPriorities.filter((p) => p !== value)
|
||||
// : [...store.issue.userSelectedPriorities, value];
|
||||
// }
|
||||
// const paramsCommaSeparated = `${`board=${store.issue.currentIssueBoardView || "list"}`}${
|
||||
// store.issue.userSelectedPriorities.length > 0 ? `&priorities=${store.issue.userSelectedPriorities.join(",")}` : ""
|
||||
// }${store.issue.userSelectedStates.length > 0 ? `&states=${store.issue.userSelectedStates.join(",")}` : ""}${
|
||||
// store.issue.userSelectedLabels.length > 0 ? `&labels=${store.issue.userSelectedLabels.join(",")}` : ""
|
||||
// }`;
|
||||
// router.replace(`${pathName}?${paramsCommaSeparated}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
button={
|
||||
<>
|
||||
<span>Filters</span>
|
||||
<ChevronDownIcon className="h-3 w-3" aria-hidden="true" />
|
||||
</>
|
||||
}
|
||||
items={[
|
||||
{
|
||||
display: "Priority",
|
||||
children: PRIORITIES.map((priority) => ({
|
||||
display: (
|
||||
<span className="capitalize flex items-center gap-x-2">
|
||||
<span className="material-symbols-rounded text-[14px]">
|
||||
{priority === "urgent"
|
||||
? "error"
|
||||
: priority === "high"
|
||||
? "signal_cellular_alt"
|
||||
: priority === "medium"
|
||||
? "signal_cellular_alt_2_bar"
|
||||
: "signal_cellular_alt_1_bar"}
|
||||
</span>
|
||||
{priority}
|
||||
</span>
|
||||
),
|
||||
onClick: () => handleOnSelect("priorities", priority),
|
||||
isSelected: store.issue.filteredPriorities.includes(priority),
|
||||
})),
|
||||
},
|
||||
{
|
||||
display: "State",
|
||||
children: (store.issue.states || []).map((state) => {
|
||||
const stateGroup = issueGroupFilter(state.group);
|
||||
|
||||
return {
|
||||
display: (
|
||||
<span className="capitalize flex items-center gap-x-2">
|
||||
{stateGroup && <stateGroup.icon />}
|
||||
{state.name}
|
||||
</span>
|
||||
),
|
||||
onClick: () => handleOnSelect("states", state.id),
|
||||
isSelected: store.issue.filteredStates.includes(state.id),
|
||||
};
|
||||
}),
|
||||
},
|
||||
{
|
||||
display: "Labels",
|
||||
children: [...(store.issue.labels || [])].map((label) => ({
|
||||
display: (
|
||||
<span className="capitalize flex items-center gap-x-2">
|
||||
<span
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{
|
||||
backgroundColor: label.color || "#000",
|
||||
}}
|
||||
/>
|
||||
{label.name}
|
||||
</span>
|
||||
),
|
||||
onClick: () => handleOnSelect("labels", label.id),
|
||||
isSelected: store.issue.filteredLabels.includes(label.id),
|
||||
})),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
@ -1,28 +1,25 @@
|
||||
"use client";
|
||||
|
||||
// next theme
|
||||
import { useTheme } from "next-themes";
|
||||
|
||||
// mobx react lite
|
||||
import { observer } from "mobx-react-lite";
|
||||
// mobx
|
||||
import { useMobxStore } from "lib/mobx/store-provider";
|
||||
import { RootStore } from "store/root";
|
||||
|
||||
export const NavbarTheme = observer(() => {
|
||||
const store: RootStore = useMobxStore();
|
||||
const { setTheme, theme } = useTheme();
|
||||
|
||||
const handleTheme = () => {
|
||||
store?.theme?.setTheme(store?.theme?.theme === "light" ? "dark" : "light");
|
||||
setTheme(theme === "light" ? "dark" : "light");
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative w-[28px] h-[28px] flex justify-center items-center rounded-sm cursor-pointer bg-gray-100 hover:bg-gray-200 hover:bg-gray-200/60 text-gray-600 transition-all"
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTheme}
|
||||
className="relative w-7 h-7 grid place-items-center bg-custom-background-100 hover:bg-custom-background-80 text-custom-text-100 rounded"
|
||||
>
|
||||
{store?.theme?.theme === "light" ? (
|
||||
<span className={`material-symbols-rounded text-[18px]`}>dark_mode</span>
|
||||
) : (
|
||||
<span className={`material-symbols-rounded text-[18px]`}>light_mode</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="material-symbols-rounded text-sm">{theme === "light" ? "dark_mode" : "light_mode"}</span>
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
104
apps/space/components/issues/peek-overview/add-comment.tsx
Normal file
104
apps/space/components/issues/peek-overview/add-comment.tsx
Normal file
@ -0,0 +1,104 @@
|
||||
import React, { useRef } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useForm, Controller } from "react-hook-form";
|
||||
// lib
|
||||
import { useMobxStore } from "lib/mobx/store-provider";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
// ui
|
||||
import { SecondaryButton } from "components/ui";
|
||||
// types
|
||||
import { Comment } from "types/issue";
|
||||
// components
|
||||
import { TipTapEditor } from "components/tiptap";
|
||||
|
||||
const defaultValues: Partial<Comment> = {
|
||||
comment_html: "",
|
||||
};
|
||||
|
||||
type Props = {
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const AddComment: React.FC<Props> = observer((props) => {
|
||||
const { disabled = false } = props;
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
control,
|
||||
setValue,
|
||||
watch,
|
||||
formState: { isSubmitting },
|
||||
reset,
|
||||
} = useForm<Comment>({ defaultValues });
|
||||
|
||||
const router = useRouter();
|
||||
const { workspace_slug, project_slug } = router.query as { workspace_slug: string; project_slug: string };
|
||||
|
||||
const { user: userStore, issueDetails: issueDetailStore } = useMobxStore();
|
||||
|
||||
const issueId = issueDetailStore.peekId;
|
||||
|
||||
const editorRef = useRef<any>(null);
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const onSubmit = async (formData: Comment) => {
|
||||
if (!workspace_slug || !project_slug || !issueId || isSubmitting || !formData.comment_html) return;
|
||||
|
||||
await issueDetailStore
|
||||
.addIssueComment(workspace_slug, project_slug, issueId, formData)
|
||||
.then(() => {
|
||||
reset(defaultValues);
|
||||
editorRef.current?.clearEditor();
|
||||
})
|
||||
.catch(() => {
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: "Comment could not be posted. Please try again.",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="issue-comments-section">
|
||||
<Controller
|
||||
name="comment_html"
|
||||
control={control}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<TipTapEditor
|
||||
workspaceSlug={workspace_slug as string}
|
||||
ref={editorRef}
|
||||
value={
|
||||
!value || value === "" || (typeof value === "object" && Object.keys(value).length === 0)
|
||||
? watch("comment_html")
|
||||
: value
|
||||
}
|
||||
customClassName="p-3 min-h-[50px] shadow-sm"
|
||||
debouncedUpdatesEnabled={false}
|
||||
onChange={(comment_json: Object, comment_html: string) => {
|
||||
onChange(comment_html);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<SecondaryButton
|
||||
onClick={(e) => {
|
||||
userStore.requiredLogin(() => {
|
||||
handleSubmit(onSubmit)(e);
|
||||
});
|
||||
}}
|
||||
type="submit"
|
||||
disabled={isSubmitting || disabled}
|
||||
className="mt-2"
|
||||
>
|
||||
{isSubmitting ? "Adding..." : "Comment"}
|
||||
</SecondaryButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
@ -0,0 +1,190 @@
|
||||
import React, { useEffect, useState, useRef } from "react";
|
||||
import { useForm, Controller } from "react-hook-form";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Menu, Transition } from "@headlessui/react";
|
||||
// lib
|
||||
import { useMobxStore } from "lib/mobx/store-provider";
|
||||
// icons
|
||||
import { ChatBubbleLeftEllipsisIcon, CheckIcon, XMarkIcon, EllipsisVerticalIcon } from "@heroicons/react/24/outline";
|
||||
// helpers
|
||||
import { timeAgo } from "helpers/date-time.helper";
|
||||
// types
|
||||
import { Comment } from "types/issue";
|
||||
// components
|
||||
import { TipTapEditor } from "components/tiptap";
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string;
|
||||
comment: Comment;
|
||||
};
|
||||
|
||||
export const CommentCard: React.FC<Props> = observer((props) => {
|
||||
const { comment, workspaceSlug } = props;
|
||||
// store
|
||||
const { user: userStore, issueDetails: issueDetailStore } = useMobxStore();
|
||||
// states
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
const {
|
||||
formState: { isSubmitting },
|
||||
handleSubmit,
|
||||
control,
|
||||
} = useForm<any>({
|
||||
defaultValues: { comment_html: comment.comment_html },
|
||||
});
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!workspaceSlug || !issueDetailStore.peekId) return;
|
||||
issueDetailStore.deleteIssueComment(workspaceSlug, comment.project, issueDetailStore.peekId, comment.id);
|
||||
};
|
||||
|
||||
const handleCommentUpdate = async (formData: Comment) => {
|
||||
if (!workspaceSlug || !issueDetailStore.peekId) return;
|
||||
issueDetailStore.updateIssueComment(workspaceSlug, comment.project, issueDetailStore.peekId, comment.id, formData);
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative flex items-start space-x-3">
|
||||
<div className="relative px-1">
|
||||
{comment.actor_detail.avatar && comment.actor_detail.avatar !== "" ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={comment.actor_detail.avatar}
|
||||
alt={
|
||||
comment.actor_detail.is_bot ? comment.actor_detail.first_name + " Bot" : comment.actor_detail.display_name
|
||||
}
|
||||
height={30}
|
||||
width={30}
|
||||
className="grid h-7 w-7 place-items-center rounded-full border-2 border-custom-border-200"
|
||||
/>
|
||||
) : (
|
||||
<div className={`grid h-7 w-7 place-items-center rounded-full border-2 border-white bg-gray-500 text-white`}>
|
||||
{comment.actor_detail.is_bot
|
||||
? comment.actor_detail.first_name.charAt(0)
|
||||
: comment.actor_detail.display_name.charAt(0)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<span className="absolute -bottom-0.5 -right-1 rounded-tl bg-custom-background-80 px-0.5 py-px">
|
||||
<ChatBubbleLeftEllipsisIcon className="h-3.5 w-3.5 text-custom-text-200" aria-hidden="true" />
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div>
|
||||
<div className="text-xs">
|
||||
{comment.actor_detail.is_bot ? comment.actor_detail.first_name + " Bot" : comment.actor_detail.display_name}
|
||||
</div>
|
||||
<p className="mt-0.5 text-xs text-custom-text-200">
|
||||
<>Commented {timeAgo(comment.created_at)}</>
|
||||
</p>
|
||||
</div>
|
||||
<div className="issue-comments-section p-0">
|
||||
<form
|
||||
onSubmit={handleSubmit(handleCommentUpdate)}
|
||||
className={`flex-col gap-2 ${isEditing ? "flex" : "hidden"}`}
|
||||
>
|
||||
<div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="comment_html"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<TipTapEditor
|
||||
workspaceSlug={workspaceSlug as string}
|
||||
value={value}
|
||||
debouncedUpdatesEnabled={false}
|
||||
customClassName="min-h-[50px] p-3 shadow-sm"
|
||||
onChange={(comment_json: Object, comment_html: string) => {
|
||||
onChange(comment_html);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-1 self-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="group rounded border border-green-500 bg-green-500/20 p-2 shadow-md duration-300 hover:bg-green-500"
|
||||
>
|
||||
<CheckIcon className="h-3 w-3 text-green-500 duration-300 group-hover:text-white" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="group rounded border border-red-500 bg-red-500/20 p-2 shadow-md duration-300 hover:bg-red-500"
|
||||
onClick={() => setIsEditing(false)}
|
||||
>
|
||||
<XMarkIcon className="h-3 w-3 text-red-500 duration-300 group-hover:text-white" />
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<div className={`${isEditing ? "hidden" : ""}`}>
|
||||
<TipTapEditor
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
value={comment.comment_html}
|
||||
editable={false}
|
||||
customClassName="text-xs border border-custom-border-200 bg-custom-background-100"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{userStore?.currentUser?.id === comment?.actor_detail?.id && (
|
||||
<Menu as="div" className="relative w-min text-left">
|
||||
<Menu.Button
|
||||
type="button"
|
||||
onClick={() => {}}
|
||||
className="relative grid place-items-center rounded p-1 text-custom-text-200 hover:text-custom-text-100 outline-none cursor-pointer hover:bg-custom-background-80"
|
||||
>
|
||||
<EllipsisVerticalIcon className="h-5 w-5 text-custom-text-200 duration-300" />
|
||||
</Menu.Button>
|
||||
|
||||
<Transition
|
||||
as={React.Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<Menu.Items className="absolute z-10 overflow-y-scroll whitespace-nowrap rounded-md max-h-36 border right-0 origin-top-right mt-1 overflow-auto min-w-[8rem] border-custom-border-300 p-1 text-xs shadow-lg focus:outline-none bg-custom-background-90">
|
||||
<Menu.Item>
|
||||
{({ active }) => (
|
||||
<div className="py-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsEditing(true);
|
||||
}}
|
||||
className={`w-full select-none truncate rounded px-1 py-1.5 text-left text-custom-text-200 hover:bg-custom-background-80 ${
|
||||
active ? "bg-custom-background-80" : ""
|
||||
}`}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</Menu.Item>
|
||||
<Menu.Item>
|
||||
{({ active }) => (
|
||||
<div className="py-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDelete}
|
||||
className={`w-full select-none truncate rounded px-1 py-1.5 text-left text-custom-text-200 hover:bg-custom-background-80 ${
|
||||
active ? "bg-custom-background-80" : ""
|
||||
}`}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</Menu.Item>
|
||||
</Menu.Items>
|
||||
</Transition>
|
||||
</Menu>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
@ -0,0 +1,71 @@
|
||||
import { useEffect } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
// lib
|
||||
import { useMobxStore } from "lib/mobx/store-provider";
|
||||
// components
|
||||
import {
|
||||
PeekOverviewHeader,
|
||||
PeekOverviewIssueActivity,
|
||||
PeekOverviewIssueDetails,
|
||||
PeekOverviewIssueProperties,
|
||||
} from "components/issues/peek-overview";
|
||||
// types
|
||||
import { Loader } from "components/ui/loader";
|
||||
import { IIssue } from "types/issue";
|
||||
|
||||
type Props = {
|
||||
handleClose: () => void;
|
||||
issueDetails: IIssue | undefined;
|
||||
};
|
||||
|
||||
export const FullScreenPeekView: React.FC<Props> = observer((props) => {
|
||||
const { handleClose, issueDetails } = props;
|
||||
|
||||
return (
|
||||
<div className="h-full w-full grid grid-cols-10 divide-x divide-custom-border-200 overflow-hidden">
|
||||
<div className="h-full w-full flex flex-col col-span-7 overflow-hidden">
|
||||
<div className="w-full p-5">
|
||||
<PeekOverviewHeader handleClose={handleClose} issueDetails={issueDetails} />
|
||||
</div>
|
||||
{issueDetails ? (
|
||||
<div className="h-full w-full px-6 overflow-y-auto">
|
||||
{/* issue title and description */}
|
||||
<div className="w-full">
|
||||
<PeekOverviewIssueDetails issueDetails={issueDetails} />
|
||||
</div>
|
||||
{/* divider */}
|
||||
<div className="h-[1] w-full border-t border-custom-border-200 my-5" />
|
||||
{/* issue activity/comments */}
|
||||
<div className="w-full pb-5">
|
||||
<PeekOverviewIssueActivity issueDetails={issueDetails} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Loader className="px-6">
|
||||
<Loader.Item height="30px" />
|
||||
<div className="space-y-2 mt-3">
|
||||
<Loader.Item height="20px" width="70%" />
|
||||
<Loader.Item height="20px" width="60%" />
|
||||
<Loader.Item height="20px" width="60%" />
|
||||
</div>
|
||||
</Loader>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-span-3 h-full w-full overflow-y-auto">
|
||||
{/* issue properties */}
|
||||
<div className="w-full px-6 py-5">
|
||||
{issueDetails ? (
|
||||
<PeekOverviewIssueProperties issueDetails={issueDetails} />
|
||||
) : (
|
||||
<Loader className="mt-11 space-y-4">
|
||||
<Loader.Item height="30px" />
|
||||
<Loader.Item height="30px" />
|
||||
<Loader.Item height="30px" />
|
||||
<Loader.Item height="30px" />
|
||||
</Loader>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
145
apps/space/components/issues/peek-overview/header.tsx
Normal file
145
apps/space/components/issues/peek-overview/header.tsx
Normal file
@ -0,0 +1,145 @@
|
||||
import React from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
// headless ui
|
||||
import { Listbox, Transition } from "@headlessui/react";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
// ui
|
||||
import { Icon } from "components/ui";
|
||||
// icons
|
||||
import { East } from "@mui/icons-material";
|
||||
// helpers
|
||||
import { copyTextToClipboard } from "helpers/string.helper";
|
||||
// store
|
||||
import { IPeekMode } from "store/issue_details";
|
||||
import { RootStore } from "store/root";
|
||||
// lib
|
||||
import { useMobxStore } from "lib/mobx/store-provider";
|
||||
// types
|
||||
import { IIssue } from "types/issue";
|
||||
|
||||
type Props = {
|
||||
handleClose: () => void;
|
||||
issueDetails: IIssue | undefined;
|
||||
};
|
||||
|
||||
const peekModes: {
|
||||
key: IPeekMode;
|
||||
icon: string;
|
||||
label: string;
|
||||
}[] = [
|
||||
{ key: "side", icon: "side_navigation", label: "Side Peek" },
|
||||
{
|
||||
key: "modal",
|
||||
icon: "dialogs",
|
||||
label: "Modal Peek",
|
||||
},
|
||||
{
|
||||
key: "full",
|
||||
icon: "nearby",
|
||||
label: "Full Screen Peek",
|
||||
},
|
||||
];
|
||||
|
||||
export const PeekOverviewHeader: React.FC<Props> = (props) => {
|
||||
const { handleClose, issueDetails } = props;
|
||||
|
||||
const { issueDetails: issueDetailStore }: RootStore = useMobxStore();
|
||||
|
||||
const router = useRouter();
|
||||
const { workspace_slug } = router.query;
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const handleCopyLink = () => {
|
||||
const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
|
||||
|
||||
copyTextToClipboard(`${originURL}/${workspace_slug}/projects/${issueDetails?.project}/`).then(() => {
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Link copied!",
|
||||
message: "Issue link copied to clipboard",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-4">
|
||||
{issueDetailStore.peekMode === "side" && (
|
||||
<button type="button" onClick={handleClose} autoFocus={false}>
|
||||
<East
|
||||
sx={{
|
||||
fontSize: "14px",
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
<Listbox
|
||||
as="div"
|
||||
value={issueDetailStore.peekMode}
|
||||
onChange={(val) => issueDetailStore.setPeekMode(val)}
|
||||
className="relative flex-shrink-0 text-left"
|
||||
>
|
||||
<Listbox.Button
|
||||
className={`grid place-items-center ${issueDetailStore.peekMode === "full" ? "rotate-45" : ""}`}
|
||||
>
|
||||
<Icon iconName={peekModes.find((m) => m.key === issueDetailStore.peekMode)?.icon ?? ""} />
|
||||
</Listbox.Button>
|
||||
|
||||
<Transition
|
||||
as={React.Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<Listbox.Options className="absolute z-10 border border-custom-border-300 mt-1 overflow-y-auto rounded-md bg-custom-background-90 text-xs shadow-lg focus:outline-none left-0 origin-top-left min-w-[8rem] whitespace-nowrap">
|
||||
<div className="space-y-1 p-2">
|
||||
{peekModes.map((mode) => (
|
||||
<Listbox.Option
|
||||
key={mode.key}
|
||||
value={mode.key}
|
||||
className={({ active, selected }) =>
|
||||
`cursor-pointer select-none truncate rounded px-1 py-1.5 ${
|
||||
active || selected ? "bg-custom-background-80" : ""
|
||||
} ${selected ? "text-custom-text-100" : "text-custom-text-200"}`
|
||||
}
|
||||
>
|
||||
{({ selected }) => (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Icon
|
||||
iconName={mode.icon}
|
||||
className={`!text-base flex-shrink-0 -my-1 ${mode.key === "full" ? "rotate-45" : ""}`}
|
||||
/>
|
||||
{mode.label}
|
||||
</div>
|
||||
</div>
|
||||
{selected && <Icon iconName="done" />}
|
||||
</div>
|
||||
)}
|
||||
</Listbox.Option>
|
||||
))}
|
||||
</div>
|
||||
</Listbox.Options>
|
||||
</Transition>
|
||||
</Listbox>
|
||||
</div>
|
||||
{(issueDetailStore.peekMode === "side" || issueDetailStore.peekMode === "modal") && (
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<button type="button" onClick={handleCopyLink} className="-rotate-45">
|
||||
<Icon iconName="link" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
12
apps/space/components/issues/peek-overview/index.ts
Normal file
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,44 @@
|
||||
import React, { useEffect } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import { observer } from "mobx-react-lite";
|
||||
// lib
|
||||
import { useMobxStore } from "lib/mobx/store-provider";
|
||||
// components
|
||||
import { CommentCard, AddComment } from "components/issues/peek-overview";
|
||||
// types
|
||||
import { IIssue } from "types/issue";
|
||||
|
||||
type Props = {
|
||||
issueDetails: IIssue;
|
||||
};
|
||||
|
||||
export const PeekOverviewIssueActivity: React.FC<Props> = observer((props) => {
|
||||
const router = useRouter();
|
||||
const { workspace_slug } = router.query;
|
||||
|
||||
const { issueDetails: issueDetailStore, project: projectStore, user: userStore } = useMobxStore();
|
||||
|
||||
const comments = issueDetailStore.details[issueDetailStore.peekId || ""]?.comments || [];
|
||||
|
||||
console.log("issueDetailStore", issueDetailStore);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h4 className="font-medium">Activity</h4>
|
||||
{workspace_slug && (
|
||||
<div className="mt-4">
|
||||
<div className="space-y-4">
|
||||
{comments.map((comment: any) => (
|
||||
<CommentCard key={comment.id} comment={comment} workspaceSlug={workspace_slug?.toString()} />
|
||||
))}
|
||||
</div>
|
||||
{projectStore.deploySettings?.comments && (
|
||||
<div className="mt-4">
|
||||
<AddComment disabled={!userStore.currentUser} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
40
apps/space/components/issues/peek-overview/issue-details.tsx
Normal file
40
apps/space/components/issues/peek-overview/issue-details.tsx
Normal file
@ -0,0 +1,40 @@
|
||||
import { IssueReactions } from "components/issues/peek-overview";
|
||||
import { TipTapEditor } from "components/tiptap";
|
||||
import { useRouter } from "next/router";
|
||||
// types
|
||||
import { IIssue } from "types/issue";
|
||||
|
||||
type Props = {
|
||||
issueDetails: IIssue;
|
||||
};
|
||||
|
||||
export const PeekOverviewIssueDetails: React.FC<Props> = ({ issueDetails }) => {
|
||||
const router = useRouter();
|
||||
const { workspace_slug } = router.query;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<h6 className="font-medium text-custom-text-200">
|
||||
{issueDetails.project_detail.identifier}-{issueDetails.sequence_id}
|
||||
</h6>
|
||||
<h4 className="break-words text-2xl font-semibold">{issueDetails.name}</h4>
|
||||
{issueDetails.description_html !== "" && issueDetails.description_html !== "<p></p>" && (
|
||||
<TipTapEditor
|
||||
workspaceSlug={workspace_slug as string}
|
||||
value={
|
||||
!issueDetails.description_html ||
|
||||
issueDetails.description_html === "" ||
|
||||
(typeof issueDetails.description_html === "object" &&
|
||||
Object.keys(issueDetails.description_html).length === 0)
|
||||
? "<p></p>"
|
||||
: issueDetails.description_html
|
||||
}
|
||||
customClassName="p-3 min-h-[50px] shadow-sm"
|
||||
debouncedUpdatesEnabled={false}
|
||||
editable={false}
|
||||
/>
|
||||
)}
|
||||
<IssueReactions />
|
||||
</div>
|
||||
);
|
||||
};
|
@ -0,0 +1,101 @@
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import { observer } from "mobx-react-lite";
|
||||
// lib
|
||||
import { useMobxStore } from "lib/mobx/store-provider";
|
||||
// helpers
|
||||
import { groupReactions, renderEmoji } from "helpers/emoji.helper";
|
||||
// components
|
||||
import { ReactionSelector, Tooltip } from "components/ui";
|
||||
|
||||
export const IssueEmojiReactions: React.FC = observer(() => {
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspace_slug, project_slug } = router.query;
|
||||
// store
|
||||
const { user: userStore, issueDetails: issueDetailsStore } = useMobxStore();
|
||||
|
||||
const user = userStore?.currentUser;
|
||||
const issueId = issueDetailsStore.peekId;
|
||||
const reactions = issueId ? issueDetailsStore.details[issueId]?.reactions || [] : [];
|
||||
const groupedReactions = groupReactions(reactions, "reaction");
|
||||
|
||||
const handleReactionSelectClick = (reactionHex: string) => {
|
||||
if (!workspace_slug || !project_slug || !issueId) return;
|
||||
const userReaction = reactions?.find((r) => r.actor_detail.id === user?.id && r.reaction === reactionHex);
|
||||
if (userReaction) return;
|
||||
issueDetailsStore.addIssueReaction(workspace_slug.toString(), project_slug.toString(), issueId, {
|
||||
reaction: reactionHex,
|
||||
});
|
||||
};
|
||||
|
||||
const handleReactionClick = (reactionHex: string) => {
|
||||
if (!workspace_slug || !project_slug || !issueId) return;
|
||||
issueDetailsStore.removeIssueReaction(workspace_slug.toString(), project_slug.toString(), issueId, reactionHex);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (user) return;
|
||||
userStore.fetchCurrentUser();
|
||||
}, [user, userStore]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ReactionSelector
|
||||
onSelect={(value) => {
|
||||
userStore.requiredLogin(() => {
|
||||
handleReactionSelectClick(value);
|
||||
});
|
||||
}}
|
||||
size="md"
|
||||
/>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{Object.keys(groupedReactions || {}).map((reaction) => {
|
||||
const reactions = groupedReactions?.[reaction] ?? [];
|
||||
const REACTIONS_LIMIT = 1000;
|
||||
|
||||
if (reactions.length > 0)
|
||||
return (
|
||||
<Tooltip
|
||||
tooltipContent={
|
||||
<div>
|
||||
{reactions
|
||||
.map((r) => r.actor_detail.display_name)
|
||||
.splice(0, REACTIONS_LIMIT)
|
||||
.join(", ")}
|
||||
{reactions.length > REACTIONS_LIMIT && " and " + (reactions.length - REACTIONS_LIMIT) + " more"}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
userStore.requiredLogin(() => {
|
||||
handleReactionClick(reaction);
|
||||
});
|
||||
}}
|
||||
key={reaction}
|
||||
className={`flex items-center gap-1 text-custom-text-100 text-sm h-full px-2 py-1 rounded-md ${
|
||||
reactions?.some((r) => r.actor_detail.id === user?.id && r.reaction === reaction)
|
||||
? "bg-custom-primary-100/10"
|
||||
: "bg-custom-background-80"
|
||||
}`}
|
||||
>
|
||||
<span>{renderEmoji(reaction)}</span>
|
||||
<span
|
||||
className={
|
||||
reactions?.some((r) => r.actor_detail.id === user?.id && r.reaction === reaction)
|
||||
? "text-custom-primary-100"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
{groupedReactions?.[reaction].length}{" "}
|
||||
</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
155
apps/space/components/issues/peek-overview/issue-properties.tsx
Normal file
155
apps/space/components/issues/peek-overview/issue-properties.tsx
Normal file
@ -0,0 +1,155 @@
|
||||
// headless ui
|
||||
import { Disclosure } from "@headlessui/react";
|
||||
// import { getStateGroupIcon } from "components/icons";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
// icons
|
||||
import { Icon } from "components/ui";
|
||||
import { copyTextToClipboard, addSpaceIfCamelCase } from "helpers/string.helper";
|
||||
// types
|
||||
import { IIssue } from "types/issue";
|
||||
// constants
|
||||
import { issueGroupFilter, issuePriorityFilter } from "constants/data";
|
||||
import { useEffect } from "react";
|
||||
import { renderDateFormat } from "constants/helpers";
|
||||
import { IPeekMode } from "store/issue_details";
|
||||
import { useRouter } from "next/router";
|
||||
import { RootStore } from "store/root";
|
||||
import { useMobxStore } from "lib/mobx/store-provider";
|
||||
|
||||
type Props = {
|
||||
issueDetails: IIssue;
|
||||
mode?: IPeekMode;
|
||||
};
|
||||
|
||||
const validDate = (date: any, state: string): string => {
|
||||
if (date === null || ["backlog", "unstarted", "cancelled"].includes(state))
|
||||
return `bg-gray-500/10 text-gray-500 border-gray-500/50`;
|
||||
else {
|
||||
const today = new Date();
|
||||
const dueDate = new Date(date);
|
||||
|
||||
if (dueDate < today) return `bg-red-500/10 text-red-500 border-red-500/50`;
|
||||
else return `bg-green-500/10 text-green-500 border-green-500/50`;
|
||||
}
|
||||
};
|
||||
|
||||
export const PeekOverviewIssueProperties: React.FC<Props> = ({ issueDetails, mode }) => {
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const { issueDetails: issueDetailStore }: RootStore = useMobxStore();
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
|
||||
const startDate = issueDetails.start_date;
|
||||
const targetDate = issueDetails.target_date;
|
||||
|
||||
const minDate = startDate ? new Date(startDate) : null;
|
||||
minDate?.setDate(minDate.getDate());
|
||||
|
||||
const maxDate = targetDate ? new Date(targetDate) : null;
|
||||
maxDate?.setDate(maxDate.getDate());
|
||||
|
||||
const state = issueDetails.state_detail;
|
||||
const stateGroup = issueGroupFilter(state.group);
|
||||
|
||||
const priority = issueDetails.priority ? issuePriorityFilter(issueDetails.priority) : null;
|
||||
|
||||
const handleCopyLink = () => {
|
||||
const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
|
||||
|
||||
copyTextToClipboard(
|
||||
`${originURL}/${workspaceSlug}/projects/${issueDetails.project}/issues/${issueDetails.id}`
|
||||
).then(() => {
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Link copied!",
|
||||
message: "Issue link copied to clipboard",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={mode === "full" ? "divide-y divide-custom-border-200" : ""}>
|
||||
{mode === "full" && (
|
||||
<div className="flex justify-between gap-2 pb-3">
|
||||
<h6 className="flex items-center gap-2 font-medium">
|
||||
{/* {getStateGroupIcon(issue.state_detail.group, "16", "16", issue.state_detail.color)} */}
|
||||
{issueDetails.project_detail.identifier}-{issueDetails.sequence_id}
|
||||
</h6>
|
||||
<div className="flex items-center gap-2">
|
||||
<button type="button" onClick={handleCopyLink} className="-rotate-45">
|
||||
<Icon iconName="link" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className={`space-y-4 ${mode === "full" ? "pt-3" : ""}`}>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<div className="flex-shrink-0 w-1/4 flex items-center gap-2 font-medium">
|
||||
<Icon iconName="radio_button_checked" className="!text-base flex-shrink-0" />
|
||||
<span className="flex-grow truncate">State</span>
|
||||
</div>
|
||||
<div className="w-3/4">
|
||||
{stateGroup && (
|
||||
<div className="inline-flex bg-custom-background-80 text-sm rounded px-2.5 py-0.5">
|
||||
<div className="flex items-center gap-1.5 text-left text-custom-text-100">
|
||||
<stateGroup.icon />
|
||||
{addSpaceIfCamelCase(state?.name ?? "")}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<div className="flex-shrink-0 w-1/4 flex items-center gap-2 font-medium">
|
||||
<Icon iconName="signal_cellular_alt" className="!text-base flex-shrink-0" />
|
||||
<span className="flex-grow truncate">Priority</span>
|
||||
</div>
|
||||
<div className="w-3/4">
|
||||
<div
|
||||
className={`inline-flex items-center gap-1.5 text-left text-sm capitalize rounded px-2.5 py-0.5 ${
|
||||
priority?.key === "urgent"
|
||||
? "border-red-500/20 bg-red-500/20 text-red-500"
|
||||
: priority?.key === "high"
|
||||
? "border-orange-500/20 bg-orange-500/20 text-orange-500"
|
||||
: priority?.key === "medium"
|
||||
? "border-yellow-500/20 bg-yellow-500/20 text-yellow-500"
|
||||
: priority?.key === "low"
|
||||
? "border-green-500/20 bg-green-500/20 text-green-500"
|
||||
: "bg-custom-background-80 border-custom-border-200"
|
||||
}`}
|
||||
>
|
||||
{priority && (
|
||||
<span className="grid place-items-center -my-1">
|
||||
<Icon iconName={priority?.icon!} />
|
||||
</span>
|
||||
)}
|
||||
<span>{priority?.title ?? "None"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<div className="flex-shrink-0 w-1/4 flex items-center gap-2 font-medium">
|
||||
<Icon iconName="calendar_today" className="!text-base flex-shrink-0" />
|
||||
<span className="flex-grow truncate">Due date</span>
|
||||
</div>
|
||||
<div>
|
||||
{issueDetails.target_date ? (
|
||||
<div
|
||||
className={`h-[24px] rounded-md flex px-2.5 py-1 items-center border border-custom-border-100 gap-1 text-custom-text-100 text-xs font-medium
|
||||
${validDate(issueDetails.target_date, state)}`}
|
||||
>
|
||||
{renderDateFormat(issueDetails.target_date)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-custom-text-200">Empty</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -0,0 +1,24 @@
|
||||
import { IssueEmojiReactions, IssueVotes } from "components/issues/peek-overview";
|
||||
import { useMobxStore } from "lib/mobx/store-provider";
|
||||
|
||||
export const IssueReactions: React.FC = () => {
|
||||
const { project: projectStore } = useMobxStore();
|
||||
|
||||
return (
|
||||
<div className="flex gap-3 items-center mt-4">
|
||||
{projectStore?.deploySettings?.votes && (
|
||||
<>
|
||||
<div className="flex gap-2 items-center">
|
||||
<IssueVotes />
|
||||
</div>
|
||||
<div className="w-0.5 h-8 bg-custom-background-200" />
|
||||
</>
|
||||
)}
|
||||
{projectStore?.deploySettings?.reactions && (
|
||||
<div className="flex gap-2 items-center">
|
||||
<IssueEmojiReactions />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
@ -0,0 +1,129 @@
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
// mobx
|
||||
import { observer } from "mobx-react-lite";
|
||||
// lib
|
||||
import { useMobxStore } from "lib/mobx/store-provider";
|
||||
import { Tooltip } from "components/ui";
|
||||
|
||||
export const IssueVotes: React.FC = observer(() => {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const { workspace_slug, project_slug } = router.query;
|
||||
|
||||
const { user: userStore, issueDetails: issueDetailsStore } = useMobxStore();
|
||||
|
||||
const user = userStore?.currentUser;
|
||||
const issueId = issueDetailsStore.peekId;
|
||||
|
||||
const votes = issueId ? issueDetailsStore.details[issueId]?.votes : [];
|
||||
|
||||
const allUpVotes = votes?.filter((vote) => vote.vote === 1);
|
||||
const allDownVotes = votes?.filter((vote) => vote.vote === -1);
|
||||
|
||||
const isUpVotedByUser = allUpVotes?.some((vote) => vote.actor === user?.id);
|
||||
const isDownVotedByUser = allDownVotes?.some((vote) => vote.actor === user?.id);
|
||||
|
||||
const handleVote = async (e: any, voteValue: 1 | -1) => {
|
||||
if (!workspace_slug || !project_slug || !issueId) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
const actionPerformed = votes?.find((vote) => vote.actor === user?.id && vote.vote === voteValue);
|
||||
|
||||
if (actionPerformed)
|
||||
await issueDetailsStore.removeIssueVote(workspace_slug.toString(), project_slug.toString(), issueId);
|
||||
else
|
||||
await issueDetailsStore.addIssueVote(workspace_slug.toString(), project_slug.toString(), issueId, {
|
||||
vote: voteValue,
|
||||
});
|
||||
|
||||
setIsSubmitting(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (user) return;
|
||||
|
||||
userStore.fetchCurrentUser();
|
||||
}, [user, userStore]);
|
||||
|
||||
const VOTES_LIMIT = 1000;
|
||||
|
||||
return (
|
||||
<div className="flex gap-2 items-center">
|
||||
{/* upvote button 👇 */}
|
||||
<Tooltip
|
||||
tooltipContent={
|
||||
<div>
|
||||
{allUpVotes.length > 0 ? (
|
||||
<>
|
||||
{allUpVotes
|
||||
.map((r) => r.actor_detail.display_name)
|
||||
.splice(0, VOTES_LIMIT)
|
||||
.join(", ")}
|
||||
{allUpVotes.length > VOTES_LIMIT && " and " + (allUpVotes.length - VOTES_LIMIT) + " more"}
|
||||
</>
|
||||
) : (
|
||||
"No upvotes yet"
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isSubmitting}
|
||||
onClick={(e) => {
|
||||
userStore.requiredLogin(() => {
|
||||
handleVote(e, 1);
|
||||
});
|
||||
}}
|
||||
className={`flex items-center justify-center overflow-hidden px-2 gap-x-1 border rounded focus:outline-none ${
|
||||
isUpVotedByUser ? "border-custom-primary-200 text-custom-primary-200" : "border-custom-border-300"
|
||||
}`}
|
||||
>
|
||||
<span className="material-symbols-rounded text-base !p-0 !m-0">arrow_upward_alt</span>
|
||||
<span className="text-sm font-normal transition-opacity ease-in-out">{allUpVotes.length}</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
{/* downvote button 👇 */}
|
||||
<Tooltip
|
||||
tooltipContent={
|
||||
<div>
|
||||
{allDownVotes.length > 0 ? (
|
||||
<>
|
||||
{allDownVotes
|
||||
.map((r) => r.actor_detail.display_name)
|
||||
.splice(0, VOTES_LIMIT)
|
||||
.join(", ")}
|
||||
{allDownVotes.length > VOTES_LIMIT && " and " + (allDownVotes.length - VOTES_LIMIT) + " more"}
|
||||
</>
|
||||
) : (
|
||||
"No downvotes yet"
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isSubmitting}
|
||||
onClick={(e) => {
|
||||
userStore.requiredLogin(() => {
|
||||
handleVote(e, -1);
|
||||
});
|
||||
}}
|
||||
className={`flex items-center justify-center overflow-hidden px-2 gap-x-1 border rounded focus:outline-none ${
|
||||
isDownVotedByUser ? "border-red-600 text-red-600" : "border-custom-border-300"
|
||||
}`}
|
||||
>
|
||||
<span className="material-symbols-rounded text-base !p-0 !m-0">arrow_downward_alt</span>
|
||||
<span className="text-sm font-normal transition-opacity ease-in-out">{allDownVotes.length}</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
});
|
127
apps/space/components/issues/peek-overview/layout.tsx
Normal file
127
apps/space/components/issues/peek-overview/layout.tsx
Normal file
@ -0,0 +1,127 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
// mobx
|
||||
import { observer } from "mobx-react-lite";
|
||||
// headless ui
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
// components
|
||||
import { FullScreenPeekView, SidePeekView } from "components/issues/peek-overview";
|
||||
// lib
|
||||
import { useMobxStore } from "lib/mobx/store-provider";
|
||||
|
||||
type Props = {};
|
||||
|
||||
export const IssuePeekOverview: React.FC<Props> = observer((props) => {
|
||||
const [isSidePeekOpen, setIsSidePeekOpen] = useState(false);
|
||||
const [isModalPeekOpen, setIsModalPeekOpen] = useState(false);
|
||||
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspace_slug, project_slug, peekId, board } = router.query;
|
||||
// store
|
||||
const { issueDetails: issueDetailStore, issue: issueStore } = useMobxStore();
|
||||
|
||||
const issueDetails = issueDetailStore.peekId && peekId ? issueDetailStore.details[peekId.toString()] : undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (workspace_slug && project_slug && peekId && issueStore.issues && issueStore.issues.length > 0) {
|
||||
if (!issueDetails) {
|
||||
issueDetailStore.fetchIssueDetails(workspace_slug.toString(), project_slug.toString(), peekId.toString());
|
||||
}
|
||||
}
|
||||
}, [workspace_slug, project_slug, issueDetailStore, issueDetails, peekId, issueStore.issues]);
|
||||
|
||||
const handleClose = () => {
|
||||
issueDetailStore.setPeekId(null);
|
||||
router.replace(
|
||||
{
|
||||
pathname: `/${workspace_slug?.toString()}/${project_slug}`,
|
||||
query: {
|
||||
board,
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
{ shallow: true }
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (peekId) {
|
||||
if (issueDetailStore.peekMode === "side") {
|
||||
setIsSidePeekOpen(true);
|
||||
setIsModalPeekOpen(false);
|
||||
} else {
|
||||
setIsModalPeekOpen(true);
|
||||
setIsSidePeekOpen(false);
|
||||
}
|
||||
} else {
|
||||
setIsSidePeekOpen(false);
|
||||
setIsModalPeekOpen(false);
|
||||
}
|
||||
}, [peekId, issueDetailStore.peekMode]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Transition.Root appear show={isSidePeekOpen} as={React.Fragment}>
|
||||
<Dialog as="div" className="relative z-20" onClose={handleClose}>
|
||||
<div className="fixed inset-0 z-20 h-full w-full overflow-y-auto">
|
||||
<Transition.Child
|
||||
as={React.Fragment}
|
||||
enter="transition-transform duration-300"
|
||||
enterFrom="translate-x-full"
|
||||
enterTo="translate-x-0"
|
||||
leave="transition-transform duration-200"
|
||||
leaveFrom="translate-x-0"
|
||||
leaveTo="translate-x-full"
|
||||
>
|
||||
<Dialog.Panel className="fixed z-20 bg-custom-background-100 top-0 right-0 h-full w-1/2 shadow-custom-shadow-sm">
|
||||
<SidePeekView handleClose={handleClose} issueDetails={issueDetails} />
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
<Transition.Root appear show={isModalPeekOpen} as={React.Fragment}>
|
||||
<Dialog as="div" className="relative z-20" onClose={handleClose}>
|
||||
<Transition.Child
|
||||
as={React.Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-custom-backdrop bg-opacity-50 transition-opacity" />
|
||||
</Transition.Child>
|
||||
<div className="fixed inset-0 z-20 h-full w-full overflow-y-auto">
|
||||
<Transition.Child
|
||||
as={React.Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<Dialog.Panel
|
||||
className={`fixed z-20 bg-custom-background-100 top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 rounded-lg shadow-custom-shadow-xl transition-all duration-300 ${
|
||||
issueDetailStore.peekMode === "modal" ? "h-[70%] w-3/5" : "h-[95%] w-[95%]"
|
||||
}`}
|
||||
>
|
||||
{issueDetailStore.peekMode === "modal" && (
|
||||
<SidePeekView handleClose={handleClose} issueDetails={issueDetails} />
|
||||
)}
|
||||
{issueDetailStore.peekMode === "full" && (
|
||||
<FullScreenPeekView handleClose={handleClose} issueDetails={issueDetails} />
|
||||
)}
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
</>
|
||||
);
|
||||
});
|
@ -0,0 +1,55 @@
|
||||
import { observer } from "mobx-react-lite";
|
||||
// components
|
||||
import {
|
||||
PeekOverviewHeader,
|
||||
PeekOverviewIssueActivity,
|
||||
PeekOverviewIssueDetails,
|
||||
PeekOverviewIssueProperties,
|
||||
} from "components/issues/peek-overview";
|
||||
|
||||
import { Loader } from "components/ui/loader";
|
||||
import { IIssue } from "types/issue";
|
||||
|
||||
type Props = {
|
||||
handleClose: () => void;
|
||||
issueDetails: IIssue | undefined;
|
||||
};
|
||||
|
||||
export const SidePeekView: React.FC<Props> = observer((props) => {
|
||||
const { handleClose, issueDetails } = props;
|
||||
|
||||
return (
|
||||
<div className="h-full w-full flex flex-col overflow-hidden">
|
||||
<div className="w-full p-5">
|
||||
<PeekOverviewHeader handleClose={handleClose} issueDetails={issueDetails} />
|
||||
</div>
|
||||
{issueDetails ? (
|
||||
<div className="h-full w-full px-6 overflow-y-auto">
|
||||
{/* issue title and description */}
|
||||
<div className="w-full">
|
||||
<PeekOverviewIssueDetails issueDetails={issueDetails} />
|
||||
</div>
|
||||
{/* issue properties */}
|
||||
<div className="w-full mt-6">
|
||||
<PeekOverviewIssueProperties issueDetails={issueDetails} />
|
||||
</div>
|
||||
{/* divider */}
|
||||
<div className="h-[1] w-full border-t border-custom-border-200 my-5" />
|
||||
{/* issue activity/comments */}
|
||||
<div className="w-full pb-5">
|
||||
<PeekOverviewIssueActivity issueDetails={issueDetails} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Loader className="px-6">
|
||||
<Loader.Item height="30px" />
|
||||
<div className="space-y-2 mt-3">
|
||||
<Loader.Item height="20px" width="70%" />
|
||||
<Loader.Item height="20px" width="60%" />
|
||||
<Loader.Item height="20px" width="60%" />
|
||||
</div>
|
||||
</Loader>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
119
apps/space/components/tiptap/bubble-menu/index.tsx
Normal file
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
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
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
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
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
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;
|
113
apps/space/components/tiptap/index.tsx
Normal file
113
apps/space/components/tiptap/index.tsx
Normal file
@ -0,0 +1,113 @@
|
||||
import { useImperativeHandle, useRef, forwardRef, useEffect } from "react";
|
||||
import { useEditor, EditorContent, Editor } from "@tiptap/react";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
// components
|
||||
import { EditorBubbleMenu } from "./bubble-menu";
|
||||
import { TiptapExtensions } from "./extensions";
|
||||
import { TiptapEditorProps } from "./props";
|
||||
import { ImageResizer } from "./extensions/image-resize";
|
||||
|
||||
export interface ITipTapRichTextEditor {
|
||||
value: string;
|
||||
noBorder?: boolean;
|
||||
borderOnFocus?: boolean;
|
||||
customClassName?: string;
|
||||
editorContentCustomClassNames?: string;
|
||||
onChange?: (json: any, html: string) => void;
|
||||
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void;
|
||||
setShouldShowAlert?: (showAlert: boolean) => void;
|
||||
workspaceSlug: string;
|
||||
editable?: boolean;
|
||||
forwardedRef?: any;
|
||||
debouncedUpdatesEnabled?: boolean;
|
||||
}
|
||||
|
||||
const Tiptap = (props: ITipTapRichTextEditor) => {
|
||||
const {
|
||||
onChange,
|
||||
debouncedUpdatesEnabled,
|
||||
forwardedRef,
|
||||
editable,
|
||||
setIsSubmitting,
|
||||
setShouldShowAlert,
|
||||
editorContentCustomClassNames,
|
||||
value,
|
||||
noBorder,
|
||||
workspaceSlug,
|
||||
borderOnFocus,
|
||||
customClassName,
|
||||
} = props;
|
||||
const editor = useEditor({
|
||||
editable: editable ?? true,
|
||||
editorProps: TiptapEditorProps(workspaceSlug, setIsSubmitting),
|
||||
extensions: TiptapExtensions(workspaceSlug, setIsSubmitting),
|
||||
content: value,
|
||||
onUpdate: async ({ editor }) => {
|
||||
// for instant feedback loop
|
||||
setIsSubmitting?.("submitting");
|
||||
setShouldShowAlert?.(true);
|
||||
if (debouncedUpdatesEnabled) {
|
||||
debouncedUpdates({ onChange, editor });
|
||||
} else {
|
||||
onChange?.(editor.getJSON(), editor.getHTML());
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (editor) {
|
||||
editor.commands.setContent(value);
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
const editorRef: React.MutableRefObject<Editor | null> = useRef(null);
|
||||
|
||||
useImperativeHandle(forwardedRef, () => ({
|
||||
clearEditor: () => {
|
||||
editorRef.current?.commands.clearContent();
|
||||
},
|
||||
setEditorValue: (content: string) => {
|
||||
editorRef.current?.commands.setContent(content);
|
||||
},
|
||||
}));
|
||||
|
||||
const debouncedUpdates = useDebouncedCallback(async ({ onChange, editor }) => {
|
||||
setTimeout(async () => {
|
||||
if (onChange) {
|
||||
onChange(editor.getJSON(), editor.getHTML());
|
||||
}
|
||||
}, 500);
|
||||
}, 1000);
|
||||
|
||||
const editorClassNames = `relative w-full max-w-full sm:rounded-lg mt-2 p-3 relative focus:outline-none rounded-md
|
||||
${noBorder ? "" : "border border-custom-border-200"} ${
|
||||
borderOnFocus ? "focus:border border-custom-border-300" : "focus:border-0"
|
||||
} ${customClassName}`;
|
||||
|
||||
if (!editor) return null;
|
||||
editorRef.current = editor;
|
||||
|
||||
return (
|
||||
<div
|
||||
id="tiptap-container"
|
||||
onClick={() => {
|
||||
editor?.chain().focus().run();
|
||||
}}
|
||||
className={`tiptap-editor-container cursor-text ${editorClassNames}`}
|
||||
>
|
||||
{editor && <EditorBubbleMenu editor={editor} />}
|
||||
<div className={`${editorContentCustomClassNames}`}>
|
||||
<EditorContent editor={editor} />
|
||||
{editor?.isActive("image") && <ImageResizer editor={editor} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const TipTapEditor = forwardRef<ITipTapRichTextEditor, ITipTapRichTextEditor>((props, ref) => (
|
||||
<Tiptap {...props} forwardedRef={ref} />
|
||||
));
|
||||
|
||||
TipTapEditor.displayName = "TipTapEditor";
|
||||
|
||||
export { TipTapEditor };
|
56
apps/space/components/tiptap/plugins/delete-image.tsx
Normal file
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
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
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
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
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
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
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>
|
||||
);
|
8
apps/space/components/ui/index.ts
Normal file
8
apps/space/components/ui/index.ts
Normal file
@ -0,0 +1,8 @@
|
||||
export * from "./dropdown";
|
||||
export * from "./input";
|
||||
export * from "./loader";
|
||||
export * from "./primary-button";
|
||||
export * from "./secondary-button";
|
||||
export * from "./icon";
|
||||
export * from "./reaction-selector";
|
||||
export * from "./tooltip";
|
37
apps/space/components/ui/input.tsx
Normal file
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;
|
25
apps/space/components/ui/loader.tsx
Normal file
25
apps/space/components/ui/loader.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
import React from "react";
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const Loader = ({ children, className = "" }: Props) => (
|
||||
<div className={`${className} animate-pulse`} role="status">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
type ItemProps = {
|
||||
height?: string;
|
||||
width?: string;
|
||||
};
|
||||
|
||||
const Item: React.FC<ItemProps> = ({ height = "auto", width = "auto" }) => (
|
||||
<div className="rounded-md bg-custom-background-80" style={{ height: height, width: width }} />
|
||||
);
|
||||
|
||||
Loader.Item = Item;
|
||||
|
||||
export { Loader };
|
35
apps/space/components/ui/primary-button.tsx
Normal file
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
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
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
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;
|
71
apps/space/components/ui/tooltip.tsx
Normal file
71
apps/space/components/ui/tooltip.tsx
Normal file
@ -0,0 +1,71 @@
|
||||
import React from "react";
|
||||
|
||||
// next-themes
|
||||
import { useTheme } from "next-themes";
|
||||
// tooltip2
|
||||
import { Tooltip2 } from "@blueprintjs/popover2";
|
||||
|
||||
type Props = {
|
||||
tooltipHeading?: string;
|
||||
tooltipContent: string | React.ReactNode;
|
||||
position?:
|
||||
| "top"
|
||||
| "right"
|
||||
| "bottom"
|
||||
| "left"
|
||||
| "auto"
|
||||
| "auto-end"
|
||||
| "auto-start"
|
||||
| "bottom-left"
|
||||
| "bottom-right"
|
||||
| "left-bottom"
|
||||
| "left-top"
|
||||
| "right-bottom"
|
||||
| "right-top"
|
||||
| "top-left"
|
||||
| "top-right";
|
||||
children: JSX.Element;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
openDelay?: number;
|
||||
closeDelay?: number;
|
||||
};
|
||||
|
||||
export const Tooltip: React.FC<Props> = ({
|
||||
tooltipHeading,
|
||||
tooltipContent,
|
||||
position = "top",
|
||||
children,
|
||||
disabled = false,
|
||||
className = "",
|
||||
openDelay = 200,
|
||||
closeDelay,
|
||||
}) => {
|
||||
const { theme } = useTheme();
|
||||
|
||||
return (
|
||||
<Tooltip2
|
||||
disabled={disabled}
|
||||
hoverOpenDelay={openDelay}
|
||||
hoverCloseDelay={closeDelay}
|
||||
content={
|
||||
<div
|
||||
className={`relative z-50 max-w-xs gap-1 rounded-md p-2 text-xs shadow-md border border-custom-border-200 ${
|
||||
theme === "custom" ? "bg-custom-background-100 text-custom-text-200" : "bg-black text-gray-400"
|
||||
} break-words overflow-hidden ${className}`}
|
||||
>
|
||||
{tooltipHeading && (
|
||||
<h5 className={`font-medium ${theme === "custom" ? "text-custom-text-100" : "text-white"}`}>
|
||||
{tooltipHeading}
|
||||
</h5>
|
||||
)}
|
||||
{tooltipContent}
|
||||
</div>
|
||||
}
|
||||
position={position}
|
||||
renderTarget={({ isOpen: isTooltipOpen, ref: eleReference, ...tooltipProps }) =>
|
||||
React.cloneElement(children, { ref: eleReference, ...tooltipProps, ...children.props })
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
84
apps/space/components/views/project-details.tsx
Normal file
84
apps/space/components/views/project-details.tsx
Normal file
@ -0,0 +1,84 @@
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import { observer } from "mobx-react-lite";
|
||||
// components
|
||||
import { IssueListView } from "components/issues/board-views/list";
|
||||
import { IssueKanbanView } from "components/issues/board-views/kanban";
|
||||
import { IssueCalendarView } from "components/issues/board-views/calendar";
|
||||
import { IssueSpreadsheetView } from "components/issues/board-views/spreadsheet";
|
||||
import { IssueGanttView } from "components/issues/board-views/gantt";
|
||||
import { IssuePeekOverview } from "components/issues/peek-overview";
|
||||
// mobx store
|
||||
import { RootStore } from "store/root";
|
||||
import { useMobxStore } from "lib/mobx/store-provider";
|
||||
|
||||
export const ProjectDetailsView = observer(() => {
|
||||
const router = useRouter();
|
||||
const { workspace_slug, project_slug, states, labels, priorities, board, peekId } = router.query;
|
||||
|
||||
const {
|
||||
issue: issueStore,
|
||||
project: projectStore,
|
||||
issueDetails: issueDetailStore,
|
||||
user: userStore,
|
||||
}: RootStore = useMobxStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (!userStore.currentUser) {
|
||||
userStore.fetchCurrentUser();
|
||||
}
|
||||
}, [userStore]);
|
||||
|
||||
useEffect(() => {
|
||||
if (workspace_slug && project_slug) {
|
||||
const params = {
|
||||
state: states || null,
|
||||
labels: labels || null,
|
||||
priority: priorities || null,
|
||||
};
|
||||
issueStore.fetchPublicIssues(workspace_slug?.toString(), project_slug.toString(), params);
|
||||
}
|
||||
}, [workspace_slug, project_slug, issueStore, states, labels, priorities]);
|
||||
|
||||
useEffect(() => {
|
||||
if (peekId && workspace_slug && project_slug) {
|
||||
issueDetailStore.setPeekId(peekId.toString());
|
||||
}
|
||||
}, [peekId, issueDetailStore, project_slug, workspace_slug]);
|
||||
|
||||
return (
|
||||
<div className="relative w-full h-full overflow-hidden">
|
||||
{workspace_slug && <IssuePeekOverview />}
|
||||
|
||||
{issueStore?.loader && !issueStore.issues ? (
|
||||
<div className="text-sm text-center py-10 text-custom-text-100">Loading...</div>
|
||||
) : (
|
||||
<>
|
||||
{issueStore?.error ? (
|
||||
<div className="text-sm text-center py-10 bg-custom-background-200 text-custom-text-100">
|
||||
Something went wrong.
|
||||
</div>
|
||||
) : (
|
||||
projectStore?.activeBoard && (
|
||||
<>
|
||||
{projectStore?.activeBoard === "list" && (
|
||||
<div className="relative w-full h-full overflow-y-auto">
|
||||
<IssueListView />
|
||||
</div>
|
||||
)}
|
||||
{projectStore?.activeBoard === "kanban" && (
|
||||
<div className="relative w-full h-full mx-auto px-9 py-5">
|
||||
<IssueKanbanView />
|
||||
</div>
|
||||
)}
|
||||
{projectStore?.activeBoard === "calendar" && <IssueCalendarView />}
|
||||
{projectStore?.activeBoard === "spreadsheet" && <IssueSpreadsheetView />}
|
||||
{projectStore?.activeBoard === "gantt" && <IssueGanttView />}
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
@ -7,7 +7,7 @@ import {
|
||||
TIssueGroupKey,
|
||||
IIssuePriorityFilters,
|
||||
IIssueGroup,
|
||||
} from "store/types/issue";
|
||||
} from "types/issue";
|
||||
// icons
|
||||
import {
|
||||
BacklogStateIcon,
|
||||
@ -18,69 +18,49 @@ import {
|
||||
} from "components/icons";
|
||||
|
||||
// all issue views
|
||||
export const issueViews: IIssueBoardViews[] = [
|
||||
{
|
||||
key: "list",
|
||||
export const issueViews: any = {
|
||||
list: {
|
||||
title: "List View",
|
||||
icon: "format_list_bulleted",
|
||||
className: "",
|
||||
},
|
||||
{
|
||||
key: "kanban",
|
||||
kanban: {
|
||||
title: "Board View",
|
||||
icon: "grid_view",
|
||||
className: "",
|
||||
},
|
||||
// {
|
||||
// key: "calendar",
|
||||
// title: "Calendar View",
|
||||
// icon: "calendar_month",
|
||||
// className: "",
|
||||
// },
|
||||
// {
|
||||
// key: "spreadsheet",
|
||||
// title: "Spreadsheet View",
|
||||
// icon: "table_chart",
|
||||
// className: "",
|
||||
// },
|
||||
// {
|
||||
// key: "gantt",
|
||||
// title: "Gantt Chart View",
|
||||
// icon: "waterfall_chart",
|
||||
// className: "rotate-90",
|
||||
// },
|
||||
];
|
||||
};
|
||||
|
||||
// issue priority filters
|
||||
export const issuePriorityFilters: IIssuePriorityFilters[] = [
|
||||
{
|
||||
key: "urgent",
|
||||
title: "Urgent",
|
||||
className: "border border-red-500/50 bg-red-500/20 text-red-500",
|
||||
className: "bg-red-500 border-red-500 text-white",
|
||||
icon: "error",
|
||||
},
|
||||
{
|
||||
key: "high",
|
||||
title: "High",
|
||||
className: "border border-orange-500/50 bg-orange-500/20 text-orange-500",
|
||||
className: "text-orange-500 border-custom-border-300",
|
||||
icon: "signal_cellular_alt",
|
||||
},
|
||||
{
|
||||
key: "medium",
|
||||
title: "Medium",
|
||||
className: "border border-yellow-500/50 bg-yellow-500/20 text-yellow-500",
|
||||
className: "text-yellow-500 border-custom-border-300",
|
||||
icon: "signal_cellular_alt_2_bar",
|
||||
},
|
||||
{
|
||||
key: "low",
|
||||
title: "Low",
|
||||
className: "border border-green-500/50 bg-green-500/20 text-green-500",
|
||||
className: "text-green-500 border-custom-border-300",
|
||||
icon: "signal_cellular_alt_1_bar",
|
||||
},
|
||||
{
|
||||
key: "none",
|
||||
title: "None",
|
||||
className: "border border-gray-500/50 bg-gray-500/20 text-gray-500",
|
||||
className: "text-gray-500 border-custom-border-300",
|
||||
icon: "block",
|
||||
},
|
||||
];
|
||||
@ -111,35 +91,35 @@ export const issueGroups: IIssueGroup[] = [
|
||||
key: "backlog",
|
||||
title: "Backlog",
|
||||
color: "#d9d9d9",
|
||||
className: `border-[#d9d9d9]/50 text-[#d9d9d9] bg-[#d9d9d9]/10`,
|
||||
className: `text-[#d9d9d9] bg-[#d9d9d9]/10`,
|
||||
icon: BacklogStateIcon,
|
||||
},
|
||||
{
|
||||
key: "unstarted",
|
||||
title: "Unstarted",
|
||||
color: "#3f76ff",
|
||||
className: `border-[#3f76ff]/50 text-[#3f76ff] bg-[#3f76ff]/10`,
|
||||
className: `text-[#3f76ff] bg-[#3f76ff]/10`,
|
||||
icon: UnstartedStateIcon,
|
||||
},
|
||||
{
|
||||
key: "started",
|
||||
title: "Started",
|
||||
color: "#f59e0b",
|
||||
className: `border-[#f59e0b]/50 text-[#f59e0b] bg-[#f59e0b]/10`,
|
||||
className: `text-[#f59e0b] bg-[#f59e0b]/10`,
|
||||
icon: StartedStateIcon,
|
||||
},
|
||||
{
|
||||
key: "completed",
|
||||
title: "Completed",
|
||||
color: "#16a34a",
|
||||
className: `border-[#16a34a]/50 text-[#16a34a] bg-[#16a34a]/10`,
|
||||
className: `text-[#16a34a] bg-[#16a34a]/10`,
|
||||
icon: CompletedStateIcon,
|
||||
},
|
||||
{
|
||||
key: "cancelled",
|
||||
title: "Cancelled",
|
||||
color: "#dc2626",
|
||||
className: `border-[#dc2626]/50 text-[#dc2626] bg-[#dc2626]/10`,
|
||||
className: `text-[#dc2626] bg-[#dc2626]/10`,
|
||||
icon: CancelledStateIcon,
|
||||
},
|
||||
];
|
||||
|
@ -11,3 +11,26 @@ export const renderDateFormat = (date: string | Date | null) => {
|
||||
|
||||
return [year, month, day].join("-");
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Returns date and month, if date is of the current year
|
||||
* @description Returns date, month adn year, if date is of a different year than current
|
||||
* @param {string} date
|
||||
* @example renderFullDate("2023-01-01") // 1 Jan
|
||||
* @example renderFullDate("2021-01-01") // 1 Jan, 2021
|
||||
*/
|
||||
|
||||
export const renderFullDate = (date: string): string => {
|
||||
if (!date) return "";
|
||||
|
||||
const months: string[] = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
||||
|
||||
const currentDate: Date = new Date();
|
||||
const [year, month, day]: number[] = date.split("-").map(Number);
|
||||
|
||||
const formattedMonth: string = months[month - 1];
|
||||
const formattedDay: string = day < 10 ? `0${day}` : day.toString();
|
||||
|
||||
if (currentDate.getFullYear() === year) return `${formattedDay} ${formattedMonth}`;
|
||||
else return `${formattedDay} ${formattedMonth}, ${year}`;
|
||||
};
|
||||
|
7
apps/space/constants/seo.ts
Normal file
7
apps/space/constants/seo.ts
Normal file
@ -0,0 +1,7 @@
|
||||
export const SITE_NAME = "Plane Deploy | Make your Plane boards and roadmaps pubic with just one-click. ";
|
||||
export const SITE_TITLE = "Plane Deploy | Make your Plane boards public with one-click";
|
||||
export const SITE_DESCRIPTION = "Plane Deploy is a customer feedback management tool built on top of plane.so";
|
||||
export const SITE_KEYWORDS =
|
||||
"software development, customer feedback, software, accelerate, code management, release management, project management, issue tracking, agile, scrum, kanban, collaboration";
|
||||
export const SITE_URL = "https://app.plane.so/";
|
||||
export const TWITTER_USER_NAME = "planepowers";
|
12
apps/space/constants/workspace.ts
Normal file
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
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
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
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
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
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
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
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;
|
30
apps/space/layouts/project-layout.tsx
Normal file
30
apps/space/layouts/project-layout.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
|
||||
// mobx
|
||||
import { observer } from "mobx-react-lite";
|
||||
// components
|
||||
import IssueNavbar from "components/issues/navbar";
|
||||
|
||||
const ProjectLayout = ({ children }: { children: React.ReactNode }) => (
|
||||
<div className="relative w-screen min-h-[500px] h-screen overflow-hidden flex flex-col">
|
||||
<div className="flex-shrink-0 h-[60px] border-b border-custom-border-300 relative flex items-center bg-custom-sidebar-background-100 select-none">
|
||||
<IssueNavbar />
|
||||
</div>
|
||||
<div className="w-full h-full relative bg-custom-background-90 overflow-hidden">{children}</div>
|
||||
<div className="absolute z-[99999] bottom-[10px] right-[10px] bg-custom-background-100 rounded-sm shadow-lg border border-custom-border-300">
|
||||
<Link href="https://plane.so">
|
||||
<a className="p-1 px-2 flex items-center gap-1" target="_blank">
|
||||
<div className="w-[24px] h-[24px] relative flex justify-center items-center">
|
||||
<Image src="/plane-logo.webp" alt="plane logo" className="w-[24px] h-[24px]" height="24" width="24" />
|
||||
</div>
|
||||
<div className="text-xs">
|
||||
Powered by <b>Plane Deploy</b>
|
||||
</div>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default observer(ProjectLayout);
|
@ -1,6 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
// next imports
|
||||
import { useRouter } from "next/router";
|
||||
// mobx store
|
||||
import { useMobxStore } from "lib/mobx/store-provider";
|
||||
import { RootStore } from "store/root";
|
||||
@ -8,12 +10,14 @@ import { RootStore } from "store/root";
|
||||
const MobxStoreInit = () => {
|
||||
const store: RootStore = useMobxStore();
|
||||
|
||||
useEffect(() => {
|
||||
// theme
|
||||
const _theme = localStorage && localStorage.getItem("app_theme") ? localStorage.getItem("app_theme") : "light";
|
||||
if (_theme && store?.theme?.theme != _theme) store.theme.setTheme(_theme);
|
||||
else localStorage.setItem("app_theme", _theme && _theme != "light" ? "dark" : "light");
|
||||
}, [store?.theme]);
|
||||
const router = useRouter();
|
||||
const { states, labels, priorities } = router.query as { states: string[]; labels: string[]; priorities: string[] };
|
||||
|
||||
// useEffect(() => {
|
||||
// store.issue.userSelectedLabels = labels || [];
|
||||
// store.issue.userSelectedPriorities = priorities || [];
|
||||
// store.issue.userSelectedStates = states || [];
|
||||
// }, [store.issue]);
|
||||
|
||||
return <></>;
|
||||
};
|
||||
|
@ -6,7 +6,6 @@ const nextConfig = {
|
||||
swcMinify: true,
|
||||
experimental: {
|
||||
outputFileTracingRoot: path.join(__dirname, "../../"),
|
||||
appDir: true,
|
||||
},
|
||||
output: "standalone",
|
||||
};
|
||||
|
@ -10,27 +10,52 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@headlessui/react": "^1.7.13",
|
||||
"@types/node": "18.14.1",
|
||||
"@types/nprogress": "^0.2.0",
|
||||
"@types/react": "18.0.28",
|
||||
"@types/react-dom": "18.0.11",
|
||||
"@mui/icons-material": "^5.14.7",
|
||||
"@tiptap-pro/extension-unique-id": "^2.1.0",
|
||||
"@tiptap/extension-code-block-lowlight": "^2.0.4",
|
||||
"@tiptap/extension-color": "^2.0.4",
|
||||
"@tiptap/extension-gapcursor": "^2.1.7",
|
||||
"@tiptap/extension-highlight": "^2.0.4",
|
||||
"@tiptap/extension-horizontal-rule": "^2.0.4",
|
||||
"@tiptap/extension-image": "^2.0.4",
|
||||
"@tiptap/extension-link": "^2.0.4",
|
||||
"@tiptap/extension-placeholder": "^2.0.4",
|
||||
"@tiptap/extension-table": "^2.1.6",
|
||||
"@tiptap/extension-table-cell": "^2.1.6",
|
||||
"@tiptap/extension-table-header": "^2.1.6",
|
||||
"@tiptap/extension-table-row": "^2.1.6",
|
||||
"@tiptap/extension-task-item": "^2.0.4",
|
||||
"@tiptap/extension-task-list": "^2.0.4",
|
||||
"@tiptap/extension-text-style": "^2.0.4",
|
||||
"@tiptap/extension-underline": "^2.0.4",
|
||||
"@tiptap/pm": "^2.0.4",
|
||||
"@tiptap/react": "^2.0.4",
|
||||
"@tiptap/starter-kit": "^2.0.4",
|
||||
"@tiptap/suggestion": "^2.0.4",
|
||||
"axios": "^1.3.4",
|
||||
"eslint": "8.34.0",
|
||||
"eslint-config-next": "13.2.1",
|
||||
"js-cookie": "^3.0.1",
|
||||
"mobx": "^6.10.0",
|
||||
"mobx-react-lite": "^4.0.3",
|
||||
"next": "^13.4.16",
|
||||
"next": "12.3.2",
|
||||
"next-theme": "^0.1.5",
|
||||
"nprogress": "^0.2.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"swr": "^2.2.2",
|
||||
"typescript": "4.9.5",
|
||||
"uuid": "^9.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/typography": "^0.5.9",
|
||||
"@types/js-cookie": "^3.0.3",
|
||||
"@types/node": "18.14.1",
|
||||
"@types/nprogress": "^0.2.0",
|
||||
"@types/react": "18.0.28",
|
||||
"@types/react-dom": "18.0.11",
|
||||
"@types/uuid": "^9.0.1",
|
||||
"autoprefixer": "^10.4.13",
|
||||
"eslint": "8.34.0",
|
||||
"eslint-config-next": "13.2.1",
|
||||
"postcss": "^8.4.21",
|
||||
"tailwindcss": "^3.2.7"
|
||||
}
|
||||
|
50
apps/space/pages/[workspace_slug]/[project_slug]/index.tsx
Normal file
50
apps/space/pages/[workspace_slug]/[project_slug]/index.tsx
Normal file
@ -0,0 +1,50 @@
|
||||
import useSWR from "swr";
|
||||
import type { GetServerSideProps } from "next";
|
||||
import { useRouter } from "next/router";
|
||||
import Head from "next/head";
|
||||
/// layouts
|
||||
import ProjectLayout from "layouts/project-layout";
|
||||
// components
|
||||
import { ProjectDetailsView } from "components/views/project-details";
|
||||
// lib
|
||||
import { useMobxStore } from "lib/mobx/store-provider";
|
||||
|
||||
const WorkspaceProjectPage = (props: any) => {
|
||||
const SITE_TITLE = props?.project_settings?.project_details?.name || "Plane | Deploy";
|
||||
|
||||
const router = useRouter();
|
||||
const { workspace_slug, project_slug, states, labels, priorities } = router.query;
|
||||
|
||||
const { project: projectStore, issue: issueStore } = useMobxStore();
|
||||
|
||||
useSWR("REVALIDATE_ALL", () => {
|
||||
if (workspace_slug && project_slug) {
|
||||
projectStore.fetchProjectSettings(workspace_slug.toString(), project_slug.toString());
|
||||
const params = {
|
||||
state: states || null,
|
||||
labels: labels || null,
|
||||
priority: priorities || null,
|
||||
};
|
||||
issueStore.fetchPublicIssues(workspace_slug.toString(), project_slug.toString(), params);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<ProjectLayout>
|
||||
<Head>
|
||||
<title>{SITE_TITLE}</title>
|
||||
</Head>
|
||||
<ProjectDetailsView />
|
||||
</ProjectLayout>
|
||||
);
|
||||
};
|
||||
|
||||
// export const getServerSideProps: GetServerSideProps<any> = async ({ query: { workspace_slug, project_slug } }) => {
|
||||
// const res = await fetch(
|
||||
// `${process.env.NEXT_PUBLIC_API_BASE_URL}/api/public/workspaces/${workspace_slug}/project-boards/${project_slug}/settings/`
|
||||
// );
|
||||
// const project_settings = await res.json();
|
||||
// return { props: { project_settings } };
|
||||
// };
|
||||
|
||||
export default WorkspaceProjectPage;
|
@ -1,10 +1,43 @@
|
||||
import Head from "next/head";
|
||||
import type { AppProps } from "next/app";
|
||||
import { ThemeProvider } from "next-themes";
|
||||
// styles
|
||||
import "styles/globals.css";
|
||||
// types
|
||||
import type { AppProps } from "next/app";
|
||||
import "styles/editor.css";
|
||||
// contexts
|
||||
import { ToastContextProvider } from "contexts/toast.context";
|
||||
// mobx store provider
|
||||
import { MobxStoreProvider } from "lib/mobx/store-provider";
|
||||
import MobxStoreInit from "lib/mobx/store-init";
|
||||
// constants
|
||||
import { SITE_NAME, SITE_DESCRIPTION, SITE_URL, TWITTER_USER_NAME, SITE_KEYWORDS, SITE_TITLE } from "constants/seo";
|
||||
|
||||
function MyApp({ Component, pageProps }: AppProps) {
|
||||
return <Component {...pageProps} />;
|
||||
return (
|
||||
<MobxStoreProvider>
|
||||
<MobxStoreInit />
|
||||
<Head>
|
||||
<title>{SITE_TITLE}</title>
|
||||
<meta property="og:site_name" content={SITE_NAME} />
|
||||
<meta property="og:title" content={SITE_TITLE} />
|
||||
<meta property="og:url" content={SITE_URL} />
|
||||
<meta name="description" content={SITE_DESCRIPTION} />
|
||||
<meta property="og:description" content={SITE_DESCRIPTION} />
|
||||
<meta name="keywords" content={SITE_KEYWORDS} />
|
||||
<meta name="twitter:site" content={`@${TWITTER_USER_NAME}`} />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/favicon/apple-touch-icon.png" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon/favicon-16x16.png" />
|
||||
<link rel="manifest" href="/site.webmanifest.json" />
|
||||
<link rel="shortcut icon" href="/favicon/favicon.ico" />
|
||||
</Head>
|
||||
<ToastContextProvider>
|
||||
<ThemeProvider themes={["light", "dark"]} defaultTheme="system" enableSystem>
|
||||
<Component {...pageProps} />
|
||||
</ThemeProvider>
|
||||
</ToastContextProvider>
|
||||
</MobxStoreProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default MyApp;
|
||||
|
@ -5,7 +5,7 @@ class MyDocument extends Document {
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<body>
|
||||
<body className="antialiased bg-custom-background-90 w-100">
|
||||
<Main />
|
||||
<NextScript />
|
||||
</body>
|
||||
|
156
apps/space/pages/index.tsx
Normal file
156
apps/space/pages/index.tsx
Normal file
@ -0,0 +1,156 @@
|
||||
import React, { useEffect } from "react";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/router";
|
||||
// assets
|
||||
import BluePlaneLogoWithoutText from "public/plane-logos/blue-without-text.png";
|
||||
// mobx
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useMobxStore } from "lib/mobx/store-provider";
|
||||
// services
|
||||
import authenticationService from "services/authentication.service";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
// components
|
||||
import { EmailPasswordForm, GithubLoginButton, GoogleLoginButton, EmailCodeForm } from "components/accounts";
|
||||
|
||||
const HomePage = () => {
|
||||
const { user: userStore } = useMobxStore();
|
||||
|
||||
const router = useRouter();
|
||||
const { next_path = "/" } = router.query;
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const onSignInError = (error: any) => {
|
||||
setToastAlert({
|
||||
title: "Error signing in!",
|
||||
type: "error",
|
||||
message: error?.error || "Something went wrong. Please try again later or contact the support team.",
|
||||
});
|
||||
};
|
||||
|
||||
const onSignInSuccess = (response: any) => {
|
||||
const isOnboarded = response?.user?.onboarding_step?.profile_complete || false;
|
||||
|
||||
userStore.setCurrentUser(response?.user);
|
||||
|
||||
if (!isOnboarded) {
|
||||
router.push(`/onboarding?next_path=${next_path}`);
|
||||
return;
|
||||
}
|
||||
router.push(next_path.toString());
|
||||
};
|
||||
|
||||
const handleGoogleSignIn = async ({ clientId, credential }: any) => {
|
||||
try {
|
||||
if (clientId && credential) {
|
||||
const socialAuthPayload = {
|
||||
medium: "google",
|
||||
credential,
|
||||
clientId,
|
||||
};
|
||||
const response = await authenticationService.socialAuth(socialAuthPayload);
|
||||
|
||||
onSignInSuccess(response);
|
||||
} else {
|
||||
throw Error("Cant find credentials");
|
||||
}
|
||||
} catch (err: any) {
|
||||
onSignInError(err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGitHubSignIn = async (credential: string) => {
|
||||
try {
|
||||
if (process.env.NEXT_PUBLIC_GITHUB_ID && credential) {
|
||||
const socialAuthPayload = {
|
||||
medium: "github",
|
||||
credential,
|
||||
clientId: process.env.NEXT_PUBLIC_GITHUB_ID,
|
||||
};
|
||||
const response = await authenticationService.socialAuth(socialAuthPayload);
|
||||
onSignInSuccess(response);
|
||||
} else {
|
||||
throw Error("Cant find credentials");
|
||||
}
|
||||
} catch (err: any) {
|
||||
onSignInError(err);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePasswordSignIn = async (formData: any) => {
|
||||
await authenticationService
|
||||
.emailLogin(formData)
|
||||
.then((response) => {
|
||||
try {
|
||||
if (response) {
|
||||
onSignInSuccess(response);
|
||||
}
|
||||
} catch (err: any) {
|
||||
onSignInError(err);
|
||||
}
|
||||
})
|
||||
.catch((err) => onSignInError(err));
|
||||
};
|
||||
|
||||
const handleEmailCodeSignIn = async (response: any) => {
|
||||
try {
|
||||
if (response) {
|
||||
onSignInSuccess(response);
|
||||
}
|
||||
} catch (err: any) {
|
||||
onSignInError(err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-screen w-full overflow-hidden">
|
||||
<div className="hidden sm:block sm:fixed border-r-[0.5px] border-custom-border-200 h-screen w-[0.5px] top-0 left-20 lg:left-32" />
|
||||
<div className="fixed grid place-items-center bg-custom-background-100 sm:py-5 top-11 sm:top-12 left-7 sm:left-16 lg:left-28">
|
||||
<div className="grid place-items-center bg-custom-background-100">
|
||||
<div className="h-[30px] w-[30px]">
|
||||
<Image src={BluePlaneLogoWithoutText} alt="Plane Logo" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid place-items-center h-full overflow-y-auto py-5 px-7">
|
||||
<div>
|
||||
{parseInt(process.env.NEXT_PUBLIC_ENABLE_OAUTH || "0") ? (
|
||||
<>
|
||||
<h1 className="text-center text-2xl sm:text-2.5xl font-semibold text-custom-text-100">
|
||||
Sign in to Plane
|
||||
</h1>
|
||||
<div className="flex flex-col divide-y divide-custom-border-200">
|
||||
<div className="pb-7">
|
||||
<EmailCodeForm handleSignIn={handleEmailCodeSignIn} />
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-center gap-4 pt-7 sm:w-[360px] mx-auto overflow-hidden">
|
||||
<GoogleLoginButton handleSignIn={handleGoogleSignIn} />
|
||||
<GithubLoginButton handleSignIn={handleGitHubSignIn} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<EmailPasswordForm onSubmit={handlePasswordSignIn} />
|
||||
)}
|
||||
|
||||
{parseInt(process.env.NEXT_PUBLIC_ENABLE_OAUTH || "0") ? (
|
||||
<p className="pt-16 text-custom-text-200 text-sm text-center">
|
||||
By signing up, you agree to the{" "}
|
||||
<a
|
||||
href="https://plane.so/terms-and-conditions"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline"
|
||||
>
|
||||
Terms & Conditions
|
||||
</a>
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HomePage;
|
53
apps/space/pages/onboarding/index.tsx
Normal file
53
apps/space/pages/onboarding/index.tsx
Normal file
@ -0,0 +1,53 @@
|
||||
import React, { useEffect } from "react";
|
||||
import Image from "next/image";
|
||||
// assets
|
||||
import BluePlaneLogoWithoutText from "public/plane-logos/blue-without-text.png";
|
||||
import BlackHorizontalLogo from "public/plane-logos/black-horizontal-with-blue-logo.svg";
|
||||
// mobx
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useMobxStore } from "lib/mobx/store-provider";
|
||||
// services
|
||||
import authenticationService from "services/authentication.service";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
// components
|
||||
import { OnBoardingForm } from "components/accounts/onboarding-form";
|
||||
|
||||
const OnBoardingPage = () => {
|
||||
const { user: userStore } = useMobxStore();
|
||||
|
||||
const user = userStore?.currentUser;
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
const user = userStore?.currentUser;
|
||||
|
||||
if (!user) {
|
||||
userStore.fetchCurrentUser();
|
||||
}
|
||||
}, [userStore]);
|
||||
|
||||
return (
|
||||
<div className="h-screen w-full overflow-hidden bg-custom-background-100">
|
||||
<div className="flex h-full w-full flex-col gap-y-2 sm:gap-y-0 sm:flex-row overflow-hidden">
|
||||
<div className="relative h-1/6 flex-shrink-0 sm:w-2/12 md:w-3/12 lg:w-1/5">
|
||||
<div className="absolute border-b-[0.5px] sm:border-r-[0.5px] border-custom-border-200 h-[0.5px] w-full top-1/2 left-0 -translate-y-1/2 sm:h-screen sm:w-[0.5px] sm:top-0 sm:left-1/2 md:left-1/3 sm:-translate-x-1/2 sm:translate-y-0 z-10" />
|
||||
<div className="absolute grid place-items-center bg-custom-background-100 px-3 sm:px-0 py-5 left-2 sm:left-1/2 md:left-1/3 sm:-translate-x-1/2 top-1/2 -translate-y-1/2 sm:translate-y-0 sm:top-12 z-10">
|
||||
<div className="h-[30px] w-[30px]">
|
||||
<Image src={BluePlaneLogoWithoutText} alt="Plane logo" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute sm:fixed text-custom-text-100 text-sm font-medium right-4 top-1/4 sm:top-12 -translate-y-1/2 sm:translate-y-0 sm:right-16 sm:py-5">
|
||||
{user?.email}
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative flex justify-center sm:items-center h-full px-8 pb-0 sm:px-0 sm:py-12 sm:pr-[8.33%] sm:w-10/12 md:w-9/12 lg:w-4/5 overflow-hidden">
|
||||
<OnBoardingForm user={user} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default observer(OnBoardingPage);
|
@ -1,5 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
"tailwindcss/nesting": {},
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
|
BIN
apps/space/public/favicon/android-chrome-192x192.png
Normal file
BIN
apps/space/public/favicon/android-chrome-192x192.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.2 KiB |
BIN
apps/space/public/favicon/android-chrome-512x512.png
Normal file
BIN
apps/space/public/favicon/android-chrome-512x512.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 3.7 KiB |
BIN
apps/space/public/favicon/apple-touch-icon.png
Normal file
BIN
apps/space/public/favicon/apple-touch-icon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.1 KiB |
BIN
apps/space/public/favicon/favicon-16x16.png
Normal file
BIN
apps/space/public/favicon/favicon-16x16.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.6 KiB |
BIN
apps/space/public/favicon/favicon-32x32.png
Normal file
BIN
apps/space/public/favicon/favicon-32x32.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.6 KiB |
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user