Skip to content

Commit 2dcda34

Browse files
committed
feat: luxe project Page stack view
1 parent d1d7feb commit 2dcda34

File tree

1 file changed

+238
-57
lines changed

1 file changed

+238
-57
lines changed

src/pages/luxe-views/LuxeProjectsPage.jsx

Lines changed: 238 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,124 @@
1-
import React from 'react';
1+
import React, { useState, useRef } from 'react';
22
import { Link } from 'react-router-dom';
3-
import { ArrowUpRightIcon } from '@phosphor-icons/react';
3+
import { motion, useScroll, useTransform, useMotionValueEvent } from 'framer-motion';
4+
import {
5+
ArrowUpRightIcon,
6+
StackIcon,
7+
GridFourIcon,
8+
ArrowLeftIcon,
9+
MouseIcon
10+
} from '@phosphor-icons/react';
11+
412
import { useProjects } from '../../utils/projectParser';
513
import Seo from '../../components/Seo';
614
import LuxeArt from '../../components/LuxeArt';
715

16+
const StackedCard = ({ project, index, scrollYProgress, total }) => {
17+
// Each card has a range in the 0-1 scroll progress
18+
const step = 1 / total;
19+
const start = index * step;
20+
21+
// Transition logic:
22+
// We want the card to stay "solo" for a while.
23+
// So it slides in during the second half of the PREVIOUS card's segment.
24+
// And it finishes sliding in exactly when its own segment starts.
25+
const slideStart = Math.max(0, start - step * 0.6); // 60% of the previous segment is used for the slide-in
26+
const slideEnd = start;
27+
28+
const y = useTransform(
29+
scrollYProgress,
30+
[slideStart, slideEnd],
31+
['100vh', '0vh']
32+
);
33+
34+
// The first card is always at 0
35+
const translateY = index === 0 ? 0 : y;
36+
return (
37+
<motion.div
38+
style={{
39+
y: translateY,
40+
zIndex: index,
41+
}}
42+
className="absolute inset-0 flex flex-col items-center justify-center px-6 md:px-12"
43+
>
44+
<div className="relative aspect-[3/4] md:aspect-square lg:aspect-[16/10] w-full max-w-[1000px] bg-[#EBEBEB] rounded-2xl overflow-hidden border border-white/20 group shadow-2xl max-h-[60vh] shrink-0">
45+
46+
<Link to={`/projects/${project.slug}`} className="block w-full h-full relative">
47+
<div className="absolute inset-0 transition-transform duration-1000 group-hover:scale-110">
48+
<LuxeArt seed={project.title} className="w-full h-full opacity-90 group-hover:opacity-100 transition-opacity" />
49+
</div>
50+
<div className="absolute inset-0 bg-gradient-to-t from-black/90 via-black/20 to-black/5 p-8 md:p-16 flex flex-col justify-between text-white">
51+
<div className="flex justify-between items-start">
52+
<span className="font-outfit text-xs uppercase tracking-widest border border-white/20 px-4 py-2 rounded-full backdrop-blur-md">
53+
Work {String(index + 1).padStart(2, '0')}
54+
</span>
55+
<div className="w-14 h-14 rounded-full bg-white/10 backdrop-blur-md flex items-center justify-center group-hover:bg-white group-hover:text-black transition-all duration-500 shadow-xl">
56+
<ArrowUpRightIcon size={28} />
57+
</div>
58+
</div>
59+
<div className="max-w-3xl">
60+
<h3 className="font-playfairDisplay italic text-4xl md:text-7xl lg:text-8xl mb-6 leading-none tracking-tighter">
61+
{project.title}
62+
</h3>
63+
<p className="font-outfit text-white/80 leading-relaxed max-w-xl text-sm md:text-lg line-clamp-3 italic">
64+
{project.shortDescription}
65+
</p>
66+
</div>
67+
</div>
68+
</Link>
69+
</div>
70+
</motion.div>
71+
);
72+
};
73+
74+
const GridCard = ({ project, index }) => (
75+
<div className="w-full h-full group">
76+
<Link to={`/projects/${project.slug}`} className="block h-full">
77+
<div className="relative aspect-[3/4] md:aspect-square lg:aspect-[4/5] w-full bg-[#EBEBEB] rounded-xl overflow-hidden border border-black/5 shadow-sm hover:shadow-xl transition-all duration-500">
78+
<div className="absolute inset-0 transition-transform duration-1000 group-hover:scale-110">
79+
<LuxeArt seed={project.title} className="w-full h-full opacity-90 group-hover:opacity-100 transition-opacity" />
80+
</div>
81+
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent p-8 flex flex-col justify-end text-white">
82+
<h3 className="font-playfairDisplay text-3xl italic mb-2">{project.title}</h3>
83+
<p className="font-outfit text-xs text-white/70 line-clamp-2">{project.shortDescription}</p>
84+
</div>
85+
</div>
86+
</Link>
87+
</div>
88+
);
89+
890
const LuxeProjectsPage = () => {
991
const { projects, loading, error } = useProjects();
92+
const [layoutMode, setLayoutMode] = useState('stack');
93+
const containerRef = useRef(null);
94+
95+
// Track active index for optimized rendering (only show current, prev, next)
96+
const [visibleIndices, setVisibleIndices] = useState([0, 1, 2]);
97+
98+
const { scrollYProgress } = useScroll({
99+
target: containerRef,
100+
offset: ["start start", "end end"]
101+
});
102+
103+
const [currentIdx, setCurrentIdx] = useState(0);
104+
105+
useMotionValueEvent(scrollYProgress, "change", (latest) => {
106+
if (!projects.length) return;
107+
const idx = Math.min(projects.length - 1, Math.floor(latest * projects.length));
108+
setCurrentIdx(idx);
109+
110+
// Window of 3: prev, current, next
111+
const start = Math.max(0, idx - 1);
112+
const end = Math.min(projects.length - 1, idx + 1);
10113

11-
if (loading) {
114+
// Only update if indices changed to avoid unnecessary re-renders
115+
if (visibleIndices[0] !== start || visibleIndices[visibleIndices.length - 1] !== end) {
116+
const newIndices = [];
117+
for (let i = start; i <= end; i++) newIndices.push(i);
118+
setVisibleIndices(newIndices);
119+
}
120+
});
121+
if (loading) {
12122
return (
13123
<div className="min-h-screen bg-[#F5F5F0] flex items-center justify-center font-outfit text-[#1A1A1A]/40 text-xs uppercase tracking-widest">
14124
Loading Archive...
@@ -25,68 +135,139 @@ const LuxeProjectsPage = () => {
25135
}
26136

27137
return (
28-
<div className="min-h-screen bg-[#F5F5F0] text-[#1A1A1A] font-sans selection:bg-[#C0B298] selection:text-black pt-24 pb-20">
138+
<div className="min-h-screen bg-[#F5F5F0] text-[#1A1A1A] font-sans selection:bg-[#C0B298] selection:text-black">
29139
<Seo
30-
title="Fezcodex | Works"
31-
description="A curated collection of digital experiments."
140+
title="Works | Fezcodex"
141+
description="A curated collection of digital experiments and architectural explorations."
32142
keywords={['Fezcodex', 'projects', 'portfolio', 'developer']}
33143
/>
34144

35-
<div className="max-w-[1800px] mx-auto px-6 md:px-12">
36-
37-
{/* Header */}
38-
<header className="mb-20 pt-12 border-b border-[#1A1A1A]/10 pb-12">
39-
<h1 className="font-playfairDisplay text-7xl md:text-9xl text-[#1A1A1A] mb-6">
40-
Selected Works
41-
</h1>
42-
<div className="flex flex-col md:flex-row justify-between items-end gap-8">
43-
<p className="font-outfit text-sm text-[#1A1A1A]/60 max-w-lg leading-relaxed">
44-
Explorations in code, design, and user interaction. A catalogue of shipped products and experimental prototypes.
45-
</p>
46-
<span className="font-outfit text-xs uppercase tracking-widest text-[#1A1A1A]/40 border border-[#1A1A1A]/10 px-4 py-2 rounded-full">
47-
Total Entries: {projects.length}
48-
</span>
145+
{/* Persistent Header */}
146+
<div className="relative z-[100] max-w-[1800px] mx-auto px-6 md:px-12 pt-24">
147+
<header className="mb-12 border-b border-[#1A1A1A]/10 pb-12">
148+
<Link to="/" className="inline-flex items-center gap-2 mb-8 font-outfit text-xs uppercase tracking-widest text-black/40 hover:text-[#8D4004] transition-colors">
149+
<ArrowLeftIcon /> Home
150+
</Link>
151+
152+
<div className="flex flex-col md:flex-row justify-between items-end gap-12">
153+
<div className="space-y-6">
154+
<h1 className="font-playfairDisplay text-7xl md:text-9xl text-[#1A1A1A] mb-0 leading-none">
155+
Works
156+
</h1>
157+
<p className="font-outfit text-sm text-[#1A1A1A]/60 max-w-lg leading-relaxed italic">
158+
Explorations in code, design, and user interaction. A catalogue of shipped products and experimental prototypes.
159+
</p>
160+
</div>
161+
162+
<div className="flex items-center gap-8">
163+
<div className="flex bg-white rounded-full p-1 border border-black/5 shadow-sm">
164+
<button
165+
onClick={() => setLayoutMode('grid')}
166+
className={`p-2 rounded-full transition-all ${layoutMode === 'grid' ? 'bg-[#1A1A1A] text-white shadow-lg' : 'text-black/40 hover:text-black'}`}
167+
title="Grid View"
168+
>
169+
<GridFourIcon size={20} />
170+
</button>
171+
<button
172+
onClick={() => setLayoutMode('stack')}
173+
className={`p-2 rounded-full transition-all ${layoutMode === 'stack' ? 'bg-[#1A1A1A] text-white shadow-lg' : 'text-black/40 hover:text-black'}`}
174+
title="Stack View"
175+
>
176+
<StackIcon size={20} />
177+
</button>
178+
</div>
179+
<span className="font-outfit text-[10px] uppercase tracking-widest text-[#1A1A1A]/40 border border-[#1A1A1A]/10 px-4 py-2 rounded-full bg-white/50">
180+
Archive Size: {projects.length}
181+
</span>
182+
</div>
49183
</div>
50184
</header>
185+
</div>
51186

52-
{/* Grid */}
53-
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-12 gap-y-20">
54-
{projects.map((project, index) => (
55-
<div key={project.slug} className={`group ${index % 2 === 1 ? 'md:translate-y-20' : ''}`}>
56-
<Link to={`/projects/${project.slug}`} className="block">
57-
<div className="relative aspect-[4/3] w-full bg-[#EBEBEB] overflow-hidden mb-8 border border-[#1A1A1A]/5 shadow-sm group-hover:shadow-2xl transition-all duration-700">
58-
{/* Art/Image */}
59-
<div className="absolute inset-0 transition-transform duration-1000 group-hover:scale-105">
60-
<LuxeArt seed={project.title} className="w-full h-full opacity-90" />
61-
</div>
62-
63-
{/* Overlay */}
64-
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-colors duration-500" />
65-
66-
<div className="absolute top-6 right-6 opacity-0 group-hover:opacity-100 transition-opacity duration-500 bg-white text-black p-4 rounded-full">
67-
<ArrowUpRightIcon size={24} />
68-
</div>
69-
</div>
70-
71-
<div className="flex justify-between items-start border-t border-[#1A1A1A]/10 pt-6">
72-
<div className="space-y-2 max-w-xl">
73-
<h2 className="font-playfairDisplay text-4xl text-[#1A1A1A] group-hover:italic transition-all leading-tight">
74-
{project.title}
75-
</h2>
76-
<p className="font-outfit text-sm text-[#1A1A1A]/60 line-clamp-2">
77-
{project.shortDescription}
78-
</p>
79-
</div>
80-
<span className="font-outfit text-[10px] uppercase tracking-widest text-[#1A1A1A]/30">
81-
{String(index + 1).padStart(2, '0')}
82-
</span>
83-
</div>
84-
</Link>
85-
</div>
86-
))}
87-
</div>
187+
{/* Content Area */}
88188

89-
</div>
189+
{layoutMode === 'grid' ? (
190+
191+
<div className="max-w-[1800px] mx-auto px-6 md:px-12 pb-32 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
192+
193+
{projects.map((project, index) => (
194+
195+
<GridCard key={project.slug} project={project} index={index} />
196+
197+
))}
198+
199+
</div>
200+
201+
) : (
202+
203+
<div
204+
205+
ref={containerRef}
206+
207+
className="relative"
208+
209+
style={{ height: `${projects.length * 150}vh` }}
210+
211+
>
212+
213+
{/* Top Progress Bar */}
214+
215+
<div className="fixed top-0 left-0 right-0 h-1 bg-[#1A1A1A]/5 z-[110]">
216+
217+
<motion.div
218+
219+
className="h-full bg-[#8D4004] origin-left"
220+
221+
style={{ scaleX: scrollYProgress }}
222+
223+
/>
224+
225+
</div>
226+
227+
{/* Right Side Scroll Prompt & Pagination */}
228+
<div className="fixed right-12 top-1/2 -translate-y-1/2 z-[110] hidden lg:flex flex-col gap-6 items-center">
229+
{/* Scroll Prompt */}
230+
<div className="flex flex-col items-center gap-2 pointer-events-none opacity-20 mb-4">
231+
<motion.div
232+
animate={{ y: [0, 8, 0] }}
233+
transition={{ duration: 2, repeat: Infinity, ease: "easeInOut" }}
234+
>
235+
<MouseIcon size={32} weight="light" />
236+
</motion.div>
237+
<span className="font-outfit text-[9px] uppercase tracking-[0.4em] [writing-mode:vertical-lr]">Scroll</span>
238+
</div>
239+
240+
<div className="w-px h-12 bg-gradient-to-b from-transparent via-[#1A1A1A]/10 to-transparent" />
241+
242+
{projects.map((_, i) => (
243+
<div
244+
key={i}
245+
className={`w-1.5 h-1.5 rounded-full transition-all duration-500 ${currentIdx === i ? 'bg-[#8D4004] scale-150' : 'bg-[#1A1A1A]/10'}`}
246+
/>
247+
))}
248+
249+
<div className="w-px h-12 bg-gradient-to-b from-transparent via-[#1A1A1A]/10 to-transparent" />
250+
</div>
251+
252+
<div className="sticky top-0 h-screen w-full overflow-hidden">
253+
254+
{projects.map((project, index) => {
255+
// Optimized Rendering: only render the 3 cards in the current window
256+
if (!visibleIndices.includes(index)) return null;
257+
258+
return (
259+
<StackedCard
260+
key={project.slug}
261+
project={project}
262+
index={index}
263+
scrollYProgress={scrollYProgress}
264+
total={projects.length}
265+
/>
266+
);
267+
})}
268+
</div>
269+
</div>
270+
)}
90271
</div>
91272
);
92273
};

0 commit comments

Comments
 (0)