213 lines
8.5 KiB
TypeScript
213 lines
8.5 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react';
|
|
import { Box, Button, Stack, Typography, Paper, TextField, MenuItem } from '@mui/material';
|
|
|
|
type MediaItem = {
|
|
key: string;
|
|
size: number;
|
|
lastModified: string | null;
|
|
};
|
|
|
|
export default function MediaLibrary({
|
|
onInsert,
|
|
onSetFeature,
|
|
showSetFeature,
|
|
selectionMode,
|
|
selectedKeys,
|
|
onToggleSelect,
|
|
}: {
|
|
onInsert?: (url: string) => void;
|
|
onSetFeature?: (url: string) => void;
|
|
showSetFeature?: boolean;
|
|
selectionMode?: boolean;
|
|
selectedKeys?: string[];
|
|
onToggleSelect?: (key: string) => void;
|
|
}) {
|
|
const [items, setItems] = useState<MediaItem[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState('');
|
|
const [query, setQuery] = useState('');
|
|
const [sortBy, setSortBy] = useState<'date_desc' | 'date_asc' | 'name_asc' | 'size_desc'>('date_desc');
|
|
const [uploading, setUploading] = useState(false);
|
|
const [uploadingCount, setUploadingCount] = useState(0);
|
|
|
|
const load = async () => {
|
|
try {
|
|
setLoading(true);
|
|
setError('');
|
|
const res = await fetch('/api/media/list?prefix=images/');
|
|
if (!res.ok) throw new Error(await res.text());
|
|
const data = await res.json();
|
|
setItems(Array.isArray(data.items) ? data.items : []);
|
|
} catch (e: any) {
|
|
setError(e?.message || 'Failed to load media');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
load();
|
|
}, []);
|
|
|
|
// Paste-to-upload clipboard images
|
|
useEffect(() => {
|
|
const handler = async (e: ClipboardEvent) => {
|
|
try {
|
|
const items = e.clipboardData?.items;
|
|
if (!items || items.length === 0) return;
|
|
const files: File[] = [];
|
|
for (let i = 0; i < items.length; i++) {
|
|
const it = items[i];
|
|
if (it.kind === 'file') {
|
|
const f = it.getAsFile();
|
|
if (f && f.type.startsWith('image/')) files.push(f);
|
|
}
|
|
}
|
|
if (files.length === 0) return;
|
|
setUploading(true);
|
|
setUploadingCount(files.length);
|
|
for (const file of files) {
|
|
try {
|
|
const fd = new FormData();
|
|
fd.append('image', file, file.name || 'pasted-image.png');
|
|
const res = await fetch('/api/media/image', { method: 'POST', body: fd });
|
|
if (!res.ok) throw new Error(await res.text());
|
|
} catch (err: any) {
|
|
setError(err?.message || 'Clipboard upload failed');
|
|
}
|
|
}
|
|
setUploading(false);
|
|
setUploadingCount(0);
|
|
await load();
|
|
} catch {}
|
|
};
|
|
window.addEventListener('paste', handler);
|
|
return () => window.removeEventListener('paste', handler);
|
|
}, []);
|
|
|
|
const del = async (key: string) => {
|
|
try {
|
|
setError('');
|
|
const res = await fetch('/api/media/obj', {
|
|
method: 'DELETE',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ key }),
|
|
});
|
|
if (!res.ok) throw new Error(await res.text());
|
|
await load();
|
|
} catch (e: any) {
|
|
setError(e?.message || 'Delete failed');
|
|
}
|
|
};
|
|
|
|
const filtered = useMemo(() => {
|
|
const q = query.trim().toLowerCase();
|
|
let arr = items.filter(it => it.key.toLowerCase().includes(q));
|
|
arr.sort((a, b) => {
|
|
if (sortBy === 'date_desc') return (new Date(b.lastModified || 0).getTime()) - (new Date(a.lastModified || 0).getTime());
|
|
if (sortBy === 'date_asc') return (new Date(a.lastModified || 0).getTime()) - (new Date(b.lastModified || 0).getTime());
|
|
if (sortBy === 'size_desc') return (b.size - a.size);
|
|
// name_asc
|
|
const an = a.key.split('/').slice(-1)[0].toLowerCase();
|
|
const bn = b.key.split('/').slice(-1)[0].toLowerCase();
|
|
return an.localeCompare(bn);
|
|
});
|
|
return arr;
|
|
}, [items, query, sortBy]);
|
|
|
|
const fmtSize = (n: number) => {
|
|
if (n >= 1024 * 1024) return (n / (1024 * 1024)).toFixed(1) + ' MB';
|
|
if (n >= 1024) return (n / 1024).toFixed(1) + ' KB';
|
|
return n + ' B';
|
|
};
|
|
|
|
const fmtDate = (s: string | null) => s ? new Date(s).toLocaleString() : '';
|
|
|
|
const copyUrl = async (url: string) => {
|
|
try {
|
|
await navigator.clipboard.writeText(url);
|
|
} catch {}
|
|
};
|
|
|
|
return (
|
|
<Paper sx={{ p: 2 }}>
|
|
<Stack direction="row" justifyContent="flex-end" alignItems="center" sx={{ mb: 2 }}>
|
|
<Stack direction="row" spacing={1} alignItems="center">
|
|
<TextField
|
|
size="small"
|
|
placeholder="Search by name…"
|
|
value={query}
|
|
onChange={e => setQuery(e.target.value)}
|
|
/>
|
|
<TextField size="small" select value={sortBy} onChange={e => setSortBy(e.target.value as any)}>
|
|
<MenuItem value="date_desc">Newest</MenuItem>
|
|
<MenuItem value="date_asc">Oldest</MenuItem>
|
|
<MenuItem value="name_asc">Name</MenuItem>
|
|
<MenuItem value="size_desc">Largest</MenuItem>
|
|
</TextField>
|
|
<Button size="small" onClick={load} disabled={loading}>Refresh</Button>
|
|
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
|
Tip: paste an image (Cmd/Ctrl+V) to upload{uploading ? ` — uploading ${uploadingCount}…` : ''}
|
|
</Typography>
|
|
</Stack>
|
|
</Stack>
|
|
{error && <Typography color="error" sx={{ mb: 1 }}>{error}</Typography>}
|
|
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))', gap: 1.5 }}>
|
|
{filtered.map((it) => {
|
|
const url = `/api/media/obj?key=${encodeURIComponent(it.key)}`;
|
|
const selected = !!selectedKeys?.includes(it.key);
|
|
const name = it.key.split('/').slice(-1)[0];
|
|
return (
|
|
<Paper
|
|
key={it.key}
|
|
sx={{
|
|
p: 1.25,
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
minHeight: 320,
|
|
overflow: 'hidden',
|
|
border: '1px solid',
|
|
borderColor: selected ? 'primary.main' : 'divider',
|
|
}}
|
|
>
|
|
<Box sx={{ width: '100%', flex: '0 0 150px', display: 'flex', alignItems: 'center', justifyContent: 'center', mb: 1, overflow: 'hidden', background: '#fafafa', borderRadius: 1 }}>
|
|
<a href={url} target="_blank" rel="noreferrer">
|
|
<img src={url} alt={name} style={{ maxWidth: '100%', maxHeight: '150px', objectFit: 'contain', display: 'block' }} />
|
|
</a>
|
|
</Box>
|
|
<Typography variant="caption" sx={{ display: 'block', whiteSpace: 'normal', overflowWrap: 'anywhere' }} title={name}>{name}</Typography>
|
|
<Typography variant="caption" sx={{ display: 'block', color: 'text.secondary' }}>{fmtSize(it.size)} · {fmtDate(it.lastModified)}</Typography>
|
|
<Box sx={{ flexGrow: 1 }} />
|
|
<Stack direction="row" spacing={1} sx={{ mt: 0.5, flexWrap: 'wrap' }}>
|
|
{selectionMode && onToggleSelect ? (
|
|
<Button
|
|
size="small"
|
|
variant={selected ? 'contained' : 'outlined'}
|
|
color={selected ? 'primary' : 'inherit'}
|
|
sx={{ flex: '1 1 48%', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}
|
|
onClick={() => onToggleSelect(it.key)}
|
|
>
|
|
{selected ? 'Selected' : 'Select'}
|
|
</Button>
|
|
) : (
|
|
onInsert && (
|
|
<Button size="small" variant="outlined" sx={{ flex: '1 1 48%', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }} onClick={() => onInsert(url)}>Insert</Button>
|
|
)
|
|
)}
|
|
{showSetFeature && onSetFeature && (
|
|
<Button size="small" variant="outlined" sx={{ flex: '1 1 48%', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }} onClick={() => onSetFeature(url)}>Set Feature</Button>
|
|
)}
|
|
<Button size="small" variant="text" sx={{ flex: '1 1 48%', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }} onClick={() => copyUrl(url)}>Copy URL</Button>
|
|
<Button size="small" color="error" variant="text" sx={{ flex: '1 1 48%', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }} onClick={() => { if (confirm('Delete this image?')) del(it.key) }}>Delete</Button>
|
|
</Stack>
|
|
</Paper>
|
|
);
|
|
})}
|
|
{filtered.length === 0 && !loading && (
|
|
<Typography variant="body2">No images found.</Typography>
|
|
)}
|
|
</Box>
|
|
</Paper>
|
|
);
|
|
}
|