2026-04-05 18:31:16 -07:00
|
|
|
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
|
|
|
|
import { useAssets } from '../../hooks/useAssets';
|
2026-07-12 14:51:00 -07:00
|
|
|
import { clickableProps } from '../../utils/a11y';
|
|
|
|
|
import { copyToClipboard } from '../../utils/clipboard';
|
|
|
|
|
|
|
|
|
|
/** How long the "Delete?" confirm state stays armed before auto-resetting. */
|
|
|
|
|
const DELETE_CONFIRM_TIMEOUT_MS = 4000;
|
2026-04-05 18:31:16 -07:00
|
|
|
|
|
|
|
|
export const AssetsPanel: React.FC = () => {
|
|
|
|
|
const { assets, loading, error, loadAssets, uploadAsset, deleteAsset } = useAssets();
|
|
|
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
|
const [isDragOver, setIsDragOver] = useState(false);
|
|
|
|
|
const [copiedUrl, setCopiedUrl] = useState<string | null>(null);
|
2026-07-12 14:51:00 -07:00
|
|
|
const [copyErrorUrl, setCopyErrorUrl] = useState<string | null>(null);
|
|
|
|
|
const [confirmDeleteName, setConfirmDeleteName] = useState<string | null>(null);
|
|
|
|
|
const deleteConfirmTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
return () => {
|
|
|
|
|
if (deleteConfirmTimeoutRef.current) clearTimeout(deleteConfirmTimeoutRef.current);
|
|
|
|
|
};
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const resetDeleteConfirm = useCallback(() => {
|
|
|
|
|
if (deleteConfirmTimeoutRef.current) clearTimeout(deleteConfirmTimeoutRef.current);
|
|
|
|
|
setConfirmDeleteName(null);
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const armDeleteConfirm = useCallback((name: string) => {
|
|
|
|
|
setConfirmDeleteName(name);
|
|
|
|
|
if (deleteConfirmTimeoutRef.current) clearTimeout(deleteConfirmTimeoutRef.current);
|
|
|
|
|
deleteConfirmTimeoutRef.current = setTimeout(() => setConfirmDeleteName(null), DELETE_CONFIRM_TIMEOUT_MS);
|
|
|
|
|
}, []);
|
2026-04-05 18:31:16 -07:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
loadAssets();
|
|
|
|
|
}, [loadAssets]);
|
|
|
|
|
|
|
|
|
|
const handleFileSelect = useCallback(async (files: FileList | null) => {
|
|
|
|
|
if (!files) return;
|
|
|
|
|
for (let i = 0; i < files.length; i++) {
|
|
|
|
|
await uploadAsset(files[i]);
|
|
|
|
|
}
|
|
|
|
|
}, [uploadAsset]);
|
|
|
|
|
|
|
|
|
|
const handleDrop = useCallback((e: React.DragEvent) => {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
setIsDragOver(false);
|
|
|
|
|
handleFileSelect(e.dataTransfer.files);
|
|
|
|
|
}, [handleFileSelect]);
|
|
|
|
|
|
|
|
|
|
const handleDragOver = useCallback((e: React.DragEvent) => {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
setIsDragOver(true);
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
setIsDragOver(false);
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-07-12 14:51:00 -07:00
|
|
|
const copyUrl = useCallback(async (url: string) => {
|
|
|
|
|
const ok = await copyToClipboard(url);
|
|
|
|
|
if (ok) {
|
|
|
|
|
setCopyErrorUrl(null);
|
2026-04-05 18:31:16 -07:00
|
|
|
setCopiedUrl(url);
|
|
|
|
|
setTimeout(() => setCopiedUrl(null), 2000);
|
2026-07-12 14:51:00 -07:00
|
|
|
} else {
|
|
|
|
|
setCopiedUrl(null);
|
|
|
|
|
setCopyErrorUrl(url);
|
|
|
|
|
setTimeout(() => setCopyErrorUrl(null), 2500);
|
|
|
|
|
}
|
2026-04-05 18:31:16 -07:00
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const isImage = (type: string) =>
|
|
|
|
|
type.startsWith('image/') || /\.(jpg|jpeg|png|gif|svg|webp|ico)$/i.test(type);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
|
|
|
|
{/* Upload button */}
|
|
|
|
|
<input
|
|
|
|
|
ref={fileInputRef}
|
|
|
|
|
type="file"
|
|
|
|
|
multiple
|
|
|
|
|
style={{ display: 'none' }}
|
|
|
|
|
onChange={(e) => handleFileSelect(e.target.files)}
|
|
|
|
|
/>
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => fileInputRef.current?.click()}
|
|
|
|
|
disabled={loading}
|
|
|
|
|
style={{
|
|
|
|
|
width: '100%',
|
|
|
|
|
padding: '8px 12px',
|
|
|
|
|
fontSize: 12,
|
|
|
|
|
fontWeight: 600,
|
|
|
|
|
color: '#fff',
|
|
|
|
|
background: loading ? 'var(--color-bg-active)' : 'var(--color-accent)',
|
|
|
|
|
border: 'none',
|
|
|
|
|
borderRadius: 'var(--radius-md)',
|
|
|
|
|
cursor: loading ? 'not-allowed' : 'pointer',
|
|
|
|
|
transition: 'all var(--transition-fast)',
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{loading ? 'Uploading...' : 'Upload File'}
|
|
|
|
|
</button>
|
|
|
|
|
|
2026-07-12 20:15:15 -07:00
|
|
|
{/* Drop zone -- a single element that doubles as the empty state.
|
|
|
|
|
Previously this was a small always-visible dropzone PLUS a
|
|
|
|
|
separate italic "No assets uploaded yet" line stacked underneath
|
|
|
|
|
it when empty; merged into one tall dropzone (icon + copy,
|
|
|
|
|
click-or-drag) so the empty state isn't two redundant messages.
|
|
|
|
|
Once assets exist it collapses back to a slim persistent drop
|
|
|
|
|
target above the grid. */}
|
2026-04-05 18:31:16 -07:00
|
|
|
<div
|
|
|
|
|
onDrop={handleDrop}
|
|
|
|
|
onDragOver={handleDragOver}
|
|
|
|
|
onDragLeave={handleDragLeave}
|
2026-07-12 20:22:11 -07:00
|
|
|
{...(assets.length === 0 ? clickableProps(() => fileInputRef.current?.click()) : {})}
|
2026-04-05 18:31:16 -07:00
|
|
|
style={{
|
2026-07-12 20:15:15 -07:00
|
|
|
display: 'flex',
|
|
|
|
|
flexDirection: 'column',
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
justifyContent: 'center',
|
|
|
|
|
gap: 8,
|
|
|
|
|
padding: assets.length === 0 ? '36px 20px' : 16,
|
2026-04-05 18:31:16 -07:00
|
|
|
border: `2px dashed ${isDragOver ? 'var(--color-accent)' : 'var(--color-border)'}`,
|
|
|
|
|
borderRadius: 'var(--radius-md)',
|
|
|
|
|
background: isDragOver ? 'var(--color-accent-subtle)' : 'transparent',
|
|
|
|
|
textAlign: 'center',
|
|
|
|
|
color: isDragOver ? 'var(--color-accent)' : 'var(--color-text-dim)',
|
|
|
|
|
fontSize: 11,
|
2026-07-12 20:15:15 -07:00
|
|
|
cursor: assets.length === 0 ? 'pointer' : 'default',
|
2026-04-05 18:31:16 -07:00
|
|
|
transition: 'all var(--transition-fast)',
|
|
|
|
|
}}
|
|
|
|
|
>
|
2026-07-12 20:15:15 -07:00
|
|
|
{assets.length === 0 && (
|
|
|
|
|
<i className="fa fa-cloud-upload" aria-hidden style={{ fontSize: 28, opacity: 0.5 }} />
|
|
|
|
|
)}
|
|
|
|
|
{assets.length === 0 ? 'Drag images here or click to upload' : 'Drop files here to upload'}
|
2026-04-05 18:31:16 -07:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Error message */}
|
|
|
|
|
{error && (
|
|
|
|
|
<div
|
|
|
|
|
style={{
|
|
|
|
|
padding: '6px 10px',
|
|
|
|
|
fontSize: 11,
|
|
|
|
|
color: 'var(--color-danger)',
|
|
|
|
|
background: 'rgba(239, 68, 68, 0.1)',
|
|
|
|
|
borderRadius: 'var(--radius-sm)',
|
|
|
|
|
border: '1px solid rgba(239, 68, 68, 0.2)',
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{error}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Asset grid */}
|
|
|
|
|
<div
|
|
|
|
|
style={{
|
|
|
|
|
display: 'grid',
|
|
|
|
|
gridTemplateColumns: 'repeat(2, 1fr)',
|
|
|
|
|
gap: 6,
|
|
|
|
|
}}
|
|
|
|
|
>
|
2026-07-12 14:51:00 -07:00
|
|
|
{assets.map((asset) => {
|
|
|
|
|
const isConfirmingDelete = confirmDeleteName === asset.name;
|
|
|
|
|
return (
|
2026-04-05 18:31:16 -07:00
|
|
|
<div
|
|
|
|
|
key={asset.name}
|
2026-07-12 14:51:00 -07:00
|
|
|
{...clickableProps(() => {
|
|
|
|
|
// While armed, clicking anywhere on the tile (other than the
|
|
|
|
|
// delete control itself) counts as "click elsewhere" and
|
|
|
|
|
// disarms the confirm instead of copying the URL.
|
|
|
|
|
if (isConfirmingDelete) {
|
|
|
|
|
resetDeleteConfirm();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
copyUrl(asset.url);
|
|
|
|
|
})}
|
2026-04-05 18:31:16 -07:00
|
|
|
style={{
|
|
|
|
|
position: 'relative',
|
|
|
|
|
background: 'var(--color-bg-elevated)',
|
|
|
|
|
border: '1px solid var(--color-border)',
|
|
|
|
|
borderRadius: 'var(--radius-md)',
|
2026-07-12 14:51:00 -07:00
|
|
|
overflow: 'visible',
|
2026-04-05 18:31:16 -07:00
|
|
|
cursor: 'pointer',
|
|
|
|
|
transition: 'border-color var(--transition-fast)',
|
|
|
|
|
}}
|
|
|
|
|
title={`Click to copy URL: ${asset.url}`}
|
|
|
|
|
>
|
|
|
|
|
{/* Thumbnail */}
|
|
|
|
|
<div
|
|
|
|
|
style={{
|
|
|
|
|
width: '100%',
|
|
|
|
|
aspectRatio: '1',
|
|
|
|
|
display: 'flex',
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
justifyContent: 'center',
|
|
|
|
|
background: 'var(--color-bg-base)',
|
|
|
|
|
overflow: 'hidden',
|
2026-07-12 14:51:00 -07:00
|
|
|
borderRadius: 'var(--radius-md) var(--radius-md) 0 0',
|
2026-04-05 18:31:16 -07:00
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{isImage(asset.type) ? (
|
|
|
|
|
<img
|
|
|
|
|
src={asset.url}
|
|
|
|
|
alt={asset.name}
|
|
|
|
|
style={{
|
|
|
|
|
width: '100%',
|
|
|
|
|
height: '100%',
|
|
|
|
|
objectFit: 'cover',
|
|
|
|
|
}}
|
|
|
|
|
loading="lazy"
|
|
|
|
|
/>
|
|
|
|
|
) : (
|
|
|
|
|
<span
|
|
|
|
|
style={{
|
|
|
|
|
fontSize: 20,
|
|
|
|
|
color: 'var(--color-text-dim)',
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
📄
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-07-12 14:51:00 -07:00
|
|
|
{/* Name / status */}
|
2026-04-05 18:31:16 -07:00
|
|
|
<div
|
|
|
|
|
style={{
|
|
|
|
|
padding: '4px 6px',
|
|
|
|
|
fontSize: 10,
|
2026-07-12 14:51:00 -07:00
|
|
|
color: copyErrorUrl === asset.url ? 'var(--color-danger)' : 'var(--color-text-muted)',
|
2026-04-05 18:31:16 -07:00
|
|
|
overflow: 'hidden',
|
|
|
|
|
textOverflow: 'ellipsis',
|
|
|
|
|
whiteSpace: 'nowrap',
|
|
|
|
|
}}
|
|
|
|
|
>
|
2026-07-12 14:51:00 -07:00
|
|
|
{copyErrorUrl === asset.url ? 'Copy failed' : copiedUrl === asset.url ? 'Copied!' : asset.name}
|
2026-04-05 18:31:16 -07:00
|
|
|
</div>
|
|
|
|
|
|
2026-07-12 14:51:00 -07:00
|
|
|
{/* Delete control: idle icon, or an in-app two-step confirm */}
|
|
|
|
|
{isConfirmingDelete ? (
|
|
|
|
|
<div
|
|
|
|
|
style={{
|
|
|
|
|
position: 'absolute',
|
|
|
|
|
top: 4,
|
|
|
|
|
right: 4,
|
|
|
|
|
display: 'flex',
|
|
|
|
|
gap: 3,
|
|
|
|
|
zIndex: 1,
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<button
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
resetDeleteConfirm();
|
|
|
|
|
deleteAsset(asset.name);
|
|
|
|
|
}}
|
|
|
|
|
title="Click again to permanently delete"
|
|
|
|
|
aria-label={`Confirm delete ${asset.name}`}
|
|
|
|
|
autoFocus
|
|
|
|
|
style={{
|
|
|
|
|
padding: '2px 6px',
|
|
|
|
|
fontSize: 9,
|
|
|
|
|
fontWeight: 700,
|
|
|
|
|
color: '#fff',
|
|
|
|
|
background: 'var(--color-danger)',
|
|
|
|
|
border: 'none',
|
|
|
|
|
borderRadius: 'var(--radius-sm)',
|
|
|
|
|
cursor: 'pointer',
|
|
|
|
|
whiteSpace: 'nowrap',
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
Delete?
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
resetDeleteConfirm();
|
|
|
|
|
}}
|
|
|
|
|
title="Cancel"
|
|
|
|
|
aria-label={`Cancel delete ${asset.name}`}
|
|
|
|
|
style={{
|
|
|
|
|
width: 18,
|
|
|
|
|
height: 18,
|
|
|
|
|
display: 'flex',
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
justifyContent: 'center',
|
|
|
|
|
fontSize: 9,
|
|
|
|
|
color: '#fff',
|
|
|
|
|
background: 'rgba(0,0,0,0.6)',
|
|
|
|
|
border: 'none',
|
|
|
|
|
borderRadius: '50%',
|
|
|
|
|
cursor: 'pointer',
|
|
|
|
|
}}
|
|
|
|
|
>
|
2026-07-12 20:14:29 -07:00
|
|
|
<i className="fa fa-times" aria-hidden />
|
2026-07-12 14:51:00 -07:00
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<button
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
armDeleteConfirm(asset.name);
|
|
|
|
|
}}
|
|
|
|
|
title="Delete asset"
|
|
|
|
|
aria-label={`Delete ${asset.name}`}
|
|
|
|
|
style={{
|
|
|
|
|
position: 'absolute',
|
|
|
|
|
top: 4,
|
|
|
|
|
right: 4,
|
|
|
|
|
width: 20,
|
|
|
|
|
height: 20,
|
|
|
|
|
display: 'flex',
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
justifyContent: 'center',
|
|
|
|
|
fontSize: 10,
|
|
|
|
|
color: '#fff',
|
|
|
|
|
background: 'rgba(0,0,0,0.6)',
|
|
|
|
|
border: 'none',
|
|
|
|
|
borderRadius: '50%',
|
|
|
|
|
cursor: 'pointer',
|
|
|
|
|
opacity: 0.7,
|
|
|
|
|
transition: 'opacity var(--transition-fast)',
|
|
|
|
|
}}
|
|
|
|
|
onMouseEnter={(e) => { (e.target as HTMLElement).style.opacity = '1'; }}
|
|
|
|
|
onMouseLeave={(e) => { (e.target as HTMLElement).style.opacity = '0.7'; }}
|
|
|
|
|
>
|
2026-07-12 20:14:29 -07:00
|
|
|
<i className="fa fa-times" aria-hidden />
|
2026-07-12 14:51:00 -07:00
|
|
|
</button>
|
|
|
|
|
)}
|
2026-04-05 18:31:16 -07:00
|
|
|
</div>
|
2026-07-12 14:51:00 -07:00
|
|
|
);
|
|
|
|
|
})}
|
2026-04-05 18:31:16 -07:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Loading indicator */}
|
|
|
|
|
{loading && assets.length > 0 && (
|
|
|
|
|
<div
|
|
|
|
|
style={{
|
|
|
|
|
textAlign: 'center',
|
|
|
|
|
padding: 10,
|
|
|
|
|
color: 'var(--color-text-dim)',
|
|
|
|
|
fontSize: 11,
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
Loading...
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|