feat: raw boards dnd

This commit is contained in:
2025-07-30 10:59:39 +04:00
parent cb6a814918
commit b8d431ae99
21 changed files with 599 additions and 8 deletions

View File

@ -0,0 +1,13 @@
import React, { FC } from "react";
import { Box } from "@mantine/core";
import { BoardSchema } from "@/types/BoardSchema";
type Props = {
board: BoardSchema;
};
const Board: FC<Props> = ({ board }) => {
return <Box miw={100} style={{ borderWidth: 1, margin: 0 }}>{board.name}</Box>;
};
export default Board;

View File

@ -0,0 +1,33 @@
"use client";
import React from "react";
import { ScrollArea } from "@mantine/core";
import Board from "@/app/deals/components/Board/Board";
import useBoards from "@/app/deals/hooks/useBoards";
import SortableDnd from "@/components/SortableDnd";
import { BoardSchema } from "@/types/BoardSchema";
const Boards = () => {
const { boards, setBoards } = useBoards();
const renderBoard = (board: BoardSchema) => {
return <Board board={board} />;
};
const onDragEnd = (itemId: number, newLexorank: string) => {};
return (
<ScrollArea
offsetScrollbars={"y"}
w={"100%"}>
<SortableDnd
initialItems={boards}
renderItem={renderBoard}
onDragEnd={onDragEnd}
rowStyle={{ flexWrap: "nowrap" }}
/>
</ScrollArea>
);
};
export default Boards;

View File

@ -0,0 +1,23 @@
import { useEffect, useState } from "react";
import { BoardSchema } from "@/types/BoardSchema";
const useBoards = () => {
const [boards, setBoards] = useState<BoardSchema[]>([]);
useEffect(() => {
setBoards([
{ id: 1, name: "1 Item", rank: "0|aaaaaa:" },
{ id: 2, name: "2 Item", rank: "0|gggggg:" },
{ id: 3, name: "3 Item", rank: "0|mmmmmm:" },
{ id: 4, name: "4 Item", rank: "0|ssssss:" },
{ id: 5, name: "5 Item", rank: "0|zzzzzz:" },
]);
}, []);
return {
boards,
setBoards,
};
};
export default useBoards;

13
src/app/deals/page.tsx Normal file
View File

@ -0,0 +1,13 @@
import Boards from "@/app/deals/components/Boards/Boards";
import PageBlock from "@/components/PageBlock/PageBlock";
import PageContainer from "@/components/PageContainer/PageContainer";
export default function DealsPage() {
return (
<PageContainer>
<PageBlock>
<Boards />
</PageBlock>
</PageContainer>
);
}

View File

@ -1,11 +1,5 @@
import { ColorSchemeToggle } from "@/components/ColorSchemeToggle/ColorSchemeToggle";
import { Welcome } from "@/components/Welcome/Welcome";
import { redirect } from "next/navigation";
export default function HomePage() {
return (
<>
<Welcome />
<ColorSchemeToggle />
</>
);
redirect("/deals");
}

View File

@ -0,0 +1,30 @@
import React, { FC, ReactNode } from "react";
import { useDraggable } from "@dnd-kit/core";
type Props = {
children: ReactNode;
};
const Draggable: FC<Props> = props => {
const { attributes, listeners, setNodeRef, transform } = useDraggable({
id: "draggable",
});
const style = transform
? {
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
}
: undefined;
return (
<div
ref={setNodeRef}
style={style}
{...listeners}
{...attributes}>
{props.children}
</div>
);
};
export default Draggable;

View File

@ -0,0 +1,25 @@
import React, { FC, ReactNode } from "react";
import { useDroppable } from "@dnd-kit/core";
type Props = {
children: ReactNode;
}
const Droppable: FC<Props> = ({ children }) => {
const { isOver, setNodeRef } = useDroppable({
id: "droppable",
});
const style = {
color: isOver ? "green" : undefined,
};
return (
<div
ref={setNodeRef}
style={style}>
{children}
</div>
);
}
export default Droppable;

View File

