feat(S02): modern UI design system — theme overhaul, skeletons, animations

- Redesigned theme.css: deeper darks (#0f1117), glassmorphism tokens,
  accent glow, softer borders (rgba), spring/slow transitions, new radii
- Added Inter + JetBrains Mono web fonts for premium typography
- Enhanced global.css: focus rings with box-shadow, skeleton shimmer animation,
  glass-card class, fade-in page transitions, slide-up animations, badge-glow pulse
- Created Skeleton component library: Skeleton, SkeletonRow, SkeletonTable,
  SkeletonChannelHeader, SkeletonChannelsList
- Replaced spinner-text loading states with skeleton placeholders
  (Channels page, ChannelDetail header and content table)
- Updated all card border-radius from radius-lg to radius-xl across all pages
- Added responsive breakpoint stub for mobile (768px)
This commit is contained in:
jlightner 2026-04-03 02:33:49 +00:00
parent c057b6a286
commit ac8905ca38
6 changed files with 268 additions and 71 deletions

View file

@ -0,0 +1,111 @@
/**
* Skeleton loading placeholder components.
* Uses the .skeleton CSS class for shimmer animation.
*/
interface SkeletonProps {
width?: string | number;
height?: string | number;
borderRadius?: string;
style?: React.CSSProperties;
}
/** Generic skeleton block. */
export function Skeleton({ width = '100%', height = 16, borderRadius, style }: SkeletonProps) {
return (
<div
className="skeleton"
style={{
width,
height,
borderRadius: borderRadius ?? 'var(--radius-md)',
...style,
}}
/>
);
}
/** Skeleton for a table row. */
export function SkeletonRow({ columns = 6 }: { columns?: number }) {
return (
<tr>
{Array.from({ length: columns }).map((_, i) => (
<td key={i}>
<Skeleton
width={i === 0 ? 32 : i === 1 ? '70%' : '50%'}
height={i === 0 ? 32 : 14}
borderRadius={i === 0 ? '50%' : undefined}
/>
</td>
))}
</tr>
);
}
/** Skeleton for the full content table. */
export function SkeletonTable({ rows = 8, columns = 6 }: { rows?: number; columns?: number }) {
return (
<div style={{ padding: 'var(--space-4)' }}>
<table style={{ width: '100%' }}>
<tbody>
{Array.from({ length: rows }).map((_, i) => (
<SkeletonRow key={i} columns={columns} />
))}
</tbody>
</table>
</div>
);
}
/** Skeleton for the channel detail header. */
export function SkeletonChannelHeader() {
return (
<div
style={{
display: 'flex',
gap: 'var(--space-5)',
padding: 'var(--space-5)',
backgroundColor: 'var(--bg-card)',
borderRadius: 'var(--radius-xl)',
border: '1px solid var(--border)',
marginBottom: 'var(--space-6)',
}}
>
<Skeleton width={80} height={80} borderRadius="50%" />
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 'var(--space-3)' }}>
<Skeleton width={200} height={24} />
<Skeleton width={300} height={14} />
<div style={{ display: 'flex', gap: 'var(--space-3)', marginTop: 'var(--space-2)' }}>
<Skeleton width={120} height={32} borderRadius="var(--radius-md)" />
<Skeleton width={120} height={32} borderRadius="var(--radius-md)" />
<Skeleton width={100} height={32} borderRadius="var(--radius-md)" />
</div>
</div>
</div>
);
}
/** Skeleton for the channels list page. */
export function SkeletonChannelsList({ rows = 4 }: { rows?: number }) {
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 'var(--space-6)' }}>
<Skeleton width={120} height={28} />
<div style={{ display: 'flex', gap: 'var(--space-3)' }}>
<Skeleton width={110} height={34} borderRadius="var(--radius-md)" />
<Skeleton width={140} height={34} borderRadius="var(--radius-md)" />
</div>
</div>
<div
style={{
backgroundColor: 'var(--bg-card)',
borderRadius: 'var(--radius-xl)',
border: '1px solid var(--border)',
overflow: 'hidden',
}}
>
<SkeletonTable rows={rows} columns={7} />
</div>
</div>
);
}

View file

