Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 51 additions & 48 deletions frontend/src/components/Layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
CalendarIcon,
CogIcon,
FilmIcon,
PlayIcon,
Squares2X2Icon,
ArrowRightOnRectangleIcon
} from '@heroicons/react/24/outline';

Expand All @@ -14,7 +14,7 @@ interface LayoutProps {
}

const Layout: React.FC<LayoutProps> = ({ children }) => {
const { logout } = useAuth();
const { user, logout } = useAuth();
const location = useLocation();
const navigate = useNavigate();

Expand All @@ -24,62 +24,65 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
};

const navigation = [
{ name: 'Dashboard', href: '/', icon: PlayIcon },
{ name: 'Watch Items', href: '/watch-items', icon: FilmIcon },
{ name: 'Overview', href: '/', icon: Squares2X2Icon },
{ name: 'Watch lists', href: '/watch-items', icon: FilmIcon },
{ name: 'Upcoming', href: '/upcoming', icon: CalendarIcon },
{ name: 'Configuration', href: '/config', icon: CogIcon },
{ name: 'Settings', href: '/config', icon: CogIcon },
];

return (
<div className="min-h-screen bg-dark-bg-primary">
<nav className="bg-dark-bg-secondary shadow-lg border-b border-dark-border">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between h-16">
<div className="flex">
<div className="flex-shrink-0 flex items-center">
<div className="flex items-center space-x-3">
<img src="/assets/logo.svg" alt="Letterboxarr" className="h-8 w-8" />
<h1 className="text-xl font-bold text-dark-text-primary">Letterboxarr</h1>
</div>
</div>
<div className="hidden sm:ml-6 sm:flex sm:space-x-8">
{navigation.map((item) => {
const isCurrent = location.pathname === item.href;
return (
<Link
key={item.name}
to={item.href}
className={`${
isCurrent
? 'border-brand-blue text-dark-text-primary'
: 'border-transparent text-dark-text-muted hover:border-dark-border hover:text-dark-text-secondary'
} inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium transition-colors`}
>
<item.icon className="h-4 w-4 mr-2" />
{item.name}
</Link>
);
})}
</div>
</div>
<div className="flex items-center">
<button
onClick={handleLogout}
className="btn-secondary inline-flex items-center text-sm font-medium"
<div className="min-h-screen bg-dark-bg-primary lg:pl-56">
<a href="#main-content" className="sr-only focus:not-sr-only focus:fixed focus:left-4 focus:top-4 focus:z-50 focus:rounded-md focus:bg-brand-blue focus:p-3 focus:text-dark-bg-primary">
Skip to content
</a>
<aside className="border-b border-dark-border/50 bg-dark-bg-secondary lg:fixed lg:inset-y-0 lg:left-0 lg:flex lg:w-56 lg:flex-col lg:border-b-0 lg:border-r">
<div className="flex items-center justify-between gap-3 px-5 pt-5 lg:px-6 lg:pt-8">
<Link to="/" className="flex items-center gap-2 text-lg font-bold tracking-tight" aria-label="Letterboxarr overview">
<img src="/assets/logo.svg" alt="" className="h-8 w-8" />
Letterboxarr
</Link>
<button onClick={handleLogout} className="rounded-md p-2 text-dark-text-muted hover:text-dark-text-primary lg:hidden" aria-label="Log out">
<ArrowRightOnRectangleIcon className="h-5 w-5" aria-hidden="true" />
</button>
</div>

<nav aria-label="Main navigation" className="grid grid-cols-4 gap-1 px-3 py-4 lg:mt-9 lg:grid-cols-1 lg:gap-2 lg:px-4 lg:py-0">
{navigation.map(item => {
const isCurrent = location.pathname === item.href
|| (item.href === '/watch-items' && location.pathname.startsWith('/movies/'));
return (
<Link
key={item.href}
to={item.href}
aria-current={isCurrent ? 'page' : undefined}
className={`flex min-h-11 items-center justify-center gap-2 rounded-md px-2 py-2.5 text-xs font-medium transition-colors sm:text-sm lg:justify-start lg:px-3 ${
isCurrent
? 'bg-dark-bg-primary text-brand-blue'
: 'text-dark-text-muted hover:bg-dark-bg-primary/50 hover:text-dark-text-primary'
}`}
>
<ArrowRightOnRectangleIcon className="h-4 w-4 mr-1" />
Logout
</button>
</div>
</div>
<item.icon className="hidden h-4 w-4 flex-shrink-0 sm:block" aria-hidden="true" />
{item.name}
</Link>
);
})}
</nav>

<div className="mt-auto hidden px-6 pb-7 pt-8 lg:block">
<p className="text-xs text-dark-text-muted">Your personal film collection</p>
<p className="mt-2 truncate text-sm text-dark-text-secondary" title={user?.username}>{user?.username}</p>
<button onClick={handleLogout} className="mt-4 inline-flex items-center gap-2 rounded-md py-2 text-xs text-dark-text-muted hover:text-dark-text-primary">
<ArrowRightOnRectangleIcon className="h-4 w-4" aria-hidden="true" />
Log out
</button>
</div>
</nav>
</aside>

<main className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
<main id="main-content" tabIndex={-1} className="mx-auto max-w-7xl px-4 py-2 sm:px-6 lg:px-10 lg:py-4">
{children}
</main>
</div>
);
};

