Files
site-builder/craft/src/panels/left/AssetsPanel.tsx
T

357 lines
12 KiB
TypeScript
Raw Normal View History

import React, { useEffect, useRef, useState, useCallback } from 'react';
import { useAssets } from '../../hooks/useAssets';
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;
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);
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);
}, []);
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);
}, []);
const copyUrl = useCallback(async (url: string) => {
const ok = await copyToClipboard(url);
if (ok) {
setCopyErrorUrl(null);
setCopiedUrl(url);
setTimeout(() => setCopiedUrl(null), 2000);
} else {
setCopiedUrl(null);
setCopyErrorUrl(url);
setTimeout(() => setCopyErrorUrl(null), 2500);
}
}, []);
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>
{/* 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. */}
<div
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
{...(assets.length === 0 ? clickableProps(() => fileInputRef.current?.click()) : {})}
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
padding: assets.length === 0 ? '36px 20px' : 16,
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,
cursor: assets.length === 0 ? 'pointer' : 'default',
transition: 'all var(--transition-fast)',
}}
>
{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'}
</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,
}}
>
{assets.map((asset) => {
const isConfirmingDelete = confirmDeleteName === asset.name;
return (
<div
key={asset.name}
{...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);
})}
style={{
position: 'relative',
background: 'var(--color-bg-elevated)',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-md)',
overflow: 'visible',
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',
borderRadius: 'var(--radius-md) var(--radius-md) 0 0',
}}
>
{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)',
}}
>
&#128196;
</span>
)}
</div>
{/* Name / status */}
<div
style={{
padding: '4px 6px',
fontSize: 10,
color: copyErrorUrl === asset.url ? 'var(--color-danger)' : 'var(--color-text-muted)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{copyErrorUrl === asset.url ? 'Copy failed' : copiedUrl === asset.url ? 'Copied!' : asset.name}
</div>
{/* 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',
}}
>
<i className="fa fa-times" aria-hidden />
</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'; }}
>
<i className="fa fa-times" aria-hidden />
</button>
)}
</div>
);
})}
</div>
{/* Loading indicator */}
{loading && assets.length > 0 && (
<div
style={{
textAlign: 'center',
padding: 10,
color: 'var(--color-text-dim)',
fontSize: 11,
}}
>
Loading...
</div>
)}
</div>
);
};