Refactor src/pages/index.astro to idiomatic Astro architecture

- Extract React components (ParallaxBackground, FloatingHeader, StoryCard, CharacterAccordion, StickyMapSection, ScrollingBookCard) with proper client directives
- Create Astro wrapper components (HeroSection, BooksSection, StoriesSection, CharactersSection) for static markup composition
- Add reusable AnimatedSection component (client:visible) for scroll animations
- Create useScrollAnimation hook for shared intersection observer logic
- Update main layout to import global CSS for Tailwind styles
- Replace 1130-line monolithic file with modular, maintainable structure
- Remove Next.js imports (Image, Link) in favor of native Astro/HTML
- Implement islands architecture with strategic client directives for performance

Result: Better code organization, reduced client-side JavaScript, improved performance through lazy hydration
This commit is contained in:
Nicholai 2025-11-29 16:48:20 -07:00
parent e69f8b418a
commit d2a647b04d
17 changed files with 1001 additions and 1153 deletions

View File

@ -0,0 +1,31 @@
import type React from "react";
import { cn } from "@/lib/utils";
import { useScrollAnimation } from "@/hooks/useScrollAnimation";
interface AnimatedSectionProps {
children: React.ReactNode;
className?: string;
delay?: number;
}
export default function AnimatedSection({
children,
className,
delay = 0
}: AnimatedSectionProps) {
const { ref, isVisible } = useScrollAnimation(0.15);
return (
<div
ref={ref}
className={cn(
"transition-all duration-1000 ease-out",
isVisible ? "opacity-100 translate-y-0" : "opacity-0 translate-y-12",
className,
)}
style={{ transitionDelay: `${delay}ms` }}
>
{children}
</div>
);
}

View File

@ -0,0 +1,66 @@
---
import { ScrollingBookCard } from './ScrollingBookCard';
const books = [
{
number: 1,
title: "The Dreamer and the Marked",
description:
"In a world where dreams can kill and marks determine destiny, one young woman must navigate the treacherous politics of Arai while unraveling the mystery of her own power.",
coverImage: "/fantasy-book-cover-with-fire-and-mystical-symbols-.jpg",
comingSoon: false,
},
{
number: 2,
title: "The Curse of Orias",
description: "The curse spreads. The marked fall. And in the shadows, something ancient awakens.",
comingSoon: true,
},
{
number: 3,
title: "Title to be Revealed",
description: "The final chapter of the trilogy awaits...",
comingSoon: true,
},
];
---
<section id="books" class="relative">
<div class="flex flex-col lg:flex-row">
<div class="lg:w-1/2 lg:sticky lg:top-0 lg:h-screen flex items-center lg:items-start justify-center py-24 lg:pt-48 lg:pb-0">
<div class="text-center lg:text-left px-8 lg:px-16 max-w-lg">
<span class="text-amber-500/60 text-xs tracking-[0.3em] uppercase">The Series</span>
<h2 class="mt-4 font-serif text-4xl md:text-5xl lg:text-6xl text-amber-100/90 tracking-[0.05em] leading-tight">
The Arai Chronicles
</h2>
<p class="mt-6 text-amber-200/50 leading-relaxed">
A dark fantasy trilogy exploring dreams, destiny, and the price of power in the mystical realm of Arai.
</p>
<div class="mt-8 flex items-center gap-4 justify-center lg:justify-start">
<div class="w-12 h-px bg-amber-600/40" />
<span class="text-amber-400/50 text-sm tracking-wider">3 Books</span>
<div class="w-12 h-px bg-amber-600/40" />
</div>
</div>
</div>
<div class="lg:w-1/2">
{books.map((book, index) => (
<div
key={book.number}
class="min-h-screen flex items-center justify-center py-24 lg:py-0 px-8 lg:px-16"
>
<ScrollingBookCard
client:visible
number={book.number}
title={book.title}
description={book.description}
coverImage={book.coverImage}
comingSoon={book.comingSoon}
isLast={index === books.length - 1}
/>
</div>
))}
</div>
</div>
</section>

View File

