|
| 1 | +import React, { useState, useEffect } from 'react'; |
| 2 | +import { useParams, Link } from 'react-router-dom'; |
| 3 | +import { motion } from 'framer-motion'; |
| 4 | +import { useProjects } from '../../utils/projectParser'; |
| 5 | +import Seo from '../../components/Seo'; |
| 6 | +import MarkdownContent from '../../components/MarkdownContent'; |
| 7 | +import * as Icons from '@phosphor-icons/react'; |
| 8 | +import { |
| 9 | + CaretRightIcon, |
| 10 | + GlobeHemisphereWestIcon, |
| 11 | + BuildingsIcon, |
| 12 | +} from '@phosphor-icons/react'; |
| 13 | + |
| 14 | +// Helper to render icon by name string |
| 15 | +const DynamicIcon = ({ name, ...props }) => { |
| 16 | + if (!name) return null; |
| 17 | + // Phosphor icons in this project use 'Icon' suffix |
| 18 | + const IconComponent = Icons[`${name}Icon`]; |
| 19 | + if (!IconComponent) return null; |
| 20 | + return <IconComponent {...props} />; |
| 21 | +}; |
| 22 | + |
| 23 | +const LandscapeProjectPage = () => { |
| 24 | + const { slug } = useParams(); |
| 25 | + const { projects, loading: loadingProjects } = useProjects(); |
| 26 | + const [sections, setSections] = useState([]); |
| 27 | + const [landingConfig, setLandingConfig] = useState(null); |
| 28 | + const [navbarConfig, setNavbarConfig] = useState({ logo: {}, links: [] }); |
| 29 | + const [sectionContent, setSectionContent] = useState({}); |
| 30 | + const [activeSection, setActiveSection] = useState(0); |
| 31 | + |
| 32 | + const project = projects.find((p) => p.slug === slug); |
| 33 | + |
| 34 | + // Fetch Configurations |
| 35 | + useEffect(() => { |
| 36 | + const fetchConfigs = async () => { |
| 37 | + try { |
| 38 | + const [landingRes, navbarRes, sectionsRes] = await Promise.all([ |
| 39 | + fetch(`/projects/${slug}/details/landing.txt`), |
| 40 | + fetch(`/projects/${slug}/details/navbar.txt`), |
| 41 | + fetch(`/projects/${slug}/details/sections.txt`), |
| 42 | + ]); |
| 43 | + |
| 44 | + if (landingRes.ok) { |
| 45 | + const lData = await landingRes.json(); |
| 46 | + setLandingConfig(lData); |
| 47 | + } |
| 48 | + if (navbarRes.ok) { |
| 49 | + const nData = await navbarRes.json(); |
| 50 | + setNavbarConfig(nData); |
| 51 | + } |
| 52 | + if (sectionsRes.ok) { |
| 53 | + const sectionsData = await sectionsRes.json(); |
| 54 | + setSections(sectionsData); |
| 55 | + |
| 56 | + // Fetch content for sections after loading sections config |
| 57 | + const contentMap = {}; |
| 58 | + await Promise.all( |
| 59 | + sectionsData.map(async (section) => { |
| 60 | + try { |
| 61 | + const response = await fetch( |
| 62 | + `/projects/${slug}/details/${section.file}`, |
| 63 | + ); |
| 64 | + if (response.ok) { |
| 65 | + const text = await response.text(); |
| 66 | + contentMap[section.id] = text; |
| 67 | + } |
| 68 | + } catch (error) { |
| 69 | + console.error(`Failed to fetch content for ${section.id}`, error); |
| 70 | + } |
| 71 | + }), |
| 72 | + ); |
| 73 | + setSectionContent(contentMap); |
| 74 | + } |
| 75 | + } catch (error) { |
| 76 | + console.error('Failed to load project configurations', error); |
| 77 | + } |
| 78 | + }; |
| 79 | + |
| 80 | + if (slug) fetchConfigs(); |
| 81 | + }, [slug]); |
| 82 | + |
| 83 | + // Handle scroll to track active section for the indicator |
| 84 | + const handleScroll = (e) => { |
| 85 | + const scrollPos = e.target.scrollTop; |
| 86 | + const windowHeight = window.innerHeight; |
| 87 | + if (windowHeight === 0) return; |
| 88 | + const index = Math.round(scrollPos / windowHeight); |
| 89 | + setActiveSection(index); |
| 90 | + }; |
| 91 | + |
| 92 | + if (loadingProjects || !project || !landingConfig) { |
| 93 | + return ( |
| 94 | + <div className="min-h-screen bg-[#050505] flex items-center justify-center text-white font-mono uppercase tracking-widest text-xs"> |
| 95 | + <span className="animate-pulse">Initializing System...</span> |
| 96 | + </div> |
| 97 | + ); |
| 98 | + } |
| 99 | + |
| 100 | + const totalSections = sections.length + 1; // +1 for Hero |
| 101 | + |
| 102 | + return ( |
| 103 | + <div className="bg-[#050505] text-white h-screen overflow-y-scroll snap-y snap-mandatory font-instr-sans selection:bg-rose-500/30 selection:text-rose-200" onScroll={handleScroll}> |
| 104 | + <Seo |
| 105 | + title={`${project.title} | Fezcodex`} |
| 106 | + description={project.shortDescription} |
| 107 | + image={project.image} |
| 108 | + keywords={project.tags} |
| 109 | + /> |
| 110 | + |
| 111 | + {/* Dot Indicator */} |
| 112 | + <div className="fixed right-8 top-1/2 -translate-y-1/2 z-50 flex flex-col gap-4"> |
| 113 | + {Array.from({ length: totalSections }).map((_, i) => ( |
| 114 | + <div |
| 115 | + key={i} |
| 116 | + className={`w-1 h-8 transition-colors duration-200 ${ |
| 117 | + activeSection === i ? 'bg-rose-500' : 'bg-white/10' |
| 118 | + }`} |
| 119 | + /> |
| 120 | + ))} |
| 121 | + </div> |
| 122 | + |
| 123 | + {/* Navigation Bar (Customizable) */} |
| 124 | + <nav className="fixed top-0 left-0 right-0 z-50 flex items-center justify-between px-6 py-6 md:px-12 backdrop-blur-sm mix-blend-difference"> |
| 125 | + <Link |
| 126 | + to="/projects" |
| 127 | + className="text-xl font-instr-serif font-bold tracking-tight flex items-center gap-2 group text-white no-underline" |
| 128 | + > |
| 129 | + {navbarConfig.logo?.icon && ( |
| 130 | + <DynamicIcon |
| 131 | + name={navbarConfig.logo.icon} |
| 132 | + className="group-hover:rotate-12 transition-transform" |
| 133 | + /> |
| 134 | + )} |
| 135 | + {navbarConfig.logo?.label || landingConfig.title} |
| 136 | + </Link> |
| 137 | + <div className="hidden md:flex items-center gap-8 text-sm font-medium opacity-80 font-instr-sans uppercase tracking-widest"> |
| 138 | + {navbarConfig.links?.map((item, index) => ( |
| 139 | + item.link.startsWith('/') ? ( |
| 140 | + <Link key={index} to={item.link} className="hover:opacity-100 transition-opacity text-white no-underline"> |
| 141 | + {item.label} |
| 142 | + </Link> |
| 143 | + ) : ( |
| 144 | + <a key={index} href={item.link} className="hover:opacity-100 transition-opacity text-white no-underline"> |
| 145 | + {item.label} |
| 146 | + </a> |
| 147 | + ) |
| 148 | + ))} |
| 149 | + </div> |
| 150 | + {navbarConfig.cta && ( |
| 151 | + <a |
| 152 | + href={navbarConfig.cta.link} |
| 153 | + target={navbarConfig.cta.link.startsWith('http') ? "_blank" : "_self"} |
| 154 | + rel={navbarConfig.cta.link.startsWith('http') ? "noopener noreferrer" : ""} |
| 155 | + className="hidden md:flex items-center gap-2 border border-white/30 px-6 py-2 rounded text-xs font-instr-sans uppercase tracking-widest hover:bg-white hover:text-black transition-colors no-underline text-white" |
| 156 | + > |
| 157 | + {navbarConfig.cta.icon && ( |
| 158 | + <DynamicIcon name={navbarConfig.cta.icon} size={16} /> |
| 159 | + )} |
| 160 | + {navbarConfig.cta.label} |
| 161 | + </a> |
| 162 | + )} |
| 163 | + </nav> |
| 164 | + |
| 165 | + {/* Hero Section */} |
| 166 | + <section className="relative h-screen w-full flex items-center overflow-hidden snap-start snap-always"> |
| 167 | + {/* Background Image */} |
| 168 | + <div className="absolute inset-0 z-0"> |
| 169 | + <img |
| 170 | + src={`/projects/${slug}/maps/${landingConfig.backgroundImage || (sections.length > 0 ? sections[0].image : project.image)}`} |
| 171 | + alt="Hero Background" |
| 172 | + className="w-full h-full object-cover filter brightness-[0.6] saturate-[0.8]" |
| 173 | + /> |
| 174 | + <div className="absolute inset-0 bg-gradient-to-r from-black/60 via-black/30 to-transparent" /> |
| 175 | + </div> |
| 176 | + |
| 177 | + {/* Content */} |
| 178 | + <div className="relative z-10 container mx-auto px-6 md:px-12 grid grid-cols-1 md:grid-cols-2 gap-12 h-full items-center"> |
| 179 | + <div className="pt-20"> |
| 180 | + <h1 className="text-5xl md:text-7xl lg:text-9xl font-instr-serif font-medium tracking-tight leading-[0.9] mb-12"> |
| 181 | + {landingConfig.title}: <br /> |
| 182 | + <span className="text-rose-400">{landingConfig.subtitle}</span> |
| 183 | + </h1> |
| 184 | + |
| 185 | + <motion.div |
| 186 | + initial={{ opacity: 0, y: 30 }} |
| 187 | + animate={{ opacity: 1, y: 0 }} |
| 188 | + transition={{ duration: 1, ease: [0.16, 1, 0.3, 1] }} |
| 189 | + className="w-full max-w-2xl bg-black/40 backdrop-blur-2xl p-10 md:p-12 border border-white/10 shadow-2xl relative" |
| 190 | + > |
| 191 | + {/* Industrial Accents */} |
| 192 | + <div className="absolute -top-px -left-px w-8 h-px bg-rose-500/50" /> |
| 193 | + <div className="absolute -top-px -left-px w-px h-8 bg-rose-500/50" /> |
| 194 | + <div className="absolute -bottom-px -right-px w-8 h-px bg-rose-500/50" /> |
| 195 | + <div className="absolute -bottom-px -right-px w-px h-8 bg-rose-500/50" /> |
| 196 | + |
| 197 | + <p className="text-xl md:text-2xl text-gray-200 leading-relaxed font-garamond italic mb-10"> |
| 198 | + {landingConfig.description} |
| 199 | + </p> |
| 200 | + |
| 201 | + <div className="grid grid-cols-1 sm:grid-cols-2 gap-6 mb-10 border-t border-white/5 pt-8"> |
| 202 | + {landingConfig.credits && |
| 203 | + landingConfig.credits.map((credit, index) => ( |
| 204 | + <div key={index}> |
| 205 | + <span className="text-[10px] font-instr-sans uppercase tracking-[0.3em] text-white/40 block mb-1"> |
| 206 | + {credit.role} |
| 207 | + </span> |
| 208 | + <span className="text-sm font-instr-serif italic text-white/80"> |
| 209 | + {credit.name} |
| 210 | + </span> |
| 211 | + </div> |
| 212 | + ))} |
| 213 | + </div> |
| 214 | + |
| 215 | + <div className="flex flex-wrap items-center gap-4 pt-8 border-t border-white/5"> |
| 216 | + {landingConfig.buttons && |
| 217 | + landingConfig.buttons.map((btn, index) => ( |
| 218 | + <a |
| 219 | + key={index} |
| 220 | + href={btn.link} |
| 221 | + target={btn.link.startsWith('http') ? '_blank' : '_self'} |
| 222 | + rel={btn.link.startsWith('http') ? 'noopener noreferrer' : ''} |
| 223 | + className={`${ |
| 224 | + btn.type === 'primary' |
| 225 | + ? 'bg-white text-black hover:bg-rose-400' |
| 226 | + : 'border border-white/30 hover:bg-white/10 backdrop-blur-md text-white' |
| 227 | + } px-8 py-4 rounded font-instr-sans font-bold text-xs uppercase tracking-[0.2em] transition-colors flex items-center gap-2 no-underline`} |
| 228 | + > |
| 229 | + {btn.label} |
| 230 | + </a> |
| 231 | + ))} |
| 232 | + </div> |
| 233 | + </motion.div> |
| 234 | + </div> |
| 235 | + </div> |
| 236 | + |
| 237 | + {/* Bottom Stats / Logos */} |
| 238 | + <div className="absolute bottom-0 left-0 right-0 p-6 md:p-12 z-20 flex flex-col md:flex-row items-end justify-between gap-8 pointer-events-none"> |
| 239 | + <div className="flex items-center gap-8 opacity-50 grayscale pointer-events-auto font-instr-sans uppercase tracking-widest text-[10px]"> |
| 240 | + <span className="font-bold flex items-center gap-2"> |
| 241 | + <GlobeHemisphereWestIcon size={18} /> Aether |
| 242 | + </span> |
| 243 | + <span className="font-bold flex items-center gap-2"> |
| 244 | + <BuildingsIcon size={18} /> UrbanOS |
| 245 | + </span> |
| 246 | + </div> |
| 247 | + |
| 248 | + <div className="flex items-center gap-12 bg-black/20 backdrop-blur-md p-8 rounded-t-2xl border-t border-x border-white/10"> |
| 249 | + {landingConfig.stats && landingConfig.stats.map((stat, index) => ( |
| 250 | + <React.Fragment key={index}> |
| 251 | + <div> |
| 252 | + <div className="text-4xl font-instr-serif font-bold">{stat.value}</div> |
| 253 | + <div className="text-[10px] font-instr-sans text-gray-400 uppercase tracking-[0.2em] mt-2"> |
| 254 | + {stat.label} |
| 255 | + </div> |
| 256 | + </div> |
| 257 | + {index < landingConfig.stats.length - 1 && <div className="w-px h-12 bg-white/10" />} |
| 258 | + </React.Fragment> |
| 259 | + ))} |
| 260 | + </div> |
| 261 | + </div> |
| 262 | + |
| 263 | + {/* Decorative Grid Lines */} |
| 264 | + <div className="absolute top-0 left-12 bottom-0 w-px bg-white/10 hidden md:block" /> |
| 265 | + <div className="absolute top-0 right-12 bottom-0 w-px bg-white/10 hidden md:block" /> |
| 266 | + <div className="absolute top-24 left-0 right-0 h-px bg-white/10 hidden md:block" /> |
| 267 | + <div className="absolute bottom-32 left-0 right-0 h-px bg-white/10 hidden md:block" /> |
| 268 | + </section> |
| 269 | + |
| 270 | + {/* Scrollable Detail Sections */} |
| 271 | + {sections.map((section, index) => ( |
| 272 | + <Section |
| 273 | + key={section.id} |
| 274 | + section={section} |
| 275 | + slug={slug} |
| 276 | + content={sectionContent[section.id]} |
| 277 | + index={index} |
| 278 | + /> |
| 279 | + ))} |
| 280 | + </div> |
| 281 | + ); |
| 282 | +}; |
| 283 | + |
| 284 | +const Section = ({ section, slug, content, index }) => { |
| 285 | + const isEven = index % 2 === 0; |
| 286 | + |
| 287 | + return ( |
| 288 | + <section className="relative h-screen w-full flex items-center overflow-hidden border-t border-white/5 snap-start snap-always"> |
| 289 | + {/* Background Wallpaper - Clear and Full Screen */} |
| 290 | + <div className="absolute inset-0 z-0"> |
| 291 | + <img |
| 292 | + src={`/projects/${slug}/maps/${section.image}`} |
| 293 | + alt={section.title} |
| 294 | + className="w-full h-full object-cover transition-transform duration-1000" |
| 295 | + /> |
| 296 | + {/* Subtle gradient to ensure text readability without killing the wallpaper */} |
| 297 | + <div className={`absolute inset-0 bg-gradient-to-b from-black/40 via-transparent to-black/60`} /> |
| 298 | + <div className={`absolute inset-0 bg-black/20`} /> |
| 299 | + </div> |
| 300 | + |
| 301 | + <div className="container mx-auto px-6 md:px-12 relative z-10"> |
| 302 | + <div |
| 303 | + className={`flex w-full ${isEven ? 'justify-start' : 'justify-end'}`} |
| 304 | + > |
| 305 | + {/* Detail Box with Negative Background (Glassmorphism / Industrial) */} |
| 306 | + <motion.div |
| 307 | + initial={{ opacity: 0, y: 30 }} |
| 308 | + whileInView={{ opacity: 1, y: 0 }} |
| 309 | + viewport={{ once: true, margin: '-100px' }} |
| 310 | + transition={{ duration: 1, ease: [0.16, 1, 0.3, 1] }} |
| 311 | + className="w-full max-w-2xl bg-black/40 backdrop-blur-2xl p-10 md:p-16 border border-white/10 shadow-2xl relative group" |
| 312 | + > |
| 313 | + {/* Industrial Accents */} |
| 314 | + <div className="absolute -top-px -left-px w-8 h-px bg-rose-500/50" /> |
| 315 | + <div className="absolute -top-px -left-px w-px h-8 bg-rose-500/50" /> |
| 316 | + <div className="absolute -bottom-px -right-px w-8 h-px bg-rose-500/50" /> |
| 317 | + <div className="absolute -bottom-px -right-px w-px h-8 bg-rose-500/50" /> |
| 318 | + |
| 319 | + {/* Component Identifier */} |
| 320 | + <div className="mb-6 flex items-center gap-3"> |
| 321 | + <span className="text-[10px] font-instr-sans font-bold uppercase tracking-[0.5em] text-rose-400"> |
| 322 | + System Module / 0{index + 1} |
| 323 | + </span> |
| 324 | + <div className="h-px w-12 bg-rose-500/30" /> |
| 325 | + </div> |
| 326 | + |
| 327 | + <h2 className="text-4xl md:text-6xl font-instr-serif font-medium tracking-tight text-white mb-8 leading-tight"> |
| 328 | + {section.title} |
| 329 | + </h2> |
| 330 | + |
| 331 | + <div className="prose prose-invert prose-xl font-garamond |
| 332 | + prose-p:text-gray-200 prose-p:leading-relaxed prose-p:font-light |
| 333 | + prose-li:text-gray-300 prose-strong:text-rose-400 prose-strong:font-medium |
| 334 | + prose-h2:text-2xl prose-h2:font-instr-serif prose-h2:mb-4 prose-h2:mt-8 |
| 335 | + prose-h3:text-xl prose-h3:font-instr-serif prose-h3:text-rose-500"> |
| 336 | + <MarkdownContent content={content || ''} /> |
| 337 | + </div> |
| 338 | + |
| 339 | + <div className="mt-12 pt-8 border-t border-white/5 flex items-center justify-between"> |
| 340 | + <div className="text-[10px] font-mono text-white/20 uppercase tracking-widest"> |
| 341 | + ID: ###-{section.id.toUpperCase()}-### |
| 342 | + </div> |
| 343 | + </div> |
| 344 | + </motion.div> |
| 345 | + </div> |
| 346 | + </div> |
| 347 | + |
| 348 | + {/* Side Label */} |
| 349 | + <div className={`absolute top-1/2 -translate-y-1/2 hidden xl:block mix-blend-difference opacity-30 font-instr-sans text-[10px] uppercase tracking-[1em] [writing-mode:vertical-lr] ${isEven ? 'right-12 rotate-180' : 'left-12'}`}> |
| 350 | + Module Archive / 0{index + 1} |
| 351 | + </div> |
| 352 | + </section> |
| 353 | + ); |
| 354 | +}; |
| 355 | + |
| 356 | +export default LandscapeProjectPage; |
0 commit comments