fix: bin creation 500 error + add drag-and-drop + project rename

- Fix 500 error when creating bins: missing updated_at column on bins table
  (migration 013 adds the column, schema.sql updated)
- Add drag-and-drop support for moving asset cards/list rows onto bin rail items
  with visual droppable highlight
- Add right-click context menu on project rail items (Rename/Delete)
- Expose RenameProjectModal via window so Library screen can reuse it
- Bins context menu already existed — was hidden by the 500 error
This commit is contained in:
Zac Gaetano 2026-05-24 13:27:24 -04:00
parent c312991bac
commit af905cf936
5 changed files with 123 additions and 8 deletions

View file

@ -0,0 +1,3 @@
-- 2026-05: Add missing updated_at column to bins table.
-- The INSERT and PATCH handlers already reference updated_at.
ALTER TABLE bins ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ DEFAULT NOW();

View file

@ -49,7 +49,8 @@ CREATE TABLE bins (
project_id UUID NOT NULL REFERENCES projects ON DELETE CASCADE,
parent_id UUID REFERENCES bins ON DELETE SET NULL,
name TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Assets table

View file

@ -51,6 +51,10 @@ function Library({ navigate, onOpenAsset, openProject }) {
const [renamingAsset, setRenamingAsset] = React.useState(null);
const [creatingBin, setCreatingBin] = React.useState(false);
const [newBinName, setNewBinName] = React.useState('');
const [draggingAssetId, setDraggingAssetId] = React.useState(null);
const [recentlyMovedId, setRecentlyMovedId] = React.useState(null);
// Rename project state
const [renamingProject, setRenamingProject] = React.useState(null);
const refreshAssets = React.useCallback(() => {
window.ZAMPP_API.fetch('/assets?limit=500')
@ -104,6 +108,62 @@ function Library({ navigate, onOpenAsset, openProject }) {
setCtxMenu({ asset, x: e.clientX, y: e.clientY });
};
// Drag-and-drop: asset bin
const onAssetDragStart = function(assetId, e) {
e.dataTransfer.setData('text/plain', assetId);
e.dataTransfer.effectAllowed = 'move';
setDraggingAssetId(assetId);
};
const onBinDragOver = function(e) {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
};
const onBinDrop = function(binId, e) {
e.preventDefault();
setDraggingAssetId(null);
var assetId = e.dataTransfer.getData('text/plain');
if (!assetId) return;
window.ZAMPP_API.fetch('/assets/' + assetId, { method: 'PATCH', body: JSON.stringify({ bin_id: binId }) })
.then(function() {
setRecentlyMovedId(assetId);
refreshAssets();
setTimeout(function() { setRecentlyMovedId(null); }, 2000);
})
.catch(function(e2) { alert('Move failed: ' + e2.message); });
};
const onBinDragLeave = function(e) {
// Remove highlight handled via CSS :hover + drag state
};
// Project rename
const renameProject = function(p) { setRenamingProject(p); };
// Close drag state when drag ends anywhere
React.useEffect(function() {
if (!draggingAssetId) return;
var onEnd = function() { setDraggingAssetId(null); };
window.addEventListener('dragend', onEnd);
return function() { window.removeEventListener('dragend', onEnd); };
}, [draggingAssetId]);
// Project context menu state
var [projectCtx, setProjectCtx] = React.useState(null);
React.useEffect(function() {
if (!projectCtx) return;
var close = function() { setProjectCtx(null); };
window.addEventListener('click', close);
return function() { window.removeEventListener('click', close); };
}, [projectCtx]);
var openProjectCtx = function(p, e) {
e.preventDefault();
e.stopPropagation();
setProjectCtx({ project: p, x: e.clientX, y: e.clientY });
};
const [selectedBinId, setSelectedBinId] = React.useState(null);
// Clear bin filter on project change so a stale id doesn't hide everything.
React.useEffect(() => { setSelectedBinId(null); }, [openProject?.id]);
@ -136,7 +196,8 @@ function Library({ navigate, onOpenAsset, openProject }) {
{PROJECTS.slice(0, 8).map(function(p) {
return (
<div key={p.id} className={`rail-item ${openProject && openProject.id === p.id ? 'active' : ''}`} style={{ cursor: 'pointer' }}
onClick={function() { navigate('projects'); }}>
onClick={function() { navigate('projects'); }}
onContextMenu={function(e) { openProjectCtx(p, e); }}>
<span className="rail-color-dot" style={{ background: p.color }} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.name}</span>
<span className="rail-count">{p.assets}</span>
@ -178,12 +239,16 @@ function Library({ navigate, onOpenAsset, openProject }) {
</div>
) : BINS.map(function(b) {
const isActive = selectedBinId === b.id;
const isDragTarget = draggingAssetId !== null;
return (
<div key={b.id}
className={'rail-item' + (isActive ? ' active' : '')}
className={'rail-item' + (isActive ? ' active' : '') + (isDragTarget ? ' droppable' : '')}
onClick={function() { setSelectedBinId(isActive ? null : b.id); }}
onDragOver={onBinDragOver}
onDrop={function(e) { onBinDrop(b.id, e); }}
onDragLeave={onBinDragLeave}
style={{ cursor: 'pointer' }}
title={isActive ? 'Click to clear bin filter' : 'Filter to this bin'}>
title={isActive ? 'Click to clear bin filter' : 'Filter to this bin'}
<Icon name={binIcon(b.icon)} size={13} className="rail-icon" />
<span>{b.name}</span>
<span className="rail-count">{b.count}</span>
@ -234,7 +299,9 @@ function Library({ navigate, onOpenAsset, openProject }) {
{assets.map(function(a) {
return <AssetCard key={a.id} asset={a}
onOpen={function() { onOpenAsset(a); }}
onContextMenu={function(e) { openCtx(a, e); }} />;
onContextMenu={function(e) { openCtx(a, e); }}
onDragStart={function(e) { onAssetDragStart(a.id, e); }}
draggable={true} />;
})}
</div>
) : (
@ -244,7 +311,8 @@ function Library({ navigate, onOpenAsset, openProject }) {
</div>
{assets.map(function(a) {
return (
<div key={a.id} className="list-row" onClick={function() { onOpenAsset(a); }} onContextMenu={function(e) { openCtx(a, e); }} style={{ cursor: 'pointer' }}>
<div key={a.id} className="list-row" onClick={function() { onOpenAsset(a); }} onContextMenu={function(e) { openCtx(a, e); }} style={{ cursor: 'pointer' }}
draggable="true" onDragStart={function(e) { onAssetDragStart(a.id, e); }}>
<div className="thumb"><AssetThumb asset={a} /></div>
<div>
<div className="name">{a.name}</div>
@ -284,6 +352,22 @@ function Library({ navigate, onOpenAsset, openProject }) {
onSaved={function() { setRenamingAsset(null); refreshAssets(); }}
/>
)}
{projectCtx && (
<ProjectContextMenu
project={projectCtx.project}
x={projectCtx.x}
y={projectCtx.y}
onClose={function() { setProjectCtx(null); }}
onRename={function(p) { setProjectCtx(null); setRenamingProject(p); }}
/>
)}
{renamingProject && (
<RenameProjectModal
project={renamingProject}
onClose={function() { setRenamingProject(null); }}
onSaved={function() { setRenamingProject(null); }}
/>
)}
</div>
);
}
@ -364,7 +448,7 @@ function AssetContextMenu({ asset, x, y, bins, onClose, onChanged, onOpen, onRen
);
}
function AssetCard({ asset, onOpen, onContextMenu }) {
function AssetCard({ asset, onOpen, onContextMenu, onDragStart, draggable }) {
const [hoverStream, setHoverStream] = React.useState(null);
const [hovered, setHovered] = React.useState(false);
const timerRef = React.useRef(null);
@ -402,7 +486,8 @@ function AssetCard({ asset, onOpen, onContextMenu }) {
const showVideo = hovered && hoverStream;
return (
<div className="asset-card" onClick={onOpen} onContextMenu={onContextMenu} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}>
<div className="asset-card" onClick={onOpen} onContextMenu={onContextMenu} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}
draggable={draggable} onDragStart={onDragStart}>
<div style={{ position: 'relative' }}>
<AssetThumb asset={asset} />
{showVideo && (
@ -443,6 +528,30 @@ function AssetCard({ asset, onOpen, onContextMenu }) {
);
}
function ProjectContextMenu({ project, x, y, onClose, onRename }) {
var ref = React.useRef(null);
var [pos, setPos] = React.useState({ left: x, top: y });
React.useLayoutEffect(function() {
if (!ref.current) return;
var r = ref.current.getBoundingClientRect();
var margin = 8;
var nx = x, ny = y;
if (x + r.width + margin > window.innerWidth) nx = window.innerWidth - r.width - margin;
if (y + r.height + margin > window.innerHeight) ny = window.innerHeight - r.height - margin;
setPos({ left: Math.max(margin, nx), top: Math.max(margin, ny) });
}, [x, y]);
return (
<div ref={ref} className="ctx-menu" style={{ left: pos.left, top: pos.top }}
onClick={function(e) { e.stopPropagation(); }}
onContextMenu={function(e) { e.preventDefault(); e.stopPropagation(); }}>
<div className="ctx-header">{project.name}</div>
<button onClick={function() { onClose(); onRename(project); }}><Icon name="edit" size={11} />Rename project</button>
<button onClick={function() { onClose(); window.ZAMPP_API.fetch('/projects/' + project.id, { method: 'DELETE' }).then(function() { window.location.reload(); }).catch(function(e) { alert('Delete failed: ' + e.message); }); }} className="danger"><Icon name="trash" size={11} />Delete project</button>
</div>
);
}
function binIcon(name) {
return { grid: 'library', live: 'record', film: 'film', proxy: 'proxy', audio: 'audio', package: 'package' }[name] || 'folder';
}

View file

@ -246,3 +246,4 @@ function ProjectCard({ project, assets, onOpen }) {
}
window.Projects = Projects;
window.RenameProjectModal = RenameProjectModal;

View file

@ -304,6 +304,7 @@
}
.rail-item:hover { background: var(--hover); color: var(--text-1); }
.rail-item.active { background: var(--accent-soft); color: var(--accent-text); }
.rail-item.droppable { outline: 2px dashed var(--accent); outline-offset: -2px; background: var(--accent-subtle); }
.rail-item.active .rail-icon { color: var(--accent); }
.rail-item .rail-icon { color: var(--text-3); }
.rail-item .rail-count { margin-left: auto; font-family: var(--font-mono); font-size: 10.5px; color: var(--text-3); }