@ -0,0 +1,41 @@
.container {
border-radius: rem(40);
background-color: white;
@mixin dark {
background-color: var(--mantine-color-dark-8);
box-shadow: 5px 5px 30px 1px var(--mantine-color-dark-6);
}
@mixin light {
box-shadow: 5px 5px 24px rgba(0, 0, 0, 0.16);
}
padding: rem(35);
}
.container-full-height {
min-height: calc(100vh - (rem(20) * 2));
}
.container-full-height-fixed {
height: calc(100vh - (rem(20) * 2));
}
.container-no-border-radius {
border-radius: 0 !important;
}
.container-full-screen-mobile {
@media (max-width: 48em) {
min-height: 100vh;
height: 100vh;
width: 100vw;
border-radius: 0 !important;
padding: rem(40) rem(20) rem(20);
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 100;
overflow-y: auto;
}
}

View File

@ -0,0 +1,36 @@
import { CSSProperties, FC, ReactNode } from "react";
import classNames from "classnames";
import styles from "./PageBlock.module.css";
5
type Props = {
children: ReactNode;
style?: CSSProperties;
fullHeight?: boolean;
fullHeightFixed?: boolean;
noBorderRadius?: boolean;
fullScreenMobile?: boolean;
};
const PageBlock: FC<Props> = ({
children,
style,
fullHeight = false,
fullHeightFixed = false,
noBorderRadius = false,
fullScreenMobile = false,
}) => {
return (
<div
style={style}
className={classNames(
styles.container,
fullHeight && styles["container-full-height"],
fullHeightFixed && styles["container-full-height-fixed"],
noBorderRadius && styles["container-no-border-radius"],
fullScreenMobile && styles["container-full-screen-mobile"]
)}>
{children}
</div>
);
};
export default PageBlock;

View File

@ -0,0 +1,7 @@
.container {
display: flex;
flex-direction: column;
gap: rem(10);
min-height: 86vh;
background-color: transparent;
}

View File

@ -0,0 +1,24 @@
import { CSSProperties, FC, ReactNode } from "react";
import styles from "./PageContainer.module.css";
type Props = {
children: ReactNode;
style?: CSSProperties;
center?: boolean;
};
const PageContainer: FC<Props> = ({ children, style, center }) => {
return (
<div
className={styles.container}
style={{
...style,
alignItems: center ? "center" : "",
justifyContent: center ? "center" : "",
}}>
{children}
</div>
);
};
export default PageContainer;

View File

@ -0,0 +1,25 @@
import React, { ReactNode, useContext } from "react";
import SortableItemContext from "@/components/SortableDnd/SortableItemContext";
type Props = {
children: ReactNode;
};
const DragHandle = ({ children }: Props) => {
const { attributes, listeners, ref } = useContext(SortableItemContext);
return (
<div
{...attributes}
{...listeners}
style={{
width: "100%",
cursor: "grab",
}}
ref={ref}>
{children}
</div>
);
};
export default DragHandle;

View File

