chore: prepare release v1.0.1 and ignore CLAUDE.md

This commit is contained in:
Psychotoxical
2026-03-11 21:56:26 +01:00
parent 730eb877ea
commit 6456b3e561
35 changed files with 1355 additions and 740 deletions
+198 -71
View File
@@ -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>