mirror of
https://github.com/jakejarvis/jarv.is.git
synced 2026-04-17 10:28:46 -04:00
fix: image comparison component weirdness
This commit is contained in:
@@ -1,40 +1,11 @@
|
||||
/* eslint-disable jsx-a11y/alt-text, @next/next/no-img-element */
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect, Children } from "react";
|
||||
import { Children } from "react";
|
||||
import { getImageProps } from "next/image";
|
||||
import { ReactCompareSlider, ReactCompareSliderImage } from "react-compare-slider";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChevronsLeftRightIcon } from "lucide-react";
|
||||
|
||||
const ImageDiff = ({ children, className }: { children: React.ReactElement[]; className?: string }) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [sliderPosition, setSliderPosition] = useState(50);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
// Add event listeners to handle dragging outside the component
|
||||
useEffect(() => {
|
||||
const handleMouseUpGlobal = () => {
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
const handleMouseMoveGlobal = (e: MouseEvent) => {
|
||||
if (!isDragging || !containerRef.current) return;
|
||||
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const x = Math.max(0, Math.min(e.clientX - rect.left, rect.width));
|
||||
const percentage = (x / rect.width) * 100;
|
||||
setSliderPosition(percentage);
|
||||
};
|
||||
|
||||
document.addEventListener("mouseup", handleMouseUpGlobal);
|
||||
document.addEventListener("mousemove", handleMouseMoveGlobal);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mouseup", handleMouseUpGlobal);
|
||||
document.removeEventListener("mousemove", handleMouseMoveGlobal);
|
||||
};
|
||||
}, [isDragging]);
|
||||
|
||||
// Extract the two image children
|
||||
const childrenArray = Children.toArray(children);
|
||||
if (childrenArray.length !== 2) {
|
||||
@@ -42,88 +13,27 @@ const ImageDiff = ({ children, className }: { children: React.ReactElement[]; cl
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get the original image source to extract dimensions for aspect ratio
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const beforeImageProps = getImageProps(children[0].props as any).props;
|
||||
const firstChildProps = children[0].props as any;
|
||||
const imageSrc = firstChildProps.src;
|
||||
const aspectRatio =
|
||||
typeof imageSrc === "object" && "width" in imageSrc && "height" in imageSrc
|
||||
? imageSrc.width / imageSrc.height
|
||||
: 16 / 9;
|
||||
|
||||
// Extract image props, stripping out MDX className (margins, etc.) that would break slider layout
|
||||
const beforeImageProps = getImageProps(firstChildProps).props;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const afterImageProps = getImageProps(children[1].props as any).props;
|
||||
|
||||
const handleMouseDown = () => {
|
||||
setIsDragging(true);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!isDragging || !containerRef.current) return;
|
||||
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const x = Math.max(0, Math.min(e.clientX - rect.left, rect.width));
|
||||
const percentage = (x / rect.width) * 100;
|
||||
setSliderPosition(percentage);
|
||||
};
|
||||
|
||||
const handleTouchMove = (e: React.TouchEvent<HTMLDivElement>) => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const touch = e.touches[0];
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const x = Math.max(0, Math.min(touch.clientX - rect.left, rect.width));
|
||||
const percentage = (x / rect.width) * 100;
|
||||
setSliderPosition(percentage);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
const step = 5;
|
||||
if (e.key === "ArrowLeft") {
|
||||
setSliderPosition((prev) => Math.max(0, prev - step));
|
||||
e.preventDefault();
|
||||
} else if (e.key === "ArrowRight") {
|
||||
setSliderPosition((prev) => Math.min(100, prev + step));
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
onMouseMove={handleMouseMove}
|
||||
onTouchMove={handleTouchMove}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={cn("relative isolate w-full max-w-full overflow-hidden select-none", className)}
|
||||
style={{ ["--slider-position" as string]: `${sliderPosition}%` }}
|
||||
role="slider"
|
||||
aria-label="Image comparison slider"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={sliderPosition}
|
||||
tabIndex={0}
|
||||
>
|
||||
{/* After image (full width, underneath) */}
|
||||
<div className="h-full w-full">
|
||||
<img {...afterImageProps} className="h-full w-full object-cover object-top-left" />
|
||||
</div>
|
||||
|
||||
{/* Before image (clipped with width based on slider position) */}
|
||||
<div className="absolute top-0 left-0 h-full w-[var(--slider-position)] overflow-hidden">
|
||||
<img {...beforeImageProps} className="h-full w-full object-cover object-top-left" />
|
||||
</div>
|
||||
|
||||
{/* Divider line */}
|
||||
<div className="bg-muted-foreground absolute top-0 bottom-0 left-[var(--slider-position)] w-1 -translate-x-1/2 drop-shadow-md" />
|
||||
|
||||
{/* Slider handle */}
|
||||
<div
|
||||
onMouseDown={handleMouseDown}
|
||||
onTouchStart={handleMouseDown}
|
||||
onTouchEnd={handleMouseUp}
|
||||
className="bg-muted absolute top-1/2 left-[var(--slider-position)] flex size-10 -translate-x-1/2 -translate-y-1/2 cursor-ew-resize touch-none items-center justify-center rounded-full border-2 drop-shadow-md"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<ChevronsLeftRightIcon className="text-foreground/70 size-6" />
|
||||
</div>
|
||||
</div>
|
||||
<ReactCompareSlider
|
||||
className={cn("my-8 w-full max-w-full overflow-hidden rounded-sm", className)}
|
||||
style={{ aspectRatio }}
|
||||
itemOne={<ReactCompareSliderImage {...beforeImageProps} className="size-full object-cover" />}
|
||||
itemTwo={<ReactCompareSliderImage {...afterImageProps} className="size-full object-cover" />}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -5,20 +5,21 @@ import siteConfig from "@/lib/config/site";
|
||||
const Footer = () => {
|
||||
return (
|
||||
<footer className="text-muted-foreground mt-8 w-full py-6 text-center text-[13px] leading-loose">
|
||||
Content <Link href="/license">licensed under {siteConfig.license}</Link>,{" "}
|
||||
<Link href="/previously" title="Previously on...">
|
||||
{siteConfig.copyrightYearStart}
|
||||
</Link>{" "}
|
||||
– 2026.{" "}
|
||||
All content is licensed under{" "}
|
||||
<Link href="/license" className="underline underline-offset-4">
|
||||
{siteConfig.license}
|
||||
</Link>
|
||||
. Code is{" "}
|
||||
<a
|
||||
href={`https://github.com/${env.NEXT_PUBLIC_GITHUB_REPO}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title="View Source on GitHub"
|
||||
className="font-medium underline underline-offset-4"
|
||||
className="underline underline-offset-4"
|
||||
>
|
||||
View source.
|
||||
open source
|
||||
</a>
|
||||
.
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -62,19 +62,19 @@ const PostStats = ({ slug }: { slug: string }) => {
|
||||
return (
|
||||
<>
|
||||
{viewCount > 0 && (
|
||||
<Badge variant="secondary" className="tabular-nums">
|
||||
<EyeIcon className="text-foreground/85" aria-hidden="true" />
|
||||
<Badge variant="secondary" className="text-foreground/80 gap-[5px] tabular-nums">
|
||||
<EyeIcon className="text-foreground/65" aria-hidden="true" />
|
||||
{numberFormatter.format(viewCount)}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{commentCount > 0 && (
|
||||
<Badge variant="secondary" className="tabular-nums" asChild>
|
||||
<Badge variant="secondary" className="text-foreground/80 gap-[5px] tabular-nums" asChild>
|
||||
<Link
|
||||
href={`/${slug}#comments`}
|
||||
title={`${numberFormatter.format(commentCount)} ${commentCount === 1 ? "comment" : "comments"}`}
|
||||
>
|
||||
<MessagesSquareIcon className="text-foreground/85" aria-hidden="true" />
|
||||
<MessagesSquareIcon className="text-foreground/65" aria-hidden="true" />
|
||||
{numberFormatter.format(commentCount)}
|
||||
</Link>
|
||||
</Badge>
|
||||
|
||||
@@ -4,7 +4,6 @@ const siteConfig = {
|
||||
description:
|
||||
"Hi there! I'm a frontend web developer based in Boston, Massachusetts specializing in TypeScript, React, Next.js, and other JavaScript frameworks.",
|
||||
license: "CC-BY-4.0",
|
||||
copyrightYearStart: 2001,
|
||||
} as const;
|
||||
|
||||
export default siteConfig;
|
||||
|
||||
@@ -66,7 +66,8 @@
|
||||
"next-themes": "^0.4.6",
|
||||
"pg": "^8.17.2",
|
||||
"react": "19.2.4",
|
||||
"react-activity-calendar": "^3.1.0",
|
||||
"react-activity-calendar": "^3.1.1",
|
||||
"react-compare-slider": "^3.1.0",
|
||||
"react-countup": "^6.5.3",
|
||||
"react-dom": "19.2.4",
|
||||
"react-lite-youtube-embed": "^3.3.3",
|
||||
@@ -97,7 +98,7 @@
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"unified": "^11.0.5",
|
||||
"use-debounce": "^10.0.4",
|
||||
"use-debounce": "^10.1.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
23
pnpm-lock.yaml
generated
23
pnpm-lock.yaml
generated
@@ -147,6 +147,9 @@ importers:
|
||||
specifier: 19.2.4
|
||||
version: 19.2.4
|
||||
react-activity-calendar:
|
||||
specifier: ^3.1.1
|
||||
version: 3.1.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
react-compare-slider:
|
||||
specifier: ^3.1.0
|
||||
version: 3.1.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
react-countup:
|
||||
@@ -240,7 +243,7 @@ importers:
|
||||
specifier: ^11.0.5
|
||||
version: 11.0.5
|
||||
use-debounce:
|
||||
specifier: ^10.0.4
|
||||
specifier: ^10.1.0
|
||||
version: 10.1.0(react@19.2.4)
|
||||
zod:
|
||||
specifier: ^4.3.6
|
||||
@@ -4375,11 +4378,18 @@ packages:
|
||||
queue-microtask@1.2.3:
|
||||
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
|
||||
|
||||
react-activity-calendar@3.1.0:
|
||||
resolution: {integrity: sha512-KUkzV9Jz60ueKiIDVkWaDO5HqyMyWUrKtPJ1bS8DfOOlQ4maZDSreGN6A+jHl1z1R2JrBn7O/VJtiQ/aT30KHw==}
|
||||
react-activity-calendar@3.1.1:
|
||||
resolution: {integrity: sha512-YvHNS4anlW3Xy0fxOU2ZTWrJkOt0ALxX8IfgDRg6jr/vgYsbh//4djf2c7CPhYNROh+5oYZzR0EJaPbrFUj2tA==}
|
||||
peerDependencies:
|
||||
react: ^18.0.0 || ^19.0.0
|
||||
|
||||
react-compare-slider@3.1.0:
|
||||
resolution: {integrity: sha512-TQVbZYmYyTIeKRmQciVXCmUwHjTThQTON7GfWfzMAOInRRG9tCiQnVXnCUd5DJ5l3Hngh4IEzOb9TG82gjoEhQ==}
|
||||
engines: {node: '>=16.9.0'}
|
||||
peerDependencies:
|
||||
react: '>=16.8'
|
||||
react-dom: '>=16.8'
|
||||
|
||||
react-countup@6.5.3:
|
||||
resolution: {integrity: sha512-udnqVQitxC7QWADSPDOxVWULkLvKUWrDapn5i53HE4DPRVgs+Y5rr4bo25qEl8jSh+0l2cToJgGMx+clxPM3+w==}
|
||||
peerDependencies:
|
||||
@@ -9427,7 +9437,7 @@ snapshots:
|
||||
|
||||
queue-microtask@1.2.3: {}
|
||||
|
||||
react-activity-calendar@3.1.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||
react-activity-calendar@3.1.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||
dependencies:
|
||||
'@floating-ui/react': 0.27.17(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
date-fns: 4.1.0
|
||||
@@ -9435,6 +9445,11 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- react-dom
|
||||
|
||||
react-compare-slider@3.1.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||
dependencies:
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
|
||||
react-countup@6.5.3(react@19.2.4):
|
||||
dependencies:
|
||||
countup.js: 2.9.0
|
||||
|
||||
Reference in New Issue
Block a user