Skip to content

Commit bbc7a60

Browse files
antfubotopencode
andcommitted
fix(assets): serve raw bytes on Vite origin, side-panel details, inline type filter
- Fix broken images in the plugin dev server: in devMiddleware mode the SPA is served by Vite while ctx.views.hostStatic() mounts the raw bytes on the side-car origin, so origin-relative <img> URLs 404'd against Vite. Re-serve the managed directory on Vite's own origin in the dev config so the relative publicPath resolves. - Details view is now an in-layout right side panel (the listing shrinks beside it) instead of an overlay drawer; removed the Drawer component. - Replace the extension-filter dropdown with an always-visible inline type-filter chip row (image/video/audio/font/text/other) modeled on vitejs/devtools' DataSearchPanel; shared TYPE_META drives the chips and the tree icons. Co-authored-by: opencode <noreply@opencode.ai>
1 parent 072653a commit bbc7a60

12 files changed

Lines changed: 181 additions & 161 deletions

File tree

docs/plugins/assets.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ Package: `@devframes/plugin-assets` · framework: **Preact**
1010

1111
## What it does
1212

13-
Search and filter by extension, switch between a thumbnail grid (grouped by folder) and a file tree, and open a details panel with a live preview (image, video, audio, font, or text), file metadata, and ready-to-copy usage snippets (`<img>`, CSS `background-image`, `@font-face`, a download link). Drag-and-drop files to upload them, or select multiple assets to delete them together. A live file watcher keeps every connected client's listing in sync with changes made outside the UI.
13+
Search by name and filter by type from an inline chip row, switch between a thumbnail grid (grouped by folder) and a file tree, and open a right-hand details panel with a live preview (image, video, audio, font, or text), file metadata, and ready-to-copy usage snippets (`<img>`, CSS `background-image`, `@font-face`, a download link). Drop files anywhere on the frame to upload them, or select multiple assets to delete them together. A live file watcher keeps every connected client's listing in sync with changes made outside the UI.
1414

1515
The standalone server requires devframe's trust handshake by default because it can read, write, and delete real files. Uploads, renames, deletes, and folder creation are enabled by default — pass `{ write: false }` (or `--read-only` on the standalone CLI) for a browse-only deployment.
1616

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

Lines changed: 57 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,27 @@
1-
import type { AssetInfo } from '../../types'
1+
import type { AssetInfo, AssetType } from '../../types'
22
import { useMemo, useState } from 'preact/hooks'
33
import { AssetDetails } from './components/AssetDetails'
44
import { AssetGrid } from './components/AssetGrid'
55
import { AssetTree } from './components/AssetTree'
66
import { DropZone } from './components/DropZone'
77
import { Toolbar } from './components/Toolbar'
8+
import { TypeFilter } from './components/TypeFilter'
89
import { Badge } from './components/ui/Badge'
910
import { Button } from './components/ui/Button'
1011
import { Dialog } from './components/ui/Dialog'
11-
import { Drawer } from './components/ui/Drawer'
1212
import { TextInput } from './components/ui/TextInput'
1313
import { connectionBody, connectionGlyph, connectionPanel, connectionState, connectionTitle, nav, navBrand } from './design'
1414
import { useAssets } from './hooks/useAssets'
1515
import { useLocalStorage } from './hooks/useLocalStorage'
1616
import { useUpload } from './hooks/useUpload'
17-
import { extensionOf } from './utils/format'
17+
import { ASSET_TYPES } from './utils/assetType'
1818

1919
type ViewMode = 'grid' | 'list'
2020

