1
mirror of https://github.com/jakejarvis/jarv.is.git synced 2025-10-30 03:16:03 -04:00

publish tailwind post with new image diff component

This commit is contained in:
2025-05-08 15:14:37 -04:00
parent eab84bfee9
commit afcced7707
12 changed files with 225 additions and 30 deletions

View File

@@ -53,7 +53,9 @@
@theme inline {
--font-sans: var(--font-geist-sans);
--font-sans--font-feature-settings: "rlig" 1, "calt" 0;
--font-mono: var(--font-geist-mono);
--font-mono--font-feature-settings: "liga" 0;
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
@@ -154,9 +156,6 @@
body {
@apply bg-background text-foreground;
font-synthesis-weight: none;
font-variant-ligatures: none;
text-rendering: optimizeLegibility;
}
::-webkit-scrollbar {

View File

@@ -69,9 +69,7 @@ const RootLayout = ({ children }: Readonly<{ children: React.ReactNode }>) => {
<div className="mx-auto w-full max-w-4xl px-5">
<Header className="mt-4 mb-6 w-full" />
<main id={SKIP_NAV_ID} tabIndex={-1}>
{children}
</main>
<main id={SKIP_NAV_ID}>{children}</main>
<Footer className="my-6 w-full" />
</div>

View File

@@ -119,16 +119,18 @@ const Page = async ({ params }: { params: Promise<{ slug: string }> }) => {
<span>Improve This Post</span>
</Link>
<div className="flex min-w-14 flex-nowrap items-center gap-x-2 whitespace-nowrap">
<EyeIcon className="inline size-4 shrink-0" />
<Suspense
// when this loads, the component will count up from zero to the actual number of hits, so we can simply
// show a zero here as a "loading indicator"
fallback={<span>0</span>}
>
<ViewCounter slug={`${POSTS_DIR}/${frontmatter!.slug}`} />
</Suspense>
</div>
{env.NEXT_PUBLIC_ENV === "production" && (
<div className="flex min-w-14 flex-nowrap items-center gap-x-2 whitespace-nowrap">
<EyeIcon className="inline size-4 shrink-0" />
<Suspense
// when this loads, the component will count up from zero to the actual number of hits, so we can simply
// show a zero here as a "loading indicator"
fallback={<span>0</span>}
>
<ViewCounter slug={`${POSTS_DIR}/${frontmatter!.slug}`} />
</Suspense>
</div>
)}
</div>
<h1 className="mt-2 mb-3 text-3xl/10 font-bold md:text-4xl/12">
@@ -141,11 +143,15 @@ const Page = async ({ params }: { params: Promise<{ slug: string }> }) => {
<MDXContent />
{!frontmatter!.noComments && (
<div id="comments" className="border-ring mt-8 min-h-36 border-t-2 pt-8">
<Suspense fallback={<Loading boxes={3} width={40} className="mx-auto my-8 block" />}>
<Comments title={frontmatter!.title} />
</Suspense>
{env.NEXT_PUBLIC_ENV === "production" && (
<div id="comments" className="mt-8 min-h-36 border-t-2 pt-8">
{!frontmatter!.noComments ? (
<Suspense fallback={<Loading boxes={3} width={40} className="mx-auto my-8 block" />}>
<Comments title={frontmatter!.title} />
</Suspense>
) : (
<div className="text-foreground/85 text-center font-medium">Comments are disabled for this post.</div>
)}
</div>
)}
</>

133
components/image-diff.tsx Normal file
View File

@@ -0,0 +1,133 @@
/* eslint-disable jsx-a11y/alt-text, @next/next/no-img-element */
"use client";
import { ReactElement, Children, useState, useRef, useEffect } from "react";
import { getImageProps } from "next/image";
import { cn } from "@/lib/utils";
import { ChevronsLeftRightIcon } from "lucide-react";
const ImageDiff = ({ children, className }: { children: 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) {
console.error("ImageDiff must have exactly two children (before and after images)");
return null;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const beforeImageProps = getImageProps(children[0].props as any).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)}
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 overflow-hidden" style={{ width: `${sliderPosition}%` }}>
<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 w-1 -translate-x-1/2 drop-shadow-md"
style={{ left: `${sliderPosition}%` }}
/>
{/* Slider handle */}
<div
onMouseDown={handleMouseDown}
onTouchStart={handleMouseDown}
onTouchEnd={handleMouseUp}
className="bg-muted absolute top-1/2 flex h-10 w-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"
style={{ left: `${sliderPosition}%` }}
aria-hidden="true"
>
<ChevronsLeftRightIcon className="text-foreground/70 size-6" />
</div>
</div>
);
};
export default ImageDiff;

View File

@@ -92,6 +92,16 @@ export const env = createEnv({
`http://localhost:${process.env.PORT || 3000}`
),
/**
* Optional. Set this to override the best guess as to the environment the site is running in.
*/
NEXT_PUBLIC_ENV: v.fallback(v.picklist(["production", "development"]), () =>
(process.env.VERCEL && process.env.VERCEL_ENV === "production") ||
(process.env.NETLIFY && process.env.CONTEXT === "production")
? "production"
: "development"
),
/**
* Optional. Enables comments on blog posts via GitHub discussions.
*
@@ -146,6 +156,7 @@ export const env = createEnv({
},
experimental__runtimeEnv: {
NEXT_PUBLIC_BASE_URL: process.env.NEXT_PUBLIC_BASE_URL,
NEXT_PUBLIC_ENV: process.env.NEXT_PUBLIC_ENV,
NEXT_PUBLIC_GISCUS_CATEGORY_ID: process.env.NEXT_PUBLIC_GISCUS_CATEGORY_ID,
NEXT_PUBLIC_GISCUS_REPO_ID: process.env.NEXT_PUBLIC_GISCUS_REPO_ID,
NEXT_PUBLIC_GITHUB_REPO: process.env.NEXT_PUBLIC_GITHUB_REPO,

View File

@@ -3,6 +3,7 @@ import Link from "@/components/link";
import CodeBlock from "@/components/code-block";
import HeadingAnchor from "@/components/heading-anchor";
import Video from "@/components/video";
import ImageDiff from "./components/image-diff";
import Tweet from "@/components/third-party/tweet";
import YouTube from "@/components/third-party/youtube";
import Gist from "@/components/third-party/gist";
@@ -115,8 +116,9 @@ export const useMDXComponents = (components: MDXComponents): MDXComponents => {
<hr className={cn("mx-auto my-6 w-11/12 border-t-2 [&+*]:mt-0", className)} {...rest} />
),
// third-party embeds:
// react components and embeds:
Video,
ImageDiff,
Tweet,
YouTube,
Gist,

Binary file not shown.

After

Width:  |  Height:  |  Size: 412 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 398 KiB

View File

@@ -0,0 +1,46 @@
---
title: I Was Wrong About Tailwind CSS
date: "2025-05-08 14:33:10-0400"
description: I never thought I'd say this, but Tailwind CSS made my frontend code cleaner, simpler, and even enjoyable.
tags:
- CSS
- Tailwind
- Frontend
- React
- AI
- Meta
- Confession
image: ./tailwind.png
---
I have a confession. I've **hated** [Tailwind CSS](https://tailwindcss.com/). For years.
Every time a Tailwind ["hit piece"](https://dev.to/kerryboyko/tailwindcss-adds-complexity-does-nothing-3hpn) went viral, I quietly enjoyed the warm, fuzzy feeling of confirmation bias.
I've also never been the biggest advocate of anything AI (beyond providing a smarter autocomplete), but that's a post for another day. I do, however, appreciate what companies like [Vercel](https://vercel.com/home) are doing with tools like [**v0**](https://v0.dev/), or [Stackblitz](https://stackblitz.com/) with [**Bolt.new**](https://bolt.new/) (or especially new startups like [**Lovable**](https://lovable.dev/)). They're embarrassingly bad at generating anything close to full-stack right now, but are incredible at prototyping small drop-in React components like a [copy button](https://v0.dev/chat/custom-copy-button-6giyaNd8fmn) or even [beautiful charts](https://v0.dev/chat/next-js-charts-eygG8PAxk8z).
So what's the conundrum? These tools _love_ Tailwind (and its offspring — [shadcn/ui](https://ui.shadcn.com/) being the poster child). And I wanted to figure out why.
<Tweet id="1842643838281961934" />
As I usually do with any piece of software I want to learn more about, I used [this website](https://github.com/jakejarvis/jarv.is) as a testing ground. I started with a few one-liner components, like my styled [`<Blockquote>`](https://github.com/jakejarvis/jarv.is/tree/v6/components/Blockquote) for Markdown content. I quickly stumbled upon dozens of CSS to Tailwind "conversion" tools, but just as quickly realized that actually digging into Tailwind would be much easier. This lead to my first discovery:
🏆 **[Tailwind's documentation](https://tailwindcss.com/docs/styling-with-utility-classes) is some of the best I've ever seen.**
Seriously, it's so good that even if you're not using Tailwind, their docs clearly explain and visually demonstrate all of the most [nonsensical](https://tailwindcss.com/docs/flex-basis) CSS "features" I've always struggled to master.
After converting a few more components, I started to feel better and better each time I deleted a [`.module.css`](https://github.com/jakejarvis/jarv.is/blob/v6/components/Blockquote/Blockquote.module.css) file. What I thought would make my code infinitely more complicated and messy was actually making it simpler and cleaner. Each component no longer needed its own folder. Colors and spacing were becoming more consistent. Pixels I struggled to line up previously were now coincidentally falling in line. Best of all, **I didn't have to name anything anymore!** 🎉
<ImageDiff>
![Developer Tools window, pre-Tailwind](./before.png)![Developer Tools window, post-Tailwind](./after.png)
</ImageDiff>
Don't get me wrong, I still think the syntax Tailwind forces you to write is an abomination. **But honestly, so was my CSS.**
Maybe that's on me, or maybe not, but my primary reason to hate on Tailwind for years — _"it makes my HTML/JSX ugly and design doesn't belong sprinkled throughout a markup language"_ — just flew out the window either way. Sure, I tried to make my CSS consistent and logical, making tons of variables for colors and sizes and border radii. But that wasn't nearly as comforting as being certain that `w-12` will **always** be twice the width of `w-6` no matter how badly I mess things up.
And on top of all of the AI tools mentioned above being Tailwind experts, the [IDE support](https://tailwindcss.com/docs/editor-setup) is also excellent. One click to install the official [IntelliSense extension](https://marketplace.visualstudio.com/items?itemName=bradlc.vscode-tailwindcss) for VS Code, and suddenly everywhere I wrote <span style={{ color: "#38bdf8" }}>`text-sky-400`</span> throughout my code had a lovely little light blue square next to it. The official [Prettier extension](https://github.com/tailwindlabs/prettier-plugin-tailwindcss) ensures the order of class names doesn't cause unexpected specificity problems from a rule four layers up overriding a rule you thought you were currently looking at — historically my biggest painpoint of CSS by far.
All of these tools together actually made the [process](https://github.com/jakejarvis/jarv.is/pull/2387) of revamping this site oddly fun. It shined a spotlight on a lot of issues I had no idea were there — especially by forcing me to think ["mobile-first"](https://tailwindcss.com/docs/responsive-design#working-mobile-first) — and gave me an opportunity to put a new coat of paint on a design I haven't made major changes to [since my last blog post](/notes/hugo-to-nextjs)...three years ago.
So, if you're a closeted Tailwind hater like I was, try it out. I don't think I'll ever love Tailwind, to be honest. But I certainly like it a lot more than I ever liked CSS.

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

View File

@@ -84,7 +84,7 @@
"@jakejarvis/eslint-config": "^4.0.7",
"@tailwindcss/postcss": "^4.1.5",
"@types/mdx": "^2.0.13",
"@types/node": "^22.15.16",
"@types/node": "^22.15.17",
"@types/react": "19.1.3",
"@types/react-dom": "19.1.3",
"babel-plugin-react-compiler": "19.0.0-beta-af1b7da-20250417",

14
pnpm-lock.yaml generated
View File

@@ -202,8 +202,8 @@ importers:
specifier: ^2.0.13
version: 2.0.13
'@types/node':
specifier: ^22.15.16
version: 22.15.16
specifier: ^22.15.17
version: 22.15.17
'@types/react':
specifier: 19.1.3
version: 19.1.3
@@ -1230,8 +1230,8 @@ packages:
'@types/nlcst@2.0.3':
resolution: {integrity: sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA==}
'@types/node@22.15.16':
resolution: {integrity: sha512-3pr+KjwpVujqWqOKT8mNR+rd09FqhBLwg+5L/4t0cNYBzm/yEiYGCxWttjaPBsLtAo+WFNoXzGJfolM1JuRXoA==}
'@types/node@22.15.17':
resolution: {integrity: sha512-wIX2aSZL5FE+MR0JlvF87BNVrtFWf6AE6rxSE9X7OwnVvoyCQjpzSRJ+M87se/4QCkCiebQAqrJ0y6fwIyi7nw==}
'@types/react-dom@19.1.3':
resolution: {integrity: sha512-rJXC08OG0h3W6wDMFxQrZF00Kq6qQvw0djHRdzl3U5DnIERz0MRce3WVc7IS6JYBwtaP/DwYtRRjVlvivNveKg==}
@@ -5200,7 +5200,7 @@ snapshots:
'@types/concat-stream@2.0.3':
dependencies:
'@types/node': 22.15.16
'@types/node': 22.15.17
'@types/debug@4.1.12':
dependencies:
@@ -5236,7 +5236,7 @@ snapshots:
dependencies:
'@types/unist': 3.0.3
'@types/node@22.15.16':
'@types/node@22.15.17':
dependencies:
undici-types: 6.21.0
@@ -8770,7 +8770,7 @@ snapshots:
'@types/concat-stream': 2.0.3
'@types/debug': 4.1.12
'@types/is-empty': 1.2.3
'@types/node': 22.15.16
'@types/node': 22.15.17
'@types/unist': 3.0.3
concat-stream: 2.0.0
debug: 4.4.0