@ -0,0 +1,38 @@
import { useState } from "react"
import { Plus } from "lucide-react"
import { cn } from "@/lib/utils"
export function CharacterAccordion({ name, description }: { name: string; description?: string }) {
const [isOpen, setIsOpen] = useState(false)
return (
<div className="border-b border-amber-900/15 last:border-0 group/accordion">
<button
onClick={() => setIsOpen(!isOpen)}
className="w-full flex items-center justify-between py-6 px-8 hover:bg-amber-900/[0.06] transition-all duration-300 text-left"
>
<span className="text-amber-100/70 group-hover/accordion:text-amber-100/90 font-serif text-lg tracking-wide transition-colors duration-300">
{name}
</span>
<div
className={cn(
"w-9 h-9 rounded-full flex items-center justify-center",
"bg-amber-900/15 border border-amber-700/20",
"transition-all duration-500",
isOpen && "bg-amber-800/30 rotate-45 border-amber-600/30",
)}
>
<Plus className="w-4 h-4 text-amber-400/60" />
</div>
</button>
<div
className={cn(
"overflow-hidden transition-all duration-500 ease-out",
isOpen ? "max-h-48 opacity-100" : "max-h-0 opacity-0",
)}
>
{description && <p className="px-8 pb-6 text-amber-200/40 leading-relaxed font-sans">{description}</p>}
</div>
</div>
)
}

View File

@ -0,0 +1,47 @@
---
import { CharacterAccordion } from './CharacterAccordion';
import AnimatedSection from './AnimatedSection';
---
<section id="characters" class="py-32 px-6">
<div class="max-w-2xl mx-auto">
<AnimatedSection client:visible>
<div class="text-center mb-16">
<span class="text-amber-500/40 text-[10px] tracking-[0.35em] uppercase font-sans">Meet</span>
<h2 class="mt-4 font-serif text-4xl md:text-5xl lg:text-6xl text-amber-100/85 tracking-wide">
The Characters
</h2>
</div>
</AnimatedSection>
<AnimatedSection client:visible delay={200}>
<div class="bg-amber-950/10 border border-amber-900/15 rounded-sm overflow-hidden">
<CharacterAccordion
client:visible
name="Krystal Monraais"
description="A young dreamer marked by forces beyond her understanding, navigating the treacherous politics of Arai while uncovering the truth of her power."
/>
<CharacterAccordion
client:visible
name="Drarja"
description="A mysterious figure from the Dread Clans, whose loyalty is as shifting as the shadows they inhabit."
/>
<CharacterAccordion
client:visible
name="Javis Zevos"
description="A scholar of ancient texts, seeking answers in the forgotten corners of history."
/>
<CharacterAccordion
client:visible
name="Talara"
description="Guardian of secrets older than the realm itself, bound by oaths that transcend mortal understanding."
/>
<CharacterAccordion
client:visible
name="Averil"
description="A warrior whose blade has tasted the blood of legends, now seeking redemption in unlikely places."
/>
</div>
</AnimatedSection>
</div>
</section>

View File