@ -28,6 +28,7 @@ import { PlatformBadge } from '../components/PlatformBadge';
import { StatusBadge } from '../components/StatusBadge'; import { StatusBadge } from '../components/StatusBadge';
import { QualityLabel } from '../components/QualityLabel'; import { QualityLabel } from '../components/QualityLabel';
import { DownloadProgressBar } from '../components/DownloadProgressBar'; import { DownloadProgressBar } from '../components/DownloadProgressBar';
import { SkeletonChannelHeader, SkeletonTable } from '../components/Skeleton';
import { Pagination } from '../components/Pagination'; import { Pagination } from '../components/Pagination';
import { Modal } from '../components/Modal'; import { Modal } from '../components/Modal';
import { useDownloadProgress } from '../contexts/DownloadProgressContext'; import { useDownloadProgress } from '../contexts/DownloadProgressContext';
@ -667,9 +668,11 @@ export function ChannelDetail() {
if (channelLoading) { if (channelLoading) {
return ( return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 'var(--space-12)', color: 'var(--text-muted)' }}> <div>
<Loader size={20} style={{ animation: 'spin 1s linear infinite', marginRight: 'var(--space-3)' }} /> <SkeletonChannelHeader />
Loading channel... <div style={{ backgroundColor: 'var(--bg-card)', borderRadius: 'var(--radius-xl)', border: '1px solid var(--border)', overflow: 'hidden' }}>
<SkeletonTable rows={10} columns={8} />
</div>
</div> </div>
); );
} }
@ -735,7 +738,7 @@ export function ChannelDetail() {
gap: 'var(--space-5)', gap: 'var(--space-5)',
padding: 'var(--space-5)', padding: 'var(--space-5)',
backgroundColor: 'var(--bg-card)', backgroundColor: 'var(--bg-card)',
borderRadius: 'var(--radius-lg)', borderRadius: 'var(--radius-xl)',
border: '1px solid var(--border)', border: '1px solid var(--border)',
marginBottom: 'var(--space-6)', marginBottom: 'var(--space-6)',
alignItems: 'flex-start', alignItems: 'flex-start',
@ -1030,7 +1033,7 @@ export function ChannelDetail() {
<div <div
style={{ style={{
backgroundColor: 'var(--bg-card)', backgroundColor: 'var(--bg-card)',
borderRadius: 'var(--radius-lg)', borderRadius: 'var(--radius-xl)',
border: '1px solid var(--border)', border: '1px solid var(--border)',
overflow: 'hidden', overflow: 'hidden',
}} }}
@ -1157,10 +1160,7 @@ export function ChannelDetail() {
</div> </div>
) : null} ) : null}
{contentLoading ? ( {contentLoading ? (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 'var(--space-8)', color: 'var(--text-muted)' }}> <SkeletonTable rows={8} columns={8} />
<Loader size={18} style={{ animation: 'spin 1s linear infinite', marginRight: 'var(--space-3)' }} />
Loading content...
</div>
) : hasPlaylistGroups ? ( ) : hasPlaylistGroups ? (
renderPlaylistGroups(playlistGroups!) renderPlaylistGroups(playlistGroups!)
) : ( ) : (
@ -1196,7 +1196,7 @@ export function ChannelDetail() {
padding: 'var(--space-3) var(--space-5)', padding: 'var(--space-3) var(--space-5)',
backgroundColor: 'var(--bg-card)', backgroundColor: 'var(--bg-card)',
border: '1px solid var(--border)', border: '1px solid var(--border)',
borderRadius: 'var(--radius-lg)', borderRadius: 'var(--radius-xl)',
boxShadow: 'var(--shadow-lg)', boxShadow: 'var(--shadow-lg)',
}} }}
> >

View file