2121
export function App() {
2222
const { assets, capabilities, loading, error, isStatic, refresh, rpc } = useAssets()
2323
const [view, setView] = useLocalStorage<ViewMode>('devframes:plugin:assets:view', 'grid')
24-
const [extensionState, setExtensionState] = useState<Record<string, boolean>>({})
24+
const [typeState, setTypeState] = useState<Partial<Record<AssetType, boolean>>>({})
2525
const [search, setSearch] = useState('')
2626
const [selected, setSelected] = useState<AssetInfo | undefined>()
2727
const [selectedPaths, setSelectedPaths] = useState<Set<string>>(new Set())
@@ -34,31 +34,30 @@ export function App() {
3434

3535
const canWrite = capabilities?.write ?? false
3636

37-
const extensions = useMemo(() => {
38-
const seen = new Set<string>()
39-
for (const asset of assets ?? []) {
40-
const ext = extensionOf(asset.path)
41-
if (ext)
42-
seen.add(ext)
43-
}
44-
return Array.from(seen).sort()
45-
}, [assets])
37+
// Types present in the listing, with counts, in canonical display order.
38+
const typeItems = useMemo(() => {
39+
const counts = new Map<AssetType, number>()
40+
for (const asset of assets ?? [])
41+
counts.set(asset.type, (counts.get(asset.type) ?? 0) + 1)
42+
return ASSET_TYPES
43+
.filter(type => counts.has(type))
44+
.map(type => ({ type, count: counts.get(type)!, checked: typeState[type] !== false }))
45+
}, [assets, typeState])
4646

4747
const filtered = useMemo(() => {
4848
const list = assets ?? []
4949
const query = search.trim().toLowerCase()
5050
return list.filter((asset) => {
51-
const ext = extensionOf(asset.path)
52-
if (ext && extensionState[ext] === false)
51+
if (typeState[asset.type] === false)
5352
return false
5453
if (query && !asset.path.toLowerCase().includes(query))
5554
return false
5655
return true
5756
})
58-
}, [assets, search, extensionState])
57+
}, [assets, search, typeState])
5958

