Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,30 @@ h1, h2, h3, h4, h5, h6 {
color: var(--bg-surface);
}

/* Moleskine notebook cards */
.moleskine-notebook {
position: relative;
transition: transform 0.4s ease-in-out;
perspective: 800px;
}

.notebook-cover {
transition: transform 0.5s linear, box-shadow 0.5s linear;
transform-style: preserve-3d;
transform-origin: left center 0px;
}

@media (hover: hover) {
.moleskine-notebook:hover {
transform: rotateZ(-10deg);
}
.moleskine-notebook:hover .notebook-cover {
transform: rotateY(-50deg);
z-index: 999;
box-shadow: 20px 10px 50px rgba(0, 0, 0, 0.2);
}
}

/* Custom select dropdown styling */
select {
appearance: none;
Expand Down
122 changes: 59 additions & 63 deletions src/components/StoryCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,100 +23,96 @@ export function StoryCard({
: false;

return (
<div className="group flex flex-col" style={{ perspective: "800px" }}>
{/* 3D Book with spine */}
<div className="flex flex-col">
<Link
href={`/story/${storyline.storyline_id}`}
className="relative block transition-transform duration-300 ease-out [transform:rotateY(-3deg)] group-hover:[transform:rotateY(0deg)_translateY(-6px)]"
style={{ transformStyle: "preserve-3d" }}
className="moleskine-notebook group relative block"
>
{/* Drop shadow beneath book — grows on hover */}
<div className="pointer-events-none absolute -bottom-2 left-2 right-2 h-4 rounded-sm bg-[var(--shelf-shadow)] blur-md transition-all duration-300 group-hover:-bottom-3 group-hover:left-1 group-hover:right-1 group-hover:blur-lg" />

{/* Spine — hardcover hinge */}
<div
className="absolute inset-y-0 left-0 w-5 rounded-l-sm"
style={{
background: "linear-gradient(to right, #1A0F0A, #2C1810 40%, #1A0F0A 48%, #0E0806 52%, #2C1810 56%, #3A2A1A)",
transform: "translateZ(-2px)",
}}
/>

{/* Page edges visible on right side */}
<div
className="pointer-events-none absolute inset-y-1 -right-[3px] w-[3px] rounded-r-[1px]"
style={{
background:
"repeating-linear-gradient(to bottom, #F5EFE4 0px, #F5EFE4 1px, #E8DFD0 1px, #E8DFD0 2px)",
}}
/>

{/* Page edges visible on bottom */}
{/* Page underneath — revealed when cover opens */}
<div
className="pointer-events-none absolute -bottom-[3px] left-5 right-0 h-[3px] rounded-b-[1px]"
className="notebook-page absolute inset-0 z-0 overflow-hidden"
style={{
background:
"repeating-linear-gradient(to right, #F5EFE4 0px, #F5EFE4 1px, #E8DFD0 1px, #E8DFD0 2px)",
borderRadius: "5px 16px 16px 5px",
backgroundColor: "#FFF8EE",
backgroundImage:
"repeating-linear-gradient(to bottom, transparent 0px, transparent 27px, #e8dfd0 27px, #e8dfd0 28px)",
}}
/>
>
<div className="flex h-full flex-col justify-between px-5 py-5">
<div className="mt-6 space-y-2 text-xs text-[var(--text-muted)]">
<p className="font-body italic leading-relaxed">
{storyline.plot_count} {storyline.plot_count === 1 ? "plot" : "plots"} linked
</p>
{storyline.token_address && (
<p className="font-body italic">
<StoryCardTVL tokenAddress={storyline.token_address} />
</p>
)}
</div>
<p className="text-[10px] text-[var(--text-muted)]">Open to read →</p>
</div>
</div>

{/* Front cover */}
{/* Cover — opens on hover (desktop) */}
<div
className="relative flex aspect-[2/3] flex-col justify-between overflow-hidden rounded-sm rounded-l-none border border-[var(--border)] transition-[border-color,box-shadow] duration-300 group-hover:border-[var(--accent-dim)] group-hover:shadow-lg"
className="notebook-cover relative z-10 flex aspect-[2/3] flex-col overflow-hidden border border-[var(--border)]"
style={{
backgroundColor: "#FFF8EE",
backgroundImage: "url(data:image/png;base64,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)",
boxShadow: "0 0 30px rgba(143, 89, 34, 0.08) inset",
borderRadius: "5px 15px 15px 5px",
backgroundColor: "var(--accent)",
boxShadow: "2px 4px 12px rgba(44, 24, 16, 0.15)",
}}
>
{/* Spine inner shadow overlay */}
{/* Elastic band */}
<div
className="pointer-events-none absolute inset-y-0 left-0 w-8"
className="pointer-events-none absolute inset-y-[-1px] right-[22px] z-20 w-[8px] rounded-[2px]"
style={{
background:
"linear-gradient(to right, rgba(26,15,10,0.18), transparent)",
"repeating-linear-gradient(to bottom, var(--accent-dim) 0px, var(--accent-dim) 2px, #5A2A0E 2px, #5A2A0E 4px)",
boxShadow: "1px 0 2px rgba(0,0,0,0.15), -1px 0 2px rgba(0,0,0,0.1)",
}}
/>

{/* Content */}
<div className="relative flex flex-1 flex-col justify-between py-5 pl-7 pr-4">
{/* Top: genre badge + completion */}
{/* Top area: genre badge */}
<div className="relative z-10 px-4 pt-4">
<div className="flex items-start justify-between gap-2">
<span className="rounded-sm bg-[var(--accent)]/15 px-2 py-0.5 text-[9px] font-semibold uppercase tracking-widest text-[var(--accent-dim)]">
<span className="rounded-sm bg-white/20 px-2 py-0.5 text-[9px] font-semibold uppercase tracking-widest text-white/80">
{displayGenre || "Uncategorized"}
</span>
{storyline.sunset && (
<span className="rounded-sm border border-[var(--border)] px-1.5 py-0.5 text-[9px] text-[var(--text-muted)]">
<span className="rounded-sm border border-white/20 px-1.5 py-0.5 text-[9px] text-white/60">
complete
</span>
)}
</div>
</div>

{/* Center: title displayed like printed book cover */}
<div className="flex flex-1 flex-col items-center justify-center px-1 text-center">
<h3 className="font-heading text-base font-bold leading-tight tracking-tight text-accent sm:text-lg">
{storyline.title}
</h3>
{storyline.language && storyline.language !== "English" && (
<span className="mt-2 text-[10px] text-[var(--text-muted)]">
{storyline.language}
</span>
)}
</div>

{/* Bottom: author name like a printed book */}
<div className="flex items-center justify-center gap-1 text-xs text-[var(--text-muted)]">
<WriterIdentityClient address={storyline.writer_address} linkProfile={false} />
{storyline.writer_type === 1 && <AgentBadge />}
</div>
{/* Label band with title */}
<div
className="relative z-10 mx-0 mt-auto px-4 py-3"
style={{
backgroundColor: "#e8e8e0",
boxShadow: "0 1px 2px rgba(0,0,0,0.15)",
}}
>
<h3 className="font-heading text-center text-sm font-bold leading-tight tracking-tight text-[#2C1810] sm:text-base">
{storyline.title}
</h3>
{storyline.language && storyline.language !== "English" && (
<span className="mt-1 block text-center text-[10px] text-[var(--text-muted)]">
{storyline.language}
</span>
)}
</div>

{/* Decorative horizontal rule near bottom */}
<div className="mx-5 mb-4 h-px bg-[var(--border)]/60" />
{/* Bottom: author */}
<div className="relative z-10 flex items-center justify-center gap-1 px-4 py-3 text-xs text-white/70">
<WriterIdentityClient address={storyline.writer_address} linkProfile={false} />
{storyline.writer_type === 1 && <AgentBadge />}
</div>
</div>
</Link>

{/* Metadata below book */}
{/* Metadata below notebook */}
<div className="mt-2.5 flex flex-col gap-0.5 pl-1 pr-1 text-[10px] text-[var(--text-muted)]">
{storyline.token_address && (
<span className="whitespace-nowrap">
Expand Down
79 changes: 4 additions & 75 deletions src/components/StoryGrid.tsx
Original file line number Diff line number Diff line change
@@ -1,98 +1,27 @@
"use client";

import { useState, useEffect } from "react";
import { type Address } from "viem";
import { type Storyline } from "../../lib/supabase";
import { BatchTokenDataProvider } from "./BatchTokenDataProvider";
import { StoryCard } from "./StoryCard";

/**
* Groups an array into chunks of the given size.
*/
function chunk<T>(arr: T[], size: number): T[][] {
const result: T[][] = [];
for (let i = 0; i < arr.length; i += size) {
result.push(arr.slice(i, i + size));
}
return result;
}

/**
* Hook that returns the current shelf size (columns per row).
* Uses matchMedia to stay in sync with the CSS breakpoint.
*/
function useShelfSize(): number {
const [cols, setCols] = useState(3);

useEffect(() => {
const mql = window.matchMedia("(min-width: 1024px)");
const update = () => setCols(mql.matches ? 3 : 2);
update();
mql.addEventListener("change", update);
return () => mql.removeEventListener("change", update);
}, []);

return cols;
}

/**
* A single bookshelf row: books sitting on a visible shelf surface.
*/
function ShelfRow({ children, cols }: { children: React.ReactNode; cols: number }) {
return (
<div className="relative pb-3">
{/* Books */}
<div
className="relative z-10 grid gap-x-4 gap-y-0"
style={{ gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))` }}
>
{children}
</div>

{/* Shelf surface */}
<div
className="relative -mt-1 h-3 rounded-b-sm"
style={{
background: "linear-gradient(to bottom, var(--bg-shelf), #D0C4B0)",
boxShadow: "0 4px 8px -2px var(--shelf-shadow), 0 2px 4px -1px var(--shelf-shadow)",
}}
/>
{/* Shelf front edge */}
<div
className="h-[2px]"
style={{
background: "linear-gradient(to right, transparent, var(--border), transparent)",
}}
/>
</div>
);
}

/**
* Story card grid wrapped in BatchTokenDataProvider.
* Fetches price + TVL for all visible stories in a single multicall
* instead of 4 individual RPC calls per card.
*
* Books are displayed on shelves — each visual row of books sits on
* a visible shelf surface. Shelf size adapts to viewport (2 on mobile, 3 on desktop).
* Plain responsive grid — 2 columns on mobile, 3 on desktop.
*/
export function StoryGrid({ storylines }: { storylines: Storyline[] }) {
const tokenAddresses = storylines
.map((s) => s.token_address)
.filter((addr): addr is string => !!addr) as Address[];

const cols = useShelfSize();
const shelves = chunk(storylines, cols);

return (
<BatchTokenDataProvider tokenAddresses={tokenAddresses}>
<div className="mt-6 flex flex-col gap-6">
{shelves.map((shelf, i) => (
<ShelfRow key={`${cols}-${i}`} cols={cols}>
{shelf.map((s) => (
<StoryCard key={s.id} storyline={s} />
))}
</ShelfRow>
<div className="mt-6 grid grid-cols-2 gap-x-6 gap-y-8 lg:grid-cols-3">
{storylines.map((s) => (
<StoryCard key={s.id} storyline={s} />
))}
</div>
</BatchTokenDataProvider>
Expand Down
Loading