Skip to content

Commit 0a4134d

Browse files
brianlovinclaude
andauthored
Add gallery view toggle for Good Websites (#2190)
Co-authored-by: Claude <[email protected]>
1 parent a18f319 commit 0a4134d

20 files changed

+381
-76
lines changed

bun.lock

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
"@aws-sdk/client-s3": "^3.975.0",
1818
"@base-ui/react": "^1.1.0",
1919
"@notionhq/client": "^5.4.0",
20+
"@paper-design/shaders-react": "^0.0.71",
2021
"@sparticuz/chromium": "^143.0.4",
2122
"@tanstack/react-virtual": "^3.13.18",
2223
"@types/dompurify": "^3.2.0",

schemas/GoodWebsitesSchema.json

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,13 @@
4848
"type": "rich_text",
4949
"rich_text": {}
5050
},
51+
"Capture Screenshot": {
52+
"id": "MQRi",
53+
"name": "Capture Screenshot",
54+
"description": null,
55+
"type": "button",
56+
"button": {}
57+
},
5158
"X": {
5259
"id": "Rw%5Ev",
5360
"name": "X",
@@ -77,6 +84,13 @@
7784
]
7885
}
7986
},
87+
"Preview Image Dark": {
88+
"id": "cn%5Do",
89+
"name": "Preview Image Dark",
90+
"description": null,
91+
"type": "url",
92+
"url": {}
93+
},
8094
"URL": {
8195
"id": "nBQE",
8296
"name": "URL",

schemas/StackSchema.json

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -30,13 +30,6 @@
3030
"type": "url",
3131
"url": {}
3232
},
33-
"Capture screenshot": {
34-
"id": "OE%60l",
35-
"name": "Capture screenshot",
36-
"description": null,
37-
"type": "button",
38-
"button": {}
39-
},
4033
"Created time": {
4134
"id": "RV%5E%5D",
4235
"name": "Created time",
@@ -51,6 +44,13 @@
5144
"type": "url",
5245
"url": {}
5346
},
47+
"Preview Image Dark": {
48+
"id": "VGAv",
49+
"name": "Preview Image Dark",
50+
"description": null,
51+
"type": "url",
52+
"url": {}
53+
},
5454
"URL": {
5555
"id": "YOu%3A",
5656
"name": "URL",
@@ -105,6 +105,13 @@
105105
"type": "date",
106106
"date": {}
107107
},
108+
"Capture Screenshot": {
109+
"id": "d%3AnZ",
110+
"name": "Capture Screenshot",
111+
"description": null,
112+
"type": "button",
113+
"button": {}
114+
},
108115
"Preview Error": {
109116
"id": "j%3COP",
110117
"name": "Preview Error",

schemas/TILSchema.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,13 @@
3030
"format": "number"
3131
}
3232
},
33+
"Created time": {
34+
"id": "sKAI",
35+
"name": "Created time",
36+
"description": null,
37+
"type": "created_time",
38+
"created_time": {}
39+
},
3340
"Title": {
3441
"id": "title",
3542
"name": "Title",

schemas/notionSchemas.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,13 @@ export const StackSchema = z.object({
88
Description: z.string().optional(),
99
Likes: z.number().optional(),
1010
Image: z.string().optional(),
11-
"Capture screenshot": z.any().optional(),
1211
"Created time": z.string().optional(),
1312
"Preview Image": z.string().optional(),
13+
"Preview Image Dark": z.string().optional(),
1414
URL: z.string().optional(),
1515
Platforms: z.array(z.enum(["Windows", "Web", "Physical", "macOS", "iOS"])).optional(),
1616
"Preview Updated": z.string().optional(),
17+
"Capture Screenshot": z.any().optional(),
1718
"Preview Error": z.string().optional(),
1819
"Process icon": z.any().optional(),
1920
Status: z.enum(["Inactive", "Active"]).optional(),
@@ -101,8 +102,10 @@ export const GoodWebsitesSchema = z.object({
101102
"Preview Status": z.enum(["Queued", "Processing", "Done", "Error"]).optional(),
102103
"Created time": z.string().optional(),
103104
"Preview Error": z.string().optional(),
105+
"Capture Screenshot": z.any().optional(),
104106
X: z.string().optional(),
105107
Tags: z.array(z.enum(["Personal site", "Company"])).optional(),
108+
"Preview Image Dark": z.string().optional(),
106109
URL: z.string().optional(),
107110
"Preview Image": z.string().optional(),
108111
"Preview Updated": z.string().optional(),
@@ -126,6 +129,7 @@ export const TILSchema = z.object({
126129
Published: z.string().optional(),
127130
"Short ID": z.string().optional(),
128131
Likes: z.number().optional(),
132+
"Created time": z.string().optional(),
129133
Title: z.string().optional(),
130134
});
131135

src/atoms/sitesViewMode.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { atomWithStorage } from "jotai/utils";
2+
3+
import type { ViewMode } from "@/components/good-websites/ViewToggle";
4+
5+
export const sitesViewModeAtom = atomWithStorage<ViewMode>("sites-view-mode", "list");
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
"use client";
2+
3+
import { AnimatePresence, motion } from "motion/react";
4+
import { useEffect, useState } from "react";
5+
6+
import { LikeButton } from "@/components/likes/LikeButton";
7+
import { PlaceholderShader } from "@/components/ui/PlaceholderShader";
8+
import type { GoodWebsiteItem } from "@/lib/goodWebsites";
9+
import { useLikes } from "@/lib/hooks/useLikes";
10+
import { imageCache } from "@/lib/imageCache";
11+
12+
interface GoodWebsiteGalleryItemProps {
13+
item: GoodWebsiteItem;
14+
}
15+
16+
const hoverAnimationProps = {
17+
initial: { opacity: 0, y: 4, scale: 0.96 },
18+
animate: { opacity: 1, y: 0, scale: 1 },
19+
exit: { opacity: 0, y: 4, scale: 0.96 },
20+
transition: { duration: 0.15, ease: "easeOut" as const },
21+
};
22+
23+
export function GoodWebsiteGalleryItem({ item }: GoodWebsiteGalleryItemProps) {
24+
// Check if image is already cached (from previous view or browser cache)
25+
const isCached = item.previewImage ? imageCache.has(item.previewImage) : false;
26+
const [imageStatus, setImageStatus] = useState<"loading" | "loaded" | "error">(
27+
isCached ? "loaded" : "loading",
28+
);
29+
const [isHovered, setIsHovered] = useState(false);
30+
const { hasLiked } = useLikes(item.id);
31+
32+
// Preload image using Image API to avoid broken image flicker
33+
useEffect(() => {
34+
if (!item.previewImage || isCached) return;
35+
36+
const img = new Image();
37+
img.onload = () => {
38+
imageCache.add(item.previewImage!);
39+
setImageStatus("loaded");
40+
};
41+
img.onerror = () => setImageStatus("error");
42+
img.src = item.previewImage;
43+
44+
return () => {
45+
img.onload = null;
46+
img.onerror = null;
47+
};
48+
}, [item.previewImage, isCached]);
49+
50+
const handleClick = () => {
51+
if (item.url?.trim()) window.open(item.url, "_blank", "noopener,noreferrer");
52+
};
53+
54+
return (
55+
<div
56+
className="group rounded-px relative cursor-pointer overflow-hidden"
57+
onClick={handleClick}
58+
onMouseEnter={() => setIsHovered(true)}
59+
onMouseLeave={() => setIsHovered(false)}
60+
>
61+
{/* Thumbnail */}
62+
<div className="bg-tertiary relative aspect-40/21 w-full overflow-hidden">
63+
{item.previewImage && imageStatus !== "error" ? (
64+
<>
65+
{imageStatus === "loading" && <PlaceholderShader />}
66+
{imageStatus === "loaded" && (
67+
<picture className="absolute inset-0 transition-transform duration-300 group-hover:scale-[1.02]">
68+
{item.previewImageDark && (
69+
<source srcSet={item.previewImageDark} media="(prefers-color-scheme: dark)" />
70+
)}
71+
<img
72+
src={item.previewImage}
73+
alt={`Preview of ${item.name}`}
74+
className="h-full w-full object-cover object-top"
75+
/>
76+
</picture>
77+
)}
78+
</>
79+
) : (
80+
<PlaceholderShader />
81+
)}
82+
83+
{/* Site name pill - always visible on mobile, animated on desktop */}
84+
<div className="pointer-events-none absolute inset-0 flex flex-col justify-end p-4">
85+
{/* Mobile: always visible, no animation */}
86+
<div className="flex h-7 min-w-0 items-center self-start rounded-full bg-black/50 px-2.5 saturate-150 backdrop-blur-3xl sm:hidden">
87+
<span className="truncate text-sm font-medium text-white">{item.name}</span>
88+
</div>
89+
{/* Desktop: animated on hover */}
90+
<AnimatePresence>
91+
{isHovered && (
92+
<motion.div
93+
className="pointer-events-auto hidden h-7 min-w-0 items-center self-start rounded-full bg-black/50 px-2.5 saturate-150 backdrop-blur-3xl hover:bg-black/90 sm:flex"
94+
{...hoverAnimationProps}
95+
>
96+
<span className="truncate text-sm font-medium text-white">{item.name}</span>
97+
</motion.div>
98+
)}
99+
</AnimatePresence>
100+
</div>
101+
102+
{/* Like button - always visible on mobile, or when liked, otherwise animated on hover */}
103+
<div className="absolute right-4 bottom-4" onClick={(e) => e.stopPropagation()}>
104+
{/* Mobile: always visible */}
105+
<div className="sm:hidden">
106+
<LikeButton pageId={item.id} variant="ghost-light" />
107+
</div>
108+
{/* Desktop: permanently visible if liked, otherwise animated on hover */}
109+
<div className="hidden sm:block">
110+
{hasLiked ? (
111+
<LikeButton pageId={item.id} variant="ghost-light" />
112+
) : (
113+
<AnimatePresence>
114+
{isHovered && (
115+
<motion.div {...hoverAnimationProps}>
116+
<LikeButton pageId={item.id} variant="ghost-light" />
117+
</motion.div>
118+
)}
119+
</AnimatePresence>
120+
)}
121+
</div>
122+
</div>
123+
</div>
124+
</div>
125+
);
126+
}

0 commit comments

Comments
 (0)