mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 05:05:38 -04:00
Comprehensive Web Interface Guidelines audit: accessibility, motion, and typography
- Add global MotionConfig reducedMotion="user" in root layout
- Add color-scheme: dark, theme-color meta, skip-to-main-content link
- Add aria-hidden={true} to ~60+ decorative icons across 33 files
- Add aria-label to all icon-only buttons (logout, star rating, play, delete, etc.)
- Replace transition-all with specific properties (11 files)
- Add motion-safe: prefix for CSS hover transforms and animate-pulse
- Add prefers-reduced-motion: reduce override in globals.css
- Add useReducedMotion guard to status-dot pulsing animation
- Fix focus-visible styles on auth form inputs (focus → focus-visible, stronger ring)
- Add text-balance to all headings (13 files)
- Replace "..." with "…" (U+2026) in all loading/placeholder text
- Add autocomplete, spellCheck, role="alert" to auth form
- Add aria-label to Switch controls and filmography select
- Fix heading hierarchy (h3 → h2 where h2 was skipped)
- Add break-words to title overview, overscroll-contain to drawer
- Add confirmation dialog for destructive library removal
- Add group-focus-within:opacity-100 for keyboard-accessible backup actions
- Add role="radio" + aria-checked to star rating buttons
- Add aria-label to carousel region, role="img" to TMDB logo SVG
- Add text-foreground to native select for dark mode consistency
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -22,10 +22,10 @@ export default async function RegisterPage() {
|
||||
<div className="absolute -inset-4 rounded-2xl bg-primary/3 blur-2xl" />
|
||||
<div className="relative space-y-6 rounded-xl border border-border/50 bg-card/80 p-8 text-center backdrop-blur-sm">
|
||||
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-primary/10">
|
||||
<IconLock className="size-6 text-primary" />
|
||||
<IconLock aria-hidden={true} className="size-6 text-primary" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h1 className="font-display text-xl tracking-tight">
|
||||
<h1 className="font-display text-xl tracking-tight text-balance">
|
||||
Registration Closed
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
|
||||
+16
-10
@@ -24,7 +24,7 @@ const steps = [
|
||||
className="inline-flex items-center gap-1 font-medium text-primary underline decoration-primary/30 underline-offset-2 transition-colors hover:decoration-primary"
|
||||
>
|
||||
themoviedb.org
|
||||
<IconExternalLink className="size-3.5" />
|
||||
<IconExternalLink aria-hidden={true} className="size-3.5" />
|
||||
</a>{" "}
|
||||
and sign up for a free account.
|
||||
</>
|
||||
@@ -43,7 +43,7 @@ const steps = [
|
||||
className="inline-flex items-center gap-1 font-medium text-primary underline decoration-primary/30 underline-offset-2 transition-colors hover:decoration-primary"
|
||||
>
|
||||
Settings → API
|
||||
<IconExternalLink className="size-3.5" />
|
||||
<IconExternalLink aria-hidden={true} className="size-3.5" />
|
||||
</a>{" "}
|
||||
and request an API key. Choose “Developer” when asked. You
|
||||
need the{" "}
|
||||
@@ -133,10 +133,10 @@ export default function SetupPage() {
|
||||
{/* Header */}
|
||||
<motion.div variants={sectionVariants} className="space-y-3">
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-primary/20 bg-primary/5 px-3 py-1 text-xs font-medium text-primary">
|
||||
<IconKey className="size-3.5" />
|
||||
<IconKey aria-hidden={true} className="size-3.5" />
|
||||
Setup required
|
||||
</div>
|
||||
<h1 className="font-display text-3xl tracking-tight sm:text-4xl">
|
||||
<h1 className="font-display text-3xl tracking-tight text-balance sm:text-4xl">
|
||||
Connect to TMDB
|
||||
</h1>
|
||||
<p className="max-w-lg text-muted-foreground leading-relaxed">
|
||||
@@ -164,7 +164,7 @@ export default function SetupPage() {
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h3 className="font-medium">{step.title}</h3>
|
||||
<h2 className="font-medium">{step.title}</h2>
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||
{step.description}
|
||||
</p>
|
||||
@@ -188,12 +188,15 @@ export default function SetupPage() {
|
||||
>
|
||||
{copiedIdx === idx ? (
|
||||
<>
|
||||
<IconCheck className="size-3 text-green-400" />
|
||||
<IconCheck
|
||||
aria-hidden={true}
|
||||
className="size-3 text-green-400"
|
||||
/>
|
||||
Copied
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<IconCopy className="size-3" />
|
||||
<IconCopy aria-hidden={true} className="size-3" />
|
||||
Copy
|
||||
</>
|
||||
)}
|
||||
@@ -217,14 +220,17 @@ export default function SetupPage() {
|
||||
{configured === true ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-green-500/10">
|
||||
<IconCheck className="size-5 text-green-400" />
|
||||
<IconCheck
|
||||
aria-hidden={true}
|
||||
className="size-5 text-green-400"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-green-400">
|
||||
TMDB API key detected
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Redirecting you to Sofa...
|
||||
Redirecting you to Sofa…
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -247,7 +253,7 @@ export default function SetupPage() {
|
||||
{checking ? (
|
||||
<>
|
||||
<span className="h-3 w-3 animate-spin rounded-full border-2 border-primary-foreground/30 border-t-primary-foreground" />
|
||||
Checking...
|
||||
Checking…
|
||||
</>
|
||||
) : (
|
||||
"Check configuration"
|
||||
|
||||
@@ -44,18 +44,21 @@ export function ContinueWatchingCard({
|
||||
alt={item.nextEpisode?.name ?? item.title.title}
|
||||
width={500}
|
||||
height={281}
|
||||
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
className="h-full w-full object-cover motion-safe:transition-transform motion-safe:duration-300 motion-safe:group-hover:scale-105"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center bg-gradient-to-br from-card via-secondary to-muted">
|
||||
<IconPlayerPlay className="size-8 text-muted-foreground/30" />
|
||||
<IconPlayerPlay
|
||||
aria-hidden={true}
|
||||
className="size-8 text-muted-foreground/30"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-black/20 to-transparent" />
|
||||
{item.nextEpisode && (
|
||||
<div className="absolute bottom-2.5 left-3 right-3">
|
||||
<p className="flex items-center gap-1.5 text-[10px] font-medium uppercase tracking-wider text-primary">
|
||||
<span className="inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-primary" />
|
||||
<span className="inline-block h-1.5 w-1.5 motion-safe:animate-pulse rounded-full bg-primary" />
|
||||
Up next
|
||||
</p>
|
||||
<p className="mt-0.5 truncate text-sm font-medium text-white">
|
||||
@@ -76,13 +79,13 @@ export function ContinueWatchingCard({
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary transition-colors group-hover:bg-primary group-hover:text-primary-foreground">
|
||||
<IconPlayerPlay className="size-3.5" />
|
||||
<IconPlayerPlay aria-hidden={true} className="size-3.5" />
|
||||
</div>
|
||||
</div>
|
||||
{progress > 0 && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-muted">
|
||||
<div
|
||||
className="h-full bg-primary transition-all"
|
||||
className="h-full bg-primary transition-[width]"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -14,8 +14,10 @@ export function FeedSection({
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
{icon}
|
||||
<h2 className="font-display text-xl tracking-tight">{title}</h2>
|
||||
<span aria-hidden={true}>{icon}</span>
|
||||
<h2 className="font-display text-xl tracking-tight text-balance">
|
||||
{title}
|
||||
</h2>
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
|
||||
@@ -67,7 +67,7 @@ function StatCard({
|
||||
<div
|
||||
className={`flex h-6 w-6 items-center justify-center rounded-md ${bgColor}`}
|
||||
>
|
||||
<Icon className={`size-[13px] ${color}`} />
|
||||
<Icon aria-hidden={true} className={`size-[13px] ${color}`} />
|
||||
</div>
|
||||
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{label}
|
||||
@@ -75,7 +75,7 @@ function StatCard({
|
||||
</div>
|
||||
<p
|
||||
suppressHydrationWarning
|
||||
className={`relative z-10 mt-2 font-display text-2xl tabular-nums tracking-tight ${color} transition-opacity duration-300`}
|
||||
className={`relative z-10 mt-2 font-display text-2xl tabular-nums tracking-tight ${color} motion-safe:transition-opacity motion-safe:duration-300`}
|
||||
>
|
||||
{value}
|
||||
</p>
|
||||
@@ -98,7 +98,7 @@ function PeriodSelector({
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="inline-flex cursor-pointer items-center gap-0.5 border-b border-dotted border-muted-foreground/50 uppercase text-foreground/80 transition-colors hover:text-foreground">
|
||||
{periodLabels[period]}
|
||||
<IconChevronDown className="size-2.5" />
|
||||
<IconChevronDown aria-hidden={true} className="size-2.5" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuRadioGroup
|
||||
|
||||
@@ -18,7 +18,7 @@ export async function StatsSection({ userId }: { userId: string }) {
|
||||
{isEmpty && (
|
||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed border-border/50 py-16 text-center">
|
||||
<div className="rounded-full bg-primary/10 p-4">
|
||||
<IconDeviceTv className="size-8 text-primary" />
|
||||
<IconDeviceTv aria-hidden={true} className="size-8 text-primary" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium">Your library is empty</p>
|
||||
@@ -28,7 +28,7 @@ export async function StatsSection({ userId }: { userId: string }) {
|
||||
</div>
|
||||
<Link
|
||||
href="/explore"
|
||||
className="inline-flex h-9 items-center rounded-lg bg-primary px-4 text-sm font-medium text-primary-foreground transition-all hover:shadow-md hover:shadow-primary/20"
|
||||
className="inline-flex h-9 items-center rounded-lg bg-primary px-4 text-sm font-medium text-primary-foreground transition-shadow hover:shadow-md hover:shadow-primary/20"
|
||||
>
|
||||
Start exploring
|
||||
</Link>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export function WelcomeHeader({ name }: { name?: string | null }) {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="font-display text-3xl tracking-tight">
|
||||
<h1 className="font-display text-3xl tracking-tight text-balance">
|
||||
Welcome back{name ? `, ${name}` : ""}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
|
||||
@@ -106,7 +106,9 @@ function FilterableTitleRowInner({
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
{icon}
|
||||
<h2 className="font-display text-xl tracking-tight">{heading}</h2>
|
||||
<h2 className="font-display text-xl tracking-tight text-balance">
|
||||
{heading}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* Genre chips */}
|
||||
@@ -116,7 +118,7 @@ function FilterableTitleRowInner({
|
||||
key={genre.id}
|
||||
type="button"
|
||||
onClick={() => toggleGenre(genre.id)}
|
||||
className={`shrink-0 rounded-full border px-3 py-1 text-xs font-medium transition-all ${
|
||||
className={`shrink-0 rounded-full border px-3 py-1 text-xs font-medium transition-colors ${
|
||||
selectedGenre === genre.id
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "border-border/50 bg-card/50 text-muted-foreground hover:border-primary/20 hover:text-foreground"
|
||||
|
||||
@@ -56,7 +56,10 @@ export function HeroBanner({
|
||||
</span>
|
||||
{voteAverage > 0 && (
|
||||
<span className="flex items-center gap-1 text-sm text-primary">
|
||||
<IconStar className="size-3.5 fill-primary" />
|
||||
<IconStar
|
||||
aria-hidden={true}
|
||||
className="size-3.5 fill-primary"
|
||||
/>
|
||||
{voteAverage.toFixed(1)}
|
||||
</span>
|
||||
)}
|
||||
@@ -65,7 +68,7 @@ export function HeroBanner({
|
||||
</span>
|
||||
</div>
|
||||
<Link href={href} className="group/title">
|
||||
<h2 className="font-display text-3xl tracking-tight sm:text-4xl transition-colors group-hover/title:text-primary">
|
||||
<h2 className="font-display text-3xl tracking-tight text-balance sm:text-4xl transition-colors group-hover/title:text-primary">
|
||||
{title}
|
||||
</h2>
|
||||
</Link>
|
||||
@@ -74,9 +77,9 @@ export function HeroBanner({
|
||||
</p>
|
||||
<Link
|
||||
href={href}
|
||||
className="mt-4 inline-flex h-9 items-center gap-2 rounded-lg bg-primary px-4 text-sm font-medium text-primary-foreground transition-all hover:shadow-md hover:shadow-primary/20"
|
||||
className="mt-4 inline-flex h-9 items-center gap-2 rounded-lg bg-primary px-4 text-sm font-medium text-primary-foreground transition-shadow hover:shadow-md hover:shadow-primary/20"
|
||||
>
|
||||
<IconPlus className="size-4" />
|
||||
<IconPlus aria-hidden={true} className="size-4" />
|
||||
Add to Library
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -91,7 +91,7 @@ export default async function ExplorePage() {
|
||||
|
||||
<TitleRow
|
||||
heading="Trending Today"
|
||||
icon={<IconFlame className="size-5 text-primary" />}
|
||||
icon={<IconFlame aria-hidden={true} className="size-5 text-primary" />}
|
||||
items={trendingItems.slice(0, 20)}
|
||||
userStatuses={userStatuses}
|
||||
episodeProgress={episodeProgress}
|
||||
@@ -99,7 +99,7 @@ export default async function ExplorePage() {
|
||||
|
||||
<FilterableTitleRow
|
||||
heading="Popular Movies"
|
||||
icon={<IconMovie className="size-5 text-primary" />}
|
||||
icon={<IconMovie aria-hidden={true} className="size-5 text-primary" />}
|
||||
mediaType="movie"
|
||||
defaultItems={popularMovieItems.slice(0, 20)}
|
||||
genres={movieGenres.genres}
|
||||
@@ -109,7 +109,9 @@ export default async function ExplorePage() {
|
||||
|
||||
<FilterableTitleRow
|
||||
heading="Popular TV Shows"
|
||||
icon={<IconDeviceTv className="size-5 text-primary" />}
|
||||
icon={
|
||||
<IconDeviceTv aria-hidden={true} className="size-5 text-primary" />
|
||||
}
|
||||
mediaType="tv"
|
||||
defaultItems={popularTvItems.slice(0, 20)}
|
||||
genres={tvGenres.genres}
|
||||
|
||||
@@ -38,7 +38,9 @@ export function TitleRow({
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
{icon}
|
||||
<h2 className="font-display text-xl tracking-tight">{heading}</h2>
|
||||
<h2 className="font-display text-xl tracking-tight text-balance">
|
||||
{heading}
|
||||
</h2>
|
||||
</div>
|
||||
<Carousel
|
||||
opts={{ align: "start", dragFree: true, containScroll: "trimSnaps" }}
|
||||
|
||||
@@ -22,7 +22,10 @@ export default async function PagesLayout({
|
||||
<NavBar />
|
||||
{/* Ambient glow */}
|
||||
<div className="pointer-events-none fixed left-1/2 top-1/4 -translate-x-1/2 -translate-y-1/2 h-[600px] w-[800px] rounded-full bg-primary/3 blur-[200px]" />
|
||||
<main className="relative mx-auto max-w-6xl px-4 py-6 sm:px-6">
|
||||
<main
|
||||
id="main-content"
|
||||
className="relative mx-auto max-w-6xl px-4 py-6 sm:px-6"
|
||||
>
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -53,8 +53,10 @@ export function FilmographyGrid({ credits }: FilmographyGridProps) {
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<IconMovie className="size-5 text-primary" />
|
||||
<h2 className="font-display text-xl tracking-tight">Filmography</h2>
|
||||
<IconMovie aria-hidden={true} className="size-5 text-primary" />
|
||||
<h2 className="font-display text-xl tracking-tight text-balance">
|
||||
Filmography
|
||||
</h2>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
({filtered.length})
|
||||
</span>
|
||||
@@ -63,6 +65,7 @@ export function FilmographyGrid({ credits }: FilmographyGridProps) {
|
||||
<select
|
||||
value={sort}
|
||||
onChange={(e) => setSort(e.target.value as Sort)}
|
||||
aria-label="Sort filmography"
|
||||
className="rounded-lg border border-border/50 bg-card px-2 py-1 text-xs text-foreground"
|
||||
>
|
||||
<option value="newest">Newest</option>
|
||||
@@ -110,7 +113,7 @@ export function FilmographyGrid({ credits }: FilmographyGridProps) {
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground/30">
|
||||
<IconMovie className="size-10" />
|
||||
<IconMovie aria-hidden={true} className="size-10" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -48,7 +48,7 @@ export function PersonHero({ person }: PersonHeroProps) {
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1 space-y-3">
|
||||
<h1 className="font-display text-3xl tracking-tight sm:text-5xl">
|
||||
<h1 className="font-display text-3xl tracking-tight text-balance sm:text-5xl">
|
||||
{person.name}
|
||||
</h1>
|
||||
|
||||
@@ -61,7 +61,7 @@ export function PersonHero({ person }: PersonHeroProps) {
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-muted-foreground">
|
||||
{person.birthday && (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<IconCalendar className="size-3.5" />
|
||||
<IconCalendar aria-hidden={true} className="size-3.5" />
|
||||
{person.birthday}
|
||||
{age !== null && (
|
||||
<span className="text-muted-foreground/60">
|
||||
@@ -72,7 +72,7 @@ export function PersonHero({ person }: PersonHeroProps) {
|
||||
)}
|
||||
{person.placeOfBirth && (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<IconMapPin className="size-3.5" />
|
||||
<IconMapPin aria-hidden={true} className="size-3.5" />
|
||||
{person.placeOfBirth}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -3,14 +3,16 @@ import Link from "next/link";
|
||||
export default function PersonNotFound() {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4 py-24 text-center">
|
||||
<h1 className="font-display text-4xl tracking-tight">Person not found</h1>
|
||||
<h1 className="font-display text-4xl tracking-tight text-balance">
|
||||
Person not found
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
The person you're looking for doesn't exist or may have been
|
||||
removed.
|
||||
</p>
|
||||
<Link
|
||||
href="/explore"
|
||||
className="inline-flex h-9 items-center rounded-lg bg-primary px-4 text-sm font-medium text-primary-foreground transition-all hover:shadow-md hover:shadow-primary/20"
|
||||
className="inline-flex h-9 items-center rounded-lg bg-primary px-4 text-sm font-medium text-primary-foreground transition-shadow hover:shadow-md hover:shadow-primary/20"
|
||||
>
|
||||
Explore titles
|
||||
</Link>
|
||||
|
||||
@@ -27,7 +27,7 @@ export function AccountSection({
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<IconUser className="size-4 text-muted-foreground" />
|
||||
<IconUser aria-hidden={true} className="size-4 text-muted-foreground" />
|
||||
<h2 className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Account
|
||||
</h2>
|
||||
@@ -59,7 +59,7 @@ export function AccountSection({
|
||||
router.refresh();
|
||||
}}
|
||||
>
|
||||
<IconLogout />
|
||||
<IconLogout aria-hidden={true} />
|
||||
Sign out
|
||||
</Button>
|
||||
</CardContent>
|
||||
|
||||
@@ -52,7 +52,10 @@ export function BackupRestoreSection() {
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
||||
<IconCloudUpload className="size-4 text-primary" />
|
||||
<IconCloudUpload
|
||||
aria-hidden={true}
|
||||
className="size-4 text-primary"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle>Restore</CardTitle>
|
||||
@@ -115,8 +118,8 @@ export function BackupRestoreSection() {
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={restoring}
|
||||
>
|
||||
{restoring ? <Spinner /> : <IconCloudUpload />}
|
||||
{restoring ? "Restoring..." : "Upload"}
|
||||
{restoring ? <Spinner /> : <IconCloudUpload aria-hidden={true} />}
|
||||
{restoring ? "Restoring\u2026" : "Upload"}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -147,7 +147,10 @@ function BackupScheduleInner() {
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
||||
<IconCalendarWeek className="size-4 text-primary" />
|
||||
<IconCalendarWeek
|
||||
aria-hidden={true}
|
||||
className="size-4 text-primary"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle>Backup schedule</CardTitle>
|
||||
@@ -163,7 +166,10 @@ function BackupScheduleInner() {
|
||||
{maxRetention === 0
|
||||
? "unlimited"
|
||||
: `last ${maxRetention}`}
|
||||
<IconChevronDown className="size-2.5" />
|
||||
<IconChevronDown
|
||||
aria-hidden={true}
|
||||
className="size-2.5"
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuRadioGroup
|
||||
@@ -190,6 +196,7 @@ function BackupScheduleInner() {
|
||||
checked={enabled}
|
||||
onCheckedChange={toggleScheduled}
|
||||
disabled={togglingSchedule}
|
||||
aria-label="Toggle scheduled backups"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -246,7 +253,10 @@ function BackupScheduleInner() {
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="inline-flex cursor-pointer items-center gap-1 rounded-md border border-border/50 bg-muted/30 px-2.5 py-1 text-xs text-foreground transition-colors hover:bg-muted/50 disabled:opacity-50">
|
||||
{DAYS_OF_WEEK[dow]}
|
||||
<IconChevronDown className="size-3 text-muted-foreground" />
|
||||
<IconChevronDown
|
||||
aria-hidden={true}
|
||||
className="size-3 text-muted-foreground"
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuRadioGroup
|
||||
@@ -297,7 +307,10 @@ function BackupScheduleInner() {
|
||||
),
|
||||
"h:mm a",
|
||||
)}
|
||||
<IconChevronDown className="size-3 text-muted-foreground" />
|
||||
<IconChevronDown
|
||||
aria-hidden={true}
|
||||
className="size-3 text-muted-foreground"
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuRadioGroup
|
||||
|
||||
@@ -99,7 +99,10 @@ export function BackupSection({
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
||||
<IconDatabaseExport className="size-4 text-primary" />
|
||||
<IconDatabaseExport
|
||||
aria-hidden={true}
|
||||
className="size-4 text-primary"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle>Database backups</CardTitle>
|
||||
@@ -111,8 +114,12 @@ export function BackupSection({
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={handleCreateBackup} disabled={creating}>
|
||||
{creating ? <Spinner className="size-3" /> : <IconPlus />}
|
||||
{creating ? "Creating..." : "New backup"}
|
||||
{creating ? (
|
||||
<Spinner className="size-3" />
|
||||
) : (
|
||||
<IconPlus aria-hidden={true} />
|
||||
)}
|
||||
{creating ? "Creating\u2026" : "New backup"}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -139,11 +146,17 @@ export function BackupSection({
|
||||
}
|
||||
>
|
||||
{backup.source === "scheduled" ? (
|
||||
<IconClock className="size-3.5" />
|
||||
<IconClock aria-hidden={true} className="size-3.5" />
|
||||
) : backup.source === "pre-restore" ? (
|
||||
<IconShieldCheck className="size-3.5" />
|
||||
<IconShieldCheck
|
||||
aria-hidden={true}
|
||||
className="size-3.5"
|
||||
/>
|
||||
) : (
|
||||
<IconPointer className="size-3.5" />
|
||||
<IconPointer
|
||||
aria-hidden={true}
|
||||
className="size-3.5"
|
||||
/>
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
@@ -172,7 +185,7 @@ export function BackupSection({
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<div className="flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100">
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
@@ -204,6 +217,7 @@ export function BackupSection({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="Delete backup"
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
disabled={deleting === backup.filename}
|
||||
/>
|
||||
|
||||
@@ -15,7 +15,10 @@ export function IntegrationsSection({
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<IconWebhook className="size-4 text-muted-foreground" />
|
||||
<IconWebhook
|
||||
aria-hidden={true}
|
||||
className="size-4 text-muted-foreground"
|
||||
/>
|
||||
<h2 className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Integrations
|
||||
</h2>
|
||||
|
||||
@@ -37,7 +37,7 @@ export function RegistrationSection({
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
||||
<IconDoorEnter className="size-4 text-primary" />
|
||||
<IconDoorEnter aria-hidden={true} className="size-4 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle>Open registration</CardTitle>
|
||||
@@ -50,6 +50,7 @@ export function RegistrationSection({
|
||||
checked={registrationOpen}
|
||||
onCheckedChange={handleToggle}
|
||||
disabled={toggling}
|
||||
aria-label="Toggle open registration"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -32,8 +32,10 @@ export function SettingsShell({
|
||||
>
|
||||
<motion.div variants={sectionVariants}>
|
||||
<div className="flex items-center gap-2">
|
||||
<IconSettings className="size-5 text-primary" />
|
||||
<h1 className="font-display text-3xl tracking-tight">Settings</h1>
|
||||
<IconSettings aria-hidden={true} className="size-5 text-primary" />
|
||||
<h1 className="font-display text-3xl tracking-tight text-balance">
|
||||
Settings
|
||||
</h1>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Manage your account and preferences
|
||||
|
||||
@@ -149,7 +149,10 @@ export function SystemHealthCards() {
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
||||
<IconActivity className="size-4 text-primary" />
|
||||
<IconActivity
|
||||
aria-hidden={true}
|
||||
className="size-4 text-primary"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle>Health status</CardTitle>
|
||||
@@ -164,6 +167,7 @@ export function SystemHealthCards() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Refresh system health"
|
||||
onClick={() => refresh()}
|
||||
disabled={isValidating}
|
||||
/>
|
||||
@@ -236,9 +240,15 @@ export function SystemHealthCards() {
|
||||
<span className="inline-flex items-center gap-1.5 text-[11px] font-medium uppercase tracking-wider text-muted-foreground/70">
|
||||
Environment
|
||||
{data.environment.dataDirWritable ? (
|
||||
<IconCheck className="size-3 text-green-500" />
|
||||
<IconCheck
|
||||
aria-hidden={true}
|
||||
className="size-3 text-green-500"
|
||||
/>
|
||||
) : (
|
||||
<IconAlertTriangle className="size-3 text-destructive" />
|
||||
<IconAlertTriangle
|
||||
aria-hidden={true}
|
||||
className="size-3 text-destructive"
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
<div className="space-y-1">
|
||||
@@ -270,7 +280,10 @@ export function SystemHealthCards() {
|
||||
<CardContent>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
||||
<IconDatabase className="size-4 text-primary" />
|
||||
<IconDatabase
|
||||
aria-hidden={true}
|
||||
className="size-4 text-primary"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle>Storage</CardTitle>
|
||||
@@ -386,7 +399,10 @@ function BackgroundJobsCard({
|
||||
<CardContent>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
||||
<IconCalendarCheck className="size-4 text-primary" />
|
||||
<IconCalendarCheck
|
||||
aria-hidden={true}
|
||||
className="size-4 text-primary"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle>Background jobs</CardTitle>
|
||||
@@ -535,6 +551,7 @@ function BackgroundJobsCard({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Trigger job"
|
||||
className="size-6"
|
||||
disabled={isRunning}
|
||||
onClick={() => handleTrigger(job.jobName)}
|
||||
@@ -544,7 +561,10 @@ function BackgroundJobsCard({
|
||||
{isRunning ? (
|
||||
<Spinner className="size-3" />
|
||||
) : (
|
||||
<IconPlayerPlay className="size-3 text-muted-foreground/50" />
|
||||
<IconPlayerPlay
|
||||
aria-hidden={true}
|
||||
className="size-3 text-muted-foreground/50"
|
||||
/>
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Run now</TooltipContent>
|
||||
|
||||
@@ -37,7 +37,10 @@ export function UpdateCheckSection({
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
||||
<IconWorldUpload className="size-4 text-primary" />
|
||||
<IconWorldUpload
|
||||
aria-hidden={true}
|
||||
className="size-4 text-primary"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle>Automatic update checks</CardTitle>
|
||||
@@ -50,6 +53,7 @@ export function UpdateCheckSection({
|
||||
checked={enabled}
|
||||
onCheckedChange={handleToggle}
|
||||
disabled={toggling}
|
||||
aria-label="Toggle automatic update checks"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -108,6 +108,7 @@ export function WebhookCard({
|
||||
</div>
|
||||
</div>
|
||||
<IconChevronDown
|
||||
aria-hidden={true}
|
||||
className={`size-4 text-muted-foreground transition-transform duration-200 ${cardOpen ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</CollapsibleTrigger>
|
||||
@@ -117,7 +118,10 @@ export function WebhookCard({
|
||||
<CardContent className="space-y-3 border-t border-border/30 pt-4">
|
||||
{isPlex && (
|
||||
<div className="flex gap-2.5 rounded-lg border border-primary/20 bg-primary/5 px-3 py-2.5">
|
||||
<IconInfoCircle className="mt-0.5 size-3.5 shrink-0 text-primary" />
|
||||
<IconInfoCircle
|
||||
aria-hidden={true}
|
||||
className="mt-0.5 size-3.5 shrink-0 text-primary"
|
||||
/>
|
||||
<p className="text-xs leading-relaxed text-foreground/80">
|
||||
Requires an active{" "}
|
||||
<a
|
||||
@@ -127,7 +131,10 @@ export function WebhookCard({
|
||||
className="inline-flex items-center gap-0.5 font-medium text-foreground underline-offset-2 hover:underline"
|
||||
>
|
||||
<span>Plex Pass</span>
|
||||
<IconExternalLink className="inline-block size-3 translate-y-[-1px]" />
|
||||
<IconExternalLink
|
||||
aria-hidden={true}
|
||||
className="inline-block size-3 translate-y-[-1px]"
|
||||
/>
|
||||
</a>{" "}
|
||||
subscription.
|
||||
</p>
|
||||
@@ -136,7 +143,10 @@ export function WebhookCard({
|
||||
|
||||
{isEmby && (
|
||||
<div className="flex gap-2.5 rounded-lg border border-primary/20 bg-primary/5 px-3 py-2.5">
|
||||
<IconInfoCircle className="mt-0.5 size-3.5 shrink-0 text-primary" />
|
||||
<IconInfoCircle
|
||||
aria-hidden={true}
|
||||
className="mt-0.5 size-3.5 shrink-0 text-primary"
|
||||
/>
|
||||
<p className="text-xs leading-relaxed text-foreground/80">
|
||||
Requires{" "}
|
||||
<span className="font-medium text-foreground">
|
||||
@@ -150,7 +160,10 @@ export function WebhookCard({
|
||||
className="inline-flex items-center gap-0.5 font-medium text-foreground underline-offset-2 hover:underline"
|
||||
>
|
||||
<span>Emby Premiere</span>
|
||||
<IconExternalLink className="inline-block size-3 translate-y-[-1px]" />
|
||||
<IconExternalLink
|
||||
aria-hidden={true}
|
||||
className="inline-block size-3 translate-y-[-1px]"
|
||||
/>
|
||||
</a>{" "}
|
||||
license.
|
||||
</p>
|
||||
@@ -164,7 +177,7 @@ export function WebhookCard({
|
||||
size="lg"
|
||||
className="w-full"
|
||||
>
|
||||
{connecting ? "Connecting..." : `Connect ${label}`}
|
||||
{connecting ? "Connecting\u2026" : `Connect ${label}`}
|
||||
</Button>
|
||||
) : (
|
||||
<AnimatePresence>
|
||||
@@ -236,6 +249,7 @@ export function WebhookCard({
|
||||
<Collapsible open={setupOpen} onOpenChange={setSetupOpen}>
|
||||
<CollapsibleTrigger className="flex w-full items-center gap-1.5 rounded-md py-1 text-xs text-muted-foreground transition-colors hover:text-foreground">
|
||||
<IconChevronDown
|
||||
aria-hidden={true}
|
||||
className={`size-3 transition-transform ${setupOpen ? "rotate-0" : "-rotate-90"}`}
|
||||
/>
|
||||
Setup instructions
|
||||
@@ -254,7 +268,10 @@ export function WebhookCard({
|
||||
className="inline-flex items-center gap-0.5 font-medium text-foreground underline-offset-2 hover:underline"
|
||||
>
|
||||
Settings > Webhooks
|
||||
<IconExternalLink className="inline-block size-3 translate-y-[-1px]" />
|
||||
<IconExternalLink
|
||||
aria-hidden={true}
|
||||
className="inline-block size-3 translate-y-[-1px]"
|
||||
/>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
@@ -293,7 +310,10 @@ export function WebhookCard({
|
||||
className="inline-flex items-center gap-0.5 font-medium text-foreground underline-offset-2 hover:underline"
|
||||
>
|
||||
Webhook plugin
|
||||
<IconExternalLink className="inline-block size-3 translate-y-[-1px]" />
|
||||
<IconExternalLink
|
||||
aria-hidden={true}
|
||||
className="inline-block size-3 translate-y-[-1px]"
|
||||
/>
|
||||
</a>{" "}
|
||||
from Jellyfin's plugin catalog
|
||||
</li>
|
||||
@@ -325,7 +345,10 @@ export function WebhookCard({
|
||||
</li>
|
||||
</ol>
|
||||
<p className="mt-2 -ml-0.5">
|
||||
<IconBook2 className="inline-block size-3 translate-y-[-1px] mr-1" />
|
||||
<IconBook2
|
||||
aria-hidden={true}
|
||||
className="inline-block size-3 translate-y-[-1px] mr-1"
|
||||
/>
|
||||
Need more help?{" "}
|
||||
<a
|
||||
href={
|
||||
@@ -340,7 +363,10 @@ export function WebhookCard({
|
||||
className="inline-flex items-center gap-0.5 font-medium text-foreground underline-offset-2 hover:underline"
|
||||
>
|
||||
Open docs{" "}
|
||||
<IconExternalLink className="inline-block size-3 translate-y-[-1px]" />
|
||||
<IconExternalLink
|
||||
aria-hidden={true}
|
||||
className="inline-block size-3 translate-y-[-1px]"
|
||||
/>
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -162,7 +162,10 @@ export default async function SettingsPage() {
|
||||
{/* Server health */}
|
||||
<div>
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<IconServer2 className="size-4 text-muted-foreground" />
|
||||
<IconServer2
|
||||
aria-hidden={true}
|
||||
className="size-4 text-muted-foreground"
|
||||
/>
|
||||
<h2 className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Server
|
||||
</h2>
|
||||
@@ -176,7 +179,10 @@ export default async function SettingsPage() {
|
||||
{/* Security */}
|
||||
<div>
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<IconShieldLock className="size-4 text-muted-foreground" />
|
||||
<IconShieldLock
|
||||
aria-hidden={true}
|
||||
className="size-4 text-muted-foreground"
|
||||
/>
|
||||
<h2 className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Security
|
||||
</h2>
|
||||
@@ -199,7 +205,10 @@ export default async function SettingsPage() {
|
||||
{/* Backups */}
|
||||
<div>
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<IconDatabaseExport className="size-4 text-muted-foreground" />
|
||||
<IconDatabaseExport
|
||||
aria-hidden={true}
|
||||
className="size-4 text-muted-foreground"
|
||||
/>
|
||||
<h2 className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Backups
|
||||
</h2>
|
||||
|
||||
@@ -20,7 +20,7 @@ export function CastCarousel({ actors, titleType }: CastCarouselProps) {
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<IconUsers className="size-5 text-primary" />
|
||||
<IconUsers aria-hidden={true} className="size-5 text-primary" />
|
||||
<h2 className="font-display text-xl tracking-tight">Cast</h2>
|
||||
</div>
|
||||
|
||||
@@ -55,11 +55,14 @@ export function CastCarousel({ actors, titleType }: CastCarouselProps) {
|
||||
alt={member.name}
|
||||
width={96}
|
||||
height={96}
|
||||
className="h-full w-full object-cover transition-transform group-hover:scale-105"
|
||||
className="h-full w-full object-cover motion-safe:transition-transform motion-safe:group-hover:scale-105"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center bg-gradient-to-br from-muted to-muted/50">
|
||||
<IconUser className="size-8 text-muted-foreground/50" />
|
||||
<IconUser
|
||||
aria-hidden={true}
|
||||
className="size-8 text-muted-foreground/50"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -12,7 +12,7 @@ export function RecommendationsGrid({
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<IconSparkles className="size-5 text-primary" />
|
||||
<IconSparkles aria-hidden={true} className="size-5 text-primary" />
|
||||
<h2 className="font-display text-2xl tracking-tight">Recommended</h2>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6">
|
||||
|
||||
@@ -25,6 +25,9 @@ export function StarRating({ value, onChange }: StarRatingProps) {
|
||||
<motion.button
|
||||
key={star}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={star === value}
|
||||
aria-label={`Rate ${star} star${star !== 1 ? "s" : ""}`}
|
||||
onClick={() => onChange(star === value ? 0 : star)}
|
||||
onMouseEnter={() => setHover(star)}
|
||||
className="p-0.5"
|
||||
|
||||
@@ -50,7 +50,7 @@ export function StatusButton({ currentStatus, onChange }: StatusButtonProps) {
|
||||
transition={{ duration: 0.15 }}
|
||||
className="inline-flex h-9 items-center gap-2 rounded-lg bg-primary/10 px-4 text-sm font-medium text-primary ring-1 ring-primary/20 transition-all hover:bg-primary/15 hover:ring-primary/30 active:scale-[0.97]"
|
||||
>
|
||||
<IconPlus className="size-3.5" strokeWidth={2.5} />
|
||||
<IconPlus aria-hidden={true} className="size-3.5" strokeWidth={2.5} />
|
||||
Watchlist
|
||||
</motion.button>
|
||||
) : (
|
||||
@@ -66,8 +66,14 @@ export function StatusButton({ currentStatus, onChange }: StatusButtonProps) {
|
||||
className={`group inline-flex h-9 items-center gap-2 rounded-lg px-4 text-sm font-medium ring-1 transition-all active:scale-[0.97] ${config.class} ${config.bgClass} ${config.borderClass} hover:ring-destructive/30 hover:bg-destructive/10 hover:text-destructive`}
|
||||
>
|
||||
<span className="grid [&>svg]:col-start-1 [&>svg]:row-start-1">
|
||||
<config.icon className="size-3.5 transition-opacity group-hover:opacity-0" />
|
||||
<IconX className="size-3.5 opacity-0 text-destructive transition-opacity group-hover:opacity-100" />
|
||||
<config.icon
|
||||
aria-hidden={true}
|
||||
className="size-3.5 transition-opacity group-hover:opacity-0"
|
||||
/>
|
||||
<IconX
|
||||
aria-hidden={true}
|
||||
className="size-3.5 opacity-0 text-destructive transition-opacity group-hover:opacity-100"
|
||||
/>
|
||||
</span>
|
||||
<span className="grid [&>span]:col-start-1 [&>span]:row-start-1">
|
||||
<span className="transition-opacity group-hover:opacity-0">
|
||||
|
||||
@@ -30,7 +30,7 @@ export function TitleActions() {
|
||||
onClick={handleWatchMovie}
|
||||
className="inline-flex h-9 items-center gap-2 rounded-lg bg-primary px-4 text-sm font-medium text-primary-foreground transition-all active:scale-[0.97] hover:shadow-md hover:shadow-primary/20"
|
||||
>
|
||||
<IconCheck className="size-3.5" />
|
||||
<IconCheck aria-hidden={true} className="size-3.5" />
|
||||
Mark Watched
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -25,7 +25,7 @@ function ProviderBadge({
|
||||
}) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger className="flex h-10 w-10 items-center justify-center overflow-hidden rounded-lg border border-border/30 bg-card transition-transform hover:scale-105">
|
||||
<TooltipTrigger className="flex h-10 w-10 items-center justify-center overflow-hidden rounded-lg border border-border/30 bg-card motion-safe:transition-transform motion-safe:hover:scale-105">
|
||||
{logoPath ? (
|
||||
<Image
|
||||
src={logoPath}
|
||||
@@ -62,9 +62,9 @@ export function TitleAvailability({
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Where to Watch
|
||||
</h3>
|
||||
</h2>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{Object.entries(availByType).map(([type, offers]) => (
|
||||
<div key={type} className="space-y-1.5">
|
||||
|
||||
@@ -96,7 +96,7 @@ export function TitleHero({
|
||||
|
||||
<div className="flex-1 space-y-5">
|
||||
<div>
|
||||
<h1 className="font-display text-2xl tracking-tight sm:text-5xl">
|
||||
<h1 className="font-display text-2xl tracking-tight text-balance sm:text-5xl">
|
||||
{title.title}
|
||||
</h1>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-3 text-sm text-muted-foreground">
|
||||
@@ -123,6 +123,7 @@ export function TitleHero({
|
||||
href={`https://www.themoviedb.org/${title.type === "movie" ? "movie" : "tv"}/${title.tmdbId}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="View on TMDB"
|
||||
className="inline-flex h-5 items-center rounded border border-border/50 px-2 text-xs text-muted-foreground transition-colors hover:border-border hover:text-foreground"
|
||||
>
|
||||
<TmdbLogo className="h-2.5 w-auto" />
|
||||
@@ -131,7 +132,7 @@ export function TitleHero({
|
||||
</div>
|
||||
|
||||
{title.overview && (
|
||||
<p className="max-w-2xl leading-relaxed text-muted-foreground">
|
||||
<p className="max-w-2xl break-words leading-relaxed text-muted-foreground">
|
||||
{title.overview}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -63,7 +63,7 @@ export function TitleSeasons({
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<IconDeviceTvOld className="size-5 text-primary" />
|
||||
<IconDeviceTvOld aria-hidden={true} className="size-5 text-primary" />
|
||||
<h2 className="font-display text-2xl tracking-tight">Seasons</h2>
|
||||
</div>
|
||||
{userStatus && userStatus !== "completed" && (
|
||||
@@ -74,7 +74,7 @@ export function TitleSeasons({
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-[10px] font-medium uppercase tracking-wider text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<IconChecks className="size-3.5" />
|
||||
<IconChecks aria-hidden={true} className="size-3.5" />
|
||||
Mark All Watched
|
||||
</button>
|
||||
}
|
||||
@@ -180,9 +180,15 @@ export function TitleSeasons({
|
||||
</span>
|
||||
)}
|
||||
{isOpen ? (
|
||||
<IconChevronUp className="size-4 text-muted-foreground" />
|
||||
<IconChevronUp
|
||||
aria-hidden={true}
|
||||
className="size-4 text-muted-foreground"
|
||||
/>
|
||||
) : (
|
||||
<IconChevronDown className="size-4 text-muted-foreground" />
|
||||
<IconChevronDown
|
||||
aria-hidden={true}
|
||||
className="size-4 text-muted-foreground"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -210,6 +216,7 @@ export function TitleSeasons({
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Mark episode ${ep.episodeNumber} as ${isWatched ? "unwatched" : "watched"}`}
|
||||
onClick={() =>
|
||||
handleWatchEpisode(
|
||||
ep.id,
|
||||
|
||||
@@ -26,8 +26,9 @@ export function TrailerDialog({
|
||||
{variant === "backdrop" ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Play trailer"
|
||||
onClick={() => setOpen(true)}
|
||||
className="group flex size-12 items-center justify-center rounded-full bg-white/10 ring-1 ring-white/20 backdrop-blur-md transition-all duration-300 hover:scale-105 hover:bg-white/20 hover:ring-white/30 active:scale-100 sm:size-16"
|
||||
className="group flex size-12 items-center justify-center rounded-full bg-white/10 ring-1 ring-white/20 backdrop-blur-md transition-[transform,background-color,box-shadow] duration-300 hover:scale-105 hover:bg-white/20 hover:ring-white/30 active:scale-100 sm:size-16"
|
||||
>
|
||||
<IconPlayerPlayFilled className="size-6 text-white drop-shadow-lg sm:size-8" />
|
||||
</button>
|
||||
@@ -37,7 +38,7 @@ export function TrailerDialog({
|
||||
onClick={() => setOpen(true)}
|
||||
className="inline-flex h-5 items-center gap-1 rounded border border-border/50 px-2 text-xs text-muted-foreground transition-colors hover:border-border hover:text-foreground"
|
||||
>
|
||||
<IconPlayerPlayFilled className="h-2.5 w-2.5" />
|
||||
<IconPlayerPlayFilled aria-hidden={true} className="h-2.5 w-2.5" />
|
||||
Trailer
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -166,3 +166,10 @@
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.animate-stagger-item,
|
||||
.animate-pulse {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
+13
-2
@@ -1,3 +1,4 @@
|
||||
import { MotionConfig } from "motion/react";
|
||||
import type { Metadata } from "next";
|
||||
import { DM_Sans, DM_Serif_Display, Geist_Mono } from "next/font/google";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
@@ -24,6 +25,7 @@ const geistMono = Geist_Mono({
|
||||
export const metadata: Metadata = {
|
||||
title: "Sofa",
|
||||
description: "Track your movies and TV shows",
|
||||
themeColor: "#090706",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -32,11 +34,20 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<html lang="en" style={{ colorScheme: "dark" }}>
|
||||
<body
|
||||
className={`${dmSans.variable} ${dmSerif.variable} ${geistMono.variable} overflow-x-hidden font-sans antialiased`}
|
||||
style={{ touchAction: "manipulation" }}
|
||||
>
|
||||
<TooltipProvider>{children}</TooltipProvider>
|
||||
<a
|
||||
href="#main-content"
|
||||
className="sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4 focus:z-50 focus:px-4 focus:py-2 focus:bg-primary focus:text-primary-foreground focus:rounded-md"
|
||||
>
|
||||
Skip to main content
|
||||
</a>
|
||||
<MotionConfig reducedMotion="user">
|
||||
<TooltipProvider>{children}</TooltipProvider>
|
||||
</MotionConfig>
|
||||
<Toaster position="bottom-right" />
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user