Skip to content

Commit cf83632

Browse files
Drive: custom right-click context menu, themed scrollbars & text selection
- Right-click anywhere in a space opens a custom context menu: on a file/folder it mirrors that item's actions (and shows bulk actions when right-clicking inside a multi-selection); on empty space it offers New file / New folder / Import / Paste / Select all. Portal-rendered, clamped to the viewport, closes on outside-click, Escape, scroll or resize. Overflow '⋮' menu and context menu now share the same item builders so they never drift. - Scrollbars restyled (slim, violet-on-hover, padded thumb) and a violet ::selection highlight, both app-wide via globals.css.
1 parent 360f06b commit cf83632

3 files changed

Lines changed: 189 additions & 28 deletions

File tree

apps/web/src/app/(app)/drive/page.tsx

Lines changed: 78 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -64,12 +64,13 @@ import {
6464
} from '@/lib/zk';
6565
import { fileVisual } from '@/lib/fileType';
6666
import { FileViewer, type ViewerSource } from '@/components/FileViewer';
67-
import { Menu } from '@/components/ui/Menu';
6867
import { confirm, prompt, choose, toast } from '@/components/ui/overlays';
6968
import { DropOverlay } from '@/components/drive/DropOverlay';
7069
import { CommandPalette, type PaletteItem } from '@/components/drive/CommandPalette';
7170
import { ShortcutsHelp } from '@/components/drive/ShortcutsHelp';
7271
import { VersionHistory } from '@/components/drive/VersionHistory';
72+
import { ContextMenu } from '@/components/drive/ContextMenu';
73+
import { Menu, type MenuItem } from '@/components/ui/Menu';
7374

7475
interface 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
}`}

apps/web/src/app/globals.css

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -73,18 +73,42 @@ body {
7373
background-attachment: fixed;
7474
}
7575

76-
/* Thin, unobtrusive scrollbars */
76+
/* Custom scrollbars — slim, accent-aware, fade-to-violet on hover. */
7777
* {
7878
scrollbar-width: thin;
79-
scrollbar-color: rgba(255, 255, 255, 0.12) transparent;
79+
scrollbar-color: rgba(139, 92, 246, 0.35) transparent;
8080
}
8181
*::-webkit-scrollbar {
82-
width: 10px;
83-
height: 10px;
82+
width: 11px;
83+
height: 11px;
84+
}
85+
*::-webkit-scrollbar-track {
86+
background: transparent;
8487
}
8588
*::-webkit-scrollbar-thumb {
86-
background: rgba(255, 255, 255, 0.12);
89+
background: rgba(255, 255, 255, 0.13);
8790
border-radius: 9999px;
91+
/* transparent border carves a little breathing room around the thumb */
92+
border: 3px solid transparent;
93+
background-clip: padding-box;
94+
transition: background-color 0.15s;
95+
}
96+
*::-webkit-scrollbar-thumb:hover {
97+
background: rgba(139, 92, 246, 0.55);
98+
background-clip: padding-box;
99+
}
100+
*::-webkit-scrollbar-corner {
101+
background: transparent;
102+
}
103+
104+
/* Custom text selection — violet tint to match the accent. */
105+
::selection {
106+
background: rgba(139, 92, 246, 0.32);
107+
color: #fff;
108+
}
109+
::-moz-selection {
110+
background: rgba(139, 92, 246, 0.32);
111+
color: #fff;
88112
}
89113

90114
@layer components {
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
'use client';
2+
3+
/**
4+
* Custom right-click context menu. Rendered in a portal at the cursor, clamped to the viewport,
5+
* and closed on outside-click, Escape, scroll, resize, or another right-click. Reuses the same
6+
* MenuItem shape as the overflow "⋮" menu so actions stay consistent between the two.
7+
*/
8+
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
9+
import { createPortal } from 'react-dom';
10+
import type { MenuItem } from '@/components/ui/Menu';
11+
12+
export function ContextMenu({
13+
x,
14+
y,
15+
items,
16+
onClose,
17+
}: {
18+
x: number;
19+
y: number;
20+
items: MenuItem[];
21+
onClose: () => void;
22+
}) {
23+
const ref = useRef<HTMLDivElement>(null);
24+
const [pos, setPos] = useState({ x, y });
25+
26+
// Keep the menu fully on screen.
27+
useLayoutEffect(() => {
28+
const el = ref.current;
29+
if (!el) return;
30+
const { width, height } = el.getBoundingClientRect();
31+
const pad = 8;
32+
setPos({
33+
x: Math.min(x, window.innerWidth - width - pad),
34+
y: Math.min(y, window.innerHeight - height - pad),
35+
});
36+
}, [x, y]);
37+
38+
useEffect(() => {
39+
const onDown = (e: MouseEvent) => {
40+
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
41+
};
42+
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onClose();
43+
document.addEventListener('mousedown', onDown);
44+
document.addEventListener('keydown', onKey);
45+
window.addEventListener('scroll', onClose, true);
46+
window.addEventListener('resize', onClose);
47+
return () => {
48+
document.removeEventListener('mousedown', onDown);
49+
document.removeEventListener('keydown', onKey);
50+
window.removeEventListener('scroll', onClose, true);
51+
window.removeEventListener('resize', onClose);
52+
};
53+
}, [onClose]);
54+
55+
if (typeof document === 'undefined') return null;
56+
57+
return createPortal(
58+
<div
59+
ref={ref}
60+
style={{ left: pos.x, top: pos.y }}
61+
className="fixed z-[95] w-52 overflow-hidden rounded-lg border border-white/10 bg-[#15151d] py-1 shadow-2xl ring-1 ring-black/40"
62+
onContextMenu={(e) => e.preventDefault()}
63+
>
64+
{items.map((it, i) => (
65+
<button
66+
key={i}
67+
onClick={() => {
68+
onClose();
69+
it.onClick();
70+
}}
71+
className={`flex w-full items-center gap-2.5 px-3 py-2 text-left text-[13px] transition hover:bg-white/[0.06] ${
72+
it.danger ? 'text-red-300' : 'text-zinc-200'
73+
}`}
74+
>
75+
<it.icon size={15} className={it.danger ? 'text-red-300' : 'text-zinc-400'} />
76+
{it.label}
77+
</button>
78+
))}
79+
</div>,
80+
document.body,
81+
);
82+
}

0 commit comments

Comments
 (0)