feat: add board favourites with animations (#318)

* feat: add board favorites with animations

* feat: add board favorites functionality with admin-only access

* refactor: make board favorites user-specific via junction table

* fix: assertUserInWorkspace

* Update pnpm lockfile to match package.json

* chore: clean up migrations

---------

Co-authored-by: Henry <henry_ball@hotmail.co.uk>
This commit is contained in:
Eliott Herbert-Byrnes
2026-02-01 22:29:51 +00:00
committed by GitHub
parent ec37f5480a
commit ef5bf87fdf
16 changed files with 6729 additions and 47 deletions

View File

@@ -1,7 +1,7 @@
import Link from "next/link";
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 PatternedBackground from "~/components/PatternedBackground";
import { Tooltip } from "~/components/Tooltip";
@@ -15,6 +15,13 @@ export function BoardsList({ isTemplate }: { isTemplate?: boolean }) {
const { openModal } = useModal();
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(
{
workspacePublicId: workspace.publicId,
@@ -23,6 +30,20 @@ export function BoardsList({ isTemplate }: { isTemplate?: boolean }) {
{ 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)
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">
@@ -62,20 +83,51 @@ export function BoardsList({ isTemplate }: { isTemplate?: boolean }) {
);
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) => (
<Link
<motion.div
key={board.publicId}
href={`${isTemplate ? "templates" : "boards"}/${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 }
}}
>
<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">
<PatternedBackground />
<p className="px-4 text-[14px] font-bold text-neutral-700 dark:text-dark-1000">
{board.name}
</p>
</div>
</Link>
<Link
href={`${isTemplate ? "templates" : "boards"}/${board.publicId}`}
>
<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 />
<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">
{board.name}
</p>
</div>
</Link>
</motion.div>
))}
</div>
</motion.div>
);
}