voxblog/apps/admin/src/components/MediaLibrary.tsx

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>
);
}