Skip to content

Commit e82e890

Browse files
committed
fix fetching ipfs
1 parent ef6f38c commit e82e890

File tree

5 files changed

+306
-0
lines changed

5 files changed

+306
-0
lines changed

src/app/api/public-timeline/route.ts

Whitespace-only changes.

src/app/page.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@ import { Card, CardContent } from "@/src/components/ui/card"
66
import { TrendingUp, Users, Shield, Zap } from "lucide-react"
77
import { FeaturedArticle } from "@/src/components/news/featured-article"
88
import { newsArticles, getAllCategories } from "@/src/lib/news-data"
9+
910
import { Hero } from "@/src/components/hero"
11+
import PublicTimeline from "@/src/components/public-timeline"
1012

1113

1214
export default function HomePage() {
@@ -35,6 +37,11 @@ export default function HomePage() {
3537
</div>
3638
</div>
3739

40+
{/* Public Timeline Section for testing */}
41+
<div className="px-4 pt-6 pb-8">
42+
<PublicTimeline />
43+
</div>
44+
3845

3946

4047
{/* Main Timeline Section

src/components/ip-asset-card.tsx

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import React from "react";
2+
import type { NFTAsset } from "../types/asset";
3+
import type { AssetIP } from "../types/asset";
4+
5+
interface IPAssetCardProps {
6+
asset: NFTAsset;
7+
metadata: AssetIP | null;
8+
}
9+
10+
export function IPAssetCard({ asset, metadata }: IPAssetCardProps) {
11+
// Fallbacks
12+
const title = metadata?.title || asset.metadata?.name || `Token #${asset.tokenId}`;
13+
const author = metadata?.author || metadata?.creator?.name || "Unknown";
14+
const description = metadata?.description || asset.metadata?.description || "No description.";
15+
const license = metadata?.licenseType || metadata?.licenseDetails || "Unspecified";
16+
const mediaUrl = metadata?.mediaUrl || asset.metadata?.image || "";
17+
const type = metadata?.type || "other";
18+
const timestamp = metadata?.timestamp || "";
19+
20+
return (
21+
<div className="bg-white rounded-lg shadow p-4 flex flex-col gap-2 border border-gray-100">
22+
<div className="text-xs text-gray-400 mb-1 flex justify-between">
23+
<span>{author}</span>
24+
{timestamp && <span>{new Date(timestamp).toLocaleString()}</span>}
25+
</div>
26+
{mediaUrl ? (
27+
<img src={mediaUrl.replace('ipfs://', 'https://ipfs.io/ipfs/')} alt={title} className="w-full h-48 object-cover rounded mb-2" />
28+
) : (
29+
<div className="w-full h-48 bg-gray-100 flex items-center justify-center rounded mb-2 text-gray-400">No Media</div>
30+
)}
31+
<div className="font-semibold text-lg truncate" title={title}>{title}</div>
32+
<div className="text-sm text-gray-600 line-clamp-2" title={description}>{description}</div>
33+
<div className="flex items-center gap-2 mt-2">
34+
<span className="text-xs bg-gray-200 rounded px-2 py-0.5">{type}</span>
35+
<span className="text-xs bg-gray-100 rounded px-2 py-0.5">License: {license}</span>
36+
</div>
37+
</div>
38+
);
39+
}

src/components/public-timeline.tsx

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
"use client";
2+
import React, { useEffect, useState, useRef, useCallback } from "react";
3+
import { getPublicTimelineAssets, PublicTimelineAsset } from "../services/public-timeline.service";
4+
import { IPAssetCard } from "./ip-asset-card";
5+
6+
const PAGE_SIZE = 12;
7+
8+
export const assetTypes = [
9+
{ label: "All", value: undefined },
10+
{ label: "Art", value: "art" },
11+
{ label: "Music", value: "music" },
12+
{ label: "Docs", value: "docs" },
13+
{ label: "Other", value: "other" },
14+
];
15+
16+
const PublicTimeline: React.FC = function PublicTimeline() {
17+
const [assets, setAssets] = useState<PublicTimelineAsset[]>([]);
18+
const [page, setPage] = useState(0);
19+
const [loading, setLoading] = useState(false);
20+
const [hasMore, setHasMore] = useState(true);
21+
const [error, setError] = useState<string | null>(null);
22+
const [filter, setFilter] = useState<string | undefined>(undefined);
23+
const observer = useRef<IntersectionObserver | null>(null);
24+
const lastAssetRef = useRef<HTMLDivElement | null>(null);
25+
26+
const fetchAssets = useCallback(async (reset = false) => {
27+
setLoading(true);
28+
setError(null);
29+
try {
30+
const nextPage = reset ? 0 : page;
31+
const newAssets = await getPublicTimelineAssets(nextPage, PAGE_SIZE, filter);
32+
setAssets(prev => reset ? newAssets : [...prev, ...newAssets]);
33+
setHasMore(newAssets.length === PAGE_SIZE);
34+
setPage(reset ? 1 : page + 1);
35+
} catch (e) {
36+
setError("Failed to load timeline. Please try again.");
37+
} finally {
38+
setLoading(false);
39+
}
40+
}, [page, filter]);
41+
42+
useEffect(() => {
43+
fetchAssets(true);
44+
// eslint-disable-next-line react-hooks/exhaustive-deps
45+
}, [filter]);
46+
47+
// Infinite scroll observer
48+
useEffect(() => {
49+
if (loading || !hasMore) return;
50+
if (observer.current) observer.current.disconnect();
51+
observer.current = new window.IntersectionObserver(entries => {
52+
if (entries[0].isIntersecting) {
53+
fetchAssets();
54+
}
55+
});
56+
if (lastAssetRef.current) {
57+
observer.current.observe(lastAssetRef.current);
58+
}
59+
return () => observer.current?.disconnect();
60+
}, [loading, hasMore, fetchAssets, assets]);
61+
62+
return (
63+
<div className="w-full max-w-3xl mx-auto py-8">
64+
<div className="flex gap-2 mb-4">
65+
{assetTypes.map(t => (
66+
<button
67+
key={t.label}
68+
className={`px-3 py-1 rounded-full border ${filter === t.value ? "bg-black text-white" : "bg-white text-black"}`}
69+
onClick={() => setFilter(t.value)}
70+
disabled={loading && filter === t.value}
71+
>
72+
{t.label}
73+
</button>
74+
))}
75+
</div>
76+
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
77+
{assets.length === 0 && !loading && !error && (
78+
<div className="col-span-full text-center text-gray-500 py-12">No assets found.</div>
79+
)}
80+
{assets.map((item, i) => (
81+
<div
82+
key={item.asset.tokenId}
83+
ref={i === assets.length - 1 ? lastAssetRef : undefined}
84+
>
85+
<IPAssetCard asset={item.asset} metadata={item.metadata ?? null} />
86+
</div>
87+
))}
88+
{loading && Array.from({ length: PAGE_SIZE }).map((_, i) => (
89+
<div key={i} className="animate-pulse bg-gray-100 h-48 rounded-lg" />
90+
))}
91+
</div>
92+
{error && <div className="text-red-500 text-center mt-4">{error}</div>}
93+
</div>
94+
);
95+
}
96+
97+
export default PublicTimeline;
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
import { Contract } from "starknet";
2+
import { ERC721_ABI } from "../abi/ERC721_ABI";
3+
import { starknetService } from "./starknet.service";
4+
import { ipfsService } from "./ipfs.service";
5+
import type { AssetIP, NFTAsset } from "../types/asset";
6+
7+
const IPFS_RETRY_LIMIT = 2;
8+
9+
export type PublicTimelineAsset = {
10+
asset: NFTAsset;
11+
metadata?: AssetIP | null;
12+
};
13+
14+
// Helper function to convert u256 to a string representation of the number
15+
function u256ToString(u256: any): string {
16+
if (!u256) return "0";
17+
// Try low/high first
18+
if (typeof u256.low !== "undefined" && typeof u256.high !== "undefined") {
19+
const low = BigInt(u256.low);
20+
const high = BigInt(u256.high);
21+
return ((high << BigInt(128)) + low).toString();
22+
}
23+
// Try array/object with 0/1 keys
24+
if (typeof u256[0] !== "undefined" && typeof u256[1] !== "undefined") {
25+
const low = BigInt(u256[0]);
26+
const high = BigInt(u256[1]);
27+
return ((high << BigInt(128)) + low).toString();
28+
}
29+
// Try direct string/number
30+
if (typeof u256 === "string" || typeof u256 === "number" || typeof u256 === "bigint") {
31+
return u256.toString();
32+
}
33+
console.error("Unknown u256 format", u256);
34+
return "0";
35+
}
36+
37+
// Helper function to decode ByteArray to a string
38+
function decodeByteArray(byteArray: { data: string[]; pending_word: string; pending_word_len: number }): string {
39+
const data = byteArray.data.map(felt => {
40+
const hex = felt.substring(2);
41+
return Buffer.from(hex, 'hex').toString('utf8');
42+
});
43+
const pending = byteArray.pending_word;
44+
const pendingLen = byteArray.pending_word_len;
45+
const pendingHex = pending.substring(2);
46+
const pendingStr = Buffer.from(pendingHex, 'hex').toString('utf8').slice(0, pendingLen);
47+
return data.join('') + pendingStr;
48+
}
49+
50+
async function fetchIpfsWithRetry(hashOrUri: string, retries = IPFS_RETRY_LIMIT): Promise<AssetIP | null> {
51+
let lastError;
52+
for (let i = 0; i < retries; i++) {
53+
try {
54+
let hash = hashOrUri;
55+
if (hash.startsWith('ipfs://')) hash = hash.replace('ipfs://', '');
56+
const meta = await ipfsService.getFromIPFS(hash);
57+
if (meta && typeof meta === 'object' && 'timestamp' in meta && 'type' in meta) return meta;
58+
return null;
59+
} catch (err) {
60+
lastError = err;
61+
await new Promise(res => setTimeout(res, 500));
62+
}
63+
}
64+
console.error("IPFS fetch failed for", hashOrUri, lastError);
65+
return null;
66+
}
67+
68+
export async function getPublicTimelineAssets(page = 0, pageSize = 12, assetType?: string): Promise<PublicTimelineAsset[]> {
69+
const contractAddress = process.env.NEXT_PUBLIC_CONTRACT_ADDRESS_MIP;
70+
if (!contractAddress) {
71+
console.warn("NEXT_PUBLIC_CONTRACT_ADDRESS_MIP is not set.");
72+
return [];
73+
}
74+
75+
const contract = new Contract(ERC721_ABI, contractAddress, starknetService['provider']);
76+
77+
// Get total supply, correctly parsing u256
78+
let totalSupply = 0;
79+
try {
80+
const result = await contract.call("total_supply", []) as any;
81+
console.log("total_supply raw result", result);
82+
totalSupply = Number(result);
83+
} catch (e) {
84+
console.error("Failed to fetch total_supply from contract", e);
85+
return [];
86+
}
87+
// console.log("totalSupply", totalSupply);
88+
89+
// 2. Get token IDs for this page
90+
const start = page * pageSize;
91+
const end = Math.min(start + pageSize, totalSupply);
92+
const tokenIds: string[] = [];
93+
for (let i = start; i < end; i++) {
94+
try {
95+
// Call token_by_index and parse the u256 result
96+
const result = await contract.call("token_by_index", [i]) as any;
97+
const tokenId = result.toString();
98+
tokenIds.push(tokenId);
99+
} catch (e) {
100+
console.warn(`Failed to fetch token_by_index(${i})`, e);
101+
}
102+
}
103+
104+
// 3. Fetch NFTAsset for each tokenId
105+
const nfts: NFTAsset[] = await Promise.all(
106+
tokenIds.map(async (tokenId) => {
107+
const [ownerResult, tokenUriResult] = await Promise.all([
108+
contract.call("owner_of", [tokenId]) as any,
109+
contract.call("token_uri", [tokenId]) as any
110+
]);
111+
const owner = ownerResult.toString();
112+
const tokenURI = tokenUriResult.toString();
113+
114+
return {
115+
contractAddress,
116+
tokenId: tokenId.toString(),
117+
owner,
118+
tokenURI,
119+
type: "ERC721",
120+
} as NFTAsset;
121+
})
122+
);
123+
// console.log("NFTs", nfts);
124+
125+
// 4. Fetch IPFS metadata for each asset (tokenURI)
126+
const assetsWithMeta: PublicTimelineAsset[] = await Promise.all(
127+
nfts.map(async (asset) => {
128+
let meta: AssetIP | null = null;
129+
if (asset.tokenURI && asset.tokenURI.startsWith('ipfs://')) {
130+
meta = await fetchIpfsWithRetry(asset.tokenURI);
131+
}
132+
return {
133+
asset,
134+
metadata: meta,
135+
};
136+
})
137+
);
138+
// console.log("assetsWithMeta", assetsWithMeta);
139+
140+
// 5. Sort and Filter
141+
let sorted = assetsWithMeta.sort((a, b) => {
142+
const ta = a.metadata?.timestamp ? Date.parse(a.metadata.timestamp) : 0;
143+
const tb = b.metadata?.timestamp ? Date.parse(b.metadata.timestamp) : 0;
144+
if (tb !== ta) return tb - ta;
145+
return Number(b.asset.tokenId) - Number(a.asset.tokenId);
146+
});
147+
148+
if (assetType) {
149+
if (assetType === "other") {
150+
sorted = sorted.filter(a => {
151+
const metaType = a.metadata?.type?.toLowerCase().trim();
152+
return !metaType || !["art", "music", "docs"].includes(metaType);
153+
});
154+
} else {
155+
sorted = sorted.filter(a => {
156+
const metaType = a.metadata?.type?.toLowerCase().trim();
157+
return metaType === assetType.toLowerCase().trim();
158+
});
159+
}
160+
}
161+
162+
return sorted;
163+
}

0 commit comments

Comments
 (0)