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

133 lines
5.6 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 }: { onInsert: (url: string) => void; onSetFeature?: (url: string) => void; showSetFeature?: boolean }) {
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 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();
}, []);
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="space-between" alignItems="center" sx={{ mb: 2 }}>
<Typography variant="h6">Media Library</Typography>
<Stack direction="row" spacing={1}>
<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>
</Stack>
</Stack>
{error && <Typography color="error" sx={{ mb: 1 }}>{error}</Typography>}
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))', gap: 1.5 }}>
{filtered.map((it) => {
const url = `/api/media/obj?key=${encodeURIComponent(it.key)}`;
const name = it.key.split('/').slice(-1)[0];
return (
<Paper key={it.key} sx={{ p: 1.25, display: 'flex', flexDirection: 'column', height: 260 }}>
<Box sx={{ width: '100%', flex: '0 0 140px', 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: '140px', objectFit: 'contain', display: 'block' }} />
</a>
</Box>
<Typography variant="caption" noWrap sx={{ display: 'block' }} 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: 1, flexWrap: 'wrap' }}>
<Button size="small" variant="outlined" sx={{ flex: '1 1 48%' }} onClick={() => onInsert(url)}>Insert</Button>
{showSetFeature && onSetFeature && (
<Button size="small" variant="outlined" sx={{ flex: '1 1 48%' }} onClick={() => onSetFeature(url)}>Set Feature</Button>
)}
<Button size="small" variant="text" sx={{ flex: '1 1 48%' }} onClick={() => copyUrl(url)}>Copy URL</Button>
<Button size="small" color="error" variant="text" sx={{ flex: '1 1 48%' }} 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>
);
}