Compare commits

..

2 Commits

Author SHA1 Message Date
Henry
53d6ebb200 chore: gen translations 2026-02-02 21:58:54 +00:00
Henry
957d3d3c09 fix: fallback to email when user name is missing in activity list 2026-02-02 21:22:54 +00:00
56 changed files with 513 additions and 5298 deletions

View File

@@ -47,17 +47,7 @@ See our [roadmap](https://kan.bn/kan/roadmap) for upcoming features.
## Self Hosting 🐳 ## Self Hosting 🐳
### One-click Deployments The easiest way to self-host Kan is with Docker Compose. This will set up everything for you including your postgres database.
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

@@ -53,6 +53,7 @@ const config = {
{ {
protocol: 'https', protocol: 'https',
hostname: 'cdn.discordapp.com', hostname: 'cdn.discordapp.com',
pathname: '/avatars/**',
}, },
]; ];
@@ -94,6 +95,12 @@ const config = {
swcPlugins: [["@lingui/swc-plugin", {}]], swcPlugins: [["@lingui/swc-plugin", {}]],
}, },
api: {
bodyParser: {
sizeLimit: env("NEXT_API_BODY_SIZE_LIMIT") || '1mb',
},
},
async rewrites() { async rewrites() {
return [ return [
{ {

View File

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

@@ -25,7 +25,7 @@ const Avatar = ({
icon?: React.ReactNode; icon?: React.ReactNode;
isLoading?: boolean; isLoading?: boolean;
}) => { }) => {
const initials = name?.trim() const initials = name
? getInitialsFromName(name) ? getInitialsFromName(name)
: inferInitialsFromEmail(email); : inferInitialsFromEmail(email);

View File

@@ -12,7 +12,6 @@ import { authClient } from "@kan/auth/client";
import { useClickOutside } from "~/hooks/useClickOutside"; import { useClickOutside } from "~/hooks/useClickOutside";
import { useModal } from "~/providers/modal"; import { useModal } from "~/providers/modal";
import { useWorkspace, WorkspaceProvider } from "~/providers/workspace"; import { useWorkspace, WorkspaceProvider } from "~/providers/workspace";
import { api } from "~/utils/api";
import SideNavigation from "./SideNavigation"; import SideNavigation from "./SideNavigation";
interface DashboardProps { interface DashboardProps {
@@ -45,12 +44,6 @@ export default function Dashboard({
const { availableWorkspaces, hasLoaded } = useWorkspace(); const { availableWorkspaces, hasLoaded } = useWorkspace();
const { data: session, isPending: sessionLoading } = authClient.useSession(); const { data: session, isPending: sessionLoading } = authClient.useSession();
const { data: user, isLoading: userLoading } = api.user.getUser.useQuery(
undefined,
{
enabled: !!session?.user,
},
);
const [isSideNavOpen, setIsSideNavOpen] = useState(false); const [isSideNavOpen, setIsSideNavOpen] = useState(false);
const [isRightPanelOpen, setIsRightPanelOpen] = useState(false); const [isRightPanelOpen, setIsRightPanelOpen] = useState(false);
@@ -162,12 +155,8 @@ export default function Dashboard({
className={`fixed top-12 z-40 h-[calc(100dvh-3rem)] w-[calc(100vw-1.5rem)] transform transition-transform duration-300 ease-in-out md:relative md:top-0 md:h-full md:w-auto md:translate-x-0 ${isSideNavOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"} `} className={`fixed top-12 z-40 h-[calc(100dvh-3rem)] w-[calc(100vw-1.5rem)] transform transition-transform duration-300 ease-in-out md:relative md:top-0 md:h-full md:w-auto md:translate-x-0 ${isSideNavOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"} `}
> >
<SideNavigation <SideNavigation
user={{ user={{ displayName: session?.user.name, email: session?.user.email, image: session?.user.image }}
displayName: user?.name ?? session?.user.name, isLoading={sessionLoading}
email: user?.email ?? session?.user.email ?? "",
image: user?.image ?? undefined,
}}
isLoading={sessionLoading || userLoading}
onCloseSideNav={closeSideNav} onCloseSideNav={closeSideNav}
/> />
</div> </div>

View File

@@ -17,7 +17,6 @@ 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 {
@@ -387,54 +386,46 @@ export interface SlashNodeAttrs {
label?: string | null; label?: string | null;
} }
const getCommandItems = (disableHeadings: boolean): SlashCommandItem[] => { const CommandItems: SlashCommandItem[] = [
const headingCommands: SlashCommandItem[] = disableHeadings {
? [] title: "Heading 1",
: [ icon: <HiH1 />,
{ command: ({ editor }) =>
title: "Heading 1", editor.chain().focus().setHeading({ level: 1 }).run(),
icon: <HiH1 />, },
command: ({ editor }) => {
editor.chain().focus().setHeading({ level: 1 }).run(), title: "Heading 2",
}, icon: <HiH2 />,
{ command: ({ editor }) =>
title: "Heading 2", editor.chain().focus().setHeading({ level: 2 }).run(),
icon: <HiH2 />, },
command: ({ editor }) => {
editor.chain().focus().setHeading({ level: 2 }).run(), title: "Heading 3",
}, icon: <HiH3 />,
{ command: ({ editor }) =>
title: "Heading 3", editor.chain().focus().setHeading({ level: 3 }).run(),
icon: <HiH3 />, },
command: ({ editor }) => {
editor.chain().focus().setHeading({ level: 3 }).run(), title: "Bullet List",
}, icon: <HiOutlineListBullet />,
]; command: ({ editor }) => editor.chain().focus().toggleBulletList().run(),
},
return [ {
...headingCommands, title: "Ordered List",
{ icon: <HiOutlineNumberedList />,
title: "Bullet List", command: ({ editor }) => editor.chain().focus().toggleOrderedList().run(),
icon: <HiOutlineListBullet />, },
command: ({ editor }) => editor.chain().focus().toggleBulletList().run(), {
}, title: "Blockquote",
{ icon: <HiOutlineChatBubbleLeftEllipsis />,
title: "Ordered List", command: ({ editor }) => editor.chain().focus().toggleBlockquote().run(),
icon: <HiOutlineNumberedList />, },
command: ({ editor }) => editor.chain().focus().toggleOrderedList().run(), {
}, title: "Code Block",
{ icon: <HiOutlineCodeBracketSquare />,
title: "Blockquote", command: ({ editor }) => editor.chain().focus().toggleCodeBlock().run(),
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,
@@ -443,8 +434,6 @@ 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;
@@ -452,23 +441,18 @@ 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.configure({ StarterKit,
heading: disableHeadings ? false : undefined,
}),
Markdown, Markdown,
Placeholder.configure({ Placeholder.configure({
placeholder: readOnly placeholder: readOnly
? "" ? ""
: placeholder ?? : t`Add description... (type '/' to open commands or '@' to mention)`,
t`Add description... (type '/' to open commands or '@' to mention)`,
}), }),
Link.configure({ Link.configure({
openOnClick: true, openOnClick: true,
@@ -481,10 +465,10 @@ export default function Editor({
autolink: true, autolink: true,
}), }),
SlashCommands.configure({ SlashCommands.configure({
commandItems: getCommandItems(disableHeadings), commandItems: CommandItems,
suggestion: { suggestion: {
items: ({ query }: { query: string }) => items: ({ query }: { query: string }) =>
filterSlashCommandItems(getCommandItems(disableHeadings), query), filterSlashCommandItems(CommandItems, query),
startOfLine: true, startOfLine: true,
char: "/", char: "/",
}, },
@@ -496,28 +480,20 @@ export default function Editor({
suggestion: { suggestion: {
char: "@", char: "@",
items: ({ query }: { query: string }) => { items: ({ query }: { query: string }) => {
const withEmail = workspaceMembers.filter((member) => member.email); const all: MentionItem[] = workspaceMembers.map(
(member: WorkspaceMember) => ({
const mapped = withEmail.map((member: WorkspaceMember) => ({ id: member.publicId,
id: member.publicId, label: member?.user?.name ?? member.email,
label: member?.user?.name?.trim() || member.email || "", image: member?.user?.image ?? null,
image: member?.user?.image ?? null, }),
}));
const all: MentionItem[] = mapped.filter(
(item) => item.label && item.label.length > 0,
); );
const q = query.toLowerCase();
const q = query.toLowerCase().trim(); return all.filter(
(u) =>
if (q === "") { u.label &&
return all; typeof u.label === "string" &&
} 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 ?? "";
@@ -538,17 +514,6 @@ 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

@@ -55,10 +55,9 @@ export default function SideNavigation({
const [isInitialised, setIsInitialised] = useState(false); const [isInitialised, setIsInitialised] = useState(false);
const { openModal } = useModal(); const { openModal } = useModal();
const { data: workspaceData } = api.workspace.byId.useQuery( const { data: workspaceData } = api.workspace.byId.useQuery({
{ workspacePublicId: workspace.publicId }, workspacePublicId: workspace.publicId,
{ enabled: !!workspace.publicId && workspace.publicId.length >= 12 }, });
);
const subscriptions = workspaceData?.subscriptions as const subscriptions = workspaceData?.subscriptions as
| Subscription[] | Subscription[]

View File

@@ -7,7 +7,7 @@ import tippy from "tippy.js";
interface TooltipProps { interface TooltipProps {
children: ReactNode; children: ReactNode;
content?: ReactNode; content: ReactNode;
placement?: Placement; placement?: Placement;
delay?: number | [number, number]; delay?: number | [number, number];
} }
@@ -24,8 +24,6 @@ export function Tooltip({
useEffect(() => { useEffect(() => {
if (!triggerRef.current) return; if (!triggerRef.current) return;
if (!content) return;
const container = document.createElement("div"); const container = document.createElement("div");
const root = createRoot(container); const root = createRoot(container);
rootRef.current = root; rootRef.current = root;

View File

@@ -1,128 +0,0 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { PutObjectCommand } from "@aws-sdk/client-s3";
import { createNextApiContext } from "@kan/api/trpc";
import * as cardRepo from "@kan/db/repository/card.repo";
import * as cardActivityRepo from "@kan/db/repository/cardActivity.repo";
import * as cardAttachmentRepo from "@kan/db/repository/cardAttachment.repo";
import { generateUID } from "@kan/shared/utils";
import { env } from "~/env";
import { withRateLimit } from "@kan/api/utils/rateLimit";
import { createS3Client } from "@kan/shared/utils";
import { assertPermission } from "@kan/api/utils/permissions";
const MAX_SIZE_BYTES = 50 * 1024 * 1024; // 50MB
export const config = {
api: {
bodyParser: false,
},
};
export default withRateLimit(
{ points: 100, duration: 60 },
async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method !== "POST") {
return res.status(405).json({ error: "Method not allowed" });
}
try {
const { user, db } = await createNextApiContext(req);
if (!user) {
return res.status(401).json({ error: "Unauthorized" });
}
const bucket = env.NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME;
if (!bucket) {
return res.status(500).json({ error: "Attachments bucket not configured" });
}
const cardPublicId = req.query.cardPublicId;
if (typeof cardPublicId !== "string" || cardPublicId.length < 12) {
return res.status(400).json({ error: "Invalid cardPublicId" });
}
const contentType = req.headers["content-type"];
const contentLengthHeader = req.headers["content-length"];
const contentLength = contentLengthHeader
? Number.parseInt(contentLengthHeader, 10)
: NaN;
if (typeof contentType !== "string") {
return res.status(400).json({ error: "Missing content type" });
}
if (!Number.isFinite(contentLength) || contentLength <= 0) {
return res.status(400).json({ error: "Missing or invalid content length" });
}
if (contentLength > MAX_SIZE_BYTES) {
return res.status(400).json({ error: "File too large" });
}
const originalFilenameHeader =
(req.headers["x-original-filename"] as string | undefined) ?? "file";
const sanitizedFilename = originalFilenameHeader
.replace(/[^a-zA-Z0-9._-]/g, "_")
.substring(0, 200);
// Get card and check permissions
const card = await cardRepo.getWorkspaceAndCardIdByCardPublicId(
db,
cardPublicId,
);
if (!card) {
return res.status(404).json({ error: "Card not found" });
}
// Check if user has permission to edit the card
try {
await assertPermission(db, user.id, card.workspaceId, "card:edit");
} catch {
return res.status(403).json({ error: "Permission denied" });
}
const s3Key = `${card.workspaceId}/${cardPublicId}/${generateUID()}-${sanitizedFilename}`;
const client = createS3Client();
// Upload the file to S3
await client.send(
new PutObjectCommand({
Bucket: bucket,
Key: s3Key,
Body: req,
ContentType: contentType,
ContentLength: contentLength,
}),
);
// Create attachment record and log activity
const attachment = await cardAttachmentRepo.create(db, {
cardId: card.id,
filename: sanitizedFilename,
originalFilename: originalFilenameHeader,
contentType,
size: contentLength,
s3Key,
createdBy: user.id,
});
await cardActivityRepo.create(db, {
type: "card.updated.attachment.added",
cardId: card.id,
createdBy: user.id,
});
return res.status(200).json({ attachment });
} catch (error) {
console.error("Attachment upload failed", error);
return res.status(500).json({ error: "Internal server error" });
}
},
);

View File

@@ -1,101 +0,0 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { PutObjectCommand } from "@aws-sdk/client-s3";
import { createNextApiContext } from "@kan/api/trpc";
import * as userRepo from "@kan/db/repository/user.repo";
import { env } from "~/env";
import { withRateLimit } from "@kan/api/utils/rateLimit";
import { createS3Client } from "@kan/shared/utils";
const MAX_SIZE_BYTES = 2 * 1024 * 1024; // 2MB
const allowedContentTypes = ["image/jpeg", "image/png", "image/webp"];
export const config = {
api: {
bodyParser: false,
},
};
export default withRateLimit(
{ points: 100, duration: 60 },
async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method !== "POST") {
return res.status(405).json({ error: "Method not allowed" });
}
try {
const { user, db } = await createNextApiContext(req);
if (!user) {
return res.status(401).json({ error: "Unauthorized" });
}
const bucket = env.NEXT_PUBLIC_AVATAR_BUCKET_NAME;
if (!bucket) {
return res.status(500).json({ error: "Avatar bucket not configured" });
}
const contentType = req.headers["content-type"];
const contentLengthHeader = req.headers["content-length"];
const contentLength = contentLengthHeader
? Number.parseInt(contentLengthHeader, 10)
: NaN;
if (typeof contentType !== "string") {
return res.status(400).json({ error: "Missing content type" });
}
if (!allowedContentTypes.includes(contentType)) {
return res.status(400).json({ error: "Invalid content type" });
}
if (!Number.isFinite(contentLength) || contentLength <= 0) {
return res.status(400).json({ error: "Missing or invalid content length" });
}
if (contentLength > MAX_SIZE_BYTES) {
return res.status(400).json({ error: "File too large" });
}
const originalFilenameHeader =
(req.headers["x-original-filename"] as string | undefined) ?? "file";
const sanitizedFilename = originalFilenameHeader
.replace(/[^a-zA-Z0-9._-]/g, "_")
.substring(0, 200);
const s3Key = `${user.id}/${sanitizedFilename}`;
const client = createS3Client();
// Upload the file to S3
await client.send(
new PutObjectCommand({
Bucket: bucket,
Key: s3Key,
Body: req,
ContentType: contentType,
ContentLength: contentLength,
}),
);
// Update user image in database
const updatedUser = await userRepo.update(db, user.id, {
image: s3Key,
});
return res.status(200).json({
key: s3Key,
filename: sanitizedFilename,
contentType,
size: contentLength,
user: updatedUser,
});
} catch (error) {
console.error("Avatar upload failed", error);
return res.status(500).json({ error: "Internal server error" });
}
},
);

View File

@@ -0,0 +1,75 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { env as nextRuntimeEnv } from "next-runtime-env";
import { createNextApiContext } from "@kan/api/trpc";
import { env } from "~/env";
import { withRateLimit } from "@kan/api/utils/rateLimit";
const allowedContentTypes = ["image/jpeg", "image/png"];
export default withRateLimit(
{ points: 100, duration: 60 },
async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method !== "POST") {
return res.status(405).json({ error: "Method not allowed" });
}
try {
const { user } = await createNextApiContext(req);
if (!user) {
return res.status(401).json({ error: "Unauthorized" });
}
const { filename, contentType } = req.body as {
filename: string;
contentType: string;
};
// Specific to avatar uploads for now
const filenameRegex = /^[a-f0-9\-]+\/[a-zA-Z0-9_\-]+(\.jpg|\.jpeg|\.png)$/;
if (!filenameRegex.test(filename)) {
return res.status(400).json({ error: "Invalid filename" });
}
if (
typeof contentType !== "string" ||
!allowedContentTypes.includes(contentType)
) {
return res.status(400).json({ error: "Invalid content type" });
}
const credentials =
env.S3_ACCESS_KEY_ID && env.S3_SECRET_ACCESS_KEY
? {
accessKeyId: env.S3_ACCESS_KEY_ID,
secretAccessKey: env.S3_SECRET_ACCESS_KEY,
}
: undefined;
const client = new S3Client({
region: env.S3_REGION ?? "",
endpoint: env.S3_ENDPOINT ?? "",
forcePathStyle: env.S3_FORCE_PATH_STYLE === "true",
credentials,
});
const signedUrl = await getSignedUrl(
client,
new PutObjectCommand({
Bucket: nextRuntimeEnv("NEXT_PUBLIC_AVATAR_BUCKET_NAME") ?? "",
Key: filename,
ACL: "public-read",
}),
);
return res.status(200).json({ url: signedUrl, key: filename });
} catch (error) {
return res.status(500).json({ error: (error as Error).message });
}
},
);

View File

@@ -1,5 +1,4 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import React from "react";
import { import {
Dialog, Dialog,
DialogBackdrop, DialogBackdrop,
@@ -465,37 +464,19 @@ function FormattedShortcut({ shortcut }: { shortcut: KeyboardShortcut }) {
? stroke.modifiers.map(stringifyModifier) ? stroke.modifiers.map(stringifyModifier)
: []; : [];
modifierStrings.forEach((mod, index) => { modifierStrings.forEach((mod) => {
parts.push( parts.push(<kbd className={kbdClassName}>{mod}</kbd>);
<kbd key={`mod-${index}-${mod}`} className={kbdClassName}>
{mod}
</kbd>,
);
}); });
parts.push( parts.push(<kbd className={kbdClassName}>{stroke.key.toUpperCase()}</kbd>);
<kbd key={`key-${stroke.key}`} className={kbdClassName}>
{stroke.key.toUpperCase()}
</kbd>,
);
return parts; return parts;
}; };
if (shortcut.type === "SEQUENCE") { if (shortcut.type === "SEQUENCE") {
const parts: ReactNode[] = []; const parts: ReactNode[] = [];
shortcut.strokes.forEach((stroke, strokeIndex) => { shortcut.strokes.forEach((stroke) => {
const strokeParts = formatStroke(stroke); parts.push(...formatStroke(stroke));
// Add stroke index to keys to ensure uniqueness across multiple strokes
const keyedParts = strokeParts.map((part, partIndex) => {
if (React.isValidElement(part)) {
return React.cloneElement(part, {
key: `stroke-${strokeIndex}-${part.key || partIndex}`,
});
}
return part;
});
parts.push(...keyedParts);
}); });
return <span className="flex items-center gap-1 text-[11px]">{parts}</span>; return <span className="flex items-center gap-1 text-[11px]">{parts}</span>;
} }

View File

@@ -8,8 +8,6 @@ export async function invalidateCard(
utils: ReturnType<typeof api.useUtils>, utils: ReturnType<typeof api.useUtils>,
cardPublicId: string, cardPublicId: string,
) { ) {
if (!cardPublicId || cardPublicId.length < 12) return;
await Promise.all([ await Promise.all([
utils.card.byId.invalidate({ cardPublicId }), utils.card.byId.invalidate({ cardPublicId }),
utils.card.getActivities.invalidate({ cardPublicId }), utils.card.getActivities.invalidate({ cardPublicId }),

View File

@@ -1,3 +1,5 @@
import { env } from "next-runtime-env";
export const formatToArray = ( export const formatToArray = (
value: string | string[] | undefined, value: string | string[] | undefined,
): string[] => { ): string[] => {
@@ -50,5 +52,14 @@ export const getAvatarUrl = (imageOrKey: string | null) => {
return imageOrKey; return imageOrKey;
} }
return ""; const bucket = env("NEXT_PUBLIC_AVATAR_BUCKET_NAME");
const useVirtualHostedUrls = env("NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS");
const storageDomain = env("NEXT_PUBLIC_STORAGE_DOMAIN");
if (useVirtualHostedUrls === "true" && storageDomain) {
return `https://${bucket}.${storageDomain}/${imageOrKey}`;
}
const storageUrl = env("NEXT_PUBLIC_STORAGE_URL");
return `${storageUrl}/${bucket}/${imageOrKey}`;
}; };

View File

@@ -1,40 +1,24 @@
import { Fragment } from "react"; import Link from "next/link";
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;
}) => { }) => {
const { showPopup } = usePopup(); if (!isLoading && (!workspaceSlug || !boardSlug)) return <></>;
if (isLoading) { if (isLoading) {
return ( return (
@@ -42,55 +26,44 @@ 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 ? t`You don't have permission` : undefined} content={!canEdit && !isLoading ? 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">
{pathSegments.map((segment, i) => ( <span>
<Fragment key={i}> {env("NEXT_PUBLIC_KAN_ENV") === "cloud"
{i > 0 && pathSeparator} ? "kan.bn"
<span>{segment}</span> : env("NEXT_PUBLIC_BASE_URL")}
</Fragment> </span>
))} <div className="mx-1.5 h-4 w-px rotate-[20deg] bg-gray-300 dark:bg-dark-600"></div>
</div> <span>{workspaceSlug}</span>
<button <div className="mx-1.5 h-4 w-px rotate-[20deg] bg-gray-300 dark:bg-dark-600"></div>
type="button" <span>{boardSlug}</span>
onClick={(e) => { </div>
e.stopPropagation(); <Link
navigator.clipboard.writeText(boardUrl).then( href={`${env("NEXT_PUBLIC_BASE_URL")}/${workspaceSlug}/${boardSlug}`}
() => target="_blank"
showPopup({ rel="noopener noreferrer"
header: t`Link copied`, onClick={(e) => {
icon: "success", e.stopPropagation();
message: t`Board URL copied to clipboard`, if (!canEdit) {
}), 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]" />
<HiLink className="h-[13px] w-[13px]" /> </Link>
</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/workspace" href="/settings?tab=workspace"
onClick={closeModal} onClick={closeModal}
> >
{t`Edit workspace URL`} {t`Edit workspace URL`}

View File

@@ -443,8 +443,6 @@ 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

View File

@@ -363,7 +363,7 @@ const ActivityList = ({
limit: ACTIVITIES_PAGE_SIZE, limit: ACTIVITIES_PAGE_SIZE,
}, },
{ {
enabled: !!cardPublicId && cardPublicId.length >= 12, enabled: !!cardPublicId,
}, },
); );

View File

@@ -7,7 +7,6 @@ import { twMerge } from "tailwind-merge";
import Button from "~/components/Button"; import Button from "~/components/Button";
import { useModal } from "~/providers/modal"; import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup"; import { usePopup } from "~/providers/popup";
import { env } from "next-runtime-env";
import { api } from "~/utils/api"; import { api } from "~/utils/api";
import { invalidateCard } from "~/utils/cardInvalidation"; import { invalidateCard } from "~/utils/cardInvalidation";
@@ -19,33 +18,62 @@ export function AttachmentUpload({ cardPublicId }: { cardPublicId: string }) {
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const inputRef = useRef<HTMLInputElement | null>(null); const inputRef = useRef<HTMLInputElement | null>(null);
const uploadFile = async (file: File) => { const generateUploadUrl = api.attachment.generateUploadUrl.useMutation();
setUploading(true); const confirmAttachment = api.attachment.confirm.useMutation({
onSuccess: async () => {
try {
const baseUrl = env("NEXT_PUBLIC_BASE_URL") ?? "";
const response = await fetch(
`${baseUrl}/api/upload/attachment?cardPublicId=${encodeURIComponent(cardPublicId)}`,
{
method: "POST",
headers: {
"Content-Type": file.type,
"x-original-filename": file.name,
},
body: file,
},
);
if (!response.ok) {
throw new Error("Upload failed");
}
await invalidateCard(utils, cardPublicId); await invalidateCard(utils, cardPublicId);
showPopup({ showPopup({
header: t`Attachment uploaded`, header: t`Attachment uploaded`,
message: t`Your file has been uploaded successfully.`, message: t`Your file has been uploaded successfully.`,
icon: "success", icon: "success",
}); });
},
onError: () => {
showPopup({
header: t`Upload failed`,
message: t`Failed to upload attachment. Please try again.`,
icon: "error",
});
},
onSettled: () => {
setUploading(false);
},
});
const uploadFile = async (file: File) => {
setUploading(true);
try {
// Generate presigned URL
const { url, key } = await generateUploadUrl.mutateAsync({
cardPublicId,
filename: file.name,
contentType: file.type,
size: file.size,
});
// Upload file to S3
const uploadResponse = await fetch(url, {
method: "PUT",
body: file,
headers: {
"Content-Type": file.type,
},
});
if (!uploadResponse.ok) {
throw new Error("Upload failed");
}
// Confirm attachment in database
await confirmAttachment.mutateAsync({
cardPublicId,
s3Key: key,
filename: file.name,
originalFilename: file.name,
contentType: file.type,
size: file.size,
});
} catch { } catch {
showPopup({ showPopup({
header: t`Upload failed`, header: t`Upload failed`,

View File

@@ -1,13 +1,12 @@
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";
@@ -30,6 +29,7 @@ const Comment = ({
createdAt, createdAt,
comment, comment,
isAuthor, isAuthor,
isAdmin,
isEdited = false, isEdited = false,
isViewOnly = false, isViewOnly = false,
}: { }: {
@@ -42,6 +42,7 @@ 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;
}) => { }) => {
@@ -56,30 +57,6 @@ 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({
@@ -165,28 +142,21 @@ const Comment = ({
)} )}
</div> </div>
{!isEditing ? ( {!isEditing ? (
<div className="mt-2"> <ContentEditable
<Editor html={comment ?? ""}
content={comment ?? null} disabled={true}
readOnly={true} className="break-anywhere mt-2 text-sm"
workspaceMembers={workspaceMembers} />
enableYouTubeEmbed={false}
disableHeadings={true}
/>
</div>
) : ( ) : (
<form onSubmit={handleSubmit(onSubmit)}> <form onSubmit={handleSubmit(onSubmit)}>
<div className="mt-2"> <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 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"
placeholder={t`Add comment... (type '/' to open commands or '@' to mention)`} />
disableHeadings={true} <div className="flex justify-end space-x-2">
/>
</div>
<div className="flex justify-end space-x-2 mt-2">
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"

View File

@@ -1,7 +1,6 @@
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";
@@ -11,54 +10,18 @@ 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,9 +1,8 @@
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";
@@ -14,13 +13,7 @@ interface FormValues {
comment: string; comment: string;
} }
const NewCommentForm = ({ const NewCommentForm = ({ cardPublicId }: { cardPublicId: string }) => {
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();
@@ -30,6 +23,10 @@ const NewCommentForm = ({
}, },
}); });
const queryParams = {
cardPublicId,
};
const addCommentMutation = api.card.addComment.useMutation({ const addCommentMutation = api.card.addComment.useMutation({
onError: (_error, _newList) => { onError: (_error, _newList) => {
showPopup({ showPopup({
@@ -60,13 +57,18 @@ const NewCommentForm = ({
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"
> >
<Editor <ContentEditable
content={watch("comment")} placeholder={t`Add a comment...`}
onChange={(value) => setValue("comment", value)} html={watch("comment")}
workspaceMembers={workspaceMembers} disabled={false}
enableYouTubeEmbed={false} onChange={(e) => setValue("comment", e.target.value)}
placeholder={t`Add comment... (type '/' to open commands or '@' to mention)`} 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"
disableHeadings={true} onKeyDown={async (e) => {
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

@@ -5,8 +5,6 @@ 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 { 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";
import FeedbackModal from "~/components/FeedbackModal"; import FeedbackModal from "~/components/FeedbackModal";
@@ -16,6 +14,8 @@ 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";
@@ -53,10 +53,9 @@ export function CardRightPanel({ isTemplate }: { isTemplate?: boolean }) {
? router.query.cardId[0] ? router.query.cardId[0]
: router.query.cardId; : router.query.cardId;
const { data: card } = api.card.byId.useQuery( const { data: card } = api.card.byId.useQuery({
{ cardPublicId: cardId ?? "" }, cardPublicId: cardId ?? "",
{ enabled: !!cardId && cardId.length >= 12 }, });
);
const isCreator = card?.createdBy && session?.user.id === card.createdBy; const isCreator = card?.createdBy && session?.user.id === card.createdBy;
const canEdit = canEditCard || isCreator; const canEdit = canEditCard || isCreator;
@@ -167,6 +166,7 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
const { const {
modalContentType, modalContentType,
entityId, entityId,
openModal,
getModalState, getModalState,
clearModalState, clearModalState,
isOpen, isOpen,
@@ -184,10 +184,9 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
? router.query.cardId[0] ? router.query.cardId[0]
: router.query.cardId; : router.query.cardId;
const { data: card, isLoading } = api.card.byId.useQuery( const { data: card, isLoading } = api.card.byId.useQuery({
{ cardPublicId: cardId ?? "" }, cardPublicId: cardId ?? "",
{ enabled: !!cardId && cardId.length >= 12 }, });
);
const isCreator = card?.createdBy && session?.user.id === card.createdBy; const isCreator = card?.createdBy && session?.user.id === card.createdBy;
const canEdit = canEditCard || isCreator; const canEdit = canEditCard || isCreator;
@@ -197,24 +196,8 @@ 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({
@@ -301,7 +284,6 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
if (!cardId) return <></>; if (!cardId) return <></>;
return ( return (
<> <>
<PageHead <PageHead
@@ -333,12 +315,7 @@ 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 <Dropdown cardCreatedBy={card?.createdBy} />
cardPublicId={cardId}
isTemplate={isTemplate}
boardPublicId={boardId}
cardCreatedBy={card?.createdBy}
/>
</div> </div>
</> </>
)} )}
@@ -395,15 +372,9 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
<div className="mt-2"> <div className="mt-2">
<Editor <Editor
content={card.description} content={card.description}
onChange={ onChange={canEdit ? (e) => setValue("description", e) : undefined}
canEdit onBlur={canEdit ? () => handleSubmit(onSubmit)() : undefined}
? (e) => setValue("description", e) workspaceMembers={board?.workspace.members ?? []}
: undefined
}
onBlur={
canEdit ? () => handleSubmit(onSubmit)() : undefined
}
workspaceMembers={workspaceMembers ?? []}
readOnly={!canEdit} readOnly={!canEdit}
/> />
</div> </div>
@@ -447,10 +418,7 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
</div> </div>
{!isTemplate && ( {!isTemplate && (
<div className="mt-6"> <div className="mt-6">
<NewCommentForm <NewCommentForm cardPublicId={cardId} />
cardPublicId={cardId}
workspaceMembers={editorWorkspaceMembers}
/>
</div> </div>
)} )}
</div> </div>

View File

@@ -37,7 +37,7 @@ export default function MembersPage() {
const { data, isLoading } = api.workspace.byId.useQuery( const { data, isLoading } = api.workspace.byId.useQuery(
{ workspacePublicId: workspace.publicId }, { workspacePublicId: workspace.publicId },
{ enabled: !!workspace.publicId && workspace.publicId.length >= 12 }, // { enabled: workspace?.publicId ? true : false },
); );
const { data: session } = authClient.useSession(); const { data: session } = authClient.useSession();
@@ -48,11 +48,9 @@ export default function MembersPage() {
const updateRoleMutation = api.member.updateRole.useMutation({ const updateRoleMutation = api.member.updateRole.useMutation({
onSuccess: async () => { onSuccess: async () => {
if (workspace.publicId && workspace.publicId.length >= 12) { await utils.workspace.byId.invalidate({
await utils.workspace.byId.invalidate({ workspacePublicId: workspace.publicId,
workspacePublicId: workspace.publicId, });
});
}
showPopup({ showPopup({
header: t`Role updated`, header: t`Role updated`,
@@ -126,6 +124,7 @@ export default function MembersPage() {
name={memberName ?? ""} name={memberName ?? ""}
email={memberEmail ?? ""} email={memberEmail ?? ""}
imageUrl={memberImage ? getAvatarUrl(memberImage) : undefined} imageUrl={memberImage ? getAvatarUrl(memberImage) : undefined}
icon={showPendingIcon ? "?" : undefined}
/> />
)} )}
</div> </div>

View File

@@ -1,13 +1,12 @@
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 { HiLink, HiXMark } from "react-icons/hi2"; import { 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";
@@ -24,35 +23,16 @@ 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 ?? "",
}, },
{ {
enabled: isOpen && !!cardPublicId && cardPublicId.length >= 12, enabled: isOpen && !!cardPublicId,
}, },
); );
@@ -85,44 +65,33 @@ 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">
<div className="absolute right-[2rem] top-[2rem] flex items-center gap-1"> <button
<button className="absolute right-[2rem] top-[2rem] rounded p-1 hover:bg-light-300 focus:outline-none dark:hover:bg-dark-300"
type="button" onClick={(e) => {
onClick={handleCopyCardLink} e.preventDefault();
className="rounded p-1.5 transition-all hover:bg-light-200 focus:outline-none dark:hover:bg-dark-100" closeModal();
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, },
{ shallow: true }, undefined,
); { shallow: true },
}, 400); );
}} }, 400);
> }}
<HiXMark >
size={18} <HiXMark
className="text-light-900 dark:text-dark-900" size={18}
/> className="dark:text-dark-9000 text-light-900"
</button> />
</div> </button>
{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,34 +70,29 @@ export default function PublicBoardView() {
}, },
); );
const handleCopyBoardLink = async () => { const CopyBoardLink = () => {
try { return (
await navigator.clipboard.writeText(window.location.href); <button
showPopup({ onClick={async () => {
header: t`Link copied`, try {
icon: "success", await navigator.clipboard.writeText(window.location.href);
message: t`Board URL copied to clipboard`, } catch (error) {
}); console.error(error);
} catch (error) { }
console.error(error);
showPopup({
header: t`Unable to copy link`,
icon: "error",
message: t`Please try again.`,
});
}
};
const CopyBoardLink = () => ( showPopup({
<button header: t`Link copied`,
type="button" icon: "success",
onClick={handleCopyBoardLink} message: t`Board URL copied to clipboard`,
className="rounded p-1.5 transition-all hover:bg-light-200 focus:outline-none dark:hover:bg-dark-100" });
aria-label="Copy board URL" }}
> className="rounded p-1.5 transition-all hover:bg-light-200 dark:hover:bg-dark-100"
<HiLink className="h-4 w-4 text-light-900 dark:text-dark-900" /> aria-label={`Copy board URL`}
</button> >
); <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("/") ?? [];

View File

@@ -28,11 +28,9 @@ export default function PermissionsSettings() {
}); });
// Refresh any relevant workspace data // Refresh any relevant workspace data
if (workspace.publicId && workspace.publicId.length >= 12) { await utils.workspace.byId.invalidate({
await utils.workspace.byId.invalidate({ workspacePublicId: workspace.publicId,
workspacePublicId: workspace.publicId, });
});
}
}, },
onError: () => { onError: () => {
showPopup({ showPopup({

View File

@@ -31,10 +31,9 @@ export default function WorkspaceSettings() {
const { data } = api.user.getUser.useQuery(); const { data } = api.user.getUser.useQuery();
const [hasOpenedUpgradeModal, setHasOpenedUpgradeModal] = useState(false); const [hasOpenedUpgradeModal, setHasOpenedUpgradeModal] = useState(false);
const { data: workspaceData } = api.workspace.byId.useQuery( const { data: workspaceData } = api.workspace.byId.useQuery({
{ workspacePublicId: workspace.publicId }, workspacePublicId: workspace.publicId,
{ enabled: !!workspace.publicId && workspace.publicId.length >= 12 }, });
);
const subscriptions = workspaceData?.subscriptions as const subscriptions = workspaceData?.subscriptions as
| Subscription[] | Subscription[]

View File

@@ -58,6 +58,28 @@ export default function Avatar({
const [crop, setCrop] = useState<PercentCrop>(); const [crop, setCrop] = useState<PercentCrop>();
const imgRef = useRef<HTMLImageElement | null>(null); const imgRef = useRef<HTMLImageElement | null>(null);
const updateUser = api.user.update.useMutation({
onSuccess: async () => {
showPopup({
header: t`Profile image updated`,
message: t`Your profile image has been updated.`,
icon: "success",
});
try {
await utils.user.getUser.refetch();
} catch (e) {
console.error(e);
throw e;
}
},
onError: () => {
showPopup({
header: t`Error updating profile image`,
message: t`Please try again later, or contact customer support.`,
icon: "error",
});
},
});
const avatarUrl = userImage ? getAvatarUrl(userImage) : undefined; const avatarUrl = userImage ? getAvatarUrl(userImage) : undefined;
@@ -165,32 +187,29 @@ export default function Avatar({
const originalExt = selectedFile.name.split(".").pop() ?? "jpg"; const originalExt = selectedFile.name.split(".").pop() ?? "jpg";
const fileName = `${userId}/avatar-${generateUID()}.${originalExt}`; const fileName = `${userId}/avatar-${generateUID()}.${originalExt}`;
const baseUrl = env("NEXT_PUBLIC_BASE_URL") ?? "";
const response = await fetch( const response = await fetch(
`${baseUrl}/api/upload/avatar`, env("NEXT_PUBLIC_BASE_URL") + "/api/upload/image",
{ {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": blob.type, "Content-Type": "application/json",
"x-original-filename": fileName,
}, },
body: blob, body: JSON.stringify({ filename: fileName, contentType: blob.type }),
}, },
); );
if (!response.ok) { if (!response.ok) throw new Error("Failed to get pre-signed URL");
throw new Error("Failed to upload profile image");
}
// User image is updated in the backend, refresh user data const { url } = (await response.json()) as { url: string };
await utils.user.getUser.refetch();
const uploadResponse = await fetch(url, {
showPopup({ method: "PUT",
header: t`Profile image updated`, body: blob,
message: t`Your profile image has been updated.`,
icon: "success",
}); });
if (!uploadResponse.ok) throw new Error("Failed to upload profile image");
updateUser.mutate({ image: fileName });
setCropDialogOpen(false); setCropDialogOpen(false);
resetCropState(); resetCropState();
} catch (error) { } catch (error) {
@@ -208,7 +227,7 @@ export default function Avatar({
resetCropState, resetCropState,
selectedFile, selectedFile,
showPopup, showPopup,
utils.user.getUser, updateUser,
userId, userId,
]); ]);

View File

@@ -22,11 +22,9 @@ export default function UpdateWorkspaceEmailVisibilityForm({
const updateWorkspace = api.workspace.update.useMutation({ const updateWorkspace = api.workspace.update.useMutation({
onSuccess: () => { onSuccess: () => {
if (workspacePublicId && workspacePublicId.length >= 12) { void utils.workspace.byId.invalidate({
void utils.workspace.byId.invalidate({ workspacePublicId,
workspacePublicId, });
});
}
}, },
}); });

View File

@@ -27,10 +27,6 @@
"./utils/rateLimit": { "./utils/rateLimit": {
"types": "./dist/utils/rateLimit.d.ts", "types": "./dist/utils/rateLimit.d.ts",
"default": "./src/utils/rateLimit.ts" "default": "./src/utils/rateLimit.ts"
},
"./utils/permissions": {
"types": "./dist/utils/permissions.d.ts",
"default": "./src/utils/permissions.ts"
} }
}, },
"license": "GPL-3.0", "license": "GPL-3.0",
@@ -43,6 +39,8 @@
"typecheck": "tsc --noEmit --emitDeclarationOnly false" "typecheck": "tsc --noEmit --emitDeclarationOnly false"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.802.0",
"@aws-sdk/s3-request-presigner": "^3.812.0",
"@kan/auth": "workspace:*", "@kan/auth": "workspace:*",
"@kan/db": "workspace:*", "@kan/db": "workspace:*",
"@kan/email": "workspace:^", "@kan/email": "workspace:^",

View File

@@ -9,7 +9,7 @@ import { generateUID } from "@kan/shared/utils";
import { createTRPCRouter, protectedProcedure } from "../trpc"; import { createTRPCRouter, protectedProcedure } from "../trpc";
import { assertPermission } from "../utils/permissions"; import { assertPermission } from "../utils/permissions";
import { deleteObject, generateUploadUrl } from "@kan/shared/utils"; import { deleteObject, generateUploadUrl } from "../utils/s3";
export const attachmentRouter = createTRPCRouter({ export const attachmentRouter = createTRPCRouter({
generateUploadUrl: protectedProcedure generateUploadUrl: protectedProcedure

View File

@@ -10,7 +10,6 @@ import * as workspaceRepo from "@kan/db/repository/workspace.repo";
import { colours } from "@kan/shared/constants"; import { colours } from "@kan/shared/constants";
import { import {
convertDueDateFiltersToRanges, convertDueDateFiltersToRanges,
generateAvatarUrl,
generateSlug, generateSlug,
generateUID, generateUID,
} from "@kan/shared/utils"; } from "@kan/shared/utils";
@@ -143,40 +142,7 @@ export const boardRouter = createTRPCRouter({
}, },
); );
if (!result) { return result;
throw new TRPCError({
message: `Board with public ID ${input.boardPublicId} not found`,
code: "NOT_FOUND",
});
}
// Generate presigned URLs for workspace member avatars
const workspaceWithAvatarUrls = result.workspace
? {
...result.workspace,
members: await Promise.all(
result.workspace.members.map(async (member) => {
if (!member.user?.image) {
return member;
}
const avatarUrl = await generateAvatarUrl(member.user.image);
return {
...member,
user: {
...member.user,
image: avatarUrl,
},
};
}),
),
}
: result.workspace;
return {
...result,
workspace: workspaceWithAvatarUrls,
};
}), }),
bySlug: publicProcedure bySlug: publicProcedure
.meta({ .meta({

View File

@@ -10,9 +10,8 @@ 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 { generateDownloadUrl } from "../utils/s3";
export const cardRouter = createTRPCRouter({ export const cardRouter = createTRPCRouter({
create: protectedProcedure create: protectedProcedure
@@ -154,17 +153,6 @@ 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
@@ -227,16 +215,6 @@ 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
@@ -317,16 +295,6 @@ 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
@@ -663,54 +631,45 @@ export const cardRouter = createTRPCRouter({
}); });
// Generate URLs for all attachments // Generate URLs for all attachments
const attachmentsWithUrls = await Promise.all( const bucket = process.env.NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME;
result.attachments.map(async (attachment) => { if (result.attachments && Array.isArray(result.attachments)) {
const url = await generateAttachmentUrl(attachment.s3Key); const attachments = result.attachments as {
return { publicId: string;
publicId: attachment.publicId, contentType: string;
contentType: attachment.contentType, s3Key: string;
s3Key: attachment.s3Key, originalFilename: string | null;
originalFilename: attachment.originalFilename, size?: number | null;
size: attachment.size, }[];
url,
};
}),
);
// Generate presigned URLs for workspace member avatars const attachmentsWithUrls = await Promise.all(
const workspaceWithAvatarUrls = result.list.board.workspace attachments.map(async (attachment) => {
? { const base = {
...result.list.board.workspace, publicId: attachment.publicId,
members: await Promise.all( contentType: attachment.contentType,
result.list.board.workspace.members.map(async (member) => { s3Key: attachment.s3Key,
if (!member.user?.image) { originalFilename: attachment.originalFilename,
return member; size: attachment.size,
} };
if (!bucket || !attachment.s3Key) {
return { ...base, url: null };
}
try {
const url = await generateDownloadUrl(
bucket,
attachment.s3Key,
86400, // 24 hours expiration
);
return { ...base, url };
} catch {
// If URL generation fails, return attachment with url: null
return { ...base, url: null };
}
}),
);
return { ...result, attachments: attachmentsWithUrls };
}
const avatarUrl = await generateAvatarUrl(member.user.image); return { ...result, attachments: [] };
return {
...member,
user: {
...member.user,
image: avatarUrl,
},
};
}),
),
}
: result.list.board.workspace;
return {
...result,
attachments: attachmentsWithUrls,
list: {
...result.list,
board: {
...result.list.board,
workspace: workspaceWithAvatarUrls,
},
},
};
}), }),
getActivities: publicProcedure getActivities: publicProcedure
.meta({ .meta({
@@ -779,39 +738,7 @@ export const cardRouter = createTRPCRouter({
}, },
); );
// Generate presigned URLs for user avatars in activities const mergedActivities = mergeActivities(result.activities);
const activitiesWithAvatarUrls = await Promise.all(
result.activities.map(async (activity) => {
const updatedActivity = { ...activity };
// Generate presigned URL for activity user avatar
if (activity.user?.image) {
const userAvatarUrl = await generateAvatarUrl(activity.user.image);
updatedActivity.user = {
...activity.user,
image: userAvatarUrl,
};
}
// Generate presigned URL for member user avatar (if exists)
if (activity.member?.user?.image) {
const memberAvatarUrl = await generateAvatarUrl(
activity.member.user.image,
);
updatedActivity.member = {
...activity.member,
user: {
...activity.member.user,
image: memberAvatarUrl,
},
};
}
return updatedActivity;
}),
);
const mergedActivities = mergeActivities(activitiesWithAvatarUrls);
return { return {
activities: mergedActivities, activities: mergedActivities,
@@ -956,15 +883,6 @@ 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

@@ -24,7 +24,7 @@ import {
createTRPCRouter, createTRPCRouter,
publicProcedure, publicProcedure,
} from "../trpc"; } from "../trpc";
import { createS3Client } from "@kan/shared/utils"; import { createS3Client } from "../utils/s3";
const checkDatabaseConnection = async (db: dbClient) => { const checkDatabaseConnection = async (db: dbClient) => {
try { try {

View File

@@ -4,7 +4,6 @@ import { z } from "zod";
import * as userRepo from "@kan/db/repository/user.repo"; import * as userRepo from "@kan/db/repository/user.repo";
import { createTRPCRouter, protectedProcedure } from "../trpc"; import { createTRPCRouter, protectedProcedure } from "../trpc";
import { generateAvatarUrl } from "@kan/shared/utils";
export const userRouter = createTRPCRouter({ export const userRouter = createTRPCRouter({
getUser: protectedProcedure getUser: protectedProcedure
@@ -56,12 +55,8 @@ export const userRouter = createTRPCRouter({
const apiKey = result.apiKeys[0]; const apiKey = result.apiKeys[0];
// Generate presigned URL for avatar
const imageUrl = await generateAvatarUrl(result.image);
return { return {
...result, ...result,
image: imageUrl,
apiKey: apiKey ?? null, apiKey: apiKey ?? null,
}; };
}), }),
@@ -107,12 +102,6 @@ export const userRouter = createTRPCRouter({
}); });
} }
// Generate presigned URL for avatar return result;
const imageUrl = await generateAvatarUrl(result.image);
return {
...result,
image: imageUrl,
};
}), }),
}); });

View File

@@ -8,7 +8,6 @@ import { generateUID } from "@kan/shared/utils";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
import { assertPermission } from "../utils/permissions"; import { assertPermission } from "../utils/permissions";
import { generateAvatarUrl } from "@kan/shared/utils";
export const workspaceRouter = createTRPCRouter({ export const workspaceRouter = createTRPCRouter({
all: protectedProcedure all: protectedProcedure
@@ -87,27 +86,9 @@ export const workspaceRouter = createTRPCRouter({
const shouldShowEmails = const shouldShowEmails =
isAdmin || result.showEmailsToMembers === true; isAdmin || result.showEmailsToMembers === true;
// Generate presigned URLs for member avatars
const membersWithAvatarUrls = await Promise.all(
result.members.map(async (member) => {
if (!member.user?.image) {
return member;
}
const avatarUrl = await generateAvatarUrl(member.user.image);
return {
...member,
user: {
...member.user,
image: avatarUrl,
},
};
}),
);
// If emails should be hidden, filter them out // If emails should be hidden, filter them out
if (!shouldShowEmails) { if (!shouldShowEmails) {
const sanitizedMembers = membersWithAvatarUrls.map((member) => { const sanitizedMembers = result.members.map((member) => {
// If user doesn't have a display name, use anonymous identifier // If user doesn't have a display name, use anonymous identifier
const displayName = const displayName =
member.user?.name?.trim() ?? `anonymous_${member.publicId}`; member.user?.name?.trim() ?? `anonymous_${member.publicId}`;
@@ -139,10 +120,7 @@ export const workspaceRouter = createTRPCRouter({
} as Awaited<ReturnType<typeof workspaceRepo.getByPublicIdWithMembers>>; } as Awaited<ReturnType<typeof workspaceRepo.getByPublicIdWithMembers>>;
} }
return { return result;
...result,
members: membersWithAvatarUrls,
};
}), }),
bySlug: publicProcedure bySlug: publicProcedure
.meta({ .meta({

View File

@@ -1,133 +0,0 @@
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

@@ -5,7 +5,6 @@ import {
S3Client, S3Client,
} from "@aws-sdk/client-s3"; } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { env } from "next-runtime-env";
export function createS3Client() { export function createS3Client() {
const credentials = const credentials =
@@ -68,60 +67,3 @@ export async function deleteObject(bucket: string, key: string) {
}), }),
); );
} }
/**
* Generate presigned URL for an avatar image
* Returns the URL as-is if it's already a full URL (external provider)
* Returns presigned URL if it's an S3 key
* Returns null if image key is missing, bucket is not configured, or URL generation fails
*/
export async function generateAvatarUrl(
imageKey: string | null | undefined,
expiresIn = 86400, // 24 hours
): Promise<string | null> {
if (!imageKey) {
return null;
}
if (imageKey.startsWith("http://") || imageKey.startsWith("https://")) {
return imageKey;
}
const bucket = env("NEXT_PUBLIC_AVATAR_BUCKET_NAME");
if (!bucket) {
return null;
}
try {
return await generateDownloadUrl(bucket, imageKey, expiresIn);
} catch {
// If URL generation fails, return null
return null;
}
}
/**
* Generate presigned URL for an attachment
* Returns null if attachment key is missing, bucket is not configured, or URL generation fails
*/
export async function generateAttachmentUrl(
attachmentKey: string | null | undefined,
expiresIn = 86400, // 24 hours
): Promise<string | null> {
if (!attachmentKey) {
return null;
}
const bucket = env("NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME");
if (!bucket) {
return null;
}
try {
return await generateDownloadUrl(bucket, attachmentKey, expiresIn);
} catch {
// If URL generation fails, return null
return null;
}
}

View File

@@ -1,4 +1,4 @@
import { PutObjectCommand } from "@aws-sdk/client-s3"; import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { ChatOrPushProviderEnum } from "@novu/api/models/components"; import { ChatOrPushProviderEnum } from "@novu/api/models/components";
import { createAuthMiddleware } from "better-auth/api"; import { createAuthMiddleware } from "better-auth/api";
import { env } from "next-runtime-env"; import { env } from "next-runtime-env";
@@ -7,7 +7,7 @@ import type { dbClient } from "@kan/db/client";
import * as memberRepo from "@kan/db/repository/member.repo"; import * as memberRepo from "@kan/db/repository/member.repo";
import * as userRepo from "@kan/db/repository/user.repo"; import * as userRepo from "@kan/db/repository/user.repo";
import { notificationClient } from "@kan/email"; import { notificationClient } from "@kan/email";
import { createEmailUnsubscribeLink, createS3Client } from "@kan/shared"; import { createEmailUnsubscribeLink } from "@kan/shared";
import { downloadImage } from "./utils"; import { downloadImage } from "./utils";
@@ -61,7 +61,20 @@ export function createDatabaseHooks(db: dbClient) {
!user.image.includes(storageDomain) !user.image.includes(storageDomain)
) { ) {
try { try {
const client = createS3Client(); const credentials =
env("S3_ACCESS_KEY_ID") && env("S3_SECRET_ACCESS_KEY")
? {
accessKeyId: env("S3_ACCESS_KEY_ID")!,
secretAccessKey: env("S3_SECRET_ACCESS_KEY")!,
}
: undefined;
const client = new S3Client({
region: env("S3_REGION") ?? "",
endpoint: env("S3_ENDPOINT") ?? "",
forcePathStyle: env("S3_FORCE_PATH_STYLE") === "true",
credentials,
});
const allowedFileExtensions = ["jpg", "jpeg", "png", "webp"]; const allowedFileExtensions = ["jpg", "jpeg", "png", "webp"];

View File

@@ -1,46 +0,0 @@
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");

File diff suppressed because it is too large Load Diff

View File

@@ -176,13 +176,6 @@
"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
} }
] ]
} }

View File

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

View File

@@ -420,7 +420,6 @@ 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,
@@ -508,7 +507,6 @@ export const getWithListAndMembersByPublicId = async (
columns: { columns: {
publicId: true, publicId: true,
email: true, email: true,
status: true,
}, },
with: { with: {
user: { user: {

View File

@@ -63,36 +63,6 @@ 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

@@ -1,98 +0,0 @@
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

@@ -13,4 +13,3 @@ 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

@@ -1,98 +0,0 @@
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,16 +3,14 @@ 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" | "MENTION"; type Templates = "MAGIC_LINK" | "JOIN_WORKSPACE" | "RESET_PASSWORD";
const emailTemplates: Record<Templates, React.ComponentType<any>> = { const emailTemplates: Record<Templates, React.FC> = {
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

@@ -1,108 +0,0 @@
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

@@ -36,8 +36,6 @@
}, },
"prettier": "@kan/prettier-config", "prettier": "@kan/prettier-config",
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.802.0",
"@aws-sdk/s3-request-presigner": "^3.812.0",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"jose": "^6.1.2", "jose": "^6.1.2",
"nanoid": "^5.0.9", "nanoid": "^5.0.9",

View File

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

View File

@@ -1,22 +0,0 @@
/**
* 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));
}

24
pnpm-lock.yaml generated
View File

@@ -136,9 +136,6 @@ 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
@@ -296,6 +293,12 @@ importers:
packages/api: packages/api:
dependencies: dependencies:
'@aws-sdk/client-s3':
specifier: ^3.802.0
version: 3.879.0
'@aws-sdk/s3-request-presigner':
specifier: ^3.812.0
version: 3.879.0
'@kan/auth': '@kan/auth':
specifier: workspace:* specifier: workspace:*
version: link:../auth version: link:../auth
@@ -480,12 +483,6 @@ importers:
packages/shared: packages/shared:
dependencies: dependencies:
'@aws-sdk/client-s3':
specifier: ^3.802.0
version: 3.879.0
'@aws-sdk/s3-request-presigner':
specifier: ^3.812.0
version: 3.879.0
date-fns: date-fns:
specifier: ^4.1.0 specifier: ^4.1.0
version: 4.1.0 version: 4.1.0
@@ -3782,11 +3779,6 @@ 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==}
@@ -11927,10 +11919,6 @@ 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