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. */
export function SkeletonChannelsList({ rows = 4 }: { rows?: number }) {
return (

View file

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

View file

@ -257,6 +257,36 @@ tr: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 ── */
@keyframes modal-enter {
from {