133 lines
5.6 KiB
TypeScript
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>
|
|
);
|
|
}
|