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..3f0d4c8 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,11 @@ export function LinkCreator({ onPreviewHash }: LinkCreatorProps) { const generationRequestRef = useRef(0); const markdownCopyTokenRef = useRef(0); const generatedLinkRef = useRef(null); + const fileInputRef = useRef(null); + const fileLoadRequestRef = useRef(0); + const draftRef = useRef(draft); + const fileDragDepthRef = useRef(0); + const [isFileDragOver, setIsFileDragOver] = useState(false); const isGeneratedLinkStale = Boolean(generatedLink) && draftVersion !== generatedVersion; const contentFieldLabel = getBodyFieldLabel(draft.kind); @@ -88,6 +99,10 @@ export function LinkCreator({ onPreviewHash }: LinkCreatorProps) { generatedLinkRef.current = generatedLink; }, [generatedLink]); + useLayoutEffect(() => { + draftRef.current = draft; + }, [draft]); + useEffect(() => { setCopyState("idle"); setMarkdownLinkCopyState("idle"); @@ -113,6 +128,110 @@ 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) => { + const requestId = fileLoadRequestRef.current + 1; + fileLoadRequestRef.current = requestId; + + try { + assertReadableLocalArtifactFile(file); + const text = await file.text(); + if (fileLoadRequestRef.current !== requestId) { + return; + } + + replaceDraft( + createDraftFromLocalFile( + { + name: file.name, + size: file.size, + type: file.type, + text, + }, + draftRef.current, + ), + ); + setError(null); + } catch (loadError) { + if (fileLoadRequestRef.current !== requestId) { + return; + } + + 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 +425,53 @@ export function LinkCreator({ onPreviewHash }: LinkCreatorProps) { 03

Load body

-