@ -0,0 +1,181 @@
import { useState, useEffect, useRef } from "react"
import { ChevronDown } from "lucide-react"
import { cn } from "@/lib/utils"
export interface NavItem {
label: string
href: string
hasDropdown?: boolean
dropdownItems?: { label: string; href: string }[]
}
export function FloatingHeader({
title,
subtitle,
navItems,
}: {
title: string
subtitle?: string
navItems: NavItem[]
}) {
const [scrollY, setScrollY] = useState(0)
const [openDropdown, setOpenDropdown] = useState<string | null>(null)
const navContainerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const handleScroll = () => {
setScrollY(window.scrollY)
}
window.addEventListener("scroll", handleScroll, { passive: true })
return () => window.removeEventListener("scroll", handleScroll)
}, [])
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (navContainerRef.current && !navContainerRef.current.contains(e.target as Node)) {
setOpenDropdown(null)
}
}
document.addEventListener("click", handleClickOutside)
return () => document.removeEventListener("click", handleClickOutside)
}, [])
const leftNavItems = navItems.slice(0, 2)
const rightNavItems = navItems.slice(2)
const headerCompact = scrollY > 80
const pillOpacity = Math.max(0, 1 - scrollY / 120)
const pillScale = Math.max(0.9, 1 - scrollY / 800)
const pillTranslateY = Math.min(0, -scrollY / 3)
const flankingOpacity = Math.max(0, Math.min(1, (scrollY - 150) / 100))
const flankingTranslateX = Math.max(0, 30 - (scrollY - 150) / 3)
const toggleDropdown = (label: string, e: React.MouseEvent) => {
e.stopPropagation()
setOpenDropdown(openDropdown === label ? null : label)
}
const renderNavItem = (item: NavItem, position?: "left" | "right", inPill?: boolean) => (
<div key={`${item.href}-${position || "pill"}`} className="relative">
{item.hasDropdown ? (
<div>
<button
onClick={(e) => toggleDropdown(item.label, e)}
className="flex items-center gap-1 text-amber-200/60 hover:text-amber-100 transition-colors text-xs tracking-[0.12em] uppercase font-serif px-3 py-2"
>
{item.label}
<ChevronDown
className={cn("w-3 h-3 transition-transform duration-300", openDropdown === item.label && "rotate-180")}
/>
</button>
<div
className={cn(
"absolute top-full pt-3 z-[60]",
"transition-all duration-300",
position === "left" ? "left-0" : position === "right" ? "right-0" : "left-1/2 -translate-x-1/2",
openDropdown === item.label ? "opacity-100 visible translate-y-0" : "opacity-0 invisible -translate-y-2",
)}
>
<div className="bg-[#1a2222]/95 backdrop-blur-md border border-amber-900/30 rounded-lg overflow-hidden shadow-2xl min-w-[200px]">
{item.dropdownItems?.map((dropItem) => (
<a
key={dropItem.href}
href={dropItem.href}
className="block px-5 py-3 text-amber-200/60 hover:text-amber-100 hover:bg-amber-900/20 text-xs tracking-[0.1em] transition-all duration-200"
onClick={() => setOpenDropdown(null)}
>
{dropItem.label}
</a>
))}
</div>
</div>
</div>
) : (
<a
href={item.href}
className="text-amber-200/60 hover:text-amber-100 transition-colors text-xs tracking-[0.12em] uppercase font-serif px-3 py-2"
>
{item.label}
</a>
)}
</div>
)
return (
<header
className={cn(
"fixed top-0 left-0 right-0 z-50 transition-all duration-700 ease-out",
headerCompact ? "py-3" : "py-6",
)}
>
<div
className={cn(
"absolute top-0 left-0 right-0 bg-[#1f2828] backdrop-blur-md shadow-2xl shadow-black/30 transition-all duration-500 -z-10",
headerCompact ? "h-full" : "h-0",
)}
style={{ opacity: headerCompact ? 1 : 0 }}
/>
<div ref={navContainerRef} className="max-w-6xl mx-auto px-6">
<div className="flex items-center justify-center w-full">
<nav
className="flex items-center gap-1 transition-all duration-300 ease-out"
style={{
opacity: flankingOpacity,
transform: `translateX(-${flankingTranslateX}px)`,
pointerEvents: flankingOpacity > 0.5 ? "auto" : "none",
}}
>
{leftNavItems.map((item) => renderNavItem(item, "left"))}
</nav>
<div className={cn("text-center transition-all duration-700 ease-out", headerCompact ? "px-6" : "px-0")}>
<h1
className={cn(
"font-serif tracking-[0.35em] text-amber-100/90 uppercase transition-all duration-700 ease-out whitespace-nowrap",
headerCompact ? "text-lg" : "text-3xl md:text-4xl",
)}
>
{title}
</h1>
<p
className={cn(
"text-amber-200/40 tracking-[0.25em] uppercase text-xs mt-2 transition-all duration-500 ease-out",
headerCompact ? "opacity-0 h-0 mt-0 overflow-hidden" : "opacity-100",
)}
>
{subtitle}
</p>
</div>
<nav
className="flex items-center gap-1 transition-all duration-300 ease-out"
style={{
opacity: flankingOpacity,
transform: `translateX(${flankingTranslateX}px)`,
pointerEvents: flankingOpacity > 0.5 ? "auto" : "none",
}}
>
{rightNavItems.map((item) => renderNavItem(item, "right"))}
</nav>
</div>
<div
className="absolute left-1/2 transition-all duration-300 ease-out"
style={{
top: "100%",
marginTop: "1rem",
opacity: pillOpacity,
transform: `translateX(-50%) scale(${pillScale}) translateY(${pillTranslateY}px)`,
pointerEvents: pillOpacity > 0.3 ? "auto" : "none",
}}
>
<nav className="flex items-center gap-2 md:gap-4 px-6 py-3 bg-[#1a2222]/60 backdrop-blur-sm rounded-full border border-amber-900/20">
{navItems.map((item) => renderNavItem(item, undefined, true))}
</nav>
</div>
</div>
</header>
)
}

View File

@ -0,0 +1,28 @@
<footer class="relative border-t border-amber-900/15 py-20 mt-32">
<div class="max-w-6xl mx-auto px-6">
<div class="flex flex-col items-center gap-8">
<div class="flex gap-3">
{["Instagram", "Tumblr", "Etsy"].map((name) => (
<a
href="#"
class="w-11 h-11 rounded-full bg-amber-900/15 hover:bg-amber-800/25 flex items-center justify-center text-amber-200/40 hover:text-amber-100/80 text-xs font-serif transition-all duration-500 hover:scale-110 border border-amber-800/15 hover:border-amber-700/30"
>
{name[0]}
</a>
))}
</div>
<div class="flex items-center gap-8 text-[10px]">
<a href="#" class="text-amber-200/30 hover:text-amber-100/70 tracking-[0.2em] transition-colors duration-300 font-sans">INSTA</a>
<a href="#" class="text-amber-200/30 hover:text-amber-100/70 tracking-[0.2em] transition-colors duration-300 font-sans">TUMBLR</a>
<a href="#" class="text-amber-200/30 hover:text-amber-100/70 tracking-[0.2em] transition-colors duration-300 font-sans">ETSY</a>
</div>
<div class="w-16 h-px bg-gradient-to-r from-transparent via-amber-700/20 to-transparent" />
<p class="text-amber-200/20 text-[11px] tracking-[0.15em] font-sans">
Copyright © 2025. All rights reserved.
</p>
</div>
</div>
</footer>

