Skip to content
Draft
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,6 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts

# Local test fixtures, not part of the site
dapper-labs-nba-topshots-cids.txt
42 changes: 42 additions & 0 deletions src/app/ipfs2filecoin/components/AgentPrompt.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
'use client'

import { Button } from '@filecoin-foundation/ui-filecoin/Button'
import { usePlausible } from 'next-plausible'

import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'

import { AGENT_PROMPT, PLAUSIBLE_EVENTS } from '../constants/migration'

type AgentPromptProps = {
/** Where on the page the prompt was copied from, so the two spots stay distinguishable. */
source: 'verdict' | 'agent-door'
}

export function AgentPrompt({ source }: AgentPromptProps) {
const { copy, isCopied } = useCopyToClipboard()
const plausible = usePlausible()

async function handleCopy() {
const copied = await copy(AGENT_PROMPT)

if (copied) {
plausible(PLAUSIBLE_EVENTS.promptCopied, { props: { source } })
}
}

return (
<div className="space-y-3">
<code className="block rounded-lg border border-(--color-border-muted) bg-(--color-card-background) p-3 font-mono text-(--color-paragraph-text) text-xs/relaxed">
{AGENT_PROMPT}
</code>

<Button type="button" variant="ghost" onClick={handleCopy}>
{isCopied ? 'Copied' : 'Copy prompt'}
</Button>

<span aria-live="polite" className="sr-only">
{isCopied ? 'Prompt copied to clipboard' : ''}
</span>
</div>
)
}
191 changes: 191 additions & 0 deletions src/app/ipfs2filecoin/components/CidListChecker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
'use client'

import { Button } from '@filecoin-foundation/ui-filecoin/Button'
import { SmartTextLink } from '@filecoin-foundation/ui-filecoin/TextLink/SmartTextLink'
import { Field, Label, Textarea } from '@headlessui/react'
import { usePlausible } from 'next-plausible'
import { useId, useState } from 'react'

import { PATHS } from '@/constants/paths'

import { AgentPrompt } from './AgentPrompt'
import {
BROWSER_CHECK_ITEM_CAP,
MAX_ITEM_SIZE_LABEL,
PLAUSIBLE_EVENTS,
} from '../constants/migration'
import { type CidListSummary, parseCidList } from '../utils/parse-cid-list'

const PLACEHOLDER = [
'bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi',
'bafkreieq5jui4j25lacwomsqgjeswwl3y5zcdrresptwgmfylxo2depppq',
'QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG',
].join('\n')

type Verdict =
| { kind: 'empty' }
| { kind: 'unreadable'; summary: CidListSummary }
| { kind: 'ok'; summary: CidListSummary }
| { kind: 'over-cap'; summary: CidListSummary }

function getVerdict(summary: CidListSummary): Verdict {
if (summary.totalLines === 0) {
return { kind: 'empty' }
}
if (summary.uniqueCids.length === 0) {
return { kind: 'unreadable', summary }
}
if (summary.uniqueCids.length > BROWSER_CHECK_ITEM_CAP) {
return { kind: 'over-cap', summary }
}
return { kind: 'ok', summary }
}

function pluralize(count: number, singular: string, plural = `${singular}s`) {
return count === 1 ? singular : plural
}

function buildNotes({ invalidCount, duplicateCount }: CidListSummary) {
const notes: Array<string> = []

if (invalidCount > 0) {
notes.push(
`${invalidCount} ${pluralize(invalidCount, 'line')} could not be read as a CID and will be skipped`,
)
}
if (duplicateCount > 0) {
notes.push(
`${duplicateCount} ${pluralize(duplicateCount, 'duplicate')} removed`,
)
}

return notes.length > 0 ? `${notes.join('. ')}.` : null
}

