feat: monorepo

This commit is contained in:
Henry
2024-12-12 14:34:10 +00:00
parent b8eed7a90c
commit 0c8d17dce5
370 changed files with 10280 additions and 39805 deletions

View File

@@ -0,0 +1,100 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { FaGoogle } from "react-icons/fa";
import { z } from "zod";
import LoadingSpinner from "~/components/LoadingSpinner";
import { api } from "~/utils/api";
interface FormValues {
email: string;
}
interface AuthProps {
setIsMagicLinkSent: (value: boolean, recipient: string) => void;
}
const EmailSchema = z.object({ email: z.string().email() });
export function Auth({ setIsMagicLinkSent }: AuthProps) {
const {
register,
handleSubmit,
watch,
formState: { errors },
} = useForm<FormValues>({
resolver: zodResolver(EmailSchema), // Apply the zodResolver
});
const email = watch("email");
const loginWithEmail = api.auth.loginWithEmail.useMutation({
onSuccess: () => {
setIsMagicLinkSent(true, email);
},
});
const loginWithOAuth = api.auth.loginWithOAuth.useMutation({
onSuccess: (data) => {
if (data?.url) window.open(data.url);
},
});
const onSubmit = (values: FormValues) => {
try {
loginWithEmail.mutate({
email: values.email,
});
} catch (error) {
console.error(error);
}
};
return (
<div className="space-y-6">
<div>
<button
type="button"
onClick={() => loginWithOAuth.mutate({ provider: "google" })}
className="flex w-full items-center justify-center rounded-md bg-dark-1000 px-3 py-2 text-sm font-semibold leading-6 text-dark-50 shadow-sm focus-visible:outline focus-visible:outline-2"
>
<FaGoogle className="mr-2" /> Continue with Google
</button>
</div>
<form onSubmit={handleSubmit(onSubmit)}>
<div className="mb-[1.5rem] h-[1px] w-full bg-dark-600" />
<input
{...register("email", { required: true })}
placeholder="Enter your email address"
autoComplete="email"
className="block w-full rounded-md border-0 bg-dark-500 bg-white/5 py-2 text-dark-1000 shadow-sm ring-1 ring-inset ring-dark-600 focus:ring-inset focus:ring-dark-800 sm:text-sm sm:leading-6"
/>
{!loginWithEmail.error && !loginWithOAuth.error && errors.email && (
<p className="mt-2 text-xs text-red-400">
Please enter a valid email address
</p>
)}
{(loginWithEmail.error ?? loginWithOAuth.error) ? (
<p className="mt-2 text-xs text-red-400">
Something went wrong, please try again later or contact customer
support.
</p>
) : null}
<div className="mt-[1.5rem]">
<button
type="submit"
disabled={loginWithEmail.isPending}
className="flex w-full items-center justify-center rounded-md bg-dark-600 px-3 py-2 text-sm font-semibold leading-6 text-dark-1000 shadow-sm focus-visible:outline focus-visible:outline-2"
>
{loginWithEmail.isPending ? (
<LoadingSpinner />
) : (
"Continue with email"
)}
</button>
</div>
</form>
</div>
);
}

View File

@@ -0,0 +1,48 @@
import { twMerge } from "tailwind-merge";
import { getInitialsFromName, inferInitialsFromEmail } from "~/utils/helpers";
const Avatar = ({
size = "md",
name,
email,
icon,
isLoading,
}: {
size?: "sm" | "md" | "lg";
name: string;
email: string;
icon?: React.ReactNode;
isLoading: boolean;
}) => {
const initials = name
? getInitialsFromName(name)
: inferInitialsFromEmail(email ?? "");
return (
<span
className={twMerge(
"inline-flex h-9 w-9 items-center justify-center rounded-full bg-light-1000 dark:bg-dark-400",
isLoading && "animate-pulse bg-light-200 dark:bg-dark-200",
size === "sm" && "h-6 w-6",
size === "lg" && "h-12 w-12",
)}
>
{icon ? (
<span className="text-[12px] text-white">{icon}</span>
) : (
<span
className={twMerge(
"text-sm font-medium leading-none text-white",
size === "sm" && "text-[10px]",
size === "lg" && "text-md",
)}
>
{initials}
</span>
)}
</span>
);
};
export default Avatar;

View File

