-
Wer hört was?
-
{t('nowPlaying.title')}
+
@@ -95,11 +96,11 @@ export default function NowPlayingDropdown() {
{loading && nowPlaying.length === 0 ? (
- Lädt...
+ {t('nowPlaying.loading')}
) : nowPlaying.length === 0 ? (
- Gerade hört niemand Musik.
+ {t('nowPlaying.nobody')}
) : (
@@ -118,7 +119,7 @@ export default function NowPlayingDropdown() {
{stream.username} ({stream.playerName || 'Web'})
- {stream.minutesAgo > 0 && • vor {stream.minutesAgo}m }
+ {stream.minutesAgo > 0 && • {t('nowPlaying.minutesAgo', { n: stream.minutesAgo })} }
diff --git a/src/i18n.ts b/src/i18n.ts
index 97b14d65..953448a9 100644
--- a/src/i18n.ts
+++ b/src/i18n.ts
@@ -18,6 +18,182 @@ const enTranslation = {
expand: 'Expand Sidebar',
collapse: 'Collapse Sidebar'
},
+ home: {
+ starred: 'Personal Favorites',
+ recent: 'Recently Added',
+ mostPlayed: 'Most Played',
+ discover: 'Discover',
+ loadMore: 'Load More',
+ discoverMore: 'Discover More'
+ },
+ hero: {
+ eyebrow: 'Featured Album',
+ playAlbum: 'Play Album',
+ enqueue: 'Enqueue',
+ enqueueTooltip: 'Add entire album to queue',
+ },
+ search: {
+ placeholder: 'Search for artist, album or song…',
+ noResults: 'No results for "{{query}}"',
+ artists: 'Artists',
+ albums: 'Albums',
+ songs: 'Songs',
+ clearLabel: 'Clear search',
+ },
+ nowPlaying: {
+ tooltip: 'Who is listening?',
+ title: 'Who is listening?',
+ loading: 'Loading…',
+ nobody: 'Nobody is currently listening.',
+ minutesAgo: '{{n}}m ago',
+ },
+ contextMenu: {
+ playNow: 'Play Now',
+ playNext: 'Play Next',
+ addToQueue: 'Add to Queue',
+ enqueueAlbum: 'Enqueue Album',
+ startRadio: 'Start Radio',
+ favorite: 'Favorite',
+ favoriteArtist: 'Favorite Artist',
+ favoriteAlbum: 'Favorite Album',
+ removeFromQueue: 'Remove from Queue',
+ openAlbum: 'Open Album',
+ goToArtist: 'Go to Artist',
+ download: 'Download (ZIP)',
+ },
+ albumDetail: {
+ back: 'Back',
+ playAll: 'Play All',
+ enqueue: 'Enqueue',
+ enqueueTooltip: 'Add entire album to queue',
+ artistBio: 'Artist Bio',
+ download: 'Download (ZIP)',
+ downloading: 'Loading…',
+ favoriteAdd: 'Add to Favorites',
+ favoriteRemove: 'Remove from Favorites',
+ favorite: 'Favorite',
+ noBio: 'No biography available.',
+ moreByArtist: 'More by {{artist}}',
+ tracksCount: '{{n}} Tracks',
+ goToArtist: 'Go to {{artist}}',
+ moreLabelAlbums: 'More albums on {{label}}',
+ trackTitle: 'Title',
+ trackFormat: 'Format',
+ trackFavorite: 'Favorite',
+ trackRating: 'Rating',
+ trackDuration: 'Duration',
+ notFound: 'Album not found.',
+ bioModal: 'Artist Biography',
+ bioClose: 'Close',
+ ratingLabel: 'Rating',
+ },
+ artistDetail: {
+ back: 'Back',
+ albums: 'Albums',
+ album: 'Album',
+ playAll: 'Play All',
+ shuffle: 'Shuffle',
+ radio: 'Radio',
+ loading: 'Loading…',
+ noRadio: 'No similar tracks found for this artist.',
+ notFound: 'Artist not found.',
+ albumsBy: 'Albums by {{name}}',
+ topTracks: 'Top Tracks',
+ noAlbums: 'No albums found.',
+ trackTitle: 'Title',
+ trackAlbum: 'Album',
+ trackDuration: 'Duration',
+ favoriteAdd: 'Add to Favorites',
+ favoriteRemove: 'Remove from Favorites',
+ favorite: 'Favorite',
+ albumCount_one: '{{count}} Album',
+ albumCount_other: '{{count}} Albums',
+ },
+ favorites: {
+ title: 'Favorites',
+ empty: "You haven't saved any favorites yet.",
+ artists: 'Artists',
+ albums: 'Albums',
+ songs: 'Songs',
+ },
+ randomMix: {
+ title: 'Random Mix',
+ remix: 'Remix',
+ remixTooltip: 'Load new random songs',
+ playAll: 'Play All',
+ trackTitle: 'Title',
+ trackAlbum: 'Album',
+ trackFavorite: 'Favorite',
+ trackDuration: 'Duration',
+ favoriteAdd: 'Add to Favorites',
+ favoriteRemove: 'Remove from Favorites',
+ play: 'Play',
+ },
+ playlists: {
+ title: 'Playlists',
+ loading: 'Loading playlists…',
+ empty: 'No playlists found.\nUse the queue to create playlists.',
+ play: 'Play',
+ deleteTooltip: 'Delete',
+ confirmDelete: 'Really delete playlist "{{name}}"?',
+ minutes: 'min.',
+ track_one: '{{count}} Track',
+ track_other: '{{count}} Tracks',
+ },
+ albums: {
+ title: 'All Albums',
+ sortByName: 'A–Z (Album)',
+ sortByArtist: 'A–Z (Artist)',
+ sortNewest: 'Newest first',
+ sortRandom: 'Random',
+ },
+ artists: {
+ title: 'Artists',
+ search: 'Search…',
+ all: 'All',
+ gridView: 'Grid view',
+ listView: 'List view',
+ loadMore: 'Load more',
+ notFound: 'No artists found.',
+ albumCount_one: '{{count}} Album',
+ albumCount_other: '{{count}} Albums',
+ },
+ login: {
+ subtitle: 'Your Navidrome Desktop Player',
+ serverName: 'Server Name (optional)',
+ serverNamePlaceholder: 'My Navidrome',
+ serverUrl: 'Server URL',
+ serverUrlPlaceholder: '192.168.1.100:4533 or music.example.com',
+ username: 'Username',
+ usernamePlaceholder: 'admin',
+ password: 'Password',
+ showPassword: 'Show password',
+ hidePassword: 'Hide password',
+ connect: 'Connect',
+ connecting: 'Connecting…',
+ connected: 'Connected!',
+ error: 'Connection failed – please check your details.',
+ urlRequired: 'Please enter a server URL.',
+ savedServers: 'Saved Servers',
+ addNew: 'Or add a new server',
+ },
+ common: {
+ albums: 'Albums',
+ album: 'Album',
+ loading: 'Loading…',
+ loadingMore: 'Loading…',
+ loadingPlaylists: 'Loading Playlists…',
+ noAlbums: 'No albums found.',
+ downloading: 'Downloading…',
+ downloadZip: 'Download (ZIP)',
+ back: 'Back',
+ cancel: 'Cancel',
+ save: 'Save',
+ delete: 'Delete',
+ use: 'Use',
+ add: 'Add',
+ active: 'Active',
+ },
settings: {
title: 'Settings',
language: 'Language',
@@ -25,19 +201,29 @@ const enTranslation = {
languageDe: 'German',
theme: 'Theme',
appearance: 'Appearance',
- connection: 'Connection',
- lanIp: 'LAN IP',
- externalUrl: 'External URL',
+ servers: 'Servers',
+ serverName: 'Server Name',
+ serverUrl: 'Server URL',
+ serverUsername: 'Username',
+ serverPassword: 'Password',
+ addServer: 'Add Server',
+ addServerTitle: 'Add New Server',
+ useServer: 'Use',
+ deleteServer: 'Delete',
+ noServers: 'No servers saved.',
+ serverActive: 'Active',
+ confirmDeleteServer: 'Delete server "{{name}}"?',
+ serverConnecting: 'Connecting…',
+ serverConnected: 'Connected!',
+ serverFailed: 'Connection failed.',
testBtn: 'Test Connection',
testingBtn: 'Testing…',
connected: 'Connected',
failed: 'Failed',
- activeConn: 'Active Connection',
- activeServer: 'Currently used server:',
- connLocal: 'Local (LAN)',
- connExternal: 'External (Internet)',
lfmTitle: 'Last.fm Scrobbling',
- lfmDesc1: 'Psysonic supports server-side scrobbling directly via Navidrome. To link Last.fm, please log in once via the
Navidrome Webplayer in your browser, go to your profile, and connect your Last.fm account.',
+ lfmDesc1: 'Psysonic supports server-side scrobbling directly via Navidrome. To link Last.fm, please log in once via the',
+ lfmDesc1NavidromeWebplayer: 'Navidrome Webplayer',
+ lfmDesc1b: 'in your browser, go to your profile, and connect your Last.fm account.',
lfmDesc2: 'Once that is done, Psysonic automatically forwards your currently playing songs to Navidrome, and they will appear on Last.fm.',
scrobbleEnabled: 'Scrobbling enabled',
scrobbleDesc: 'Send songs to Last.fm after 50% playtime',
@@ -59,7 +245,7 @@ const enTranslation = {
cancel: 'Cancel',
save: 'Save',
loadPlaylist: 'Load Playlist',
- loading: 'Loading...',
+ loading: 'Loading…',
noPlaylists: 'No playlists found.',
load: 'Load',
delete: 'Delete',
@@ -70,9 +256,19 @@ const enTranslation = {
nextTracks: 'Next Tracks',
emptyQueue: 'The queue is empty.'
},
+ statistics: {
+ title: 'Statistics',
+ mostPlayed: 'Most Played Albums',
+ highestRated: 'Highest Rated Albums',
+ genreDistribution: 'Genre Distribution (Top 20)',
+ loadMore: 'Load more',
+ },
player: {
regionLabel: 'Music Player',
openFullscreen: 'Open Fullscreen Player',
+ fullscreen: 'Fullscreen Player',
+ closeFullscreen: 'Close Fullscreen',
+ closeTooltip: 'Close (Esc)',
noTitle: 'No Title',
stop: 'Stop',
prev: 'Previous Track',
@@ -106,6 +302,182 @@ const deTranslation = {
expand: 'Sidebar einblenden',
collapse: 'Sidebar ausblenden'
},
+ home: {
+ starred: 'Persönliche Favoriten',
+ recent: 'Zuletzt hinzugefügt',
+ mostPlayed: 'Meistgehört',
+ discover: 'Entdecken',
+ loadMore: 'Mehr laden',
+ discoverMore: 'Mehr entdecken'
+ },
+ hero: {
+ eyebrow: 'Album des Augenblicks',
+ playAlbum: 'Album abspielen',
+ enqueue: 'Einreihen',
+ enqueueTooltip: 'Ganzes Album zur Warteschlange hinzufügen',
+ },
+ search: {
+ placeholder: 'Suchen nach Künstler, Album oder Song…',
+ noResults: 'Keine Ergebnisse für „{{query}}"',
+ artists: 'Künstler',
+ albums: 'Alben',
+ songs: 'Songs',
+ clearLabel: 'Suche leeren',
+ },
+ nowPlaying: {
+ tooltip: 'Wer hört was?',
+ title: 'Wer hört was?',
+ loading: 'Lädt…',
+ nobody: 'Gerade hört niemand Musik.',
+ minutesAgo: 'vor {{n}}m',
+ },
+ contextMenu: {
+ playNow: 'Direkt abspielen',
+ playNext: 'Als Nächstes abspielen',
+ addToQueue: 'Zur Warteschlange hinzufügen',
+ enqueueAlbum: 'Ganzes Album einreihen',
+ startRadio: 'Radio starten',
+ favorite: 'Favorisieren',
+ favoriteArtist: 'Künstler favorisieren',
+ favoriteAlbum: 'Album favorisieren',
+ removeFromQueue: 'Diesen Song entfernen',
+ openAlbum: 'Album öffnen',
+ goToArtist: 'Zum Künstler',
+ download: 'Herunterladen (ZIP)',
+ },
+ albumDetail: {
+ back: 'Zurück',
+ playAll: 'Alle abspielen',
+ enqueue: 'Einreihen',
+ enqueueTooltip: 'Ganzes Album zur Warteschlange hinzufügen',
+ artistBio: 'Künstler-Bio',
+ download: 'Download (ZIP)',
+ downloading: 'Lade…',
+ favoriteAdd: 'Zu Favoriten hinzufügen',
+ favoriteRemove: 'Aus Favoriten entfernen',
+ favorite: 'Als Favorit',
+ noBio: 'Keine Biografie verfügbar.',
+ moreByArtist: 'Mehr von {{artist}}',
+ tracksCount: '{{n}} Tracks',
+ goToArtist: 'Zu {{artist}} wechseln',
+ moreLabelAlbums: 'Weitere Alben von {{label}} anzeigen',
+ trackTitle: 'Titel',
+ trackFormat: 'Format',
+ trackFavorite: 'Favorit',
+ trackRating: 'Bewertung',
+ trackDuration: 'Dauer',
+ notFound: 'Album nicht gefunden.',
+ bioModal: 'Künstler-Biografie',
+ bioClose: 'Schließen',
+ ratingLabel: 'Bewertung',
+ },
+ artistDetail: {
+ back: 'Zurück',
+ albums: 'Alben',
+ album: 'Album',
+ playAll: 'Alle abspielen',
+ shuffle: 'Zufallswiedergabe',
+ radio: 'Radio',
+ loading: 'Lädt…',
+ noRadio: 'Keine ähnlichen Titel für diesen Künstler gefunden.',
+ notFound: 'Künstler nicht gefunden.',
+ albumsBy: 'Alben von {{name}}',
+ topTracks: 'Beliebteste Titel',
+ noAlbums: 'Keine Alben gefunden.',
+ trackTitle: 'Titel',
+ trackAlbum: 'Album',
+ trackDuration: 'Dauer',
+ favoriteAdd: 'Zu Favoriten hinzufügen',
+ favoriteRemove: 'Aus Favoriten entfernen',
+ favorite: 'Als Favorit',
+ albumCount_one: '{{count}} Album',
+ albumCount_other: '{{count}} Alben',
+ },
+ favorites: {
+ title: 'Favoriten',
+ empty: 'Du hast noch keine Favoriten gespeichert.',
+ artists: 'Künstler',
+ albums: 'Alben',
+ songs: 'Songs',
+ },
+ randomMix: {
+ title: 'Zufallsmix',
+ remix: 'Neu mixen',
+ remixTooltip: 'Neue Songs laden',
+ playAll: 'Alle abspielen',
+ trackTitle: 'Titel',
+ trackAlbum: 'Album',
+ trackFavorite: 'Favorit',
+ trackDuration: 'Dauer',
+ favoriteAdd: 'Zu Favoriten hinzufügen',
+ favoriteRemove: 'Aus Favoriten entfernen',
+ play: 'Abspielen',
+ },
+ playlists: {
+ title: 'Playlists',
+ loading: 'Lade Playlists…',
+ empty: 'Keine Playlists gefunden.\nNutze die Warteschlange, um Playlists zu erstellen.',
+ play: 'Abspielen',
+ deleteTooltip: 'Löschen',
+ confirmDelete: 'Playlist "{{name}}" wirklich löschen?',
+ minutes: 'Min.',
+ track_one: '{{count}} Track',
+ track_other: '{{count}} Tracks',
+ },
+ albums: {
+ title: 'Alle Alben',
+ sortByName: 'A–Z (Album)',
+ sortByArtist: 'A–Z (Künstler)',
+ sortNewest: 'Neueste zuerst',
+ sortRandom: 'Zufällig',
+ },
+ artists: {
+ title: 'Künstler',
+ search: 'Suchen…',
+ all: 'Alle',
+ gridView: 'Gitteransicht',
+ listView: 'Listenansicht',
+ loadMore: 'Mehr laden',
+ notFound: 'Keine Künstler gefunden.',
+ albumCount_one: '{{count}} Album',
+ albumCount_other: '{{count}} Alben',
+ },
+ login: {
+ subtitle: 'Dein Navidrome Desktop Player',
+ serverName: 'Server-Name (optional)',
+ serverNamePlaceholder: 'Mein Navidrome',
+ serverUrl: 'Server-URL',
+ serverUrlPlaceholder: '192.168.1.100:4533 oder music.example.com',
+ username: 'Benutzername',
+ usernamePlaceholder: 'admin',
+ password: 'Passwort',
+ showPassword: 'Passwort anzeigen',
+ hidePassword: 'Passwort verstecken',
+ connect: 'Verbinden',
+ connecting: 'Verbinde…',
+ connected: 'Verbunden!',
+ error: 'Verbindung fehlgeschlagen – bitte Daten prüfen.',
+ urlRequired: 'Bitte Server-URL eingeben.',
+ savedServers: 'Gespeicherte Server',
+ addNew: 'Oder neuen Server hinzufügen',
+ },
+ common: {
+ albums: 'Alben',
+ album: 'Album',
+ loading: 'Lade…',
+ loadingMore: 'Lade…',
+ loadingPlaylists: 'Lade Playlists…',
+ noAlbums: 'Keine Alben gefunden.',
+ downloading: 'Lade…',
+ downloadZip: 'Download (ZIP)',
+ back: 'Zurück',
+ cancel: 'Abbrechen',
+ save: 'Speichern',
+ delete: 'Löschen',
+ use: 'Verwenden',
+ add: 'Hinzufügen',
+ active: 'Aktiv',
+ },
settings: {
title: 'Einstellungen',
language: 'Sprache',
@@ -113,19 +485,29 @@ const deTranslation = {
languageDe: 'Deutsch',
theme: 'Design',
appearance: 'Darstellung',
- connection: 'Verbindung',
- lanIp: 'LAN-IP',
- externalUrl: 'Externe URL',
+ servers: 'Server',
+ serverName: 'Server-Name',
+ serverUrl: 'Server-URL',
+ serverUsername: 'Benutzername',
+ serverPassword: 'Passwort',
+ addServer: 'Server hinzufügen',
+ addServerTitle: 'Neuen Server hinzufügen',
+ useServer: 'Verwenden',
+ deleteServer: 'Löschen',
+ noServers: 'Keine Server gespeichert.',
+ serverActive: 'Aktiv',
+ confirmDeleteServer: 'Server „{{name}}" löschen?',
+ serverConnecting: 'Verbinde…',
+ serverConnected: 'Verbunden!',
+ serverFailed: 'Verbindung fehlgeschlagen.',
testBtn: 'Verbindung testen',
testingBtn: 'Teste…',
connected: 'Verbunden',
failed: 'Fehlgeschlagen',
- activeConn: 'Aktive Verbindung',
- activeServer: 'Aktuell verwendeter Server:',
- connLocal: 'Lokal (LAN)',
- connExternal: 'Extern (Internet)',
lfmTitle: 'Last.fm Scrobbling',
- lfmDesc1: 'Psysonic unterstützt serverseitiges Scrobbling direkt über Navidrome. Um Last.fm zu verknüpfen, logge dich bitte einmalig über den
Navidrome Webplayer im Browser ein, gehe auf dein Profil und verbinde deinen Last.fm Account.',
+ lfmDesc1: 'Psysonic unterstützt serverseitiges Scrobbling direkt über Navidrome. Um Last.fm zu verknüpfen, logge dich bitte einmalig über den',
+ lfmDesc1NavidromeWebplayer: 'Navidrome Webplayer',
+ lfmDesc1b: 'im Browser ein, gehe auf dein Profil und verbinde deinen Last.fm Account.',
lfmDesc2: 'Sobald das erledigt ist, leitet Psysonic deine aktuell gespielten Songs automatisch an Navidrome weiter, und diese erscheinen auf Last.fm.',
scrobbleEnabled: 'Scrobbling aktiviert',
scrobbleDesc: 'Songs nach 50% Laufzeit an Last.fm senden',
@@ -147,7 +529,7 @@ const deTranslation = {
cancel: 'Abbrechen',
save: 'Speichern',
loadPlaylist: 'Playlist laden',
- loading: 'Lade...',
+ loading: 'Lade…',
noPlaylists: 'Keine Playlists gefunden.',
load: 'Laden',
delete: 'Löschen',
@@ -158,9 +540,19 @@ const deTranslation = {
nextTracks: 'Nächste Titel',
emptyQueue: 'Die Warteschlange ist leer.'
},
+ statistics: {
+ title: 'Statistiken',
+ mostPlayed: 'Meistgespielte Alben',
+ highestRated: 'Höchstbewertete Alben',
+ genreDistribution: 'Genre-Verteilung (Top 20)',
+ loadMore: 'Mehr laden',
+ },
player: {
regionLabel: 'Musikplayer',
openFullscreen: 'Vollbild-Player öffnen',
+ fullscreen: 'Vollbild-Player',
+ closeFullscreen: 'Vollbild schließen',
+ closeTooltip: 'Schließen (Esc)',
noTitle: 'Kein Titel',
stop: 'Stop',
prev: 'Vorheriger Titel',
@@ -190,11 +582,10 @@ i18n
lng: savedLanguage,
fallbackLng: 'en',
interpolation: {
- escapeValue: false // react already safes from xss
+ escapeValue: false
}
});
-// Setup listener to persist language changes
i18n.on('languageChanged', (lng) => {
localStorage.setItem('psysonic_language', lng);
});
diff --git a/src/pages/AlbumDetail.tsx b/src/pages/AlbumDetail.tsx
index d2244b50..2009e0cf 100644
--- a/src/pages/AlbumDetail.tsx
+++ b/src/pages/AlbumDetail.tsx
@@ -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 (
-
+
{[1,2,3,4,5].map(n => (
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 (
-
+
e.stopPropagation()}>
-
-
Künstler-Biografie
-
+
+
{t('albumDetail.bioModal')}
+
);
}
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
();
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 ;
- if (!album) return Album nicht gefunden.
;
+ if (!album) return {t('albumDetail.notFound')}
;
const { album: info, songs } = album;
const coverUrl = info.coverArt ? buildCoverArtUrl(info.coverArt, 400) : '';
@@ -238,96 +251,95 @@ export default function AlbumDetail() {
/>
)}
-
+
navigate(-1)} style={{ marginBottom: '1rem', gap: '6px' }}>
- Zurück
+ {t('albumDetail.back')}
- {coverUrl ? (
-
- ) : (
-
♪
- )}
-
-
Album
-
{info.name}
-
- navigate(`/artist/${info.artistId}`)}
- >
- {info.artist}
-
-
-
- {info.year && {info.year} }
- {info.genre && · {info.genre} }
- · {songs.length} Tracks
- · {formatDuration(totalDuration)}
- {info.recordLabel && (
- <>
- ·
- navigate(`/label/${encodeURIComponent(info.recordLabel!)}`)}
- >
- {info.recordLabel}
-
- >
- )}
-
-
-
-
- Alle abspielen
-
-
+ ) : (
+
♪
+ )}
+
+
{t('common.album')}
+
{info.name}
+
+ navigate(`/artist/${info.artistId}`)}
>
- Einreihen
+ {info.artist}
+
+
+
+ {info.year && {info.year} }
+ {info.genre && · {info.genre} }
+ · {songs.length} Tracks
+ · {formatDuration(totalDuration)}
+ {info.recordLabel && (
+ <>
+ ·
+ navigate(`/label/${encodeURIComponent(info.recordLabel!)}`)}
+ >
+ {info.recordLabel}
+
+ >
+ )}
+
+
+
+
+ {t('albumDetail.playAll')}
+
+
+ {t('albumDetail.enqueue')}
+
+
+
+
+
+ {t('albumDetail.favorite')}
+
+
+
+ {t('albumDetail.artistBio')}
+
+
handleDownload(info.name, info.id)} disabled={downloading}>
+ {downloading ? t('albumDetail.downloading') : t('albumDetail.download')}
-
-
-
- {isStarred ? 'Favorit' : 'Als Favorit'}
-
-
-
- Künstler-Bio
-
-
handleDownload(info.name, info.id)} disabled={downloading}>
- {downloading ? 'Lade…' : 'Download (ZIP)'}
-
-
#
-
Titel
-
Format
-
Favorit
-
Bewertung
-
Dauer
+
{t('albumDetail.trackTitle')}
+
{t('albumDetail.trackFormat')}
+
{t('albumDetail.trackFavorite')}
+
{t('albumDetail.trackRating')}
+
{t('albumDetail.trackDuration')}
{(() => {
- // Group songs by disc number
const discs = new Map
();
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 }));
}}
>
{song.track ?? i + 1}
@@ -390,10 +399,10 @@ export default function AlbumDetail() {
)}
-
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)' }}
>
@@ -415,7 +424,7 @@ export default function AlbumDetail() {
{relatedAlbums.length > 0 && (
-
Mehr von {info.artist}
+
{t('albumDetail.moreByArtist', { artist: info.artist })}
{relatedAlbums.map(a =>
)}
diff --git a/src/pages/Albums.tsx b/src/pages/Albums.tsx
index 0beb320b..996c0359 100644
--- a/src/pages/Albums.tsx
+++ b/src/pages/Albums.tsx
@@ -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
([]);
const [sort, setSort] = useState('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 (
-
Alle Alben
+
{t('albums.title')}
{sortOptions.map(o => (
{albums.map(a => )}
-
+
diff --git a/src/pages/ArtistDetail.tsx b/src/pages/ArtistDetail.tsx
index 0d9c036a..93abc136 100644
--- a/src/pages/ArtistDetail.tsx
+++ b/src/pages/ArtistDetail.tsx
@@ -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 (
@@ -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(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 (
- Künstler nicht gefunden.
+ {t('artistDetail.notFound')}
);
@@ -138,10 +152,9 @@ export default function ArtistDetail() {
onClick={() => navigate(-1)}
style={{ marginBottom: '1.5rem', display: 'flex', alignItems: 'center', gap: '0.5rem' }}
>
- Zurück
+ {t('artistDetail.back')}
- {/* Header: avatar + name + meta + links */}
{coverId ? (
@@ -161,11 +174,10 @@ export default function ArtistDetail() {
{artist.name}
- {artist.albumCount} {artist.albumCount === 1 ? 'Album' : 'Alben'}
+ {t('artistDetail.albumCount_other', { count: artist.albumCount ?? 0 })}
- {/* External links */}
{(info?.lastFmUrl || artist.name) && (
{info?.lastFmUrl && (
@@ -180,44 +192,43 @@ export default function ArtistDetail() {
)}
-
- {/* Star toggle */}
-
-
- {isStarred ? 'Favorit' : 'Als Favorit'}
+
+ {t('artistDetail.favorite')}
-
+
{topSongs.length > 0 && (
<>
- Alle abspielen
+ {t('artistDetail.playAll')}
- Zufallswiedergabe
+ {t('artistDetail.shuffle')}
>
)}
{radioLoading ?
: }
- {radioLoading ? 'Lädt...' : 'Radio'}
+ {radioLoading ? t('artistDetail.loading') : t('artistDetail.radio')}
- {/* Biography — only shown when available */}
+ {/* Biography — sanitized HTML from server */}
{info?.biography && (
)}
@@ -226,14 +237,14 @@ export default function ArtistDetail() {
{topSongs.length > 0 && (
<>
- Beliebteste Titel
+ {t('artistDetail.topTracks')}
#
-
Titel
-
Album
-
Dauer
+
{t('artistDetail.trackTitle')}
+
{t('artistDetail.trackAlbum')}
+
{t('artistDetail.trackDuration')}
{topSongs.map((song, idx) => (
-
- {idx + 1}
-
-
+
{idx + 1}
{song.coverArt && (
-
{ e.currentTarget.style.display = 'none'; }}
+
{ e.currentTarget.style.display = 'none'; }}
/>
)}
-
{song.album}
-
{formatDuration(song.duration)}
@@ -284,7 +290,7 @@ export default function ArtistDetail() {
{/* Albums */}
0) ? '2rem' : '0', marginBottom: '1rem' }}>
- Alben von {artist.name}
+ {t('artistDetail.albumsBy', { name: artist.name })}
{albums.length > 0 ? (
@@ -292,7 +298,7 @@ export default function ArtistDetail() {
{albums.map(a =>
)}
) : (
-
Keine Alben gefunden.
+
{t('artistDetail.noAlbums')}
)}
);
diff --git a/src/pages/Artists.tsx b/src/pages/Artists.tsx
index d541b72d..3e89456d 100644
--- a/src/pages/Artists.tsx
+++ b/src/pages/Artists.tsx
@@ -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([]);
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() {
-
Künstler
+ {t('artists.title')}
setFilter(e.target.value)}
id="artist-filter-input"
/>
-
+
- 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')}
>
- 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')}
>
@@ -111,7 +114,7 @@ export default function Artists() {
transition: 'all 0.2s'
}}
>
- {l}
+ {l === ALL_SENTINEL ? t('artists.all') : l}
))}
@@ -123,8 +126,8 @@ export default function Artists() {
{visible.map(artist => {
const coverId = artist.coverArt || artist.id;
return (
-
navigate(`/artist/${artist.id}`)}
onContextMenu={(e) => {
@@ -134,8 +137,8 @@ export default function Artists() {
>
{coverId ? (
-
{
@@ -151,7 +154,7 @@ export default function Artists() {
{artist.name}
{artist.albumCount != null && (
-
{artist.albumCount} Alben
+
{t('artists.albumCount', { count: artist.albumCount })}
)}
@@ -181,8 +184,8 @@ export default function Artists() {
>
{coverId ? (
-
{
@@ -198,7 +201,7 @@ export default function Artists() {
{artist.name}
{artist.albumCount != null && (
-
{artist.albumCount} Alben
+
{t('artists.albumCount', { count: artist.albumCount })}
)}
@@ -213,14 +216,14 @@ export default function Artists() {
{!loading && hasMore && (
- Mehr laden
+ {t('artists.loadMore')}
)}
-
+
{!loading && filtered.length === 0 && (
- Keine Künstler gefunden.
+ {t('artists.notFound')}
)}
diff --git a/src/pages/Favorites.tsx b/src/pages/Favorites.tsx
index 811c1ccf..9519eeee 100644
--- a/src/pages/Favorites.tsx
+++ b/src/pages/Favorites.tsx
@@ -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
([]);
const [artists, setArtists] = useState([]);
const [songs, setSongs] = useState([]);
@@ -37,28 +39,27 @@ export default function Favorites() {
return (
-
Favoriten
+ {t('favorites.title')}
{!hasAnyFavorites ? (
-
Du hast noch keine Favoriten gespeichert.
+
{t('favorites.empty')}
) : (
<>
{artists.length > 0 && (
-
+
)}
{albums.length > 0 && (
-
+
)}
{songs.length > 0 && (
-
Songs
+ {t('favorites.songs')}
- {/* Wir können für die Favoriten-Seite ruhig alle Songs anzeigen, statt nur 10 wie auf der Startseite */}
{songs.map((song) => (
-
{ e.stopPropagation(); playTrack(song, songs); }}
>
diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx
index 00f6de5d..4f15c926 100644
--- a/src/pages/Home.tsx
+++ b/src/pages/Home.tsx
@@ -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([]);
@@ -42,6 +43,8 @@ export default function Home() {
}
};
+ const { t } = useTranslation();
+
return (
@@ -55,29 +58,29 @@ export default function Home() {
<>
{starred.length > 0 && (
loadMore('starred', starred, setStarred)}
- moreText="Mehr laden"
+ moreText={t('home.loadMore')}
/>
)}
loadMore('newest', recent, setRecent)}
- moreText="Mehr laden"
+ moreText={t('home.loadMore')}
/>
loadMore('frequent', mostPlayed, setMostPlayed)}
- moreText="Mehr laden"
+ moreText={t('home.loadMore')}
/>
loadMore('random', random, setRandom)}
- moreText="Mehr entdecken"
+ moreText={t('home.discoverMore')}
/>
>
)}
diff --git a/src/pages/LabelAlbums.tsx b/src/pages/LabelAlbums.tsx
index d58b2910..3d86489f 100644
--- a/src/pages/LabelAlbums.tsx
+++ b/src/pages/LabelAlbums.tsx
@@ -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([]);
@@ -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 (
navigate(-1)} style={{ margin: '1rem 0', gap: '6px' }}>
- Zurück
+ {t('common.back')}
@@ -46,7 +48,7 @@ export default function LabelAlbums() {
) : albums.length === 0 ? (
- Keine Alben für dieses Label gefunden.
+ {t('common.noAlbums')}
) : (
{albums.map(a => (
diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx
index c1ae7cc4..33f6cf86 100644
--- a/src/pages/Login.tsx
+++ b/src/pages/Login.tsx
@@ -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 = () => (
-
-
- P
-
-
-
-
-
-
-
-
-
-
-
-
+
);
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
) =>
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 (
@@ -80,64 +93,72 @@ export default function Login() {
Psysonic
- Dein Navidrome Desktop Player
+ {t('login.subtitle')}
-