Compare commits
18 Commits
feat/serve
...
feat/impro
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac9bdaf316 | ||
|
|
031a42b9eb | ||
|
|
8a1189d011 | ||
|
|
53f6ada4d3 | ||
|
|
6ea02b1db1 | ||
|
|
737b1383d0 | ||
|
|
38d0e9495c | ||
|
|
19798ddbc9 | ||
|
|
08ba965921 | ||
|
|
008b5fd1a0 | ||
|
|
97cfd60b59 | ||
|
|
24e283e22c | ||
|
|
07db7409cb | ||
|
|
346ddc16be | ||
|
|
f9eee53ed0 | ||
|
|
1ab4e24c96 | ||
|
|
34165b390f | ||
|
|
ef0d53db8f |
12
README.md
12
README.md
@@ -47,7 +47,17 @@ See our [roadmap](https://kan.bn/kan/roadmap) for upcoming features.
|
||||
|
||||
## Self Hosting 🐳
|
||||
|
||||
The easiest way to self-host Kan is with Docker Compose. This will set up everything for you including your postgres database.
|
||||
### One-click Deployments
|
||||
|
||||
The easiest way to deploy Kan is through Railway. We've partnered with Railway to maintain an official template that supports the development of the project.
|
||||
|
||||
<a href="https://railway.com/deploy/kan?referralCode=bZPsr2&utm_medium=integration&utm_source=template&utm_campaign=generic">
|
||||
<img src="https://railway.app/button.svg" alt="Deploy on Railway" height="40" />
|
||||
</a>
|
||||
|
||||
### Docker Compose
|
||||
|
||||
Alternatively, you can self-host Kan with Docker Compose. This will set up everything for you including your postgres database.
|
||||
|
||||
1. Create a new file called `docker-compose.yml` and paste the following configuration:
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.802.0",
|
||||
"@aws-sdk/lib-storage": "^3.802.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.812.0",
|
||||
"@headlessui/react": "^2.2.0",
|
||||
"@hookform/resolvers": "^3.3.4",
|
||||
@@ -38,6 +39,7 @@
|
||||
"@tiptap/extension-link": "^2.22.2",
|
||||
"@tiptap/extension-mention": "^3.0.9",
|
||||
"@tiptap/extension-placeholder": "^2.14.0",
|
||||
"@tiptap/extension-typography": "^3.18.0",
|
||||
"@tiptap/pm": "^2.14.0",
|
||||
"@tiptap/react": "^2.14.0",
|
||||
"@tiptap/starter-kit": "^2.14.0",
|
||||
|
||||
@@ -361,8 +361,8 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
{!isCredentialsEnabled && socialProviders?.length === 0 && (
|
||||
{!(isCredentialsEnabled || isMagicLinkAvailable) &&
|
||||
socialProviders?.length === 0 && (
|
||||
<div className="flex w-full items-center gap-4">
|
||||
<div className="h-[1px] w-1/3 bg-light-600 dark:bg-dark-600" />
|
||||
<span className="text-center text-sm text-light-900 dark:text-dark-900">
|
||||
@@ -371,65 +371,62 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
|
||||
<div className="h-[1px] w-1/3 bg-light-600 dark:bg-dark-600" />
|
||||
</div>
|
||||
)}
|
||||
{!isCredentialsEnabled && socialProviders?.length !== 0 && (
|
||||
<div className="mb-[1.5rem] flex w-full items-center gap-4">
|
||||
<div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" />
|
||||
<span className="text-sm text-light-900 dark:text-dark-900">
|
||||
{t`or`}
|
||||
</span>
|
||||
<div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" />
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
{isSignUp && isCredentialsEnabled && (
|
||||
<div>
|
||||
<Input
|
||||
{...register("name", { required: true })}
|
||||
placeholder={t`Enter your name`}
|
||||
/>
|
||||
{errors.name && (
|
||||
<p className="mt-2 text-xs text-red-400">
|
||||
{t`Please enter a valid name`}
|
||||
</p>
|
||||
)}
|
||||
{(isCredentialsEnabled || isMagicLinkAvailable) && (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
{socialProviders?.length !== 0 && (
|
||||
<div className="mb-[1.5rem] flex w-full items-center gap-4">
|
||||
<div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" />
|
||||
<span className="text-sm text-light-900 dark:text-dark-900">
|
||||
{t`or`}
|
||||
</span>
|
||||
<div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" />
|
||||
</div>
|
||||
)}
|
||||
{(isCredentialsEnabled || isMagicLinkAvailable) && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
{isSignUp && isCredentialsEnabled && (
|
||||
<div>
|
||||
<Input
|
||||
{...register("email", { required: true })}
|
||||
placeholder={t`Enter your email address`}
|
||||
{...register("name", { required: true })}
|
||||
placeholder={t`Enter your name`}
|
||||
/>
|
||||
{errors.email && (
|
||||
{errors.name && (
|
||||
<p className="mt-2 text-xs text-red-400">
|
||||
{t`Please enter a valid email address`}
|
||||
{t`Please enter a valid name`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isCredentialsEnabled && (
|
||||
<div>
|
||||
<Input
|
||||
type="password"
|
||||
{...register("password", { required: true })}
|
||||
placeholder={t`Enter your password`}
|
||||
/>
|
||||
{errors.password && (
|
||||
<p className="mt-2 text-xs text-red-400">
|
||||
{errors.password.message ??
|
||||
t`Please enter a valid password`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<Input
|
||||
{...register("email", { required: true })}
|
||||
placeholder={t`Enter your email address`}
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="mt-2 text-xs text-red-400">
|
||||
{t`Please enter a valid email address`}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{loginError && (
|
||||
<p className="mt-2 text-xs text-red-400">{loginError}</p>
|
||||
)}
|
||||
</div>
|
||||
{(isCredentialsEnabled || isMagicLinkAvailable) && (
|
||||
</div>
|
||||
|
||||
{isCredentialsEnabled && (
|
||||
<div>
|
||||
<Input
|
||||
type="password"
|
||||
{...register("password", { required: true })}
|
||||
placeholder={t`Enter your password`}
|
||||
/>
|
||||
{errors.password && (
|
||||
<p className="mt-2 text-xs text-red-400">
|
||||
{errors.password.message ??
|
||||
t`Please enter a valid password`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{loginError && (
|
||||
<p className="mt-2 text-xs text-red-400">{loginError}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-[1.5rem] flex items-center gap-4">
|
||||
<Button
|
||||
isLoading={isLoginWithEmailPending}
|
||||
@@ -441,8 +438,11 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
|
||||
{isMagicLinkMode ? t`magic link` : t`email`}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</form>
|
||||
)}
|
||||
{!(isCredentialsEnabled || isMagicLinkAvailable) && loginError && (
|
||||
<p className="mt-2 text-xs text-red-400">{loginError}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ const Avatar = ({
|
||||
icon?: React.ReactNode;
|
||||
isLoading?: boolean;
|
||||
}) => {
|
||||
const initials = name
|
||||
const initials = name?.trim()
|
||||
? getInitialsFromName(name)
|
||||
: inferInitialsFromEmail(email);
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import { authClient } from "@kan/auth/client";
|
||||
import { useClickOutside } from "~/hooks/useClickOutside";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { useWorkspace, WorkspaceProvider } from "~/providers/workspace";
|
||||
import { api } from "~/utils/api";
|
||||
import SideNavigation from "./SideNavigation";
|
||||
|
||||
interface DashboardProps {
|
||||
@@ -44,6 +45,12 @@ export default function Dashboard({
|
||||
const { availableWorkspaces, hasLoaded } = useWorkspace();
|
||||
|
||||
const { data: session, isPending: sessionLoading } = authClient.useSession();
|
||||
const { data: user, isLoading: userLoading } = api.user.getUser.useQuery(
|
||||
undefined,
|
||||
{
|
||||
enabled: !!session?.user,
|
||||
},
|
||||
);
|
||||
|
||||
const [isSideNavOpen, setIsSideNavOpen] = useState(false);
|
||||
const [isRightPanelOpen, setIsRightPanelOpen] = useState(false);
|
||||
@@ -155,8 +162,12 @@ export default function Dashboard({
|
||||
className={`fixed top-12 z-40 h-[calc(100dvh-3rem)] w-[calc(100vw-1.5rem)] transform transition-transform duration-300 ease-in-out md:relative md:top-0 md:h-full md:w-auto md:translate-x-0 ${isSideNavOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"} `}
|
||||
>
|
||||
<SideNavigation
|
||||
user={{ displayName: session?.user.name, email: session?.user.email, image: session?.user.image }}
|
||||
isLoading={sessionLoading}
|
||||
user={{
|
||||
displayName: user?.name ?? session?.user.name,
|
||||
email: user?.email ?? session?.user.email ?? "",
|
||||
image: user?.image ?? undefined,
|
||||
}}
|
||||
isLoading={sessionLoading || userLoading}
|
||||
onCloseSideNav={closeSideNav}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
ReactRenderer,
|
||||
useEditor,
|
||||
} from "@tiptap/react";
|
||||
import Typography from "@tiptap/extension-typography";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import Suggestion from "@tiptap/suggestion";
|
||||
import {
|
||||
@@ -386,46 +387,54 @@ export interface SlashNodeAttrs {
|
||||
label?: string | null;
|
||||
}
|
||||
|
||||
const CommandItems: SlashCommandItem[] = [
|
||||
{
|
||||
title: "Heading 1",
|
||||
icon: <HiH1 />,
|
||||
command: ({ editor }) =>
|
||||
editor.chain().focus().setHeading({ level: 1 }).run(),
|
||||
},
|
||||
{
|
||||
title: "Heading 2",
|
||||
icon: <HiH2 />,
|
||||
command: ({ editor }) =>
|
||||
editor.chain().focus().setHeading({ level: 2 }).run(),
|
||||
},
|
||||
{
|
||||
title: "Heading 3",
|
||||
icon: <HiH3 />,
|
||||
command: ({ editor }) =>
|
||||
editor.chain().focus().setHeading({ level: 3 }).run(),
|
||||
},
|
||||
{
|
||||
title: "Bullet List",
|
||||
icon: <HiOutlineListBullet />,
|
||||
command: ({ editor }) => editor.chain().focus().toggleBulletList().run(),
|
||||
},
|
||||
{
|
||||
title: "Ordered List",
|
||||
icon: <HiOutlineNumberedList />,
|
||||
command: ({ editor }) => editor.chain().focus().toggleOrderedList().run(),
|
||||
},
|
||||
{
|
||||
title: "Blockquote",
|
||||
icon: <HiOutlineChatBubbleLeftEllipsis />,
|
||||
command: ({ editor }) => editor.chain().focus().toggleBlockquote().run(),
|
||||
},
|
||||
{
|
||||
title: "Code Block",
|
||||
icon: <HiOutlineCodeBracketSquare />,
|
||||
command: ({ editor }) => editor.chain().focus().toggleCodeBlock().run(),
|
||||
},
|
||||
];
|
||||
const getCommandItems = (disableHeadings: boolean): SlashCommandItem[] => {
|
||||
const headingCommands: SlashCommandItem[] = disableHeadings
|
||||
? []
|
||||
: [
|
||||
{
|
||||
title: "Heading 1",
|
||||
icon: <HiH1 />,
|
||||
command: ({ editor }) =>
|
||||
editor.chain().focus().setHeading({ level: 1 }).run(),
|
||||
},
|
||||
{
|
||||
title: "Heading 2",
|
||||
icon: <HiH2 />,
|
||||
command: ({ editor }) =>
|
||||
editor.chain().focus().setHeading({ level: 2 }).run(),
|
||||
},
|
||||
{
|
||||
title: "Heading 3",
|
||||
icon: <HiH3 />,
|
||||
command: ({ editor }) =>
|
||||
editor.chain().focus().setHeading({ level: 3 }).run(),
|
||||
},
|
||||
];
|
||||
|
||||
return [
|
||||
...headingCommands,
|
||||
{
|
||||
title: "Bullet List",
|
||||
icon: <HiOutlineListBullet />,
|
||||
command: ({ editor }) => editor.chain().focus().toggleBulletList().run(),
|
||||
},
|
||||
{
|
||||
title: "Ordered List",
|
||||
icon: <HiOutlineNumberedList />,
|
||||
command: ({ editor }) => editor.chain().focus().toggleOrderedList().run(),
|
||||
},
|
||||
{
|
||||
title: "Blockquote",
|
||||
icon: <HiOutlineChatBubbleLeftEllipsis />,
|
||||
command: ({ editor }) => editor.chain().focus().toggleBlockquote().run(),
|
||||
},
|
||||
{
|
||||
title: "Code Block",
|
||||
icon: <HiOutlineCodeBracketSquare />,
|
||||
command: ({ editor }) => editor.chain().focus().toggleCodeBlock().run(),
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
export default function Editor({
|
||||
content,
|
||||
@@ -434,6 +443,8 @@ export default function Editor({
|
||||
readOnly = false,
|
||||
workspaceMembers,
|
||||
enableYouTubeEmbed = true,
|
||||
placeholder,
|
||||
disableHeadings = false,
|
||||
}: {
|
||||
content: string | null;
|
||||
onChange?: (value: string) => void;
|
||||
@@ -441,18 +452,23 @@ export default function Editor({
|
||||
readOnly?: boolean;
|
||||
workspaceMembers: WorkspaceMember[];
|
||||
enableYouTubeEmbed?: boolean;
|
||||
placeholder?: string;
|
||||
disableHeadings?: boolean;
|
||||
}) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const editor = useEditor(
|
||||
{
|
||||
extensions: [
|
||||
StarterKit,
|
||||
StarterKit.configure({
|
||||
heading: disableHeadings ? false : undefined,
|
||||
}),
|
||||
Markdown,
|
||||
Placeholder.configure({
|
||||
placeholder: readOnly
|
||||
? ""
|
||||
: t`Add description... (type '/' to open commands or '@' to mention)`,
|
||||
: placeholder ??
|
||||
t`Add description... (type '/' to open commands or '@' to mention)`,
|
||||
}),
|
||||
Link.configure({
|
||||
openOnClick: true,
|
||||
@@ -465,10 +481,10 @@ export default function Editor({
|
||||
autolink: true,
|
||||
}),
|
||||
SlashCommands.configure({
|
||||
commandItems: CommandItems,
|
||||
commandItems: getCommandItems(disableHeadings),
|
||||
suggestion: {
|
||||
items: ({ query }: { query: string }) =>
|
||||
filterSlashCommandItems(CommandItems, query),
|
||||
filterSlashCommandItems(getCommandItems(disableHeadings), query),
|
||||
startOfLine: true,
|
||||
char: "/",
|
||||
},
|
||||
@@ -480,20 +496,28 @@ export default function Editor({
|
||||
suggestion: {
|
||||
char: "@",
|
||||
items: ({ query }: { query: string }) => {
|
||||
const all: MentionItem[] = workspaceMembers.map(
|
||||
(member: WorkspaceMember) => ({
|
||||
id: member.publicId,
|
||||
label: member?.user?.name ?? member.email,
|
||||
image: member?.user?.image ?? null,
|
||||
}),
|
||||
const withEmail = workspaceMembers.filter((member) => member.email);
|
||||
|
||||
const mapped = withEmail.map((member: WorkspaceMember) => ({
|
||||
id: member.publicId,
|
||||
label: member?.user?.name?.trim() || member.email || "",
|
||||
image: member?.user?.image ?? null,
|
||||
}));
|
||||
|
||||
const all: MentionItem[] = mapped.filter(
|
||||
(item) => item.label && item.label.length > 0,
|
||||
);
|
||||
const q = query.toLowerCase();
|
||||
return all.filter(
|
||||
(u) =>
|
||||
u.label &&
|
||||
typeof u.label === "string" &&
|
||||
u.label.toLowerCase().includes(q),
|
||||
|
||||
const q = query.toLowerCase().trim();
|
||||
|
||||
if (q === "") {
|
||||
return all;
|
||||
}
|
||||
|
||||
const filtered = all.filter((u) =>
|
||||
u.label.toLowerCase().includes(q),
|
||||
);
|
||||
return filtered;
|
||||
},
|
||||
command: ({ editor, range, props }) => {
|
||||
const id = props.id ?? "";
|
||||
@@ -514,6 +538,17 @@ export default function Editor({
|
||||
return `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`;
|
||||
},
|
||||
}),
|
||||
Typography.configure({
|
||||
openDoubleQuote: false,
|
||||
closeDoubleQuote: false,
|
||||
openSingleQuote: false,
|
||||
closeSingleQuote: false,
|
||||
oneHalf: false,
|
||||
oneQuarter: false,
|
||||
threeQuarters: false,
|
||||
superscriptTwo: false,
|
||||
superscriptThree: false,
|
||||
}),
|
||||
...(enableYouTubeEmbed ? [YouTubeNode] : []),
|
||||
],
|
||||
content,
|
||||
|
||||
@@ -7,7 +7,7 @@ import tippy from "tippy.js";
|
||||
|
||||
interface TooltipProps {
|
||||
children: ReactNode;
|
||||
content: ReactNode;
|
||||
content?: ReactNode;
|
||||
placement?: Placement;
|
||||
delay?: number | [number, number];
|
||||
}
|
||||
@@ -24,6 +24,8 @@ export function Tooltip({
|
||||
useEffect(() => {
|
||||
if (!triggerRef.current) return;
|
||||
|
||||
if (!content) return;
|
||||
|
||||
const container = document.createElement("div");
|
||||
const root = createRoot(container);
|
||||
rootRef.current = root;
|
||||
|
||||
@@ -13,6 +13,23 @@ const authHandler = toNodeHandler(auth.handler);
|
||||
export default withRateLimit(
|
||||
{ points: 100, duration: 60 },
|
||||
async (req, res) => {
|
||||
/**
|
||||
* Better-auth behind proxies (Nginx/Cloudflare) can sometimes fail to parse the protocol
|
||||
* if headers are incorrectly set or if there are multiple values in X-Forwarded-Proto.
|
||||
* We sanitize these headers here to ensure better-auth gets a clean protocol and host.
|
||||
*/
|
||||
const forwardedProto = req.headers["x-forwarded-proto"];
|
||||
if (forwardedProto) {
|
||||
const p = Array.isArray(forwardedProto) ? forwardedProto[0] : forwardedProto;
|
||||
req.headers["x-forwarded-proto"] = p?.split(",")[0]?.trim();
|
||||
}
|
||||
|
||||
const forwardedHost = req.headers["x-forwarded-host"];
|
||||
if (forwardedHost) {
|
||||
const h = Array.isArray(forwardedHost) ? forwardedHost[0] : forwardedHost;
|
||||
req.headers["host"] = h?.split(",")[0]?.trim();
|
||||
}
|
||||
|
||||
return await authHandler(req, res);
|
||||
},
|
||||
);
|
||||
|
||||
136
apps/web/src/pages/api/upload/attachment.ts
Normal file
136
apps/web/src/pages/api/upload/attachment.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { Upload } from "@aws-sdk/lib-storage";
|
||||
|
||||
import { createNextApiContext } from "@kan/api/trpc";
|
||||
import { assertPermission } from "@kan/api/utils/permissions";
|
||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||
import * as cardRepo from "@kan/db/repository/card.repo";
|
||||
import * as cardActivityRepo from "@kan/db/repository/cardActivity.repo";
|
||||
import * as cardAttachmentRepo from "@kan/db/repository/cardAttachment.repo";
|
||||
import { createS3Client, generateUID } from "@kan/shared/utils";
|
||||
|
||||
import { env } from "~/env";
|
||||
|
||||
// FIXME: Respect the environment variable: NEXT_API_BODY_SIZE_LIMIT
|
||||
const MAX_SIZE_BYTES = 50 * 1024 * 1024; // 50MB
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
bodyParser: false,
|
||||
},
|
||||
};
|
||||
|
||||
export default withRateLimit(
|
||||
{ points: 100, duration: 60 },
|
||||
async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
if (req.method !== "POST") {
|
||||
return res.status(405).json({ error: "Method not allowed" });
|
||||
}
|
||||
|
||||
try {
|
||||
const { user, db } = await createNextApiContext(req);
|
||||
|
||||
if (!user) {
|
||||
return res.status(401).json({ error: "Unauthorized" });
|
||||
}
|
||||
|
||||
const bucket = env.NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME;
|
||||
if (!bucket) {
|
||||
return res.status(500).json({ error: "Attachments bucket not configured" });
|
||||
}
|
||||
|
||||
const cardPublicId = req.query.cardPublicId;
|
||||
if (typeof cardPublicId !== "string" || cardPublicId.length < 12) {
|
||||
return res.status(400).json({ error: "Invalid cardPublicId" });
|
||||
}
|
||||
|
||||
const contentType = req.headers["content-type"];
|
||||
const contentLengthHeader = req.headers["content-length"];
|
||||
const contentLength = contentLengthHeader
|
||||
? Number.parseInt(contentLengthHeader, 10)
|
||||
: NaN;
|
||||
|
||||
if (typeof contentType !== "string") {
|
||||
return res.status(400).json({ error: "Missing content type" });
|
||||
}
|
||||
|
||||
if (!Number.isFinite(contentLength) || contentLength <= 0) {
|
||||
return res.status(400).json({ error: "Missing or invalid content length" });
|
||||
}
|
||||
|
||||
if (contentLength > MAX_SIZE_BYTES) {
|
||||
return res.status(400).json({ error: "File too large" });
|
||||
}
|
||||
|
||||
const originalFilenameHeader =
|
||||
(req.headers["x-original-filename"] as string | undefined) ?? "file";
|
||||
|
||||
const sanitizedFilename = originalFilenameHeader
|
||||
.replace(/[^a-zA-Z0-9._-]/g, "_")
|
||||
.substring(0, 200);
|
||||
|
||||
// Get card and check permissions
|
||||
const card = await cardRepo.getWorkspaceAndCardIdByCardPublicId(
|
||||
db,
|
||||
cardPublicId,
|
||||
);
|
||||
|
||||
if (!card) {
|
||||
return res.status(404).json({ error: "Card not found" });
|
||||
}
|
||||
|
||||
// Check if user has permission to edit the card
|
||||
try {
|
||||
await assertPermission(db, user.id, card.workspaceId, "card:edit");
|
||||
} catch {
|
||||
return res.status(403).json({ error: "Permission denied" });
|
||||
}
|
||||
|
||||
const s3Key = `${card.workspaceId}/${cardPublicId}/${generateUID()}-${sanitizedFilename}`;
|
||||
|
||||
const client = createS3Client();
|
||||
|
||||
const upload = new Upload({
|
||||
client,
|
||||
params: {
|
||||
Bucket: bucket,
|
||||
Key: s3Key,
|
||||
Body: req,
|
||||
ContentType: contentType,
|
||||
ContentLength: contentLength,
|
||||
},
|
||||
leavePartsOnError: false,
|
||||
});
|
||||
|
||||
await upload.done();
|
||||
|
||||
// Create attachment record and log activity
|
||||
const attachment = await cardAttachmentRepo.create(db, {
|
||||
cardId: card.id,
|
||||
filename: sanitizedFilename,
|
||||
originalFilename: originalFilenameHeader,
|
||||
contentType,
|
||||
size: contentLength,
|
||||
s3Key,
|
||||
createdBy: user.id,
|
||||
});
|
||||
|
||||
if (!attachment) {
|
||||
return res.status(500).json({ error: "Failed to create attachment" });
|
||||
}
|
||||
|
||||
await cardActivityRepo.create(db, {
|
||||
type: "card.updated.attachment.added",
|
||||
cardId: card.id,
|
||||
attachmentId: attachment.id,
|
||||
toTitle: originalFilenameHeader,
|
||||
createdBy: user.id,
|
||||
});
|
||||
|
||||
return res.status(200).json({ attachment });
|
||||
} catch (error) {
|
||||
console.error("Attachment upload failed", error);
|
||||
return res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
},
|
||||
);
|
||||
101
apps/web/src/pages/api/upload/avatar.ts
Normal file
101
apps/web/src/pages/api/upload/avatar.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { PutObjectCommand } from "@aws-sdk/client-s3";
|
||||
|
||||
import { createNextApiContext } from "@kan/api/trpc";
|
||||
import * as userRepo from "@kan/db/repository/user.repo";
|
||||
|
||||
import { env } from "~/env";
|
||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||
import { createS3Client } from "@kan/shared/utils";
|
||||
|
||||
const MAX_SIZE_BYTES = 2 * 1024 * 1024; // 2MB
|
||||
const allowedContentTypes = ["image/jpeg", "image/png", "image/webp"];
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
bodyParser: false,
|
||||
},
|
||||
};
|
||||
|
||||
export default withRateLimit(
|
||||
{ points: 100, duration: 60 },
|
||||
async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
if (req.method !== "POST") {
|
||||
return res.status(405).json({ error: "Method not allowed" });
|
||||
}
|
||||
|
||||
try {
|
||||
const { user, db } = await createNextApiContext(req);
|
||||
|
||||
if (!user) {
|
||||
return res.status(401).json({ error: "Unauthorized" });
|
||||
}
|
||||
|
||||
const bucket = env.NEXT_PUBLIC_AVATAR_BUCKET_NAME;
|
||||
if (!bucket) {
|
||||
return res.status(500).json({ error: "Avatar bucket not configured" });
|
||||
}
|
||||
|
||||
const contentType = req.headers["content-type"];
|
||||
const contentLengthHeader = req.headers["content-length"];
|
||||
const contentLength = contentLengthHeader
|
||||
? Number.parseInt(contentLengthHeader, 10)
|
||||
: NaN;
|
||||
|
||||
if (typeof contentType !== "string") {
|
||||
return res.status(400).json({ error: "Missing content type" });
|
||||
}
|
||||
|
||||
if (!allowedContentTypes.includes(contentType)) {
|
||||
return res.status(400).json({ error: "Invalid content type" });
|
||||
}
|
||||
|
||||
if (!Number.isFinite(contentLength) || contentLength <= 0) {
|
||||
return res.status(400).json({ error: "Missing or invalid content length" });
|
||||
}
|
||||
|
||||
if (contentLength > MAX_SIZE_BYTES) {
|
||||
return res.status(400).json({ error: "File too large" });
|
||||
}
|
||||
|
||||
const originalFilenameHeader =
|
||||
(req.headers["x-original-filename"] as string | undefined) ?? "file";
|
||||
|
||||
const sanitizedFilename = originalFilenameHeader
|
||||
.replace(/[^a-zA-Z0-9._-]/g, "_")
|
||||
.substring(0, 200);
|
||||
|
||||
const s3Key = `${user.id}/${sanitizedFilename}`;
|
||||
|
||||
const client = createS3Client();
|
||||
|
||||
// Upload the file to S3
|
||||
await client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: s3Key,
|
||||
Body: req,
|
||||
ContentType: contentType,
|
||||
ContentLength: contentLength,
|
||||
}),
|
||||
);
|
||||
|
||||
// Update user image in database
|
||||
const updatedUser = await userRepo.update(db, user.id, {
|
||||
image: s3Key,
|
||||
});
|
||||
|
||||
return res.status(200).json({
|
||||
key: s3Key,
|
||||
filename: sanitizedFilename,
|
||||
contentType,
|
||||
size: contentLength,
|
||||
user: updatedUser,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Avatar upload failed", error);
|
||||
return res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
|
||||
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
||||
import { env as nextRuntimeEnv } from "next-runtime-env";
|
||||
|
||||
import { createNextApiContext } from "@kan/api/trpc";
|
||||
|
||||
import { env } from "~/env";
|
||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||
|
||||
const allowedContentTypes = ["image/jpeg", "image/png"];
|
||||
|
||||
export default withRateLimit(
|
||||
{ points: 100, duration: 60 },
|
||||
async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
if (req.method !== "POST") {
|
||||
return res.status(405).json({ error: "Method not allowed" });
|
||||
}
|
||||
|
||||
try {
|
||||
const { user } = await createNextApiContext(req);
|
||||
|
||||
if (!user) {
|
||||
return res.status(401).json({ error: "Unauthorized" });
|
||||
}
|
||||
|
||||
const { filename, contentType } = req.body as {
|
||||
filename: string;
|
||||
contentType: string;
|
||||
};
|
||||
|
||||
// Specific to avatar uploads for now
|
||||
const filenameRegex = /^[a-f0-9\-]+\/[a-zA-Z0-9_\-]+(\.jpg|\.jpeg|\.png)$/;
|
||||
|
||||
if (!filenameRegex.test(filename)) {
|
||||
return res.status(400).json({ error: "Invalid filename" });
|
||||
}
|
||||
|
||||
if (
|
||||
typeof contentType !== "string" ||
|
||||
!allowedContentTypes.includes(contentType)
|
||||
) {
|
||||
return res.status(400).json({ error: "Invalid content type" });
|
||||
}
|
||||
|
||||
const credentials =
|
||||
env.S3_ACCESS_KEY_ID && env.S3_SECRET_ACCESS_KEY
|
||||
? {
|
||||
accessKeyId: env.S3_ACCESS_KEY_ID,
|
||||
secretAccessKey: env.S3_SECRET_ACCESS_KEY,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const client = new S3Client({
|
||||
region: env.S3_REGION ?? "",
|
||||
endpoint: env.S3_ENDPOINT ?? "",
|
||||
forcePathStyle: env.S3_FORCE_PATH_STYLE === "true",
|
||||
credentials,
|
||||
});
|
||||
|
||||
const signedUrl = await getSignedUrl(
|
||||
client,
|
||||
new PutObjectCommand({
|
||||
Bucket: nextRuntimeEnv("NEXT_PUBLIC_AVATAR_BUCKET_NAME") ?? "",
|
||||
Key: filename,
|
||||
ACL: "public-read",
|
||||
}),
|
||||
);
|
||||
|
||||
return res.status(200).json({ url: signedUrl, key: filename });
|
||||
} catch (error) {
|
||||
return res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -1,5 +1,3 @@
|
||||
import { env } from "next-runtime-env";
|
||||
|
||||
export const formatToArray = (
|
||||
value: string | string[] | undefined,
|
||||
): string[] => {
|
||||
@@ -52,14 +50,5 @@ export const getAvatarUrl = (imageOrKey: string | null) => {
|
||||
return imageOrKey;
|
||||
}
|
||||
|
||||
const bucket = env("NEXT_PUBLIC_AVATAR_BUCKET_NAME");
|
||||
const useVirtualHostedUrls = env("NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS");
|
||||
const storageDomain = env("NEXT_PUBLIC_STORAGE_DOMAIN");
|
||||
|
||||
if (useVirtualHostedUrls === "true" && storageDomain) {
|
||||
return `https://${bucket}.${storageDomain}/${imageOrKey}`;
|
||||
}
|
||||
|
||||
const storageUrl = env("NEXT_PUBLIC_STORAGE_URL");
|
||||
return `${storageUrl}/${bucket}/${imageOrKey}`;
|
||||
return "";
|
||||
};
|
||||
|
||||
@@ -66,8 +66,8 @@ const Card = ({
|
||||
const hasDueDate = !!dueDate;
|
||||
|
||||
return (
|
||||
<div className="flex 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">
|
||||
<span>{title}</span>
|
||||
<div className="flex flex-col overflow-hidden 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">
|
||||
<span className="break-words">{title}</span>
|
||||
{labels.length ||
|
||||
members.length ||
|
||||
checklists.length > 0 ||
|
||||
|
||||
@@ -1,24 +1,40 @@
|
||||
import Link from "next/link";
|
||||
import { Fragment } from "react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { env } from "next-runtime-env";
|
||||
import { HiLink } from "react-icons/hi";
|
||||
|
||||
import { Tooltip } from "~/components/Tooltip";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
|
||||
const displayBaseUrl =
|
||||
env("NEXT_PUBLIC_KAN_ENV") === "cloud"
|
||||
? "kan.bn"
|
||||
: env("NEXT_PUBLIC_BASE_URL");
|
||||
|
||||
const linkBaseUrl = env("NEXT_PUBLIC_BASE_URL");
|
||||
|
||||
const pathSeparator = (
|
||||
<div className="mx-1.5 h-4 w-px rotate-[20deg] bg-gray-300 dark:bg-dark-600" />
|
||||
);
|
||||
|
||||
const UpdateBoardSlugButton = ({
|
||||
handleOnClick,
|
||||
workspaceSlug,
|
||||
boardSlug,
|
||||
boardPublicId,
|
||||
visibility,
|
||||
isLoading,
|
||||
canEdit,
|
||||
}: {
|
||||
handleOnClick: () => void;
|
||||
workspaceSlug: string;
|
||||
boardSlug: string;
|
||||
boardPublicId: string;
|
||||
visibility: "public" | "private";
|
||||
isLoading: boolean;
|
||||
canEdit: boolean;
|
||||
}) => {
|
||||
if (!isLoading && (!workspaceSlug || !boardSlug)) return <></>;
|
||||
const { showPopup } = usePopup();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -26,44 +42,55 @@ const UpdateBoardSlugButton = ({
|
||||
);
|
||||
}
|
||||
|
||||
if (!workspaceSlug || !boardSlug || !boardPublicId) return <></>;
|
||||
|
||||
const isPublic = visibility === "public";
|
||||
const boardUrl = isPublic
|
||||
? `${linkBaseUrl}/${workspaceSlug}/${boardSlug}`
|
||||
: `${linkBaseUrl}/boards/${boardPublicId}`;
|
||||
|
||||
const pathSegments = isPublic
|
||||
? [displayBaseUrl, workspaceSlug, boardSlug]
|
||||
: [displayBaseUrl, "boards", boardPublicId];
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
content={!canEdit && !isLoading ? t`You don't have permission` : undefined}
|
||||
content={!canEdit ? t`You don't have permission` : undefined}
|
||||
>
|
||||
<button
|
||||
onClick={canEdit ? handleOnClick : undefined}
|
||||
disabled={!canEdit || isLoading}
|
||||
className="hidden cursor-pointer items-center gap-2 rounded-full border-[1px] bg-light-50 p-1 pl-4 pr-1 text-sm text-light-950 hover:bg-light-100 disabled:cursor-not-allowed disabled:opacity-60 dark:border-dark-600 dark:bg-dark-50 dark:text-dark-900 dark:hover:bg-dark-100 xl:flex"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<span>
|
||||
{env("NEXT_PUBLIC_KAN_ENV") === "cloud"
|
||||
? "kan.bn"
|
||||
: env("NEXT_PUBLIC_BASE_URL")}
|
||||
</span>
|
||||
<div className="mx-1.5 h-4 w-px rotate-[20deg] bg-gray-300 dark:bg-dark-600"></div>
|
||||
<span>{workspaceSlug}</span>
|
||||
<div className="mx-1.5 h-4 w-px rotate-[20deg] bg-gray-300 dark:bg-dark-600"></div>
|
||||
<span>{boardSlug}</span>
|
||||
</div>
|
||||
<Link
|
||||
href={`${env("NEXT_PUBLIC_BASE_URL")}/${workspaceSlug}/${boardSlug}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (!canEdit) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
className="flex h-7 w-7 items-center justify-center rounded-full hover:bg-light-200 dark:hover:bg-dark-200"
|
||||
>
|
||||
<HiLink className="h-[13px] w-[13px]" />
|
||||
</Link>
|
||||
</button>
|
||||
<div className="flex items-center">
|
||||
{pathSegments.map((segment, i) => (
|
||||
<Fragment key={i}>
|
||||
{i > 0 && pathSeparator}
|
||||
<span>{segment}</span>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigator.clipboard.writeText(boardUrl).then(
|
||||
() =>
|
||||
showPopup({
|
||||
header: t`Link copied`,
|
||||
icon: "success",
|
||||
message: t`Board URL copied to clipboard`,
|
||||
}),
|
||||
).catch(() => undefined);
|
||||
}}
|
||||
className="flex h-7 w-7 items-center justify-center rounded-full hover:bg-light-200 dark:hover:bg-dark-200"
|
||||
aria-label={t`Copy board link`}
|
||||
>
|
||||
<HiLink className="h-[13px] w-[13px]" />
|
||||
</button>
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
export default UpdateBoardSlugButton;
|
||||
|
||||
@@ -156,7 +156,7 @@ export function UpdateBoardSlugForm({
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
href="/settings?tab=workspace"
|
||||
href="/settings/workspace"
|
||||
onClick={closeModal}
|
||||
>
|
||||
{t`Edit workspace URL`}
|
||||
|
||||
@@ -58,13 +58,14 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
const [selectedPublicListId, setSelectedPublicListId] =
|
||||
useState<PublicListId>("");
|
||||
const [isInitialLoading, setIsInitialLoading] = useState(true);
|
||||
|
||||
|
||||
const { ref: scrollRef, onMouseDown } = useDragToScroll({
|
||||
enabled: true,
|
||||
direction: "horizontal",
|
||||
});
|
||||
|
||||
const { canCreateList, canEditList, canEditCard, canEditBoard } = usePermissions();
|
||||
const { canCreateList, canEditList, canEditCard, canEditBoard } =
|
||||
usePermissions();
|
||||
|
||||
const { tooltipContent: createListShortcutTooltipContent } =
|
||||
useKeyboardShortcut({
|
||||
@@ -417,10 +418,9 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
{...register("name")}
|
||||
onBlur={canEditBoard ? handleSubmit(onSubmit) : undefined}
|
||||
readOnly={!canEditBoard}
|
||||
className="block border-0 bg-transparent p-0 py-0 font-bold leading-[2.3rem] tracking-tight text-neutral-900 focus:ring-0 focus-visible:outline-none dark:text-dark-1000 sm:text-[1.2rem] disabled:cursor-not-allowed"
|
||||
className="block border-0 bg-transparent p-0 py-0 font-bold leading-[2.3rem] tracking-tight text-neutral-900 focus:ring-0 focus-visible:outline-none disabled:cursor-not-allowed dark:text-dark-1000 sm:text-[1.2rem]"
|
||||
/>
|
||||
</form>
|
||||
|
||||
)}
|
||||
{!boardData && !isLoading && (
|
||||
<p className="order-2 block p-0 py-0 font-bold leading-[2.3rem] tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem] md:order-1">
|
||||
@@ -443,6 +443,8 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
isLoading={isLoading}
|
||||
workspaceSlug={workspace.slug ?? ""}
|
||||
boardSlug={boardData?.slug ?? ""}
|
||||
boardPublicId={boardId ?? ""}
|
||||
visibility={boardData?.visibility ?? "private"}
|
||||
canEdit={canEditBoard}
|
||||
/>
|
||||
<VisibilityButton
|
||||
@@ -596,12 +598,13 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
? `/templates/${boardId}/cards/${card.publicId}`
|
||||
: `/cards/${card.publicId}`
|
||||
}
|
||||
className={`mb-2 flex !cursor-pointer flex-col ${card.publicId.startsWith(
|
||||
"PLACEHOLDER",
|
||||
)
|
||||
? "pointer-events-none"
|
||||
: ""
|
||||
}`}
|
||||
className={`mb-2 flex !cursor-pointer flex-col ${
|
||||
card.publicId.startsWith(
|
||||
"PLACEHOLDER",
|
||||
)
|
||||
? "pointer-events-none"
|
||||
: ""
|
||||
}`}
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
{...provided.dragHandleProps}
|
||||
|
||||
@@ -23,6 +23,18 @@ export const getTemplates = (): Template[] => [
|
||||
lists: [t`Backlog`, t`To Do`, t`In Progress`, t`Code Review`, t`Done`],
|
||||
labels: [t`Bug`, t`Feature`, t`Enhancement`, t`Critical`, t`Documentation`],
|
||||
},
|
||||
{
|
||||
id: "roadmap-basic",
|
||||
name: t`Basic Roadmap`,
|
||||
lists: [t`Requested`, t`Planned`, t`In Progress`, t`Done`],
|
||||
labels: [t`Feature`, t`Enhancement`, t`Critical`, t`Documentation`],
|
||||
},
|
||||
{
|
||||
id: "roadmap-extended",
|
||||
name: t`Extended Roadmap`,
|
||||
lists: [t`Requested`, t`Under Review`, t`Planned`, t`In Progress`, t`Done`, t`Rejected`],
|
||||
labels: [t`Feature`, t`Enhancement`, t`Critical`, t`Documentation`],
|
||||
},
|
||||
{
|
||||
id: "content-creation",
|
||||
name: t`Content Creation`,
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
HiOutlineArrowRight,
|
||||
HiOutlineCheckCircle,
|
||||
HiOutlineClock,
|
||||
HiOutlinePaperClip,
|
||||
HiOutlinePencil,
|
||||
HiOutlinePlus,
|
||||
HiOutlineTag,
|
||||
@@ -31,6 +32,16 @@ import Comment from "./Comment";
|
||||
type ActivityType =
|
||||
NonNullable<GetCardByIdOutput>["activities"][number]["type"];
|
||||
|
||||
type ActivityWithMergedLabels =
|
||||
GetCardActivitiesOutput["activities"][number] & {
|
||||
mergedLabels?: string[];
|
||||
attachment?: {
|
||||
publicId: string;
|
||||
filename: string;
|
||||
originalFilename: string;
|
||||
} | null;
|
||||
};
|
||||
|
||||
const truncate = (value: string | null, maxLength = 50) => {
|
||||
if (!value) return value;
|
||||
return value.length > maxLength ? `${value.slice(0, maxLength - 1)}…` : value;
|
||||
@@ -57,6 +68,7 @@ const getActivityText = ({
|
||||
toDueDate,
|
||||
dateLocale,
|
||||
mergedLabels,
|
||||
attachmentName,
|
||||
}: {
|
||||
type: ActivityType;
|
||||
toTitle: string | null;
|
||||
@@ -71,6 +83,7 @@ const getActivityText = ({
|
||||
toDueDate?: Date | null;
|
||||
dateLocale: DateFnsLocale;
|
||||
mergedLabels?: string[];
|
||||
attachmentName?: string | null;
|
||||
}) => {
|
||||
const displayName = memberName ?? memberEmail ?? t`Member`;
|
||||
const TextHighlight = ({ children }: { children: React.ReactNode }) => (
|
||||
@@ -124,6 +137,8 @@ const getActivityText = ({
|
||||
"card.updated.checklist.item.completed": t`completed a checklist item`,
|
||||
"card.updated.checklist.item.uncompleted": t`marked a checklist item as incomplete`,
|
||||
"card.updated.checklist.item.deleted": t`deleted a checklist item`,
|
||||
"card.updated.attachment.added": t`added an attachment`,
|
||||
"card.updated.attachment.removed": t`removed an attachment`,
|
||||
"card.updated.dueDate.added": t`set the due date`,
|
||||
"card.updated.dueDate.updated": t`updated the due date`,
|
||||
"card.updated.dueDate.removed": t`removed the due date`,
|
||||
@@ -256,6 +271,27 @@ const getActivityText = ({
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "card.updated.attachment.added") {
|
||||
const filename = attachmentName ?? toTitle;
|
||||
if (!filename) return baseText;
|
||||
return (
|
||||
<Trans>
|
||||
added an attachment <TextHighlight>{truncate(filename)}</TextHighlight>
|
||||
</Trans>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "card.updated.attachment.removed") {
|
||||
const filename = attachmentName ?? fromTitle;
|
||||
if (!filename) return baseText;
|
||||
return (
|
||||
<Trans>
|
||||
removed an attachment{" "}
|
||||
<TextHighlight>{truncate(filename)}</TextHighlight>
|
||||
</Trans>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "card.updated.dueDate.added" && toDueDate) {
|
||||
const showYear = !isSameYear(toDueDate, new Date());
|
||||
const formattedDate = format(
|
||||
@@ -308,6 +344,8 @@ const ACTIVITY_ICON_MAP: Partial<Record<ActivityType, React.ReactNode | null>> =
|
||||
"card.updated.checklist.item.completed": <HiOutlineCheckCircle />,
|
||||
"card.updated.checklist.item.uncompleted": <HiOutlineCheckCircle />,
|
||||
"card.updated.checklist.item.deleted": <HiOutlineTrash />,
|
||||
"card.updated.attachment.added": <HiOutlinePaperClip />,
|
||||
"card.updated.attachment.removed": <HiOutlinePaperClip />,
|
||||
"card.updated.dueDate.added": <HiOutlineClock />,
|
||||
"card.updated.dueDate.updated": <HiOutlineClock />,
|
||||
"card.updated.dueDate.removed": <HiOutlineClock />,
|
||||
@@ -341,7 +379,7 @@ const ActivityList = ({
|
||||
isAdmin?: boolean;
|
||||
isViewOnly?: boolean;
|
||||
}) => {
|
||||
const { dateLocale, locale } = useLocalisation();
|
||||
const { dateLocale } = useLocalisation();
|
||||
const { data: sessionData } = authClient.useSession();
|
||||
const utils = api.useUtils();
|
||||
const [allActivities, setAllActivities] = useState<
|
||||
@@ -391,25 +429,21 @@ const ActivityList = ({
|
||||
cursor: nextCursor,
|
||||
});
|
||||
|
||||
if (nextPage) {
|
||||
const existingIds = new Set(
|
||||
currentActivities.map((a) => a.publicId),
|
||||
);
|
||||
const newActivities = nextPage.activities.filter(
|
||||
(a: { publicId: string }) => !existingIds.has(a.publicId),
|
||||
);
|
||||
currentActivities = [...currentActivities, ...newActivities];
|
||||
currentHasMore = nextPage.hasMore;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
const existingIds = new Set(
|
||||
currentActivities.map((a) => a.publicId),
|
||||
);
|
||||
const newActivities = nextPage.activities.filter(
|
||||
(a: { publicId: string }) => !existingIds.has(a.publicId),
|
||||
);
|
||||
currentActivities = [...currentActivities, ...newActivities];
|
||||
currentHasMore = nextPage.hasMore;
|
||||
}
|
||||
|
||||
setAllActivities(currentActivities);
|
||||
setHasMore(false);
|
||||
};
|
||||
|
||||
fetchAllRemaining();
|
||||
void fetchAllRemaining();
|
||||
} else {
|
||||
setAllActivities(firstPageData.activities);
|
||||
setHasMore(firstPageData.hasMore);
|
||||
@@ -436,17 +470,15 @@ const ActivityList = ({
|
||||
cursor: nextCursor,
|
||||
});
|
||||
|
||||
if (nextPage) {
|
||||
const existingIds = new Set(allActivities.map((a) => a.publicId));
|
||||
const newActivities = nextPage.activities.filter(
|
||||
(a: { publicId: string }) => !existingIds.has(a.publicId),
|
||||
);
|
||||
setAllActivities((prev) => [...prev, ...newActivities]);
|
||||
setHasMore(nextPage.hasMore);
|
||||
const existingIds = new Set(allActivities.map((a) => a.publicId));
|
||||
const newActivities = nextPage.activities.filter(
|
||||
(a: { publicId: string }) => !existingIds.has(a.publicId),
|
||||
);
|
||||
setAllActivities((prev) => [...prev, ...newActivities]);
|
||||
setHasMore(nextPage.hasMore);
|
||||
|
||||
if (!nextPage.hasMore) {
|
||||
isFullyExpandedRef.current = true;
|
||||
}
|
||||
if (!nextPage.hasMore) {
|
||||
isFullyExpandedRef.current = true;
|
||||
}
|
||||
} finally {
|
||||
setIsLoadingMore(false);
|
||||
@@ -473,7 +505,10 @@ const ActivityList = ({
|
||||
fromDueDate: activity.fromDueDate ?? null,
|
||||
toDueDate: activity.toDueDate ?? null,
|
||||
dateLocale: dateLocale,
|
||||
mergedLabels: (activity as any).mergedLabels,
|
||||
mergedLabels: (activity as ActivityWithMergedLabels).mergedLabels,
|
||||
attachmentName:
|
||||
(activity as ActivityWithMergedLabels).attachment?.originalFilename ??
|
||||
null,
|
||||
});
|
||||
|
||||
if (activity.type === "card.updated.comment.added")
|
||||
|
||||
@@ -7,6 +7,7 @@ import { twMerge } from "tailwind-merge";
|
||||
import Button from "~/components/Button";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { env } from "next-runtime-env";
|
||||
import { api } from "~/utils/api";
|
||||
import { invalidateCard } from "~/utils/cardInvalidation";
|
||||
|
||||
@@ -18,62 +19,33 @@ export function AttachmentUpload({ cardPublicId }: { cardPublicId: string }) {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const generateUploadUrl = api.attachment.generateUploadUrl.useMutation();
|
||||
const confirmAttachment = api.attachment.confirm.useMutation({
|
||||
onSuccess: async () => {
|
||||
const uploadFile = async (file: File) => {
|
||||
setUploading(true);
|
||||
|
||||
try {
|
||||
const baseUrl = env("NEXT_PUBLIC_BASE_URL") ?? "";
|
||||
const response = await fetch(
|
||||
`${baseUrl}/api/upload/attachment?cardPublicId=${encodeURIComponent(cardPublicId)}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": file.type,
|
||||
"x-original-filename": file.name,
|
||||
},
|
||||
body: file,
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Upload failed");
|
||||
}
|
||||
|
||||
await invalidateCard(utils, cardPublicId);
|
||||
showPopup({
|
||||
header: t`Attachment uploaded`,
|
||||
message: t`Your file has been uploaded successfully.`,
|
||||
icon: "success",
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
showPopup({
|
||||
header: t`Upload failed`,
|
||||
message: t`Failed to upload attachment. Please try again.`,
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
onSettled: () => {
|
||||
setUploading(false);
|
||||
},
|
||||
});
|
||||
|
||||
const uploadFile = async (file: File) => {
|
||||
setUploading(true);
|
||||
|
||||
try {
|
||||
// Generate presigned URL
|
||||
const { url, key } = await generateUploadUrl.mutateAsync({
|
||||
cardPublicId,
|
||||
filename: file.name,
|
||||
contentType: file.type,
|
||||
size: file.size,
|
||||
});
|
||||
|
||||
// Upload file to S3
|
||||
const uploadResponse = await fetch(url, {
|
||||
method: "PUT",
|
||||
body: file,
|
||||
headers: {
|
||||
"Content-Type": file.type,
|
||||
},
|
||||
});
|
||||
|
||||
if (!uploadResponse.ok) {
|
||||
throw new Error("Upload failed");
|
||||
}
|
||||
|
||||
// Confirm attachment in database
|
||||
await confirmAttachment.mutateAsync({
|
||||
cardPublicId,
|
||||
s3Key: key,
|
||||
filename: file.name,
|
||||
originalFilename: file.name,
|
||||
contentType: file.type,
|
||||
size: file.size,
|
||||
});
|
||||
} catch {
|
||||
showPopup({
|
||||
header: t`Upload failed`,
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { useState } from "react";
|
||||
import ContentEditable from "react-contenteditable";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { HiEllipsisHorizontal, HiPencil, HiTrash } from "react-icons/hi2";
|
||||
|
||||
import Avatar from "~/components/Avatar";
|
||||
import Button from "~/components/Button";
|
||||
import Editor from "~/components/Editor";
|
||||
import type { WorkspaceMember } from "~/components/Editor";
|
||||
import Dropdown from "~/components/Dropdown";
|
||||
import { usePermissions } from "~/hooks/usePermissions";
|
||||
import { useModal } from "~/providers/modal";
|
||||
@@ -29,7 +30,6 @@ const Comment = ({
|
||||
createdAt,
|
||||
comment,
|
||||
isAuthor,
|
||||
isAdmin,
|
||||
isEdited = false,
|
||||
isViewOnly = false,
|
||||
}: {
|
||||
@@ -42,7 +42,6 @@ const Comment = ({
|
||||
createdAt: string;
|
||||
comment: string | undefined;
|
||||
isAuthor: boolean;
|
||||
isAdmin: boolean;
|
||||
isEdited: boolean;
|
||||
isViewOnly: boolean;
|
||||
}) => {
|
||||
@@ -57,6 +56,30 @@ const Comment = ({
|
||||
},
|
||||
});
|
||||
|
||||
const { data: cardData } = api.card.byId.useQuery(
|
||||
{
|
||||
cardPublicId,
|
||||
},
|
||||
{
|
||||
enabled: !!cardPublicId && cardPublicId.length >= 12,
|
||||
},
|
||||
);
|
||||
|
||||
const workspaceMembers: WorkspaceMember[] =
|
||||
cardData?.list.board.workspace.members
|
||||
.filter((member) => member.email)
|
||||
.map((member) => ({
|
||||
publicId: member.publicId,
|
||||
email: member.email,
|
||||
user: member.user
|
||||
? {
|
||||
id: member.user.id,
|
||||
name: member.user.name ?? null,
|
||||
image: member.user.image ?? null,
|
||||
}
|
||||
: null,
|
||||
})) ?? [];
|
||||
|
||||
if (!publicId) return null;
|
||||
|
||||
const updateCommentMutation = api.card.updateComment.useMutation({
|
||||
@@ -142,21 +165,28 @@ const Comment = ({
|
||||
)}
|
||||
</div>
|
||||
{!isEditing ? (
|
||||
<ContentEditable
|
||||
html={comment ?? ""}
|
||||
disabled={true}
|
||||
className="break-anywhere mt-2 text-sm"
|
||||
/>
|
||||
<div className="mt-2">
|
||||
<Editor
|
||||
content={comment ?? null}
|
||||
readOnly={true}
|
||||
workspaceMembers={workspaceMembers}
|
||||
enableYouTubeEmbed={false}
|
||||
disableHeadings={true}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<ContentEditable
|
||||
placeholder={t`Add a comment...`}
|
||||
html={watch("comment")}
|
||||
disabled={false}
|
||||
onChange={(e) => setValue("comment", e.target.value)}
|
||||
className="block w-full max-w-[800px] border-0 bg-transparent py-1.5 text-sm 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">
|
||||
<div className="mt-2">
|
||||
<Editor
|
||||
content={watch("comment")}
|
||||
onChange={(value) => setValue("comment", value)}
|
||||
workspaceMembers={workspaceMembers}
|
||||
enableYouTubeEmbed={false}
|
||||
placeholder={t`Add comment... (type '/' to open commands or '@' to mention)`}
|
||||
disableHeadings={true}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end space-x-2 mt-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import {
|
||||
HiEllipsisHorizontal,
|
||||
HiLink,
|
||||
HiOutlineCheckCircle,
|
||||
HiOutlineTrash,
|
||||
} from "react-icons/hi2";
|
||||
@@ -10,18 +11,54 @@ import { authClient } from "@kan/auth/client";
|
||||
import Dropdown from "~/components/Dropdown";
|
||||
import { usePermissions } from "~/hooks/usePermissions";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
|
||||
export default function CardDropdown({
|
||||
cardPublicId,
|
||||
isTemplate,
|
||||
boardPublicId,
|
||||
cardCreatedBy,
|
||||
}: {
|
||||
cardPublicId: string;
|
||||
isTemplate?: boolean;
|
||||
boardPublicId?: string;
|
||||
cardCreatedBy?: string | null;
|
||||
}) {
|
||||
const { openModal } = useModal();
|
||||
const { showPopup } = usePopup();
|
||||
const { canEditCard, canDeleteCard } = usePermissions();
|
||||
const { data: session } = authClient.useSession();
|
||||
const isCreator = cardCreatedBy && session?.user.id === cardCreatedBy;
|
||||
|
||||
const handleCopyCardLink = async () => {
|
||||
const path =
|
||||
isTemplate && boardPublicId
|
||||
? `/templates/${boardPublicId}/cards/${cardPublicId}`
|
||||
: `/cards/${cardPublicId}`;
|
||||
const url = `${window.location.origin}${path}`;
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
showPopup({
|
||||
header: t`Link copied`,
|
||||
icon: "success",
|
||||
message: t`Card URL copied to clipboard`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
showPopup({
|
||||
header: t`Unable to copy link`,
|
||||
icon: "error",
|
||||
message: t`Please try again.`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const items = [
|
||||
{
|
||||
label: t`Copy card link`,
|
||||
action: handleCopyCardLink,
|
||||
icon: <HiLink className="h-[16px] w-[16px] text-dark-900" />,
|
||||
},
|
||||
...(canEditCard
|
||||
? [
|
||||
{
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import ContentEditable from "react-contenteditable";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { HiOutlineArrowUp } from "react-icons/hi2";
|
||||
|
||||
import Editor from "~/components/Editor";
|
||||
import type { WorkspaceMember } from "~/components/Editor";
|
||||
import LoadingSpinner from "~/components/LoadingSpinner";
|
||||
import { usePermissions } from "~/hooks/usePermissions";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
@@ -13,7 +14,13 @@ interface FormValues {
|
||||
comment: string;
|
||||
}
|
||||
|
||||
const NewCommentForm = ({ cardPublicId }: { cardPublicId: string }) => {
|
||||
const NewCommentForm = ({
|
||||
cardPublicId,
|
||||
workspaceMembers,
|
||||
}: {
|
||||
cardPublicId: string;
|
||||
workspaceMembers: WorkspaceMember[];
|
||||
}) => {
|
||||
const utils = api.useUtils();
|
||||
const { showPopup } = usePopup();
|
||||
const { canCreateComment } = usePermissions();
|
||||
@@ -23,10 +30,6 @@ const NewCommentForm = ({ cardPublicId }: { cardPublicId: string }) => {
|
||||
},
|
||||
});
|
||||
|
||||
const queryParams = {
|
||||
cardPublicId,
|
||||
};
|
||||
|
||||
const addCommentMutation = api.card.addComment.useMutation({
|
||||
onError: (_error, _newList) => {
|
||||
showPopup({
|
||||
@@ -57,18 +60,13 @@ const NewCommentForm = ({ cardPublicId }: { cardPublicId: string }) => {
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="flex w-full max-w-[800px] flex-col rounded-xl border border-light-600 bg-light-100 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={t`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"
|
||||
onKeyDown={async (e) => {
|
||||
if (e.key === "Enter" && e.shiftKey) {
|
||||
e.preventDefault();
|
||||
await handleSubmit(onSubmit)();
|
||||
}
|
||||
}}
|
||||
<Editor
|
||||
content={watch("comment")}
|
||||
onChange={(value) => setValue("comment", value)}
|
||||
workspaceMembers={workspaceMembers}
|
||||
enableYouTubeEmbed={false}
|
||||
placeholder={t`Add comment... (type '/' to open commands or '@' to mention)`}
|
||||
disableHeadings={true}
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
|
||||
@@ -4,6 +4,9 @@ import { t } from "@lingui/core/macro";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { IoChevronForwardSharp } from "react-icons/io5";
|
||||
import { HiXMark } from "react-icons/hi2";
|
||||
|
||||
import { authClient } from "@kan/auth/client";
|
||||
|
||||
import Avatar from "~/components/Avatar";
|
||||
import Editor from "~/components/Editor";
|
||||
@@ -14,8 +17,6 @@ import Modal from "~/components/modal";
|
||||
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
|
||||
import { PageHead } from "~/components/PageHead";
|
||||
import { EditYouTubeModal } from "~/components/YouTubeEmbed/EditYouTubeModal";
|
||||
import { authClient } from "@kan/auth/client";
|
||||
|
||||
import { usePermissions } from "~/hooks/usePermissions";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
@@ -167,7 +168,6 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
const {
|
||||
modalContentType,
|
||||
entityId,
|
||||
openModal,
|
||||
getModalState,
|
||||
clearModalState,
|
||||
isOpen,
|
||||
@@ -198,8 +198,24 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
};
|
||||
|
||||
const board = card?.list.board;
|
||||
const workspaceMembers = board?.workspace.members;
|
||||
const boardId = board?.publicId;
|
||||
|
||||
const editorWorkspaceMembers =
|
||||
workspaceMembers
|
||||
?.filter((member) => member.email)
|
||||
.map((member) => ({
|
||||
publicId: member.publicId,
|
||||
email: member.email,
|
||||
user: member.user
|
||||
? {
|
||||
id: member.user.id,
|
||||
name: member.user.name ?? null,
|
||||
image: member.user.image ?? null,
|
||||
}
|
||||
: null,
|
||||
})) ?? [];
|
||||
|
||||
const updateCard = api.card.update.useMutation({
|
||||
onError: () => {
|
||||
showPopup({
|
||||
@@ -286,6 +302,7 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
|
||||
if (!cardId) return <></>;
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead
|
||||
@@ -317,7 +334,19 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Dropdown cardCreatedBy={card?.createdBy} />
|
||||
<Dropdown
|
||||
cardPublicId={cardId}
|
||||
isTemplate={isTemplate}
|
||||
boardPublicId={boardId}
|
||||
cardCreatedBy={card?.createdBy}
|
||||
/>
|
||||
<Link
|
||||
href={`/${isTemplate ? "templates" : "boards"}/${boardId}`}
|
||||
className="flex h-7 w-7 items-center justify-center rounded-[5px] text-light-900 hover:bg-light-200 dark:text-dark-900 dark:hover:bg-dark-200"
|
||||
aria-label={t`Close`}
|
||||
>
|
||||
<HiXMark className="h-5 w-5" />
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -374,9 +403,15 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
<div className="mt-2">
|
||||
<Editor
|
||||
content={card.description}
|
||||
onChange={canEdit ? (e) => setValue("description", e) : undefined}
|
||||
onBlur={canEdit ? () => handleSubmit(onSubmit)() : undefined}
|
||||
workspaceMembers={board?.workspace.members ?? []}
|
||||
onChange={
|
||||
canEdit
|
||||
? (e) => setValue("description", e)
|
||||
: undefined
|
||||
}
|
||||
onBlur={
|
||||
canEdit ? () => handleSubmit(onSubmit)() : undefined
|
||||
}
|
||||
workspaceMembers={workspaceMembers ?? []}
|
||||
readOnly={!canEdit}
|
||||
/>
|
||||
</div>
|
||||
@@ -420,7 +455,10 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
</div>
|
||||
{!isTemplate && (
|
||||
<div className="mt-6">
|
||||
<NewCommentForm cardPublicId={cardId} />
|
||||
<NewCommentForm
|
||||
cardPublicId={cardId}
|
||||
workspaceMembers={editorWorkspaceMembers}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -126,7 +126,6 @@ export default function MembersPage() {
|
||||
name={memberName ?? ""}
|
||||
email={memberEmail ?? ""}
|
||||
imageUrl={memberImage ? getAvatarUrl(memberImage) : undefined}
|
||||
icon={showPendingIcon ? "?" : undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,293 +1,587 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { HiCheckCircle, HiXCircle } from "react-icons/hi2";
|
||||
|
||||
interface Feature {
|
||||
key: string;
|
||||
label: string;
|
||||
kan: boolean | string;
|
||||
trello: boolean | string;
|
||||
}
|
||||
|
||||
interface Section {
|
||||
name: string;
|
||||
features: Feature[];
|
||||
}
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
type FrequencyValue = "monthly" | "annually";
|
||||
|
||||
const CellValue = ({ value }: { value: boolean | string }) => {
|
||||
if (typeof value === "string") {
|
||||
return (
|
||||
<span className="text-sm text-dark-900 dark:text-dark-900">{value}</span>
|
||||
);
|
||||
}
|
||||
return value ? (
|
||||
<HiCheckCircle className="h-5 w-5 text-light-950 dark:text-dark-1000" />
|
||||
) : (
|
||||
<HiXCircle className="h-5 w-5 text-light-700 dark:text-dark-600" />
|
||||
);
|
||||
};
|
||||
interface PlanFeature {
|
||||
key: string;
|
||||
label: string;
|
||||
free: string | boolean | { text: string; highlight?: string };
|
||||
teams: string | boolean | { text: string; highlight?: string };
|
||||
pro: string | boolean | { text: string; highlight?: string };
|
||||
enterprise: string | boolean | { text: string; highlight?: string };
|
||||
}
|
||||
|
||||
interface FeatureSection {
|
||||
name: string;
|
||||
features: PlanFeature[];
|
||||
}
|
||||
|
||||
const FeatureComparisonTable = ({
|
||||
frequencyValue,
|
||||
frequencyValue: _frequencyValue,
|
||||
}: {
|
||||
frequencyValue: FrequencyValue;
|
||||
}) => {
|
||||
const sections: Section[] = [
|
||||
// Keep pricing column headers visible as you scroll
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [isHeaderFixed, setIsHeaderFixed] = useState(false);
|
||||
const [headerRect, setHeaderRect] = useState<{ left: number; width: number }>({
|
||||
left: 0,
|
||||
width: 0,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const HEADER_OFFSET = 64; // px; matches fixed top nav height
|
||||
|
||||
const handleScroll = () => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
// Hide fixed header on mobile (screens smaller than 620px)
|
||||
if (window.innerWidth < 620) {
|
||||
setIsHeaderFixed(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const pastHeader = rect.top <= HEADER_OFFSET;
|
||||
const aboveBottom = rect.bottom > HEADER_OFFSET + 40;
|
||||
|
||||
const shouldFix = pastHeader && aboveBottom;
|
||||
setIsHeaderFixed(shouldFix);
|
||||
|
||||
if (shouldFix) {
|
||||
setHeaderRect({
|
||||
left: rect.left,
|
||||
width: rect.width,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
handleScroll();
|
||||
window.addEventListener("scroll", handleScroll, { passive: true });
|
||||
window.addEventListener("resize", handleScroll);
|
||||
return () => {
|
||||
window.removeEventListener("scroll", handleScroll);
|
||||
window.removeEventListener("resize", handleScroll);
|
||||
};
|
||||
}, []);
|
||||
const planSpecificLimits: PlanFeature[] = [
|
||||
{
|
||||
name: t`Core features`,
|
||||
features: [
|
||||
{
|
||||
key: "ulimited-workspaces",
|
||||
label: t`Unlimited workspaces`,
|
||||
kan: true,
|
||||
trello: false,
|
||||
},
|
||||
{
|
||||
key: "unlimited-boards",
|
||||
label: t`Unlimited boards`,
|
||||
kan: true,
|
||||
trello: false,
|
||||
},
|
||||
{
|
||||
key: "unlimited-lists",
|
||||
label: t`Unlimited lists`,
|
||||
kan: true,
|
||||
trello: true,
|
||||
},
|
||||
{
|
||||
key: "unlimited-cards",
|
||||
label: t`Unlimited cards`,
|
||||
kan: true,
|
||||
trello: true,
|
||||
},
|
||||
{
|
||||
key: "activity-log",
|
||||
label: t`Activity log`,
|
||||
kan: true,
|
||||
trello: true,
|
||||
},
|
||||
{
|
||||
key: "templates",
|
||||
label: t`Custom templates`,
|
||||
kan: true,
|
||||
trello: false,
|
||||
},
|
||||
{ key: "labels", label: t`Labels & filters`, kan: true, trello: true },
|
||||
{ key: "checklists", label: t`Checklists`, kan: true, trello: true },
|
||||
{
|
||||
key: "import",
|
||||
label: t`Import from Trello`,
|
||||
kan: true,
|
||||
trello: false,
|
||||
},
|
||||
{
|
||||
key: "visibility",
|
||||
label: t`Board visibility`,
|
||||
kan: true,
|
||||
trello: true,
|
||||
},
|
||||
{
|
||||
key: "search",
|
||||
label: t`Intelligent search`,
|
||||
kan: true,
|
||||
trello: true,
|
||||
},
|
||||
{
|
||||
key: "workspace-url",
|
||||
label: t`Custom workspace link`,
|
||||
kan: true,
|
||||
trello: false,
|
||||
},
|
||||
],
|
||||
key: "boards",
|
||||
label: t`Boards`,
|
||||
free: { text: t`Unlimited boards`, highlight: "Unlimited" },
|
||||
teams: { text: t`Unlimited boards`, highlight: "Unlimited" },
|
||||
pro: { text: t`Unlimited boards`, highlight: "Unlimited" },
|
||||
enterprise: { text: t`Unlimited boards`, highlight: "Unlimited" },
|
||||
},
|
||||
{
|
||||
name: t`Teams`,
|
||||
features: [
|
||||
{
|
||||
key: "pricing",
|
||||
label: t`Price per user/month`,
|
||||
kan: frequencyValue === "monthly" ? `$10.00` : `$8.00`,
|
||||
trello: frequencyValue === "monthly" ? `$12.00` : `$10.00`,
|
||||
},
|
||||
{
|
||||
key: "comments",
|
||||
label: t`Comments & mentions`,
|
||||
kan: true,
|
||||
trello: true,
|
||||
},
|
||||
{
|
||||
key: "assignments",
|
||||
label: t`Assignees`,
|
||||
kan: true,
|
||||
trello: true,
|
||||
},
|
||||
{
|
||||
key: "sharing",
|
||||
label: t`Board sharing & invites`,
|
||||
kan: true,
|
||||
trello: true,
|
||||
},
|
||||
{
|
||||
key: "invite-links",
|
||||
label: t`Invite links`,
|
||||
kan: true,
|
||||
trello: true,
|
||||
},
|
||||
],
|
||||
key: "members",
|
||||
label: t`Members`,
|
||||
free: { text: t`1 user`, highlight: "1" },
|
||||
teams: { text: t`Per-seat pricing`, highlight: "Per-seat" },
|
||||
pro: { text: t`Unlimited members`, highlight: "Unlimited" },
|
||||
enterprise: { text: t`Unlimited members`, highlight: "Unlimited" },
|
||||
},
|
||||
{
|
||||
key: "file-uploads",
|
||||
label: t`File uploads`,
|
||||
free: { text: t`10mb file uploads`, highlight: "10mb" },
|
||||
teams: { text: t`Unlimited file uploads`, highlight: "Unlimited" },
|
||||
pro: { text: t`Unlimited file uploads`, highlight: "Unlimited" },
|
||||
enterprise: { text: t`Unlimited file uploads`, highlight: "Unlimited" },
|
||||
},
|
||||
{
|
||||
key: "workspace-username",
|
||||
label: t`Workspace username`,
|
||||
free: { text: t`Default username`, highlight: "Default" },
|
||||
teams: { text: t`Default username`, highlight: "Default" },
|
||||
pro: { text: t`Custom username`, highlight: "Custom" },
|
||||
enterprise: { text: t`Custom username`, highlight: "Custom" },
|
||||
},
|
||||
];
|
||||
|
||||
const coreFeatures: PlanFeature[] = [
|
||||
{
|
||||
key: "checklists",
|
||||
label: t`Checklists`,
|
||||
free: true,
|
||||
teams: true,
|
||||
pro: true,
|
||||
enterprise: true,
|
||||
},
|
||||
{
|
||||
key: "custom-templates",
|
||||
label: t`Board templates`,
|
||||
free: true,
|
||||
teams: true,
|
||||
pro: true,
|
||||
enterprise: true,
|
||||
},
|
||||
{
|
||||
key: "labels-filters",
|
||||
label: t`Labels & filters`,
|
||||
free: true,
|
||||
teams: true,
|
||||
pro: true,
|
||||
enterprise: true,
|
||||
},
|
||||
{
|
||||
key: "board-visibility",
|
||||
label: t`Board visibility`,
|
||||
free: true,
|
||||
teams: true,
|
||||
pro: true,
|
||||
enterprise: true,
|
||||
},
|
||||
{
|
||||
key: "search",
|
||||
label: t`Intelligent search`,
|
||||
free: true,
|
||||
teams: true,
|
||||
pro: true,
|
||||
enterprise: true,
|
||||
},
|
||||
{
|
||||
key: "activity-log",
|
||||
label: t`Activity log`,
|
||||
free: true,
|
||||
teams: true,
|
||||
pro: true,
|
||||
enterprise: true,
|
||||
},
|
||||
{
|
||||
key: "comments",
|
||||
label: t`Comments`,
|
||||
free: true,
|
||||
teams: true,
|
||||
pro: true,
|
||||
enterprise: true,
|
||||
},
|
||||
{
|
||||
key: "roles-permissions",
|
||||
label: t`Roles & permissions`,
|
||||
free: false,
|
||||
teams: false,
|
||||
pro: true,
|
||||
enterprise: true,
|
||||
},
|
||||
{
|
||||
key: "custom-branding",
|
||||
label: t`Custom branding`,
|
||||
free: false,
|
||||
teams: false,
|
||||
pro: true,
|
||||
enterprise: true,
|
||||
},
|
||||
];
|
||||
|
||||
const collaborationFeatures: PlanFeature[] = [
|
||||
{
|
||||
key: "mentions",
|
||||
label: t`Mentions`,
|
||||
free: false,
|
||||
teams: true,
|
||||
pro: true,
|
||||
enterprise: true,
|
||||
},
|
||||
{
|
||||
key: "email-notifications",
|
||||
label: t`Email notifications`,
|
||||
free: false,
|
||||
teams: true,
|
||||
pro: true,
|
||||
enterprise: true,
|
||||
},
|
||||
{
|
||||
key: "assignees",
|
||||
label: t`Assignees`,
|
||||
free: false,
|
||||
teams: true,
|
||||
pro: true,
|
||||
enterprise: true,
|
||||
},
|
||||
{
|
||||
key: "invite-links",
|
||||
label: t`Invite links`,
|
||||
free: false,
|
||||
teams: true,
|
||||
pro: true,
|
||||
enterprise: true,
|
||||
},
|
||||
];
|
||||
|
||||
const importsFeatures: PlanFeature[] = [
|
||||
{
|
||||
key: "import-export",
|
||||
label: t`Trello`,
|
||||
free: true,
|
||||
teams: true,
|
||||
pro: true,
|
||||
enterprise: true,
|
||||
},
|
||||
];
|
||||
|
||||
const platformFeatures: PlanFeature[] = [
|
||||
{
|
||||
key: "open-source",
|
||||
label: t`Open source`,
|
||||
free: true,
|
||||
teams: true,
|
||||
pro: true,
|
||||
enterprise: true,
|
||||
},
|
||||
{
|
||||
key: "rest-api",
|
||||
label: t`API`,
|
||||
free: { text: t`Limited API access` },
|
||||
teams: { text: t`Full API access` },
|
||||
pro: { text: t`Full API access` },
|
||||
enterprise: { text: t`Full API access` },
|
||||
},
|
||||
{
|
||||
key: "self-hostable",
|
||||
label: t`On-premise`,
|
||||
free: false,
|
||||
teams: false,
|
||||
pro: false,
|
||||
enterprise: true,
|
||||
},
|
||||
];
|
||||
|
||||
const securityFeatures: PlanFeature[] = [
|
||||
{
|
||||
key: "sso",
|
||||
label: t`SSO`,
|
||||
free: { text: t`Google SSO` },
|
||||
teams: { text: t`Google SSO` },
|
||||
pro: { text: t`Google SSO` },
|
||||
enterprise: { text: t`Google SSO + SAML` },
|
||||
},
|
||||
{
|
||||
key: "admin-roles",
|
||||
label: t`Admin roles`,
|
||||
free: { text: t`Admin roles` },
|
||||
teams: { text: t`Admin roles` },
|
||||
pro: { text: t`Admin roles` },
|
||||
enterprise: { text: t`Advanced admin roles` },
|
||||
},
|
||||
];
|
||||
|
||||
const supportFeatures: PlanFeature[] = [
|
||||
{
|
||||
key: "priority-email-support",
|
||||
label: t`Priority email support`,
|
||||
free: false,
|
||||
teams: true,
|
||||
pro: true,
|
||||
enterprise: true,
|
||||
},
|
||||
{
|
||||
key: "account-manager",
|
||||
label: t`Account manager`,
|
||||
free: false,
|
||||
teams: false,
|
||||
pro: false,
|
||||
enterprise: true,
|
||||
},
|
||||
{
|
||||
key: "sla",
|
||||
label: t`SLA`,
|
||||
free: false,
|
||||
teams: false,
|
||||
pro: false,
|
||||
enterprise: true,
|
||||
},
|
||||
];
|
||||
|
||||
const sections: FeatureSection[] = [
|
||||
{
|
||||
name: "",
|
||||
features: planSpecificLimits,
|
||||
},
|
||||
{
|
||||
name: t`Core features`,
|
||||
features: coreFeatures,
|
||||
},
|
||||
{
|
||||
name: t`Collaboration`,
|
||||
features: collaborationFeatures,
|
||||
},
|
||||
{
|
||||
name: t`Imports`,
|
||||
features: importsFeatures,
|
||||
},
|
||||
{
|
||||
name: t`Platform`,
|
||||
features: [
|
||||
{
|
||||
key: "api",
|
||||
label: t`REST API`,
|
||||
kan: true,
|
||||
trello: true,
|
||||
},
|
||||
{
|
||||
key: "open-source",
|
||||
label: t`Open source`,
|
||||
kan: true,
|
||||
trello: false,
|
||||
},
|
||||
{
|
||||
key: "self-hostable",
|
||||
label: t`Self-hostable`,
|
||||
kan: true,
|
||||
trello: false,
|
||||
},
|
||||
{
|
||||
key: "white-label",
|
||||
label: t`White label`,
|
||||
kan: true,
|
||||
trello: false,
|
||||
},
|
||||
{
|
||||
key: "performance",
|
||||
label: t`Fast & lightweight`,
|
||||
kan: true,
|
||||
trello: false,
|
||||
},
|
||||
{
|
||||
key: "this-is-a-joke",
|
||||
label: t`Owned by Atlassian`,
|
||||
kan: false,
|
||||
trello: true,
|
||||
},
|
||||
],
|
||||
features: platformFeatures,
|
||||
},
|
||||
{
|
||||
name: t`Security`,
|
||||
features: securityFeatures,
|
||||
},
|
||||
{
|
||||
name: t`Support`,
|
||||
features: supportFeatures,
|
||||
},
|
||||
];
|
||||
|
||||
const products = [
|
||||
{
|
||||
id: "kan",
|
||||
name: t`Kan`,
|
||||
featured: true,
|
||||
},
|
||||
{
|
||||
id: "trello",
|
||||
name: t`Trello`,
|
||||
featured: false,
|
||||
},
|
||||
const plans = [
|
||||
{ id: "free", name: t`Free`, tierId: "tier-free" },
|
||||
{ id: "teams", name: t`Teams`, tierId: "tier-teams" },
|
||||
{ id: "pro", name: t`Pro`, tierId: "tier-pro" },
|
||||
{ id: "enterprise", name: t`Enterprise`, tierId: "tier-enterprise" },
|
||||
];
|
||||
|
||||
const isHighlightValue = (
|
||||
value: string | boolean | { text: string; highlight?: string },
|
||||
): value is { text: string; highlight?: string } => {
|
||||
return typeof value === "object" && value !== null && "text" in value;
|
||||
};
|
||||
|
||||
const shouldHighlight = (text: string): boolean => {
|
||||
const lowerText = text.toLowerCase();
|
||||
return ["unlimited", "multiple", "per-seat", "custom", "mentions", "full", "sso", "admin"].some(
|
||||
(keyword) => lowerText.includes(keyword),
|
||||
);
|
||||
};
|
||||
|
||||
const CellValue = ({
|
||||
value,
|
||||
label,
|
||||
}: {
|
||||
value: string | boolean | { text: string; highlight?: string };
|
||||
label: string;
|
||||
}) => {
|
||||
// Handle object with text and highlight
|
||||
if (isHighlightValue(value)) {
|
||||
const { text, highlight } = value;
|
||||
const isUnlimited = shouldHighlight(text);
|
||||
|
||||
let displayText;
|
||||
if (highlight) {
|
||||
const parts = text.split(new RegExp(`(${highlight})`, "gi"));
|
||||
displayText = (
|
||||
<>
|
||||
{parts.map((part, index) =>
|
||||
part.toLowerCase() === highlight.toLowerCase() ? (
|
||||
<span key={index} className="text-light-1000 dark:text-dark-1000">
|
||||
{part}
|
||||
</span>
|
||||
) : (
|
||||
<span key={index} className="text-light-950 dark:text-dark-800">
|
||||
{part}
|
||||
</span>
|
||||
),
|
||||
)}
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
displayText = text;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2.5">
|
||||
<HiCheckCircle
|
||||
className={twMerge(
|
||||
"mt-0.5 h-4 w-4 shrink-0",
|
||||
isUnlimited
|
||||
? "text-light-1000 dark:text-dark-1000"
|
||||
: "text-light-400 dark:text-dark-600",
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={twMerge(
|
||||
"text-sm font-medium",
|
||||
isUnlimited
|
||||
? "text-light-1000 dark:text-dark-950"
|
||||
: "text-light-950 dark:text-dark-800",
|
||||
)}
|
||||
>
|
||||
{displayText}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
const isUnlimited = shouldHighlight(value);
|
||||
return (
|
||||
<div className="flex items-center gap-2.5">
|
||||
<HiCheckCircle
|
||||
className={twMerge(
|
||||
"mt-0.5 h-4 w-4 shrink-0",
|
||||
isUnlimited
|
||||
? "text-light-1000 dark:text-dark-1000"
|
||||
: "text-light-400 dark:text-dark-600",
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={twMerge(
|
||||
"text-sm font-medium",
|
||||
isUnlimited
|
||||
? "text-light-1000 dark:text-dark-950"
|
||||
: "text-light-950 dark:text-dark-800",
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (value) {
|
||||
// Boolean true - show checkmark icon
|
||||
return (
|
||||
<div className="flex items-center gap-2.5">
|
||||
<HiCheckCircle className="mt-0.5 h-4 w-4 shrink-0 text-light-1000 dark:text-dark-1000" />
|
||||
<span className="text-sm font-medium text-light-1000 dark:text-dark-950">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// Boolean false - show cross icon
|
||||
return (
|
||||
<div className="flex items-center gap-2.5">
|
||||
<HiXCircle className="mt-0.5 h-4 w-4 shrink-0 text-light-400 dark:text-dark-600" />
|
||||
<span className="text-sm font-medium text-light-800 dark:text-dark-800">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getFeatureValue = (
|
||||
feature: PlanFeature,
|
||||
planId: string,
|
||||
): string | boolean | { text: string; highlight?: string } => {
|
||||
switch (planId) {
|
||||
case "free":
|
||||
return feature.free;
|
||||
case "teams":
|
||||
return feature.teams;
|
||||
case "pro":
|
||||
return feature.pro;
|
||||
case "enterprise":
|
||||
return feature.enterprise;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section aria-labelledby="comparison-heading" className="w-full px-4">
|
||||
<div className="mx-auto max-w-6xl py-8 lg:py-10">
|
||||
<div className="grid grid-cols-3 gap-x-8 border-t border-light-500 before:block dark:border-dark-300">
|
||||
{products.map((product) => (
|
||||
<div key={product.id} aria-hidden="true" className="-mt-px">
|
||||
<div
|
||||
className={`${product.featured ? "border-dark-200 dark:border-dark-800" : "border-transparent"} border-t-2 pt-8`}
|
||||
>
|
||||
<p
|
||||
className={`${product.featured ? "text-dark-50 dark:text-dark-1000" : "text-light-1000 dark:text-dark-1000"} text-center text-sm font-semibold`}
|
||||
>
|
||||
{product.name}
|
||||
</p>
|
||||
<section aria-labelledby="comparison-heading" className="w-full">
|
||||
<div className="mx-auto max-w-7xl">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative overflow-x-auto rounded-lg border border-light-300 bg-light-50 dark:border-dark-300 dark:bg-dark-50"
|
||||
>
|
||||
{isHeaderFixed && (
|
||||
<div
|
||||
className="hidden sm:block fixed z-40 border-b border-light-300 bg-light-50/95 dark:border-dark-400 dark:bg-dark-50/95"
|
||||
style={{
|
||||
top: 64,
|
||||
left: headerRect.left,
|
||||
width: headerRect.width,
|
||||
}}
|
||||
>
|
||||
<div className="grid grid-cols-4 border-x border-light-300 dark:border-dark-300">
|
||||
{plans.map((plan) => {
|
||||
const isMostPopular = plan.id === "pro";
|
||||
return (
|
||||
<div
|
||||
key={`fixed-${plan.id}`}
|
||||
className={twMerge(
|
||||
"flex items-center px-6 py-3 text-left text-base font-semibold",
|
||||
isMostPopular
|
||||
? "bg-light-200 text-light-1000 dark:bg-dark-100 dark:text-dark-1000"
|
||||
: "bg-light-50 text-dark-50 dark:bg-dark-50 dark:text-dark-1000",
|
||||
)}
|
||||
>
|
||||
{plan.name}
|
||||
{plan.id === "pro" && <span className="ml-1 text-xl">∞</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="-mt-6 space-y-12">
|
||||
{sections.map((section) => (
|
||||
<div key={section.name}>
|
||||
<h4 className="text-sm font-semibold text-light-1000 dark:text-dark-1000">
|
||||
{section.name}
|
||||
</h4>
|
||||
<div className="relative -mx-8 mt-8">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute inset-x-8 inset-y-0 grid grid-cols-3 gap-x-8 before:block"
|
||||
>
|
||||
{products.map((product) => (
|
||||
<div
|
||||
key={product.id}
|
||||
className={`size-full rounded-lg ${product.featured ? "bg-white shadow-sm dark:bg-dark-50 dark:shadow-none" : ""}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<table className="relative w-full border-separate border-spacing-x-8">
|
||||
<thead>
|
||||
<tr className="text-left">
|
||||
<th scope="col">
|
||||
<span className="sr-only">{t`Feature`}</span>
|
||||
</th>
|
||||
{products.map((p) => (
|
||||
<th key={p.id} scope="col">
|
||||
<span className="sr-only">{p.name}</span>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{section.features.map((feature, featureIdx) => (
|
||||
<tr key={feature.key}>
|
||||
<th
|
||||
scope="row"
|
||||
className="w-1/3 py-3 pr-4 text-left text-sm font-normal text-light-1000 dark:text-dark-1000"
|
||||
>
|
||||
{feature.label}
|
||||
{featureIdx !== section.features.length - 1 ? (
|
||||
<div className="absolute inset-x-8 mt-3 h-px bg-light-500 dark:bg-dark-300" />
|
||||
) : null}
|
||||
</th>
|
||||
{products.map((p) => (
|
||||
)}
|
||||
<table className="w-full border-separate border-spacing-0">
|
||||
<thead>
|
||||
<tr>
|
||||
{plans.map((plan) => {
|
||||
const isMostPopular = plan.id === "pro";
|
||||
return (
|
||||
<th
|
||||
key={plan.id}
|
||||
scope="col"
|
||||
className={twMerge(
|
||||
"w-1/4 px-6 py-4 text-left text-base font-semibold",
|
||||
isMostPopular
|
||||
? "bg-light-200 text-light-1000 dark:bg-dark-100 dark:text-dark-1000"
|
||||
: "bg-light-50 text-dark-50 dark:bg-dark-50 dark:text-dark-1000",
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center">
|
||||
{plan.name}
|
||||
{plan.id === "pro" && <span className="ml-1 text-xl">∞</span>}
|
||||
</span>
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sections.map((section, sectionIdx) => (
|
||||
<React.Fragment key={section.name || `section-${sectionIdx}`}>
|
||||
{section.name && (
|
||||
<tr>
|
||||
{plans.map((plan) => {
|
||||
const isPro = plan.id === "pro";
|
||||
return (
|
||||
<td
|
||||
key={p.id}
|
||||
className="relative w-1/3 px-4 py-0 text-center"
|
||||
key={plan.id}
|
||||
className={twMerge(
|
||||
"border-t px-6 py-3 text-left text-sm font-semibold text-dark-900 dark:text-dark-900",
|
||||
isPro
|
||||
? "border-light-400 bg-light-200 dark:border-dark-400 dark:bg-dark-100"
|
||||
: "border-light-300 bg-light-50 dark:border-dark-400 dark:bg-dark-50",
|
||||
)}
|
||||
>
|
||||
<span className="relative inline-flex w-full items-center justify-center py-3">
|
||||
<CellValue
|
||||
value={
|
||||
p.id === "kan" ? feature.kan : feature.trello
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
{plan.id === "free" ? section.name : ""}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-x-8 inset-y-0 grid grid-cols-3 gap-x-8 before:block"
|
||||
>
|
||||
{products.map((product) => (
|
||||
<div
|
||||
key={product.id}
|
||||
className={`${product.featured ? "ring-1 ring-light-500 dark:ring-dark-800" : "ring-0"} rounded-2xl`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
)}
|
||||
{section.features.map((feature) => (
|
||||
<tr key={feature.key}>
|
||||
{plans.map((plan) => {
|
||||
const isPro = plan.id === "pro";
|
||||
return (
|
||||
<td
|
||||
key={plan.id}
|
||||
className={twMerge(
|
||||
"w-1/4 border-t px-6 py-3 text-left",
|
||||
isPro
|
||||
? "border-light-400 bg-light-200 dark:border-dark-400 dark:bg-dark-100"
|
||||
: "border-light-300 dark:border-dark-400",
|
||||
)}
|
||||
>
|
||||
<CellValue
|
||||
value={getFeatureValue(feature, plan.id)}
|
||||
label={feature.label}
|
||||
/>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -23,81 +23,115 @@ const Pricing = ({
|
||||
}) => {
|
||||
const tiers = [
|
||||
{
|
||||
name: t`Individuals`,
|
||||
id: "tier-individuals",
|
||||
name: t`Free`,
|
||||
id: "tier-free",
|
||||
href: "signup",
|
||||
buttonText: t`Get Started`,
|
||||
price: { monthly: t`Free`, annually: t`Free` },
|
||||
description: t`Everything you need, free forever. Unlimited boards, unlimited lists, unlimited cards. Upgrade any time.`,
|
||||
featureHeader: t`Free, forever`,
|
||||
buttonText: t`Get started`,
|
||||
price: { monthly: t`$0`, annually: t`$0` },
|
||||
description: t`Free for everyone`,
|
||||
bestFor: t`Best for individuals and solo creators getting started`,
|
||||
featureHeader: undefined,
|
||||
features: [
|
||||
t`1 user`,
|
||||
t`Unlimited boards`,
|
||||
t`Unlimited cards`,
|
||||
t`Custom board templates`,
|
||||
t`Checklists`,
|
||||
{ text: t`1 user` },
|
||||
{ text: t`Unlimited boards` },
|
||||
{ text: t`Unlimited cards` },
|
||||
{ text: t`Custom board templates` },
|
||||
{ text: t`Checklists` },
|
||||
],
|
||||
showPrice: true,
|
||||
ctaSubtext: undefined,
|
||||
mostPopular: false,
|
||||
highlighted: false,
|
||||
},
|
||||
{
|
||||
name: t`Teams`,
|
||||
id: "tier-teams",
|
||||
href: "signup",
|
||||
buttonText: t`Get Started`,
|
||||
price: { monthly: "$10.00", annually: "$8.00" },
|
||||
description: t`Kan is better with a team. Perfect for small and growing teams looking to collaborate.`,
|
||||
featureHeader: t`Everything in the free plan, plus:`,
|
||||
buttonText: t`Get started`,
|
||||
price: { monthly: "$10", annually: "$8" },
|
||||
description: t`Perfect for small and growing teams looking to collaborate.`,
|
||||
bestFor: t`Best for small and growing teams that need collaboration`,
|
||||
featureHeader: undefined,
|
||||
features: [
|
||||
t`Workspace members`,
|
||||
t`Admin roles`,
|
||||
t`Priority email support`,
|
||||
t`Support the development of the project`,
|
||||
{ text: t`All Free features +` },
|
||||
{ text: t`Workspace members` },
|
||||
{ text: t`Invite links` },
|
||||
{ text: t`Unlimited file uploads` },
|
||||
{ text: t`Email notifications for mentions` },
|
||||
{ text: t`Priority email support` },
|
||||
],
|
||||
highlighted: false,
|
||||
showPrice: true,
|
||||
showPriceSuffix: true,
|
||||
ctaSubtext: t`14 day free trial · Switch plans or cancel anytime`,
|
||||
mostPopular: false,
|
||||
},
|
||||
{
|
||||
name: t`Pro`,
|
||||
id: "tier-pro",
|
||||
href: "signup",
|
||||
buttonText: t`Get started`,
|
||||
price: { monthly: "$29", annually: "$24" },
|
||||
description: t`Unlimited seats and advanced features for growing teams.`,
|
||||
bestFor: t`Best for teams that want to scale without limits`,
|
||||
featureHeader: undefined,
|
||||
features: [
|
||||
{ text: t`All Teams features +` },
|
||||
{ text: t`Unlimited members` },
|
||||
{ text: t`Custom workspace username` },
|
||||
{ text: t`Configurable user roles and permissions` },
|
||||
{ text: t`Custom branding` },
|
||||
],
|
||||
highlighted: true,
|
||||
showPrice: true,
|
||||
showPriceSuffix: true,
|
||||
showPriceSuffix: false,
|
||||
ctaSubtext: t`14 day free trial · Switch plans or cancel anytime`,
|
||||
mostPopular: true,
|
||||
},
|
||||
{
|
||||
name: t`Self Host`,
|
||||
id: "tier-self-host",
|
||||
href: "https://github.com/kanbn/kan",
|
||||
buttonText: t`View docs`,
|
||||
price: { monthly: "-", annually: "-" },
|
||||
description: t`Host Kan on your own infrastructure. Ideal for organisations that need complete control over their data.`,
|
||||
featureHeader: t`Complete control and ownership:`,
|
||||
name: t`Enterprise`,
|
||||
id: "tier-enterprise",
|
||||
href: "mailto:support@kan.bn?subject=Enterprise Inquiry",
|
||||
buttonText: t`Contact Sales`,
|
||||
price: { monthly: t`Contact us`, annually: t`Contact us` },
|
||||
description: t`Advanced security, compliance, and dedicated support for large organizations.`,
|
||||
bestFor: t`Best for large organizations with advanced needs`,
|
||||
featureHeader: undefined,
|
||||
features: [
|
||||
t`Run on your own infrastructure`,
|
||||
t`Own your data`,
|
||||
t`Custom domain`,
|
||||
{ text: t`All Pro features +` },
|
||||
{ text: t`SAML SSO` },
|
||||
{ text: t`Advanced admin controls` },
|
||||
{ text: t`Dedicated account manager` },
|
||||
{ text: t`Custom SLA` },
|
||||
{ text: t`On-premise deployment option` },
|
||||
],
|
||||
highlighted: false,
|
||||
showPrice: true,
|
||||
showPriceSuffix: false,
|
||||
ctaSubtext: undefined,
|
||||
mostPopular: false,
|
||||
showPrice: false,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col items-center justify-center px-4 pb-10">
|
||||
<div className="mt-14 flex flex-col items-center justify-center">
|
||||
<div className="mb-8 flex items-center gap-2 rounded-full border bg-white px-4 py-1.5 text-center text-xs font-bold text-gray-800 dark:border-dark-300 dark:bg-dark-1000 dark:text-gray-800 lg:text-sm">
|
||||
<HiBolt />
|
||||
<p>{t`Launch offer: unlimited seats for just $29/month with Pro`}</p>
|
||||
</div>
|
||||
<div className="mx-auto max-w-7xl px-4">
|
||||
<div className="mb-8 flex items-center justify-center">
|
||||
<fieldset aria-label={t`Payment frequency`}>
|
||||
<RadioGroup
|
||||
value={frequency}
|
||||
onChange={(value) => setFrequency(value)}
|
||||
className="grid grid-cols-2 gap-x-1 rounded-full p-1 text-center text-xs/5 font-semibold ring-1 ring-inset ring-light-600 dark:ring-dark-600"
|
||||
className="flex gap-1 rounded-lg border border-light-300 bg-light-50 p-1 dark:border-dark-300 dark:bg-dark-50"
|
||||
>
|
||||
{frequencies.map((option) => (
|
||||
<Radio
|
||||
key={option.value}
|
||||
value={option}
|
||||
className={twMerge(
|
||||
"cursor-pointer rounded-full px-2.5 py-1 text-xs transition-colors lg:text-sm",
|
||||
"cursor-pointer rounded-md px-4 py-1.5 text-sm font-medium transition-colors",
|
||||
frequency?.value === option.value
|
||||
? "bg-dark-50 text-white dark:bg-light-50 dark:text-dark-50"
|
||||
: "text-light-900 hover:bg-light-100 dark:hover:bg-dark-100",
|
||||
? "bg-white text-light-1000 shadow-sm dark:bg-dark-100 dark:text-dark-1000"
|
||||
: "text-light-600 dark:text-dark-700",
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
@@ -106,103 +140,129 @@ const Pricing = ({
|
||||
</RadioGroup>
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="isolate mx-auto mb-10 grid max-w-md grid-cols-1 gap-8 px-4 lg:mx-0 lg:max-w-none lg:grid-cols-3">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{tiers.map((tier) => (
|
||||
<div
|
||||
key={tier.id}
|
||||
className={twMerge(
|
||||
tier.highlighted
|
||||
? "bg-dark-50 ring-1 ring-dark-50 dark:ring-dark-800"
|
||||
: "bg-light-50 ring-1 ring-light-300 dark:bg-dark-50 dark:ring-dark-300",
|
||||
"rounded-3xl p-8 xl:p-10",
|
||||
"relative flex h-full flex-col rounded-lg border p-6",
|
||||
tier.mostPopular
|
||||
? "border-light-400 bg-light-200 dark:border-dark-400 dark:bg-dark-100"
|
||||
: "border-light-300 bg-light-50 dark:border-dark-300 dark:bg-dark-50",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-x-4">
|
||||
<h3
|
||||
id={tier.id}
|
||||
className={twMerge(
|
||||
tier.highlighted
|
||||
? "text-dark-1000 dark:text-dark-1000"
|
||||
: "text-dark-50 dark:text-dark-1000",
|
||||
"text-lg/8 font-semibold",
|
||||
{tier.id === "tier-pro" && (
|
||||
<div className="absolute right-2 top-2 z-10">
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-light-400 bg-light-100 px-3 py-1 text-center text-xs text-dark-600 dark:border-dark-400 dark:bg-dark-100 dark:text-dark-900">
|
||||
<HiBolt className="h-3 w-3 -ml-0.5" />
|
||||
{t`Launch offer`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<h3
|
||||
id={tier.id}
|
||||
className={twMerge(
|
||||
"mb-4 flex items-center text-base font-semibold",
|
||||
tier.mostPopular
|
||||
? "text-light-1000 dark:text-dark-1000"
|
||||
: "text-light-1000 dark:text-dark-1000",
|
||||
)}
|
||||
>
|
||||
{tier.name}
|
||||
{tier.id === "tier-pro" && <span className="ml-1 text-xl leading-none">∞</span>}
|
||||
</h3>
|
||||
<div className="mb-4">
|
||||
<p className="flex items-baseline gap-x-1">
|
||||
<span
|
||||
className={twMerge(
|
||||
"text-2xl font-semibold",
|
||||
tier.mostPopular
|
||||
? "text-light-1000 dark:text-dark-1000"
|
||||
: "text-light-1000 dark:text-dark-1000",
|
||||
!tier.showPrice && "opacity-0",
|
||||
)}
|
||||
>
|
||||
{tier.price[frequency?.value ?? "monthly"]}
|
||||
</span>
|
||||
{tier.id === "tier-pro" && (
|
||||
<span
|
||||
className={twMerge(
|
||||
"text-2xl font-semibold line-through",
|
||||
tier.mostPopular
|
||||
? "text-light-600 dark:text-dark-600"
|
||||
: "text-light-600 dark:text-dark-600",
|
||||
)}
|
||||
>
|
||||
{frequency?.value === "annually" ? "$79" : "$100"}
|
||||
</span>
|
||||
)}
|
||||
>
|
||||
{tier.name}
|
||||
</h3>
|
||||
{tier.highlighted && (
|
||||
<p className="rounded-full bg-light-50 px-2.5 py-1 text-[12px] font-semibold text-dark-500 dark:bg-dark-1000 dark:text-dark-50">
|
||||
{t`14 day free trial`}
|
||||
</p>
|
||||
{tier.showPriceSuffix && (
|
||||
<span className="text-sm font-normal text-dark-400 dark:text-dark-700">
|
||||
{frequency?.priceSuffix}
|
||||
</span>
|
||||
)}
|
||||
{tier.id === "tier-pro" && (
|
||||
<span className="text-sm font-normal text-dark-400 dark:text-dark-700">
|
||||
{t`/month`}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{tier.bestFor && (
|
||||
<p className="mb-6 text-sm text-dark-400 dark:text-dark-700">
|
||||
{tier.bestFor}
|
||||
</p>
|
||||
)}
|
||||
<div className="mb-6">
|
||||
{tier.id === "tier-enterprise" ? (
|
||||
<a
|
||||
href={tier.href}
|
||||
aria-describedby={tier.id}
|
||||
className="block w-full rounded-md border border-dark-200 bg-transparent px-4 py-2 text-center text-sm font-medium text-dark-50 transition-colors hover:bg-dark-50 hover:text-light-50 dark:border-dark-300 dark:text-dark-1000 dark:hover:bg-dark-200"
|
||||
>
|
||||
{tier.buttonText}
|
||||
</a>
|
||||
) : (
|
||||
<>
|
||||
<Link
|
||||
href={tier.href}
|
||||
aria-describedby={tier.id}
|
||||
className={twMerge(
|
||||
"block w-full rounded-md px-4 py-2 text-center text-sm font-medium transition-colors",
|
||||
tier.mostPopular
|
||||
? "bg-dark-50 text-light-50 shadow-sm hover:bg-dark-100 dark:bg-dark-1000 dark:text-dark-50 dark:hover:bg-dark-900"
|
||||
: "border border-dark-200 bg-transparent text-dark-50 transition-colors hover:bg-dark-50 hover:text-light-50 dark:border-dark-300 dark:text-dark-1000 dark:hover:bg-dark-200",
|
||||
)}
|
||||
>
|
||||
{tier.buttonText}
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<p
|
||||
className={twMerge(
|
||||
"mt-4 text-sm/6 text-dark-950 dark:text-dark-900",
|
||||
tier.highlighted ? "text-light-100" : "text-dark-50",
|
||||
)}
|
||||
>
|
||||
{tier.description}
|
||||
</p>
|
||||
<p className="mt-6 flex items-baseline gap-x-1">
|
||||
<span
|
||||
className={twMerge(
|
||||
"text-3xl font-semibold tracking-tight text-light-100",
|
||||
tier.highlighted
|
||||
? "text-light-50 dark:text-dark-1000"
|
||||
: "text-gray-900 dark:text-dark-1000",
|
||||
!tier.showPrice && "opacity-0",
|
||||
)}
|
||||
>
|
||||
{tier.price[frequency?.value ?? "monthly"]}
|
||||
</span>
|
||||
{tier.showPriceSuffix && (
|
||||
<span className="text-sm/6 font-semibold text-light-50 dark:text-dark-900">
|
||||
{frequency?.priceSuffix}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<Link
|
||||
href={tier.href}
|
||||
aria-describedby={tier.id}
|
||||
className={twMerge(
|
||||
tier.highlighted
|
||||
? "bg-light-50 text-dark-50 shadow-sm dark:bg-dark-1000 dark:text-dark-50"
|
||||
: "bg-dark-50 text-light-50 dark:bg-dark-200 dark:text-dark-1000",
|
||||
"mt-6 block rounded-md px-3 py-2 text-center text-sm/6 font-semibold focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600",
|
||||
)}
|
||||
>
|
||||
{tier.buttonText}
|
||||
</Link>
|
||||
<p
|
||||
className={twMerge(
|
||||
"mt-8 text-sm/6 font-bold",
|
||||
tier.highlighted
|
||||
? "text-light-100 dark:text-dark-1000"
|
||||
: "text-dark-50 dark:text-dark-1000",
|
||||
)}
|
||||
>
|
||||
{tier.featureHeader}
|
||||
</p>
|
||||
<ul
|
||||
role="list"
|
||||
className={twMerge(
|
||||
"mt-2 space-y-3 text-sm/6 text-light-600",
|
||||
tier.highlighted
|
||||
? "text-light-100 dark:text-dark-1000"
|
||||
: "text-dark-50 dark:text-dark-1000",
|
||||
)}
|
||||
>
|
||||
{tier.features.map((feature) => (
|
||||
<li key={feature} className="flex items-center gap-x-3">
|
||||
<HiCheckCircle className="h-5 w-5" />
|
||||
{feature}
|
||||
<ul role="list" className="space-y-2.5">
|
||||
{tier.features.map((feature, index) => (
|
||||
<li
|
||||
key={`${tier.id}-feature-${index}`}
|
||||
className="flex items-start gap-x-2.5"
|
||||
>
|
||||
<HiCheckCircle
|
||||
className="mt-0.5 h-4 w-4 shrink-0 text-light-1000 dark:text-dark-1000"
|
||||
/>
|
||||
<span className="text-sm text-dark-600 dark:text-dark-800">
|
||||
{feature.text}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mx-auto mt-8 max-w-3xl px-4 text-center">
|
||||
<div className="inline-flex items-center gap-2 rounded-full border bg-light-50 px-4 py-1 text-center text-xs text-light-1000 dark:border-dark-300 dark:bg-dark-50 dark:text-dark-900">
|
||||
<p>{t`14 day free trial · Switch plans or cancel anytime`}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -43,8 +43,8 @@ export default function PricingView() {
|
||||
<p className="mt-4 text-center text-3xl font-bold text-light-1000 dark:text-dark-1000 lg:text-5xl">
|
||||
{t`Simple pricing`}
|
||||
</p>
|
||||
<p className="text:md lg:text-md mt-6 max-w-[500px] text-center text-light-950 dark:text-dark-900">
|
||||
{t`Get started for free, with no usage limits. For collaboration, upgrade to a plan that fits the size of your team.`}
|
||||
<p className="text:md lg:text-md mt-6 max-w-[300px] text-center text-light-950 dark:text-dark-900">
|
||||
{t`Start free. Upgrade as your team grows. No hidden fees, no contracts.`}
|
||||
</p>
|
||||
</div>
|
||||
<PricingTiers
|
||||
@@ -59,23 +59,9 @@ export default function PricingView() {
|
||||
</div>
|
||||
|
||||
<div className="pb-22 flex flex-col items-center justify-center px-4">
|
||||
<div className="flex items-center gap-2 rounded-full border bg-light-50 px-4 py-1 text-center text-xs text-light-1000 dark:border-dark-300 dark:bg-dark-50 dark:text-dark-900 lg:text-sm">
|
||||
<p>{t`Features`}</p>
|
||||
</div>
|
||||
|
||||
|
||||
<p className="mt-4 text-center text-3xl font-bold text-light-1000 dark:text-dark-1000 lg:text-4xl">
|
||||
{t`Feature breakdown`}
|
||||
</p>
|
||||
<p className="text-md lg:text-md my-4 max-w-[500px] text-center text-light-950 dark:text-dark-900">
|
||||
{t`Compare our features to see why Kan is the best choice.`}
|
||||
</p>
|
||||
<div className="mt-2">
|
||||
<Button variant="primary" href="/signup">
|
||||
{t`Get started`}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 w-full max-w-[900px]">
|
||||
<div className="mt-10 w-full">
|
||||
<FeatureComparisonTable
|
||||
frequencyValue={frequency?.value ?? "annually"}
|
||||
/>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { useRouter } from "next/router";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { HiXMark } from "react-icons/hi2";
|
||||
import { HiLink, HiXMark } from "react-icons/hi2";
|
||||
|
||||
import Badge from "~/components/Badge";
|
||||
import Editor from "~/components/Editor";
|
||||
import LabelIcon from "~/components/LabelIcon";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { api } from "~/utils/api";
|
||||
import ActivityList from "~/views/card/components/ActivityList";
|
||||
import { AttachmentThumbnails } from "~/views/card/components/AttachmentThumbnails";
|
||||
@@ -23,10 +24,29 @@ export function CardModal({
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const { closeModal, isOpen } = useModal();
|
||||
const { showPopup } = usePopup();
|
||||
const [showFade, setShowFade] = useState(false);
|
||||
const [showTopFade, setShowTopFade] = useState(false);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleCopyCardLink = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(window.location.href);
|
||||
showPopup({
|
||||
header: t`Link copied`,
|
||||
icon: "success",
|
||||
message: t`Card URL copied to clipboard`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
showPopup({
|
||||
header: t`Unable to copy link`,
|
||||
icon: "error",
|
||||
message: t`Please try again.`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const { data, isLoading } = api.card.byId.useQuery(
|
||||
{
|
||||
cardPublicId: cardPublicId ?? "",
|
||||
@@ -65,33 +85,44 @@ export function CardModal({
|
||||
<div className="h-full p-8">
|
||||
<div className="mb-6">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<button
|
||||
className="absolute right-[2rem] top-[2rem] rounded p-1 hover:bg-light-300 focus:outline-none dark:hover:bg-dark-300"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
closeModal();
|
||||
<div className="absolute right-[2rem] top-[2rem] flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopyCardLink}
|
||||
className="rounded p-1.5 transition-all hover:bg-light-200 focus:outline-none dark:hover:bg-dark-100"
|
||||
aria-label="Copy card link"
|
||||
>
|
||||
<HiLink className="h-4 w-4 text-light-900 dark:text-dark-900" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded p-1.5 transition-all hover:bg-light-200 focus:outline-none dark:hover:bg-dark-100"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
closeModal();
|
||||
|
||||
setTimeout(() => {
|
||||
void router.replace(
|
||||
{
|
||||
pathname: router.pathname,
|
||||
query: {
|
||||
...router.query,
|
||||
workspaceSlug,
|
||||
boardSlug: [boardSlug],
|
||||
setTimeout(() => {
|
||||
void router.replace(
|
||||
{
|
||||
pathname: router.pathname,
|
||||
query: {
|
||||
...router.query,
|
||||
workspaceSlug: workspaceSlug ?? "",
|
||||
boardSlug: [boardSlug ?? ""],
|
||||
},
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
{ shallow: true },
|
||||
);
|
||||
}, 400);
|
||||
}}
|
||||
>
|
||||
<HiXMark
|
||||
size={18}
|
||||
className="dark:text-dark-9000 text-light-900"
|
||||
/>
|
||||
</button>
|
||||
undefined,
|
||||
{ shallow: true },
|
||||
);
|
||||
}, 400);
|
||||
}}
|
||||
>
|
||||
<HiXMark
|
||||
size={18}
|
||||
className="text-light-900 dark:text-dark-900"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<div className="flex space-x-2">
|
||||
<div className="h-[2.3rem] w-[300px] animate-pulse rounded-[5px] bg-light-300 dark:bg-dark-300" />
|
||||
|
||||
@@ -30,7 +30,7 @@ export default function PublicBoardView() {
|
||||
const { showPopup } = usePopup();
|
||||
const [isRouteLoaded, setIsRouteLoaded] = useState(false);
|
||||
const { openModal } = useModal();
|
||||
|
||||
|
||||
const { ref: scrollRef, onMouseDown } = useDragToScroll({
|
||||
enabled: true,
|
||||
direction: "horizontal",
|
||||
@@ -70,30 +70,35 @@ export default function PublicBoardView() {
|
||||
},
|
||||
);
|
||||
|
||||
const CopyBoardLink = () => {
|
||||
return (
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(window.location.href);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
showPopup({
|
||||
header: t`Link copied`,
|
||||
icon: "success",
|
||||
message: t`Board URL copied to clipboard`,
|
||||
});
|
||||
}}
|
||||
className="rounded p-1.5 transition-all hover:bg-light-200 dark:hover:bg-dark-100"
|
||||
aria-label={`Copy board URL`}
|
||||
>
|
||||
<HiLink className={`h-4 w-4 text-light-900 dark:text-dark-900`} />
|
||||
</button>
|
||||
);
|
||||
const handleCopyBoardLink = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(window.location.href);
|
||||
showPopup({
|
||||
header: t`Link copied`,
|
||||
icon: "success",
|
||||
message: t`Board URL copied to clipboard`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
showPopup({
|
||||
header: t`Unable to copy link`,
|
||||
icon: "error",
|
||||
message: t`Please try again.`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const CopyBoardLink = () => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopyBoardLink}
|
||||
className="rounded p-1.5 transition-all hover:bg-light-200 focus:outline-none dark:hover:bg-dark-100"
|
||||
aria-label="Copy board URL"
|
||||
>
|
||||
<HiLink className="h-4 w-4 text-light-900 dark:text-dark-900" />
|
||||
</button>
|
||||
);
|
||||
|
||||
const pathWithoutQuery = router.asPath.split("?")[0];
|
||||
const splitPath = pathWithoutQuery?.split("/") ?? [];
|
||||
const cardPublicId = splitPath.length > 3 ? splitPath[3] : null;
|
||||
|
||||
@@ -58,28 +58,6 @@ export default function Avatar({
|
||||
const [crop, setCrop] = useState<PercentCrop>();
|
||||
const imgRef = useRef<HTMLImageElement | null>(null);
|
||||
|
||||
const updateUser = api.user.update.useMutation({
|
||||
onSuccess: async () => {
|
||||
showPopup({
|
||||
header: t`Profile image updated`,
|
||||
message: t`Your profile image has been updated.`,
|
||||
icon: "success",
|
||||
});
|
||||
try {
|
||||
await utils.user.getUser.refetch();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
showPopup({
|
||||
header: t`Error updating profile image`,
|
||||
message: t`Please try again later, or contact customer support.`,
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const avatarUrl = userImage ? getAvatarUrl(userImage) : undefined;
|
||||
|
||||
@@ -187,29 +165,32 @@ export default function Avatar({
|
||||
const originalExt = selectedFile.name.split(".").pop() ?? "jpg";
|
||||
const fileName = `${userId}/avatar-${generateUID()}.${originalExt}`;
|
||||
|
||||
const baseUrl = env("NEXT_PUBLIC_BASE_URL") ?? "";
|
||||
const response = await fetch(
|
||||
env("NEXT_PUBLIC_BASE_URL") + "/api/upload/image",
|
||||
`${baseUrl}/api/upload/avatar`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Content-Type": blob.type,
|
||||
"x-original-filename": fileName,
|
||||
},
|
||||
body: JSON.stringify({ filename: fileName, contentType: blob.type }),
|
||||
body: blob,
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) throw new Error("Failed to get pre-signed URL");
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to upload profile image");
|
||||
}
|
||||
|
||||
const { url } = (await response.json()) as { url: string };
|
||||
|
||||
const uploadResponse = await fetch(url, {
|
||||
method: "PUT",
|
||||
body: blob,
|
||||
// User image is updated in the backend, refresh user data
|
||||
await utils.user.getUser.refetch();
|
||||
|
||||
showPopup({
|
||||
header: t`Profile image updated`,
|
||||
message: t`Your profile image has been updated.`,
|
||||
icon: "success",
|
||||
});
|
||||
|
||||
if (!uploadResponse.ok) throw new Error("Failed to upload profile image");
|
||||
|
||||
updateUser.mutate({ image: fileName });
|
||||
|
||||
setCropDialogOpen(false);
|
||||
resetCropState();
|
||||
} catch (error) {
|
||||
@@ -227,7 +208,7 @@ export default function Avatar({
|
||||
resetCropState,
|
||||
selectedFile,
|
||||
showPopup,
|
||||
updateUser,
|
||||
utils.user.getUser,
|
||||
userId,
|
||||
]);
|
||||
|
||||
|
||||
@@ -27,6 +27,10 @@
|
||||
"./utils/rateLimit": {
|
||||
"types": "./dist/utils/rateLimit.d.ts",
|
||||
"default": "./src/utils/rateLimit.ts"
|
||||
},
|
||||
"./utils/permissions": {
|
||||
"types": "./dist/utils/permissions.d.ts",
|
||||
"default": "./src/utils/permissions.ts"
|
||||
}
|
||||
},
|
||||
"license": "GPL-3.0",
|
||||
@@ -39,8 +43,6 @@
|
||||
"typecheck": "tsc --noEmit --emitDeclarationOnly false"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.802.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.812.0",
|
||||
"@kan/auth": "workspace:*",
|
||||
"@kan/db": "workspace:*",
|
||||
"@kan/email": "workspace:^",
|
||||
|
||||
@@ -9,7 +9,7 @@ import { generateUID } from "@kan/shared/utils";
|
||||
|
||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||
import { assertPermission } from "../utils/permissions";
|
||||
import { deleteObject, generateUploadUrl } from "../utils/s3";
|
||||
import { deleteObject, generateUploadUrl } from "@kan/shared/utils";
|
||||
|
||||
export const attachmentRouter = createTRPCRouter({
|
||||
generateUploadUrl: protectedProcedure
|
||||
@@ -142,9 +142,18 @@ export const attachmentRouter = createTRPCRouter({
|
||||
createdBy: userId,
|
||||
});
|
||||
|
||||
if (!attachment) {
|
||||
throw new TRPCError({
|
||||
message: "Failed to create attachment",
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
});
|
||||
}
|
||||
|
||||
await cardActivityRepo.create(ctx.db, {
|
||||
type: "card.updated.attachment.added",
|
||||
cardId: card.id,
|
||||
attachmentId: attachment.id,
|
||||
toTitle: input.originalFilename,
|
||||
createdBy: userId,
|
||||
});
|
||||
|
||||
@@ -206,6 +215,8 @@ export const attachmentRouter = createTRPCRouter({
|
||||
await cardActivityRepo.create(ctx.db, {
|
||||
type: "card.updated.attachment.removed",
|
||||
cardId: attachment.cardId,
|
||||
attachmentId: attachment.id,
|
||||
fromTitle: attachment.originalFilename,
|
||||
createdBy: userId,
|
||||
});
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||
import { colours } from "@kan/shared/constants";
|
||||
import {
|
||||
convertDueDateFiltersToRanges,
|
||||
generateAvatarUrl,
|
||||
generateSlug,
|
||||
generateUID,
|
||||
} from "@kan/shared/utils";
|
||||
@@ -142,7 +143,63 @@ export const boardRouter = createTRPCRouter({
|
||||
},
|
||||
);
|
||||
|
||||
return result;
|
||||
if (!result) {
|
||||
throw new TRPCError({
|
||||
message: `Board with public ID ${input.boardPublicId} not found`,
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
}
|
||||
|
||||
// Generate presigned URLs for workspace member avatars
|
||||
const workspaceWithAvatarUrls = result.workspace
|
||||
? {
|
||||
...result.workspace,
|
||||
members: await Promise.all(
|
||||
result.workspace.members.map(async (member) => {
|
||||
if (!member.user?.image) {
|
||||
return member;
|
||||
}
|
||||
|
||||
const avatarUrl = await generateAvatarUrl(member.user.image);
|
||||
return {
|
||||
...member,
|
||||
user: {
|
||||
...member.user,
|
||||
image: avatarUrl,
|
||||
},
|
||||
};
|
||||
}),
|
||||
),
|
||||
}
|
||||
: result.workspace;
|
||||
|
||||
// Generate presigned URLs for card member avatars
|
||||
const listsWithAvatarUrls = await Promise.all(
|
||||
result.lists.map(async (list) => ({
|
||||
...list,
|
||||
cards: await Promise.all(
|
||||
list.cards.map(async (card) => ({
|
||||
...card,
|
||||
members: await Promise.all(
|
||||
card.members.map(async (member) => {
|
||||
if (!member.user?.image) return member;
|
||||
const avatarUrl = await generateAvatarUrl(member.user.image);
|
||||
return {
|
||||
...member,
|
||||
user: { ...member.user, image: avatarUrl },
|
||||
};
|
||||
}),
|
||||
),
|
||||
})),
|
||||
),
|
||||
})),
|
||||
);
|
||||
|
||||
return {
|
||||
...result,
|
||||
lists: listsWithAvatarUrls,
|
||||
workspace: workspaceWithAvatarUrls,
|
||||
};
|
||||
}),
|
||||
bySlug: publicProcedure
|
||||
.meta({
|
||||
|
||||
@@ -10,8 +10,9 @@ import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||
|
||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
|
||||
import { mergeActivities } from "../utils/activities";
|
||||
import { sendMentionEmails } from "../utils/notifications";
|
||||
import { assertCanDelete, assertCanEdit, assertPermission } from "../utils/permissions";
|
||||
import { generateDownloadUrl } from "../utils/s3";
|
||||
import { generateAttachmentUrl, generateAvatarUrl } from "@kan/shared/utils";
|
||||
|
||||
export const cardRouter = createTRPCRouter({
|
||||
create: protectedProcedure
|
||||
@@ -153,6 +154,17 @@ export const cardRouter = createTRPCRouter({
|
||||
await cardActivityRepo.bulkCreate(ctx.db, cardActivitesInsert);
|
||||
}
|
||||
|
||||
if (input.description) {
|
||||
sendMentionEmails({
|
||||
db: ctx.db,
|
||||
cardPublicId: newCard.publicId,
|
||||
commentHtml: input.description,
|
||||
commenterUserId: userId,
|
||||
}).catch((error) => {
|
||||
console.error("Failed to send mention emails:", error);
|
||||
});
|
||||
}
|
||||
|
||||
return newCard;
|
||||
}),
|
||||
addComment: protectedProcedure
|
||||
@@ -215,6 +227,16 @@ export const cardRouter = createTRPCRouter({
|
||||
createdBy: userId,
|
||||
});
|
||||
|
||||
sendMentionEmails({
|
||||
db: ctx.db,
|
||||
cardPublicId: input.cardPublicId,
|
||||
commentHtml: input.comment,
|
||||
commenterUserId: userId,
|
||||
commentId: newComment.id,
|
||||
}).catch((error) => {
|
||||
console.error("Failed to send mention emails:", error);
|
||||
});
|
||||
|
||||
return newComment;
|
||||
}),
|
||||
updateComment: protectedProcedure
|
||||
@@ -295,6 +317,16 @@ export const cardRouter = createTRPCRouter({
|
||||
createdBy: userId,
|
||||
});
|
||||
|
||||
sendMentionEmails({
|
||||
db: ctx.db,
|
||||
cardPublicId: input.cardPublicId,
|
||||
commentHtml: input.comment,
|
||||
commenterUserId: userId,
|
||||
commentId: updatedComment.id,
|
||||
}).catch((error) => {
|
||||
console.error("Failed to send mention emails:", error);
|
||||
});
|
||||
|
||||
return updatedComment;
|
||||
}),
|
||||
deleteComment: protectedProcedure
|
||||
@@ -631,45 +663,54 @@ export const cardRouter = createTRPCRouter({
|
||||
});
|
||||
|
||||
// Generate URLs for all attachments
|
||||
const bucket = process.env.NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME;
|
||||
if (result.attachments && Array.isArray(result.attachments)) {
|
||||
const attachments = result.attachments as {
|
||||
publicId: string;
|
||||
contentType: string;
|
||||
s3Key: string;
|
||||
originalFilename: string | null;
|
||||
size?: number | null;
|
||||
}[];
|
||||
const attachmentsWithUrls = await Promise.all(
|
||||
result.attachments.map(async (attachment) => {
|
||||
const url = await generateAttachmentUrl(attachment.s3Key);
|
||||
return {
|
||||
publicId: attachment.publicId,
|
||||
contentType: attachment.contentType,
|
||||
s3Key: attachment.s3Key,
|
||||
originalFilename: attachment.originalFilename,
|
||||
size: attachment.size,
|
||||
url,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const attachmentsWithUrls = await Promise.all(
|
||||
attachments.map(async (attachment) => {
|
||||
const base = {
|
||||
publicId: attachment.publicId,
|
||||
contentType: attachment.contentType,
|
||||
s3Key: attachment.s3Key,
|
||||
originalFilename: attachment.originalFilename,
|
||||
size: attachment.size,
|
||||
};
|
||||
if (!bucket || !attachment.s3Key) {
|
||||
return { ...base, url: null };
|
||||
}
|
||||
try {
|
||||
const url = await generateDownloadUrl(
|
||||
bucket,
|
||||
attachment.s3Key,
|
||||
86400, // 24 hours expiration
|
||||
);
|
||||
return { ...base, url };
|
||||
} catch {
|
||||
// If URL generation fails, return attachment with url: null
|
||||
return { ...base, url: null };
|
||||
}
|
||||
}),
|
||||
);
|
||||
return { ...result, attachments: attachmentsWithUrls };
|
||||
}
|
||||
// Generate presigned URLs for workspace member avatars
|
||||
const workspaceWithAvatarUrls = result.list.board.workspace
|
||||
? {
|
||||
...result.list.board.workspace,
|
||||
members: await Promise.all(
|
||||
result.list.board.workspace.members.map(async (member) => {
|
||||
if (!member.user?.image) {
|
||||
return member;
|
||||
}
|
||||
|
||||
return { ...result, attachments: [] };
|
||||
const avatarUrl = await generateAvatarUrl(member.user.image);
|
||||
return {
|
||||
...member,
|
||||
user: {
|
||||
...member.user,
|
||||
image: avatarUrl,
|
||||
},
|
||||
};
|
||||
}),
|
||||
),
|
||||
}
|
||||
: result.list.board.workspace;
|
||||
|
||||
return {
|
||||
...result,
|
||||
attachments: attachmentsWithUrls,
|
||||
list: {
|
||||
...result.list,
|
||||
board: {
|
||||
...result.list.board,
|
||||
workspace: workspaceWithAvatarUrls,
|
||||
},
|
||||
},
|
||||
};
|
||||
}),
|
||||
getActivities: publicProcedure
|
||||
.meta({
|
||||
@@ -738,7 +779,39 @@ export const cardRouter = createTRPCRouter({
|
||||
},
|
||||
);
|
||||
|
||||
const mergedActivities = mergeActivities(result.activities);
|
||||
// Generate presigned URLs for user avatars in activities
|
||||
const activitiesWithAvatarUrls = await Promise.all(
|
||||
result.activities.map(async (activity) => {
|
||||
const updatedActivity = { ...activity };
|
||||
|
||||
// Generate presigned URL for activity user avatar
|
||||
if (activity.user?.image) {
|
||||
const userAvatarUrl = await generateAvatarUrl(activity.user.image);
|
||||
updatedActivity.user = {
|
||||
...activity.user,
|
||||
image: userAvatarUrl,
|
||||
};
|
||||
}
|
||||
|
||||
// Generate presigned URL for member user avatar (if exists)
|
||||
if (activity.member?.user?.image) {
|
||||
const memberAvatarUrl = await generateAvatarUrl(
|
||||
activity.member.user.image,
|
||||
);
|
||||
updatedActivity.member = {
|
||||
...activity.member,
|
||||
user: {
|
||||
...activity.member.user,
|
||||
image: memberAvatarUrl,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return updatedActivity;
|
||||
}),
|
||||
);
|
||||
|
||||
const mergedActivities = mergeActivities(activitiesWithAvatarUrls);
|
||||
|
||||
return {
|
||||
activities: mergedActivities,
|
||||
@@ -883,6 +956,15 @@ export const cardRouter = createTRPCRouter({
|
||||
fromDescription: existingCard.description ?? undefined,
|
||||
toDescription: input.description,
|
||||
});
|
||||
|
||||
sendMentionEmails({
|
||||
db: ctx.db,
|
||||
cardPublicId: input.cardPublicId,
|
||||
commentHtml: input.description,
|
||||
commenterUserId: userId,
|
||||
}).catch((error) => {
|
||||
console.error("Failed to send mention emails:", error);
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
createTRPCRouter,
|
||||
publicProcedure,
|
||||
} from "../trpc";
|
||||
import { createS3Client } from "../utils/s3";
|
||||
import { createS3Client } from "@kan/shared/utils";
|
||||
|
||||
const checkDatabaseConnection = async (db: dbClient) => {
|
||||
try {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { z } from "zod";
|
||||
import * as userRepo from "@kan/db/repository/user.repo";
|
||||
|
||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||
import { generateAvatarUrl } from "@kan/shared/utils";
|
||||
|
||||
export const userRouter = createTRPCRouter({
|
||||
getUser: protectedProcedure
|
||||
@@ -55,8 +56,12 @@ export const userRouter = createTRPCRouter({
|
||||
|
||||
const apiKey = result.apiKeys[0];
|
||||
|
||||
// Generate presigned URL for avatar
|
||||
const imageUrl = await generateAvatarUrl(result.image);
|
||||
|
||||
return {
|
||||
...result,
|
||||
image: imageUrl,
|
||||
apiKey: apiKey ?? null,
|
||||
};
|
||||
}),
|
||||
@@ -102,6 +107,12 @@ export const userRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
// Generate presigned URL for avatar
|
||||
const imageUrl = await generateAvatarUrl(result.image);
|
||||
|
||||
return {
|
||||
...result,
|
||||
image: imageUrl,
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ import { generateUID } from "@kan/shared/utils";
|
||||
|
||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
|
||||
import { assertPermission } from "../utils/permissions";
|
||||
import { generateAvatarUrl } from "@kan/shared/utils";
|
||||
|
||||
export const workspaceRouter = createTRPCRouter({
|
||||
all: protectedProcedure
|
||||
@@ -86,9 +87,27 @@ export const workspaceRouter = createTRPCRouter({
|
||||
const shouldShowEmails =
|
||||
isAdmin || result.showEmailsToMembers === true;
|
||||
|
||||
// Generate presigned URLs for member avatars
|
||||
const membersWithAvatarUrls = await Promise.all(
|
||||
result.members.map(async (member) => {
|
||||
if (!member.user?.image) {
|
||||
return member;
|
||||
}
|
||||
|
||||
const avatarUrl = await generateAvatarUrl(member.user.image);
|
||||
return {
|
||||
...member,
|
||||
user: {
|
||||
...member.user,
|
||||
image: avatarUrl,
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
// If emails should be hidden, filter them out
|
||||
if (!shouldShowEmails) {
|
||||
const sanitizedMembers = result.members.map((member) => {
|
||||
const sanitizedMembers = membersWithAvatarUrls.map((member) => {
|
||||
// If user doesn't have a display name, use anonymous identifier
|
||||
const displayName =
|
||||
member.user?.name?.trim() ?? `anonymous_${member.publicId}`;
|
||||
@@ -120,7 +139,10 @@ export const workspaceRouter = createTRPCRouter({
|
||||
} as Awaited<ReturnType<typeof workspaceRepo.getByPublicIdWithMembers>>;
|
||||
}
|
||||
|
||||
return result;
|
||||
return {
|
||||
...result,
|
||||
members: membersWithAvatarUrls,
|
||||
};
|
||||
}),
|
||||
bySlug: publicProcedure
|
||||
.meta({
|
||||
|
||||
133
packages/api/src/utils/notifications.ts
Normal file
133
packages/api/src/utils/notifications.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { env } from "next-runtime-env";
|
||||
|
||||
import type { dbClient } from "@kan/db/client";
|
||||
import * as cardRepo from "@kan/db/repository/card.repo";
|
||||
import * as memberRepo from "@kan/db/repository/member.repo";
|
||||
import * as notificationRepo from "@kan/db/repository/notification.repo";
|
||||
import * as userRepo from "@kan/db/repository/user.repo";
|
||||
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||
import { sendEmail } from "@kan/email";
|
||||
import { parseMentionsFromHTML } from "@kan/shared/utils";
|
||||
|
||||
/**
|
||||
* Sends mention notification emails to mentioned members
|
||||
* Only sends emails for new mentions (checks notification table to avoid duplicates)
|
||||
*/
|
||||
export async function sendMentionEmails({
|
||||
db,
|
||||
cardPublicId,
|
||||
commentHtml,
|
||||
commenterUserId,
|
||||
commentId,
|
||||
}: {
|
||||
db: dbClient;
|
||||
cardPublicId: string;
|
||||
commentHtml: string;
|
||||
commenterUserId: string;
|
||||
commentId?: number;
|
||||
}) {
|
||||
try {
|
||||
// Parse mentions from HTML
|
||||
const mentionPublicIds = parseMentionsFromHTML(commentHtml);
|
||||
if (mentionPublicIds.length === 0) return;
|
||||
|
||||
// Get card with board information
|
||||
const card = await cardRepo.getWithListAndMembersByPublicId(db, cardPublicId);
|
||||
if (!card?.list.board) return;
|
||||
|
||||
const board = card.list.board;
|
||||
const boardName = board.name;
|
||||
const cardTitle = card.title;
|
||||
const cardId = card.id;
|
||||
|
||||
// Get workspace ID from workspace publicId
|
||||
const workspace = await workspaceRepo.getByPublicId(
|
||||
db,
|
||||
board.workspace.publicId,
|
||||
);
|
||||
if (!workspace?.id) return;
|
||||
|
||||
const workspaceId = workspace.id;
|
||||
|
||||
// Get commenter information
|
||||
const commenter = await userRepo.getById(db, commenterUserId);
|
||||
if (!commenter) return;
|
||||
|
||||
const commenterName = commenter.name ?? commenter.email;
|
||||
|
||||
// Get mentioned members with full details (filtered by workspace)
|
||||
const membersWithDetails = await memberRepo.getByPublicIdsWithUsers(
|
||||
db,
|
||||
mentionPublicIds,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
// Filter out the commenter
|
||||
const membersToNotify = membersWithDetails.filter(
|
||||
(member) => member.user?.id !== commenterUserId,
|
||||
);
|
||||
|
||||
if (membersToNotify.length === 0) return;
|
||||
|
||||
const baseUrl = env("NEXT_PUBLIC_BASE_URL");
|
||||
const cardUrl = `${baseUrl}/cards/${cardPublicId}`;
|
||||
|
||||
// Send emails to all mentioned members (only if notification doesn't exist)
|
||||
await Promise.all(
|
||||
membersToNotify.map(async (member) => {
|
||||
const userId = member.user?.id;
|
||||
const email = member.user?.email ?? member.email;
|
||||
|
||||
// Skip pending members (no userId) - they can be mentioned but won't receive emails
|
||||
if (!userId || !email) return;
|
||||
|
||||
try {
|
||||
// Check if notification already exists for this mention
|
||||
const notificationExists = await notificationRepo.exists(db, {
|
||||
userId,
|
||||
cardId,
|
||||
type: "mention",
|
||||
});
|
||||
|
||||
// If notification already exists, skip sending email
|
||||
if (notificationExists) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create notification record
|
||||
await notificationRepo.create(db, {
|
||||
type: "mention",
|
||||
userId,
|
||||
cardId,
|
||||
commentId,
|
||||
});
|
||||
|
||||
// Send email
|
||||
await sendEmail(
|
||||
email,
|
||||
`${commenterName} mentioned you in a comment on ${cardTitle}`,
|
||||
"MENTION",
|
||||
{
|
||||
commenterName,
|
||||
boardName,
|
||||
cardTitle,
|
||||
cardUrl,
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to send mention email:", {
|
||||
email,
|
||||
cardPublicId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error sending mention emails:", {
|
||||
cardPublicId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,15 +11,16 @@ import { createPlugins } from "./plugins";
|
||||
import { configuredProviders } from "./providers";
|
||||
|
||||
export const initAuth = (db: dbClient) => {
|
||||
const baseURL = env("NEXT_PUBLIC_BASE_URL") || env("BETTER_AUTH_URL");
|
||||
const trustedOrigins = env("BETTER_AUTH_TRUSTED_ORIGINS")?.split(",") ?? [];
|
||||
|
||||
return betterAuth({
|
||||
secret: env("BETTER_AUTH_SECRET"),
|
||||
baseURL: env("NEXT_PUBLIC_BASE_URL"),
|
||||
trustedOrigins: env("BETTER_AUTH_TRUSTED_ORIGINS")
|
||||
? [
|
||||
env("NEXT_PUBLIC_BASE_URL") ?? "",
|
||||
...(env("BETTER_AUTH_TRUSTED_ORIGINS")?.split(",") ?? []),
|
||||
]
|
||||
: [env("NEXT_PUBLIC_BASE_URL") ?? ""],
|
||||
baseURL,
|
||||
trustedOrigins: [
|
||||
...(baseURL ? [baseURL] : []),
|
||||
...trustedOrigins,
|
||||
],
|
||||
database: drizzleAdapter(db, {
|
||||
provider: "pg",
|
||||
schema: {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
|
||||
import { PutObjectCommand } from "@aws-sdk/client-s3";
|
||||
import { ChatOrPushProviderEnum } from "@novu/api/models/components";
|
||||
import { createAuthMiddleware } from "better-auth/api";
|
||||
import { env } from "next-runtime-env";
|
||||
@@ -7,7 +7,7 @@ import type { dbClient } from "@kan/db/client";
|
||||
import * as memberRepo from "@kan/db/repository/member.repo";
|
||||
import * as userRepo from "@kan/db/repository/user.repo";
|
||||
import { notificationClient } from "@kan/email";
|
||||
import { createEmailUnsubscribeLink } from "@kan/shared";
|
||||
import { createEmailUnsubscribeLink, createS3Client } from "@kan/shared";
|
||||
|
||||
import { downloadImage } from "./utils";
|
||||
|
||||
@@ -61,20 +61,7 @@ export function createDatabaseHooks(db: dbClient) {
|
||||
!user.image.includes(storageDomain)
|
||||
) {
|
||||
try {
|
||||
const credentials =
|
||||
env("S3_ACCESS_KEY_ID") && env("S3_SECRET_ACCESS_KEY")
|
||||
? {
|
||||
accessKeyId: env("S3_ACCESS_KEY_ID")!,
|
||||
secretAccessKey: env("S3_SECRET_ACCESS_KEY")!,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const client = new S3Client({
|
||||
region: env("S3_REGION") ?? "",
|
||||
endpoint: env("S3_ENDPOINT") ?? "",
|
||||
forcePathStyle: env("S3_FORCE_PATH_STYLE") === "true",
|
||||
credentials,
|
||||
});
|
||||
const client = createS3Client();
|
||||
|
||||
const allowedFileExtensions = ["jpg", "jpeg", "png", "webp"];
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { magicLink } from "better-auth/plugins/magic-link";
|
||||
import type { dbClient } from "@kan/db/client";
|
||||
import * as memberRepo from "@kan/db/repository/member.repo";
|
||||
import * as subscriptionRepo from "@kan/db/repository/subscription.repo";
|
||||
import * as userRepo from "@kan/db/repository/user.repo";
|
||||
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||
import { generateUID } from "@kan/shared/utils";
|
||||
import { sendEmail } from "@kan/email";
|
||||
@@ -133,10 +134,10 @@ export function createPlugins(db: dbClient) {
|
||||
|
||||
if (workspace?.id) {
|
||||
await memberRepo.pauseAllMembers(db, workspace.id);
|
||||
|
||||
|
||||
// Reset slug to publicId, or generate a UID if publicId is taken
|
||||
let newSlug = workspace.publicId;
|
||||
|
||||
|
||||
if (workspace.slug !== workspace.publicId) {
|
||||
const isPublicIdAvailable = await workspaceRepo.isWorkspaceSlugAvailable(
|
||||
db,
|
||||
@@ -146,7 +147,7 @@ export function createPlugins(db: dbClient) {
|
||||
newSlug = generateUID();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
await workspaceRepo.update(db, subscription.referenceId, {
|
||||
plan: "free",
|
||||
slug: newSlug,
|
||||
@@ -171,19 +172,69 @@ export function createPlugins(db: dbClient) {
|
||||
magicLink({
|
||||
expiresIn: 60 * 60 * 24 * 7, // 7 days
|
||||
sendMagicLink: async ({ email, url }) => {
|
||||
if (url.includes("type=invite")) {
|
||||
const decodedUrl = decodeURIComponent(url);
|
||||
console.log("Sending magic link to:", email, "URL:", url);
|
||||
console.log(
|
||||
"Magic link contains invite:",
|
||||
decodedUrl.includes("type=invite"),
|
||||
);
|
||||
if (decodedUrl.includes("type=invite")) {
|
||||
let inviterName = "";
|
||||
let workspaceName = "";
|
||||
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
const callbackUrl = urlObj.searchParams.get("callbackURL");
|
||||
if (callbackUrl) {
|
||||
const callbackParams = new URL(
|
||||
callbackUrl,
|
||||
process.env.NEXT_PUBLIC_BASE_URL,
|
||||
).searchParams;
|
||||
const memberPublicId = callbackParams.get("memberPublicId");
|
||||
|
||||
if (memberPublicId) {
|
||||
const member = await memberRepo.getByPublicId(
|
||||
db,
|
||||
memberPublicId,
|
||||
);
|
||||
if (member) {
|
||||
const [workspace, inviter] = await Promise.all([
|
||||
workspaceRepo.getById(db, member.workspaceId),
|
||||
userRepo.getById(db, member.createdBy),
|
||||
]);
|
||||
|
||||
if (workspace) workspaceName = workspace.name;
|
||||
if (inviter) inviterName = inviter.name ?? "";
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch invite details:", error);
|
||||
}
|
||||
|
||||
await sendEmail(
|
||||
email,
|
||||
"Invitation to join workspace",
|
||||
workspaceName
|
||||
? `Invitation to join the workspace ${workspaceName}`
|
||||
: "Invitation to join workspace",
|
||||
"JOIN_WORKSPACE",
|
||||
{
|
||||
magicLoginUrl: url,
|
||||
inviterName,
|
||||
workspaceName,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
await sendEmail(
|
||||
email,
|
||||
process.env.NEXT_PUBLIC_WHITE_LABEL_HIDE_POWERED_BY === "true"
|
||||
? "Sign in to your account"
|
||||
: "Sign in to Kan",
|
||||
"MAGIC_LINK",
|
||||
{
|
||||
magicLoginUrl: url,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
await sendEmail(email, "Sign in to kan.bn", "MAGIC_LINK", {
|
||||
magicLoginUrl: url,
|
||||
});
|
||||
}
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
CREATE TYPE "public"."notification_type" AS ENUM('mention', 'workspace.member.added', 'workspace.member.removed', 'workspace.role.changed');--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "notification" (
|
||||
"id" bigserial PRIMARY KEY NOT NULL,
|
||||
"publicId" varchar(12) NOT NULL,
|
||||
"type" "notification_type" NOT NULL,
|
||||
"userId" uuid NOT NULL,
|
||||
"cardId" bigint,
|
||||
"commentId" bigint,
|
||||
"workspaceId" bigint,
|
||||
"metadata" text,
|
||||
"readAt" timestamp,
|
||||
"createdAt" timestamp DEFAULT now() NOT NULL,
|
||||
"deletedAt" timestamp,
|
||||
CONSTRAINT "notification_publicId_unique" UNIQUE("publicId")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "notification" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "notification" ADD CONSTRAINT "notification_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "notification" ADD CONSTRAINT "notification_cardId_card_id_fk" FOREIGN KEY ("cardId") REFERENCES "public"."card"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "notification" ADD CONSTRAINT "notification_commentId_card_comments_id_fk" FOREIGN KEY ("commentId") REFERENCES "public"."card_comments"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "notification" ADD CONSTRAINT "notification_workspaceId_workspace_id_fk" FOREIGN KEY ("workspaceId") REFERENCES "public"."workspace"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "notification_user_deleted_idx" ON "notification" USING btree ("userId","deletedAt");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "notification_user_read_deleted_idx" ON "notification" USING btree ("userId","readAt","deletedAt");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "notification_user_type_card_idx" ON "notification" USING btree ("userId","type","cardId");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "notification_user_type_workspace_idx" ON "notification" USING btree ("userId","type","workspaceId");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "notification_user_created_idx" ON "notification" USING btree ("userId","createdAt");
|
||||
@@ -0,0 +1,6 @@
|
||||
ALTER TABLE "card_activity" ADD COLUMN "attachmentId" bigint;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "card_activity" ADD CONSTRAINT "card_activity_attachmentId_card_attachment_id_fk" FOREIGN KEY ("attachmentId") REFERENCES "public"."card_attachment"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
3687
packages/db/migrations/meta/20260207214056_snapshot.json
Normal file
3687
packages/db/migrations/meta/20260207214056_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
3430
packages/db/migrations/meta/20260208033314_snapshot.json
Normal file
3430
packages/db/migrations/meta/20260208033314_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -176,6 +176,20 @@
|
||||
"when": 1769983198190,
|
||||
"tag": "20260201215958_AddUserBoardFavouritesTable",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 25,
|
||||
"version": "7",
|
||||
"when": 1770500457005,
|
||||
"tag": "20260207214056_AddNotificationsTable",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 26,
|
||||
"version": "7",
|
||||
"when": 1770521594167,
|
||||
"tag": "20260208033314_AddAttachmentToActivity",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -212,6 +212,7 @@ export const getByPublicId = async (
|
||||
columns: {
|
||||
publicId: true,
|
||||
email: true,
|
||||
status: true,
|
||||
},
|
||||
with: {
|
||||
user: {
|
||||
|
||||
@@ -420,6 +420,7 @@ export const getWithListAndMembersByPublicId = async (
|
||||
) => {
|
||||
const card = await db.query.cards.findFirst({
|
||||
columns: {
|
||||
id: true,
|
||||
publicId: true,
|
||||
title: true,
|
||||
description: true,
|
||||
@@ -507,6 +508,7 @@ export const getWithListAndMembersByPublicId = async (
|
||||
columns: {
|
||||
publicId: true,
|
||||
email: true,
|
||||
status: true,
|
||||
},
|
||||
with: {
|
||||
user: {
|
||||
|
||||
@@ -33,6 +33,7 @@ export const create = async (
|
||||
fromDueDate?: Date;
|
||||
toDueDate?: Date;
|
||||
sourceBoardId?: number;
|
||||
attachmentId?: number;
|
||||
},
|
||||
) => {
|
||||
const [result] = await db
|
||||
@@ -58,6 +59,7 @@ export const create = async (
|
||||
fromDueDate: activityInput.fromDueDate,
|
||||
toDueDate: activityInput.toDueDate,
|
||||
sourceBoardId: activityInput.sourceBoardId,
|
||||
attachmentId: activityInput.attachmentId,
|
||||
})
|
||||
.returning({ id: cardActivities.id });
|
||||
|
||||
@@ -83,6 +85,7 @@ export const bulkCreate = async (
|
||||
fromDueDate?: Date;
|
||||
toDueDate?: Date;
|
||||
sourceBoardId?: number;
|
||||
attachmentId?: number;
|
||||
}[],
|
||||
) => {
|
||||
const activitiesWithPublicIds = activityInputs.map((activity) => ({
|
||||
@@ -191,6 +194,13 @@ export const getPaginatedActivities = async (
|
||||
deletedAt: true,
|
||||
},
|
||||
},
|
||||
attachment: {
|
||||
columns: {
|
||||
publicId: true,
|
||||
filename: true,
|
||||
originalFilename: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: asc(cardActivities.createdAt), // required for merging and pagination
|
||||
limit: limit + 1, // fetch one extra to check if there are more
|
||||
|
||||
@@ -63,6 +63,36 @@ export const getById = async (db: dbClient, memberId: number) => {
|
||||
});
|
||||
};
|
||||
|
||||
export const getByPublicIdsWithUsers = async (
|
||||
db: dbClient,
|
||||
memberPublicIds: string[],
|
||||
workspaceId?: number,
|
||||
) => {
|
||||
return db.query.workspaceMembers.findMany({
|
||||
where: (members, { inArray: inArrayFn, eq, and, isNull: isNullFn }) => {
|
||||
const conditions = [inArrayFn(members.publicId, memberPublicIds)];
|
||||
|
||||
if (workspaceId) {
|
||||
conditions.push(eq(members.workspaceId, workspaceId));
|
||||
}
|
||||
|
||||
conditions.push(eq(members.status, "active"));
|
||||
conditions.push(isNullFn(members.deletedAt));
|
||||
|
||||
return and(...conditions);
|
||||
},
|
||||
with: {
|
||||
user: {
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const getByEmailAndStatus = async (
|
||||
db: dbClient,
|
||||
email: string,
|
||||
|
||||
98
packages/db/src/repository/notification.repo.ts
Normal file
98
packages/db/src/repository/notification.repo.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { and, count, eq, isNull } from "drizzle-orm";
|
||||
|
||||
import type { dbClient } from "@kan/db/client";
|
||||
import type { NotificationType } from "@kan/db/schema";
|
||||
import { notifications } from "@kan/db/schema";
|
||||
import { generateUID } from "@kan/shared/utils";
|
||||
|
||||
export const create = async (
|
||||
db: dbClient,
|
||||
notificationInput: {
|
||||
type: NotificationType;
|
||||
userId: string;
|
||||
cardId?: number;
|
||||
commentId?: number;
|
||||
workspaceId?: number;
|
||||
metadata?: string;
|
||||
},
|
||||
) => {
|
||||
const [result] = await db
|
||||
.insert(notifications)
|
||||
.values({
|
||||
publicId: generateUID(),
|
||||
type: notificationInput.type,
|
||||
userId: notificationInput.userId,
|
||||
cardId: notificationInput.cardId,
|
||||
commentId: notificationInput.commentId,
|
||||
workspaceId: notificationInput.workspaceId,
|
||||
metadata: notificationInput.metadata,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
export const exists = async (
|
||||
db: dbClient,
|
||||
args: {
|
||||
userId: string;
|
||||
type: NotificationType;
|
||||
cardId?: number;
|
||||
workspaceId?: number;
|
||||
commentId?: number;
|
||||
},
|
||||
) => {
|
||||
const result = await db.query.notifications.findFirst({
|
||||
where: (notifications, { eq, and, isNull: isNullFn }) => {
|
||||
const conditions = [
|
||||
eq(notifications.userId, args.userId),
|
||||
eq(notifications.type, args.type),
|
||||
isNullFn(notifications.deletedAt),
|
||||
];
|
||||
|
||||
if (args.cardId) {
|
||||
conditions.push(eq(notifications.cardId, args.cardId));
|
||||
}
|
||||
|
||||
if (args.workspaceId) {
|
||||
conditions.push(eq(notifications.workspaceId, args.workspaceId));
|
||||
}
|
||||
|
||||
return and(...conditions);
|
||||
},
|
||||
});
|
||||
|
||||
return !!result;
|
||||
};
|
||||
|
||||
export const markAsRead = async (
|
||||
db: dbClient,
|
||||
notificationId: number,
|
||||
) => {
|
||||
const [result] = await db
|
||||
.update(notifications)
|
||||
.set({ readAt: new Date() })
|
||||
.where(eq(notifications.id, notificationId))
|
||||
.returning();
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
export const getUnreadCount = async (
|
||||
db: dbClient,
|
||||
userId: string,
|
||||
) => {
|
||||
const result = await db
|
||||
.select({ count: count() })
|
||||
.from(notifications)
|
||||
.where(
|
||||
and(
|
||||
eq(notifications.userId, userId),
|
||||
isNull(notifications.readAt),
|
||||
isNull(notifications.deletedAt),
|
||||
),
|
||||
);
|
||||
|
||||
return result[0]?.count ?? 0;
|
||||
};
|
||||
|
||||
@@ -147,6 +147,10 @@ export const cardActivities = pgTable("card_activity", {
|
||||
() => boards.id,
|
||||
{ onDelete: "set null" },
|
||||
),
|
||||
attachmentId: bigint("attachmentId", { mode: "number" }).references(
|
||||
() => cardAttachments.id,
|
||||
{ onDelete: "cascade" },
|
||||
),
|
||||
}).enableRLS();
|
||||
|
||||
export const cardActivitiesRelations = relations(cardActivities, ({ one }) => ({
|
||||
@@ -190,6 +194,11 @@ export const cardActivitiesRelations = relations(cardActivities, ({ one }) => ({
|
||||
references: [comments.id],
|
||||
relationName: "cardActivitiesComment",
|
||||
}),
|
||||
attachment: one(cardAttachments, {
|
||||
fields: [cardActivities.attachmentId],
|
||||
references: [cardAttachments.id],
|
||||
relationName: "cardActivitiesAttachment",
|
||||
}),
|
||||
}));
|
||||
|
||||
export const cardsToLabels = pgTable(
|
||||
|
||||
@@ -13,3 +13,4 @@ export * from "./workspaces";
|
||||
export * from "./subscriptions";
|
||||
export * from "./workspaceInviteLinks";
|
||||
export * from "./permissions";
|
||||
export * from "./notifications";
|
||||
|
||||
98
packages/db/src/schema/notifications.ts
Normal file
98
packages/db/src/schema/notifications.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { relations } from "drizzle-orm";
|
||||
import {
|
||||
bigint,
|
||||
bigserial,
|
||||
index,
|
||||
pgEnum,
|
||||
pgTable,
|
||||
text,
|
||||
timestamp,
|
||||
uuid,
|
||||
varchar,
|
||||
} from "drizzle-orm/pg-core";
|
||||
|
||||
import { cards } from "./cards";
|
||||
import { comments } from "./cards";
|
||||
import { users } from "./users";
|
||||
import { workspaces } from "./workspaces";
|
||||
|
||||
export const notificationTypes = [
|
||||
"mention",
|
||||
"workspace.member.added",
|
||||
"workspace.member.removed",
|
||||
"workspace.role.changed",
|
||||
] as const;
|
||||
|
||||
export type NotificationType = (typeof notificationTypes)[number];
|
||||
|
||||
export const notificationTypeEnum = pgEnum("notification_type", notificationTypes);
|
||||
|
||||
export const notifications = pgTable(
|
||||
"notification",
|
||||
{
|
||||
id: bigserial("id", { mode: "number" }).primaryKey(),
|
||||
publicId: varchar("publicId", { length: 12 }).notNull().unique(),
|
||||
type: notificationTypeEnum("type").notNull(),
|
||||
userId: uuid("userId")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
cardId: bigint("cardId", { mode: "number" }).references(() => cards.id, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
commentId: bigint("commentId", { mode: "number" }).references(
|
||||
() => comments.id,
|
||||
{ onDelete: "cascade" },
|
||||
),
|
||||
workspaceId: bigint("workspaceId", { mode: "number" }).references(
|
||||
() => workspaces.id,
|
||||
{ onDelete: "cascade" },
|
||||
),
|
||||
metadata: text("metadata"),
|
||||
readAt: timestamp("readAt"),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
deletedAt: timestamp("deletedAt"),
|
||||
},
|
||||
(table) => [
|
||||
index("notification_user_deleted_idx").on(table.userId, table.deletedAt),
|
||||
index("notification_user_read_deleted_idx").on(
|
||||
table.userId,
|
||||
table.readAt,
|
||||
table.deletedAt,
|
||||
),
|
||||
index("notification_user_type_card_idx").on(
|
||||
table.userId,
|
||||
table.type,
|
||||
table.cardId,
|
||||
),
|
||||
index("notification_user_type_workspace_idx").on(
|
||||
table.userId,
|
||||
table.type,
|
||||
table.workspaceId,
|
||||
),
|
||||
index("notification_user_created_idx").on(table.userId, table.createdAt),
|
||||
],
|
||||
).enableRLS();
|
||||
|
||||
export const notificationsRelations = relations(notifications, ({ one }) => ({
|
||||
user: one(users, {
|
||||
fields: [notifications.userId],
|
||||
references: [users.id],
|
||||
relationName: "notificationsUser",
|
||||
}),
|
||||
card: one(cards, {
|
||||
fields: [notifications.cardId],
|
||||
references: [cards.id],
|
||||
relationName: "notificationsCard",
|
||||
}),
|
||||
comment: one(comments, {
|
||||
fields: [notifications.commentId],
|
||||
references: [comments.id],
|
||||
relationName: "notificationsComment",
|
||||
}),
|
||||
workspace: one(workspaces, {
|
||||
fields: [notifications.workspaceId],
|
||||
references: [workspaces.id],
|
||||
relationName: "notificationsWorkspace",
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -3,14 +3,16 @@ import nodemailer from "nodemailer";
|
||||
|
||||
import JoinWorkspaceTemplate from "./templates/join-workspace";
|
||||
import MagicLinkTemplate from "./templates/magic-link";
|
||||
import MentionTemplate from "./templates/mention";
|
||||
import ResetPasswordTemplate from "./templates/reset-password";
|
||||
|
||||
type Templates = "MAGIC_LINK" | "JOIN_WORKSPACE" | "RESET_PASSWORD";
|
||||
type Templates = "MAGIC_LINK" | "JOIN_WORKSPACE" | "RESET_PASSWORD" | "MENTION";
|
||||
|
||||
const emailTemplates: Record<Templates, React.FC> = {
|
||||
const emailTemplates: Record<Templates, React.ComponentType<any>> = {
|
||||
MAGIC_LINK: MagicLinkTemplate,
|
||||
JOIN_WORKSPACE: JoinWorkspaceTemplate,
|
||||
RESET_PASSWORD: ResetPasswordTemplate,
|
||||
MENTION: MentionTemplate,
|
||||
};
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
|
||||
@@ -13,12 +13,16 @@ import * as React from "react";
|
||||
|
||||
export const JoinWorkspaceTemplate = ({
|
||||
magicLoginUrl,
|
||||
inviterName,
|
||||
workspaceName,
|
||||
}: {
|
||||
magicLoginUrl?: string;
|
||||
inviterName?: string;
|
||||
workspaceName?: string;
|
||||
}) => (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>Log in with this magic link</Preview>
|
||||
<Preview>Join {workspaceName ?? "workspace"} on kan.bn</Preview>
|
||||
<Body style={{ backgroundColor: "white" }}>
|
||||
<Container
|
||||
style={{
|
||||
@@ -38,12 +42,16 @@ export const JoinWorkspaceTemplate = ({
|
||||
color: "#232323",
|
||||
}}
|
||||
>
|
||||
kan.bn
|
||||
{env("NEXT_PUBLIC_WHITE_LABEL_HIDE_POWERED_BY") !== "true" && "Kan"}
|
||||
</Heading>
|
||||
<Heading
|
||||
style={{ fontSize: "24px", fontWeight: "bold", color: "#232323" }}
|
||||
>
|
||||
Login to your Kan account
|
||||
{inviterName
|
||||
? `${inviterName} invited you to join ${
|
||||
workspaceName ?? "a workspace"
|
||||
}`
|
||||
: `You've been invited to join ${workspaceName ?? "a workspace"}`}
|
||||
</Heading>
|
||||
<Text
|
||||
style={{
|
||||
@@ -52,7 +60,7 @@ export const JoinWorkspaceTemplate = ({
|
||||
color: "#232323",
|
||||
}}
|
||||
>
|
||||
Click the button below to instantly login to your account.
|
||||
Click the button below to add this workspace to your account.
|
||||
</Text>
|
||||
<Button
|
||||
target="_blank"
|
||||
@@ -71,7 +79,7 @@ export const JoinWorkspaceTemplate = ({
|
||||
color: "white",
|
||||
}}
|
||||
>
|
||||
Login to your account
|
||||
Join {workspaceName ?? "workspace"}
|
||||
</Button>
|
||||
<Text
|
||||
style={{
|
||||
@@ -80,25 +88,29 @@ export const JoinWorkspaceTemplate = ({
|
||||
color: "#7e7e7e",
|
||||
}}
|
||||
>
|
||||
If you didn't try to login, you can safely ignore this email.
|
||||
</Text>
|
||||
<Hr
|
||||
style={{
|
||||
marginTop: "2.5rem",
|
||||
marginBottom: "2rem",
|
||||
borderWidth: "1px",
|
||||
}}
|
||||
/>
|
||||
<Text style={{ color: "#7e7e7e" }}>
|
||||
<Link
|
||||
href={env("NEXT_PUBLIC_BASE_URL")}
|
||||
target="_blank"
|
||||
style={{ color: "#7e7e7e", textDecoration: "underline" }}
|
||||
>
|
||||
Kan
|
||||
</Link>
|
||||
, the open source Trello alternative.
|
||||
If you don't want to join this workspace, you can safely ignore this email.
|
||||
</Text>
|
||||
{env("NEXT_PUBLIC_WHITE_LABEL_HIDE_POWERED_BY") !== "true" && (
|
||||
<>
|
||||
<Hr
|
||||
style={{
|
||||
marginTop: "2.5rem",
|
||||
marginBottom: "2rem",
|
||||
borderWidth: "1px",
|
||||
}}
|
||||
/>
|
||||
<Text style={{ color: "#7e7e7e" }}>
|
||||
<Link
|
||||
href={env("NEXT_PUBLIC_BASE_URL")}
|
||||
target="_blank"
|
||||
style={{ color: "#7e7e7e", textDecoration: "underline" }}
|
||||
>
|
||||
Kan
|
||||
</Link>
|
||||
, the open source Trello alternative.
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
|
||||
108
packages/email/src/templates/mention.tsx
Normal file
108
packages/email/src/templates/mention.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import { Body } from "@react-email/body";
|
||||
import { Button } from "@react-email/button";
|
||||
import { Container } from "@react-email/container";
|
||||
import { Head } from "@react-email/head";
|
||||
import { Heading } from "@react-email/heading";
|
||||
import { Hr } from "@react-email/hr";
|
||||
import { Html } from "@react-email/html";
|
||||
import { Link } from "@react-email/link";
|
||||
import { Preview } from "@react-email/preview";
|
||||
import { Text } from "@react-email/text";
|
||||
import { env } from "next-runtime-env";
|
||||
import * as React from "react";
|
||||
|
||||
export const MentionTemplate = ({
|
||||
commenterName,
|
||||
boardName,
|
||||
cardTitle,
|
||||
cardUrl,
|
||||
}: {
|
||||
commenterName: string;
|
||||
boardName: string;
|
||||
cardTitle: string;
|
||||
cardUrl: string;
|
||||
}) => (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>
|
||||
{commenterName} mentioned you in a comment on {cardTitle}
|
||||
</Preview>
|
||||
<Body style={{ backgroundColor: "white" }}>
|
||||
<Container
|
||||
style={{
|
||||
fontFamily:
|
||||
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif',
|
||||
margin: "auto",
|
||||
paddingLeft: "0.75rem",
|
||||
paddingRight: "0.75rem",
|
||||
}}
|
||||
>
|
||||
<Heading
|
||||
style={{
|
||||
marginTop: "2.5rem",
|
||||
marginBottom: "2.5rem",
|
||||
fontSize: "24px",
|
||||
fontWeight: "bold",
|
||||
color: "#232323",
|
||||
}}
|
||||
>
|
||||
kan.bn
|
||||
</Heading>
|
||||
<Heading
|
||||
style={{ fontSize: "24px", fontWeight: "bold", color: "#232323" }}
|
||||
>
|
||||
You were mentioned in a comment
|
||||
</Heading>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: "0.875rem",
|
||||
marginBottom: "1rem",
|
||||
color: "#232323",
|
||||
}}
|
||||
>
|
||||
<strong>{commenterName}</strong> mentioned you in a comment on the
|
||||
card <strong>{cardTitle}</strong> in the board <strong>{boardName}</strong>.
|
||||
</Text>
|
||||
<Button
|
||||
target="_blank"
|
||||
href={cardUrl}
|
||||
style={{
|
||||
marginBottom: "2rem",
|
||||
borderRadius: "0.375rem",
|
||||
backgroundColor: "#282828",
|
||||
paddingLeft: "1.5rem",
|
||||
paddingRight: "1.5rem",
|
||||
paddingTop: "1rem",
|
||||
paddingBottom: "1rem",
|
||||
fontSize: "0.875rem",
|
||||
fontWeight: "500",
|
||||
lineHeight: "1",
|
||||
color: "white",
|
||||
}}
|
||||
>
|
||||
View Card
|
||||
</Button>
|
||||
<Hr
|
||||
style={{
|
||||
marginTop: "2.5rem",
|
||||
marginBottom: "2rem",
|
||||
borderWidth: "1px",
|
||||
}}
|
||||
/>
|
||||
<Text style={{ color: "#7e7e7e" }}>
|
||||
<Link
|
||||
href={env("NEXT_PUBLIC_BASE_URL")}
|
||||
target="_blank"
|
||||
style={{ color: "#7e7e7e", textDecoration: "underline" }}
|
||||
>
|
||||
Kan
|
||||
</Link>
|
||||
, the open source Trello alternative.
|
||||
</Text>
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
);
|
||||
|
||||
export default MentionTemplate;
|
||||
|
||||
@@ -36,6 +36,8 @@
|
||||
},
|
||||
"prettier": "@kan/prettier-config",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.802.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.812.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"jose": "^6.1.2",
|
||||
"nanoid": "^5.0.9",
|
||||
|
||||
@@ -3,3 +3,5 @@ export * from "./generateSlug";
|
||||
export * from "./subscriptions";
|
||||
export * from "./email";
|
||||
export * from "./dueDateFilters";
|
||||
export * from "./s3";
|
||||
export * from "./mentions";
|
||||
|
||||
22
packages/shared/src/utils/mentions.ts
Normal file
22
packages/shared/src/utils/mentions.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Parses mention data-id attributes from HTML content
|
||||
* Mentions are stored as: <span data-type="mention" data-id="..." data-label="...">@label</span>
|
||||
* @param htmlContent - The HTML content to parse
|
||||
* @returns Array of unique mention public IDs
|
||||
*/
|
||||
export function parseMentionsFromHTML(htmlContent: string): string[] {
|
||||
if (!htmlContent) return [];
|
||||
|
||||
// Match all mention spans with data-id attributes
|
||||
const mentionRegex = /<span[^>]*data-type="mention"[^>]*data-id="([^"]+)"[^>]*>/gi;
|
||||
const matches = Array.from(htmlContent.matchAll(mentionRegex));
|
||||
|
||||
// Extract unique mention IDs
|
||||
const mentionIds = matches
|
||||
.map((match) => match[1])
|
||||
.filter((id): id is string => !!id && id.length >= 12);
|
||||
|
||||
// Return unique IDs
|
||||
return Array.from(new Set(mentionIds));
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
S3Client,
|
||||
} from "@aws-sdk/client-s3";
|
||||
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
||||
import { env } from "next-runtime-env";
|
||||
|
||||
export function createS3Client() {
|
||||
const credentials =
|
||||
@@ -67,3 +68,60 @@ export async function deleteObject(bucket: string, key: string) {
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate presigned URL for an avatar image
|
||||
* Returns the URL as-is if it's already a full URL (external provider)
|
||||
* Returns presigned URL if it's an S3 key
|
||||
* Returns null if image key is missing, bucket is not configured, or URL generation fails
|
||||
*/
|
||||
export async function generateAvatarUrl(
|
||||
imageKey: string | null | undefined,
|
||||
expiresIn = 86400, // 24 hours
|
||||
): Promise<string | null> {
|
||||
if (!imageKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (imageKey.startsWith("http://") || imageKey.startsWith("https://")) {
|
||||
return imageKey;
|
||||
}
|
||||
|
||||
const bucket = env("NEXT_PUBLIC_AVATAR_BUCKET_NAME");
|
||||
if (!bucket) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return await generateDownloadUrl(bucket, imageKey, expiresIn);
|
||||
} catch {
|
||||
// If URL generation fails, return null
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate presigned URL for an attachment
|
||||
* Returns null if attachment key is missing, bucket is not configured, or URL generation fails
|
||||
*/
|
||||
export async function generateAttachmentUrl(
|
||||
attachmentKey: string | null | undefined,
|
||||
expiresIn = 86400, // 24 hours
|
||||
): Promise<string | null> {
|
||||
if (!attachmentKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const bucket = env("NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME");
|
||||
if (!bucket) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return await generateDownloadUrl(bucket, attachmentKey, expiresIn);
|
||||
} catch {
|
||||
// If URL generation fails, return null
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
325
pnpm-lock.yaml
generated
325
pnpm-lock.yaml
generated
@@ -82,6 +82,9 @@ importers:
|
||||
'@aws-sdk/client-s3':
|
||||
specifier: ^3.802.0
|
||||
version: 3.879.0
|
||||
'@aws-sdk/lib-storage':
|
||||
specifier: ^3.802.0
|
||||
version: 3.985.0(@aws-sdk/client-s3@3.879.0)
|
||||
'@aws-sdk/s3-request-presigner':
|
||||
specifier: ^3.812.0
|
||||
version: 3.879.0
|
||||
@@ -136,6 +139,9 @@ importers:
|
||||
'@tiptap/extension-placeholder':
|
||||
specifier: ^2.14.0
|
||||
version: 2.26.1(@tiptap/core@2.26.1(@tiptap/pm@2.26.1))(@tiptap/pm@2.26.1)
|
||||
'@tiptap/extension-typography':
|
||||
specifier: ^3.18.0
|
||||
version: 3.18.0(@tiptap/core@2.26.1(@tiptap/pm@2.26.1))
|
||||
'@tiptap/pm':
|
||||
specifier: ^2.14.0
|
||||
version: 2.26.1
|
||||
@@ -293,12 +299,6 @@ importers:
|
||||
|
||||
packages/api:
|
||||
dependencies:
|
||||
'@aws-sdk/client-s3':
|
||||
specifier: ^3.802.0
|
||||
version: 3.879.0
|
||||
'@aws-sdk/s3-request-presigner':
|
||||
specifier: ^3.812.0
|
||||
version: 3.879.0
|
||||
'@kan/auth':
|
||||
specifier: workspace:*
|
||||
version: link:../auth
|
||||
@@ -483,6 +483,12 @@ importers:
|
||||
|
||||
packages/shared:
|
||||
dependencies:
|
||||
'@aws-sdk/client-s3':
|
||||
specifier: ^3.802.0
|
||||
version: 3.879.0
|
||||
'@aws-sdk/s3-request-presigner':
|
||||
specifier: ^3.812.0
|
||||
version: 3.879.0
|
||||
date-fns:
|
||||
specifier: ^4.1.0
|
||||
version: 4.1.0
|
||||
@@ -734,6 +740,12 @@ packages:
|
||||
resolution: {integrity: sha512-Jy4uPFfGzHk1Mxy+/Wr43vuw9yXsE2yiF4e4598vc3aJfO0YtA2nSfbKD3PNKRORwXbeKqWPfph9SCKQpWoxEg==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@aws-sdk/lib-storage@3.985.0':
|
||||
resolution: {integrity: sha512-EnqXf2E+5+7e5zuz8mPl+3Dk/DI0ObSL8s7Tsf6H8FL/p2BJw2LhgtxPnFZUmpanpoMO0hC9/qjXNd+aGPV0/w==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
peerDependencies:
|
||||
'@aws-sdk/client-s3': ^3.985.0
|
||||
|
||||
'@aws-sdk/middleware-bucket-endpoint@3.873.0':
|
||||
resolution: {integrity: sha512-b4bvr0QdADeTUs+lPc9Z48kXzbKHXQKgTvxx/jXDgSW9tv4KmYPO1gIj6Z9dcrBkRWQuUtSW3Tu2S5n6pe+zeg==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
@@ -3300,6 +3312,10 @@ packages:
|
||||
resolution: {integrity: sha512-jcrqdTQurIrBbUm4W2YdLVMQDoL0sA9DTxYd2s+R/y+2U9NLOP7Xf/YqfSg1FZhlZIYEnvk2mwbyvIfdLEPo8g==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/abort-controller@4.2.8':
|
||||
resolution: {integrity: sha512-peuVfkYHAmS5ybKxWcfraK7WBBP0J+rkfUcbHJJKQ4ir3UAUNQI+Y4Vt/PqSzGqgloJ5O1dk7+WzNL8wcCSXbw==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/chunked-blob-reader-native@4.0.0':
|
||||
resolution: {integrity: sha512-R9wM2yPmfEMsUmlMlIgSzOyICs0x9uu7UTHoccMyt7BWw8shcGM8HqB355+BZCPBcySvbTYMs62EgEQkNxz2ig==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
@@ -3312,6 +3328,10 @@ packages:
|
||||
resolution: {integrity: sha512-viuHMxBAqydkB0AfWwHIdwf/PRH2z5KHGUzqyRtS/Wv+n3IHI993Sk76VCA7dD/+GzgGOmlJDITfPcJC1nIVIw==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/core@3.22.1':
|
||||
resolution: {integrity: sha512-x3ie6Crr58MWrm4viHqqy2Du2rHYZjwu8BekasrQx4ca+Y24dzVAwq3yErdqIbc2G3I0kLQA13PQ+/rde+u65g==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/core@3.9.0':
|
||||
resolution: {integrity: sha512-B/GknvCfS3llXd/b++hcrwIuqnEozQDnRL4sBmOac5/z/dr0/yG1PURNPOyU4Lsiy1IyTj8scPxVqRs5dYWf6A==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
@@ -3344,6 +3364,10 @@ packages:
|
||||
resolution: {integrity: sha512-61WjM0PWmZJR+SnmzaKI7t7G0UkkNFboDpzIdzSoy7TByUzlxo18Qlh9s71qug4AY4hlH/CwXdubMtkcNEb/sQ==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/fetch-http-handler@5.3.9':
|
||||
resolution: {integrity: sha512-I4UhmcTYXBrct03rwzQX1Y/iqQlzVQaPxWjCjula++5EmWq9YGBrx6bbGqluGc1f0XEfhSkiY4jhLgbsJUMKRA==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/hash-blob-browser@4.0.5':
|
||||
resolution: {integrity: sha512-F7MmCd3FH/Q2edhcKd+qulWkwfChHbc9nhguBlVjSUE6hVHhec3q6uPQ+0u69S6ppvLtR3eStfCuEKMXBXhvvA==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
@@ -3368,6 +3392,10 @@ packages:
|
||||
resolution: {integrity: sha512-saYhF8ZZNoJDTvJBEWgeBccCg+yvp1CX+ed12yORU3NilJScfc6gfch2oVb4QgxZrGUx3/ZJlb+c/dJbyupxlw==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/is-array-buffer@4.2.0':
|
||||
resolution: {integrity: sha512-DZZZBvC7sjcYh4MazJSGiWMI2L7E0oCiRHREDzIxi/M2LY79/21iXt6aPLHge82wi5LsuRF5A06Ds3+0mlh6CQ==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/md5-js@4.0.5':
|
||||
resolution: {integrity: sha512-8n2XCwdUbGr8W/XhMTaxILkVlw2QebkVTn5tm3HOcbPbOpWg89zr6dPXsH8xbeTsbTXlJvlJNTQsKAIoqQGbdA==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
@@ -3380,6 +3408,10 @@ packages:
|
||||
resolution: {integrity: sha512-EAlEPncqo03siNZJ9Tm6adKCQ+sw5fNU8ncxWwaH0zTCwMPsgmERTi6CEKaermZdgJb+4Yvh0NFm36HeO4PGgQ==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/middleware-endpoint@4.4.13':
|
||||
resolution: {integrity: sha512-x6vn0PjYmGdNuKh/juUJJewZh7MoQ46jYaJ2mvekF4EesMuFfrl4LaW/k97Zjf8PTCPQmPgMvwewg7eNoH9n5w==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/middleware-retry@4.1.20':
|
||||
resolution: {integrity: sha512-T3maNEm3Masae99eFdx1Q7PIqBBEVOvRd5hralqKZNeIivnoGNx5OFtI3DiZ5gCjUkl0mNondlzSXeVxkinh7Q==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
@@ -3388,34 +3420,66 @@ packages:
|
||||
resolution: {integrity: sha512-uAFFR4dpeoJPGz8x9mhxp+RPjo5wW0QEEIPPPbLXiRRWeCATf/Km3gKIVR5vaP8bN1kgsPhcEeh+IZvUlBv6Xg==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/middleware-serde@4.2.9':
|
||||
resolution: {integrity: sha512-eMNiej0u/snzDvlqRGSN3Vl0ESn3838+nKyVfF2FKNXFbi4SERYT6PR392D39iczngbqqGG0Jl1DlCnp7tBbXQ==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/middleware-stack@4.0.5':
|
||||
resolution: {integrity: sha512-/yoHDXZPh3ocRVyeWQFvC44u8seu3eYzZRveCMfgMOBcNKnAmOvjbL9+Cp5XKSIi9iYA9PECUuW2teDAk8T+OQ==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/middleware-stack@4.2.8':
|
||||
resolution: {integrity: sha512-w6LCfOviTYQjBctOKSwy6A8FIkQy7ICvglrZFl6Bw4FmcQ1Z420fUtIhxaUZZshRe0VCq4kvDiPiXrPZAe8oRA==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/node-config-provider@4.1.4':
|
||||
resolution: {integrity: sha512-+UDQV/k42jLEPPHSn39l0Bmc4sB1xtdI9Gd47fzo/0PbXzJ7ylgaOByVjF5EeQIumkepnrJyfx86dPa9p47Y+w==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/node-config-provider@4.3.8':
|
||||
resolution: {integrity: sha512-aFP1ai4lrbVlWjfpAfRSL8KFcnJQYfTl5QxLJXY32vghJrDuFyPZ6LtUL+JEGYiFRG1PfPLHLoxj107ulncLIg==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/node-http-handler@4.1.1':
|
||||
resolution: {integrity: sha512-RHnlHqFpoVdjSPPiYy/t40Zovf3BBHc2oemgD7VsVTFFZrU5erFFe0n52OANZZ/5sbshgD93sOh5r6I35Xmpaw==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/node-http-handler@4.4.9':
|
||||
resolution: {integrity: sha512-KX5Wml5mF+luxm1szW4QDz32e3NObgJ4Fyw+irhph4I/2geXwUy4jkIMUs5ZPGflRBeR6BUkC2wqIab4Llgm3w==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/property-provider@4.0.5':
|
||||
resolution: {integrity: sha512-R/bswf59T/n9ZgfgUICAZoWYKBHcsVDurAGX88zsiUtOTA/xUAPyiT+qkNCPwFn43pZqN84M4MiUsbSGQmgFIQ==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/property-provider@4.2.8':
|
||||
resolution: {integrity: sha512-EtCTbyIveCKeOXDSWSdze3k612yCPq1YbXsbqX3UHhkOSW8zKsM9NOJG5gTIya0vbY2DIaieG8pKo1rITHYL0w==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/protocol-http@5.1.3':
|
||||
resolution: {integrity: sha512-fCJd2ZR7D22XhDY0l+92pUag/7je2BztPRQ01gU5bMChcyI0rlly7QFibnYHzcxDvccMjlpM/Q1ev8ceRIb48w==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/protocol-http@5.3.8':
|
||||
resolution: {integrity: sha512-QNINVDhxpZ5QnP3aviNHQFlRogQZDfYlCkQT+7tJnErPQbDhysondEjhikuANxgMsZrkGeiAxXy4jguEGsDrWQ==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/querystring-builder@4.0.5':
|
||||
resolution: {integrity: sha512-NJeSCU57piZ56c+/wY+AbAw6rxCCAOZLCIniRE7wqvndqxcKKDOXzwWjrY7wGKEISfhL9gBbAaWWgHsUGedk+A==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/querystring-builder@4.2.8':
|
||||
resolution: {integrity: sha512-Xr83r31+DrE8CP3MqPgMJl+pQlLLmOfiEUnoyAlGzzJIrEsbKsPy1hqH0qySaQm4oWrCBlUqRt+idEgunKB+iw==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/querystring-parser@4.0.5':
|
||||
resolution: {integrity: sha512-6SV7md2CzNG/WUeTjVe6Dj8noH32r4MnUeFKZrnVYsQxpGSIcphAanQMayi8jJLZAWm6pdM9ZXvKCpWOsIGg0w==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/querystring-parser@4.2.8':
|
||||
resolution: {integrity: sha512-vUurovluVy50CUlazOiXkPq40KGvGWSdmusa3130MwrR1UNnNgKAlj58wlOe61XSHRpUfIIh6cE0zZ8mzKaDPA==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/service-error-classification@4.0.7':
|
||||
resolution: {integrity: sha512-XvRHOipqpwNhEjDf2L5gJowZEm5nsxC16pAZOeEcsygdjv9A2jdOh3YoDQvOXBGTsaJk6mNWtzWalOB9976Wlg==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
@@ -3424,14 +3488,26 @@ packages:
|
||||
resolution: {integrity: sha512-YVVwehRDuehgoXdEL4r1tAAzdaDgaC9EQvhK0lEbfnbrd0bd5+CTQumbdPryX3J2shT7ZqQE+jPW4lmNBAB8JQ==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/shared-ini-file-loader@4.4.3':
|
||||
resolution: {integrity: sha512-DfQjxXQnzC5UbCUPeC3Ie8u+rIWZTvuDPAGU/BxzrOGhRvgUanaP68kDZA+jaT3ZI+djOf+4dERGlm9mWfFDrg==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/signature-v4@5.1.3':
|
||||
resolution: {integrity: sha512-mARDSXSEgllNzMw6N+mC+r1AQlEBO3meEAkR/UlfAgnMzJUB3goRBWgip1EAMG99wh36MDqzo86SfIX5Y+VEaw==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/smithy-client@4.11.2':
|
||||
resolution: {integrity: sha512-SCkGmFak/xC1n7hKRsUr6wOnBTJ3L22Qd4e8H1fQIuKTAjntwgU8lrdMe7uHdiT2mJAOWA/60qaW9tiMu69n1A==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/smithy-client@4.5.0':
|
||||
resolution: {integrity: sha512-ZSdE3vl0MuVbEwJBxSftm0J5nL/gw76xp5WF13zW9cN18MFuFXD5/LV0QD8P+sCU5bSWGyy6CTgUupE1HhOo1A==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/types@4.12.0':
|
||||
resolution: {integrity: sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/types@4.3.2':
|
||||
resolution: {integrity: sha512-QO4zghLxiQ5W9UZmX2Lo0nta2PuE1sSrXUYDoaB6HMR762C0P7v/HEPHf6ZdglTVssJG1bsrSBxdc3quvDSihw==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
@@ -3440,14 +3516,26 @@ packages:
|
||||
resolution: {integrity: sha512-j+733Um7f1/DXjYhCbvNXABV53NyCRRA54C7bNEIxNPs0YjfRxeMKjjgm2jvTYrciZyCjsicHwQ6Q0ylo+NAUw==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/url-parser@4.2.8':
|
||||
resolution: {integrity: sha512-NQho9U68TGMEU639YkXnVMV3GEFFULmmaWdlu1E9qzyIePOHsoSnagTGSDv1Zi8DCNN6btxOSdgmy5E/hsZwhA==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/util-base64@4.0.0':
|
||||
resolution: {integrity: sha512-CvHfCmO2mchox9kjrtzoHkWHxjHZzaFojLc8quxXY7WAAMAg43nuxwv95tATVgQFNDwd4M9S1qFzj40Ul41Kmg==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/util-base64@4.3.0':
|
||||
resolution: {integrity: sha512-GkXZ59JfyxsIwNTWFnjmFEI8kZpRNIBfxKjv09+nkAWPt/4aGaEWMM04m4sxgNVWkbt2MdSvE3KF/PfX4nFedQ==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/util-body-length-browser@4.0.0':
|
||||
resolution: {integrity: sha512-sNi3DL0/k64/LO3A256M+m3CDdG6V7WKWHdAiBBMUN8S3hK3aMPhwnPik2A/a2ONN+9doY9UxaLfgqsIRg69QA==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/util-body-length-browser@4.2.0':
|
||||
resolution: {integrity: sha512-Fkoh/I76szMKJnBXWPdFkQJl2r9SjPt3cMzLdOB6eJ4Pnpas8hVoWPYemX/peO0yrrvldgCUVJqOAjUrOLjbxg==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/util-body-length-node@4.0.0':
|
||||
resolution: {integrity: sha512-q0iDP3VsZzqJyje8xJWEJCNIu3lktUGVoSy1KB0UWym2CL1siV3artm+u1DFYTLejpsrdGyCSWBdGNjJzfDPjg==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
@@ -3460,6 +3548,10 @@ packages:
|
||||
resolution: {integrity: sha512-9TOQ7781sZvddgO8nxueKi3+yGvkY35kotA0Y6BWRajAv8jjmigQ1sBwz0UX47pQMYXJPahSKEKYFgt+rXdcug==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/util-buffer-from@4.2.0':
|
||||
resolution: {integrity: sha512-kAY9hTKulTNevM2nlRtxAG2FQ3B2OR6QIrPY3zE5LqJy1oxzmgBGsHLWTcNhWXKchgA0WHW+mZkQrng/pgcCew==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/util-config-provider@4.0.0':
|
||||
resolution: {integrity: sha512-L1RBVzLyfE8OXH+1hsJ8p+acNUSirQnWQ6/EgpchV88G6zGBTDPdXiiExei6Z1wR2RxYvxY/XLw6AMNCCt8H3w==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
@@ -3480,10 +3572,18 @@ packages:
|
||||
resolution: {integrity: sha512-Yk5mLhHtfIgW2W2WQZWSg5kuMZCVbvhFmC7rV4IO2QqnZdbEFPmQnCcGMAX2z/8Qj3B9hYYNjZOhWym+RwhePw==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/util-hex-encoding@4.2.0':
|
||||
resolution: {integrity: sha512-CCQBwJIvXMLKxVbO88IukazJD9a4kQ9ZN7/UMGBjBcJYvatpWk+9g870El4cB8/EJxfe+k+y0GmR9CAzkF+Nbw==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/util-middleware@4.0.5':
|
||||
resolution: {integrity: sha512-N40PfqsZHRSsByGB81HhSo+uvMxEHT+9e255S53pfBw/wI6WKDI7Jw9oyu5tJTLwZzV5DsMha3ji8jk9dsHmQQ==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/util-middleware@4.2.8':
|
||||
resolution: {integrity: sha512-PMqfeJxLcNPMDgvPbbLl/2Vpin+luxqTGPpW3NAQVLbRrFRzTa4rNAASYeIGjRV9Ytuhzny39SpyU04EQreF+A==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/util-retry@4.0.7':
|
||||
resolution: {integrity: sha512-TTO6rt0ppK70alZpkjwy+3nQlTiqNfoXja+qwuAchIEAIoSZW8Qyd76dvBv3I5bCpE38APafG23Y/u270NspiQ==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
@@ -3492,10 +3592,18 @@ packages:
|
||||
resolution: {integrity: sha512-vSKnvNZX2BXzl0U2RgCLOwWaAP9x/ddd/XobPK02pCbzRm5s55M53uwb1rl/Ts7RXZvdJZerPkA+en2FDghLuQ==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/util-stream@4.5.11':
|
||||
resolution: {integrity: sha512-lKmZ0S/3Qj2OF5H1+VzvDLb6kRxGzZHq6f3rAsoSu5cTLGsn3v3VQBA8czkNNXlLjoFEtVu3OQT2jEeOtOE2CA==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/util-uri-escape@4.0.0':
|
||||
resolution: {integrity: sha512-77yfbCbQMtgtTylO9itEAdpPXSog3ZxMe09AEhm0dU0NLTalV70ghDZFR+Nfi1C60jnJoh/Re4090/DuZh2Omg==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/util-uri-escape@4.2.0':
|
||||
resolution: {integrity: sha512-igZpCKV9+E/Mzrpq6YacdTQ0qTiLm85gD6N/IrmyDvQFA4UnU3d5g3m8tMT/6zG/vVkWSU+VxeUyGonL62DuxA==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/util-utf8@2.3.0':
|
||||
resolution: {integrity: sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
@@ -3504,10 +3612,18 @@ packages:
|
||||
resolution: {integrity: sha512-b+zebfKCfRdgNJDknHCob3O7FpeYQN6ZG6YLExMcasDHsCXlsXCEuiPZeLnJLpwa5dvPetGlnGCiMHuLwGvFow==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/util-utf8@4.2.0':
|
||||
resolution: {integrity: sha512-zBPfuzoI8xyBtR2P6WQj63Rz8i3AmfAaJLuNG8dWsfvPe8lO4aCPYLn879mEgHndZH1zQ2oXmG8O1GGzzaoZiw==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/util-waiter@4.0.7':
|
||||
resolution: {integrity: sha512-mYqtQXPmrwvUljaHyGxYUIIRI3qjBTEb/f5QFi3A6VlxhpmZd5mWXn9W+qUkf2pVE1Hv3SqxefiZOPGdxmO64A==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/uuid@1.1.0':
|
||||
resolution: {integrity: sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@socket.io/component-emitter@3.1.2':
|
||||
resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==}
|
||||
|
||||
@@ -3779,6 +3895,11 @@ packages:
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^2.7.0
|
||||
|
||||
'@tiptap/extension-typography@3.18.0':
|
||||
resolution: {integrity: sha512-zTNGJjhJG3lObUhhTbDC1IOyi1DCiCd6i11xsnJDPy5BODYc7t7ZP6VMOWU0LIuMsK9kX02dXoNU7OarJgLpCg==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.18.0
|
||||
|
||||
'@tiptap/pm@2.26.1':
|
||||
resolution: {integrity: sha512-8aF+mY/vSHbGFqyG663ds84b+vca5Lge3tHdTMTKazxCnhXR9dn2oQJMnZ78YZvdRbkPkMJJHti9h3K7u2UQvw==}
|
||||
|
||||
@@ -4478,6 +4599,9 @@ packages:
|
||||
buffer-from@1.1.2:
|
||||
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
|
||||
|
||||
buffer@5.6.0:
|
||||
resolution: {integrity: sha512-/gDYp/UtU0eA1ys8bOs9J6a+E/KWIY+DZ+Q2WESNUA0jFRsJOc0SNUO6xJ5SGA1xueg3NL65W6s+NY5l9cunuw==}
|
||||
|
||||
buffer@5.7.1:
|
||||
resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==}
|
||||
|
||||
@@ -7752,6 +7876,9 @@ packages:
|
||||
resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
stream-browserify@3.0.0:
|
||||
resolution: {integrity: sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA==}
|
||||
|
||||
string-width@4.2.3:
|
||||
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -8894,6 +9021,17 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- aws-crt
|
||||
|
||||
'@aws-sdk/lib-storage@3.985.0(@aws-sdk/client-s3@3.879.0)':
|
||||
dependencies:
|
||||
'@aws-sdk/client-s3': 3.879.0
|
||||
'@smithy/abort-controller': 4.2.8
|
||||
'@smithy/middleware-endpoint': 4.4.13
|
||||
'@smithy/smithy-client': 4.11.2
|
||||
buffer: 5.6.0
|
||||
events: 3.3.0
|
||||
stream-browserify: 3.0.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/middleware-bucket-endpoint@3.873.0':
|
||||
dependencies:
|
||||
'@aws-sdk/types': 3.862.0
|
||||
@@ -11328,6 +11466,11 @@ snapshots:
|
||||
'@smithy/types': 4.3.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/abort-controller@4.2.8':
|
||||
dependencies:
|
||||
'@smithy/types': 4.12.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/chunked-blob-reader-native@4.0.0':
|
||||
dependencies:
|
||||
'@smithy/util-base64': 4.0.0
|
||||
@@ -11345,6 +11488,19 @@ snapshots:
|
||||
'@smithy/util-middleware': 4.0.5
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/core@3.22.1':
|
||||
dependencies:
|
||||
'@smithy/middleware-serde': 4.2.9
|
||||
'@smithy/protocol-http': 5.3.8
|
||||
'@smithy/types': 4.12.0
|
||||
'@smithy/util-base64': 4.3.0
|
||||
'@smithy/util-body-length-browser': 4.2.0
|
||||
'@smithy/util-middleware': 4.2.8
|
||||
'@smithy/util-stream': 4.5.11
|
||||
'@smithy/util-utf8': 4.2.0
|
||||
'@smithy/uuid': 1.1.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/core@3.9.0':
|
||||
dependencies:
|
||||
'@smithy/middleware-serde': 4.0.9
|
||||
@@ -11405,6 +11561,14 @@ snapshots:
|
||||
'@smithy/util-base64': 4.0.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/fetch-http-handler@5.3.9':
|
||||
dependencies:
|
||||
'@smithy/protocol-http': 5.3.8
|
||||
'@smithy/querystring-builder': 4.2.8
|
||||
'@smithy/types': 4.12.0
|
||||
'@smithy/util-base64': 4.3.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/hash-blob-browser@4.0.5':
|
||||
dependencies:
|
||||
'@smithy/chunked-blob-reader': 5.0.0
|
||||
@@ -11438,6 +11602,10 @@ snapshots:
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/is-array-buffer@4.2.0':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/md5-js@4.0.5':
|
||||
dependencies:
|
||||
'@smithy/types': 4.3.2
|
||||
@@ -11461,6 +11629,17 @@ snapshots:
|
||||
'@smithy/util-middleware': 4.0.5
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/middleware-endpoint@4.4.13':
|
||||
dependencies:
|
||||
'@smithy/core': 3.22.1
|
||||
'@smithy/middleware-serde': 4.2.9
|
||||
'@smithy/node-config-provider': 4.3.8
|
||||
'@smithy/shared-ini-file-loader': 4.4.3
|
||||
'@smithy/types': 4.12.0
|
||||
'@smithy/url-parser': 4.2.8
|
||||
'@smithy/util-middleware': 4.2.8
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/middleware-retry@4.1.20':
|
||||
dependencies:
|
||||
'@smithy/node-config-provider': 4.1.4
|
||||
@@ -11480,11 +11659,22 @@ snapshots:
|
||||
'@smithy/types': 4.3.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/middleware-serde@4.2.9':
|
||||
dependencies:
|
||||
'@smithy/protocol-http': 5.3.8
|
||||
'@smithy/types': 4.12.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/middleware-stack@4.0.5':
|
||||
dependencies:
|
||||
'@smithy/types': 4.3.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/middleware-stack@4.2.8':
|
||||
dependencies:
|
||||
'@smithy/types': 4.12.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/node-config-provider@4.1.4':
|
||||
dependencies:
|
||||
'@smithy/property-provider': 4.0.5
|
||||
@@ -11492,6 +11682,13 @@ snapshots:
|
||||
'@smithy/types': 4.3.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/node-config-provider@4.3.8':
|
||||
dependencies:
|
||||
'@smithy/property-provider': 4.2.8
|
||||
'@smithy/shared-ini-file-loader': 4.4.3
|
||||
'@smithy/types': 4.12.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/node-http-handler@4.1.1':
|
||||
dependencies:
|
||||
'@smithy/abort-controller': 4.0.5
|
||||
@@ -11500,27 +11697,56 @@ snapshots:
|
||||
'@smithy/types': 4.3.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/node-http-handler@4.4.9':
|
||||
dependencies:
|
||||
'@smithy/abort-controller': 4.2.8
|
||||
'@smithy/protocol-http': 5.3.8
|
||||
'@smithy/querystring-builder': 4.2.8
|
||||
'@smithy/types': 4.12.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/property-provider@4.0.5':
|
||||
dependencies:
|
||||
'@smithy/types': 4.3.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/property-provider@4.2.8':
|
||||
dependencies:
|
||||
'@smithy/types': 4.12.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/protocol-http@5.1.3':
|
||||
dependencies:
|
||||
'@smithy/types': 4.3.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/protocol-http@5.3.8':
|
||||
dependencies:
|
||||
'@smithy/types': 4.12.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/querystring-builder@4.0.5':
|
||||
dependencies:
|
||||
'@smithy/types': 4.3.2
|
||||
'@smithy/util-uri-escape': 4.0.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/querystring-builder@4.2.8':
|
||||
dependencies:
|
||||
'@smithy/types': 4.12.0
|
||||
'@smithy/util-uri-escape': 4.2.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/querystring-parser@4.0.5':
|
||||
dependencies:
|
||||
'@smithy/types': 4.3.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/querystring-parser@4.2.8':
|
||||
dependencies:
|
||||
'@smithy/types': 4.12.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/service-error-classification@4.0.7':
|
||||
dependencies:
|
||||
'@smithy/types': 4.3.2
|
||||
@@ -11530,6 +11756,11 @@ snapshots:
|
||||
'@smithy/types': 4.3.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/shared-ini-file-loader@4.4.3':
|
||||
dependencies:
|
||||
'@smithy/types': 4.12.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/signature-v4@5.1.3':
|
||||
dependencies:
|
||||
'@smithy/is-array-buffer': 4.0.0
|
||||
@@ -11541,6 +11772,16 @@ snapshots:
|
||||
'@smithy/util-utf8': 4.0.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/smithy-client@4.11.2':
|
||||
dependencies:
|
||||
'@smithy/core': 3.22.1
|
||||
'@smithy/middleware-endpoint': 4.4.13
|
||||
'@smithy/middleware-stack': 4.2.8
|
||||
'@smithy/protocol-http': 5.3.8
|
||||
'@smithy/types': 4.12.0
|
||||
'@smithy/util-stream': 4.5.11
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/smithy-client@4.5.0':
|
||||
dependencies:
|
||||
'@smithy/core': 3.9.0
|
||||
@@ -11551,6 +11792,10 @@ snapshots:
|
||||
'@smithy/util-stream': 4.2.4
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/types@4.12.0':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/types@4.3.2':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
@@ -11561,16 +11806,32 @@ snapshots:
|
||||
'@smithy/types': 4.3.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/url-parser@4.2.8':
|
||||
dependencies:
|
||||
'@smithy/querystring-parser': 4.2.8
|
||||
'@smithy/types': 4.12.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/util-base64@4.0.0':
|
||||
dependencies:
|
||||
'@smithy/util-buffer-from': 4.0.0
|
||||
'@smithy/util-utf8': 4.0.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/util-base64@4.3.0':
|
||||
dependencies:
|
||||
'@smithy/util-buffer-from': 4.2.0
|
||||
'@smithy/util-utf8': 4.2.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/util-body-length-browser@4.0.0':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/util-body-length-browser@4.2.0':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/util-body-length-node@4.0.0':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
@@ -11585,6 +11846,11 @@ snapshots:
|
||||
'@smithy/is-array-buffer': 4.0.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/util-buffer-from@4.2.0':
|
||||
dependencies:
|
||||
'@smithy/is-array-buffer': 4.2.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/util-config-provider@4.0.0':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
@@ -11617,11 +11883,20 @@ snapshots:
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/util-hex-encoding@4.2.0':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/util-middleware@4.0.5':
|
||||
dependencies:
|
||||
'@smithy/types': 4.3.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/util-middleware@4.2.8':
|
||||
dependencies:
|
||||
'@smithy/types': 4.12.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/util-retry@4.0.7':
|
||||
dependencies:
|
||||
'@smithy/service-error-classification': 4.0.7
|
||||
@@ -11639,10 +11914,25 @@ snapshots:
|
||||
'@smithy/util-utf8': 4.0.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/util-stream@4.5.11':
|
||||
dependencies:
|
||||
'@smithy/fetch-http-handler': 5.3.9
|
||||
'@smithy/node-http-handler': 4.4.9
|
||||
'@smithy/types': 4.12.0
|
||||
'@smithy/util-base64': 4.3.0
|
||||
'@smithy/util-buffer-from': 4.2.0
|
||||
'@smithy/util-hex-encoding': 4.2.0
|
||||
'@smithy/util-utf8': 4.2.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/util-uri-escape@4.0.0':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/util-uri-escape@4.2.0':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/util-utf8@2.3.0':
|
||||
dependencies:
|
||||
'@smithy/util-buffer-from': 2.2.0
|
||||
@@ -11653,12 +11943,21 @@ snapshots:
|
||||
'@smithy/util-buffer-from': 4.0.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/util-utf8@4.2.0':
|
||||
dependencies:
|
||||
'@smithy/util-buffer-from': 4.2.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/util-waiter@4.0.7':
|
||||
dependencies:
|
||||
'@smithy/abort-controller': 4.0.5
|
||||
'@smithy/types': 4.3.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/uuid@1.1.0':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
'@socket.io/component-emitter@3.1.2': {}
|
||||
|
||||
'@standard-schema/spec@1.0.0': {}
|
||||
@@ -11919,6 +12218,10 @@ snapshots:
|
||||
dependencies:
|
||||
'@tiptap/core': 2.26.1(@tiptap/pm@2.26.1)
|
||||
|
||||
'@tiptap/extension-typography@3.18.0(@tiptap/core@2.26.1(@tiptap/pm@2.26.1))':
|
||||
dependencies:
|
||||
'@tiptap/core': 2.26.1(@tiptap/pm@2.26.1)
|
||||
|
||||
'@tiptap/pm@2.26.1':
|
||||
dependencies:
|
||||
prosemirror-changeset: 2.3.1
|
||||
@@ -12778,6 +13081,11 @@ snapshots:
|
||||
|
||||
buffer-from@1.1.2: {}
|
||||
|
||||
buffer@5.6.0:
|
||||
dependencies:
|
||||
base64-js: 1.5.1
|
||||
ieee754: 1.2.1
|
||||
|
||||
buffer@5.7.1:
|
||||
dependencies:
|
||||
base64-js: 1.5.1
|
||||
@@ -16898,6 +17206,11 @@ snapshots:
|
||||
es-errors: 1.3.0
|
||||
internal-slot: 1.1.0
|
||||
|
||||
stream-browserify@3.0.0:
|
||||
dependencies:
|
||||
inherits: 2.0.4
|
||||
readable-stream: 3.6.2
|
||||
|
||||
string-width@4.2.3:
|
||||
dependencies:
|
||||
emoji-regex: 8.0.0
|
||||
|
||||
Reference in New Issue
Block a user