@@ -0,0 +1,87 @@
import Link from "next/link";
import { twMerge } from "tailwind-merge";
import LoadingSpinner from "./LoadingSpinner";
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: "primary" | "secondary" | "danger" | "ghost";
size?: "sm" | "md" | "lg";
isLoading?: boolean;
iconLeft?: React.ReactNode;
iconRight?: React.ReactNode;
href?: string;
openInNewTab?: boolean;
}
const Button = ({
children,
size = "md",
iconLeft,
iconRight,
isLoading,
variant = "primary",
href,
openInNewTab,
...props
}: ButtonProps) => {
const classes = twMerge(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-2 text-sm font-semibold text-light-50 shadow-sm focus-visible:outline-none",
size === "sm" && "text-xs",
size === "lg" && "px-4 py-3 text-lg",
variant === "primary" &&
"bg-light-1000 dark:bg-dark-1000 dark:text-dark-50",
variant === "secondary" &&
"border-[1px] border-light-600 bg-light-50 text-light-1000 dark:border-dark-600 dark:bg-dark-300 dark:text-dark-1000",
variant === "danger" &&
"dark:text-red-1000 border-[1px] border-red-600 bg-red-50 dark:border-red-600 dark:bg-red-500",
variant === "ghost" &&
"bg-none text-light-1000 shadow-none hover:bg-light-300 dark:text-dark-1000 dark:hover:bg-dark-200",
props.disabled && "opacity-50",
);
const content = (
<span className="relative flex items-center justify-center">
{isLoading && (
<span className="absolute">
<LoadingSpinner size={size} />
</span>
)}
<div
className={twMerge(
"flex items-center",
isLoading ? "invisible" : "visible",
)}
>
{iconLeft && <span className="mr-2">{iconLeft}</span>}
{children}
{iconRight && <span className="ml-1">{iconRight}</span>}
</div>
</span>
);
if (href) {
return (
<Link
href={href}
className={classes}
target={openInNewTab ? "_blank" : undefined}
rel={openInNewTab ? "noopener noreferrer" : undefined}
{...(props as React.AnchorHTMLAttributes<HTMLAnchorElement>)}
>
{content}
</Link>
);
}
return (
<button
className={classes}
disabled={isLoading ?? props.disabled}
{...props}
>
{content}
</button>
);
};
export default Button;

View File

@@ -0,0 +1,166 @@
import { Menu, Transition } from "@headlessui/react";
import { Fragment, useState } from "react";
import { twMerge } from "tailwind-merge";
interface Item {
key: string;
value: string;
selected: boolean;
leftIcon?: React.ReactNode;
}
interface Group {
key: string;
label: string;
icon: React.ReactNode;
items: Item[];
}
interface CheckboxDropdownProps {
children: React.ReactNode;
items?: Item[];
groups?: Group[];
menuSpacing?: "sm" | "md" | "lg";
handleSelect: (groupKey: string | null, item: { key: string }) => void;
}
export default function CheckboxDropdown({
children,
items,
groups,
menuSpacing = "sm",
handleSelect,
}: CheckboxDropdownProps) {
const [selectedGroup, setSelectedGroup] = useState<string | null>(null);
const menuSpacingClass = {
sm: "top-[26px]",
md: "top-[32px]",
lg: "top-[38px]",
};
return (
<Menu
as="div"
className="relative flex w-full flex-wrap items-center text-left"
>
<>
<Menu.Button className="focus-visible:outline-none">
{children}
</Menu.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"
afterLeave={() => setSelectedGroup(null)}
>
<Menu.Items
className={twMerge(
"absolute left-0 z-50 mt-2 w-56 origin-top-left rounded-md border-[1px] border-light-200 bg-light-50 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-500 dark:bg-dark-200",
menuSpacingClass[menuSpacing],
)}
>
<div className="p-1">
{!selectedGroup ? (
<>
{items?.map((item) => (
<Menu.Item key={item.key}>
<div
className="flex items-center rounded-[5px] p-2 hover:bg-light-200 dark:hover:bg-dark-300"
onClick={(e) => {
e.preventDefault();
handleSelect(null, { key: item.key });
}}
>
<input
id={item.key}
name={item.key}
type="checkbox"
className="h-[14px] w-[14px] rounded bg-transparent"
onClick={(event) => event.stopPropagation()}
onChange={() => handleSelect(null, { key: item.key })}
checked={item.selected}
/>
{item.leftIcon && (
<span className="ml-3 flex items-center">
{item.leftIcon}
</span>
)}
<label
htmlFor={item.key}
className="ml-3 text-[12px] text-dark-900"
>
{item.value}
</label>
</div>
</Menu.Item>
))}
{groups?.map((group) => (
<Menu.Item key={group.key}>
<div
className="flex items-center rounded-[5px] p-2 hover:bg-light-200 dark:hover:bg-dark-300"
onClick={(e) => {
e.preventDefault();
setSelectedGroup(group.key);
}}
>
<span className="mr-2 text-dark-900">{group.icon}</span>
<span className="pointer-events-none text-[12px] text-dark-900">
{group.label}
</span>
</div>
</Menu.Item>
))}
</>
) : (
<>
{groups
?.find((g) => g.key === selectedGroup)
?.items.map((item) => (
<Menu.Item key={item.key}>
<div
className="flex items-center rounded-[5px] p-2 hover:bg-light-200 dark:hover:bg-dark-300"
onClick={(e) => {
e.preventDefault();
handleSelect(selectedGroup, { key: item.key });
}}
>
<input
id={item.key}
name={item.key}
type="checkbox"
className="h-[14px] w-[14px] rounded bg-transparent"
onClick={(event) => event.stopPropagation()}
onChange={() =>
handleSelect(selectedGroup, { key: item.key })
}
checked={item.selected}
/>
{item.leftIcon && (
<span className="ml-3 flex items-center">
{item.leftIcon}
</span>
)}
<label
htmlFor={item.key}
className="ml-3 text-[12px] text-dark-900"
>
{item.value}
</label>
</div>
</Menu.Item>
))}
</>
)}
</div>
</Menu.Items>
</Transition>
</>
</Menu>
);
}

