Skip to content
Open
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
22 changes: 22 additions & 0 deletions app/api/my-assets/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { NextResponse } from 'next/server';
import db from '@/lib/database';

export async function GET(req: Request) {
const { searchParams } = new URL(req.url);
const owner = searchParams.get('owner');

if (!owner) {
return NextResponse.json({ error: 'Missing owner query parameter' }, { status: 400 });
}

try {
const assets = await db.localIPAsset.findMany({
where: { ownerAddress: owner.toLowerCase() },
orderBy: { createdAt: 'desc' },
});
return NextResponse.json({ assets });
} catch (error: any) {
console.error('Failed to fetch local assets:', error);
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
23 changes: 23 additions & 0 deletions app/api/register-ip/route.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { NextResponse } from 'next/server';
import db from '@/lib/database';

export async function POST(req: Request) {
try {
Expand Down Expand Up @@ -119,6 +120,28 @@ export async function POST(req: Request) {
}

const data = await res.json();

// Persist to local DB for immediate visibility before subgraph indexes
try {
await db.localIPAsset.create({
data: {
ownerAddress: creatorAddress.toLowerCase(),
title,
description,
ipType,
mediaUrl: mediaUrl,
imageUrl: finalImageUrl,
metadataUri: '',
licenses,
royaltyRate: parseInt(royalty, 10) || 10,
crossmintId: data.id || data.actionId || null,
},
});
} catch (dbErr) {
// Log but don't fail the registration — Crossmint succeeded
console.error('Failed to persist IP asset locally:', dbErr);
}

return NextResponse.json(data);
} catch (error: any) {
console.error("Register IP Route Error:", error);
Expand Down
9 changes: 8 additions & 1 deletion components/ip-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import Link from 'next/link'
import Image from 'next/image'
import { Music, BookOpen, ImageIcon, ShoppingCart, ExternalLink, Video } from 'lucide-react'
import { Music, BookOpen, ImageIcon, ShoppingCart, ExternalLink, Video, AlertTriangle } from 'lucide-react'
import { cn } from '@/lib/utils'
import { type IPAsset } from '@/lib/data'
import { Button } from './ui/button'
Expand Down Expand Up @@ -75,6 +75,13 @@ export function IPCard({ asset, className }: IPCardProps) {
<span className={TYPE_COLOR[asset.type]}>{asset.type}</span>
</div>

{/* Metadata status warning */}
{(asset as any).metadataStatus === 'failed' && (
<div className="absolute top-3 right-3 flex items-center gap-1 rounded-full px-2 py-1 bg-amber-500/20 border border-amber-500/30 font-mono text-[9px] text-amber-400">
<AlertTriangle className="w-3 h-3" /> Metadata unavailable
</div>
)}

{/* Story Protocol ID */}
<div className="absolute bottom-3 left-3 right-3 font-mono text-[9px] text-white/60 truncate">
{asset.storyProtocolId}
Expand Down
65 changes: 54 additions & 11 deletions components/launchpad.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import {
Loader2,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { graphQLClient, GET_USER_IP_ASSETS, mapSubgraphAssetToIPAsset } from '@/lib/graphql'
import { graphQLClient, GET_USER_IP_ASSETS, mapSubgraphAssetToIPAsset, MetadataStatus } from '@/lib/graphql'

const RECENT_ACTIVITY: { action: string, asset: string, by: string, time: string, amount: string | null }[] = [] // Empty activity stream for production MVP

Expand All @@ -37,18 +37,61 @@ export function Launchpad() {
}
setLoading(true)
try {
const data: any = await graphQLClient.request(GET_USER_IP_ASSETS, {
first: 10,
skip: 0
})
if (data.ipregistereds) {
const assets = await Promise.all(
data.ipregistereds.map((item: any) => mapSubgraphAssetToIPAsset(item))
)
setMyPortfolio(assets)
const ownerAddress = wallet.address.toLowerCase()

// Fetch from both subgraph and local DB in parallel
const [subgraphResult, localResult] = await Promise.allSettled([
graphQLClient.request(GET_USER_IP_ASSETS, {
first: 50,
skip: 0,
owner: ownerAddress,
}),
fetch(`/api/my-assets?owner=${encodeURIComponent(ownerAddress)}`).then(r => r.json()),
])

// Map subgraph assets
let subgraphAssets: (IPAsset & { metadataStatus?: MetadataStatus })[] = []
if (subgraphResult.status === 'fulfilled') {
const data = subgraphResult.value as any
if (data.ipregistereds) {
subgraphAssets = await Promise.all(
data.ipregistereds.map((item: any) => mapSubgraphAssetToIPAsset(item))
)
}
} else {
console.error('Failed to fetch from Goldsky:', subgraphResult.reason)
}

// Map local DB assets to IPAsset shape
let localAssets: (IPAsset & { metadataStatus?: MetadataStatus })[] = []
if (localResult.status === 'fulfilled' && localResult.value.assets) {
localAssets = localResult.value.assets.map((a: any) => ({
id: a.id,
storyProtocolId: a.storyProtocolId || '',
title: a.title,
creator: 'You',
creatorHandle: `${ownerAddress.substring(0, 6)}...${ownerAddress.substring(ownerAddress.length - 4)}`,
type: a.ipType || 'music',
coverImage: a.imageUrl || '/images/art-1.jpg',
description: a.description || '',
licenses: a.licenses ? a.licenses.split(',').map((s: string) => s.trim()) : ['Commercial'],
price: 0,
currency: 'USDC' as const,
royaltyRate: a.royaltyRate || 10,
registered: new Date(a.createdAt).toISOString().split('T')[0],
tags: ['On-Chain', 'Story Protocol'],
stats: { views: 0, licenses: 0, revenue: 0 },
metadataStatus: 'loaded' as MetadataStatus,
metadataURI: a.metadataUri || '',
}))
}

// Deduplicate: subgraph wins when storyProtocolId matches
const seenIds = new Set(subgraphAssets.map(a => a.storyProtocolId).filter(Boolean))
const uniqueLocal = localAssets.filter(a => !a.storyProtocolId || !seenIds.has(a.storyProtocolId))
setMyPortfolio([...subgraphAssets, ...uniqueLocal])
} catch (err) {
console.error('Failed to fetch from Goldsky:', err)
console.error('Failed to fetch assets:', err)
} finally {
setLoading(false)
}
Expand Down
32 changes: 28 additions & 4 deletions lib/graphql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ export const GET_RECENT_IP_ASSETS = gql`
`

export const GET_USER_IP_ASSETS = gql`
query GetUserIPAssets($first: Int!, $skip: Int!) {
ipregistereds(first: $first, skip: $skip, orderBy: timestamp_, orderDirection: desc) {
query GetUserIPAssets($first: Int!, $skip: Int!, $owner: String!) {
ipregistereds(first: $first, skip: $skip, orderBy: timestamp_, orderDirection: desc, where: { caller: $owner }) {
id
ipId
chainId
Expand All @@ -38,6 +38,7 @@ export const GET_USER_IP_ASSETS = gql`
uri
timestamp_
transactionHash_
caller
}
}
`
Expand All @@ -57,22 +58,44 @@ export const GET_IP_ASSET_BY_ID = gql`
}
`

export type MetadataStatus = 'loading' | 'loaded' | 'failed'

async function fetchWithRetry(url: string, retries = 3, baseDelayMs = 500): Promise<Response> {
for (let attempt = 0; attempt < retries; attempt++) {
const res = await fetch(url)
if (res.ok) return res
if (attempt < retries - 1) {
await new Promise(r => setTimeout(r, baseDelayMs * Math.pow(2, attempt)))
}
}
// Final attempt — let caller handle the error
return fetch(url)
}

// Helper to map the raw Subgraph data to our application's IPAsset interface
export async function mapSubgraphAssetToIPAsset(subgraphData: any): Promise<IPAsset> {
export async function mapSubgraphAssetToIPAsset(subgraphData: any): Promise<IPAsset & { metadataStatus: MetadataStatus }> {
// Extract block timestamp safely (converting seconds to ms if needed)
const dateObj = new Date(Number(subgraphData.timestamp_) * 1000)

let metadata: any = null
let metadataStatus: MetadataStatus = 'loading'
if (subgraphData.uri) {
try {
const url = resolveGroveURI(subgraphData.uri)
if (url) {
const res = await fetch(url)
const res = await fetchWithRetry(url)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
metadata = await res.json()
metadataStatus = 'loaded'
} else {
metadataStatus = 'failed'
}
} catch (e) {
console.warn('Failed to fetch metadata from Grove:', e)
metadataStatus = 'failed'
}
} else {
metadataStatus = 'failed'
}

const getAttr = (key: string) => metadata?.attributes?.find((a: any) => a.key === key)?.value
Expand All @@ -94,6 +117,7 @@ export async function mapSubgraphAssetToIPAsset(subgraphData: any): Promise<IPAs
registered: dateObj.toISOString().split('T')[0],
tags: metadata?.tags || ['On-Chain', 'Story Protocol'],
stats: { views: 0, licenses: 0, revenue: 0 },
metadataStatus,
// Retaining raw mapping data mapping
metadataURI: subgraphData.uri,
transactionHash: subgraphData.transactionHash_,
Expand Down
22 changes: 22 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,25 @@ model ReleaseQueue {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}

model LocalIPAsset {
id String @id @default(cuid())
storyProtocolId String? @unique // ipId from Story Protocol, null until on-chain
ownerAddress String // wallet address of the creator
title String
description String @default("")
ipType String @default("music") // music, image, literature, video
mediaUrl String @default("")
imageUrl String @default("")
metadataUri String @default("")
licenses String @default("Commercial") // comma-separated
royaltyRate Int @default(10)
crossmintId String? // Crossmint action ID returned on registration
tokenContract String?
tokenId String?
transactionHash String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

@@index([ownerAddress])
}