diff --git a/package.json b/package.json index 20883fe..1650784 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,8 @@ "generate-client": "openapi --input http://127.0.0.1:8000/openapi.json --output ./src/client --client axios --useOptions --useUnionTypes --exportSchemas true && prettier --write ./src/client/**/*.ts" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", "@mantine/core": "8.1.2", "@mantine/form": "^8.1.3", "@mantine/hooks": "8.1.2", @@ -23,6 +25,7 @@ "classnames": "^2.5.1", "framer-motion": "^12.23.7", "i18n-iso-countries": "^7.14.0", + "lexorank": "^1.0.5", "libphonenumber-js": "^1.12.10", "next": "15.3.3", "openapi-typescript-codegen": "^0.29.0", diff --git a/src/app/deals/components/Board/Board.tsx b/src/app/deals/components/Board/Board.tsx new file mode 100644 index 0000000..bb61c0d --- /dev/null +++ b/src/app/deals/components/Board/Board.tsx @@ -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 = ({ board }) => { + return {board.name}; +}; + +export default Board; diff --git a/src/app/deals/components/Boards/Boards.tsx b/src/app/deals/components/Boards/Boards.tsx new file mode 100644 index 0000000..d1205cd --- /dev/null +++ b/src/app/deals/components/Boards/Boards.tsx @@ -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 ; + }; + + const onDragEnd = (itemId: number, newLexorank: string) => {}; + + return ( + + + + ); +}; + +export default Boards; diff --git a/src/app/deals/hooks/useBoards.ts b/src/app/deals/hooks/useBoards.ts new file mode 100644 index 0000000..c8b69c8 --- /dev/null +++ b/src/app/deals/hooks/useBoards.ts @@ -0,0 +1,23 @@ +import { useEffect, useState } from "react"; +import { BoardSchema } from "@/types/BoardSchema"; + +const useBoards = () => { + const [boards, setBoards] = useState([]); + + 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; diff --git a/src/app/deals/page.tsx b/src/app/deals/page.tsx new file mode 100644 index 0000000..bb4fe41 --- /dev/null +++ b/src/app/deals/page.tsx @@ -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 ( + + + + + + ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 07bee77..c148324 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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 ( - <> - - - - ); + redirect("/deals"); } diff --git a/src/components/Draggable/Draggable.tsx b/src/components/Draggable/Draggable.tsx new file mode 100644 index 0000000..a12b442 --- /dev/null +++ b/src/components/Draggable/Draggable.tsx @@ -0,0 +1,30 @@ +import React, { FC, ReactNode } from "react"; +import { useDraggable } from "@dnd-kit/core"; + +type Props = { + children: ReactNode; +}; + +const Draggable: FC = props => { + const { attributes, listeners, setNodeRef, transform } = useDraggable({ + id: "draggable", + }); + + const style = transform + ? { + transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`, + } + : undefined; + + return ( +
+ {props.children} +
+ ); +}; + +export default Draggable; diff --git a/src/components/Droppable/Droppable.tsx b/src/components/Droppable/Droppable.tsx new file mode 100644 index 0000000..a68c787 --- /dev/null +++ b/src/components/Droppable/Droppable.tsx @@ -0,0 +1,25 @@ +import React, { FC, ReactNode } from "react"; +import { useDroppable } from "@dnd-kit/core"; + +type Props = { + children: ReactNode; +} + +const Droppable: FC = ({ children }) => { + const { isOver, setNodeRef } = useDroppable({ + id: "droppable", + }); + const style = { + color: isOver ? "green" : undefined, + }; + + return ( +
+ {children} +
+ ); +} + +export default Droppable; diff --git a/src/components/PageBlock/PageBlock.module.css b/src/components/PageBlock/PageBlock.module.css new file mode 100644 index 0000000..56cf0b8 --- /dev/null +++ b/src/components/PageBlock/PageBlock.module.css @@ -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; + } +} diff --git a/src/components/PageBlock/PageBlock.tsx b/src/components/PageBlock/PageBlock.tsx new file mode 100644 index 0000000..18f0e15 --- /dev/null +++ b/src/components/PageBlock/PageBlock.tsx @@ -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 = ({ + children, + style, + fullHeight = false, + fullHeightFixed = false, + noBorderRadius = false, + fullScreenMobile = false, +}) => { + return ( +
+ {children} +
+ ); +}; +export default PageBlock; diff --git a/src/components/PageContainer/PageContainer.module.css b/src/components/PageContainer/PageContainer.module.css new file mode 100644 index 0000000..a2cca97 --- /dev/null +++ b/src/components/PageContainer/PageContainer.module.css @@ -0,0 +1,7 @@ +.container { + display: flex; + flex-direction: column; + gap: rem(10); + min-height: 86vh; + background-color: transparent; +} diff --git a/src/components/PageContainer/PageContainer.tsx b/src/components/PageContainer/PageContainer.tsx new file mode 100644 index 0000000..ad88f49 --- /dev/null +++ b/src/components/PageContainer/PageContainer.tsx @@ -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 = ({ children, style, center }) => { + return ( +
+ {children} +
+ ); +}; + +export default PageContainer; diff --git a/src/components/SortableDnd/DragHandle.tsx b/src/components/SortableDnd/DragHandle.tsx new file mode 100644 index 0000000..a5491f8 --- /dev/null +++ b/src/components/SortableDnd/DragHandle.tsx @@ -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 ( +
+ {children} +
+ ); +}; + +export default DragHandle; diff --git a/src/components/SortableDnd/SortableDnd.tsx b/src/components/SortableDnd/SortableDnd.tsx new file mode 100644 index 0000000..62eec41 --- /dev/null +++ b/src/components/SortableDnd/SortableDnd.tsx @@ -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 = { + initialItems: T[]; + renderItem: (item: T) => ReactNode; + onDragEnd: (itemId: number, newLexorank: string) => void; + rowStyle?: CSSProperties; + itemStyle?: CSSProperties; +}; + +const SortableDnd = ({ + initialItems, + renderItem, + onDragEnd, + rowStyle, + itemStyle, +}: Props) => { + const [active, setActive] = useState(null); + const [items, setItems] = useState([]); + 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 ( + setActive(active)} + onDragEnd={onDragEndLocal} + onDragCancel={() => setActive(null)}> + + + {items.map((item, index) => ( + + {renderItem(item)} + + ))} + + + +
+ {activeItem ? renderItem(activeItem) : null} +
+
+
+ ); +}; + +export default SortableDnd; diff --git a/src/components/SortableDnd/SortableItem.tsx b/src/components/SortableDnd/SortableItem.tsx new file mode 100644 index 0000000..9208746 --- /dev/null +++ b/src/components/SortableDnd/SortableItem.tsx @@ -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) => { + 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 ( + +
+ {children} +
+
+ ); +}; diff --git a/src/components/SortableDnd/SortableItemContext.tsx b/src/components/SortableDnd/SortableItemContext.tsx new file mode 100644 index 0000000..21279d4 --- /dev/null +++ b/src/components/SortableDnd/SortableItemContext.tsx @@ -0,0 +1,16 @@ +import type { DraggableSyntheticListeners } from "@dnd-kit/core"; +import { createContext } from "react"; + +interface Context { + attributes: Record; + listeners: DraggableSyntheticListeners; + ref: (node: HTMLElement | null) => void; +} + +const SortableItemContext = createContext({ + attributes: {}, + listeners: undefined, + ref() {}, +}); + +export default SortableItemContext; diff --git a/src/components/SortableDnd/SortableOverlay.tsx b/src/components/SortableDnd/SortableOverlay.tsx new file mode 100644 index 0000000..1021528 --- /dev/null +++ b/src/components/SortableDnd/SortableOverlay.tsx @@ -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 ( + + {children} + + ); +} diff --git a/src/components/SortableDnd/index.ts b/src/components/SortableDnd/index.ts new file mode 100644 index 0000000..262151e --- /dev/null +++ b/src/components/SortableDnd/index.ts @@ -0,0 +1,3 @@ +import SortableDnd from "@/components/SortableDnd/SortableDnd"; + +export default SortableDnd; diff --git a/src/types/BoardSchema.ts b/src/types/BoardSchema.ts new file mode 100644 index 0000000..021f1fc --- /dev/null +++ b/src/types/BoardSchema.ts @@ -0,0 +1,5 @@ +export type BoardSchema = { + id: number; + name: string; + rank: string; +}; diff --git a/src/utils/lexorank.ts b/src/utils/lexorank.ts new file mode 100644 index 0000000..ac6b8bd --- /dev/null +++ b/src/utils/lexorank.ts @@ -0,0 +1,36 @@ +import { LexoRank } from "lexorank"; + +type LexorankSortable = { + rank: string; +}; + +export function compareByLexorank( + 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(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(); +} diff --git a/yarn.lock b/yarn.lock index 0a420e1..06a9c83 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1544,6 +1544,55 @@ __metadata: languageName: node linkType: hard +"@dnd-kit/accessibility@npm:^3.1.1": + version: 3.1.1 + resolution: "@dnd-kit/accessibility@npm:3.1.1" + dependencies: + tslib: "npm:^2.0.0" + peerDependencies: + react: ">=16.8.0" + checksum: 10c0/be0bf41716dc58f9386bc36906ec1ce72b7b42b6d1d0e631d347afe9bd8714a829bd6f58a346dd089b1519e93918ae2f94497411a61a4f5e4d9247c6cfd1fef8 + languageName: node + linkType: hard + +"@dnd-kit/core@npm:^6.3.1": + version: 6.3.1 + resolution: "@dnd-kit/core@npm:6.3.1" + dependencies: + "@dnd-kit/accessibility": "npm:^3.1.1" + "@dnd-kit/utilities": "npm:^3.2.2" + tslib: "npm:^2.0.0" + peerDependencies: + react: ">=16.8.0" + react-dom: ">=16.8.0" + checksum: 10c0/196db95d81096d9dc248983533eab91ba83591770fa5c894b1ac776f42af0d99522b3fd5bb3923411470e4733fcfa103e6ee17adc17b9b7eb54c7fbec5ff7c52 + languageName: node + linkType: hard + +"@dnd-kit/sortable@npm:^10.0.0": + version: 10.0.0 + resolution: "@dnd-kit/sortable@npm:10.0.0" + dependencies: + "@dnd-kit/utilities": "npm:^3.2.2" + tslib: "npm:^2.0.0" + peerDependencies: + "@dnd-kit/core": ^6.3.0 + react: ">=16.8.0" + checksum: 10c0/37ee48bc6789fb512dc0e4c374a96d19abe5b2b76dc34856a5883aaa96c3297891b94cc77bbc409e074dcce70967ebcb9feb40cd9abadb8716fc280b4c7f99af + languageName: node + linkType: hard + +"@dnd-kit/utilities@npm:^3.2.2": + version: 3.2.2 + resolution: "@dnd-kit/utilities@npm:3.2.2" + dependencies: + tslib: "npm:^2.0.0" + peerDependencies: + react: ">=16.8.0" + checksum: 10c0/9aa90526f3e3fd567b5acc1b625a63177b9e8d00e7e50b2bd0e08fa2bf4dba7e19529777e001fdb8f89a7ce69f30b190c8364d390212634e0afdfa8c395e85a0 + languageName: node + linkType: hard + "@dual-bundle/import-meta-resolve@npm:^4.1.0": version: 4.1.0 resolution: "@dual-bundle/import-meta-resolve@npm:4.1.0" @@ -5849,6 +5898,8 @@ __metadata: resolution: "crm-frontend@workspace:." dependencies: "@babel/core": "npm:^7.27.4" + "@dnd-kit/core": "npm:^6.3.1" + "@dnd-kit/sortable": "npm:^10.0.0" "@eslint/js": "npm:^9.29.0" "@ianvs/prettier-plugin-sort-imports": "npm:^4.4.2" "@mantine/core": "npm:8.1.2" @@ -5885,6 +5936,7 @@ __metadata: i18n-iso-countries: "npm:^7.14.0" jest: "npm:^30.0.0" jest-environment-jsdom: "npm:^30.0.0" + lexorank: "npm:^1.0.5" libphonenumber-js: "npm:^1.12.10" next: "npm:15.3.3" openapi-typescript-codegen: "npm:^0.29.0" @@ -9404,6 +9456,13 @@ __metadata: languageName: node linkType: hard +"lexorank@npm:^1.0.5": + version: 1.0.5 + resolution: "lexorank@npm:1.0.5" + checksum: 10c0/76b4f4d9a837850a2f3e6d3c32dfd8700aa3bad9fb94921adf6ea0d8241c07ba89f62f106eaf54af0fac85c0f8653ef07da1489dd81556d2edd4f5e5fb4045cf + languageName: node + linkType: hard + "libphonenumber-js@npm:^1.12.10": version: 1.12.10 resolution: "libphonenumber-js@npm:1.12.10"