@ -8,6 +8,7 @@ import { PlatformBadge } from '../components/PlatformBadge';
import { StatusBadge } from '../components/StatusBadge'; import { StatusBadge } from '../components/StatusBadge';
import { ProgressBar } from '../components/ProgressBar'; import { ProgressBar } from '../components/ProgressBar';
import { AddChannelModal } from '../components/AddChannelModal'; import { AddChannelModal } from '../components/AddChannelModal';
import { SkeletonChannelsList } from '../components/Skeleton';
import type { ChannelWithCounts } from '@shared/types/api'; import type { ChannelWithCounts } from '@shared/types/api';
// ── Helpers ── // ── Helpers ──
@ -184,12 +185,7 @@ export function Channels() {
); );
if (isLoading) { if (isLoading) {
return ( return <SkeletonChannelsList />;
<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 channels...
</div>
);
} }
if (error) { if (error) {
@ -339,7 +335,7 @@ export function Channels() {
<div <div
style={{ style={{
backgroundColor: 'var(--bg-card)', backgroundColor: 'var(--bg-card)',
borderRadius: 'var(--radius-lg)', borderRadius: 'var(--radius-xl)',
border: '1px solid var(--border)', border: '1px solid var(--border)',
overflow: 'hidden', overflow: 'hidden',
}} }}

View file

@ -725,7 +725,7 @@ export function SettingsPage() {
<div <div
style={{ style={{
backgroundColor: 'var(--bg-card)', backgroundColor: 'var(--bg-card)',
borderRadius: 'var(--radius-lg)', borderRadius: 'var(--radius-xl)',
border: '1px solid var(--border)', border: '1px solid var(--border)',
overflow: 'hidden', overflow: 'hidden',
}} }}
@ -963,7 +963,7 @@ export function SettingsPage() {
<div <div
style={{ style={{
backgroundColor: 'var(--bg-card)', backgroundColor: 'var(--bg-card)',
borderRadius: 'var(--radius-lg)', borderRadius: 'var(--radius-xl)',
border: '1px solid var(--border)', border: '1px solid var(--border)',
overflow: 'hidden', overflow: 'hidden',
}} }}
@ -1015,7 +1015,7 @@ export function SettingsPage() {
<div <div
style={{ style={{
backgroundColor: 'var(--bg-card)', backgroundColor: 'var(--bg-card)',
borderRadius: 'var(--radius-lg)', borderRadius: 'var(--radius-xl)',
border: '1px solid var(--border)', border: '1px solid var(--border)',
overflow: 'hidden', overflow: 'hidden',
}} }}
@ -1068,7 +1068,7 @@ export function SettingsPage() {
<div <div
style={{ style={{
backgroundColor: 'var(--bg-card)', backgroundColor: 'var(--bg-card)',
borderRadius: 'var(--radius-lg)', borderRadius: 'var(--radius-xl)',
border: '1px solid var(--border)', border: '1px solid var(--border)',
overflow: 'hidden', overflow: 'hidden',
}} }}

View file

@ -1,5 +1,6 @@
/* ── Global Reset & Base Styles ── */ /* ── Global Reset & Base Styles ── */
@import './theme.css'; @import './theme.css';
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap');
*, *,
*::before, *::before,
@ -13,6 +14,7 @@ html {
font-size: 16px; font-size: 16px;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
} }
body { body {
@ -22,6 +24,7 @@ body {
background-color: var(--bg-main); background-color: var(--bg-main);
line-height: 1.5; line-height: 1.5;
min-height: 100vh; min-height: 100vh;
letter-spacing: -0.01em;
} }
#root { #root {
@ -32,6 +35,7 @@ body {
a { a {
color: var(--text-link); color: var(--text-link);
text-decoration: none; text-decoration: none;
transition: color var(--transition-fast);
} }
a:hover { a:hover {
@ -42,25 +46,26 @@ a:hover {
:focus-visible { :focus-visible {
outline: 2px solid var(--accent); outline: 2px solid var(--accent);
outline-offset: 2px; outline-offset: 2px;
border-radius: var(--radius-sm);
} }
/* ── Scrollbar styling ── */ /* ── Scrollbar styling ── */
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 8px; width: 6px;
height: 8px; height: 6px;
} }
::-webkit-scrollbar-track { ::-webkit-scrollbar-track {
background: var(--bg-main); background: transparent;
} }
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
background: var(--border-light); background: rgba(255, 255, 255, 0.08);
border-radius: 4px; border-radius: 3px;
} }
::-webkit-scrollbar-thumb:hover { ::-webkit-scrollbar-thumb:hover {
background: var(--text-muted); background: rgba(255, 255, 255, 0.15);
} }
/* ── Buttons base ── */ /* ── Buttons base ── */
@ -71,6 +76,7 @@ button {
border: none; border: none;
background: none; background: none;
color: inherit; color: inherit;
transition: all var(--transition-fast);
} }
/* ── Inputs base ── */ /* ── Inputs base ── */
@ -81,15 +87,17 @@ select {
font-size: inherit; font-size: inherit;
color: var(--text-primary); color: var(--text-primary);
background-color: var(--bg-input); background-color: var(--bg-input);
border: 1px solid var(--border); border: 1px solid var(--border-light);
border-radius: var(--radius-md); border-radius: var(--radius-md);
padding: var(--space-2) var(--space-3); padding: var(--space-2) var(--space-3);
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
} }
input:focus, input:focus,
textarea:focus, textarea:focus,
select:focus { select:focus {
border-color: var(--accent); border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-subtle);
outline: none; outline: none;
} }
@ -106,10 +114,10 @@ table {
th { th {
text-align: left; text-align: left;
font-weight: 600; font-weight: 600;
font-size: var(--font-size-sm); font-size: var(--font-size-xs);
color: var(--text-secondary); color: var(--text-muted);
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.03em; letter-spacing: 0.06em;
padding: var(--space-3) var(--space-4); padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
} }
@ -136,6 +144,43 @@ tr:hover {
border-width: 0; border-width: 0;
} }
/* ── Glassmorphism card ── */
.glass-card {
background: var(--glass-bg);
backdrop-filter: blur(var(--glass-blur));
-webkit-backdrop-filter: blur(var(--glass-blur));
border: 1px solid var(--glass-border);
border-radius: var(--radius-xl);
}
/* ── Skeleton loader ── */
.skeleton {
background: linear-gradient(
90deg,
var(--bg-input) 25%,
rgba(255, 255, 255, 0.04) 50%,
var(--bg-input) 75%
);
background-size: 200% 100%;
animation: skeleton-shimmer 1.8s ease-in-out infinite;
border-radius: var(--radius-md);
}
@keyframes skeleton-shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
/* ── Status badge glow ── */
.badge-pulse {
animation: badge-glow 2s ease-in-out infinite;
}
@keyframes badge-glow {
0%, 100% { box-shadow: none; }
50% { box-shadow: 0 0 8px var(--accent-glow); }
}
/* ── Animations ── */ /* ── Animations ── */
@keyframes pulse { @keyframes pulse {
0%, 100% { opacity: 1; } 0%, 100% { opacity: 1; }
@ -146,3 +191,34 @@ tr:hover {
from { transform: rotate(0deg); } from { transform: rotate(0deg); }
to { transform: rotate(360deg); } to { transform: rotate(360deg); }
} }
@keyframes fade-in {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes slide-up {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
/* ── Page transition ── */
main {
animation: fade-in 200ms ease-out;
}
/* ── Table row transitions ── */
tbody tr {
transition: background-color var(--transition-fast);
}
tbody tr:hover {
background-color: var(--bg-hover);
}
/* ── Responsive ── */
@media (max-width: 768px) {
:root {
--sidebar-width: 0px;
}
}

View file

@ -1,56 +1,60 @@
/* *arr Dark Theme /* *arr Dark Theme
* Color palette matching Sonarr/Radarr aesthetic. * Color palette matching Sonarr/Radarr aesthetic with modern glassmorphism touches.
* All UI components reference these custom properties. * All UI components reference these custom properties.
*/ */
:root { :root {
/* ── Backgrounds ── */ /* ── Backgrounds ── */
--bg-main: #1a1d23; --bg-main: #0f1117;
--bg-sidebar: #14161a; --bg-sidebar: #0a0c10;
--bg-card: #242731; --bg-card: rgba(30, 33, 44, 0.8);
--bg-input: #2a2e38; --bg-card-solid: #1e212c;
--bg-hover: #2f3341; --bg-input: #1a1d26;
--bg-selected: #35394a; --bg-hover: rgba(255, 255, 255, 0.04);
--bg-header: #1e2029; --bg-selected: rgba(255, 255, 255, 0.08);
--bg-toolbar: #1e2129; --bg-header: #13151c;
--bg-modal-overlay: rgba(0, 0, 0, 0.6); --bg-toolbar: #13151c;
--bg-modal-overlay: rgba(0, 0, 0, 0.7);
--bg-glass: rgba(20, 22, 30, 0.6);
/* ── Accent ── */ /* ── Accent ── */
--accent: #e05d44; --accent: #e05d44;
--accent-hover: #c94e38; --accent-hover: #f06a51;
--accent-subtle: rgba(224, 93, 68, 0.12); --accent-subtle: rgba(224, 93, 68, 0.1);
--accent-glow: rgba(224, 93, 68, 0.25);
/* ── Text ── */ /* ── Text ── */
--text-primary: #e1e2e6; --text-primary: #e8e9ed;
--text-secondary: #8b8d97; --text-secondary: #8b8d97;
--text-muted: #5d5f69; --text-muted: #4d5060;
--text-inverse: #14161a; --text-inverse: #0f1117;
--text-link: #e05d44; --text-link: #e05d44;
/* ── Status colors ── */ /* ── Status colors ── */
--success: #27c24c; --success: #34d058;
--success-bg: rgba(39, 194, 76, 0.12); --success-bg: rgba(52, 208, 88, 0.1);
--warning: #ff902b; --warning: #ff9f43;
--warning-bg: rgba(255, 144, 43, 0.12); --warning-bg: rgba(255, 159, 67, 0.1);
--danger: #f05050; --danger: #f05050;
--danger-bg: rgba(240, 80, 80, 0.12); --danger-bg: rgba(240, 80, 80, 0.1);
--info: #e05d44; --info: #e05d44;
--info-bg: rgba(224, 93, 68, 0.12); --info-bg: rgba(224, 93, 68, 0.1);
/* ── Borders ── */ /* ── Borders ── */
--border: #2d3040; --border: rgba(255, 255, 255, 0.06);
--border-light: #373b4e; --border-light: rgba(255, 255, 255, 0.1);
--border-accent: rgba(224, 93, 68, 0.3);
/* ── Typography ── */ /* ── Typography ── */
--font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; --font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
--font-mono: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; --font-mono: 'JetBrains Mono', 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
--font-size-xs: 0.75rem; --font-size-xs: 0.6875rem;
--font-size-sm: 0.8125rem; --font-size-sm: 0.8125rem;
--font-size-base: 0.875rem; --font-size-base: 0.875rem;
--font-size-md: 1rem; --font-size-md: 1rem;
--font-size-lg: 1.125rem; --font-size-lg: 1.125rem;
--font-size-xl: 1.25rem; --font-size-xl: 1.375rem;
--font-size-2xl: 1.5rem; --font-size-2xl: 1.75rem;
/* ── Spacing ── */ /* ── Spacing ── */
--space-1: 0.25rem; --space-1: 0.25rem;
@ -64,22 +68,32 @@
--space-12: 3rem; --space-12: 3rem;
/* ── Border Radius ── */ /* ── Border Radius ── */
--radius-sm: 3px; --radius-sm: 4px;
--radius-md: 4px; --radius-md: 6px;
--radius-lg: 6px; --radius-lg: 10px;
--radius-xl: 8px; --radius-xl: 14px;
--radius-2xl: 20px;
--radius-full: 9999px;
/* ── Shadows ── */ /* ── Shadows ── */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3); --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.4), 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-md: 0 2px 8px rgba(0, 0, 0, 0.3); --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 4px 16px rgba(0, 0, 0, 0.4); --shadow-lg: 0 8px 30px rgba(0, 0, 0, 0.5);
--shadow-glow: 0 0 20px rgba(224, 93, 68, 0.15);
/* ── Layout ── */ /* ── Layout ── */
--sidebar-width: 210px; --sidebar-width: 220px;
--sidebar-collapsed: 50px; --sidebar-collapsed: 56px;
--header-height: 55px; --header-height: 56px;
/* ── Transitions ── */ /* ── Transitions ── */
--transition-fast: 150ms ease; --transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-normal: 250ms ease; --transition-normal: 250ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-slow: 400ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-spring: 500ms cubic-bezier(0.34, 1.56, 0.64, 1);
/* ── Glassmorphism ── */
--glass-blur: 12px;
--glass-bg: rgba(20, 22, 30, 0.6);
--glass-border: rgba(255, 255, 255, 0.08);
} }