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,
|
project_id UUID NOT NULL REFERENCES projects ON DELETE CASCADE,
|
||||||
parent_id UUID REFERENCES bins ON DELETE SET NULL,
|
parent_id UUID REFERENCES bins ON DELETE SET NULL,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Assets table
|
-- Assets table
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,10 @@ function Library({ navigate, onOpenAsset, openProject }) {
|
||||||
const [renamingAsset, setRenamingAsset] = React.useState(null);
|
const [renamingAsset, setRenamingAsset] = React.useState(null);
|
||||||
const [creatingBin, setCreatingBin] = React.useState(false);
|
const [creatingBin, setCreatingBin] = React.useState(false);
|
||||||
const [newBinName, setNewBinName] = React.useState('');
|
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(() => {
|
const refreshAssets = React.useCallback(() => {
|
||||||
window.ZAMPP_API.fetch('/assets?limit=500')
|
window.ZAMPP_API.fetch('/assets?limit=500')
|
||||||
|
|
@ -104,6 +108,62 @@ function Library({ navigate, onOpenAsset, openProject }) {
|
||||||
setCtxMenu({ asset, x: e.clientX, y: e.clientY });
|
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);
|
const [selectedBinId, setSelectedBinId] = React.useState(null);
|
||||||
// Clear bin filter on project change so a stale id doesn't hide everything.
|
// Clear bin filter on project change so a stale id doesn't hide everything.
|
||||||
React.useEffect(() => { setSelectedBinId(null); }, [openProject?.id]);
|
React.useEffect(() => { setSelectedBinId(null); }, [openProject?.id]);
|
||||||
|
|
@ -136,7 +196,8 @@ function Library({ navigate, onOpenAsset, openProject }) {
|
||||||
{PROJECTS.slice(0, 8).map(function(p) {
|
{PROJECTS.slice(0, 8).map(function(p) {
|
||||||
return (
|
return (
|
||||||
<div key={p.id} className={`rail-item ${openProject && openProject.id === p.id ? 'active' : ''}`} style={{ cursor: 'pointer' }}
|
<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 className="rail-color-dot" style={{ background: p.color }} />
|
||||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.name}</span>
|
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.name}</span>
|
||||||
<span className="rail-count">{p.assets}</span>
|
<span className="rail-count">{p.assets}</span>
|
||||||
|
|
@ -178,12 +239,16 @@ function Library({ navigate, onOpenAsset, openProject }) {
|
||||||
</div>
|
</div>
|
||||||
) : BINS.map(function(b) {
|
) : BINS.map(function(b) {
|
||||||
const isActive = selectedBinId === b.id;
|
const isActive = selectedBinId === b.id;
|
||||||
|
const isDragTarget = draggingAssetId !== null;
|
||||||
return (
|
return (
|
||||||
<div key={b.id}
|
<div key={b.id}
|
||||||
className={'rail-item' + (isActive ? ' active' : '')}
|
className={'rail-item' + (isActive ? ' active' : '') + (isDragTarget ? ' droppable' : '')}
|
||||||
onClick={function() { setSelectedBinId(isActive ? null : b.id); }}
|
onClick={function() { setSelectedBinId(isActive ? null : b.id); }}
|
||||||
|
onDragOver={onBinDragOver}
|
||||||
|
onDrop={function(e) { onBinDrop(b.id, e); }}
|
||||||
|
onDragLeave={onBinDragLeave}
|
||||||
style={{ cursor: 'pointer' }}
|
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" />
|
<Icon name={binIcon(b.icon)} size={13} className="rail-icon" />
|
||||||
<span>{b.name}</span>
|
<span>{b.name}</span>
|
||||||
<span className="rail-count">{b.count}</span>
|
<span className="rail-count">{b.count}</span>
|
||||||
|
|
@ -234,7 +299,9 @@ function Library({ navigate, onOpenAsset, openProject }) {
|
||||||
{assets.map(function(a) {
|
{assets.map(function(a) {
|
||||||
return <AssetCard key={a.id} asset={a}
|
return <AssetCard key={a.id} asset={a}
|
||||||
onOpen={function() { onOpenAsset(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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -244,7 +311,8 @@ function Library({ navigate, onOpenAsset, openProject }) {
|
||||||
</div>
|
</div>
|
||||||
{assets.map(function(a) {
|
{assets.map(function(a) {
|
||||||
return (
|
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 className="thumb"><AssetThumb asset={a} /></div>
|
||||||
<div>
|
<div>
|
||||||
<div className="name">{a.name}</div>
|
<div className="name">{a.name}</div>
|
||||||
|
|
@ -284,6 +352,22 @@ function Library({ navigate, onOpenAsset, openProject }) {
|
||||||
onSaved={function() { setRenamingAsset(null); refreshAssets(); }}
|
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>
|
</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 [hoverStream, setHoverStream] = React.useState(null);
|
||||||
const [hovered, setHovered] = React.useState(false);
|
const [hovered, setHovered] = React.useState(false);
|
||||||
const timerRef = React.useRef(null);
|
const timerRef = React.useRef(null);
|
||||||
|
|
@ -402,7 +486,8 @@ function AssetCard({ asset, onOpen, onContextMenu }) {
|
||||||
const showVideo = hovered && hoverStream;
|
const showVideo = hovered && hoverStream;
|
||||||
|
|
||||||
return (
|
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' }}>
|
<div style={{ position: 'relative' }}>
|
||||||
<AssetThumb asset={asset} />
|
<AssetThumb asset={asset} />
|
||||||
{showVideo && (
|
{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) {
|
function binIcon(name) {
|
||||||
return { grid: 'library', live: 'record', film: 'film', proxy: 'proxy', audio: 'audio', package: 'package' }[name] || 'folder';
|
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.Projects = Projects;
|
||||||
|
window.RenameProjectModal = RenameProjectModal;
|
||||||
|
|
|
||||||
|
|
@ -304,6 +304,7 @@
|
||||||
}
|
}
|
||||||
.rail-item:hover { background: var(--hover); color: var(--text-1); }
|
.rail-item:hover { background: var(--hover); color: var(--text-1); }
|
||||||
.rail-item.active { background: var(--accent-soft); color: var(--accent-text); }
|
.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.active .rail-icon { color: var(--accent); }
|
||||||
.rail-item .rail-icon { color: var(--text-3); }
|
.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); }
|
.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