Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
c4a9825
feat(ipfs2filecoin): add IPFS to Filecoin campaign landing page
SgtPooki Jul 29, 2026
f8d4d44
feat(ipfs2filecoin): replace the draft brief with an executable runbook
SgtPooki Jul 29, 2026
cc32d8e
fix(ipfs2filecoin): correct two runbook claims the CLI does not support
SgtPooki Jul 30, 2026
55dcabd
chore(ipfs2filecoin): re-verify competitor rates
SgtPooki Aug 3, 2026
b67a9fb
fix(ipfs2filecoin): stop pinning the CLI to an unpublished version
SgtPooki Aug 3, 2026
abbe4ca
fix(ipfs2filecoin): add the flat data set fee once, not per TiB
SgtPooki Aug 3, 2026
bc84838
feat(ipfs2filecoin): rewrite the runbook for the direct-upload flow
SgtPooki Aug 3, 2026
18992c2
Merge remote-tracking branch 'origin/main' into feat/ipfs2filecoin-la…
SgtPooki Aug 14, 2026
4b57966
fix(ipfs2filecoin): design review follow-ups for the landing page (#350)
gmoranxyz Aug 14, 2026
e15f45a
fix(ipfs2filecoin): drop llms.txt links to the hidden service-provide…
SgtPooki Aug 14, 2026
5a6ecc7
fix(ipfs2filecoin): accept subdomain-style gateway URLs in the CID ch…
SgtPooki Aug 14, 2026
93b8963
fix(ipfs2filecoin): describe resumability as the CLI state database, …
SgtPooki Aug 14, 2026
e6a9f59
fix(ipfs2filecoin): runbook requires Node 24 per ipfs2foc 0.8.1
SgtPooki Aug 14, 2026
b8225b0
feat(ipfs2filecoin): drop the 500-item browser check cap
SgtPooki Aug 17, 2026
030bb75
feat(ipfs2filecoin): remove llms.txt routes, the runbook at migrate.m…
SgtPooki Aug 17, 2026
2562889
feat(ipfs2filecoin): carry the agent prompt in the runbook itself
SgtPooki Aug 17, 2026
e9db548
perf(ipfs2filecoin): single-pass CID list parse
SgtPooki Aug 20, 2026
81f6d58
docs(ipfs2filecoin): steer agents off pre-upgrade data sets
SgtPooki Aug 20, 2026
e6af50e
docs(ipfs2filecoin): disclose CLI telemetry in runbook
SgtPooki Aug 20, 2026
5a02931
feat(ipfs2filecoin): state the list stays in the browser
SgtPooki Aug 21, 2026
991613f
fix(ipfs2filecoin): dedupe trust line with the fact chips
SgtPooki Aug 21, 2026
1475680
fix(ipfs2filecoin): keep the check button on one line
SgtPooki Aug 24, 2026
b542fd1
feat(ipfs2filecoin): apply product-sync feedback
SgtPooki Aug 24, 2026
0d558ad
feat(ipfs2filecoin): lead with the agent prompt
SgtPooki Aug 24, 2026
31227d1
fix(ipfs2filecoin): close with the promised actions
SgtPooki Aug 24, 2026
65760e4
chore(ipfs2filecoin): point prompt at the preview
SgtPooki Aug 24, 2026
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
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,8 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts

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

import { Icon } from '@filecoin-foundation/ui-filecoin/Icon'
import { CheckIcon, CopyIcon } from '@phosphor-icons/react/dist/ssr'
import { usePlausible } from 'next-plausible'

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

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

type AgentPromptProps = {
/** Where on the page the prompt was copied from, so the two spots stay distinguishable. */
source: 'hero' | 'verdict' | 'agent-door'
/**
* The user's checked list. When present it is inlined into the prompt so the
* copied line carries the actual CIDs instead of pointing at a cids.txt the
* user would have to assemble themselves.
*/
cids?: ReadonlyArray<string>
}

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

const prompt = buildAgentPrompt(cids)

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

if (copied) {
plausible(PLAUSIBLE_EVENTS.promptCopied, {
props: { source, cidCount: cids?.length ?? 0 },
})
}
}

