Skip to content

Commit 0768252

Browse files
feat: custom context menu (right click) (#316)
* fix/context menu implementation * fix the position of menu * fix: improve context menu positioning --------- Co-authored-by: Bryan Lundberg <bryanlundberg@outlook.com>
1 parent 0ccadf7 commit 0768252

File tree

4 files changed

+187
-90
lines changed

4 files changed

+187
-90
lines changed

src/app/[locale]/solves/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ export default function SolvesPage() {
7676
</ButtonsSection>
7777
</div>
7878
</SolveFilters>
79-
<SolvesArea displaySolves={displaySolves} />
79+
<SolvesArea displaySolves={displaySolves} currentTab={currentTab} />
8080
<ModalSolve currentTab={currentTab} />
8181
</OverallContainer>
8282
{isOpenMoveModal && (

src/components/solves/ContextMenu.tsx

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import { Solve } from "@/interfaces/Solve";
2+
import { SolveTab } from "@/interfaces/types/SolveTabs";
3+
import formatTime from "@/lib/formatTime";
4+
import moveSolve from "@/lib/moveSolve";
5+
import updateSolve from "@/lib/updateSolve";
6+
import { useSolvesStore } from "@/store/SolvesStore";
7+
import { useTimerStore } from "@/store/timerStore";
8+
import {
9+
ArchiveBoxArrowDownIcon,
10+
DocumentDuplicateIcon,
11+
TrashIcon,
12+
} from "@heroicons/react/24/outline";
13+
import { AnimatePresence, motion } from "framer-motion";
14+
import { useTranslations } from "next-intl";
15+
import React from "react";
16+
import { twMerge } from "tailwind-merge";
17+
18+
interface ContextMenuProps extends React.HTMLAttributes<HTMLDivElement> {
19+
currentTab: SolveTab;
20+
submenuRef: React.RefObject<HTMLDivElement>;
21+
solve: Solve | null;
22+
setShowOptions?: () => void;
23+
}
24+
25+
export default function ContextMenu({
26+
currentTab,
27+
submenuRef,
28+
solve,
29+
className,
30+
setShowOptions,
31+
...rest
32+
}: ContextMenuProps) {
33+
const t = useTranslations("Index.SolvesPage");
34+
const { setStatus } = useSolvesStore();
35+
const { selectedCube, mergeUpdateSelectedCube, cubes } = useTimerStore();
36+
37+
const handleMove = async (solve: Solve, currentTab: SolveTab) => {
38+
if (!selectedCube) return;
39+
const updatedCube = await moveSolve({
40+
solve,
41+
selectedCube,
42+
type: currentTab,
43+
});
44+
mergeUpdateSelectedCube(updatedCube, cubes);
45+
setStatus(false);
46+
if (typeof setShowOptions === "function") {
47+
setShowOptions();
48+
}
49+
};
50+
51+
const handleDelete = async (solve: Solve) => {
52+
if (!selectedCube) return;
53+
const updatedCube = await updateSolve({
54+
selectedCube: selectedCube,
55+
solveId: solve.id,
56+
type: "DELETE",
57+
});
58+
mergeUpdateSelectedCube(updatedCube, cubes);
59+
setStatus(false);
60+
if (typeof setShowOptions === "function") {
61+
setShowOptions();
62+
}
63+
};
64+
65+
const handleCopyToClipboard = async (text: string) => {
66+
if ("clipboard" in navigator) {
67+
await navigator.clipboard.writeText(text);
68+
}
69+
setStatus(false);
70+
if (typeof setShowOptions === "function") {
71+
setShowOptions();
72+
}
73+
};
74+
75+
return (
76+
<>
77+
<AnimatePresence>
78+
{solve && (
79+
<motion.div
80+
initial={{ y: 0, scale: 0.9, opacity: 0 }}
81+
animate={{ y: 0, scale: 1, opacity: 1 }}
82+
exit={{ x: 0, scale: 0.9, opacity: 0 }}
83+
ref={submenuRef}
84+
className={twMerge(
85+
"absolute w-full flex flex-col gap-3 py-2 mt-1 bg-white rounded-md text-xs text-black",
86+
className
87+
)}
88+
>
89+
<div
90+
className="flex items-center gap-1 py-1 transition duration-200 ps-2 hover:text-neutral-500 hover:cursor-pointer"
91+
onClick={() =>
92+
currentTab === "Session"
93+
? handleMove(solve, "Session")
94+
: handleMove(solve, "All")
95+
}
96+
>
97+
<div className="w-4 h-4">
98+
<ArchiveBoxArrowDownIcon className="w-4 h-4 fill-none stroke-black" />
99+
</div>
100+
<div>
101+
{currentTab === "Session" ? t("archive") : t("unarchive")}
102+
</div>
103+
</div>
104+
<div
105+
className="flex items-center gap-1 py-1 transition duration-200 ps-2 hover:text-neutral-500 hover:cursor-pointer"
106+
onClick={() =>
107+
handleCopyToClipboard(
108+
`[${formatTime(solve.time)}s] - ${solve.scramble}`
109+
)
110+
}
111+
>
112+
<div className="w-4 h-4">
113+
<DocumentDuplicateIcon className="w-4 h-4 fill-none stroke-black" />
114+
</div>
115+
<div>{t("copy")}</div>
116+
</div>
117+
<div
118+
className="flex items-center gap-1 py-1 transition duration-200 ps-2 hover:text-neutral-500 hover:cursor-pointer"
119+
onClick={() => handleDelete(solve)}
120+
>
121+
<div className="w-4 h-4">
122+
<TrashIcon className="w-4 h-4 fill-none stroke-black" />
123+
</div>
124+
<div>{t("remove")}</div>
125+
</div>
126+
</motion.div>
127+
)}
128+
</AnimatePresence>
129+
</>
130+
);
131+
}

src/components/solves/ModalSolve.tsx

Lines changed: 9 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import updateSolve from "@/lib/updateSolve";
22
import { useSolvesStore } from "@/store/SolvesStore";
33
import { useTimerStore } from "@/store/timerStore";
44
import formatTime from "@/lib/formatTime";
5-
import moveSolve from "@/lib/moveSolve";
65
import { ScrambleDisplay } from "@/components/scramble-display/index";
76
import useEscape from "@/hooks/useEscape";
87
import { format } from "date-fns";
@@ -13,20 +12,18 @@ import { Solve } from "@/interfaces/Solve";
1312
import { SolveTab } from "@/interfaces/types/SolveTabs";
1413
import { useTranslations } from "next-intl";
1514
import {
16-
ArchiveBoxArrowDownIcon,
1715
CalendarDaysIcon,
1816
ChevronDownIcon,
1917
ChevronUpIcon,
2018
CubeTransparentIcon,
21-
DocumentDuplicateIcon,
2219
EllipsisHorizontalIcon,
2320
StarIcon,
24-
TrashIcon,
2521
} from "@heroicons/react/24/solid";
2622
import {
2723
StarIcon as StarIconO,
2824
ChatBubbleBottomCenterIcon as ChatBubbleBottomCenterIconO,
2925
} from "@heroicons/react/24/outline";
26+
import ContextMenu from "./ContextMenu";
3027

3128
export default function ModalSolve({ currentTab }: { currentTab: SolveTab }) {
3229
const [showOptions, setShowOptions] = useState<boolean>(false);
@@ -45,28 +42,6 @@ export default function ModalSolve({ currentTab }: { currentTab: SolveTab }) {
4542

4643
useEscape(() => setStatus(false));
4744

48-
const handleMove = async (solve: Solve, currentTab: SolveTab) => {
49-
if (!selectedCube) return;
50-
const updatedCube = await moveSolve({
51-
solve,
52-
selectedCube,
53-
type: currentTab,
54-
});
55-
mergeUpdateSelectedCube(updatedCube, cubes);
56-
setStatus(false);
57-
};
58-
59-
const handleDelete = async (solve: Solve) => {
60-
if (!selectedCube) return;
61-
const updatedCube = await updateSolve({
62-
selectedCube: selectedCube,
63-
solveId: solve.id,
64-
type: "DELETE",
65-
});
66-
mergeUpdateSelectedCube(updatedCube, cubes);
67-
setStatus(false);
68-
};
69-
7045
const handlePlusTwo = async (solve: Solve) => {
7146
if (!selectedCube) return;
7247
const updatedCube = await updateSolve({
@@ -101,13 +76,6 @@ export default function ModalSolve({ currentTab }: { currentTab: SolveTab }) {
10176
setStatus(false);
10277
};
10378

104-
const handleCopyToClipboard = async (text: string) => {
105-
if ("clipboard" in navigator) {
106-
await navigator.clipboard.writeText(text);
107-
}
108-
setStatus(false);
109-
};
110-
11179
return (
11280
<>
11381
<AnimatePresence>
@@ -230,57 +198,14 @@ export default function ModalSolve({ currentTab }: { currentTab: SolveTab }) {
230198
</div>
231199
</div>
232200
{/* options menu */}
233-
<AnimatePresence>
234-
{showOptions && (
235-
<motion.div
236-
initial={{ y: 0, scale: 0.9, opacity: 0 }}
237-
animate={{ y: 0, scale: 1, opacity: 1 }}
238-
exit={{ x: 0, scale: 0.9, opacity: 0 }}
239-
ref={submenuRef}
240-
className="absolute flex flex-col w-32 gap-3 py-2 mt-1 bg-white rounded-md"
241-
>
242-
<div
243-
className="flex items-center gap-1 py-1 transition duration-200 ps-2 hover:text-neutral-500 hover:cursor-pointer"
244-
onClick={() =>
245-
currentTab === "Session"
246-
? handleMove(solve, "Session")
247-
: handleMove(solve, "All")
248-
}
249-
>
250-
<div className="w-4 h-4">
251-
<ArchiveBoxArrowDownIcon className="w-4 h-4 fill-none stroke-black" />
252-
</div>
253-
<div>
254-
{currentTab === "Session"
255-
? t("archive")
256-
: t("unarchive")}
257-
</div>
258-
</div>
259-
<div
260-
className="flex items-center gap-1 py-1 transition duration-200 ps-2 hover:text-neutral-500 hover:cursor-pointer"
261-
onClick={() =>
262-
handleCopyToClipboard(
263-
`[${formatTime(solve.time)}s] - ${solve.scramble}`
264-
)
265-
}
266-
>
267-
<div className="w-4 h-4">
268-
<DocumentDuplicateIcon className="w-4 h-4 fill-none stroke-black" />
269-
</div>
270-
<div>{t("copy")}</div>
271-
</div>
272-
<div
273-
className="flex items-center gap-1 py-1 transition duration-200 ps-2 hover:text-neutral-500 hover:cursor-pointer"
274-
onClick={() => handleDelete(solve)}
275-
>
276-
<div className="w-4 h-4">
277-
<TrashIcon className="w-4 h-4 fill-none stroke-black" />
278-
</div>
279-
<div>{t("remove")}</div>
280-
</div>
281-
</motion.div>
282-
)}
283-
</AnimatePresence>
201+
{showOptions && (
202+
<ContextMenu
203+
submenuRef={submenuRef}
204+
currentTab={currentTab}
205+
solve={solve}
206+
className="max-w-32"
207+
/>
208+
)}
284209
</motion.div>
285210
</div>
286211
) : null}

src/components/solves/SolvesArea.tsx

Lines changed: 46 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,19 +6,31 @@ import { useSolvesStore } from "@/store/SolvesStore";
66
import formatTime from "@/lib/formatTime";
77
import formatDate from "@/lib/formatDate";
88
import { useTranslations } from "next-intl";
9+
910
import {
1011
ChatBubbleBottomCenterTextIcon,
1112
StarIcon,
1213
} from "@heroicons/react/24/solid";
14+
import { SolveTab } from "@/interfaces/types/SolveTabs";
15+
import ContextMenu from "./ContextMenu";
16+
import { useRef, useState } from "react";
17+
import useClickOutside from "@/hooks/useClickOutside";
1318

1419
interface SolvesArea {
1520
displaySolves: Solve[] | null;
21+
currentTab: SolveTab;
1622
}
1723

18-
export function SolvesArea({ displaySolves }: SolvesArea) {
24+
export function SolvesArea({ displaySolves, currentTab }: SolvesArea) {
1925
const { selectedCube } = useTimerStore();
2026
const t = useTranslations("Index.SolvesPage");
21-
const { setStatus, setSolve } = useSolvesStore();
27+
const { setStatus, solve, setSolve } = useSolvesStore();
28+
const submenuRef = useRef<HTMLDivElement | null>(null);
29+
const [showOptions, setShowOptions] = useState<boolean>(false);
30+
31+
useClickOutside(submenuRef, () => {
32+
setShowOptions(false), setSolve(null);
33+
});
2234

2335
if (!selectedCube) {
2436
return (
@@ -29,23 +41,38 @@ export function SolvesArea({ displaySolves }: SolvesArea) {
2941
if (!displaySolves || displaySolves.length === 0) {
3042
return <EmptySolves message={t("no-solves")} icon="no-solves" />;
3143
}
44+
const handleContextMenu = (
45+
event: React.MouseEvent<HTMLDivElement, MouseEvent>,
46+
index: number
47+
) => {
48+
event.preventDefault();
49+
setShowOptions(true);
50+
setSolve(displaySolves[index]);
51+
};
3252

3353
return (
3454
<VirtualizedGrid
3555
itemCount={displaySolves.length}
3656
rowHeight={60}
3757
cellWidth={150}
38-
className="grid w-full grid-cols-3 gap-3 px-3 py-3 overflow-y-auto sm:grid-cols-4 md:grid-cols-6 xl:grid-cols-6 mx-auto overflow-x-hidden"
58+
className="p-3 pb-[70dvh]"
3959
gridGap={10}
40-
gridHeight={"minmax(60px, 100%)"}
4160
>
4261
{(index) => (
4362
<div
4463
onClick={() => {
4564
setSolve(displaySolves[index]);
4665
setStatus(true);
4766
}}
48-
className="relative grow flex items-center justify-center w-auto p-1 text-lg font-medium text-center transition duration-200 rounded-md cursor-pointer z-1 h-14 light:bg-neutral-100 light:shadow-sm light:shadow-neutral-400 light:hover:bg-neutral-200 light:text-zinc-800 dark:bg-zinc-900 dark:hover:bg-zinc-800 dark:shadow-sm dark:text-neutral-200"
67+
onContextMenu={(event) => handleContextMenu(event, index)}
68+
className={`relative grow flex items-center justify-center w-auto p-1 text-lg font-medium text-center transition duration-200 rounded-md cursor-pointer z-1 h-14
69+
light:bg-neutral-100 light:shadow-sm light:shadow-neutral-400 light:hover:bg-neutral-200 light:text-zinc-800
70+
dark:bg-zinc-900 dark:hover:bg-zinc-800 dark:shadow-sm dark:text-neutral-200
71+
${
72+
displaySolves[index] === solve && solve !== null
73+
? "border border-neutral-600"
74+
: "border-none"
75+
}`}
4976
>
5077
<div className="tracking-wider">
5178
<span className="text-md">
@@ -72,6 +99,20 @@ export function SolvesArea({ displaySolves }: SolvesArea) {
7299
<ChatBubbleBottomCenterTextIcon className="w-4 h-4" />
73100
</div>
74101
)}
102+
103+
{showOptions && displaySolves[index] === solve && (
104+
<div className="absolute z-50 top-14 left-0 w-full">
105+
<ContextMenu
106+
currentTab={currentTab}
107+
solve={solve}
108+
submenuRef={submenuRef}
109+
className="border border-neutral-300"
110+
setShowOptions={() => {
111+
setShowOptions((status) => !status);
112+
}}
113+
/>
114+
</div>
115+
)}
75116
</div>
76117
)}
77118
</VirtualizedGrid>

0 commit comments

Comments
 (0)