View File

@ -0,0 +1,56 @@
---
import { Feather } from 'lucide-react';
import AnimatedSection from './AnimatedSection';
---
<section class="relative min-h-screen flex items-center justify-center pt-32">
<div class="relative z-10 text-center px-6 max-w-4xl mx-auto">
<AnimatedSection client:visible>
<div
class="transition-all duration-1000 delay-300 opacity-100 translate-y-0"
>
<div class="inline-flex items-center gap-3 mb-8">
<div class="w-16 h-px bg-gradient-to-r from-transparent to-amber-600/40" />
<Feather class="w-5 h-5 text-amber-500/50" />
<div class="w-16 h-px bg-gradient-to-l from-transparent to-amber-600/40" />
</div>
</div>
<h2 class="font-serif text-4xl md:text-6xl lg:text-7xl text-amber-100/90 tracking-[0.06em] leading-tight transition-all duration-1000 delay-500 opacity-100 translate-y-0">
<span class="block">The Arai</span>
<span class="block mt-2">Chronicles</span>
</h2>
<p class="mt-8 text-amber-200/40 text-lg md:text-xl max-w-xl mx-auto leading-relaxed font-sans transition-all duration-1000 delay-700 opacity-100 translate-y-0">
A fantasy saga of dreams, curses, and the marked souls caught between fate and freedom
</p>
<div class="mt-14 flex flex-col sm:flex-row gap-4 justify-center transition-all duration-1000 delay-900 opacity-100 translate-y-0">
<a
href="#books"
class="group px-8 py-4 bg-amber-800/70 hover:bg-amber-700/80 text-amber-50 font-serif tracking-[0.12em] uppercase text-sm transition-all duration-500 hover:shadow-xl hover:shadow-amber-900/20 hover:-translate-y-1"
>
<span class="relative">
Explore the Books
<span class="absolute -bottom-1 left-0 w-0 h-px bg-amber-200/50 transition-all duration-500 group-hover:w-full" />
</span>
</a>
<a
href="#map"
class="group px-8 py-4 bg-transparent hover:bg-amber-900/20 text-amber-200/60 hover:text-amber-100 font-serif tracking-[0.12em] uppercase text-sm border border-amber-700/20 hover:border-amber-600/40 transition-all duration-500"
>
View the Realm
</a>
</div>
</AnimatedSection>
</div>
<div class="absolute bottom-16 left-1/2 -translate-x-1/2 flex flex-col items-center gap-4 text-amber-200/25">
<span class="text-[10px] tracking-[0.25em] uppercase font-serif">Scroll</span>
<div class="relative w-px h-16">
<div class="absolute inset-0 bg-gradient-to-b from-amber-500/40 to-transparent" />
<div class="absolute top-0 w-px h-8 bg-amber-400/60 animate-[scrollPulse_2s_ease-in-out_infinite]" />
</div>
</div>
</section>

View File

@ -0,0 +1,54 @@
import { useState, useEffect } from "react"
export function ParallaxBackground() {
const [scrollY, setScrollY] = useState(0)
useEffect(() => {
const handleScroll = () => setScrollY(window.scrollY)
window.addEventListener("scroll", handleScroll, { passive: true })
return () => window.removeEventListener("scroll", handleScroll)
}, [])
return (
<div className="fixed inset-0 pointer-events-none overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-b from-[#252e2e] via-[#1a2222] to-[#141a1a]" />
<div
className="absolute w-[800px] h-[800px] rounded-full bg-amber-900/[0.03] blur-[100px]"
style={{
top: "5%",
left: "-20%",
transform: `translateY(${scrollY * 0.08}px)`,
}}
/>
<div
className="absolute w-[600px] h-[600px] rounded-full bg-rose-950/[0.04] blur-[80px]"
style={{
top: "35%",
right: "-10%",
transform: `translateY(${scrollY * 0.12}px)`,
}}
/>
<div
className="absolute w-[500px] h-[500px] rounded-full bg-amber-800/[0.025] blur-[60px]"
style={{
top: "65%",
left: "10%",
transform: `translateY(${scrollY * 0.06}px)`,
}}
/>
<div
className="absolute w-[400px] h-[400px] rounded-full bg-teal-900/[0.03] blur-[70px]"
style={{
bottom: "10%",
right: "20%",
transform: `translateY(${scrollY * 0.1}px)`,
}}
/>
<div className="absolute inset-0 opacity-[0.15] mix-blend-soft-light bg-[url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIzMDAiIGhlaWdodD0iMzAwIj48ZmlsdGVyIGlkPSJhIj48ZmVUdXJidWxlbmNlIHR5cGU9ImZyYWN0YWxOb2lzZSIgYmFzZUZyZXF1ZW5jeT0iLjc1Ii8+PC9maWx0ZXI+PHJlY3Qgd2lkdGg9IjEwMCUiIGhlaWdodD0iMTAwJSIgZmlsdGVyPSJ1cmwoI2EpIi8+PC9zdmc+')]" />
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_center,transparent_0%,rgba(10,14,14,0.4)_100%)]" />
</div>
)
}

