Compare commits
8 Commits
feat/notif
...
feat/serve
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1d8a43d3c | ||
|
|
8ed719c485 | ||
|
|
f455bec6a7 | ||
|
|
aee5391f28 | ||
|
|
c8517ed4c2 | ||
|
|
ceac4c0d81 | ||
|
|
4c5856ec24 | ||
|
|
976373a65b |
12
README.md
12
README.md
@@ -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:
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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`}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -167,6 +167,7 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
|||||||
const {
|
const {
|
||||||
modalContentType,
|
modalContentType,
|
||||||
entityId,
|
entityId,
|
||||||
|
openModal,
|
||||||
getModalState,
|
getModalState,
|
||||||
clearModalState,
|
clearModalState,
|
||||||
isOpen,
|
isOpen,
|
||||||
@@ -197,24 +198,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 +286,6 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
|||||||
|
|
||||||
if (!cardId) return <></>;
|
if (!cardId) return <></>;
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHead
|
<PageHead
|
||||||
@@ -333,12 +317,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 +374,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 +420,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>
|
||||||
|
|||||||
@@ -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,29 +23,10 @@ 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 ?? "",
|
||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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("/") ?? [];
|
||||||
|
|||||||
@@ -143,13 +143,6 @@ export const boardRouter = createTRPCRouter({
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!result) {
|
|
||||||
throw new TRPCError({
|
|
||||||
message: `Board with public ID ${input.boardPublicId} not found`,
|
|
||||||
code: "NOT_FOUND",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate presigned URLs for workspace member avatars
|
// Generate presigned URLs for workspace member avatars
|
||||||
const workspaceWithAvatarUrls = result.workspace
|
const workspaceWithAvatarUrls = result.workspace
|
||||||
? {
|
? {
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
|||||||
|
|
||||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
|
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
|
||||||
import { mergeActivities } from "../utils/activities";
|
import { mergeActivities } from "../utils/activities";
|
||||||
import { sendMentionEmails } from "../utils/notifications";
|
|
||||||
import { assertCanDelete, assertCanEdit, assertPermission } from "../utils/permissions";
|
import { assertCanDelete, assertCanEdit, assertPermission } from "../utils/permissions";
|
||||||
import { generateAttachmentUrl, generateAvatarUrl } from "@kan/shared/utils";
|
import { generateAttachmentUrl, generateAvatarUrl } from "@kan/shared/utils";
|
||||||
|
|
||||||
@@ -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
|
||||||
@@ -956,15 +924,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 (
|
||||||
|
|||||||
@@ -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),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -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
@@ -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
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -212,7 +212,6 @@ export const getByPublicId = async (
|
|||||||
columns: {
|
columns: {
|
||||||
publicId: true,
|
publicId: true,
|
||||||
email: true,
|
email: true,
|
||||||
status: true,
|
|
||||||
},
|
},
|
||||||
with: {
|
with: {
|
||||||
user: {
|
user: {
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
|
||||||
};
|
|
||||||
|
|
||||||
@@ -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";
|
|
||||||
|
|||||||
@@ -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",
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
@@ -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({
|
||||||
|
|||||||
@@ -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;
|
|
||||||
|
|
||||||
@@ -4,4 +4,3 @@ export * from "./subscriptions";
|
|||||||
export * from "./email";
|
export * from "./email";
|
||||||
export * from "./dueDateFilters";
|
export * from "./dueDateFilters";
|
||||||
export * from "./s3";
|
export * from "./s3";
|
||||||
export * from "./mentions";
|
|
||||||
|
|||||||
@@ -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));
|
|
||||||
}
|
|
||||||
|
|
||||||
12
pnpm-lock.yaml
generated
12
pnpm-lock.yaml
generated
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user