View File

@@ -0,0 +1,37 @@
import Link from "next/link";
import { api } from "~/utils/api";
import FeedbackButton from "./FeedbackButton";
import SideNavigation from "./SideNavigation";
export default function Dashboard(props: { children: React.ReactNode }) {
const { data, isLoading } = api.auth.getUser.useQuery();
return (
<>
<style jsx global>{`
html {
height: 100vh;
overflow: hidden;
}
`}</style>
<div className="flex h-screen flex-col items-center bg-light-100 dark:bg-dark-50">
<div className="m-auto flex h-16 min-h-16 w-full justify-between border-b border-light-600 px-5 py-2 align-middle dark:border-dark-400">
<div className="my-auto flex w-full items-center justify-between">
<Link href="/">
<h1 className="text-lg font-bold tracking-tight text-neutral-900 dark:text-dark-1000">
kan.bn
</h1>
</Link>
<FeedbackButton />
</div>
</div>
<div className="flex h-full w-full">
<SideNavigation user={{ email: data?.email }} isLoading={isLoading} />
<div className="w-full overflow-hidden">{props.children}</div>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,46 @@
import { Menu, Transition } from "@headlessui/react";
import { Fragment } from "react";
export default function Dropdown({
items,
children,
}: {
items: { label: string; action: () => void; icon?: React.ReactNode }[];
children: React.ReactNode;
}) {
return (
<Menu as="div" className="relative inline-block text-left">
<div>
<Menu.Button className="flex h-7 w-7 items-center justify-center rounded-[5px] hover:bg-light-200 dark:hover:bg-dark-200">
{children}
</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 className="absolute right-0 z-30 mt-2 w-56 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">
<div className="flex flex-col">
{items.map((item) => (
<Menu.Item key={item.label}>
<button
onClick={item.action}
className="flex w-auto items-center gap-2 rounded-[5px] px-2.5 py-1.5 text-left text-sm text-neutral-900 hover:bg-light-200 dark:text-dark-950 dark:hover:bg-dark-400"
>
{item.icon}
{item.label}
</button>
</Menu.Item>
))}
</div>
</Menu.Items>
</Transition>
</Menu>
);
}

View File

@@ -0,0 +1,34 @@
import { useState } from "react";
import chatIconDark from "~/assets/chat-dark.json";
import chatIconLight from "~/assets/chat-light.json";
import LottieIcon from "~/components/LottieIcon";
import { useTheme } from "~/providers/theme";
const FeedbackButton: React.FC = () => {
const { activeTheme } = useTheme();
const [isHovered, setIsHovered] = useState(false);
const [index, setIndex] = useState(0);
const handleMouseEnter = () => {
setIsHovered(true);
setIndex((index) => index + 1);
};
return (
<button
type="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">Feedback</span>
</button>
);
};
export default FeedbackButton;

View File

@@ -0,0 +1,44 @@
import React, { forwardRef } from "react";
import ContentEditable from "react-contenteditable";
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
contentEditable?: boolean;
value?: string;
errorMessage?: string;
onChange?: (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
) => void;
}
const Input = forwardRef<HTMLInputElement, InputProps>(
({ contentEditable, errorMessage, value, onChange, ...props }, ref) => {
if (contentEditable) {
return (
<ContentEditable
placeholder={props.placeholder}
html={value ?? ""}
onChange={onChange}
className="block min-h-[70px] w-full cursor-text rounded-md border-0 bg-dark-300 bg-white/5 px-3 py-1.5 text-light-900 text-neutral-900 shadow-sm ring-1 ring-inset ring-light-600 focus:ring-2 focus:ring-inset focus:ring-light-600 focus-visible:outline-none dark:text-dark-1000 dark:ring-dark-700 dark:focus:ring-dark-700 sm:text-sm sm:leading-6"
/>
);
}
return (
<div className="flex w-full flex-col gap-1">
<input
ref={ref}
onChange={onChange}
className="block w-full rounded-md border-0 bg-dark-300 bg-white/5 py-1.5 shadow-sm ring-1 ring-inset ring-light-600 placeholder:text-dark-800 focus:ring-2 focus:ring-inset focus:ring-light-700 dark:text-dark-1000 dark:ring-dark-700 dark:focus:ring-dark-700 sm:text-sm sm:leading-6"
{...props}
/>
{errorMessage && (
<div className="text-xs text-red-500">{errorMessage}</div>
)}
</div>
);
},
);
Input.displayName = "Input";
export default Input;

View File

@@ -0,0 +1,36 @@
import { twMerge } from "tailwind-merge";
const LoadingSpinner = ({ size = "md" }: { size?: "sm" | "md" | "lg" }) => {
return (
<svg
className={twMerge(
"animate-spin",
size === "sm" && "h-4 w-4",
size === "md" && "h-5 w-5",
size === "lg" && "h-6 w-6",
)}
viewBox="0 0 100 100"
>
<circle
fill="none"
stroke-width="10"
className="stroke-current opacity-40"
cx="50"
cy="50"
r="40"
/>
<circle
fill="none"
stroke-width="10"
className="stroke-current"
stroke-dasharray="280"
stroke-dashoffset="210"
cx="50"
cy="50"
r="40"
/>
</svg>
);
};
export default LoadingSpinner;

View File

@@ -0,0 +1,22 @@
import Lottie from "react-lottie-player";
type IconProps = {
isPlaying: boolean;
index: number;
json: object;
};
const Icon: React.FC<IconProps> = ({ isPlaying, index, json }) => {
return (
<Lottie
key={index}
animationData={json}
play={isPlaying}
loop={false}
style={{ width: 20, height: 20, fill: "white" }}
rendererSettings={{ preserveAspectRatio: "xMidYMid slice" }}
/>
);
};
export default Icon;

View File

@@ -0,0 +1,82 @@
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 { useWorkspace } from "~/providers/workspace";
import { api } from "~/utils/api";
interface FormValues {
name: string;
}
export function NewWorkspaceForm() {
const { closeModal } = useModal();
const { showPopup } = usePopup();
const { switchWorkspace } = useWorkspace();
const { register, handleSubmit } = useForm<FormValues>();
const createWorkspace = api.workspace.create.useMutation({
onSuccess: (values) => {
if (values?.publicId && values.name) {
switchWorkspace({ publicId: values.publicId, name: values.name });
closeModal();
}
},
onError: () => {
showPopup({
header: "Unable to create workspace",
message: "Please try again later, or contact customer support.",
});
},
});
useEffect(() => {
const nameElement: HTMLElement | null =
document?.querySelector<HTMLElement>("#workspace-name");
if (nameElement) nameElement.focus();
}, []);
const onSubmit = (values: FormValues) => {
createWorkspace.mutate({
name: values.name,
});
};
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">
New workspace
</h2>
<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="workspace-name"
placeholder="Workspace name"
{...register("name")}
/>
</div>
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
<div>
<Button type="submit" isLoading={createWorkspace.isPending}>
Create workspace
</Button>
</div>
</div>
</form>
);
}

