mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 01:35:39 -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="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="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">
|
<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>
|
||||||
<div className="space-y-2">
|
<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
|
Registration Closed
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-muted-foreground">
|
<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"
|
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
|
themoviedb.org
|
||||||
<IconExternalLink className="size-3.5" />
|
<IconExternalLink aria-hidden={true} className="size-3.5" />
|
||||||
</a>{" "}
|
</a>{" "}
|
||||||
and sign up for a free account.
|
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"
|
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
|
Settings → API
|
||||||
<IconExternalLink className="size-3.5" />
|
<IconExternalLink aria-hidden={true} className="size-3.5" />
|
||||||
</a>{" "}
|
</a>{" "}
|
||||||
and request an API key. Choose “Developer” when asked. You
|
and request an API key. Choose “Developer” when asked. You
|
||||||
need the{" "}
|
need the{" "}
|
||||||
@@ -133,10 +133,10 @@ export default function SetupPage() {
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<motion.div variants={sectionVariants} className="space-y-3">
|
<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">
|
<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
|
Setup required
|
||||||
</div>
|
</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
|
Connect to TMDB
|
||||||
</h1>
|
</h1>
|
||||||
<p className="max-w-lg text-muted-foreground leading-relaxed">
|
<p className="max-w-lg text-muted-foreground leading-relaxed">
|
||||||
@@ -164,7 +164,7 @@ export default function SetupPage() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<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">
|
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||||
{step.description}
|
{step.description}
|
||||||
</p>
|
</p>
|
||||||
@@ -188,12 +188,15 @@ export default function SetupPage() {
|
|||||||
>
|
>
|
||||||
{copiedIdx === idx ? (
|
{copiedIdx === idx ? (
|
||||||
<>
|
<>
|
||||||
<IconCheck className="size-3 text-green-400" />
|
<IconCheck
|
||||||
|
aria-hidden={true}
|
||||||
|
className="size-3 text-green-400"
|
||||||
|
/>
|
||||||
Copied
|
Copied
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<IconCopy className="size-3" />
|
<IconCopy aria-hidden={true} className="size-3" />
|
||||||
Copy
|
Copy
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -217,14 +220,17 @@ export default function SetupPage() {
|
|||||||
{configured === true ? (
|
{configured === true ? (
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-green-500/10">
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-green-400">
|
<p className="font-medium text-green-400">
|
||||||
TMDB API key detected
|
TMDB API key detected
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Redirecting you to Sofa...
|
Redirecting you to Sofa…
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -247,7 +253,7 @@ export default function SetupPage() {
|
|||||||
{checking ? (
|
{checking ? (
|
||||||
<>
|
<>
|
||||||
<span className="h-3 w-3 animate-spin rounded-full border-2 border-primary-foreground/30 border-t-primary-foreground" />
|
<span className="h-3 w-3 animate-spin rounded-full border-2 border-primary-foreground/30 border-t-primary-foreground" />
|
||||||
Checking...
|
Checking…
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
"Check configuration"
|
"Check configuration"
|
||||||
|
|||||||
@@ -44,18 +44,21 @@ export function ContinueWatchingCard({
|
|||||||
alt={item.nextEpisode?.name ?? item.title.title}
|
alt={item.nextEpisode?.name ?? item.title.title}
|
||||||
width={500}
|
width={500}
|
||||||
height={281}
|
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">
|
<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>
|
||||||
)}
|
)}
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-black/20 to-transparent" />
|
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-black/20 to-transparent" />
|
||||||
{item.nextEpisode && (
|
{item.nextEpisode && (
|
||||||
<div className="absolute bottom-2.5 left-3 right-3">
|
<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">
|
<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
|
Up next
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-0.5 truncate text-sm font-medium text-white">
|
<p className="mt-0.5 truncate text-sm font-medium text-white">
|
||||||
@@ -76,13 +79,13 @@ export function ContinueWatchingCard({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
{progress > 0 && (
|
{progress > 0 && (
|
||||||
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-muted">
|
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-muted">
|
||||||
<div
|
<div
|
||||||
className="h-full bg-primary transition-all"
|
className="h-full bg-primary transition-[width]"
|
||||||
style={{ width: `${progress}%` }}
|
style={{ width: `${progress}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,8 +14,10 @@ export function FeedSection({
|
|||||||
return (
|
return (
|
||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{icon}
|
<span aria-hidden={true}>{icon}</span>
|
||||||
<h2 className="font-display text-xl tracking-tight">{title}</h2>
|
<h2 className="font-display text-xl tracking-tight text-balance">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
{children}
|
{children}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ function StatCard({
|
|||||||
<div
|
<div
|
||||||
className={`flex h-6 w-6 items-center justify-center rounded-md ${bgColor}`}
|
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>
|
</div>
|
||||||
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||||
{label}
|
{label}
|
||||||
@@ -75,7 +75,7 @@ function StatCard({
|
|||||||
</div>
|
</div>
|
||||||
<p
|
<p
|
||||||
suppressHydrationWarning
|
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}
|
{value}
|
||||||
</p>
|
</p>
|
||||||
@@ -98,7 +98,7 @@ function PeriodSelector({
|
|||||||
<DropdownMenu>
|
<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">
|
<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]}
|
{periodLabels[period]}
|
||||||
<IconChevronDown className="size-2.5" />
|
<IconChevronDown aria-hidden={true} className="size-2.5" />
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="start">
|
<DropdownMenuContent align="start">
|
||||||
<DropdownMenuRadioGroup
|
<DropdownMenuRadioGroup
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export async function StatsSection({ userId }: { userId: string }) {
|
|||||||
{isEmpty && (
|
{isEmpty && (
|
||||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed border-border/50 py-16 text-center">
|
<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">
|
<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>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<p className="font-medium">Your library is empty</p>
|
<p className="font-medium">Your library is empty</p>
|
||||||
@@ -28,7 +28,7 @@ export async function StatsSection({ userId }: { userId: string }) {
|
|||||||
</div>
|
</div>
|
||||||
<Link
|
<Link
|
||||||
href="/explore"
|
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
|
Start exploring
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
export function WelcomeHeader({ name }: { name?: string | null }) {
|
export function WelcomeHeader({ name }: { name?: string | null }) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1 className="font-display text-3xl tracking-tight">
|
<h1 className="font-display text-3xl tracking-tight text-balance">
|
||||||
Welcome back{name ? `, ${name}` : ""}
|
Welcome back{name ? `, ${name}` : ""}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
|||||||
@@ -106,7 +106,9 @@ function FilterableTitleRowInner({
|
|||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{icon}
|
{icon}
|
||||||
<h2 className="font-display text-xl tracking-tight">{heading}</h2>
|
<h2 className="font-display text-xl tracking-tight text-balance">
|
||||||
|
{heading}
|
||||||
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Genre chips */}
|
{/* Genre chips */}
|
||||||
@@ -116,7 +118,7 @@ function FilterableTitleRowInner({
|
|||||||
key={genre.id}
|
key={genre.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => toggleGenre(genre.id)}
|
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
|
selectedGenre === genre.id
|
||||||
? "border-primary bg-primary/10 text-primary"
|
? "border-primary bg-primary/10 text-primary"
|
||||||
: "border-border/50 bg-card/50 text-muted-foreground hover:border-primary/20 hover:text-foreground"
|
: "border-border/50 bg-card/50 text-muted-foreground hover:border-primary/20 hover:text-foreground"
|
||||||
|
|||||||
@@ -56,7 +56,10 @@ export function HeroBanner({
|
|||||||
</span>
|
</span>
|
||||||
{voteAverage > 0 && (
|
{voteAverage > 0 && (
|
||||||
<span className="flex items-center gap-1 text-sm text-primary">
|
<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)}
|
{voteAverage.toFixed(1)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -65,7 +68,7 @@ export function HeroBanner({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Link href={href} className="group/title">
|
<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}
|
{title}
|
||||||
</h2>
|
</h2>
|
||||||
</Link>
|
</Link>
|
||||||
@@ -74,9 +77,9 @@ export function HeroBanner({
|
|||||||
</p>
|
</p>
|
||||||
<Link
|
<Link
|
||||||
href={href}
|
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
|
Add to Library
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ export default async function ExplorePage() {
|
|||||||
|
|
||||||
<TitleRow
|
<TitleRow
|
||||||
heading="Trending Today"
|
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)}
|
items={trendingItems.slice(0, 20)}
|
||||||
userStatuses={userStatuses}
|
userStatuses={userStatuses}
|
||||||
episodeProgress={episodeProgress}
|
episodeProgress={episodeProgress}
|
||||||
@@ -99,7 +99,7 @@ export default async function ExplorePage() {
|
|||||||
|
|
||||||
<FilterableTitleRow
|
<FilterableTitleRow
|
||||||
heading="Popular Movies"
|
heading="Popular Movies"
|
||||||
icon={<IconMovie className="size-5 text-primary" />}
|
icon={<IconMovie aria-hidden={true} className="size-5 text-primary" />}
|
||||||
mediaType="movie"
|
mediaType="movie"
|
||||||
defaultItems={popularMovieItems.slice(0, 20)}
|
defaultItems={popularMovieItems.slice(0, 20)}
|
||||||
genres={movieGenres.genres}
|
genres={movieGenres.genres}
|
||||||
@@ -109,7 +109,9 @@ export default async function ExplorePage() {
|
|||||||
|
|
||||||
<FilterableTitleRow
|
<FilterableTitleRow
|
||||||
heading="Popular TV Shows"
|
heading="Popular TV Shows"
|
||||||
icon={<IconDeviceTv className="size-5 text-primary" />}
|
icon={
|
||||||
|
<IconDeviceTv aria-hidden={true} className="size-5 text-primary" />
|
||||||
|
}
|
||||||
mediaType="tv"
|
mediaType="tv"
|
||||||
defaultItems={popularTvItems.slice(0, 20)}
|
defaultItems={popularTvItems.slice(0, 20)}
|
||||||
genres={tvGenres.genres}
|
genres={tvGenres.genres}
|
||||||
|
|||||||
@@ -38,7 +38,9 @@ export function TitleRow({
|
|||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{icon}
|
{icon}
|
||||||
<h2 className="font-display text-xl tracking-tight">{heading}</h2>
|
<h2 className="font-display text-xl tracking-tight text-balance">
|
||||||
|
{heading}
|
||||||
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<Carousel
|
<Carousel
|
||||||
opts={{ align: "start", dragFree: true, containScroll: "trimSnaps" }}
|
opts={{ align: "start", dragFree: true, containScroll: "trimSnaps" }}
|
||||||
|
|||||||
@@ -22,7 +22,10 @@ export default async function PagesLayout({
|
|||||||
<NavBar />
|
<NavBar />
|
||||||
{/* Ambient glow */}
|
{/* 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]" />
|
<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}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -53,8 +53,10 @@ export function FilmographyGrid({ credits }: FilmographyGridProps) {
|
|||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<IconMovie className="size-5 text-primary" />
|
<IconMovie aria-hidden={true} className="size-5 text-primary" />
|
||||||
<h2 className="font-display text-xl tracking-tight">Filmography</h2>
|
<h2 className="font-display text-xl tracking-tight text-balance">
|
||||||
|
Filmography
|
||||||
|
</h2>
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
({filtered.length})
|
({filtered.length})
|
||||||
</span>
|
</span>
|
||||||
@@ -63,6 +65,7 @@ export function FilmographyGrid({ credits }: FilmographyGridProps) {
|
|||||||
<select
|
<select
|
||||||
value={sort}
|
value={sort}
|
||||||
onChange={(e) => setSort(e.target.value as 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"
|
className="rounded-lg border border-border/50 bg-card px-2 py-1 text-xs text-foreground"
|
||||||
>
|
>
|
||||||
<option value="newest">Newest</option>
|
<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">
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export function PersonHero({ person }: PersonHeroProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="min-w-0 flex-1 space-y-3">
|
<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}
|
{person.name}
|
||||||
</h1>
|
</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">
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-muted-foreground">
|
||||||
{person.birthday && (
|
{person.birthday && (
|
||||||
<span className="flex items-center gap-1.5">
|
<span className="flex items-center gap-1.5">
|
||||||
<IconCalendar className="size-3.5" />
|
<IconCalendar aria-hidden={true} className="size-3.5" />
|
||||||
{person.birthday}
|
{person.birthday}
|
||||||
{age !== null && (
|
{age !== null && (
|
||||||
<span className="text-muted-foreground/60">
|
<span className="text-muted-foreground/60">
|
||||||
@@ -72,7 +72,7 @@ export function PersonHero({ person }: PersonHeroProps) {
|
|||||||
)}
|
)}
|
||||||
{person.placeOfBirth && (
|
{person.placeOfBirth && (
|
||||||
<span className="flex items-center gap-1.5">
|
<span className="flex items-center gap-1.5">
|
||||||
<IconMapPin className="size-3.5" />
|
<IconMapPin aria-hidden={true} className="size-3.5" />
|
||||||
{person.placeOfBirth}
|
{person.placeOfBirth}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -3,14 +3,16 @@ import Link from "next/link";
|
|||||||
export default function PersonNotFound() {
|
export default function PersonNotFound() {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center gap-4 py-24 text-center">
|
<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">
|
<p className="text-muted-foreground">
|
||||||
The person you're looking for doesn't exist or may have been
|
The person you're looking for doesn't exist or may have been
|
||||||
removed.
|
removed.
|
||||||
</p>
|
</p>
|
||||||
<Link
|
<Link
|
||||||
href="/explore"
|
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
|
Explore titles
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export function AccountSection({
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-3 flex items-center gap-2">
|
<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">
|
<h2 className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||||
Account
|
Account
|
||||||
</h2>
|
</h2>
|
||||||
@@ -59,7 +59,7 @@ export function AccountSection({
|
|||||||
router.refresh();
|
router.refresh();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<IconLogout />
|
<IconLogout aria-hidden={true} />
|
||||||
Sign out
|
Sign out
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -52,7 +52,10 @@ export function BackupRestoreSection() {
|
|||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<div className="flex items-start gap-3">
|
<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">
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<CardTitle>Restore</CardTitle>
|
<CardTitle>Restore</CardTitle>
|
||||||
@@ -115,8 +118,8 @@ export function BackupRestoreSection() {
|
|||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
disabled={restoring}
|
disabled={restoring}
|
||||||
>
|
>
|
||||||
{restoring ? <Spinner /> : <IconCloudUpload />}
|
{restoring ? <Spinner /> : <IconCloudUpload aria-hidden={true} />}
|
||||||
{restoring ? "Restoring..." : "Upload"}
|
{restoring ? "Restoring\u2026" : "Upload"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -147,7 +147,10 @@ function BackupScheduleInner() {
|
|||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<div className="flex items-start gap-3">
|
<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">
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<CardTitle>Backup schedule</CardTitle>
|
<CardTitle>Backup schedule</CardTitle>
|
||||||
@@ -163,7 +166,10 @@ function BackupScheduleInner() {
|
|||||||
{maxRetention === 0
|
{maxRetention === 0
|
||||||
? "unlimited"
|
? "unlimited"
|
||||||
: `last ${maxRetention}`}
|
: `last ${maxRetention}`}
|
||||||
<IconChevronDown className="size-2.5" />
|
<IconChevronDown
|
||||||
|
aria-hidden={true}
|
||||||
|
className="size-2.5"
|
||||||
|
/>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="start">
|
<DropdownMenuContent align="start">
|
||||||
<DropdownMenuRadioGroup
|
<DropdownMenuRadioGroup
|
||||||
@@ -190,6 +196,7 @@ function BackupScheduleInner() {
|
|||||||
checked={enabled}
|
checked={enabled}
|
||||||
onCheckedChange={toggleScheduled}
|
onCheckedChange={toggleScheduled}
|
||||||
disabled={togglingSchedule}
|
disabled={togglingSchedule}
|
||||||
|
aria-label="Toggle scheduled backups"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -246,7 +253,10 @@ function BackupScheduleInner() {
|
|||||||
<DropdownMenu>
|
<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">
|
<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]}
|
{DAYS_OF_WEEK[dow]}
|
||||||
<IconChevronDown className="size-3 text-muted-foreground" />
|
<IconChevronDown
|
||||||
|
aria-hidden={true}
|
||||||
|
className="size-3 text-muted-foreground"
|
||||||
|
/>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="start">
|
<DropdownMenuContent align="start">
|
||||||
<DropdownMenuRadioGroup
|
<DropdownMenuRadioGroup
|
||||||
@@ -297,7 +307,10 @@ function BackupScheduleInner() {
|
|||||||
),
|
),
|
||||||
"h:mm a",
|
"h:mm a",
|
||||||
)}
|
)}
|
||||||
<IconChevronDown className="size-3 text-muted-foreground" />
|
<IconChevronDown
|
||||||
|
aria-hidden={true}
|
||||||
|
className="size-3 text-muted-foreground"
|
||||||
|
/>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="start">
|
<DropdownMenuContent align="start">
|
||||||
<DropdownMenuRadioGroup
|
<DropdownMenuRadioGroup
|
||||||
|
|||||||
@@ -99,7 +99,10 @@ export function BackupSection({
|
|||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<div className="flex items-start gap-3">
|
<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">
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<CardTitle>Database backups</CardTitle>
|
<CardTitle>Database backups</CardTitle>
|
||||||
@@ -111,8 +114,12 @@ export function BackupSection({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={handleCreateBackup} disabled={creating}>
|
<Button onClick={handleCreateBackup} disabled={creating}>
|
||||||
{creating ? <Spinner className="size-3" /> : <IconPlus />}
|
{creating ? (
|
||||||
{creating ? "Creating..." : "New backup"}
|
<Spinner className="size-3" />
|
||||||
|
) : (
|
||||||
|
<IconPlus aria-hidden={true} />
|
||||||
|
)}
|
||||||
|
{creating ? "Creating\u2026" : "New backup"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -139,11 +146,17 @@ export function BackupSection({
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{backup.source === "scheduled" ? (
|
{backup.source === "scheduled" ? (
|
||||||
<IconClock className="size-3.5" />
|
<IconClock aria-hidden={true} className="size-3.5" />
|
||||||
) : backup.source === "pre-restore" ? (
|
) : 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>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
@@ -172,7 +185,7 @@ export function BackupSection({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
<Tooltip>
|
||||||
<TooltipTrigger
|
<TooltipTrigger
|
||||||
render={
|
render={
|
||||||
@@ -204,6 +217,7 @@ export function BackupSection({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
|
aria-label="Delete backup"
|
||||||
className="text-muted-foreground hover:text-destructive"
|
className="text-muted-foreground hover:text-destructive"
|
||||||
disabled={deleting === backup.filename}
|
disabled={deleting === backup.filename}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -15,7 +15,10 @@ export function IntegrationsSection({
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-3 flex items-center gap-2">
|
<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">
|
<h2 className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||||
Integrations
|
Integrations
|
||||||
</h2>
|
</h2>
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export function RegistrationSection({
|
|||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<div className="flex items-start gap-3">
|
<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">
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<CardTitle>Open registration</CardTitle>
|
<CardTitle>Open registration</CardTitle>
|
||||||
@@ -50,6 +50,7 @@ export function RegistrationSection({
|
|||||||
checked={registrationOpen}
|
checked={registrationOpen}
|
||||||
onCheckedChange={handleToggle}
|
onCheckedChange={handleToggle}
|
||||||
disabled={toggling}
|
disabled={toggling}
|
||||||
|
aria-label="Toggle open registration"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -32,8 +32,10 @@ export function SettingsShell({
|
|||||||
>
|
>
|
||||||
<motion.div variants={sectionVariants}>
|
<motion.div variants={sectionVariants}>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<IconSettings className="size-5 text-primary" />
|
<IconSettings aria-hidden={true} className="size-5 text-primary" />
|
||||||
<h1 className="font-display text-3xl tracking-tight">Settings</h1>
|
<h1 className="font-display text-3xl tracking-tight text-balance">
|
||||||
|
Settings
|
||||||
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
Manage your account and preferences
|
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-center justify-between gap-4">
|
||||||
<div className="flex items-start gap-3">
|
<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">
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<CardTitle>Health status</CardTitle>
|
<CardTitle>Health status</CardTitle>
|
||||||
@@ -164,6 +167,7 @@ export function SystemHealthCards() {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
|
aria-label="Refresh system health"
|
||||||
onClick={() => refresh()}
|
onClick={() => refresh()}
|
||||||
disabled={isValidating}
|
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">
|
<span className="inline-flex items-center gap-1.5 text-[11px] font-medium uppercase tracking-wider text-muted-foreground/70">
|
||||||
Environment
|
Environment
|
||||||
{data.environment.dataDirWritable ? (
|
{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>
|
</span>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
@@ -270,7 +280,10 @@ export function SystemHealthCards() {
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="flex items-start gap-3">
|
<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">
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<CardTitle>Storage</CardTitle>
|
<CardTitle>Storage</CardTitle>
|
||||||
@@ -386,7 +399,10 @@ function BackgroundJobsCard({
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="flex items-start gap-3">
|
<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">
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<CardTitle>Background jobs</CardTitle>
|
<CardTitle>Background jobs</CardTitle>
|
||||||
@@ -535,6 +551,7 @@ function BackgroundJobsCard({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
|
aria-label="Trigger job"
|
||||||
className="size-6"
|
className="size-6"
|
||||||
disabled={isRunning}
|
disabled={isRunning}
|
||||||
onClick={() => handleTrigger(job.jobName)}
|
onClick={() => handleTrigger(job.jobName)}
|
||||||
@@ -544,7 +561,10 @@ function BackgroundJobsCard({
|
|||||||
{isRunning ? (
|
{isRunning ? (
|
||||||
<Spinner className="size-3" />
|
<Spinner className="size-3" />
|
||||||
) : (
|
) : (
|
||||||
<IconPlayerPlay className="size-3 text-muted-foreground/50" />
|
<IconPlayerPlay
|
||||||
|
aria-hidden={true}
|
||||||
|
className="size-3 text-muted-foreground/50"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>Run now</TooltipContent>
|
<TooltipContent>Run now</TooltipContent>
|
||||||
|
|||||||
@@ -37,7 +37,10 @@ export function UpdateCheckSection({
|
|||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<div className="flex items-start gap-3">
|
<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">
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<CardTitle>Automatic update checks</CardTitle>
|
<CardTitle>Automatic update checks</CardTitle>
|
||||||
@@ -50,6 +53,7 @@ export function UpdateCheckSection({
|
|||||||
checked={enabled}
|
checked={enabled}
|
||||||
onCheckedChange={handleToggle}
|
onCheckedChange={handleToggle}
|
||||||
disabled={toggling}
|
disabled={toggling}
|
||||||
|
aria-label="Toggle automatic update checks"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ export function WebhookCard({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<IconChevronDown
|
<IconChevronDown
|
||||||
|
aria-hidden={true}
|
||||||
className={`size-4 text-muted-foreground transition-transform duration-200 ${cardOpen ? "rotate-180" : ""}`}
|
className={`size-4 text-muted-foreground transition-transform duration-200 ${cardOpen ? "rotate-180" : ""}`}
|
||||||
/>
|
/>
|
||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
@@ -117,7 +118,10 @@ export function WebhookCard({
|
|||||||
<CardContent className="space-y-3 border-t border-border/30 pt-4">
|
<CardContent className="space-y-3 border-t border-border/30 pt-4">
|
||||||
{isPlex && (
|
{isPlex && (
|
||||||
<div className="flex gap-2.5 rounded-lg border border-primary/20 bg-primary/5 px-3 py-2.5">
|
<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">
|
<p className="text-xs leading-relaxed text-foreground/80">
|
||||||
Requires an active{" "}
|
Requires an active{" "}
|
||||||
<a
|
<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"
|
className="inline-flex items-center gap-0.5 font-medium text-foreground underline-offset-2 hover:underline"
|
||||||
>
|
>
|
||||||
<span>Plex Pass</span>
|
<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>{" "}
|
</a>{" "}
|
||||||
subscription.
|
subscription.
|
||||||
</p>
|
</p>
|
||||||
@@ -136,7 +143,10 @@ export function WebhookCard({
|
|||||||
|
|
||||||
{isEmby && (
|
{isEmby && (
|
||||||
<div className="flex gap-2.5 rounded-lg border border-primary/20 bg-primary/5 px-3 py-2.5">
|
<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">
|
<p className="text-xs leading-relaxed text-foreground/80">
|
||||||
Requires{" "}
|
Requires{" "}
|
||||||
<span className="font-medium text-foreground">
|
<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"
|
className="inline-flex items-center gap-0.5 font-medium text-foreground underline-offset-2 hover:underline"
|
||||||
>
|
>
|
||||||
<span>Emby Premiere</span>
|
<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>{" "}
|
</a>{" "}
|
||||||
license.
|
license.
|
||||||
</p>
|
</p>
|
||||||
@@ -164,7 +177,7 @@ export function WebhookCard({
|
|||||||
size="lg"
|
size="lg"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
>
|
>
|
||||||
{connecting ? "Connecting..." : `Connect ${label}`}
|
{connecting ? "Connecting\u2026" : `Connect ${label}`}
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
@@ -236,6 +249,7 @@ export function WebhookCard({
|
|||||||
<Collapsible open={setupOpen} onOpenChange={setSetupOpen}>
|
<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">
|
<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
|
<IconChevronDown
|
||||||
|
aria-hidden={true}
|
||||||
className={`size-3 transition-transform ${setupOpen ? "rotate-0" : "-rotate-90"}`}
|
className={`size-3 transition-transform ${setupOpen ? "rotate-0" : "-rotate-90"}`}
|
||||||
/>
|
/>
|
||||||
Setup instructions
|
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"
|
className="inline-flex items-center gap-0.5 font-medium text-foreground underline-offset-2 hover:underline"
|
||||||
>
|
>
|
||||||
Settings > Webhooks
|
Settings > Webhooks
|
||||||
<IconExternalLink className="inline-block size-3 translate-y-[-1px]" />
|
<IconExternalLink
|
||||||
|
aria-hidden={true}
|
||||||
|
className="inline-block size-3 translate-y-[-1px]"
|
||||||
|
/>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<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"
|
className="inline-flex items-center gap-0.5 font-medium text-foreground underline-offset-2 hover:underline"
|
||||||
>
|
>
|
||||||
Webhook plugin
|
Webhook plugin
|
||||||
<IconExternalLink className="inline-block size-3 translate-y-[-1px]" />
|
<IconExternalLink
|
||||||
|
aria-hidden={true}
|
||||||
|
className="inline-block size-3 translate-y-[-1px]"
|
||||||
|
/>
|
||||||
</a>{" "}
|
</a>{" "}
|
||||||
from Jellyfin's plugin catalog
|
from Jellyfin's plugin catalog
|
||||||
</li>
|
</li>
|
||||||
@@ -325,7 +345,10 @@ export function WebhookCard({
|
|||||||
</li>
|
</li>
|
||||||
</ol>
|
</ol>
|
||||||
<p className="mt-2 -ml-0.5">
|
<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?{" "}
|
Need more help?{" "}
|
||||||
<a
|
<a
|
||||||
href={
|
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"
|
className="inline-flex items-center gap-0.5 font-medium text-foreground underline-offset-2 hover:underline"
|
||||||
>
|
>
|
||||||
Open docs{" "}
|
Open docs{" "}
|
||||||
<IconExternalLink className="inline-block size-3 translate-y-[-1px]" />
|
<IconExternalLink
|
||||||
|
aria-hidden={true}
|
||||||
|
className="inline-block size-3 translate-y-[-1px]"
|
||||||
|
/>
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -162,7 +162,10 @@ export default async function SettingsPage() {
|
|||||||
{/* Server health */}
|
{/* Server health */}
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-3 flex items-center gap-2">
|
<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">
|
<h2 className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||||
Server
|
Server
|
||||||
</h2>
|
</h2>
|
||||||
@@ -176,7 +179,10 @@ export default async function SettingsPage() {
|
|||||||
{/* Security */}
|
{/* Security */}
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-3 flex items-center gap-2">
|
<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">
|
<h2 className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||||
Security
|
Security
|
||||||
</h2>
|
</h2>
|
||||||
@@ -199,7 +205,10 @@ export default async function SettingsPage() {
|
|||||||
{/* Backups */}
|
{/* Backups */}
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-3 flex items-center gap-2">
|
<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">
|
<h2 className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||||
Backups
|
Backups
|
||||||
</h2>
|
</h2>
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export function CastCarousel({ actors, titleType }: CastCarouselProps) {
|
|||||||
return (
|
return (
|
||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
<div className="flex items-center gap-2">
|
<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>
|
<h2 className="font-display text-xl tracking-tight">Cast</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -55,11 +55,14 @@ export function CastCarousel({ actors, titleType }: CastCarouselProps) {
|
|||||||
alt={member.name}
|
alt={member.name}
|
||||||
width={96}
|
width={96}
|
||||||
height={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">
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export function RecommendationsGrid({
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center gap-2">
|
<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>
|
<h2 className="font-display text-2xl tracking-tight">Recommended</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6">
|
<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
|
<motion.button
|
||||||
key={star}
|
key={star}
|
||||||
type="button"
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={star === value}
|
||||||
|
aria-label={`Rate ${star} star${star !== 1 ? "s" : ""}`}
|
||||||
onClick={() => onChange(star === value ? 0 : star)}
|
onClick={() => onChange(star === value ? 0 : star)}
|
||||||
onMouseEnter={() => setHover(star)}
|
onMouseEnter={() => setHover(star)}
|
||||||
className="p-0.5"
|
className="p-0.5"
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export function StatusButton({ currentStatus, onChange }: StatusButtonProps) {
|
|||||||
transition={{ duration: 0.15 }}
|
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]"
|
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
|
Watchlist
|
||||||
</motion.button>
|
</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`}
|
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">
|
<span className="grid [&>svg]:col-start-1 [&>svg]:row-start-1">
|
||||||
<config.icon className="size-3.5 transition-opacity group-hover:opacity-0" />
|
<config.icon
|
||||||
<IconX className="size-3.5 opacity-0 text-destructive transition-opacity group-hover:opacity-100" />
|
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>
|
||||||
<span className="grid [&>span]:col-start-1 [&>span]:row-start-1">
|
<span className="grid [&>span]:col-start-1 [&>span]:row-start-1">
|
||||||
<span className="transition-opacity group-hover:opacity-0">
|
<span className="transition-opacity group-hover:opacity-0">
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export function TitleActions() {
|
|||||||
onClick={handleWatchMovie}
|
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"
|
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
|
Mark Watched
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ function ProviderBadge({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Tooltip>
|
<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 ? (
|
{logoPath ? (
|
||||||
<Image
|
<Image
|
||||||
src={logoPath}
|
src={logoPath}
|
||||||
@@ -62,9 +62,9 @@ export function TitleAvailability({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<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
|
Where to Watch
|
||||||
</h3>
|
</h2>
|
||||||
<div className="flex flex-wrap gap-4">
|
<div className="flex flex-wrap gap-4">
|
||||||
{Object.entries(availByType).map(([type, offers]) => (
|
{Object.entries(availByType).map(([type, offers]) => (
|
||||||
<div key={type} className="space-y-1.5">
|
<div key={type} className="space-y-1.5">
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ export function TitleHero({
|
|||||||
|
|
||||||
<div className="flex-1 space-y-5">
|
<div className="flex-1 space-y-5">
|
||||||
<div>
|
<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}
|
{title.title}
|
||||||
</h1>
|
</h1>
|
||||||
<div className="mt-2 flex flex-wrap items-center gap-3 text-sm text-muted-foreground">
|
<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}`}
|
href={`https://www.themoviedb.org/${title.type === "movie" ? "movie" : "tv"}/${title.tmdbId}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
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"
|
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" />
|
<TmdbLogo className="h-2.5 w-auto" />
|
||||||
@@ -131,7 +132,7 @@ export function TitleHero({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{title.overview && (
|
{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}
|
{title.overview}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export function TitleSeasons({
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<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>
|
<h2 className="font-display text-2xl tracking-tight">Seasons</h2>
|
||||||
</div>
|
</div>
|
||||||
{userStatus && userStatus !== "completed" && (
|
{userStatus && userStatus !== "completed" && (
|
||||||
@@ -74,7 +74,7 @@ export function TitleSeasons({
|
|||||||
type="button"
|
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"
|
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
|
Mark All Watched
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
@@ -180,9 +180,15 @@ export function TitleSeasons({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{isOpen ? (
|
{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>
|
||||||
</div>
|
</div>
|
||||||
@@ -210,6 +216,7 @@ export function TitleSeasons({
|
|||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
aria-label={`Mark episode ${ep.episodeNumber} as ${isWatched ? "unwatched" : "watched"}`}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
handleWatchEpisode(
|
handleWatchEpisode(
|
||||||
ep.id,
|
ep.id,
|
||||||
|
|||||||
@@ -26,8 +26,9 @@ export function TrailerDialog({
|
|||||||
{variant === "backdrop" ? (
|
{variant === "backdrop" ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
aria-label="Play trailer"
|
||||||
onClick={() => setOpen(true)}
|
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" />
|
<IconPlayerPlayFilled className="size-6 text-white drop-shadow-lg sm:size-8" />
|
||||||
</button>
|
</button>
|
||||||
@@ -37,7 +38,7 @@ export function TrailerDialog({
|
|||||||
onClick={() => setOpen(true)}
|
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"
|
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
|
Trailer
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -166,3 +166,10 @@
|
|||||||
transform: translateY(0);
|
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 type { Metadata } from "next";
|
||||||
import { DM_Sans, DM_Serif_Display, Geist_Mono } from "next/font/google";
|
import { DM_Sans, DM_Serif_Display, Geist_Mono } from "next/font/google";
|
||||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
@@ -24,6 +25,7 @@ const geistMono = Geist_Mono({
|
|||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Sofa",
|
title: "Sofa",
|
||||||
description: "Track your movies and TV shows",
|
description: "Track your movies and TV shows",
|
||||||
|
themeColor: "#090706",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
@@ -32,11 +34,20 @@ export default function RootLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en" style={{ colorScheme: "dark" }}>
|
||||||
<body
|
<body
|
||||||
className={`${dmSans.variable} ${dmSerif.variable} ${geistMono.variable} overflow-x-hidden font-sans antialiased`}
|
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" />
|
<Toaster position="bottom-right" />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ export function AuthForm({
|
|||||||
<Link href="/" className="inline-flex justify-center text-primary">
|
<Link href="/" className="inline-flex justify-center text-primary">
|
||||||
<SofaLogo className="size-9" />
|
<SofaLogo className="size-9" />
|
||||||
</Link>
|
</Link>
|
||||||
<h1 className="text-lg font-medium">
|
<h1 className="text-lg font-medium text-balance">
|
||||||
{isRegister ? "Create your account" : "Welcome back"}
|
{isRegister ? "Create your account" : "Welcome back"}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
@@ -125,9 +125,9 @@ export function AuthForm({
|
|||||||
whileTap={{ scale: 0.98 }}
|
whileTap={{ scale: 0.98 }}
|
||||||
className="inline-flex h-11 w-full items-center justify-center gap-2 rounded-lg border border-border/50 bg-background/50 text-sm font-medium transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-50"
|
className="inline-flex h-11 w-full items-center justify-center gap-2 rounded-lg border border-border/50 bg-background/50 text-sm font-medium transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<IconKey className="size-4" />
|
<IconKey aria-hidden={true} className="size-4" />
|
||||||
{oidcLoading
|
{oidcLoading
|
||||||
? "Redirecting..."
|
? "Redirecting\u2026"
|
||||||
: `Sign in with ${authConfig?.oidcProviderName || "SSO"}`}
|
: `Sign in with ${authConfig?.oidcProviderName || "SSO"}`}
|
||||||
</motion.button>
|
</motion.button>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@@ -164,10 +164,11 @@ export function AuthForm({
|
|||||||
id="name"
|
id="name"
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
|
autoComplete="name"
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
className="flex h-11 w-full rounded-lg border border-border/50 bg-background/50 px-4 text-sm transition-colors placeholder:text-muted-foreground/50 focus:border-primary/40 focus:outline-none focus:ring-1 focus:ring-primary/20"
|
className="flex h-11 w-full rounded-lg border border-border/50 bg-background/50 px-4 text-sm transition-colors placeholder:text-muted-foreground/50 focus-visible:border-primary/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
placeholder="Your name"
|
placeholder="Your name\u2026"
|
||||||
/>
|
/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
@@ -183,9 +184,11 @@ export function AuthForm({
|
|||||||
id="email"
|
id="email"
|
||||||
type="email"
|
type="email"
|
||||||
required
|
required
|
||||||
|
autoComplete="email"
|
||||||
|
spellCheck={false}
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
className="flex h-11 w-full rounded-lg border border-border/50 bg-background/50 px-4 text-sm transition-colors placeholder:text-muted-foreground/50 focus:border-primary/40 focus:outline-none focus:ring-1 focus:ring-primary/20"
|
className="flex h-11 w-full rounded-lg border border-border/50 bg-background/50 px-4 text-sm transition-colors placeholder:text-muted-foreground/50 focus-visible:border-primary/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
placeholder="wwhite@graymatter.biz"
|
placeholder="wwhite@graymatter.biz"
|
||||||
/>
|
/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@@ -202,10 +205,13 @@ export function AuthForm({
|
|||||||
type="password"
|
type="password"
|
||||||
required
|
required
|
||||||
minLength={8}
|
minLength={8}
|
||||||
|
autoComplete={
|
||||||
|
mode === "login" ? "current-password" : "new-password"
|
||||||
|
}
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
className="flex h-11 w-full rounded-lg border border-border/50 bg-background/50 px-4 text-sm transition-colors placeholder:text-muted-foreground/50 focus:border-primary/40 focus:outline-none focus:ring-1 focus:ring-primary/20"
|
className="flex h-11 w-full rounded-lg border border-border/50 bg-background/50 px-4 text-sm transition-colors placeholder:text-muted-foreground/50 focus-visible:border-primary/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
placeholder="Min 8 characters"
|
placeholder="Min 8 characters\u2026"
|
||||||
/>
|
/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
@@ -217,7 +223,7 @@ export function AuthForm({
|
|||||||
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-primary font-medium text-primary-foreground transition-shadow hover:shadow-lg hover:shadow-primary/20 disabled:pointer-events-none disabled:opacity-50"
|
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-primary font-medium text-primary-foreground transition-shadow hover:shadow-lg hover:shadow-primary/20 disabled:pointer-events-none disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{loading
|
{loading
|
||||||
? "Loading..."
|
? "Loading\u2026"
|
||||||
: isRegister
|
: isRegister
|
||||||
? "Create account"
|
? "Create account"
|
||||||
: "Sign in"}
|
: "Sign in"}
|
||||||
@@ -228,6 +234,7 @@ export function AuthForm({
|
|||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{error && (
|
{error && (
|
||||||
<motion.div
|
<motion.div
|
||||||
|
role="alert"
|
||||||
initial={{ opacity: 0, height: 0 }}
|
initial={{ opacity: 0, height: 0 }}
|
||||||
animate={{ opacity: 1, height: "auto" }}
|
animate={{ opacity: 1, height: "auto" }}
|
||||||
exit={{ opacity: 0, height: 0 }}
|
exit={{ opacity: 0, height: 0 }}
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ export function CommandPalette() {
|
|||||||
>
|
>
|
||||||
<Command shouldFilter={false}>
|
<Command shouldFilter={false}>
|
||||||
<CommandInput
|
<CommandInput
|
||||||
placeholder="Search movies & TV shows..."
|
placeholder="Search movies & TV shows\u2026"
|
||||||
value={query}
|
value={query}
|
||||||
onValueChange={setQuery}
|
onValueChange={setQuery}
|
||||||
/>
|
/>
|
||||||
@@ -196,7 +196,10 @@ export function CommandPalette() {
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-full items-center justify-center">
|
<div className="flex h-full items-center justify-center">
|
||||||
<IconUser className="size-4 text-muted-foreground" />
|
<IconUser
|
||||||
|
aria-hidden={true}
|
||||||
|
className="size-4 text-muted-foreground"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -223,11 +226,20 @@ export function CommandPalette() {
|
|||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-1.5 text-[10px] text-muted-foreground">
|
<div className="flex items-center gap-1.5 text-[10px] text-muted-foreground">
|
||||||
{r.type === "person" ? (
|
{r.type === "person" ? (
|
||||||
<IconUser className="size-[11px]" />
|
<IconUser
|
||||||
|
aria-hidden={true}
|
||||||
|
className="size-[11px]"
|
||||||
|
/>
|
||||||
) : r.type === "movie" ? (
|
) : r.type === "movie" ? (
|
||||||
<IconMovie className="size-[11px]" />
|
<IconMovie
|
||||||
|
aria-hidden={true}
|
||||||
|
className="size-[11px]"
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<IconDeviceTv className="size-[11px]" />
|
<IconDeviceTv
|
||||||
|
aria-hidden={true}
|
||||||
|
className="size-[11px]"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
<span className="uppercase">{r.type}</span>
|
<span className="uppercase">{r.type}</span>
|
||||||
{r.type !== "person" && r.releaseDate && (
|
{r.type !== "person" && r.releaseDate && (
|
||||||
@@ -270,7 +282,10 @@ export function CommandPalette() {
|
|||||||
onSelect={() => handleRecentSearch(q)}
|
onSelect={() => handleRecentSearch(q)}
|
||||||
className="group"
|
className="group"
|
||||||
>
|
>
|
||||||
<IconSearch className="size-3.5 text-muted-foreground" />
|
<IconSearch
|
||||||
|
aria-hidden={true}
|
||||||
|
className="size-3.5 text-muted-foreground"
|
||||||
|
/>
|
||||||
<span className="flex-1">{q}</span>
|
<span className="flex-1">{q}</span>
|
||||||
<span
|
<span
|
||||||
data-slot="command-shortcut"
|
data-slot="command-shortcut"
|
||||||
@@ -278,6 +293,7 @@ export function CommandPalette() {
|
|||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
aria-label="Remove from recent searches"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
handleRemoveRecent(q);
|
handleRemoveRecent(q);
|
||||||
@@ -300,7 +316,7 @@ export function CommandPalette() {
|
|||||||
router.push("/dashboard");
|
router.push("/dashboard");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<IconHome className="size-3.5" />
|
<IconHome aria-hidden={true} className="size-3.5" />
|
||||||
Go to Dashboard
|
Go to Dashboard
|
||||||
<CommandShortcut>G H</CommandShortcut>
|
<CommandShortcut>G H</CommandShortcut>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
@@ -311,7 +327,7 @@ export function CommandPalette() {
|
|||||||
router.push("/explore");
|
router.push("/explore");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<IconSearch className="size-3.5" />
|
<IconSearch aria-hidden={true} className="size-3.5" />
|
||||||
Go to Explore
|
Go to Explore
|
||||||
<CommandShortcut>G E</CommandShortcut>
|
<CommandShortcut>G E</CommandShortcut>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
@@ -321,7 +337,7 @@ export function CommandPalette() {
|
|||||||
setHelpOpen(true);
|
setHelpOpen(true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<IconKeyboard className="size-3.5" />
|
<IconKeyboard aria-hidden={true} className="size-3.5" />
|
||||||
Keyboard Shortcuts
|
Keyboard Shortcuts
|
||||||
<CommandShortcut>?</CommandShortcut>
|
<CommandShortcut>?</CommandShortcut>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
|
|||||||
@@ -180,7 +180,7 @@ export function LandingPage({
|
|||||||
{freshInstall ? (
|
{freshInstall ? (
|
||||||
<Link
|
<Link
|
||||||
href="/register"
|
href="/register"
|
||||||
className="group relative inline-flex h-12 items-center justify-center overflow-hidden rounded-lg bg-primary px-8 font-medium text-primary-foreground transition-all hover:shadow-lg hover:shadow-primary/20"
|
className="group relative inline-flex h-12 items-center justify-center overflow-hidden rounded-lg bg-primary px-8 font-medium text-primary-foreground transition-shadow hover:shadow-lg hover:shadow-primary/20"
|
||||||
>
|
>
|
||||||
<span className="relative z-10">Get Started</span>
|
<span className="relative z-10">Get Started</span>
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-black/10 to-transparent opacity-0 transition-opacity group-hover:opacity-100" />
|
<div className="absolute inset-0 bg-gradient-to-t from-black/10 to-transparent opacity-0 transition-opacity group-hover:opacity-100" />
|
||||||
@@ -189,7 +189,7 @@ export function LandingPage({
|
|||||||
<>
|
<>
|
||||||
<Link
|
<Link
|
||||||
href="/login"
|
href="/login"
|
||||||
className="group relative inline-flex h-12 items-center justify-center overflow-hidden rounded-lg bg-primary px-8 font-medium text-primary-foreground transition-all hover:shadow-lg hover:shadow-primary/20"
|
className="group relative inline-flex h-12 items-center justify-center overflow-hidden rounded-lg bg-primary px-8 font-medium text-primary-foreground transition-shadow hover:shadow-lg hover:shadow-primary/20"
|
||||||
>
|
>
|
||||||
<span className="relative z-10">Sign In</span>
|
<span className="relative z-10">Sign In</span>
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-black/10 to-transparent opacity-0 transition-opacity group-hover:opacity-100" />
|
<div className="absolute inset-0 bg-gradient-to-t from-black/10 to-transparent opacity-0 transition-opacity group-hover:opacity-100" />
|
||||||
@@ -197,7 +197,7 @@ export function LandingPage({
|
|||||||
{registrationOpen && (
|
{registrationOpen && (
|
||||||
<Link
|
<Link
|
||||||
href="/register"
|
href="/register"
|
||||||
className="inline-flex h-12 items-center justify-center rounded-lg border border-border px-8 font-medium transition-all hover:border-primary/40 hover:bg-primary/5"
|
className="inline-flex h-12 items-center justify-center rounded-lg border border-border px-8 font-medium transition-colors hover:border-primary/40 hover:bg-primary/5"
|
||||||
>
|
>
|
||||||
Register
|
Register
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -71,19 +71,19 @@ export function NavBar() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setCommandPaletteOpen(true)}
|
onClick={() => setCommandPaletteOpen(true)}
|
||||||
className="flex flex-1 items-center gap-2 rounded-lg border border-border/50 bg-card/50 px-3 py-1.5 text-sm text-muted-foreground transition-all hover:border-primary/20 hover:bg-card sm:hidden"
|
className="flex flex-1 items-center gap-2 rounded-lg border border-border/50 bg-card/50 px-3 py-1.5 text-sm text-muted-foreground transition-colors hover:border-primary/20 hover:bg-card sm:hidden"
|
||||||
>
|
>
|
||||||
<IconSearch className="size-3.5" />
|
<IconSearch aria-hidden={true} className="size-3.5" />
|
||||||
<span>Search...</span>
|
<span>Search…</span>
|
||||||
</button>
|
</button>
|
||||||
{/* Desktop search trigger pill */}
|
{/* Desktop search trigger pill */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setCommandPaletteOpen(true)}
|
onClick={() => setCommandPaletteOpen(true)}
|
||||||
className="hidden items-center gap-2 rounded-lg border border-border/50 bg-card/50 px-3 py-1.5 text-sm text-muted-foreground transition-all hover:border-primary/20 hover:bg-card sm:inline-flex"
|
className="hidden items-center gap-2 rounded-lg border border-border/50 bg-card/50 px-3 py-1.5 text-sm text-muted-foreground transition-colors hover:border-primary/20 hover:bg-card sm:inline-flex"
|
||||||
>
|
>
|
||||||
<IconSearch className="size-3.5" />
|
<IconSearch aria-hidden={true} className="size-3.5" />
|
||||||
<span>Search...</span>
|
<span>Search…</span>
|
||||||
<Kbd className="ml-1">⌘K</Kbd>
|
<Kbd className="ml-1">⌘K</Kbd>
|
||||||
</button>
|
</button>
|
||||||
<div className="mx-1.5 hidden h-4 w-px bg-border/50 sm:block" />
|
<div className="mx-1.5 hidden h-4 w-px bg-border/50 sm:block" />
|
||||||
@@ -93,7 +93,7 @@ export function NavBar() {
|
|||||||
className="hidden items-center gap-1.5 rounded-md px-2 py-1.5 text-sm leading-none text-muted-foreground transition-colors hover:text-foreground sm:inline-flex"
|
className="hidden items-center gap-1.5 rounded-md px-2 py-1.5 text-sm leading-none text-muted-foreground transition-colors hover:text-foreground sm:inline-flex"
|
||||||
>
|
>
|
||||||
{session?.user?.name}
|
{session?.user?.name}
|
||||||
<IconSettings className="size-3.5" />
|
<IconSettings aria-hidden={true} className="size-3.5" />
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>Settings</TooltipContent>
|
<TooltipContent>Settings</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -104,6 +104,7 @@ export function NavBar() {
|
|||||||
router.push("/");
|
router.push("/");
|
||||||
router.refresh();
|
router.refresh();
|
||||||
}}
|
}}
|
||||||
|
aria-label="Sign out"
|
||||||
className="hidden h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground sm:inline-flex"
|
className="hidden h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground sm:inline-flex"
|
||||||
>
|
>
|
||||||
<IconLogout className="size-[15px]" />
|
<IconLogout className="size-[15px]" />
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { motion } from "motion/react";
|
import { motion, useReducedMotion } from "motion/react";
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
@@ -42,6 +42,7 @@ export function StatusDot({
|
|||||||
const { bg, shadow } = colors[status];
|
const { bg, shadow } = colors[status];
|
||||||
const pulse = status !== "inactive";
|
const pulse = status !== "inactive";
|
||||||
const tooltipText = label ?? defaultLabels[status];
|
const tooltipText = label ?? defaultLabels[status];
|
||||||
|
const prefersReducedMotion = useReducedMotion();
|
||||||
|
|
||||||
const dotEl = pulse ? (
|
const dotEl = pulse ? (
|
||||||
<motion.span
|
<motion.span
|
||||||
@@ -50,9 +51,16 @@ export function StatusDot({
|
|||||||
bg,
|
bg,
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
animate={{
|
animate={
|
||||||
boxShadow: [shadow, pulseColors[status as keyof typeof pulseColors]],
|
prefersReducedMotion
|
||||||
}}
|
? {}
|
||||||
|
: {
|
||||||
|
boxShadow: [
|
||||||
|
shadow,
|
||||||
|
pulseColors[status as keyof typeof pulseColors],
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
transition={{
|
transition={{
|
||||||
duration: 1.2,
|
duration: 1.2,
|
||||||
repeat: Number.POSITIVE_INFINITY,
|
repeat: Number.POSITIVE_INFINITY,
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ function ProgressBar({ watched, total }: { watched: number; total: number }) {
|
|||||||
render={<div />}
|
render={<div />}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="h-full bg-status-watching transition-all duration-500 ease-out"
|
className="h-full bg-status-watching transition-[width] duration-500 ease-out"
|
||||||
style={{ width: `${pct}%` }}
|
style={{ width: `${pct}%` }}
|
||||||
/>
|
/>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
@@ -249,11 +249,14 @@ function CardInner({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1.5 flex items-center gap-2 text-xs text-muted-foreground">
|
<div className="mt-1.5 flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
<TypeIcon className="size-3.5 shrink-0 text-primary/60" />
|
<TypeIcon
|
||||||
|
aria-hidden={true}
|
||||||
|
className="size-3.5 shrink-0 text-primary/60"
|
||||||
|
/>
|
||||||
{year && <span>{year}</span>}
|
{year && <span>{year}</span>}
|
||||||
{voteAverage != null && voteAverage > 0 && (
|
{voteAverage != null && voteAverage > 0 && (
|
||||||
<span className="ml-auto flex items-center gap-0.5 text-primary/80">
|
<span className="ml-auto flex items-center gap-0.5 text-primary/80">
|
||||||
<IconStarFilled className="size-[11px]" />
|
<IconStarFilled aria-hidden={true} className="size-[11px]" />
|
||||||
{voteAverage.toFixed(1)}
|
{voteAverage.toFixed(1)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export function TmdbLogo({ className }: { className?: string }) {
|
|||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
viewBox="0 0 190.24 81.52"
|
viewBox="0 0 190.24 81.52"
|
||||||
className={className}
|
className={className}
|
||||||
|
role="img"
|
||||||
aria-label="TMDB"
|
aria-label="TMDB"
|
||||||
>
|
>
|
||||||
<defs>
|
<defs>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { cva, type VariantProps } from "class-variance-authority";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const buttonVariants = cva(
|
const buttonVariants = cva(
|
||||||
"focus-visible:border-ring focus-visible:ring-ring/30 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-md border border-transparent bg-clip-padding text-xs/relaxed font-medium focus-visible:ring-2 aria-invalid:ring-2 [&_svg:not([class*='size-'])]:size-4 inline-flex items-center justify-center whitespace-nowrap transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none shrink-0 [&_svg]:shrink-0 outline-none group/button select-none",
|
"focus-visible:border-ring focus-visible:ring-ring/30 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-md border border-transparent bg-clip-padding text-xs/relaxed font-medium focus-visible:ring-2 aria-invalid:ring-2 [&_svg:not([class*='size-'])]:size-4 inline-flex items-center justify-center whitespace-nowrap transition-colors disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none shrink-0 [&_svg]:shrink-0 outline-none group/button select-none",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
|
|||||||
@@ -123,6 +123,7 @@ function Carousel({
|
|||||||
className={cn("relative", className)}
|
className={cn("relative", className)}
|
||||||
role="region"
|
role="region"
|
||||||
aria-roledescription="carousel"
|
aria-roledescription="carousel"
|
||||||
|
aria-label="Carousel"
|
||||||
data-slot="carousel"
|
data-slot="carousel"
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ function DrawerContent({
|
|||||||
<DrawerPrimitive.Content
|
<DrawerPrimitive.Content
|
||||||
data-slot="drawer-content"
|
data-slot="drawer-content"
|
||||||
className={cn(
|
className={cn(
|
||||||
"before:bg-background before:border-border group/drawer-content fixed z-50 flex h-auto flex-col bg-transparent p-2 text-xs/relaxed before:absolute before:inset-2 before:-z-10 before:rounded-xl before:border data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=left]:sm:max-w-sm data-[vaul-drawer-direction=right]:sm:max-w-sm",
|
"before:bg-background before:border-border group/drawer-content fixed z-50 flex h-auto flex-col bg-transparent p-2 text-xs/relaxed overscroll-contain before:absolute before:inset-2 before:-z-10 before:rounded-xl before:border data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=left]:sm:max-w-sm data-[vaul-drawer-direction=right]:sm:max-w-sm",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ function NativeSelect({
|
|||||||
<select
|
<select
|
||||||
data-slot="native-select"
|
data-slot="native-select"
|
||||||
data-size={size}
|
data-size={size}
|
||||||
className="border-input bg-input/20 placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 dark:hover:bg-input/50 focus-visible:border-ring focus-visible:ring-ring/30 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 h-7 w-full min-w-0 appearance-none rounded-md border py-0.5 pr-6 pl-2 text-xs/relaxed transition-colors outline-none select-none focus-visible:ring-2 disabled:pointer-events-none disabled:cursor-not-allowed aria-invalid:ring-2 data-[size=sm]:h-6 data-[size=sm]:text-[0.625rem]"
|
className="text-foreground border-input bg-input/20 placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 dark:hover:bg-input/50 focus-visible:border-ring focus-visible:ring-ring/30 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 h-7 w-full min-w-0 appearance-none rounded-md border py-0.5 pr-6 pl-2 text-xs/relaxed transition-colors outline-none select-none focus-visible:ring-2 disabled:pointer-events-none disabled:cursor-not-allowed aria-invalid:ring-2 data-[size=sm]:h-6 data-[size=sm]:text-[0.625rem]"
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
<IconSelector
|
<IconSelector
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
|
|||||||
<TabsPrimitive.Tab
|
<TabsPrimitive.Tab
|
||||||
data-slot="tabs-trigger"
|
data-slot="tabs-trigger"
|
||||||
className={cn(
|
className={cn(
|
||||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground/60 hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-xs font-medium whitespace-nowrap transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start group-data-vertical/tabs:py-[calc(--spacing(1.25))] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
|
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground/60 hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-xs font-medium whitespace-nowrap transition-colors group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start group-data-vertical/tabs:py-[calc(--spacing(1.25))] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
|
||||||
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
|
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
|
||||||
"data-active:bg-background dark:data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 data-active:text-foreground",
|
"data-active:bg-background dark:data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 data-active:text-foreground",
|
||||||
"after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
|
"after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
|
||||||
|
|||||||
Reference in New Issue
Block a user