Skip to content

Commit fb1a3ad

Browse files
antfubotopencode
andcommitted
feat(assets): move search + actions into the nav bar, drop anywhere to upload
- Fold the search box, asset count, and action buttons into the single top nav bar instead of a second toolbar row; the error banner moves to its own slim strip below. - DropZone now listens for file drags across the whole frame whenever write is enabled, so dropping files opens the upload overlay without first clicking Upload. A drag-depth counter keeps the overlay from flickering, and only drags carrying files (not text/element drags) trigger it. Co-authored-by: opencode <noreply@opencode.ai>
1 parent 1d6a0f6 commit fb1a3ad

3 files changed

Lines changed: 86 additions & 35 deletions

File tree

‎plugins/assets/src/spa/app/App.tsx‎

Lines changed: 21 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -107,33 +107,34 @@ export function App() {
107107

108108
return (
109109
<div class="flex h-screen flex-col bg-base color-base">
110-
<header class={nav()}>
110+
<header class={nav('gap-3')}>
111111
<span class={navBrand()}>
112112
<span class="i-ph-image-square-duotone text-base color-active" />
113113
<span>Assets</span>
114114
</span>
115115
{isStatic && <Badge variant="secondary">static</Badge>}
116116
{!canWrite && !loading && <Badge variant="outline">read-only</Badge>}
117-
<span class="flex-1" />
118-
{error && <span class="text-xs text-error">{error}</span>}
117+
<Toolbar
118+
search={search}
119+
onSearchChange={setSearch}
120+
extensions={extensions.map(name => ({ name, checked: extensionState[name] !== false }))}
121+
onToggleExtension={toggleExtension}
122+
view={view}
123+
onViewChange={setView}
124+
total={assets?.length ?? 0}
125+
filtered={filtered.length}
126+
canWrite={canWrite}
127+
onUpload={() => setDropzoneOpen(true)}
128+
onNewFolder={() => setMkdirOpen(true)}
129+
selectedCount={selectedPaths.size}
130+
onBulkDelete={() => setBulkDeleteOpen(true)}
131+
onClearSelection={() => setSelectedPaths(new Set())}
132+
/>
119133
</header>
120134

121-
<Toolbar
122-
search={search}
123-
onSearchChange={setSearch}
124-
extensions={extensions.map(name => ({ name, checked: extensionState[name] !== false }))}
125-
onToggleExtension={toggleExtension}
126-
view={view}
127-
onViewChange={setView}
128-
total={assets?.length ?? 0}
129-
filtered={filtered.length}
130-
canWrite={canWrite}
131-
onUpload={() => setDropzoneOpen(true)}
132-
onNewFolder={() => setMkdirOpen(true)}
133-
selectedCount={selectedPaths.size}
134-
onBulkDelete={() => setBulkDeleteOpen(true)}
135-
onClearSelection={() => setSelectedPaths(new Set())}
136-
/>
135+
{error && (
136+
<div class="shrink-0 border-b border-base bg-error/10 px-3 py-1 text-xs text-error">{error}</div>
137+
)}
137138

138139
<main class="min-h-0 flex-1 overflow-auto">
139140
{loading
@@ -177,6 +178,7 @@ export function App() {
177178
<DropZone
178179
open={dropzoneOpen}
179180
onClose={() => setDropzoneOpen(false)}
181+
enabled={canWrite}
180182
folder=""
181183
uploading={uploading}
182184
errors={uploadErrors}

‎plugins/assets/src/spa/app/components/DropZone.tsx‎

Lines changed: 56 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,46 +1,90 @@
11
import type { QueuedFile } from '../hooks/useUpload'
2-
import { useEffect, useState } from 'preact/hooks'
2+
import { useEffect, useRef, useState } from 'preact/hooks'
33
import { Button } from './ui/Button'
44
import { IconButton } from './ui/IconButton'
55
import { TextInput } from './ui/TextInput'
66

77
export interface DropZoneProps {
88
open: boolean
99
onClose: () => void
10+
/**
11+
* Whether drag-and-drop upload is active. When `false` (read-only mode)
12+
* the whole-frame drag listeners are never attached.
13+
*/
14+
enabled: boolean
1015
/** Root-relative folder new uploads land in, with a trailing slash — `''` for the root. */
1116
folder: string
1217
uploading: boolean
1318
errors: string[]
1419
onUpload: (files: QueuedFile[]) => Promise<string[]>
1520
}
1621

17-
export function DropZone({ open, onClose, folder, uploading, errors, onUpload }: DropZoneProps) {
22+
/** A drag carries files (not a text selection or an in-page element drag). */
23+
function dragHasFiles(e: DragEvent): boolean {
24+
return Array.from(e.dataTransfer?.types ?? []).includes('Files')
25+
}
26+
27+
export function DropZone({ open, onClose, enabled, folder, uploading, errors, onUpload }: DropZoneProps) {
1828
const [files, setFiles] = useState<File[]>([])
29+
const [dragging, setDragging] = useState(false)
30+
// `dragenter`/`dragleave` fire per child element as the cursor moves over
31+
// the page; a depth counter keeps the overlay from flickering.
32+
const dragDepth = useRef(0)
1933

2034
useEffect(() => {
21-
if (!open)
35+
if (!enabled)
2236
return
23-
function onDragOver(e: DragEvent) {
37+
function onDragEnter(e: DragEvent) {
38+
if (!dragHasFiles(e))
39+
return
2440
e.preventDefault()
41+
dragDepth.current++
42+
setDragging(true)
43+
}
44+
function onDragOver(e: DragEvent) {
45+
if (dragHasFiles(e))
46+
e.preventDefault()
47+
}
48+
function onDragLeave(e: DragEvent) {
49+
if (!dragHasFiles(e))
50+
return
51+
dragDepth.current = Math.max(0, dragDepth.current - 1)
52+
if (dragDepth.current === 0)
53+
setDragging(false)
2554
}
2655
function onDrop(e: DragEvent) {
56+
if (!dragHasFiles(e))
57+
return
2758
e.preventDefault()
59+
dragDepth.current = 0
60+
setDragging(false)
2861
addFiles(e.dataTransfer?.files ?? null)
2962
}
63+
window.addEventListener('dragenter', onDragEnter)
3064
window.addEventListener('dragover', onDragOver)
65+
window.addEventListener('dragleave', onDragLeave)
3166
window.addEventListener('drop', onDrop)
3267
return () => {
68+
window.removeEventListener('dragenter', onDragEnter)
3369
window.removeEventListener('dragover', onDragOver)
70+
window.removeEventListener('dragleave', onDragLeave)
3471
window.removeEventListener('drop', onDrop)
3572
}
36-
}, [open])
73+
}, [enabled])
3774

3875
function addFiles(list: FileList | null): void {
3976
if (!list)
4077
return
4178
setFiles(prev => [...prev, ...Array.from(list)])
4279
}
4380

81+
function handleClose(): void {
82+
setFiles([])
83+
setDragging(false)
84+
dragDepth.current = 0
85+
onClose()
86+
}
87+
4488
function removeFile(index: number): void {
4589
setFiles(prev => prev.filter((_, i) => i !== index))
4690
}
@@ -58,18 +102,19 @@ export function DropZone({ open, onClose, folder, uploading, errors, onUpload }:
58102

59103
async function submit(): Promise<void> {
60104
const failed = await onUpload(files.map(file => ({ file, targetPath: `${folder}${file.name}` })))
61-
if (failed.length === 0) {
62-
setFiles([])
63-
onClose()
64-
}
105+
if (failed.length === 0)
106+
handleClose()
65107
}
66108

67-
if (!open)
109+
// Visible on the explicit Upload button, while files are being dragged
110+
// anywhere over the frame, or once files are queued for upload.
111+
const visible = enabled && (open || dragging || files.length > 0)
112+
if (!visible)
68113
return null
69114

70115
return (
71116
<div class="fixed inset-0 z-drawer-content bg-base/95 backdrop-blur-xl">
72-
<IconButton icon="i-ph-x" title="Close" variant="ghost" class="absolute right-5 top-5 z-1 text-xl" onClick={onClose} />
117+
<IconButton icon="i-ph-x" title="Close" variant="ghost" class="absolute right-5 top-5 z-1 text-xl" onClick={handleClose} />
73118
{files.length === 0
74119
? (
75120
<label class="flex h-full w-full cursor-pointer flex-col items-center justify-center gap-2 text-2xl hover:color-active">

‎plugins/assets/src/spa/app/components/Toolbar.tsx‎

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import type { ExtensionFilterItem } from './ExtensionFilter'
2-
import { toolbar } from '../design'
32
import { ExtensionFilter } from './ExtensionFilter'
43
import { Button } from './ui/Button'
54
import { IconButton } from './ui/IconButton'
@@ -22,11 +21,16 @@ export interface ToolbarProps {
2221
onClearSelection: () => void
2322
}
2423

24+
/**
25+
* The search box, asset count, and actions — rendered inline in the top
26+
* nav bar (see `App`). Swaps to a selection action bar while assets are
27+
* multi-selected.
28+
*/
2529
export function Toolbar(props: ToolbarProps) {
2630
if (props.selectedCount > 0) {
2731
return (
28-
<div class={toolbar('justify-between')}>
29-
<span class="font-medium">
32+
<div class="flex flex-1 items-center justify-between gap-3">
33+
<span class="text-sm font-medium">
3034
{props.selectedCount}
3135
{' '}
3236
selected
@@ -44,14 +48,14 @@ export function Toolbar(props: ToolbarProps) {
4448
}
4549

4650
return (
47-
<div class={toolbar('gap-3')}>
51+
<div class="flex min-w-0 flex-1 items-center gap-3">
4852
<TextInput
4953
placeholder="Search assets…"
5054
value={props.search}
5155
onInput={e => props.onSearchChange((e.target as HTMLInputElement).value)}
5256
class="max-w-60"
5357
/>
54-
<span class="op-fade text-xs whitespace-nowrap">
58+
<span class="op-fade whitespace-nowrap text-xs">
5559
{props.search ? `${props.filtered} matched · ` : ''}
5660
{props.total}
5761
{' '}

0 commit comments

Comments
 (0)