import { getAlbumList } from '../api/subsonicLibrary'; import type { SubsonicAlbum } from '../api/subsonicTypes'; import { useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { useTranslation } from 'react-i18next'; import { X } from 'lucide-react'; import { save } from '@tauri-apps/plugin-dialog'; import { writeFile } from '@tauri-apps/plugin-fs'; import { showToast } from '../utils/ui/toast'; import { exportAlbumCardBlob, renderAlbumCardCanvas, ExportFormat, ExportGridSize, } from '../utils/export/exportAlbumCard'; interface Props { open: boolean; /** Pre-loaded albums (e.g. from the statistics page). The modal will fetch * more on open if this list is shorter than 25 (max grid 5×5). */ albums: SubsonicAlbum[]; /** Footer-right meta string, e.g. "Most Played" or a date. */ meta?: string; onClose: () => void; } const MAX_NEEDED = 25; // 5 × 5 grid const FORMATS: { key: ExportFormat; ratioBox: { w: number; h: number } }[] = [ { key: 'story', ratioBox: { w: 36, h: 64 } }, { key: 'square', ratioBox: { w: 50, h: 50 } }, { key: 'twitter', ratioBox: { w: 64, h: 36 } }, ]; const GRID_SIZES: ExportGridSize[] = [3, 4, 5]; export default function StatsExportModal({ open, albums, meta, onClose }: Props) { const { t } = useTranslation(); const [format, setFormat] = useState('square'); const [gridSize, setGridSize] = useState(3); const [saving, setSaving] = useState(false); const [topUpAlbums, setTopUpAlbums] = useState(null); const previewRef = useRef(null); const previewSeqRef = useRef(0); const effectiveAlbums = topUpAlbums ?? albums; const required = gridSize * gridSize; const enoughAlbums = effectiveAlbums.length >= required; // On open: if the caller-provided list is shorter than the largest grid, // fetch up to 25 in the background so the user can pick 4×4 / 5×5 even // when the entry surface only loaded a few albums. useEffect(() => { if (!open) return; if (albums.length >= MAX_NEEDED) { setTopUpAlbums(albums); return; } setTopUpAlbums(null); let cancelled = false; (async () => { try { const more = await getAlbumList('frequent', MAX_NEEDED, 0); if (cancelled) return; setTopUpAlbums(more.length > albums.length ? more : albums); } catch { if (!cancelled) setTopUpAlbums(albums); } })(); return () => { cancelled = true; }; }, [open, albums]); const title = t('statistics.exportFooterLabel'); // Live preview: re-renders on format / gridSize / albums changes. useEffect(() => { if (!open) return; if (!enoughAlbums) return; const host = previewRef.current; if (!host) return; const seq = ++previewSeqRef.current; let cancelled = false; (async () => { try { const canvas = await renderAlbumCardCanvas({ albums: effectiveAlbums, format, gridSize, title, meta, preview: true, }); if (cancelled || seq !== previewSeqRef.current) return; // Replace any previous preview canvas. host.replaceChildren(canvas); canvas.style.width = '100%'; canvas.style.height = 'auto'; canvas.style.display = 'block'; canvas.style.borderRadius = '12px'; canvas.style.boxShadow = '0 8px 24px rgba(0,0,0,0.35)'; } catch (e) { if (!cancelled && seq === previewSeqRef.current) { host.textContent = String(e); } } })(); return () => { cancelled = true; }; }, [open, format, gridSize, effectiveAlbums, enoughAlbums, title, meta]); // Esc-to-close. useEffect(() => { if (!open) return; const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [open, onClose]); if (!open) return null; const onSave = async () => { if (saving || !enoughAlbums) return; setSaving(true); try { const blob = await exportAlbumCardBlob({ albums: effectiveAlbums, format, gridSize, title, meta }); const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 16); const suggested = `psysonic-top-albums-${gridSize}x${gridSize}-${format}-${stamp}.png`; const path = await save({ title: t('statistics.exportSave'), defaultPath: suggested, filters: [{ name: 'PNG', extensions: ['png'] }], }); if (!path) { setSaving(false); return; } const buf = new Uint8Array(await blob.arrayBuffer()); await writeFile(path, buf); showToast(t('statistics.exportSaved'), 2400, 'info'); onClose(); } catch (err) { console.error('[stats-export] save failed', err); showToast(t('statistics.exportSaveFailed'), 3200, 'error'); } finally { setSaving(false); } }; return createPortal(
e.stopPropagation()} style={{ maxWidth: '720px', width: 'min(720px, 92vw)' }} >

{t('statistics.exportTitle')}

{t('statistics.exportSubtitle')}

{/* Format */}
{t('statistics.exportFormat')}
{FORMATS.map(f => { const active = format === f.key; return ( ); })}
{/* Grid */}
{t('statistics.exportGrid')}
{GRID_SIZES.map(n => { const active = gridSize === n; return ( ); })}
{/* Preview */}
{t('statistics.exportPreview')}
{!enoughAlbums ? (
{t('statistics.exportNotEnough', { count: required, n: gridSize })}
) : (
)}
, document.body, ); } const PreviewFrame = ({ format, children }: { format: ExportFormat; children: React.ReactNode }) => { // Aspect-aware bounds: cap BOTH dimensions so the preview always fits inside // the modal at any ratio. The earlier version capped only `maxHeight`, so // Square (1:1) tried to span the full modal width — the 1:1 canvas then // overflowed `maxHeight: 52vh` and the bottom rows were clipped by // `overflow: hidden` with no way to scroll them into view. const { aspect, maxWidth } = useMemo(() => { if (format === 'story') return { aspect: '9 / 16', maxWidth: 'min(320px, calc(52vh * 9 / 16))' }; if (format === 'square') return { aspect: '1 / 1', maxWidth: '52vh' }; return { aspect: '16 / 9', maxWidth: undefined as string | undefined }; }, [format]); return (
{children}
); };