diff --git a/apps/web/src/components/common/before-after-slider.tsx b/apps/web/src/components/common/before-after-slider.tsx index 5fae8b9c..61ae9c11 100644 --- a/apps/web/src/components/common/before-after-slider.tsx +++ b/apps/web/src/components/common/before-after-slider.tsx @@ -1,6 +1,8 @@ import { type PointerEvent, useCallback, useEffect, useRef, useState } from "react"; import type { BgPreviewState } from "@/components/common/image-viewer"; import { useTranslation } from "@/contexts/i18n-context"; +import { useMobile } from "@/hooks/use-mobile"; +import { cn } from "@/lib/utils"; interface BeforeAfterSliderProps { /** URL or data URL of original image. */ @@ -40,25 +42,35 @@ export function BeforeAfterSlider({ bgPreview, }: BeforeAfterSliderProps) { const { t } = useTranslation(); + const isMobile = useMobile(); const containerRef = useRef(null); const [position, setPosition] = useState(initialPosition); // percentage 0-100 const [isDragging, setIsDragging] = useState(false); - const updatePosition = useCallback((clientX: number) => { - const container = containerRef.current; - if (!container) return; - const rect = container.getBoundingClientRect(); - const x = clientX - rect.left; - const pct = Math.max(0, Math.min(100, (x / rect.width) * 100)); - setPosition(pct); - }, []); + const updatePosition = useCallback( + (clientX: number, clientY: number) => { + const container = containerRef.current; + if (!container) return; + const rect = container.getBoundingClientRect(); + if (isMobile) { + const y = clientY - rect.top; + const pct = Math.max(0, Math.min(100, (y / rect.height) * 100)); + setPosition(pct); + } else { + const x = clientX - rect.left; + const pct = Math.max(0, Math.min(100, (x / rect.width) * 100)); + setPosition(pct); + } + }, + [isMobile], + ); const handlePointerDown = useCallback( (e: PointerEvent) => { e.preventDefault(); setIsDragging(true); (e.target as HTMLElement).setPointerCapture(e.pointerId); - updatePosition(e.clientX); + updatePosition(e.clientX, e.clientY); }, [updatePosition], ); @@ -66,7 +78,7 @@ export function BeforeAfterSlider({ const handlePointerMove = useCallback( (e: PointerEvent) => { if (!isDragging) return; - updatePosition(e.clientX); + updatePosition(e.clientX, e.clientY); }, [isDragging, updatePosition], ); @@ -128,26 +140,36 @@ export function BeforeAfterSlider({ aria-valuemax={100} tabIndex={0} className="relative w-full overflow-hidden rounded-lg border border-border select-none touch-none" - style={{ cursor: isDragging ? "ew-resize" : "default" }} + style={{ cursor: isDragging ? (isMobile ? "ns-resize" : "ew-resize") : "default" }} onPointerDown={handlePointerDown} onPointerMove={handlePointerMove} onPointerUp={handlePointerUp} onPointerCancel={handlePointerUp} onKeyDown={(e) => { - if (e.key === "ArrowLeft") setPosition((p) => Math.max(0, p - 1)); - else if (e.key === "ArrowRight") setPosition((p) => Math.min(100, p + 1)); + if (isMobile) { + if (e.key === "ArrowUp") setPosition((p) => Math.max(0, p - 1)); + else if (e.key === "ArrowDown") setPosition((p) => Math.min(100, p + 1)); + } else { + if (e.key === "ArrowLeft") setPosition((p) => Math.max(0, p - 1)); + else if (e.key === "ArrowRight") setPosition((p) => Math.min(100, p + 1)); + } }} > {/* Before image (full width, bottom layer) */} Original {/* After panel (clipped, top layer) */} -
+
{/* Background layers constrained to the image content area */} {contentBox && (
{/* Divider line */} -
- {/* Handle grip */} -
- + {isMobile ? ( +
+ {/* Handle grip */} +
+ +
-
+ ) : ( +
+ {/* Handle grip */} +
+ +
+
+ )} {/* Labels */} -
+
{t.comparison.original}
-
+
{t.comparison.processed}
diff --git a/apps/web/src/components/common/bottom-sheet.tsx b/apps/web/src/components/common/bottom-sheet.tsx new file mode 100644 index 00000000..beff292a --- /dev/null +++ b/apps/web/src/components/common/bottom-sheet.tsx @@ -0,0 +1,108 @@ +import { useDrag } from "@use-gesture/react"; +import { X } from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; + +interface BottomSheetProps { + open: boolean; + onClose: () => void; + title?: string; + children: React.ReactNode; + maxHeight?: string; +} + +export function BottomSheet({ + open, + onClose, + title, + children, + maxHeight = "70dvh", +}: BottomSheetProps) { + const sheetRef = useRef(null); + const [translateY, setTranslateY] = useState(0); + + // Close on Escape key + useEffect(() => { + if (!open) return; + const handler = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [open, onClose]); + + // Reset translation when opened + useEffect(() => { + if (open) setTranslateY(0); + }, [open]); + + const handleDismiss = useCallback(() => { + setTranslateY(0); + onClose(); + }, [onClose]); + + const bind = useDrag( + ({ movement: [, my], last, cancel }) => { + // Only allow downward dragging + if (my < 0) { + setTranslateY(0); + return; + } + if (last) { + if (my > 100) { + handleDismiss(); + } else { + setTranslateY(0); + } + return; + } + setTranslateY(my); + }, + { axis: "y", filterTaps: true }, + ); + + if (!open) return null; + + return ( + <> + {/* Backdrop */} +