Skip to content

Commit 9550bed

Browse files
committed
Gallery updates
1 parent 2032f28 commit 9550bed

4 files changed

Lines changed: 94 additions & 86 deletions

File tree

src/App.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,7 @@ export default function App() {
155155
transition={{ duration: 0.9, ease: [0.22, 1, 0.36, 1] }}
156156
>
157157
<span id="top" />
158-
<Hero theme={theme} reveal={introDone} />
158+
<Hero reveal={introDone} />
159159

160160
<section className="mt-8">
161161
<SectionLabel

src/components/Gallery.tsx

Lines changed: 84 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,23 @@
1-
import { useCallback, useEffect, useState } from 'react';
1+
import { useCallback, useEffect, useId, useState } from 'react';
22
import { createPortal } from 'react-dom';
33
import { ScrollShadow } from '@heroui/react';
4-
import { AnimatePresence, motion } from 'motion/react';
4+
import { AnimatePresence, LayoutGroup, motion } from 'motion/react';
55
import { TbChevronLeft, TbChevronRight, TbX } from 'react-icons/tb';
6-
import { CornerBrackets } from './hud/CornerBrackets';
76

87
type Props = {
98
images: string[];
109
label: string;
1110
};
1211

1312
/**
14-
* Horizontal row of HUD-framed, duotone screenshot thumbnails. Click opens a
15-
* full-colour lightbox with keyboard navigation.
13+
* HUD thumbnail row + lightbox. Opening shares a `layoutId` with the clicked
14+
* thumbnail (app-store style zoom-morph). Inside, all images live on one track
15+
* that translates by index, so prev/next slide left/right.
1616
*/
1717
export function Gallery({ images, label }: Props) {
1818
const [open, setOpen] = useState<number | null>(null);
19+
const uid = useId();
20+
const lid = (i: number) => `${uid}-frame-${i}`;
1921

2022
const close = useCallback(() => setOpen(null), []);
2123
const step = useCallback(
@@ -38,7 +40,7 @@ export function Gallery({ images, label }: Props) {
3840
}, [open, close, step]);
3941

4042
return (
41-
<div>
43+
<LayoutGroup>
4244
<div className="mb-2 flex items-center gap-2">
4345
<span className="hud-label text-[9px]">SURVEILLANCE //</span>
4446
<span className="hud-label text-[9px] text-[var(--accent)]">
@@ -62,11 +64,13 @@ export function Gallery({ images, label }: Props) {
6264
}}
6365
aria-label={`${label} — frame ${i + 1}`}
6466
>
65-
<img
67+
<motion.img
68+
layoutId={lid(i)}
6669
src={src}
6770
alt={`${label} screenshot ${i + 1}`}
6871
loading="lazy"
6972
className="size-full object-cover"
73+
style={{ opacity: open === null ? 1 : 0 }}
7074
/>
7175
<span className="absolute left-1.5 top-1.5 bg-[color:var(--background)]/70 px-1 py-0.5 hud-label text-[8px] text-[var(--accent)]">
7276
{String(i + 1).padStart(2, '0')}
@@ -79,75 +83,87 @@ export function Gallery({ images, label }: Props) {
7983
<AnimatePresence>
8084
{open !== null && (
8185
<motion.div
82-
className="fixed inset-0 z-[70] flex items-center justify-center p-4 sm:p-10"
83-
initial={{ opacity: 0 }}
84-
animate={{ opacity: 1 }}
85-
exit={{ opacity: 0 }}
86-
transition={{ duration: 0.18 }}
87-
onClick={close}
88-
style={{
89-
background:
90-
'color-mix(in oklab, var(--background) 86%, transparent)',
91-
backdropFilter: 'blur(8px)',
92-
}}
93-
>
94-
<div className="absolute left-5 top-5 flex items-center gap-3">
95-
<span className="hud-label text-[var(--accent)]">{label}</span>
96-
<span className="hud-label">
97-
{String((open ?? 0) + 1).padStart(2, '0')} /{' '}
98-
{String(images.length).padStart(2, '0')}
99-
</span>
100-
</div>
101-
102-
<button
86+
key="lightbox"
87+
className="fixed inset-0 z-[70] overflow-hidden"
88+
initial={{ opacity: 0 }}
89+
animate={{ opacity: 1 }}
90+
exit={{ opacity: 0 }}
91+
transition={{ duration: 0.25 }}
10392
onClick={close}
104-
className="pointer-events-auto absolute right-5 top-5 grid size-9 place-items-center border border-[var(--border)] text-[var(--foreground)] transition-colors hover:border-[var(--accent)] hover:text-[var(--accent)]"
105-
aria-label="Close"
106-
>
107-
<TbX size={18} />
108-
</button>
109-
110-
<button
111-
onClick={(e) => {
112-
e.stopPropagation();
113-
step(-1);
93+
style={{
94+
background:
95+
'color-mix(in oklab, var(--background) 86%, transparent)',
96+
backdropFilter: 'blur(8px)',
11497
}}
115-
className="pointer-events-auto absolute left-3 top-1/2 grid size-10 -translate-y-1/2 place-items-center border border-[var(--border)] text-[var(--foreground)] transition-colors hover:border-[var(--accent)] hover:text-[var(--accent)]"
116-
aria-label="Previous"
11798
>
118-
<TbChevronLeft size={20} />
119-
</button>
120-
<button
121-
onClick={(e) => {
122-
e.stopPropagation();
123-
step(1);
124-
}}
125-
className="pointer-events-auto absolute right-3 top-1/2 grid size-10 -translate-y-1/2 place-items-center border border-[var(--border)] text-[var(--foreground)] transition-colors hover:border-[var(--accent)] hover:text-[var(--accent)]"
126-
aria-label="Next"
127-
>
128-
<TbChevronRight size={20} />
129-
</button>
99+
{/* sliding track — one image per viewport, translated by index */}
100+
<motion.div
101+
className="flex h-full w-full"
102+
initial={false}
103+
animate={{ x: `${open * -100}vw` }}
104+
transition={{ type: 'spring', stiffness: 320, damping: 36 }}
105+
>
106+
{images.map((src, i) => (
107+
<div
108+
key={src}
109+
className="flex h-full w-full shrink-0 items-center justify-center p-4 sm:p-12"
110+
>
111+
<motion.img
112+
layoutId={lid(i)}
113+
src={src}
114+
alt={`${label} screenshot ${i + 1}`}
115+
onClick={(e) => e.stopPropagation()}
116+
className="max-h-[82vh] max-w-[88vw] cursor-default border border-[var(--border)] object-contain"
117+
transition={{
118+
layout: { type: 'spring', stiffness: 240, damping: 30 },
119+
}}
120+
/>
121+
</div>
122+
))}
123+
</motion.div>
130124

131-
<motion.div
132-
key={open}
133-
className="relative max-h-full max-w-4xl"
134-
initial={{ scale: 0.97, opacity: 0 }}
135-
animate={{ scale: 1, opacity: 1 }}
136-
transition={{ duration: 0.2, ease: [0.22, 1, 0.36, 1] }}
137-
onClick={(e) => e.stopPropagation()}
138-
>
139-
<CornerBrackets size={18} inset={-6} color="var(--accent)" />
140-
<img
141-
src={images[open]}
142-
alt={`${label} screenshot ${open + 1}`}
143-
className="max-h-[80vh] w-auto border border-[var(--border)] object-contain"
144-
/>
125+
{/* HUD chrome */}
126+
<div className="pointer-events-none absolute left-5 top-5 flex items-center gap-3">
127+
<span className="hud-label text-[var(--accent)]">{label}</span>
128+
<span className="hud-label">
129+
{String(open + 1).padStart(2, '0')} /{' '}
130+
{String(images.length).padStart(2, '0')}
131+
</span>
132+
</div>
133+
134+
<button
135+
onClick={close}
136+
className="absolute right-5 top-5 grid size-9 place-items-center border border-[var(--border)] bg-[color:var(--background)]/40 text-[var(--foreground)] transition-colors hover:border-[var(--accent)] hover:text-[var(--accent)]"
137+
aria-label="Close"
138+
>
139+
<TbX size={18} />
140+
</button>
141+
142+
<button
143+
onClick={(e) => {
144+
e.stopPropagation();
145+
step(-1);
146+
}}
147+
className="absolute left-3 top-1/2 grid size-10 -translate-y-1/2 place-items-center border border-[var(--border)] bg-[color:var(--background)]/40 text-[var(--foreground)] transition-colors hover:border-[var(--accent)] hover:text-[var(--accent)]"
148+
aria-label="Previous"
149+
>
150+
<TbChevronLeft size={20} />
151+
</button>
152+
<button
153+
onClick={(e) => {
154+
e.stopPropagation();
155+
step(1);
156+
}}
157+
className="absolute right-3 top-1/2 grid size-10 -translate-y-1/2 place-items-center border border-[var(--border)] bg-[color:var(--background)]/40 text-[var(--foreground)] transition-colors hover:border-[var(--accent)] hover:text-[var(--accent)]"
158+
aria-label="Next"
159+
>
160+
<TbChevronRight size={20} />
161+
</button>
145162
</motion.div>
146-
</motion.div>
147163
)}
148164
</AnimatePresence>,
149165
document.body
150166
)}
151-
</div>
167+
</LayoutGroup>
152168
);
153169
}

src/components/Hero.tsx

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,6 @@ import { CornerBrackets } from './hud/CornerBrackets';
77
import { PixelPortrait } from './PixelPortrait';
88
import { RoleCycler } from './RoleCycler';
99

10-
type Theme = 'light' | 'dark';
11-
1210
const actionBase =
1311
'pointer-events-auto inline-flex items-center gap-2 border px-4 py-2.5 font-mono text-[11px] font-bold uppercase tracking-[0.16em] transition-colors duration-200';
1412

@@ -26,7 +24,7 @@ function Barcode({ className }: { className?: string }) {
2624
);
2725
}
2826

