mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 06:25:41 +00:00
chore: prepare release v1.0.1 and ignore CLAUDE.md
This commit is contained in:
+131
-122
@@ -9,6 +9,15 @@ import { open } from '@tauri-apps/plugin-shell';
|
||||
import { writeFile } from '@tauri-apps/plugin-fs';
|
||||
import { join } from '@tauri-apps/api/path';
|
||||
import AlbumCard from '../components/AlbumCard';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
function sanitizeFilename(name: string): string {
|
||||
return name
|
||||
.replace(/[/\\?%*:|"<>]/g, '-')
|
||||
.replace(/\.{2,}/g, '.')
|
||||
.replace(/^[\s.]+|[\s.]+$/g, '')
|
||||
.substring(0, 200) || 'download';
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const m = Math.floor(seconds / 60);
|
||||
@@ -29,10 +38,28 @@ function codecLabel(song: { suffix?: string; bitRate?: number; samplingRate?: nu
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
/** Strip dangerous tags/attributes from server-provided HTML (e.g. artist bios from Last.fm) */
|
||||
function sanitizeHtml(html: string): string {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, 'text/html');
|
||||
doc.querySelectorAll('script, style, iframe, object, embed, form, input, button, select, base, meta, link').forEach(el => el.remove());
|
||||
doc.querySelectorAll('*').forEach(el => {
|
||||
Array.from(el.attributes).forEach(attr => {
|
||||
const name = attr.name.toLowerCase();
|
||||
const val = attr.value.toLowerCase().trim();
|
||||
if (name.startsWith('on') || (name === 'href' && (val.startsWith('javascript:') || val.startsWith('data:'))) || (name === 'src' && (val.startsWith('javascript:') || val.startsWith('data:')))) {
|
||||
el.removeAttribute(attr.name);
|
||||
}
|
||||
});
|
||||
});
|
||||
return doc.body.innerHTML;
|
||||
}
|
||||
|
||||
function StarRating({ value, onChange }: { value: number; onChange: (r: number) => void }) {
|
||||
const { t } = useTranslation();
|
||||
const [hover, setHover] = useState(0);
|
||||
return (
|
||||
<div className="star-rating" role="radiogroup" aria-label="Bewertung">
|
||||
<div className="star-rating" role="radiogroup" aria-label={t('albumDetail.ratingLabel')}>
|
||||
{[1,2,3,4,5].map(n => (
|
||||
<button
|
||||
key={n}
|
||||
@@ -40,7 +67,7 @@ function StarRating({ value, onChange }: { value: number; onChange: (r: number)
|
||||
onMouseEnter={() => setHover(n)}
|
||||
onMouseLeave={() => setHover(0)}
|
||||
onClick={() => onChange(n)}
|
||||
aria-label={`${n} Stern${n !== 1 ? 'e' : ''}`}
|
||||
aria-label={`${n}`}
|
||||
role="radio"
|
||||
aria-checked={(hover || value) >= n}
|
||||
>
|
||||
@@ -53,18 +80,20 @@ function StarRating({ value, onChange }: { value: number; onChange: (r: number)
|
||||
|
||||
interface BioModalProps { bio: string; onClose: () => void; }
|
||||
function BioModal({ bio, onClose }: BioModalProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose} role="dialog" aria-modal="true" aria-label="Künstler-Biografie">
|
||||
<div className="modal-overlay" onClick={onClose} role="dialog" aria-modal="true" aria-label={t('albumDetail.bioModal')}>
|
||||
<div className="modal-content" onClick={e => e.stopPropagation()}>
|
||||
<button className="modal-close" onClick={onClose} aria-label="Schließen"><X size={18} /></button>
|
||||
<h3 style={{ marginBottom: '1rem', fontFamily: 'var(--font-display)' }}>Künstler-Biografie</h3>
|
||||
<div className="artist-bio" dangerouslySetInnerHTML={{ __html: bio }} data-selectable />
|
||||
<button className="modal-close" onClick={onClose} aria-label={t('albumDetail.bioClose')}><X size={18} /></button>
|
||||
<h3 style={{ marginBottom: '1rem', fontFamily: 'var(--font-display)' }}>{t('albumDetail.bioModal')}</h3>
|
||||
<div className="artist-bio" dangerouslySetInnerHTML={{ __html: sanitizeHtml(bio) }} data-selectable />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AlbumDetail() {
|
||||
const { t } = useTranslation();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const auth = useAuthStore();
|
||||
@@ -85,19 +114,15 @@ export default function AlbumDetail() {
|
||||
if (!id) return;
|
||||
setLoading(true);
|
||||
setRelatedAlbums([]);
|
||||
getAlbum(id).then(async data => {
|
||||
setAlbum(data);
|
||||
getAlbum(id).then(async data => {
|
||||
setAlbum(data);
|
||||
setIsStarred(!!data.album.starred);
|
||||
|
||||
const initialStarred = new Set<string>();
|
||||
data.songs.forEach(s => { if (s.starred) initialStarred.add(s.id); });
|
||||
setStarredSongs(initialStarred);
|
||||
|
||||
setLoading(false);
|
||||
// Fetch related albums by the same artist
|
||||
setLoading(false);
|
||||
try {
|
||||
const artistData = await getArtist(data.album.artistId);
|
||||
// Filter out the current album from the related list
|
||||
setRelatedAlbums(artistData.albums.filter(a => a.id !== id));
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch related albums', e);
|
||||
@@ -127,10 +152,10 @@ export default function AlbumDetail() {
|
||||
|
||||
const handlePlaySong = (song: SubsonicSong) => {
|
||||
const track = {
|
||||
id: song.id, title: song.title, artist: song.artist, album: song.album,
|
||||
albumId: song.albumId, duration: song.duration, coverArt: song.coverArt,
|
||||
track: song.track, year: song.year, bitRate: song.bitRate,
|
||||
suffix: song.suffix, userRating: song.userRating
|
||||
id: song.id, title: song.title, artist: song.artist, album: song.album,
|
||||
albumId: song.albumId, duration: song.duration, coverArt: song.coverArt,
|
||||
track: song.track, year: song.year, bitRate: song.bitRate,
|
||||
suffix: song.suffix, userRating: song.userRating
|
||||
};
|
||||
playTrack(track, [track]);
|
||||
};
|
||||
@@ -144,7 +169,7 @@ export default function AlbumDetail() {
|
||||
if (!album) return;
|
||||
if (bio) { setBioOpen(true); return; }
|
||||
const info = await getArtistInfo(album.album.artistId);
|
||||
setBio(info.biography ?? 'Keine Biografie verfügbar.');
|
||||
setBio(info.biography ?? t('albumDetail.noBio'));
|
||||
setBioOpen(true);
|
||||
};
|
||||
|
||||
@@ -155,24 +180,23 @@ export default function AlbumDetail() {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const blob = await response.blob();
|
||||
|
||||
|
||||
if (auth.downloadFolder) {
|
||||
const buffer = await blob.arrayBuffer();
|
||||
const path = await join(auth.downloadFolder, `${albumName}.zip`);
|
||||
const path = await join(auth.downloadFolder, `${sanitizeFilename(albumName)}.zip`);
|
||||
await writeFile(path, new Uint8Array(buffer));
|
||||
console.log(`Saved to ${path}`);
|
||||
} else {
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = blobUrl;
|
||||
a.download = `${albumName}.zip`;
|
||||
a.download = `${sanitizeFilename(albumName)}.zip`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
setTimeout(() => URL.revokeObjectURL(blobUrl), 2000);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Download fehlgeschlagen:', e);
|
||||
console.error('Download failed:', e);
|
||||
} finally {
|
||||
setDownloading(false);
|
||||
}
|
||||
@@ -181,45 +205,34 @@ export default function AlbumDetail() {
|
||||
const toggleStar = async () => {
|
||||
if (!album) return;
|
||||
const currentlyStarred = isStarred;
|
||||
setIsStarred(!currentlyStarred); // Optimistic UI update
|
||||
setIsStarred(!currentlyStarred);
|
||||
try {
|
||||
if (currentlyStarred) {
|
||||
await unstar(album.album.id);
|
||||
} else {
|
||||
await star(album.album.id);
|
||||
}
|
||||
if (currentlyStarred) await unstar(album.album.id);
|
||||
else await star(album.album.id);
|
||||
} catch (e) {
|
||||
console.error('Failed to toggle star', e);
|
||||
setIsStarred(currentlyStarred); // Revert on failure
|
||||
setIsStarred(currentlyStarred);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSongStar = async (song: SubsonicSong, e: React.MouseEvent) => {
|
||||
e.stopPropagation(); // prevent play on double click trigger
|
||||
e.stopPropagation();
|
||||
const currentlyStarred = starredSongs.has(song.id);
|
||||
|
||||
// Optimistic UI
|
||||
const nextStarred = new Set(starredSongs);
|
||||
if (currentlyStarred) nextStarred.delete(song.id);
|
||||
else nextStarred.add(song.id);
|
||||
setStarredSongs(nextStarred);
|
||||
|
||||
try {
|
||||
if (currentlyStarred) {
|
||||
await unstar(song.id, 'song');
|
||||
} else {
|
||||
await star(song.id, 'song');
|
||||
}
|
||||
if (currentlyStarred) await unstar(song.id, 'song');
|
||||
else await star(song.id, 'song');
|
||||
} catch (err) {
|
||||
console.error('Failed to toggle song star', err);
|
||||
// Revert
|
||||
const revert = new Set(starredSongs);
|
||||
setStarredSongs(revert);
|
||||
setStarredSongs(new Set(starredSongs));
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <div className="loading-center"><div className="spinner" /></div>;
|
||||
if (!album) return <div className="empty-state">Album nicht gefunden.</div>;
|
||||
if (!album) return <div className="empty-state">{t('albumDetail.notFound')}</div>;
|
||||
|
||||
const { album: info, songs } = album;
|
||||
const coverUrl = info.coverArt ? buildCoverArtUrl(info.coverArt, 400) : '';
|
||||
@@ -238,96 +251,95 @@ export default function AlbumDetail() {
|
||||
/>
|
||||
)}
|
||||
<div className="album-detail-overlay" aria-hidden="true" />
|
||||
|
||||
|
||||
<div style={{ position: 'relative', zIndex: 1 }}>
|
||||
<button className="btn btn-ghost" onClick={() => navigate(-1)} style={{ marginBottom: '1rem', gap: '6px' }}>
|
||||
<ChevronLeft size={16} /> Zurück
|
||||
<ChevronLeft size={16} /> {t('albumDetail.back')}
|
||||
</button>
|
||||
<div className="album-detail-hero">
|
||||
{coverUrl ? (
|
||||
<img className="album-detail-cover" src={coverUrl} alt={`${info.name} Cover`} />
|
||||
) : (
|
||||
<div className="album-detail-cover album-cover-placeholder">♪</div>
|
||||
)}
|
||||
<div className="album-detail-meta">
|
||||
<span className="badge" style={{ marginBottom: '0.5rem' }}>Album</span>
|
||||
<h1 className="album-detail-title">{info.name}</h1>
|
||||
<p className="album-detail-artist">
|
||||
<button
|
||||
className="album-detail-artist-link"
|
||||
data-tooltip={`Zu ${info.artist} wechseln`}
|
||||
onClick={() => navigate(`/artist/${info.artistId}`)}
|
||||
>
|
||||
{info.artist}
|
||||
</button>
|
||||
</p>
|
||||
<div className="album-detail-info">
|
||||
{info.year && <span>{info.year}</span>}
|
||||
{info.genre && <span>· {info.genre}</span>}
|
||||
<span>· {songs.length} Tracks</span>
|
||||
<span>· {formatDuration(totalDuration)}</span>
|
||||
{info.recordLabel && (
|
||||
<>
|
||||
<span style={{ margin: '0 4px' }}>·</span>
|
||||
<button
|
||||
className="album-detail-artist-link"
|
||||
data-tooltip={`Weitere Alben von ${info.recordLabel} anzeigen`}
|
||||
onClick={() => navigate(`/label/${encodeURIComponent(info.recordLabel!)}`)}
|
||||
>
|
||||
{info.recordLabel}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="album-detail-actions">
|
||||
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
||||
<button className="btn btn-primary" id="album-play-all-btn" onClick={handlePlayAll}>
|
||||
<Play size={16} fill="currentColor" /> Alle abspielen
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-surface"
|
||||
onClick={handleEnqueueAll}
|
||||
data-tooltip="Ganzes Album zur Warteschlange hinzufügen"
|
||||
{coverUrl ? (
|
||||
<img className="album-detail-cover" src={coverUrl} alt={`${info.name} Cover`} />
|
||||
) : (
|
||||
<div className="album-detail-cover album-cover-placeholder">♪</div>
|
||||
)}
|
||||
<div className="album-detail-meta">
|
||||
<span className="badge" style={{ marginBottom: '0.5rem' }}>{t('common.album')}</span>
|
||||
<h1 className="album-detail-title">{info.name}</h1>
|
||||
<p className="album-detail-artist">
|
||||
<button
|
||||
className="album-detail-artist-link"
|
||||
data-tooltip={t('albumDetail.goToArtist', { artist: info.artist })}
|
||||
onClick={() => navigate(`/artist/${info.artistId}`)}
|
||||
>
|
||||
<ListPlus size={16} /> Einreihen
|
||||
{info.artist}
|
||||
</button>
|
||||
</p>
|
||||
<div className="album-detail-info">
|
||||
{info.year && <span>{info.year}</span>}
|
||||
{info.genre && <span>· {info.genre}</span>}
|
||||
<span>· {songs.length} Tracks</span>
|
||||
<span>· {formatDuration(totalDuration)}</span>
|
||||
{info.recordLabel && (
|
||||
<>
|
||||
<span style={{ margin: '0 4px' }}>·</span>
|
||||
<button
|
||||
className="album-detail-artist-link"
|
||||
data-tooltip={t('albumDetail.moreLabelAlbums', { label: info.recordLabel })}
|
||||
onClick={() => navigate(`/label/${encodeURIComponent(info.recordLabel!)}`)}
|
||||
>
|
||||
{info.recordLabel}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="album-detail-actions">
|
||||
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
||||
<button className="btn btn-primary" id="album-play-all-btn" onClick={handlePlayAll}>
|
||||
<Play size={16} fill="currentColor" /> {t('albumDetail.playAll')}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-surface"
|
||||
onClick={handleEnqueueAll}
|
||||
data-tooltip={t('albumDetail.enqueueTooltip')}
|
||||
>
|
||||
<ListPlus size={16} /> {t('albumDetail.enqueue')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
id="album-star-btn"
|
||||
onClick={toggleStar}
|
||||
data-tooltip={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
|
||||
style={{ color: isStarred ? 'var(--accent)' : 'inherit', border: isStarred ? '1px solid var(--accent)' : undefined }}
|
||||
>
|
||||
<Star size={16} fill={isStarred ? "currentColor" : "none"} />
|
||||
{t('albumDetail.favorite')}
|
||||
</button>
|
||||
|
||||
<button className="btn btn-ghost" id="album-bio-btn" onClick={handleBio}>
|
||||
<ExternalLink size={16} /> {t('albumDetail.artistBio')}
|
||||
</button>
|
||||
<button className="btn btn-ghost" id="album-download-btn" onClick={() => handleDownload(info.name, info.id)} disabled={downloading}>
|
||||
<Download size={16} /> {downloading ? t('albumDetail.downloading') : t('albumDetail.download')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
id="album-star-btn"
|
||||
onClick={toggleStar}
|
||||
data-tooltip={isStarred ? "Aus Favoriten entfernen" : "Zu Favoriten hinzufügen"}
|
||||
style={{ color: isStarred ? 'var(--accent)' : 'inherit', border: isStarred ? '1px solid var(--accent)' : undefined }}
|
||||
>
|
||||
<Star size={16} fill={isStarred ? "currentColor" : "none"} />
|
||||
{isStarred ? 'Favorit' : 'Als Favorit'}
|
||||
</button>
|
||||
|
||||
<button className="btn btn-ghost" id="album-bio-btn" onClick={handleBio}>
|
||||
<ExternalLink size={16} /> Künstler-Bio
|
||||
</button>
|
||||
<button className="btn btn-ghost" id="album-download-btn" onClick={() => handleDownload(info.name, info.id)} disabled={downloading}>
|
||||
<Download size={16} /> {downloading ? 'Lade…' : 'Download (ZIP)'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="tracklist">
|
||||
<div className="tracklist-header">
|
||||
<div style={{ textAlign: 'center' }}>#</div>
|
||||
<div>Titel</div>
|
||||
<div>Format</div>
|
||||
<div style={{ textAlign: 'center' }}>Favorit</div>
|
||||
<div>Bewertung</div>
|
||||
<div style={{ textAlign: 'right' }}>Dauer</div>
|
||||
<div>{t('albumDetail.trackTitle')}</div>
|
||||
<div>{t('albumDetail.trackFormat')}</div>
|
||||
<div style={{ textAlign: 'center' }}>{t('albumDetail.trackFavorite')}</div>
|
||||
<div>{t('albumDetail.trackRating')}</div>
|
||||
<div style={{ textAlign: 'right' }}>{t('albumDetail.trackDuration')}</div>
|
||||
</div>
|
||||
|
||||
{(() => {
|
||||
// Group songs by disc number
|
||||
const discs = new Map<number, SubsonicSong[]>();
|
||||
songs.forEach(song => {
|
||||
const disc = song.discNumber ?? 1;
|
||||
@@ -368,10 +380,7 @@ export default function AlbumDetail() {
|
||||
albumId: song.albumId, duration: song.duration, coverArt: song.coverArt, track: song.track,
|
||||
year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating,
|
||||
};
|
||||
e.dataTransfer.setData('application/json', JSON.stringify({
|
||||
type: 'song',
|
||||
track
|
||||
}));
|
||||
e.dataTransfer.setData('application/json', JSON.stringify({ type: 'song', track }));
|
||||
}}
|
||||
>
|
||||
<div className="track-num" style={{ textAlign: 'center' }}>{song.track ?? i + 1}</div>
|
||||
@@ -390,10 +399,10 @@ export default function AlbumDetail() {
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'center' }}>
|
||||
<button
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={(e) => toggleSongStar(song, e)}
|
||||
data-tooltip={starredSongs.has(song.id) ? "Aus Favoriten entfernen" : "Zu Favoriten hinzufügen"}
|
||||
data-tooltip={starredSongs.has(song.id) ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
|
||||
style={{ padding: '4px', height: 'auto', minHeight: 'unset', color: starredSongs.has(song.id) ? 'var(--accent)' : 'var(--text-muted)' }}
|
||||
>
|
||||
<Star size={14} fill={starredSongs.has(song.id) ? "currentColor" : "none"} />
|
||||
@@ -415,7 +424,7 @@ export default function AlbumDetail() {
|
||||
|
||||
{relatedAlbums.length > 0 && (
|
||||
<div style={{ padding: '0 var(--space-6) var(--space-8)' }}>
|
||||
<h2 className="section-title" style={{ marginBottom: '1rem' }}>Mehr von {info.artist}</h2>
|
||||
<h2 className="section-title" style={{ marginBottom: '1rem' }}>{t('albumDetail.moreByArtist', { artist: info.artist })}</h2>
|
||||
<div className="album-grid-wrap">
|
||||
{relatedAlbums.map(a => <AlbumCard key={a.id} album={a} />)}
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import AlbumCard from '../components/AlbumCard';
|
||||
import { getAlbumList, SubsonicAlbum } from '../api/subsonic';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
type SortType = 'alphabeticalByName' | 'alphabeticalByArtist' | 'newest' | 'random';
|
||||
|
||||
export default function Albums() {
|
||||
const { t } = useTranslation();
|
||||
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
|
||||
const [sort, setSort] = useState<SortType>('alphabeticalByName');
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -51,16 +53,16 @@ export default function Albums() {
|
||||
}, [loadMore]);
|
||||
|
||||
const sortOptions: { value: SortType; label: string }[] = [
|
||||
{ value: 'alphabeticalByName', label: 'A–Z (Album)' },
|
||||
{ value: 'alphabeticalByArtist', label: 'A–Z (Künstler)' },
|
||||
{ value: 'newest', label: 'Neueste zuerst' },
|
||||
{ value: 'random', label: 'Zufällig' },
|
||||
{ value: 'alphabeticalByName', label: t('albums.sortByName') },
|
||||
{ value: 'alphabeticalByArtist', label: t('albums.sortByArtist') },
|
||||
{ value: 'newest', label: t('albums.sortNewest') },
|
||||
{ value: 'random', label: t('albums.sortRandom') },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="content-body animate-fade-in">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem' }}>
|
||||
<h1 className="page-title">Alle Alben</h1>
|
||||
<h1 className="page-title">{t('albums.title')}</h1>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||
{sortOptions.map(o => (
|
||||
<button
|
||||
@@ -84,7 +86,7 @@ export default function Albums() {
|
||||
<div className="album-grid-wrap">
|
||||
{albums.map(a => <AlbumCard key={a.id} album={a} />)}
|
||||
</div>
|
||||
|
||||
|
||||
<div ref={observerTarget} style={{ height: '20px', margin: '2rem 0', display: 'flex', justifyContent: 'center' }}>
|
||||
{loading && hasMore && <div className="spinner" style={{ width: 20, height: 20 }} />}
|
||||
</div>
|
||||
|
||||
+53
-47
@@ -5,6 +5,7 @@ import AlbumCard from '../components/AlbumCard';
|
||||
import { ArrowLeft, Users, ExternalLink, Star, Play, Shuffle, Radio } from 'lucide-react';
|
||||
import { open } from '@tauri-apps/plugin-shell';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const m = Math.floor(seconds / 60);
|
||||
@@ -12,7 +13,23 @@ function formatDuration(seconds: number): string {
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// Inline Last.fm SVG icon
|
||||
/** Strip dangerous tags/attributes from server-provided HTML */
|
||||
function sanitizeHtml(html: string): string {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, 'text/html');
|
||||
doc.querySelectorAll('script, style, iframe, object, embed, form, input, button, select, base, meta, link').forEach(el => el.remove());
|
||||
doc.querySelectorAll('*').forEach(el => {
|
||||
Array.from(el.attributes).forEach(attr => {
|
||||
const name = attr.name.toLowerCase();
|
||||
const val = attr.value.toLowerCase().trim();
|
||||
if (name.startsWith('on') || (name === 'href' && (val.startsWith('javascript:') || val.startsWith('data:'))) || (name === 'src' && (val.startsWith('javascript:') || val.startsWith('data:')))) {
|
||||
el.removeAttribute(attr.name);
|
||||
}
|
||||
});
|
||||
});
|
||||
return doc.body.innerHTML;
|
||||
}
|
||||
|
||||
function LastfmIcon({ size = 16 }: { size?: number }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
@@ -22,6 +39,7 @@ function LastfmIcon({ size = 16 }: { size?: number }) {
|
||||
}
|
||||
|
||||
export default function ArtistDetail() {
|
||||
const { t } = useTranslation();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [artist, setArtist] = useState<SubsonicArtist | null>(null);
|
||||
@@ -31,7 +49,7 @@ export default function ArtistDetail() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [radioLoading, setRadioLoading] = useState(false);
|
||||
const [isStarred, setIsStarred] = useState(false);
|
||||
|
||||
|
||||
const playTrack = usePlayerStore(state => state.playTrack);
|
||||
const enqueue = usePlayerStore(state => state.enqueue);
|
||||
const clearQueue = usePlayerStore(state => state.clearQueue);
|
||||
@@ -44,7 +62,6 @@ export default function ArtistDetail() {
|
||||
setArtist(artistData.artist);
|
||||
setAlbums(artistData.albums);
|
||||
setIsStarred(!!artistData.artist.starred);
|
||||
|
||||
return Promise.all([
|
||||
getArtistInfo(id).catch(() => null),
|
||||
getTopSongs(artistData.artist.name).catch(() => [])
|
||||
@@ -64,16 +81,13 @@ export default function ArtistDetail() {
|
||||
const toggleStar = async () => {
|
||||
if (!artist) return;
|
||||
const currentlyStarred = isStarred;
|
||||
setIsStarred(!currentlyStarred); // Optimistic UI update
|
||||
setIsStarred(!currentlyStarred);
|
||||
try {
|
||||
if (currentlyStarred) {
|
||||
await unstar(artist.id, 'artist');
|
||||
} else {
|
||||
await star(artist.id, 'artist');
|
||||
}
|
||||
if (currentlyStarred) await unstar(artist.id, 'artist');
|
||||
else await star(artist.id, 'artist');
|
||||
} catch (e) {
|
||||
console.error('Failed to toggle star', e);
|
||||
setIsStarred(currentlyStarred); // Revert on failure
|
||||
setIsStarred(currentlyStarred);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -101,10 +115,10 @@ export default function ArtistDetail() {
|
||||
clearQueue();
|
||||
playTrack(similar[0], similar);
|
||||
} else {
|
||||
alert("Keine ähnlichen Titel für diesen Künstler gefunden.");
|
||||
alert(t('artistDetail.noRadio'));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Radio start failed", e);
|
||||
console.error('Radio start failed', e);
|
||||
} finally {
|
||||
setRadioLoading(false);
|
||||
}
|
||||
@@ -122,7 +136,7 @@ export default function ArtistDetail() {
|
||||
return (
|
||||
<div className="content-body">
|
||||
<div style={{ textAlign: 'center', padding: '4rem', color: 'var(--text-muted)' }}>
|
||||
Künstler nicht gefunden.
|
||||
{t('artistDetail.notFound')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -138,10 +152,9 @@ export default function ArtistDetail() {
|
||||
onClick={() => navigate(-1)}
|
||||
style={{ marginBottom: '1.5rem', display: 'flex', alignItems: 'center', gap: '0.5rem' }}
|
||||
>
|
||||
<ArrowLeft size={16} /> <span>Zurück</span>
|
||||
<ArrowLeft size={16} /> <span>{t('artistDetail.back')}</span>
|
||||
</button>
|
||||
|
||||
{/* Header: avatar + name + meta + links */}
|
||||
<div className="artist-detail-header">
|
||||
<div className="artist-detail-avatar">
|
||||
{coverId ? (
|
||||
@@ -161,11 +174,10 @@ export default function ArtistDetail() {
|
||||
{artist.name}
|
||||
</h1>
|
||||
<div style={{ color: 'var(--text-secondary)', fontSize: '1rem', marginBottom: '1rem' }}>
|
||||
{artist.albumCount} {artist.albumCount === 1 ? 'Album' : 'Alben'}
|
||||
{t('artistDetail.albumCount_other', { count: artist.albumCount ?? 0 })}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
||||
{/* External links */}
|
||||
{(info?.lastFmUrl || artist.name) && (
|
||||
<div className="artist-detail-links">
|
||||
{info?.lastFmUrl && (
|
||||
@@ -180,44 +192,43 @@ export default function ArtistDetail() {
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Star toggle */}
|
||||
<button
|
||||
className="artist-ext-link"
|
||||
|
||||
<button
|
||||
className="artist-ext-link"
|
||||
onClick={toggleStar}
|
||||
data-tooltip={isStarred ? "Aus Favoriten entfernen" : "Zu Favoriten hinzufügen"}
|
||||
data-tooltip={isStarred ? t('artistDetail.favoriteRemove') : t('artistDetail.favoriteAdd')}
|
||||
style={{ color: isStarred ? 'var(--accent)' : 'inherit', border: isStarred ? '1px solid var(--accent)' : undefined }}
|
||||
>
|
||||
<Star size={14} fill={isStarred ? "currentColor" : "none"} />
|
||||
{isStarred ? 'Favorit' : 'Als Favorit'}
|
||||
<Star size={14} fill={isStarred ? "currentColor" : "none"} />
|
||||
{t('artistDetail.favorite')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
<div style={{ display: 'flex', gap: '8px', marginTop: '1.5rem', flexWrap: 'wrap' }}>
|
||||
{topSongs.length > 0 && (
|
||||
<>
|
||||
<button className="btn btn-primary" onClick={handlePlayAll}>
|
||||
<Play size={16} /> Alle abspielen
|
||||
<Play size={16} /> {t('artistDetail.playAll')}
|
||||
</button>
|
||||
<button className="btn btn-surface" onClick={handleShuffle}>
|
||||
<Shuffle size={16} /> Zufallswiedergabe
|
||||
<Shuffle size={16} /> {t('artistDetail.shuffle')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button className="btn btn-surface" onClick={handleStartRadio} disabled={radioLoading}>
|
||||
{radioLoading ? <div className="spinner" style={{ width: 16, height: 16, borderTopColor: 'currentColor' }} /> : <Radio size={16} />}
|
||||
{radioLoading ? 'Lädt...' : 'Radio'}
|
||||
{radioLoading ? t('artistDetail.loading') : t('artistDetail.radio')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Biography — only shown when available */}
|
||||
{/* Biography — sanitized HTML from server */}
|
||||
{info?.biography && (
|
||||
<div className="artist-bio-section">
|
||||
<div
|
||||
className="artist-bio-text"
|
||||
dangerouslySetInnerHTML={{ __html: info.biography }}
|
||||
dangerouslySetInnerHTML={{ __html: sanitizeHtml(info.biography) }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -226,14 +237,14 @@ export default function ArtistDetail() {
|
||||
{topSongs.length > 0 && (
|
||||
<>
|
||||
<h2 className="section-title" style={{ marginTop: info?.biography ? '2rem' : '0', marginBottom: '1rem' }}>
|
||||
Beliebteste Titel
|
||||
{t('artistDetail.topTracks')}
|
||||
</h2>
|
||||
<div className="tracklist" style={{ padding: 0, marginBottom: '2rem' }}>
|
||||
<div className="tracklist-header" style={{ gridTemplateColumns: '36px minmax(150px, 2fr) minmax(100px, 1fr) 60px' }}>
|
||||
<div style={{ textAlign: 'center' }}>#</div>
|
||||
<div>Titel</div>
|
||||
<div>Album</div>
|
||||
<div style={{ textAlign: 'right' }}>Dauer</div>
|
||||
<div>{t('artistDetail.trackTitle')}</div>
|
||||
<div>{t('artistDetail.trackAlbum')}</div>
|
||||
<div style={{ textAlign: 'right' }}>{t('artistDetail.trackDuration')}</div>
|
||||
</div>
|
||||
{topSongs.map((song, idx) => (
|
||||
<div
|
||||
@@ -251,28 +262,23 @@ export default function ArtistDetail() {
|
||||
openContextMenu(e.clientX, e.clientY, track, 'song');
|
||||
}}
|
||||
>
|
||||
<div className="track-num" style={{ textAlign: 'center' }}>
|
||||
{idx + 1}
|
||||
</div>
|
||||
|
||||
<div className="track-num" style={{ textAlign: 'center' }}>{idx + 1}</div>
|
||||
<div className="track-info" style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
||||
{song.coverArt && (
|
||||
<img
|
||||
src={buildCoverArtUrl(song.coverArt, 64)}
|
||||
alt={song.album}
|
||||
style={{ width: '32px', height: '32px', borderRadius: '4px', objectFit: 'cover', flexShrink: 0 }}
|
||||
onError={(e) => { e.currentTarget.style.display = 'none'; }}
|
||||
<img
|
||||
src={buildCoverArtUrl(song.coverArt, 64)}
|
||||
alt={song.album}
|
||||
style={{ width: '32px', height: '32px', borderRadius: '4px', objectFit: 'cover', flexShrink: 0 }}
|
||||
onError={(e) => { e.currentTarget.style.display = 'none'; }}
|
||||
/>
|
||||
)}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', minWidth: 0 }}>
|
||||
<div className="track-title">{song.title}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="track-album truncate" style={{ color: 'var(--text-secondary)', fontSize: '12px' }}>
|
||||
{song.album}
|
||||
</div>
|
||||
|
||||
<div className="track-duration" style={{ textAlign: 'right' }}>
|
||||
{formatDuration(song.duration)}
|
||||
</div>
|
||||
@@ -284,7 +290,7 @@ export default function ArtistDetail() {
|
||||
|
||||
{/* Albums */}
|
||||
<h2 className="section-title" style={{ marginTop: (info?.biography || topSongs.length > 0) ? '2rem' : '0', marginBottom: '1rem' }}>
|
||||
Alben von {artist.name}
|
||||
{t('artistDetail.albumsBy', { name: artist.name })}
|
||||
</h2>
|
||||
|
||||
{albums.length > 0 ? (
|
||||
@@ -292,7 +298,7 @@ export default function ArtistDetail() {
|
||||
{albums.map(a => <AlbumCard key={a.id} album={a} />)}
|
||||
</div>
|
||||
) : (
|
||||
<p style={{ color: 'var(--text-muted)' }}>Keine Alben gefunden.</p>
|
||||
<p style={{ color: 'var(--text-muted)' }}>{t('artistDetail.noAlbums')}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
+27
-24
@@ -3,16 +3,19 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { getArtists, SubsonicArtist, buildCoverArtUrl } from '../api/subsonic';
|
||||
import { Users, LayoutGrid, List } from 'lucide-react';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const ALPHABET = ['Alle', '#', ...'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('')];
|
||||
const ALL_SENTINEL = 'ALL';
|
||||
const ALPHABET = [ALL_SENTINEL, '#', ...'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('')];
|
||||
|
||||
export default function Artists() {
|
||||
const { t } = useTranslation();
|
||||
const [artists, setArtists] = useState<SubsonicArtist[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filter, setFilter] = useState('');
|
||||
const [letterFilter, setLetterFilter] = useState('Alle');
|
||||
const [letterFilter, setLetterFilter] = useState(ALL_SENTINEL);
|
||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
|
||||
|
||||
|
||||
const [visibleCount, setVisibleCount] = useState(50);
|
||||
const navigate = useNavigate();
|
||||
const openContextMenu = usePlayerStore(state => state.openContextMenu);
|
||||
@@ -32,8 +35,8 @@ export default function Artists() {
|
||||
|
||||
// Filter pipeline
|
||||
let filtered = artists;
|
||||
|
||||
if (letterFilter !== 'Alle') {
|
||||
|
||||
if (letterFilter !== ALL_SENTINEL) {
|
||||
filtered = filtered.filter(a => {
|
||||
const first = a.name[0]?.toUpperCase() ?? '#';
|
||||
const isAlpha = /^[A-Z]$/.test(first);
|
||||
@@ -63,31 +66,31 @@ export default function Artists() {
|
||||
<div className="content-body animate-fade-in">
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '1.5rem', flexWrap: 'wrap', gap: '1rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
||||
<h1 className="page-title" style={{ marginBottom: 0 }}>Künstler</h1>
|
||||
<h1 className="page-title" style={{ marginBottom: 0 }}>{t('artists.title')}</h1>
|
||||
<input
|
||||
className="input"
|
||||
style={{ maxWidth: 220 }}
|
||||
placeholder="Suchen…"
|
||||
placeholder={t('artists.search')}
|
||||
value={filter}
|
||||
onChange={e => setFilter(e.target.value)}
|
||||
id="artist-filter-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<button
|
||||
<button
|
||||
className={`btn btn-surface ${viewMode === 'grid' ? 'btn-sort-active' : ''}`}
|
||||
onClick={() => setViewMode('grid')}
|
||||
style={viewMode === 'grid' ? { background: 'var(--accent)', color: 'var(--ctp-crust)', padding: '0.5rem' } : { padding: '0.5rem' }}
|
||||
data-tooltip="Grid ansicht"
|
||||
data-tooltip={t('artists.gridView')}
|
||||
>
|
||||
<LayoutGrid size={20} />
|
||||
</button>
|
||||
<button
|
||||
<button
|
||||
className={`btn btn-surface ${viewMode === 'list' ? 'btn-sort-active' : ''}`}
|
||||
onClick={() => setViewMode('list')}
|
||||
style={viewMode === 'list' ? { background: 'var(--accent)', color: 'var(--ctp-crust)', padding: '0.5rem' } : { padding: '0.5rem' }}
|
||||
data-tooltip="Listenansicht"
|
||||
data-tooltip={t('artists.listView')}
|
||||
>
|
||||
<List size={20} />
|
||||
</button>
|
||||
@@ -111,7 +114,7 @@ export default function Artists() {
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
>
|
||||
{l}
|
||||
{l === ALL_SENTINEL ? t('artists.all') : l}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -123,8 +126,8 @@ export default function Artists() {
|
||||
{visible.map(artist => {
|
||||
const coverId = artist.coverArt || artist.id;
|
||||
return (
|
||||
<div
|
||||
key={artist.id}
|
||||
<div
|
||||
key={artist.id}
|
||||
className="artist-card"
|
||||
onClick={() => navigate(`/artist/${artist.id}`)}
|
||||
onContextMenu={(e) => {
|
||||
@@ -134,8 +137,8 @@ export default function Artists() {
|
||||
>
|
||||
<div className="artist-card-avatar" style={{ position: 'relative', overflow: 'hidden' }}>
|
||||
{coverId ? (
|
||||
<img
|
||||
src={buildCoverArtUrl(coverId, 200)}
|
||||
<img
|
||||
src={buildCoverArtUrl(coverId, 200)}
|
||||
alt=""
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
onError={(e) => {
|
||||
@@ -151,7 +154,7 @@ export default function Artists() {
|
||||
<div>
|
||||
<div className="artist-card-name">{artist.name}</div>
|
||||
{artist.albumCount != null && (
|
||||
<div className="artist-card-meta">{artist.albumCount} Alben</div>
|
||||
<div className="artist-card-meta">{t('artists.albumCount', { count: artist.albumCount })}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -181,8 +184,8 @@ export default function Artists() {
|
||||
>
|
||||
<div className="artist-avatar" style={{ position: 'relative', overflow: 'hidden' }}>
|
||||
{coverId ? (
|
||||
<img
|
||||
src={buildCoverArtUrl(coverId, 100)}
|
||||
<img
|
||||
src={buildCoverArtUrl(coverId, 100)}
|
||||
alt=""
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
onError={(e) => {
|
||||
@@ -198,7 +201,7 @@ export default function Artists() {
|
||||
<div style={{ textAlign: 'left' }}>
|
||||
<div className="artist-name">{artist.name}</div>
|
||||
{artist.albumCount != null && (
|
||||
<div className="artist-meta">{artist.albumCount} Alben</div>
|
||||
<div className="artist-meta">{t('artists.albumCount', { count: artist.albumCount })}</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
@@ -213,14 +216,14 @@ export default function Artists() {
|
||||
{!loading && hasMore && (
|
||||
<div style={{ margin: '2rem 0', display: 'flex', justifyContent: 'center' }}>
|
||||
<button className="btn btn-ghost" onClick={loadMore}>
|
||||
Mehr laden
|
||||
{t('artists.loadMore')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{!loading && filtered.length === 0 && (
|
||||
<div style={{ textAlign: 'center', padding: '3rem', color: 'var(--text-muted)' }}>
|
||||
Keine Künstler gefunden.
|
||||
{t('artists.notFound')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+10
-12
@@ -4,8 +4,10 @@ import ArtistRow from '../components/ArtistRow';
|
||||
import { getStarred, SubsonicAlbum, SubsonicArtist, SubsonicSong } from '../api/subsonic';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { Play } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export default function Favorites() {
|
||||
const { t } = useTranslation();
|
||||
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
|
||||
const [artists, setArtists] = useState<SubsonicArtist[]>([]);
|
||||
const [songs, setSongs] = useState<SubsonicSong[]>([]);
|
||||
@@ -37,28 +39,27 @@ export default function Favorites() {
|
||||
return (
|
||||
<div className="content-body animate-fade-in" style={{ display: 'flex', flexDirection: 'column', gap: '3rem' }}>
|
||||
<div style={{ marginBottom: '-1.5rem' }}>
|
||||
<h1 className="page-title">Favoriten</h1>
|
||||
<h1 className="page-title">{t('favorites.title')}</h1>
|
||||
</div>
|
||||
|
||||
{!hasAnyFavorites ? (
|
||||
<div className="empty-state">Du hast noch keine Favoriten gespeichert.</div>
|
||||
<div className="empty-state">{t('favorites.empty')}</div>
|
||||
) : (
|
||||
<>
|
||||
{artists.length > 0 && (
|
||||
<ArtistRow title="Künstler" artists={artists} />
|
||||
<ArtistRow title={t('favorites.artists')} artists={artists} />
|
||||
)}
|
||||
|
||||
{albums.length > 0 && (
|
||||
<AlbumRow title="Alben" albums={albums} />
|
||||
<AlbumRow title={t('favorites.albums')} albums={albums} />
|
||||
)}
|
||||
|
||||
{songs.length > 0 && (
|
||||
<section className="album-row-section">
|
||||
<div className="album-row-header" style={{ marginBottom: '1rem' }}>
|
||||
<h2 className="section-title" style={{ marginBottom: 0 }}>Songs</h2>
|
||||
<h2 className="section-title" style={{ marginBottom: 0 }}>{t('favorites.songs')}</h2>
|
||||
</div>
|
||||
<div className="tracklist" style={{ padding: 0 }}>
|
||||
{/* Wir können für die Favoriten-Seite ruhig alle Songs anzeigen, statt nur 10 wie auf der Startseite */}
|
||||
{songs.map((song) => (
|
||||
<div
|
||||
key={song.id}
|
||||
@@ -74,14 +75,11 @@ export default function Favorites() {
|
||||
albumId: song.albumId, duration: song.duration, coverArt: song.coverArt, track: song.track,
|
||||
year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating,
|
||||
};
|
||||
e.dataTransfer.setData('application/json', JSON.stringify({
|
||||
type: 'song',
|
||||
track
|
||||
}));
|
||||
e.dataTransfer.setData('application/json', JSON.stringify({ type: 'song', track }));
|
||||
}}
|
||||
>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ padding: 4 }}
|
||||
onClick={(e) => { e.stopPropagation(); playTrack(song, songs); }}
|
||||
>
|
||||
|
||||
+11
-8
@@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react';
|
||||
import Hero from '../components/Hero';
|
||||
import AlbumRow from '../components/AlbumRow';
|
||||
import { getAlbumList, SubsonicAlbum } from '../api/subsonic';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export default function Home() {
|
||||
const [starred, setStarred] = useState<SubsonicAlbum[]>([]);
|
||||
@@ -42,6 +43,8 @@ export default function Home() {
|
||||
}
|
||||
};
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in">
|
||||
<Hero />
|
||||
@@ -55,29 +58,29 @@ export default function Home() {
|
||||
<>
|
||||
{starred.length > 0 && (
|
||||
<AlbumRow
|
||||
title="Persönliche Favoriten"
|
||||
title={t('home.starred')}
|
||||
albums={starred}
|
||||
onLoadMore={() => loadMore('starred', starred, setStarred)}
|
||||
moreText="Mehr laden"
|
||||
moreText={t('home.loadMore')}
|
||||
/>
|
||||
)}
|
||||
<AlbumRow
|
||||
title="Zuletzt hinzugefügt"
|
||||
title={t('home.recent')}
|
||||
albums={recent}
|
||||
onLoadMore={() => loadMore('newest', recent, setRecent)}
|
||||
moreText="Mehr laden"
|
||||
moreText={t('home.loadMore')}
|
||||
/>
|
||||
<AlbumRow
|
||||
title="Meistgehört"
|
||||
title={t('home.mostPlayed')}
|
||||
albums={mostPlayed}
|
||||
onLoadMore={() => loadMore('frequent', mostPlayed, setMostPlayed)}
|
||||
moreText="Mehr laden"
|
||||
moreText={t('home.loadMore')}
|
||||
/>
|
||||
<AlbumRow
|
||||
title="Entdecken"
|
||||
title={t('home.discover')}
|
||||
albums={random}
|
||||
onLoadMore={() => loadMore('random', random, setRandom)}
|
||||
moreText="Mehr entdecken"
|
||||
moreText={t('home.discoverMore')}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -3,8 +3,10 @@ import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
import AlbumCard from '../components/AlbumCard';
|
||||
import { search, SubsonicAlbum } from '../api/subsonic';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export default function LabelAlbums() {
|
||||
const { t } = useTranslation();
|
||||
const { name } = useParams<{ name: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
|
||||
@@ -13,17 +15,17 @@ export default function LabelAlbums() {
|
||||
useEffect(() => {
|
||||
if (!name) return;
|
||||
setLoading(true);
|
||||
|
||||
|
||||
// Search for the label name and ask for a large number of albums
|
||||
search(name, { albumCount: 200, artistCount: 0, songCount: 0 })
|
||||
.then(res => {
|
||||
// Filter out albums that don't match the record label exactly if possible,
|
||||
// Filter out albums that don't match the record label exactly if possible,
|
||||
// to avoid unrelated search hits. We do case-insensitive comparison.
|
||||
const matches = res.albums.filter(a =>
|
||||
const matches = res.albums.filter(a =>
|
||||
a.recordLabel?.toLowerCase() === name.toLowerCase()
|
||||
);
|
||||
// Fallback: if Navidrome's search doesn't return the exact label in the recordLabel field
|
||||
// (or it's not indexed exactly as typed), just show all album matches
|
||||
// Fallback: if Navidrome's search doesn't return the exact label in the recordLabel field
|
||||
// (or it's not indexed exactly as typed), just show all album matches
|
||||
// as a decent best-effort if our strict filter yields nothing.
|
||||
setAlbums(matches.length > 0 ? matches : res.albums);
|
||||
})
|
||||
@@ -34,7 +36,7 @@ export default function LabelAlbums() {
|
||||
return (
|
||||
<div className="animate-fade-in" style={{ padding: '0 var(--space-6)' }}>
|
||||
<button className="btn btn-ghost" onClick={() => navigate(-1)} style={{ margin: '1rem 0', gap: '6px' }}>
|
||||
<ChevronLeft size={16} /> Zurück
|
||||
<ChevronLeft size={16} /> {t('common.back')}
|
||||
</button>
|
||||
|
||||
<h1 className="page-title" style={{ marginBottom: '2rem' }}>
|
||||
@@ -46,7 +48,7 @@ export default function LabelAlbums() {
|
||||
<div className="spinner" />
|
||||
</div>
|
||||
) : albums.length === 0 ? (
|
||||
<div className="empty-state">Keine Alben für dieses Label gefunden.</div>
|
||||
<div className="empty-state">{t('common.noAlbums')}</div>
|
||||
) : (
|
||||
<div className="album-grid-wrap">
|
||||
{albums.map(a => (
|
||||
|
||||
+92
-71
@@ -1,38 +1,20 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Wifi, WifiOff, Eye, EyeOff } from 'lucide-react';
|
||||
import { Wifi, WifiOff, Eye, EyeOff, Server } from 'lucide-react';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { ping } from '../api/subsonic';
|
||||
import { pingWithCredentials } from '../api/subsonic';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const PsysonicLogo = () => (
|
||||
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="64" height="64" rx="18" fill="url(#grad-login)" />
|
||||
<text x="8" y="47" fontFamily="Inter, sans-serif" fontWeight="800" fontSize="42" fill="white">P</text>
|
||||
<line x1="40" y1="18" x2="58" y2="18" stroke="white" strokeWidth="3.5" strokeLinecap="round" opacity="0.9"/>
|
||||
<line x1="37" y1="26" x2="58" y2="26" stroke="white" strokeWidth="3.5" strokeLinecap="round" opacity="0.75"/>
|
||||
<line x1="40" y1="34" x2="58" y2="34" stroke="white" strokeWidth="3.5" strokeLinecap="round" opacity="0.9"/>
|
||||
<line x1="37" y1="42" x2="58" y2="42" stroke="white" strokeWidth="3.5" strokeLinecap="round" opacity="0.6"/>
|
||||
<line x1="42" y1="50" x2="58" y2="50" stroke="white" strokeWidth="3.5" strokeLinecap="round" opacity="0.4"/>
|
||||
<defs>
|
||||
<linearGradient id="grad-login" x1="0" y1="0" x2="64" y2="64" gradientUnits="userSpaceOnUse">
|
||||
<stop stopColor="#cba6f7"/>
|
||||
<stop offset="1" stopColor="#89b4fa"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
<img src="/logo.png" width="64" height="64" alt="Psysonic" style={{ borderRadius: 18 }} />
|
||||
);
|
||||
|
||||
export default function Login() {
|
||||
const navigate = useNavigate();
|
||||
const { setCredentials, setLoggedIn, setConnecting, setConnectionError, connectionError } = useAuthStore();
|
||||
const { t } = useTranslation();
|
||||
const { addServer, updateServer, setActiveServer, setLoggedIn, setConnecting, setConnectionError, servers } = useAuthStore();
|
||||
|
||||
const [form, setForm] = useState({
|
||||
serverName: '',
|
||||
lanIp: '',
|
||||
externalUrl: '',
|
||||
username: '',
|
||||
password: '',
|
||||
});
|
||||
const [form, setForm] = useState({ serverName: '', url: '', username: '', password: '' });
|
||||
const [showPass, setShowPass] = useState(false);
|
||||
const [status, setStatus] = useState<'idle' | 'testing' | 'ok' | 'error'>('idle');
|
||||
const [testMessage, setTestMessage] = useState('');
|
||||
@@ -40,38 +22,69 @@ export default function Login() {
|
||||
const update = (k: keyof typeof form) => (e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setForm(f => ({ ...f, [k]: e.target.value }));
|
||||
|
||||
const handleConnect = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!form.lanIp && !form.externalUrl) {
|
||||
setTestMessage('Bitte LAN-IP oder externe URL eingeben.');
|
||||
const attemptConnect = async (profile: { name: string; url: string; username: string; password: string }) => {
|
||||
if (!profile.url.trim()) {
|
||||
setTestMessage(t('login.urlRequired'));
|
||||
setStatus('error');
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus('testing');
|
||||
setTestMessage('Verbinde…');
|
||||
setTestMessage(t('login.connecting'));
|
||||
setConnecting(true);
|
||||
setCredentials(form);
|
||||
setConnectionError(null);
|
||||
|
||||
// Small delay to let store update
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
// Test connection directly with entered credentials — don't touch the store yet.
|
||||
// This avoids any race condition with Zustand's async store rehydration.
|
||||
let ok = false;
|
||||
try {
|
||||
ok = await pingWithCredentials(profile.url.trim(), profile.username.trim(), profile.password);
|
||||
} catch {
|
||||
ok = false;
|
||||
}
|
||||
|
||||
const ok = await ping();
|
||||
setConnecting(false);
|
||||
|
||||
if (ok) {
|
||||
// Connection succeeded — now persist to store
|
||||
const existing = servers.find(s => s.url === profile.url.trim() && s.username === profile.username.trim());
|
||||
let serverId: string;
|
||||
if (existing) {
|
||||
updateServer(existing.id, {
|
||||
name: profile.name.trim() || profile.url.trim(),
|
||||
password: profile.password,
|
||||
});
|
||||
serverId = existing.id;
|
||||
} else {
|
||||
serverId = addServer({
|
||||
name: profile.name.trim() || profile.url.trim(),
|
||||
url: profile.url.trim(),
|
||||
username: profile.username.trim(),
|
||||
password: profile.password,
|
||||
});
|
||||
}
|
||||
setActiveServer(serverId);
|
||||
setLoggedIn(true);
|
||||
setStatus('ok');
|
||||
setTestMessage('Verbunden!');
|
||||
setTestMessage(t('login.connected'));
|
||||
setTimeout(() => navigate('/'), 600);
|
||||
} else {
|
||||
setStatus('error');
|
||||
setConnectionError('Verbindung fehlgeschlagen – bitte Daten prüfen.');
|
||||
setTestMessage('Verbindung fehlgeschlagen.');
|
||||
setConnectionError(t('login.error'));
|
||||
setTestMessage(t('login.error'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleFormSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
await attemptConnect({ name: form.serverName, url: form.url, username: form.username, password: form.password });
|
||||
};
|
||||
|
||||
const handleQuickConnect = async (srv: typeof servers[0]) => {
|
||||
setForm({ serverName: srv.name, url: srv.url, username: srv.username, password: srv.password });
|
||||
await attemptConnect({ name: srv.name, url: srv.url, username: srv.username, password: srv.password });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="login-page">
|
||||
<div className="login-bg" aria-hidden="true" />
|
||||
@@ -80,64 +93,72 @@ export default function Login() {
|
||||
<PsysonicLogo />
|
||||
</div>
|
||||
<h1 className="login-title">Psysonic</h1>
|
||||
<p className="login-subtitle">Dein Navidrome Desktop Player</p>
|
||||
<p className="login-subtitle">{t('login.subtitle')}</p>
|
||||
|
||||
<form className="login-form" onSubmit={handleConnect} noValidate>
|
||||
{/* Saved servers quick-connect */}
|
||||
{servers.length > 0 && (
|
||||
<div className="login-saved-servers">
|
||||
<div className="login-saved-label">{t('login.savedServers')}</div>
|
||||
{servers.map(srv => (
|
||||
<button
|
||||
key={srv.id}
|
||||
className="btn btn-surface login-server-btn"
|
||||
onClick={() => handleQuickConnect(srv)}
|
||||
disabled={status === 'testing'}
|
||||
>
|
||||
<Server size={14} style={{ flexShrink: 0 }} />
|
||||
<div style={{ textAlign: 'left', minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 600 }} className="truncate">{srv.name || srv.url}</div>
|
||||
<div style={{ fontSize: 11, opacity: 0.7 }} className="truncate">{srv.username}@{srv.url}</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
<div className="login-divider"><span>{t('login.addNew')}</span></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form className="login-form" onSubmit={handleFormSubmit} noValidate>
|
||||
<div className="form-group">
|
||||
<label htmlFor="login-server-name">Server-Name (optional)</label>
|
||||
<label htmlFor="login-server-name">{t('login.serverName')}</label>
|
||||
<input
|
||||
id="login-server-name"
|
||||
className="input"
|
||||
type="text"
|
||||
placeholder="Mein Navidrome"
|
||||
placeholder={t('login.serverNamePlaceholder')}
|
||||
value={form.serverName}
|
||||
onChange={update('serverName')}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label htmlFor="login-lan-ip">LAN-IP / URL</label>
|
||||
<input
|
||||
id="login-lan-ip"
|
||||
className="input"
|
||||
type="text"
|
||||
placeholder="192.168.1.100:4533"
|
||||
value={form.lanIp}
|
||||
onChange={update('lanIp')}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="login-external-url">Externe URL (FQDN)</label>
|
||||
<input
|
||||
id="login-external-url"
|
||||
className="input"
|
||||
type="text"
|
||||
placeholder="music.example.com"
|
||||
value={form.externalUrl}
|
||||
onChange={update('externalUrl')}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="login-url">{t('login.serverUrl')}</label>
|
||||
<input
|
||||
id="login-url"
|
||||
className="input"
|
||||
type="text"
|
||||
placeholder={t('login.serverUrlPlaceholder')}
|
||||
value={form.url}
|
||||
onChange={update('url')}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label htmlFor="login-username">Benutzername</label>
|
||||
<label htmlFor="login-username">{t('login.username')}</label>
|
||||
<input
|
||||
id="login-username"
|
||||
className="input"
|
||||
type="text"
|
||||
placeholder="admin"
|
||||
placeholder={t('login.usernamePlaceholder')}
|
||||
value={form.username}
|
||||
onChange={update('username')}
|
||||
autoComplete="username"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="login-password">Passwort</label>
|
||||
<label htmlFor="login-password">{t('login.password')}</label>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<input
|
||||
id="login-password"
|
||||
@@ -153,7 +174,7 @@ export default function Login() {
|
||||
type="button"
|
||||
style={{ position: 'absolute', right: '10px', top: '50%', transform: 'translateY(-50%)', color: 'var(--text-muted)' }}
|
||||
onClick={() => setShowPass(v => !v)}
|
||||
aria-label={showPass ? 'Passwort verstecken' : 'Passwort anzeigen'}
|
||||
aria-label={showPass ? t('login.hidePassword') : t('login.showPassword')}
|
||||
>
|
||||
{showPass ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
@@ -177,7 +198,7 @@ export default function Login() {
|
||||
id="login-connect-btn"
|
||||
disabled={status === 'testing'}
|
||||
>
|
||||
{status === 'testing' ? 'Verbinde…' : 'Verbinden'}
|
||||
{status === 'testing' ? t('login.connecting') : t('login.connect')}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
+17
-16
@@ -2,11 +2,13 @@ import React, { useEffect, useState } from 'react';
|
||||
import { SubsonicPlaylist, getPlaylists, getPlaylist, deletePlaylist } from '../api/subsonic';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { ListMusic, Play, Trash2 } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export default function Playlists() {
|
||||
const { t } = useTranslation();
|
||||
const [playlists, setPlaylists] = useState<SubsonicPlaylist[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
|
||||
const playTrack = usePlayerStore(s => s.playTrack);
|
||||
const clearQueue = usePlayerStore(s => s.clearQueue);
|
||||
|
||||
@@ -45,7 +47,7 @@ export default function Playlists() {
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string, name: string) => {
|
||||
if (confirm(`Playlist "${name}" wirklich löschen?`)) {
|
||||
if (confirm(t('playlists.confirmDelete', { name }))) {
|
||||
try {
|
||||
await deletePlaylist(id);
|
||||
fetchPlaylists();
|
||||
@@ -59,21 +61,20 @@ export default function Playlists() {
|
||||
<div className="content-body animate-fade-in">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '2rem' }}>
|
||||
<ListMusic size={32} style={{ color: 'var(--accent)' }} />
|
||||
<h1 className="page-title" style={{ margin: 0 }}>Playlists</h1>
|
||||
<h1 className="page-title" style={{ margin: 0 }}>{t('playlists.title')}</h1>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="empty-state">Lade Playlists...</div>
|
||||
<div className="empty-state">{t('playlists.loading')}</div>
|
||||
) : playlists.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
Keine Playlists gefunden.<br/>
|
||||
Nutze die Warteschlange, um Playlists zu erstellen.
|
||||
<div className="empty-state" style={{ whiteSpace: 'pre-line' }}>
|
||||
{t('playlists.empty')}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: '1rem' }}>
|
||||
{playlists.map(p => (
|
||||
<div
|
||||
key={p.id}
|
||||
<div
|
||||
key={p.id}
|
||||
style={{
|
||||
background: 'var(--surface0)',
|
||||
borderRadius: '12px',
|
||||
@@ -91,22 +92,22 @@ export default function Playlists() {
|
||||
{p.name}
|
||||
</h3>
|
||||
<p style={{ margin: 0, fontSize: '0.9rem', color: 'var(--subtext0)' }}>
|
||||
{p.songCount} {p.songCount === 1 ? 'Track' : 'Tracks'} • {Math.floor(p.duration / 60)} Min.
|
||||
{t('playlists.track', { count: p.songCount })} • {Math.floor(p.duration / 60)} {t('playlists.minutes')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '0.5rem', marginTop: 'auto' }}>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => handlePlay(p.id)}
|
||||
style={{ flex: 1, display: 'flex', justifyContent: 'center', alignItems: 'center', gap: '0.5rem' }}
|
||||
>
|
||||
<Play size={16} fill="currentColor" /> Abspielen
|
||||
<Play size={16} fill="currentColor" /> {t('playlists.play')}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={() => handleDelete(p.id, p.name)}
|
||||
data-tooltip="Löschen"
|
||||
data-tooltip={t('playlists.deleteTooltip')}
|
||||
style={{ width: '42px', display: 'flex', justifyContent: 'center', alignItems: 'center', color: 'var(--red)' }}
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
|
||||
+21
-31
@@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react';
|
||||
import { getRandomSongs, SubsonicSong, star, unstar } from '../api/subsonic';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { Play, HandMetal, RefreshCw } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
if (!seconds || isNaN(seconds)) return '0:00';
|
||||
@@ -11,6 +12,7 @@ function formatDuration(seconds: number): string {
|
||||
}
|
||||
|
||||
export default function RandomMix() {
|
||||
const { t } = useTranslation();
|
||||
const [songs, setSongs] = useState<SubsonicSong[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const playTrack = usePlayerStore(s => s.playTrack);
|
||||
@@ -35,15 +37,7 @@ export default function RandomMix() {
|
||||
}, []);
|
||||
|
||||
const handlePlayAll = () => {
|
||||
if (songs.length > 0) {
|
||||
playTrack(songs[0], songs);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEnqueueAll = () => {
|
||||
if (songs.length > 0) {
|
||||
enqueue(songs);
|
||||
}
|
||||
if (songs.length > 0) playTrack(songs[0], songs);
|
||||
};
|
||||
|
||||
const toggleSongStar = async (song: SubsonicSong, e: React.MouseEvent) => {
|
||||
@@ -59,22 +53,21 @@ export default function RandomMix() {
|
||||
else await star(song.id, 'song');
|
||||
} catch (err) {
|
||||
console.error('Failed to toggle song star', err);
|
||||
const revert = new Set(starredSongs);
|
||||
setStarredSongs(revert);
|
||||
setStarredSongs(new Set(starredSongs));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="content-body animate-fade-in">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '2rem' }}>
|
||||
<h1 className="page-title">Zufallsmix</h1>
|
||||
|
||||
<h1 className="page-title">{t('randomMix.title')}</h1>
|
||||
|
||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||
<button className="btn btn-surface" onClick={fetchSongs} disabled={loading} data-tooltip="Neue Songs laden">
|
||||
<RefreshCw size={18} className={loading ? 'spin' : ''} /> Neu mixen
|
||||
<button className="btn btn-surface" onClick={fetchSongs} disabled={loading} data-tooltip={t('randomMix.remixTooltip')}>
|
||||
<RefreshCw size={18} className={loading ? 'spin' : ''} /> {t('randomMix.remix')}
|
||||
</button>
|
||||
<button className="btn btn-primary" onClick={handlePlayAll} disabled={loading || songs.length === 0}>
|
||||
<Play size={18} fill="currentColor" /> Alle abspielen
|
||||
<Play size={18} fill="currentColor" /> {t('randomMix.playAll')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -87,10 +80,10 @@ export default function RandomMix() {
|
||||
<div className="tracklist">
|
||||
<div className="tracklist-header" style={{ gridTemplateColumns: '36px 1fr 1fr 60px 80px' }}>
|
||||
<span></span>
|
||||
<span>Titel</span>
|
||||
<span>Album</span>
|
||||
<span style={{ textAlign: 'center' }}>Favorit</span>
|
||||
<span style={{ textAlign: 'right' }}>Dauer</span>
|
||||
<span>{t('randomMix.trackTitle')}</span>
|
||||
<span>{t('randomMix.trackAlbum')}</span>
|
||||
<span style={{ textAlign: 'center' }}>{t('randomMix.trackFavorite')}</span>
|
||||
<span style={{ textAlign: 'right' }}>{t('randomMix.trackDuration')}</span>
|
||||
</div>
|
||||
|
||||
{songs.map((song) => (
|
||||
@@ -108,35 +101,32 @@ export default function RandomMix() {
|
||||
albumId: song.albumId, duration: song.duration, coverArt: song.coverArt, track: song.track,
|
||||
year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating,
|
||||
};
|
||||
e.dataTransfer.setData('application/json', JSON.stringify({
|
||||
type: 'song',
|
||||
track
|
||||
}));
|
||||
e.dataTransfer.setData('application/json', JSON.stringify({ type: 'song', track }));
|
||||
}}
|
||||
>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ padding: 4 }}
|
||||
onClick={(e) => { e.stopPropagation(); playTrack(song, songs); }}
|
||||
data-tooltip="Abspielen"
|
||||
data-tooltip={t('randomMix.play')}
|
||||
>
|
||||
<Play size={14} fill="currentColor" />
|
||||
</button>
|
||||
|
||||
|
||||
<div className="track-info">
|
||||
<span className="track-title" data-tooltip={song.title}>{song.title}</span>
|
||||
<span className="track-artist">{song.artist}</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="track-info">
|
||||
<span className="track-title" style={{ fontSize: '0.85rem', color: 'var(--subtext0)' }} data-tooltip={song.album}>{song.album}</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'center' }}>
|
||||
<button
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={(e) => toggleSongStar(song, e)}
|
||||
data-tooltip={starredSongs.has(song.id) ? "Aus Favoriten entfernen" : "Zu Favoriten hinzufügen"}
|
||||
data-tooltip={starredSongs.has(song.id) ? t('randomMix.favoriteRemove') : t('randomMix.favoriteAdd')}
|
||||
style={{ padding: '4px', height: 'auto', minHeight: 'unset', color: starredSongs.has(song.id) ? 'var(--accent)' : 'var(--text-muted)' }}
|
||||
>
|
||||
<HandMetal size={14} fill={starredSongs.has(song.id) ? "currentColor" : "none"} />
|
||||
|
||||
+198
-71
@@ -1,32 +1,130 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Wifi, WifiOff, Globe, Server, Music2, Sliders, LogOut, CheckCircle2, FolderOpen, Palette
|
||||
Wifi, WifiOff, Globe, Music2, Sliders, LogOut, CheckCircle2, FolderOpen, Palette, Server, Plus, Trash2, Eye, EyeOff
|
||||
} from 'lucide-react';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useAuthStore, ServerProfile } from '../store/authStore';
|
||||
import { useThemeStore } from '../store/themeStore';
|
||||
import { ping } from '../api/subsonic';
|
||||
import { pingWithCredentials } from '../api/subsonic';
|
||||
import { open as openDialog } from '@tauri-apps/plugin-dialog';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
function AddServerForm({ onSave, onCancel }: { onSave: (data: Omit<ServerProfile, 'id'>) => void; onCancel: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const [form, setForm] = useState({ name: '', url: '', username: '', password: '' });
|
||||
const [showPass, setShowPass] = useState(false);
|
||||
|
||||
const update = (k: keyof typeof form) => (e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setForm(f => ({ ...f, [k]: e.target.value }));
|
||||
|
||||
return (
|
||||
<div className="settings-card" style={{ marginTop: '1rem' }}>
|
||||
<h3 style={{ fontWeight: 600, marginBottom: '1rem', fontSize: '14px' }}>{t('settings.addServerTitle')}</h3>
|
||||
<div className="form-group" style={{ marginBottom: '0.75rem' }}>
|
||||
<label style={{ fontSize: 13 }}>{t('settings.serverName')}</label>
|
||||
<input className="input" type="text" value={form.name} onChange={update('name')} placeholder="My Navidrome" autoComplete="off" />
|
||||
</div>
|
||||
<div className="form-group" style={{ marginBottom: '0.75rem' }}>
|
||||
<label style={{ fontSize: 13 }}>{t('settings.serverUrl')}</label>
|
||||
<input className="input" type="text" value={form.url} onChange={update('url')} placeholder="192.168.1.100:4533" autoComplete="off" />
|
||||
</div>
|
||||
<div className="form-row" style={{ marginBottom: '0.75rem' }}>
|
||||
<div className="form-group">
|
||||
<label style={{ fontSize: 13 }}>{t('settings.serverUsername')}</label>
|
||||
<input className="input" type="text" value={form.username} onChange={update('username')} placeholder="admin" autoComplete="off" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label style={{ fontSize: 13 }}>{t('settings.serverPassword')}</label>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<input
|
||||
className="input"
|
||||
type={showPass ? 'text' : 'password'}
|
||||
value={form.password}
|
||||
onChange={update('password')}
|
||||
placeholder="••••••••"
|
||||
style={{ paddingRight: '2.5rem' }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
style={{ position: 'absolute', right: '10px', top: '50%', transform: 'translateY(-50%)', color: 'var(--text-muted)' }}
|
||||
onClick={() => setShowPass(v => !v)}
|
||||
>
|
||||
{showPass ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '8px', justifyContent: 'flex-end' }}>
|
||||
<button className="btn btn-ghost" onClick={onCancel}>{t('common.cancel')}</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => form.url.trim() && onSave({ name: form.name.trim() || form.url.trim(), url: form.url.trim(), username: form.username.trim(), password: form.password })}
|
||||
>
|
||||
{t('common.add')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Settings() {
|
||||
const auth = useAuthStore();
|
||||
const theme = useThemeStore();
|
||||
const navigate = useNavigate();
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
const [lanIp, setLanIp] = useState(auth.lanIp);
|
||||
const [externalUrl, setExternalUrl] = useState(auth.externalUrl);
|
||||
const [connStatus, setConnStatus] = useState<'idle' | 'testing' | 'ok' | 'error'>('idle');
|
||||
const [connStatus, setConnStatus] = useState<Record<string, 'idle' | 'testing' | 'ok' | 'error'>>({});
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
|
||||
const [lfmApiKey, setLfmApiKey] = useState(auth.lastfmApiKey);
|
||||
const [lfmSecret, setLfmSecret] = useState(auth.lastfmApiSecret);
|
||||
const testConnection = async () => {
|
||||
setConnStatus('testing');
|
||||
auth.setCredentials({ serverName: auth.serverName, lanIp, externalUrl, username: auth.username, password: auth.password });
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
const ok = await ping();
|
||||
setConnStatus(ok ? 'ok' : 'error');
|
||||
const testConnection = async (server: ServerProfile) => {
|
||||
setConnStatus(s => ({ ...s, [server.id]: 'testing' }));
|
||||
try {
|
||||
const ok = await pingWithCredentials(server.url, server.username, server.password);
|
||||
setConnStatus(s => ({ ...s, [server.id]: ok ? 'ok' : 'error' }));
|
||||
} catch {
|
||||
setConnStatus(s => ({ ...s, [server.id]: 'error' }));
|
||||
}
|
||||
};
|
||||
|
||||
const switchToServer = async (server: ServerProfile) => {
|
||||
setConnStatus(s => ({ ...s, [server.id]: 'testing' }));
|
||||
try {
|
||||
const ok = await pingWithCredentials(server.url, server.username, server.password);
|
||||
if (ok) {
|
||||
auth.setActiveServer(server.id);
|
||||
auth.setLoggedIn(true);
|
||||
navigate('/');
|
||||
} else {
|
||||
setConnStatus(s => ({ ...s, [server.id]: 'error' }));
|
||||
}
|
||||
} catch {
|
||||
setConnStatus(s => ({ ...s, [server.id]: 'error' }));
|
||||
}
|
||||
};
|
||||
|
||||
const deleteServer = (server: ServerProfile) => {
|
||||
if (confirm(t('settings.confirmDeleteServer', { name: server.name || server.url }))) {
|
||||
auth.removeServer(server.id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddServer = async (data: Omit<ServerProfile, 'id'>) => {
|
||||
setShowAddForm(false);
|
||||
const tempId = '_new';
|
||||
setConnStatus(s => ({ ...s, [tempId]: 'testing' }));
|
||||
try {
|
||||
const ok = await pingWithCredentials(data.url, data.username, data.password);
|
||||
if (ok) {
|
||||
const id = auth.addServer(data);
|
||||
auth.setActiveServer(id);
|
||||
auth.setLoggedIn(true);
|
||||
setConnStatus(s => ({ ...s, [id]: 'ok' }));
|
||||
} else {
|
||||
setConnStatus(s => ({ ...s, [tempId]: 'error' }));
|
||||
}
|
||||
} catch {
|
||||
setConnStatus(s => ({ ...s, [tempId]: 'error' }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
@@ -53,9 +151,9 @@ export default function Settings() {
|
||||
</div>
|
||||
<div className="settings-card">
|
||||
<div className="form-group" style={{ maxWidth: '300px' }}>
|
||||
<select
|
||||
className="input"
|
||||
value={i18n.language}
|
||||
<select
|
||||
className="input"
|
||||
value={i18n.language}
|
||||
onChange={(e) => i18n.changeLanguage(e.target.value)}
|
||||
aria-label={t('settings.language')}
|
||||
>
|
||||
@@ -74,9 +172,9 @@ export default function Settings() {
|
||||
</div>
|
||||
<div className="settings-card">
|
||||
<div className="form-group" style={{ maxWidth: '300px' }}>
|
||||
<select
|
||||
className="input"
|
||||
value={theme.theme}
|
||||
<select
|
||||
className="input"
|
||||
value={theme.theme}
|
||||
onChange={(e) => theme.setTheme(e.target.value as any)}
|
||||
aria-label={t('settings.theme')}
|
||||
>
|
||||
@@ -87,59 +185,84 @@ export default function Settings() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Connection */}
|
||||
{/* Servers */}
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<Wifi size={18} />
|
||||
<h2>{t('settings.connection')}</h2>
|
||||
<Server size={18} />
|
||||
<h2>{t('settings.servers')}</h2>
|
||||
</div>
|
||||
<div className="settings-card">
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label htmlFor="settings-lan">{t('settings.lanIp')}</label>
|
||||
<input id="settings-lan" className="input" value={lanIp} onChange={e => setLanIp(e.target.value)} placeholder="192.168.1.100:4533" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="settings-ext">{t('settings.externalUrl')}</label>
|
||||
<input id="settings-ext" className="input" value={externalUrl} onChange={e => setExternalUrl(e.target.value)} placeholder="music.example.com" />
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginTop: '0.75rem' }}>
|
||||
<button className="btn btn-primary" onClick={testConnection} id="settings-test-conn-btn" disabled={connStatus === 'testing'}>
|
||||
{connStatus === 'testing' ? t('settings.testingBtn') : t('settings.testBtn')}
|
||||
</button>
|
||||
{connStatus === 'ok' && <span style={{ color: 'var(--positive)', display: 'flex', alignItems: 'center', gap: '4px' }}><CheckCircle2 size={16} /> {t('settings.connected')}</span>}
|
||||
{connStatus === 'error' && <span style={{ color: 'var(--danger)', display: 'flex', alignItems: 'center', gap: '4px' }}><WifiOff size={16} /> {t('settings.failed')}</span>}
|
||||
</div>
|
||||
|
||||
<div className="divider" style={{ margin: '1rem 0' }} />
|
||||
|
||||
{/* Active connection toggle */}
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.activeConn')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.activeServer')} <strong>{auth.activeConnection === 'local' ? t('settings.connLocal') : t('settings.connExternal')}</strong></div>
|
||||
</div>
|
||||
<div className="conn-toggle" role="group" aria-label="Verbindung umschalten">
|
||||
<button
|
||||
className={`conn-toggle-btn ${auth.activeConnection === 'local' ? 'active' : ''}`}
|
||||
onClick={() => auth.toggleConnection()}
|
||||
id="conn-local-btn"
|
||||
aria-pressed={auth.activeConnection === 'local'}
|
||||
>
|
||||
<Server size={13} /> {t('settings.connLocal')}
|
||||
</button>
|
||||
<button
|
||||
className={`conn-toggle-btn ${auth.activeConnection === 'external' ? 'active' : ''}`}
|
||||
onClick={() => auth.toggleConnection()}
|
||||
id="conn-extern-btn"
|
||||
aria-pressed={auth.activeConnection === 'external'}
|
||||
>
|
||||
<Globe size={13} /> {t('settings.connExternal')}
|
||||
</button>
|
||||
</div>
|
||||
{auth.servers.length === 0 && !showAddForm ? (
|
||||
<div className="settings-card" style={{ color: 'var(--text-muted)', fontSize: 14 }}>
|
||||
{t('settings.noServers')}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||
{auth.servers.map(srv => {
|
||||
const isActive = srv.id === auth.activeServerId;
|
||||
const status = connStatus[srv.id];
|
||||
return (
|
||||
<div key={srv.id} className="settings-card" style={{ border: isActive ? '1px solid var(--accent)' : undefined }}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: '1rem' }}>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '2px' }}>
|
||||
<span style={{ fontWeight: 600 }}>{srv.name || srv.url}</span>
|
||||
{isActive && (
|
||||
<span style={{ fontSize: 11, background: 'var(--accent)', color: 'var(--ctp-crust)', padding: '1px 6px', borderRadius: '10px', fontWeight: 600 }}>
|
||||
{t('settings.serverActive')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{srv.username}@{srv.url}</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '6px', flexShrink: 0, alignItems: 'center' }}>
|
||||
{status === 'ok' && <CheckCircle2 size={16} style={{ color: 'var(--positive)' }} />}
|
||||
{status === 'error' && <WifiOff size={16} style={{ color: 'var(--danger)' }} />}
|
||||
{status === 'testing' && <div className="spinner" style={{ width: 16, height: 16 }} />}
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ fontSize: 12, padding: '4px 10px' }}
|
||||
onClick={() => testConnection(srv)}
|
||||
disabled={status === 'testing'}
|
||||
>
|
||||
<Wifi size={13} />
|
||||
{t('settings.testBtn')}
|
||||
</button>
|
||||
{!isActive && (
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
style={{ fontSize: 12, padding: '4px 10px' }}
|
||||
onClick={() => switchToServer(srv)}
|
||||
disabled={status === 'testing'}
|
||||
id={`settings-use-server-${srv.id}`}
|
||||
>
|
||||
{t('settings.useServer')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ color: 'var(--danger)', padding: '4px 8px' }}
|
||||
onClick={() => deleteServer(srv)}
|
||||
data-tooltip={t('settings.deleteServer')}
|
||||
id={`settings-delete-server-${srv.id}`}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showAddForm ? (
|
||||
<AddServerForm onSave={handleAddServer} onCancel={() => setShowAddForm(false)} />
|
||||
) : (
|
||||
<button className="btn btn-ghost" style={{ marginTop: '0.75rem' }} onClick={() => setShowAddForm(true)} id="settings-add-server-btn">
|
||||
<Plus size={16} /> {t('settings.addServer')}
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Last.fm */}
|
||||
@@ -150,7 +273,11 @@ export default function Settings() {
|
||||
</div>
|
||||
<div className="settings-card">
|
||||
<div style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: '1rem', lineHeight: 1.5 }}>
|
||||
<p style={{ marginBottom: '0.5rem' }} dangerouslySetInnerHTML={{ __html: t('settings.lfmDesc1') }} />
|
||||
<p style={{ marginBottom: '0.5rem' }}>
|
||||
{t('settings.lfmDesc1')}{' '}
|
||||
<strong>{t('settings.lfmDesc1NavidromeWebplayer')}</strong>
|
||||
{' '}{t('settings.lfmDesc1b')}
|
||||
</p>
|
||||
<p>{t('settings.lfmDesc2')}</p>
|
||||
</div>
|
||||
|
||||
@@ -159,7 +286,7 @@ export default function Settings() {
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.scrobbleEnabled')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.scrobbleDesc')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label="Scrobbling aktivieren">
|
||||
<label className="toggle-switch" aria-label={t('settings.scrobbleEnabled')}>
|
||||
<input type="checkbox" checked={auth.scrobblingEnabled} onChange={e => auth.setScrobblingEnabled(e.target.checked)} id="scrobbling-toggle" />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
@@ -179,7 +306,7 @@ export default function Settings() {
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.trayTitle')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.trayDesc')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label="In Tray minimieren">
|
||||
<label className="toggle-switch" aria-label={t('settings.trayTitle')}>
|
||||
<input type="checkbox" checked={auth.minimizeToTray} onChange={e => auth.setMinimizeToTray(e.target.checked)} id="tray-toggle" />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
|
||||
+21
-20
@@ -1,9 +1,11 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { getAlbumList, getGenres, SubsonicAlbum, SubsonicGenre } from '../api/subsonic';
|
||||
import AlbumRow from '../components/AlbumRow';
|
||||
import { BarChart3, TrendingUp, Star, Music } from 'lucide-react';
|
||||
import { BarChart3 } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export default function Statistics() {
|
||||
const { t } = useTranslation();
|
||||
const [frequent, setFrequent] = useState<SubsonicAlbum[]>([]);
|
||||
const [highest, setHighest] = useState<SubsonicAlbum[]>([]);
|
||||
const [genres, setGenres] = useState<SubsonicGenre[]>([]);
|
||||
@@ -45,7 +47,7 @@ export default function Statistics() {
|
||||
<div className="content-body animate-fade-in">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '2rem' }}>
|
||||
<BarChart3 size={32} style={{ color: 'var(--accent)' }} />
|
||||
<h1 className="page-title" style={{ margin: 0 }}>Statistiken</h1>
|
||||
<h1 className="page-title" style={{ margin: 0 }}>{t('statistics.title')}</h1>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
@@ -54,28 +56,27 @@ export default function Statistics() {
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '3rem' }}>
|
||||
|
||||
<AlbumRow
|
||||
title="Meistgespielte Alben"
|
||||
albums={frequent}
|
||||
|
||||
<AlbumRow
|
||||
title={t('statistics.mostPlayed')}
|
||||
albums={frequent}
|
||||
onLoadMore={() => loadMore('frequent', frequent, setFrequent)}
|
||||
moreText="Mehr laden"
|
||||
moreText={t('statistics.loadMore')}
|
||||
/>
|
||||
|
||||
<AlbumRow
|
||||
title="Höchstbewertete Alben"
|
||||
albums={highest}
|
||||
<AlbumRow
|
||||
title={t('statistics.highestRated')}
|
||||
albums={highest}
|
||||
onLoadMore={() => loadMore('highest', highest, setHighest)}
|
||||
moreText="Mehr laden"
|
||||
moreText={t('statistics.loadMore')}
|
||||
/>
|
||||
|
||||
{genres.length > 0 && (
|
||||
<div>
|
||||
<div className="section-title">
|
||||
<Music size={20} />
|
||||
<h2>Genre-Verteilung (Top 20)</h2>
|
||||
<h2>{t('statistics.genreDistribution')}</h2>
|
||||
</div>
|
||||
|
||||
|
||||
<div style={{ display: 'grid', gap: '1rem', background: 'var(--surface0)', padding: '1.5rem', borderRadius: '12px' }}>
|
||||
{genres.map(genre => {
|
||||
const percentage = (genre.songCount / maxGenreCount) * 100;
|
||||
@@ -86,14 +87,14 @@ export default function Statistics() {
|
||||
<span style={{ color: 'var(--subtext0)' }}>{genre.songCount} Songs</span>
|
||||
</div>
|
||||
<div style={{ width: '100%', height: '8px', background: 'var(--surface2)', borderRadius: '4px', overflow: 'hidden' }}>
|
||||
<div
|
||||
style={{
|
||||
width: `${percentage}%`,
|
||||
height: '100%',
|
||||
background: 'var(--accent)',
|
||||
<div
|
||||
style={{
|
||||
width: `${percentage}%`,
|
||||
height: '100%',
|
||||
background: 'var(--accent)',
|
||||
borderRadius: '4px',
|
||||
transition: 'width 1s ease-out'
|
||||
}}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user