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:
parent
c312991bac
commit
af905cf936
5 changed files with 123 additions and 8 deletions
|
|
@ -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();
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -246,3 +246,4 @@ function ProjectCard({ project, assets, onOpen }) {
|
|||
}
|
||||
|
||||
window.Projects = Projects;
|
||||
window.RenameProjectModal = RenameProjectModal;
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
|
|
|
|||
Loading…
Reference in a new issue