From de84084cbca7fb50626c72cb7b3c1682255f806c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 17 Sep 2026 05:01:21 +0000 Subject: [PATCH 1/2] Add local file loading to the homepage link creator The create-a-link flow could only accept pasted text. Users can now choose or drop a local text file, which is read in the browser and compressed into the same fragment link. Co-authored-by: aanishbhirud --- AGENTS.md | 2 +- CHANGELOG.md | 1 + docs/architecture.md | 2 +- src/app/globals.css | 29 +++ src/components/home/link-creator.tsx | 164 ++++++++++++++- src/lib/payload/local-file.ts | 277 +++++++++++++++++++++++++ tests/components/link-creator.test.tsx | 40 ++++ tests/e2e/viewer.spec.ts | 29 +++ tests/local-file.test.ts | 155 ++++++++++++++ 9 files changed, 686 insertions(+), 13 deletions(-) create mode 100644 src/lib/payload/local-file.ts create mode 100644 tests/local-file.test.ts diff --git a/AGENTS.md b/AGENTS.md index 79834a0..f466efd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,7 +45,7 @@ Describe and preserve what is already true in the repo today. - The app renders as one export-friendly shell. - The empty state explains the product and exposes sample fragment presets. -- A built-in link creator can generate fragment-based links locally in the browser. +- A built-in link creator can generate fragment-based links locally in the browser. The load-body step accepts pasted text or a local file read in the browser; the file is not sent to the host. - When a valid fragment is present, the app switches to a viewer-first artifact layout. - The artifact stage toolbar exposes copy-to-clipboard, file download, (for markdown) browser print-to-PDF, and an editor that regenerates a new fragment link from the currently open artifact. - `activeArtifactId` controls which artifact opens first. diff --git a/CHANGELOG.md b/CHANGELOG.md index 62f892b..5296547 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and ### Added +- Link creator can load a local text file into the draft (client-side only) instead of requiring a paste. - In-viewer edit-and-reshare: open any artifact, correct its content, and generate a new fragment link without leaving the viewer. - `arx5` (ARX 4.5): ARX4's context mixer on ARX2's tuple pipeline, scored by honest serialized transport length so Discord and WhatsApp no longer detonate Unicode wires. diff --git a/docs/architecture.md b/docs/architecture.md index 0130670..ea64255 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -29,7 +29,7 @@ The static export also emits `sitemap.xml` at the site root (and under `NEXT_PUB The viewer shell now routes all five artifact kinds through dynamically imported client-only renderers so the landing shell stays light and static-host friendly. -When a valid fragment is present, the shell switches into a viewer-first layout with bundle navigation beside the active artifact. The active artifact header includes copy, download, markdown print, and edit-and-reshare actions. Edit regenerates a new fragment link from the current artifact (or the current artifact inside a bundle) without writing anything to a server. The landing/samples experience is only the empty state. +When a valid fragment is present, the shell switches into a viewer-first layout with bundle navigation beside the active artifact. The active artifact header includes copy, download, markdown print, and edit-and-reshare actions. Edit regenerates a new fragment link from the current artifact (or the current artifact inside a bundle) without writing anything to a server. The landing/samples experience is only the empty state. The empty-state link creator can paste content or read a local text file in the browser before compressing it into a fragment; the file never enters the request path. Diff file navigation is intentionally internal UI state now. The URL fragment remains reserved for payload transport and active-artifact selection instead of being reused as an in-page file anchor system. diff --git a/src/app/globals.css b/src/app/globals.css index 38bb5fc..5f44095 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -283,6 +283,7 @@ button:disabled { .artifact-switcher:focus-visible, .creator-input:focus-visible, .creator-textarea:focus-visible, +.creator-file-input:focus-visible, .artifact-disclosure-summary:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 2px; @@ -603,6 +604,34 @@ button:disabled { white-space: nowrap; } +.creator-file-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; +} + +.creator-file-input { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + clip-path: inset(50%); + white-space: nowrap; +} + +.creator-file-note { + color: var(--on-chassis-muted); + font-size: 11px; + line-height: 1.35; +} + +.creator-field.is-drop-target .creator-textarea { + border-color: var(--control-accent); + box-shadow: inset 0 2px 5px rgba(0, 0, 0, 0.44), 0 0 0 1px var(--control-accent); +} + .creator-input, .creator-textarea { width: 100%; diff --git a/src/components/home/link-creator.tsx b/src/components/home/link-creator.tsx index d0622eb..1a0ac31 100644 --- a/src/components/home/link-creator.tsx +++ b/src/components/home/link-creator.tsx @@ -1,7 +1,7 @@ "use client"; -import { useEffect, useLayoutEffect, useRef, useState } from "react"; -import { Link2 } from "lucide-react"; +import { useEffect, useLayoutEffect, useRef, useState, type DragEvent } from "react"; +import { Link2, Upload } from "lucide-react"; import { copyTextToClipboard } from "@/lib/copy-text"; import { CODE_LANGUAGE_CHOICES } from "@/lib/code/language"; import { CodecPicker, GeneratedLinkResult } from "@/components/generated-link"; @@ -9,6 +9,11 @@ import type { GeneratedArtifactLink, LinkCreatorDraft, } from "@/lib/payload/link-creator"; +import { + assertReadableLocalArtifactFile, + createDraftFromLocalFile, + LOCAL_ARTIFACT_FILE_ACCEPT, +} from "@/lib/payload/local-file"; import { artifactKinds, type ArtifactKind } from "@/lib/payload/schema"; import { cn } from "@/lib/utils"; @@ -17,11 +22,11 @@ type LinkCreatorProps = { }; const fieldHints: Record = { - markdown: "Paste markdown notes, release docs, or a spec excerpt.", - code: "Paste a code snippet and add a language hint when known.", - diff: "Paste a unified git patch.", - csv: "Paste comma-separated headings and rows.", - json: "Paste formatted or compact JSON.", + markdown: "Paste notes or load a local markdown file.", + code: "Paste a snippet or load a local source file.", + diff: "Paste a unified git patch or load a .diff / .patch file.", + csv: "Paste rows or load a local CSV file.", + json: "Paste JSON or load a local .json file.", }; const fieldPlaceholders: Record = { @@ -58,7 +63,8 @@ function getBodyFieldLabel(kind: ArtifactKind) { } /** - * Builds shareable fragment links from pasted artifact content in the home empty state flow. + * Builds shareable fragment links from pasted or locally loaded artifact content + * in the home empty state flow. File selection is read in the browser only. * Accepts `onPreviewHash` so the parent shell can preview the generated fragment before navigation. * Generates links client-side with validation, and exposes inline copy/error/stale-result states. */ @@ -80,6 +86,9 @@ export function LinkCreator({ onPreviewHash }: LinkCreatorProps) { const generationRequestRef = useRef(0); const markdownCopyTokenRef = useRef(0); const generatedLinkRef = useRef(null); + const fileInputRef = useRef(null); + const fileDragDepthRef = useRef(0); + const [isFileDragOver, setIsFileDragOver] = useState(false); const isGeneratedLinkStale = Boolean(generatedLink) && draftVersion !== generatedVersion; const contentFieldLabel = getBodyFieldLabel(draft.kind); @@ -113,6 +122,99 @@ export function LinkCreator({ onPreviewHash }: LinkCreatorProps) { }); }; + const replaceDraft = (nextDraft: LinkCreatorDraft) => { + setDraftState((current) => { + if ( + current.draft.kind === nextDraft.kind && + current.draft.title === nextDraft.title && + current.draft.filename === nextDraft.filename && + current.draft.content === nextDraft.content && + current.draft.language === nextDraft.language && + current.draft.diffView === nextDraft.diffView && + current.draft.codec === nextDraft.codec + ) { + return current; + } + + return { + draft: nextDraft, + version: current.version + 1, + }; + }); + }; + + const handleLocalFile = async (file: File) => { + try { + assertReadableLocalArtifactFile(file); + const text = await file.text(); + replaceDraft( + createDraftFromLocalFile( + { + name: file.name, + size: file.size, + type: file.type, + text, + }, + draft, + ), + ); + setError(null); + } catch (loadError) { + setError( + loadError instanceof Error + ? loadError.message + : "The file could not be read.", + ); + } + }; + + const resetFileDragState = () => { + fileDragDepthRef.current = 0; + setIsFileDragOver(false); + }; + + const handleFileDragEnter = (event: DragEvent) => { + if (!event.dataTransfer.types.includes("Files")) { + return; + } + + event.preventDefault(); + fileDragDepthRef.current += 1; + setIsFileDragOver(true); + }; + + const handleFileDragOver = (event: DragEvent) => { + if (!event.dataTransfer.types.includes("Files")) { + return; + } + + event.preventDefault(); + event.dataTransfer.dropEffect = "copy"; + }; + + const handleFileDragLeave = (event: DragEvent) => { + if (!event.dataTransfer.types.includes("Files")) { + return; + } + + event.preventDefault(); + fileDragDepthRef.current -= 1; + if (fileDragDepthRef.current <= 0) { + resetFileDragState(); + } + }; + + const handleFileDrop = (event: DragEvent) => { + const file = event.dataTransfer.files.item(0); + resetFileDragState(); + if (!file) { + return; + } + + event.preventDefault(); + void handleLocalFile(file); + }; + const handleGenerate = async () => { const requestId = generationRequestRef.current + 1; generationRequestRef.current = requestId; @@ -306,12 +408,52 @@ export function LinkCreator({ onPreviewHash }: LinkCreatorProps) { 03

Load body

-