Compare commits
8 Commits
feat/custo
...
fix/hide-e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de87f6237e | ||
|
|
2269d23c94 | ||
|
|
e437d075e3 | ||
|
|
b422907b53 | ||
|
|
672dfe6540 | ||
|
|
ef5bf87fdf | ||
|
|
ec37f5480a | ||
|
|
7f5a1ab513 |
@@ -47,6 +47,7 @@
|
|||||||
"@trpc/react-query": "catalog:",
|
"@trpc/react-query": "catalog:",
|
||||||
"@trpc/server": "catalog:",
|
"@trpc/server": "catalog:",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
"framer-motion": "^12.26.2",
|
||||||
"geist": "^1.3.1",
|
"geist": "^1.3.1",
|
||||||
"jose": "^6.1.2",
|
"jose": "^6.1.2",
|
||||||
"next": "15.5.9",
|
"next": "15.5.9",
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export default function Dropdown({
|
|||||||
leaveFrom="transform opacity-100 scale-100"
|
leaveFrom="transform opacity-100 scale-100"
|
||||||
leaveTo="transform opacity-0 scale-95"
|
leaveTo="transform opacity-0 scale-95"
|
||||||
>
|
>
|
||||||
<Menu.Items className="absolute right-0 z-50 mt-2 w-56 origin-top-right rounded-md border border-light-200 bg-light-50 p-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-400 dark:bg-dark-300">
|
<Menu.Items className="absolute right-0 z-[100] isolate mt-2 w-56 origin-top-right rounded-md border border-light-200 bg-white p-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-400 dark:bg-dark-300">
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<Menu.Item key={item.label} disabled={item.disabled}>
|
<Menu.Item key={item.label} disabled={item.disabled}>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { Permission } from "@kan/shared";
|
import type { Permission } from "@kan/shared";
|
||||||
|
import { useContext } from "react";
|
||||||
|
|
||||||
import { useWorkspace } from "~/providers/workspace";
|
import { WorkspaceContext } from "~/providers/workspace";
|
||||||
import { api } from "~/utils/api";
|
import { api } from "~/utils/api";
|
||||||
|
|
||||||
interface UsePermissionsResult {
|
interface UsePermissionsResult {
|
||||||
@@ -30,7 +31,40 @@ interface UsePermissionsResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function usePermissions(): UsePermissionsResult {
|
export function usePermissions(): UsePermissionsResult {
|
||||||
const { workspace } = useWorkspace();
|
// Check if WorkspaceProvider is available (for public board views, it may not be)
|
||||||
|
const workspaceContext = useContext(WorkspaceContext);
|
||||||
|
|
||||||
|
// If WorkspaceProvider is not available, return safe defaults
|
||||||
|
if (!workspaceContext) {
|
||||||
|
const emptyPermissions: UsePermissionsResult = {
|
||||||
|
permissions: [],
|
||||||
|
role: null,
|
||||||
|
isLoading: false,
|
||||||
|
hasPermission: () => false,
|
||||||
|
canViewCard: false,
|
||||||
|
canCreateCard: false,
|
||||||
|
canEditCard: false,
|
||||||
|
canDeleteCard: false,
|
||||||
|
canCreateList: false,
|
||||||
|
canEditList: false,
|
||||||
|
canDeleteList: false,
|
||||||
|
canCreateBoard: false,
|
||||||
|
canEditBoard: false,
|
||||||
|
canDeleteBoard: false,
|
||||||
|
canViewComment: false,
|
||||||
|
canCreateComment: false,
|
||||||
|
canEditComment: false,
|
||||||
|
canDeleteComment: false,
|
||||||
|
canInviteMember: false,
|
||||||
|
canEditMember: false,
|
||||||
|
canRemoveMember: false,
|
||||||
|
canViewWorkspace: false,
|
||||||
|
canEditWorkspace: false,
|
||||||
|
};
|
||||||
|
return emptyPermissions;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { workspace } = workspaceContext;
|
||||||
|
|
||||||
const { data, isLoading } = api.permission.getMyPermissions.useQuery(
|
const { data, isLoading } = api.permission.getMyPermissions.useQuery(
|
||||||
{ workspacePublicId: workspace.publicId },
|
{ workspacePublicId: workspace.publicId },
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ const initialWorkspace: Workspace = {
|
|||||||
|
|
||||||
const initialAvailableWorkspaces: Workspace[] = [];
|
const initialAvailableWorkspaces: Workspace[] = [];
|
||||||
|
|
||||||
const WorkspaceContext = createContext<WorkspaceContextProps | undefined>(
|
export const WorkspaceContext = createContext<WorkspaceContextProps | undefined>(
|
||||||
undefined,
|
undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import {
|
|||||||
HiLink,
|
HiLink,
|
||||||
HiOutlineDocumentDuplicate,
|
HiOutlineDocumentDuplicate,
|
||||||
HiOutlineTrash,
|
HiOutlineTrash,
|
||||||
|
HiOutlineStar,
|
||||||
|
HiStar,
|
||||||
} from "react-icons/hi2";
|
} from "react-icons/hi2";
|
||||||
|
|
||||||
import Dropdown from "~/components/Dropdown";
|
import Dropdown from "~/components/Dropdown";
|
||||||
@@ -16,29 +18,67 @@ export default function BoardDropdown({
|
|||||||
isTemplate,
|
isTemplate,
|
||||||
isLoading,
|
isLoading,
|
||||||
boardPublicId,
|
boardPublicId,
|
||||||
workspacePublicId,
|
isFavorite,
|
||||||
|
boardName,
|
||||||
}: {
|
}: {
|
||||||
isTemplate: boolean;
|
isTemplate: boolean;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
boardPublicId: string;
|
boardPublicId: string;
|
||||||
workspacePublicId: string;
|
isFavorite?: boolean;
|
||||||
|
boardName?: string;
|
||||||
}) {
|
}) {
|
||||||
const { openModal } = useModal();
|
const { openModal } = useModal();
|
||||||
const { canEditBoard, canDeleteBoard, canCreateBoard } = usePermissions();
|
const { canEditBoard, canDeleteBoard, canCreateBoard } = usePermissions();
|
||||||
|
const { showPopup } = usePopup();
|
||||||
|
const utils = api.useUtils();
|
||||||
|
|
||||||
|
const handleToggleFavorite = () => {
|
||||||
|
updateBoard.mutate({
|
||||||
|
boardPublicId,
|
||||||
|
favorite: !isFavorite,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateBoard = api.board.update.useMutation({
|
||||||
|
onSuccess: (data, variables) => {
|
||||||
|
void utils.board.all.invalidate();
|
||||||
|
void utils.board.byId.invalidate();
|
||||||
|
|
||||||
|
// Show popup notification
|
||||||
|
if (variables.favorite !== undefined) {
|
||||||
|
showPopup({
|
||||||
|
header: variables.favorite
|
||||||
|
? t`Added to favorites`
|
||||||
|
: t`Removed from favorites`,
|
||||||
|
message: variables.favorite
|
||||||
|
? t`${boardName ?? "Board"} has been added to your favorites.`
|
||||||
|
: t`${boardName ?? "Board"} has been removed from your favorites.`,
|
||||||
|
icon: "success",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
showPopup({
|
||||||
|
header: t`Unable to update board`,
|
||||||
|
message: t`Please try again later, or contact customer support.`,
|
||||||
|
icon: "error",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const items = [
|
const items = [
|
||||||
...(isTemplate
|
...(isTemplate && canCreateBoard
|
||||||
? []
|
? [
|
||||||
: [
|
|
||||||
{
|
{
|
||||||
label: t`Make template`,
|
label: t`Make template`,
|
||||||
action: canCreateBoard ? () => openModal("CREATE_TEMPLATE") : undefined,
|
action: () => openModal("CREATE_TEMPLATE"),
|
||||||
icon: (
|
icon: (
|
||||||
<HiOutlineDocumentDuplicate className="h-[16px] w-[16px] text-dark-900" />
|
<HiOutlineDocumentDuplicate className="h-[16px] w-[16px] text-dark-900" />
|
||||||
),
|
),
|
||||||
disabled: !canCreateBoard,
|
|
||||||
},
|
},
|
||||||
...(canEditBoard
|
]
|
||||||
|
: []),
|
||||||
|
...(!isTemplate && canEditBoard
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
label: t`Edit board URL`,
|
label: t`Edit board URL`,
|
||||||
@@ -47,8 +87,17 @@ export default function BoardDropdown({
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
]),
|
{
|
||||||
|
label: isFavorite
|
||||||
|
? t`Remove from favorites`
|
||||||
|
: t`Add to favorites`,
|
||||||
|
action: handleToggleFavorite,
|
||||||
|
icon: isFavorite ? (
|
||||||
|
<HiStar className="h-[16px] w-[16px] text-dark-900" />
|
||||||
|
) : (
|
||||||
|
<HiOutlineStar className="h-[16px] w-[16px] text-dark-900" />
|
||||||
|
),
|
||||||
|
},
|
||||||
...(canDeleteBoard
|
...(canDeleteBoard
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
@@ -60,6 +109,7 @@ export default function BoardDropdown({
|
|||||||
: []),
|
: []),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
||||||
if (items.length === 0) {
|
if (items.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -493,6 +493,8 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
|||||||
isLoading={!boardData}
|
isLoading={!boardData}
|
||||||
boardPublicId={boardId ?? ""}
|
boardPublicId={boardId ?? ""}
|
||||||
workspacePublicId={workspace.publicId}
|
workspacePublicId={workspace.publicId}
|
||||||
|
isFavorite={boardData?.favorite}
|
||||||
|
boardName={boardData?.name}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -594,8 +596,7 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
|||||||
? `/templates/${boardId}/cards/${card.publicId}`
|
? `/templates/${boardId}/cards/${card.publicId}`
|
||||||
: `/cards/${card.publicId}`
|
: `/cards/${card.publicId}`
|
||||||
}
|
}
|
||||||
className={`mb-2 flex !cursor-pointer flex-col ${
|
className={`mb-2 flex !cursor-pointer flex-col ${card.publicId.startsWith(
|
||||||
card.publicId.startsWith(
|
|
||||||
"PLACEHOLDER",
|
"PLACEHOLDER",
|
||||||
)
|
)
|
||||||
? "pointer-events-none"
|
? "pointer-events-none"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { t } from "@lingui/core/macro";
|
import { t } from "@lingui/core/macro";
|
||||||
import { HiOutlineRectangleStack } from "react-icons/hi2";
|
import { HiOutlineRectangleStack, HiOutlineStar, HiStar } from "react-icons/hi2";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
import Button from "~/components/Button";
|
import Button from "~/components/Button";
|
||||||
import PatternedBackground from "~/components/PatternedBackground";
|
import PatternedBackground from "~/components/PatternedBackground";
|
||||||
import { Tooltip } from "~/components/Tooltip";
|
import { Tooltip } from "~/components/Tooltip";
|
||||||
@@ -15,6 +15,13 @@ export function BoardsList({ isTemplate }: { isTemplate?: boolean }) {
|
|||||||
const { openModal } = useModal();
|
const { openModal } = useModal();
|
||||||
const { canCreateBoard } = usePermissions();
|
const { canCreateBoard } = usePermissions();
|
||||||
|
|
||||||
|
const utils = api.useUtils();
|
||||||
|
const updateBoard = api.board.update.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
void utils.board.all.invalidate();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const { data, isLoading } = api.board.all.useQuery(
|
const { data, isLoading } = api.board.all.useQuery(
|
||||||
{
|
{
|
||||||
workspacePublicId: workspace.publicId,
|
workspacePublicId: workspace.publicId,
|
||||||
@@ -23,6 +30,20 @@ export function BoardsList({ isTemplate }: { isTemplate?: boolean }) {
|
|||||||
{ enabled: workspace.publicId ? true : false },
|
{ enabled: workspace.publicId ? true : false },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleToggleFavorite = (
|
||||||
|
e: React.MouseEvent,
|
||||||
|
boardPublicId: string,
|
||||||
|
currentFavorite: boolean | undefined
|
||||||
|
) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
updateBoard.mutate({
|
||||||
|
boardPublicId,
|
||||||
|
favorite: !currentFavorite,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
if (isLoading)
|
if (isLoading)
|
||||||
return (
|
return (
|
||||||
<div className="3xl:grid-cols-4 grid h-fit w-full grid-cols-1 gap-4 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-3">
|
<div className="3xl:grid-cols-4 grid h-fit w-full grid-cols-1 gap-4 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-3">
|
||||||
@@ -62,20 +83,51 @@ export function BoardsList({ isTemplate }: { isTemplate?: boolean }) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="3xl:grid-cols-4 grid h-fit w-full grid-cols-1 gap-4 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-3">
|
<motion.div
|
||||||
|
className="3xl:grid-cols-4 grid h-fit w-full grid-cols-1 gap-4 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-3"
|
||||||
|
layout
|
||||||
|
>
|
||||||
{data?.map((board) => (
|
{data?.map((board) => (
|
||||||
<Link
|
<motion.div
|
||||||
key={board.publicId}
|
key={board.publicId}
|
||||||
|
layout
|
||||||
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
transition={{
|
||||||
|
layout: {
|
||||||
|
type: "spring",
|
||||||
|
stiffness: 300,
|
||||||
|
damping: 30,
|
||||||
|
mass: 1
|
||||||
|
},
|
||||||
|
opacity: { duration: 0.2 },
|
||||||
|
scale: { duration: 0.2 }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Link
|
||||||
href={`${isTemplate ? "templates" : "boards"}/${board.publicId}`}
|
href={`${isTemplate ? "templates" : "boards"}/${board.publicId}`}
|
||||||
>
|
>
|
||||||
<div className="align-center relative mr-5 flex h-[150px] w-full items-center justify-center rounded-md border border-dashed border-light-400 bg-light-50 shadow-sm hover:bg-light-200 dark:border-dark-600 dark:bg-dark-50 dark:hover:bg-dark-100">
|
<div className="group relative mr-5 flex h-[150px] w-full items-center justify-center rounded-md border border-dashed border-light-400 bg-light-50 shadow-sm hover:bg-light-200 dark:border-dark-600 dark:bg-dark-50 dark:hover:bg-dark-100">
|
||||||
<PatternedBackground />
|
<PatternedBackground />
|
||||||
|
<button
|
||||||
|
onClick={(e) => handleToggleFavorite(e, board.publicId, board.favorite)}
|
||||||
|
className={`absolute right-3 top-3 z-10 rounded p-1 transition-all hover:bg-light-300 dark:hover:bg-dark-200 ${board.favorite ? "" : "md:opacity-0 md:group-hover:opacity-100"
|
||||||
|
}`}
|
||||||
|
aria-label={board.favorite ? "Remove from favorites" : "Add to favorites"}
|
||||||
|
>
|
||||||
|
{board.favorite ? (
|
||||||
|
<HiStar className="h-5 w-5 text-neutral-700 dark:text-dark-1000" />
|
||||||
|
) : (
|
||||||
|
<HiOutlineStar className="h-5 w-5 text-neutral-700 dark:text-dark-800" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
<p className="px-4 text-[14px] font-bold text-neutral-700 dark:text-dark-1000">
|
<p className="px-4 text-[14px] font-bold text-neutral-700 dark:text-dark-1000">
|
||||||
{board.name}
|
{board.name}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
</motion.div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -212,7 +212,7 @@ export default function MembersPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"relative z-50",
|
"relative",
|
||||||
(workspace.role !== "admin" || showSkeleton) && "hidden",
|
(workspace.role !== "admin" || showSkeleton) && "hidden",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { t } from "@lingui/core/macro";
|
import { t } from "@lingui/core/macro";
|
||||||
|
|
||||||
import { PageHead } from "~/components/PageHead";
|
import { PageHead } from "~/components/PageHead";
|
||||||
import PatternedBackground from "~/components/PatternedBackground";
|
import PatternedBackground from "~/components/PatternedBackground";
|
||||||
import { api } from "~/utils/api";
|
import { api } from "~/utils/api";
|
||||||
|
|||||||
@@ -60,9 +60,12 @@ export const boardRouter = createTRPCRouter({
|
|||||||
|
|
||||||
await assertPermission(ctx.db, userId, workspace.id, "board:view");
|
await assertPermission(ctx.db, userId, workspace.id, "board:view");
|
||||||
|
|
||||||
const result = boardRepo.getAllByWorkspaceId(ctx.db, workspace.id, {
|
const result = boardRepo.getAllByWorkspaceId(
|
||||||
type: input.type,
|
ctx.db,
|
||||||
});
|
workspace.id,
|
||||||
|
userId,
|
||||||
|
{ type: input.type }
|
||||||
|
);
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}),
|
}),
|
||||||
@@ -129,6 +132,7 @@ export const boardRouter = createTRPCRouter({
|
|||||||
const result = await boardRepo.getByPublicId(
|
const result = await boardRepo.getByPublicId(
|
||||||
ctx.db,
|
ctx.db,
|
||||||
input.boardPublicId,
|
input.boardPublicId,
|
||||||
|
userId,
|
||||||
{
|
{
|
||||||
members: input.members ?? [],
|
members: input.members ?? [],
|
||||||
labels: input.labels ?? [],
|
labels: input.labels ?? [],
|
||||||
@@ -275,6 +279,7 @@ export const boardRouter = createTRPCRouter({
|
|||||||
const sourceBoard = await boardRepo.getByPublicId(
|
const sourceBoard = await boardRepo.getByPublicId(
|
||||||
ctx.db,
|
ctx.db,
|
||||||
input.sourceBoardPublicId,
|
input.sourceBoardPublicId,
|
||||||
|
userId,
|
||||||
{
|
{
|
||||||
members: [],
|
members: [],
|
||||||
labels: [],
|
labels: [],
|
||||||
@@ -399,9 +404,10 @@ export const boardRouter = createTRPCRouter({
|
|||||||
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/)
|
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/)
|
||||||
.optional(),
|
.optional(),
|
||||||
visibility: z.enum(["public", "private"]).optional(),
|
visibility: z.enum(["public", "private"]).optional(),
|
||||||
|
favorite: z.boolean().optional()
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.output(z.custom<Awaited<ReturnType<typeof boardRepo.update>>>())
|
.output(z.object({ success: z.boolean() }).or(z.custom<Awaited<ReturnType<typeof boardRepo.update>>>()))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const userId = ctx.user?.id;
|
const userId = ctx.user?.id;
|
||||||
|
|
||||||
@@ -430,6 +436,23 @@ export const boardRouter = createTRPCRouter({
|
|||||||
board.createdBy ?? null,
|
board.createdBy ?? null,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Handle favorite toggle separately
|
||||||
|
if (input.favorite !== undefined) {
|
||||||
|
if (input.favorite) {
|
||||||
|
await boardRepo.addUserFavorite(ctx.db, userId, board.id);
|
||||||
|
} else {
|
||||||
|
await boardRepo.removeUserFavorite(ctx.db, userId, board.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle other updates (name, slug, visibility)
|
||||||
|
const hasOtherUpdates = input.name || input.slug || input.visibility !== undefined;
|
||||||
|
|
||||||
|
if (!hasOtherUpdates) {
|
||||||
|
// Only favorite was updated, return success
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
if (input.slug) {
|
if (input.slug) {
|
||||||
const isBoardSlugAvailable = await boardRepo.isBoardSlugAvailable(
|
const isBoardSlugAvailable = await boardRepo.isBoardSlugAvailable(
|
||||||
ctx.db,
|
ctx.db,
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
ALTER TABLE "workspace" ADD COLUMN "showEmailsToMembers" boolean NOT NULL DEFAULT true;
|
ALTER TABLE "workspace" ADD COLUMN IF NOT EXISTS "showEmailsToMembers" boolean NOT NULL DEFAULT true;
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ CREATE TABLE IF NOT EXISTS "workspace_roles" (
|
|||||||
--> statement-breakpoint
|
--> statement-breakpoint
|
||||||
ALTER TABLE "workspace_roles" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
ALTER TABLE "workspace_roles" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||||
ALTER TABLE "workspace_members" ADD COLUMN "roleId" bigint;--> statement-breakpoint
|
ALTER TABLE "workspace_members" ADD COLUMN "roleId" bigint;--> statement-breakpoint
|
||||||
ALTER TABLE "workspace" ADD COLUMN "showEmailsToMembers" boolean DEFAULT true NOT NULL;--> statement-breakpoint
|
|
||||||
DO $$ BEGIN
|
DO $$ BEGIN
|
||||||
ALTER TABLE "workspace_role_permissions" ADD CONSTRAINT "workspace_role_permissions_workspaceRoleId_workspace_roles_id_fk" FOREIGN KEY ("workspaceRoleId") REFERENCES "public"."workspace_roles"("id") ON DELETE cascade ON UPDATE no action;
|
ALTER TABLE "workspace_role_permissions" ADD CONSTRAINT "workspace_role_permissions_workspaceRoleId_workspace_roles_id_fk" FOREIGN KEY ("workspaceRoleId") REFERENCES "public"."workspace_roles"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
EXCEPTION
|
EXCEPTION
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS "user_board_favorites" (
|
||||||
|
"userId" uuid NOT NULL,
|
||||||
|
"boardId" bigint NOT NULL,
|
||||||
|
"createdAt" timestamp DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "user_board_favorites_userId_boardId_pk" PRIMARY KEY("userId","boardId")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "user_board_favorites" ADD CONSTRAINT "user_board_favorites_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 "user_board_favorites" ADD CONSTRAINT "user_board_favorites_boardId_board_id_fk" FOREIGN KEY ("boardId") REFERENCES "public"."board"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "user_board_favorite_user_idx" ON "user_board_favorites" USING btree ("userId");--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "user_board_favorite_board_idx" ON "user_board_favorites" USING btree ("boardId");
|
||||||
2982
packages/db/migrations/meta/20260119164257_snapshot.json
Normal file
2982
packages/db/migrations/meta/20260119164257_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"id": "c24a8e89-c100-4483-9f0b-391201327ac5",
|
"id": "c24a8e89-c100-4483-9f0b-391201327ac5",
|
||||||
"prevId": "4e1ebb8b-bd52-48c5-87d6-8e6e5eb8bbde",
|
"prevId": "3b54938c-c8d2-41c1-b4ba-a377719891bc",
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"dialect": "postgresql",
|
"dialect": "postgresql",
|
||||||
"tables": {
|
"tables": {
|
||||||
|
|||||||
3411
packages/db/migrations/meta/20260201215958_snapshot.json
Normal file
3411
packages/db/migrations/meta/20260201215958_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -169,6 +169,13 @@
|
|||||||
"when": 1769435949476,
|
"when": 1769435949476,
|
||||||
"tag": "20260126135909_AddWorkspaceRoles",
|
"tag": "20260126135909_AddWorkspaceRoles",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 24,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1769983198190,
|
||||||
|
"tag": "20260201215958_AddUserBoardFavouritesTable",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
comments,
|
comments,
|
||||||
labels,
|
labels,
|
||||||
lists,
|
lists,
|
||||||
|
userBoardFavorites,
|
||||||
workspaceMembers,
|
workspaceMembers,
|
||||||
} from "@kan/db/schema";
|
} from "@kan/db/schema";
|
||||||
import { generateUID } from "@kan/shared/utils";
|
import { generateUID } from "@kan/shared/utils";
|
||||||
@@ -39,17 +40,24 @@ export const getCount = async (db: dbClient) => {
|
|||||||
return result[0]?.count ?? 0;
|
return result[0]?.count ?? 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getAllByWorkspaceId = (
|
export const getAllByWorkspaceId = async (
|
||||||
db: dbClient,
|
db: dbClient,
|
||||||
workspaceId: number,
|
workspaceId: number,
|
||||||
|
userId: string,
|
||||||
opts?: { type?: "regular" | "template" },
|
opts?: { type?: "regular" | "template" },
|
||||||
) => {
|
) => {
|
||||||
return db.query.boards.findMany({
|
const boardsData = await db.query.boards.findMany({
|
||||||
columns: {
|
columns: {
|
||||||
publicId: true,
|
publicId: true,
|
||||||
name: true,
|
name: true,
|
||||||
},
|
},
|
||||||
with: {
|
with: {
|
||||||
|
userFavorites: {
|
||||||
|
where: eq(userBoardFavorites.userId, userId),
|
||||||
|
columns: {
|
||||||
|
userId: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
lists: {
|
lists: {
|
||||||
columns: {
|
columns: {
|
||||||
publicId: true,
|
publicId: true,
|
||||||
@@ -72,6 +80,21 @@ export const getAllByWorkspaceId = (
|
|||||||
opts?.type ? eq(boards.type, opts.type) : undefined,
|
opts?.type ? eq(boards.type, opts.type) : undefined,
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Transform and sort: favorites first, then alphabetically
|
||||||
|
return boardsData
|
||||||
|
.map((board) => ({
|
||||||
|
...board,
|
||||||
|
favorite: board.userFavorites.length > 0,
|
||||||
|
userFavorites: undefined,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => {
|
||||||
|
// Sort favorites first
|
||||||
|
if (a.favorite && !b.favorite) return -1;
|
||||||
|
if (!a.favorite && b.favorite) return 1;
|
||||||
|
// Then alphabetically by name
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getIdByPublicId = async (db: dbClient, boardPublicId: string) => {
|
export const getIdByPublicId = async (db: dbClient, boardPublicId: string) => {
|
||||||
@@ -122,6 +145,7 @@ const buildDueDateWhere = (filters: DueDateFilter[]) => {
|
|||||||
export const getByPublicId = async (
|
export const getByPublicId = async (
|
||||||
db: dbClient,
|
db: dbClient,
|
||||||
boardPublicId: string,
|
boardPublicId: string,
|
||||||
|
userId: string,
|
||||||
filters: {
|
filters: {
|
||||||
members: string[];
|
members: string[];
|
||||||
labels: string[];
|
labels: string[];
|
||||||
@@ -173,6 +197,12 @@ export const getByPublicId = async (
|
|||||||
visibility: true,
|
visibility: true,
|
||||||
},
|
},
|
||||||
with: {
|
with: {
|
||||||
|
userFavorites: {
|
||||||
|
where: eq(userBoardFavorites.userId, userId),
|
||||||
|
columns: {
|
||||||
|
userId: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
workspace: {
|
workspace: {
|
||||||
columns: {
|
columns: {
|
||||||
publicId: true,
|
publicId: true,
|
||||||
@@ -325,6 +355,8 @@ export const getByPublicId = async (
|
|||||||
|
|
||||||
const formattedResult = {
|
const formattedResult = {
|
||||||
...board,
|
...board,
|
||||||
|
favorite: board.userFavorites.length > 0,
|
||||||
|
userFavorites: undefined,
|
||||||
lists: board.lists.map((list) => ({
|
lists: board.lists.map((list) => ({
|
||||||
...list,
|
...list,
|
||||||
cards: list.cards.map((card) => ({
|
cards: list.cards.map((card) => ({
|
||||||
@@ -924,3 +956,34 @@ export const createFromSnapshot = async (
|
|||||||
return newBoard;
|
return newBoard;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const addUserFavorite = async (
|
||||||
|
db: dbClient,
|
||||||
|
userId: string,
|
||||||
|
boardId: number,
|
||||||
|
) => {
|
||||||
|
return db
|
||||||
|
.insert(userBoardFavorites)
|
||||||
|
.values({
|
||||||
|
userId,
|
||||||
|
boardId,
|
||||||
|
})
|
||||||
|
.onConflictDoNothing()
|
||||||
|
.returning();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const removeUserFavorite = async (
|
||||||
|
db: dbClient,
|
||||||
|
userId: string,
|
||||||
|
boardId: number,
|
||||||
|
) => {
|
||||||
|
return db
|
||||||
|
.delete(userBoardFavorites)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(userBoardFavorites.userId, userId),
|
||||||
|
eq(userBoardFavorites.boardId, boardId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.returning();
|
||||||
|
};
|
||||||
@@ -93,7 +93,7 @@ export const create = async (
|
|||||||
index: index,
|
index: index,
|
||||||
dueDate: cardInput.dueDate ?? null,
|
dueDate: cardInput.dueDate ?? null,
|
||||||
})
|
})
|
||||||
.returning({ id: cards.id, listId: cards.listId });
|
.returning({ id: cards.id, listId: cards.listId, publicId: cards.publicId });
|
||||||
|
|
||||||
if (!result[0]) throw new Error("Unable to create card");
|
if (!result[0]) throw new Error("Unable to create card");
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
ilike,
|
ilike,
|
||||||
inArray,
|
inArray,
|
||||||
isNull,
|
isNull,
|
||||||
|
asc,
|
||||||
or,
|
or,
|
||||||
sql,
|
sql,
|
||||||
} from "drizzle-orm";
|
} from "drizzle-orm";
|
||||||
@@ -185,6 +186,8 @@ export const getByPublicIdWithMembers = (
|
|||||||
columns: {
|
columns: {
|
||||||
id: true,
|
id: true,
|
||||||
publicId: true,
|
publicId: true,
|
||||||
|
name: true,
|
||||||
|
slug: true,
|
||||||
showEmailsToMembers: true,
|
showEmailsToMembers: true,
|
||||||
},
|
},
|
||||||
with: {
|
with: {
|
||||||
@@ -248,6 +251,7 @@ export const getBySlugWithBoards = (db: dbClient, workspaceSlug: string) => {
|
|||||||
name: true,
|
name: true,
|
||||||
},
|
},
|
||||||
where: and(isNull(boards.deletedAt), eq(boards.visibility, "public")),
|
where: and(isNull(boards.deletedAt), eq(boards.visibility, "public")),
|
||||||
|
orderBy: [asc(boards.name)]
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
where: and(
|
where: and(
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ import { relations, sql } from "drizzle-orm";
|
|||||||
import {
|
import {
|
||||||
bigint,
|
bigint,
|
||||||
bigserial,
|
bigserial,
|
||||||
|
boolean,
|
||||||
index,
|
index,
|
||||||
pgEnum,
|
pgEnum,
|
||||||
pgTable,
|
pgTable,
|
||||||
|
primaryKey,
|
||||||
text,
|
text,
|
||||||
timestamp,
|
timestamp,
|
||||||
uniqueIndex,
|
uniqueIndex,
|
||||||
@@ -67,6 +69,7 @@ export const boards = pgTable(
|
|||||||
).enableRLS();
|
).enableRLS();
|
||||||
|
|
||||||
export const boardsRelations = relations(boards, ({ one, many }) => ({
|
export const boardsRelations = relations(boards, ({ one, many }) => ({
|
||||||
|
userFavorites: many(userBoardFavorites),
|
||||||
createdBy: one(users, {
|
createdBy: one(users, {
|
||||||
fields: [boards.createdBy],
|
fields: [boards.createdBy],
|
||||||
references: [users.id],
|
references: [users.id],
|
||||||
@@ -91,3 +94,21 @@ export const boardsRelations = relations(boards, ({ one, many }) => ({
|
|||||||
relationName: "boardWorkspace",
|
relationName: "boardWorkspace",
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
export const userBoardFavorites = pgTable(
|
||||||
|
"user_board_favorites",
|
||||||
|
{
|
||||||
|
userId: uuid("userId")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
boardId: bigint("boardId", { mode: "number" })
|
||||||
|
.notNull()
|
||||||
|
.references(() => boards.id, { onDelete: "cascade" }),
|
||||||
|
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
pk: primaryKey({ columns: [table.userId, table.boardId] }),
|
||||||
|
userIdx: index("user_board_favorite_user_idx").on(table.userId),
|
||||||
|
boardIdx: index("user_board_favorite_board_idx").on(table.boardId),
|
||||||
|
}),
|
||||||
|
);
|
||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
} from "drizzle-orm/pg-core";
|
} from "drizzle-orm/pg-core";
|
||||||
|
|
||||||
import { apikey } from "./auth";
|
import { apikey } from "./auth";
|
||||||
import { boards } from "./boards";
|
import { boards, userBoardFavorites } from "./boards";
|
||||||
import { cards } from "./cards";
|
import { cards } from "./cards";
|
||||||
import { imports } from "./imports";
|
import { imports } from "./imports";
|
||||||
import { lists } from "./lists";
|
import { lists } from "./lists";
|
||||||
@@ -84,3 +84,14 @@ export const usersToWorkspacesRelations = relations(
|
|||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const userBoardFavoritesRelations = relations(userBoardFavorites, ({ one }) => ({
|
||||||
|
user: one(users, {
|
||||||
|
fields: [userBoardFavorites.userId],
|
||||||
|
references: [users.id],
|
||||||
|
}),
|
||||||
|
board: one(boards, {
|
||||||
|
fields: [userBoardFavorites.boardId],
|
||||||
|
references: [boards.id],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
38
pnpm-lock.yaml
generated
38
pnpm-lock.yaml
generated
@@ -163,6 +163,9 @@ importers:
|
|||||||
date-fns:
|
date-fns:
|
||||||
specifier: ^4.1.0
|
specifier: ^4.1.0
|
||||||
version: 4.1.0
|
version: 4.1.0
|
||||||
|
framer-motion:
|
||||||
|
specifier: ^12.26.2
|
||||||
|
version: 12.26.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
geist:
|
geist:
|
||||||
specifier: ^1.3.1
|
specifier: ^1.3.1
|
||||||
version: 1.4.2(next@15.5.9(@babel/core@7.28.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))
|
version: 1.4.2(next@15.5.9(@babel/core@7.28.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))
|
||||||
@@ -5482,6 +5485,20 @@ packages:
|
|||||||
resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==}
|
resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==}
|
||||||
engines: {node: '>=0.4.x'}
|
engines: {node: '>=0.4.x'}
|
||||||
|
|
||||||
|
framer-motion@12.26.2:
|
||||||
|
resolution: {integrity: sha512-lflOQEdjquUi9sCg5Y1LrsZDlsjrHw7m0T9Yedvnk7Bnhqfkc89/Uha10J3CFhkL+TCZVCRw9eUGyM/lyYhXQA==}
|
||||||
|
peerDependencies:
|
||||||
|
'@emotion/is-prop-valid': '*'
|
||||||
|
react: ^18.0.0 || ^19.0.0
|
||||||
|
react-dom: ^18.0.0 || ^19.0.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@emotion/is-prop-valid':
|
||||||
|
optional: true
|
||||||
|
react:
|
||||||
|
optional: true
|
||||||
|
react-dom:
|
||||||
|
optional: true
|
||||||
|
|
||||||
fs-extra@10.1.0:
|
fs-extra@10.1.0:
|
||||||
resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==}
|
resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -6594,6 +6611,12 @@ packages:
|
|||||||
moo@0.5.2:
|
moo@0.5.2:
|
||||||
resolution: {integrity: sha512-iSAJLHYKnX41mKcJKjqvnAN9sf0LMDTXDEvFv+ffuRR9a1MIuXLjMNL6EsnDHSkKLTWNqQQ5uo61P4EbU4NU+Q==}
|
resolution: {integrity: sha512-iSAJLHYKnX41mKcJKjqvnAN9sf0LMDTXDEvFv+ffuRR9a1MIuXLjMNL6EsnDHSkKLTWNqQQ5uo61P4EbU4NU+Q==}
|
||||||
|
|
||||||
|
motion-dom@12.26.2:
|
||||||
|
resolution: {integrity: sha512-KLMT1BroY8oKNeliA3JMNJ+nbCIsTKg6hJpDb4jtRAJ7nCKnnpg/LTq/NGqG90Limitz3kdAnAVXecdFVGlWTw==}
|
||||||
|
|
||||||
|
motion-utils@12.24.10:
|
||||||
|
resolution: {integrity: sha512-x5TFgkCIP4pPsRLpKoI86jv/q8t8FQOiM/0E8QKBzfMozWHfkKap2gA1hOki+B5g3IsBNpxbUnfOum1+dgvYww==}
|
||||||
|
|
||||||
mri@1.2.0:
|
mri@1.2.0:
|
||||||
resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==}
|
resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==}
|
||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
@@ -13955,6 +13978,15 @@ snapshots:
|
|||||||
|
|
||||||
format@0.2.2: {}
|
format@0.2.2: {}
|
||||||
|
|
||||||
|
framer-motion@12.26.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
||||||
|
dependencies:
|
||||||
|
motion-dom: 12.26.2
|
||||||
|
motion-utils: 12.24.10
|
||||||
|
tslib: 2.8.1
|
||||||
|
optionalDependencies:
|
||||||
|
react: 18.3.1
|
||||||
|
react-dom: 18.3.1(react@18.3.1)
|
||||||
|
|
||||||
fs-extra@10.1.0:
|
fs-extra@10.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
graceful-fs: 4.2.11
|
graceful-fs: 4.2.11
|
||||||
@@ -15504,6 +15536,12 @@ snapshots:
|
|||||||
|
|
||||||
moo@0.5.2: {}
|
moo@0.5.2: {}
|
||||||
|
|
||||||
|
motion-dom@12.26.2:
|
||||||
|
dependencies:
|
||||||
|
motion-utils: 12.24.10
|
||||||
|
|
||||||
|
motion-utils@12.24.10: {}
|
||||||
|
|
||||||
mri@1.2.0: {}
|
mri@1.2.0: {}
|
||||||
|
|
||||||
ms@2.1.3: {}
|
ms@2.1.3: {}
|
||||||
|
|||||||
Reference in New Issue
Block a user