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

14
apps/web/eslint.config.js Normal file
View File

@@ -0,0 +1,14 @@
import baseConfig, { restrictEnvAccess } from "@kan/eslint-config/base";
import nextjsConfig from "@kan/eslint-config/nextjs";
import reactConfig from "@kan/eslint-config/react";
/** @type {import('typescript-eslint').Config} */
export default [
{
ignores: [".next/**"],
},
...baseConfig,
...reactConfig,
...nextjsConfig,
...restrictEnvAccess,
];

19
apps/web/next.config.js Normal file
View File

@@ -0,0 +1,19 @@
import { fileURLToPath } from "url";
import createJiti from "jiti";
// Import env files to validate at build time. Use jiti so we can load .ts files in here.
createJiti(fileURLToPath(import.meta.url))("./src/env");
/** @type {import("next").NextConfig} */
const config = {
reactStrictMode: true,
/** Enables hot reloading for local packages without a build step */
transpilePackages: ["@kan/api", "@kan/db"],
/** We already do linting and typechecking as separate tasks in CI */
eslint: { ignoreDuringBuilds: true },
typescript: { ignoreBuildErrors: true },
};
export default config;

62
apps/web/package.json Normal file
View File

@@ -0,0 +1,62 @@
{
"name": "@kan/web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"build": "pnpm with-env next build",
"clean": "git clean -xdf .cache .next .turbo node_modules",
"dev": "pnpm with-env next dev",
"format": "prettier --check . --ignore-path ../../.gitignore",
"lint": "eslint",
"start": "pnpm with-env next start",
"typecheck": "tsc --noEmit",
"with-env": "dotenv -e ../../.env --"
},
"dependencies": {
"@headlessui/react": "^2.2.0",
"@hookform/resolvers": "^3.3.4",
"@kan/api": "workspace:*",
"@kan/supabase": "workspace:^",
"@kan/utils": "workspace:^",
"@t3-oss/env-nextjs": "^0.11.1",
"@tanstack/react-query": "catalog:",
"@trpc/client": "catalog:",
"@trpc/next": "^11.0.0-rc.660",
"@trpc/react-query": "catalog:",
"@trpc/server": "catalog:",
"date-fns": "^4.1.0",
"geist": "^1.3.1",
"js-cookie": "^3.0.5",
"next": "^14.2.15",
"nextjs-cors": "^2.2.0",
"react": "catalog:react18",
"react-beautiful-dnd": "^13.1.1",
"react-contenteditable": "^3.3.7",
"react-dom": "catalog:react18",
"react-hook-form": "^7.51.1",
"react-icons": "^4.12.0",
"react-lottie-player": "^1.5.5",
"superjson": "2.2.1",
"tailwind-merge": "^2.5.2",
"zod": "catalog:"
},
"devDependencies": {
"@kan/eslint-config": "workspace:*",
"@kan/prettier-config": "workspace:*",
"@kan/tailwind-config": "workspace:*",
"@kan/tsconfig": "workspace:*",
"@types/js-cookie": "^3.0.6",
"@types/node": "^20.17.7",
"@types/react": "catalog:react18",
"@types/react-beautiful-dnd": "^13.1.7",
"@types/react-dom": "catalog:react18",
"dotenv-cli": "^7.4.4",
"eslint": "catalog:",
"jiti": "^1.21.6",
"prettier": "catalog:",
"tailwindcss": "catalog:",
"typescript": "catalog:"
},
"prettier": "@kan/prettier-config"
}

View File

@@ -0,0 +1,5 @@
module.exports = {
plugins: {
tailwindcss: {},
},
};

BIN
apps/web/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 400 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 433 KiB

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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;

37
apps/web/src/env.ts Normal file
View File

@@ -0,0 +1,37 @@
import { createEnv } from "@t3-oss/env-nextjs";
import { vercel } from "@t3-oss/env-nextjs/presets";
import { z } from "zod";
export const env = createEnv({
extends: [vercel()],
shared: {
NODE_ENV: z
.enum(["development", "production", "test"])
.default("development"),
},
/**
* Specify your server-side environment variables schema here.
* This way you can ensure the app isn't built with invalid env vars.
*/
server: {
POSTGRES_URL: z.string().url(),
},
/**
* Specify your client-side environment variables schema here.
* For them to be exposed to the client, prefix them with `NEXT_PUBLIC_`.
*/
client: {
NEXT_PUBLIC_SUPABASE_AUTH_COOKIE_NAME: z.string(),
},
/**
* Destructure all variables from `process.env` to make sure they aren't tree-shaken away.
*/
experimental__runtimeEnv: {
NODE_ENV: process.env.NODE_ENV,
NEXT_PUBLIC_SUPABASE_AUTH_COOKIE_NAME:
process.env.NEXT_PUBLIC_SUPABASE_AUTH_COOKIE_NAME,
},
skipValidation:
!!process.env.CI || process.env.npm_lifecycle_event === "lint",
});

View File

@@ -0,0 +1,50 @@
import "~/styles/globals.css";
import { type AppType } from "next/app";
import { Plus_Jakarta_Sans } from "next/font/google";
import { ModalProvider } from "~/providers/modal";
import { BoardProvider } from "~/providers/board";
import { PopupProvider } from "~/providers/popup";
import { ThemeProvider } from "~/providers/theme";
import { api } from "~/utils/api";
const jakarta = Plus_Jakarta_Sans({
subsets: ["latin"],
display: "swap",
});
export const metadata = {
title: "Kan",
description: "The open source Trello alternative",
icons: [{ rel: "icon", url: "/favicon.ico" }],
};
const MyApp: AppType = ({ Component, pageProps }) => {
return (
<>
<style jsx global>{`
html {
font-family: ${jakarta.style.fontFamily};
}
body {
position: relative;
}
`}</style>
<main className="font-sans">
<ThemeProvider>
<ModalProvider>
<PopupProvider>
<BoardProvider>
<Component {...pageProps} />
</BoardProvider>
</PopupProvider>
</ModalProvider>
</ThemeProvider>
</main>
</>
);
};
export default api.withTRPC(MyApp);

View File

@@ -0,0 +1,90 @@
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { type EmailOtpType } from "@supabase/supabase-js";
import * as memberRepo from "@kan/db/repository/member.repo";
import * as userRepo from "@kan/db/repository/user.repo";
import { createNextClient } from "@kan/supabase/clients";
export default async function handler(req: NextRequest) {
if (req.method !== "GET") {
return new NextResponse(null, {
status: 405,
headers: { Allow: "GET" },
});
}
if (!req.url) {
return new NextResponse(null, {
status: 400,
});
}
const url = new URL(req.url);
const queryParams = Object.fromEntries(url.searchParams.entries());
const tokenHash = queryParams.token_hash;
const type = queryParams.type;
const code = queryParams.code;
const memberPublicId = queryParams.memberPublicId;
let next = "/error";
let authRes;
const response = NextResponse.next();
if ((tokenHash && type) ?? code) {
const db = createNextClient(req, response);
if (tokenHash && type) {
authRes = await db.auth.verifyOtp({
type: type as EmailOtpType,
token_hash: tokenHash,
});
}
if (code) {
authRes = await db.auth.exchangeCodeForSession(code);
}
const user = authRes?.data.user;
if (user?.id && user.email) {
const existingUser = await userRepo.getById(db, user.id);
if (!existingUser) {
await userRepo.create(db, {
id: user.id,
email: user.email,
});
}
}
if (memberPublicId) {
const member = await memberRepo.getByPublicId(db, memberPublicId);
if (member?.id) {
await memberRepo.acceptInvite(db, member.id);
}
}
if (authRes?.error) {
console.error(authRes.error);
} else {
next = queryParams.next ?? "/";
}
}
const redirectResponse = NextResponse.redirect(new URL(next, req.url));
response.headers.getSetCookie().forEach((cookie) => {
redirectResponse.headers.append("Set-Cookie", cookie);
});
return redirectResponse;
}
export const runtime = "edge";
export const preferredRegion = "lhr1";
export const dynamic = "force-dynamic";

View File

@@ -0,0 +1,26 @@
import { type NextRequest } from "next/server";
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
import { appRouter } from "@kan/api/root";
import { createTRPCContext } from "@kan/api/trpc";
export default async function handler(req: NextRequest) {
return fetchRequestHandler({
endpoint: "/api/trpc",
router: appRouter,
req,
createContext: createTRPCContext,
onError:
process.env.NODE_ENV === "development"
? ({ path, error }) => {
console.error(
`❌ tRPC failed on ${path ?? "<no-path>"}: ${error.message}`,
);
}
: undefined,
});
}
export const runtime = "edge";
export const preferredRegion = "lhr1";
export const dynamic = "force-dynamic";

View File

@@ -0,0 +1,31 @@
import { type NextApiRequest, type NextApiResponse } from "next";
import cors from "nextjs-cors";
import { createOpenApiNextHandler } from "trpc-to-openapi";
import { appRouter } from "@kan/api";
import { createRESTContext } from "@kan/api/trpc";
import { env } from "~/env";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
await cors(req, res);
const openApiHandler = createOpenApiNextHandler({
router: appRouter,
createContext: createRESTContext,
responseMeta: () => ({ headers: {} }),
onError:
env.NODE_ENV === "development"
? ({ path, error }) => {
console.error(
`❌ tRPC failed on ${path ?? "<no-path>"}: ${error.message}`,
);
}
: undefined,
});
return await openApiHandler(req, res);
}

View File

@@ -0,0 +1,9 @@
import { type NextApiRequest, type NextApiResponse } from "next";
import { openApiDocument } from "@kan/api/openapi";
const handler = (req: NextApiRequest, res: NextApiResponse) => {
res.status(200).send(openApiDocument);
};
export default handler;

View File

@@ -0,0 +1,15 @@
import { WorkspaceProvider } from "~/providers/workspace";
import Dashboard from "~/components/Dashboard";
import Popup from "~/components/Popup";
import BoardView from "~/views/board";
export default function BoardPage() {
return (
<WorkspaceProvider>
<Dashboard>
<BoardView />
</Dashboard>
<Popup />
</WorkspaceProvider>
);
}

View File

@@ -0,0 +1,15 @@
import { WorkspaceProvider } from "~/providers/workspace";
import Dashboard from "~/components/Dashboard";
import Popup from "~/components/Popup";
import BoardsView from "~/views/boards";
export default function BoardsPage() {
return (
<WorkspaceProvider>
<Dashboard>
<BoardsView />
</Dashboard>
<Popup />
</WorkspaceProvider>
);
}

View File

@@ -0,0 +1,15 @@
import { WorkspaceProvider } from "~/providers/workspace";
import Dashboard from "~/components/Dashboard";
import Popup from "~/components/Popup";
import CardView from "~/views/card";
export default function CardPage() {
return (
<WorkspaceProvider>
<Dashboard>
<CardView />
</Dashboard>
<Popup />
</WorkspaceProvider>
);
}

View File

@@ -0,0 +1,5 @@
import HomeView from "~/views/home";
export default function Home() {
return <HomeView />;
}

View File

@@ -0,0 +1,5 @@
import LoginView from "~/views/auth/login";
export default function LoginPage() {
return <LoginView />;
}

View File

@@ -0,0 +1,15 @@
import { WorkspaceProvider } from "~/providers/workspace";
import Dashboard from "~/components/Dashboard";
import Popup from "~/components/Popup";
import MembersView from "~/views/members";
export default function MembersPage() {
return (
<WorkspaceProvider>
<Dashboard>
<MembersView />
</Dashboard>
<Popup />
</WorkspaceProvider>
);
}

View File

@@ -0,0 +1,15 @@
import { WorkspaceProvider } from "~/providers/workspace";
import Dashboard from "~/components/Dashboard";
import SettingsView from "~/views/settings";
import Popup from "~/components/Popup";
export default function SettingsPage() {
return (
<WorkspaceProvider>
<Dashboard>
<SettingsView />
</Dashboard>
<Popup />
</WorkspaceProvider>
);
}

View File

@@ -0,0 +1,5 @@
import SignupView from "~/views/auth/signup";
export default function SignupPage() {
return <SignupView />;
}

View File

@@ -0,0 +1,210 @@
import type { ReactNode } from "react";
import React, { createContext, useContext, useState } from "react";
import {
type GetBoardByIdOutput,
type NewCardInput,
type NewListInput,
type ReorderCardInput,
type ReorderListInput,
} from "@kan/api/types";
import { generateUID } from "@kan/utils";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
interface BoardContextProps {
boardData: GetBoardByIdOutput;
setBoardData: React.Dispatch<React.SetStateAction<GetBoardByIdOutput>>;
updateList: (params: ReorderListInput) => void;
updateCard: (params: ReorderCardInput) => void;
addCard: (params: NewCardInput) => void;
addList: (params: NewListInput) => void;
removeCard: (params: { cardPublicId: string }) => void;
refetchBoard: () => Promise<void>;
}
const initialBoardData: GetBoardByIdOutput = {
name: "",
publicId: "",
lists: [],
labels: [],
workspace: {
publicId: "",
members: [],
},
};
const BoardContext = createContext<BoardContextProps | undefined>(undefined);
export const BoardProvider: React.FC<{ children: ReactNode }> = ({
children,
}) => {
const utils = api.useUtils();
const [boardData, setBoardData] =
useState<GetBoardByIdOutput>(initialBoardData);
const { showPopup } = usePopup();
const refetchBoard = async () => {
if (!boardData?.publicId) return;
try {
await utils.board.byId.refetch();
} catch (e) {
showPopup({
header: "Error fetching board",
message: "Please try again later, or contact customer support.",
});
}
};
const updateCardMutation = api.card.reorder.useMutation({
onSuccess: async () => {
await refetchBoard();
},
onError: async () => {
await refetchBoard();
showPopup({
header: "Unable to update card",
message: "Please try again later, or contact customer support.",
});
},
});
const updateListMutation = api.list.reorder.useMutation({
onSuccess: async () => {
await refetchBoard();
},
onError: async () => {
await refetchBoard();
showPopup({
header: "Unable to update list",
message: "Please try again later, or contact customer support.",
});
},
});
const addCard = ({
title,
listPublicId,
labelPublicIds,
memberPublicIds,
position,
}: {
title: string;
listPublicId: string;
labelPublicIds: string[];
memberPublicIds: string[];
position: "start" | "end";
}) => {
if (!boardData) return;
const updatedLists = boardData.lists.map((list) => {
if (list.publicId === listPublicId) {
const newCard = {
publicId: `PLACEHOLDER_${generateUID()}`,
title,
listId: 2,
description: "",
labels: boardData.labels.filter((label) =>
labelPublicIds.includes(label.publicId),
),
members:
boardData.workspace?.members.filter((member) =>
memberPublicIds.includes(member.publicId),
) ?? [],
index: position === "start" ? 0 : list.cards.length,
};
const updatedCards =
position === "start"
? [newCard, ...list.cards]
: [...list.cards, newCard];
return { ...list, cards: updatedCards };
}
return list;
});
setBoardData({ ...boardData, lists: updatedLists });
};
const addList = ({ name, boardPublicId }: NewListInput) => {
if (!boardData) return;
const newList = {
publicId: generateUID(),
name,
boardId: 1,
boardPublicId,
cards: [],
index: boardData.lists.length,
};
const updatedLists = [...boardData.lists, newList];
setBoardData({ ...boardData, lists: updatedLists });
};
const removeCard = ({ cardPublicId }: { cardPublicId: string }) => {
if (!boardData) return;
const updatedLists = boardData.lists.map((list) => {
const updatedCards = list.cards.filter(
(card) => card.publicId !== cardPublicId,
);
return { ...list, cards: updatedCards };
});
setBoardData({ ...boardData, lists: updatedLists });
};
const updateList = ({
listPublicId,
currentIndex,
newIndex,
}: ReorderListInput) => {
updateListMutation.mutate({
listPublicId,
currentIndex,
newIndex,
});
};
const updateCard = ({
cardPublicId,
newListPublicId,
newIndex,
}: ReorderCardInput) => {
updateCardMutation.mutate({
cardPublicId,
newListPublicId,
newIndex,
});
};
return (
<BoardContext.Provider
value={{
boardData,
setBoardData,
updateList,
updateCard,
addCard,
addList,
removeCard,
refetchBoard,
}}
>
{children}
</BoardContext.Provider>
);
};
export const useBoard = () => {
const context = useContext(BoardContext);
if (context === undefined) {
throw new Error("useBoard must be used within a BoardProvider");
}
return context;
};