export default Layout;
export default Layout;
9 changes: 5 additions & 4 deletions frontend/src/components/SyncStatusBanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { ProgressTrack } from './CategoryProgress';

interface SyncStatusBannerProps {
progress: SyncProgress;
embedded?: boolean;
}

// A round is four phases long and the slow ones are a page a second, so a sync
Expand All @@ -15,16 +16,16 @@ interface SyncStatusBannerProps {
// It is only rendered while a round runs, so there is no idle state to design:
// it appears when one starts, whether or not this browser was what started it,
// and goes when it ends.
const SyncStatusBanner: React.FC<SyncStatusBannerProps> = ({ progress }) => {
const SyncStatusBanner: React.FC<SyncStatusBannerProps> = ({ progress, embedded = false }) => {
const { label, item, done, total, step, steps, added } = progress;

// A phase with nothing due — release dates already current, say — is still a
// step of the round, and reads better as done than as a bar stuck at zero
const share = total > 0 ? done / total : 1;

return (
<div className="card mt-6 p-4" role="status" aria-live="polite">
<div className="flex items-baseline gap-3">
<div className={embedded ? 'mt-5 border-t border-dark-border/50 pt-4' : 'card mt-6 p-4'} role="status" aria-live="polite">
<div className="flex flex-wrap items-baseline gap-3">
{/* Self-evident from the moving bar below, so kept out of the reading */}
<ArrowPathIcon className="h-4 w-4 flex-shrink-0 animate-spin text-brand-blue self-center" aria-hidden="true" />

Expand All @@ -36,7 +37,7 @@ const SyncStatusBanner: React.FC<SyncStatusBannerProps> = ({ progress }) => {
it changes every second or so, and a line that reflows as it goes
would move the counts beside it. */}
{item && (
<p className="min-w-0 flex-1 truncate text-sm text-dark-text-muted" title={item}>
<p className="min-w-0 basis-full truncate text-sm text-dark-text-muted sm:flex-1" title={item}>
· {item}
</p>
)}
Expand Down
22 changes: 18 additions & 4 deletions frontend/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -20,18 +20,32 @@ code {

@layer components {
.btn-primary {
@apply bg-transparent text-brand-green border border-brand-green font-medium px-4 py-2 rounded-md hover:opacity-70 transition-all duration-200;
@apply bg-brand-green text-dark-bg-primary border border-brand-green font-semibold px-4 py-2 rounded-md hover:opacity-80 transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed;
}

.btn-secondary {
@apply bg-dark-bg-tertiary text-dark-text-primary border border-dark-border hover:bg-dark-border transition-colors px-4 py-2 rounded-md;
@apply bg-dark-bg-secondary text-dark-text-secondary border border-dark-border/60 hover:bg-dark-bg-tertiary transition-colors px-4 py-2 rounded-md;
}

.card {
@apply bg-dark-bg-secondary border border-dark-border rounded-lg shadow-lg;
@apply bg-dark-bg-secondary border border-dark-border/50 rounded-lg;
}

.input-field {
@apply bg-dark-bg-tertiary border border-dark-border text-dark-text-primary placeholder-dark-text-muted focus:border-brand-blue focus:ring-1 focus:ring-brand-blue rounded-md px-3 py-2;
}
}
}

@layer base {
:where(a, button, input, select, textarea, summary):focus-visible {
@apply outline outline-2 outline-offset-4 outline-brand-blue;
}

@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
}
30 changes: 15 additions & 15 deletions frontend/src/pages/ConfigPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,9 @@ const ConfigPage: React.FC = () => {

return (
<Layout>
<div className="px-4 py-6 sm:px-0">
<div className="py-6 lg:py-8">
<div className="border-b border-dark-border pb-5">
<h1 className="text-2xl font-bold leading-6 text-dark-text-primary">Configuration</h1>
<h1 className="text-2xl font-bold leading-6 text-dark-text-primary">Settings</h1>
<p className="mt-2 max-w-4xl text-sm text-dark-text-muted">
Configure your Radarr connection, sync settings, and Letterboxd filters.
</p>
Expand All @@ -94,7 +94,7 @@ const ConfigPage: React.FC = () => {
<input
type="number"
{...register('sync.interval_minutes', { required: true, min: 1, valueAsNumber: true })}
className="input-field"
className="input-field w-full min-w-0"
placeholder="60"
/>
<p className="mt-2 text-sm text-dark-text-muted">
Expand Down Expand Up @@ -129,7 +129,7 @@ const ConfigPage: React.FC = () => {
<input
type="url"
{...register('radarr.url', { required: true })}
className="input-field"
className="input-field w-full min-w-0"
placeholder="https://radarr.example.com"
/>
{errors.radarr?.url && (
Expand All @@ -144,15 +144,15 @@ const ConfigPage: React.FC = () => {
<input
type="password"
{...register('radarr.api_key', { required: true })}
className="input-field"
className="input-field w-full min-w-0"
placeholder="Enter your Radarr API key"
/>
{errors.radarr?.api_key && (
<p className="mt-2 text-sm text-brand-orange">API key is required</p>
)}
</div>

<div className="col-span-3">
<div className="col-span-6 sm:col-span-3">
<label htmlFor="radarr.quality_profile" className="block text-sm font-medium text-dark-text-secondary">
Quality Profile
</label>
Expand All @@ -161,7 +161,7 @@ const ConfigPage: React.FC = () => {
<input
type="number"
{...register('radarr.quality_profile', { required: true, min: 1, valueAsNumber: true })}
className="input-field"
className="input-field w-full min-w-0"
placeholder="1"
/>
<p className="mt-2 text-sm text-dark-text-muted">
Expand All @@ -170,14 +170,14 @@ const ConfigPage: React.FC = () => {
</p>
</>
) : profiles === null ? (
<div className="input-field flex items-center text-dark-text-muted">
<div className="input-field flex min-w-0 items-center text-dark-text-muted">
<div className="animate-spin rounded-full h-3 w-3 border-b-2 border-dark-text-muted mr-2"></div>
Loading profiles from Radarr...
</div>
) : (
<select
{...register('radarr.quality_profile', { required: true, valueAsNumber: true })}
className="input-field"
className="input-field w-full min-w-0"
>
{/* A profile deleted in Radarr would otherwise silently
become whichever one happens to be listed first */}
Expand All @@ -198,14 +198,14 @@ const ConfigPage: React.FC = () => {
)}
</div>

<div className="col-span-3">
<div className="col-span-6 sm:col-span-3">
<label htmlFor="radarr.root_folder" className="block text-sm font-medium text-dark-text-secondary">
Root Folder Path
</label>
<input
type="text"
{...register('radarr.root_folder', { required: true })}
className="input-field"
className="input-field w-full min-w-0"
placeholder="/media/movies"
/>
{errors.radarr?.root_folder && (
Expand All @@ -214,7 +214,7 @@ const ConfigPage: React.FC = () => {
</div>

<div className="col-span-6">
<div className="flex items-center space-x-6">
<div className="flex flex-wrap items-center gap-x-6 gap-y-3">
<div className="flex items-center">
<input
id="monitor_added"
Expand Down Expand Up @@ -266,7 +266,7 @@ const ConfigPage: React.FC = () => {
<input
type="text"
{...register('letterboxd.username')}
className="input-field rounded-none rounded-r-md w-full"
className="input-field min-w-0 rounded-none rounded-r-md w-full"
placeholder="your-username"
/>
</div>
Expand Down Expand Up @@ -294,7 +294,7 @@ const ConfigPage: React.FC = () => {
<label htmlFor="letterboxd.country" className="block text-sm font-medium text-dark-text-secondary">
Country (optional)
</label>
<select {...register('letterboxd.country')} className="input-field">
<select {...register('letterboxd.country')} className="input-field w-full min-w-0">
<option value="">No preference — earliest date anywhere</option>
{/* A country set by hand in config.yml that Letterboxd has
never been seen to name would otherwise be replaced by
Expand Down Expand Up @@ -382,7 +382,7 @@ const ConfigPage: React.FC = () => {
disabled={saving}
className="btn-primary disabled:opacity-50 disabled:cursor-not-allowed"
>
{saving ? 'Saving...' : 'Save Configuration'}
{saving ? 'Saving...' : 'Save settings'}
</button>
</div>
</form>
Expand Down
Loading
Loading