mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 01:35:39 -04:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
+7
-5
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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" />}
|
||||
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user