View File

@@ -0,0 +1,65 @@
import { createContext, useContext, useState } from "react";
type ModalContextType = {
isOpen: boolean;
openModal: (
contentType: string,
entityId?: string,
entityLabel?: string,
) => void;
closeModal: () => void;
modalContentType: string;
entityId: string;
entityLabel: string;
};
interface Props {
children: React.ReactNode;
}
const ModalContext = createContext<ModalContextType | undefined>(undefined);
export const ModalProvider: React.FC<Props> = ({ children }) => {
const [isOpen, setIsOpen] = useState(false);
const [entityId, setEntityId] = useState("");
const [entityLabel, setEntityLabel] = useState("");
const [modalContentType, setModalContentType] = useState("");
const openModal = (
contentType: string,
entityId?: string,
entityLabel?: string,
) => {
setIsOpen(true);
setModalContentType(contentType);
if (entityId) setEntityId(entityId);
if (entityLabel) setEntityLabel(entityLabel);
};
const closeModal = () => {
setIsOpen(false);
};
return (
<ModalContext.Provider
value={{
isOpen,
openModal,
closeModal,
modalContentType,
entityId,
entityLabel,
}}
>
{children}
</ModalContext.Provider>
);
};
export const useModal = () => {
const context = useContext(ModalContext);
if (context === undefined) {
throw new Error("useModal must be used within a ModalProvider");
}
return context;
};

View File

@@ -0,0 +1,53 @@
import { createContext, useContext, useState } from "react";
type PopupContextType = {
isOpen: boolean;
showPopup: (params: { header: string; message: string }) => void;
hidePopup: () => void;
popupHeader: string;
popupMessage: string;
};
interface Props {
children: React.ReactNode;
}
const PopupContext = createContext<PopupContextType | undefined>(undefined);
export const PopupProvider: React.FC<Props> = ({ children }) => {
const [isOpen, setIsOpen] = useState(false);
const [popupHeader, setPopupHeader] = useState("");
const [popupMessage, setPopupMessage] = useState("");
const showPopup = ({
header,
message,
}: {
header: string;
message: string;
}) => {
setIsOpen(true);
setPopupHeader(header);
setPopupMessage(message);
};
const hidePopup = () => {
setIsOpen(false);
};
return (
<PopupContext.Provider
value={{ isOpen, showPopup, hidePopup, popupHeader, popupMessage }}
>
{children}
</PopupContext.Provider>
);
};
export const usePopup = () => {
const context = useContext(PopupContext);
if (context === undefined) {
throw new Error("usePopup must be used within a PopupProvider");
}
return context;
};

View File

@@ -0,0 +1,69 @@
import React, {
createContext,
useContext,
useEffect,
useState,
type ReactNode,
} from "react";
interface ThemeContextProps {
themePreference: "light" | "dark" | "system";
activeTheme: "light" | "dark";
switchTheme: (theme: "light" | "dark" | "system") => void;
}
const ThemeContext = createContext<ThemeContextProps | undefined>(undefined);
export const ThemeProvider: React.FC<{ children: ReactNode }> = ({
children,
}) => {
const [themePreference, setThemePreference] = useState<
"light" | "dark" | "system"
>("system");
const [activeTheme, setActiveTheme] = useState<"light" | "dark">("light");
const switchTheme = (theme: "light" | "dark" | "system") => {
if (theme === "system") {
localStorage.removeItem("theme");
const isDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
setActiveTheme(isDark ? "dark" : "light");
document.documentElement.classList.toggle("dark", isDark);
} else {
const isDark = theme === "dark";
document.documentElement.classList.toggle("dark", isDark);
localStorage.theme = theme;
setActiveTheme(isDark ? "dark" : "light");
}
setThemePreference(theme);
};
useEffect(() => {
if (!("theme" in localStorage)) {
const isDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
document.documentElement.classList.toggle("dark", isDark);
setActiveTheme(isDark ? "dark" : "light");
setThemePreference("system");
} else {
const isDark = localStorage.theme === "dark";
document.documentElement.classList.toggle("dark", isDark);
setActiveTheme(isDark ? "dark" : "light");
setThemePreference(localStorage.theme as "light" | "dark");
}
}, []);
return (
<ThemeContext.Provider
value={{ switchTheme, themePreference, activeTheme }}
>
{themePreference.length ? children : null}
</ThemeContext.Provider>
);
};
export const useTheme = (): ThemeContextProps => {
const context = useContext(ThemeContext);
if (!context) {
throw new Error("useTheme must be used within a ThemeProvider");
}
return context;
};

View File

@@ -0,0 +1,114 @@
import React, {
createContext,
useContext,
useEffect,
useState,
type ReactNode,
} from "react";
import { api } from "~/utils/api";
import { useRouter } from "next/navigation";
interface WorkspaceContextProps {
workspace: Workspace;
isLoading: boolean;
switchWorkspace: (_workspace: Workspace) => void;
availableWorkspaces: Workspace[];
}
interface Workspace {
name: string;
publicId: string;
}
const initialWorkspace: Workspace = {
name: "",
publicId: "",
};
const initialAvailableWorkspaces: Workspace[] = [];
const WorkspaceContext = createContext<WorkspaceContextProps | undefined>(
undefined,
);
export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({
children,
}) => {
const router = useRouter();
const [workspace, setWorkspace] = useState<Workspace>(initialWorkspace);
const [availableWorkspaces, setAvailableWorkspaces] = useState<Workspace[]>(
initialAvailableWorkspaces,
);
const { data, isLoading } = api.workspace.all.useQuery();
const switchWorkspace = (_workspace: Workspace) => {
localStorage.setItem("workspacePublicId", _workspace.publicId);
setWorkspace(_workspace);
router.push(`/boards`);
};
useEffect(() => {
if (!data) return;
const storedWorkspaceId: string | null =
localStorage.getItem("workspacePublicId");
if (data?.length) {
const workspaces = data
.map(({ workspace }) => {
if (!workspace) return;
return {
publicId: workspace.publicId,
name: workspace.name,
};
})
.filter((workspace) => workspace !== null) as Workspace[];
if (workspaces.length) setAvailableWorkspaces(workspaces);
}
if (storedWorkspaceId !== null) {
const newData = data;
const selectedWorkspace = newData?.find(
({ workspace }) => workspace?.publicId === storedWorkspaceId,
);
if (!selectedWorkspace?.workspace) return;
setWorkspace({
publicId: selectedWorkspace.workspace.publicId,
name: selectedWorkspace.workspace.name,
});
} else {
const primaryWorkspace = data?.[0]?.workspace;
if (!primaryWorkspace) return;
localStorage.setItem("workspacePublicId", primaryWorkspace?.publicId);
setWorkspace({
publicId: primaryWorkspace?.publicId,
name: primaryWorkspace?.name,
});
}
}, [data]);
return (
<WorkspaceContext.Provider
value={{ workspace, isLoading, availableWorkspaces, switchWorkspace }}
>
{children}
</WorkspaceContext.Provider>
);
};
export const useWorkspace = (): WorkspaceContextProps => {
const context = useContext(WorkspaceContext);
if (!context) {
throw new Error("useWorkspace must be used within a WorkspaceProvider");
}
return context;
};

View File

@@ -0,0 +1,26 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
[contenteditable=true]:empty:before {
content: attr(placeholder);
display: block;
color: #707070;
}
.gradient-border::before {
background: conic-gradient(
from 0deg,
transparent 0deg,
transparent 45deg,
rgba(244, 114, 182, 0.4) 85deg,
rgba(192, 132, 252, 0.4) 95deg,
transparent 110deg,
transparent 360deg
);
content: '';
position: absolute;
inset: -50%;
width: 200%;
height: 200%;
}

31
apps/web/src/types/contenteditable.d.ts vendored Normal file
View File

@@ -0,0 +1,31 @@
declare module "react-contenteditable" {
import React from "react";
type ChangeEventHandler<T = Element> = (event: React.ChangeEvent<T>) => void;
type KeyUpHandler<T = Element> = (event: React.KeyboardEvent<T>) => void;
type KeyDownHandler<T = Element> = (event: React.KeyboardEvent<T>) => void;
interface ContentEditableProps extends React.HTMLAttributes<HTMLDivElement> {
html: string;
onChange?: ChangeEventHandler<HTMLTextAreaElement | HTMLInputElement>;
onBlur?: () => void;
onKeyUp?: KeyUpHandler<HTMLTextAreaElement | HTMLInputElement>;
onKeyDown?: KeyDownHandler<HTMLTextAreaElement | HTMLInputElement>;
disabled?: boolean;
tagName?: string;
className?: string;
style?: React.CSSProperties;
innerRef?:
| React.RefObject<HTMLElement>
| ((instance: HTMLElement | null) => void);
placeholder?: string;
}
class ContentEditable extends React.Component<ContentEditableProps> {}
interface ContentEditableElement extends HTMLElement {
value: string;
}
export default ContentEditable;
}

78
apps/web/src/utils/api.ts Normal file
View File

@@ -0,0 +1,78 @@
import type { CreateTRPCClientOptions, TRPCLink } from "@trpc/client";
import { httpBatchLink, loggerLink } from "@trpc/client";
import { createTRPCNext } from "@trpc/next";
import { type inferRouterInputs, type inferRouterOutputs } from "@trpc/server";
import { observable } from "@trpc/server/observable";
import superjson from "superjson";
import { type AppRouter } from "@kan/api/root";
/**
* This is the client-side entrypoint for your tRPC API. It is used to create the `api` object which
* contains the Next.js App-wrapper, as well as your type-safe React Query hooks.
*
* We also create a few inference helpers for input and output types.
*/
const authLink: TRPCLink<AppRouter> = () => {
return ({ next, op }) => {
return observable((observer) => {
const unsubscribe = next(op).subscribe({
next(value) {
observer.next(value);
},
error(err) {
if (typeof window !== "undefined" && err.message === "UNAUTHORIZED") {
window.location.href = "/login";
}
observer.error(err);
},
complete() {
observer.complete();
},
});
return unsubscribe;
});
};
};
const getBaseUrl = () => {
if (typeof window !== "undefined") return ""; // browser should use relative url
if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`; // SSR should use vercel url
return `http://localhost:${process.env.PORT ?? 3000}`; // dev SSR should use localhost
};
// @ts-expect-error
export const api = createTRPCNext<AppRouter>({
config() {
return {
links: [
loggerLink({
enabled: (opts) =>
process.env.NODE_ENV === "development" ||
(opts.direction === "down" && opts.result instanceof Error),
}),
authLink,
httpBatchLink({
url: `${getBaseUrl()}/api/trpc`,
transformer: superjson,
}),
],
};
},
ssr: false,
});
/**
* Inference helper for inputs.
*
* @example type HelloInput = RouterInputs['example']['hello']
*/
export type RouterInputs = inferRouterInputs<AppRouter>;
/**
* Inference helper for outputs.
*
* @example type HelloOutput = RouterOutputs['example']['hello']
*/
export type RouterOutputs = inferRouterOutputs<AppRouter>;

View File

@@ -0,0 +1,30 @@
export const formatToArray = (
value: string | string[] | undefined,
): string[] => {
if (Array.isArray(value)) {
return value.filter((item) => item !== undefined);
}
return value ? [value] : [];
};
export const inferInitialsFromEmail = (email: string) => {
const localPart = email.split("@")[0];
if (!localPart) return "";
const separators = /[._-]/;
const parts = localPart.split(separators);
if (parts.length > 1) {
return (
(parts[0]?.[0] ?? "") + (parts[parts.length - 1]?.[0] ?? "")
).toUpperCase();
} else {
return localPart.slice(0, 2).toUpperCase();
}
};
export const getInitialsFromName = (name: string) => {
return name
.split(" ")
.map((namePart) => namePart.charAt(0).toUpperCase())
.join("");
};

View File

@@ -0,0 +1,12 @@
import { createBrowserClient } from "@supabase/ssr";
import { type Database } from "@kan/db/types/database.types";
export default function createClient() {
const supabase = createBrowserClient<Database>(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
);
return supabase;
}

View File

@@ -0,0 +1,56 @@
import { useState } from "react";
// import { useRouter } from "next/navigation";
import { Auth } from "~/components/AuthForm";
import { PageHead } from "~/components/PageHead";
// import { api } from "~/utils/api";
export default function LoginPage() {
// const router = useRouter();
const [isMagicLinkSent, setIsMagicLinkSent] = useState<boolean>(false);
const [magicLinkRecipient, setMagicLinkRecipient] = useState<string>("");
const handleMagicLinkSent = (value: boolean, recipient: string) => {
setIsMagicLinkSent(value);
setMagicLinkRecipient(recipient);
};
// const authCookieExists = document.cookie
// .split("; ")
// .some((cookie) => cookie.includes("auth-token"));
// const { data } = api.auth.getUser.useQuery(undefined, {
// enabled: authCookieExists ? true : false,
// });
// if (data?.id) router.push("/boards");
return (
<>
<PageHead title="Login | kan.bn" />
<main className="h-screen bg-dark-50">
<div className="flex h-full flex-col items-center justify-center">
<h1 className="mb-6 text-lg font-bold tracking-tight text-dark-1000">
kan.bn
</h1>
<p className="mb-10 text-3xl text-dark-1000">
{isMagicLinkSent ? "Check your inbox" : "Welcome back"}
</p>
{isMagicLinkSent ? (
<div className="sm:mx-auto sm:w-full sm:max-w-sm">
<p className="text-md mt-2 text-center text-dark-1000">
{`Click on the link we've sent to ${magicLinkRecipient} to sign in.`}
</p>
</div>
) : (
<div className="w-full rounded-lg border border-dark-400 bg-dark-200 px-10 py-10 sm:max-w-md">
<div className="sm:mx-auto sm:w-full sm:max-w-sm">
<Auth setIsMagicLinkSent={handleMagicLinkSent} />
</div>
</div>
)}
</div>
</main>
</>
);
}

View File

