mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
feat(stats): shareable Top-Albums card export (#425)
* feat(stats): add shareable Top-Albums card export
Adds a shareable PNG export of the user's most-played albums,
available from the Statistics page under the "Most Played Albums"
section header.
Layout:
* 3 formats — Story (1080×1920, 9:16), Square (1080×1080), Twitter
Card (1200×675, 16:9). Story and Square stack a centered URL footer;
Twitter packs logo + label + URL into a single header row.
* 3 grid sizes — 3×3 (default), 4×4, 5×5. Modal fetches up to 25
frequent albums on open so larger grids work even when the entry
surface only loaded a handful.
* Cover tiles render the rank + play count in a thin black info strip
at the bottom of the cover (rank left, "N Plays" right).
* Header label is hardcoded English ("Top Albums") so a shared image
remains legible to followers who don't share the user's UI language.
Caller can override via `opts.meta`.
Source path is local-only — `getAlbumList(type='frequent')` from
Subsonic. Last.fm is intentionally not used.
Implementation:
* `src/utils/exportAlbumCard.ts` — pure Canvas-API renderer. Reads
theme accent / bg from the document's CSS variables so the export
matches the active theme. Cover art is loaded through the existing
`getCachedBlob` IndexedDB cache, so repeated exports are cheap.
Wordmark is rendered from the `PsysonicLogo` React component via
`renderToStaticMarkup`, with both gradient stops baked to a single
accent color for crisp single-tone branding.
* `src/components/StatsExportModal.tsx` — UI: format + grid pickers,
live downscaled preview, native save dialog via
`@tauri-apps/plugin-dialog` + `plugin-fs`.
* `src/components/AlbumRow.tsx` — adds optional `headerExtra` slot so
the share button sits next to the existing scroll-nav arrows.
* i18n keys added under `statistics.*` (`en`, `de`) — other locales
fall back to English.
* docs(changelog): add Top-Albums card export for PR #425
This commit is contained in:
committed by
GitHub
parent
297c9f1125
commit
7064ca500e
@@ -0,0 +1,308 @@
|
||||
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 { getAlbumList, SubsonicAlbum } from '../api/subsonic';
|
||||
import { showToast } from '../utils/toast';
|
||||
import {
|
||||
exportAlbumCardBlob,
|
||||
renderAlbumCardCanvas,
|
||||
ExportFormat,
|
||||
ExportGridSize,
|
||||
} from '../utils/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<ExportFormat>('square');
|
||||
const [gridSize, setGridSize] = useState<ExportGridSize>(3);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [topUpAlbums, setTopUpAlbums] = useState<SubsonicAlbum[] | null>(null);
|
||||
const previewRef = useRef<HTMLDivElement | null>(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(
|
||||
<div
|
||||
className="modal-overlay"
|
||||
onClick={onClose}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
style={{ alignItems: 'center', paddingTop: 0 }}
|
||||
>
|
||||
<div
|
||||
className="modal-content"
|
||||
onClick={e => e.stopPropagation()}
|
||||
style={{ maxWidth: '720px', width: 'min(720px, 92vw)' }}
|
||||
>
|
||||
<button className="modal-close" onClick={onClose} aria-label={t('statistics.exportCancel')}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
<h3 style={{ marginBottom: '0.25rem', fontFamily: 'var(--font-display)' }}>
|
||||
{t('statistics.exportTitle')}
|
||||
</h3>
|
||||
<p style={{ color: 'var(--text-secondary)', marginBottom: '1rem', fontSize: '0.875rem' }}>
|
||||
{t('statistics.exportSubtitle')}
|
||||
</p>
|
||||
|
||||
{/* Format */}
|
||||
<div style={{ marginBottom: '0.875rem' }}>
|
||||
<div style={{ fontSize: '0.75rem', fontWeight: 600, color: 'var(--text-muted)', marginBottom: '0.5rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||
{t('statistics.exportFormat')}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||
{FORMATS.map(f => {
|
||||
const active = format === f.key;
|
||||
return (
|
||||
<button
|
||||
key={f.key}
|
||||
type="button"
|
||||
onClick={() => setFormat(f.key)}
|
||||
className="btn btn-surface"
|
||||
style={{
|
||||
padding: '0.5rem 0.75rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
border: `1px solid ${active ? 'var(--accent)' : 'var(--glass-border)'}`,
|
||||
background: active ? 'color-mix(in srgb, var(--accent) 12%, transparent)' : undefined,
|
||||
}}
|
||||
>
|
||||
<span style={{
|
||||
display: 'inline-block',
|
||||
width: f.ratioBox.w * 0.4,
|
||||
height: f.ratioBox.h * 0.4,
|
||||
background: active ? 'var(--accent)' : 'var(--text-muted)',
|
||||
opacity: active ? 0.9 : 0.5,
|
||||
borderRadius: 2,
|
||||
}} />
|
||||
{t(`statistics.exportFormat${f.key[0].toUpperCase()}${f.key.slice(1)}`)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grid */}
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<div style={{ fontSize: '0.75rem', fontWeight: 600, color: 'var(--text-muted)', marginBottom: '0.5rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||
{t('statistics.exportGrid')}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||
{GRID_SIZES.map(n => {
|
||||
const active = gridSize === n;
|
||||
return (
|
||||
<button
|
||||
key={n}
|
||||
type="button"
|
||||
onClick={() => setGridSize(n)}
|
||||
className="btn btn-surface"
|
||||
style={{
|
||||
padding: '0.5rem 0.875rem',
|
||||
border: `1px solid ${active ? 'var(--accent)' : 'var(--glass-border)'}`,
|
||||
background: active ? 'color-mix(in srgb, var(--accent) 12%, transparent)' : undefined,
|
||||
}}
|
||||
>
|
||||
{t('statistics.exportGridLabel', { n })}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preview */}
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<div style={{ fontSize: '0.75rem', fontWeight: 600, color: 'var(--text-muted)', marginBottom: '0.5rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||
{t('statistics.exportPreview')}
|
||||
</div>
|
||||
<PreviewFrame format={format}>
|
||||
{!enoughAlbums ? (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
textAlign: 'center',
|
||||
color: 'var(--text-muted)',
|
||||
fontSize: '0.875rem',
|
||||
padding: '1rem',
|
||||
}}>
|
||||
{t('statistics.exportNotEnough', { count: required, n: gridSize })}
|
||||
</div>
|
||||
) : (
|
||||
<div ref={previewRef} style={{ width: '100%' }} />
|
||||
)}
|
||||
</PreviewFrame>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px' }}>
|
||||
<button className="btn btn-ghost" onClick={onClose} disabled={saving}>
|
||||
{t('statistics.exportCancel')}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={onSave}
|
||||
disabled={!enoughAlbums || saving}
|
||||
>
|
||||
{saving ? t('statistics.exportSaving') : t('statistics.exportSave')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
const PreviewFrame = ({ format, children }: { format: ExportFormat; children: React.ReactNode }) => {
|
||||
const aspect = useMemo(() => {
|
||||
if (format === 'story') return '9 / 16';
|
||||
if (format === 'square') return '1 / 1';
|
||||
return '16 / 9';
|
||||
}, [format]);
|
||||
// Cap preview height so the modal stays manageable on a typical desktop.
|
||||
// Narrow ratios (Story) become height-bounded; wide ratios (Twitter) are
|
||||
// width-bounded by the modal itself.
|
||||
return (
|
||||
<div style={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
maxHeight: '52vh',
|
||||
aspectRatio: aspect,
|
||||
margin: '0 auto',
|
||||
maxWidth: format === 'story' ? '320px' : undefined,
|
||||
background: 'var(--glass-bg)',
|
||||
borderRadius: 12,
|
||||
border: '1px solid var(--glass-border)',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user