diff --git a/.gitignore b/.gitignore index 5ef6a520..fc1263e7 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/src/app/ipfs2filecoin/components/AgentPrompt.tsx b/src/app/ipfs2filecoin/components/AgentPrompt.tsx new file mode 100644 index 00000000..660e4296 --- /dev/null +++ b/src/app/ipfs2filecoin/components/AgentPrompt.tsx @@ -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 +} + +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 ( +
+ + {prompt} + + + + + + {isCopied ? 'Prompt copied to clipboard' : ''} + +
+ ) +} diff --git a/src/app/ipfs2filecoin/components/CidListChecker.tsx b/src/app/ipfs2filecoin/components/CidListChecker.tsx new file mode 100644 index 00000000..32e519f6 --- /dev/null +++ b/src/app/ipfs2filecoin/components/CidListChecker.tsx @@ -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(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(null) + const verdictRef = useRef(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 ( +
+ + {/* + 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. + */} +
+
+ + {progress && ( + + {progress} + + )} +
+ + {/* + 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. + */} +