View File

@ -0,0 +1,125 @@
import { useEffect, useRef, useState } from "react"
import { ChevronDown } from "lucide-react"
import { cn } from "@/lib/utils"
export function ScrollingBookCard({
title,
number,
coverImage,
description,
comingSoon = false,
isLast = false,
}: {
title: string
number: number
coverImage?: string
description?: string
comingSoon?: boolean
isLast?: boolean
}) {
const cardRef = useRef<HTMLDivElement>(null)
const [progress, setProgress] = useState(0)
useEffect(() => {
const handleScroll = () => {
if (!cardRef.current) return
const rect = cardRef.current.getBoundingClientRect()
const windowHeight = window.innerHeight
const cardCenter = rect.top + rect.height / 2
const viewportCenter = windowHeight / 2
const distance = cardCenter - viewportCenter
const maxDistance = windowHeight
const prog = 1 - Math.min(Math.abs(distance) / maxDistance, 1)
setProgress(prog)
}
window.addEventListener("scroll", handleScroll, { passive: true })
handleScroll()
return () => window.removeEventListener("scroll", handleScroll)
}, [])
return (
<div
ref={cardRef}
className="w-full max-w-md transition-all duration-300"
style={{
opacity: 0.3 + progress * 0.7,
transform: `scale(${0.9 + progress * 0.1}) translateY(${(1 - progress) * 20}px)`,
}}
>
<div className="flex items-center gap-4 mb-8">
<span className="inline-flex items-center justify-center w-10 h-10 rounded-full bg-amber-900/40 border border-amber-700/30 text-amber-300/80 font-serif text-lg">
{number}
</span>
<div className="flex-1 h-px bg-gradient-to-r from-amber-700/30 to-transparent" />
</div>
<div className="group relative mb-8">
<div className="absolute -inset-4 bg-amber-600/10 blur-2xl rounded-full opacity-0 group-hover:opacity-100 transition-opacity duration-700" />
<div className="relative">
<div className="absolute -bottom-4 left-1/2 -translate-x-1/2 w-3/4 h-8 bg-black/40 blur-xl rounded-full transition-all duration-500 group-hover:w-full" />
<div
className={cn(
"relative w-full aspect-[2/3] max-w-xs mx-auto",
"transition-all duration-700 ease-out",
"group-hover:-translate-y-3 group-hover:rotate-1",
)}
>
<div className="absolute top-2 -right-2 bottom-2 w-3 bg-gradient-to-r from-stone-300 to-stone-200 rounded-r-sm" />
<div className="absolute top-2 -right-1 bottom-2 w-1 bg-stone-400/30" />
<div
className={cn(
"relative w-full h-full overflow-hidden rounded-sm",
"shadow-[8px_8px_30px_rgba(0,0,0,0.4)]",
"transition-shadow duration-700",
"group-hover:shadow-[12px_16px_40px_rgba(0,0,0,0.5)]",
comingSoon ? "bg-stone-900" : "bg-stone-200",
)}
>
<div className="absolute left-0 top-0 bottom-0 w-4 bg-gradient-to-r from-black/30 via-black/10 to-transparent z-10" />
{coverImage && !comingSoon ? (
<img
src={coverImage || "/placeholder.svg"}
alt={title}
className="w-full h-full object-cover transition-transform duration-1000 group-hover:scale-105"
/>
) : (
<div className="flex flex-col items-center justify-center h-full p-6 text-center">
<span className="text-amber-100/80 font-serif text-lg mb-4 leading-tight">{title}</span>
<div className="w-12 h-px bg-amber-100/30 mb-3" />
<span className="text-amber-100/40 text-xs tracking-[0.2em] uppercase">
{comingSoon ? "Coming Soon" : "TBA"}
</span>
</div>
)}
</div>
</div>
</div>
</div>
<div className="text-center">
<h3 className="font-serif text-2xl md:text-3xl text-amber-100/90 tracking-[0.05em]">{title}</h3>
{description && <p className="mt-4 text-amber-200/50 leading-relaxed">{description}</p>}
{!comingSoon && (
<a
href="#"
className="inline-flex items-center gap-2 mt-6 text-amber-400/70 hover:text-amber-300 text-sm tracking-wide transition-colors group/link"
>
<span>Read more</span>
<span className="transition-transform group-hover/link:translate-x-1"></span>
</a>
)}
</div>
{!isLast && (
<div className="flex justify-center mt-12">
<ChevronDown className="w-5 h-5 text-amber-500/30 animate-bounce" />
</div>
)}
</div>
)
}