View File

@@ -0,0 +1,9 @@
import Head from "next/head";
export const PageHead = ({ title }: { title: string }) => {
return (
<Head>
<title>{title}</title>
</Head>
);
};

View File

@@ -0,0 +1,25 @@
const PatternedBackground = () => (
<div className="absolute inset-0 h-full w-full">
<svg className="h-full w-full">
<pattern
id="pattern"
x="0.034759358288862785"
y="3.335370511841166"
width="14.423223834988539"
height="14.423223834988539"
patternUnits="userSpaceOnUse"
patternTransform="translate(-0.45072574484339184,-0.45072574484339184)"
>
<circle
cx="0.45072574484339184"
cy="0.45072574484339184"
r="0.45072574484339184"
fill="#3e3e3e"
></circle>
</pattern>
<rect x="0" y="0" width="100%" height="100%" fill="url(#pattern)"></rect>
</svg>
</div>
);
export default PatternedBackground;

View File

@@ -0,0 +1,76 @@
import { Transition } from "@headlessui/react";
import { useEffect } from "react";
import { HiOutlineExclamationCircle, HiXMark } from "react-icons/hi2";
import { usePopup } from "~/providers/popup";
const Popup: React.FC = () => {
const { isOpen, popupHeader, popupMessage, hidePopup } = usePopup();
useEffect(() => {
if (isOpen) {
const timer = setTimeout(() => {
hidePopup();
}, 5000);
return () => clearTimeout(timer);
}
}, [isOpen, hidePopup]);
return (
<div
aria-live="assertive"
className="pointer-events-none fixed inset-0 z-10 flex items-end px-4 py-6 sm:items-end sm:p-6"
>
<div className="flex w-full flex-col items-center space-y-4 sm:items-end">
<Transition
show={isOpen}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<div className="pointer-events-auto w-full max-w-sm overflow-hidden rounded-lg border border-light-400 bg-light-50 shadow-lg ring-1 ring-black ring-opacity-5 transition data-[closed]:data-[enter]:translate-y-2 data-[enter]:transform data-[closed]:opacity-0 data-[enter]:duration-300 data-[leave]:duration-100 data-[enter]:ease-out data-[leave]:ease-in dark:border-dark-300 dark:bg-dark-200 data-[closed]:data-[enter]:sm:translate-x-2 data-[closed]:data-[enter]:sm:translate-y-0">
<div className="p-4">
<div className="flex items-start">
<div className="flex-shrink-0">
<HiOutlineExclamationCircle
aria-hidden="true"
className="h-6 w-6 text-red-400"
/>
</div>
<div className="ml-3 w-0 flex-1 pt-0.5">
<p className="text-sm font-medium text-neutral-900 dark:text-dark-1000">
{popupHeader}
</p>
<p className="mt-1 text-sm text-neutral-500 dark:text-dark-900">
{popupMessage}
</p>
</div>
<div className="ml-4 flex flex-shrink-0">
<button
type="button"
onClick={() => {
hidePopup();
}}
className="mx-1 inline-flex h-fit items-center rounded-md p-1 px-1 text-sm font-semibold text-dark-50 hover:bg-light-100 dark:hover:bg-dark-400"
>
<span className="sr-only">Close</span>
<HiXMark
aria-hidden="true"
className="h-5 w-5 text-dark-900"
/>
</button>
</div>
</div>
</div>
</div>
</Transition>
</div>
</div>
);
};
export default Popup;