29-
function IDBadge({ theme }: { theme: Theme }) {
27+
function IDBadge() {
3028
return (
3129
<div className="hud-panel relative mx-auto w-full max-w-sm">
3230
<CornerBrackets size={14} inset={-1} color="var(--accent)" />
@@ -43,15 +41,14 @@ function IDBadge({ theme }: { theme: Theme }) {
4341
clipPath:
4442
'polygon(0 0, calc(100% - 14px) 0, 100% 14px, 100% 100%, 14px 100%, 0 calc(100% - 14px))',
4543
background:
46-
'radial-gradient(125% 90% at 50% 32%, color-mix(in oklab, var(--accent) 16%, transparent), transparent 72%), var(--background)',
44+
'radial-gradient(125% 90% at 50% 32%, rgba(255,106,26,0.18), transparent 72%), #0b0a0a',
4745
}}
4846
>
4947
<PixelPortrait
5048
src={profile.portrait}
5149
columns={44}
5250
aspect={3 / 4}
5351
mode="dots"
54-
theme={theme}
5552
className="absolute inset-0"
5653
/>
5754
<span className="scanlines absolute inset-0 opacity-40" />
@@ -83,7 +80,7 @@ function IDBadge({ theme }: { theme: Theme }) {
8380
);
8481
}
8582

86-
export function Hero({ theme, reveal }: { theme: Theme; reveal: boolean }) {
83+
export function Hero({ reveal }: { reveal: boolean }) {
8784
return (
8885
<section
8986
id="intro"
@@ -156,7 +153,7 @@ export function Hero({ theme, reveal }: { theme: Theme; reveal: boolean }) {
156153
</div>
157154

158155
{/* ID badge */}
159-
<IDBadge theme={theme} />
156+
<IDBadge />
160157
</div>
161158

162159
{/* scroll hint */}

src/components/PixelPortrait.tsx

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,13 @@ type Props = {
1010
/** Output aspect ratio (width / height). */
1111
aspect?: number;
1212
mode?: 'dots' | 'ascii';
13-
theme?: 'light' | 'dark';
1413
className?: string;
1514
};
1615

17-
// Orange ramps: shadow -> highlight. Dark uses bright neon; light uses burnt
18-
// orange so the dots read on the pale badge.
19-
const RAMP: Record<'light' | 'dark', { cool: RGB; hot: RGB }> = {
20-
dark: { cool: [255, 106, 26], hot: [255, 201, 138] },
21-
light: { cool: [138, 60, 16], hot: [196, 96, 38] },
22-
};
16+
// Neon-orange ramp: shadow -> highlight. Always rendered on a dark backing,
17+
// so the colour is theme-independent.
18+
const COOL: RGB = [255, 106, 26];
19+
const HOT: RGB = [255, 201, 138];
2320
// dark -> light
2421
const ASCII_RAMP = ' .,:;irsXA253hMHGS#9B&@';
2522

@@ -35,7 +32,6 @@ export function PixelPortrait({
3532
columns = 48,
3633
aspect = 3 / 4,
3734
mode = 'dots',
38-
theme = 'dark',
3935
className,
4036
}: Props) {
4137
const canvasRef = useRef<HTMLCanvasElement>(null);
@@ -44,7 +40,6 @@ export function PixelPortrait({
4440
const canvas = canvasRef.current;
4541
if (!canvas) return;
4642
let cancelled = false;
47-
const { cool: COOL, hot: HOT } = RAMP[theme];
4843

4944
const img = new Image();
5045
img.decoding = 'async';
@@ -142,7 +137,7 @@ export function PixelPortrait({
142137
return () => {
143138
cancelled = true;
144139
};
145-
}, [src, columns, aspect, mode, theme]);
140+
}, [src, columns, aspect, mode]);
146141

147142
return (
148143
<canvas

0 commit comments

Comments
 (0)