Files
SnapOtter/apps/web/src/components/common/before-after-slider.tsx
T
Siddharth Kumar Sah ce03aad10f feat: production Docker, Playwright tests, settings API, and bug fixes
- Add user management endpoints (register, list, delete, change password)
- Add API key management (create, list, delete)
- Add settings persistence endpoints (get, put)
- Wire settings dialog to real backend (People, API Keys, System, Security)
- Fix login auth flow (window.location.href for full reload)
- Fix download URLs returning 401 (make public since UUIDs are unguessable)
- Fix border tool shadowColor validation (accept 6-8 hex digits)
- Fix remove-bg alpha matting fallback (retry without on failure)
- Fix AI tool silent fallbacks (report errors instead of no-ops)
- Add checkerboard background to before/after slider for transparency
- Add progress bars to all AI tool components
- Add Playwright E2E test suite (131 tests across 9 test files)
- Rewrite Dockerfile for production (tsx runtime, pre-baked AI models)
- Add .dockerignore for faster builds
- Add proper accessible labels to login form
2026-03-22 19:28:57 +08:00

189 lines
6.2 KiB
TypeScript

import { useRef, useState, useCallback, useEffect, type PointerEvent } from "react";
interface BeforeAfterSliderProps {
/** URL or data URL of original image. */
beforeSrc: string;
/** URL or data URL of processed image. */
afterSrc: string;
/** Original file size in bytes. */
beforeSize?: number;
/** Processed file size in bytes. */
afterSize?: number;
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
}
/**
* Before/After image comparison slider.
*
* Shows two images overlapping with a draggable vertical divider.
* The "before" image is on the left, "after" on the right.
* Supports mouse and touch interaction via pointer events.
*/
export function BeforeAfterSlider({
beforeSrc,
afterSrc,
beforeSize,
afterSize,
}: BeforeAfterSliderProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [position, setPosition] = useState(50); // 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 handlePointerDown = useCallback(
(e: PointerEvent) => {
e.preventDefault();
setIsDragging(true);
(e.target as HTMLElement).setPointerCapture(e.pointerId);
updatePosition(e.clientX);
},
[updatePosition],
);
const handlePointerMove = useCallback(
(e: PointerEvent) => {
if (!isDragging) return;
updatePosition(e.clientX);
},
[isDragging, updatePosition],
);
const handlePointerUp = useCallback(() => {
setIsDragging(false);
}, []);
// Prevent default drag behavior on images
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const preventDrag = (e: Event) => e.preventDefault();
container.addEventListener("dragstart", preventDrag);
return () => container.removeEventListener("dragstart", preventDrag);
}, []);
const savingsPercent =
beforeSize && afterSize && beforeSize > 0
? ((1 - afterSize / beforeSize) * 100).toFixed(1)
: null;
return (
<div className="flex flex-col items-center gap-3 w-full max-w-2xl mx-auto">
{/* Slider container */}
<div
ref={containerRef}
className="relative w-full overflow-hidden rounded-lg border border-border select-none touch-none"
style={{ cursor: isDragging ? "ew-resize" : "default" }}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
>
{/* Before image (full width, bottom layer) */}
<img
src={beforeSrc}
alt="Original"
className="block w-full h-auto"
draggable={false}
/>
{/* After image (clipped, top layer) — checkerboard background shows transparency */}
<div
className="absolute inset-0"
style={{
clipPath: `inset(0 0 0 ${position}%)`,
backgroundImage: `linear-gradient(45deg, #ccc 25%, transparent 25%),
linear-gradient(-45deg, #ccc 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #ccc 75%),
linear-gradient(-45deg, transparent 75%, #ccc 75%)`,
backgroundSize: "16px 16px",
backgroundPosition: "0 0, 0 8px, 8px -8px, -8px 0px",
}}
>
<img
src={afterSrc}
alt="Processed"
className="w-full h-full object-contain"
draggable={false}
/>
</div>
{/* Divider line */}
<div
className="absolute top-0 bottom-0 w-0.5 bg-white/80 pointer-events-none"
style={{ left: `${position}%`, transform: "translateX(-50%)" }}
>
{/* Handle grip */}
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-8 h-8 rounded-full bg-white border-2 border-primary shadow-lg flex items-center justify-center pointer-events-none">
<svg
width="14"
height="14"
viewBox="0 0 14 14"
fill="none"
className="text-primary"
>
<path
d="M4 3L1 7L4 11"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M10 3L13 7L10 11"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</div>
</div>
{/* Labels */}
<div className="absolute top-2 left-2 px-2 py-0.5 rounded bg-black/50 text-white text-xs font-medium pointer-events-none">
Original
</div>
<div className="absolute top-2 right-2 px-2 py-0.5 rounded bg-black/50 text-white text-xs font-medium pointer-events-none">
Processed
</div>
</div>
{/* Size comparison badges */}
{beforeSize != null && afterSize != null && (
<div className="flex items-center gap-4 text-xs">
<span className="px-2 py-1 rounded bg-muted text-muted-foreground">
Original: {formatSize(beforeSize)}
</span>
<span className="px-2 py-1 rounded bg-primary/10 text-primary font-medium">
Processed: {formatSize(afterSize)}
{savingsPercent !== null && Number(savingsPercent) > 0 && (
<span className="ml-1">({savingsPercent}% smaller)</span>
)}
{savingsPercent !== null && Number(savingsPercent) < 0 && (
<span className="ml-1">
({Math.abs(Number(savingsPercent))}% larger)
</span>
)}
</span>
</div>
)}
</div>
);
}