Skip to content

Commit 3d47768

Browse files
gnick18claude
andcommitted
feat(ux): add drag-and-drop to all picker-only file/folder surfaces
- AccountHome: connect-folder card now accepts a dragged folder alongside the click button. Uses extractDirectoryHandleFromDrop -> connectWithHandle, the same pipeline as FolderConnectGate. Ref-counted drag counter prevents flicker on child-boundary crossings. Visual highlight (blue dashed border + ring) matches the FolderConnectGate pattern. Drop error shown inline. - SharingSection: Recovery Kit file picker replaced with FileDropzone (drag or click). Keeps .html/.json accept filter. - LabSiteDashboard: BYO static-site zip upload replaced with FileDropzone. - RoomMap: floor-plan canvas now accepts drag-dropped .svg files via handleCanvasDrop, feeding onUploadFile. Overlay shows "Drop SVG floor plan to upload" while dragging. The hidden input + Upload button remain. tsc clean. No drop zones removed; click paths all preserved. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 10c3e50 commit 3d47768

4 files changed

Lines changed: 169 additions & 38 deletions

File tree

frontend/src/components/account/AccountHome.tsx

Lines changed: 105 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,14 @@
99
//
1010
// No emojis, no em-dashes, no mid-sentence colons.
1111

12-
import { useEffect, useState } from "react";
12+
import { useEffect, useRef, useState } from "react";
1313
import { useRouter } from "next/navigation";
1414
import { getSession } from "next-auth/react";
1515
import { useFileSystem } from "@/lib/file-system/file-system-context";
16+
import {
17+
extractDirectoryHandleFromDrop,
18+
describeDropExtractionError,
19+
} from "@/lib/file-system/drop-folder";
1620
import { ONBOARDING_WIZARD_ENABLED } from "@/lib/onboarding/config";
1721
import { isDeviceKeyV2Enabled } from "@/lib/sharing/identity/device-key-v2";
1822
import { loadKeysAtRest } from "@/lib/sharing/identity/device-vault";
@@ -61,12 +65,20 @@ export default function AccountHome() {
6165
const {
6266
isConnected,
6367
connect,
68+
connectWithHandle,
6469
lastConnectedFolder,
6570
reconnectWithStoredHandle,
6671
initializeFolder,
6772
} = useFileSystem();
6873
const router = useRouter();
6974
const [connecting, setConnecting] = useState(false);
75+
76+
// Drag-and-drop state for the folder connect card. Ref-counted so nested
77+
// children don't flicker the highlight off when the pointer crosses an
78+
// internal boundary (same pattern as FolderConnectGate).
79+
const [isDragOver, setIsDragOver] = useState(false);
80+
const [dropError, setDropError] = useState<string | null>(null);
81+
const dragCounterRef = useRef(0);
7082
// Set when a folder-requiring surface bounced the user here (account-first).
7183
const [fromRoute, setFromRoute] = useState<string | null>(null);
7284
// The signed-in name (from the profile or the session), for the welcome-back.
@@ -314,6 +326,62 @@ export default function AccountHome() {
314326
}
315327
};
316328