View File

@@ -0,0 +1,39 @@
import Link from "next/link";
import { useState } from "react";
import LottieIcon from "~/components/LottieIcon";
function classNames(...classes: string[]): string {
return classes.filter(Boolean).join(" ");
}
const Button: React.FC<{
href: string;
current: boolean;
name: string;
json: object;
}> = ({ href, current, name, json }) => {
const [isHovered, setIsHovered] = useState(false);
const [index, setIndex] = useState(0);
const handleMouseEnter = () => {
setIsHovered(true);
setIndex((index) => index + 1);
};
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",
)}
>
<LottieIcon index={index} json={json} isPlaying={isHovered} />
{name}
</Link>
);
};
export default Button;

View File

@@ -0,0 +1,77 @@
import { usePathname } from "next/navigation";
import boardsIconDark from "~/assets/boards-dark.json";
import boardsIconLight from "~/assets/boards-light.json";
import membersIconDark from "~/assets/members-dark.json";
import membersIconLight from "~/assets/members-light.json";
import settingsIconDark from "~/assets/settings-dark.json";
import settingsIconLight from "~/assets/settings-light.json";
import ReactiveButton from "~/components/ReactiveButton";
import UserMenu from "~/components/UserMenu";
import WorkspaceMenu from "~/components/WorkspaceMenu";
import { useTheme } from "~/providers/theme";
interface SideNavigationProps {
user: UserType;
isLoading: boolean;
}
interface UserType {
email?: string | null | undefined;
image?: string | null | undefined;
}
export default function SideNavigation({
user,
isLoading,
}: SideNavigationProps) {
const pathname = usePathname();
const { activeTheme } = useTheme();
const isDarkMode = activeTheme === "dark";
const navigation = [
{
name: "Boards",
href: "/boards",
icon: isDarkMode ? boardsIconDark : boardsIconLight,
},
{
name: "Members",
href: "/members",
icon: isDarkMode ? membersIconDark : membersIconLight,
},
{
name: "Settings",
href: "/settings",
icon: isDarkMode ? settingsIconDark : settingsIconLight,
},
];
return (
<>
<nav className="flex w-72 flex-col justify-between border-r border-light-600 px-3 pb-3 pt-5 dark:border-dark-400">
<div>
<WorkspaceMenu />
<ul role="list" className="space-y-1">
{navigation.map((item) => (
<li key={item.name}>
<ReactiveButton
href={item.href}
current={pathname?.includes(item.href)}
name={item.name}
json={item.icon}
/>
</li>
))}
</ul>
</div>
<UserMenu
email={user?.email ?? ""}
imageUrl={user?.image ?? undefined}
isLoading={isLoading}
/>
</nav>
</>
);
}

