diff --git a/.gitignore b/.gitignore index 4fc5fa81..fc1263e7 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,5 @@ 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 index 1569d9f6..c83bd1a9 100644 --- a/src/app/ipfs2filecoin/components/AgentPrompt.tsx +++ b/src/app/ipfs2filecoin/components/AgentPrompt.tsx @@ -1,38 +1,54 @@ 'use client' -import { Button } from '@filecoin-foundation/ui-filecoin/Button' +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 { AGENT_PROMPT, PLAUSIBLE_EVENTS } from '../constants/migration' +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: '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 }: AgentPromptProps) { +export function AgentPrompt({ source, cids }: AgentPromptProps) { const { copy, isCopied } = useCopyToClipboard() const plausible = usePlausible() + const prompt = buildAgentPrompt(cids) + async function handleCopy() { - const copied = await copy(AGENT_PROMPT) + const copied = await copy(prompt) if (copied) { - plausible(PLAUSIBLE_EVENTS.promptCopied, { props: { source } }) + plausible(PLAUSIBLE_EVENTS.promptCopied, { + props: { source, cidCount: cids?.length ?? 0 }, + }) } } return ( -
- - {AGENT_PROMPT} +
+ + {prompt} - + {isCopied ? 'Prompt copied to clipboard' : ''} diff --git a/src/app/ipfs2filecoin/components/CidListChecker.tsx b/src/app/ipfs2filecoin/components/CidListChecker.tsx index 14e25136..e800d1e7 100644 --- a/src/app/ipfs2filecoin/components/CidListChecker.tsx +++ b/src/app/ipfs2filecoin/components/CidListChecker.tsx @@ -1,75 +1,106 @@ '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 { Description, Field, Label, Textarea } from '@headlessui/react' import { usePlausible } from 'next-plausible' -import { useId, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' -import { PATHS } from '@/constants/paths' - -import { AgentPrompt } from './AgentPrompt' +import { CidListVerdict, getVerdict, type Verdict } from './CidListVerdict' 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 } -} +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', + `Up to ${BROWSER_CHECK_ITEM_CAP.toLocaleString()} items`, + `${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 ↵') + }, []) -function pluralize(count: number, singular: string, plural = `${singular}s`) { - return count === 1 ? singular : plural + return label } -function buildNotes({ invalidCount, duplicateCount }: CidListSummary) { - const notes: Array = [] - - if (invalidCount > 0) { - notes.push( - `${invalidCount} ${pluralize(invalidCount, 'line')} could not be read as a CID and will be skipped`, - ) +/** + * 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 (duplicateCount > 0) { - notes.push( - `${duplicateCount} ${pluralize(duplicateCount, 'duplicate')} removed`, - ) + if (recognizedCount === 0) { + return 'No CIDs recognized yet' + } + if (recognizedCount === totalLines) { + return `${recognizedCount.toLocaleString()} ${pluralize(recognizedCount, 'CID')}` } - return notes.length > 0 ? `${notes.join('. ')}.` : null + return `${recognizedCount.toLocaleString()} of ${totalLines.toLocaleString()} lines are CIDs` } export function CidListChecker() { - const inputId = useId() 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 summary = parseCidList(value) const next = getVerdict(summary) setVerdict(next) @@ -88,104 +119,88 @@ export function CidListChecker() { return (
- -