Compare commits

..

18 Commits

Author SHA1 Message Date
Henry
ac9bdaf316 feat: improve pricing tiers and add comparison table 2026-02-18 21:58:58 +00:00
Nick Meinhold
031a42b9eb fix: generate presigned avatar URLs for card members on board view (#388)
The board.byId query was generating presigned S3 URLs for workspace
member avatars but not for card member avatars. This caused card avatars
to silently fall back to initials since the frontend helper returns ""
for raw S3 keys.

Closes #387

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 21:10:23 +00:00
Matt
8a1189d011 feat: Activity log for uploading attachments (closes #354) (#366)
* feat: Activity log for uploading attachments (closes #354)

* chore: fix coding style

* revert: upload spinning icon patch

* refactor: add fallbacks, extended card_activity
2026-02-15 20:53:01 +00:00
Charity
53f6ada4d3 feat: add close button to card view header (#383) 2026-02-11 13:30:05 +00:00
Henry
6ea02b1db1 feat: send email notifications on user mentions (#372)
* feat: add notifications schema

* chore: gen migration

* feat: add mention email template

* feat: add sendMentionEmail func

* feat: add repo funcs

* feat: update card router to send emails on mention

* fix: update the editor suggestion to show all members

* feat: skip pending members in sendMentionEmails

* feat: update comments to use tiptap editor
2026-02-11 13:10:46 +00:00
Matt
737b1383d0 feat: add two types of roadmap templates (#369) 2026-02-11 13:03:18 +00:00
Matt
38d0e9495c Join Workspace Email improvements (#371)
* fix failing on percent-encoding invitation urls

* feat: add the workspace and inviter names

* fix: respect the NEXT_PUBLIC_WHITE_LABEL_HIDE_POWERED_BY environment variable
2026-02-08 21:34:17 +00:00
Henry
19798ddbc9 fix: update pnpm-lock 2026-02-08 21:11:26 +00:00
Matt
08ba965921 fix: handle x-forwarded-host and proxy issues behind Cloudflare (#370) 2026-02-08 20:59:01 +00:00
kOaDT
008b5fd1a0 fix: prevent long card titles from overflowing column width (#367) 2026-02-08 20:54:44 +00:00
Henry
97cfd60b59 fix: use multipart upload for attachments to resolve chunk size error (#368) 2026-02-07 22:42:38 +00:00
Ivan Shelepugin
24e283e22c feat: smart typography (#360)
* build(web, deps): add `@tiptap/extension-typography`

* feat(editor): add typography extension

* chore(editor): disable some typographic characters

* chore(editor): disable superscript
2026-02-07 20:04:37 +00:00
Matt
07db7409cb fix: remove phantom spacing below social providers in auth form (closes #334) (#364) 2026-02-07 19:34:06 +00:00
Charity
346ddc16be feat: add functionality to copy card link (#358)
* Implemented link copying feature in CardDropdown and CardModal components.
* Updated Dropdown component to accept new props for card identification.
2026-02-06 23:34:23 +00:00
Charity
f9eee53ed0 fix: board and workspace URL links and copy behavior (#357)
- Use /boards/{id} for private board links so copied link works; keep pretty URL for public boards
- Show matching path in board header (pretty path for public, boards/id for private)
- Copy link button copies URL to clipboard instead of opening in new tab (opening a new tab seems redundant since we're already on it)
- Edit workspace URL button links to /settings/workspace instead of account settings
2026-02-06 23:31:08 +00:00
Henry
1ab4e24c96 docs: add railway deployment template to self hosting docs (#363) 2026-02-05 21:52:40 +00:00
Henry
34165b390f fix: handle missing board in board router 2026-02-04 22:56:17 +00:00
Henry
ef0d53db8f refactor: standardise S3 URL generation (#362)
* refactor: replace presigned URL uploads with backend upload endpoints

* feat: update avatar upload to use new endpoint

* refactor: use createS3Client in auth hooks

* feat: generate presigned URLs for avatars

* fix: show avatar image in user menu

* fix: hide tooltip if content is empty

* fix: support external avatar URLs in generateAvatarUrl

* fix: remove content type restriction on attachments
2026-02-04 22:40:14 +00:00
46 changed files with 9509 additions and 724 deletions

View File

@@ -47,7 +47,17 @@ See our [roadmap](https://kan.bn/kan/roadmap) for upcoming features.
## Self Hosting 🐳 ## 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: 1. Create a new file called `docker-compose.yml` and paste the following configuration:

View File

@@ -20,6 +20,7 @@
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.802.0", "@aws-sdk/client-s3": "^3.802.0",
"@aws-sdk/lib-storage": "^3.802.0",
"@aws-sdk/s3-request-presigner": "^3.812.0", "@aws-sdk/s3-request-presigner": "^3.812.0",
"@headlessui/react": "^2.2.0", "@headlessui/react": "^2.2.0",
"@hookform/resolvers": "^3.3.4", "@hookform/resolvers": "^3.3.4",
@@ -38,6 +39,7 @@
"@tiptap/extension-link": "^2.22.2", "@tiptap/extension-link": "^2.22.2",
"@tiptap/extension-mention": "^3.0.9", "@tiptap/extension-mention": "^3.0.9",
"@tiptap/extension-placeholder": "^2.14.0", "@tiptap/extension-placeholder": "^2.14.0",
"@tiptap/extension-typography": "^3.18.0",
"@tiptap/pm": "^2.14.0", "@tiptap/pm": "^2.14.0",
"@tiptap/react": "^2.14.0", "@tiptap/react": "^2.14.0",
"@tiptap/starter-kit": "^2.14.0", "@tiptap/starter-kit": "^2.14.0",

View File

@@ -361,8 +361,8 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
})} })}
</div> </div>
)} )}
<form onSubmit={handleSubmit(onSubmit)}> {!(isCredentialsEnabled || isMagicLinkAvailable) &&
{!isCredentialsEnabled && socialProviders?.length === 0 && ( socialProviders?.length === 0 && (
<div className="flex w-full items-center gap-4"> <div className="flex w-full items-center gap-4">
<div className="h-[1px] w-1/3 bg-light-600 dark:bg-dark-600" /> <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"> <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 className="h-[1px] w-1/3 bg-light-600 dark:bg-dark-600" />
</div> </div>
)} )}
{!isCredentialsEnabled && socialProviders?.length !== 0 && ( {(isCredentialsEnabled || isMagicLinkAvailable) && (
<div className="mb-[1.5rem] flex w-full items-center gap-4"> <form onSubmit={handleSubmit(onSubmit)}>
<div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" /> {socialProviders?.length !== 0 && (
<span className="text-sm text-light-900 dark:text-dark-900"> <div className="mb-[1.5rem] flex w-full items-center gap-4">
{t`or`} <div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" />
</span> <span className="text-sm text-light-900 dark:text-dark-900">
<div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" /> {t`or`}
</div> </span>
)} <div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" />
<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>
)}
</div> </div>
)} )}
{(isCredentialsEnabled || isMagicLinkAvailable) && ( <div className="space-y-2">
<> {isSignUp && isCredentialsEnabled && (
<div> <div>
<Input <Input
{...register("email", { required: true })} {...register("name", { required: true })}
placeholder={t`Enter your email address`} placeholder={t`Enter your name`}
/> />
{errors.email && ( {errors.name && (
<p className="mt-2 text-xs text-red-400"> <p className="mt-2 text-xs text-red-400">
{t`Please enter a valid email address`} {t`Please enter a valid name`}
</p> </p>
)} )}
</div> </div>
)}
{isCredentialsEnabled && ( <div>
<div> <Input
<Input {...register("email", { required: true })}
type="password" placeholder={t`Enter your email address`}
{...register("password", { required: true })} />
placeholder={t`Enter your password`} {errors.email && (
/> <p className="mt-2 text-xs text-red-400">
{errors.password && ( {t`Please enter a valid email address`}
<p className="mt-2 text-xs text-red-400"> </p>
{errors.password.message ??
t`Please enter a valid password`}
</p>
)}
</div>
)} )}
</> </div>
)}
{loginError && ( {isCredentialsEnabled && (
<p className="mt-2 text-xs text-red-400">{loginError}</p> <div>
)} <Input
</div> type="password"
{(isCredentialsEnabled || isMagicLinkAvailable) && ( {...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"> <div className="mt-[1.5rem] flex items-center gap-4">
<Button <Button
isLoading={isLoginWithEmailPending} isLoading={isLoginWithEmailPending}
@@ -441,8 +438,11 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
{isMagicLinkMode ? t`magic link` : t`email`} {isMagicLinkMode ? t`magic link` : t`email`}
</Button> </Button>
</div> </div>
)} </form>
</form> )}
{!(isCredentialsEnabled || isMagicLinkAvailable) && loginError && (
<p className="mt-2 text-xs text-red-400">{loginError}</p>
)}
</div> </div>
); );
} }

View File

@@ -17,6 +17,7 @@ import {
ReactRenderer, ReactRenderer,
useEditor, useEditor,
} from "@tiptap/react"; } from "@tiptap/react";
import Typography from "@tiptap/extension-typography";
import StarterKit from "@tiptap/starter-kit"; import StarterKit from "@tiptap/starter-kit";
import Suggestion from "@tiptap/suggestion"; import Suggestion from "@tiptap/suggestion";
import { import {
@@ -386,46 +387,54 @@ export interface SlashNodeAttrs {
label?: string | null; label?: string | null;
} }
const CommandItems: SlashCommandItem[] = [ 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 1",
}, icon: <HiH1 />,
{ command: ({ editor }) =>
title: "Heading 2", editor.chain().focus().setHeading({ level: 1 }).run(),
icon: <HiH2 />, },
command: ({ editor }) => {
editor.chain().focus().setHeading({ level: 2 }).run(), title: "Heading 2",
}, icon: <HiH2 />,
{ command: ({ editor }) =>
title: "Heading 3", editor.chain().focus().setHeading({ level: 2 }).run(),
icon: <HiH3 />, },
command: ({ editor }) => {
editor.chain().focus().setHeading({ level: 3 }).run(), title: "Heading 3",
}, icon: <HiH3 />,
{ command: ({ editor }) =>
title: "Bullet List", editor.chain().focus().setHeading({ level: 3 }).run(),
icon: <HiOutlineListBullet />, },
command: ({ editor }) => editor.chain().focus().toggleBulletList().run(), ];
},
{ return [
title: "Ordered List", ...headingCommands,
icon: <HiOutlineNumberedList />, {
command: ({ editor }) => editor.chain().focus().toggleOrderedList().run(), title: "Bullet List",
}, icon: <HiOutlineListBullet />,
{ command: ({ editor }) => editor.chain().focus().toggleBulletList().run(),
title: "Blockquote", },
icon: <HiOutlineChatBubbleLeftEllipsis />, {
command: ({ editor }) => editor.chain().focus().toggleBlockquote().run(), title: "Ordered List",
}, icon: <HiOutlineNumberedList />,
{ command: ({ editor }) => editor.chain().focus().toggleOrderedList().run(),
title: "Code Block", },
icon: <HiOutlineCodeBracketSquare />, {
command: ({ editor }) => editor.chain().focus().toggleCodeBlock().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({ export default function Editor({
content, content,
@@ -434,6 +443,8 @@ export default function Editor({
readOnly = false, readOnly = false,
workspaceMembers, workspaceMembers,
enableYouTubeEmbed = true, enableYouTubeEmbed = true,
placeholder,
disableHeadings = false,
}: { }: {
content: string | null; content: string | null;
onChange?: (value: string) => void; onChange?: (value: string) => void;
@@ -441,18 +452,23 @@ export default function Editor({
readOnly?: boolean; readOnly?: boolean;
workspaceMembers: WorkspaceMember[]; workspaceMembers: WorkspaceMember[];
enableYouTubeEmbed?: boolean; enableYouTubeEmbed?: boolean;
placeholder?: string;
disableHeadings?: boolean;
}) { }) {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const editor = useEditor( const editor = useEditor(
{ {
extensions: [ extensions: [
StarterKit, StarterKit.configure({
heading: disableHeadings ? false : undefined,
}),
Markdown, Markdown,
Placeholder.configure({ Placeholder.configure({
placeholder: readOnly 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({ Link.configure({
openOnClick: true, openOnClick: true,
@@ -465,10 +481,10 @@ export default function Editor({
autolink: true, autolink: true,
}), }),
SlashCommands.configure({ SlashCommands.configure({
commandItems: CommandItems, commandItems: getCommandItems(disableHeadings),
suggestion: { suggestion: {
items: ({ query }: { query: string }) => items: ({ query }: { query: string }) =>
filterSlashCommandItems(CommandItems, query), filterSlashCommandItems(getCommandItems(disableHeadings), query),
startOfLine: true, startOfLine: true,
char: "/", char: "/",
}, },
@@ -480,20 +496,28 @@ export default function Editor({
suggestion: { suggestion: {
char: "@", char: "@",
items: ({ query }: { query: string }) => { items: ({ query }: { query: string }) => {
const all: MentionItem[] = workspaceMembers.map( const withEmail = workspaceMembers.filter((member) => member.email);
(member: WorkspaceMember) => ({
id: member.publicId, const mapped = withEmail.map((member: WorkspaceMember) => ({
label: member?.user?.name ?? member.email, id: member.publicId,
image: member?.user?.image ?? null, 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( const q = query.toLowerCase().trim();
(u) =>
u.label && if (q === "") {
typeof u.label === "string" && return all;
u.label.toLowerCase().includes(q), }
const filtered = all.filter((u) =>
u.label.toLowerCase().includes(q),
); );
return filtered;
}, },
command: ({ editor, range, props }) => { command: ({ editor, range, props }) => {
const id = props.id ?? ""; const id = props.id ?? "";
@@ -514,6 +538,17 @@ export default function Editor({
return `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`; 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] : []), ...(enableYouTubeEmbed ? [YouTubeNode] : []),
], ],
content, content,

View File

@@ -13,6 +13,23 @@ const authHandler = toNodeHandler(auth.handler);
export default withRateLimit( export default withRateLimit(
{ points: 100, duration: 60 }, { points: 100, duration: 60 },
async (req, res) => { 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); return await authHandler(req, res);
}, },
); );

View File

@@ -1,17 +1,17 @@
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import { PutObjectCommand } from "@aws-sdk/client-s3"; import { Upload } from "@aws-sdk/lib-storage";
import { createNextApiContext } from "@kan/api/trpc"; 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 cardRepo from "@kan/db/repository/card.repo";
import * as cardActivityRepo from "@kan/db/repository/cardActivity.repo"; import * as cardActivityRepo from "@kan/db/repository/cardActivity.repo";
import * as cardAttachmentRepo from "@kan/db/repository/cardAttachment.repo"; import * as cardAttachmentRepo from "@kan/db/repository/cardAttachment.repo";
import { generateUID } from "@kan/shared/utils"; import { createS3Client, generateUID } from "@kan/shared/utils";
import { env } from "~/env"; import { env } from "~/env";
import { withRateLimit } from "@kan/api/utils/rateLimit";
import { createS3Client } from "@kan/shared/utils";
import { assertPermission } from "@kan/api/utils/permissions";
// FIXME: Respect the environment variable: NEXT_API_BODY_SIZE_LIMIT
const MAX_SIZE_BYTES = 50 * 1024 * 1024; // 50MB const MAX_SIZE_BYTES = 50 * 1024 * 1024; // 50MB
export const config = { export const config = {
@@ -90,16 +90,19 @@ export default withRateLimit(
const client = createS3Client(); const client = createS3Client();
// Upload the file to S3 const upload = new Upload({
await client.send( client,
new PutObjectCommand({ params: {
Bucket: bucket, Bucket: bucket,
Key: s3Key, Key: s3Key,
Body: req, Body: req,
ContentType: contentType, ContentType: contentType,
ContentLength: contentLength, ContentLength: contentLength,
}), },
); leavePartsOnError: false,
});
await upload.done();
// Create attachment record and log activity // Create attachment record and log activity
const attachment = await cardAttachmentRepo.create(db, { const attachment = await cardAttachmentRepo.create(db, {
@@ -112,9 +115,15 @@ export default withRateLimit(
createdBy: user.id, createdBy: user.id,
}); });
if (!attachment) {
return res.status(500).json({ error: "Failed to create attachment" });
}
await cardActivityRepo.create(db, { await cardActivityRepo.create(db, {
type: "card.updated.attachment.added", type: "card.updated.attachment.added",
cardId: card.id, cardId: card.id,
attachmentId: attachment.id,
toTitle: originalFilenameHeader,
createdBy: user.id, createdBy: user.id,
}); });
@@ -125,4 +134,3 @@ export default withRateLimit(
} }
}, },
); );

View File

@@ -66,8 +66,8 @@ const Card = ({
const hasDueDate = !!dueDate; const hasDueDate = !!dueDate;
return ( 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"> <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>{title}</span> <span className="break-words">{title}</span>
{labels.length || {labels.length ||
members.length || members.length ||
checklists.length > 0 || checklists.length > 0 ||

View File

@@ -1,24 +1,40 @@
import Link from "next/link"; import { Fragment } from "react";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { env } from "next-runtime-env"; import { env } from "next-runtime-env";
import { HiLink } from "react-icons/hi"; import { HiLink } from "react-icons/hi";
import { Tooltip } from "~/components/Tooltip"; 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 = ({ const UpdateBoardSlugButton = ({
handleOnClick, handleOnClick,
workspaceSlug, workspaceSlug,
boardSlug, boardSlug,
boardPublicId,
visibility,
isLoading, isLoading,
canEdit, canEdit,
}: { }: {
handleOnClick: () => void; handleOnClick: () => void;
workspaceSlug: string; workspaceSlug: string;
boardSlug: string; boardSlug: string;
boardPublicId: string;
visibility: "public" | "private";
isLoading: boolean; isLoading: boolean;
canEdit: boolean; canEdit: boolean;
}) => { }) => {
if (!isLoading && (!workspaceSlug || !boardSlug)) return <></>; const { showPopup } = usePopup();
if (isLoading) { if (isLoading) {
return ( 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 ( return (
<Tooltip <Tooltip
content={!canEdit && !isLoading ? t`You don't have permission` : undefined} content={!canEdit ? t`You don't have permission` : undefined}
> >
<button <button
onClick={canEdit ? handleOnClick : undefined} onClick={canEdit ? handleOnClick : undefined}
disabled={!canEdit || isLoading} 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" 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"> <div className="flex items-center">
<span> {pathSegments.map((segment, i) => (
{env("NEXT_PUBLIC_KAN_ENV") === "cloud" <Fragment key={i}>
? "kan.bn" {i > 0 && pathSeparator}
: env("NEXT_PUBLIC_BASE_URL")} <span>{segment}</span>
</span> </Fragment>
<div className="mx-1.5 h-4 w-px rotate-[20deg] bg-gray-300 dark:bg-dark-600"></div> ))}
<span>{workspaceSlug}</span> </div>
<div className="mx-1.5 h-4 w-px rotate-[20deg] bg-gray-300 dark:bg-dark-600"></div> <button
<span>{boardSlug}</span> type="button"
</div> onClick={(e) => {
<Link e.stopPropagation();
href={`${env("NEXT_PUBLIC_BASE_URL")}/${workspaceSlug}/${boardSlug}`} navigator.clipboard.writeText(boardUrl).then(
target="_blank" () =>
rel="noopener noreferrer" showPopup({
onClick={(e) => { header: t`Link copied`,
e.stopPropagation(); icon: "success",
if (!canEdit) { message: t`Board URL copied to clipboard`,
e.preventDefault(); }),
} ).catch(() => undefined);
}} }}
className="flex h-7 w-7 items-center justify-center rounded-full hover:bg-light-200 dark:hover:bg-dark-200" 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]" /> >
</Link> <HiLink className="h-[13px] w-[13px]" />
</button> </button>
</button>
</Tooltip> </Tooltip>
); );
}; };
export default UpdateBoardSlugButton; export default UpdateBoardSlugButton;

View File

@@ -156,7 +156,7 @@ export function UpdateBoardSlugForm({
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button <Button
variant="secondary" variant="secondary"
href="/settings?tab=workspace" href="/settings/workspace"
onClick={closeModal} onClick={closeModal}
> >
{t`Edit workspace URL`} {t`Edit workspace URL`}

View File

@@ -58,13 +58,14 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
const [selectedPublicListId, setSelectedPublicListId] = const [selectedPublicListId, setSelectedPublicListId] =
useState<PublicListId>(""); useState<PublicListId>("");
const [isInitialLoading, setIsInitialLoading] = useState(true); const [isInitialLoading, setIsInitialLoading] = useState(true);
const { ref: scrollRef, onMouseDown } = useDragToScroll({ const { ref: scrollRef, onMouseDown } = useDragToScroll({
enabled: true, enabled: true,
direction: "horizontal", direction: "horizontal",
}); });
const { canCreateList, canEditList, canEditCard, canEditBoard } = usePermissions(); const { canCreateList, canEditList, canEditCard, canEditBoard } =
usePermissions();
const { tooltipContent: createListShortcutTooltipContent } = const { tooltipContent: createListShortcutTooltipContent } =
useKeyboardShortcut({ useKeyboardShortcut({
@@ -417,10 +418,9 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
{...register("name")} {...register("name")}
onBlur={canEditBoard ? handleSubmit(onSubmit) : undefined} onBlur={canEditBoard ? handleSubmit(onSubmit) : undefined}
readOnly={!canEditBoard} 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> </form>
)} )}
{!boardData && !isLoading && ( {!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"> <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} isLoading={isLoading}
workspaceSlug={workspace.slug ?? ""} workspaceSlug={workspace.slug ?? ""}
boardSlug={boardData?.slug ?? ""} boardSlug={boardData?.slug ?? ""}
boardPublicId={boardId ?? ""}
visibility={boardData?.visibility ?? "private"}
canEdit={canEditBoard} canEdit={canEditBoard}
/> />
<VisibilityButton <VisibilityButton
@@ -596,12 +598,13 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
? `/templates/${boardId}/cards/${card.publicId}` ? `/templates/${boardId}/cards/${card.publicId}`
: `/cards/${card.publicId}` : `/cards/${card.publicId}`
} }
className={`mb-2 flex !cursor-pointer flex-col ${card.publicId.startsWith( className={`mb-2 flex !cursor-pointer flex-col ${
"PLACEHOLDER", card.publicId.startsWith(
) "PLACEHOLDER",
? "pointer-events-none" )
: "" ? "pointer-events-none"
}`} : ""
}`}
ref={provided.innerRef} ref={provided.innerRef}
{...provided.draggableProps} {...provided.draggableProps}
{...provided.dragHandleProps} {...provided.dragHandleProps}

View File

@@ -23,6 +23,18 @@ export const getTemplates = (): Template[] => [
lists: [t`Backlog`, t`To Do`, t`In Progress`, t`Code Review`, t`Done`], 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`], 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", id: "content-creation",
name: t`Content Creation`, name: t`Content Creation`,

View File

@@ -8,6 +8,7 @@ import {
HiOutlineArrowRight, HiOutlineArrowRight,
HiOutlineCheckCircle, HiOutlineCheckCircle,
HiOutlineClock, HiOutlineClock,
HiOutlinePaperClip,
HiOutlinePencil, HiOutlinePencil,
HiOutlinePlus, HiOutlinePlus,
HiOutlineTag, HiOutlineTag,
@@ -31,6 +32,16 @@ import Comment from "./Comment";
type ActivityType = type ActivityType =
NonNullable<GetCardByIdOutput>["activities"][number]["type"]; 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) => { const truncate = (value: string | null, maxLength = 50) => {
if (!value) return value; if (!value) return value;
return value.length > maxLength ? `${value.slice(0, maxLength - 1)}` : value; return value.length > maxLength ? `${value.slice(0, maxLength - 1)}` : value;
@@ -57,6 +68,7 @@ const getActivityText = ({
toDueDate, toDueDate,
dateLocale, dateLocale,
mergedLabels, mergedLabels,
attachmentName,
}: { }: {
type: ActivityType; type: ActivityType;
toTitle: string | null; toTitle: string | null;
@@ -71,6 +83,7 @@ const getActivityText = ({
toDueDate?: Date | null; toDueDate?: Date | null;
dateLocale: DateFnsLocale; dateLocale: DateFnsLocale;
mergedLabels?: string[]; mergedLabels?: string[];
attachmentName?: string | null;
}) => { }) => {
const displayName = memberName ?? memberEmail ?? t`Member`; const displayName = memberName ?? memberEmail ?? t`Member`;
const TextHighlight = ({ children }: { children: React.ReactNode }) => ( 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.completed": t`completed a checklist item`,
"card.updated.checklist.item.uncompleted": t`marked a checklist item as incomplete`, "card.updated.checklist.item.uncompleted": t`marked a checklist item as incomplete`,
"card.updated.checklist.item.deleted": t`deleted a checklist item`, "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.added": t`set the due date`,
"card.updated.dueDate.updated": t`updated the due date`, "card.updated.dueDate.updated": t`updated the due date`,
"card.updated.dueDate.removed": t`removed 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) { if (type === "card.updated.dueDate.added" && toDueDate) {
const showYear = !isSameYear(toDueDate, new Date()); const showYear = !isSameYear(toDueDate, new Date());
const formattedDate = format( 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.completed": <HiOutlineCheckCircle />,
"card.updated.checklist.item.uncompleted": <HiOutlineCheckCircle />, "card.updated.checklist.item.uncompleted": <HiOutlineCheckCircle />,
"card.updated.checklist.item.deleted": <HiOutlineTrash />, "card.updated.checklist.item.deleted": <HiOutlineTrash />,
"card.updated.attachment.added": <HiOutlinePaperClip />,
"card.updated.attachment.removed": <HiOutlinePaperClip />,
"card.updated.dueDate.added": <HiOutlineClock />, "card.updated.dueDate.added": <HiOutlineClock />,
"card.updated.dueDate.updated": <HiOutlineClock />, "card.updated.dueDate.updated": <HiOutlineClock />,
"card.updated.dueDate.removed": <HiOutlineClock />, "card.updated.dueDate.removed": <HiOutlineClock />,
@@ -341,7 +379,7 @@ const ActivityList = ({
isAdmin?: boolean; isAdmin?: boolean;
isViewOnly?: boolean; isViewOnly?: boolean;
}) => { }) => {
const { dateLocale, locale } = useLocalisation(); const { dateLocale } = useLocalisation();
const { data: sessionData } = authClient.useSession(); const { data: sessionData } = authClient.useSession();
const utils = api.useUtils(); const utils = api.useUtils();
const [allActivities, setAllActivities] = useState< const [allActivities, setAllActivities] = useState<
@@ -391,25 +429,21 @@ const ActivityList = ({
cursor: nextCursor, cursor: nextCursor,
}); });
if (nextPage) { const existingIds = new Set(
const existingIds = new Set( currentActivities.map((a) => a.publicId),
currentActivities.map((a) => a.publicId), );
); const newActivities = nextPage.activities.filter(
const newActivities = nextPage.activities.filter( (a: { publicId: string }) => !existingIds.has(a.publicId),
(a: { publicId: string }) => !existingIds.has(a.publicId), );
); currentActivities = [...currentActivities, ...newActivities];
currentActivities = [...currentActivities, ...newActivities]; currentHasMore = nextPage.hasMore;
currentHasMore = nextPage.hasMore;
} else {
break;
}
} }
setAllActivities(currentActivities); setAllActivities(currentActivities);
setHasMore(false); setHasMore(false);
}; };
fetchAllRemaining(); void fetchAllRemaining();
} else { } else {
setAllActivities(firstPageData.activities); setAllActivities(firstPageData.activities);
setHasMore(firstPageData.hasMore); setHasMore(firstPageData.hasMore);
@@ -436,17 +470,15 @@ const ActivityList = ({
cursor: nextCursor, cursor: nextCursor,
}); });
if (nextPage) { const existingIds = new Set(allActivities.map((a) => a.publicId));
const existingIds = new Set(allActivities.map((a) => a.publicId)); const newActivities = nextPage.activities.filter(
const newActivities = nextPage.activities.filter( (a: { publicId: string }) => !existingIds.has(a.publicId),
(a: { publicId: string }) => !existingIds.has(a.publicId), );
); setAllActivities((prev) => [...prev, ...newActivities]);
setAllActivities((prev) => [...prev, ...newActivities]); setHasMore(nextPage.hasMore);
setHasMore(nextPage.hasMore);
if (!nextPage.hasMore) { if (!nextPage.hasMore) {
isFullyExpandedRef.current = true; isFullyExpandedRef.current = true;
}
} }
} finally { } finally {
setIsLoadingMore(false); setIsLoadingMore(false);
@@ -473,7 +505,10 @@ const ActivityList = ({
fromDueDate: activity.fromDueDate ?? null, fromDueDate: activity.fromDueDate ?? null,
toDueDate: activity.toDueDate ?? null, toDueDate: activity.toDueDate ?? null,
dateLocale: dateLocale, 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") if (activity.type === "card.updated.comment.added")

View File

@@ -1,12 +1,13 @@
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { formatDistanceToNow } from "date-fns"; import { formatDistanceToNow } from "date-fns";
import { useState } from "react"; import { useState } from "react";
import ContentEditable from "react-contenteditable";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { HiEllipsisHorizontal, HiPencil, HiTrash } from "react-icons/hi2"; import { HiEllipsisHorizontal, HiPencil, HiTrash } from "react-icons/hi2";
import Avatar from "~/components/Avatar"; import Avatar from "~/components/Avatar";
import Button from "~/components/Button"; import Button from "~/components/Button";
import Editor from "~/components/Editor";
import type { WorkspaceMember } from "~/components/Editor";
import Dropdown from "~/components/Dropdown"; import Dropdown from "~/components/Dropdown";
import { usePermissions } from "~/hooks/usePermissions"; import { usePermissions } from "~/hooks/usePermissions";
import { useModal } from "~/providers/modal"; import { useModal } from "~/providers/modal";
@@ -29,7 +30,6 @@ const Comment = ({
createdAt, createdAt,
comment, comment,
isAuthor, isAuthor,
isAdmin,
isEdited = false, isEdited = false,
isViewOnly = false, isViewOnly = false,
}: { }: {
@@ -42,7 +42,6 @@ const Comment = ({
createdAt: string; createdAt: string;
comment: string | undefined; comment: string | undefined;
isAuthor: boolean; isAuthor: boolean;
isAdmin: boolean;
isEdited: boolean; isEdited: boolean;
isViewOnly: 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; if (!publicId) return null;
const updateCommentMutation = api.card.updateComment.useMutation({ const updateCommentMutation = api.card.updateComment.useMutation({
@@ -142,21 +165,28 @@ const Comment = ({
)} )}
</div> </div>
{!isEditing ? ( {!isEditing ? (
<ContentEditable <div className="mt-2">
html={comment ?? ""} <Editor
disabled={true} content={comment ?? null}
className="break-anywhere mt-2 text-sm" readOnly={true}
/> workspaceMembers={workspaceMembers}
enableYouTubeEmbed={false}
disableHeadings={true}
/>
</div>
) : ( ) : (
<form onSubmit={handleSubmit(onSubmit)}> <form onSubmit={handleSubmit(onSubmit)}>
<ContentEditable <div className="mt-2">
placeholder={t`Add a comment...`} <Editor
html={watch("comment")} content={watch("comment")}
disabled={false} onChange={(value) => setValue("comment", value)}
onChange={(e) => setValue("comment", e.target.value)} workspaceMembers={workspaceMembers}
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" enableYouTubeEmbed={false}
/> placeholder={t`Add comment... (type '/' to open commands or '@' to mention)`}
<div className="flex justify-end space-x-2"> disableHeadings={true}
/>
</div>
<div className="flex justify-end space-x-2 mt-2">
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"

View File

@@ -1,6 +1,7 @@
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { import {
HiEllipsisHorizontal, HiEllipsisHorizontal,
HiLink,
HiOutlineCheckCircle, HiOutlineCheckCircle,
HiOutlineTrash, HiOutlineTrash,
} from "react-icons/hi2"; } from "react-icons/hi2";
@@ -10,18 +11,54 @@ import { authClient } from "@kan/auth/client";
import Dropdown from "~/components/Dropdown"; import Dropdown from "~/components/Dropdown";
import { usePermissions } from "~/hooks/usePermissions"; import { usePermissions } from "~/hooks/usePermissions";
import { useModal } from "~/providers/modal"; import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
export default function CardDropdown({ export default function CardDropdown({
cardPublicId,
isTemplate,
boardPublicId,
cardCreatedBy, cardCreatedBy,
}: { }: {
cardPublicId: string;
isTemplate?: boolean;
boardPublicId?: string;
cardCreatedBy?: string | null; cardCreatedBy?: string | null;
}) { }) {
const { openModal } = useModal(); const { openModal } = useModal();
const { showPopup } = usePopup();
const { canEditCard, canDeleteCard } = usePermissions(); const { canEditCard, canDeleteCard } = usePermissions();
const { data: session } = authClient.useSession(); const { data: session } = authClient.useSession();
const isCreator = cardCreatedBy && session?.user.id === cardCreatedBy; 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 = [ const items = [
{
label: t`Copy card link`,
action: handleCopyCardLink,
icon: <HiLink className="h-[16px] w-[16px] text-dark-900" />,
},
...(canEditCard ...(canEditCard
? [ ? [
{ {

View File

@@ -1,8 +1,9 @@
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import ContentEditable from "react-contenteditable";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { HiOutlineArrowUp } from "react-icons/hi2"; import { HiOutlineArrowUp } from "react-icons/hi2";
import Editor from "~/components/Editor";
import type { WorkspaceMember } from "~/components/Editor";
import LoadingSpinner from "~/components/LoadingSpinner"; import LoadingSpinner from "~/components/LoadingSpinner";
import { usePermissions } from "~/hooks/usePermissions"; import { usePermissions } from "~/hooks/usePermissions";
import { usePopup } from "~/providers/popup"; import { usePopup } from "~/providers/popup";
@@ -13,7 +14,13 @@ interface FormValues {
comment: string; comment: string;
} }
const NewCommentForm = ({ cardPublicId }: { cardPublicId: string }) => { const NewCommentForm = ({
cardPublicId,
workspaceMembers,
}: {
cardPublicId: string;
workspaceMembers: WorkspaceMember[];
}) => {
const utils = api.useUtils(); const utils = api.useUtils();
const { showPopup } = usePopup(); const { showPopup } = usePopup();
const { canCreateComment } = usePermissions(); const { canCreateComment } = usePermissions();
@@ -23,10 +30,6 @@ const NewCommentForm = ({ cardPublicId }: { cardPublicId: string }) => {
}, },
}); });
const queryParams = {
cardPublicId,
};
const addCommentMutation = api.card.addComment.useMutation({ const addCommentMutation = api.card.addComment.useMutation({
onError: (_error, _newList) => { onError: (_error, _newList) => {
showPopup({ showPopup({
@@ -57,18 +60,13 @@ const NewCommentForm = ({ cardPublicId }: { cardPublicId: string }) => {
onSubmit={handleSubmit(onSubmit)} 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" 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 <Editor
placeholder={t`Add a comment...`} content={watch("comment")}
html={watch("comment")} onChange={(value) => setValue("comment", value)}
disabled={false} workspaceMembers={workspaceMembers}
onChange={(e) => setValue("comment", e.target.value)} enableYouTubeEmbed={false}
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" placeholder={t`Add comment... (type '/' to open commands or '@' to mention)`}
onKeyDown={async (e) => { disableHeadings={true}
if (e.key === "Enter" && e.shiftKey) {
e.preventDefault();
await handleSubmit(onSubmit)();
}
}}
/> />
<div className="flex justify-end"> <div className="flex justify-end">
<button <button

View File

@@ -4,6 +4,9 @@ import { t } from "@lingui/core/macro";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { IoChevronForwardSharp } from "react-icons/io5"; import { IoChevronForwardSharp } from "react-icons/io5";
import { HiXMark } from "react-icons/hi2";
import { authClient } from "@kan/auth/client";
import Avatar from "~/components/Avatar"; import Avatar from "~/components/Avatar";
import Editor from "~/components/Editor"; import Editor from "~/components/Editor";
@@ -14,8 +17,6 @@ import Modal from "~/components/modal";
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm"; import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
import { PageHead } from "~/components/PageHead"; import { PageHead } from "~/components/PageHead";
import { EditYouTubeModal } from "~/components/YouTubeEmbed/EditYouTubeModal"; import { EditYouTubeModal } from "~/components/YouTubeEmbed/EditYouTubeModal";
import { authClient } from "@kan/auth/client";
import { usePermissions } from "~/hooks/usePermissions"; import { usePermissions } from "~/hooks/usePermissions";
import { useModal } from "~/providers/modal"; import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup"; import { usePopup } from "~/providers/popup";
@@ -167,7 +168,6 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
const { const {
modalContentType, modalContentType,
entityId, entityId,
openModal,
getModalState, getModalState,
clearModalState, clearModalState,
isOpen, isOpen,
@@ -198,8 +198,24 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
}; };
const board = card?.list.board; const board = card?.list.board;
const workspaceMembers = board?.workspace.members;
const boardId = board?.publicId; 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({ const updateCard = api.card.update.useMutation({
onError: () => { onError: () => {
showPopup({ showPopup({
@@ -286,6 +302,7 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
if (!cardId) return <></>; if (!cardId) return <></>;
return ( return (
<> <>
<PageHead <PageHead
@@ -317,7 +334,19 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
</Link> </Link>
</div> </div>
<div className="flex items-center gap-2"> <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> </div>
</> </>
)} )}
@@ -374,9 +403,15 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
<div className="mt-2"> <div className="mt-2">
<Editor <Editor
content={card.description} content={card.description}
onChange={canEdit ? (e) => setValue("description", e) : undefined} onChange={
onBlur={canEdit ? () => handleSubmit(onSubmit)() : undefined} canEdit
workspaceMembers={board?.workspace.members ?? []} ? (e) => setValue("description", e)
: undefined
}
onBlur={
canEdit ? () => handleSubmit(onSubmit)() : undefined
}
workspaceMembers={workspaceMembers ?? []}
readOnly={!canEdit} readOnly={!canEdit}
/> />
</div> </div>
@@ -420,7 +455,10 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
</div> </div>
{!isTemplate && ( {!isTemplate && (
<div className="mt-6"> <div className="mt-6">
<NewCommentForm cardPublicId={cardId} /> <NewCommentForm
cardPublicId={cardId}
workspaceMembers={editorWorkspaceMembers}
/>
</div> </div>
)} )}
</div> </div>

View File

@@ -1,293 +1,587 @@
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import React, { useEffect, useRef, useState } from "react";
import { HiCheckCircle, HiXCircle } from "react-icons/hi2"; import { HiCheckCircle, HiXCircle } from "react-icons/hi2";
import { twMerge } from "tailwind-merge";
interface Feature {
key: string;
label: string;
kan: boolean | string;
trello: boolean | string;
}
interface Section {
name: string;
features: Feature[];
}
type FrequencyValue = "monthly" | "annually"; type FrequencyValue = "monthly" | "annually";
const CellValue = ({ value }: { value: boolean | string }) => { interface PlanFeature {
if (typeof value === "string") { key: string;
return ( label: string;
<span className="text-sm text-dark-900 dark:text-dark-900">{value}</span> free: string | boolean | { text: string; highlight?: string };
); teams: string | boolean | { text: string; highlight?: string };
} pro: string | boolean | { text: string; highlight?: string };
return value ? ( enterprise: string | boolean | { text: string; highlight?: string };
<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 FeatureSection {
); name: string;
}; features: PlanFeature[];
}
const FeatureComparisonTable = ({ const FeatureComparisonTable = ({
frequencyValue, frequencyValue: _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`, key: "boards",
features: [ label: t`Boards`,
{ free: { text: t`Unlimited boards`, highlight: "Unlimited" },
key: "ulimited-workspaces", teams: { text: t`Unlimited boards`, highlight: "Unlimited" },
label: t`Unlimited workspaces`, pro: { text: t`Unlimited boards`, highlight: "Unlimited" },
kan: true, enterprise: { text: t`Unlimited boards`, highlight: "Unlimited" },
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,
},
],
}, },
{ {
name: t`Teams`, key: "members",
features: [ label: t`Members`,
{ free: { text: t`1 user`, highlight: "1" },
key: "pricing", teams: { text: t`Per-seat pricing`, highlight: "Per-seat" },
label: t`Price per user/month`, pro: { text: t`Unlimited members`, highlight: "Unlimited" },
kan: frequencyValue === "monthly" ? `$10.00` : `$8.00`, enterprise: { text: t`Unlimited members`, highlight: "Unlimited" },
trello: frequencyValue === "monthly" ? `$12.00` : `$10.00`, },
}, {
{ key: "file-uploads",
key: "comments", label: t`File uploads`,
label: t`Comments & mentions`, free: { text: t`10mb file uploads`, highlight: "10mb" },
kan: true, teams: { text: t`Unlimited file uploads`, highlight: "Unlimited" },
trello: true, pro: { text: t`Unlimited file uploads`, highlight: "Unlimited" },
}, enterprise: { text: t`Unlimited file uploads`, highlight: "Unlimited" },
{ },
key: "assignments", {
label: t`Assignees`, key: "workspace-username",
kan: true, label: t`Workspace username`,
trello: true, free: { text: t`Default username`, highlight: "Default" },
}, teams: { text: t`Default username`, highlight: "Default" },
{ pro: { text: t`Custom username`, highlight: "Custom" },
key: "sharing", enterprise: { text: t`Custom username`, highlight: "Custom" },
label: t`Board sharing & invites`, },
kan: true, ];
trello: true,
}, const coreFeatures: PlanFeature[] = [
{ {
key: "invite-links", key: "checklists",
label: t`Invite links`, label: t`Checklists`,
kan: true, free: true,
trello: 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`, name: t`Platform`,
features: [ features: platformFeatures,
{ },
key: "api", {
label: t`REST API`, name: t`Security`,
kan: true, features: securityFeatures,
trello: true, },
}, {
{ name: t`Support`,
key: "open-source", features: supportFeatures,
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,
},
],
}, },
]; ];
const products = [ const plans = [
{ { id: "free", name: t`Free`, tierId: "tier-free" },
id: "kan", { id: "teams", name: t`Teams`, tierId: "tier-teams" },
name: t`Kan`, { id: "pro", name: t`Pro`, tierId: "tier-pro" },
featured: true, { id: "enterprise", name: t`Enterprise`, tierId: "tier-enterprise" },
},
{
id: "trello",
name: t`Trello`,
featured: false,
},
]; ];
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 ( return (
<section aria-labelledby="comparison-heading" className="w-full px-4"> <section aria-labelledby="comparison-heading" className="w-full">
<div className="mx-auto max-w-6xl py-8 lg:py-10"> <div className="mx-auto max-w-7xl">
<div className="grid grid-cols-3 gap-x-8 border-t border-light-500 before:block dark:border-dark-300"> <div
{products.map((product) => ( ref={containerRef}
<div key={product.id} aria-hidden="true" className="-mt-px"> className="relative overflow-x-auto rounded-lg border border-light-300 bg-light-50 dark:border-dark-300 dark:bg-dark-50"
<div >
className={`${product.featured ? "border-dark-200 dark:border-dark-800" : "border-transparent"} border-t-2 pt-8`} {isHeaderFixed && (
> <div
<p 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"
className={`${product.featured ? "text-dark-50 dark:text-dark-1000" : "text-light-1000 dark:text-dark-1000"} text-center text-sm font-semibold`} style={{
> top: 64,
{product.name} left: headerRect.left,
</p> 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>
))} )}
</div> <table className="w-full border-separate border-spacing-0">
<thead>
<div className="-mt-6 space-y-12"> <tr>
{sections.map((section) => ( {plans.map((plan) => {
<div key={section.name}> const isMostPopular = plan.id === "pro";
<h4 className="text-sm font-semibold text-light-1000 dark:text-dark-1000"> return (
{section.name} <th
</h4> key={plan.id}
<div className="relative -mx-8 mt-8"> scope="col"
<div className={twMerge(
aria-hidden="true" "w-1/4 px-6 py-4 text-left text-base font-semibold",
className="absolute inset-x-8 inset-y-0 grid grid-cols-3 gap-x-8 before:block" isMostPopular
> ? "bg-light-200 text-light-1000 dark:bg-dark-100 dark:text-dark-1000"
{products.map((product) => ( : "bg-light-50 text-dark-50 dark:bg-dark-50 dark:text-dark-1000",
<div )}
key={product.id} >
className={`size-full rounded-lg ${product.featured ? "bg-white shadow-sm dark:bg-dark-50 dark:shadow-none" : ""}`} <span className="flex items-center">
/> {plan.name}
))} {plan.id === "pro" && <span className="ml-1 text-xl"></span>}
</div> </span>
</th>
<table className="relative w-full border-separate border-spacing-x-8"> );
<thead> })}
<tr className="text-left"> </tr>
<th scope="col"> </thead>
<span className="sr-only">{t`Feature`}</span> <tbody>
</th> {sections.map((section, sectionIdx) => (
{products.map((p) => ( <React.Fragment key={section.name || `section-${sectionIdx}`}>
<th key={p.id} scope="col"> {section.name && (
<span className="sr-only">{p.name}</span> <tr>
</th> {plans.map((plan) => {
))} const isPro = plan.id === "pro";
</tr> return (
</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) => (
<td <td
key={p.id} key={plan.id}
className="relative w-1/3 px-4 py-0 text-center" 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"> {plan.id === "free" ? section.name : ""}
<CellValue
value={
p.id === "kan" ? feature.kan : feature.trello
}
/>
</span>
</td> </td>
))} );
</tr> })}
))} </tr>
</tbody> )}
</table> {section.features.map((feature) => (
<tr key={feature.key}>
<div {plans.map((plan) => {
aria-hidden="true" const isPro = plan.id === "pro";
className="pointer-events-none absolute inset-x-8 inset-y-0 grid grid-cols-3 gap-x-8 before:block" return (
> <td
{products.map((product) => ( key={plan.id}
<div className={twMerge(
key={product.id} "w-1/4 border-t px-6 py-3 text-left",
className={`${product.featured ? "ring-1 ring-light-500 dark:ring-dark-800" : "ring-0"} rounded-2xl`} 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> </React.Fragment>
</div> ))}
</div> </tbody>
))} </table>
</div> </div>
</div> </div>
</section> </section>

View File

@@ -23,81 +23,115 @@ const Pricing = ({
}) => { }) => {
const tiers = [ const tiers = [
{ {
name: t`Individuals`, name: t`Free`,
id: "tier-individuals", id: "tier-free",
href: "signup", href: "signup",
buttonText: t`Get Started`, buttonText: t`Get started`,
price: { monthly: t`Free`, annually: t`Free` }, price: { monthly: t`$0`, annually: t`$0` },
description: t`Everything you need, free forever. Unlimited boards, unlimited lists, unlimited cards. Upgrade any time.`, description: t`Free for everyone`,
featureHeader: t`Free, forever`, bestFor: t`Best for individuals and solo creators getting started`,
featureHeader: undefined,
features: [ features: [
t`1 user`, { text: t`1 user` },
t`Unlimited boards`, { text: t`Unlimited boards` },
t`Unlimited cards`, { text: t`Unlimited cards` },
t`Custom board templates`, { text: t`Custom board templates` },
t`Checklists`, { text: t`Checklists` },
], ],
showPrice: true, showPrice: true,
ctaSubtext: undefined,
mostPopular: false,
highlighted: false,
}, },
{ {
name: t`Teams`, name: t`Teams`,
id: "tier-teams", id: "tier-teams",
href: "signup", href: "signup",
buttonText: t`Get Started`, buttonText: t`Get started`,
price: { monthly: "$10.00", annually: "$8.00" }, price: { monthly: "$10", annually: "$8" },
description: t`Kan is better with a team. Perfect for small and growing teams looking to collaborate.`, description: t`Perfect for small and growing teams looking to collaborate.`,
featureHeader: t`Everything in the free plan, plus:`, bestFor: t`Best for small and growing teams that need collaboration`,
featureHeader: undefined,
features: [ features: [
t`Workspace members`, { text: t`All Free features +` },
t`Admin roles`, { text: t`Workspace members` },
t`Priority email support`, { text: t`Invite links` },
t`Support the development of the project`, { 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, highlighted: true,
showPrice: true, showPrice: true,
showPriceSuffix: true, showPriceSuffix: false,
ctaSubtext: t`14 day free trial · Switch plans or cancel anytime`,
mostPopular: true,
}, },
{ {
name: t`Self Host`, name: t`Enterprise`,
id: "tier-self-host", id: "tier-enterprise",
href: "https://github.com/kanbn/kan", href: "mailto:support@kan.bn?subject=Enterprise Inquiry",
buttonText: t`View docs`, buttonText: t`Contact Sales`,
price: { monthly: "-", annually: "-" }, price: { monthly: t`Contact us`, annually: t`Contact us` },
description: t`Host Kan on your own infrastructure. Ideal for organisations that need complete control over their data.`, description: t`Advanced security, compliance, and dedicated support for large organizations.`,
featureHeader: t`Complete control and ownership:`, bestFor: t`Best for large organizations with advanced needs`,
featureHeader: undefined,
features: [ features: [
t`Run on your own infrastructure`, { text: t`All Pro features +` },
t`Own your data`, { text: t`SAML SSO` },
t`Custom domain`, { 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, mostPopular: false,
showPrice: false,
}, },
]; ];
return ( return (
<> <>
<div className="flex flex-col items-center justify-center px-4 pb-10"> <div className="mx-auto max-w-7xl px-4">
<div className="mt-14 flex flex-col items-center justify-center"> <div className="mb-8 flex 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>
<fieldset aria-label={t`Payment frequency`}> <fieldset aria-label={t`Payment frequency`}>
<RadioGroup <RadioGroup
value={frequency} value={frequency}
onChange={(value) => setFrequency(value)} 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) => ( {frequencies.map((option) => (
<Radio <Radio
key={option.value} key={option.value}
value={option} value={option}
className={twMerge( 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 frequency?.value === option.value
? "bg-dark-50 text-white dark:bg-light-50 dark:text-dark-50" ? "bg-white text-light-1000 shadow-sm dark:bg-dark-100 dark:text-dark-1000"
: "text-light-900 hover:bg-light-100 dark:hover:bg-dark-100", : "text-light-600 dark:text-dark-700",
)} )}
> >
{option.label} {option.label}
@@ -106,103 +140,129 @@ const Pricing = ({
</RadioGroup> </RadioGroup>
</fieldset> </fieldset>
</div> </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) => ( {tiers.map((tier) => (
<div <div
key={tier.id} key={tier.id}
className={twMerge( className={twMerge(
tier.highlighted "relative flex h-full flex-col rounded-lg border p-6",
? "bg-dark-50 ring-1 ring-dark-50 dark:ring-dark-800" tier.mostPopular
: "bg-light-50 ring-1 ring-light-300 dark:bg-dark-50 dark:ring-dark-300", ? "border-light-400 bg-light-200 dark:border-dark-400 dark:bg-dark-100"
"rounded-3xl p-8 xl:p-10", : "border-light-300 bg-light-50 dark:border-dark-300 dark:bg-dark-50",
)} )}
> >
<div className="flex items-center justify-between gap-x-4"> {tier.id === "tier-pro" && (
<h3 <div className="absolute right-2 top-2 z-10">
id={tier.id} <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">
className={twMerge( <HiBolt className="h-3 w-3 -ml-0.5" />
tier.highlighted {t`Launch offer`}
? "text-dark-1000 dark:text-dark-1000" </span>
: "text-dark-50 dark:text-dark-1000", </div>
"text-lg/8 font-semibold", )}
<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.showPriceSuffix && (
{tier.name} <span className="text-sm font-normal text-dark-400 dark:text-dark-700">
</h3> {frequency?.priceSuffix}
{tier.highlighted && ( </span>
<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`} {tier.id === "tier-pro" && (
</p> <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> </div>
<p <ul role="list" className="space-y-2.5">
className={twMerge( {tier.features.map((feature, index) => (
"mt-4 text-sm/6 text-dark-950 dark:text-dark-900", <li
tier.highlighted ? "text-light-100" : "text-dark-50", key={`${tier.id}-feature-${index}`}
)} className="flex items-start gap-x-2.5"
> >
{tier.description} <HiCheckCircle
</p> className="mt-0.5 h-4 w-4 shrink-0 text-light-1000 dark:text-dark-1000"
<p className="mt-6 flex items-baseline gap-x-1"> />
<span <span className="text-sm text-dark-600 dark:text-dark-800">
className={twMerge( {feature.text}
"text-3xl font-semibold tracking-tight text-light-100", </span>
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}
</li> </li>
))} ))}
</ul> </ul>
</div> </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> </div>
</> </>
); );

View File

@@ -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"> <p className="mt-4 text-center text-3xl font-bold text-light-1000 dark:text-dark-1000 lg:text-5xl">
{t`Simple pricing`} {t`Simple pricing`}
</p> </p>
<p className="text:md lg:text-md mt-6 max-w-[500px] text-center text-light-950 dark:text-dark-900"> <p className="text:md lg:text-md mt-6 max-w-[300px] 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.`} {t`Start free. Upgrade as your team grows. No hidden fees, no contracts.`}
</p> </p>
</div> </div>
<PricingTiers <PricingTiers
@@ -59,23 +59,9 @@ export default function PricingView() {
</div> </div>
<div className="pb-22 flex flex-col items-center justify-center px-4"> <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"> <div className="mt-10 w-full">
{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]">
<FeatureComparisonTable <FeatureComparisonTable
frequencyValue={frequency?.value ?? "annually"} frequencyValue={frequency?.value ?? "annually"}
/> />

View File

@@ -1,12 +1,13 @@
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { useEffect, useRef, useState } from "react"; 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 Badge from "~/components/Badge";
import Editor from "~/components/Editor"; import Editor from "~/components/Editor";
import LabelIcon from "~/components/LabelIcon"; import LabelIcon from "~/components/LabelIcon";
import { useModal } from "~/providers/modal"; import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api"; import { api } from "~/utils/api";
import ActivityList from "~/views/card/components/ActivityList"; import ActivityList from "~/views/card/components/ActivityList";
import { AttachmentThumbnails } from "~/views/card/components/AttachmentThumbnails"; import { AttachmentThumbnails } from "~/views/card/components/AttachmentThumbnails";
@@ -23,10 +24,29 @@ export function CardModal({
}) { }) {
const router = useRouter(); const router = useRouter();
const { closeModal, isOpen } = useModal(); const { closeModal, isOpen } = useModal();
const { showPopup } = usePopup();
const [showFade, setShowFade] = useState(false); const [showFade, setShowFade] = useState(false);
const [showTopFade, setShowTopFade] = useState(false); const [showTopFade, setShowTopFade] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null); 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( const { data, isLoading } = api.card.byId.useQuery(
{ {
cardPublicId: cardPublicId ?? "", cardPublicId: cardPublicId ?? "",
@@ -65,33 +85,44 @@ export function CardModal({
<div className="h-full p-8"> <div className="h-full p-8">
<div className="mb-6"> <div className="mb-6">
<div className="flex w-full items-center justify-between"> <div className="flex w-full items-center justify-between">
<button <div className="absolute right-[2rem] top-[2rem] flex items-center gap-1">
className="absolute right-[2rem] top-[2rem] rounded p-1 hover:bg-light-300 focus:outline-none dark:hover:bg-dark-300" <button
onClick={(e) => { type="button"
e.preventDefault(); onClick={handleCopyCardLink}
closeModal(); 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(() => { setTimeout(() => {
void router.replace( void router.replace(
{ {
pathname: router.pathname, pathname: router.pathname,
query: { query: {
...router.query, ...router.query,
workspaceSlug, workspaceSlug: workspaceSlug ?? "",
boardSlug: [boardSlug], boardSlug: [boardSlug ?? ""],
},
}, },
}, undefined,
undefined, { shallow: true },
{ shallow: true }, );
); }, 400);
}, 400); }}
}} >
> <HiXMark
<HiXMark size={18}
size={18} className="text-light-900 dark:text-dark-900"
className="dark:text-dark-9000 text-light-900" />
/> </button>
</button> </div>
{isLoading ? ( {isLoading ? (
<div className="flex space-x-2"> <div className="flex space-x-2">
<div className="h-[2.3rem] w-[300px] animate-pulse rounded-[5px] bg-light-300 dark:bg-dark-300" /> <div className="h-[2.3rem] w-[300px] animate-pulse rounded-[5px] bg-light-300 dark:bg-dark-300" />

View File

@@ -30,7 +30,7 @@ export default function PublicBoardView() {
const { showPopup } = usePopup(); const { showPopup } = usePopup();
const [isRouteLoaded, setIsRouteLoaded] = useState(false); const [isRouteLoaded, setIsRouteLoaded] = useState(false);
const { openModal } = useModal(); const { openModal } = useModal();
const { ref: scrollRef, onMouseDown } = useDragToScroll({ const { ref: scrollRef, onMouseDown } = useDragToScroll({
enabled: true, enabled: true,
direction: "horizontal", direction: "horizontal",
@@ -70,30 +70,35 @@ export default function PublicBoardView() {
}, },
); );
const CopyBoardLink = () => { const handleCopyBoardLink = async () => {
return ( try {
<button await navigator.clipboard.writeText(window.location.href);
onClick={async () => { showPopup({
try { header: t`Link copied`,
await navigator.clipboard.writeText(window.location.href); icon: "success",
} catch (error) { message: t`Board URL copied to clipboard`,
console.error(error); });
} } catch (error) {
console.error(error);
showPopup({ showPopup({
header: t`Link copied`, header: t`Unable to copy link`,
icon: "success", icon: "error",
message: t`Board URL copied to clipboard`, message: t`Please try again.`,
}); });
}} }
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 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 pathWithoutQuery = router.asPath.split("?")[0];
const splitPath = pathWithoutQuery?.split("/") ?? []; const splitPath = pathWithoutQuery?.split("/") ?? [];
const cardPublicId = splitPath.length > 3 ? splitPath[3] : null; const cardPublicId = splitPath.length > 3 ? splitPath[3] : null;

View File

@@ -142,9 +142,18 @@ export const attachmentRouter = createTRPCRouter({
createdBy: userId, createdBy: userId,
}); });
if (!attachment) {
throw new TRPCError({
message: "Failed to create attachment",
code: "INTERNAL_SERVER_ERROR",
});
}
await cardActivityRepo.create(ctx.db, { await cardActivityRepo.create(ctx.db, {
type: "card.updated.attachment.added", type: "card.updated.attachment.added",
cardId: card.id, cardId: card.id,
attachmentId: attachment.id,
toTitle: input.originalFilename,
createdBy: userId, createdBy: userId,
}); });
@@ -206,6 +215,8 @@ export const attachmentRouter = createTRPCRouter({
await cardActivityRepo.create(ctx.db, { await cardActivityRepo.create(ctx.db, {
type: "card.updated.attachment.removed", type: "card.updated.attachment.removed",
cardId: attachment.cardId, cardId: attachment.cardId,
attachmentId: attachment.id,
fromTitle: attachment.originalFilename,
createdBy: userId, createdBy: userId,
}); });

View File

@@ -143,6 +143,13 @@ export const boardRouter = createTRPCRouter({
}, },
); );
if (!result) {
throw new TRPCError({
message: `Board with public ID ${input.boardPublicId} not found`,
code: "NOT_FOUND",
});
}
// Generate presigned URLs for workspace member avatars // Generate presigned URLs for workspace member avatars
const workspaceWithAvatarUrls = result.workspace const workspaceWithAvatarUrls = result.workspace
? { ? {
@@ -166,8 +173,31 @@ export const boardRouter = createTRPCRouter({
} }
: result.workspace; : 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 { return {
...result, ...result,
lists: listsWithAvatarUrls,
workspace: workspaceWithAvatarUrls, workspace: workspaceWithAvatarUrls,
}; };
}), }),

View File

@@ -10,6 +10,7 @@ import * as workspaceRepo from "@kan/db/repository/workspace.repo";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
import { mergeActivities } from "../utils/activities"; import { mergeActivities } from "../utils/activities";
import { sendMentionEmails } from "../utils/notifications";
import { assertCanDelete, assertCanEdit, assertPermission } from "../utils/permissions"; import { assertCanDelete, assertCanEdit, assertPermission } from "../utils/permissions";
import { generateAttachmentUrl, generateAvatarUrl } from "@kan/shared/utils"; import { generateAttachmentUrl, generateAvatarUrl } from "@kan/shared/utils";
@@ -153,6 +154,17 @@ export const cardRouter = createTRPCRouter({
await cardActivityRepo.bulkCreate(ctx.db, cardActivitesInsert); 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; return newCard;
}), }),
addComment: protectedProcedure addComment: protectedProcedure
@@ -215,6 +227,16 @@ export const cardRouter = createTRPCRouter({
createdBy: userId, 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; return newComment;
}), }),
updateComment: protectedProcedure updateComment: protectedProcedure
@@ -295,6 +317,16 @@ export const cardRouter = createTRPCRouter({
createdBy: userId, 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; return updatedComment;
}), }),
deleteComment: protectedProcedure deleteComment: protectedProcedure
@@ -924,6 +956,15 @@ export const cardRouter = createTRPCRouter({
fromDescription: existingCard.description ?? undefined, fromDescription: existingCard.description ?? undefined,
toDescription: input.description, 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 ( if (

View 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),
});
}
}

View File

@@ -11,15 +11,16 @@ import { createPlugins } from "./plugins";
import { configuredProviders } from "./providers"; import { configuredProviders } from "./providers";
export const initAuth = (db: dbClient) => { 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({ return betterAuth({
secret: env("BETTER_AUTH_SECRET"), secret: env("BETTER_AUTH_SECRET"),
baseURL: env("NEXT_PUBLIC_BASE_URL"), baseURL,
trustedOrigins: env("BETTER_AUTH_TRUSTED_ORIGINS") trustedOrigins: [
? [ ...(baseURL ? [baseURL] : []),
env("NEXT_PUBLIC_BASE_URL") ?? "", ...trustedOrigins,
...(env("BETTER_AUTH_TRUSTED_ORIGINS")?.split(",") ?? []), ],
]
: [env("NEXT_PUBLIC_BASE_URL") ?? ""],
database: drizzleAdapter(db, { database: drizzleAdapter(db, {
provider: "pg", provider: "pg",
schema: { schema: {

View File

@@ -5,6 +5,7 @@ import { magicLink } from "better-auth/plugins/magic-link";
import type { dbClient } from "@kan/db/client"; import type { dbClient } from "@kan/db/client";
import * as memberRepo from "@kan/db/repository/member.repo"; import * as memberRepo from "@kan/db/repository/member.repo";
import * as subscriptionRepo from "@kan/db/repository/subscription.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 * as workspaceRepo from "@kan/db/repository/workspace.repo";
import { generateUID } from "@kan/shared/utils"; import { generateUID } from "@kan/shared/utils";
import { sendEmail } from "@kan/email"; import { sendEmail } from "@kan/email";
@@ -133,10 +134,10 @@ export function createPlugins(db: dbClient) {
if (workspace?.id) { if (workspace?.id) {
await memberRepo.pauseAllMembers(db, workspace.id); await memberRepo.pauseAllMembers(db, workspace.id);
// Reset slug to publicId, or generate a UID if publicId is taken // Reset slug to publicId, or generate a UID if publicId is taken
let newSlug = workspace.publicId; let newSlug = workspace.publicId;
if (workspace.slug !== workspace.publicId) { if (workspace.slug !== workspace.publicId) {
const isPublicIdAvailable = await workspaceRepo.isWorkspaceSlugAvailable( const isPublicIdAvailable = await workspaceRepo.isWorkspaceSlugAvailable(
db, db,
@@ -146,7 +147,7 @@ export function createPlugins(db: dbClient) {
newSlug = generateUID(); newSlug = generateUID();
} }
} }
await workspaceRepo.update(db, subscription.referenceId, { await workspaceRepo.update(db, subscription.referenceId, {
plan: "free", plan: "free",
slug: newSlug, slug: newSlug,
@@ -171,19 +172,69 @@ export function createPlugins(db: dbClient) {
magicLink({ magicLink({
expiresIn: 60 * 60 * 24 * 7, // 7 days expiresIn: 60 * 60 * 24 * 7, // 7 days
sendMagicLink: async ({ email, url }) => { 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( await sendEmail(
email, email,
"Invitation to join workspace", workspaceName
? `Invitation to join the workspace ${workspaceName}`
: "Invitation to join workspace",
"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, magicLoginUrl: url,
}, },
); );
} else {
await sendEmail(email, "Sign in to kan.bn", "MAGIC_LINK", {
magicLoginUrl: url,
});
} }
}, },
}), }),

View File

@@ -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");

View File

@@ -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 $$;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -176,6 +176,20 @@
"when": 1769983198190, "when": 1769983198190,
"tag": "20260201215958_AddUserBoardFavouritesTable", "tag": "20260201215958_AddUserBoardFavouritesTable",
"breakpoints": true "breakpoints": true
},
{
"idx": 25,
"version": "7",
"when": 1770500457005,
"tag": "20260207214056_AddNotificationsTable",
"breakpoints": true
},
{
"idx": 26,
"version": "7",
"when": 1770521594167,
"tag": "20260208033314_AddAttachmentToActivity",
"breakpoints": true
} }
] ]
} }

View File

@@ -212,6 +212,7 @@ export const getByPublicId = async (
columns: { columns: {
publicId: true, publicId: true,
email: true, email: true,
status: true,
}, },
with: { with: {
user: { user: {

View File

@@ -420,6 +420,7 @@ export const getWithListAndMembersByPublicId = async (
) => { ) => {
const card = await db.query.cards.findFirst({ const card = await db.query.cards.findFirst({
columns: { columns: {
id: true,
publicId: true, publicId: true,
title: true, title: true,
description: true, description: true,
@@ -507,6 +508,7 @@ export const getWithListAndMembersByPublicId = async (
columns: { columns: {
publicId: true, publicId: true,
email: true, email: true,
status: true,
}, },
with: { with: {
user: { user: {

View File

@@ -33,6 +33,7 @@ export const create = async (
fromDueDate?: Date; fromDueDate?: Date;
toDueDate?: Date; toDueDate?: Date;
sourceBoardId?: number; sourceBoardId?: number;
attachmentId?: number;
}, },
) => { ) => {
const [result] = await db const [result] = await db
@@ -58,6 +59,7 @@ export const create = async (
fromDueDate: activityInput.fromDueDate, fromDueDate: activityInput.fromDueDate,
toDueDate: activityInput.toDueDate, toDueDate: activityInput.toDueDate,
sourceBoardId: activityInput.sourceBoardId, sourceBoardId: activityInput.sourceBoardId,
attachmentId: activityInput.attachmentId,
}) })
.returning({ id: cardActivities.id }); .returning({ id: cardActivities.id });
@@ -83,6 +85,7 @@ export const bulkCreate = async (
fromDueDate?: Date; fromDueDate?: Date;
toDueDate?: Date; toDueDate?: Date;
sourceBoardId?: number; sourceBoardId?: number;
attachmentId?: number;
}[], }[],
) => { ) => {
const activitiesWithPublicIds = activityInputs.map((activity) => ({ const activitiesWithPublicIds = activityInputs.map((activity) => ({
@@ -191,6 +194,13 @@ export const getPaginatedActivities = async (
deletedAt: true, deletedAt: true,
}, },
}, },
attachment: {
columns: {
publicId: true,
filename: true,
originalFilename: true,
},
},
}, },
orderBy: asc(cardActivities.createdAt), // required for merging and pagination orderBy: asc(cardActivities.createdAt), // required for merging and pagination
limit: limit + 1, // fetch one extra to check if there are more limit: limit + 1, // fetch one extra to check if there are more

View File

@@ -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 ( export const getByEmailAndStatus = async (
db: dbClient, db: dbClient,
email: string, email: string,

View 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;
};

View File

@@ -147,6 +147,10 @@ export const cardActivities = pgTable("card_activity", {
() => boards.id, () => boards.id,
{ onDelete: "set null" }, { onDelete: "set null" },
), ),
attachmentId: bigint("attachmentId", { mode: "number" }).references(
() => cardAttachments.id,
{ onDelete: "cascade" },
),
}).enableRLS(); }).enableRLS();
export const cardActivitiesRelations = relations(cardActivities, ({ one }) => ({ export const cardActivitiesRelations = relations(cardActivities, ({ one }) => ({
@@ -190,6 +194,11 @@ export const cardActivitiesRelations = relations(cardActivities, ({ one }) => ({
references: [comments.id], references: [comments.id],
relationName: "cardActivitiesComment", relationName: "cardActivitiesComment",
}), }),
attachment: one(cardAttachments, {
fields: [cardActivities.attachmentId],
references: [cardAttachments.id],
relationName: "cardActivitiesAttachment",
}),
})); }));
export const cardsToLabels = pgTable( export const cardsToLabels = pgTable(

View File

@@ -13,3 +13,4 @@ export * from "./workspaces";
export * from "./subscriptions"; export * from "./subscriptions";
export * from "./workspaceInviteLinks"; export * from "./workspaceInviteLinks";
export * from "./permissions"; export * from "./permissions";
export * from "./notifications";

View 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",
}),
}));

View File

@@ -3,14 +3,16 @@ import nodemailer from "nodemailer";
import JoinWorkspaceTemplate from "./templates/join-workspace"; import JoinWorkspaceTemplate from "./templates/join-workspace";
import MagicLinkTemplate from "./templates/magic-link"; import MagicLinkTemplate from "./templates/magic-link";
import MentionTemplate from "./templates/mention";
import ResetPasswordTemplate from "./templates/reset-password"; 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, MAGIC_LINK: MagicLinkTemplate,
JOIN_WORKSPACE: JoinWorkspaceTemplate, JOIN_WORKSPACE: JoinWorkspaceTemplate,
RESET_PASSWORD: ResetPasswordTemplate, RESET_PASSWORD: ResetPasswordTemplate,
MENTION: MentionTemplate,
}; };
const transporter = nodemailer.createTransport({ const transporter = nodemailer.createTransport({

View File

@@ -13,12 +13,16 @@ import * as React from "react";
export const JoinWorkspaceTemplate = ({ export const JoinWorkspaceTemplate = ({
magicLoginUrl, magicLoginUrl,
inviterName,
workspaceName,
}: { }: {
magicLoginUrl?: string; magicLoginUrl?: string;
inviterName?: string;
workspaceName?: string;
}) => ( }) => (
<Html> <Html>
<Head /> <Head />
<Preview>Log in with this magic link</Preview> <Preview>Join {workspaceName ?? "workspace"} on kan.bn</Preview>
<Body style={{ backgroundColor: "white" }}> <Body style={{ backgroundColor: "white" }}>
<Container <Container
style={{ style={{
@@ -38,12 +42,16 @@ export const JoinWorkspaceTemplate = ({
color: "#232323", color: "#232323",
}} }}
> >
kan.bn {env("NEXT_PUBLIC_WHITE_LABEL_HIDE_POWERED_BY") !== "true" && "Kan"}
</Heading> </Heading>
<Heading <Heading
style={{ fontSize: "24px", fontWeight: "bold", color: "#232323" }} 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> </Heading>
<Text <Text
style={{ style={{
@@ -52,7 +60,7 @@ export const JoinWorkspaceTemplate = ({
color: "#232323", color: "#232323",
}} }}
> >
Click the button below to instantly login to your account. Click the button below to add this workspace to your account.
</Text> </Text>
<Button <Button
target="_blank" target="_blank"
@@ -71,7 +79,7 @@ export const JoinWorkspaceTemplate = ({
color: "white", color: "white",
}} }}
> >
Login to your account Join {workspaceName ?? "workspace"}
</Button> </Button>
<Text <Text
style={{ style={{
@@ -80,25 +88,29 @@ export const JoinWorkspaceTemplate = ({
color: "#7e7e7e", color: "#7e7e7e",
}} }}
> >
If you didn&apos;t try to login, you can safely ignore this email. If you don&apos;t want to join this workspace, 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.
</Text> </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> </Container>
</Body> </Body>
</Html> </Html>

View 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;

View File

@@ -4,3 +4,4 @@ export * from "./subscriptions";
export * from "./email"; export * from "./email";
export * from "./dueDateFilters"; export * from "./dueDateFilters";
export * from "./s3"; export * from "./s3";
export * from "./mentions";

View 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));
}

313
pnpm-lock.yaml generated
View File

@@ -82,6 +82,9 @@ importers:
'@aws-sdk/client-s3': '@aws-sdk/client-s3':
specifier: ^3.802.0 specifier: ^3.802.0
version: 3.879.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': '@aws-sdk/s3-request-presigner':
specifier: ^3.812.0 specifier: ^3.812.0
version: 3.879.0 version: 3.879.0
@@ -136,6 +139,9 @@ importers:
'@tiptap/extension-placeholder': '@tiptap/extension-placeholder':
specifier: ^2.14.0 specifier: ^2.14.0
version: 2.26.1(@tiptap/core@2.26.1(@tiptap/pm@2.26.1))(@tiptap/pm@2.26.1) 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': '@tiptap/pm':
specifier: ^2.14.0 specifier: ^2.14.0
version: 2.26.1 version: 2.26.1
@@ -734,6 +740,12 @@ packages:
resolution: {integrity: sha512-Jy4uPFfGzHk1Mxy+/Wr43vuw9yXsE2yiF4e4598vc3aJfO0YtA2nSfbKD3PNKRORwXbeKqWPfph9SCKQpWoxEg==} resolution: {integrity: sha512-Jy4uPFfGzHk1Mxy+/Wr43vuw9yXsE2yiF4e4598vc3aJfO0YtA2nSfbKD3PNKRORwXbeKqWPfph9SCKQpWoxEg==}
engines: {node: '>=18.0.0'} 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': '@aws-sdk/middleware-bucket-endpoint@3.873.0':
resolution: {integrity: sha512-b4bvr0QdADeTUs+lPc9Z48kXzbKHXQKgTvxx/jXDgSW9tv4KmYPO1gIj6Z9dcrBkRWQuUtSW3Tu2S5n6pe+zeg==} resolution: {integrity: sha512-b4bvr0QdADeTUs+lPc9Z48kXzbKHXQKgTvxx/jXDgSW9tv4KmYPO1gIj6Z9dcrBkRWQuUtSW3Tu2S5n6pe+zeg==}
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
@@ -3300,6 +3312,10 @@ packages:
resolution: {integrity: sha512-jcrqdTQurIrBbUm4W2YdLVMQDoL0sA9DTxYd2s+R/y+2U9NLOP7Xf/YqfSg1FZhlZIYEnvk2mwbyvIfdLEPo8g==} resolution: {integrity: sha512-jcrqdTQurIrBbUm4W2YdLVMQDoL0sA9DTxYd2s+R/y+2U9NLOP7Xf/YqfSg1FZhlZIYEnvk2mwbyvIfdLEPo8g==}
engines: {node: '>=18.0.0'} 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': '@smithy/chunked-blob-reader-native@4.0.0':
resolution: {integrity: sha512-R9wM2yPmfEMsUmlMlIgSzOyICs0x9uu7UTHoccMyt7BWw8shcGM8HqB355+BZCPBcySvbTYMs62EgEQkNxz2ig==} resolution: {integrity: sha512-R9wM2yPmfEMsUmlMlIgSzOyICs0x9uu7UTHoccMyt7BWw8shcGM8HqB355+BZCPBcySvbTYMs62EgEQkNxz2ig==}
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
@@ -3312,6 +3328,10 @@ packages:
resolution: {integrity: sha512-viuHMxBAqydkB0AfWwHIdwf/PRH2z5KHGUzqyRtS/Wv+n3IHI993Sk76VCA7dD/+GzgGOmlJDITfPcJC1nIVIw==} resolution: {integrity: sha512-viuHMxBAqydkB0AfWwHIdwf/PRH2z5KHGUzqyRtS/Wv+n3IHI993Sk76VCA7dD/+GzgGOmlJDITfPcJC1nIVIw==}
engines: {node: '>=18.0.0'} 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': '@smithy/core@3.9.0':
resolution: {integrity: sha512-B/GknvCfS3llXd/b++hcrwIuqnEozQDnRL4sBmOac5/z/dr0/yG1PURNPOyU4Lsiy1IyTj8scPxVqRs5dYWf6A==} resolution: {integrity: sha512-B/GknvCfS3llXd/b++hcrwIuqnEozQDnRL4sBmOac5/z/dr0/yG1PURNPOyU4Lsiy1IyTj8scPxVqRs5dYWf6A==}
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
@@ -3344,6 +3364,10 @@ packages:
resolution: {integrity: sha512-61WjM0PWmZJR+SnmzaKI7t7G0UkkNFboDpzIdzSoy7TByUzlxo18Qlh9s71qug4AY4hlH/CwXdubMtkcNEb/sQ==} resolution: {integrity: sha512-61WjM0PWmZJR+SnmzaKI7t7G0UkkNFboDpzIdzSoy7TByUzlxo18Qlh9s71qug4AY4hlH/CwXdubMtkcNEb/sQ==}
engines: {node: '>=18.0.0'} 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': '@smithy/hash-blob-browser@4.0.5':
resolution: {integrity: sha512-F7MmCd3FH/Q2edhcKd+qulWkwfChHbc9nhguBlVjSUE6hVHhec3q6uPQ+0u69S6ppvLtR3eStfCuEKMXBXhvvA==} resolution: {integrity: sha512-F7MmCd3FH/Q2edhcKd+qulWkwfChHbc9nhguBlVjSUE6hVHhec3q6uPQ+0u69S6ppvLtR3eStfCuEKMXBXhvvA==}
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
@@ -3368,6 +3392,10 @@ packages:
resolution: {integrity: sha512-saYhF8ZZNoJDTvJBEWgeBccCg+yvp1CX+ed12yORU3NilJScfc6gfch2oVb4QgxZrGUx3/ZJlb+c/dJbyupxlw==} resolution: {integrity: sha512-saYhF8ZZNoJDTvJBEWgeBccCg+yvp1CX+ed12yORU3NilJScfc6gfch2oVb4QgxZrGUx3/ZJlb+c/dJbyupxlw==}
engines: {node: '>=18.0.0'} 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': '@smithy/md5-js@4.0.5':
resolution: {integrity: sha512-8n2XCwdUbGr8W/XhMTaxILkVlw2QebkVTn5tm3HOcbPbOpWg89zr6dPXsH8xbeTsbTXlJvlJNTQsKAIoqQGbdA==} resolution: {integrity: sha512-8n2XCwdUbGr8W/XhMTaxILkVlw2QebkVTn5tm3HOcbPbOpWg89zr6dPXsH8xbeTsbTXlJvlJNTQsKAIoqQGbdA==}
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
@@ -3380,6 +3408,10 @@ packages:
resolution: {integrity: sha512-EAlEPncqo03siNZJ9Tm6adKCQ+sw5fNU8ncxWwaH0zTCwMPsgmERTi6CEKaermZdgJb+4Yvh0NFm36HeO4PGgQ==} resolution: {integrity: sha512-EAlEPncqo03siNZJ9Tm6adKCQ+sw5fNU8ncxWwaH0zTCwMPsgmERTi6CEKaermZdgJb+4Yvh0NFm36HeO4PGgQ==}
engines: {node: '>=18.0.0'} 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': '@smithy/middleware-retry@4.1.20':
resolution: {integrity: sha512-T3maNEm3Masae99eFdx1Q7PIqBBEVOvRd5hralqKZNeIivnoGNx5OFtI3DiZ5gCjUkl0mNondlzSXeVxkinh7Q==} resolution: {integrity: sha512-T3maNEm3Masae99eFdx1Q7PIqBBEVOvRd5hralqKZNeIivnoGNx5OFtI3DiZ5gCjUkl0mNondlzSXeVxkinh7Q==}
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
@@ -3388,34 +3420,66 @@ packages:
resolution: {integrity: sha512-uAFFR4dpeoJPGz8x9mhxp+RPjo5wW0QEEIPPPbLXiRRWeCATf/Km3gKIVR5vaP8bN1kgsPhcEeh+IZvUlBv6Xg==} resolution: {integrity: sha512-uAFFR4dpeoJPGz8x9mhxp+RPjo5wW0QEEIPPPbLXiRRWeCATf/Km3gKIVR5vaP8bN1kgsPhcEeh+IZvUlBv6Xg==}
engines: {node: '>=18.0.0'} 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': '@smithy/middleware-stack@4.0.5':
resolution: {integrity: sha512-/yoHDXZPh3ocRVyeWQFvC44u8seu3eYzZRveCMfgMOBcNKnAmOvjbL9+Cp5XKSIi9iYA9PECUuW2teDAk8T+OQ==} resolution: {integrity: sha512-/yoHDXZPh3ocRVyeWQFvC44u8seu3eYzZRveCMfgMOBcNKnAmOvjbL9+Cp5XKSIi9iYA9PECUuW2teDAk8T+OQ==}
engines: {node: '>=18.0.0'} 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': '@smithy/node-config-provider@4.1.4':
resolution: {integrity: sha512-+UDQV/k42jLEPPHSn39l0Bmc4sB1xtdI9Gd47fzo/0PbXzJ7ylgaOByVjF5EeQIumkepnrJyfx86dPa9p47Y+w==} resolution: {integrity: sha512-+UDQV/k42jLEPPHSn39l0Bmc4sB1xtdI9Gd47fzo/0PbXzJ7ylgaOByVjF5EeQIumkepnrJyfx86dPa9p47Y+w==}
engines: {node: '>=18.0.0'} 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': '@smithy/node-http-handler@4.1.1':
resolution: {integrity: sha512-RHnlHqFpoVdjSPPiYy/t40Zovf3BBHc2oemgD7VsVTFFZrU5erFFe0n52OANZZ/5sbshgD93sOh5r6I35Xmpaw==} resolution: {integrity: sha512-RHnlHqFpoVdjSPPiYy/t40Zovf3BBHc2oemgD7VsVTFFZrU5erFFe0n52OANZZ/5sbshgD93sOh5r6I35Xmpaw==}
engines: {node: '>=18.0.0'} 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': '@smithy/property-provider@4.0.5':
resolution: {integrity: sha512-R/bswf59T/n9ZgfgUICAZoWYKBHcsVDurAGX88zsiUtOTA/xUAPyiT+qkNCPwFn43pZqN84M4MiUsbSGQmgFIQ==} resolution: {integrity: sha512-R/bswf59T/n9ZgfgUICAZoWYKBHcsVDurAGX88zsiUtOTA/xUAPyiT+qkNCPwFn43pZqN84M4MiUsbSGQmgFIQ==}
engines: {node: '>=18.0.0'} 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': '@smithy/protocol-http@5.1.3':
resolution: {integrity: sha512-fCJd2ZR7D22XhDY0l+92pUag/7je2BztPRQ01gU5bMChcyI0rlly7QFibnYHzcxDvccMjlpM/Q1ev8ceRIb48w==} resolution: {integrity: sha512-fCJd2ZR7D22XhDY0l+92pUag/7je2BztPRQ01gU5bMChcyI0rlly7QFibnYHzcxDvccMjlpM/Q1ev8ceRIb48w==}
engines: {node: '>=18.0.0'} 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': '@smithy/querystring-builder@4.0.5':
resolution: {integrity: sha512-NJeSCU57piZ56c+/wY+AbAw6rxCCAOZLCIniRE7wqvndqxcKKDOXzwWjrY7wGKEISfhL9gBbAaWWgHsUGedk+A==} resolution: {integrity: sha512-NJeSCU57piZ56c+/wY+AbAw6rxCCAOZLCIniRE7wqvndqxcKKDOXzwWjrY7wGKEISfhL9gBbAaWWgHsUGedk+A==}
engines: {node: '>=18.0.0'} 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': '@smithy/querystring-parser@4.0.5':
resolution: {integrity: sha512-6SV7md2CzNG/WUeTjVe6Dj8noH32r4MnUeFKZrnVYsQxpGSIcphAanQMayi8jJLZAWm6pdM9ZXvKCpWOsIGg0w==} resolution: {integrity: sha512-6SV7md2CzNG/WUeTjVe6Dj8noH32r4MnUeFKZrnVYsQxpGSIcphAanQMayi8jJLZAWm6pdM9ZXvKCpWOsIGg0w==}
engines: {node: '>=18.0.0'} 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': '@smithy/service-error-classification@4.0.7':
resolution: {integrity: sha512-XvRHOipqpwNhEjDf2L5gJowZEm5nsxC16pAZOeEcsygdjv9A2jdOh3YoDQvOXBGTsaJk6mNWtzWalOB9976Wlg==} resolution: {integrity: sha512-XvRHOipqpwNhEjDf2L5gJowZEm5nsxC16pAZOeEcsygdjv9A2jdOh3YoDQvOXBGTsaJk6mNWtzWalOB9976Wlg==}
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
@@ -3424,14 +3488,26 @@ packages:
resolution: {integrity: sha512-YVVwehRDuehgoXdEL4r1tAAzdaDgaC9EQvhK0lEbfnbrd0bd5+CTQumbdPryX3J2shT7ZqQE+jPW4lmNBAB8JQ==} resolution: {integrity: sha512-YVVwehRDuehgoXdEL4r1tAAzdaDgaC9EQvhK0lEbfnbrd0bd5+CTQumbdPryX3J2shT7ZqQE+jPW4lmNBAB8JQ==}
engines: {node: '>=18.0.0'} 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': '@smithy/signature-v4@5.1.3':
resolution: {integrity: sha512-mARDSXSEgllNzMw6N+mC+r1AQlEBO3meEAkR/UlfAgnMzJUB3goRBWgip1EAMG99wh36MDqzo86SfIX5Y+VEaw==} resolution: {integrity: sha512-mARDSXSEgllNzMw6N+mC+r1AQlEBO3meEAkR/UlfAgnMzJUB3goRBWgip1EAMG99wh36MDqzo86SfIX5Y+VEaw==}
engines: {node: '>=18.0.0'} 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': '@smithy/smithy-client@4.5.0':
resolution: {integrity: sha512-ZSdE3vl0MuVbEwJBxSftm0J5nL/gw76xp5WF13zW9cN18MFuFXD5/LV0QD8P+sCU5bSWGyy6CTgUupE1HhOo1A==} resolution: {integrity: sha512-ZSdE3vl0MuVbEwJBxSftm0J5nL/gw76xp5WF13zW9cN18MFuFXD5/LV0QD8P+sCU5bSWGyy6CTgUupE1HhOo1A==}
engines: {node: '>=18.0.0'} 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': '@smithy/types@4.3.2':
resolution: {integrity: sha512-QO4zghLxiQ5W9UZmX2Lo0nta2PuE1sSrXUYDoaB6HMR762C0P7v/HEPHf6ZdglTVssJG1bsrSBxdc3quvDSihw==} resolution: {integrity: sha512-QO4zghLxiQ5W9UZmX2Lo0nta2PuE1sSrXUYDoaB6HMR762C0P7v/HEPHf6ZdglTVssJG1bsrSBxdc3quvDSihw==}
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
@@ -3440,14 +3516,26 @@ packages:
resolution: {integrity: sha512-j+733Um7f1/DXjYhCbvNXABV53NyCRRA54C7bNEIxNPs0YjfRxeMKjjgm2jvTYrciZyCjsicHwQ6Q0ylo+NAUw==} resolution: {integrity: sha512-j+733Um7f1/DXjYhCbvNXABV53NyCRRA54C7bNEIxNPs0YjfRxeMKjjgm2jvTYrciZyCjsicHwQ6Q0ylo+NAUw==}
engines: {node: '>=18.0.0'} 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': '@smithy/util-base64@4.0.0':
resolution: {integrity: sha512-CvHfCmO2mchox9kjrtzoHkWHxjHZzaFojLc8quxXY7WAAMAg43nuxwv95tATVgQFNDwd4M9S1qFzj40Ul41Kmg==} resolution: {integrity: sha512-CvHfCmO2mchox9kjrtzoHkWHxjHZzaFojLc8quxXY7WAAMAg43nuxwv95tATVgQFNDwd4M9S1qFzj40Ul41Kmg==}
engines: {node: '>=18.0.0'} 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': '@smithy/util-body-length-browser@4.0.0':
resolution: {integrity: sha512-sNi3DL0/k64/LO3A256M+m3CDdG6V7WKWHdAiBBMUN8S3hK3aMPhwnPik2A/a2ONN+9doY9UxaLfgqsIRg69QA==} resolution: {integrity: sha512-sNi3DL0/k64/LO3A256M+m3CDdG6V7WKWHdAiBBMUN8S3hK3aMPhwnPik2A/a2ONN+9doY9UxaLfgqsIRg69QA==}
engines: {node: '>=18.0.0'} 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': '@smithy/util-body-length-node@4.0.0':
resolution: {integrity: sha512-q0iDP3VsZzqJyje8xJWEJCNIu3lktUGVoSy1KB0UWym2CL1siV3artm+u1DFYTLejpsrdGyCSWBdGNjJzfDPjg==} resolution: {integrity: sha512-q0iDP3VsZzqJyje8xJWEJCNIu3lktUGVoSy1KB0UWym2CL1siV3artm+u1DFYTLejpsrdGyCSWBdGNjJzfDPjg==}
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
@@ -3460,6 +3548,10 @@ packages:
resolution: {integrity: sha512-9TOQ7781sZvddgO8nxueKi3+yGvkY35kotA0Y6BWRajAv8jjmigQ1sBwz0UX47pQMYXJPahSKEKYFgt+rXdcug==} resolution: {integrity: sha512-9TOQ7781sZvddgO8nxueKi3+yGvkY35kotA0Y6BWRajAv8jjmigQ1sBwz0UX47pQMYXJPahSKEKYFgt+rXdcug==}
engines: {node: '>=18.0.0'} 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': '@smithy/util-config-provider@4.0.0':
resolution: {integrity: sha512-L1RBVzLyfE8OXH+1hsJ8p+acNUSirQnWQ6/EgpchV88G6zGBTDPdXiiExei6Z1wR2RxYvxY/XLw6AMNCCt8H3w==} resolution: {integrity: sha512-L1RBVzLyfE8OXH+1hsJ8p+acNUSirQnWQ6/EgpchV88G6zGBTDPdXiiExei6Z1wR2RxYvxY/XLw6AMNCCt8H3w==}
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
@@ -3480,10 +3572,18 @@ packages:
resolution: {integrity: sha512-Yk5mLhHtfIgW2W2WQZWSg5kuMZCVbvhFmC7rV4IO2QqnZdbEFPmQnCcGMAX2z/8Qj3B9hYYNjZOhWym+RwhePw==} resolution: {integrity: sha512-Yk5mLhHtfIgW2W2WQZWSg5kuMZCVbvhFmC7rV4IO2QqnZdbEFPmQnCcGMAX2z/8Qj3B9hYYNjZOhWym+RwhePw==}
engines: {node: '>=18.0.0'} 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': '@smithy/util-middleware@4.0.5':
resolution: {integrity: sha512-N40PfqsZHRSsByGB81HhSo+uvMxEHT+9e255S53pfBw/wI6WKDI7Jw9oyu5tJTLwZzV5DsMha3ji8jk9dsHmQQ==} resolution: {integrity: sha512-N40PfqsZHRSsByGB81HhSo+uvMxEHT+9e255S53pfBw/wI6WKDI7Jw9oyu5tJTLwZzV5DsMha3ji8jk9dsHmQQ==}
engines: {node: '>=18.0.0'} 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': '@smithy/util-retry@4.0.7':
resolution: {integrity: sha512-TTO6rt0ppK70alZpkjwy+3nQlTiqNfoXja+qwuAchIEAIoSZW8Qyd76dvBv3I5bCpE38APafG23Y/u270NspiQ==} resolution: {integrity: sha512-TTO6rt0ppK70alZpkjwy+3nQlTiqNfoXja+qwuAchIEAIoSZW8Qyd76dvBv3I5bCpE38APafG23Y/u270NspiQ==}
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
@@ -3492,10 +3592,18 @@ packages:
resolution: {integrity: sha512-vSKnvNZX2BXzl0U2RgCLOwWaAP9x/ddd/XobPK02pCbzRm5s55M53uwb1rl/Ts7RXZvdJZerPkA+en2FDghLuQ==} resolution: {integrity: sha512-vSKnvNZX2BXzl0U2RgCLOwWaAP9x/ddd/XobPK02pCbzRm5s55M53uwb1rl/Ts7RXZvdJZerPkA+en2FDghLuQ==}
engines: {node: '>=18.0.0'} 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': '@smithy/util-uri-escape@4.0.0':
resolution: {integrity: sha512-77yfbCbQMtgtTylO9itEAdpPXSog3ZxMe09AEhm0dU0NLTalV70ghDZFR+Nfi1C60jnJoh/Re4090/DuZh2Omg==} resolution: {integrity: sha512-77yfbCbQMtgtTylO9itEAdpPXSog3ZxMe09AEhm0dU0NLTalV70ghDZFR+Nfi1C60jnJoh/Re4090/DuZh2Omg==}
engines: {node: '>=18.0.0'} 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': '@smithy/util-utf8@2.3.0':
resolution: {integrity: sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==} resolution: {integrity: sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==}
engines: {node: '>=14.0.0'} engines: {node: '>=14.0.0'}
@@ -3504,10 +3612,18 @@ packages:
resolution: {integrity: sha512-b+zebfKCfRdgNJDknHCob3O7FpeYQN6ZG6YLExMcasDHsCXlsXCEuiPZeLnJLpwa5dvPetGlnGCiMHuLwGvFow==} resolution: {integrity: sha512-b+zebfKCfRdgNJDknHCob3O7FpeYQN6ZG6YLExMcasDHsCXlsXCEuiPZeLnJLpwa5dvPetGlnGCiMHuLwGvFow==}
engines: {node: '>=18.0.0'} 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': '@smithy/util-waiter@4.0.7':
resolution: {integrity: sha512-mYqtQXPmrwvUljaHyGxYUIIRI3qjBTEb/f5QFi3A6VlxhpmZd5mWXn9W+qUkf2pVE1Hv3SqxefiZOPGdxmO64A==} resolution: {integrity: sha512-mYqtQXPmrwvUljaHyGxYUIIRI3qjBTEb/f5QFi3A6VlxhpmZd5mWXn9W+qUkf2pVE1Hv3SqxefiZOPGdxmO64A==}
engines: {node: '>=18.0.0'} 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': '@socket.io/component-emitter@3.1.2':
resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==} resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==}
@@ -3779,6 +3895,11 @@ packages:
peerDependencies: peerDependencies:
'@tiptap/core': ^2.7.0 '@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': '@tiptap/pm@2.26.1':
resolution: {integrity: sha512-8aF+mY/vSHbGFqyG663ds84b+vca5Lge3tHdTMTKazxCnhXR9dn2oQJMnZ78YZvdRbkPkMJJHti9h3K7u2UQvw==} resolution: {integrity: sha512-8aF+mY/vSHbGFqyG663ds84b+vca5Lge3tHdTMTKazxCnhXR9dn2oQJMnZ78YZvdRbkPkMJJHti9h3K7u2UQvw==}
@@ -4478,6 +4599,9 @@ packages:
buffer-from@1.1.2: buffer-from@1.1.2:
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} 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: buffer@5.7.1:
resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==}
@@ -7752,6 +7876,9 @@ packages:
resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
stream-browserify@3.0.0:
resolution: {integrity: sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA==}
string-width@4.2.3: string-width@4.2.3:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -8894,6 +9021,17 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- aws-crt - 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': '@aws-sdk/middleware-bucket-endpoint@3.873.0':
dependencies: dependencies:
'@aws-sdk/types': 3.862.0 '@aws-sdk/types': 3.862.0
@@ -11328,6 +11466,11 @@ snapshots:
'@smithy/types': 4.3.2 '@smithy/types': 4.3.2
tslib: 2.8.1 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': '@smithy/chunked-blob-reader-native@4.0.0':
dependencies: dependencies:
'@smithy/util-base64': 4.0.0 '@smithy/util-base64': 4.0.0
@@ -11345,6 +11488,19 @@ snapshots:
'@smithy/util-middleware': 4.0.5 '@smithy/util-middleware': 4.0.5
tslib: 2.8.1 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': '@smithy/core@3.9.0':
dependencies: dependencies:
'@smithy/middleware-serde': 4.0.9 '@smithy/middleware-serde': 4.0.9
@@ -11405,6 +11561,14 @@ snapshots:
'@smithy/util-base64': 4.0.0 '@smithy/util-base64': 4.0.0
tslib: 2.8.1 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': '@smithy/hash-blob-browser@4.0.5':
dependencies: dependencies:
'@smithy/chunked-blob-reader': 5.0.0 '@smithy/chunked-blob-reader': 5.0.0
@@ -11438,6 +11602,10 @@ snapshots:
dependencies: dependencies:
tslib: 2.8.1 tslib: 2.8.1
'@smithy/is-array-buffer@4.2.0':
dependencies:
tslib: 2.8.1
'@smithy/md5-js@4.0.5': '@smithy/md5-js@4.0.5':
dependencies: dependencies:
'@smithy/types': 4.3.2 '@smithy/types': 4.3.2
@@ -11461,6 +11629,17 @@ snapshots:
'@smithy/util-middleware': 4.0.5 '@smithy/util-middleware': 4.0.5
tslib: 2.8.1 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': '@smithy/middleware-retry@4.1.20':
dependencies: dependencies:
'@smithy/node-config-provider': 4.1.4 '@smithy/node-config-provider': 4.1.4
@@ -11480,11 +11659,22 @@ snapshots:
'@smithy/types': 4.3.2 '@smithy/types': 4.3.2
tslib: 2.8.1 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': '@smithy/middleware-stack@4.0.5':
dependencies: dependencies:
'@smithy/types': 4.3.2 '@smithy/types': 4.3.2
tslib: 2.8.1 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': '@smithy/node-config-provider@4.1.4':
dependencies: dependencies:
'@smithy/property-provider': 4.0.5 '@smithy/property-provider': 4.0.5
@@ -11492,6 +11682,13 @@ snapshots:
'@smithy/types': 4.3.2 '@smithy/types': 4.3.2
tslib: 2.8.1 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': '@smithy/node-http-handler@4.1.1':
dependencies: dependencies:
'@smithy/abort-controller': 4.0.5 '@smithy/abort-controller': 4.0.5
@@ -11500,27 +11697,56 @@ snapshots:
'@smithy/types': 4.3.2 '@smithy/types': 4.3.2
tslib: 2.8.1 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': '@smithy/property-provider@4.0.5':
dependencies: dependencies:
'@smithy/types': 4.3.2 '@smithy/types': 4.3.2
tslib: 2.8.1 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': '@smithy/protocol-http@5.1.3':
dependencies: dependencies:
'@smithy/types': 4.3.2 '@smithy/types': 4.3.2
tslib: 2.8.1 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': '@smithy/querystring-builder@4.0.5':
dependencies: dependencies:
'@smithy/types': 4.3.2 '@smithy/types': 4.3.2
'@smithy/util-uri-escape': 4.0.0 '@smithy/util-uri-escape': 4.0.0
tslib: 2.8.1 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': '@smithy/querystring-parser@4.0.5':
dependencies: dependencies:
'@smithy/types': 4.3.2 '@smithy/types': 4.3.2
tslib: 2.8.1 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': '@smithy/service-error-classification@4.0.7':
dependencies: dependencies:
'@smithy/types': 4.3.2 '@smithy/types': 4.3.2
@@ -11530,6 +11756,11 @@ snapshots:
'@smithy/types': 4.3.2 '@smithy/types': 4.3.2
tslib: 2.8.1 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': '@smithy/signature-v4@5.1.3':
dependencies: dependencies:
'@smithy/is-array-buffer': 4.0.0 '@smithy/is-array-buffer': 4.0.0
@@ -11541,6 +11772,16 @@ snapshots:
'@smithy/util-utf8': 4.0.0 '@smithy/util-utf8': 4.0.0
tslib: 2.8.1 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': '@smithy/smithy-client@4.5.0':
dependencies: dependencies:
'@smithy/core': 3.9.0 '@smithy/core': 3.9.0
@@ -11551,6 +11792,10 @@ snapshots:
'@smithy/util-stream': 4.2.4 '@smithy/util-stream': 4.2.4
tslib: 2.8.1 tslib: 2.8.1
'@smithy/types@4.12.0':
dependencies:
tslib: 2.8.1
'@smithy/types@4.3.2': '@smithy/types@4.3.2':
dependencies: dependencies:
tslib: 2.8.1 tslib: 2.8.1
@@ -11561,16 +11806,32 @@ snapshots:
'@smithy/types': 4.3.2 '@smithy/types': 4.3.2
tslib: 2.8.1 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': '@smithy/util-base64@4.0.0':
dependencies: dependencies:
'@smithy/util-buffer-from': 4.0.0 '@smithy/util-buffer-from': 4.0.0
'@smithy/util-utf8': 4.0.0 '@smithy/util-utf8': 4.0.0
tslib: 2.8.1 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': '@smithy/util-body-length-browser@4.0.0':
dependencies: dependencies:
tslib: 2.8.1 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': '@smithy/util-body-length-node@4.0.0':
dependencies: dependencies:
tslib: 2.8.1 tslib: 2.8.1
@@ -11585,6 +11846,11 @@ snapshots:
'@smithy/is-array-buffer': 4.0.0 '@smithy/is-array-buffer': 4.0.0
tslib: 2.8.1 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': '@smithy/util-config-provider@4.0.0':
dependencies: dependencies:
tslib: 2.8.1 tslib: 2.8.1
@@ -11617,11 +11883,20 @@ snapshots:
dependencies: dependencies:
tslib: 2.8.1 tslib: 2.8.1
'@smithy/util-hex-encoding@4.2.0':
dependencies:
tslib: 2.8.1
'@smithy/util-middleware@4.0.5': '@smithy/util-middleware@4.0.5':
dependencies: dependencies:
'@smithy/types': 4.3.2 '@smithy/types': 4.3.2
tslib: 2.8.1 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': '@smithy/util-retry@4.0.7':
dependencies: dependencies:
'@smithy/service-error-classification': 4.0.7 '@smithy/service-error-classification': 4.0.7
@@ -11639,10 +11914,25 @@ snapshots:
'@smithy/util-utf8': 4.0.0 '@smithy/util-utf8': 4.0.0
tslib: 2.8.1 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': '@smithy/util-uri-escape@4.0.0':
dependencies: dependencies:
tslib: 2.8.1 tslib: 2.8.1
'@smithy/util-uri-escape@4.2.0':
dependencies:
tslib: 2.8.1
'@smithy/util-utf8@2.3.0': '@smithy/util-utf8@2.3.0':
dependencies: dependencies:
'@smithy/util-buffer-from': 2.2.0 '@smithy/util-buffer-from': 2.2.0
@@ -11653,12 +11943,21 @@ snapshots:
'@smithy/util-buffer-from': 4.0.0 '@smithy/util-buffer-from': 4.0.0
tslib: 2.8.1 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': '@smithy/util-waiter@4.0.7':
dependencies: dependencies:
'@smithy/abort-controller': 4.0.5 '@smithy/abort-controller': 4.0.5
'@smithy/types': 4.3.2 '@smithy/types': 4.3.2
tslib: 2.8.1 tslib: 2.8.1
'@smithy/uuid@1.1.0':
dependencies:
tslib: 2.8.1
'@socket.io/component-emitter@3.1.2': {} '@socket.io/component-emitter@3.1.2': {}
'@standard-schema/spec@1.0.0': {} '@standard-schema/spec@1.0.0': {}
@@ -11919,6 +12218,10 @@ snapshots:
dependencies: dependencies:
'@tiptap/core': 2.26.1(@tiptap/pm@2.26.1) '@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': '@tiptap/pm@2.26.1':
dependencies: dependencies:
prosemirror-changeset: 2.3.1 prosemirror-changeset: 2.3.1
@@ -12778,6 +13081,11 @@ snapshots:
buffer-from@1.1.2: {} buffer-from@1.1.2: {}
buffer@5.6.0:
dependencies:
base64-js: 1.5.1
ieee754: 1.2.1
buffer@5.7.1: buffer@5.7.1:
dependencies: dependencies:
base64-js: 1.5.1 base64-js: 1.5.1
@@ -16898,6 +17206,11 @@ snapshots:
es-errors: 1.3.0 es-errors: 1.3.0
internal-slot: 1.1.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: string-width@4.2.3:
dependencies: dependencies:
emoji-regex: 8.0.0 emoji-regex: 8.0.0