@@ -596,7 +606,7 @@ export default function Home(): ReactNode {
-
{
+ {
setSelecting(pressed);
setSelectedProjects([]);
}}>
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 8fe1ddb..df61045 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -6,6 +6,7 @@ import ThemeProvider from "./theme-provider";
import Analytics from "./analytics";
import { Toaster } from "@/components/ui/sonner";
import PWAHead from "./pwa-head";
+import { SwipeToDeleteContextProvider } from "@/components/swipe-to-delete";
const geist = Geist({
variable: "--font-geist",
@@ -43,7 +44,9 @@ export default async function RootLayout({
- {children}
+
+ {children}
+
diff --git a/src/components/swipe-to-delete/SwipeToDelete.tsx b/src/components/swipe-to-delete/SwipeToDelete.tsx
new file mode 100644
index 0000000..c3f8809
--- /dev/null
+++ b/src/components/swipe-to-delete/SwipeToDelete.tsx
@@ -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>;
+}
+
+const SwipeToDeleteContext = createContext(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 (
+
+ {children}
+
+ );
+};
+
+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(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 (
+
+
+
+
+
+ {children}
+
+
+ );
+};
\ No newline at end of file
diff --git a/src/components/swipe-to-delete/index.ts b/src/components/swipe-to-delete/index.ts
new file mode 100644
index 0000000..ff2dd59
--- /dev/null
+++ b/src/components/swipe-to-delete/index.ts
@@ -0,0 +1,7 @@
+import { SwipeToDelete, SwipeToDeleteContextProvider, useSwipeToDeleteContext } from "./SwipeToDelete";
+
+export default SwipeToDelete;
+export {
+ SwipeToDeleteContextProvider,
+ useSwipeToDeleteContext
+};
\ No newline at end of file
diff --git a/src/components/swipe-to-delete/styles.css b/src/components/swipe-to-delete/styles.css
new file mode 100644
index 0000000..f4a74ea
--- /dev/null
+++ b/src/components/swipe-to-delete/styles.css
@@ -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;
+}
\ No newline at end of file