feat: dashboard 2.0 redesign (#124)
* feat: add patterned background to boards * feat: setup dashboard 2.0 * fix: prevent patterned background from overlaying buttons * feat: set max width for main card view * feat: adjust colour scheme for card view * feat: adjust dashboard border colour scheme and radius * fix: use correct colour for description placeholder * fix: use getLayout to persist layout state across pages * feat: reduce max width for settings and members pages * feat: add feedback button and modal * feat: adjust page max width * feat: extend mobile layout support * fix: hide pro badge when collapsed * feat: tweak nav styling for light mode * feat: tweak skeleton loaders for board list * feat: extend list max height * fix: hide side nav right border on desktop * chore: update hero images * chore: update hero images for docs * chore: update translations
This commit is contained in:
@@ -1,4 +1,3 @@
|
||||
import Link from "next/link";
|
||||
import { useRef, useState } from "react";
|
||||
import {
|
||||
TbLayoutSidebarLeftCollapse,
|
||||
@@ -10,8 +9,11 @@ import {
|
||||
import { authClient } from "@kan/auth/client";
|
||||
|
||||
import { useClickOutside } from "~/hooks/useClickOutside";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { useTheme } from "~/providers/theme";
|
||||
import FeedbackButton from "./FeedbackButton";
|
||||
import { WorkspaceProvider } from "~/providers/workspace";
|
||||
import FeedbackModal from "./FeedbackModal";
|
||||
import Modal from "./modal";
|
||||
import SideNavigation from "./SideNavigation";
|
||||
|
||||
interface DashboardProps {
|
||||
@@ -20,14 +22,30 @@ interface DashboardProps {
|
||||
hasRightPanel?: boolean;
|
||||
}
|
||||
|
||||
export function getDashboardLayout(
|
||||
page: React.ReactElement,
|
||||
rightPanel?: React.ReactNode,
|
||||
hasRightPanel = false,
|
||||
) {
|
||||
return (
|
||||
<WorkspaceProvider>
|
||||
<Dashboard rightPanel={rightPanel} hasRightPanel={hasRightPanel}>
|
||||
{page}
|
||||
</Dashboard>
|
||||
</WorkspaceProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Dashboard({
|
||||
children,
|
||||
rightPanel,
|
||||
hasRightPanel = false,
|
||||
}: DashboardProps) {
|
||||
const theme = useTheme();
|
||||
const { modalContentType } = useModal();
|
||||
|
||||
const { data: session, isPending: sessionLoading } = authClient.useSession();
|
||||
|
||||
const [isSideNavOpen, setIsSideNavOpen] = useState(false);
|
||||
const [isRightPanelOpen, setIsRightPanelOpen] = useState(false);
|
||||
|
||||
@@ -43,6 +61,10 @@ export default function Dashboard({
|
||||
}
|
||||
};
|
||||
|
||||
const closeSideNav = () => {
|
||||
setIsSideNavOpen(false);
|
||||
};
|
||||
|
||||
const toggleRightPanel = () => {
|
||||
setIsRightPanelOpen(!isRightPanelOpen);
|
||||
if (!isRightPanelOpen) {
|
||||
@@ -77,93 +99,84 @@ export default function Dashboard({
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
min-width: 320px;
|
||||
background-color: ${!isDarkMode ? "hsl(0deg 0% 97.3%)" : "#161616"};
|
||||
background-color: ${!isDarkMode ? "hsl(0deg 0% 97.3%)" : "#1c1c1c"};
|
||||
}
|
||||
`}</style>
|
||||
<div className="flex h-screen flex-col items-center bg-light-100 dark:bg-dark-50 md:min-w-[800px]">
|
||||
<div className="flex h-12 min-h-12 w-full justify-between border-b border-light-600 px-5 py-2 align-middle dark:border-dark-400 md:h-16 md:min-h-16">
|
||||
<div className="my-auto flex w-full items-center justify-between">
|
||||
<div className="relative flex h-screen flex-col bg-light-50 dark:bg-dark-50 md:bg-light-100 md:p-3 md:dark:bg-dark-100">
|
||||
{/* Mobile Header */}
|
||||
<div className="flex h-12 items-center justify-between border-b border-light-300 bg-light-50 px-3 dark:border-dark-300 dark:bg-dark-50 md:hidden">
|
||||
<button
|
||||
ref={sideNavButtonRef}
|
||||
onClick={toggleSideNav}
|
||||
className="rounded p-1.5 transition-all hover:bg-light-200 dark:hover:bg-dark-100"
|
||||
>
|
||||
{isSideNavOpen ? (
|
||||
<TbLayoutSidebarLeftCollapse
|
||||
size={20}
|
||||
className="text-light-900 dark:text-dark-900"
|
||||
/>
|
||||
) : (
|
||||
<TbLayoutSidebarLeftExpand
|
||||
size={20}
|
||||
className="text-light-900 dark:text-dark-900"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{hasRightPanel && (
|
||||
<button
|
||||
ref={sideNavButtonRef}
|
||||
onClick={toggleSideNav}
|
||||
className="text-neutral-900 dark:text-dark-1000 md:hidden"
|
||||
ref={rightPanelButtonRef}
|
||||
onClick={toggleRightPanel}
|
||||
className="rounded p-1.5 transition-all hover:bg-light-200 dark:hover:bg-dark-100"
|
||||
>
|
||||
{isSideNavOpen ? (
|
||||
<TbLayoutSidebarLeftCollapse
|
||||
{isRightPanelOpen ? (
|
||||
<TbLayoutSidebarRightCollapse
|
||||
size={20}
|
||||
className="text-light-900 dark:text-dark-900"
|
||||
/>
|
||||
) : (
|
||||
<TbLayoutSidebarLeftExpand
|
||||
<TbLayoutSidebarRightExpand
|
||||
size={20}
|
||||
className="text-light-900 dark:text-dark-900"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<Link href="/" className="hidden md:block">
|
||||
<h1 className="text-lg font-bold tracking-tight text-neutral-900 dark:text-dark-1000">
|
||||
kan.bn
|
||||
</h1>
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{hasRightPanel && (
|
||||
<button
|
||||
ref={rightPanelButtonRef}
|
||||
onClick={toggleRightPanel}
|
||||
className="text-neutral-900 dark:text-dark-1000 md:hidden"
|
||||
>
|
||||
{isRightPanelOpen ? (
|
||||
<TbLayoutSidebarRightCollapse
|
||||
size={20}
|
||||
className="text-light-900 dark:text-dark-900"
|
||||
/>
|
||||
) : (
|
||||
<TbLayoutSidebarRightExpand
|
||||
size={20}
|
||||
className="text-light-900 dark:text-dark-900"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<div className="hidden md:block">
|
||||
<FeedbackButton />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex h-full min-h-0 w-full">
|
||||
<div className="flex h-[calc(100dvh-4.5rem)] min-h-0 w-full md:h-[calc(100dvh-1.5rem)]">
|
||||
<div
|
||||
ref={sideNavRef}
|
||||
className={`fixed left-0 top-12 z-50 h-[calc(100dvh-3rem)] transform transition-transform duration-300 ease-in-out md:relative md:top-0 md:h-full md:translate-x-0 ${isSideNavOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"} `}
|
||||
className={`fixed top-12 z-40 h-[calc(100dvh-3rem)] w-[calc(100vw-1.5rem)] transform transition-transform duration-300 ease-in-out md:relative md:top-0 md:h-full md:w-auto md:translate-x-0 ${isSideNavOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"} `}
|
||||
>
|
||||
<SideNavigation
|
||||
user={{ email: session?.user.email, image: session?.user.image }}
|
||||
isLoading={sessionLoading}
|
||||
onCloseSideNav={closeSideNav}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="relative flex min-h-0 w-full overflow-hidden">
|
||||
<div className="h-full w-full overflow-y-auto">{children}</div>
|
||||
<div className="relative h-full min-h-0 w-full overflow-hidden md:rounded-lg md:border md:border-light-300 md:bg-light-50 md:dark:border-dark-300 md:dark:bg-dark-50">
|
||||
<div className="relative flex h-full min-h-0 w-full overflow-hidden">
|
||||
<div className="h-full w-full overflow-y-auto">{children}</div>
|
||||
|
||||
{/* Mobile Right Panel */}
|
||||
{hasRightPanel && rightPanel && (
|
||||
<div
|
||||
ref={rightPanelRef}
|
||||
className={`fixed right-0 top-12 z-50 h-[calc(100dvh-3rem)] w-80 transform bg-light-200 transition-transform duration-300 ease-in-out dark:bg-dark-100 md:hidden ${
|
||||
isRightPanelOpen ? "translate-x-0" : "translate-x-full"
|
||||
}`}
|
||||
>
|
||||
<div className="h-full overflow-y-auto">{rightPanel}</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Mobile Right Panel */}
|
||||
{hasRightPanel && rightPanel && (
|
||||
<div
|
||||
ref={rightPanelRef}
|
||||
className={`fixed right-0 top-12 z-40 h-[calc(100dvh-3rem)] w-80 transform border-l border-light-300 bg-light-200 transition-transform duration-300 ease-in-out dark:border-dark-300 dark:bg-dark-100 md:hidden ${
|
||||
isRightPanelOpen ? "translate-x-0" : "translate-x-full"
|
||||
}`}
|
||||
>
|
||||
<div className="h-full">{rightPanel}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Desktop Right Panel */}
|
||||
{hasRightPanel && rightPanel && (
|
||||
<div className="hidden md:block">{rightPanel}</div>
|
||||
)}
|
||||
{/* Desktop Right Panel */}
|
||||
{hasRightPanel && rightPanel && (
|
||||
<div className="hidden md:block">{rightPanel}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -350,7 +350,6 @@ export default function Editor({
|
||||
<div ref={containerRef}>
|
||||
<style jsx global>{`
|
||||
.tiptap p.is-empty::before {
|
||||
color: #adb5bd;
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
height: 0;
|
||||
@@ -363,7 +362,7 @@ export default function Editor({
|
||||
{!readOnly && editor && <EditorBubbleMenu editor={editor} />}
|
||||
<EditorContent
|
||||
editor={editor}
|
||||
className="prose dark:prose-invert prose-sm max-w-none overflow-y-auto [&_blockquote]:!text-xs [&_h1]:!text-lg [&_h2]:!text-base [&_h3]:!text-sm [&_ol]:!text-xs [&_p.is-empty::before]:text-dark-800 [&_p.is-empty::before]:dark:text-dark-800 [&_p]:!text-sm [&_p]:text-black [&_p]:dark:text-white [&_ul]:!text-xs"
|
||||
className="prose dark:prose-invert prose-sm max-w-none overflow-y-auto [&_blockquote]:!text-xs [&_h1]:!text-lg [&_h2]:!text-base [&_h3]:!text-sm [&_ol]:!text-xs [&_p.is-empty::before]:text-light-900 [&_p.is-empty::before]:dark:text-dark-800 [&_p]:!text-sm [&_p]:text-black [&_p]:dark:text-white [&_ul]:!text-xs"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
import Link from "next/link";
|
||||
import { Menu, Transition } from "@headlessui/react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Fragment, useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import chatIconDark from "~/assets/chat-dark.json";
|
||||
import chatIconLight from "~/assets/chat-light.json";
|
||||
import Button from "~/components/Button";
|
||||
import Input from "~/components/Input";
|
||||
import LottieIcon from "~/components/LottieIcon";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { useTheme } from "~/providers/theme";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
interface NewFeedbackFormInput {
|
||||
feedback: string;
|
||||
}
|
||||
|
||||
const FeedbackButton: React.FC = () => {
|
||||
const outsideRef = useRef<HTMLDivElement>(null);
|
||||
const { activeTheme } = useTheme();
|
||||
const { showPopup } = usePopup();
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [index, setIndex] = useState(0);
|
||||
|
||||
const { handleSubmit, setValue, watch, reset } =
|
||||
useForm<NewFeedbackFormInput>({
|
||||
defaultValues: {
|
||||
feedback: "",
|
||||
},
|
||||
});
|
||||
|
||||
const createFeedback = api.feedback.create.useMutation({
|
||||
onSuccess: async () => {
|
||||
reset();
|
||||
showPopup({
|
||||
header: t`Feedback sent`,
|
||||
message: t`Thank you for your feedback!`,
|
||||
icon: "success",
|
||||
});
|
||||
},
|
||||
onError: async () => {
|
||||
showPopup({
|
||||
header: t`Unable to send feedback`,
|
||||
message: t`Please try again later, or contact customer support.`,
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
setIsHovered(true);
|
||||
setIndex((index) => index + 1);
|
||||
};
|
||||
|
||||
const onSubmit = (values: NewFeedbackFormInput) => {
|
||||
createFeedback.mutate({
|
||||
feedback: values.feedback,
|
||||
url: window.location.href,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={outsideRef} />
|
||||
<Menu as="div" className="relative inline-block text-left">
|
||||
<div>
|
||||
<Menu.Button
|
||||
onMouseEnter={handleMouseEnter}
|
||||
className="flex items-center rounded-md border-[1px] border-light-600 bg-light-50 px-2.5 py-1.5 text-sm font-normal text-neutral-900 shadow-sm dark:border-dark-400 dark:bg-dark-50 dark:text-dark-1000"
|
||||
>
|
||||
<LottieIcon
|
||||
index={index}
|
||||
json={activeTheme === "dark" ? chatIconDark : chatIconLight}
|
||||
isPlaying={isHovered}
|
||||
/>
|
||||
<span className="ml-1">{t`Feedback`}</span>
|
||||
</Menu.Button>
|
||||
</div>
|
||||
|
||||
<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"
|
||||
>
|
||||
<Menu.Items
|
||||
as="div"
|
||||
static
|
||||
className="absolute right-0 z-30 mt-2 w-[350px] origin-top-right rounded-md border border-light-200 bg-light-50 p-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-400 dark:bg-dark-300"
|
||||
>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="p-1">
|
||||
<Input
|
||||
placeholder={t`Ideas to improve this page...`}
|
||||
onChange={(e) => {
|
||||
setValue("feedback", e.target.value);
|
||||
}}
|
||||
value={watch("feedback")}
|
||||
contentEditable
|
||||
className="max-h-[300px] min-h-[100px]"
|
||||
onKeyDown={async (e) => {
|
||||
e.stopPropagation();
|
||||
if (e.key === "Enter" && e.shiftKey) {
|
||||
e.preventDefault();
|
||||
await handleSubmit(onSubmit)();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-row items-center justify-between pt-2">
|
||||
<div>
|
||||
<p className="ml-2 text-xs text-neutral-900 dark:text-dark-1000">
|
||||
{t`Need help?`}{" "}
|
||||
<Link
|
||||
href="mailto:support@kan.bn"
|
||||
className="text-blue-600 underline dark:text-blue-300"
|
||||
>
|
||||
{t`Contact us`}
|
||||
</Link>
|
||||
{t`, or see our`}{" "}
|
||||
<Link
|
||||
href="https://docs.kan.bn"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-blue-600 underline dark:text-blue-300"
|
||||
>
|
||||
{t`docs`}
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
<div className="justify-end">
|
||||
<Button size="sm" isLoading={createFeedback.isPending}>
|
||||
{t`Send`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Menu.Items>
|
||||
</Transition>
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeedbackButton;
|
||||
122
apps/web/src/components/FeedbackModal.tsx
Normal file
122
apps/web/src/components/FeedbackModal.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import Link from "next/link";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { HiXMark } from "react-icons/hi2";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import Input from "~/components/Input";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
interface NewFeedbackFormInput {
|
||||
feedback: string;
|
||||
}
|
||||
|
||||
export default function FeedbackModal() {
|
||||
const { closeModal } = useModal();
|
||||
const { showPopup } = usePopup();
|
||||
|
||||
const { handleSubmit, setValue, watch, reset } =
|
||||
useForm<NewFeedbackFormInput>({
|
||||
defaultValues: {
|
||||
feedback: "",
|
||||
},
|
||||
});
|
||||
|
||||
const createFeedback = api.feedback.create.useMutation({
|
||||
onSuccess: async () => {
|
||||
reset();
|
||||
closeModal();
|
||||
showPopup({
|
||||
header: t`Feedback sent`,
|
||||
message: t`Thank you for your feedback!`,
|
||||
icon: "success",
|
||||
});
|
||||
},
|
||||
onError: async () => {
|
||||
showPopup({
|
||||
header: t`Unable to send feedback`,
|
||||
message: t`Please try again later, or contact customer support.`,
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (values: NewFeedbackFormInput) => {
|
||||
createFeedback.mutate({
|
||||
feedback: values.feedback,
|
||||
url: window.location.href,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="px-5 pt-5">
|
||||
<div className="flex w-full items-center justify-between pb-4">
|
||||
<h2 className="text-sm font-bold text-neutral-900 dark:text-dark-1000">
|
||||
{t`Feedback`}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded p-1 hover:bg-light-200 focus:outline-none dark:hover:bg-dark-300"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
closeModal();
|
||||
}}
|
||||
>
|
||||
<HiXMark size={18} className="text-light-900 dark:text-dark-900" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
id="feedback"
|
||||
placeholder={t`Ideas to improve this page...`}
|
||||
onChange={(e) => setValue("feedback", e.target.value)}
|
||||
contentEditable
|
||||
value={watch("feedback")}
|
||||
className="min-h-[120px]"
|
||||
onKeyDown={async (e) => {
|
||||
e.stopPropagation();
|
||||
if (e.key === "Enter" && e.shiftKey) {
|
||||
e.preventDefault();
|
||||
await handleSubmit(onSubmit)();
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
closeModal();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-6 flex items-center justify-between border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
|
||||
<div className="text-xs text-neutral-600 dark:text-dark-800">
|
||||
<p>
|
||||
{t`Need help?`}{" "}
|
||||
<Link
|
||||
href="mailto:support@kan.bn"
|
||||
className="text-blue-600 underline dark:text-blue-300"
|
||||
>
|
||||
{t`Contact us`}
|
||||
</Link>
|
||||
{t`, or see our`}{" "}
|
||||
<Link
|
||||
href="https://docs.kan.bn"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-blue-600 underline dark:text-blue-300"
|
||||
>
|
||||
{t`docs`}
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Button type="submit" isLoading={createFeedback.isPending}>
|
||||
{t`Send feedback`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -2,11 +2,11 @@ import dynamic from "next/dynamic";
|
||||
|
||||
const Lottie = dynamic(() => import("react-lottie-player"), { ssr: false });
|
||||
|
||||
type IconProps = {
|
||||
interface IconProps {
|
||||
isPlaying: boolean;
|
||||
index: number;
|
||||
json: object;
|
||||
};
|
||||
}
|
||||
|
||||
const Icon: React.FC<IconProps> = ({ isPlaying, index, json }) => {
|
||||
return (
|
||||
@@ -15,7 +15,7 @@ const Icon: React.FC<IconProps> = ({ isPlaying, index, json }) => {
|
||||
animationData={json}
|
||||
play={isPlaying}
|
||||
loop={false}
|
||||
style={{ width: 20, height: 20, fill: "white" }}
|
||||
style={{ width: 18, height: 18, fill: "white" }}
|
||||
rendererSettings={{ preserveAspectRatio: "xMidYMid slice" }}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -3,8 +3,8 @@ const PatternedBackground = () => (
|
||||
<svg className="h-full w-full">
|
||||
<pattern
|
||||
id="pattern"
|
||||
x="0.034759358288862785"
|
||||
y="3.335370511841166"
|
||||
x="10"
|
||||
y="10"
|
||||
width="14.423223834988539"
|
||||
height="14.423223834988539"
|
||||
patternUnits="userSpaceOnUse"
|
||||
|
||||
@@ -1,37 +1,51 @@
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import LottieIcon from "~/components/LottieIcon";
|
||||
|
||||
function classNames(...classes: string[]): string {
|
||||
return classes.filter(Boolean).join(" ");
|
||||
}
|
||||
import { useIsMobile } from "~/hooks/useMediaQuery";
|
||||
|
||||
const Button: React.FC<{
|
||||
href: string;
|
||||
current: boolean;
|
||||
name: string;
|
||||
json: object;
|
||||
}> = ({ href, current, name, json }) => {
|
||||
isCollapsed?: boolean;
|
||||
onCloseSideNav?: () => void;
|
||||
}> = ({ href, current, name, json, isCollapsed = false, onCloseSideNav }) => {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [index, setIndex] = useState(0);
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
setIsHovered(true);
|
||||
setIndex((index) => index + 1);
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
if (onCloseSideNav && isMobile) {
|
||||
onCloseSideNav();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
className={classNames(
|
||||
current ? "bg-light-200 dark:bg-dark-200" : "dark:bg-dark-50",
|
||||
"group flex items-center gap-x-3 rounded-md p-1.5 text-sm font-normal leading-6 text-neutral-900 hover:bg-light-200 dark:text-dark-1000 dark:hover:bg-dark-200",
|
||||
onClick={handleClick}
|
||||
className={twMerge(
|
||||
"group flex h-[34px] items-center rounded-md p-1.5 text-sm font-normal leading-6 hover:bg-light-200 hover:text-light-1000 dark:hover:bg-dark-200 dark:hover:text-dark-1000",
|
||||
current
|
||||
? "bg-light-200 text-light-1000 dark:bg-dark-200 dark:text-dark-1000"
|
||||
: "text-neutral-600 dark:bg-dark-100 dark:text-dark-900",
|
||||
isCollapsed
|
||||
? "justify-start gap-x-3 md:justify-center md:gap-x-0"
|
||||
: "gap-x-3",
|
||||
)}
|
||||
title={isCollapsed ? name : undefined}
|
||||
>
|
||||
<LottieIcon index={index} json={json} isPlaying={isHovered} />
|
||||
{name}
|
||||
<span className={twMerge(isCollapsed && "md:hidden")}>{name}</span>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { Button } from "@headlessui/react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
TbLayoutSidebarLeftCollapse,
|
||||
TbLayoutSidebarLeftExpand,
|
||||
} from "react-icons/tb";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import boardsIconDark from "~/assets/boards-dark.json";
|
||||
import boardsIconLight from "~/assets/boards-light.json";
|
||||
@@ -15,6 +23,7 @@ import { useTheme } from "~/providers/theme";
|
||||
interface SideNavigationProps {
|
||||
user: UserType;
|
||||
isLoading: boolean;
|
||||
onCloseSideNav?: () => void;
|
||||
}
|
||||
|
||||
interface UserType {
|
||||
@@ -25,8 +34,28 @@ interface UserType {
|
||||
export default function SideNavigation({
|
||||
user,
|
||||
isLoading,
|
||||
onCloseSideNav,
|
||||
}: SideNavigationProps) {
|
||||
const router = useRouter();
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
const [isInitialised, setIsInitialised] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const savedState = localStorage.getItem("kan_sidebar-collapsed");
|
||||
if (savedState !== null) {
|
||||
setIsCollapsed(JSON.parse(savedState));
|
||||
}
|
||||
setIsInitialised(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isInitialised) {
|
||||
localStorage.setItem(
|
||||
"kan_sidebar-collapsed",
|
||||
JSON.stringify(isCollapsed),
|
||||
);
|
||||
}
|
||||
}, [isCollapsed, isInitialised]);
|
||||
|
||||
const { pathname } = router;
|
||||
|
||||
@@ -52,11 +81,50 @@ export default function SideNavigation({
|
||||
},
|
||||
];
|
||||
|
||||
const toggleCollapse = () => {
|
||||
setIsCollapsed(!isCollapsed);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<nav className="flex h-full w-64 flex-col justify-between border-r border-light-600 bg-light-50 px-3 pb-3 pt-5 dark:border-dark-400 dark:bg-dark-50">
|
||||
<nav
|
||||
className={twMerge(
|
||||
"flex h-full w-64 flex-col justify-between border-r border-light-300 bg-light-100 p-3 dark:border-dark-300 dark:bg-dark-100 md:border-r-0 md:py-0 md:pl-0",
|
||||
isCollapsed && "md:w-auto",
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
<WorkspaceMenu />
|
||||
<div className="hidden h-14 items-center justify-between pb-[18px] pt-1.5 md:flex">
|
||||
{!isCollapsed && (
|
||||
<Link href="/" className="block">
|
||||
<h1 className="pl-2 text-lg font-bold tracking-tight text-neutral-900 dark:text-dark-1000">
|
||||
kan.bn
|
||||
</h1>
|
||||
</Link>
|
||||
)}
|
||||
<Button
|
||||
onClick={toggleCollapse}
|
||||
className={twMerge(
|
||||
"flex h-8 items-center justify-center rounded-md hover:bg-light-200 dark:hover:bg-dark-200",
|
||||
isCollapsed ? "w-full" : "w-8",
|
||||
)}
|
||||
>
|
||||
{isCollapsed ? (
|
||||
<TbLayoutSidebarLeftExpand
|
||||
size={18}
|
||||
className="text-light-900 dark:text-dark-900"
|
||||
/>
|
||||
) : (
|
||||
<TbLayoutSidebarLeftCollapse
|
||||
size={18}
|
||||
className="text-light-900 dark:text-dark-900"
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mx-1 mb-4 hidden w-auto border-b border-light-300 dark:border-dark-400 md:block" />
|
||||
|
||||
<WorkspaceMenu isCollapsed={isCollapsed} />
|
||||
<ul role="list" className="space-y-1">
|
||||
{navigation.map((item) => (
|
||||
<li key={item.name}>
|
||||
@@ -65,16 +133,23 @@ export default function SideNavigation({
|
||||
current={pathname.includes(item.href)}
|
||||
name={item.name}
|
||||
json={item.icon}
|
||||
isCollapsed={isCollapsed}
|
||||
onCloseSideNav={onCloseSideNav}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<UserMenu
|
||||
email={user.email ?? ""}
|
||||
imageUrl={user.image ?? undefined}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
|
||||
<div className="space-y-3">
|
||||
<UserMenu
|
||||
email={user.email ?? ""}
|
||||
imageUrl={user.image ?? undefined}
|
||||
isLoading={isLoading}
|
||||
isCollapsed={isCollapsed}
|
||||
onCloseSideNav={onCloseSideNav}
|
||||
/>
|
||||
</div>
|
||||
</nav>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Menu, Transition } from "@headlessui/react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Fragment } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { authClient } from "@kan/auth/client";
|
||||
|
||||
import { useIsMobile } from "~/hooks/useMediaQuery";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { useTheme } from "~/providers/theme";
|
||||
import { getAvatarUrl } from "~/utils/helpers";
|
||||
|
||||
@@ -13,38 +17,63 @@ interface UserMenuProps {
|
||||
imageUrl: string | undefined;
|
||||
email: string;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
function classNames(...classes: string[]): string {
|
||||
return classes.filter(Boolean).join(" ");
|
||||
isCollapsed?: boolean;
|
||||
onCloseSideNav?: () => void;
|
||||
}
|
||||
|
||||
export default function UserMenu({
|
||||
imageUrl,
|
||||
email,
|
||||
isLoading,
|
||||
isCollapsed = false,
|
||||
onCloseSideNav,
|
||||
}: UserMenuProps) {
|
||||
const router = useRouter();
|
||||
const { themePreference, switchTheme } = useTheme();
|
||||
const { openModal } = useModal();
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const handleLogout = async () => {
|
||||
if (onCloseSideNav && isMobile) {
|
||||
onCloseSideNav();
|
||||
}
|
||||
await authClient.signOut();
|
||||
|
||||
router.push("/login");
|
||||
};
|
||||
|
||||
const handleLinkClick = () => {
|
||||
if (onCloseSideNav && isMobile) {
|
||||
onCloseSideNav();
|
||||
}
|
||||
};
|
||||
|
||||
const handleModalOpen = (modalType: string) => {
|
||||
if (onCloseSideNav && isMobile) {
|
||||
onCloseSideNav();
|
||||
}
|
||||
openModal(modalType);
|
||||
};
|
||||
|
||||
const avatarUrl = imageUrl ? getAvatarUrl(imageUrl) : null;
|
||||
|
||||
return (
|
||||
<Menu as="div" className="relative inline-block w-full text-left">
|
||||
<div>
|
||||
{isLoading ? (
|
||||
<div className="flex">
|
||||
<div className={twMerge(!isCollapsed && "flex")}>
|
||||
<div className="h-[30px] w-[30px] animate-pulse rounded-full bg-light-200 dark:bg-dark-200" />
|
||||
<div className="mx-2 h-[30px] w-[175px] animate-pulse rounded-md bg-light-200 dark:bg-dark-200" />
|
||||
<div
|
||||
className={twMerge(
|
||||
"mx-2 h-[30px] w-[175px] animate-pulse rounded-md bg-light-200 dark:bg-dark-200",
|
||||
isCollapsed && "md:hidden",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Menu.Button className="flex w-full items-center rounded-md p-1.5 text-neutral-900 hover:bg-light-200 dark:text-dark-900 dark:hover:bg-dark-200 dark:hover:text-dark-1000">
|
||||
<Menu.Button
|
||||
className="flex w-full items-center rounded-md p-1.5 text-neutral-900 hover:bg-light-200 dark:text-dark-900 dark:hover:bg-dark-200 dark:hover:text-dark-1000"
|
||||
title={isCollapsed ? email : undefined}
|
||||
>
|
||||
{avatarUrl ? (
|
||||
<Image
|
||||
src={avatarUrl}
|
||||
@@ -64,7 +93,14 @@ export default function UserMenu({
|
||||
</svg>
|
||||
</span>
|
||||
)}
|
||||
<span className="mx-2 truncate text-sm">{email}</span>
|
||||
<span
|
||||
className={twMerge(
|
||||
"mx-2 truncate text-sm",
|
||||
isCollapsed && "md:hidden",
|
||||
)}
|
||||
>
|
||||
{email}
|
||||
</span>
|
||||
</Menu.Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -78,7 +114,12 @@ export default function UserMenu({
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<Menu.Items className="absolute bottom-[40px] left-0 z-10 mt-2 w-full origin-top-left rounded-md border border-light-600 bg-light-50 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-600 dark:bg-dark-300">
|
||||
<Menu.Items
|
||||
className={twMerge(
|
||||
"absolute bottom-[40px] z-10 mt-2 origin-top-left rounded-md border border-light-600 bg-light-50 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-600 dark:bg-dark-300",
|
||||
isCollapsed ? "left-0 w-48" : "left-0 w-full",
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col text-neutral-900 dark:text-dark-1000">
|
||||
<div className="p-1">
|
||||
<div className="flex w-full items-center px-3 py-2 text-left text-xs">
|
||||
@@ -90,7 +131,7 @@ export default function UserMenu({
|
||||
className="flex w-full items-center rounded-[5px] px-3 py-2 text-left text-xs hover:bg-light-200 dark:hover:bg-dark-400"
|
||||
>
|
||||
<span
|
||||
className={classNames(
|
||||
className={twMerge(
|
||||
themePreference === "system" ? "visible" : "invisible",
|
||||
"mr-4 h-[6px] w-[6px] rounded-full bg-light-900 dark:bg-dark-900",
|
||||
)}
|
||||
@@ -104,7 +145,7 @@ export default function UserMenu({
|
||||
className="flex w-full items-center rounded-[5px] px-3 py-2 text-left text-xs hover:bg-light-200 dark:hover:bg-dark-400"
|
||||
>
|
||||
<span
|
||||
className={classNames(
|
||||
className={twMerge(
|
||||
themePreference === "dark" ? "visible" : "invisible",
|
||||
"mr-4 h-[6px] w-[6px] rounded-full bg-light-900 dark:bg-dark-900",
|
||||
)}
|
||||
@@ -118,7 +159,7 @@ export default function UserMenu({
|
||||
className="flex w-full items-center rounded-[5px] px-3 py-2 text-left text-xs hover:bg-light-200 dark:hover:bg-dark-400"
|
||||
>
|
||||
<span
|
||||
className={classNames(
|
||||
className={twMerge(
|
||||
themePreference === "light" ? "visible" : "invisible",
|
||||
"mr-4 h-[6px] w-[6px] rounded-full bg-light-900 dark:bg-dark-900",
|
||||
)}
|
||||
@@ -127,6 +168,38 @@ export default function UserMenu({
|
||||
</button>
|
||||
</Menu.Item>
|
||||
</div>
|
||||
<div className="light-border-600 border-t-[1px] p-1 dark:border-dark-600">
|
||||
<Menu.Item>
|
||||
<Link
|
||||
href="mailto:support@kan.bn"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
onClick={handleLinkClick}
|
||||
className="flex w-full items-center rounded-[5px] px-3 py-2 text-left text-xs hover:bg-light-200 dark:hover:bg-dark-400"
|
||||
>
|
||||
{t`Support`}
|
||||
</Link>
|
||||
</Menu.Item>
|
||||
<Menu.Item>
|
||||
<Link
|
||||
href="https://docs.kan.bn"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
onClick={handleLinkClick}
|
||||
className="flex w-full items-center rounded-[5px] px-3 py-2 text-left text-xs hover:bg-light-200 dark:hover:bg-dark-400"
|
||||
>
|
||||
{t`Documentation`}
|
||||
</Link>
|
||||
</Menu.Item>
|
||||
<Menu.Item>
|
||||
<button
|
||||
onClick={() => handleModalOpen("NEW_FEEDBACK")}
|
||||
className="flex w-full items-center rounded-[5px] px-3 py-2 text-left text-xs hover:bg-light-200 dark:hover:bg-dark-400"
|
||||
>
|
||||
{t`Feedback`}
|
||||
</button>
|
||||
</Menu.Item>
|
||||
</div>
|
||||
<div className="light-border-600 border-t-[1px] p-1 dark:border-dark-600">
|
||||
<Menu.Item>
|
||||
<button
|
||||
|
||||
@@ -2,11 +2,16 @@ import { Menu, Transition } from "@headlessui/react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Fragment } from "react";
|
||||
import { HiCheck } from "react-icons/hi2";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { useWorkspace } from "~/providers/workspace";
|
||||
|
||||
export default function WorkspaceMenu() {
|
||||
export default function WorkspaceMenu({
|
||||
isCollapsed = false,
|
||||
}: {
|
||||
isCollapsed?: boolean;
|
||||
}) {
|
||||
const { workspace, isLoading, availableWorkspaces, switchWorkspace } =
|
||||
useWorkspace();
|
||||
const { openModal } = useModal();
|
||||
@@ -15,22 +20,43 @@ export default function WorkspaceMenu() {
|
||||
<Menu as="div" className="relative inline-block w-full pb-3 text-left">
|
||||
<div>
|
||||
{isLoading ? (
|
||||
<div className="mb-1 flex p-1.5">
|
||||
<div className="h-6 w-6 animate-pulse rounded-[5px] bg-light-200 dark:bg-dark-200" />
|
||||
<div className="ml-2 h-6 w-[150px] animate-pulse rounded-[5px] bg-light-200 dark:bg-dark-200" />
|
||||
<div className={twMerge("mb-1", isCollapsed && "md:flex md:p-1.5")}>
|
||||
<div className="h-6 w-6 animate-pulse rounded-md bg-light-200 dark:bg-dark-200" />
|
||||
<div
|
||||
className={twMerge(
|
||||
"ml-2 h-6 w-[150px] animate-pulse rounded-md bg-light-200 dark:bg-dark-200",
|
||||
isCollapsed && "hidden md:block",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Menu.Button className="mb-1 flex w-full items-center rounded-[5px] p-1.5 hover:bg-light-200 dark:hover:bg-dark-200">
|
||||
<span className="inline-flex h-6 w-6 items-center justify-center rounded-[5px] bg-indigo-700">
|
||||
<Menu.Button
|
||||
className={twMerge(
|
||||
"mb-1 flex h-[34px] w-full items-center rounded-md p-1.5 hover:bg-light-200 dark:hover:bg-dark-200",
|
||||
isCollapsed && "md:mb-1.5 md:h-9 md:p-1",
|
||||
)}
|
||||
title={isCollapsed ? workspace.name : undefined}
|
||||
>
|
||||
<span className="inline-flex h-6 w-6 items-center justify-center rounded-md bg-indigo-700">
|
||||
<span className="text-xs font-bold leading-none text-white">
|
||||
{workspace.name.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</span>
|
||||
<span className="ml-2 text-sm font-bold text-neutral-900 dark:text-dark-1000">
|
||||
<span
|
||||
className={twMerge(
|
||||
"ml-2 text-sm font-bold text-neutral-900 dark:text-dark-1000",
|
||||
isCollapsed && "md:hidden",
|
||||
)}
|
||||
>
|
||||
{workspace.name}
|
||||
</span>
|
||||
{workspace.plan === "pro" && (
|
||||
<span className="ml-2 inline-flex items-center rounded-md bg-indigo-100 px-2 py-1 text-[10px] font-medium text-indigo-700">
|
||||
<span
|
||||
className={twMerge(
|
||||
"ml-2 inline-flex items-center rounded-md bg-indigo-100 px-2 py-1 text-[10px] font-medium text-indigo-700",
|
||||
isCollapsed && "md:hidden",
|
||||
)}
|
||||
>
|
||||
Pro
|
||||
</span>
|
||||
)}
|
||||
@@ -47,7 +73,12 @@ export default function WorkspaceMenu() {
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<Menu.Items className="absolute left-0 z-10 w-full origin-top-left rounded-md border border-light-600 bg-light-50 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-600 dark:bg-dark-300">
|
||||
<Menu.Items
|
||||
className={twMerge(
|
||||
"absolute left-0 z-10 origin-top-left rounded-md border border-light-600 bg-light-50 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-600 dark:bg-dark-300",
|
||||
isCollapsed ? "w-48" : "w-full",
|
||||
)}
|
||||
>
|
||||
<div className="p-1">
|
||||
{availableWorkspaces.map((availableWorkspace) => (
|
||||
<div key={availableWorkspace.publicId} className="flex">
|
||||
|
||||
Reference in New Issue
Block a user