feat: Replaced all 28 JS hover handlers in Settings.tsx with CSS utilit…

- "src/frontend/src/pages/Settings.tsx"
- "src/frontend/src/styles/global.css"
- "src/frontend/src/components/Skeleton.tsx"

GSD-Task: S02/T03
This commit is contained in:
jlightner 2026-04-03 04:21:08 +00:00
parent 3355326526
commit 538f9ec69b
3 changed files with 117 additions and 229 deletions

View file

@ -174,6 +174,65 @@ export function SkeletonSystem() {
); );
} }
/** Skeleton for the settings page. */
export function SkeletonSettings() {
return (
<div>
<Skeleton width={120} height={28} style={{ marginBottom: 'var(--space-6)' }} />
{/* General section */}
<div style={{ marginBottom: 'var(--space-8)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)', marginBottom: 'var(--space-4)' }}>
<Skeleton width={20} height={20} borderRadius="var(--radius-sm)" />
<Skeleton width={80} height={20} />
</div>
<div
style={{
backgroundColor: 'var(--bg-card)',
borderRadius: 'var(--radius-xl)',
border: '1px solid var(--border)',
padding: 'var(--space-4)',
}}
>
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} style={{ display: 'flex', gap: 'var(--space-4)', marginBottom: i < 3 ? 'var(--space-4)' : 0, alignItems: 'center' }}>
<Skeleton width={140} height={14} />
<Skeleton width={250} height={32} borderRadius="var(--radius-md)" />
</div>
))}
</div>
</div>
{/* Platform Settings section */}
<div style={{ marginBottom: 'var(--space-8)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)', marginBottom: 'var(--space-4)' }}>
<Skeleton width={20} height={20} borderRadius="var(--radius-sm)" />
<Skeleton width={140} height={20} />
</div>
<SkeletonTable rows={2} columns={6} />
</div>
{/* Format Profiles section */}
<div style={{ marginBottom: 'var(--space-8)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 'var(--space-4)' }}>
<Skeleton width={130} height={20} />
<Skeleton width={110} height={32} borderRadius="var(--radius-md)" />
</div>
<SkeletonTable rows={3} columns={6} />
</div>
{/* Notifications section */}
<div style={{ marginBottom: 'var(--space-8)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 'var(--space-4)' }}>
<Skeleton width={120} height={20} />
<Skeleton width={110} height={32} borderRadius="var(--radius-md)" />
</div>
<SkeletonTable rows={2} columns={5} />
</div>
</div>
);
}
/** Skeleton for the channels list page. */ /** Skeleton for the channels list page. */
export function SkeletonChannelsList({ rows = 4 }: { rows?: number }) { export function SkeletonChannelsList({ rows = 4 }: { rows?: number }) {
return ( return (

View file

@ -25,6 +25,7 @@ import { Modal } from '../components/Modal';
import { FormatProfileForm, type FormatProfileFormValues } from '../components/FormatProfileForm'; import { FormatProfileForm, type FormatProfileFormValues } from '../components/FormatProfileForm';
import { PlatformSettingsForm, type PlatformSettingsFormValues } from '../components/PlatformSettingsForm'; import { PlatformSettingsForm, type PlatformSettingsFormValues } from '../components/PlatformSettingsForm';
import { NotificationForm, type NotificationFormValues } from '../components/NotificationForm'; import { NotificationForm, type NotificationFormValues } from '../components/NotificationForm';
import { SkeletonSettings } from '../components/Skeleton';
import type { FormatProfile, PlatformSettings } from '@shared/types/index'; import type { FormatProfile, PlatformSettings } from '@shared/types/index';
// ── Badge styles ── // ── Badge styles ──
@ -40,17 +41,6 @@ const badgeBase: React.CSSProperties = {
letterSpacing: '0.04em', letterSpacing: '0.04em',
}; };
const iconButtonBase: React.CSSProperties = {
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: 28,
height: 28,
borderRadius: 'var(--radius-sm)',
color: 'var(--text-muted)',
transition: 'color var(--transition-fast), background-color var(--transition-fast)',
};
// ── Component ── // ── Component ──
export function SettingsPage() { export function SettingsPage() {
@ -297,15 +287,7 @@ export function SettingsPage() {
onClick={(e) => { e.stopPropagation(); setEditingPlatform(row.platform); }} onClick={(e) => { e.stopPropagation(); setEditingPlatform(row.platform); }}
title={`Edit ${row.label} settings`} title={`Edit ${row.label} settings`}
aria-label={`Edit ${row.label} settings`} aria-label={`Edit ${row.label} settings`}
style={iconButtonBase} className="btn-icon btn-icon-edit"
onMouseEnter={(e) => {
e.currentTarget.style.color = 'var(--accent)';
e.currentTarget.style.backgroundColor = 'var(--accent-subtle)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.color = 'var(--text-muted)';
e.currentTarget.style.backgroundColor = 'transparent';
}}
> >
<Pencil size={14} /> <Pencil size={14} />
</button> </button>
@ -479,15 +461,7 @@ export function SettingsPage() {
onClick={(e) => { e.stopPropagation(); setEditingProfile(p); }} onClick={(e) => { e.stopPropagation(); setEditingProfile(p); }}
title="Edit profile" title="Edit profile"
aria-label={`Edit ${p.name}`} aria-label={`Edit ${p.name}`}
style={iconButtonBase} className="btn-icon btn-icon-edit"
onMouseEnter={(e) => {
e.currentTarget.style.color = 'var(--accent)';
e.currentTarget.style.backgroundColor = 'var(--accent-subtle)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.color = 'var(--text-muted)';
e.currentTarget.style.backgroundColor = 'transparent';
}}
> >
<Pencil size={14} /> <Pencil size={14} />
</button> </button>
@ -496,15 +470,7 @@ export function SettingsPage() {
onClick={(e) => { e.stopPropagation(); setDeletingProfile(p); }} onClick={(e) => { e.stopPropagation(); setDeletingProfile(p); }}
title="Delete profile" title="Delete profile"
aria-label={`Delete ${p.name}`} aria-label={`Delete ${p.name}`}
style={iconButtonBase} className="btn-icon btn-icon-delete"
onMouseEnter={(e) => {
e.currentTarget.style.color = 'var(--danger)';
e.currentTarget.style.backgroundColor = 'var(--danger-bg)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.color = 'var(--text-muted)';
e.currentTarget.style.backgroundColor = 'transparent';
}}
> >
<Trash2 size={14} /> <Trash2 size={14} />
</button> </button>
@ -587,18 +553,8 @@ export function SettingsPage() {
title="Send test notification" title="Send test notification"
aria-label={`Test ${n.name}`} aria-label={`Test ${n.name}`}
disabled={result === 'loading'} disabled={result === 'loading'}
style={{ className="btn-icon btn-icon-test"
...iconButtonBase, style={{ opacity: result === 'loading' ? 0.5 : 1 }}
opacity: result === 'loading' ? 0.5 : 1,
}}
onMouseEnter={(e) => {
e.currentTarget.style.color = 'var(--success)';
e.currentTarget.style.backgroundColor = 'var(--success-bg)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.color = 'var(--text-muted)';
e.currentTarget.style.backgroundColor = 'transparent';
}}
> >
{result === 'loading' {result === 'loading'
? <Loader size={14} style={{ animation: 'spin 1s linear infinite' }} /> ? <Loader size={14} style={{ animation: 'spin 1s linear infinite' }} />
@ -611,15 +567,7 @@ export function SettingsPage() {
onClick={(e) => { e.stopPropagation(); setEditingNotification(n); }} onClick={(e) => { e.stopPropagation(); setEditingNotification(n); }}
title="Edit channel" title="Edit channel"
aria-label={`Edit ${n.name}`} aria-label={`Edit ${n.name}`}
style={iconButtonBase} className="btn-icon btn-icon-edit"
onMouseEnter={(e) => {
e.currentTarget.style.color = 'var(--accent)';
e.currentTarget.style.backgroundColor = 'var(--accent-subtle)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.color = 'var(--text-muted)';
e.currentTarget.style.backgroundColor = 'transparent';
}}
> >
<Pencil size={14} /> <Pencil size={14} />
</button> </button>
@ -629,15 +577,7 @@ export function SettingsPage() {
onClick={(e) => { e.stopPropagation(); setDeletingNotification(n); }} onClick={(e) => { e.stopPropagation(); setDeletingNotification(n); }}
title="Delete channel" title="Delete channel"
aria-label={`Delete ${n.name}`} aria-label={`Delete ${n.name}`}
style={iconButtonBase} className="btn-icon btn-icon-delete"
onMouseEnter={(e) => {
e.currentTarget.style.color = 'var(--danger)';
e.currentTarget.style.backgroundColor = 'var(--danger-bg)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.color = 'var(--text-muted)';
e.currentTarget.style.backgroundColor = 'transparent';
}}
> >
<Trash2 size={14} /> <Trash2 size={14} />
</button> </button>
@ -652,12 +592,7 @@ export function SettingsPage() {
// ── Loading state ── // ── Loading state ──
if (profilesLoading) { if (profilesLoading) {
return ( return <SkeletonSettings />;
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 'var(--space-12)', color: 'var(--text-muted)' }}>
<Loader size={20} style={{ animation: 'spin 1s linear infinite', marginRight: 'var(--space-3)' }} />
Loading settings...
</div>
);
} }
// ── Error state ── // ── Error state ──
@ -683,17 +618,7 @@ export function SettingsPage() {
<button <button
onClick={() => refetchProfiles()} onClick={() => refetchProfiles()}
aria-label="Retry" aria-label="Retry"
style={{ className="btn btn-danger"
display: 'inline-flex',
alignItems: 'center',
gap: 'var(--space-2)',
padding: 'var(--space-2) var(--space-3)',
borderRadius: 'var(--radius-md)',
backgroundColor: 'var(--danger)',
color: '#fff',
fontSize: 'var(--font-size-sm)',
fontWeight: 600,
}}
> >
<RefreshCw size={14} /> <RefreshCw size={14} />
Retry Retry
@ -774,15 +699,7 @@ export function SettingsPage() {
onClick={() => setShowApiKey((v) => !v)} onClick={() => setShowApiKey((v) => !v)}
title={showApiKey ? 'Hide API key' : 'Show API key'} title={showApiKey ? 'Hide API key' : 'Show API key'}
aria-label={showApiKey ? 'Hide API key' : 'Show API key'} aria-label={showApiKey ? 'Hide API key' : 'Show API key'}
style={iconButtonBase} className="btn-icon btn-icon-edit"
onMouseEnter={(e) => {
e.currentTarget.style.color = 'var(--accent)';
e.currentTarget.style.backgroundColor = 'var(--accent-subtle)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.color = 'var(--text-muted)';
e.currentTarget.style.backgroundColor = 'transparent';
}}
> >
{showApiKey ? <EyeOff size={14} /> : <Eye size={14} />} {showApiKey ? <EyeOff size={14} /> : <Eye size={14} />}
</button> </button>
@ -792,22 +709,8 @@ export function SettingsPage() {
onClick={handleCopyApiKey} onClick={handleCopyApiKey}
title={copySuccess ? 'Copied!' : 'Copy to clipboard'} title={copySuccess ? 'Copied!' : 'Copy to clipboard'}
aria-label="Copy API key to clipboard" aria-label="Copy API key to clipboard"
style={{ className="btn-icon btn-icon-edit"
...iconButtonBase, style={copySuccess ? { color: 'var(--success)' } : undefined}
color: copySuccess ? 'var(--success)' : 'var(--text-muted)',
}}
onMouseEnter={(e) => {
if (!copySuccess) {
e.currentTarget.style.color = 'var(--accent)';
e.currentTarget.style.backgroundColor = 'var(--accent-subtle)';
}
}}
onMouseLeave={(e) => {
if (!copySuccess) {
e.currentTarget.style.color = 'var(--text-muted)';
e.currentTarget.style.backgroundColor = 'transparent';
}
}}
> >
{copySuccess ? <CheckCircle size={14} /> : <Copy size={14} />} {copySuccess ? <CheckCircle size={14} /> : <Copy size={14} />}
</button> </button>
@ -817,15 +720,7 @@ export function SettingsPage() {
onClick={() => setShowRegenerateConfirm(true)} onClick={() => setShowRegenerateConfirm(true)}
title="Regenerate API key" title="Regenerate API key"
aria-label="Regenerate API key" aria-label="Regenerate API key"
style={iconButtonBase} className="btn-icon btn-icon-warning"
onMouseEnter={(e) => {
e.currentTarget.style.color = 'var(--warning)';
e.currentTarget.style.backgroundColor = 'var(--warning-bg)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.color = 'var(--text-muted)';
e.currentTarget.style.backgroundColor = 'transparent';
}}
> >
<RotateCw size={14} /> <RotateCw size={14} />
</button> </button>
@ -911,17 +806,10 @@ export function SettingsPage() {
<button <button
onClick={handleSaveSettings} onClick={handleSaveSettings}
disabled={!settingsDirty || !settingsValid || updateAppSettingsMutation.isPending} disabled={!settingsDirty || !settingsValid || updateAppSettingsMutation.isPending}
className={`btn ${settingsSaveFlash ? 'btn-primary' : 'btn-primary'}`}
style={{ style={{
display: 'inline-flex', backgroundColor: settingsSaveFlash ? 'var(--success)' : undefined,
alignItems: 'center', borderColor: settingsSaveFlash ? 'var(--success)' : undefined,
gap: 'var(--space-2)',
padding: 'var(--space-2) var(--space-4)',
borderRadius: 'var(--radius-md)',
backgroundColor: settingsSaveFlash ? 'var(--success)' : 'var(--accent)',
color: 'var(--text-inverse)',
fontSize: 'var(--font-size-sm)',
fontWeight: 600,
transition: 'background-color var(--transition-fast), opacity var(--transition-fast)',
opacity: !settingsDirty || !settingsValid ? 0.5 : 1, opacity: !settingsDirty || !settingsValid ? 0.5 : 1,
cursor: !settingsDirty || !settingsValid ? 'not-allowed' : 'pointer', cursor: !settingsDirty || !settingsValid ? 'not-allowed' : 'pointer',
}} }}
@ -992,20 +880,7 @@ export function SettingsPage() {
</h2> </h2>
<button <button
onClick={() => setShowCreateProfileModal(true)} onClick={() => setShowCreateProfileModal(true)}
style={{ className="btn btn-primary"
display: 'inline-flex',
alignItems: 'center',
gap: 'var(--space-2)',
padding: 'var(--space-2) var(--space-4)',
borderRadius: 'var(--radius-md)',
backgroundColor: 'var(--accent)',
color: 'var(--text-inverse)',
fontSize: 'var(--font-size-sm)',
fontWeight: 600,
transition: 'background-color var(--transition-fast)',
}}
onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = 'var(--accent-hover)')}
onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = 'var(--accent)')}
> >
<Plus size={16} /> <Plus size={16} />
Add Profile Add Profile
@ -1045,20 +920,7 @@ export function SettingsPage() {
</h2> </h2>
<button <button
onClick={() => setShowCreateNotifModal(true)} onClick={() => setShowCreateNotifModal(true)}
style={{ className="btn btn-primary"
display: 'inline-flex',
alignItems: 'center',
gap: 'var(--space-2)',
padding: 'var(--space-2) var(--space-4)',
borderRadius: 'var(--radius-md)',
backgroundColor: 'var(--accent)',
color: 'var(--text-inverse)',
fontSize: 'var(--font-size-sm)',
fontWeight: 600,
transition: 'background-color var(--transition-fast)',
}}
onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = 'var(--accent-hover)')}
onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = 'var(--accent)')}
> >
<Plus size={16} /> <Plus size={16} />
Add Channel Add Channel
@ -1148,36 +1010,15 @@ export function SettingsPage() {
<button <button
onClick={() => setDeletingProfile(null)} onClick={() => setDeletingProfile(null)}
disabled={deleteProfileMutation.isPending} disabled={deleteProfileMutation.isPending}
style={{ className="btn btn-ghost"
padding: 'var(--space-2) var(--space-4)',
borderRadius: 'var(--radius-md)',
backgroundColor: 'var(--bg-hover)',
color: 'var(--text-primary)',
fontSize: 'var(--font-size-sm)',
fontWeight: 500,
transition: 'background-color var(--transition-fast)',
}}
onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = 'var(--bg-selected)')}
onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = 'var(--bg-hover)')}
> >
Cancel Cancel
</button> </button>
<button <button
onClick={handleDeleteProfile} onClick={handleDeleteProfile}
disabled={deleteProfileMutation.isPending} disabled={deleteProfileMutation.isPending}
style={{ className="btn btn-danger"
display: 'inline-flex', style={{ opacity: deleteProfileMutation.isPending ? 0.6 : 1 }}
alignItems: 'center',
gap: 'var(--space-2)',
padding: 'var(--space-2) var(--space-4)',
borderRadius: 'var(--radius-md)',
backgroundColor: 'var(--danger)',
color: '#fff',
fontSize: 'var(--font-size-sm)',
fontWeight: 600,
transition: 'background-color var(--transition-fast)',
opacity: deleteProfileMutation.isPending ? 0.6 : 1,
}}
> >
{deleteProfileMutation.isPending && <Loader size={14} style={{ animation: 'spin 1s linear infinite' }} />} {deleteProfileMutation.isPending && <Loader size={14} style={{ animation: 'spin 1s linear infinite' }} />}
Delete Delete
@ -1271,36 +1112,15 @@ export function SettingsPage() {
<button <button
onClick={() => setDeletingNotification(null)} onClick={() => setDeletingNotification(null)}
disabled={deleteNotifMutation.isPending} disabled={deleteNotifMutation.isPending}
style={{ className="btn btn-ghost"
padding: 'var(--space-2) var(--space-4)',
borderRadius: 'var(--radius-md)',
backgroundColor: 'var(--bg-hover)',
color: 'var(--text-primary)',
fontSize: 'var(--font-size-sm)',
fontWeight: 500,
transition: 'background-color var(--transition-fast)',
}}
onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = 'var(--bg-selected)')}
onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = 'var(--bg-hover)')}
> >
Cancel Cancel
</button> </button>
<button <button
onClick={handleDeleteNotification} onClick={handleDeleteNotification}
disabled={deleteNotifMutation.isPending} disabled={deleteNotifMutation.isPending}
style={{ className="btn btn-danger"
display: 'inline-flex', style={{ opacity: deleteNotifMutation.isPending ? 0.6 : 1 }}
alignItems: 'center',
gap: 'var(--space-2)',
padding: 'var(--space-2) var(--space-4)',
borderRadius: 'var(--radius-md)',
backgroundColor: 'var(--danger)',
color: '#fff',
fontSize: 'var(--font-size-sm)',
fontWeight: 600,
transition: 'background-color var(--transition-fast)',
opacity: deleteNotifMutation.isPending ? 0.6 : 1,
}}
> >
{deleteNotifMutation.isPending && <Loader size={14} style={{ animation: 'spin 1s linear infinite' }} />} {deleteNotifMutation.isPending && <Loader size={14} style={{ animation: 'spin 1s linear infinite' }} />}
Delete Delete
@ -1338,36 +1158,15 @@ export function SettingsPage() {
<button <button
onClick={() => setShowRegenerateConfirm(false)} onClick={() => setShowRegenerateConfirm(false)}
disabled={regenerateApiKeyMutation.isPending} disabled={regenerateApiKeyMutation.isPending}
style={{ className="btn btn-ghost"
padding: 'var(--space-2) var(--space-4)',
borderRadius: 'var(--radius-md)',
backgroundColor: 'var(--bg-hover)',
color: 'var(--text-primary)',
fontSize: 'var(--font-size-sm)',
fontWeight: 500,
transition: 'background-color var(--transition-fast)',
}}
onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = 'var(--bg-selected)')}
onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = 'var(--bg-hover)')}
> >
Cancel Cancel
</button> </button>
<button <button
onClick={handleRegenerateApiKey} onClick={handleRegenerateApiKey}
disabled={regenerateApiKeyMutation.isPending} disabled={regenerateApiKeyMutation.isPending}
style={{ className="btn btn-warning"
display: 'inline-flex', style={{ opacity: regenerateApiKeyMutation.isPending ? 0.6 : 1 }}
alignItems: 'center',
gap: 'var(--space-2)',
padding: 'var(--space-2) var(--space-4)',
borderRadius: 'var(--radius-md)',
backgroundColor: 'var(--warning)',
color: '#fff',
fontSize: 'var(--font-size-sm)',
fontWeight: 600,
transition: 'background-color var(--transition-fast)',
opacity: regenerateApiKeyMutation.isPending ? 0.6 : 1,
}}
> >
{regenerateApiKeyMutation.isPending && <Loader size={14} style={{ animation: 'spin 1s linear infinite' }} />} {regenerateApiKeyMutation.isPending && <Loader size={14} style={{ animation: 'spin 1s linear infinite' }} />}
Regenerate Regenerate

View file

@ -257,6 +257,36 @@ tr:hover {
background-color: var(--bg-hover); background-color: var(--bg-hover);
} }
.btn-icon-edit:hover {
color: var(--accent);
background-color: var(--accent-subtle);
}
.btn-icon-delete:hover {
color: var(--danger);
background-color: var(--danger-bg);
}
.btn-icon-test:hover {
color: var(--success);
background-color: var(--success-bg);
}
.btn-icon-warning:hover {
color: var(--warning);
background-color: var(--warning-bg);
}
.btn-warning {
background-color: var(--warning);
color: #fff;
border-color: var(--warning);
}
.btn-warning:hover:not(:disabled) {
filter: brightness(1.15);
}
/* ── Modal animation ── */ /* ── Modal animation ── */
@keyframes modal-enter { @keyframes modal-enter {
from { from {