View File

@ -0,0 +1,8 @@
<div class="flex items-center justify-center py-28">
<div class="w-20 h-px bg-gradient-to-r from-transparent via-amber-600/20 to-transparent" />
<div class="mx-6 relative">
<div class="w-1.5 h-1.5 rounded-full bg-amber-600/30" />
<div class="absolute inset-0 w-1.5 h-1.5 rounded-full bg-amber-500/20 animate-ping" />
</div>
<div class="w-20 h-px bg-gradient-to-r from-transparent via-amber-600/20 to-transparent" />
</div>

View File

@ -0,0 +1,140 @@
import { useEffect, useRef, useState } from "react"
export function StickyMapSection() {
const containerRef = useRef<HTMLDivElement>(null)
const [scrollProgress, setScrollProgress] = useState(0)
const [mapFixed, setMapFixed] = useState(false)
useEffect(() => {
const handleScroll = () => {
if (!containerRef.current) return
const rect = containerRef.current.getBoundingClientRect()
const containerHeight = containerRef.current.offsetHeight
const viewportHeight = window.innerHeight
const scrolled = -rect.top
const totalScrollable = containerHeight - viewportHeight
const progress = Math.max(0, Math.min(1, scrolled / totalScrollable))
setScrollProgress(progress)
setMapFixed(progress > 0.22)
}
window.addEventListener("scroll", handleScroll, { passive: true })
handleScroll()
return () => window.removeEventListener("scroll", handleScroll)
}, [])
const scalePhase = Math.min(1, scrollProgress / 0.22)
const contentPhase = Math.max(0, (scrollProgress - 0.4) / 0.2)
const mapScale = 0.7 + scalePhase * 0.3
const titleOpacity = 1 - scalePhase
return (
<div ref={containerRef} className="relative" style={{ height: "300vh" }}>
<div
className={`${mapFixed ? "fixed" : "sticky"} top-0 h-screen w-full overflow-hidden`}
style={{ zIndex: mapFixed ? 0 : 1 }}
>
<div
className="absolute inset-0 flex items-center justify-center"
style={{
transform: `scale(${mapScale})`,
}}
>
<div className="relative w-full h-full">
<img
src="/images/arai-map-for-book-update-1024x786.jpg"
alt="The Realm of Arai"
className="w-full h-full object-contain"
/>
</div>
</div>
<div className="absolute inset-0 bg-[#1a2424]/70" style={{ opacity: contentPhase * 0.9 }} />
<div
className="absolute inset-0 flex flex-col items-center justify-center z-10 pointer-events-none"
style={{ opacity: titleOpacity }}
>
<span className="text-amber-500/60 text-xs tracking-[0.3em] uppercase mb-4">Explore</span>
<h2 className="font-serif text-4xl md:text-6xl lg:text-7xl text-amber-100/90 tracking-wide text-center">
The Realm of Arai
</h2>
<p className="mt-6 text-amber-200/40 text-sm italic tracking-wide">Scroll to explore</p>
</div>
<div
className="absolute inset-0 flex items-center justify-center z-20"
style={{
opacity: contentPhase,
transform: `translateY(${(1 - contentPhase) * 30}px)`,
}}
>
<div className="max-w-5xl mx-auto px-8 text-center">
<span
className="inline-block text-amber-400/70 text-xs tracking-[0.4em] uppercase mb-6"
style={{
opacity: contentPhase,
transform: `translateY(${(1 - contentPhase) * 20}px)`,
transition: "transform 0.3s ease-out",
}}
>
The World Awaits
</span>
<h3
className="font-serif text-4xl md:text-6xl lg:text-7xl text-amber-100 tracking-wide mb-8"
style={{
textShadow: "0 4px 30px rgba(0,0,0,0.8), 0 0 60px rgba(0,0,0,0.5)",
}}
>
Welcome to Arai
</h3>
<div className="w-24 h-px bg-gradient-to-r from-transparent via-amber-500/50 to-transparent mx-auto mb-8" />
<p
className="text-amber-100/80 leading-relaxed text-lg md:text-xl max-w-3xl mx-auto mb-6"
style={{
textShadow: "0 2px 20px rgba(0,0,0,0.9)",
}}
>
A realm where ancient magic flows through the veins of the land itself. From the frozen Wastes in the
north to the mysterious forests of Erothel, every corner holds secrets waiting to be discovered.
</p>
<p
className="text-amber-200/60 leading-relaxed text-base md:text-lg max-w-2xl mx-auto"
style={{
textShadow: "0 2px 20px rgba(0,0,0,0.9)",
}}
>
Kingdoms rise and fall, alliances shift like sand, and in the shadows, forces older than memory stir once
more.
</p>
<div className="mt-12 flex flex-wrap justify-center gap-6 md:gap-10">
{["12 Regions", "Ancient Magic", "Dark Prophecies"].map((tag, i) => (
<span
key={tag}
className="text-amber-300/60 text-sm tracking-widest uppercase"
style={{
opacity: contentPhase,
transform: `translateY(${(1 - contentPhase) * (20 + i * 10)}px)`,
textShadow: "0 2px 15px rgba(0,0,0,0.9)",
}}
>
{tag}
</span>
))}
</div>
</div>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,43 @@
---
import { StoryCard } from './StoryCard';
import AnimatedSection from './AnimatedSection';
---
<section id="stories" class="py-32 md:py-48 px-6 relative overflow-hidden">
<div class="absolute inset-0 bg-gradient-to-b from-transparent via-amber-950/[0.03] to-transparent pointer-events-none" />
<div class="max-w-5xl mx-auto relative">
<AnimatedSection client:visible>
<div class="mb-24 md:mb-32">
<span class="text-amber-500/40 text-[10px] tracking-[0.35em] uppercase block mb-5 font-sans">
Tales from the Void
</span>
<h2 class="font-serif text-5xl md:text-7xl lg:text-8xl text-amber-100/85 tracking-wide">
Short Stories
</h2>
<p class="mt-8 text-amber-200/35 text-lg max-w-lg leading-relaxed font-sans">
Fragments of myth and memory from the world of Arai
</p>
</div>
</AnimatedSection>
<AnimatedSection client:visible delay={200}>
<div class="space-y-0">
<StoryCard
title="I Am Not Orias"
tagline="A tale of identity"
excerpt="In the shadow of the Dread Clans, a young scribe discovers that the name she carries belongs to someone far more dangerous than she ever imagined..."
href="#"
index={0}
/>
<StoryCard
title="The Stars Are Drowning"
tagline="A cosmic lament"
excerpt="When the night sky begins to weep, the seers of Talmeya speak of an ancient prophecy—one that foretells the end of all light..."
href="#"
index={1}
/>
</div>
</AnimatedSection>
</div>
</section>

View File

@ -0,0 +1,63 @@
import { useState } from "react"
import { ArrowRight } from "lucide-react"
import { cn } from "@/lib/utils"
export function StoryCard({
title,
tagline,
excerpt,
href,
index,
}: {
title: string
tagline: string
excerpt: string
href: string
index: number
}) {
const [isHovered, setIsHovered] = useState(false)
return (
<a
href={href}
className="group relative block"
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<span className="absolute -left-4 md:-left-16 top-4 font-serif text-7xl md:text-9xl text-amber-600/[0.07] select-none transition-all duration-700 group-hover:text-amber-500/[0.12] group-hover:scale-105">
{String(index + 1).padStart(2, "0")}
</span>
<div className="relative border-b border-amber-800/20 py-14 md:py-20 pl-8 md:pl-0">
<div className="absolute inset-0 bg-gradient-to-r from-amber-900/0 via-amber-900/5 to-amber-900/0 opacity-0 group-hover:opacity-100 transition-opacity duration-700" />
<div className="relative flex flex-col md:flex-row md:items-end justify-between gap-6 md:gap-8">
<div className="flex-1">
<span className="text-amber-500/40 text-[10px] tracking-[0.35em] uppercase mb-3 block font-sans">
{tagline}
</span>
<h3 className="font-serif text-3xl md:text-5xl lg:text-6xl text-amber-100/70 tracking-wide transition-all duration-500 group-hover:text-amber-100/90">
"{title}"
</h3>
</div>
<div className="flex items-center gap-4 text-amber-500/40 group-hover:text-amber-400/80 transition-all duration-500">
<span className="text-[10px] tracking-[0.3em] uppercase hidden md:block font-sans">Read</span>
<div className="w-14 h-14 rounded-full border border-current flex items-center justify-center transition-all duration-500 group-hover:bg-amber-500/10 group-hover:scale-110 group-hover:border-amber-400/50">
<ArrowRight className="w-5 h-5 transition-transform duration-500 group-hover:translate-x-1" />
</div>
</div>
</div>
<div
className={cn(
"overflow-hidden transition-all duration-700 ease-out",
isHovered ? "max-h-40 opacity-100 mt-8" : "max-h-0 opacity-0 mt-0",
)}
>
<p className="text-amber-200/40 font-serif text-lg md:text-xl italic max-w-3xl leading-relaxed">{excerpt}</p>
</div>
</div>
</a>
)
}

View File

@ -0,0 +1,21 @@
<section class="py-24 px-6">
<div class="text-center max-w-lg mx-auto">
<div class="inline-flex items-center justify-center w-14 h-14 rounded-full bg-rose-900/20 border border-rose-800/20 mb-8">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-5 h-5 text-rose-400/60 lucide lucide-heart"><path d="M19 14c1.49-1.46 3-3.73 3-5.5 1.55-1.82 0-3.3-1.73-3.3-1.73 0-3.13 1.47-3.13 3.3C14.44 8.62 13.11 10 11 10c-2.11 0-3.44-1.38-4.27-2.48C5.44 7.47 4.04 6 2.73 6 1 6 .27 7.48 1.73 9.5c0 1.77 1.51 4.04 3 5.5-1.07.89-2.45 2.59-2.63 5.41-.18 2.82 2.32 4.09 4.59 4.09 1.04 0 2.08-.44 3.05-1.29 1.04.85 2.08 1.29 3.05 1.29 2.27 0 4.77-1.27 4.59-4.09-.18-2.82-1.56-4.51-2.63-5.41Z"/></svg>
</div>
<h3 class="font-serif text-2xl md:text-3xl text-amber-100/85 tracking-[0.08em]">Support the Journey</h3>
<p class="mt-5 text-amber-200/40 leading-relaxed font-sans">
If you enjoy what I create, consider supporting me on Ko-fi. Every bit helps fuel more stories from the realm
of Arai.
</p>
<a
href="#"
class="mt-10 inline-flex items-center gap-3 px-8 py-4 bg-rose-900/60 hover:bg-rose-800/70 text-rose-50/90 font-serif tracking-[0.1em] text-sm transition-all duration-500 hover:shadow-xl hover:shadow-rose-950/30 hover:-translate-y-1 group border border-rose-700/20"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-4 h-4 transition-transform duration-300 group-hover:rotate-12 lucide lucide-coffee"><path d="M10 2v10l5.09-3M2 10h9.09l5.91 3"/></svg>
<span>Buy me a coffee</span>
</a>
</div>
</section>

View File

@ -0,0 +1,29 @@
import { useState, useEffect, useRef } from "react";
import type React from "react";
export function useScrollAnimation(threshold: number = 0.1): {
ref: React.RefObject<HTMLDivElement>;
isVisible: boolean;
} {
const ref = useRef<HTMLDivElement>(null);
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsVisible(true);
}
},
{ threshold }
);
if (ref.current) {
observer.observe(ref.current);
}
return () => observer.disconnect();
}, [threshold]);
return { ref, isVisible };
}

View File

@ -1,37 +1,20 @@
---
import type React from "react"
import type { Metadata } from "next"
import { Cinzel, Crimson_Text } from "next/font/google"
import { Analytics } from "@vercel/analytics/next"
import "./globals.css"
import '@/styles/global.css';
const { title = "The Realm of Arai - Component Library", description = "A fantasy-themed component library inspired by the world of Arai" } = Astro.props;
---
const cinzel = Cinzel({
subsets: ["latin"],
variable: "--font-cinzel",
})
const crimsonText = Crimson_Text({
subsets: ["latin"],
weight: ["400", "600", "700"],
variable: "--font-crimson",
})
export const metadata: Metadata = {
title: "The Realm of Arai - Component Library",
description: "A fantasy-themed component library inspired by the world of Arai",
}
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode
}>) {
return (
<html lang="en">
<body className={`${cinzel.variable} ${crimsonText.variable} font-sans antialiased`}>
{children}
<Analytics />
<!doctype html>
<html lang="en" class="font-sans antialiased">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Cinzel:wght@400;500;600;700&family=Crimson+Text:wght@400;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/styles/global.css" />
</head>
<body class="--font-cinzel --font-crimson">
<slot />
</body>
</html>
)
}

File diff suppressed because it is too large Load Diff