@ -0,0 +1,134 @@
"use client";
import React, {
CSSProperties,
ReactNode,
useEffect,
useMemo,
useState,
} from "react";
import {
Active,
DndContext,
DragEndEvent,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
import {
SortableContext,
sortableKeyboardCoordinates,
} from "@dnd-kit/sortable";
import { LexoRank } from "lexorank";
import { Group } from "@mantine/core";
import { SortableItem } from "@/components/SortableDnd/SortableItem";
import { SortableOverlay } from "@/components/SortableDnd/SortableOverlay";
import { getNewLexorank, sortByLexorank } from "@/utils/lexorank";
type BaseItem = {
id: number;
rank: string;
};
type Props<T extends BaseItem> = {
initialItems: T[];
renderItem: (item: T) => ReactNode;
onDragEnd: (itemId: number, newLexorank: string) => void;
rowStyle?: CSSProperties;
itemStyle?: CSSProperties;
};
const SortableDnd = <T extends BaseItem>({
initialItems,
renderItem,
onDragEnd,
rowStyle,
itemStyle,
}: Props<T>) => {
const [active, setActive] = useState<Active | null>(null);
const [items, setItems] = useState<T[]>([]);
const activeItem = useMemo(
() => initialItems.find(item => item.id === active?.id),
[active, items]
);
useEffect(() => {
console.log(sortByLexorank(initialItems));
setItems(sortByLexorank(initialItems));
}, [initialItems]);
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
const onDragEndLocal = ({ active, over }: DragEndEvent) => {
if (over && active.id !== over?.id && activeItem) {
const overIndex: number = items.findIndex(
({ id }) => id === over.id
);
const activeIndex: number = items.findIndex(
({ id }) => id === activeItem.id
);
let leftIndex = overIndex;
let rightIndex = overIndex + 1;
if (overIndex < activeIndex) {
leftIndex = overIndex - 1;
rightIndex = overIndex;
}
const leftLexorank: LexoRank | null =
leftIndex >= 0 ? LexoRank.parse(items[leftIndex].rank) : null;
const rightLexorank: LexoRank | null =
rightIndex < items.length
? LexoRank.parse(items[rightIndex].rank)
: null;
const newLexorank = getNewLexorank(
leftLexorank,
rightLexorank
).toString();
items[activeIndex].rank = newLexorank;
onDragEnd(items[activeIndex].id, newLexorank);
const sortedItems = sortByLexorank(items);
setItems([...sortedItems]);
}
setActive(null);
};
return (
<DndContext
sensors={sensors}
onDragStart={({ active }) => setActive(active)}
onDragEnd={onDragEndLocal}
onDragCancel={() => setActive(null)}>
<SortableContext items={items}>
<Group
gap={0}
style={rowStyle}
role="application">
{items.map((item, index) => (
<SortableItem
key={index}
itemStyle={itemStyle}
id={item.id}>
{renderItem(item)}
</SortableItem>
))}
</Group>
</SortableContext>
<SortableOverlay>
<div style={{ cursor: "grabbing" }}>
{activeItem ? renderItem(activeItem) : null}
</div>
</SortableOverlay>
</DndContext>
);
};
export default SortableDnd;

View File

@ -0,0 +1,47 @@
import React, { CSSProperties, PropsWithChildren, useMemo } from "react";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import DragHandle from "@/components/SortableDnd/DragHandle";
import SortableItemContext from "./SortableItemContext";
type Props = {
id: number;
itemStyle?: CSSProperties;
};
export const SortableItem = ({ children, id }: PropsWithChildren<Props>) => {
const {
attributes,
isDragging,
listeners,
setNodeRef,
setActivatorNodeRef,
transform,
transition,
} = useSortable({ id });
const context = useMemo(
() => ({
attributes,
listeners,
ref: setActivatorNodeRef,
}),
[attributes, listeners, setActivatorNodeRef]
);
const style: CSSProperties = {
opacity: isDragging ? 0.4 : undefined,
transform: CSS.Translate.toString(transform),
transition,
};
return (
<SortableItemContext.Provider value={context}>
<div
ref={setNodeRef}
style={style}>
<DragHandle>{children}</DragHandle>
</div>
</SortableItemContext.Provider>
);
};

View File

@ -0,0 +1,16 @@
import type { DraggableSyntheticListeners } from "@dnd-kit/core";
import { createContext } from "react";
interface Context {
attributes: Record<string, any>;
listeners: DraggableSyntheticListeners;
ref: (node: HTMLElement | null) => void;
}
const SortableItemContext = createContext<Context>({
attributes: {},
listeners: undefined,
ref() {},
});
export default SortableItemContext;

View File

@ -0,0 +1,24 @@
import type { PropsWithChildren } from "react";
import {
defaultDropAnimationSideEffects,
DragOverlay,
DropAnimation,
} from "@dnd-kit/core";
const dropAnimationConfig: DropAnimation = {
sideEffects: defaultDropAnimationSideEffects({
styles: {
active: {
opacity: "0.4",
},
},
}),
};
export function SortableOverlay({ children }: PropsWithChildren) {
return (
<DragOverlay dropAnimation={dropAnimationConfig}>
{children}
</DragOverlay>
);
}

View File

@ -0,0 +1,3 @@
import SortableDnd from "@/components/SortableDnd/SortableDnd";
export default SortableDnd;

5
src/types/BoardSchema.ts Normal file
View File

@ -0,0 +1,5 @@
export type BoardSchema = {
id: number;
name: string;
rank: string;
};

36
src/utils/lexorank.ts Normal file
View File

@ -0,0 +1,36 @@
import { LexoRank } from "lexorank";
type LexorankSortable = {
rank: string;
};
export function compareByLexorank<T extends LexorankSortable>(
a: T,
b: T
): -1 | 1 | 0 {
if (a.rank < b.rank) {
return -1;
}
if (a.rank > b.rank) {
return 1;
}
return 0;
}
export function sortByLexorank<T extends LexorankSortable>(items: T[]): T[] {
return items.sort(compareByLexorank);
}
export function getNewLexorank(
left?: LexoRank | null,
right?: LexoRank | null
): LexoRank {
if (right) {
if (left) return left?.between(right);
return right.genPrev();
}
if (left) {
return left.genNext();
}
return LexoRank.middle();
}