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