Fix Select trigger display values and add TMDB attribution

Base UI Select.Value renders raw values by default — add children render
functions to map values to human-readable labels (e.g. "this_month" →
"This Month", "0" → "unlimited"). Switch inline select underlines from
border-bottom to text-decoration for proper baseline alignment. Add TMDB
attribution with logo and disclaimer to settings footer.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-05 19:15:52 -05:00
co-authored by Claude Opus 4.6
parent 4773243cda
commit 436f3d7fad
21 changed files with 351 additions and 321 deletions
+12 -9
View File
@@ -9,6 +9,8 @@ import {
import { motion } from "motion/react";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
const steps = [
{
@@ -181,10 +183,11 @@ export default function SetupPage() {
<span className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
{snippet.label}
</span>
<button
type="button"
<Button
variant="ghost"
size="xs"
onClick={() => copySnippet(idx, snippet.code)}
className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[11px] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
className="text-[11px] text-muted-foreground"
>
{copiedIdx === idx ? (
<>
@@ -200,7 +203,7 @@ export default function SetupPage() {
Copy
</>
)}
</button>
</Button>
</div>
<pre className="overflow-x-auto p-3 font-mono text-sm text-foreground/80">
{snippet.code}
@@ -244,21 +247,21 @@ export default function SetupPage() {
Click the button to verify your configuration
</p>
</div>
<button
type="button"
<Button
onClick={checkStatus}
disabled={checking}
className="inline-flex h-9 items-center justify-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 disabled:opacity-50"
size="lg"
className="h-9 rounded-lg px-4 text-sm hover:shadow-md hover:shadow-primary/20"
>
{checking ? (
<>
<span className="h-3 w-3 animate-spin rounded-full border-2 border-primary-foreground/30 border-t-primary-foreground" />
<Spinner className="size-3" />
Checking
</>
) : (
"Check configuration"
)}
</button>
</Button>
</div>
)}
</div>
@@ -2,19 +2,18 @@
import {
IconCheck,
IconChevronDown,
IconLibrary,
IconMovie,
IconPlayerPlay,
} from "@tabler/icons-react";
import { useAtom, useAtomValue } from "jotai";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
episodePeriodAtom,
episodeStatsAtom,
@@ -83,6 +82,9 @@ function StatCard({
);
}
const inlineTriggerClass =
"h-auto w-auto gap-0.5 rounded-none border-0 bg-transparent p-0 [font-size:inherit] [line-height:inherit] shadow-none underline decoration-dotted decoration-muted-foreground/50 underline-offset-4 hover:bg-transparent hover:text-foreground hover:decoration-foreground/50 focus-visible:ring-0 focus-visible:decoration-solid focus-visible:decoration-foreground dark:bg-transparent dark:hover:bg-transparent";
function PeriodSelector({
noun,
period,
@@ -95,24 +97,29 @@ function PeriodSelector({
return (
<span className="inline-flex items-baseline gap-1">
{noun}{" "}
<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 aria-hidden={true} className="size-2.5" />
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuRadioGroup
value={period}
onValueChange={(v) => onPeriodChange(v as TimePeriod)}
>
{periods.map((p) => (
<DropdownMenuRadioItem key={p} value={p}>
{periodLabels[p]}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
<Select
value={period}
onValueChange={(v) => v && onPeriodChange(v as TimePeriod)}
>
<SelectTrigger
className={`${inlineTriggerClass} uppercase text-foreground/80`}
>
<SelectValue>
{(value: TimePeriod | null) => (value ? periodLabels[value] : null)}
</SelectValue>
</SelectTrigger>
<SelectContent
align="start"
alignItemWithTrigger={false}
className="p-1"
>
{periods.map((p) => (
<SelectItem key={p} value={p}>
{periodLabels[p]}
</SelectItem>
))}
</SelectContent>
</Select>
</span>
);
}
@@ -5,6 +5,7 @@ import { createStore, Provider, useAtom, useAtomValue } from "jotai";
import { useState } from "react";
import { TitleCardSkeleton } from "@/components/skeletons";
import { ExploreTitleCard } from "@/components/title-card";
import { Button } from "@/components/ui/button";
import {
Carousel,
CarouselContent,
@@ -114,18 +115,19 @@ function FilterableTitleRowInner({
{/* Genre chips */}
<div className="no-scrollbar -mx-4 flex gap-2 overflow-x-auto px-4 pb-1 sm:-mx-0 sm:flex-wrap sm:px-0">
{genres.map((genre) => (
<button
<Button
key={genre.id}
type="button"
variant={selectedGenre === genre.id ? "default" : "outline"}
size="xs"
onClick={() => toggleGenre(genre.id)}
className={`shrink-0 rounded-full border px-3 py-1 text-xs font-medium transition-colors ${
className={`shrink-0 rounded-full ${
selectedGenre === genre.id
? "border-primary bg-primary/10 text-primary"
? "border-primary bg-primary/10 text-primary hover:bg-primary/20"
: "border-border/50 bg-card/50 text-muted-foreground hover:border-primary/20 hover:text-foreground"
}`}
>
{genre.name}
</button>
</Button>
))}
</div>
+3 -3
View File
@@ -6,9 +6,9 @@ import {
} from "@/lib/services/tracking";
import { getGenres, getPopular, getTrending } from "@/lib/tmdb/client";
import { tmdbImageUrl } from "@/lib/tmdb/image";
import { FilterableTitleRow } from "./filterable-title-row";
import { HeroBanner } from "./hero-banner";
import { TitleRow } from "./title-row";
import { FilterableTitleRow } from "./_components/filterable-title-row";
import { HeroBanner } from "./_components/hero-banner";
import { TitleRow } from "./_components/title-row";
function mapResults(
results: {
@@ -4,6 +4,14 @@ import { IconMovie } from "@tabler/icons-react";
import Image from "next/image";
import Link from "next/link";
import { useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { PersonCredit } from "@/lib/types/title";
type Filter = "all" | "movie" | "tv";
@@ -62,31 +70,44 @@ export function FilmographyGrid({ credits }: FilmographyGridProps) {
</span>
</div>
<select
<Select
value={sort}
onChange={(e) => setSort(e.target.value as Sort)}
onValueChange={(v) => v && setSort(v 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>
<option value="rating">Rating</option>
</select>
<SelectTrigger size="sm">
<SelectValue>
{(value: string | null) =>
value === "newest"
? "Newest"
: value === "rating"
? "Rating"
: null
}
</SelectValue>
</SelectTrigger>
<SelectContent
align="end"
alignItemWithTrigger={false}
className="p-1"
>
<SelectItem value="newest">Newest</SelectItem>
<SelectItem value="rating">Rating</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex gap-2">
{filters.map((f) => (
<button
<Button
key={f.value}
type="button"
variant={filter === f.value ? "default" : "secondary"}
size="xs"
onClick={() => setFilter(f.value)}
className={`rounded-full px-3 py-1 text-xs font-medium transition-colors ${
filter === f.value
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground hover:text-foreground"
}`}
className="rounded-full"
>
{f.label}
</button>
</Button>
))}
</div>
@@ -3,6 +3,7 @@
import { IconCalendar, IconMapPin } from "@tabler/icons-react";
import Image from "next/image";
import { useState } from "react";
import { Badge } from "@/components/ui/badge";
import type { ResolvedPerson } from "@/lib/types/title";
interface PersonHeroProps {
@@ -53,9 +54,9 @@ export function PersonHero({ person }: PersonHeroProps) {
</h1>
{person.knownForDepartment && (
<span className="inline-block rounded-full bg-primary/10 px-2.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-primary">
<Badge className="border-0 bg-primary/10 px-2.5 font-semibold uppercase tracking-wider text-primary">
{person.knownForDepartment}
</span>
</Badge>
)}
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-muted-foreground">
@@ -2,6 +2,7 @@
import { IconLogout, IconUser } from "@tabler/icons-react";
import { useRouter } from "next/navigation";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
@@ -41,9 +42,9 @@ export function AccountSection({
<CardTitle>
{user.name}
{user.role === "admin" && (
<span className="ml-2 inline-flex items-center rounded-md bg-primary/10 px-1.5 py-0.5 align-middle text-[10px] font-medium text-primary">
<Badge className="ml-2 rounded-md border-0 bg-primary/10 align-middle text-primary">
Admin
</span>
</Badge>
)}
</CardTitle>
<CardDescription>{user.email}</CardDescription>
@@ -119,7 +119,7 @@ export function BackupRestoreSection() {
disabled={restoring}
>
{restoring ? <Spinner /> : <IconCloudUpload aria-hidden={true} />}
{restoring ? "Restoring\u2026" : "Upload"}
{restoring ? "Restoring" : "Upload"}
</Button>
</div>
</CardContent>
@@ -1,19 +1,20 @@
"use client";
import { IconCalendarWeek, IconChevronDown } from "@tabler/icons-react";
import { IconCalendarWeek } from "@tabler/icons-react";
import { format, formatDistanceToNow } from "date-fns";
import { useAtomValue } from "jotai";
import { useHydrateAtoms } from "jotai/utils";
import { AnimatePresence, motion } from "motion/react";
import { Button } from "@/components/ui/button";
import { ButtonGroup } from "@/components/ui/button-group";
import { CardContent, CardDescription, CardTitle } from "@/components/ui/card";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import {
backupScheduleAtom,
@@ -161,29 +162,33 @@ function BackupScheduleInner() {
{formatNextBackup(frequency, time, dow)}.
</span>{" "}
Keeping{" "}
<DropdownMenu>
<DropdownMenuTrigger className="inline-flex cursor-pointer items-center gap-0.5 border-b border-dotted border-muted-foreground/50 transition-colors hover:text-foreground">
{maxRetention === 0
? "unlimited"
: `last ${maxRetention}`}
<IconChevronDown
aria-hidden={true}
className="size-2.5"
/>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuRadioGroup
value={String(maxRetention)}
onValueChange={(v) => changeMaxRetention(Number(v))}
>
{[3, 5, 7, 14, 30, 0].map((n) => (
<DropdownMenuRadioItem key={n} value={String(n)}>
{n === 0 ? "unlimited" : n}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>{" "}
<Select
value={String(maxRetention)}
onValueChange={(v) => v && changeMaxRetention(Number(v))}
>
<SelectTrigger className="h-auto w-auto gap-0.5 rounded-none border-0 bg-transparent p-0 shadow-none underline decoration-dotted decoration-muted-foreground/50 underline-offset-4 hover:bg-transparent hover:text-foreground hover:decoration-foreground/50 focus-visible:ring-0 focus-visible:decoration-solid focus-visible:decoration-foreground dark:bg-transparent dark:hover:bg-transparent">
<SelectValue>
{(value: string | null) =>
value === "0"
? "unlimited"
: value
? `last ${value}`
: null
}
</SelectValue>
</SelectTrigger>
<SelectContent
align="start"
alignItemWithTrigger={false}
className="p-1"
>
{[3, 5, 7, 14, 30, 0].map((n) => (
<SelectItem key={n} value={String(n)}>
{n === 0 ? "unlimited" : `last ${n}`}
</SelectItem>
))}
</SelectContent>
</Select>{" "}
backups.
</span>
) : (
@@ -217,7 +222,7 @@ function BackupScheduleInner() {
<span className="inline-block text-[11px] font-medium uppercase tracking-wider text-muted-foreground/70">
Frequency
</span>
<div className="flex gap-1">
<ButtonGroup>
{FREQUENCY_OPTIONS.map((opt) => (
<Button
key={opt.value}
@@ -234,7 +239,7 @@ function BackupScheduleInner() {
{opt.label}
</Button>
))}
</div>
</ButtonGroup>
</div>
{/* Day of week — shown for 7d only */}
@@ -250,32 +255,33 @@ function BackupScheduleInner() {
<span className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground/70">
Day:{" "}
</span>
<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
aria-hidden={true}
className="size-3 text-muted-foreground"
/>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuRadioGroup
value={String(dow)}
onValueChange={(v) =>
changeSchedule(frequency, time, Number(v))
<Select
value={String(dow)}
onValueChange={(v) =>
v && changeSchedule(frequency, time, Number(v))
}
>
<SelectTrigger className="h-auto gap-1 border-border/50 bg-muted/30 px-2.5 py-1 text-xs text-foreground hover:bg-muted/50 dark:bg-muted/30 dark:hover:bg-muted/50">
<SelectValue>
{(value: string | null) =>
value !== null
? DAYS_OF_WEEK[Number(value)]
: null
}
>
{DAYS_OF_WEEK.map((day, i) => (
<DropdownMenuRadioItem
key={day}
value={String(i)}
>
{day}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</SelectValue>
</SelectTrigger>
<SelectContent
align="start"
alignItemWithTrigger={false}
className="p-1"
>
{DAYS_OF_WEEK.map((day, i) => (
<SelectItem key={day} value={String(i)}>
{day}
</SelectItem>
))}
</SelectContent>
</Select>
</motion.div>
)}
</AnimatePresence>
@@ -293,41 +299,43 @@ function BackupScheduleInner() {
<span className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground/70">
{frequency === "12h" ? "Starting at" : "Time:"}{" "}
</span>
<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">
{format(
new Date(
2000,
0,
1,
...(time.split(":").map(Number) as [
number,
number,
]),
),
"h:mm a",
)}
<IconChevronDown
aria-hidden={true}
className="size-3 text-muted-foreground"
/>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuRadioGroup
value={time}
onValueChange={(v) => changeSchedule(frequency, v)}
>
{HOURS.map((h) => {
const val = `${String(h).padStart(2, "0")}:00`;
return (
<DropdownMenuRadioItem key={h} value={val}>
{format(new Date(2000, 0, 1, h, 0), "h:mm a")}
</DropdownMenuRadioItem>
);
})}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
<Select
value={time}
onValueChange={(v) => v && changeSchedule(frequency, v)}
>
<SelectTrigger className="h-auto gap-1 border-border/50 bg-muted/30 px-2.5 py-1 text-xs text-foreground hover:bg-muted/50 dark:bg-muted/30 dark:hover:bg-muted/50">
<SelectValue>
{(value: string | null) =>
value
? format(
new Date(
2000,
0,
1,
Number(value.split(":")[0]),
0,
),
"h:mm a",
)
: null
}
</SelectValue>
</SelectTrigger>
<SelectContent
align="start"
alignItemWithTrigger={false}
className="p-1"
>
{HOURS.map((h) => {
const val = `${String(h).padStart(2, "0")}:00`;
return (
<SelectItem key={h} value={val}>
{format(new Date(2000, 0, 1, h, 0), "h:mm a")}
</SelectItem>
);
})}
</SelectContent>
</Select>
</motion.div>
)}
</AnimatePresence>
@@ -119,7 +119,7 @@ export function BackupSection({
) : (
<IconPlus aria-hidden={true} />
)}
{creating ? "Creating\u2026" : "New backup"}
{creating ? "Creating" : "New backup"}
</Button>
</div>
</CardContent>
@@ -13,6 +13,7 @@ import {
import { formatDistanceToNow } from "date-fns";
import { AnimatePresence, motion } from "motion/react";
import { useState } from "react";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
Card,
@@ -25,7 +26,13 @@ import {
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { Input } from "@/components/ui/input";
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from "@/components/ui/input-group";
import { Label } from "@/components/ui/label";
import {
Tooltip,
TooltipContent,
@@ -117,12 +124,9 @@ export function WebhookCard({
<CollapsibleContent className="h-[var(--collapsible-panel-height)] overflow-hidden transition-[height] duration-200 ease-out data-[ending-style]:h-0 data-[starting-style]:h-0">
<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
aria-hidden={true}
className="mt-0.5 size-3.5 shrink-0 text-primary"
/>
<p className="text-xs leading-relaxed text-foreground/80">
<Alert className="border-primary/20 bg-primary/5 [&>svg]:text-primary">
<IconInfoCircle aria-hidden={true} />
<AlertDescription className="text-foreground/80">
Requires an active{" "}
<a
href="https://www.plex.tv/plex-pass/"
@@ -137,17 +141,14 @@ export function WebhookCard({
/>
</a>{" "}
subscription.
</p>
</div>
</AlertDescription>
</Alert>
)}
{isEmby && (
<div className="flex gap-2.5 rounded-lg border border-primary/20 bg-primary/5 px-3 py-2.5">
<IconInfoCircle
aria-hidden={true}
className="mt-0.5 size-3.5 shrink-0 text-primary"
/>
<p className="text-xs leading-relaxed text-foreground/80">
<Alert className="border-primary/20 bg-primary/5 [&>svg]:text-primary">
<IconInfoCircle aria-hidden={true} />
<AlertDescription className="text-foreground/80">
Requires{" "}
<span className="font-medium text-foreground">
Emby Server 4.7.9+
@@ -166,8 +167,8 @@ export function WebhookCard({
/>
</a>{" "}
license.
</p>
</div>
</AlertDescription>
</Alert>
)}
{!connection ? (
@@ -177,7 +178,7 @@ export function WebhookCard({
size="lg"
className="w-full"
>
{connecting ? "Connecting\u2026" : `Connect ${label}`}
{connecting ? "Connecting" : `Connect ${label}`}
</Button>
) : (
<AnimatePresence>
@@ -189,38 +190,39 @@ export function WebhookCard({
className="space-y-3 overflow-hidden"
>
<div>
<label
<Label
htmlFor={`${provider}-webhook-url`}
className="mb-1 block text-xs text-muted-foreground"
className="mb-1 text-muted-foreground"
>
Webhook URL
</label>
<div className="flex gap-2">
<Input
</Label>
<InputGroup>
<InputGroupInput
id={`${provider}-webhook-url`}
readOnly
value={webhookUrl}
className="font-mono text-[10px] text-muted-foreground"
/>
<Tooltip>
<TooltipTrigger
render={
<Button
variant="outline"
size="icon"
onClick={handleCopy}
/>
}
>
{copied ? (
<IconCheck className="text-green-400" />
) : (
<IconCopy />
)}
</TooltipTrigger>
<TooltipContent>Copy URL</TooltipContent>
</Tooltip>
</div>
<InputGroupAddon align="inline-end">
<Tooltip>
<TooltipTrigger
render={
<InputGroupButton
size="icon-xs"
onClick={handleCopy}
/>
}
>
{copied ? (
<IconCheck className="text-green-400" />
) : (
<IconCopy />
)}
</TooltipTrigger>
<TooltipContent>Copy URL</TooltipContent>
</Tooltip>
</InputGroupAddon>
</InputGroup>
</div>
<div className="flex items-center gap-2">
+15
View File
@@ -5,6 +5,7 @@ import {
} from "@tabler/icons-react";
import { desc, eq } from "drizzle-orm";
import { redirect } from "next/navigation";
import { TmdbLogo } from "@/components/tmdb-logo";
import { Card } from "@/components/ui/card";
import { getSession } from "@/lib/auth/session";
import { db } from "@/lib/db/client";
@@ -145,6 +146,20 @@ export default async function SettingsPage() {
</span>
)}
</p>
<div className="mt-4 flex flex-col items-center gap-2">
<a
href="https://www.themoviedb.org/"
target="_blank"
rel="noopener noreferrer"
className="hover:opacity-70 transition-opacity"
>
<TmdbLogo className="h-3" />
</a>
<p className="text-[10px] leading-relaxed text-muted-foreground">
This product uses the TMDB API but is not endorsed or certified by
TMDB.
</p>
</div>
</footer>
}
>
@@ -2,6 +2,8 @@
import { IconCheck } from "@tabler/icons-react";
import { useAtomValue } from "jotai";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import {
titleTypeAtom,
userRatingAtom,
@@ -25,16 +27,16 @@ export function TitleActions() {
onChange={handleStatusChange}
/>
{titleType === "movie" && (
<button
type="button"
<Button
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"
size="lg"
className="h-9 rounded-lg px-4 text-sm active:scale-[0.97] hover:shadow-md hover:shadow-primary/20"
>
<IconCheck aria-hidden={true} className="size-3.5" />
Mark Watched
</button>
</Button>
)}
<span className="mx-0.5 h-4 w-px bg-border/50" />
<Separator orientation="vertical" className="mx-0.5 h-4 bg-border/50" />
<StarRating value={userRating ?? 0} onChange={handleRating} />
</div>
);
@@ -1,6 +1,7 @@
import Image from "next/image";
import type { ReactNode } from "react";
import { TmdbLogo } from "@/components/tmdb-logo";
import { Badge } from "@/components/ui/badge";
import type { ColorPalette, ResolvedTitle } from "@/lib/types/title";
import { TrailerDialog } from "./trailer-dialog";
@@ -100,9 +101,9 @@ export function TitleHero({
{title.title}
</h1>
<div className="mt-2 flex flex-wrap items-center gap-3 text-sm text-muted-foreground">
<span className="rounded bg-primary/10 px-2 py-0.5 text-xs font-semibold uppercase tracking-wider text-primary">
<Badge className="rounded border-0 bg-primary/10 font-semibold uppercase tracking-wider text-primary">
{title.type}
</span>
</Badge>
{year && <span>{year}</span>}
{title.voteAverage != null && title.voteAverage > 0 && (
<span className="flex items-center gap-1 text-primary">
@@ -115,9 +116,12 @@ export function TitleHero({
</span>
)}
{title.status && (
<span className="inline-flex h-5 items-center rounded border border-border/50 px-2 text-xs">
<Badge
variant="outline"
className="rounded border-border/50 font-normal"
>
{title.status}
</span>
</Badge>
)}
<a
href={`https://www.themoviedb.org/${title.type === "movie" ? "movie" : "tv"}/${title.tmdbId}`}
@@ -22,6 +22,7 @@ import {
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import {
episodeWatchesAtom,
@@ -64,19 +65,20 @@ export function TitleSeasons({
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<IconDeviceTvOld aria-hidden={true} className="size-5 text-primary" />
<h2 className="font-display text-2xl tracking-tight">Seasons</h2>
<h2 className="font-display text-2xl tracking-tight">Episodes</h2>
</div>
{userStatus && userStatus !== "completed" && (
<AlertDialog open={markAllOpen} onOpenChange={setMarkAllOpen}>
<AlertDialogTrigger
render={
<button
type="button"
className="inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-[10px] font-medium uppercase tracking-wider text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
<Button
variant="ghost"
size="xs"
className="uppercase tracking-wider text-muted-foreground"
>
<IconChecks aria-hidden={true} className="size-3.5" />
Mark All Watched
</button>
</Button>
}
/>
<AlertDialogContent>
@@ -151,28 +153,30 @@ export function TitleSeasons({
</>
)}
{totalCount > 0 && watchedCount < totalCount && (
<button
type="button"
<Button
variant="ghost"
size="xs"
onClick={(e) => {
e.stopPropagation();
handleMarkSeason(season);
}}
className="w-24 rounded-md px-2 py-1 text-center text-[10px] font-medium uppercase tracking-wider text-primary transition-colors hover:bg-primary/10 sm:hidden sm:group-hover/season:block"
className="w-24 uppercase tracking-wider text-primary hover:bg-primary/10 hover:text-primary sm:hidden sm:group-hover/season:block"
>
Watch all
</button>
</Button>
)}
{totalCount > 0 && watchedCount === totalCount && (
<button
type="button"
<Button
variant="ghost"
size="xs"
onClick={(e) => {
e.stopPropagation();
handleUnmarkSeason(season);
}}
className="w-24 rounded-md px-2 py-1 text-center text-[10px] font-medium uppercase tracking-wider text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive sm:hidden sm:group-hover/season:block"
className="w-24 uppercase tracking-wider text-muted-foreground hover:bg-destructive/10 hover:text-destructive sm:hidden sm:group-hover/season:block"
>
Unwatch all
</button>
</Button>
)}
{totalCount > 0 && (
<span className="hidden font-mono text-xs tabular-nums text-muted-foreground sm:inline">
+57 -46
View File
@@ -6,6 +6,10 @@ import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { SofaLogo } from "@/components/sofa-logo";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { authClient, signIn, signUp } from "@/lib/auth/client";
export interface AuthConfig {
@@ -15,6 +19,9 @@ export interface AuthConfig {
registrationOpen?: boolean;
}
const authInputClass =
"h-11 rounded-lg border-border/50 bg-background/50 px-4 py-0 placeholder:text-muted-foreground/50 focus-visible:border-primary/40 focus-visible:ring-ring md:text-sm";
const fieldVariants = {
hidden: { opacity: 0, y: 10 },
visible: {
@@ -117,19 +124,20 @@ export function AuthForm({
visible: { transition: { staggerChildren: 0.08 } },
}}
>
<motion.button
type="button"
onClick={handleOidcLogin}
disabled={oidcLoading}
variants={fieldVariants}
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 aria-hidden={true} className="size-4" />
{oidcLoading
? "Redirecting\u2026"
: `Sign in with ${authConfig?.oidcProviderName || "SSO"}`}
</motion.button>
<motion.div variants={fieldVariants}>
<Button
type="button"
variant="outline"
onClick={handleOidcLogin}
disabled={oidcLoading}
className="h-11 w-full gap-2 rounded-lg border-border/50 bg-background/50 text-sm hover:bg-accent hover:text-foreground"
>
<IconKey aria-hidden={true} className="size-4" />
{oidcLoading
? "Redirecting"
: `Sign in with ${authConfig?.oidcProviderName || "SSO"}`}
</Button>
</motion.div>
</motion.div>
)}
@@ -154,33 +162,33 @@ export function AuthForm({
>
{isRegister && (
<motion.div variants={fieldVariants} className="space-y-1.5">
<label
<Label
htmlFor="name"
className="text-xs font-medium uppercase tracking-wider text-muted-foreground"
className="uppercase tracking-wider text-muted-foreground"
>
Name
</label>
<input
</Label>
<Input
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-visible:border-primary/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
placeholder="Your name\u2026"
className={authInputClass}
placeholder="Your name"
/>
</motion.div>
)}
<motion.div variants={fieldVariants} className="space-y-1.5">
<label
<Label
htmlFor="email"
className="text-xs font-medium uppercase tracking-wider text-muted-foreground"
className="uppercase tracking-wider text-muted-foreground"
>
Email
</label>
<input
</Label>
<Input
id="email"
type="email"
required
@@ -188,19 +196,19 @@ export function AuthForm({
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-visible:border-primary/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
className={authInputClass}
placeholder="wwhite@graymatter.biz"
/>
</motion.div>
<motion.div variants={fieldVariants} className="space-y-1.5">
<label
<Label
htmlFor="password"
className="text-xs font-medium uppercase tracking-wider text-muted-foreground"
className="uppercase tracking-wider text-muted-foreground"
>
Password
</label>
<input
</Label>
<Input
id="password"
type="password"
required
@@ -210,37 +218,40 @@ export function AuthForm({
}
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-visible:border-primary/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
placeholder="Min 8 characters\u2026"
className={authInputClass}
placeholder="Min 8 characters"
/>
</motion.div>
<motion.button
type="submit"
disabled={loading}
variants={fieldVariants}
whileTap={{ scale: 0.98 }}
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\u2026"
: isRegister
? "Create account"
: "Sign in"}
</motion.button>
<motion.div variants={fieldVariants}>
<Button
type="submit"
disabled={loading}
className="h-11 w-full rounded-lg text-sm hover:shadow-lg hover:shadow-primary/20"
>
{loading
? "Loading…"
: isRegister
? "Create account"
: "Sign in"}
</Button>
</motion.div>
</motion.form>
)}
<AnimatePresence>
{error && (
<motion.div
role="alert"
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
className="overflow-hidden rounded-lg bg-destructive/10 px-3 py-2 text-sm text-destructive"
className="overflow-hidden"
>
{error}
<Alert variant="destructive" className="bg-destructive/10">
<AlertDescription className="text-destructive text-sm">
{error}
</AlertDescription>
</Alert>
</motion.div>
)}
</AnimatePresence>
+1 -1
View File
@@ -152,7 +152,7 @@ export function CommandPalette() {
>
<Command shouldFilter={false}>
<CommandInput
placeholder="Search movies & TV shows\u2026"
placeholder="Search movies & TV shows"
value={query}
onValueChange={setQuery}
/>
+5 -1
View File
@@ -7,6 +7,7 @@ import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { SofaLogo } from "@/components/sofa-logo";
import { Kbd } from "@/components/ui/kbd";
import { Separator } from "@/components/ui/separator";
import {
Tooltip,
TooltipContent,
@@ -86,7 +87,10 @@ export function NavBar() {
<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" />
<Separator
orientation="vertical"
className="mx-1.5 hidden h-4 bg-border/50 sm:block"
/>
<Tooltip>
<TooltipTrigger
render={<Link href="/settings" />}
-55
View File
@@ -1,55 +0,0 @@
import { IconSelector } from "@tabler/icons-react";
import type * as React from "react";
import { cn } from "@/lib/utils";
type NativeSelectProps = Omit<React.ComponentProps<"select">, "size"> & {
size?: "sm" | "default";
};
function NativeSelect({
className,
size = "default",
...props
}: NativeSelectProps) {
return (
<div
className={cn(
"group/native-select relative w-fit has-[select:disabled]:opacity-50",
className,
)}
data-slot="native-select-wrapper"
data-size={size}
>
<select
data-slot="native-select"
data-size={size}
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
className="text-muted-foreground pointer-events-none absolute top-1/2 right-1.5 size-3.5 -translate-y-1/2 select-none group-data-[size=sm]/native-select:size-3 group-data-[size=sm]/native-select:-translate-y-[calc(--spacing(1.25))]"
aria-hidden="true"
data-slot="native-select-icon"
/>
</div>
);
}
function NativeSelectOption({ ...props }: React.ComponentProps<"option">) {
return <option data-slot="native-select-option" {...props} />;
}
function NativeSelectOptGroup({
className,
...props
}: React.ComponentProps<"optgroup">) {
return (
<optgroup
data-slot="native-select-optgroup"
className={cn(className)}
{...props}
/>
);
}
export { NativeSelect, NativeSelectOptGroup, NativeSelectOption };