View File

@@ -0,0 +1,37 @@
import { Switch } from "@headlessui/react";
import { twMerge } from "tailwind-merge";
const Toggle = ({
isChecked,
onChange,
label,
}: {
isChecked: boolean;
onChange: () => void;
label: string;
}) => (
<div className="mr-4 flex items-center justify-end">
<span className="mr-2 text-xs text-light-900 dark:text-dark-900">
{label}
</span>
<Switch
checked={isChecked}
onChange={onChange}
className={twMerge(
"relative inline-flex h-4 w-6 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent bg-light-800 transition-colors duration-200 ease-in-out focus:outline-none dark:bg-dark-800",
isChecked && "bg-indigo-600 dark:bg-indigo-600",
)}
>
<span className="sr-only">{label}</span>
<span
aria-hidden="true"
className={twMerge(
"pointer-events-none inline-block h-3 w-3 translate-x-0 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out",
isChecked && "translate-x-2",
)}
/>
</Switch>
</div>
);
export default Toggle;

View File

@@ -0,0 +1,142 @@
import Image from "next/image";
import { useRouter } from "next/navigation";
import { Menu, Transition } from "@headlessui/react";
import { Fragment } from "react";
import { useTheme } from "~/providers/theme";
import createClient from "~/utils/supabase/client";
interface UserMenuProps {
imageUrl: string | undefined;
email: string;
isLoading: boolean;
}
function classNames(...classes: string[]): string {
return classes.filter(Boolean).join(" ");
}
export default function UserMenu({
imageUrl,
email,
isLoading,
}: UserMenuProps) {
const router = useRouter();
const { themePreference, switchTheme } = useTheme();
const handleLogout = async () => {
const db = createClient();
await db.auth.signOut();
router.push("/login");
};
return (
<Menu as="div" className="relative inline-block w-full text-left">
<div>
{isLoading ? (
<div className="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>
) : (
<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">
{imageUrl ? (
<Image
src={imageUrl ?? ""}
className="h-8 w-8 rounded-full bg-gray-50"
width={30}
height={30}
alt=""
/>
) : (
<span className="inline-block h-6 w-6 overflow-hidden rounded-full bg-light-400 dark:bg-dark-400">
<svg
className="h-full w-full text-dark-700"
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M24 20.993V24H0v-2.996A14.977 14.977 0 0112.004 15c4.904 0 9.26 2.354 11.996 5.993zM16.002 8.999a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
</span>
)}
<span className="mx-2 truncate text-sm">{email}</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 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">
<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">
<span>Theme</span>
</div>
<Menu.Item>
<button
onClick={() => switchTheme("system")}
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(
themePreference === "system" ? "visible" : "invisible",
"mr-4 h-[6px] w-[6px] rounded-full bg-light-900 dark:bg-dark-900",
)}
/>
System
</button>
</Menu.Item>
<Menu.Item>
<button
onClick={() => switchTheme("dark")}
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(
themePreference === "dark" ? "visible" : "invisible",
"mr-4 h-[6px] w-[6px] rounded-full bg-light-900 dark:bg-dark-900",
)}
/>
Dark
</button>
</Menu.Item>
<Menu.Item>
<button
onClick={() => switchTheme("light")}
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(
themePreference === "light" ? "visible" : "invisible",
"mr-4 h-[6px] w-[6px] rounded-full bg-light-900 dark:bg-dark-900",
)}
/>
Light
</button>
</Menu.Item>
</div>
<div className="light-border-600 border-t-[1px] p-1 dark:border-dark-600">
<Menu.Item>
<button
onClick={handleLogout}
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"
>
Logout
</button>
</Menu.Item>
</div>
</div>
</Menu.Items>
</Transition>
</Menu>
);
}

