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

View file

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

View file

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

View file

@ -1,5 +1,6 @@
/* ── Global Reset & Base Styles ── */
@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,
@ -13,6 +14,7 @@ html {
font-size: 16px;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
body {
@ -22,6 +24,7 @@ body {
background-color: var(--bg-main);
line-height: 1.5;
min-height: 100vh;
letter-spacing: -0.01em;
}
#root {
@ -32,6 +35,7 @@ body {
a {
color: var(--text-link);
text-decoration: none;
transition: color var(--transition-fast);
}
a:hover {
@ -42,25 +46,26 @@ a:hover {
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: var(--radius-sm);
}
/* ── Scrollbar styling ── */
::-webkit-scrollbar {
width: 8px;
height: 8px;
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: var(--bg-main);
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--border-light);
border-radius: 4px;
background: rgba(255, 255, 255, 0.08);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--text-muted);
background: rgba(255, 255, 255, 0.15);
}
/* ── Buttons base ── */
@ -71,6 +76,7 @@ button {
border: none;
background: none;
color: inherit;
transition: all var(--transition-fast);
}
/* ── Inputs base ── */
@ -81,15 +87,17 @@ select {
font-size: inherit;
color: var(--text-primary);
background-color: var(--bg-input);
border: 1px solid var(--border);
border: 1px solid var(--border-light);
border-radius: var(--radius-md);
padding: var(--space-2) var(--space-3);
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
}
input:focus,
textarea:focus,
select:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-subtle);
outline: none;
}
@ -106,10 +114,10 @@ table {
th {
text-align: left;
font-weight: 600;
font-size: var(--font-size-sm);
color: var(--text-secondary);
font-size: var(--font-size-xs);
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.03em;
letter-spacing: 0.06em;
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--border);
}
@ -136,6 +144,43 @@ tr:hover {
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 ── */
@keyframes pulse {
0%, 100% { opacity: 1; }
@ -146,3 +191,34 @@ tr:hover {
from { transform: rotate(0deg); }
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
* Color palette matching Sonarr/Radarr aesthetic.
* Color palette matching Sonarr/Radarr aesthetic with modern glassmorphism touches.
* All UI components reference these custom properties.
*/
:root {
/* ── Backgrounds ── */
--bg-main: #1a1d23;
--bg-sidebar: #14161a;
--bg-card: #242731;
--bg-input: #2a2e38;
--bg-hover: #2f3341;
--bg-selected: #35394a;
--bg-header: #1e2029;
--bg-toolbar: #1e2129;
--bg-modal-overlay: rgba(0, 0, 0, 0.6);
--bg-main: #0f1117;
--bg-sidebar: #0a0c10;
--bg-card: rgba(30, 33, 44, 0.8);
--bg-card-solid: #1e212c;
--bg-input: #1a1d26;
--bg-hover: rgba(255, 255, 255, 0.04);
--bg-selected: rgba(255, 255, 255, 0.08);
--bg-header: #13151c;
--bg-toolbar: #13151c;
--bg-modal-overlay: rgba(0, 0, 0, 0.7);
--bg-glass: rgba(20, 22, 30, 0.6);
/* ── Accent ── */
--accent: #e05d44;
--accent-hover: #c94e38;
--accent-subtle: rgba(224, 93, 68, 0.12);
--accent-hover: #f06a51;
--accent-subtle: rgba(224, 93, 68, 0.1);
--accent-glow: rgba(224, 93, 68, 0.25);
/* ── Text ── */
--text-primary: #e1e2e6;
--text-primary: #e8e9ed;
--text-secondary: #8b8d97;
--text-muted: #5d5f69;
--text-inverse: #14161a;
--text-muted: #4d5060;
--text-inverse: #0f1117;
--text-link: #e05d44;
/* ── Status colors ── */
--success: #27c24c;
--success-bg: rgba(39, 194, 76, 0.12);
--warning: #ff902b;
--warning-bg: rgba(255, 144, 43, 0.12);
--success: #34d058;
--success-bg: rgba(52, 208, 88, 0.1);
--warning: #ff9f43;
--warning-bg: rgba(255, 159, 67, 0.1);
--danger: #f05050;
--danger-bg: rgba(240, 80, 80, 0.12);
--danger-bg: rgba(240, 80, 80, 0.1);
--info: #e05d44;
--info-bg: rgba(224, 93, 68, 0.12);
--info-bg: rgba(224, 93, 68, 0.1);
/* ── Borders ── */
--border: #2d3040;
--border-light: #373b4e;
--border: rgba(255, 255, 255, 0.06);
--border-light: rgba(255, 255, 255, 0.1);
--border-accent: rgba(224, 93, 68, 0.3);
/* ── Typography ── */
--font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
--font-mono: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
--font-size-xs: 0.75rem;
--font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
--font-mono: 'JetBrains Mono', 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
--font-size-xs: 0.6875rem;
--font-size-sm: 0.8125rem;
--font-size-base: 0.875rem;
--font-size-md: 1rem;
--font-size-lg: 1.125rem;
--font-size-xl: 1.25rem;
--font-size-2xl: 1.5rem;
--font-size-xl: 1.375rem;
--font-size-2xl: 1.75rem;
/* ── Spacing ── */
--space-1: 0.25rem;
@ -64,22 +68,32 @@
--space-12: 3rem;
/* ── Border Radius ── */
--radius-sm: 3px;
--radius-md: 4px;
--radius-lg: 6px;
--radius-xl: 8px;
--radius-sm: 4px;
--radius-md: 6px;
--radius-lg: 10px;
--radius-xl: 14px;
--radius-2xl: 20px;
--radius-full: 9999px;
/* ── Shadows ── */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-md: 0 2px 8px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 4px 16px rgba(0, 0, 0, 0.4);
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.4), 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 12px 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 ── */
--sidebar-width: 210px;
--sidebar-collapsed: 50px;
--header-height: 55px;
--sidebar-width: 220px;
--sidebar-collapsed: 56px;
--header-height: 56px;
/* ── Transitions ── */
--transition-fast: 150ms ease;
--transition-normal: 250ms ease;
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
--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);
}