329+
// Drag handlers for the folder connect card. Mirror the FolderConnectGate
330+
// pattern: ref-counted enter/leave avoids flickering on child-boundary
331+
// crossings, and the drop feeds extractDirectoryHandleFromDrop -> connectWithHandle,
332+
// which is the same pipeline as the OS picker button.
333+
const handleDragEnter = (e: React.DragEvent<HTMLDivElement>) => {
334+
e.preventDefault();
335+
e.stopPropagation();
336+
if (!e.dataTransfer?.types?.includes("Files")) return;
337+
dragCounterRef.current += 1;
338+
setIsDragOver(true);
339+
setDropError(null);
340+
};
341+
342+
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
343+
e.preventDefault();
344+
e.stopPropagation();
345+
if (e.dataTransfer) e.dataTransfer.dropEffect = "copy";
346+
};
347+
348+
const handleDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
349+
e.preventDefault();
350+
e.stopPropagation();
351+
dragCounterRef.current = Math.max(0, dragCounterRef.current - 1);
352+
if (dragCounterRef.current === 0) setIsDragOver(false);
353+
};
354+
355+
const handleDrop = async (e: React.DragEvent<HTMLDivElement>) => {
356+
e.preventDefault();
357+
e.stopPropagation();
358+
dragCounterRef.current = 0;
359+
setIsDragOver(false);
360+
const items = e.dataTransfer?.items;
361+
if (!items || items.length === 0) return;
362+
const result = await extractDirectoryHandleFromDrop(items);
363+
if (result.kind === "ok") {
364+
setDropError(null);
365+
setConnecting(true);
366+
try {
367+
const ok = await connectWithHandle(result.handle);
368+
if (ok) {
369+
router.push("/");
370+
return;
371+
}
372+
const initialized = await initializeFolder();
373+
if (initialized) {
374+
router.push("/");
375+
return;
376+
}
377+
} finally {
378+
setConnecting(false);
379+
}
380+
return;
381+
}
382+
setDropError(describeDropExtractionError(result.kind, "message" in result ? result.message : undefined));
383+
};
384+
317385
// Returning = they have used ResearchOS before (a cloud profile or a remembered
318386
// folder on this device), so the folderless state is "reconnect", not "set up".
319387
const isReturning = Boolean(profile) || Boolean(lastConnectedFolder);
@@ -460,7 +528,17 @@ export default function AccountHome() {
460528
{/* Your data: connect a folder (folderless) or open the app (connected).
461529
The account is the cloud part; the data folder is the optional local
462530
part, and this card is the bridge to it in both states. */}
463-
<div className="rounded-2xl border border-brand-action/30 bg-brand-action/5 p-5">
531+
<div
532+
onDragEnter={!isConnected ? handleDragEnter : undefined}
533+
onDragOver={!isConnected ? handleDragOver : undefined}
534+
onDragLeave={!isConnected ? handleDragLeave : undefined}
535+
onDrop={!isConnected ? (e) => void handleDrop(e) : undefined}
536+
className={`rounded-2xl border p-5 transition-all ${
537+
!isConnected && isDragOver
538+
? "border-2 border-dashed border-blue-400 bg-blue-500/15 ring-4 ring-blue-400/30"
539+
: "border-brand-action/30 bg-brand-action/5"
540+
}`}
541+
>
464542
{isConnected ? (
465543
<>
466544
<h2 className="text-body font-bold text-foreground">Your data is connected</h2>
@@ -484,21 +562,31 @@ export default function AccountHome() {
484562
computer. Point us to it below to continue.
485563
</p>
486564
)}
487-
<h2 className="text-body font-bold text-foreground">
488-
{isReturning
489-
? `Welcome back${welcomeName ? `, ${welcomeName}` : ""}`
490-
: "Connect your data folder"}
491-
</h2>
492-
<p className="mt-1 text-meta text-foreground-muted">
565+
{/* Drag-over heading replaces the normal heading while a folder hovers.
566+
Always in layout (invisible when not dragging) so the card height
567+
stays stable and the drop target never shrinks under the cursor. */}
568+
{isDragOver ? (
569+
<h2 className="text-body font-bold text-blue-700 dark:text-blue-100">
570+
Release to connect this folder
571+
</h2>
572+
) : (
573+
<h2 className="text-body font-bold text-foreground">
574+
{isReturning
575+
? `Welcome back${welcomeName ? `, ${welcomeName}` : ""}`
576+
: "Connect your data folder"}
577+
</h2>
578+
)}
579+
<p className={`mt-1 text-meta text-foreground-muted ${isDragOver ? "invisible" : ""}`} aria-hidden={isDragOver || undefined}>
493580
{isReturning
494581
? "You are signed in. Your research data lives in a folder on your computer, not on our servers. Point us to it to pick up where you left off."
495-
: "Your notes, experiments, and files live in a folder on this computer, never on our servers. Connect one to start working. You can do this any time, from any device that has your data."}
582+
: "Your notes, experiments, and files live in a folder on this computer, never on our servers. Connect one to start working. Drag your data folder here or click to browse."}
496583
</p>
584+
<div className={`flex items-center gap-3 mt-3 ${isDragOver ? "invisible" : ""}`} aria-hidden={isDragOver || undefined}>
497585
<button
498586
type="button"
499587
onClick={() => void onConnect()}
500588
disabled={connecting}
501-
className="ros-btn-raise mt-3 rounded-lg bg-brand-action px-4 py-2 text-meta font-semibold text-white disabled:opacity-60"
589+
className="ros-btn-raise rounded-lg bg-brand-action px-4 py-2 text-meta font-semibold text-white disabled:opacity-60"
502590
>
503591
{connecting
504592
? "Opening…"
@@ -508,6 +596,13 @@ export default function AccountHome() {
508596
? "Point us to your folder"
509597
: "Connect a data folder"}
510598
</button>
599+
<span className="text-meta text-foreground-subtle">or drag a folder here</span>
600+
</div>
601+
{dropError && (
602+
<p role="alert" className="mt-2 text-meta text-red-600 dark:text-red-300">
603+
{dropError}
604+
</p>
605+
)}
511606
{/*
512607
Go-live (gated by NEXT_PUBLIC_ONBOARDING_WIZARD): a permanent escape
513608
to the read-only /demo workspace so a signed-in user without a

frontend/src/components/inventory/RoomMap.tsx

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,12 @@ export default function RoomMap({ nodes, stocks }: RoomMapProps) {
7878
const dragRef = useRef<{ pinId: string; moved: boolean } | null>(null);
7979
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
8080

81+
// Drag-and-drop for the floor-plan canvas. The drop target is the outer
82+
// canvas wrapper; a dropped .svg file feeds the same onUploadFile handler
83+
// as the click-to-pick button, so both paths produce the same result.
84+
const [isFloorPlanDragOver, setIsFloorPlanDragOver] = useState(false);
85+
const floorPlanDragCounter = useRef(0);
86+
8187
// Seed local pins + plan once the map loads.
8288
useEffect(() => {
8389
if (loadedMap) {
@@ -175,6 +181,33 @@ export default function RoomMap({ nodes, stocks }: RoomMapProps) {
175181
[setFloorplan],
176182
);
177183

184+
const handleCanvasDragEnter = (e: React.DragEvent<HTMLDivElement>) => {
185+
e.preventDefault();
186+
if (!e.dataTransfer?.types?.includes("Files")) return;
187+
floorPlanDragCounter.current += 1;
188+
setIsFloorPlanDragOver(true);
189+
};
190+
191+
const handleCanvasDragOver = (e: React.DragEvent<HTMLDivElement>) => {
192+
e.preventDefault();
193+
if (e.dataTransfer) e.dataTransfer.dropEffect = "copy";
194+
};
195+
196+
const handleCanvasDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
197+
e.preventDefault();
198+
floorPlanDragCounter.current = Math.max(0, floorPlanDragCounter.current - 1);
199+
if (floorPlanDragCounter.current === 0) setIsFloorPlanDragOver(false);
200+
};
201+
202+
const handleCanvasDrop = (e: React.DragEvent<HTMLDivElement>) => {
203+
e.preventDefault();
204+
e.stopPropagation();
205+
floorPlanDragCounter.current = 0;
206+
setIsFloorPlanDragOver(false);
207+
const file = e.dataTransfer?.files?.[0];
208+
if (file) onUploadFile(file);
209+
};
210+
178211
// Nodes not yet pinned, offered in the "Place a location" rail. Containers
179212
// (anything that is not a `box`) read most naturally on a room map, but any
180213
// node can be pinned.
@@ -349,7 +382,20 @@ export default function RoomMap({ nodes, stocks }: RoomMapProps) {
349382
))}
350383
</div>
351384
) : null}
352-
<div className="relative w-full" style={{ aspectRatio: String(aspect) }}>
385+
<div
386+
className="relative w-full"
387+
style={{ aspectRatio: String(aspect) }}
388+
onDragEnter={handleCanvasDragEnter}
389+
onDragOver={handleCanvasDragOver}
390+
onDragLeave={handleCanvasDragLeave}
391+
onDrop={handleCanvasDrop}
392+
data-attach-target
393+
>
394+
{isFloorPlanDragOver ? (
395+
<div className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center rounded-xl border-2 border-dashed border-brand-action bg-brand-action/5">
396+
<p className="text-body font-semibold text-brand-action">Drop SVG floor plan to upload</p>
397+
</div>
398+
) : null}
353399
<ZoomPanCanvas
354400
contentWidth={CONTENT_W}
355401
contentHeight={CONTENT_H}

frontend/src/components/settings/SharingSection.tsx

Lines changed: 10 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
import { useCallback, useEffect, useState } from "react";
3535
import { useQuery } from "@tanstack/react-query";
3636

37+
import FileDropzone from "@/components/ui/FileDropzone";
3738
import Tooltip from "@/components/Tooltip";
3839
import { useEscapeToClose } from "@/hooks/useEscapeToClose";
3940
import { usePopupLayer } from "@/lib/ui/popup-stack";
@@ -1710,20 +1711,15 @@ export function RestoreIdentityPopup({
17101711
</p>
17111712

17121713
<div className="space-y-2">
1713-
<label className="block">
1714-
<span className="sr-only">Choose your Recovery Kit file</span>
1715-
<input
1716-
type="file"
1717-
accept=".html,text/html,application/json,.json"
1718-
disabled={busy}
1719-
onChange={(e) => {
1720-
void onKitFile(e.target.files?.[0]);
1721-
// Reset so re-choosing the same file fires onChange again.
1722-
e.target.value = "";
1723-
}}
1724-
className="block w-full text-meta text-foreground-muted file:mr-3 file:rounded-lg file:border-0 file:bg-blue-600 file:px-3 file:py-2 file:text-body file:font-medium file:text-white hover:file:bg-blue-700 disabled:opacity-50"
1725-
/>
1726-
</label>
1714+
<FileDropzone
1715+
accept=".html,text/html,application/json,.json"
1716+
disabled={busy}
1717+
label="Drag and drop your Recovery Kit file"
1718+
hint=".html or .json"
1719+
icon="import"
1720+
compact
1721+
onFiles={(files) => void onKitFile(files[0])}
1722+
/>
17271723
<textarea
17281724
onChange={(e) => loadKitText(e.target.value)}
17291725
disabled={busy}

frontend/src/components/social/LabSiteDashboard.tsx

Lines changed: 7 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
import { useCallback, useEffect, useRef, useState } from "react";
2020

21+
import FileDropzone from "@/components/ui/FileDropzone";
2122
import MarketingNav from "@/components/MarketingNav";
2223
import MarketingFooter from "@/components/MarketingFooter";
2324
import MarketingBackdrop from "@/components/marketing/MarketingBackdrop";
@@ -59,7 +60,6 @@ function pathLabel(path: string): string {
5960
function ByoUploadSection({ slug }: { slug: string }) {
6061
const [busy, setBusy] = useState(false);
6162
const [msg, setMsg] = useState<string | null>(null);
62-
const fileRef = useRef<HTMLInputElement>(null);
6363

6464
const upload = useCallback(async (file: File) => {
6565
setBusy(true);
@@ -86,7 +86,6 @@ function ByoUploadSection({ slug }: { slug: string }) {
8686
setMsg("Could not upload the site right now.");
8787
} finally {
8888
setBusy(false);
89-
if (fileRef.current) fileRef.current.value = "";
9089
}
9190
}, []);
9291

@@ -99,20 +98,15 @@ function ByoUploadSection({ slug }: { slug: string }) {
9998
sandboxed domain ({slug}.research-os.com) so your site&apos;s code stays
10099
isolated from your account.
101100
</p>
102-
<div className="mt-4 flex items-center gap-2">
103-
<input
104-
ref={fileRef}
105-
type="file"
101+
<div className="mt-4">
102+
<FileDropzone
106103
accept=".zip,application/zip"
107104
disabled={busy}
108-
onChange={(e) => {
109-
const f = e.target.files?.[0];
110-
if (f) void upload(f);
111-
}}
112-
className="text-sm text-foreground"
113-
aria-label="Static site zip"
105+
label={busy ? "Uploading…" : "Drag and drop a zip file"}
106+
hint=".zip"
107+
icon="import"
108+
onFiles={(files) => void upload(files[0])}
114109
/>
115-
{busy && <span className="text-xs text-muted-foreground">Uploading.</span>}
116110
</div>
117111
{msg && <p className="mt-3 text-sm text-foreground">{msg}</p>}
118112
</section>

0 commit comments

Comments
 (0)