View File

@@ -0,0 +1,87 @@
import { Menu, Transition } from "@headlessui/react";
import { Fragment } from "react";
import { HiCheck } from "react-icons/hi2";
import { useModal } from "~/providers/modal";
import { useWorkspace } from "~/providers/workspace";
export default function WorkspaceMenu() {
const { workspace, isLoading, availableWorkspaces, switchWorkspace } =
useWorkspace();
const { openModal } = useModal();
return (
<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>
) : (
<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">
<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">
{workspace?.name}
</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 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">
<div className="p-1">
{availableWorkspaces.map((availableWorkspace) => (
<div key={availableWorkspace.publicId} className="flex">
<Menu.Item>
<button
onClick={() => switchWorkspace(availableWorkspace)}
className="flex w-full items-center justify-between rounded-[5px] px-3 py-2 text-left text-sm text-neutral-900 hover:bg-light-200 dark:text-dark-1000 dark:hover:bg-dark-400"
>
<div>
<span className="inline-flex h-5 w-5 items-center justify-center rounded-[5px] bg-indigo-700">
<span className="text-xs font-medium leading-none text-white">
{availableWorkspace?.name.charAt(0).toUpperCase()}
</span>
</span>
<span className="ml-2 text-xs font-medium">
{availableWorkspace?.name}
</span>
</div>
{workspace?.name === availableWorkspace?.name && (
<span>
<HiCheck className="h-4 w-4" aria-hidden="true" />
</span>
)}
</button>
</Menu.Item>
</div>
))}
</div>
<div className="border-t-[1px] border-light-600 p-1 dark:border-dark-500">
<Menu.Item>
<button
onClick={() => openModal("NEW_WORKSPACE")}
className="flex w-full items-center justify-between rounded-[5px] px-3 py-2 text-left text-xs text-neutral-900 hover:bg-light-200 dark:text-dark-1000 dark:hover:bg-dark-400"
>
Create workspace
</button>
</Menu.Item>
</div>
</Menu.Items>
</Transition>
</Menu>
);
}

View File

@@ -0,0 +1,59 @@
import { Dialog, Transition } from "@headlessui/react";
import { Fragment } from "react";
import { useModal } from "~/providers/modal";
interface Props {
children: React.ReactNode;
modalSize?: "sm" | "md" | "lg";
}
const Modal: React.FC<Props> = ({ children, modalSize = "sm" }) => {
const { isOpen, closeModal } = useModal();
const modalSizeMap = {
sm: "max-w-[400px]",
md: "max-w-[550px]",
lg: "max-w-[800px]",
};
return (
<Transition.Root show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-10" onClose={closeModal}>
<Transition.Child
as={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-light-50 bg-opacity-40 transition-opacity dark:bg-dark-50 dark:bg-opacity-40" />
</Transition.Child>
<div className="fixed inset-0 z-10 w-screen overflow-y-auto">
<div className="flex min-h-full items-start justify-center p-4 text-center sm:items-start sm:p-0">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel
className={`bg-white/1 relative mt-[25vh] w-full transform rounded-lg border border-light-600 text-left shadow-3xl-light backdrop-blur-[10px] transition-all dark:border-dark-600 dark:bg-dark-100/20 dark:shadow-3xl-dark ${modalSizeMap[modalSize]}`}
>
{children}
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
};
export default Modal;