+ {/* Top: genre badge + completion */}
-
+
{displayGenre || "Uncategorized"}
{storyline.sunset && (
-
+
complete
)}
- {/* Center: title */}
-
-
+ {/* Center: title displayed like printed book cover */}
+
+
{storyline.title}
{storyline.language && storyline.language !== "English" && (
-
+
{storyline.language}
)}
- {/* Bottom: author */}
-
+ {/* Bottom: author name like a printed book */}
+
{storyline.writer_type === 1 &&
}
-
-
- {/* Metadata below card */}
-
+ {/* Decorative horizontal rule near bottom */}
+
+
+
+
+ {/* Metadata below book */}
+
{storyline.token_address && (
@@ -85,7 +118,7 @@ export function StoryCard({
)}
{storyline.plot_count} {storyline.plot_count === 1 ? "plot" : "plots"} linked
- {isNew && NEW}
+ {isNew && NEW}
diff --git a/src/components/StoryCardStats.tsx b/src/components/StoryCardStats.tsx
index 1c652c86..facc7a45 100644
--- a/src/components/StoryCardStats.tsx
+++ b/src/components/StoryCardStats.tsx
@@ -40,9 +40,9 @@ export function StoryCardStats({ tokenAddress }: { tokenAddress: string }) {
: "—";
return (
-
-
Price: {price} {RESERVE_LABEL}
-
TVL: {tvl} {RESERVE_LABEL}
+
+ Price: {price} {RESERVE_LABEL}
+ TVL: {tvl} {RESERVE_LABEL}
);
}
@@ -65,6 +65,6 @@ export function StoryCardTVL({ tokenAddress }: { tokenAddress: string }) {
const tvl = tvlData ? formatCompact(tvlData.tvl) : "—";
return (
-
TVL: {tvl} {RESERVE_LABEL}
+
TVL: {tvl} {RESERVE_LABEL}
);
}
diff --git a/src/components/StoryGrid.tsx b/src/components/StoryGrid.tsx
index 11efa088..e90b99b3 100644
--- a/src/components/StoryGrid.tsx
+++ b/src/components/StoryGrid.tsx
@@ -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
(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 (
+
+ {/* Books */}
+
+ {children}
+
+
+ {/* Shelf surface */}
+
+ {/* Shelf front edge */}
+
+
+ );
+}
+
/**
* 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 (
-
- {storylines.map((s) => (
-
+
+ {shelves.map((shelf, i) => (
+
+ {shelf.map((s) => (
+
+ ))}
+
))}