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