feat: monorepo
This commit is contained in:
14
apps/web/eslint.config.js
Normal file
14
apps/web/eslint.config.js
Normal 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
19
apps/web/next.config.js
Normal 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
62
apps/web/package.json
Normal 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"
|
||||
}
|
||||
5
apps/web/postcss.config.cjs
Normal file
5
apps/web/postcss.config.cjs
Normal file
@@ -0,0 +1,5 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
},
|
||||
};
|
||||
BIN
apps/web/public/favicon.ico
Normal file
BIN
apps/web/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.7 KiB |
BIN
apps/web/public/hero-dark.png
Normal file
BIN
apps/web/public/hero-dark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 400 KiB |
BIN
apps/web/public/hero-light.png
Normal file
BIN
apps/web/public/hero-light.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 433 KiB |
4007
apps/web/src/assets/activity-logs-dark.json
Normal file
4007
apps/web/src/assets/activity-logs-dark.json
Normal file
File diff suppressed because it is too large
Load Diff
4007
apps/web/src/assets/activity-logs-light.json
Normal file
4007
apps/web/src/assets/activity-logs-light.json
Normal file
File diff suppressed because it is too large
Load Diff
7956
apps/web/src/assets/board-visibility-dark.json
Normal file
7956
apps/web/src/assets/board-visibility-dark.json
Normal file
File diff suppressed because it is too large
Load Diff
7956
apps/web/src/assets/board-visibility-light.json
Normal file
7956
apps/web/src/assets/board-visibility-light.json
Normal file
File diff suppressed because it is too large
Load Diff
7728
apps/web/src/assets/boards-dark.json
Normal file
7728
apps/web/src/assets/boards-dark.json
Normal file
File diff suppressed because it is too large
Load Diff
7732
apps/web/src/assets/boards-light.json
Normal file
7732
apps/web/src/assets/boards-light.json
Normal file
File diff suppressed because it is too large
Load Diff
3357
apps/web/src/assets/chat-dark.json
Normal file
3357
apps/web/src/assets/chat-dark.json
Normal file
File diff suppressed because it is too large
Load Diff
3361
apps/web/src/assets/chat-light.json
Normal file
3361
apps/web/src/assets/chat-light.json
Normal file
File diff suppressed because it is too large
Load Diff
6013
apps/web/src/assets/comments-dark.json
Normal file
6013
apps/web/src/assets/comments-dark.json
Normal file
File diff suppressed because it is too large
Load Diff
6017
apps/web/src/assets/comments-light.json
Normal file
6017
apps/web/src/assets/comments-light.json
Normal file
File diff suppressed because it is too large
Load Diff
3490
apps/web/src/assets/imports-dark.json
Normal file
3490
apps/web/src/assets/imports-dark.json
Normal file
File diff suppressed because it is too large
Load Diff
3490
apps/web/src/assets/imports-light.json
Normal file
3490
apps/web/src/assets/imports-light.json
Normal file
File diff suppressed because it is too large
Load Diff
2323
apps/web/src/assets/integrations-dark.json
Normal file
2323
apps/web/src/assets/integrations-dark.json
Normal file
File diff suppressed because it is too large
Load Diff
2323
apps/web/src/assets/integrations-light.json
Normal file
2323
apps/web/src/assets/integrations-light.json
Normal file
File diff suppressed because it is too large
Load Diff
1675
apps/web/src/assets/labels-dark.json
Normal file
1675
apps/web/src/assets/labels-dark.json
Normal file
File diff suppressed because it is too large
Load Diff
1675
apps/web/src/assets/labels-light.json
Normal file
1675
apps/web/src/assets/labels-light.json
Normal file
File diff suppressed because it is too large
Load Diff
5881
apps/web/src/assets/members-dark.json
Normal file
5881
apps/web/src/assets/members-dark.json
Normal file
File diff suppressed because it is too large
Load Diff
5885
apps/web/src/assets/members-light.json
Normal file
5885
apps/web/src/assets/members-light.json
Normal file
File diff suppressed because it is too large
Load Diff
4367
apps/web/src/assets/settings-dark.json
Normal file
4367
apps/web/src/assets/settings-dark.json
Normal file
File diff suppressed because it is too large
Load Diff
4374
apps/web/src/assets/settings-light.json
Normal file
4374
apps/web/src/assets/settings-light.json
Normal file
File diff suppressed because it is too large
Load Diff
6287
apps/web/src/assets/templates-dark.json
Normal file
6287
apps/web/src/assets/templates-dark.json
Normal file
File diff suppressed because it is too large
Load Diff
6291
apps/web/src/assets/templates-light.json
Normal file
6291
apps/web/src/assets/templates-light.json
Normal file
File diff suppressed because it is too large
Load Diff
100
apps/web/src/components/AuthForm.tsx
Normal file
100
apps/web/src/components/AuthForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
48
apps/web/src/components/Avatar.tsx
Normal file
48
apps/web/src/components/Avatar.tsx
Normal 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;
|
||||
87
apps/web/src/components/Button.tsx
Normal file
87
apps/web/src/components/Button.tsx
Normal 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;
|
||||
166
apps/web/src/components/CheckboxDropdown.tsx
Normal file
166
apps/web/src/components/CheckboxDropdown.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
37
apps/web/src/components/Dashboard.tsx
Normal file
37
apps/web/src/components/Dashboard.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
46
apps/web/src/components/Dropdown.tsx
Normal file
46
apps/web/src/components/Dropdown.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
34
apps/web/src/components/FeedbackButton.tsx
Normal file
34
apps/web/src/components/FeedbackButton.tsx
Normal 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;
|
||||
44
apps/web/src/components/Input.tsx
Normal file
44
apps/web/src/components/Input.tsx
Normal 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;
|
||||
36
apps/web/src/components/LoadingSpinner.tsx
Normal file
36
apps/web/src/components/LoadingSpinner.tsx
Normal 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;
|
||||
22
apps/web/src/components/LottieIcon.tsx
Normal file
22
apps/web/src/components/LottieIcon.tsx
Normal 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;
|
||||
82
apps/web/src/components/NewWorkspaceForm.tsx
Normal file
82
apps/web/src/components/NewWorkspaceForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
9
apps/web/src/components/PageHead.tsx
Normal file
9
apps/web/src/components/PageHead.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import Head from "next/head";
|
||||
|
||||
export const PageHead = ({ title }: { title: string }) => {
|
||||
return (
|
||||
<Head>
|
||||
<title>{title}</title>
|
||||
</Head>
|
||||
);
|
||||
};
|
||||
25
apps/web/src/components/PatternedBackground.tsx
Normal file
25
apps/web/src/components/PatternedBackground.tsx
Normal 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;
|
||||
76
apps/web/src/components/Popup.tsx
Normal file
76
apps/web/src/components/Popup.tsx
Normal 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;
|
||||
39
apps/web/src/components/ReactiveButton.tsx
Normal file
39
apps/web/src/components/ReactiveButton.tsx
Normal 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;
|
||||
77
apps/web/src/components/SideNavigation.tsx
Normal file
77
apps/web/src/components/SideNavigation.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
37
apps/web/src/components/Toggle.tsx
Normal file
37
apps/web/src/components/Toggle.tsx
Normal 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;
|
||||
142
apps/web/src/components/UserMenu.tsx
Normal file
142
apps/web/src/components/UserMenu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
87
apps/web/src/components/WorkspaceMenu.tsx
Normal file
87
apps/web/src/components/WorkspaceMenu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
59
apps/web/src/components/modal.tsx
Normal file
59
apps/web/src/components/modal.tsx
Normal 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
37
apps/web/src/env.ts
Normal 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",
|
||||
});
|
||||
50
apps/web/src/pages/_app.tsx
Normal file
50
apps/web/src/pages/_app.tsx
Normal 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);
|
||||
90
apps/web/src/pages/api/auth/confirm.ts
Normal file
90
apps/web/src/pages/api/auth/confirm.ts
Normal 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";
|
||||
26
apps/web/src/pages/api/trpc/[trpc].ts
Normal file
26
apps/web/src/pages/api/trpc/[trpc].ts
Normal 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";
|
||||
31
apps/web/src/pages/api/v1/[...trpc].ts
Normal file
31
apps/web/src/pages/api/v1/[...trpc].ts
Normal 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);
|
||||
}
|
||||
9
apps/web/src/pages/api/v1/openapi.json.ts
Normal file
9
apps/web/src/pages/api/v1/openapi.json.ts
Normal 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;
|
||||
15
apps/web/src/pages/boards/[...boardId]/index.tsx
Normal file
15
apps/web/src/pages/boards/[...boardId]/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
15
apps/web/src/pages/boards/index.tsx
Normal file
15
apps/web/src/pages/boards/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
15
apps/web/src/pages/cards/[cardId]/index.tsx
Normal file
15
apps/web/src/pages/cards/[cardId]/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
5
apps/web/src/pages/index.tsx
Normal file
5
apps/web/src/pages/index.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import HomeView from "~/views/home";
|
||||
|
||||
export default function Home() {
|
||||
return <HomeView />;
|
||||
}
|
||||
5
apps/web/src/pages/login/index.tsx
Normal file
5
apps/web/src/pages/login/index.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import LoginView from "~/views/auth/login";
|
||||
|
||||
export default function LoginPage() {
|
||||
return <LoginView />;
|
||||
}
|
||||
15
apps/web/src/pages/members/index.tsx
Normal file
15
apps/web/src/pages/members/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
15
apps/web/src/pages/settings/index.tsx
Normal file
15
apps/web/src/pages/settings/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
5
apps/web/src/pages/signup/index.tsx
Normal file
5
apps/web/src/pages/signup/index.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import SignupView from "~/views/auth/signup";
|
||||
|
||||
export default function SignupPage() {
|
||||
return <SignupView />;
|
||||
}
|
||||
210
apps/web/src/providers/board.tsx
Normal file
210
apps/web/src/providers/board.tsx
Normal 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;
|
||||
};
|
||||
65
apps/web/src/providers/modal.tsx
Normal file
65
apps/web/src/providers/modal.tsx
Normal 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;
|
||||
};
|
||||
53
apps/web/src/providers/popup.tsx
Normal file
53
apps/web/src/providers/popup.tsx
Normal 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;
|
||||
};
|
||||
69
apps/web/src/providers/theme.tsx
Normal file
69
apps/web/src/providers/theme.tsx
Normal 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;
|
||||
};
|
||||
114
apps/web/src/providers/workspace.tsx
Normal file
114
apps/web/src/providers/workspace.tsx
Normal 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;
|
||||
};
|
||||
26
apps/web/src/styles/globals.css
Normal file
26
apps/web/src/styles/globals.css
Normal 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
31
apps/web/src/types/contenteditable.d.ts
vendored
Normal 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
78
apps/web/src/utils/api.ts
Normal 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>;
|
||||
30
apps/web/src/utils/helpers.ts
Normal file
30
apps/web/src/utils/helpers.ts
Normal 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("");
|
||||
};
|
||||
12
apps/web/src/utils/supabase/client.ts
Normal file
12
apps/web/src/utils/supabase/client.ts
Normal 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;
|
||||
}
|
||||
56
apps/web/src/views/auth/login/index.tsx
Normal file
56
apps/web/src/views/auth/login/index.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
55
apps/web/src/views/auth/signup/index.tsx
Normal file
55
apps/web/src/views/auth/signup/index.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
44
apps/web/src/views/board/components/BoardDropdown.tsx
Normal file
44
apps/web/src/views/board/components/BoardDropdown.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
139
apps/web/src/views/board/components/Filters.tsx
Normal file
139
apps/web/src/views/board/components/Filters.tsx
Normal 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;
|
||||
135
apps/web/src/views/board/components/List.tsx
Normal file
135
apps/web/src/views/board/components/List.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
300
apps/web/src/views/board/components/NewCardForm.tsx
Normal file
300
apps/web/src/views/board/components/NewCardForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
105
apps/web/src/views/board/components/NewListForm.tsx
Normal file
105
apps/web/src/views/board/components/NewListForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
319
apps/web/src/views/board/index.tsx
Normal file
319
apps/web/src/views/board/index.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
40
apps/web/src/views/boards/components/BoardsList.tsx
Normal file
40
apps/web/src/views/boards/components/BoardsList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
238
apps/web/src/views/boards/components/ImportBoardsForm.tsx
Normal file
238
apps/web/src/views/boards/components/ImportBoardsForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
70
apps/web/src/views/boards/components/NewBoardForm.tsx
Normal file
70
apps/web/src/views/boards/components/NewBoardForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
67
apps/web/src/views/boards/index.tsx
Normal file
67
apps/web/src/views/boards/index.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
221
apps/web/src/views/card/components/ActivityList.tsx
Normal file
221
apps/web/src/views/card/components/ActivityList.tsx
Normal 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;
|
||||
146
apps/web/src/views/card/components/Comment.tsx
Normal file
146
apps/web/src/views/card/components/Comment.tsx
Normal 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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
44
apps/web/src/views/card/components/Dropdown.tsx
Normal file
44
apps/web/src/views/card/components/Dropdown.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
229
apps/web/src/views/card/components/LabelForm.tsx
Normal file
229
apps/web/src/views/card/components/LabelForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
165
apps/web/src/views/card/components/LabelSelector.tsx
Normal file
165
apps/web/src/views/card/components/LabelSelector.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
114
apps/web/src/views/card/components/ListSelector.tsx
Normal file
114
apps/web/src/views/card/components/ListSelector.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
141
apps/web/src/views/card/components/MemberSelector.tsx
Normal file
141
apps/web/src/views/card/components/MemberSelector.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
72
apps/web/src/views/card/components/NewCommentForm.tsx
Normal file
72
apps/web/src/views/card/components/NewCommentForm.tsx
Normal 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;
|
||||
246
apps/web/src/views/card/index.tsx
Normal file
246
apps/web/src/views/card/index.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
155
apps/web/src/views/home/components/Features.tsx
Normal file
155
apps/web/src/views/home/components/Features.tsx
Normal 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;
|
||||
143
apps/web/src/views/home/components/Footer.tsx
Normal file
143
apps/web/src/views/home/components/Footer.tsx
Normal 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
Reference in New Issue
Block a user