@@ -0,0 +1,55 @@
import { useState } from "react";
// import { useRouter } from "next/navigation";
import { Auth } from "~/components/AuthForm";
import { PageHead } from "~/components/PageHead";
// import { api } from "~/utils/api";
export default function SignupPage() {
// const router = useRouter();
const [isMagicLinkSent, setIsMagicLinkSent] = useState<boolean>(false);
const [magicLinkRecipient, setMagicLinkRecipient] = useState<string>("");
const handleMagicLinkSent = (value: boolean, recipient: string) => {
setIsMagicLinkSent(value);
setMagicLinkRecipient(recipient);
};
// const authCookieExists = document.cookie
// .split("; ")
// .some((cookie) => cookie.includes("auth-token"));
// const { data } = api.auth.getUser.useQuery(undefined, {
// enabled: authCookieExists ? true : false,
// });
// if (data?.id) router.push("/boards");
return (
<>
<PageHead title="Signup | kan.bn" />
<main className="h-screen bg-dark-50">
<div className="flex h-full flex-col items-center justify-center">
<h1 className="mb-6 text-lg font-bold tracking-tight text-dark-1000">
kan.bn
</h1>
<p className="mb-10 text-3xl text-dark-1000">
{isMagicLinkSent ? "Check your inbox" : "Get started"}
</p>
{isMagicLinkSent ? (
<div className="sm:mx-auto sm:w-full sm:max-w-sm">
<p className="text-md mt-2 text-center text-dark-1000">
{`Click on the link we've sent to ${magicLinkRecipient} to sign in.`}
</p>
</div>
) : (
<div className="w-full rounded-lg border border-dark-400 bg-dark-200 px-10 py-10 sm:max-w-md">
<div className="sm:mx-auto sm:w-full sm:max-w-sm">
<Auth setIsMagicLinkSent={handleMagicLinkSent} />
</div>
</div>
)}
</div>
</main>
</>
);
}

View File

@@ -0,0 +1,44 @@
import { Fragment } from "react";
import { Menu, Transition } from "@headlessui/react";
import { HiEllipsisHorizontal } from "react-icons/hi2";
import { useModal } from "~/providers/modal";
export default function BoardDropdown() {
const { openModal } = useModal();
return (
<Menu as="div" className="relative inline-block text-left">
<div>
<Menu.Button className="flex h-8 w-8 items-center justify-center rounded-[5px] hover:bg-light-200 dark:hover:bg-dark-200">
<HiEllipsisHorizontal
size={25}
className="text-light-900 dark:text-dark-900"
/>
</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 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-400 dark:bg-dark-300">
<div className="flex">
<Menu.Item>
<button
onClick={() => openModal("DELETE_BOARD")}
className="m-1 w-full 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"
>
Delete board
</button>
</Menu.Item>
</div>
</Menu.Items>
</Transition>
</Menu>
);
}

View File

