implemented swiping to delete projects (like on ios)

This commit is contained in:
corgifist 2025-08-02 03:43:12 +03:00
parent f05545b570
commit f04761345e
5 changed files with 323 additions and 8 deletions

View File

@ -36,6 +36,7 @@ import Link from "next/link";
import StickyTopContainer from "@/components/sticky-top-container"; import StickyTopContainer from "@/components/sticky-top-container";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import truncate from "@/lib/truncate"; import truncate from "@/lib/truncate";
import SwipeToDelete, { useSwipeToDeleteContext } from "@/components/swipe-to-delete";
type SortingType = "byCreationDate" type SortingType = "byCreationDate"
| "byEditDate" | "byEditDate"
@ -274,9 +275,9 @@ const ProjectDropdown = ({
{selected ? <Grid2X2XIcon /> : <Grid2X2CheckIcon />}{selected ? "Deselect" : "Select"} {selected ? <Grid2X2XIcon /> : <Grid2X2CheckIcon />}{selected ? "Deselect" : "Select"}
</Button> </Button>
</SheetClose> </SheetClose>
<Button variant="ghost" className="justify-start w-full" onClick={handleEdit}> <Button variant="ghost" className="justify-start w-full" onClick={handleEdit}>
<EditIcon /> Edit <EditIcon /> Edit
</Button> </Button>
<SheetClose asChild> <SheetClose asChild>
<Button variant="ghost" className="justify-start w-full" onClick={handleDuplicate}> <Button variant="ghost" className="justify-start w-full" onClick={handleDuplicate}>
<CopyIcon /> Duplicate <CopyIcon /> Duplicate
@ -402,10 +403,10 @@ const ProjectContainer = ({
project: Project project: Project
}): ReactNode => { }): ReactNode => {
const { selecting, selectedProjects, setSelectedProjects } = useSelectContext(); const { selecting, selectedProjects, setSelectedProjects } = useSelectContext();
const isMobile = useIsMobile();
const date = new Date(project.editDate); const date = new Date(project.editDate);
// TODO: data-selectable is a really dirty way of deciding whether to trigger selection or not should be reworked in the future
const handleCheck = (e: React.MouseEvent<HTMLDivElement | HTMLAnchorElement>) => { const handleCheck = (e: React.MouseEvent<HTMLDivElement | HTMLAnchorElement>) => {
const index = selectedProjects.indexOf(project.uuid); const index = selectedProjects.indexOf(project.uuid);
if (index >= 0) { if (index >= 0) {
@ -444,11 +445,20 @@ const ProjectContainer = ({
</AspectRatio> </AspectRatio>
); );
return projectComponent; return isMobile ? (
<div className="h-full w-[100% + 5 * var(--spacing)] -mx-5 origin-center">
<SwipeToDelete onDelete={() => deleteProject(project.uuid)} transitionDuration={200} height={210.38} deleteThreshold={45}>
<div className="w-screen bg-background px-5">
{projectComponent}
</div>
</SwipeToDelete>
</div>
) : projectComponent;
}; };
export default function Home(): ReactNode { export default function Home(): ReactNode {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const { deleting } = useSwipeToDeleteContext();
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [debouncedSearch] = useDebounce(search, 300); const [debouncedSearch] = useDebounce(search, 300);
const [selecting, setSelecting] = useState(false); const [selecting, setSelecting] = useState(false);
@ -502,7 +512,7 @@ export default function Home(): ReactNode {
return ( return (
<SelectContext.Provider value={context}> <SelectContext.Provider value={context}>
<div className="flex flex-col justify-between h-screen"> <div className="flex flex-col justify-between h-screen overscroll-x-none overflow-x-hidden" style={{ touchAction: deleting ? "none" : "pan-y pinch-zoom" }}>
<div className="p-5"> <div className="p-5">
<div className="flex flex-row items-center gap-2"> <div className="flex flex-row items-center gap-2">
<StaticSidebarTrigger /> <StaticSidebarTrigger />
@ -596,7 +606,7 @@ export default function Home(): ReactNode {
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</div> </div>
<Toggle variant="default" pressed={selecting} onPressedChange={(pressed: boolean) => { <Toggle variant="default" pressed={selecting} onPressedChange={(pressed: boolean) => {
setSelecting(pressed); setSelecting(pressed);
setSelectedProjects([]); setSelectedProjects([]);
}}> }}>

View File

@ -6,6 +6,7 @@ import ThemeProvider from "./theme-provider";
import Analytics from "./analytics"; import Analytics from "./analytics";
import { Toaster } from "@/components/ui/sonner"; import { Toaster } from "@/components/ui/sonner";
import PWAHead from "./pwa-head"; import PWAHead from "./pwa-head";
import { SwipeToDeleteContextProvider } from "@/components/swipe-to-delete";
const geist = Geist({ const geist = Geist({
variable: "--font-geist", variable: "--font-geist",
@ -43,7 +44,9 @@ export default async function RootLayout({
</head> </head>
<body className={`${geist.variable} ${geistMono.variable} antialiased`}> <body className={`${geist.variable} ${geistMono.variable} antialiased`}>
<ThemeProvider> <ThemeProvider>
{children} <SwipeToDeleteContextProvider>
{children}
</SwipeToDeleteContextProvider>
<Toaster/> <Toaster/>
</ThemeProvider> </ThemeProvider>
</body> </body>

View File

@ -0,0 +1,218 @@
'use client';
import React, { createContext, Dispatch, ReactNode, SetStateAction, useCallback, useContext, useEffect, useRef, useState } from "react";
import "./styles.css";
export interface Props {
onDelete: Function;
onDeleteConfirm?: Function;
deleteComponent?: React.ReactNode;
disabled?: boolean;
height?: number;
transitionDuration?: number;
deleteWidth?: number;
deleteThreshold?: number;
showDeleteAction?: boolean;
deleteColor?: string;
deleteText?: string;
className?: string;
id?: string;
rtl?: boolean;
children?: React.ReactNode;
}
const cursorPosition = (event: any) => {
if (event?.touches?.[0]?.clientX) return event.touches[0].clientX;
if (event?.clientX) return event?.clientX;
if (event?.nativeEvent?.touches?.[0]?.clientX) return event.nativeEvent.touches[0].clientX;
return event?.nativeEvent?.clientX;
};
interface SwipeToDeleteContextProps {
deleting: boolean;
setDeleting: Dispatch<SetStateAction<boolean>>;
}
const SwipeToDeleteContext = createContext<SwipeToDeleteContextProps | undefined>(undefined);
export const useSwipeToDeleteContext = (): SwipeToDeleteContextProps => {
const data = useContext(SwipeToDeleteContext);
if (!data) throw new Error("SwipeToDeleteContext is not defined");
return data;
};
export const SwipeToDeleteContextProvider = ({
children
}: {
children: ReactNode
}) => {
const [deleting, setDeleting] = useState(false);
return (
<SwipeToDeleteContext.Provider value={{deleting: deleting, setDeleting: setDeleting}}>
{children}
</SwipeToDeleteContext.Provider>
);
};
export const SwipeToDelete = ({
onDelete,
onDeleteConfirm,
deleteComponent,
disabled = false,
height = 50,
transitionDuration = 250,
deleteWidth = 75,
deleteThreshold = 75,
showDeleteAction = true,
deleteColor = "rgba(252, 58, 48, 1.00)",
deleteText = "Delete",
className = "",
id = "",
rtl = false,
children,
}: Props) => {
const { deleting, setDeleting } = useSwipeToDeleteContext();
const [touching, setTouching] = useState(false);
const [translate, setTranslate] = useState(0);
const [internalDeleting, setInternalDeleting] = useState(false);
const startTouchPosition = useRef(0);
const initTranslate = useRef(0);
const container = useRef<HTMLDivElement>(null);
const containerWidth: number = container.current?.getBoundingClientRect().width || 0;
const deleteWithoutConfirmThreshold: number = containerWidth * (deleteThreshold / 100);
const onStart = useCallback(
(event: React.TouchEvent | React.MouseEvent) => {
if (disabled) return;
if (touching) return;
startTouchPosition.current = cursorPosition(event);
initTranslate.current = translate;
setTouching(true);
if (!deleting) setDeleting(true);
},
[disabled, touching, translate, deleting, setDeleting]
);
useEffect(() => {
const root = container.current;
root?.style.setProperty("--rstdiHeight", height + "px");
root?.style.setProperty("--rstdiTransitionDuration", transitionDuration + "ms");
root?.style.setProperty("--rstdiIsRtl", rtl ? "1" : "-1");
root?.style.setProperty("--rstdiDeleteColor", deleteColor);
root?.style.setProperty("--rstdiDeleteWidth", deleteWidth + "px");
}, [deleteColor, deleteWidth, height, rtl, transitionDuration, deleting]);
useEffect(() => {
const root = container.current;
root?.style.setProperty("--rstdiTranslate", translate * (rtl ? -1 : 1) + "px");
const shiftDelete = -translate >= deleteWithoutConfirmThreshold;
root?.style.setProperty(
`--rstdiButtonMargin${rtl ? "Right" : "Left"}`,
(shiftDelete ? containerWidth + translate : containerWidth - deleteWidth) + "px"
);
}, [translate, deleteWidth, containerWidth, rtl, deleteWithoutConfirmThreshold]);
const onMove = useCallback(
function (event: TouchEvent | MouseEvent) {
if (!touching) return;
if (!rtl && cursorPosition(event) > startTouchPosition.current - initTranslate.current)
return setTranslate(0);
if (rtl && cursorPosition(event) < startTouchPosition.current - initTranslate.current)
return setTranslate(0);
setTranslate(cursorPosition(event) - startTouchPosition.current + initTranslate.current);
if (!deleting) setDeleting(true);
},
[rtl, touching, deleting, setDeleting]
);
const onMouseMove = useCallback(
function (event: MouseEvent): any {
onMove(event);
},
[onMove]
);
const onTouchMove = useCallback(
function (event: TouchEvent): any {
onMove(event);
},
[onMove]
);
const onDeleteConfirmed = useCallback(() => {
setInternalDeleting(() => true);
window.setTimeout(onDelete, transitionDuration);
}, [onDelete, transitionDuration]);
const onDeleteCancel = useCallback(() => {
setTouching(() => false);
setTranslate(() => 0);
setInternalDeleting(() => false);
startTouchPosition.current = 0;
initTranslate.current = 0;
}, [onDelete, transitionDuration]);
const onDeleteClick = useCallback(() => {
if (onDeleteConfirm) {
onDeleteConfirm(onDeleteConfirmed, onDeleteCancel);
} else {
onDeleteConfirmed();
}
}, [onDeleteConfirm, onDeleteConfirmed]);
const onMouseUp = useCallback(
function () {
startTouchPosition.current = 0;
const acceptableMove = -deleteWidth * 0.7;
const showDelete = showDeleteAction ? (rtl ? -1 : 1) * translate < acceptableMove : false;
const notShowDelete = showDeleteAction ? (rtl ? -1 : 1) * translate >= acceptableMove : true;
const deleteWithoutConfirm = (rtl ? 1 : -1) * translate >= deleteWithoutConfirmThreshold;
if (deleteWithoutConfirm) {
setTranslate(() => -containerWidth);
} else if (notShowDelete) {
setTranslate(() => 0);
} else if (showDelete && !deleteWithoutConfirm) {
setTranslate(() => (rtl ? 1 : -1) * deleteWidth);
}
setTouching(() => false);
if (deleting) setDeleting(false);
if (deleteWithoutConfirm) onDeleteClick();
},
[containerWidth, deleteWidth, deleteWithoutConfirmThreshold, onDeleteClick, rtl, translate, deleting, setDeleting]
);
useEffect(() => {
if (touching) {
window.addEventListener("mousemove", onMouseMove);
window.addEventListener("touchmove", onTouchMove);
window.addEventListener("mouseup", onMouseUp);
window.addEventListener("touchend", onMouseUp);
} else {
window.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("touchmove", onTouchMove);
window.removeEventListener("mouseup", onMouseUp);
window.removeEventListener("touchend", onMouseUp);
}
return () => {
window.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("touchmove", onTouchMove);
window.removeEventListener("mouseup", onMouseUp);
window.removeEventListener("touchend", onMouseUp);
};
}, [onMouseMove, onMouseUp, onTouchMove, touching]);
return (
<div id={id} className={`rstdi${internalDeleting ? " deleting" : ""} ${className}`} ref={container}>
<div className={`delete${internalDeleting ? " deleting" : ""}`}>
<button onClick={onDeleteClick}>{deleteComponent ? deleteComponent : deleteText}</button>
</div>
<div
className={`content${internalDeleting ? " deleting" : ""}${!touching ? " transition" : ""}`}
onMouseDown={onStart}
onTouchStart={onStart}>
{children}
</div>
</div>
);
};

View File

@ -0,0 +1,7 @@
import { SwipeToDelete, SwipeToDeleteContextProvider, useSwipeToDeleteContext } from "./SwipeToDelete";
export default SwipeToDelete;
export {
SwipeToDeleteContextProvider,
useSwipeToDeleteContext
};

View File

@ -0,0 +1,77 @@
/* rstdi = react-swipe-to-delete-ios */
.rstdi {
--rstdiHeight: 30px;
--rstdiTransitionDuration: 250ms;
--rstdiTranslate: 0px;
--rstdiIsRtl: 0;
--rstdiDeleteColor: rgba(252, 58, 48, 1);
--rstdiDeleteWidth: 75px;
--rstdiButtonMarginRight: 0px;
--rstdiButtonMarginLeft: 0px;
width: auto;
position: relative;
box-sizing: border-box;
overflow: hidden;
height: 100%;
max-height: 100%;
}
.rstdi *,
.rstdi *:before,
.rstdi *:after {
box-sizing: border-box;
}
.rstdi.deleting {
transition: all var(--rstdiTransitionDuration) ease-out;
opacity: 0;
max-height: 0;
}
.rstdi .content {
height: 100%;
width: auto;
position: relative;
transform: translateX(var(--rstdiTranslate));
}
.rstdi .content.transition {
transition: all var(--rstdiTransitionDuration) ease-out;
}
.rstdi .content.deleting {
height: 0%;
width: auto;
position: relative;
transform: translateX(-100%);
transition: all var(--rstdiTransitionDuration) ease-out;
}
.rstdi .delete {
position: absolute;
right: 0;
margin-top: 1px;
height: calc(100% - 3px);
width: 100%;
top: 0;
background: var(--rstdiDeleteColor);
font-weight: 400;
display: inline-flex;
justify-content: flex-start;
align-items: center;
}
.rstdi .delete button {
width: var(--rstdiDeleteWidth);
transition: transform var(--rstdiTransitionDuration) ease-out;
transform: translateX(var(--rstdiButtonMarginLeft));
text-align: center;
height: 100%;
background: transparent;
border: none;
color: white;
font-size: 1rem;
cursor: pointer;
}