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 { 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-5
@@ -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>
|
||||||
|
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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" />}
|
||||||
|
|||||||
@@ -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