@@ -0,0 +1,46 @@
import { useRouter } from "next/navigation";
import { api } from "~/utils/api";
import { useBoard } from "~/providers/board";
import { useModal } from "~/providers/modal";
import Button from "~/components/Button";
export function DeleteBoardConfirmation() {
const router = useRouter();
const { boardData } = useBoard();
const { closeModal } = useModal();
const deleteBoard = api.board.delete.useMutation({
onSuccess: () => {
closeModal();
router.push(`/boards`);
},
});
const handleDeleteBoard = () => {
if (boardData?.publicId)
deleteBoard.mutate({
boardPublicId: boardData.publicId,
});
};
return (
<div className="p-5">
<div className="flex w-full flex-col justify-between pb-4">
<h2 className="text-md pb-4 font-medium text-neutral-900 dark:text-dark-1000">
Are you sure you want to delete this board?
</h2>
<p className="text-sm font-medium text-light-900 dark:text-dark-900">
{"This action can't be undone."}
</p>
</div>
<div className="mt-5 flex justify-end space-x-2 sm:mt-6">
<Button onClick={() => closeModal()} variant="secondary">
Cancel
</Button>
<Button onClick={handleDeleteBoard}>Delete</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,68 @@
import { api } from "~/utils/api";
import { useBoard } from "~/providers/board";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import Button from "~/components/Button";
interface DeleteListConfirmationProps {
listPublicId: string;
}
export function DeleteListConfirmation({
listPublicId,
}: DeleteListConfirmationProps) {
const utils = api.useUtils();
const { boardData } = useBoard();
const { closeModal } = useModal();
const { showPopup } = usePopup();
const refetchBoard = async () => {
if (boardData?.publicId) {
try {
await utils.board.byId.refetch();
} catch (e) {
console.error(e);
}
}
};
const deleteList = api.list.delete.useMutation({
onSuccess: () => {
closeModal();
return refetchBoard();
},
onError: async () => {
closeModal();
await refetchBoard();
showPopup({
header: "Unable to delete list",
message: "Please try again later, or contact customer support.",
});
},
});
return (
<div className="p-5">
<div className="flex w-full flex-col justify-between pb-4">
<h2 className="text-md pb-4 font-medium text-neutral-900 dark:text-dark-1000">
Are you sure you want to delete this list?
</h2>
<p className="text-sm font-medium text-light-900 dark:text-dark-900">
{"This action can't be undone."}
</p>
</div>
<div className="mt-5 flex justify-end space-x-2 sm:mt-6">
<Button onClick={() => closeModal()} variant="secondary">
Cancel
</Button>
<Button
isLoading={deleteList.isPending}
onClick={() => deleteList.mutate({ listPublicId })}
>
Delete
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,139 @@
import { IoFilterOutline } from "react-icons/io5";
import {
HiOutlineUserCircle,
HiOutlineTag,
HiMiniXMark,
} from "react-icons/hi2";
import { useRouter } from "next/router";
import Button from "~/components/Button";
import CheckboxDropdown from "~/components/CheckboxDropdown";
import { formatToArray } from "~/utils/helpers";
import { useBoard } from "~/providers/board";
const LabelIcon = ({ colourCode }: { colourCode: string | null }) => (
<svg
fill={colourCode ?? "#3730a3"}
className="h-2 w-2"
viewBox="0 0 6 6"
aria-hidden="true"
>
<circle cx={3} cy={3} r={3} />
</svg>
);
const Avatar = ({ name }: { name: string }) => (
<span className="inline-flex h-4 w-4 items-center justify-center rounded-full bg-gray-400 ring-1 ring-light-200 dark:ring-dark-500">
<span className="text-[8px] font-medium leading-none text-white">
{name
?.split(" ")
.map((namePart) => namePart.charAt(0).toUpperCase())
.join("")}
</span>
</span>
);
const Filters = () => {
const { boardData } = useBoard();
const router = useRouter();
const clearFilters = async (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
try {
await router.push({
pathname: router.pathname,
query: { ...router.query, members: [], labels: [] },
});
} catch (error) {
console.error(error);
}
};
const formattedMembers =
boardData?.workspace?.members?.map((member) => ({
key: member.publicId,
value: member.user?.name ?? "",
selected: !!router.query.members?.includes(member.publicId),
leftIcon: <Avatar name={member.user?.name ?? ""} />,
})) ?? [];
const formattedLabels =
boardData?.labels.map((label) => ({
key: label.publicId,
value: label.name,
selected: !!router.query.labels?.includes(label.publicId),
leftIcon: <LabelIcon colourCode={label.colourCode} />,
})) ?? [];
const groups = [
{
key: "members",
label: "Members",
icon: <HiOutlineUserCircle size={16} />,
items: formattedMembers,
},
{
key: "labels",
label: "Labels",
icon: <HiOutlineTag size={16} />,
items: formattedLabels,
},
];
const handleSelect = async (
groupKey: string | null,
item: { key: string },
) => {
if (groupKey === null) return;
const currentQuery = router.query[groupKey] ?? [];
const formattedCurrentQuery = Array.isArray(currentQuery)
? currentQuery
: [currentQuery];
const updatedQuery = formattedCurrentQuery.includes(item.key)
? formattedCurrentQuery.filter((key) => key !== item.key)
: [...formattedCurrentQuery, item.key];
try {
await router.push({
pathname: router.pathname,
query: { ...router.query, [groupKey]: updatedQuery },
});
} catch (error) {
console.error(error);
}
};
const numOfFilters = [
...formatToArray(router.query.members),
...formatToArray(router.query.labels),
].length;
return (
<div className="relative">
<CheckboxDropdown
groups={groups}
handleSelect={handleSelect}
menuSpacing="md"
>
<Button variant="secondary" iconLeft={<IoFilterOutline />}>
Filter
{numOfFilters > 0 && (
<button
onClick={clearFilters}
className="group absolute -right-[18px] -top-[15px] flex h-5 w-5 items-center justify-center rounded-full border-2 border-light-100 bg-light-1000 text-[8px] font-[700] text-light-600 dark:border-dark-50 dark:bg-dark-1000 dark:text-dark-600 dark:text-dark-600"
>
<span className="group-hover:hidden">{numOfFilters}</span>
<span className="hidden text-light-50 group-hover:inline dark:text-dark-50">
<HiMiniXMark size={12} />
</span>
</button>
)}
</Button>
</CheckboxDropdown>
</div>
);
};
export default Filters;

View File

@@ -0,0 +1,135 @@
import { type ReactNode } from "react";
import {
HiOutlinePlusSmall,
HiEllipsisHorizontal,
HiOutlineTrash,
HiOutlineSquaresPlus,
} from "react-icons/hi2";
import { Draggable } from "react-beautiful-dnd";
import { useForm } from "react-hook-form";
import { api } from "~/utils/api";
import { useModal } from "~/providers/modal";
import Dropdown from "~/components/Dropdown";
interface ListProps {
children: ReactNode;
index: number;
list: List;
setSelectedPublicListId: (publicListId: PublicListId) => void;
}
interface List {
publicId: string;
name: string;
}
interface FormValues {
listPublicId: string;
name: string;
}
type PublicListId = string;
export default function List({
children,
index,
list,
setSelectedPublicListId,
}: ListProps) {
const { openModal } = useModal();
const openNewCardForm = (publicListId: PublicListId) => {
openModal("NEW_CARD");
setSelectedPublicListId(publicListId);
};
const updateList = api.list.update.useMutation();
const { register, handleSubmit } = useForm<FormValues>({
defaultValues: {
listPublicId: list.publicId,
name: list.name,
},
values: {
listPublicId: list.publicId,
name: list.name,
},
});
const onSubmit = (values: FormValues) => {
updateList.mutate({
listPublicId: values.listPublicId,
name: values.name,
});
};
const handleOpenDeleteListConfirmation = () => {
setSelectedPublicListId(list.publicId);
openModal("DELETE_LIST");
};
return (
<Draggable key={list.publicId} draggableId={list.publicId} index={index}>
{(provided) => (
<div
key={list.publicId}
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
className="dark-text-dark-1000 mr-5 h-fit min-w-[18rem] max-w-[18rem] rounded-md border border-light-400 bg-light-300 py-2 pl-2 pr-1 text-neutral-900 dark:border-dark-300 dark:bg-dark-100"
>
<div className="flex justify-between">
<form
onSubmit={handleSubmit(onSubmit)}
className="focus-visible:outline-none"
>
<input
id="name"
type="text"
{...register("name")}
onBlur={handleSubmit(onSubmit)}
className="mb-4 block border-0 bg-transparent px-4 pt-1 text-sm font-medium text-neutral-900 focus:ring-0 focus-visible:outline-none dark:text-dark-1000"
/>
</form>
<div>
<button
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-400 dark:hover:bg-dark-200"
onClick={() => openNewCardForm(list.publicId)}
>
<HiOutlinePlusSmall
className="h-5 w-5 text-dark-900"
aria-hidden="true"
/>
</button>
<div className="relative mr-1 inline-block">
<Dropdown
items={[
{
label: "Add a card",
action: () => openNewCardForm(list.publicId),
icon: (
<HiOutlineSquaresPlus className="h-[18px] w-[18px] text-dark-900" />
),
},
{
label: "Delete list",
action: handleOpenDeleteListConfirmation,
icon: (
<HiOutlineTrash className="h-[18px] w-[18px] text-dark-900" />
),
},
]}
>
<HiEllipsisHorizontal className="h-5 w-5 text-dark-900" />
</Dropdown>
</div>
</div>
</div>
{children}
</div>
)}
</Draggable>
);
}

View File

@@ -0,0 +1,300 @@
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import {
HiOutlineBarsArrowDown,
HiOutlineBarsArrowUp,
HiXMark,
} from "react-icons/hi2";
import { type NewCardInput } from "@kan/api/types";
import Button from "~/components/Button";
import CheckboxDropdown from "~/components/CheckboxDropdown";
import Input from "~/components/Input";
import Toggle from "~/components/Toggle";
import { useBoard } from "~/providers/board";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
type NewCardFormInput = NewCardInput & {
isCreateAnotherEnabled: boolean;
};
interface NewCardFormProps {
listPublicId: string;
}
export function NewCardForm({ listPublicId }: NewCardFormProps) {
const { boardData, addCard, refetchBoard } = useBoard();
const { showPopup } = usePopup();
const { closeModal } = useModal();
const { register, handleSubmit, reset, setValue, watch } =
useForm<NewCardFormInput>({
defaultValues: {
title: "",
description: "",
listPublicId,
labelPublicIds: [],
memberPublicIds: [],
isCreateAnotherEnabled: false,
position: "start",
},
});
const labelPublicIds = watch("labelPublicIds") || [];
const memberPublicIds = watch("memberPublicIds") || [];
const isCreateAnotherEnabled = watch("isCreateAnotherEnabled");
const position = watch("position");
const createCard = api.card.create.useMutation({
onSuccess: async () => {
await refetchBoard();
},
onError: async () => {
closeModal();
await refetchBoard();
showPopup({
header: "Unable to create card",
message: "Please try again later, or contact customer support.",
});
},
});
useEffect(() => {
const titleElement: HTMLElement | null =
document?.querySelector<HTMLElement>("#title");
if (titleElement) titleElement.focus();
}, []);
const formattedLabels =
boardData?.labels.map((label) => ({
key: label.publicId,
value: label.name,
selected: labelPublicIds.includes(label.publicId),
})) ?? [];
const formattedLists =
boardData?.lists.map((list) => ({
key: list.publicId,
value: list.name,
selected: list.publicId === watch("listPublicId"),
})) ?? [];
const formattedMembers =
boardData?.workspace?.members?.map((member) => ({
key: member.publicId,
value: member.user?.name ?? "",
selected: memberPublicIds.includes(member.publicId),
})) ?? [];
const onSubmit = (data: NewCardInput) => {
addCard(data);
const isCreateAnotherEnabled = watch("isCreateAnotherEnabled");
if (!isCreateAnotherEnabled) closeModal();
reset({
title: "",
description: "",
listPublicId: watch("listPublicId"),
labelPublicIds: [],
memberPublicIds: [],
isCreateAnotherEnabled,
position,
});
createCard.mutate({
title: data.title,
description: data.description,
listPublicId: data.listPublicId,
labelPublicIds: data.labelPublicIds,
memberPublicIds: data.memberPublicIds,
position: data.position,
});
};
const handleToggleCreateAnother = (): void => {
setValue("isCreateAnotherEnabled", !isCreateAnotherEnabled);
};
const handleSelectList = (listPublicId: string): void => {
setValue("listPublicId", listPublicId);
};
const handleSelectMembers = (memberPublicId: string): void => {
const currentIndex = memberPublicIds.indexOf(memberPublicId);
if (currentIndex === -1) {
setValue("memberPublicIds", [...memberPublicIds, memberPublicId]);
} else {
const newMemberPublicIds = [...memberPublicIds];
newMemberPublicIds.splice(currentIndex, 1);
setValue("memberPublicIds", newMemberPublicIds);
}
};
const handleSelectLabels = (labelPublicId: string): void => {
const currentIndex = labelPublicIds.indexOf(labelPublicId);
if (currentIndex === -1) {
setValue("labelPublicIds", [...labelPublicIds, labelPublicId]);
} else {
const newLabelPublicIds = [...labelPublicIds];
newLabelPublicIds.splice(currentIndex, 1);
setValue("labelPublicIds", newLabelPublicIds);
}
};
const selectedList = formattedLists.find((item) => item.selected);
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div className="px-5 pt-5">
<div className="flex w-full items-center justify-between pb-5">
<h2 className="text-sm font-bold text-neutral-900 dark:text-dark-1000">
New card
</h2>
<button
className="rounded p-1 hover:bg-light-200 focus:outline-none dark:hover:bg-dark-300"
onClick={(e) => {
closeModal();
e.preventDefault();
}}
>
<HiXMark size={18} className="text-light-900 dark:text-dark-900" />
</button>
</div>
<div>
<Input id="title" placeholder="Card title" {...register("title")} />
</div>
<div className="mt-2">
<Input
placeholder="Add description..."
onChange={(e) => setValue("description", e.target.value)}
value={watch("description")}
contentEditable
/>
</div>
<div className="mt-2 flex space-x-1">
<div className="w-fit">
<CheckboxDropdown
items={formattedLists}
handleSelect={(_groupKey, item) => handleSelectList(item.key)}
>
<div className="flex h-full w-full items-center rounded-[5px] border-[1px] border-light-600 bg-light-200 px-2 py-1 text-left text-xs text-light-800 hover:bg-light-300 dark:border-dark-600 dark:bg-dark-400 dark:text-dark-1000 dark:hover:bg-dark-500">
{selectedList?.value}
</div>
</CheckboxDropdown>
</div>
<div className="w-fit">
<CheckboxDropdown
items={formattedMembers}
handleSelect={(_groupKey, item) => handleSelectMembers(item.key)}
>
<div className="flex h-full w-full items-center rounded-[5px] border-[1px] border-light-600 bg-light-200 px-2 py-1 text-left text-xs text-light-800 hover:bg-light-300 dark:border-dark-600 dark:bg-dark-400 dark:text-dark-1000 dark:hover:bg-dark-500">
{!memberPublicIds.length ? (
"Members"
) : (
<div className="flex -space-x-1 overflow-hidden">
{memberPublicIds.map((memberPublicId) => {
const member = formattedMembers.find(
(member) => member.key === memberPublicId,
);
return (
<span
key={member?.key}
className="inline-flex h-4 w-4 items-center justify-center rounded-full bg-gray-400 ring-1 ring-light-200 dark:ring-dark-500"
>
<span className="text-[8px] font-medium leading-none text-white">
{member?.value
?.split(" ")
.map((namePart) =>
namePart.charAt(0).toUpperCase(),
)
.join("")}
</span>
</span>
);
})}
</div>
)}
</div>
</CheckboxDropdown>
</div>
<div className="w-fit">
<CheckboxDropdown
items={formattedLabels}
handleSelect={(_groupKey, item) => handleSelectLabels(item.key)}
>
<div className="flex h-full w-full items-center rounded-[5px] border-[1px] border-light-600 bg-light-200 px-2 py-1 text-left text-xs text-light-800 hover:bg-light-300 dark:border-dark-600 dark:bg-dark-400 dark:text-dark-1000 dark:hover:bg-dark-500">
{!labelPublicIds.length ? (
"Labels"
) : (
<>
<div
className={
labelPublicIds.length > 1
? "flex -space-x-[2px] overflow-hidden"
: "flex items-center"
}
>
{labelPublicIds.map((labelPublicId) => {
const label = boardData?.labels.find(
(label) => label.publicId === labelPublicId,
);
return (
<>
<svg
fill={label?.colourCode ?? "#3730a3"}
className="h-2 w-2"
viewBox="0 0 6 6"
aria-hidden="true"
>
<circle cx={3} cy={3} r={3} />
</svg>
{labelPublicIds.length === 1 && (
<div className="ml-1">{label?.name}</div>
)}
</>
);
})}
</div>
{labelPublicIds.length > 1 && (
<div className="ml-1">{`${labelPublicIds.length} labels`}</div>
)}
</>
)}
</div>
</CheckboxDropdown>
</div>
<button
onClick={(e) => {
e.preventDefault();
setValue("position", position === "start" ? "end" : "start");
}}
className="flex h-auto items-center rounded-[5px] border-[1px] border-light-600 bg-light-200 px-1.5 py-1 text-left text-xs text-light-800 hover:bg-light-300 focus-visible:outline-none dark:border-dark-600 dark:bg-dark-400 dark:text-dark-1000 dark:hover:bg-dark-500"
>
{position === "start" ? (
<HiOutlineBarsArrowUp size={14} />
) : (
<HiOutlineBarsArrowDown size={14} />
)}
</button>
</div>
</div>
<div className="mt-5 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
<Toggle
label="Create another"
isChecked={isCreateAnotherEnabled}
onChange={handleToggleCreateAnother}
/>
<div>
<Button type="submit">Create card</Button>
</div>
</div>
</form>
);
}

View File

@@ -0,0 +1,105 @@
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { HiXMark } from "react-icons/hi2";
import { type NewListInput } from "@kan/api/types";
import Button from "~/components/Button";
import Input from "~/components/Input";
import Toggle from "~/components/Toggle";
import { useBoard } from "~/providers/board";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
type NewListFormInput = NewListInput & {
isCreateAnotherEnabled: boolean;
};
export function NewListForm({ boardPublicId }: { boardPublicId: string }) {
const { refetchBoard, addList } = useBoard();
const { closeModal } = useModal();
const { showPopup } = usePopup();
const { register, handleSubmit, reset, setValue, watch } =
useForm<NewListFormInput>({
defaultValues: {
name: "",
boardPublicId: boardPublicId,
isCreateAnotherEnabled: false,
},
});
const isCreateAnotherEnabled = watch("isCreateAnotherEnabled");
const createList = api.list.create.useMutation({
onSuccess: async () => {
await refetchBoard();
},
onError: async () => {
closeModal();
await refetchBoard();
showPopup({
header: "Unable to create list",
message: "Please try again later, or contact customer support.",
});
},
});
useEffect(() => {
const nameElement: HTMLElement | null =
document?.querySelector<HTMLElement>("#list-name");
if (nameElement) nameElement.focus();
}, []);
const onSubmit = (data: NewListInput) => {
addList(data);
const isCreateAnotherEnabled = watch("isCreateAnotherEnabled");
if (!isCreateAnotherEnabled) closeModal();
reset({
name: "",
isCreateAnotherEnabled,
});
createList.mutate({
name: data.name,
boardPublicId,
});
};
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 list
</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="list-name" placeholder="List 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">
<Toggle
label="Create another"
isChecked={isCreateAnotherEnabled}
onChange={() =>
setValue("isCreateAnotherEnabled", !isCreateAnotherEnabled)
}
/>
<div>
<Button type="submit">Create list</Button>
</div>
</div>
</form>
);
}

View File

@@ -0,0 +1,319 @@
import type { DropResult } from "react-beautiful-dnd";
import Link from "next/link";
import { useParams } from "next/navigation";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import { DragDropContext, Draggable, Droppable } from "react-beautiful-dnd";
import { useForm } from "react-hook-form";
import { HiOutlinePlusSmall } from "react-icons/hi2";
import { type UpdateBoardInput } from "@kan/api/types";
import Modal from "~/components/modal";
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
import { PageHead } from "~/components/PageHead";
import PatternedBackground from "~/components/PatternedBackground";
import { useBoard } from "~/providers/board";
import { useModal } from "~/providers/modal";
import { useWorkspace } from "~/providers/workspace";
import { api } from "~/utils/api";
import { formatToArray } from "~/utils/helpers";
import BoardDropdown from "./components/BoardDropdown";
import { DeleteBoardConfirmation } from "./components/DeleteBoardConfirmation";
import { DeleteListConfirmation } from "./components/DeleteListConfirmation";
import Filters from "./components/Filters";
import List from "./components/List";
import { NewCardForm } from "./components/NewCardForm";
import { NewListForm } from "./components/NewListForm";
type PublicListId = string;
export default function BoardPage() {
const params = useParams();
const router = useRouter();
const { boardData, setBoardData, updateCard, updateList } = useBoard();
const { workspace } = useWorkspace();
const { openModal, modalContentType } = useModal();
const [selectedPublicListId, setSelectedPublicListId] =
useState<PublicListId>("");
const boardId = params?.boardId?.length ? params.boardId[0] : null;
const updateBoard = api.board.update.useMutation();
const { register, handleSubmit, setValue } = useForm<UpdateBoardInput>({
values: {
boardPublicId: boardId ?? "",
name: "",
},
});
const onSubmit = (values: UpdateBoardInput) => {
updateBoard.mutate({
boardPublicId: values.boardPublicId,
name: values.name,
});
};
const { data, isSuccess, isLoading } = api.board.byId.useQuery(
{
boardPublicId: boardId ?? "",
members: formatToArray(router.query.members),
labels: formatToArray(router.query.labels),
},
{
enabled: !!boardId,
},
);
useEffect(() => {
if (isSuccess && data) {
setBoardData(data);
setValue("name", data.name || "");
}
}, [isSuccess, data, setBoardData, setValue]);
if (!boardId || !boardData) return <></>;
const openNewListForm = (publicBoardId: string) => {
openModal("NEW_LIST");
setSelectedPublicListId(publicBoardId);
};
const onDragEnd = ({
source,
destination,
draggableId,
type,
}: DropResult): void => {
if (!destination) {
return;
}
if (type === "LIST") {
const updatedLists = Array.from(boardData.lists);
const removedList = updatedLists.splice(source.index, 1)[0];
if (removedList) {
updatedLists.splice(destination.index, 0, removedList);
setBoardData({ ...boardData, lists: updatedLists });
}
updateList({
listPublicId: draggableId,
currentIndex: source.index,
newIndex: destination.index,
});
}
if (type === "CARD") {
const updatedLists = Array.from(boardData.lists);
const sourceList = updatedLists.find(
(list) => list.publicId === source.droppableId,
);
const destinationList = updatedLists.find(
(list) => list.publicId === destination.droppableId,
);
const removedCard = sourceList?.cards.splice(source.index, 1)[0];
if (sourceList && destinationList && removedCard) {
destinationList.cards.splice(destination.index, 0, removedCard);
setBoardData({ ...boardData, lists: updatedLists });
}
updateCard({
cardPublicId: draggableId,
newListPublicId: destination.droppableId,
newIndex: destination.index,
});
}
};
return (
<>
<PageHead
title={`${boardData?.name ?? "Board"} | ${workspace?.name ?? "Workspace"}`}
/>
<div className="relative flex h-full flex-col">
<PatternedBackground />
<div className="z-10 flex w-full justify-between p-8">
{isLoading ? (
<div className="flex space-x-2">
<div className="h-[2.3rem] w-[150px] animate-pulse rounded-[5px] bg-light-200 dark:bg-dark-100" />
</div>
) : (
<form
onSubmit={handleSubmit(onSubmit)}
className="focus-visible:outline-none"
>
<input
id="name"
type="text"
{...register("name")}
onBlur={handleSubmit(onSubmit)}
className="block border-0 bg-transparent p-0 py-0 font-medium leading-[2.3rem] tracking-tight text-neutral-900 focus:ring-0 focus-visible:outline-none dark:text-dark-1000 sm:text-[1.2rem]"
/>
</form>
)}
<div className="flex items-center space-x-2">
<Filters />
<button
type="button"
className="mr-2 inline-flex items-center gap-x-1.5 rounded-md bg-light-1000 px-3 py-2 text-sm font-semibold text-light-50 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 dark:bg-dark-1000 dark:text-dark-50"
onClick={() => openNewListForm(boardId)}
>
<HiOutlinePlusSmall
className="-mr-0.5 h-5 w-5"
aria-hidden="true"
/>
New list
</button>
<BoardDropdown />
</div>
</div>
<div className="scrollbar-w-none scrollbar-track-rounded-[4px] scrollbar-thumb-rounded-[4px] scrollbar-h-[8px] z-0 flex-1 overflow-y-hidden overflow-x-scroll overscroll-contain scrollbar scrollbar-track-light-200 scrollbar-thumb-light-400 dark:scrollbar-track-dark-100 dark:scrollbar-thumb-dark-300">
{isLoading ? (
<div className="ml-[2rem] flex">
<div className="0 mr-5 h-[500px] w-[18rem] animate-pulse rounded-md bg-light-200 dark:bg-dark-100" />
<div className="0 mr-5 h-[275px] w-[18rem] animate-pulse rounded-md bg-light-200 dark:bg-dark-100" />
<div className="0 mr-5 h-[375px] w-[18rem] animate-pulse rounded-md bg-light-200 dark:bg-dark-100" />
</div>
) : (
<DragDropContext onDragEnd={onDragEnd}>
<Droppable
droppableId="all-lists"
direction="horizontal"
type="LIST"
>
{(provided) => (
<div
className="flex"
ref={provided.innerRef}
{...provided.droppableProps}
>
<div className="min-w-[2rem]" />
{boardData?.lists?.map((list, index) => (
<List
index={index}
key={index}
list={list}
setSelectedPublicListId={(publicListId) =>
setSelectedPublicListId(publicListId)
}
>
<Droppable droppableId={`${list.publicId}`} type="CARD">
{(provided) => (
<div
ref={provided.innerRef}
{...provided.droppableProps}
className="scrollbar-track-rounded-[4px] scrollbar-thumb-rounded-[4px] scrollbar-w-[8px] z-10 h-full max-h-[calc(100vh-265px)] min-h-[2rem] overflow-y-auto pr-1 scrollbar scrollbar-track-dark-100 scrollbar-thumb-dark-600"
>
{list.cards?.map((card, index) => (
<Draggable
key={card.publicId}
draggableId={card.publicId}
index={index}
>
{(provided) => (
<Link
onClick={(e) => {
if (
card.publicId.startsWith(
"PLACEHOLDER",
)
)
e.preventDefault();
}}
key={card.publicId}
href={`/cards/${card.publicId}`}
className={`mb-2 flex !cursor-pointer flex-col rounded-md border border-light-200 bg-light-50 px-3 py-2 text-sm text-neutral-900 dark:border-dark-200 dark:bg-dark-200 dark:text-dark-1000 dark:hover:bg-dark-300 ${
card.publicId.startsWith("PLACEHOLDER")
? "pointer-events-none"
: ""
}`}
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
>
<div>{card.title}</div>
{(card.labels?.length ?? 0) ||
(card.members?.length ?? 0) ? (
<div className="mt-2 flex justify-end space-x-1">
{card.labels?.map((label) => (
<span
key={label.publicId}
className="inline-flex w-fit items-center gap-x-1.5 rounded-full px-2 py-1 text-[10px] font-medium text-neutral-600 ring-1 ring-inset ring-light-600 dark:text-dark-1000 dark:ring-dark-800"
>
<svg
fill={
label.colourCode ?? undefined
}
className="h-2 w-2"
viewBox="0 0 6 6"
aria-hidden="true"
>
<circle cx={3} cy={3} r={3} />
</svg>
<div>{label.name}</div>
</span>
))}
<div className="isolate flex -space-x-1 overflow-hidden">
{card.members?.map((member) => (
<span
key={member.publicId}
className="inline-flex h-6 w-6 items-center justify-center rounded-full bg-light-900 ring-2 ring-light-50 dark:bg-gray-500 dark:ring-dark-500"
>
<span className="text-[10px] font-medium leading-none text-white">
{member?.user?.name
?.split(" ")
.map((namePart) =>
namePart
.charAt(0)
.toUpperCase(),
)
.join("")}
</span>
</span>
))}
</div>
</div>
) : null}
</Link>
)}
</Draggable>
))}
{provided.placeholder}
</div>
)}
</Droppable>
</List>
))}
<div className="min-w-[0.75rem]" />
{provided.placeholder}
</div>
)}
</Droppable>
</DragDropContext>
)}
</div>
<Modal modalSize={modalContentType === "NEW_CARD" ? "md" : "sm"}>
{modalContentType === "DELETE_BOARD" && <DeleteBoardConfirmation />}
{modalContentType === "DELETE_LIST" && (
<DeleteListConfirmation listPublicId={selectedPublicListId} />
)}
{modalContentType === "NEW_CARD" && (
<NewCardForm listPublicId={selectedPublicListId} />
)}
{modalContentType === "NEW_LIST" && (
<NewListForm boardPublicId={boardId} />
)}
{modalContentType === "NEW_WORKSPACE" && <NewWorkspaceForm />}
</Modal>
</div>
</>
);
}

View File

@@ -0,0 +1,40 @@
import Link from "next/link";
import { api } from "~/utils/api";
import { useWorkspace } from "~/providers/workspace";
import PatternedBackground from "~/components/PatternedBackground";
export function BoardsList() {
const { workspace } = useWorkspace();
const { data, isLoading } = api.board.all.useQuery(
{ workspacePublicId: workspace?.publicId },
{ enabled: workspace?.publicId ? true : false },
);
if (isLoading)
return (
<div className="grid w-full grid-cols-1 gap-4 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 xxl:grid-cols-5">
<div className="mr-5 flex h-[150px] w-full animate-pulse rounded-md bg-light-200 dark:bg-dark-100" />
<div className="mr-5 flex h-[150px] w-full animate-pulse rounded-md bg-light-200 dark:bg-dark-100" />
<div className="mr-5 flex h-[150px] w-full animate-pulse rounded-md bg-light-200 dark:bg-dark-100" />
</div>
);
if (data?.length === 0) return <></>;
return (
<div className="grid w-full grid-cols-1 gap-4 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 xxl:grid-cols-5">
{data?.map((board) => (
<Link key={board.publicId} href={`boards/${board.publicId}`}>
<div className="align-center relative mr-5 flex h-[150px] w-full items-center justify-center rounded-md border border-dashed border-light-400 bg-light-50 shadow-sm hover:bg-light-200 dark:border-dark-600 dark:bg-dark-50 dark:hover:bg-dark-100">
<PatternedBackground />
<p className="text-md px-4 font-medium text-neutral-900 dark:text-dark-1000">
{board.name}
</p>
</div>
</Link>
))}
</div>
);
}

View File

@@ -0,0 +1,238 @@
import { Fragment, useState } from "react";
import { api } from "~/utils/api";
import { Listbox, Transition } from "@headlessui/react";
import { useForm, Controller } from "react-hook-form";
import { FaTrello } from "react-icons/fa";
import { HiChevronUpDown, HiXMark } from "react-icons/hi2";
import { useModal } from "~/providers/modal";
import { useWorkspace } from "~/providers/workspace";
import Button from "~/components/Button";
import Input from "~/components/Input";
interface TrelloFormValues {
apiKey: string;
token: string;
}
const sources = [{ source: "Trello" }];
const SelectSource = ({ handleNextStep }: { handleNextStep: () => void }) => {
const { control, handleSubmit } = useForm({
defaultValues: {
source: "Trello",
},
});
const onSubmit = () => {
handleNextStep();
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div className="px-5">
<Controller
name="source"
control={control}
render={({ field }) => (
<Listbox {...field}>
{({ open }) => (
<>
<div className="relative">
<Listbox.Button className="focus-ring-light-700 block w-full rounded-md border-0 bg-dark-300 bg-white/5 px-4 py-1.5 text-neutral-900 shadow-sm ring-1 ring-inset ring-light-600 focus:ring-2 focus:ring-inset dark:text-dark-1000 dark:ring-dark-700 dark:focus:ring-dark-700 sm:text-sm sm:leading-6">
<span className="flex items-center">
<FaTrello />
<span className="ml-2 block truncate">
{field.value}
</span>
</span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<HiChevronUpDown
className="h-5 w-5 text-gray-400"
aria-hidden="true"
/>
</span>
</Listbox.Button>
<Transition
show={open}
as={Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Listbox.Options className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-light-50 py-1 text-base text-neutral-900 shadow-lg ring-1 ring-light-600 ring-opacity-5 focus:outline-none dark:bg-dark-300 dark:text-dark-1000 sm:text-sm">
{sources.map(({ source }, index) => (
<Listbox.Option
key={`source_${index}`}
className="relative cursor-default select-none px-1"
value={source}
>
<div className="flex items-center rounded-[5px] p-1 hover:bg-light-200 dark:hover:bg-dark-400">
<FaTrello className="ml-1" />
<span className="ml-2 block truncate font-normal">
{source}
</span>
</div>
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</div>
</>
)}
</Listbox>
)}
/>
</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">Select source</Button>
</div>
</div>
</form>
);
};
const ImportTrello: React.FC = () => {
const utils = api.useUtils();
const [apiKey, setApiKey] = useState("");
const [token, setToken] = useState("");
const { closeModal } = useModal();
const { workspace } = useWorkspace();
const refetchBoards = () => utils.board.all.refetch();
const boards = api.import.trello.getBoards.useQuery(
{ apiKey, token },
{
enabled: apiKey && token ? true : false,
},
);
const handleSetAuthDetails = (apiKey: string, token: string) => {
setApiKey(apiKey);
setToken(token);
};
const importBoards = api.import.trello.importBoards.useMutation({
onSuccess: async () => {
try {
await refetchBoards();
closeModal();
} catch (e) {
console.log(e);
}
},
});
const { register, handleSubmit } = useForm<TrelloFormValues>({
defaultValues: {
apiKey: "",
token: "",
},
});
const onSubmit = (values: TrelloFormValues) => {
handleSetAuthDetails(values.apiKey, values.token);
};
const { register: registerBoards, handleSubmit: handleSubmitBoards } =
useForm({
defaultValues: Object.fromEntries(
boards?.data?.map((board) => [board.id, true]) ?? [],
),
});
const onSubmitBoards = (values: Record<string, boolean>) => {
const boardIds = Object.keys(values).filter((key) => values[key] === true);
importBoards.mutate({
boardIds,
apiKey,
token,
workspacePublicId: workspace?.publicId,
});
};
if (boards?.data?.length)
return (
<form onSubmit={handleSubmitBoards(onSubmitBoards)}>
<div className="h-[105px] overflow-scroll px-5">
{boards.data.map((board) => (
<div key={board.id}>
<label
className="flex cursor-pointer items-center rounded-[5px] p-2 hover:bg-light-100 dark:hover:bg-dark-300"
htmlFor={board.id}
>
<input
id={board.id}
type="checkbox"
className="h-[14px] w-[14px] rounded bg-transparent ring-0 focus:outline-none focus:ring-0 focus:ring-offset-0"
{...registerBoards(board.id)}
/>
<span className="ml-3 text-sm text-neutral-900 dark:text-dark-1000">
{board.name}
</span>
</label>
</div>
))}
</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={importBoards.isPending}>
Import boards
</Button>
</div>
</div>
</form>
);
return (
<form
onSubmit={handleSubmit(onSubmit)}
className="text-neutral-900 dark:text-dark-1000"
>
<div className="space-y-4 px-5">
<Input id="apiKey" placeholder="API key" {...register("apiKey")} />
<Input id="token" placeholder="Token" {...register("token")} />
</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={boards.isLoading}>
Fetch boards
</Button>
</div>
</div>
</form>
);
};
export function ImportBoardsForm() {
const { closeModal } = useModal();
const [step, setStep] = useState(1);
return (
<div>
<div className="flex w-full items-center justify-between px-5 pb-4 pt-5">
<h2 className="text-sm font-medium text-neutral-900 dark:text-dark-1000">
New import
</h2>
<button
className="rounded p-1 hover:bg-light-200 dark:hover:bg-dark-300"
onClick={() => closeModal()}
>
<HiXMark size={18} className="text-dark-900" />
</button>
</div>
{step === 1 && <SelectSource handleNextStep={() => setStep(step + 1)} />}
{step === 2 && <ImportTrello />}
</div>
);
}

View File

@@ -0,0 +1,70 @@
import { useForm } from "react-hook-form";
import { HiXMark } from "react-icons/hi2";
import { type NewBoardInput } from "@kan/api/types";
import { useModal } from "~/providers/modal";
import { useWorkspace } from "~/providers/workspace";
import { api } from "~/utils/api";
export function NewBoardForm() {
const utils = api.useUtils();
const { closeModal } = useModal();
const { workspace } = useWorkspace();
const { register, handleSubmit } = useForm<NewBoardInput>({
defaultValues: {
name: "",
workspacePublicId: workspace?.publicId || "",
},
});
const refetchBoards = () => utils.board.all.refetch();
const createBoard = api.board.create.useMutation({
onSuccess: async () => {
closeModal();
await refetchBoards();
},
});
const onSubmit = (data: NewBoardInput) => {
createBoard.mutate(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div className="px-5 pt-5">
<div className="text-neutral-9000 flex w-full items-center justify-between pb-4 dark:text-dark-1000">
<h2 className="text-sm font-bold">New board</h2>
<button
className="hover:bg-li ght-300 rounded p-1 focus:outline-none dark:hover:bg-dark-300"
onClick={(e) => {
e.preventDefault();
closeModal();
}}
>
<HiXMark size={18} className="dark:text-dark-9000 text-light-900" />
</button>
</div>
<input
id="name"
placeholder="Name"
{...register("name", { required: true })}
className="block w-full rounded-md border-0 bg-white/5 py-1.5 text-neutral-900 placeholder-dark-800 shadow-sm ring-1 ring-inset ring-light-600 focus:ring-2 focus:ring-inset focus:ring-light-600 dark:bg-dark-300 dark:text-dark-1000 dark:ring-dark-700 dark:focus:ring-dark-700 sm:text-sm sm:leading-6"
/>
</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"
className="inline-flex w-full justify-center rounded-md bg-light-1000 px-3 py-2 text-sm font-semibold text-light-50 shadow-sm focus-visible:outline-none dark:bg-dark-1000 dark:text-dark-50"
>
Create board
</button>
</div>
</div>
</form>
);
}

View File

@@ -0,0 +1,67 @@
import { HiArrowDownTray, HiOutlinePlusSmall } from "react-icons/hi2";
import { BoardsList } from "./components/BoardsList";
import { useModal } from "~/providers/modal";
import { useWorkspace } from "~/providers/workspace";
import Modal from "~/components/modal";
import { PageHead } from "~/components/PageHead";
import { ImportBoardsForm } from "./components/ImportBoardsForm";
import { NewBoardForm } from "./components/NewBoardForm";
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
export default function BoardsPage() {
const { openModal, modalContentType } = useModal();
const { workspace } = useWorkspace();
return (
<>
<PageHead title={`Boards | ${workspace?.name ?? "Workspace"}`} />
<div className="p-8">
<div className="mb-8 flex w-full justify-between">
<h1 className="font-medium tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem]">
Boards
</h1>
<div className="flex">
<button
type="button"
className="bg-dark-3000 mr-2 flex items-center gap-x-1.5 rounded-md border-[1px] border-light-600 px-3 py-2 text-sm text-neutral-900 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 dark:border-dark-600 dark:text-dark-1000"
onClick={() => openModal("IMPORT_BOARDS")}
>
<div className="flex h-5 w-5 items-center">
<HiArrowDownTray
className="-mr-0.5 h-4 w-4"
aria-hidden="true"
/>
</div>
Import
</button>
<button
type="button"
className="flex items-center gap-x-1.5 rounded-md bg-light-1000 px-3 py-2 text-sm font-semibold text-light-50 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 dark:bg-dark-1000 dark:text-dark-50"
onClick={() => openModal("NEW_BOARD")}
>
<div className="h-5 w-5 items-center">
<HiOutlinePlusSmall
className="-mr-0.5 h-5 w-5"
aria-hidden="true"
/>
</div>
New
</button>
</div>
</div>
<Modal>
{modalContentType === "NEW_BOARD" && <NewBoardForm />}
{modalContentType === "IMPORT_BOARDS" && <ImportBoardsForm />}
{modalContentType === "NEW_WORKSPACE" && <NewWorkspaceForm />}
</Modal>
<div className="flex flex-row">
<BoardsList />
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,221 @@
import { formatDistanceToNow } from "date-fns";
import {
HiOutlineArrowLeft,
HiOutlineArrowRight,
HiOutlinePencil,
HiOutlinePlus,
HiOutlineTag,
HiOutlineUserMinus,
HiOutlineUserPlus,
} from "react-icons/hi2";
import { type GetCardByIdOutput } from "@kan/api/types";
import Avatar from "~/components/Avatar";
import Comment from "./Comment";
type ActivityType =
NonNullable<GetCardByIdOutput>["activities"][number]["type"];
const ACTIVITY_TYPE_MAP = {
"card.created": "created the card",
"card.updated.title": "updated the title",
"card.updated.description": "updated the description",
"card.updated.list": "moved the card to another list",
"card.updated.label.added": "added a label to the card",
"card.updated.label.removed": "removed a label from the card",
"card.updated.member.added": "added a member to the card",
"card.updated.member.removed": "removed a member from the card",
} as const;
const getActivityText = ({
type,
toTitle,
fromList,
toList,
memberName,
isSelf,
label,
}: {
type: ActivityType;
toTitle: string | null;
fromList: string | null;
toList: string | null;
memberName: string | null;
isSelf: boolean;
label: string | null;
}) => {
if (!(type in ACTIVITY_TYPE_MAP)) return null;
const baseText = ACTIVITY_TYPE_MAP[type as keyof typeof ACTIVITY_TYPE_MAP];
const TextHighlight = ({ children }: { children: React.ReactNode }) => (
<span className="font-medium text-light-1000 dark:text-dark-1000">
{children}
</span>
);
if (type === "card.updated.title" && toTitle) {
return (
<>
updated the title to <TextHighlight>{toTitle}</TextHighlight>
</>
);
}
if (type === "card.updated.list" && fromList && toList) {
return (
<>
moved the card from <TextHighlight>{fromList}</TextHighlight> to
<TextHighlight>{toList}</TextHighlight>
</>
);
}
if (type === "card.updated.member.added" && memberName) {
if (isSelf) return <>self-assigned the card</>;
return (
<>
assigned <TextHighlight>{memberName}</TextHighlight> to the card
</>
);
}
if (type === "card.updated.member.removed" && memberName) {
if (isSelf) return <>unassigned themselves from the card</>;
return (
<>
unassigned <TextHighlight>{memberName}</TextHighlight> from the card
</>
);
}
if (type === "card.updated.label.added" && label) {
return (
<>
added label <TextHighlight>{label}</TextHighlight>
</>
);
}
if (type === "card.updated.label.removed" && label) {
return (
<>
removed label <TextHighlight>{label}</TextHighlight>
</>
);
}
return baseText;
};
const ACTIVITY_ICON_MAP: Partial<Record<ActivityType, React.ReactNode | null>> =
{
"card.created": <HiOutlinePlus />,
"card.updated.title": <HiOutlinePencil />,
"card.updated.description": <HiOutlinePencil />,
"card.updated.label.added": <HiOutlineTag />,
"card.updated.label.removed": <HiOutlineTag />,
"card.updated.member.added": <HiOutlineUserPlus />,
"card.updated.member.removed": <HiOutlineUserMinus />,
} as const;
const getActivityIcon = (
type: ActivityType,
fromIndex?: number | null,
toIndex?: number | null,
): React.ReactNode | null => {
console.log({ fromIndex, toIndex });
if (type === "card.updated.list" && fromIndex != null && toIndex != null) {
return fromIndex > toIndex ? (
<HiOutlineArrowLeft />
) : (
<HiOutlineArrowRight />
);
}
return ACTIVITY_ICON_MAP[type] ?? null;
};
const ActivityList = ({
activities,
cardPublicId,
isLoading,
}: {
activities: NonNullable<GetCardByIdOutput>["activities"];
cardPublicId: string;
isLoading: boolean;
}) => {
return (
<div className="flex flex-col space-y-4 pt-4">
{activities?.map((activity, index) => {
const activityText = getActivityText({
type: activity.type,
toTitle: activity.toTitle,
fromList: activity.fromList?.name ?? null,
toList: activity.toList?.name ?? null,
memberName: activity.member?.user?.name ?? null,
isSelf: activity.member?.user?.id === activity.user?.id,
label: activity.label?.name ?? null,
});
if (activity.type === "card.updated.comment.added")
return (
<Comment
key={activity.publicId}
publicId={activity.comment?.publicId}
cardPublicId={cardPublicId}
name={activity.user?.name ?? ""}
email={activity.user?.email ?? ""}
isLoading={isLoading}
createdAt={activity.createdAt}
comment={activity.comment?.comment}
isEdited={!!activity.comment?.updatedAt}
/>
);
if (!activityText) return null;
return (
<div
key={activity.publicId}
className="relative flex items-center space-x-2"
>
<div className="relative">
<Avatar
size="sm"
name={activity.user?.name ?? ""}
email={activity.user?.email ?? ""}
icon={getActivityIcon(
activity.type,
activity.fromList?.index,
activity.toList?.index,
)}
isLoading={isLoading}
/>
{index !== activities.length - 1 &&
activities[index + 1]?.type !==
"card.updated.comment.added" && (
<div className="absolute bottom-[-14px] left-1/2 top-[30px] w-0.5 -translate-x-1/2 bg-light-600 dark:bg-dark-600" />
)}
</div>
<p className="text-sm">
<span className="font-medium dark:text-dark-1000">{`${activity.user?.name} `}</span>
<span className="space-x-1 text-light-900 dark:text-dark-800">
{activityText}
</span>
<span className="mx-1 text-light-900 dark:text-dark-800">·</span>
<span className="space-x-1 text-light-900 dark:text-dark-800">
{formatDistanceToNow(new Date(activity.createdAt), {
addSuffix: true,
})}
</span>
</p>
</div>
);
})}
</div>
);
};
export default ActivityList;

View File

@@ -0,0 +1,146 @@
import { useState } from "react";
import { useForm } from "react-hook-form";
import ContentEditable from "react-contenteditable";
import { formatDistanceToNow } from "date-fns";
import { api } from "~/utils/api";
import { usePopup } from "~/providers/popup";
import Avatar from "~/components/Avatar";
import Button from "~/components/Button";
import Dropdown from "~/components/Dropdown";
import { HiEllipsisHorizontal, HiPencil } from "react-icons/hi2";
interface FormValues {
comment: string;
}
const Comment = ({
publicId,
cardPublicId,
name,
email,
isLoading,
createdAt,
comment,
isEdited = false,
}: {
publicId: string | undefined;
cardPublicId: string;
name: string;
email: string;
isLoading: boolean;
createdAt: string;
comment: string | undefined;
isEdited: boolean;
}) => {
const [isEditing, setIsEditing] = useState(false);
const utils = api.useUtils();
const { showPopup } = usePopup();
const { handleSubmit, setValue, watch } = useForm<FormValues>({
defaultValues: {
comment,
},
});
if (!publicId) return null;
const updateCommentMutation = api.card.updateComment.useMutation({
onSuccess: async () => {
await utils.card.byId.refetch();
setIsEditing(false);
},
onError: () => {
showPopup({
header: "Unable to update comment",
message: "Please try again later, or contact customer support.",
});
},
});
const onSubmit = (data: FormValues) => {
updateCommentMutation.mutate({
cardPublicId,
comment: data.comment,
commentPublicId: publicId,
});
};
return (
<div
key={publicId}
className="group relative flex w-full flex-col rounded-xl border border-light-600 bg-light-200 p-4 text-light-900 focus-visible:outline-none dark:border-dark-400 dark:bg-dark-100 dark:text-dark-1000 sm:text-sm sm:leading-6"
>
<div className="flex justify-between">
<div className="flex items-center space-x-2">
<Avatar
size="sm"
name={name ?? ""}
email={email ?? ""}
isLoading={isLoading}
/>
<p className="text-sm">
<span className="font-medium dark:text-dark-1000">{`${name} `}</span>
<span className="mx-1 text-light-900 dark:text-dark-800">·</span>
<span className="space-x-1 text-light-900 dark:text-dark-800">
{formatDistanceToNow(new Date(createdAt), {
addSuffix: true,
})}
</span>
{isEdited && (
<span className="text-light-900 dark:text-dark-800">
{" (edited)"}
</span>
)}
</p>
</div>
<div className="absolute right-4 top-4">
<Dropdown
items={[
{
label: "Edit comment",
action: () => setIsEditing(true),
icon: <HiPencil className="h-[18px] w-[18px] text-dark-900" />,
},
]}
>
<HiEllipsisHorizontal className="h-5 w-5 text-light-900 dark:text-dark-800" />
</Dropdown>
</div>
</div>
{!isEditing ? (
<p className="mt-2 text-sm">{comment}</p>
) : (
<form onSubmit={handleSubmit(onSubmit)}>
<ContentEditable
placeholder="Add a comment..."
html={watch("comment")}
disabled={false}
onChange={(e) => setValue("comment", e.target.value)}
className="block w-full border-0 bg-transparent py-1.5 text-light-900 focus-visible:outline-none dark:text-dark-1000 sm:text-sm sm:leading-6"
/>
<div className="flex justify-end space-x-2">
<Button
size="sm"
variant="ghost"
onClick={() => setIsEditing(false)}
>
Cancel
</Button>
<Button
isLoading={updateCommentMutation.isPending}
type="submit"
size="sm"
>
Save
</Button>
</div>
</form>
)}
</div>
);
};
export default Comment;

View File

@@ -0,0 +1,67 @@
import { useRouter } from "next/navigation";
import { api } from "~/utils/api";
import { useModal } from "~/providers/modal";
import { useBoard } from "~/providers/board";
import { usePopup } from "~/providers/popup";
interface DeleteCardConfirmationProps {
cardPublicId: string;
boardPublicId: string;
}
export function DeleteCardConfirmation({
cardPublicId,
boardPublicId,
}: DeleteCardConfirmationProps) {
const { closeModal } = useModal();
const router = useRouter();
const { removeCard, refetchBoard } = useBoard();
const { showPopup } = usePopup();
const deleteCardMutation = api.card.delete.useMutation({
onSuccess: () => refetchBoard(),
onError: () =>
showPopup({
header: "Error deleting card",
message: "Please try again later, or contact customer support.",
}),
});
const handleDeleteCard = () => {
removeCard({
cardPublicId,
});
closeModal();
router.push(`/boards/${boardPublicId}`);
deleteCardMutation.mutate({
cardPublicId,
});
};
return (
<div className="p-5">
<div className="flex w-full flex-col justify-between pb-4">
<h2 className="text-md pb-4 font-medium text-neutral-900 dark:text-dark-1000">
Are you sure you want to delete this card?
</h2>
<p className="text-sm font-medium text-light-900 dark:text-dark-900">
{"This action can't be undone."}
</p>
</div>
<div className="mt-5 flex justify-end sm:mt-6">
<button
className="mr-4 inline-flex justify-center rounded-md border-[1px] border-light-600 bg-light-50 px-3 py-2 text-sm font-semibold text-neutral-900 shadow-sm focus-visible:outline-none dark:border-dark-600 dark:bg-dark-300 dark:text-dark-1000"
onClick={() => closeModal()}
>
Cancel
</button>
<button
onClick={handleDeleteCard}
className="inline-flex justify-center rounded-md bg-light-1000 px-3 py-2 text-sm font-semibold text-light-50 shadow-sm focus-visible:outline-none dark:bg-dark-1000 dark:text-dark-50"
>
Delete
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,54 @@
import { api } from "~/utils/api";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import Button from "~/components/Button";
export function DeleteLabelConfirmation({
cardPublicId,
labelPublicId,
}: {
cardPublicId: string;
labelPublicId: string;
}) {
const utils = api.useUtils();
const { closeModal } = useModal();
const { showPopup } = usePopup();
const refetchCard = () => utils.card.byId.refetch({ cardPublicId });
const deleteLabelMutation = api.label.delete.useMutation({
onSuccess: () => refetchCard(),
onError: () =>
showPopup({
header: "Error deleting label",
message: "Please try again later, or contact customer support.",
}),
});
const handleDeleteLabel = () => {
closeModal();
deleteLabelMutation.mutate({
labelPublicId,
});
};
return (
<div className="p-5">
<div className="flex w-full flex-col justify-between pb-4">
<h2 className="text-md pb-4 font-medium text-neutral-900 dark:text-dark-1000">
Are you sure you want to delete this label?
</h2>
<p className="text-sm font-medium text-light-900 dark:text-dark-900">
{"This action can't be undone."}
</p>
</div>
<div className="mt-5 flex justify-end space-x-2 sm:mt-6">
<Button variant="secondary" onClick={() => closeModal()}>
Cancel
</Button>
<Button onClick={handleDeleteLabel}>Delete</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,44 @@
import { Fragment } from "react";
import { Menu, Transition } from "@headlessui/react";
import { HiEllipsisHorizontal } from "react-icons/hi2";
import { useModal } from "~/providers/modal";
export default function Dropdown() {
const { openModal } = useModal();
return (
<Menu as="div" className="relative inline-block text-left">
<div>
<Menu.Button className="flex h-8 w-8 items-center justify-center rounded-[5px] hover:bg-light-200 dark:hover:bg-dark-200">
<HiEllipsisHorizontal
size={25}
className="text-light-900 dark:text-dark-900"
/>
</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 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-400 dark:bg-dark-300">
<div className="flex">
<Menu.Item>
<button
onClick={() => openModal("DELETE_CARD")}
className="m-1 w-full 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"
>
Delete card
</button>
</Menu.Item>
</div>
</Menu.Items>
</Transition>
</Menu>
);
}

View File

@@ -0,0 +1,229 @@
import { Fragment } from "react";
import { HiChevronUpDown, HiXMark } from "react-icons/hi2";
import { useForm, Controller } from "react-hook-form";
import { Listbox, Transition } from "@headlessui/react";
import { api } from "~/utils/api";
import { useModal } from "~/providers/modal";
import Button from "~/components/Button";
import Input from "~/components/Input";
import Toggle from "~/components/Toggle";
type LabelFormInput = {
name: string;
colour: Colour;
isCreateAnotherEnabled?: boolean;
};
type Colour = {
name: string;
code: string;
};
const colours = [
{ name: "Teal", code: "#0d9488" },
{ name: "Green", code: "#65a30d" },
{ name: "Blue", code: "#0284c7" },
{ name: "Purple", code: "#4f46e5" },
{ name: "Yellow", code: "#ca8a04" },
{ name: "Orange", code: "#ea580c" },
{ name: "Red", code: "#dc2626" },
{ name: "Pink", code: "#db2777" },
];
export function LabelForm({
cardPublicId,
isEdit,
}: {
cardPublicId: string;
isEdit?: boolean;
}) {
const utils = api.useUtils();
const { closeModal, entityId, openModal } = useModal();
const label = api.label.byPublicId.useQuery(
{
labelPublicId: entityId,
},
{
enabled: isEdit && !!entityId,
},
);
const { control, register, reset, handleSubmit, setValue, watch } =
useForm<LabelFormInput>({
values: {
name: isEdit && label?.data?.name ? label?.data?.name : "",
colour: (isEdit && label?.data?.colourCode
? colours.find((c) => c.code === label?.data?.colourCode)
: colours[0]) as Colour,
isCreateAnotherEnabled: false,
},
});
const refetchCard = () => utils.card.byId.refetch({ cardPublicId });
const isCreateAnotherEnabled = watch("isCreateAnotherEnabled");
const createLabel = api.label.create.useMutation({
onSuccess: async () => {
const currentColourIndex = colours.findIndex(
(c) => c.code === watch("colour").code,
);
try {
await refetchCard();
if (!isCreateAnotherEnabled) closeModal();
reset({
name: "",
colour: colours[(currentColourIndex + 1) % colours.length],
isCreateAnotherEnabled,
});
} catch (e) {
console.log(e);
}
},
});
const updateLabel = api.label.update.useMutation({
onSuccess: async () => {
await refetchCard();
closeModal();
reset({
name: "",
colour: colours[0],
});
},
});
const onSubmit = (values: LabelFormInput) => {
if (!values.colour?.code) return;
if (isEdit) {
updateLabel.mutate({
labelPublicId: label.data?.publicId ?? "",
name: values.name,
colourCode: values.colour.code,
});
} else {
createLabel.mutate({
name: values.name,
cardPublicId,
colourCode: values.colour.code,
});
}
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div className="px-5 pt-5">
<div className="flex w-full items-center justify-between pb-4 text-neutral-900 dark:text-dark-1000">
<h2 className="text-sm font-medium">
{isEdit ? "Edit label" : "New label"}
</h2>
<button
className="rounded p-1 hover:bg-light-300 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="label-name" placeholder="Name" {...register("name")} />
<Controller
name="colour"
control={control}
render={({ field }) => (
<Listbox {...field}>
{({ open }) => (
<>
<div className="relative mt-4">
<Listbox.Button className="block w-full rounded-md border-0 bg-white/5 px-4 py-1.5 shadow-sm ring-1 ring-inset ring-light-600 focus:ring-2 focus:ring-inset focus:ring-light-600 dark:bg-dark-300 dark:text-dark-1000 dark:ring-dark-700 dark:focus:ring-dark-700 sm:text-sm sm:leading-6">
<span className="flex items-center">
<span
style={{ backgroundColor: field.value?.code }}
className={`inline-block h-2 w-2 flex-shrink-0 rounded-full`}
/>
<span className="ml-3 block truncate">
{field.value?.name}
</span>
</span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<HiChevronUpDown
className="h-5 w-5 text-gray-400"
aria-hidden="true"
/>
</span>
</Listbox.Button>
<Transition
show={open}
as={Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Listbox.Options className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-light-50 py-2 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:bg-dark-300 sm:text-sm">
{colours.map((colour, index) => (
<Listbox.Option
key={`colours_${index}`}
className="relative cursor-default select-none px-2 text-neutral-900 dark:text-dark-1000 "
value={colour}
>
{() => (
<>
<div className="flex items-center rounded-[5px] p-2 hover:bg-light-200 dark:hover:bg-dark-400">
<span
style={{ backgroundColor: colour?.code }}
className="ml-2 inline-block h-2 w-2 flex-shrink-0 rounded-full"
aria-hidden="true"
/>
<span className="ml-3 block truncate font-normal">
{colour.name}
</span>
</div>
</>
)}
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</div>
</>
)}
</Listbox>
)}
/>
</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">
{!isEdit && (
<Toggle
label="Create another"
isChecked={!!isCreateAnotherEnabled}
onChange={() =>
setValue("isCreateAnotherEnabled", !isCreateAnotherEnabled)
}
/>
)}
<div className="space-x-2">
{isEdit && (
<Button
variant="secondary"
onClick={() => openModal("DELETE_LABEL", entityId)}
>
Delete
</Button>
)}
<Button type="submit">
{isEdit ? "Update label" : "Create label"}
</Button>
</div>
</div>
</form>
);
}

View File

@@ -0,0 +1,165 @@
import { Fragment } from "react";
import { api } from "~/utils/api";
import { Menu, Transition } from "@headlessui/react";
import { HiMiniPlus } from "react-icons/hi2";
import { useForm } from "react-hook-form";
import { useModal } from "~/providers/modal";
import { HiEllipsisHorizontal } from "react-icons/hi2";
interface LabelSelectorProps {
cardPublicId: string;
labels: {
publicId: string;
name: string;
selected: boolean;
colourCode: string;
}[];
isLoading: boolean;
}
export default function LabelSelector({
cardPublicId,
labels,
isLoading,
}: LabelSelectorProps) {
const { openModal } = useModal();
const utils = api.useUtils();
const refetchCard = () => utils.card.byId.refetch({ cardPublicId });
const addOrRemoveLabel = api.card.addOrRemoveLabel.useMutation({
onSuccess: async () => {
await refetchCard();
},
});
const { register, handleSubmit, setValue, watch } = useForm({
values: Object.fromEntries(
labels?.map((label) => [label.publicId, label.selected]) ?? [],
),
});
const onSubmit = (values: Record<string, boolean>) => {
console.log({ values });
};
const selectedLabels = labels.filter((label) => label.selected);
return (
<>
{isLoading ? (
<div className="flex w-full">
<div className="h-full w-[175px] animate-pulse rounded-[5px] bg-light-300 dark:bg-dark-300" />
</div>
) : (
<Menu
as="div"
className="relative flex w-full flex-wrap items-center text-left"
>
{selectedLabels.length ? (
<>
{selectedLabels.map((label) => (
<Menu.Button
key={label.publicId}
className="my-1 mr-2 inline-flex w-fit items-center gap-x-1.5 rounded-full px-2 py-1 text-[12px] font-medium text-light-800 ring-1 ring-inset ring-light-600 dark:text-dark-1000 dark:ring-dark-800"
>
<svg
fill={label.colourCode}
className="h-2 w-2"
viewBox="0 0 6 6"
aria-hidden="true"
>
<circle cx={3} cy={3} r={3} />
</svg>
<div>{label.name}</div>
</Menu.Button>
))}
<Menu.Button className="my-1 inline-flex w-fit items-center gap-x-1.5 rounded-full py-1 pl-2 pr-4 text-[12px] font-medium text-dark-800 ring-inset ring-dark-800 hover:bg-light-400 dark:hover:bg-dark-200">
<HiMiniPlus size={16} />
Add label
</Menu.Button>
</>
) : (
<Menu.Button className="flex h-full w-full items-center rounded-[5px] border-[1px] border-light-200 pl-2 text-left text-sm text-neutral-900 hover:bg-light-300 dark:border-dark-100 dark:text-dark-1000 dark:hover:border-dark-300 dark:hover:bg-dark-200">
<HiMiniPlus size={22} className="pr-2" />
Add label
</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"
>
<Menu.Items className="absolute right-[200px] top-[30px] z-10 mt-2 w-56 origin-top-right rounded-md border-[1px] border-light-600 bg-light-50 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-500 dark:bg-dark-200">
<div className="p-2">
<form onSubmit={handleSubmit(onSubmit)}>
{labels?.map((label) => (
<Menu.Item key={label.publicId}>
{() => (
<div
key={label.publicId}
className="group flex items-center rounded-[5px] p-2 hover:bg-light-200 dark:hover:bg-dark-300"
onClick={() => {
const newValue = !watch(label.publicId);
setValue(label.publicId, newValue);
addOrRemoveLabel.mutate({
cardPublicId,
labelPublicId: label.publicId,
});
handleSubmit(onSubmit);
}}
>
<input
id={label.publicId}
type="checkbox"
className="h-[14px] w-[14px] rounded bg-transparent"
onClick={(event) => event.stopPropagation()}
{...register(label.publicId)}
checked={watch(label.publicId)}
/>
<div className="flex w-full items-center justify-between">
<label
htmlFor={label.publicId}
className="ml-3 text-sm"
>
{label.name}
</label>
<button
className="invisible group-hover:visible"
onClick={(event) => {
event.stopPropagation();
openModal("EDIT_LABEL", label.publicId);
}}
>
<HiEllipsisHorizontal size={20} />
</button>
</div>
</div>
)}
</Menu.Item>
))}
<button
onClick={() => openModal("NEW_LABEL")}
className="flex w-full items-center rounded-[5px] p-1.5 px-2 text-sm hover:bg-light-200 dark:hover:bg-dark-300"
>
<HiMiniPlus size={22} className="pr-2" />
Create new label
</button>
</form>
</div>
</Menu.Items>
</Transition>
</Menu>
)}
</>
);
}

View File

@@ -0,0 +1,114 @@
import { Fragment } from "react";
import { api } from "~/utils/api";
import { Menu, Transition } from "@headlessui/react";
import { useForm } from "react-hook-form";
interface ListSelectorProps {
cardPublicId: string;
lists: {
publicId: string;
name: string;
selected: boolean;
}[];
isLoading: boolean;
}
export default function ListSelector({
cardPublicId,
lists,
isLoading,
}: ListSelectorProps) {
const utils = api.useUtils();
const refetchCard = () => utils.card.byId.refetch({ cardPublicId });
const updateCardList = api.card.reorder.useMutation({
onSuccess: async () => {
await refetchCard();
},
});
const { register, handleSubmit, setValue, watch } = useForm({
values: Object.fromEntries(
lists?.map((list) => [list.publicId, list.selected]) ?? [],
),
});
const onSubmit = (values: Record<string, boolean>) => {
console.log({ values });
};
const selectedList = lists.find((list) => list.selected);
return (
<>
{isLoading ? (
<div className="flex w-full">
<div className="h-full w-[150px] animate-pulse rounded-[5px] bg-light-300 dark:bg-dark-300" />
</div>
) : (
<Menu
as="div"
className="relative flex w-full flex-wrap items-center text-left"
>
<Menu.Button className="flex h-full w-full items-center rounded-[5px] border-[1px] border-light-200 pl-2 text-left text-sm text-neutral-900 hover:border-light-300 hover:bg-light-300 dark:border-dark-100 dark:text-dark-1000 dark:hover:border-dark-300 dark:hover:bg-dark-200">
{selectedList?.name}
</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"
>
<Menu.Items className="absolute right-[200px] top-[30px] z-10 mt-2 w-56 origin-top-right rounded-md border-[1px] border-light-600 bg-light-50 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-500 dark:bg-dark-200">
<div className="p-2">
<form onSubmit={handleSubmit(onSubmit)}>
{lists?.map((list) => (
<Menu.Item key={list.publicId}>
{() => (
<div
key={list.publicId}
className="flex items-center rounded-[5px] p-2 hover:bg-light-200 dark:hover:bg-dark-300"
onClick={() => {
const newValue = !watch(list.publicId);
setValue(list.publicId, newValue);
updateCardList.mutate({
cardPublicId,
newListPublicId: list.publicId,
});
handleSubmit(onSubmit);
}}
>
<input
id={list.publicId}
type="checkbox"
className="h-[14px] w-[14px] rounded bg-transparent"
onClick={(event) => event.stopPropagation()}
{...register(list.publicId)}
checked={watch(list.publicId)}
/>
<label
htmlFor={list.publicId}
className="ml-3 text-sm"
>
{list.name}
</label>
</div>
)}
</Menu.Item>
))}
</form>
</div>
</Menu.Items>
</Transition>
</Menu>
)}
</>
);
}

View File

@@ -0,0 +1,141 @@
import { Fragment } from "react";
import { api } from "~/utils/api";
import { Menu, Transition } from "@headlessui/react";
import { HiMiniPlus } from "react-icons/hi2";
import { useForm } from "react-hook-form";
interface MemberSelectorProps {
cardPublicId: string;
members: {
publicId: string;
user: {
id: string;
name: string | null;
};
selected: boolean;
}[];
isLoading: boolean;
}
export default function MemberSelector({
cardPublicId,
members,
isLoading,
}: MemberSelectorProps) {
const utils = api.useUtils();
const refetchCard = () => utils.card.byId.refetch({ cardPublicId });
const addOrRemoveMember = api.card.addOrRemoveMember.useMutation({
onSuccess: async () => {
await refetchCard();
},
});
const { register, handleSubmit, setValue, watch } = useForm({
values: Object.fromEntries(
members?.map((member) => [member.publicId, member.selected]) ?? [],
),
});
const onSubmit = (values: Record<string, boolean>) => {
console.log({ values });
};
const selectedMembers = members.filter((member) => member.selected);
return (
<>
{isLoading ? (
<div className="flex w-full">
<div className="h-full w-[125px] animate-pulse rounded-[5px] bg-light-300 dark:bg-dark-300" />
</div>
) : (
<Menu
as="div"
className="relative flex w-full flex-wrap items-center text-left"
>
<Menu.Button className="flex h-full w-full items-center rounded-[5px] border-[1px] border-light-200 pl-2 text-left text-sm text-neutral-900 hover:bg-light-300 dark:border-dark-100 dark:text-dark-1000 dark:hover:border-dark-300 dark:hover:bg-dark-200">
{selectedMembers.length ? (
<div className="isolate flex -space-x-1 overflow-hidden">
{selectedMembers.map((member) => (
<span
key={member.publicId}
className="relative z-30 inline-flex h-6 w-6 items-center justify-center rounded-full bg-gray-500 ring-1 ring-light-200 dark:ring-dark-100"
>
<span className="text-[10px] font-medium leading-none text-white">
{member.user?.name
? member.user?.name
.split(" ")
.map((namePart) => namePart.charAt(0).toUpperCase())
.join("")
: null}
</span>
</span>
))}
</div>
) : (
<>
<HiMiniPlus size={22} className="pr-2" />
{"Add member"}
</>
)}
</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"
>
<Menu.Items className="absolute right-[200px] top-[30px] z-10 mt-2 w-56 origin-top-right rounded-md border-[1px] border-light-600 bg-light-50 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-500 dark:bg-dark-200">
<div className="p-2">
<form onSubmit={handleSubmit(onSubmit)}>
{members?.map((member) => (
<Menu.Item key={member.publicId}>
{() => (
<div
key={member.publicId}
className="flex items-center rounded-[5px] p-2 hover:bg-light-200 dark:hover:bg-dark-300"
onClick={() => {
const newValue = !watch(member.publicId);
setValue(member.publicId, newValue);
addOrRemoveMember.mutate({
cardPublicId,
workspaceMemberPublicId: member.publicId,
});
handleSubmit(onSubmit);
}}
>
<input
id={member.publicId}
type="checkbox"
className="h-[14px] w-[14px] rounded bg-transparent"
onClick={(event) => event.stopPropagation()}
{...register(member.publicId)}
checked={watch(member.publicId)}
/>
<label
htmlFor={member.publicId}
className="ml-3 text-sm"
>
{member?.user?.name}
</label>
</div>
)}
</Menu.Item>
))}
</form>
</div>
</Menu.Items>
</Transition>
</Menu>
)}
</>
);
}

View File

@@ -0,0 +1,72 @@
import { useForm } from "react-hook-form";
import ContentEditable from "react-contenteditable";
import { HiOutlineArrowUp } from "react-icons/hi2";
import LoadingSpinner from "~/components/LoadingSpinner";
import { api } from "~/utils/api";
import { usePopup } from "~/providers/popup";
interface FormValues {
comment: string;
}
const NewCommentForm = ({ cardPublicId }: { cardPublicId: string }) => {
const utils = api.useUtils();
const { showPopup } = usePopup();
const { handleSubmit, setValue, watch, reset } = useForm<FormValues>({
values: {
comment: "",
},
});
const addCommentMutation = api.card.addComment.useMutation({
onSuccess: async () => {
await utils.card.byId.refetch();
reset();
},
onError: () => {
showPopup({
header: "Unable to add comment",
message: "Please try again later, or contact customer support.",
});
},
});
const onSubmit = (data: FormValues) => {
addCommentMutation.mutate({
cardPublicId,
comment: data.comment,
});
};
return (
<form
onSubmit={handleSubmit(onSubmit)}
className="flex w-full flex-col rounded-xl border border-light-600 bg-light-200 p-4 text-light-900 focus-visible:outline-none dark:border-dark-400 dark:bg-dark-100 dark:text-dark-1000 sm:text-sm sm:leading-6"
>
<ContentEditable
placeholder="Add a comment..."
html={watch("comment")}
disabled={false}
onChange={(e) => setValue("comment", e.target.value)}
className="block w-full border-0 bg-transparent py-1.5 text-light-900 focus-visible:outline-none dark:text-dark-1000 sm:text-sm sm:leading-6"
/>
<div className="flex justify-end">
<button
type="submit"
disabled={addCommentMutation.isPending}
className="flex h-8 w-8 items-center justify-center rounded-full border border-light-600 bg-light-300 hover:bg-light-400 disabled:opacity-50 dark:border-dark-400 dark:bg-dark-200 dark:hover:bg-dark-400"
>
{addCommentMutation.isPending ? (
<LoadingSpinner size="sm" />
) : (
<HiOutlineArrowUp />
)}
</button>
</div>
</form>
);
};
export default NewCommentForm;

View File

@@ -0,0 +1,246 @@
import Link from "next/link";
import { useParams } from "next/navigation";
import { useForm } from "react-hook-form";
import ContentEditable from "react-contenteditable";
import { IoChevronForwardSharp } from "react-icons/io5";
import ActivityList from "./components/ActivityList";
import Dropdown from "./components/Dropdown";
import { DeleteCardConfirmation } from "./components/DeleteCardConfirmation";
import { DeleteLabelConfirmation } from "./components/DeleteLabelConfirmation";
import LabelSelector from "./components/LabelSelector";
import ListSelector from "./components/ListSelector";
import MemberSelector from "./components/MemberSelector";
import { LabelForm } from "./components/LabelForm";
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
import NewCommentForm from "./components/NewCommentForm";
import { PageHead } from "~/components/PageHead";
import Modal from "~/components/modal";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
interface FormValues {
cardId: string;
title: string;
description: string;
}
export default function CardPage() {
const params = useParams();
const utils = api.useUtils();
const { modalContentType, entityId } = useModal();
const { showPopup } = usePopup();
const cardId = Array.isArray(params?.cardId)
? params.cardId[0]
: params?.cardId;
const { data, isLoading } = api.card.byId.useQuery({
cardPublicId: cardId ?? "",
});
const board = data?.list?.board;
const boardId = board?.publicId;
const labels = board?.labels;
const activities = data?.activities;
const workspaceMembers = board?.workspace?.members;
const selectedLabels = data?.labels;
const selectedMembers = data?.members;
const formattedLabels =
labels?.map((label) => {
const isSelected = selectedLabels?.some(
(selectedLabel) => selectedLabel.publicId === label.publicId,
);
return {
...label,
selected: isSelected ?? false,
colourCode: label.colourCode ?? "",
};
}) ?? [];
const formattedLists =
board?.lists.map((list) => ({
...list,
selected: list.publicId === data?.list?.publicId,
})) ?? [];
const formattedMembers =
workspaceMembers?.map((member) => {
const isSelected = selectedMembers?.some(
(assignedMember) => assignedMember.publicId === member.publicId,
);
return {
...member,
user: member.user ?? { id: "", name: null },
selected: isSelected ?? false,
};
}) ?? [];
const updateCard = api.card.update.useMutation({
onSuccess: async () => {
await utils.card.byId.refetch();
},
onError: () => {
showPopup({
header: "Unable to update card",
message: "Please try again later, or contact customer support.",
});
},
});
const { register, handleSubmit, setValue, watch } = useForm<FormValues>({
values: {
cardId: cardId ?? "",
title: data?.title ?? "",
description: data?.description ?? "",
},
});
const onSubmit = (values: FormValues) => {
updateCard.mutate({
cardPublicId: values.cardId,
title: values.title,
description: values.description,
});
};
if (!cardId) return <></>;
return (
<>
<PageHead
title={`${data?.title ?? "Card"} | ${board?.name ?? "Board"}`}
/>
<div className="flex h-full flex-1 flex-row">
<div className="flex h-full w-full flex-col overflow-hidden">
<div className="h-full max-h-[calc(100vh-4rem)] overflow-y-auto p-8">
<div className="mb-8 flex w-full items-center justify-between">
{isLoading ? (
<div className="flex space-x-2">
<div className="h-[2.3rem] w-[150px] animate-pulse rounded-[5px] bg-light-300 dark:bg-dark-300" />
<div className="h-[2.3rem] w-[300px] animate-pulse rounded-[5px] bg-light-300 dark:bg-dark-300" />
</div>
) : (
<>
<Link
className="whitespace-nowrap font-medium leading-[2.3rem] tracking-tight text-light-900 dark:text-dark-900 sm:text-[1.2rem]"
href={`/boards/${board?.publicId}`}
>
{board?.name}
</Link>
<IoChevronForwardSharp
size={18}
className="mx-2 text-light-900 dark:text-dark-900"
/>
<form
onSubmit={handleSubmit(onSubmit)}
className="w-full space-y-6"
>
<div>
<input
type="text"
id="title"
{...register("title")}
onBlur={handleSubmit(onSubmit)}
className="block w-full border-0 bg-transparent p-0 py-0 font-medium tracking-tight text-neutral-900 focus:ring-0 dark:text-dark-1000 sm:text-[1.2rem]"
/>
</div>
</form>
<div className="flex">
<Dropdown />
</div>
</>
)}
</div>
<div className="mb-10 flex w-full max-w-2xl justify-between">
<form
onSubmit={handleSubmit(onSubmit)}
className="w-full space-y-6"
>
<div className="mt-2">
<ContentEditable
placeholder="Add description..."
html={watch("description")}
disabled={false}
onChange={(e) => setValue("description", e.target.value)}
onBlur={handleSubmit(onSubmit)}
className="block w-full border-0 bg-transparent py-1.5 text-light-900 focus-visible:outline-none dark:text-dark-1000 sm:text-sm sm:leading-6"
/>
</div>
</form>
</div>
<div className="border-t-[1px] border-light-600 pt-12 dark:border-dark-400">
<h2 className="text-md pb-4 font-medium text-light-900 dark:text-dark-1000">
Activity
</h2>
<div>
<ActivityList
cardPublicId={cardId}
activities={activities ?? []}
isLoading={isLoading}
/>
</div>
<div className="mt-6">
<NewCommentForm cardPublicId={cardId} />
</div>
</div>
</div>
</div>
<div className="min-w-[325px] border-l-[1px] border-light-600 bg-light-200 p-8 text-light-900 dark:border-dark-400 dark:bg-dark-100 dark:text-dark-900">
<div className="mb-4 flex w-full">
<p className="my-2 w-[100px] text-sm">List</p>
<ListSelector
cardPublicId={cardId}
lists={formattedLists}
isLoading={isLoading}
/>
</div>
<div className="mb-4 flex w-full">
<p className="my-2 w-[100px] text-sm">Labels</p>
<LabelSelector
cardPublicId={cardId}
labels={formattedLabels}
isLoading={isLoading}
/>
</div>
<div className="flex w-full">
<p className="my-2 w-[100px] text-sm">Members</p>
<MemberSelector
cardPublicId={cardId}
members={formattedMembers}
isLoading={isLoading}
/>
</div>
</div>
<Modal>
{modalContentType === "NEW_LABEL" && (
<LabelForm cardPublicId={cardId} />
)}
{modalContentType === "EDIT_LABEL" && (
<LabelForm cardPublicId={cardId} isEdit />
)}
{modalContentType === "DELETE_LABEL" && (
<DeleteLabelConfirmation
cardPublicId={cardId}
labelPublicId={entityId}
/>
)}
{modalContentType === "DELETE_CARD" && (
<DeleteCardConfirmation
boardPublicId={boardId ?? ""}
cardPublicId={cardId}
/>
)}
{modalContentType === "NEW_WORKSPACE" && <NewWorkspaceForm />}
</Modal>
</div>
</>
);
}

View File

@@ -0,0 +1,155 @@
import { useState } from "react";
import Link from "next/link";
import LottieIcon from "~/components/LottieIcon";
import boardVisibilityIconLight from "~/assets/board-visibility-light.json";
import boardVisibilityIconDark from "~/assets/board-visibility-dark.json";
import membersIconLight from "~/assets/members-light.json";
import membersIconDark from "~/assets/members-dark.json";
import commentsIconLight from "~/assets/comments-light.json";
import commentsIconDark from "~/assets/comments-dark.json";
import integrationsIconLight from "~/assets/integrations-light.json";
import integrationsIconDark from "~/assets/integrations-dark.json";
import labelsIconLight from "~/assets/labels-light.json";
import labelsIconDark from "~/assets/labels-dark.json";
import importsIconLight from "~/assets/imports-light.json";
import importsIconDark from "~/assets/imports-dark.json";
import activityLogsIconLight from "~/assets/activity-logs-light.json";
import activityLogsIconDark from "~/assets/activity-logs-dark.json";
import templatesIconLight from "~/assets/templates-light.json";
import templatesIconDark from "~/assets/templates-dark.json";
const FeatureItem = ({
feature,
}: {
feature: {
title: string;
description: string;
icon: Record<string, unknown>;
comingSoon?: boolean;
};
}) => {
const [isHovered, setIsHovered] = useState(false);
const [index, setIndex] = useState(0);
const handleMouseEnter = () => {
setIsHovered(true);
setIndex((index) => index + 1);
};
return (
<div
onMouseEnter={handleMouseEnter}
className="group relative flex h-56 w-56 flex-col items-center justify-center overflow-hidden rounded-3xl border border-light-200 bg-light-50 dark:border-dark-200 dark:bg-dark-50"
>
<div className="absolute left-8 top-8 h-2 w-2 rounded-full bg-light-200 dark:bg-dark-200 " />
<div className="absolute right-8 top-8 h-2 w-2 rounded-full bg-light-200 dark:bg-dark-200" />
<div className="absolute bottom-8 left-8 h-2 w-2 rounded-full bg-light-200 dark:bg-dark-200" />
<div className="absolute bottom-8 right-8 h-2 w-2 rounded-full bg-light-200 dark:bg-dark-200" />
<div className="flex h-10 w-10 items-center justify-center rounded-xl border border-light-300 bg-light-200 dark:border-dark-600 dark:bg-dark-200">
<LottieIcon index={index} json={feature.icon} isPlaying={isHovered} />
</div>
<div className="relative mt-2 w-full px-4 text-center">
<p className="text-sm font-bold text-light-1000 transition-opacity duration-200 group-hover:opacity-0 dark:text-dark-1000">
{feature.title}
</p>
<p className="absolute inset-0 px-4 text-sm text-light-950 opacity-0 transition-opacity duration-200 group-hover:opacity-100 dark:text-dark-900">
{feature.description}
</p>
</div>
{feature.comingSoon && (
<div className="absolute right-4 top-4 rounded-full border border-light-300 px-2 py-1 text-[10px] text-light-1000 dark:border-dark-600 dark:bg-dark-50 dark:text-dark-900">
Coming soon
</div>
)}
</div>
);
};
const Features = ({ theme }: { theme: "light" | "dark" }) => {
const isDark = theme === "dark";
const features = [
{
title: "Board visibility",
description: "Control who can view and edit your boards.",
icon: isDark ? boardVisibilityIconDark : boardVisibilityIconLight,
},
{
title: "Workspace members",
description: "Collaborate seamlessly with your team.",
icon: isDark ? membersIconDark : membersIconLight,
},
{
title: "Trello imports",
description: "Import your Trello boards and hit the ground running.",
icon: isDark ? importsIconDark : importsIconLight,
},
{
title: "Labels & Filters",
description:
"Organize and find cards quickly with powerful filtering tools.",
icon: isDark ? labelsIconDark : labelsIconLight,
},
{
title: "Comments",
description: "Discuss and collaborate on cards.",
icon: isDark ? commentsIconDark : commentsIconLight,
},
{
title: "Activity logs",
description: "Track all card changes with detailed activity history.",
icon: isDark ? activityLogsIconDark : activityLogsIconLight,
},
{
title: "Templates",
description: "Save time with reusable board templates.",
icon: isDark ? templatesIconDark : templatesIconLight,
comingSoon: true,
},
{
title: "Integrations",
description: "Connect your favorite tools to streamline your workflow.",
icon: isDark ? integrationsIconDark : integrationsIconLight,
comingSoon: true,
},
];
return (
<>
<div className="flex flex-col items-center justify-center pb-24">
<div className="flex items-center gap-2 rounded-full border bg-light-50 px-4 py-1 text-center text-sm text-light-1000 dark:border-dark-300 dark:bg-dark-50 dark:text-dark-900">
<p>Features</p>
</div>
<p className="mt-2 text-center text-4xl font-bold text-light-1000 dark:text-dark-1000">
Kanban simplified
</p>
<p className="mt-3 max-w-[600px] text-center text-lg text-dark-900">
Simple, visual task management that just works. Drag and drop cards,
collaborate with your team, and get more done.
</p>
<div className="mt-16 grid grid-cols-4 gap-6 [mask-image:linear-gradient(to_bottom,black_80%,transparent_100%)]">
{features.map((feature, index) => {
return <FeatureItem key={`feature-${index}`} feature={feature} />;
})}
</div>
<div>
<div className="mt-8 flex items-center gap-2 rounded-full border bg-light-50 px-4 py-1 text-center text-sm text-light-1000 dark:border-dark-300 dark:bg-dark-50 dark:text-dark-900">
<p>
{`We're just getting started. `}
<Link href="/roadmap" className="underline">
View our roadmap.
</Link>
</p>
</div>
</div>
</div>
</>
);
};
export default Features;

View File

@@ -0,0 +1,143 @@
import Link from "next/link";
import { FaDiscord, FaGithub } from "react-icons/fa";
const navigation = {
documentation: [
{ name: "Getting started", href: "#" },
{ name: "Importing from Trello", href: "#" },
{ name: "API Reference", href: "#" },
],
company: [
{ name: "Roadmap", href: "/roadmap" },
{ name: "GitHub", href: "https://github.com/kanbn/kan" },
{ name: "Contact", href: "mailto:support@kan.bn" },
],
legal: [
{ name: "Terms of service", href: "/terms" },
{ name: "Privacy policy", href: "/privacy" },
{
name: "License",
href: "https://github.com/kanbn/kan?tab=GPL-3.0-1-ov-file#readme",
},
],
resources: [
{ name: "Features", href: "/#features" },
{ name: "Pricing", href: "/#pricing" },
{ name: "FAQs", href: "/#faq" },
],
};
const StatusMarker = () => (
<Link
href="https://openstatus.dev"
target="_blank"
rel="noopener noreferrer"
className="flex w-fit items-center gap-1.5 rounded-full border border-light-300 py-2 pl-3 pr-4 text-xs text-light-950 hover:bg-light-100 dark:border-dark-300 dark:text-dark-800 dark:hover:bg-dark-100"
>
<span className="relative mr-1 h-2 w-2">
<span className="absolute -inset-[1px] animate-[ping_1s_infinite] rounded-full bg-green-500/30"></span>
<span className="absolute inset-0 rounded-full bg-green-500"></span>
</span>
All systems operational
</Link>
);
const Footer = () => {
return (
<footer className="z-10 mt-20 w-full border-t border-light-300 border-light-300 bg-light-50 py-8 dark:border-dark-300 dark:bg-dark-50">
<div className="mx-auto max-w-7xl px-6 py-16 sm:py-24 lg:px-8 lg:py-24">
<div className="xl:grid xl:grid-cols-3 xl:gap-8">
<div>
<div className="mb-2 flex items-center gap-2">
<Link href="https://github.com/kanbn/kan" target="_blank">
<FaGithub className="h-8 w-8 rounded-lg border border-light-300 border-light-300 p-1.5 text-light-1000 hover:bg-light-100 dark:border-dark-300 dark:text-dark-1000 dark:hover:bg-dark-100" />
</Link>
<Link href="#" target="_blank">
<FaDiscord className="h-8 w-8 rounded-lg border border-light-300 border-light-300 p-1.5 text-light-1000 hover:bg-light-100 dark:border-dark-300 dark:text-dark-1000 dark:hover:bg-dark-100" />
</Link>
</div>
<StatusMarker />
</div>
<div className="mt-16 grid grid-cols-2 gap-8 xl:col-span-2 xl:mt-0">
<div className="md:grid md:grid-cols-2 md:gap-8">
<div>
<h3 className="text-sm/6 font-semibold text-light-1000 dark:text-dark-1000">
Documentation
</h3>
<ul role="list" className="mt-6 space-y-4">
{navigation.documentation.map((item) => (
<li key={item.name}>
<a
href={item.href}
className="text-sm/6 text-light-900 hover:text-light-1000 dark:text-dark-950 dark:hover:text-dark-1000"
>
{item.name}
</a>
</li>
))}
</ul>
</div>
<div>
<h3 className="text-sm/6 font-semibold text-light-1000 dark:text-dark-1000">
Company
</h3>
<ul role="list" className="mt-6 space-y-4">
{navigation.company.map((item) => (
<li key={item.name}>
<a
href={item.href}
className="text-sm/6 text-light-900 hover:text-light-1000 dark:text-dark-950 dark:hover:text-dark-1000"
>
{item.name}
</a>
</li>
))}
</ul>
</div>
</div>
<div className="md:grid md:grid-cols-2 md:gap-8">
<div>
<h3 className="text-sm/6 font-semibold text-light-1000 dark:text-dark-1000">
Resources
</h3>
<ul role="list" className="mt-6 space-y-4">
{navigation.resources.map((item) => (
<li key={item.name}>
<a
href={item.href}
className="text-sm/6 text-light-900 hover:text-light-1000 dark:text-dark-950 dark:hover:text-dark-1000"
>
{item.name}
</a>
</li>
))}
</ul>
</div>
<div>
<h3 className="text-sm/6 font-semibold text-light-1000 dark:text-dark-1000">
Legal
</h3>
<ul role="list" className="mt-6 space-y-4">
{navigation.legal.map((item) => (
<li key={item.name}>
<a
href={item.href}
className="text-sm/6 text-light-900 hover:text-light-1000 dark:text-dark-950 dark:hover:text-dark-1000"
>
{item.name}
</a>
</li>
))}
</ul>
</div>
</div>
</div>
</div>
</div>
</footer>
);
};
export default Footer;

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