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
19 changes: 12 additions & 7 deletions src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,17 +42,22 @@ export default async function Home({

return (
<div className="mx-auto max-w-5xl px-6 py-10">
{/* Compact hero */}
<header className="mb-8">
<h1 className="text-accent text-xl font-bold tracking-tight">
Your story is a token.
{/* Hero: featured section */}
<header className="mb-10">
<h1 className="font-heading text-2xl font-bold tracking-tight text-[var(--accent)] sm:text-3xl">
The Bookshelf
</h1>
<p className="text-muted mt-1 text-sm">
Every plot you publish drives the market — and every trade pays you. Write more, earn more.
<p className="mt-2 font-body text-sm leading-relaxed text-[var(--text-muted)]">
Every plot you publish drives the market — and every trade pays you. Browse the shelf, pick a story.
</p>
</header>

{/* Filter bar */}
{/* Section heading + filter bar */}
<div className="mb-2">
<h2 className="font-body text-base font-normal italic text-[var(--text-muted)]">
{tab === "trending" ? "Trending" : "New & Noteworthy"}
</h2>
</div>
<FilterBar writer={writer} genre={genre} lang={lang} tab={tab} />

{/* Story grid — batched multicall for price/TVL */}
Expand Down
10 changes: 5 additions & 5 deletions src/components/FilterBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ export function FilterBar({ writer, genre, lang, tab }: FilterBarProps) {

return (
<div ref={barRef}>
<div className="border-border flex min-w-0 items-center gap-x-3 rounded border px-3 py-2 text-xs">
<div className="flex min-w-0 items-center gap-x-2 rounded-full border border-[var(--border)] bg-[var(--bg-shelf)]/50 px-4 py-2 text-xs">
{/* Writer token + dropdown */}
<div className="relative min-w-0">
<Token
Expand Down Expand Up @@ -196,11 +196,11 @@ function Token({
return (
<button
onClick={onClick}
className={`whitespace-nowrap transition-colors hover:opacity-80 ${active ? "opacity-80" : ""}`}
className={`whitespace-nowrap rounded-full px-2.5 py-0.5 transition-colors hover:bg-[var(--accent)]/10 ${active ? "bg-[var(--accent)]/10" : ""}`}
>
<span className="text-muted sm:hidden">{shortLabel}:</span>
<span className="text-muted hidden sm:inline">{label}:</span>
<span className="text-accent">{value}</span>
<span className="text-[var(--text-muted)] sm:hidden">{shortLabel}:</span>
<span className="text-[var(--text-muted)] hidden sm:inline">{label}:</span>
<span className="font-semibold text-[var(--accent)]">{value}</span>
</button>
);
}
Expand Down
95 changes: 64 additions & 31 deletions src/components/StoryCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,69 +23,102 @@ export function StoryCard({
: false;

return (
<div className="flex flex-col">
{/* Book cover with page-thickness lines */}
<div className="relative mr-0 mb-[6px] ml-[6px]">
{/* Page layer 2 (furthest back) */}
<div className="border-border/40 pointer-events-none absolute -bottom-[5px] -left-[5px] right-[5px] top-[5px] rounded border" />
{/* Page layer 1 */}
<div className="border-border/60 pointer-events-none absolute -bottom-[3px] -left-[3px] right-[3px] top-[3px] rounded border" />
<div className="group flex flex-col" style={{ perspective: "800px" }}>
{/* 3D Book with spine */}
<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" }}
>
{/* 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" />

{/* Main card (front cover) */}
<Link
href={`/story/${storyline.storyline_id}`}
className="border-border hover:border-accent-dim relative flex aspect-[2/3] flex-col justify-between rounded border transition-colors"
>
{/* Spine edge — thicker left border */}
<div className="bg-border absolute inset-y-0 left-0 w-[3px] rounded-l" />
{/* Spine — left edge with depth */}
<div
className="absolute inset-y-0 left-0 w-3 rounded-l-sm"
style={{
background: "linear-gradient(to right, #A6926F, #C4A87A, #B89E72)",
transform: "translateZ(-2px)",
}}
/>

{/* Top edge — page block thickness */}
<div className="bg-border/40 absolute inset-x-0 top-0 h-[1px]" />
{/* 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)",
}}
/>

<div className="flex flex-1 flex-col justify-between py-6 pl-6 pr-5">
{/* Top: genre tag + completion badge */}
{/* Page edges visible on bottom */}
<div
className="pointer-events-none absolute -bottom-[3px] left-3 right-0 h-[3px] rounded-b-[1px]"
style={{
background:
"repeating-linear-gradient(to right, #F5EFE4 0px, #F5EFE4 1px, #E8DFD0 1px, #E8DFD0 2px)",
}}
/>

{/* Front cover */}
<div className="relative flex aspect-[2/3] flex-col justify-between overflow-hidden rounded-sm rounded-l-none border border-[var(--border)] bg-gradient-to-br from-[#F7F0E5] via-[#F2E8D6] to-[#EBE0CE] transition-[border-color,box-shadow] duration-300 group-hover:border-[var(--accent-dim)] group-hover:shadow-lg">
{/* Spine inner shadow overlay */}
<div
className="pointer-events-none absolute inset-y-0 left-0 w-6"
style={{
background:
"linear-gradient(to right, rgba(139,115,85,0.12), transparent)",
}}
/>

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

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

{/* Bottom: author */}
<div className="text-muted flex items-center justify-center gap-1 text-xs">
{/* 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>
</div>
</Link>
</div>

{/* Metadata below card */}
<div className="text-muted mt-2 flex flex-col gap-0.5 pl-[7px] pr-1 text-[10px]">
{/* Decorative horizontal rule near bottom */}
<div className="mx-5 mb-4 h-px bg-[var(--border)]/60" />
</div>
</Link>

{/* Metadata below book */}
<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">
<StoryCardTVL tokenAddress={storyline.token_address} />
</span>
)}
<span className="whitespace-nowrap">
{storyline.plot_count} {storyline.plot_count === 1 ? "plot" : "plots"} linked
{isNew && <span className="text-accent ml-1 text-[10px] font-bold">NEW</span>}
{isNew && <span className="ml-1 text-[10px] font-bold text-[var(--accent)]">NEW</span>}
</span>
<RatingSummary storylineId={storyline.storyline_id} />
</div>
Expand Down
8 changes: 4 additions & 4 deletions src/components/StoryCardStats.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,9 @@ export function StoryCardStats({ tokenAddress }: { tokenAddress: string }) {
: "—";

return (
<div className="text-muted flex flex-wrap gap-x-3 gap-y-0.5 text-[10px]">
<span>Price: <span className="text-foreground">{price} {RESERVE_LABEL}</span></span>
<span>TVL: <span className="text-foreground">{tvl} {RESERVE_LABEL}</span></span>
<div className="flex flex-wrap gap-x-3 gap-y-0.5 text-[10px] text-[var(--text-muted)]">
<span>Price: <span className="font-semibold text-[var(--accent)]">{price} {RESERVE_LABEL}</span></span>
<span>TVL: <span className="font-semibold text-[var(--accent)]">{tvl} {RESERVE_LABEL}</span></span>
</div>
);
}
Expand All @@ -65,6 +65,6 @@ export function StoryCardTVL({ tokenAddress }: { tokenAddress: string }) {
const tvl = tvlData ? formatCompact(tvlData.tvl) : "—";

return (
<span>TVL: <span className="text-foreground">{tvl} {RESERVE_LABEL}</span></span>
<span>TVL: <span className="font-semibold text-[var(--accent)]">{tvl} {RESERVE_LABEL}</span></span>
);
}
79 changes: 76 additions & 3 deletions src/components/StoryGrid.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,98 @@
"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).
*/
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 grid grid-cols-2 gap-3 lg:grid-cols-3">
{storylines.map((s) => (
<StoryCard key={s.id} storyline={s} />
<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>
</BatchTokenDataProvider>
Expand Down
Loading