@@ -64,12 +64,13 @@ import {
6464} from '@/lib/zk' ;
6565import { fileVisual } from '@/lib/fileType' ;
6666import { FileViewer , type ViewerSource } from '@/components/FileViewer' ;
67- import { Menu } from '@/components/ui/Menu' ;
6867import { confirm , prompt , choose , toast } from '@/components/ui/overlays' ;
6968import { DropOverlay } from '@/components/drive/DropOverlay' ;
7069import { CommandPalette , type PaletteItem } from '@/components/drive/CommandPalette' ;
7170import { ShortcutsHelp } from '@/components/drive/ShortcutsHelp' ;
7271import { VersionHistory } from '@/components/drive/VersionHistory' ;
72+ import { ContextMenu } from '@/components/drive/ContextMenu' ;
73+ import { Menu , type MenuItem } from '@/components/ui/Menu' ;
7374
7475interface ZkFile {
7576 id : string ;
@@ -135,6 +136,7 @@ export default function EspacesPage() {
135136 const [ clipboard , setClipboard ] = useState < Clipboard | null > ( null ) ;
136137 const [ paletteOpen , setPaletteOpen ] = useState ( false ) ;
137138 const [ helpOpen , setHelpOpen ] = useState ( false ) ;
139+ const [ ctxMenu , setCtxMenu ] = useState < { x : number ; y : number ; items : MenuItem [ ] } | null > ( null ) ;
138140
139141 // passphrase prompt
140142 const [ askPass , setAskPass ] = useState < PublicFolder | null > ( null ) ;
@@ -871,10 +873,70 @@ export default function EspacesPage() {
871873 }
872874 }
873875
876+ // ── Menu items (shared by the "⋮" overflow menu and the right-click context menu) ──
877+ function folderMenuItems ( folder : PublicFolder ) : MenuItem [ ] {
878+ return [
879+ { label : t ( 'drive.actionRename' ) , icon : Pencil , onClick : ( ) => void renameFolder ( folder ) } ,
880+ ...( ! isZk ? [ { label : t ( 'drive.actionMove' ) , icon : FolderInput , onClick : ( ) => void move ( 'folder' , folder . id ) } ] : [ ] ) ,
881+ ...( ! isZk ? [ { label : t ( 'drive.actionShare' ) , icon : Share2 , onClick : ( ) => void share ( 'folder' , folder . id ) } ] : [ ] ) ,
882+ { label : t ( 'drive.actionDelete' ) , icon : Trash2 , danger : true , onClick : ( ) => void deleteFolder ( folder . id ) } ,
883+ ] ;
884+ }
885+ function fileMenuItems ( file : PublicFile ) : MenuItem [ ] {
886+ return [
887+ { label : t ( 'drive.actionRename' ) , icon : Pencil , onClick : ( ) => void renameFile ( file ) } ,
888+ { label : t ( 'drive.actionMove' ) , icon : FolderInput , onClick : ( ) => void move ( 'file' , file . id ) } ,
889+ { label : t ( 'drive.actionDuplicate' ) , icon : Files , onClick : ( ) => void duplicateFile ( file . id , file . name , file . mimeType , currentFolderId ! , true ) . then ( reloadCurrent ) } ,
890+ { label : t ( 'drive.actionShare' ) , icon : Share2 , onClick : ( ) => void share ( 'file' , file . id ) } ,
891+ { label : t ( 'drive.actionVersions' ) , icon : History , onClick : ( ) => setVersionsFor ( file ) } ,
892+ { label : t ( 'drive.actionDelete' ) , icon : Trash2 , danger : true , onClick : ( ) => void deleteFile ( file . id ) } ,
893+ ] ;
894+ }
895+ function zkMenuItems ( zk : ZkFile ) : MenuItem [ ] {
896+ return [ { label : t ( 'drive.actionDelete' ) , icon : Trash2 , danger : true , onClick : ( ) => void deleteFile ( zk . id ) } ] ;
897+ }
898+ function entryMenuItems ( e : Entry ) : MenuItem [ ] {
899+ return e . kind === 'folder' ? folderMenuItems ( e . folder ) : e . kind === 'file' ? fileMenuItems ( e . file ) : zkMenuItems ( e . zk ) ;
900+ }
901+ function bulkMenuItems ( ) : MenuItem [ ] {
902+ const items : MenuItem [ ] = [ { label : t ( 'drive.bulkDownload' ) , icon : Download , onClick : ( ) => void bulkDownload ( ) } ] ;
903+ if ( ! isZk ) {
904+ items . push ( { label : t ( 'drive.actionCopy' ) , icon : Copy , onClick : ( ) => buildClip ( 'copy' ) } ) ;
905+ items . push ( { label : t ( 'drive.actionCut' ) , icon : Scissors , onClick : ( ) => buildClip ( 'cut' ) } ) ;
906+ items . push ( { label : t ( 'drive.bulkMove' ) , icon : FolderInput , onClick : ( ) => void bulkMove ( ) } ) ;
907+ }
908+ items . push ( { label : t ( 'drive.bulkDelete' ) , icon : Trash2 , danger : true , onClick : ( ) => void bulkDelete ( ) } ) ;
909+ return items ;
910+ }
911+ function backgroundMenuItems ( ) : MenuItem [ ] {
912+ const items : MenuItem [ ] = [
913+ { label : t ( 'drive.cmdNewFile' ) , icon : FilePlus , onClick : ( ) => void createFileDoc ( ) } ,
914+ { label : t ( 'drive.cmdNewFolder' ) , icon : FolderPlus , onClick : ( ) => void createFolder ( ) } ,
915+ { label : t ( 'drive.cmdImport' ) , icon : Upload , onClick : ( ) => fileInput . current ?. click ( ) } ,
916+ ] ;
917+ if ( ! isZk && clipboard ) items . push ( { label : t ( 'drive.scPaste' ) , icon : ClipboardPaste , onClick : ( ) => void paste ( ) } ) ;
918+ if ( entries . length > 0 ) items . push ( { label : t ( 'drive.scSelectAll' ) , icon : Check , onClick : ( ) => selectAll ( ) } ) ;
919+ return items ;
920+ }
921+ function openCtx ( ev : React . MouseEvent , items : MenuItem [ ] ) {
922+ ev . preventDefault ( ) ;
923+ ev . stopPropagation ( ) ;
924+ if ( items . length > 0 ) setCtxMenu ( { x : ev . clientX , y : ev . clientY , items } ) ;
925+ }
926+ function onEntryContext ( ev : React . MouseEvent , e : Entry ) {
927+ if ( selected . has ( e . key ) && selected . size > 1 ) {
928+ openCtx ( ev , bulkMenuItems ( ) ) ;
929+ return ;
930+ }
931+ setSelected ( new Set ( [ e . key ] ) ) ;
932+ setAnchorKey ( e . key ) ;
933+ openCtx ( ev , entryMenuItems ( e ) ) ;
934+ }
935+
874936 // ── Keyboard shortcuts ───────────────────────────────────────────────────────
875937 // A ref keeps the latest handler so we register the window listener only once.
876938 const keyHandler = ( e : KeyboardEvent ) => {
877- if ( paletteOpen || helpOpen || viewing || askPass || shareLink || versionsFor ) return ;
939+ if ( paletteOpen || helpOpen || viewing || askPass || shareLink || versionsFor || ctxMenu ) return ;
878940 if ( document . querySelector ( '[class*="z-[100]"]' ) ) return ; // an in-app dialog is open
879941 const mod = e . metaKey || e . ctrlKey ;
880942 if ( mod && e . key . toLowerCase ( ) === 'k' ) {
@@ -1146,7 +1208,10 @@ export default function EspacesPage() {
11461208 ) : entries . length === 0 ? (
11471209 < Empty icon = { Upload } title = { t ( 'drive.emptyFolderTitle' ) } hint = { t ( 'drive.emptyFolderHint' ) } />
11481210 ) : view === 'grid' ? (
1149- < div className = "grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4" >
1211+ < div
1212+ className = "grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4"
1213+ onContextMenu = { ( ev ) => ! needPass && openCtx ( ev , backgroundMenuItems ( ) ) }
1214+ >
11501215 { entries . map ( ( e ) => (
11511216 < GridCard
11521217 key = { e . key }
@@ -1156,11 +1221,12 @@ export default function EspacesPage() {
11561221 sub = { e . kind === 'folder' ? undefined : formatBytes ( e . size ) }
11571222 onClick = { ( ev ) => onEntryClick ( ev , e ) }
11581223 onDoubleClick = { ( ) => openEntry ( e ) }
1224+ onContextMenu = { ( ev ) => onEntryContext ( ev , e ) }
11591225 />
11601226 ) ) }
11611227 </ div >
11621228 ) : (
1163- < div className = "space-y-2" >
1229+ < div className = "space-y-2" onContextMenu = { ( ev ) => ! needPass && openCtx ( ev , backgroundMenuItems ( ) ) } >
11641230 { /* column headers */ }
11651231 < div className = "flex items-center gap-3 px-3 text-xs text-zinc-500" >
11661232 < button className = "flex items-center gap-1 hover:text-zinc-300" onClick = { ( ) => toggleSort ( 'name' ) } >
@@ -1181,6 +1247,7 @@ export default function EspacesPage() {
11811247 key = { e . key }
11821248 onClick = { ( ev ) => onEntryClick ( ev , e ) }
11831249 onDoubleClick = { ( ) => openEntry ( e ) }
1250+ onContextMenu = { ( ev ) => onEntryContext ( ev , e ) }
11841251 className = { `row cursor-default ${ selected . has ( e . key ) ? 'bg-accent/[0.08] ring-1 ring-inset ring-accent/40' : '' } ` }
11851252 >
11861253 < button className = "flex min-w-0 flex-1 items-center gap-3 text-left" onClick = { ( ev ) => onNameClick ( ev , e ) } >
@@ -1191,36 +1258,20 @@ export default function EspacesPage() {
11911258 < span className = "w-20 shrink-0 text-right text-xs text-zinc-500" > { e . kind === 'folder' ? '—' : formatBytes ( e . size ) } </ span >
11921259 < div className = "flex shrink-0 items-center gap-0.5" onClick = { ( ev ) => ev . stopPropagation ( ) } >
11931260 { e . kind === 'folder' ? (
1194- < Menu
1195- items = { [
1196- { label : t ( 'drive.actionRename' ) , icon : Pencil , onClick : ( ) => renameFolder ( e . folder ) } ,
1197- ...( ! isZk ? [ { label : t ( 'drive.actionMove' ) , icon : FolderInput , onClick : ( ) => move ( 'folder' , e . folder . id ) } ] : [ ] ) ,
1198- ...( ! isZk ? [ { label : t ( 'drive.actionShare' ) , icon : Share2 , onClick : ( ) => share ( 'folder' , e . folder . id ) } ] : [ ] ) ,
1199- { label : t ( 'drive.actionDelete' ) , icon : Trash2 , danger : true , onClick : ( ) => deleteFolder ( e . folder . id ) } ,
1200- ] }
1201- />
1261+ < Menu items = { folderMenuItems ( e . folder ) } />
12021262 ) : e . kind === 'file' ? (
12031263 < >
12041264 < IconBtn title = { t ( 'drive.open' ) } icon = { Eye } onClick = { ( ) => openFile ( e . file ) } />
12051265 < a className = "rounded-lg p-1.5 text-zinc-400 hover:bg-white/5 hover:text-zinc-100" title = { t ( 'drive.actionDownload' ) } href = { api . url ( `/files/${ e . file . id } /download` ) } >
12061266 < Download size = { 16 } />
12071267 </ a >
1208- < Menu
1209- items = { [
1210- { label : t ( 'drive.actionRename' ) , icon : Pencil , onClick : ( ) => renameFile ( e . file ) } ,
1211- { label : t ( 'drive.actionMove' ) , icon : FolderInput , onClick : ( ) => move ( 'file' , e . file . id ) } ,
1212- { label : t ( 'drive.actionDuplicate' ) , icon : Files , onClick : ( ) => void duplicateFile ( e . file . id , e . file . name , e . file . mimeType , currentFolderId ! , true ) . then ( reloadCurrent ) } ,
1213- { label : t ( 'drive.actionShare' ) , icon : Share2 , onClick : ( ) => share ( 'file' , e . file . id ) } ,
1214- { label : t ( 'drive.actionVersions' ) , icon : History , onClick : ( ) => setVersionsFor ( e . file ) } ,
1215- { label : t ( 'drive.actionDelete' ) , icon : Trash2 , danger : true , onClick : ( ) => deleteFile ( e . file . id ) } ,
1216- ] }
1217- />
1268+ < Menu items = { fileMenuItems ( e . file ) } />
12181269 </ >
12191270 ) : (
12201271 < >
12211272 < IconBtn title = { t ( 'drive.open' ) } icon = { Eye } onClick = { ( ) => openZk ( e . zk ) } />
12221273 < IconBtn title = { t ( 'drive.actionDownload' ) } icon = { Download } onClick = { ( ) => downloadZk ( e . zk ) } />
1223- < Menu items = { [ { label : t ( 'drive.actionDelete' ) , icon : Trash2 , danger : true , onClick : ( ) => deleteFile ( e . zk . id ) } ] } />
1274+ < Menu items = { zkMenuItems ( e . zk ) } />
12241275 </ >
12251276 ) }
12261277 </ div >
@@ -1286,6 +1337,7 @@ export default function EspacesPage() {
12861337 { paletteOpen && < CommandPalette items = { paletteItems } onClose = { ( ) => setPaletteOpen ( false ) } /> }
12871338 { helpOpen && < ShortcutsHelp onClose = { ( ) => setHelpOpen ( false ) } /> }
12881339 { viewing && < FileViewer source = { viewing } onClose = { ( ) => setViewing ( null ) } /> }
1340+ { ctxMenu && < ContextMenu x = { ctxMenu . x } y = { ctxMenu . y } items = { ctxMenu . items } onClose = { ( ) => setCtxMenu ( null ) } /> }
12891341 < DropOverlay show = { dragging } />
12901342 </ div >
12911343 ) ;
@@ -1387,18 +1439,21 @@ function GridCard({
13871439 selected,
13881440 onClick,
13891441 onDoubleClick,
1442+ onContextMenu,
13901443} : {
13911444 iconNode : React . ReactNode ;
13921445 name : string ;
13931446 sub ?: string ;
13941447 selected ?: boolean ;
13951448 onClick ?: ( e : React . MouseEvent ) => void ;
13961449 onDoubleClick ?: ( ) => void ;
1450+ onContextMenu ?: ( e : React . MouseEvent ) => void ;
13971451} ) {
13981452 return (
13991453 < div
14001454 onClick = { onClick }
14011455 onDoubleClick = { onDoubleClick }
1456+ onContextMenu = { onContextMenu }
14021457 className = { `card flex cursor-default select-none flex-col items-center gap-2 py-5 text-center transition hover:border-white/15 hover:bg-white/[0.04] ${
14031458 selected ? 'bg-accent/[0.08] ring-1 ring-inset ring-accent/40' : ''
14041459 } `}
0 commit comments