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" ;
1313import { useRouter } from "next/navigation" ;
1414import { getSession } from "next-auth/react" ;
1515import { useFileSystem } from "@/lib/file-system/file-system-context" ;
16+ import {
17+ extractDirectoryHandleFromDrop ,
18+ describeDropExtractionError ,
19+ } from "@/lib/file-system/drop-folder" ;
1620import { ONBOARDING_WIZARD_ENABLED } from "@/lib/onboarding/config" ;
1721import { isDeviceKeyV2Enabled } from "@/lib/sharing/identity/device-key-v2" ;
1822import { 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
0 commit comments