export function CidListChecker() {
const inputId = useId()
const plausible = usePlausible()
const [value, setValue] = useState('')
const [verdict, setVerdict] = useState<Verdict | null>(null)

function handleCheck() {
const summary = parseCidList(value)
const next = getVerdict(summary)
setVerdict(next)

if (next.kind === 'empty') {
return
}

plausible(PLAUSIBLE_EVENTS.cidListChecked, {
props: {
outcome: next.kind,
cidCount: summary.uniqueCids.length,
},
})
}

return (
<div className="mx-auto w-full max-w-2xl text-left">
<Field>
<Label
htmlFor={inputId}
className="mb-2 inline-block text-(--color-paragraph-text) text-sm"
>
Paste the CIDs you want to move
</Label>
<Textarea
id={inputId}
value={value}
spellCheck={false}
rows={5}
placeholder={PLACEHOLDER}
onChange={(event) => setValue(event.target.value)}
className="focus:brand-outline block min-h-32 w-full resize-y rounded-lg border border-(--input-border-color) p-3 font-mono text-(--color-text-base) text-sm placeholder:text-(--input-placeholder-color)"
/>
</Field>

<div className="mt-3">
<Button type="button" variant="primary" onClick={handleCheck}>
Check my list
</Button>
</div>

{verdict && (
<div
role="status"
aria-live="polite"
className="mt-4 rounded-lg border border-(--color-border-muted) bg-(--color-card-background) p-4 text-(--color-paragraph-text) text-sm"
>
<VerdictMessage verdict={verdict} />
</div>
)}

<p className="mt-3 text-(--color-paragraph-text) text-sm/relaxed">
Checking is free and needs no wallet, up to{' '}
{BROWSER_CHECK_ITEM_CAP.toLocaleString()} items and{' '}
{MAX_ITEM_SIZE_LABEL} per item. It runs entirely in your browser, so
your list is never sent anywhere. Past that,{' '}
<SmartTextLink href={`${PATHS.IPFS_TO_FILECOIN.path}#agent`}>
hand the migration to your agent
</SmartTextLink>
.
</p>
</div>
)
}

function VerdictMessage({ verdict }: { verdict: Verdict }) {
if (verdict.kind === 'empty') {
return (
<p>
<strong className="font-medium text-(--color-text-base)">
Nothing to check yet.
</strong>{' '}
Paste one CID per line.
</p>
)
}

const { summary } = verdict
const notes = buildNotes(summary)
const count = summary.uniqueCids.length

if (verdict.kind === 'unreadable') {
return (
<p>
<strong className="font-medium text-(--color-text-base)">
None of these look like CIDs.
</strong>{' '}
{summary.totalLines} {pluralize(summary.totalLines, 'line')} could not
be read as a CID. Check for extra text, URLs, or commas.
</p>
)
}

if (verdict.kind === 'over-cap') {
return (
<div className="space-y-3">
<p>
<strong className="font-medium text-(--color-text-base)">
{count.toLocaleString()} CIDs is more than a browser check handles.
</strong>{' '}
The limit here is {BROWSER_CHECK_ITEM_CAP.toLocaleString()} items.
Hand the list to your agent instead: no cap, and it resumes if it
stops. {notes}
</p>
<AgentPrompt source="verdict" />
</div>
)
}

return (
<p>
<strong className="font-medium text-(--color-text-base)">
{count.toLocaleString()} {pluralize(count, 'CID')} ready to check.
</strong>{' '}
Estimate what they cost to store below, then hand the list to your agent
to fetch, measure, and migrate them. {notes}
</p>
)
}
56 changes: 56 additions & 0 deletions src/app/ipfs2filecoin/components/ComparisonTable.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { clsx } from 'clsx'

import { pricingComparison } from '../data/pricing-comparison'

export function ComparisonTable() {
return (
<div className="overflow-x-auto rounded-xl border border-(--color-border-muted)">
<table className="w-full min-w-140 text-left text-sm">
<thead>
<tr className="border-(--color-border-muted) border-b text-(--color-paragraph-text)">
<th scope="col" className="px-5 py-4 font-medium">
Where the data lives
</th>
<th scope="col" className="px-5 py-4 font-medium">
Storage, per TB per month
</th>
<th scope="col" className="px-5 py-4 font-medium">
Egress
</th>
</tr>
</thead>
<tbody className="text-(--color-paragraph-text)">
{pricingComparison.map(
({ service, storagePerTbMonth, egress, highlighted }) => (
<tr
key={service}
className="border-(--color-border-muted) border-b last:border-b-0"
>
<th
scope="row"
className={clsx(
'px-5 py-4 font-normal',
highlighted && 'font-medium text-(--color-text-base)',
)}
>
{service}
</th>
<td
className={clsx(
'px-5 py-4 font-mono whitespace-nowrap',
highlighted && 'text-(--color-text-base) text-lg',
)}
>
{storagePerTbMonth}
</td>
<td className="px-5 py-4 font-mono whitespace-nowrap">
{egress}
</td>
</tr>
),
)}
</tbody>
</table>
</div>
)
}
Loading
Loading