return (
<div className="relative">
<code className="block max-h-48 overflow-y-auto whitespace-pre-wrap rounded-lg border border-(--color-border-muted) bg-(--color-card-background) p-3 pr-12 font-mono text-(--color-paragraph-text) text-xs/relaxed">
{prompt}
</code>

<button
type="button"
onClick={handleCopy}
aria-label={isCopied ? 'Prompt copied' : 'Copy prompt'}
className="focus-visible:brand-outline absolute top-2 right-2 rounded-md border border-(--color-border-muted) bg-(--color-card-background) p-1.5 text-(--color-paragraph-text) hover:text-(--color-text-base)"
>
<Icon component={isCopied ? CheckIcon : CopyIcon} size={16} />
</button>

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

import { Button } from '@filecoin-foundation/ui-filecoin/Button'
import { Description, Field, Label, Textarea } from '@headlessui/react'
import { usePlausible } from 'next-plausible'
import { useEffect, useMemo, useRef, useState } from 'react'

import { CidListVerdict, getVerdict, type Verdict } from './CidListVerdict'
import { MAX_ITEM_SIZE_LABEL, PLAUSIBLE_EVENTS } from '../constants/migration'
import { parseCidList } from '../utils/parse-cid-list'
import { pluralize } from '../utils/pluralize'

/**
* One line, not three. Three full CIDs fill the field edge to edge and read as
* a list already pasted; a single dim example reads as the prompt it is. The
* field keeps its height from `min-h`, so it still invites a list.
*/
const PLACEHOLDER =
'bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi'

/** The free-check facts, broken out so they scan as features rather than fine print. */
const CHECK_FACTS = [
'Free, no wallet required',
'No list-size limit',
`${MAX_ITEM_SIZE_LABEL} per item`,
'Runs in your browser',
'Never sent anywhere',
]

/**
* The submit shortcut accepts Cmd or Ctrl, so the chip has to name whichever
* one this visitor actually has. Resolved after mount rather than rendered on
* the server, which has no way to know: `null` until then keeps the first
* client render identical to the server's and avoids a hydration mismatch.
*/
function useShortcutLabel() {
const [label, setLabel] = useState<string | null>(null)

useEffect(() => {
const isApple = /mac|iphone|ipad|ipod/i.test(
navigator.platform || navigator.userAgent,
)
setLabel(isApple ? '⌘ ↵' : 'Ctrl ↵')
}, [])

return label
}

/**
* What the parser makes of the list as it is typed, so the button confirms a
* result the user can already see rather than being the first sign of one.
*/
function describeProgress(
totalLines: number,
recognizedCount: number,
): string | null {
if (totalLines === 0) {
return null
}
if (recognizedCount === 0) {
return 'No CIDs recognized yet'
}
if (recognizedCount === totalLines) {
return `${recognizedCount.toLocaleString()} ${pluralize(recognizedCount, 'CID')}`
}

return `${recognizedCount.toLocaleString()} of ${totalLines.toLocaleString()} lines are CIDs`
}

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

const shortcutLabel = useShortcutLabel()
const summary = useMemo(() => parseCidList(value), [value])
const hasInput = value.trim().length > 0
const progress = describeProgress(
summary.totalLines,
summary.uniqueCids.length,
)

/**
* The panel lands below the fold on a laptop, so a check would otherwise look
* like it did nothing. Aligning its bottom edge reveals the whole result while
* leaving the list itself in view, which `nearest` does not: for an element
* taller than the gap below it, the minimum scroll is barely any scroll.
*/
useEffect(() => {
if (verdict) {
verdictRef.current?.scrollIntoView({
block: 'end',
behavior: 'smooth',
})
}
}, [verdict])

function handleCheck() {
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>
{/*
One enclosed surface rather than a label, a field and an action loose
on the page: the border is what says "this is the thing you use". The
header and footer bars carry the supporting text, so the input itself
stays uninterrupted.
*/}
<div className="overflow-hidden rounded-xl border border-(--color-border-muted) bg-(--color-card-background) transition-colors has-[textarea:focus]:border-brand-600 has-[textarea:focus]:ring-1 has-[textarea:focus]:ring-brand-600">
<div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1 px-4 pt-3">
<Label className="font-medium text-(--color-text-base) text-sm">
Paste the CIDs you want to move
</Label>
{progress && (
<span
aria-live="polite"
className="text-(--color-paragraph-text) text-xs tabular-nums"
>
{progress}
</span>
)}
</div>

{/*
One entry per visual row: soft wrapping would render a long gateway
URL as two rows and contradict the line count in the header, so long
lines scroll sideways instead, the way a code editor holds them.
*/}
<Textarea
value={value}
spellCheck={false}
wrap="off"
placeholder={PLACEHOLDER}
onChange={(event) => setValue(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
handleCheck()
}
}}
className="mt-2 block max-h-64 min-h-16 w-full resize-none overflow-auto whitespace-pre field-sizing-content border-0 bg-transparent px-4 pb-3 font-mono text-(--color-text-base) text-sm/relaxed placeholder:text-(--color-paragraph-text-subtle) focus:outline-none"
/>

<div className="flex flex-col items-stretch gap-3 border-(--color-border-muted) border-t px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:gap-x-4">
<Description className="text-(--color-paragraph-text) text-xs">
One per line. Gateway URLs are fine, the prefix is stripped. Only
the count is measured, never the list.
</Description>
<Button
type="button"
variant="primary"
size="compact"
onClick={handleCheck}
disabled={!hasInput}
className="w-full whitespace-nowrap sm:w-auto sm:shrink-0"
>
Check my list
{/* Pointer-only: a shortcut is noise without a modifier key. */}
{shortcutLabel && (
<kbd className="ml-2 hidden rounded border border-(--color-border-base) px-1 py-px font-sans text-(--color-paragraph-text) text-xs/none sm:inline">
{shortcutLabel}
</kbd>
)}
</Button>
</div>
</div>
</Field>

{/* Directly under the control that produced it: the reassurance below is
what you read before deciding, not between a question and its answer. */}
{verdict && (
<div ref={verdictRef} className="mt-3 scroll-mb-6">
<CidListVerdict verdict={verdict} />
</div>
)}

<ul className="mt-3 flex flex-wrap items-center justify-center gap-x-2 gap-y-1 text-(--color-paragraph-text) text-xs">
{CHECK_FACTS.map((fact) => (
<li
key={fact}
className="flex items-center gap-2 before:text-(--color-border-base) before:content-['•'] first:before:hidden"
>
{fact}
</li>
))}
</ul>
</div>
)
}
Loading
Loading