Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@
"@aws-sdk/client-s3": "^3.802.0",
"@aws-sdk/lib-storage": "^3.802.0",
"@aws-sdk/s3-request-presigner": "^3.812.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@headlessui/react": "^2.2.0",
"@hookform/resolvers": "^3.3.4",
"@kan/api": "workspace:*",
Expand Down
232 changes: 171 additions & 61 deletions apps/web/src/views/boards/components/BoardsList.tsx
Original file line number Diff line number Diff line change
@@ -1,40 +1,123 @@
import Link from "next/link";
import type { DragEndEvent } from "@dnd-kit/core";
import { useRouter } from "next/router";
import {
closestCenter,
DndContext,
PointerSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
import {
arrayMove,
rectSortingStrategy,
SortableContext,
} from "@dnd-kit/sortable";
import { t } from "@lingui/core/macro";
import { HiOutlineRectangleStack, HiOutlineStar, HiStar } from "react-icons/hi2";
import { motion } from "framer-motion";
import { useRef } from "react";
import { HiOutlineRectangleStack } from "react-icons/hi2";

import Button from "~/components/Button";
import PatternedBackground from "~/components/PatternedBackground";
import { Tooltip } from "~/components/Tooltip";
import { usePermissions } from "~/hooks/usePermissions";
import { useModal } from "~/providers/modal";
import { useWorkspace } from "~/providers/workspace";
import { api } from "~/utils/api";
import { SortableBoardCard } from "./SortableBoardCard";

export function BoardsList({ isTemplate, archived = false }: { isTemplate?: boolean; archived?: boolean }) {
export function BoardsList({
isTemplate,
archived = false,
}: {
isTemplate?: boolean;
archived?: boolean;
}) {
const { workspace } = useWorkspace();
const { openModal } = useModal();
const { canCreateBoard } = usePermissions();
const router = useRouter();

const utils = api.useUtils();

const queryInput = {
workspacePublicId: workspace.publicId,
type: isTemplate ? ("template" as const) : ("regular" as const),
archived,
};

const updateBoard = api.board.update.useMutation({
onSuccess: () => {
void utils.board.all.invalidate();
},
});

const { data, isLoading } = api.board.all.useQuery(
{
workspacePublicId: workspace.publicId,
type: isTemplate ? "template" : "regular",
archived: archived,
const reorderBoard = api.board.reorder.useMutation({
onMutate: ({ boardPublicId, newPosition }) => {
const previousData = utils.board.all.getData(queryInput);

// Apply the optimistic reorder synchronously (before any await) so it
// batches into the same render as dnd-kit clearing the drag transform.
// Otherwise the card snaps back to its original slot for one frame before
// jumping to the new slot.
utils.board.all.setData(queryInput, (old) => {
if (!old) return old;

const moved = old.find((b) => b.publicId === boardPublicId);
if (!moved) return old;

// newPosition is the index within the board's favourite group; map it
// back to an absolute index in the full (favourites-first) list.
const group = old.filter((b) => b.favorite === moved.favorite);
const target = group[newPosition];
if (!target) return old;

const oldIndex = old.findIndex((b) => b.publicId === boardPublicId);
const newIndex = old.findIndex((b) => b.publicId === target.publicId);
if (oldIndex === -1 || newIndex === -1) return old;

return arrayMove(old, oldIndex, newIndex);
});

// Cancel in-flight refetches so they can't clobber the optimistic order.
void utils.board.all.cancel(queryInput);

return { previousData };
},
onError: (_err, _vars, context) => {
if (context?.previousData) {
utils.board.all.setData(queryInput, context.previousData);
}
},
{ enabled: workspace.publicId ? true : false },
onSuccess: () => {
// The optimistic order already matches what the server computes, so mark
// the query stale WITHOUT refetching now. Refetching immediately replaces
// the array mid-animation, which makes dnd-kit re-measure from stale
// positions and the cards fly in from a default spot. It refreshes on the
// next natural trigger (focus/remount).
void utils.board.all.invalidate(queryInput, { refetchType: "none" });
},
});

const { data, isLoading } = api.board.all.useQuery(queryInput, {
enabled: workspace.publicId ? true : false,
});

const sensors = useSensors(
useSensor(PointerSensor, {
// Require an 8px drag before activating so plain clicks still navigate.
activationConstraint: { distance: 8 },
}),
);

// Whether the current pointer interaction became a drag. The browser fires a
// `click` on pointer-release after a drag; `handleOpenBoard` reads this to skip
// navigation for that click. Reset on pointer-down, set in `onDragStart` (which
// only fires once the 8px threshold is passed).
const didDragRef = useRef(false);

const handleToggleFavorite = (
e: React.MouseEvent,
boardPublicId: string,
currentFavorite: boolean | undefined
currentFavorite: boolean | undefined,
) => {
e.preventDefault();
e.stopPropagation();
Expand All @@ -44,6 +127,52 @@ export function BoardsList({ isTemplate, archived = false }: { isTemplate?: bool
});
};

const handlePointerDownCapture = () => {
didDragRef.current = false;
};

const handleDragStart = () => {
didDragRef.current = true;
};

const handleOpenBoard = (
e: React.MouseEvent | React.KeyboardEvent,
boardPublicId: string,
) => {
// Skip the click the browser fires right after a drag.
if (didDragRef.current) return;

const path = `/${isTemplate ? "templates" : "boards"}/${boardPublicId}`;

if (e.metaKey || e.ctrlKey) {
window.open(path, "_blank");
return;
}

void router.push(path);
};

const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id || !data) return;

const activeBoard = data.find((b) => b.publicId === active.id);
const overBoard = data.find((b) => b.publicId === over.id);
if (!activeBoard || !overBoard) return;

// Reordering only happens within the same favourite group; cross-group
// moves are handled via the favourite toggle.
if (activeBoard.favorite !== overBoard.favorite) return;

const group = data.filter((b) => b.favorite === activeBoard.favorite);
const newPosition = group.findIndex((b) => b.publicId === over.id);
if (newPosition === -1) return;

reorderBoard.mutate({
boardPublicId: active.id as string,
newPosition,
});
};

if (isLoading)
return (
Expand All @@ -60,16 +189,18 @@ export function BoardsList({ isTemplate, archived = false }: { isTemplate?: bool
<div className="flex flex-col items-center">
<HiOutlineRectangleStack className="h-10 w-10 text-light-800 dark:text-dark-800" />
<p className="mb-2 mt-4 text-[14px] font-bold text-light-1000 dark:text-dark-950">
{archived ? t`No archived boards` : t`No ${isTemplate ? "templates" : "boards"}`}
{archived
? t`No archived boards`
: t`No ${isTemplate ? "templates" : "boards"}`}
</p>
<p className="text-[14px] text-light-900 dark:text-dark-900">
{archived ? t`Boards you archive will appear here.` : t`Get started by creating a new ${isTemplate ? "template" : "board"}`}
{archived
? t`Boards you archive will appear here.`
: t`Get started by creating a new ${isTemplate ? "template" : "board"}`}
</p>
</div>
<Tooltip
content={
!canCreateBoard ? t`You don't have permission` : undefined
}
content={!canCreateBoard ? t`You don't have permission` : undefined}
>
<Button
onClick={() => {
Expand All @@ -84,51 +215,30 @@ export function BoardsList({ isTemplate, archived = false }: { isTemplate?: bool
);

return (
<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
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
{data?.map((board) => (
<motion.div
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 }
}}
<SortableContext
items={(data ?? []).map((b) => b.publicId)}
strategy={rectSortingStrategy}
>
<div
onPointerDownCapture={handlePointerDownCapture}
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"
>
<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>
))}
</motion.div>
{data?.map((board) => (
<SortableBoardCard
key={board.publicId}
board={board}
onOpen={handleOpenBoard}
onToggleFavorite={handleToggleFavorite}
/>
))}
</div>
</SortableContext>
</DndContext>
);
}
89 changes: 89 additions & 0 deletions apps/web/src/views/boards/components/SortableBoardCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import type { AnimateLayoutChanges } from "@dnd-kit/sortable";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { HiOutlineStar, HiStar } from "react-icons/hi2";

import PatternedBackground from "~/components/PatternedBackground";

// Disable dnd-kit's layout-change (FLIP) animation. When the list reorders after
// a drop, the optimistic data already puts cards in the right slots; letting
// dnd-kit also FLIP-animate the change makes displaced cards fly in from the
// grid container's corner (its rect measurement ignores grid gap/padding).
const animateLayoutChanges: AnimateLayoutChanges = () => false;

interface SortableBoardCardProps {
board: {
publicId: string;
name: string;
favorite: boolean;
};
onOpen: (
e: React.MouseEvent | React.KeyboardEvent,
boardPublicId: string,
) => void;
onToggleFavorite: (
e: React.MouseEvent,
boardPublicId: string,
currentFavorite: boolean | undefined,
) => void;
}

export function SortableBoardCard({
board,
onOpen,
onToggleFavorite,
}: SortableBoardCardProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: board.publicId, animateLayoutChanges });

const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
zIndex: isDragging ? 10 : undefined,
};

// Navigation is programmatic (no anchor): an `<a>` initiates the browser's
// native link navigation/drag during a pointer drag, which fires before
// dnd-kit's drag-end and cannot be reliably cancelled. `onOpen` is gated on
// the drag flag in the parent.
return (
<div ref={setNodeRef} style={style} {...attributes} {...listeners}>
<div
role="button"
tabIndex={0}
onClick={(e) => onOpen(e, board.publicId)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") onOpen(e, board.publicId);
}}
className="group relative mr-5 flex h-[150px] w-full cursor-pointer 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) => onToggleFavorite(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>
</div>
);
}
Loading