From ff58db1ee1aa569a4c75bf65305e55a3f80dff52 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Sun, 5 Apr 2026 07:45:53 +0100 Subject: [PATCH 1/4] [#844] Dashboard: show all AI agents owned by connected wallet Rewrite AgentDashboard to enumerate all ERC-8004 agent NFTs owned by the connected wallet using balanceOf + tokenOfOwnerByIndex (batched via useReadContracts). For each agent, fetch agentURI and getAgentWallet to display name, wallet, and OWS/Direct badge. Show activity cards with storyline counts for each agent. Aggregate stats shown when multiple agents exist. Handle empty state for no owned agents. Also detect if connected wallet is itself an agent wallet (not owner). Bump to v0.1.16. Fixes #844 Co-Authored-By: Claude Opus 4.6 (1M context) --- package.json | 2 +- src/components/AgentDashboard.tsx | 350 ++++++++++++++++++------------ 2 files changed, 213 insertions(+), 139 deletions(-) diff --git a/package.json b/package.json index 915bba2b..95341c3d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "plotlink", - "version": "0.1.15", + "version": "0.1.16", "private": true, "workspaces": [ "packages/*" diff --git a/src/components/AgentDashboard.tsx b/src/components/AgentDashboard.tsx index 955f31f5..7d584f86 100644 --- a/src/components/AgentDashboard.tsx +++ b/src/components/AgentDashboard.tsx @@ -1,127 +1,153 @@ "use client"; -import { useEffect, useRef } from "react"; -import { useAccount, useReadContract } from "wagmi"; +import { useMemo } from "react"; +import { useAccount, useReadContract, useReadContracts } from "wagmi"; import { useQuery } from "@tanstack/react-query"; import Link from "next/link"; import { erc8004Abi } from "../../lib/contracts/erc8004"; import { ERC8004_REGISTRY } from "../../lib/contracts/constants"; -import { getAgentUserFromDB, checkUserExists, cacheAgentById } from "../../lib/actions"; -export function AgentDashboard() { - const { address } = useAccount(); - - // DB-first: check cached agent data - const { data: dbUser, isLoading: dbLoading } = useQuery({ - queryKey: ["db-user-dashboard", address], - queryFn: () => getAgentUserFromDB(address!), - enabled: !!address, - }); +const ZERO_ADDR = "0x0000000000000000000000000000000000000000"; - const dbAgentId = dbUser?.agent_id; - const dbDetected = dbAgentId != null; - const dbIsOwner = dbDetected && dbUser?.agent_owner?.toLowerCase() === address?.toLowerCase(); - const dbIsAgentWallet = dbDetected && dbUser?.agent_wallet?.toLowerCase() === address?.toLowerCase(); - const dbAgentWallet = dbUser?.agent_wallet; - - // Check if user exists in DB at all (even without agent_id) - const { data: userExists, isLoading: userExistsLoading } = useQuery({ - queryKey: ["user-exists-dashboard", address], - queryFn: () => checkUserExists(address!), - enabled: !!address && !dbLoading && !dbDetected, - }); +interface AgentInfo { + agentId: bigint; + name: string; + agentWallet?: string; + source: "ows" | "direct"; +} - // RPC fallback: only for completely unknown wallets (no DB record at all) - // Known users with agent_id=NULL are definitively non-agents — zero RPC calls - const needsRpcFallback = !dbLoading && !dbDetected && !userExistsLoading && userExists === false && !!address; +export function AgentDashboard() { + const { address } = useAccount(); - const { data: rpcAgentId, isLoading: rpcWalletLoading } = useReadContract({ + // Step 1: Get number of agent NFTs owned by connected wallet + const { data: balance, isLoading: balanceLoading } = useReadContract({ address: ERC8004_REGISTRY, abi: erc8004Abi, - functionName: "agentIdByWallet", + functionName: "balanceOf", args: address ? [address] : undefined, - query: { enabled: needsRpcFallback }, + query: { enabled: !!address }, }); - const { data: rpcBalance, isLoading: rpcBalanceLoading } = useReadContract({ - address: ERC8004_REGISTRY, - abi: erc8004Abi, - functionName: "balanceOf", - args: address ? [address] : undefined, - query: { enabled: needsRpcFallback }, + const agentCount = balance !== undefined ? Number(balance) : 0; + + // Step 2: Enumerate all owned agent IDs + const tokenIndexCalls = useMemo(() => { + if (!address || agentCount === 0) return []; + return Array.from({ length: agentCount }, (_, i) => ({ + address: ERC8004_REGISTRY, + abi: erc8004Abi, + functionName: "tokenOfOwnerByIndex" as const, + args: [address, BigInt(i)] as const, + })); + }, [address, agentCount]); + + const { data: tokenResults, isLoading: tokensLoading } = useReadContracts({ + contracts: tokenIndexCalls, + query: { enabled: tokenIndexCalls.length > 0 }, }); - const rpcHasNft = rpcBalance !== undefined && rpcBalance > BigInt(0); - const { data: rpcOwnedToken, isLoading: rpcTokenLoading } = useReadContract({ - address: ERC8004_REGISTRY, - abi: erc8004Abi, - functionName: "tokenOfOwnerByIndex", - args: address ? [address, BigInt(0)] : undefined, - query: { enabled: needsRpcFallback && rpcHasNft }, + const agentIds = useMemo(() => { + if (!tokenResults) return []; + return tokenResults + .filter((r) => r.status === "success" && r.result !== undefined) + .map((r) => r.result as bigint); + }, [tokenResults]); + + // Step 3: Fetch metadata (agentURI + getAgentWallet) for each agent + const metadataCalls = useMemo(() => { + if (agentIds.length === 0) return []; + return agentIds.flatMap((id) => [ + { + address: ERC8004_REGISTRY, + abi: erc8004Abi, + functionName: "agentURI" as const, + args: [id] as const, + }, + { + address: ERC8004_REGISTRY, + abi: erc8004Abi, + functionName: "getAgentWallet" as const, + args: [id] as const, + }, + ]); + }, [agentIds]); + + const { data: metadataResults, isLoading: metadataLoading } = useReadContracts({ + contracts: metadataCalls, + query: { enabled: metadataCalls.length > 0 }, }); - const rpcIsOwner = rpcHasNft && rpcOwnedToken !== undefined; - const { data: rpcBoundWallet } = useReadContract({ + // Parse agent info from contract results + const agents: AgentInfo[] = useMemo(() => { + if (agentIds.length === 0 || !metadataResults) return []; + return agentIds.map((id, i) => { + const uriResult = metadataResults[i * 2]; + const walletResult = metadataResults[i * 2 + 1]; + + let name = `Agent #${id.toString()}`; + let source: "ows" | "direct" = "direct"; + if (uriResult?.status === "success" && uriResult.result) { + try { + const meta = JSON.parse(uriResult.result as string); + if (meta.name) name = meta.name; + if (meta.type === "ows-writer" || meta.owsWallet) source = "ows"; + } catch { /* not JSON */ } + } + + const walletAddr = walletResult?.status === "success" ? (walletResult.result as string) : undefined; + const agentWallet = walletAddr && walletAddr !== ZERO_ADDR ? walletAddr : undefined; + + return { agentId: id, name, agentWallet, source }; + }); + }, [agentIds, metadataResults]); + + // Also check if connected wallet itself is an agent wallet (not owner) + const { data: selfAgentId } = useReadContract({ address: ERC8004_REGISTRY, abi: erc8004Abi, - functionName: "getAgentWallet", - args: rpcOwnedToken !== undefined ? [rpcOwnedToken] : undefined, - query: { enabled: needsRpcFallback && rpcIsOwner }, + functionName: "agentIdByWallet", + args: address ? [address] : undefined, + query: { enabled: !!address }, }); - const rpcIsAgentWallet = rpcAgentId !== undefined && rpcAgentId > BigInt(0); - - // Combine DB + RPC - let agentId: bigint | undefined; - let isOwner = false; - let isAgentWallet = false; - let writerAddress: string | undefined = address; - - if (dbDetected) { - agentId = BigInt(dbAgentId!); - isOwner = dbIsOwner; - isAgentWallet = dbIsAgentWallet; - // For owner, use cached agent_wallet for storyline lookup - if (dbIsOwner && dbAgentWallet) { - writerAddress = dbAgentWallet; - } - } else if (rpcIsOwner) { - agentId = rpcOwnedToken; - isOwner = true; - const hasValidRpcWallet = rpcBoundWallet && rpcBoundWallet !== "0x0000000000000000000000000000000000000000"; - if (hasValidRpcWallet) writerAddress = rpcBoundWallet as string; - } else if (rpcIsAgentWallet) { - agentId = rpcAgentId; - isAgentWallet = true; - } - - const isAgent = agentId !== undefined; - const detectLoading = dbLoading || (!dbDetected && userExistsLoading) || (needsRpcFallback && (rpcWalletLoading || rpcBalanceLoading || (rpcHasNft && rpcTokenLoading))); + const isSelfAgent = selfAgentId !== undefined && selfAgentId > BigInt(0); + const selfAlreadyInList = agents.some( + (a) => a.agentWallet?.toLowerCase() === address?.toLowerCase() || a.agentId === selfAgentId, + ); - // Auto-cache: when RPC fallback detects an agent not in DB, persist it - const cachedRef = useRef(false); - useEffect(() => { - if (!dbDetected && isAgent && address && agentId && !cachedRef.current) { - cachedRef.current = true; - cacheAgentById(address, agentId.toString()).catch(() => - cacheAgentById(address, agentId.toString()).catch(() => {}), - ); + // Fetch storylines for each agent's writer address + const writerAddresses = useMemo(() => { + const addrs: string[] = []; + for (const agent of agents) { + addrs.push(agent.agentWallet || address || ""); + } + if (isSelfAgent && !selfAlreadyInList && address) { + addrs.push(address); } - }, [dbDetected, isAgent, address, agentId]); + return addrs.filter(Boolean); + }, [agents, isSelfAgent, selfAlreadyInList, address]); - // Fetch agent's storylines from Supabase - const { data: storylines, isLoading: storylinesLoading } = useQuery({ - queryKey: ["agent-storylines", writerAddress], + const { data: allStorylines, isLoading: storylinesLoading } = useQuery({ + queryKey: ["all-agent-storylines", writerAddresses], queryFn: async () => { - if (!writerAddress) return []; - const res = await fetch(`/api/storyline/by-writer?writer=${writerAddress}&type=agent`); - if (!res.ok) return []; - return res.json() as Promise>; + const results: Record> = {}; + await Promise.all( + writerAddresses.map(async (addr) => { + try { + const res = await fetch(`/api/storyline/by-writer?writer=${addr}&type=agent`); + if (res.ok) results[addr.toLowerCase()] = await res.json(); + else results[addr.toLowerCase()] = []; + } catch { results[addr.toLowerCase()] = []; } + }), + ); + return results; }, - enabled: !!writerAddress && isAgent, + enabled: writerAddresses.length > 0, }); - if (detectLoading) { + const isLoading = balanceLoading || tokensLoading || metadataLoading; + + if (isLoading) { return (

Loading agent status...

@@ -129,63 +155,111 @@ export function AgentDashboard() { ); } - if (!isAgent) { + const hasAgents = agents.length > 0 || (isSelfAgent && !selfAlreadyInList); + + if (!hasAgents) { return (
-

This wallet is not registered as an agent.

+

You have no AI agents registered.

- Switch to the Register tab to register your agent. + Switch to the Register tab to register an agent or link an OWS Writer.

); } + // Aggregate stats + const allAgentStorylines = Object.values(allStorylines || {}).flat(); + const totalStories = allAgentStorylines.length; + + // Build full agent list (owned + self-as-agent-wallet) + const displayAgents = [...agents]; + if (isSelfAgent && !selfAlreadyInList) { + displayAgents.push({ + agentId: selfAgentId, + name: "This Wallet (Agent)", + agentWallet: address, + source: "direct", + }); + } + return (
-
-

Agent #{agentId!.toString()}

-

- {isOwner && isAgentWallet - ? "Connected as owner + agent wallet" - : isOwner - ? "Connected as owner" - : "Connected as agent wallet"} -

-

- {address?.slice(0, 6)}...{address?.slice(-4)} -

-
+ {/* Aggregate stats */} + {displayAgents.length > 1 && ( +
+

{displayAgents.length} Agents

+

+ {totalStories} storyline{totalStories !== 1 ? "s" : ""} published across all agents +

+
+ )} -

Your Storylines

+ {/* Agent cards */} +
+ {displayAgents.map((agent) => { + const writerAddr = agent.agentWallet || address || ""; + const storylines = allStorylines?.[writerAddr.toLowerCase()] || []; - {storylinesLoading ? ( -

Loading storylines...

- ) : !storylines || storylines.length === 0 ? ( -
-

No storylines yet.

- - Create your first storyline - -
- ) : ( -
- {storylines.map((s) => ( - -
-

{s.title}

-

- {s.plot_count} plot{s.plot_count !== 1 ? "s" : ""} -

+ return ( +
+
+
+

{agent.name}

+ #{agent.agentId.toString()} + + {agent.source === "ows" ? "OWS Writer" : "Direct"} + +
+ + Profile +
- #{s.storyline_id} - - ))} -
- )} + + {agent.agentWallet && ( +

+ Wallet: {agent.agentWallet.slice(0, 6)}...{agent.agentWallet.slice(-4)} +

+ )} + +

+ {storylines.length} storyline{storylines.length !== 1 ? "s" : ""} +

+ + {storylinesLoading ? ( +

Loading...

+ ) : storylines.length === 0 ? ( +

No storylines yet

+ ) : ( +
+ {storylines.map((s) => ( + +
+

{s.title}

+

+ {s.plot_count} plot{s.plot_count !== 1 ? "s" : ""} +

+
+ #{s.storyline_id} + + ))} +
+ )} +
+ ); + })} +
); } From 56be000f9611eb31a9b2dbcf7b5bd561b23cbb7d Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Sun, 5 Apr 2026 07:48:34 +0100 Subject: [PATCH 2/4] [#844] Add royalty data to agent dashboard cards and aggregate stats Fetch getRoyaltyInfo from MCV2_Bond for each agent's writer address. Show total PLOT earned (unclaimed + claimed) per agent card and in the aggregate stats bar for multi-agent view. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/AgentDashboard.tsx | 49 +++++++++++++++++++++++++++---- 1 file changed, 44 insertions(+), 5 deletions(-) diff --git a/src/components/AgentDashboard.tsx b/src/components/AgentDashboard.tsx index 7d584f86..5196a5f3 100644 --- a/src/components/AgentDashboard.tsx +++ b/src/components/AgentDashboard.tsx @@ -5,7 +5,10 @@ import { useAccount, useReadContract, useReadContracts } from "wagmi"; import { useQuery } from "@tanstack/react-query"; import Link from "next/link"; import { erc8004Abi } from "../../lib/contracts/erc8004"; -import { ERC8004_REGISTRY } from "../../lib/contracts/constants"; +import { mcv2BondAbi } from "../../lib/price"; +import { ERC8004_REGISTRY, MCV2_BOND, PLOT_TOKEN } from "../../lib/contracts/constants"; +import { browserClient } from "../../lib/rpc"; +import { formatUnits } from "viem"; const ZERO_ADDR = "0x0000000000000000000000000000000000000000"; @@ -145,6 +148,29 @@ export function AgentDashboard() { enabled: writerAddresses.length > 0, }); + // Fetch royalties for each agent's writer address + const { data: royalties } = useQuery({ + queryKey: ["agent-royalties", writerAddresses], + queryFn: async () => { + const results: Record = {}; + await Promise.all( + writerAddresses.map(async (addr) => { + try { + const [balance, claimed] = await browserClient.readContract({ + address: MCV2_BOND, + abi: mcv2BondAbi, + functionName: "getRoyaltyInfo", + args: [addr as `0x${string}`, PLOT_TOKEN], + }); + results[addr.toLowerCase()] = { unclaimed: balance as bigint, claimed: claimed as bigint }; + } catch { results[addr.toLowerCase()] = { unclaimed: BigInt(0), claimed: BigInt(0) }; } + }), + ); + return results; + }, + enabled: writerAddresses.length > 0, + }); + const isLoading = balanceLoading || tokensLoading || metadataLoading; if (isLoading) { @@ -171,6 +197,11 @@ export function AgentDashboard() { // Aggregate stats const allAgentStorylines = Object.values(allStorylines || {}).flat(); const totalStories = allAgentStorylines.length; + const totalRoyalties = Object.values(royalties || {}).reduce( + (acc, r) => ({ unclaimed: acc.unclaimed + r.unclaimed, claimed: acc.claimed + r.claimed }), + { unclaimed: BigInt(0), claimed: BigInt(0) }, + ); + const totalEarned = totalRoyalties.unclaimed + totalRoyalties.claimed; // Build full agent list (owned + self-as-agent-wallet) const displayAgents = [...agents]; @@ -190,7 +221,8 @@ export function AgentDashboard() {

{displayAgents.length} Agents

- {totalStories} storyline{totalStories !== 1 ? "s" : ""} published across all agents + {totalStories} storyline{totalStories !== 1 ? "s" : ""} published + {totalEarned > BigInt(0) && <> · {formatUnits(totalEarned, 18)} PLOT earned}

)} @@ -229,9 +261,16 @@ export function AgentDashboard() {

)} -

- {storylines.length} storyline{storylines.length !== 1 ? "s" : ""} -

+ {(() => { + const agentRoyalty = royalties?.[writerAddr.toLowerCase()]; + const earned = agentRoyalty ? agentRoyalty.unclaimed + agentRoyalty.claimed : BigInt(0); + return ( +
+ {storylines.length} storyline{storylines.length !== 1 ? "s" : ""} + {earned > BigInt(0) && {formatUnits(earned, 18)} PLOT earned} +
+ ); + })()} {storylinesLoading ? (

Loading...

From 8db4cafd3171ed2a336b072909b7cbaec2175ad0 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Sun, 5 Apr 2026 07:51:12 +0100 Subject: [PATCH 3/4] [#844] Fix: no fallback for unbound wallets, dynamic PLOT decimals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unbound agents now show "No wallet bound — complete binding in the Manage tab" instead of silently falling back to owner address. Storylines and royalties only shown for agents with bound wallets. PLOT token decimals fetched dynamically via on-chain decimals() call. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/AgentDashboard.tsx | 29 +++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/src/components/AgentDashboard.tsx b/src/components/AgentDashboard.tsx index 5196a5f3..f8ac67f3 100644 --- a/src/components/AgentDashboard.tsx +++ b/src/components/AgentDashboard.tsx @@ -118,16 +118,16 @@ export function AgentDashboard() { (a) => a.agentWallet?.toLowerCase() === address?.toLowerCase() || a.agentId === selfAgentId, ); - // Fetch storylines for each agent's writer address + // Fetch storylines for each agent's writer address (only bound wallets) const writerAddresses = useMemo(() => { const addrs: string[] = []; for (const agent of agents) { - addrs.push(agent.agentWallet || address || ""); + if (agent.agentWallet) addrs.push(agent.agentWallet); } if (isSelfAgent && !selfAlreadyInList && address) { addrs.push(address); } - return addrs.filter(Boolean); + return [...new Set(addrs.filter(Boolean))]; }, [agents, isSelfAgent, selfAlreadyInList, address]); const { data: allStorylines, isLoading: storylinesLoading } = useQuery({ @@ -171,6 +171,15 @@ export function AgentDashboard() { enabled: writerAddresses.length > 0, }); + // Fetch PLOT token decimals dynamically + const { data: plotDecimals } = useReadContract({ + address: PLOT_TOKEN, + abi: [{ type: "function", name: "decimals", stateMutability: "view", inputs: [], outputs: [{ name: "", type: "uint8" }] }] as const, + functionName: "decimals", + query: { staleTime: Infinity }, + }); + const decimals = plotDecimals !== undefined ? Number(plotDecimals) : 18; + const isLoading = balanceLoading || tokensLoading || metadataLoading; if (isLoading) { @@ -222,7 +231,7 @@ export function AgentDashboard() {

{displayAgents.length} Agents

{totalStories} storyline{totalStories !== 1 ? "s" : ""} published - {totalEarned > BigInt(0) && <> · {formatUnits(totalEarned, 18)} PLOT earned} + {totalEarned > BigInt(0) && <> · {formatUnits(totalEarned, decimals)} PLOT earned}

)} @@ -255,24 +264,28 @@ export function AgentDashboard() {
- {agent.agentWallet && ( + {agent.agentWallet ? (

Wallet: {agent.agentWallet.slice(0, 6)}...{agent.agentWallet.slice(-4)}

+ ) : ( +

+ No wallet bound — complete binding in the Manage tab to see activity +

)} - {(() => { + {agent.agentWallet && (() => { const agentRoyalty = royalties?.[writerAddr.toLowerCase()]; const earned = agentRoyalty ? agentRoyalty.unclaimed + agentRoyalty.claimed : BigInt(0); return (
{storylines.length} storyline{storylines.length !== 1 ? "s" : ""} - {earned > BigInt(0) && {formatUnits(earned, 18)} PLOT earned} + {earned > BigInt(0) && {formatUnits(earned, decimals)} PLOT earned}
); })()} - {storylinesLoading ? ( + {!agent.agentWallet ? null : storylinesLoading ? (

Loading...

) : storylines.length === 0 ? (

No storylines yet

From 6896db66a7968bad673fae6b4f56aeb0f0447f98 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Sun, 5 Apr 2026 07:54:06 +0100 Subject: [PATCH 4/4] [#844] Fix: direct agents use owner wallet for activity lookup Direct agents implicitly use the owner wallet as their agent wallet, so fall back to owner address for storyline/royalty lookups. Only OWS agents without explicit binding show "No wallet bound" message. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/AgentDashboard.tsx | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/src/components/AgentDashboard.tsx b/src/components/AgentDashboard.tsx index f8ac67f3..2badcb3e 100644 --- a/src/components/AgentDashboard.tsx +++ b/src/components/AgentDashboard.tsx @@ -118,11 +118,16 @@ export function AgentDashboard() { (a) => a.agentWallet?.toLowerCase() === address?.toLowerCase() || a.agentId === selfAgentId, ); - // Fetch storylines for each agent's writer address (only bound wallets) + // Fetch storylines for each agent's writer address + // Direct agents use owner wallet implicitly; OWS agents need an explicit bound wallet const writerAddresses = useMemo(() => { const addrs: string[] = []; for (const agent of agents) { - if (agent.agentWallet) addrs.push(agent.agentWallet); + if (agent.agentWallet) { + addrs.push(agent.agentWallet); + } else if (agent.source === "direct" && address) { + addrs.push(address); + } } if (isSelfAgent && !selfAlreadyInList && address) { addrs.push(address); @@ -239,8 +244,10 @@ export function AgentDashboard() { {/* Agent cards */}
{displayAgents.map((agent) => { - const writerAddr = agent.agentWallet || address || ""; - const storylines = allStorylines?.[writerAddr.toLowerCase()] || []; + // Direct agents use owner wallet; OWS agents need explicit binding + const hasActivity = agent.agentWallet || agent.source === "direct"; + const writerAddr = agent.agentWallet || (agent.source === "direct" ? address : "") || ""; + const storylines = hasActivity ? (allStorylines?.[writerAddr.toLowerCase()] || []) : []; return (
@@ -268,13 +275,13 @@ export function AgentDashboard() {

Wallet: {agent.agentWallet.slice(0, 6)}...{agent.agentWallet.slice(-4)}

- ) : ( + ) : agent.source === "ows" ? (

No wallet bound — complete binding in the Manage tab to see activity

- )} + ) : null} - {agent.agentWallet && (() => { + {hasActivity && (() => { const agentRoyalty = royalties?.[writerAddr.toLowerCase()]; const earned = agentRoyalty ? agentRoyalty.unclaimed + agentRoyalty.claimed : BigInt(0); return ( @@ -285,7 +292,7 @@ export function AgentDashboard() { ); })()} - {!agent.agentWallet ? null : storylinesLoading ? ( + {!hasActivity ? null : storylinesLoading ? (

Loading...

) : storylines.length === 0 ? (

No storylines yet