60-
function toggleExtension(name: string): void {
61-
setExtensionState(prev => ({ ...prev, [name]: prev[name] === false }))
59+
function toggleType(type: AssetType): void {
60+
setTypeState(prev => ({ ...prev, [type]: prev[type] === false }))
6261
}
6362

6463
function toggleSelect(path: string): void {
@@ -117,8 +116,6 @@ export function App() {
117116
<Toolbar
118117
search={search}
119118
onSearchChange={setSearch}
120-
extensions={extensions.map(name => ({ name, checked: extensionState[name] !== false }))}
121-
onToggleExtension={toggleExtension}
122119
view={view}
123120
onViewChange={setView}
124121
total={assets?.length ?? 0}
@@ -132,48 +129,52 @@ export function App() {
132129
/>
133130
</header>
134131

132+
<TypeFilter items={typeItems} onToggle={toggleType} />
133+
135134
{error && (
136135
<div class="shrink-0 border-b border-base bg-error/10 px-3 py-1 text-xs text-error">{error}</div>
137136
)}
138137

139-
<main class="min-h-0 flex-1 overflow-auto">
140-
{loading
141-
? <div class="flex h-full items-center justify-center op-fade text-sm">Loading assets…</div>
142-
: filtered.length === 0
143-
? <div class="flex h-full items-center justify-center op-fade text-sm">No assets found.</div>
144-
: view === 'grid'
145-
? (
146-
<AssetGrid
147-
assets={filtered}
148-
selectable={canWrite}
149-
selectedPaths={selectedPaths}
150-
onSelectToggle={toggleSelect}
151-
onSelect={setSelected}
152-
/>
153-
)
154-
: (
155-
<AssetTree
156-
assets={filtered}
157-
selectedPath={selected?.path}
158-
selectable={canWrite}
159-
selectedPaths={selectedPaths}
160-
onSelectToggle={toggleSelect}
161-
onSelect={setSelected}
162-
/>
163-
)}
164-
</main>
165-
166-
<Drawer open={!!selected} onClose={() => setSelected(undefined)}>
138+
<div class="flex min-h-0 flex-1">
139+
<main class="min-h-0 flex-1 overflow-auto">
140+
{loading
141+
? <div class="flex h-full items-center justify-center op-fade text-sm">Loading assets…</div>
142+
: filtered.length === 0
143+
? <div class="flex h-full items-center justify-center op-fade text-sm">No assets found.</div>
144+
: view === 'grid'
145+
? (
146+
<AssetGrid
147+
assets={filtered}
148+
selectable={canWrite}
149+
selectedPaths={selectedPaths}
150+
onSelectToggle={toggleSelect}
151+
onSelect={setSelected}
152+
/>
153+
)
154+
: (
155+
<AssetTree
156+
assets={filtered}
157+
selectedPath={selected?.path}
158+
selectable={canWrite}
159+
selectedPaths={selectedPaths}
160+
onSelectToggle={toggleSelect}
161+
onSelect={setSelected}
162+
/>
163+
)}
164+
</main>
165+
167166
{selected && (
168-
<AssetDetails
169-
asset={selected}
170-
rpc={rpc}
171-
canWrite={canWrite}
172-
onClose={() => setSelected(undefined)}
173-
onChanged={() => void refresh()}
174-
/>
167+
<aside class="min-h-0 w-96 shrink-0 overflow-y-auto border-l border-base bg-base">
168+
<AssetDetails
169+
asset={selected}
170+
rpc={rpc}
171+
canWrite={canWrite}
172+
onClose={() => setSelected(undefined)}
173+
onChanged={() => void refresh()}
174+
/>
175+
</aside>
175176
)}
176-
</Drawer>
177+
</div>
177178

178179
<DropZone
179180
open={dropzoneOpen}

plugins/assets/src/spa/app/components/AssetListItem.tsx

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,9 @@
11
import type { TreeNode } from '../utils/tree'
22
import { useState } from 'preact/hooks'
3+
import { TYPE_META } from '../utils/assetType'
34
import { Checkbox } from './ui/Checkbox'
45
import { Icon } from './ui/Icon'
56

6-
const TYPE_ICON: Record<string, string> = {
7-
image: 'i-ph-image-duotone',
8-
video: 'i-ph-video-duotone',
9-
audio: 'i-ph-speaker-high-duotone',
10-
font: 'i-ph-text-aa-duotone',
11-
text: 'i-ph-file-text-duotone',
12-
other: 'i-ph-file-duotone',
13-
}
14-
157
export interface AssetListItemProps {
168
node: TreeNode
179
depth?: number
@@ -24,7 +16,7 @@ export interface AssetListItemProps {
2416

2517
export function AssetListItem({ node, depth = 0, selectedPath, selectable, selectedPaths, onSelectToggle, onSelect }: AssetListItemProps) {
2618
const [open, setOpen] = useState(true)
27-
const icon = node.isFolder ? 'i-ph-folder-duotone' : TYPE_ICON[node.asset?.type ?? 'other']
19+
const icon = node.isFolder ? 'i-ph-folder-duotone' : TYPE_META[node.asset?.type ?? 'other'].icon
2820
const isActive = !node.isFolder && node.asset?.path === selectedPath
2921

3022
return (

plugins/assets/src/spa/app/components/ExtensionFilter.tsx

Lines changed: 0 additions & 38 deletions
This file was deleted.

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

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,6 @@ type Story = StoryObj<typeof Toolbar>
1212
const base = {
1313
search: '',
1414
onSearchChange: () => {},
15-
extensions: [
16-
{ name: 'png', checked: true },
17-
{ name: 'svg', checked: true },
18-
{ name: 'mp4', checked: false },
19-
],
20-
onToggleExtension: () => {},
2115
view: 'grid' as const,
2216
onViewChange: () => {},
2317
total: 42,

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

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,10 @@
1-
import type { ExtensionFilterItem } from './ExtensionFilter'
2-
import { ExtensionFilter } from './ExtensionFilter'
31
import { Button } from './ui/Button'
42
import { IconButton } from './ui/IconButton'
53
import { TextInput } from './ui/TextInput'
64

75
export interface ToolbarProps {
86
search: string
97
onSearchChange: (value: string) => void
10-
extensions: ExtensionFilterItem[]
11-
onToggleExtension: (name: string) => void
128
view: 'grid' | 'list'
139
onViewChange: (view: 'grid' | 'list') => void
1410
total: number
@@ -68,7 +64,6 @@ export function Toolbar(props: ToolbarProps) {
6864
<IconButton icon="i-ph-cloud-arrow-up-duotone" title="Upload" variant="ghost" onClick={props.onUpload} />
6965
</>
7066
)}
71-
<ExtensionFilter extensions={props.extensions} onToggle={props.onToggleExtension} />
7267
<IconButton
7368
icon={props.view === 'grid' ? 'i-ph-list-duotone' : 'i-ph-grid-four-duotone'}
7469
title="Toggle view"
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import type { Meta, StoryObj } from '@storybook/preact-vite'
2+
import { TypeFilter } from './TypeFilter'
3+
4+
const meta: Meta<typeof TypeFilter> = {
5+
title: 'Assets/TypeFilter',
6+
component: TypeFilter,
7+
}
8+
export default meta
9+
10+
type Story = StoryObj<typeof TypeFilter>
11+
12+
export const Default: Story = {
13+
args: {
14+
items: [
15+
{ type: 'image', count: 12, checked: true },
16+
{ type: 'video', count: 2, checked: true },
17+
{ type: 'font', count: 3, checked: true },
18+
{ type: 'text', count: 8, checked: true },
19+
{ type: 'other', count: 1, checked: true },
20+
],
21+
onToggle: () => {},
22+
},
23+
}
24+
25+
export const SomeDeselected: Story = {
26+
args: {
27+
items: [
28+
{ type: 'image', count: 12, checked: true },
29+
{ type: 'video', count: 2, checked: false },
30+
{ type: 'font', count: 3, checked: false },
31+
{ type: 'text', count: 8, checked: true },
32+
{ type: 'other', count: 1, checked: true },
33+
],
34+
onToggle: () => {},
35+
},
36+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import type { AssetType } from '../../../types'
2+
import { TYPE_META } from '../utils/assetType'
3+
4+
export interface TypeFilterItem {
5+
type: AssetType
6+
count: number
7+
checked: boolean
8+
}
9+
10+
export interface TypeFilterProps {
11+
items: TypeFilterItem[]
12+
onToggle: (type: AssetType) => void
13+
}
14+
15+
/**
16+
* Inline row of type-filter chips — one per asset type present in the
17+
* listing. Modeled on vitejs/devtools' `DataSearchPanel`: a selected chip
18+
* reads normally, an unselected one is greyed out. Always visible (no
19+
* dropdown), so the active filter is self-evident.
20+
*/
21+
export function TypeFilter({ items, onToggle }: TypeFilterProps) {
22+
if (items.length <= 1)
23+
return null
24+
25+
return (
26+
<div class="flex shrink-0 flex-wrap items-center gap-2 border-b border-base bg-secondary px-3 py-1.5">
27+
{items.map(({ type, count, checked }) => (
28+
<button
29+
key={type}
30+
type="button"
31+
title={`${TYPE_META[type].label} (${count})`}
32+
aria-pressed={checked}
33+
class={`flex select-none items-center gap-1.5 rounded-md border border-base px-2 py-1 text-xs transition ${checked ? 'bg-active' : 'op50 grayscale hover:op-100'}`}
34+
onClick={() => onToggle(type)}
35+
>
36+
<span class={TYPE_META[type].icon} />
37+
<span>{TYPE_META[type].label}</span>
38+
<span class="op-fade">{count}</span>
39+
</button>
40+
))}
41+
</div>
42+
)
43+
}

0 commit comments

Comments
 (0)