From 383f0b819b7d0a6db8f82b06cee249e8b83a4b63 Mon Sep 17 00:00:00 2001 From: Jake Jarvis Date: Sat, 7 Mar 2026 12:09:51 -0500 Subject: [PATCH] =?UTF-8?q?Polish=20mobile=20layouts,=20extract=20Expandab?= =?UTF-8?q?leText,=20fix=20image=20sizing=20-=20Stack=20title-hero=20poste?= =?UTF-8?q?r/metadata=20vertically=20on=20mobile=20(flex-col=20md:flex-row?= =?UTF-8?q?),=20=20=20switch=20backdrop=20tall=20breakpoint=20from=20sm=20?= =?UTF-8?q?to=20md=20-=20Extract=20bio=20expand/collapse=20logic=20into=20?= =?UTF-8?q?reusable=20ExpandableText=20component;=20=20=20use=20it=20in=20?= =?UTF-8?q?PersonHero=20and=20TitleHero=20-=20Fix=20ContinueWatchingCard?= =?UTF-8?q?=20image=20to=20use=20fill=20+=20sizes=20instead=20of=20fixed?= =?UTF-8?q?=20=20=20width/height=20to=20avoid=20layout=20shift=20-=20Add?= =?UTF-8?q?=20fade-out=20gradient=20on=20genre=20chip=20scrollbar=20edge?= =?UTF-8?q?=20on=20mobile=20-=20Show=20scaled-down=20ambient=20glow=20on?= =?UTF-8?q?=20mobile=20instead=20of=20hiding=20it=20entirely=20-=20Humaniz?= =?UTF-8?q?e=20knownForDepartment=20labels=20(Acting=E2=86=92Actor,=20Dire?= =?UTF-8?q?cting=E2=86=92Director,=20etc.)=20-=20Change=20cast=20member=20?= =?UTF-8?q?name=20from=20truncate=20to=20line-clamp-2=20for=20wrapping=20-?= =?UTF-8?q?=20Hide=20tooltip=20arrow=20via=20[&>:last-child]:hidden=20inst?= =?UTF-8?q?ead=20of=20color-matching=20it=20-=20Apply=20safe-area-inset=20?= =?UTF-8?q?padding=20to=20HeroBanner=20content=20on=20notched=20devices?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(auth)/register/page.tsx | 6 +- app/(auth)/setup/_components/setup-form.tsx | 22 +-- .../_components/continue-watching-card.tsx | 24 +-- .../_components/continue-watching-list.tsx | 2 +- .../dashboard/_components/feed-section.tsx | 2 +- .../dashboard/_components/stats-display.tsx | 10 +- .../dashboard/_components/stats-section.tsx | 6 +- .../dashboard/_components/welcome-header.tsx | 4 +- app/(pages)/error.tsx | 6 +- .../_components/filterable-title-row.tsx | 43 +++-- .../explore/_components/hero-banner.tsx | 20 +- app/(pages)/explore/_components/title-row.tsx | 6 +- app/(pages)/explore/loading.tsx | 4 +- app/(pages)/layout.tsx | 6 +- app/(pages)/not-found.tsx | 12 +- .../[id]/_components/filmography-grid.tsx | 6 +- .../people/[id]/_components/person-hero.tsx | 60 ++---- app/(pages)/people/[id]/not-found.tsx | 12 +- .../settings/_components/account-section.tsx | 4 +- .../_components/backup-schedule-section.tsx | 14 +- .../settings/_components/backup-section.tsx | 6 +- .../settings/_components/integration-card.tsx | 8 +- .../_components/integration-configs.tsx | 2 +- .../_components/integrations-section.tsx | 2 +- .../settings/_components/settings-shell.tsx | 4 +- .../_components/system-health-section.tsx | 66 +++---- app/(pages)/settings/page.tsx | 24 +-- .../titles/[id]/_components/cast-carousel.tsx | 2 +- .../[id]/_components/genre-collapse.tsx | 2 +- .../titles/[id]/_components/status-button.tsx | 6 +- .../titles/[id]/_components/title-actions.tsx | 2 +- .../[id]/_components/title-availability.tsx | 20 +- .../titles/[id]/_components/title-hero.tsx | 171 +++++++++++++----- .../titles/[id]/_components/title-seasons.tsx | 161 ++++++++++------- .../[id]/_components/trailer-dialog.tsx | 4 +- app/(pages)/titles/[id]/not-found.tsx | 12 +- app/layout.tsx | 2 +- app/not-found.tsx | 18 +- biome.json | 20 +- components/auth-form.tsx | 14 +- components/command-palette.tsx | 8 +- components/expandable-text.tsx | 57 ++++++ components/landing-page.tsx | 8 +- components/mobile-tab-bar.tsx | 4 +- components/nav-bar.tsx | 12 +- components/skeletons.tsx | 4 +- components/title-card.tsx | 20 +- components/ui/accordion.tsx | 8 +- components/ui/alert-dialog.tsx | 10 +- components/ui/alert.tsx | 8 +- components/ui/avatar.tsx | 10 +- components/ui/badge.tsx | 6 +- components/ui/breadcrumb.tsx | 6 +- components/ui/button-group.tsx | 10 +- components/ui/button.tsx | 8 +- components/ui/calendar.tsx | 38 ++-- components/ui/card.tsx | 6 +- components/ui/chart.tsx | 10 +- components/ui/checkbox.tsx | 2 +- components/ui/combobox.tsx | 16 +- components/ui/command.tsx | 14 +- components/ui/context-menu.tsx | 16 +- components/ui/dialog.tsx | 8 +- components/ui/drawer.tsx | 8 +- components/ui/dropdown-menu.tsx | 18 +- components/ui/empty.tsx | 10 +- components/ui/field.tsx | 18 +- components/ui/hover-card.tsx | 2 +- components/ui/input-group.tsx | 16 +- components/ui/input.tsx | 2 +- components/ui/item.tsx | 12 +- components/ui/kbd.tsx | 2 +- components/ui/label.tsx | 2 +- components/ui/menubar.tsx | 22 +-- components/ui/navigation-menu.tsx | 12 +- components/ui/popover.tsx | 4 +- components/ui/progress.tsx | 8 +- components/ui/radio-group.tsx | 4 +- components/ui/resizable.tsx | 4 +- components/ui/scroll-area.tsx | 6 +- components/ui/select.tsx | 16 +- components/ui/separator.tsx | 2 +- components/ui/sheet.tsx | 6 +- components/ui/sidebar.tsx | 40 ++-- components/ui/skeleton.tsx | 2 +- components/ui/slider.tsx | 10 +- components/ui/switch.tsx | 4 +- components/ui/table.tsx | 10 +- components/ui/tabs.tsx | 8 +- components/ui/textarea.tsx | 2 +- components/ui/toggle-group.tsx | 2 +- components/ui/toggle.tsx | 4 +- components/ui/tooltip.tsx | 4 +- components/update-toast.tsx | 2 +- lib/services/lists.ts | 2 +- lib/services/tracking.ts | 4 +- lib/services/webhooks.ts | 3 +- 97 files changed, 767 insertions(+), 608 deletions(-) create mode 100644 components/expandable-text.tsx diff --git a/app/(auth)/register/page.tsx b/app/(auth)/register/page.tsx index bc681ee..2ba9194 100644 --- a/app/(auth)/register/page.tsx +++ b/app/(auth)/register/page.tsx @@ -25,17 +25,17 @@ export default async function RegisterPage() {
-

+

Registration Closed

-

+

New accounts are not being accepted right now. Contact the admin if you need access.

Sign in instead diff --git a/app/(auth)/setup/_components/setup-form.tsx b/app/(auth)/setup/_components/setup-form.tsx index 0924aab..640886b 100644 --- a/app/(auth)/setup/_components/setup-form.tsx +++ b/app/(auth)/setup/_components/setup-form.tsx @@ -50,7 +50,7 @@ const steps = [ {" "} and request an API key. Choose “Developer” when asked. You need the{" "} - + API Read Access Token {" "} (the long one). @@ -123,11 +123,11 @@ export function SetupForm() { > {/* Header */} -
+
Setup required
-

+

Connect to TMDB

@@ -150,13 +150,13 @@ export function SetupForm() { {steps.map((step, i) => (

- + {step.number}

{step.title}

-

+

{step.description}

@@ -168,8 +168,8 @@ export function SetupForm() { key={snippet.label} className="group relative overflow-hidden rounded-lg border border-border/50 bg-card/60" > -
- +
+ {snippet.label}
-
+                      
                         {snippet.code}
                       
@@ -221,7 +221,7 @@ export function SetupForm() {

TMDB API key detected

-

+

Redirecting you to Sofa…

@@ -229,10 +229,10 @@ export function SetupForm() { ) : (
-

+

After setting the key and restarting:

-

+

Click the button to verify your configuration

diff --git a/app/(pages)/dashboard/_components/continue-watching-card.tsx b/app/(pages)/dashboard/_components/continue-watching-card.tsx index 1b2bf6d..9f88037 100644 --- a/app/(pages)/dashboard/_components/continue-watching-card.tsx +++ b/app/(pages)/dashboard/_components/continue-watching-card.tsx @@ -35,16 +35,16 @@ export function ContinueWatchingCard({ return (
{stillUrl ? ( {item.nextEpisode?.name ) : (
@@ -56,13 +56,13 @@ export function ContinueWatchingCard({ )}
{item.nextEpisode && ( -
-

- +

+

+ Up next

-

- +

+ S{item.nextEpisode.seasonNumber} E {item.nextEpisode.episodeNumber} {" "} @@ -73,8 +73,8 @@ export function ContinueWatchingCard({

-

{item.title.title}

-

+

{item.title.title}

+

{item.watchedEpisodes}/{item.totalEpisodes} episodes

@@ -83,7 +83,7 @@ export function ContinueWatchingCard({
{progress > 0 && ( -
+
{items.map((item, i) => ( diff --git a/app/(pages)/dashboard/_components/feed-section.tsx b/app/(pages)/dashboard/_components/feed-section.tsx index 9bdaa38..84deeaf 100644 --- a/app/(pages)/dashboard/_components/feed-section.tsx +++ b/app/(pages)/dashboard/_components/feed-section.tsx @@ -15,7 +15,7 @@ export function FeedSection({
{icon} -

+

{title}

diff --git a/app/(pages)/dashboard/_components/stats-display.tsx b/app/(pages)/dashboard/_components/stats-display.tsx index 94dddcb..6987376 100644 --- a/app/(pages)/dashboard/_components/stats-display.tsx +++ b/app/(pages)/dashboard/_components/stats-display.tsx @@ -53,7 +53,7 @@ function StatCard({ }: StatCardProps) { return (
{sparklineData && } @@ -63,7 +63,7 @@ function StatCard({ >
- + {label}
@@ -97,7 +97,7 @@ function PeriodSelector({ onValueChange={(v) => v && onPeriodChange(v as TimePeriod)} > {(value: TimePeriod | null) => (value ? periodLabels[value] : null)} @@ -132,11 +132,11 @@ export function StatsDisplay({ stats }: { stats: DashboardStats }) { } | null>(null); useEffect(() => { - getStatsAction("movies", moviePeriod).then(setMovieStats); + void getStatsAction("movies", moviePeriod).then(setMovieStats); }, [moviePeriod]); useEffect(() => { - getStatsAction("episodes", episodePeriod).then(setEpisodeStats); + void getStatsAction("episodes", episodePeriod).then(setEpisodeStats); }, [episodePeriod]); const movieCount = movieStats?.count ?? stats.moviesThisMonth; diff --git a/app/(pages)/dashboard/_components/stats-section.tsx b/app/(pages)/dashboard/_components/stats-section.tsx index 626bf74..b7be24c 100644 --- a/app/(pages)/dashboard/_components/stats-section.tsx +++ b/app/(pages)/dashboard/_components/stats-section.tsx @@ -16,19 +16,19 @@ export async function StatsSection({ userId }: { userId: string }) { <> {isEmpty && ( -
+

Your library is empty

-

+

Search for movies and TV shows to start tracking

Start exploring diff --git a/app/(pages)/dashboard/_components/welcome-header.tsx b/app/(pages)/dashboard/_components/welcome-header.tsx index c94f8fa..0eea40b 100644 --- a/app/(pages)/dashboard/_components/welcome-header.tsx +++ b/app/(pages)/dashboard/_components/welcome-header.tsx @@ -1,10 +1,10 @@ export function WelcomeHeader({ name }: { name?: string | null }) { return (
-

+

Welcome back{name ? `, ${name}` : ""}

-

+

Here's what's happening with your library

diff --git a/app/(pages)/error.tsx b/app/(pages)/error.tsx index 3c69f4b..793fefa 100644 --- a/app/(pages)/error.tsx +++ b/app/(pages)/error.tsx @@ -26,7 +26,7 @@ export default function PagesError({

Something went wrong

-

+

An unexpected error occurred while loading this page. You can try again or head back to the dashboard.

@@ -42,7 +42,7 @@ export default function PagesError({ Dashboard diff --git a/app/(pages)/explore/_components/filterable-title-row.tsx b/app/(pages)/explore/_components/filterable-title-row.tsx index 55e2765..51e0541 100644 --- a/app/(pages)/explore/_components/filterable-title-row.tsx +++ b/app/(pages)/explore/_components/filterable-title-row.tsx @@ -106,28 +106,31 @@ function FilterableTitleRowInner({
{icon} -

+

{heading}

{/* Genre chips */} -
- {genres.map((genre) => ( - - ))} +
+
+ {genres.map((genre) => ( + + ))} +
+
{/* Loading skeleton */} @@ -147,7 +150,7 @@ function FilterableTitleRowInner({ {/* Empty state */} {!loading && selectedGenre !== null && items.length === 0 && ( -

+

No titles found for this genre.

)} @@ -161,13 +164,13 @@ function FilterableTitleRowInner({ dragFree: true, containScroll: "trimSnaps", }} - className="-mx-6 sm:-mx-2 carousel-tilt" + className="carousel-tilt -mx-6 sm:-mx-2" > {items.slice(0, 20).map((item, i) => (
-
+
+
{backdropPath ? ( -
-
+
+
- + {type} {voteAverage > 0 && ( - + )} - + Trending today
-

+

{title}

-

+

{overview}

Add to Library diff --git a/app/(pages)/explore/_components/title-row.tsx b/app/(pages)/explore/_components/title-row.tsx index 06002e7..ed48167 100644 --- a/app/(pages)/explore/_components/title-row.tsx +++ b/app/(pages)/explore/_components/title-row.tsx @@ -37,19 +37,19 @@ export function TitleRow({
{icon} -

+

{heading}

{items.map((item, i) => (
{/* Hero skeleton — full viewport width like the actual HeroBanner */} -
- +
+
diff --git a/app/(pages)/layout.tsx b/app/(pages)/layout.tsx index 8b34ac5..31b1d01 100644 --- a/app/(pages)/layout.tsx +++ b/app/(pages)/layout.tsx @@ -20,11 +20,11 @@ export default async function PagesLayout({
- {/* Ambient glow — hidden on mobile where it overwhelms the viewport */} -
+ {/* Ambient glow — smaller on mobile to add warmth without overwhelming */} +
{children}
diff --git a/app/(pages)/not-found.tsx b/app/(pages)/not-found.tsx index f0ee101..e21207c 100644 --- a/app/(pages)/not-found.tsx +++ b/app/(pages)/not-found.tsx @@ -4,37 +4,37 @@ export default function PagesNotFound() { return (
{/* Ghosted 404 */} -

+

404

Page not found

-

+

This page doesn't exist or may have been moved. Try searching for what you're looking for instead.

Dashboard
Explore diff --git a/app/(pages)/people/[id]/_components/filmography-grid.tsx b/app/(pages)/people/[id]/_components/filmography-grid.tsx index b3ea61d..0d83aa7 100644 --- a/app/(pages)/people/[id]/_components/filmography-grid.tsx +++ b/app/(pages)/people/[id]/_components/filmography-grid.tsx @@ -65,10 +65,10 @@ export function FilmographyGrid({
-

+

Filmography

- + ({filtered.length})
@@ -105,7 +105,7 @@ export function FilmographyGrid({ - )} -
- )} + {person.biography && }
); diff --git a/app/(pages)/people/[id]/not-found.tsx b/app/(pages)/people/[id]/not-found.tsx index b63b2f4..eb8e574 100644 --- a/app/(pages)/people/[id]/not-found.tsx +++ b/app/(pages)/people/[id]/not-found.tsx @@ -4,39 +4,39 @@ export default function PersonNotFound() { return (

404

Person not found

-

+

The person you're looking for doesn't exist or may have been removed from the database.

Explore titles
Dashboard diff --git a/app/(pages)/settings/_components/account-section.tsx b/app/(pages)/settings/_components/account-section.tsx index f99b461..a730335 100644 --- a/app/(pages)/settings/_components/account-section.tsx +++ b/app/(pages)/settings/_components/account-section.tsx @@ -29,7 +29,7 @@ export function AccountSection({
-

+

Account

@@ -48,7 +48,7 @@ export function AccountSection({ )} {user.email} -

+

Member since {memberSince}

diff --git a/app/(pages)/settings/_components/backup-schedule-section.tsx b/app/(pages)/settings/_components/backup-schedule-section.tsx index 7045b7b..b5cf68e 100644 --- a/app/(pages)/settings/_components/backup-schedule-section.tsx +++ b/app/(pages)/settings/_components/backup-schedule-section.tsx @@ -184,7 +184,7 @@ function BackupScheduleInner() { value={String(maxRetention)} onValueChange={(v) => v && changeMaxRetention(Number(v))} > - + {(value: string | null) => value === "0" @@ -226,7 +226,7 @@ function BackupScheduleInner() { {enabled && ( - + {/* Frequency selector */}
- + Frequency @@ -270,7 +270,7 @@ function BackupScheduleInner() { transition={{ duration: 0.15 }} className="overflow-hidden" > - + Day:{" "} v && changeSchedule(frequency, v)} > - + {(value: string | null) => value diff --git a/app/(pages)/settings/_components/backup-section.tsx b/app/(pages)/settings/_components/backup-section.tsx index 3807116..7da1b48 100644 --- a/app/(pages)/settings/_components/backup-section.tsx +++ b/app/(pages)/settings/_components/backup-section.tsx @@ -127,7 +127,7 @@ export function BackupSection({ {/* Backup list */} {backups.length > 0 && ( - +
{backups.map((backup) => (
- + {formatBackupDate(backup.createdAt)} @@ -185,7 +185,7 @@ export function BackupSection({
-
+
- + {config.alert} {!connection ? ( @@ -216,7 +216,7 @@ export function IntegrationCard({ config }: { config: IntegrationConfig }) { )} - + -
+ diff --git a/app/(pages)/settings/_components/settings-shell.tsx b/app/(pages)/settings/_components/settings-shell.tsx index 9d9e2f0..282717b 100644 --- a/app/(pages)/settings/_components/settings-shell.tsx +++ b/app/(pages)/settings/_components/settings-shell.tsx @@ -33,11 +33,11 @@ export function SettingsShell({
-

+

Settings

-

+

Manage your account and preferences

diff --git a/app/(pages)/settings/_components/system-health-section.tsx b/app/(pages)/settings/_components/system-health-section.tsx index bdecb31..29f8c22 100644 --- a/app/(pages)/settings/_components/system-health-section.tsx +++ b/app/(pages)/settings/_components/system-health-section.tsx @@ -225,9 +225,9 @@ function SystemStatusCard() { {/* Database */} - +
- + Database @@ -239,22 +239,22 @@ function SystemStatusCard() { {/* TMDB */} - +
- + TMDB API {!data.tmdb.tokenConfigured ? ( <> - + Not configured ) : data.tmdb.connected && data.tmdb.tokenValid ? ( <> - Connected + Connected {data.tmdb.responseTimeMs}ms @@ -262,12 +262,12 @@ function SystemStatusCard() { ) : data.tmdb.connected && !data.tmdb.tokenValid ? ( <> - Invalid token + Invalid token ) : ( <> - Unreachable + Unreachable {data.tmdb.error && ( {data.tmdb.error} @@ -279,9 +279,9 @@ function SystemStatusCard() { {/* Environment */} - +
- + Environment {data.environment.dataDirWritable ? ( @@ -301,7 +301,7 @@ function SystemStatusCard() { className="flex items-baseline gap-[1px] font-mono text-[11px] leading-relaxed" > {env.name}= - + {env.value}
@@ -365,23 +365,23 @@ function BackgroundJobsCard() {
- + - + Job - + Schedule - + Last run - + Next run - + Actions @@ -413,7 +413,7 @@ function BackgroundJobsCard() { label={job.lastError ?? "Last run failed"} /> )} - + {JOB_LABELS[job.jobName] ?? job.jobName} @@ -424,7 +424,7 @@ function BackgroundJobsCard() { {job.cronPattern ? ( - + {cronToHuman(job.cronPattern)} @@ -433,7 +433,7 @@ function BackgroundJobsCard() { ) : ( - + )} @@ -446,7 +446,7 @@ function BackgroundJobsCard() {
@@ -469,7 +469,7 @@ function BackgroundJobsCard() { ) : ( - + Never )} @@ -481,7 +481,7 @@ function BackgroundJobsCard() { @@ -492,7 +492,7 @@ function BackgroundJobsCard() { ) : ( - + )} @@ -561,9 +561,9 @@ function StorageCard() { {/* Image cache */} - +
- + Image cache {data.imageCache.enabled ? ( @@ -574,17 +574,17 @@ function StorageCard() {
{data.imageCache.enabled ? ( <> -

+

{data.imageCache.imageCount.toLocaleString()} cached images

-

+

{Object.entries(data.imageCache.categories) .map(([name, cat]) => `${name} ${cat.count}`) .join(" · ")}

) : ( -

+

Disabled

@@ -592,9 +592,9 @@ function StorageCard() {
{/* Backup summary */} - +
- + Backups {data.backups.backupCount > 0 && ( @@ -605,14 +605,14 @@ function StorageCard() {
{data.backups.backupCount > 0 ? (

{data.backups.backupCount} backups · last{" "}

) : ( -

+

No backups yet

diff --git a/app/(pages)/settings/page.tsx b/app/(pages)/settings/page.tsx index a8af027..8750c37 100644 --- a/app/(pages)/settings/page.tsx +++ b/app/(pages)/settings/page.tsx @@ -110,13 +110,13 @@ export default async function SettingsPage() { return ( +

Sofa {" "} @@ -129,7 +129,7 @@ export default async function SettingsPage() { href={`https://github.com/${GITHUB_REPO}/commit/${GIT_COMMIT_SHA}`} target="_blank" rel="noopener noreferrer" - className="font-mono hover:text-primary transition-colors" + className="font-mono transition-colors hover:text-primary" > {GIT_COMMIT_SHA} @@ -137,7 +137,7 @@ export default async function SettingsPage() { )} {updateCheck?.updateAvailable && ( - + @@ -161,11 +161,11 @@ export default async function SettingsPage() { href="https://www.themoviedb.org/" target="_blank" rel="noopener noreferrer" - className="hover:opacity-70 transition-opacity" + className="transition-opacity hover:opacity-70" > -

+

This product uses the TMDB API but is not endorsed or certified by TMDB.

@@ -191,10 +191,10 @@ export default async function SettingsPage() { aria-hidden={true} className="size-4 text-muted-foreground" /> -

+

Server

- + Admin only
@@ -210,10 +210,10 @@ export default async function SettingsPage() { aria-hidden={true} className="size-4 text-muted-foreground" /> -

+

Security

- + Admin only @@ -236,10 +236,10 @@ export default async function SettingsPage() { aria-hidden={true} className="size-4 text-muted-foreground" /> -

+

Backups

- + Admin only diff --git a/app/(pages)/titles/[id]/_components/cast-carousel.tsx b/app/(pages)/titles/[id]/_components/cast-carousel.tsx index d2c94f2..c98a941 100644 --- a/app/(pages)/titles/[id]/_components/cast-carousel.tsx +++ b/app/(pages)/titles/[id]/_components/cast-carousel.tsx @@ -65,7 +65,7 @@ export function CastCarousel({ actors, titleType }: CastCarouselProps) { )}
-

+

{member.name}

{member.character && ( diff --git a/app/(pages)/titles/[id]/_components/genre-collapse.tsx b/app/(pages)/titles/[id]/_components/genre-collapse.tsx index 1804dcf..5d936db 100644 --- a/app/(pages)/titles/[id]/_components/genre-collapse.tsx +++ b/app/(pages)/titles/[id]/_components/genre-collapse.tsx @@ -29,7 +29,7 @@ export function GenreCollapse({ genres }: { genres: string[] }) { {remaining.map((genre) => ( {genre} diff --git a/app/(pages)/titles/[id]/_components/status-button.tsx b/app/(pages)/titles/[id]/_components/status-button.tsx index d2485f7..f867df2 100644 --- a/app/(pages)/titles/[id]/_components/status-button.tsx +++ b/app/(pages)/titles/[id]/_components/status-button.tsx @@ -48,7 +48,7 @@ export function StatusButton({ currentStatus, onChange }: StatusButtonProps) { animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -4 }} 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 font-medium text-primary text-sm ring-1 ring-primary/20 transition-all hover:bg-primary/15 hover:ring-primary/30 active:scale-[0.97]" > Watchlist @@ -63,7 +63,7 @@ export function StatusButton({ currentStatus, onChange }: StatusButtonProps) { exit={{ opacity: 0, y: -4 }} transition={{ duration: 0.15 }} title="Remove from library" - 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 font-medium text-sm ring-1 transition-all active:scale-[0.97] ${config.class} ${config.bgClass} ${config.borderClass} hover:bg-destructive/10 hover:text-destructive hover:ring-destructive/30`} > diff --git a/app/(pages)/titles/[id]/_components/title-actions.tsx b/app/(pages)/titles/[id]/_components/title-actions.tsx index 06a5f69..9bc3244 100644 --- a/app/(pages)/titles/[id]/_components/title-actions.tsx +++ b/app/(pages)/titles/[id]/_components/title-actions.tsx @@ -30,7 +30,7 @@ export function TitleActions() {
@@ -143,8 +143,8 @@ export function TitleAvailability({ if (Object.keys(availByType).length === 0) return null; return ( -
-

+
+

Where to Watch

@@ -154,7 +154,7 @@ export function TitleAvailability({ return (
- + {offerLabels[type] ?? type}
diff --git a/app/(pages)/titles/[id]/_components/title-hero.tsx b/app/(pages)/titles/[id]/_components/title-hero.tsx index 1d073ac..1eb20de 100644 --- a/app/(pages)/titles/[id]/_components/title-hero.tsx +++ b/app/(pages)/titles/[id]/_components/title-hero.tsx @@ -8,6 +8,7 @@ import { } from "@tabler/icons-react"; import Image from "next/image"; import type { ReactNode } from "react"; +import { ExpandableText } from "@/components/expandable-text"; import { TmdbLogo } from "@/components/tmdb-logo"; import type { ColorPalette, ResolvedTitle } from "@/lib/types/title"; import { GenreCollapse } from "./genre-collapse"; @@ -33,7 +34,7 @@ export function TitleHero({ <> {/* Backdrop hero */} {title.backdropPath && ( -
+
{title.posterPath && ( -
+
@@ -106,30 +107,35 @@ export function TitleHero({
-

+

{title.title}

-
- -
- {title.contentRating && {title.contentRating}} - {year && {year}} - {title.genres.length > 0 && ( - - )} - {title.voteAverage != null && title.voteAverage > 0 && ( - - - {title.voteAverage.toFixed(1)} - - )} - {title.status && - !(title.type === "movie" && title.status === "Released") && ( - - - {title.status} +
+ {/* Desktop: single row with dot separators */} +
+ +
+ {title.contentRating && {title.contentRating}} + {year && {year}} + {title.genres.length > 0 && ( + + )} + {title.voteAverage != null && title.voteAverage > 0 && ( + + + {title.voteAverage.toFixed(1)} )} + {title.status && + !( + title.type === "movie" && title.status === "Released" + ) && ( + + + {title.status} + + )} +
+ + {/* Mobile: two clean rows */} +
+ + {title.contentRating && {title.contentRating}} + {year && ( + <> + {title.contentRating && ( + · + )} + {year} + + )} + {title.genres.length > 0 && ( + <> + · + + + )} + {title.voteAverage != null && title.voteAverage > 0 && ( + <> + · + + + {title.voteAverage.toFixed(1)} + + + )} +
+ {title.status && + !(title.type === "movie" && title.status === "Released") && ( +
+ + + {title.status} + + + + +
+ )} + {/* TMDB link for mobile when no status is shown */} + {(!title.status || + (title.type === "movie" && title.status === "Released")) && ( + + + + )}
- {title.overview && ( -

- {title.overview} -

- )} + {title.overview && } {actions} @@ -176,26 +238,41 @@ function StatusIcon({ status }: { status: string }) { function AmbientGlow({ palette }: { palette: ColorPalette | null }) { if (!palette) return null; + const glowColor = palette.vibrant ?? palette.darkMuted ?? palette.muted; + return ( -
- {palette.vibrant && ( + <> + {/* Mobile: single full-bleed color wash that flows from the backdrop edge-to-edge */} + {glowColor && (
)} - {palette.darkMuted && ( -
- )} - {palette.muted && ( -
- )} -
+ + {/* Desktop: multi-orb ambient glow with room to breathe */} +
+ {palette.vibrant && ( +
+ )} + {palette.darkMuted && ( +
+ )} + {palette.muted && ( +
+ )} +
+ ); } diff --git a/app/(pages)/titles/[id]/_components/title-seasons.tsx b/app/(pages)/titles/[id]/_components/title-seasons.tsx index 538dcf2..db0cb31 100644 --- a/app/(pages)/titles/[id]/_components/title-seasons.tsx +++ b/app/(pages)/titles/[id]/_components/title-seasons.tsx @@ -88,7 +88,7 @@ export function TitleSeasons({ )} {totalCount > 0 && watchedCount === totalCount && ( @@ -180,13 +184,17 @@ export function TitleSeasons({ e.stopPropagation(); handleUnmarkSeason(season); }} - className="w-24 uppercase tracking-wider text-muted-foreground hover:bg-destructive/10 hover:text-destructive sm:hidden sm:group-hover/season:block" + className="text-muted-foreground uppercase tracking-wider hover:bg-destructive/10 hover:text-destructive sm:hidden sm:w-24 sm:group-hover/season:block" > - Unwatch all + + Unwatch all )} {totalCount > 0 && ( - + {watchedCount}/{totalCount} )} @@ -215,7 +223,7 @@ export function TitleSeasons({ stiffness: 300, damping: 30, }} - className="overflow-hidden border-t border-border/50" + className="overflow-hidden border-border/50 border-t" > {season.episodes.map((ep) => { const isWatched = watchedSet.has(ep.id); @@ -223,72 +231,99 @@ export function TitleSeasons({ return (
- + {/* Mobile: still banner above episode info */} {stillPath && ( -
+
+
+ + E{String(ep.episodeNumber).padStart(2, "0")} +
)} -
-

- - E{String(ep.episodeNumber).padStart(2, "0")} - {" "} - - {ep.name ?? "Untitled"} - -

-

- {ep.airDate - ? format(parseISO(ep.airDate), "MMM d, yyyy") - : ""} - {ep.airDate && ep.runtimeMinutes ? " · " : ""} - {ep.runtimeMinutes ? `${ep.runtimeMinutes}m` : ""} -

- {ep.overview && ( -

- {ep.overview} -

+
+ + {/* Desktop: inline thumbnail */} + {stillPath && ( +
+ +
)} +
+

+ {/* Episode number shown inline on desktop, or mobile without still */} + + E{String(ep.episodeNumber).padStart(2, "0")} + + {stillPath && ( + + )} + + {ep.name ?? "Untitled"} + +

+

+ {ep.airDate + ? format(parseISO(ep.airDate), "MMM d, yyyy") + : ""} + {ep.airDate && ep.runtimeMinutes ? " · " : ""} + {ep.runtimeMinutes + ? `${ep.runtimeMinutes}m` + : ""} +

+ {ep.overview && ( +

+ {ep.overview} +

+ )} +
); diff --git a/app/(pages)/titles/[id]/_components/trailer-dialog.tsx b/app/(pages)/titles/[id]/_components/trailer-dialog.tsx index a70a696..7aab5c9 100644 --- a/app/(pages)/titles/[id]/_components/trailer-dialog.tsx +++ b/app/(pages)/titles/[id]/_components/trailer-dialog.tsx @@ -36,13 +36,13 @@ export function TrailerDialog({ )} - + Trailer

404

Title not found

-

+

The title you're looking for doesn't exist or may have been removed from the database.

Explore titles
Dashboard diff --git a/app/layout.tsx b/app/layout.tsx index 20724da..3943628 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -49,7 +49,7 @@ export default function RootLayout({ > Skip to main content diff --git a/app/not-found.tsx b/app/not-found.tsx index 7b18e8b..e0ee2fc 100644 --- a/app/not-found.tsx +++ b/app/not-found.tsx @@ -5,11 +5,11 @@ export default function NotFound() { return (
{/* Warm projector glow */} -
+
{/* Subtle vertical light beam */}
{/* Frame counter label */}

Scene not found @@ -27,7 +27,7 @@ export default function NotFound() { {/* Large ghosted 404 */}

404 @@ -35,13 +35,13 @@ export default function NotFound() { {/* Message */}

Lost in the credits

-

+

This page was left on the cutting room floor. It may have been moved, removed, or never made it past the screenplay.

@@ -49,12 +49,12 @@ export default function NotFound() { {/* Actions */}
Return home
@@ -63,7 +63,7 @@ export default function NotFound() { {/* Logo watermark */}
diff --git a/biome.json b/biome.json index 7915611..1c7f0a4 100644 --- a/biome.json +++ b/biome.json @@ -22,15 +22,27 @@ }, "linter": { "enabled": true, + "domains": { + "next": "recommended", + "react": "recommended" + }, "rules": { "recommended": true, "suspicious": { "noUnknownAtRules": "off" + }, + "nursery": { + "useSortedClasses": { + "level": "error", + "options": { + "attributes": ["classList"], + "functions": ["cn", "clsx", "cva"] + } + }, + "noFloatingPromises": "error", + "noMisusedPromises": "error", + "useDestructuring": "error" } - }, - "domains": { - "next": "recommended", - "react": "recommended" } }, "assist": { diff --git a/components/auth-form.tsx b/components/auth-form.tsx index 413fcef..d2305bc 100644 --- a/components/auth-form.tsx +++ b/components/auth-form.tsx @@ -107,10 +107,10 @@ export function AuthForm({ -

+

{isRegister ? "Create your account" : "Welcome back"}

-

+

{isRegister ? "Start tracking your watches" : "Sign in to continue"}

@@ -144,7 +144,7 @@ export function AuthForm({ {showOidc && showPasswordForm && (
- or + or
)} @@ -164,7 +164,7 @@ export function AuthForm({ @@ -184,7 +184,7 @@ export function AuthForm({ @@ -204,7 +204,7 @@ export function AuthForm({ @@ -258,7 +258,7 @@ export function AuthForm({ {showPasswordForm && (isRegister || authConfig?.registrationOpen !== false) && ( -

+

{isRegister ? ( <> Already have an account?{" "} diff --git a/components/command-palette.tsx b/components/command-palette.tsx index 3abce8d..542f59e 100644 --- a/components/command-palette.tsx +++ b/components/command-palette.tsx @@ -227,7 +227,7 @@ export function CommandPalette() {

)}
-

+

{r.title}

@@ -275,7 +275,7 @@ export function CommandPalette() { @@ -363,7 +363,7 @@ export function CommandPalette() {
{Object.entries(groupedShortcuts).map(([scope, items]) => (
-

+

{scope}

@@ -372,7 +372,7 @@ export function CommandPalette() { key={item.description} className="flex items-center justify-between rounded-md px-2 py-1.5" > - + {item.description}
diff --git a/components/expandable-text.tsx b/components/expandable-text.tsx new file mode 100644 index 0000000..c1acb35 --- /dev/null +++ b/components/expandable-text.tsx @@ -0,0 +1,57 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { cn } from "@/lib/utils"; + +interface ExpandableTextProps { + text: string; + /** Tailwind line-clamp class applied when collapsed (default: "line-clamp-3") */ + clampClass?: string; + className?: string; + textClassName?: string; +} + +export function ExpandableText({ + text, + clampClass = "line-clamp-3", + className, + textClassName, +}: ExpandableTextProps) { + const [expanded, setExpanded] = useState(false); + const [clamped, setClamped] = useState(false); + const ref = useRef(null); + + useEffect(() => { + const el = ref.current; + if (!el) return; + const check = () => setClamped(el.scrollHeight > el.clientHeight + 1); + check(); + const observer = new ResizeObserver(check); + observer.observe(el); + return () => observer.disconnect(); + }, []); + + return ( +
+

+ {text} +

+ {(clamped || expanded) && ( + + )} +
+ ); +} diff --git a/components/landing-page.tsx b/components/landing-page.tsx index cb56e64..29c78b9 100644 --- a/components/landing-page.tsx +++ b/components/landing-page.tsx @@ -81,7 +81,7 @@ export function LandingPage({ {/* Warm primary glow */}
{/* Bottom fade */} -
+
); } diff --git a/components/mobile-tab-bar.tsx b/components/mobile-tab-bar.tsx index ea1dabd..b184daa 100644 --- a/components/mobile-tab-bar.tsx +++ b/components/mobile-tab-bar.tsx @@ -15,7 +15,7 @@ export function MobileTabBar() { const pathname = usePathname(); return ( -

tr]:last:border-b-0", + "border-t bg-muted/50 font-medium [&>tr]:last:border-b-0", className, )} {...props} @@ -57,7 +57,7 @@ function TableRow({ className, ...props }: React.ComponentProps<"tr">) { ) {
) { ); diff --git a/components/ui/tabs.tsx b/components/ui/tabs.tsx index 1b16f18..aa13b65 100644 --- a/components/ui/tabs.tsx +++ b/components/ui/tabs.tsx @@ -24,7 +24,7 @@ function Tabs({ } const tabsListVariants = cva( - "rounded-lg p-[3px] group-data-horizontal/tabs:h-8 data-[variant=line]:rounded-none group/tabs-list text-muted-foreground inline-flex w-fit items-center justify-center group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col", + "group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground data-[variant=line]:rounded-none group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col", { variants: { variant: { @@ -58,10 +58,10 @@ function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) { ) {