refactor: replace Biome with oxlint + oxfmt

Migrate the entire monorepo from Biome 2.4.7 to oxlint 1.56.0 (linter)
and oxfmt 0.41.0 (formatter) for faster lint/format and broader rule
coverage.

- Add `.oxlintrc.json` with React, TypeScript, unicorn, import plugins
  and correctness/suspicious categories
- Add `.oxfmtrc.json` with 2-space indent, import sorting, and Tailwind
  class sorting (all 30+ custom className attributes migrated)
- Add `docs/.oxlintrc.json` and `docs/.oxfmtrc.json` with Next.js plugin
- Update all 12 workspace package.json scripts: `oxlint`, `oxfmt`,
  `oxfmt --check`
- Add `format:check` turbo task and CI step
- Update VS Code settings/extensions to use `oxc.oxc-vscode`
- Update CI path triggers from `biome.json` to new config files
- Remove all `biome-ignore` comments and fix shadowed variables
- Delete `biome.json` and `docs/biome.json`
- Reformat entire codebase with oxfmt

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-18 13:26:50 -04:00
co-authored by Claude Opus 4.6
parent a80c8c9a0c
commit 2b0c683b7b
359 changed files with 3894 additions and 7208 deletions
+41 -60
View File
@@ -3,6 +3,7 @@ import { IconKey } from "@tabler/icons-react";
import { Link, useNavigate } from "@tanstack/react-router";
import { AnimatePresence, motion } from "motion/react";
import { useState } from "react";
import { SofaLogo } from "@/components/sofa-logo";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
@@ -93,24 +94,20 @@ export function AuthForm({
return (
<div className="relative mx-auto w-full max-w-sm">
{/* Subtle glow behind card */}
<div className="absolute -inset-4 rounded-2xl bg-primary/3 blur-2xl" />
<div className="bg-primary/3 absolute -inset-4 rounded-2xl blur-2xl" />
<motion.div
className="relative space-y-8 rounded-xl border border-border/50 bg-card/80 p-8 backdrop-blur-sm"
className="border-border/50 bg-card/80 relative space-y-8 rounded-xl border p-8 backdrop-blur-sm"
initial={{ opacity: 0, y: 20, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ type: "spring" as const, stiffness: 200, damping: 20 }}
>
<div className="space-y-2 text-center">
<Link to="/" className="inline-flex justify-center text-primary">
<Link to="/" className="text-primary inline-flex justify-center">
<SofaLogo className="size-9" />
</Link>
<h1 className="text-balance font-medium text-lg">
{isRegister ? (
<Trans>Create your account</Trans>
) : (
<Trans>Welcome back</Trans>
)}
<h1 className="text-lg font-medium text-balance">
{isRegister ? <Trans>Create your account</Trans> : <Trans>Welcome back</Trans>}
</h1>
<p className="text-muted-foreground text-sm">
{isRegister ? (
@@ -136,15 +133,13 @@ export function AuthForm({
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"
className="border-border/50 bg-background/50 hover:bg-accent hover:text-foreground h-11 w-full gap-2 rounded-lg text-sm"
>
<IconKey aria-hidden={true} className="size-4" />
{oidcLoading ? (
<Trans>Redirecting</Trans>
) : (
<Trans>
Sign in with {authConfig?.oidcProviderName || "SSO"}
</Trans>
<Trans>Sign in with {authConfig?.oidcProviderName || "SSO"}</Trans>
)}
</Button>
</motion.div>
@@ -153,11 +148,11 @@ export function AuthForm({
{showOidc && showPasswordForm && (
<div className="flex items-center gap-3">
<div className="h-px flex-1 bg-border/50" />
<div className="bg-border/50 h-px flex-1" />
<span className="text-muted-foreground text-xs">
<Trans>or</Trans>
</span>
<div className="h-px flex-1 bg-border/50" />
<div className="bg-border/50 h-px flex-1" />
</div>
)}
@@ -174,10 +169,7 @@ export function AuthForm({
>
{isRegister && (
<motion.div variants={fieldVariants} className="space-y-1.5">
<Label
htmlFor="name"
className="text-muted-foreground uppercase tracking-wider"
>
<Label htmlFor="name" className="text-muted-foreground tracking-wider uppercase">
<Trans>Name</Trans>
</Label>
<Input
@@ -194,10 +186,7 @@ export function AuthForm({
)}
<motion.div variants={fieldVariants} className="space-y-1.5">
<Label
htmlFor="email"
className="text-muted-foreground uppercase tracking-wider"
>
<Label htmlFor="email" className="text-muted-foreground tracking-wider uppercase">
<Trans>Email</Trans>
</Label>
<Input
@@ -214,10 +203,7 @@ export function AuthForm({
</motion.div>
<motion.div variants={fieldVariants} className="space-y-1.5">
<Label
htmlFor="password"
className="text-muted-foreground uppercase tracking-wider"
>
<Label htmlFor="password" className="text-muted-foreground tracking-wider uppercase">
<Trans>Password</Trans>
</Label>
<Input
@@ -225,9 +211,7 @@ export function AuthForm({
type="password"
required
minLength={8}
autoComplete={
mode === "login" ? "current-password" : "new-password"
}
autoComplete={mode === "login" ? "current-password" : "new-password"}
value={password}
onChange={(e) => setPassword(e.target.value)}
className={authInputClass}
@@ -239,7 +223,7 @@ export function AuthForm({
<Button
type="submit"
disabled={loading}
className="h-11 w-full rounded-lg text-sm hover:shadow-lg hover:shadow-primary/20"
className="hover:shadow-primary/20 h-11 w-full rounded-lg text-sm hover:shadow-lg"
>
{loading ? (
<Trans>Loading</Trans>
@@ -262,40 +246,37 @@ export function AuthForm({
className="overflow-hidden"
>
<Alert variant="destructive" className="bg-destructive/10">
<AlertDescription className="text-destructive text-sm">
{error}
</AlertDescription>
<AlertDescription className="text-destructive text-sm">{error}</AlertDescription>
</Alert>
</motion.div>
)}
</AnimatePresence>
{showPasswordForm &&
(isRegister || authConfig?.registrationOpen !== false) && (
<p className="text-center text-muted-foreground text-sm">
{isRegister ? (
<>
<Trans>Already have an account?</Trans>{" "}
<Link
to="/login"
className="font-medium text-primary transition-colors hover:text-primary/80"
>
<Trans>Sign in</Trans>
</Link>
</>
) : (
<>
<Trans>Don&apos;t have an account?</Trans>{" "}
<Link
to="/register"
className="font-medium text-primary transition-colors hover:text-primary/80"
>
<Trans>Register</Trans>
</Link>
</>
)}
</p>
)}
{showPasswordForm && (isRegister || authConfig?.registrationOpen !== false) && (
<p className="text-muted-foreground text-center text-sm">
{isRegister ? (
<>
<Trans>Already have an account?</Trans>{" "}
<Link
to="/login"
className="text-primary hover:text-primary/80 font-medium transition-colors"
>
<Trans>Sign in</Trans>
</Link>
</>
) : (
<>
<Trans>Don&apos;t have an account?</Trans>{" "}
<Link
to="/register"
className="text-primary hover:text-primary/80 font-medium transition-colors"
>
<Trans>Register</Trans>
</Link>
</>
)}
</p>
)}
</motion.div>
</div>
);
+22 -49
View File
@@ -13,6 +13,7 @@ import { skipToken, useQuery } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import { useAtom } from "jotai";
import { useCallback, useEffect, useRef, useState } from "react";
import { useProgress } from "@/components/navigation-progress";
import {
Command,
@@ -59,10 +60,7 @@ const SHORTCUT_DESCRIPTIONS = [
{ scope: "Title", description: "Rate 5 stars", keys: ["5"] },
] as const;
const groupedShortcuts: Record<
string,
{ description: string; keys: readonly string[] }[]
> = {};
const groupedShortcuts: Record<string, { description: string; keys: readonly string[] }[]> = {};
for (const entry of SHORTCUT_DESCRIPTIONS) {
if (!groupedShortcuts[entry.scope]) groupedShortcuts[entry.scope] = [];
groupedShortcuts[entry.scope].push(entry);
@@ -85,9 +83,7 @@ export function CommandPalette() {
const { t } = useLingui();
const navigate = useNavigate();
const progress = useProgress();
const [commandPaletteOpen, setCommandPaletteOpen] = useAtom(
commandPaletteOpenAtom,
);
const [commandPaletteOpen, setCommandPaletteOpen] = useAtom(commandPaletteOpenAtom);
const [helpOpen, setHelpOpen] = useAtom(helpOpenAtom);
const [recentSearches, setRecentSearches] = useAtom(recentSearchesAtom);
const [query, setQuery] = useState("");
@@ -200,7 +196,6 @@ export function CommandPalette() {
{hasQuery && loading && (
<div className="space-y-2 p-3">
{Array.from({ length: 3 }).map((_, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: static skeleton placeholders
<div key={`skel-${i}`} className="flex items-center gap-3">
<Skeleton className="h-12 w-8 shrink-0 rounded" />
<div className="flex-1 space-y-1.5">
@@ -227,7 +222,7 @@ export function CommandPalette() {
className="flex items-center gap-3 py-2"
>
{r.type === "person" ? (
<div className="size-10 shrink-0 overflow-hidden rounded-full bg-muted">
<div className="bg-muted size-10 shrink-0 overflow-hidden rounded-full">
{r.profilePath ? (
<img
src={r.profilePath}
@@ -242,13 +237,13 @@ export function CommandPalette() {
<div className="flex h-full items-center justify-center">
<IconUser
aria-hidden={true}
className="size-4 text-muted-foreground"
className="text-muted-foreground size-4"
/>
</div>
)}
</div>
) : (
<div className="h-12 w-8 shrink-0 overflow-hidden rounded bg-muted">
<div className="bg-muted h-12 w-8 shrink-0 overflow-hidden rounded">
{r.posterPath ? (
<img
src={r.posterPath}
@@ -260,44 +255,29 @@ export function CommandPalette() {
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full items-center justify-center text-[8px] text-muted-foreground">
<div className="text-muted-foreground flex h-full items-center justify-center text-[8px]">
?
</div>
)}
</div>
)}
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-xs">
{r.title}
</p>
<div className="flex items-center gap-1.5 text-[10px] text-muted-foreground">
<p className="truncate text-xs font-medium">{r.title}</p>
<div className="text-muted-foreground flex items-center gap-1.5 text-[10px]">
{r.type === "person" ? (
<IconUser
aria-hidden={true}
className="size-[11px]"
/>
<IconUser aria-hidden={true} className="size-[11px]" />
) : r.type === "movie" ? (
<IconMovie
aria-hidden={true}
className="size-[11px]"
/>
<IconMovie aria-hidden={true} className="size-[11px]" />
) : (
<IconDeviceTv
aria-hidden={true}
className="size-[11px]"
/>
<IconDeviceTv aria-hidden={true} className="size-[11px]" />
)}
<span className="uppercase">{r.type}</span>
{r.type !== "person" && r.releaseDate && (
<span>{r.releaseDate.slice(0, 4)}</span>
)}
{r.type === "person" &&
r.knownFor &&
r.knownFor.length > 0 && (
<span className="truncate">
{r.knownFor.join(", ")}
</span>
)}
{r.type === "person" && r.knownFor && r.knownFor.length > 0 && (
<span className="truncate">{r.knownFor.join(", ")}</span>
)}
</div>
</div>
</CommandItem>
@@ -317,7 +297,7 @@ export function CommandPalette() {
<button
type="button"
onClick={handleClearRecent}
className="font-normal text-[10px] text-muted-foreground transition-colors hover:text-foreground"
className="text-muted-foreground hover:text-foreground text-[10px] font-normal transition-colors"
>
<Trans>Clear all</Trans>
</button>
@@ -332,13 +312,10 @@ export function CommandPalette() {
>
<IconSearch
aria-hidden={true}
className="size-3.5 text-muted-foreground"
className="text-muted-foreground size-3.5"
/>
<span className="flex-1">{q}</span>
<span
data-slot="command-shortcut"
className="ml-auto"
>
<span data-slot="command-shortcut" className="ml-auto">
<button
type="button"
aria-label="Remove from recent searches"
@@ -346,7 +323,7 @@ export function CommandPalette() {
e.stopPropagation();
handleRemoveRecent(q);
}}
className="rounded-sm p-0.5 text-muted-foreground opacity-0 transition-opacity hover:text-foreground group-data-[selected=true]:opacity-100"
className="text-muted-foreground hover:text-foreground rounded-sm p-0.5 opacity-0 transition-opacity group-data-[selected=true]:opacity-100"
>
<IconX className="size-3" />
</button>
@@ -407,7 +384,7 @@ export function CommandPalette() {
<div className="space-y-5 py-2">
{Object.entries(groupedShortcuts).map(([scope, items]) => (
<div key={scope} className="space-y-2">
<h3 className="font-semibold text-[10px] text-muted-foreground uppercase tracking-wider">
<h3 className="text-muted-foreground text-[10px] font-semibold tracking-wider uppercase">
{scope}
</h3>
<div className="space-y-1">
@@ -416,16 +393,12 @@ export function CommandPalette() {
key={item.description}
className="flex items-center justify-between rounded-md px-2 py-1.5"
>
<span className="text-foreground text-xs">
{item.description}
</span>
<span className="text-foreground text-xs">{item.description}</span>
<div className="flex items-center gap-1">
{item.keys.map((key, i) => (
<span key={key} className="flex items-center gap-1">
{i > 0 && (
<span className="text-[10px] text-muted-foreground">
then
</span>
<span className="text-muted-foreground text-[10px]">then</span>
)}
<Kbd>{formatKey(key)}</Kbd>
</span>
@@ -1,8 +1,8 @@
import { plural } from "@lingui/core/macro";
import { useLingui } from "@lingui/react/macro";
import { IconPlayerPlay } from "@tabler/icons-react";
import { Link } from "@tanstack/react-router";
import { thumbHashToUrl } from "@/lib/thumbhash";
export interface ContinueWatchingItemProps {
@@ -23,34 +23,23 @@ export interface ContinueWatchingItemProps {
watchedEpisodes: number;
}
export function ContinueWatchingCard({
item,
}: {
item: ContinueWatchingItemProps;
}) {
export function ContinueWatchingCard({ item }: { item: ContinueWatchingItemProps }) {
const { t } = useLingui();
const stillUrl =
item.nextEpisode?.stillPath ?? item.title.backdropPath ?? null;
const progress =
item.totalEpisodes > 0
? (item.watchedEpisodes / item.totalEpisodes) * 100
: 0;
const stillUrl = item.nextEpisode?.stillPath ?? item.title.backdropPath ?? null;
const progress = item.totalEpisodes > 0 ? (item.watchedEpisodes / item.totalEpisodes) * 100 : 0;
return (
<Link
to="/titles/$id"
params={{ id: item.title.id }}
className="group relative inline-block w-64 shrink-0 overflow-hidden rounded-xl bg-card/50 ring-1 ring-white/[0.06] transition-shadow hover:shadow-black/25 hover:shadow-lg sm:w-72"
className="group bg-card/50 relative inline-block w-64 shrink-0 overflow-hidden rounded-xl ring-1 ring-white/[0.06] transition-shadow hover:shadow-lg hover:shadow-black/25 sm:w-72"
>
<div
className="relative aspect-video overflow-hidden rounded-t-xl bg-muted"
className="bg-muted relative aspect-video overflow-hidden rounded-t-xl"
style={(() => {
const hash =
item.nextEpisode?.stillThumbHash ?? item.title.backdropThumbHash;
const hash = item.nextEpisode?.stillThumbHash ?? item.title.backdropThumbHash;
const url = thumbHashToUrl(hash);
return url
? { backgroundImage: `url(${url})`, backgroundSize: "cover" }
: undefined;
return url ? { backgroundImage: `url(${url})`, backgroundSize: "cover" } : undefined;
})()}
>
{stillUrl ? (
@@ -62,24 +51,20 @@ export function ContinueWatchingCard({
className="absolute inset-0 h-full w-full object-cover motion-safe:transition-transform motion-safe:duration-300 motion-safe:group-hover:scale-105"
/>
) : (
<div className="flex h-full items-center justify-center bg-gradient-to-br from-card via-secondary to-muted">
<IconPlayerPlay
aria-hidden={true}
className="size-8 text-muted-foreground/30"
/>
<div className="from-card via-secondary to-muted flex h-full items-center justify-center bg-gradient-to-br">
<IconPlayerPlay aria-hidden={true} className="text-muted-foreground/30 size-8" />
</div>
)}
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-black/20 to-transparent" />
{item.nextEpisode && (
<div className="absolute right-3 bottom-2.5 left-3">
<p className="flex items-center gap-1.5 font-medium text-[10px] text-primary uppercase tracking-wider">
<span className="inline-block h-1.5 w-1.5 rounded-full bg-primary motion-safe:animate-pulse" />
<p className="text-primary flex items-center gap-1.5 text-[10px] font-medium tracking-wider uppercase">
<span className="bg-primary inline-block h-1.5 w-1.5 rounded-full motion-safe:animate-pulse" />
{t`Up next`}
</p>
<p className="mt-0.5 truncate font-medium text-sm text-white">
<span className="mr-0.5 font-mono text-white/60 text-xs [word-spacing:-0.25em]">
S{item.nextEpisode.seasonNumber} E
{item.nextEpisode.episodeNumber}
<p className="mt-0.5 truncate text-sm font-medium text-white">
<span className="mr-0.5 font-mono text-xs text-white/60 [word-spacing:-0.25em]">
S{item.nextEpisode.seasonNumber} E{item.nextEpisode.episodeNumber}
</span>{" "}
{item.nextEpisode.name}
</p>
@@ -88,21 +73,18 @@ export function ContinueWatchingCard({
</div>
<div className="flex items-center gap-3 p-3">
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-sm">{item.title.title}</p>
<p className="truncate text-sm font-medium">{item.title.title}</p>
<p className="text-muted-foreground text-xs">
{t`${item.watchedEpisodes}/${item.totalEpisodes} ${plural(item.totalEpisodes, { one: "episode", other: "episodes" })}`}
</p>
</div>
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary transition-colors group-hover:bg-primary group-hover:text-primary-foreground">
<div className="bg-primary/10 text-primary group-hover:bg-primary group-hover:text-primary-foreground flex h-8 w-8 shrink-0 items-center justify-center rounded-full transition-colors">
<IconPlayerPlay aria-hidden={true} className="size-3.5" />
</div>
</div>
{progress > 0 && (
<div className="absolute right-0 bottom-0 left-0 h-0.5 bg-muted">
<div
className="h-full bg-primary transition-[width]"
style={{ width: `${progress}%` }}
/>
<div className="bg-muted absolute right-0 bottom-0 left-0 h-0.5">
<div className="bg-primary h-full transition-[width]" style={{ width: `${progress}%` }} />
</div>
)}
</Link>
@@ -1,13 +1,11 @@
import { ScrollArea } from "@/components/ui/scroll-area";
import { Skeleton } from "@/components/ui/skeleton";
import {
ContinueWatchingCard,
type ContinueWatchingItemProps,
} from "./continue-watching-card";
import { ContinueWatchingCard, type ContinueWatchingItemProps } from "./continue-watching-card";
function ContinueWatchingSkeleton() {
return (
<div className="w-64 shrink-0 overflow-hidden rounded-xl bg-card/50 ring-1 ring-white/[0.06] sm:w-72">
<div className="bg-card/50 w-64 shrink-0 overflow-hidden rounded-xl ring-1 ring-white/[0.06] sm:w-72">
<Skeleton className="aspect-video w-full rounded-none" />
<div className="flex items-center gap-3 p-3">
<div className="min-w-0 flex-1 space-y-2">
@@ -37,11 +35,7 @@ export function ContinueWatchingSectionSkeleton() {
);
}
export function ContinueWatchingList({
items,
}: {
items: ContinueWatchingItemProps[];
}) {
export function ContinueWatchingList({ items }: { items: ContinueWatchingItemProps[] }) {
return (
<ScrollArea
scrollFade
@@ -1,17 +1,14 @@
import { useLingui } from "@lingui/react/macro";
import { IconPlayerPlay } from "@tabler/icons-react";
import { useQuery } from "@tanstack/react-query";
import { orpc } from "@/lib/orpc/client";
import {
ContinueWatchingList,
ContinueWatchingSectionSkeleton,
} from "./continue-watching-list";
import { ContinueWatchingList, ContinueWatchingSectionSkeleton } from "./continue-watching-list";
import { FeedSection } from "./feed-section";
export function ContinueWatchingSection() {
const { data, isPending } = useQuery(
orpc.dashboard.continueWatching.queryOptions(),
);
const { data, isPending } = useQuery(orpc.dashboard.continueWatching.queryOptions());
const { t } = useLingui();
@@ -23,7 +20,7 @@ export function ContinueWatchingSection() {
return (
<FeedSection
title={t`Continue Watching`}
icon={<IconPlayerPlay className="size-5 text-primary" />}
icon={<IconPlayerPlay className="text-primary size-5" />}
>
<ContinueWatchingList items={items} />
</FeedSection>
@@ -13,9 +13,7 @@ export function FeedSection({
<section className="space-y-4">
<div className="flex items-center gap-2">
<span aria-hidden={true}>{icon}</span>
<h2 className="text-balance font-display text-xl tracking-tight">
{title}
</h2>
<h2 className="font-display text-xl tracking-tight text-balance">{title}</h2>
</div>
{children}
</section>
@@ -1,21 +1,22 @@
import { useLingui } from "@lingui/react/macro";
import { IconBooks } from "@tabler/icons-react";
import { useInfiniteQuery } from "@tanstack/react-query";
import { useInfiniteScroll } from "@/hooks/use-infinite-scroll";
import { orpc } from "@/lib/orpc/client";
import { FeedSection } from "./feed-section";
import { TitleGrid, TitleGridSectionSkeleton } from "./title-grid";
export function LibrarySection() {
const { data, isPending, fetchNextPage, hasNextPage, isFetchingNextPage } =
useInfiniteQuery(
orpc.dashboard.library.infiniteOptions({
input: (pageParam: number) => ({ page: pageParam }),
initialPageParam: 1,
getNextPageParam: (lastPage) =>
lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined,
}),
);
const { data, isPending, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery(
orpc.dashboard.library.infiniteOptions({
input: (pageParam: number) => ({ page: pageParam }),
initialPageParam: 1,
getNextPageParam: (lastPage) =>
lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined,
}),
);
const { t } = useLingui();
@@ -31,10 +32,7 @@ export function LibrarySection() {
if (items.length === 0) return null;
return (
<FeedSection
title={t`In Your Library`}
icon={<IconBooks className="size-5 text-primary" />}
>
<FeedSection title={t`In Your Library`} icon={<IconBooks className="text-primary size-5" />}>
<TitleGrid items={items} />
<div ref={sentinelRef} />
{isFetchingNextPage && <TitleGridSectionSkeleton />}
@@ -1,14 +1,14 @@
import { useLingui } from "@lingui/react/macro";
import { IconThumbUp } from "@tabler/icons-react";
import { useQuery } from "@tanstack/react-query";
import { orpc } from "@/lib/orpc/client";
import { FeedSection } from "./feed-section";
import { TitleGrid, TitleGridSectionSkeleton } from "./title-grid";
export function RecommendationsSection() {
const { data, isPending } = useQuery(
orpc.dashboard.recommendations.queryOptions(),
);
const { data, isPending } = useQuery(orpc.dashboard.recommendations.queryOptions());
const { t } = useLingui();
@@ -20,7 +20,7 @@ export function RecommendationsSection() {
return (
<FeedSection
title={t`Recommended for You`}
icon={<IconThumbUp className="size-5 text-primary" />}
icon={<IconThumbUp className="text-primary size-5" />}
>
<TitleGrid items={items} />
</FeedSection>
@@ -1,17 +1,8 @@
import { Trans, useLingui } from "@lingui/react/macro";
import type {
DashboardStats,
HistoryBucket,
TimePeriod,
} from "@sofa/api/schemas";
import {
IconCheck,
IconLibrary,
IconMovie,
IconPlayerPlay,
} from "@tabler/icons-react";
import { IconCheck, IconLibrary, IconMovie, IconPlayerPlay } from "@tabler/icons-react";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import {
Select,
SelectContent,
@@ -21,11 +12,13 @@ import {
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { orpc } from "@/lib/orpc/client";
import type { DashboardStats, HistoryBucket, TimePeriod } from "@sofa/api/schemas";
import { Sparkline } from "./sparkline";
function StatCardSkeleton() {
return (
<div className="overflow-hidden rounded-xl border border-border/30 bg-card/50 p-4">
<div className="border-border/30 bg-card/50 overflow-hidden rounded-xl border p-4">
<div className="flex items-center gap-2">
<Skeleton className="h-6 w-6 rounded-md" />
<Skeleton className="h-3 w-16" />
@@ -70,23 +63,21 @@ function StatCard({
}: StatCardProps) {
return (
<div
className="relative animate-stagger-item overflow-hidden rounded-xl border border-border/30 bg-card/50 p-4"
className="animate-stagger-item border-border/30 bg-card/50 relative overflow-hidden rounded-xl border p-4"
style={{ "--stagger-index": index } as React.CSSProperties}
>
{sparklineData && <Sparkline data={sparklineData} color={color} />}
<div className="relative z-10 flex items-center gap-2">
<div
className={`flex h-6 w-6 items-center justify-center rounded-md ${bgColor}`}
>
<div className={`flex h-6 w-6 items-center justify-center rounded-md ${bgColor}`}>
<Icon aria-hidden={true} className={`size-[13px] ${color}`} />
</div>
<span className="font-medium text-[10px] text-muted-foreground uppercase tracking-wider">
<span className="text-muted-foreground text-[10px] font-medium tracking-wider uppercase">
{label}
</span>
</div>
<p
suppressHydrationWarning
className={`relative z-10 mt-2 font-display text-2xl tabular-nums tracking-tight ${color} motion-safe:transition-opacity motion-safe:duration-300`}
className={`font-display relative z-10 mt-2 text-2xl tracking-tight tabular-nums ${color} motion-safe:transition-opacity motion-safe:duration-300`}
>
{value}
</p>
@@ -112,9 +103,7 @@ function PeriodSelect({
onValueChange={(v) => v && onPeriodChange(v as TimePeriod)}
modal={false}
>
<SelectTrigger
className={`${inlineTriggerClass} text-foreground/80 uppercase`}
>
<SelectTrigger className={`${inlineTriggerClass} text-foreground/80 uppercase`}>
<SelectValue>
{(value: TimePeriod | null) => (value ? periodLabels[value] : null)}
</SelectValue>
@@ -158,11 +147,7 @@ function PeriodSelector({
return (
<span className="inline-flex items-baseline gap-1">
{type === "movies" ? (
<Trans>Movies {select}</Trans>
) : (
<Trans>Episodes {select}</Trans>
)}
{type === "movies" ? <Trans>Movies {select}</Trans> : <Trans>Episodes {select}</Trans>}
</span>
);
}
@@ -199,11 +184,7 @@ export function StatsDisplay({ stats }: { stats: DashboardStats }) {
index={0}
sparklineData={movieHistory}
label={
<PeriodSelector
type="movies"
period={moviePeriod}
onPeriodChange={setMoviePeriod}
/>
<PeriodSelector type="movies" period={moviePeriod} onPeriodChange={setMoviePeriod} />
}
/>
<StatCard
@@ -2,13 +2,13 @@ import { Trans } from "@lingui/react/macro";
import { IconDeviceTv } from "@tabler/icons-react";
import { useQuery } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
import { orpc } from "@/lib/orpc/client";
import { StatsDisplay, StatsSectionSkeleton } from "./stats-display";
export function StatsSection() {
const { data: stats, isPending } = useQuery(
orpc.dashboard.stats.queryOptions(),
);
const { data: stats, isPending } = useQuery(orpc.dashboard.stats.queryOptions());
if (isPending) return <StatsSectionSkeleton />;
if (!stats) return null;
@@ -23,9 +23,9 @@ export function StatsSection() {
<>
<StatsDisplay stats={stats} />
{isEmpty && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-border/50 border-dashed py-16 text-center">
<div className="rounded-full bg-primary/10 p-4">
<IconDeviceTv aria-hidden={true} className="size-8 text-primary" />
<div className="border-border/50 flex flex-col items-center gap-4 rounded-xl border border-dashed py-16 text-center">
<div className="bg-primary/10 rounded-full p-4">
<IconDeviceTv aria-hidden={true} className="text-primary size-8" />
</div>
<div className="space-y-1">
<p className="font-medium">
@@ -37,7 +37,7 @@ export function StatsSection() {
</div>
<Link
to="/explore"
className="inline-flex h-9 items-center rounded-lg bg-primary px-4 font-medium text-primary-foreground text-sm transition-shadow hover:shadow-md hover:shadow-primary/20"
className="bg-primary text-primary-foreground hover:shadow-primary/20 inline-flex h-9 items-center rounded-lg px-4 text-sm font-medium transition-shadow hover:shadow-md"
>
<Trans>Start exploring</Trans>
</Link>
@@ -3,14 +3,10 @@ import { Trans } from "@lingui/react/macro";
export function WelcomeHeader({ name }: { name?: string | null }) {
return (
<div>
<h1 className="text-balance font-display text-3xl tracking-tight">
{name ? (
<Trans>Welcome back, {name}</Trans>
) : (
<Trans>Welcome back</Trans>
)}
<h1 className="font-display text-3xl tracking-tight text-balance">
{name ? <Trans>Welcome back, {name}</Trans> : <Trans>Welcome back</Trans>}
</h1>
<p className="mt-1 text-muted-foreground text-sm">
<p className="text-muted-foreground mt-1 text-sm">
<Trans>Here&apos;s what&apos;s happening with your library</Trans>
</p>
</div>
+3 -2
View File
@@ -1,5 +1,6 @@
import { Trans } from "@lingui/react/macro";
import { useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";
interface ExpandableTextProps {
@@ -35,7 +36,7 @@ export function ExpandableText({
<p
ref={ref}
className={cn(
"break-words text-muted-foreground leading-relaxed",
"text-muted-foreground leading-relaxed break-words",
!expanded && clampClass,
textClassName,
)}
@@ -46,7 +47,7 @@ export function ExpandableText({
<button
type="button"
onClick={() => setExpanded(!expanded)}
className="mt-1 font-medium text-primary text-xs transition-colors hover:text-primary/80"
className="text-primary hover:text-primary/80 mt-1 text-xs font-medium transition-colors"
>
{expanded ? <Trans>Show less</Trans> : <Trans>Read more</Trans>}
</button>
@@ -2,8 +2,10 @@ import { useLingui } from "@lingui/react/macro";
import { IconDeviceTv, IconFlame, IconMovie } from "@tabler/icons-react";
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { useMemo } from "react";
import { Skeleton } from "@/components/ui/skeleton";
import { orpc } from "@/lib/orpc/client";
import { FilterableTitleRow } from "./filterable-title-row";
import { HeroBanner } from "./hero-banner";
import { TitleRow } from "./title-row";
@@ -20,11 +22,7 @@ function ExploreSkeletons() {
</div>
<div className="flex gap-4 overflow-hidden">
{Array.from({ length: 8 }).map((_, j) => (
<div
// biome-ignore lint/suspicious/noArrayIndexKey: static skeleton
key={j}
className="w-[140px] shrink-0 sm:w-[160px]"
>
<div key={j} className="w-[140px] shrink-0 sm:w-[160px]">
<Skeleton className="aspect-[2/3] w-full rounded-xl" />
</div>
))}
@@ -35,9 +33,7 @@ function ExploreSkeletons() {
);
}
function mergeMaps<T>(
...maps: (Record<string, T> | undefined)[]
): Record<string, T> {
function mergeMaps<T>(...maps: (Record<string, T> | undefined)[]): Record<string, T> {
return Object.assign({}, ...maps.filter(Boolean));
}
@@ -110,9 +106,7 @@ export function ExploreClient() {
<div>
<TitleRow
heading={t`Trending Today`}
icon={
<IconFlame aria-hidden={true} className="size-5 text-primary" />
}
icon={<IconFlame aria-hidden={true} className="text-primary size-5" />}
items={trendingItems}
userStatuses={userStatuses}
episodeProgress={episodeProgress}
@@ -124,7 +118,7 @@ export function ExploreClient() {
<FilterableTitleRow
heading={t`Popular Movies`}
icon={<IconMovie aria-hidden={true} className="size-5 text-primary" />}
icon={<IconMovie aria-hidden={true} className="text-primary size-5" />}
mediaType="movie"
defaultItems={(popularMoviesData?.items ?? []).slice(0, 20)}
genres={movieGenreData?.genres ?? []}
@@ -134,9 +128,7 @@ export function ExploreClient() {
<FilterableTitleRow
heading={t`Popular TV Shows`}
icon={
<IconDeviceTv aria-hidden={true} className="size-5 text-primary" />
}
icon={<IconDeviceTv aria-hidden={true} className="text-primary size-5" />}
mediaType="tv"
defaultItems={(popularTvData?.items ?? []).slice(0, 20)}
genres={tvGenreData?.genres ?? []}
@@ -1,6 +1,7 @@
import { Trans } from "@lingui/react/macro";
import { skipToken, useInfiniteQuery } from "@tanstack/react-query";
import { useMemo, useRef, useState } from "react";
import { TitleCard, TitleCardSkeleton } from "@/components/title-card";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
@@ -74,27 +75,25 @@ export function FilterableTitleRow({
);
const discoverStatuses = useMemo(
() =>
Object.assign(
{},
...(discoverData?.pages.map((p) => p.userStatuses) ?? []),
) as Record<string, TitleStatus>,
Object.assign({}, ...(discoverData?.pages.map((p) => p.userStatuses) ?? [])) as Record<
string,
TitleStatus
>,
[discoverData?.pages],
);
const discoverProgress = useMemo(
() =>
Object.assign(
{},
...(discoverData?.pages.map((p) => p.episodeProgress) ?? []),
) as Record<string, { watched: number; total: number }>,
Object.assign({}, ...(discoverData?.pages.map((p) => p.episodeProgress) ?? [])) as Record<
string,
{ watched: number; total: number }
>,
[discoverData?.pages],
);
const isLoading = selectedGenre !== null && isPending;
const items = selectedGenre === null ? defaultItems : discoverItems;
const userStatuses =
selectedGenre === null ? initialStatuses : discoverStatuses;
const episodeProgress =
selectedGenre === null ? initialProgress : discoverProgress;
const userStatuses = selectedGenre === null ? initialStatuses : discoverStatuses;
const episodeProgress = selectedGenre === null ? initialProgress : discoverProgress;
function toggleGenre(genreId: number) {
setSelectedGenre(genreId === selectedGenre ? null : genreId);
@@ -104,9 +103,7 @@ export function FilterableTitleRow({
<section className="space-y-4">
<div className="flex items-center gap-2">
{icon}
<h2 className="text-balance font-display text-xl tracking-tight">
{heading}
</h2>
<h2 className="font-display text-xl tracking-tight text-balance">{heading}</h2>
</div>
{/* Genre chips */}
@@ -134,11 +131,7 @@ export function FilterableTitleRow({
{isLoading && (
<div className="-mx-4 flex gap-4 overflow-hidden px-4 sm:-mx-0 sm:px-0">
{Array.from({ length: 8 }).map((_, i) => (
<div
// biome-ignore lint/suspicious/noArrayIndexKey: static skeleton placeholders
key={`skel-${i}`}
className="w-[140px] shrink-0 sm:w-[160px]"
>
<div key={`skel-${i}`} className="w-[140px] shrink-0 sm:w-[160px]">
<TitleCardSkeleton />
</div>
))}
@@ -147,7 +140,7 @@ export function FilterableTitleRow({
{/* Empty state */}
{!isLoading && selectedGenre !== null && items.length === 0 && (
<p className="py-8 text-center text-muted-foreground text-sm">
<p className="text-muted-foreground py-8 text-center text-sm">
<Trans>No titles found for this genre.</Trans>
</p>
)}
@@ -199,7 +192,7 @@ export function FilterableTitleRow({
))}
{isFetchingNextPage && (
<div className="flex shrink-0 items-center px-4">
<div className="size-5 animate-spin rounded-full border-2 border-primary border-t-transparent" />
<div className="border-primary size-5 animate-spin rounded-full border-2 border-t-transparent" />
</div>
)}
</div>
+12 -24
View File
@@ -1,10 +1,5 @@
import { Trans } from "@lingui/react/macro";
import {
IconDeviceTv,
IconMovie,
IconPlus,
IconStar,
} from "@tabler/icons-react";
import { IconDeviceTv, IconMovie, IconPlus, IconStar } from "@tabler/icons-react";
import { Link } from "@tanstack/react-router";
interface HeroBannerProps {
@@ -25,7 +20,7 @@ export function HeroBanner({
voteAverage,
}: HeroBannerProps) {
return (
<div className="relative -mt-6 mr-[calc(-50vw+50%)] mb-4 ml-[calc(-50vw+50%)] animate-stagger-item overflow-hidden">
<div className="animate-stagger-item relative -mt-6 mr-[calc(-50vw+50%)] mb-4 ml-[calc(-50vw+50%)] overflow-hidden">
<div className="relative aspect-[21/9] max-h-[420px] min-h-[280px] w-full">
{backdropPath ? (
<img
@@ -36,12 +31,12 @@ export function HeroBanner({
className="absolute inset-0 h-full w-full object-cover"
/>
) : (
<div className="h-full w-full bg-gradient-to-br from-card via-secondary to-muted" />
<div className="from-card via-secondary to-muted h-full w-full bg-gradient-to-br" />
)}
{/* Gradient overlays */}
<div className="absolute inset-0 bg-gradient-to-t from-background via-background/60 to-transparent" />
<div className="absolute inset-0 bg-gradient-to-r from-background/80 via-transparent to-transparent" />
<div className="from-background via-background/60 absolute inset-0 bg-gradient-to-t to-transparent" />
<div className="from-background/80 absolute inset-0 bg-gradient-to-r via-transparent to-transparent" />
{/* Content */}
<div className="absolute inset-0 flex items-end">
@@ -52,7 +47,7 @@ export function HeroBanner({
style={{ "--stagger-index": 3 } as React.CSSProperties}
>
<div className="mb-3 flex items-center gap-2">
<span className="inline-flex cursor-default items-center justify-center gap-1 rounded bg-primary/10 px-1.5 py-1 font-medium text-primary text-xs">
<span className="bg-primary/10 text-primary inline-flex cursor-default items-center justify-center gap-1 rounded px-1.5 py-1 text-xs font-medium">
{type === "movie" ? (
<>
<IconMovie aria-hidden className="size-3.5" />
@@ -66,11 +61,8 @@ export function HeroBanner({
)}
</span>
{voteAverage > 0 && (
<span className="flex items-center gap-1 text-primary text-sm">
<IconStar
aria-hidden={true}
className="size-3.5 fill-primary"
/>
<span className="text-primary flex items-center gap-1 text-sm">
<IconStar aria-hidden={true} className="fill-primary size-3.5" />
{voteAverage.toFixed(1)}
</span>
)}
@@ -78,22 +70,18 @@ export function HeroBanner({
<Trans>Trending today</Trans>
</span>
</div>
<Link
to="/titles/$id"
params={{ id }}
className="group/title text-left"
>
<h2 className="text-balance font-display text-3xl tracking-tight transition-colors group-hover/title:text-primary sm:text-4xl">
<Link to="/titles/$id" params={{ id }} className="group/title text-left">
<h2 className="font-display group-hover/title:text-primary text-3xl tracking-tight text-balance transition-colors sm:text-4xl">
{title}
</h2>
</Link>
<p className="mt-2 line-clamp-2 max-w-2xl text-muted-foreground text-sm">
<p className="text-muted-foreground mt-2 line-clamp-2 max-w-2xl text-sm">
{overview}
</p>
<Link
to="/titles/$id"
params={{ id }}
className="mt-4 inline-flex h-9 items-center gap-2 rounded-lg bg-primary px-4 font-medium text-primary-foreground text-sm transition-shadow hover:shadow-md hover:shadow-primary/20"
className="bg-primary text-primary-foreground hover:shadow-primary/20 mt-4 inline-flex h-9 items-center gap-2 rounded-lg px-4 text-sm font-medium transition-shadow hover:shadow-md"
>
<IconPlus aria-hidden={true} className="size-4" />
<Trans>Add to Library</Trans>
@@ -1,4 +1,5 @@
import { useRef } from "react";
import { TitleCard } from "@/components/title-card";
import { ScrollArea } from "@/components/ui/scroll-area";
import { hasReachedHorizontalEnd } from "@/hooks/use-infinite-scroll";
@@ -43,9 +44,7 @@ export function TitleRow({
<section className="space-y-4">
<div className="flex items-center gap-2">
{icon}
<h2 className="text-balance font-display text-xl tracking-tight">
{heading}
</h2>
<h2 className="font-display text-xl tracking-tight text-balance">{heading}</h2>
</div>
<ScrollArea
scrollFade
@@ -91,7 +90,7 @@ export function TitleRow({
))}
{isFetchingNextPage && (
<div className="flex shrink-0 items-center px-4">
<div className="size-5 animate-spin rounded-full border-2 border-primary border-t-transparent" />
<div className="border-primary size-5 animate-spin rounded-full border-2 border-t-transparent" />
</div>
)}
</div>
+10 -9
View File
@@ -1,6 +1,7 @@
import { Trans } from "@lingui/react/macro";
import { Link } from "@tanstack/react-router";
import { motion } from "motion/react";
import { SofaLogo } from "@/components/sofa-logo";
// Poster positions arranged in angled columns behind the hero
@@ -77,11 +78,11 @@ export function LandingPage({
</div>
{/* Radial fade over posters to keep center clear */}
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_center,_var(--tw-gradient-stops))] from-background via-background/95 to-background/40" />
<div className="from-background via-background/95 to-background/40 pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_center,_var(--tw-gradient-stops))]" />
{/* Warm primary glow */}
<motion.div
className="pointer-events-none absolute top-1/3 left-1/2 h-[600px] w-[600px] -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary/5 blur-[120px]"
className="bg-primary/5 pointer-events-none absolute top-1/3 left-1/2 h-[600px] w-[600px] -translate-x-1/2 -translate-y-1/2 rounded-full blur-[120px]"
animate={{ opacity: [0.4, 0.7, 0.4] }}
transition={{
duration: 6,
@@ -93,7 +94,7 @@ export function LandingPage({
<main className="relative z-10 flex flex-col items-center gap-10 px-6 text-center">
<div className="space-y-4">
<motion.p
className="font-medium text-primary text-sm uppercase tracking-[0.3em]"
className="text-primary text-sm font-medium tracking-[0.3em] uppercase"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{
@@ -105,7 +106,7 @@ export function LandingPage({
<Trans>Self-hosted movie & TV tracker</Trans>
</motion.p>
<motion.div
className="flex justify-center text-primary"
className="text-primary flex justify-center"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{
@@ -118,7 +119,7 @@ export function LandingPage({
<SofaLogo className="size-24 sm:size-28 md:size-32" />
</motion.div>
<motion.p
className="mx-auto max-w-md text-lg text-muted-foreground leading-relaxed"
className="text-muted-foreground mx-auto max-w-md text-lg leading-relaxed"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{
@@ -150,7 +151,7 @@ export function LandingPage({
{freshInstall ? (
<Link
to="/register"
className="group relative inline-flex h-12 items-center justify-center overflow-hidden rounded-lg bg-primary px-8 font-medium text-primary-foreground transition-shadow hover:shadow-lg hover:shadow-primary/20"
className="group bg-primary text-primary-foreground hover:shadow-primary/20 relative inline-flex h-12 items-center justify-center overflow-hidden rounded-lg px-8 font-medium transition-shadow hover:shadow-lg"
>
<span className="relative z-10">
<Trans>Get Started</Trans>
@@ -161,7 +162,7 @@ export function LandingPage({
<>
<Link
to="/login"
className="group relative inline-flex h-12 items-center justify-center overflow-hidden rounded-lg bg-primary px-8 font-medium text-primary-foreground transition-shadow hover:shadow-lg hover:shadow-primary/20"
className="group bg-primary text-primary-foreground hover:shadow-primary/20 relative inline-flex h-12 items-center justify-center overflow-hidden rounded-lg px-8 font-medium transition-shadow hover:shadow-lg"
>
<span className="relative z-10">
<Trans>Sign In</Trans>
@@ -171,7 +172,7 @@ export function LandingPage({
{registrationOpen && (
<Link
to="/register"
className="inline-flex h-12 items-center justify-center rounded-lg border border-border px-8 font-medium transition-colors hover:border-primary/40 hover:bg-primary/5"
className="border-border hover:border-primary/40 hover:bg-primary/5 inline-flex h-12 items-center justify-center rounded-lg border px-8 font-medium transition-colors"
>
<Trans>Register</Trans>
</Link>
@@ -182,7 +183,7 @@ export function LandingPage({
</main>
{/* Bottom fade */}
<div className="pointer-events-none absolute right-0 bottom-0 left-0 h-32 bg-gradient-to-t from-background to-transparent" />
<div className="from-background pointer-events-none absolute right-0 bottom-0 left-0 h-32 bg-gradient-to-t to-transparent" />
</div>
);
}
+23 -41
View File
@@ -1,15 +1,10 @@
import { Trans, useLingui } from "@lingui/react/macro";
import {
IconCompass,
IconHome,
IconLogout,
IconSearch,
IconSettings,
} from "@tabler/icons-react";
import { IconCompass, IconHome, IconLogout, IconSearch, IconSettings } from "@tabler/icons-react";
import { Link, useLocation, useNavigate } from "@tanstack/react-router";
import { useSetAtom } from "jotai";
import { motion } from "motion/react";
import { useLayoutEffect, useRef, useState } from "react";
import { SofaLogo } from "@/components/sofa-logo";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
@@ -81,12 +76,7 @@ function useActiveIndicator<T>(
const item = itemRefs.current[activeIndex];
const container = containerRef.current;
if (item && container && container.offsetWidth > 0) {
setValue(
measure(
item.getBoundingClientRect(),
container.getBoundingClientRect(),
),
);
setValue(measure(item.getBoundingClientRect(), container.getBoundingClientRect()));
} else {
setValue(null);
}
@@ -128,9 +118,7 @@ export function NavBar({
const initial = userName?.charAt(0).toUpperCase() ?? "?";
const activeIndex = navLinks.findIndex((link) =>
isLinkActive(pathname, link.href),
);
const activeIndex = navLinks.findIndex((link) => isLinkActive(pathname, link.href));
const navRef = useRef<HTMLElement>(null);
const linkRefs = useRef<(HTMLAnchorElement | null)[]>([]);
const { value: indicator, instant: desktopInstant } = useActiveIndicator(
@@ -141,12 +129,12 @@ export function NavBar({
);
return (
<header className="sticky top-0 z-50 border-border/50 border-b bg-background/80 pt-[env(safe-area-inset-top)] backdrop-blur-xl">
<header className="border-border/50 bg-background/80 sticky top-0 z-50 border-b pt-[env(safe-area-inset-top)] backdrop-blur-xl">
<div className="mx-auto flex h-14 max-w-6xl items-center justify-between gap-5 pr-[max(1rem,env(safe-area-inset-right))] pl-[max(1rem,env(safe-area-inset-left))] sm:gap-0 sm:pr-[max(1.5rem,env(safe-area-inset-right))] sm:pl-[max(1.5rem,env(safe-area-inset-left))]">
<div className="flex items-center gap-3 sm:gap-6">
<Link
to="/dashboard"
className="shrink-0 text-foreground transition-colors hover:text-primary"
className="text-foreground hover:text-primary shrink-0 transition-colors"
>
<SofaLogo className="size-7" />
</Link>
@@ -165,7 +153,7 @@ export function NavBar({
}}
to={link.href}
aria-current={isActive ? "page" : undefined}
className="relative inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-muted-foreground text-sm transition-colors hover:text-foreground focus-visible:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40"
className="text-muted-foreground hover:text-foreground focus-visible:text-foreground focus-visible:ring-primary/40 relative inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm transition-colors focus-visible:ring-2 focus-visible:outline-none"
>
{link.label}
</Link>
@@ -173,7 +161,7 @@ export function NavBar({
})}
{indicator && (
<motion.div
className="absolute -bottom-[11px] h-0.5 rounded-full bg-primary"
className="bg-primary absolute -bottom-[11px] h-0.5 rounded-full"
initial={false}
animate={{ left: indicator.left, width: indicator.width }}
transition={desktopInstant ? { duration: 0 } : springTransition}
@@ -187,7 +175,7 @@ export function NavBar({
<button
type="button"
onClick={() => setCommandPaletteOpen(true)}
className="flex flex-1 items-center gap-2 rounded-lg border border-border/50 bg-card/50 px-3 py-1.5 text-[13px] text-muted-foreground transition-colors hover:border-primary/20 hover:bg-card sm:hidden"
className="border-border/50 bg-card/50 text-muted-foreground hover:border-primary/20 hover:bg-card flex flex-1 items-center gap-2 rounded-lg border px-3 py-1.5 text-[13px] transition-colors sm:hidden"
>
<IconSearch aria-hidden={true} className="size-3.5" />
<span>{t`Search…`}</span>
@@ -196,7 +184,7 @@ export function NavBar({
<button
type="button"
onClick={() => setCommandPaletteOpen(true)}
className="hidden items-center gap-2 rounded-lg border border-border/50 bg-card/50 px-3 py-1.5 text-[13px] text-muted-foreground transition-colors hover:border-primary/20 hover:bg-card sm:inline-flex"
className="border-border/50 bg-card/50 text-muted-foreground hover:border-primary/20 hover:bg-card hidden items-center gap-2 rounded-lg border px-3 py-1.5 text-[13px] transition-colors sm:inline-flex"
>
<IconSearch aria-hidden={true} className="size-3.5" />
<span>{t`Search…`}</span>
@@ -204,12 +192,12 @@ export function NavBar({
</button>
<Separator
orientation="vertical"
className="mx-1.5 my-auto hidden h-6 bg-border/50 sm:block"
className="bg-border/50 mx-1.5 my-auto hidden h-6 sm:block"
/>
{/* User avatar dropdown */}
<DropdownMenu modal={false}>
<DropdownMenuTrigger
className="hidden cursor-pointer rounded-full outline-none ring-2 ring-transparent transition-all hover:ring-primary/40 focus-visible:ring-primary/60 sm:block"
className="hover:ring-primary/40 focus-visible:ring-primary/60 hidden cursor-pointer rounded-full ring-2 ring-transparent transition-all outline-none sm:block"
aria-label="Account menu"
>
<Avatar>
@@ -228,17 +216,15 @@ export function NavBar({
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-foreground text-sm leading-tight">
<p className="text-foreground truncate text-sm leading-tight font-medium">
{userName}
{userRole === "admin" && (
<Badge className="mb-0.5 ml-1.5 rounded-md border-0 bg-primary/10 align-middle text-primary">
<Badge className="bg-primary/10 text-primary mb-0.5 ml-1.5 rounded-md border-0 align-middle">
<Trans>Admin</Trans>
</Badge>
)}
</p>
<p className="truncate text-muted-foreground text-xs">
{userEmail}
</p>
<p className="text-muted-foreground truncate text-xs">{userEmail}</p>
</div>
</div>
<DropdownMenuSeparator />
@@ -266,12 +252,12 @@ export function NavBar({
{/* Mobile: simple avatar link to settings */}
<Link
to="/settings"
className="rounded-full ring-2 ring-transparent transition-all hover:ring-primary/40 sm:hidden"
className="hover:ring-primary/40 rounded-full ring-2 ring-transparent transition-all sm:hidden"
aria-label="Settings"
>
<Avatar size="sm">
<AvatarImage src={userImage} alt={userName} />
<AvatarFallback className="bg-primary/10 font-display text-[10px] text-primary">
<AvatarFallback className="bg-primary/10 font-display text-primary text-[10px]">
{initial}
</AvatarFallback>
</Avatar>
@@ -292,9 +278,7 @@ export function MobileTabBar() {
{ href: "/settings", label: t`Settings`, icon: IconSettings },
] as const;
const activeIndex = mobileTabs.findIndex((tab) =>
isLinkActive(pathname, tab.href),
);
const activeIndex = mobileTabs.findIndex((tab) => isLinkActive(pathname, tab.href));
const containerRef = useRef<HTMLDivElement>(null);
const tabRefs = useRef<(HTMLAnchorElement | null)[]>([]);
const { value: indicatorLeft, instant: mobileInstant } = useActiveIndicator(
@@ -307,7 +291,7 @@ export function MobileTabBar() {
return (
<nav
aria-label="Primary"
className="fixed right-0 bottom-0 left-0 z-50 border-border/50 border-t bg-background/90 pr-[env(safe-area-inset-right)] pl-[env(safe-area-inset-left)] backdrop-blur-xl sm:hidden"
className="border-border/50 bg-background/90 fixed right-0 bottom-0 left-0 z-50 border-t pr-[env(safe-area-inset-right)] pl-[env(safe-area-inset-left)] backdrop-blur-xl sm:hidden"
>
<div ref={containerRef} className="relative flex h-14 items-stretch">
{mobileTabs.map((tab, i) => {
@@ -321,13 +305,11 @@ export function MobileTabBar() {
}}
to={tab.href}
aria-current={isActive ? "page" : undefined}
className="relative flex flex-1 flex-col items-center justify-center gap-0.5 focus-visible:text-foreground focus-visible:outline-none"
className="focus-visible:text-foreground relative flex flex-1 flex-col items-center justify-center gap-0.5 focus-visible:outline-none"
>
<Icon
className={`size-5 ${isActive ? "text-primary" : "text-muted-foreground"}`}
/>
<Icon className={`size-5 ${isActive ? "text-primary" : "text-muted-foreground"}`} />
<span
className={`font-medium text-[10px] ${isActive ? "text-primary" : "text-muted-foreground"}`}
className={`text-[10px] font-medium ${isActive ? "text-primary" : "text-muted-foreground"}`}
>
{tab.label}
</span>
@@ -336,7 +318,7 @@ export function MobileTabBar() {
})}
{indicatorLeft !== null && (
<motion.div
className="absolute top-0 h-0.5 w-8 rounded-full bg-primary"
className="bg-primary absolute top-0 h-0.5 w-8 rounded-full"
initial={false}
animate={{ left: indicatorLeft }}
transition={mobileInstant ? { duration: 0 } : springTransition}
@@ -134,7 +134,6 @@ export function ProgressProvider({ children }: { children: ReactNode }) {
// Finish on route change — routeKey is intentionally a dep to trigger on navigation
const routeKey = pathname + (searchStr ?? "");
const firstRenderRef = useRef(true);
// biome-ignore lint/correctness/useExhaustiveDependencies: routeKey drives re-runs on route change
useEffect(() => {
if (firstRenderRef.current) {
firstRenderRef.current = false;
@@ -174,7 +173,7 @@ export function ProgressProvider({ children }: { children: ReactNode }) {
style={{ opacity: visible ? 1 : 0 }}
>
<div
className={`h-full origin-left bg-primary motion-safe:[box-shadow:0_0_8px_var(--color-primary)] ${progress === 0 ? "" : "motion-safe:transition-transform motion-safe:duration-150 motion-safe:ease-out"}`}
className={`bg-primary h-full origin-left motion-safe:[box-shadow:0_0_8px_var(--color-primary)] ${progress === 0 ? "" : "motion-safe:transition-transform motion-safe:duration-150 motion-safe:ease-out"}`}
style={{ transform: `scaleX(${clamp(progress, 0, 100) / 100})` }}
/>
</div>
@@ -1,7 +1,7 @@
import { Trans, useLingui } from "@lingui/react/macro";
import type { PersonCredit } from "@sofa/api/schemas";
import { IconMovie } from "@tabler/icons-react";
import { useMemo, useState } from "react";
import { TitleCard } from "@/components/title-card";
import { Button } from "@/components/ui/button";
import {
@@ -11,6 +11,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { PersonCredit } from "@sofa/api/schemas";
type Filter = "all" | "movie" | "tv";
type Sort = "newest" | "rating";
@@ -20,10 +21,7 @@ interface FilmographyGridProps {
userStatuses?: Record<string, "watchlist" | "in_progress" | "completed">;
}
export function FilmographyGrid({
credits,
userStatuses,
}: FilmographyGridProps) {
export function FilmographyGrid({ credits, userStatuses }: FilmographyGridProps) {
const { t } = useLingui();
const [filter, setFilter] = useState<Filter>("all");
const [sort, setSort] = useState<Sort>("newest");
@@ -64,13 +62,11 @@ export function FilmographyGrid({
<section className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<IconMovie aria-hidden={true} className="size-5 text-primary" />
<h2 className="text-balance font-display text-xl tracking-tight">
<IconMovie aria-hidden={true} className="text-primary size-5" />
<h2 className="font-display text-xl tracking-tight text-balance">
<Trans>Filmography</Trans>
</h2>
<span className="text-muted-foreground text-sm">
({filtered.length})
</span>
<span className="text-muted-foreground text-sm">({filtered.length})</span>
</div>
<Select
@@ -82,19 +78,11 @@ export function FilmographyGrid({
<SelectTrigger size="sm">
<SelectValue>
{(value: string | null) =>
value === "newest"
? t`Newest`
: value === "rating"
? t`Rating`
: null
value === "newest" ? t`Newest` : value === "rating" ? t`Rating` : null
}
</SelectValue>
</SelectTrigger>
<SelectContent
align="end"
alignItemWithTrigger={false}
className="p-1"
>
<SelectContent align="end" alignItemWithTrigger={false} className="p-1">
<SelectItem value="newest">
<Trans>Newest</Trans>
</SelectItem>
@@ -1,8 +1,10 @@
import { useInfiniteQuery } from "@tanstack/react-query";
import { useMemo } from "react";
import { Skeleton } from "@/components/ui/skeleton";
import { useInfiniteScroll } from "@/hooks/use-infinite-scroll";
import { orpc } from "@/lib/orpc/client";
import { FilmographyGrid } from "./filmography-grid";
import { PersonHero } from "./person-hero";
@@ -30,15 +32,14 @@ export function PersonDetailSkeleton() {
}
export function PersonDetailClient({ id }: { id: string }) {
const { data, isPending, fetchNextPage, hasNextPage, isFetchingNextPage } =
useInfiniteQuery(
orpc.people.detail.infiniteOptions({
input: (pageParam: number) => ({ id, page: pageParam, limit: 20 }),
initialPageParam: 1,
getNextPageParam: (lastPage) =>
lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined,
}),
);
const { data, isPending, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery(
orpc.people.detail.infiniteOptions({
input: (pageParam: number) => ({ id, page: pageParam, limit: 20 }),
initialPageParam: 1,
getNextPageParam: (lastPage) =>
lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined,
}),
);
const sentinelRef = useInfiniteScroll({
fetchNextPage,
@@ -47,16 +48,13 @@ export function PersonDetailClient({ id }: { id: string }) {
});
const person = data?.pages[0]?.person;
const filmography = useMemo(
() => data?.pages.flatMap((p) => p.filmography) ?? [],
[data?.pages],
);
const filmography = useMemo(() => data?.pages.flatMap((p) => p.filmography) ?? [], [data?.pages]);
const userStatuses = useMemo(
() =>
Object.assign(
{},
...(data?.pages.map((p) => p.userStatuses) ?? []),
) as Record<string, "watchlist" | "in_progress" | "completed">,
Object.assign({}, ...(data?.pages.map((p) => p.userStatuses) ?? [])) as Record<
string,
"watchlist" | "in_progress" | "completed"
>,
[data?.pages],
);
@@ -70,7 +68,7 @@ export function PersonDetailClient({ id }: { id: string }) {
<div ref={sentinelRef} />
{isFetchingNextPage && (
<div className="flex justify-center py-4">
<div className="size-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
<div className="border-primary size-6 animate-spin rounded-full border-2 border-t-transparent" />
</div>
)}
</div>
+9 -11
View File
@@ -1,11 +1,11 @@
import { useLingui } from "@lingui/react/macro";
import type { ResolvedPerson } from "@sofa/api/schemas";
import { formatDate } from "@sofa/i18n/format";
import { IconCalendar, IconMapPin } from "@tabler/icons-react";
import { ExpandableText } from "@/components/expandable-text";
import { Badge } from "@/components/ui/badge";
import { thumbHashToUrl } from "@/lib/thumbhash";
import type { ResolvedPerson } from "@sofa/api/schemas";
import { formatDate } from "@sofa/i18n/format";
interface PersonHeroProps {
person: ResolvedPerson;
@@ -24,12 +24,10 @@ function calculateAge(birthday: string, deathday?: string | null): number {
export function PersonHero({ person }: PersonHeroProps) {
const { t } = useLingui();
const age = person.birthday
? calculateAge(person.birthday, person.deathday)
: null;
const age = person.birthday ? calculateAge(person.birthday, person.deathday) : null;
return (
<div className="flex animate-stagger-item flex-col gap-6 sm:flex-row sm:gap-8">
<div className="animate-stagger-item flex flex-col gap-6 sm:flex-row sm:gap-8">
<div
className="size-40 shrink-0 self-center overflow-hidden rounded-2xl shadow-2xl ring-1 ring-white/10 sm:size-56 sm:self-start"
style={
@@ -52,8 +50,8 @@ export function PersonHero({ person }: PersonHeroProps) {
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full w-full items-center justify-center bg-gradient-to-br from-muted to-muted/50">
<span className="font-display text-5xl text-muted-foreground/40">
<div className="from-muted to-muted/50 flex h-full w-full items-center justify-center bg-gradient-to-br">
<span className="font-display text-muted-foreground/40 text-5xl">
{person.name.charAt(0)}
</span>
</div>
@@ -61,12 +59,12 @@ export function PersonHero({ person }: PersonHeroProps) {
</div>
<div className="min-w-0 flex-1 space-y-3">
<h1 className="text-balance font-display text-3xl tracking-tight sm:text-5xl">
<h1 className="font-display text-3xl tracking-tight text-balance sm:text-5xl">
{person.name}
</h1>
{person.knownForDepartment && (
<Badge className="border-0 bg-primary/10 px-2.5 font-semibold text-primary uppercase tracking-wider">
<Badge className="bg-primary/10 text-primary border-0 px-2.5 font-semibold tracking-wider uppercase">
{person.knownForDepartment === "Acting"
? t`Actor`
: person.knownForDepartment === "Directing"
@@ -81,7 +79,7 @@ export function PersonHero({ person }: PersonHeroProps) {
</Badge>
)}
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-muted-foreground text-sm">
<div className="text-muted-foreground flex flex-wrap items-center gap-x-4 gap-y-1 text-sm">
{person.birthday && (
<span className="flex items-center gap-1.5">
<IconCalendar aria-hidden={true} className="size-3.5" />
@@ -1,5 +1,4 @@
import { Trans, useLingui } from "@lingui/react/macro";
import { formatDate } from "@sofa/i18n/format";
import {
IconAlertTriangle,
IconCamera,
@@ -16,16 +15,12 @@ import { useNavigate, useRouter } from "@tanstack/react-router";
import { AnimatePresence, motion } from "motion/react";
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardTitle,
} from "@/components/ui/card";
import { Card, CardContent, CardDescription, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
@@ -39,14 +34,11 @@ import {
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Spinner } from "@/components/ui/spinner";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { authClient, signOut } from "@/lib/auth/client";
import { getErrorMessage } from "@/lib/error-messages";
import { orpc } from "@/lib/orpc/client";
import { formatDate } from "@sofa/i18n/format";
export function AccountSection({
user,
@@ -135,8 +127,7 @@ export function AccountSection({
},
}),
);
const isAvatarPending =
uploadAvatarMutation.isPending || removeAvatarMutation.isPending;
const isAvatarPending = uploadAvatarMutation.isPending || removeAvatarMutation.isPending;
function handleRemoveAvatar() {
removeAvatarMutation.mutate();
@@ -170,8 +161,8 @@ export function AccountSection({
return (
<div>
<div className="mb-3 flex items-center gap-2">
<IconUser aria-hidden={true} className="size-4 text-muted-foreground" />
<h2 className="font-medium text-muted-foreground text-xs uppercase tracking-wider">
<IconUser aria-hidden={true} className="text-muted-foreground size-4" />
<h2 className="text-muted-foreground text-xs font-medium tracking-wider uppercase">
<Trans>Account</Trans>
</h2>
</div>
@@ -183,29 +174,18 @@ export function AccountSection({
render={
<button
type="button"
onClick={
avatarUrl
? handleRemoveAvatar
: () => fileInputRef.current?.click()
}
onClick={avatarUrl ? handleRemoveAvatar : () => fileInputRef.current?.click()}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
disabled={isAvatarPending}
/>
}
className="relative shrink-0 cursor-pointer rounded-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
aria-label={
avatarUrl
? t`Remove profile picture`
: t`Upload profile picture`
}
className="focus-visible:ring-ring focus-visible:ring-offset-background relative shrink-0 cursor-pointer rounded-full focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
aria-label={avatarUrl ? t`Remove profile picture` : t`Upload profile picture`}
>
<Avatar className="size-12 overflow-hidden">
<AvatarImage
src={isAvatarPending ? undefined : avatarUrl}
alt={displayName}
/>
<AvatarFallback className="bg-primary/10 font-display text-lg text-primary">
<AvatarImage src={isAvatarPending ? undefined : avatarUrl} alt={displayName} />
<AvatarFallback className="bg-primary/10 font-display text-primary text-lg">
{initial}
</AvatarFallback>
</Avatar>
@@ -217,10 +197,8 @@ export function AccountSection({
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
className={`absolute inset-0 flex items-center justify-center rounded-full text-foreground/70 backdrop-blur-sm ${
avatarUrl && !isAvatarPending
? "bg-destructive/40"
: "bg-black/50"
className={`text-foreground/70 absolute inset-0 flex items-center justify-center rounded-full backdrop-blur-sm ${
avatarUrl && !isAvatarPending ? "bg-destructive/40" : "bg-black/50"
}`}
>
{isAvatarPending ? (
@@ -235,11 +213,7 @@ export function AccountSection({
</AnimatePresence>
</TooltipTrigger>
<TooltipContent>
{avatarUrl ? (
<Trans>Remove picture</Trans>
) : (
<Trans>Upload picture</Trans>
)}
{avatarUrl ? <Trans>Remove picture</Trans> : <Trans>Upload picture</Trans>}
</TooltipContent>
</Tooltip>
@@ -265,7 +239,7 @@ export function AccountSection({
>
<div className="relative inline-grid items-center">
<span
className="invisible col-start-1 row-start-1 whitespace-pre font-medium text-sm"
className="invisible col-start-1 row-start-1 text-sm font-medium whitespace-pre"
aria-hidden="true"
>
{editValue || " "}
@@ -279,11 +253,11 @@ export function AccountSection({
onBlur={handleNameSave}
disabled={isNamePending}
maxLength={100}
className="col-start-1 row-start-1 min-w-4 border-0 border-primary/40 border-b border-dashed bg-transparent font-medium text-sm outline-none transition-colors focus:border-primary"
className="border-primary/40 focus:border-primary col-start-1 row-start-1 min-w-4 border-0 border-b border-dashed bg-transparent text-sm font-medium transition-colors outline-none"
/>
</div>
{isNamePending ? (
<Spinner className="size-3.5 shrink-0 text-muted-foreground" />
<Spinner className="text-muted-foreground size-3.5 shrink-0" />
) : (
<>
<button
@@ -292,7 +266,7 @@ export function AccountSection({
e.preventDefault();
handleNameSave();
}}
className="shrink-0 rounded-md p-0.5 text-muted-foreground transition-colors hover:text-primary"
className="text-muted-foreground hover:text-primary shrink-0 rounded-md p-0.5 transition-colors"
aria-label={t`Save name`}
>
<IconCheck className="size-3.5" />
@@ -303,7 +277,7 @@ export function AccountSection({
e.preventDefault();
handleNameCancel();
}}
className="shrink-0 rounded-md p-0.5 text-muted-foreground transition-colors hover:text-destructive"
className="text-muted-foreground hover:text-destructive shrink-0 rounded-md p-0.5 transition-colors"
aria-label={t`Cancel editing`}
>
<IconX className="size-3.5" />
@@ -320,10 +294,10 @@ export function AccountSection({
transition={{ duration: 0.1 }}
type="button"
onClick={() => setIsEditingName(true)}
className="group/name inline-flex items-center gap-1.5 rounded-md px-0 text-left transition-colors hover:text-primary"
className="group/name hover:text-primary inline-flex items-center gap-1.5 rounded-md px-0 text-left transition-colors"
>
{displayName}
<IconPencil className="size-3 text-transparent transition-colors group-hover/name:text-muted-foreground" />
<IconPencil className="group-hover/name:text-muted-foreground size-3 text-transparent transition-colors" />
</motion.button>
)}
</AnimatePresence>
@@ -331,12 +305,12 @@ export function AccountSection({
<CardDescription>
{user.email}
{user.role === "admin" && (
<Badge className="ml-1.5 rounded-md border-0 bg-primary/10 align-middle text-primary">
<Badge className="bg-primary/10 text-primary ml-1.5 rounded-md border-0 align-middle">
<Trans>Admin</Trans>
</Badge>
)}
</CardDescription>
<p className="mt-0.5 text-muted-foreground/60 text-xs">
<p className="text-muted-foreground/60 mt-0.5 text-xs">
<Trans>Member since {memberSince}</Trans>
</p>
</div>
@@ -490,9 +464,7 @@ function ChangePasswordDialog() {
<Checkbox
id="revoke-sessions"
checked={revokeOtherSessions}
onCheckedChange={(checked) =>
setRevokeOtherSessions(checked === true)
}
onCheckedChange={(checked) => setRevokeOtherSessions(checked === true)}
disabled={isSubmitting}
/>
<Label htmlFor="revoke-sessions" className="cursor-pointer">
@@ -3,6 +3,7 @@ import { IconCloudUpload } from "@tabler/icons-react";
import { useMutation } from "@tanstack/react-query";
import { useRef, useState } from "react";
import { toast } from "sonner";
import {
AlertDialog,
AlertDialogAction,
@@ -48,11 +49,8 @@ export function BackupRestoreSection() {
<CardContent>
<div className="flex items-center justify-between gap-4">
<div className="flex items-start gap-3">
<div className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<IconCloudUpload
aria-hidden={true}
className="size-4 text-primary"
/>
<div className="bg-primary/10 mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg">
<IconCloudUpload aria-hidden={true} className="text-primary size-4" />
</div>
<div>
<CardTitle>
@@ -60,8 +58,7 @@ export function BackupRestoreSection() {
</CardTitle>
<CardDescription>
<Trans>
Upload a .db file to replace the current database. A safety
backup is created first.
Upload a .db file to replace the current database. A safety backup is created first.
</Trans>
</CardDescription>
</div>
@@ -79,10 +76,7 @@ export function BackupRestoreSection() {
}
}}
/>
<AlertDialog
open={restoreDialogOpen}
onOpenChange={setRestoreDialogOpen}
>
<AlertDialog open={restoreDialogOpen} onOpenChange={setRestoreDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
@@ -90,9 +84,9 @@ export function BackupRestoreSection() {
</AlertDialogTitle>
<AlertDialogDescription>
<Trans>
This will replace your entire database with the uploaded file.
A safety backup of your current data will be created first.
Active sessions may need to refresh after restore.
This will replace your entire database with the uploaded file. A safety backup of
your current data will be created first. Active sessions may need to refresh after
restore.
</Trans>
</AlertDialogDescription>
</AlertDialogHeader>
@@ -123,16 +117,8 @@ export function BackupRestoreSection() {
onClick={() => fileInputRef.current?.click()}
disabled={restoreMutation.isPending}
>
{restoreMutation.isPending ? (
<Spinner />
) : (
<IconCloudUpload aria-hidden={true} />
)}
{restoreMutation.isPending ? (
<Trans>Restoring</Trans>
) : (
<Trans>Upload</Trans>
)}
{restoreMutation.isPending ? <Spinner /> : <IconCloudUpload aria-hidden={true} />}
{restoreMutation.isPending ? <Trans>Restoring</Trans> : <Trans>Upload</Trans>}
</Button>
</div>
</CardContent>
@@ -1,11 +1,10 @@
import { Trans, useLingui } from "@lingui/react/macro";
import type { BackupFrequency } from "@sofa/api/schemas";
import { formatDate, formatRelativeTime } from "@sofa/i18n/format";
import { IconCalendarWeek } from "@tabler/icons-react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { AnimatePresence, motion } from "motion/react";
import { useCallback, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { ButtonGroup } from "@/components/ui/button-group";
import { CardContent, CardDescription, CardTitle } from "@/components/ui/card";
@@ -19,6 +18,8 @@ import {
import { Skeleton } from "@/components/ui/skeleton";
import { Switch } from "@/components/ui/switch";
import { orpc } from "@/lib/orpc/client";
import type { BackupFrequency } from "@sofa/api/schemas";
import { formatDate, formatRelativeTime } from "@sofa/i18n/format";
const FREQUENCY_OPTIONS: { value: BackupFrequency; label: string }[] = [
{ value: "6h", label: "6h" },
@@ -37,11 +38,7 @@ interface BackupScheduleState {
dow: number;
}
function getNextBackupDate(
frequency: BackupFrequency,
time: string,
dayOfWeek: number,
): Date {
function getNextBackupDate(frequency: BackupFrequency, time: string, dayOfWeek: number): Date {
const now = new Date();
const [h, m] = time.split(":").map(Number);
@@ -121,12 +118,8 @@ export function BackupScheduleSection() {
const { enabled, maxRetention, frequency, time, dow } = current;
function formatNextBackup(
frequency: BackupFrequency,
time: string,
dayOfWeek: number,
): string {
const next = getNextBackupDate(frequency, time, dayOfWeek);
function formatNextBackup(freq: BackupFrequency, timeOfDay: string, dayOfWeek: number): string {
const next = getNextBackupDate(freq, timeOfDay, dayOfWeek);
const distance = formatRelativeTime(next);
return t`Next backup ${distance}`;
}
@@ -137,10 +130,8 @@ export function BackupScheduleSection() {
const previous = { ...current };
const patch: Partial<BackupScheduleState> = {};
if (input.enabled !== undefined) patch.enabled = input.enabled;
if (input.maxRetention !== undefined)
patch.maxRetention = input.maxRetention;
if (input.frequency !== undefined)
patch.frequency = input.frequency as BackupFrequency;
if (input.maxRetention !== undefined) patch.maxRetention = input.maxRetention;
if (input.frequency !== undefined) patch.frequency = input.frequency as BackupFrequency;
if (input.time !== undefined) patch.time = input.time;
if (input.dayOfWeek !== undefined) patch.dow = input.dayOfWeek;
setSchedule({ ...current, ...patch });
@@ -160,11 +151,9 @@ export function BackupScheduleSection() {
);
const togglingSchedule =
updateScheduleMutation.isPending &&
updateScheduleMutation.variables?.enabled !== undefined;
updateScheduleMutation.isPending && updateScheduleMutation.variables?.enabled !== undefined;
const savingSchedule =
updateScheduleMutation.isPending &&
updateScheduleMutation.variables?.frequency !== undefined;
updateScheduleMutation.isPending && updateScheduleMutation.variables?.frequency !== undefined;
const toggleScheduled = useCallback(
(checked: boolean) => {
@@ -172,11 +161,7 @@ export function BackupScheduleSection() {
{ enabled: checked },
{
onSuccess: () =>
toast.success(
checked
? t`Scheduled backups enabled`
: t`Scheduled backups disabled`,
),
toast.success(checked ? t`Scheduled backups enabled` : t`Scheduled backups disabled`),
},
);
},
@@ -223,11 +208,8 @@ export function BackupScheduleSection() {
<CardContent>
<div className="flex items-center justify-between gap-4">
<div className="flex items-start gap-3">
<div className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<IconCalendarWeek
aria-hidden={true}
className="size-4 text-primary"
/>
<div className="bg-primary/10 mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg">
<IconCalendarWeek aria-hidden={true} className="text-primary size-4" />
</div>
<div>
<CardTitle>
@@ -235,36 +217,23 @@ export function BackupScheduleSection() {
</CardTitle>
<CardDescription>
{enabled ? (
<span
className="inline-flex flex-wrap items-baseline"
suppressHydrationWarning
>
<span className="inline-flex flex-wrap items-baseline" suppressHydrationWarning>
{formatNextBackup(frequency, time, dow)}.{" "}
<Trans>
Keeping{" "}
<Select
value={String(maxRetention)}
onValueChange={(v) =>
v && changeMaxRetention(Number(v))
}
onValueChange={(v) => v && changeMaxRetention(Number(v))}
modal={false}
>
<SelectTrigger className="!h-auto mr-0.5 ml-1.5 w-auto gap-0.5 rounded-none border-0 bg-transparent p-0 underline decoration-muted-foreground/50 decoration-dotted underline-offset-4 shadow-none hover:bg-transparent hover:text-foreground hover:decoration-foreground/50 focus-visible:decoration-foreground focus-visible:decoration-solid focus-visible:ring-0 dark:bg-transparent dark:hover:bg-transparent">
<SelectTrigger className="decoration-muted-foreground/50 hover:text-foreground hover:decoration-foreground/50 focus-visible:decoration-foreground mr-0.5 ml-1.5 !h-auto w-auto gap-0.5 rounded-none border-0 bg-transparent p-0 underline decoration-dotted underline-offset-4 shadow-none hover:bg-transparent focus-visible:decoration-solid focus-visible:ring-0 dark:bg-transparent dark:hover:bg-transparent">
<SelectValue>
{(value: string | null) =>
value === "0"
? t`unlimited`
: value
? t`last ${value}`
: null
value === "0" ? t`unlimited` : value ? t`last ${value}` : null
}
</SelectValue>
</SelectTrigger>
<SelectContent
align="start"
alignItemWithTrigger={false}
className="p-1"
>
<SelectContent align="start" alignItemWithTrigger={false} className="p-1">
{[3, 5, 7, 14, 30, 0].map((n) => (
<SelectItem key={n} value={String(n)}>
{n === 0 ? t`unlimited` : t`last ${n}`}
@@ -276,9 +245,7 @@ export function BackupScheduleSection() {
</Trans>
</span>
) : (
<Trans>
Automatically back up your database on a schedule
</Trans>
<Trans>Automatically back up your database on a schedule</Trans>
)}
</CardDescription>
</div>
@@ -305,7 +272,7 @@ export function BackupScheduleSection() {
<div className="space-y-3">
{/* Frequency selector */}
<div className="space-y-1.5">
<span className="inline-block font-medium text-[11px] text-muted-foreground/70 uppercase tracking-wider">
<span className="text-muted-foreground/70 inline-block text-[11px] font-medium tracking-wider uppercase">
<Trans>Frequency</Trans>
</span>
<ButtonGroup>
@@ -318,7 +285,7 @@ export function BackupScheduleSection() {
onClick={() => changeSchedule(opt.value, time)}
className={
frequency === opt.value
? "border-primary/50 bg-primary text-primary-foreground shadow-sm hover:bg-primary/90 hover:text-primary-foreground"
? "border-primary/50 bg-primary text-primary-foreground hover:bg-primary/90 hover:text-primary-foreground shadow-sm"
: "border-border/50 bg-muted/30 text-muted-foreground hover:bg-muted/50 hover:text-foreground"
}
>
@@ -338,30 +305,22 @@ export function BackupScheduleSection() {
transition={{ duration: 0.15 }}
className="overflow-hidden"
>
<span className="font-medium text-[11px] text-muted-foreground/70 uppercase tracking-wider">
<span className="text-muted-foreground/70 text-[11px] font-medium tracking-wider uppercase">
<Trans>Day:</Trans>{" "}
</span>
<Select
value={String(dow)}
onValueChange={(v) =>
v && changeSchedule(frequency, time, Number(v))
}
onValueChange={(v) => v && changeSchedule(frequency, time, Number(v))}
modal={false}
>
<SelectTrigger className="h-auto gap-1 border-border/50 bg-muted/30 px-2.5 py-1 text-foreground text-xs hover:bg-muted/50 dark:bg-muted/30 dark:hover:bg-muted/50">
<SelectTrigger className="border-border/50 bg-muted/30 text-foreground hover:bg-muted/50 dark:bg-muted/30 dark:hover:bg-muted/50 h-auto gap-1 px-2.5 py-1 text-xs">
<SelectValue>
{(value: string | null) =>
value !== null
? DAYS_OF_WEEK[Number(value)]
: null
value !== null ? DAYS_OF_WEEK[Number(value)] : null
}
</SelectValue>
</SelectTrigger>
<SelectContent
align="start"
alignItemWithTrigger={false}
className="p-1"
>
<SelectContent align="start" alignItemWithTrigger={false} className="p-1">
{DAYS_OF_WEEK.map((day, i) => (
<SelectItem key={day} value={String(i)}>
{day}
@@ -383,7 +342,7 @@ export function BackupScheduleSection() {
transition={{ duration: 0.15 }}
className="overflow-hidden"
>
<span className="font-medium text-[11px] text-muted-foreground/70 uppercase tracking-wider">
<span className="text-muted-foreground/70 text-[11px] font-medium tracking-wider uppercase">
{frequency === "12h" ? (
<Trans>Starting at</Trans>
) : (
@@ -395,35 +354,22 @@ export function BackupScheduleSection() {
onValueChange={(v) => v && changeSchedule(frequency, v)}
modal={false}
>
<SelectTrigger className="h-auto gap-1 border-border/50 bg-muted/30 px-2.5 py-1 text-foreground text-xs hover:bg-muted/50 dark:bg-muted/30 dark:hover:bg-muted/50">
<SelectTrigger className="border-border/50 bg-muted/30 text-foreground hover:bg-muted/50 dark:bg-muted/30 dark:hover:bg-muted/50 h-auto gap-1 px-2.5 py-1 text-xs">
<SelectValue>
{(value: string | null) =>
value
? formatDate(
new Date(
2000,
0,
1,
Number(value.split(":")[0]),
0,
),
{
hour: "numeric",
minute: "2-digit",
year: undefined,
month: undefined,
day: undefined,
},
)
? formatDate(new Date(2000, 0, 1, Number(value.split(":")[0]), 0), {
hour: "numeric",
minute: "2-digit",
year: undefined,
month: undefined,
day: undefined,
})
: null
}
</SelectValue>
</SelectTrigger>
<SelectContent
align="start"
alignItemWithTrigger={false}
className="p-1"
>
<SelectContent align="start" alignItemWithTrigger={false} className="p-1">
{HOURS.map((h) => {
const val = `${String(h).padStart(2, "0")}:00`;
return (
@@ -1,11 +1,5 @@
import { plural } from "@lingui/core/macro";
import { Trans, useLingui } from "@lingui/react/macro";
import type { BackupInfo } from "@sofa/api/schemas";
import {
formatBytes as formatBytesI18n,
formatDate,
formatRelativeTime,
} from "@sofa/i18n/format";
import {
IconClock,
IconCloudDownload,
@@ -19,6 +13,7 @@ import { useMutation, useQuery } from "@tanstack/react-query";
import { AnimatePresence, motion } from "motion/react";
import { useState } from "react";
import { toast } from "sonner";
import {
AlertDialog,
AlertDialogAction,
@@ -33,12 +28,10 @@ import {
import { Button } from "@/components/ui/button";
import { CardContent, CardDescription, CardTitle } from "@/components/ui/card";
import { Spinner } from "@/components/ui/spinner";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { orpc } from "@/lib/orpc/client";
import type { BackupInfo } from "@sofa/api/schemas";
import { formatBytes as formatBytesI18n, formatDate, formatRelativeTime } from "@sofa/i18n/format";
function formatBackupDate(dateStr: string): string {
return formatDate(dateStr, {
@@ -86,9 +79,7 @@ export function BackupSection() {
orpc.admin.backups.delete.mutationOptions({
onMutate: ({ filename }) => {
const previous = displayBackups;
setBackups(
displayBackups.filter((b: BackupInfo) => b.filename !== filename),
);
setBackups(displayBackups.filter((b: BackupInfo) => b.filename !== filename));
return { previous };
},
onSuccess: () => toast.success(t`Backup deleted`),
@@ -100,9 +91,7 @@ export function BackupSection() {
);
const creating = createMutation.isPending;
const deleting = deleteMutation.isPending
? (deleteMutation.variables?.filename ?? null)
: null;
const deleting = deleteMutation.isPending ? (deleteMutation.variables?.filename ?? null) : null;
const backupCountLabel =
displayBackups.length > 0
@@ -115,11 +104,8 @@ export function BackupSection() {
<CardContent>
<div className="flex items-center justify-between gap-4">
<div className="flex items-start gap-3">
<div className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<IconDatabaseExport
aria-hidden={true}
className="size-4 text-primary"
/>
<div className="bg-primary/10 mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg">
<IconDatabaseExport aria-hidden={true} className="text-primary size-4" />
</div>
<div>
<CardTitle>
@@ -129,11 +115,7 @@ export function BackupSection() {
</div>
</div>
<Button onClick={() => createMutation.mutate()} disabled={creating}>
{creating ? (
<Spinner className="size-3" />
) : (
<IconPlus aria-hidden={true} />
)}
{creating ? <Spinner className="size-3" /> : <IconPlus aria-hidden={true} />}
{creating ? <Trans>Creating</Trans> : <Trans>New backup</Trans>}
</Button>
</div>
@@ -153,25 +135,19 @@ export function BackupSection() {
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
<div className="group flex items-center gap-3 rounded-md px-2.5 py-1.5 transition-colors hover:bg-muted/40">
<div className="group hover:bg-muted/40 flex items-center gap-3 rounded-md px-2.5 py-1.5 transition-colors">
<Tooltip>
<TooltipTrigger
render={
<span className="flex shrink-0 items-center text-muted-foreground" />
<span className="text-muted-foreground flex shrink-0 items-center" />
}
>
{backup.source === "scheduled" ? (
<IconClock aria-hidden={true} className="size-3.5" />
) : backup.source === "pre-restore" ? (
<IconShieldCheck
aria-hidden={true}
className="size-3.5"
/>
<IconShieldCheck aria-hidden={true} className="size-3.5" />
) : (
<IconPointer
aria-hidden={true}
className="size-3.5"
/>
<IconPointer aria-hidden={true} className="size-3.5" />
)}
</TooltipTrigger>
<TooltipContent>
@@ -184,14 +160,14 @@ export function BackupSection() {
</Tooltip>
<div className="min-w-0 flex-1">
<div className="flex items-baseline gap-2">
<span className="font-medium text-foreground text-xs">
<span className="text-foreground text-xs font-medium">
{formatBackupDate(backup.createdAt)}
</span>
<span className="text-[11px] text-muted-foreground">
<span className="text-muted-foreground text-[11px]">
{formatBytesI18n(backup.sizeBytes)}
</span>
<span
className="text-[11px] text-muted-foreground/50"
className="text-muted-foreground/50 text-[11px]"
suppressHydrationWarning
>
{formatRelativeTime(backup.createdAt)}
@@ -240,11 +216,7 @@ export function BackupSection() {
/>
}
>
{deleting === backup.filename ? (
<Spinner />
) : (
<IconTrash />
)}
{deleting === backup.filename ? <Spinner /> : <IconTrash />}
</AlertDialogTrigger>
<TooltipContent>
<Trans>Delete</Trans>
@@ -258,10 +230,8 @@ export function BackupSection() {
<AlertDialogDescription>
<Trans>
This will permanently delete the backup from{" "}
<strong>
{formatBackupDate(backup.createdAt)}
</strong>
. This cannot be undone.
<strong>{formatBackupDate(backup.createdAt)}</strong>. This cannot
be undone.
</Trans>
</AlertDialogDescription>
</AlertDialogHeader>
@@ -3,6 +3,7 @@ import { Trans, useLingui } from "@lingui/react/macro";
import { IconDatabase, IconPhoto, IconTrash } from "@tabler/icons-react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import {
AlertDialog,
AlertDialogAction,
@@ -22,8 +23,7 @@ import { client, orpc } from "@/lib/orpc/client";
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024)
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
}
@@ -61,10 +61,7 @@ export function CacheSection() {
const purgeAll = useMutation({
mutationFn: () =>
Promise.all([
client.admin.purgeMetadataCache(),
client.admin.purgeImageCache(),
]),
Promise.all([client.admin.purgeMetadataCache(), client.admin.purgeImageCache()]),
onSuccess: ([metaResult, imageResult]) => {
const freed = formatBytes(imageResult.freedBytes);
toast.success(
@@ -75,31 +72,26 @@ export function CacheSection() {
onError: () => toast.error(t`Failed to purge caches`),
});
const disabled =
purgeMetadata.isPending || purgeImages.isPending || purgeAll.isPending;
const disabled = purgeMetadata.isPending || purgeImages.isPending || purgeAll.isPending;
return (
<CardContent>
<div className="flex items-start gap-3">
<div className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<IconTrash aria-hidden={true} className="size-4 text-primary" />
<div className="bg-primary/10 mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg">
<IconTrash aria-hidden={true} className="text-primary size-4" />
</div>
<div className="flex-1">
<CardTitle>
<Trans>Cache management</Trans>
</CardTitle>
<CardDescription>
<Trans>
Free up disk space by clearing cached metadata and images
</Trans>
<Trans>Free up disk space by clearing cached metadata and images</Trans>
</CardDescription>
<div className="mt-4 flex flex-wrap gap-2">
{/* Purge metadata */}
<AlertDialog>
<AlertDialogTrigger
render={<Button variant="outline" disabled={disabled} />}
>
<AlertDialogTrigger render={<Button variant="outline" disabled={disabled} />}>
{purgeMetadata.isPending ? (
<Spinner className="size-3" />
) : (
@@ -118,9 +110,9 @@ export function CacheSection() {
</AlertDialogTitle>
<AlertDialogDescription>
<Trans>
This will delete un-enriched stub titles that aren't in
any user's library and clean up orphaned person records.
Deleted titles will be re-imported if accessed again.
This will delete un-enriched stub titles that aren't in any user's library and
clean up orphaned person records. Deleted titles will be re-imported if
accessed again.
</Trans>
</AlertDialogDescription>
</AlertDialogHeader>
@@ -128,10 +120,7 @@ export function CacheSection() {
<AlertDialogCancel>
<Trans>Cancel</Trans>
</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={() => purgeMetadata.mutate()}
>
<AlertDialogAction variant="destructive" onClick={() => purgeMetadata.mutate()}>
<Trans>Purge metadata</Trans>
</AlertDialogAction>
</AlertDialogFooter>
@@ -140,19 +129,13 @@ export function CacheSection() {
{/* Purge images */}
<AlertDialog>
<AlertDialogTrigger
render={<Button variant="outline" disabled={disabled} />}
>
<AlertDialogTrigger render={<Button variant="outline" disabled={disabled} />}>
{purgeImages.isPending ? (
<Spinner className="size-3" />
) : (
<IconPhoto aria-hidden={true} />
)}
{purgeImages.isPending ? (
<Trans>Purging...</Trans>
) : (
<Trans>Purge images</Trans>
)}
{purgeImages.isPending ? <Trans>Purging...</Trans> : <Trans>Purge images</Trans>}
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
@@ -161,8 +144,8 @@ export function CacheSection() {
</AlertDialogTitle>
<AlertDialogDescription>
<Trans>
This will delete all cached TMDB images from disk. Images
will be re-downloaded automatically as needed.
This will delete all cached TMDB images from disk. Images will be
re-downloaded automatically as needed.
</Trans>
</AlertDialogDescription>
</AlertDialogHeader>
@@ -170,10 +153,7 @@ export function CacheSection() {
<AlertDialogCancel>
<Trans>Cancel</Trans>
</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={() => purgeImages.mutate()}
>
<AlertDialogAction variant="destructive" onClick={() => purgeImages.mutate()}>
<Trans>Purge images</Trans>
</AlertDialogAction>
</AlertDialogFooter>
@@ -182,19 +162,13 @@ export function CacheSection() {
{/* Purge all */}
<AlertDialog>
<AlertDialogTrigger
render={<Button variant="destructive" disabled={disabled} />}
>
<AlertDialogTrigger render={<Button variant="destructive" disabled={disabled} />}>
{purgeAll.isPending ? (
<Spinner className="size-3" />
) : (
<IconTrash aria-hidden={true} />
)}
{purgeAll.isPending ? (
<Trans>Purging...</Trans>
) : (
<Trans>Purge all</Trans>
)}
{purgeAll.isPending ? <Trans>Purging...</Trans> : <Trans>Purge all</Trans>}
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
@@ -203,9 +177,8 @@ export function CacheSection() {
</AlertDialogTitle>
<AlertDialogDescription>
<Trans>
This will delete all un-enriched stub titles and all
cached images from disk. Everything will be re-imported
and re-downloaded as needed.
This will delete all un-enriched stub titles and all cached images from disk.
Everything will be re-imported and re-downloaded as needed.
</Trans>
</AlertDialogDescription>
</AlertDialogHeader>
@@ -213,10 +186,7 @@ export function CacheSection() {
<AlertDialogCancel>
<Trans>Cancel</Trans>
</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={() => purgeAll.mutate()}
>
<AlertDialogAction variant="destructive" onClick={() => purgeAll.mutate()}>
<Trans>Purge all</Trans>
</AlertDialogAction>
</AlertDialogFooter>
+1 -4
View File
@@ -11,10 +11,7 @@ export function PlexIcon(props: SVGProps<SVGSVGElement>) {
{...props}
>
{/* Icon from CoreUI Brands by creativeLabs Łukasz Holeczek - https://creativecommons.org/publicdomain/zero/1.0/ */}
<path
fill="currentColor"
d="M15.527 0H6.24l10.239 16L6.24 32h9.287L25.76 16z"
/>
<path fill="currentColor" d="M15.527 0H6.24l10.239 16L6.24 32h9.287L25.76 16z" />
</svg>
);
}
@@ -1,16 +1,11 @@
import { Trans, useLingui } from "@lingui/react/macro";
import type { NormalizedImport } from "@sofa/api/schemas";
import { IconCloudUpload, IconFileImport, IconLink } from "@tabler/icons-react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardTitle,
} from "@/components/ui/card";
import { Card, CardContent, CardDescription, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
@@ -26,6 +21,7 @@ import { Progress } from "@/components/ui/progress";
import { Spinner } from "@/components/ui/spinner";
import { getErrorMessage } from "@/lib/error-messages";
import { client, orpc } from "@/lib/orpc/client";
import type { NormalizedImport } from "@sofa/api/schemas";
// ─── Source Configs ──────────────────────────────────────────
@@ -105,7 +101,7 @@ const SOURCES: SourceConfig[] = [
label: "Trakt",
description: "Connect your Trakt account or upload a JSON export.",
accept: ".json",
icon: <TraktLogo className="size-4 text-primary" />,
icon: <TraktLogo className="text-primary size-4" />,
supportsOAuth: true,
},
{
@@ -113,7 +109,7 @@ const SOURCES: SourceConfig[] = [
label: "Simkl",
description: "Connect your Simkl account or upload a JSON export.",
accept: ".json",
icon: <SimklLogo className="size-4 text-primary" />,
icon: <SimklLogo className="text-primary size-4" />,
supportsOAuth: true,
},
{
@@ -121,7 +117,7 @@ const SOURCES: SourceConfig[] = [
label: "Letterboxd",
description: "Upload the ZIP export from your Letterboxd account settings.",
accept: ".zip",
icon: <LetterboxdLogo className="size-4 text-primary" />,
icon: <LetterboxdLogo className="text-primary size-4" />,
supportsOAuth: false,
},
];
@@ -174,8 +170,8 @@ export function ImportsSection() {
return (
<div>
<div className="mb-3 flex items-center gap-2">
<IconFileImport aria-hidden className="size-4 text-muted-foreground" />
<h2 className="font-medium text-muted-foreground text-xs uppercase tracking-wider">
<IconFileImport aria-hidden className="text-muted-foreground size-4" />
<h2 className="text-muted-foreground text-xs font-medium tracking-wider uppercase">
<Trans>Import</Trans>
</h2>
</div>
@@ -193,13 +189,10 @@ export function ImportsSection() {
function ImportSourceCard({ config }: { config: SourceConfig }) {
const { t } = useLingui();
const { data: systemStatus } = useQuery(orpc.system.status.queryOptions());
const publicApiUrl =
systemStatus?.publicApiUrl ?? "https://public-api.sofa.watch";
const publicApiUrl = systemStatus?.publicApiUrl ?? "https://public-api.sofa.watch";
const fileInputRef = useRef<HTMLInputElement>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const [step, setStep] = useState<DialogStep>(
config.supportsOAuth ? "choose" : "preview",
);
const [step, setStep] = useState<DialogStep>(config.supportsOAuth ? "choose" : "preview");
const [preview, setPreview] = useState<ImportPreview | null>(null);
const [result, setResult] = useState<ImportResult | null>(null);
const [options, setOptions] = useState({
@@ -268,10 +261,7 @@ function ImportSourceCard({ config }: { config: SourceConfig }) {
options,
});
const eventSource = await client.imports.jobEvents(
{ id: job.id },
{ signal: abort.signal },
);
const eventSource = await client.imports.jobEvents({ id: job.id }, { signal: abort.signal });
let receivedComplete = false;
@@ -288,15 +278,11 @@ function ImportSourceCard({ config }: { config: SourceConfig }) {
});
setStep("done");
if (event.job.importedCount > 0) {
toast.success(
t`Imported ${event.job.importedCount} items from ${config.label}`,
);
toast.success(t`Imported ${event.job.importedCount} items from ${config.label}`);
}
} else if (event.type === "timeout") {
receivedComplete = true;
toast.info(
t`Import is still running in the background. Check back later.`,
);
toast.info(t`Import is still running in the background. Check back later.`);
setStep("preview");
} else {
setProgress({
@@ -325,9 +311,7 @@ function ImportSourceCard({ config }: { config: SourceConfig }) {
});
setStep("done");
} else {
toast.info(
t`Import is still running in the background. Check back later.`,
);
toast.info(t`Import is still running in the background. Check back later.`);
setStep("preview");
}
} catch {
@@ -377,15 +361,13 @@ function ImportSourceCard({ config }: { config: SourceConfig }) {
setStep("device-code");
try {
const res = await fetch(
`${publicApiUrl}/v1/import/${config.source}/device-code`,
{ method: "POST" },
);
const res = await fetch(`${publicApiUrl}/v1/import/${config.source}/device-code`, {
method: "POST",
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(
(err as { error?: string }).error ??
t`Failed to start ${config.label} connection`,
(err as { error?: string }).error ?? t`Failed to start ${config.label} connection`,
);
}
const data = (await res.json()) as DeviceCodeInfo;
@@ -415,14 +397,11 @@ function ImportSourceCard({ config }: { config: SourceConfig }) {
}
try {
const res = await fetch(
`${publicApiUrl}/v1/import/${config.source}/poll`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ device_code: code.device_code }),
},
);
const res = await fetch(`${publicApiUrl}/v1/import/${config.source}/poll`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ device_code: code.device_code }),
});
if (!res.ok) return;
const data = (await res.json()) as {
@@ -468,7 +447,7 @@ function ImportSourceCard({ config }: { config: SourceConfig }) {
<CardContent>
<div className="flex items-center justify-between gap-4">
<div className="flex items-start gap-3">
<div className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<div className="bg-primary/10 mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg">
{config.icon}
</div>
<div>
@@ -526,11 +505,7 @@ function ImportSourceCard({ config }: { config: SourceConfig }) {
/>
)}
{step === "device-code" && (
<DeviceCodeStep
source={config.label}
deviceCode={deviceCode}
onCancel={handleClose}
/>
<DeviceCodeStep source={config.label} deviceCode={deviceCode} onCancel={handleClose} />
)}
{step === "fetching" && <FetchingStep source={config.label} />}
{step === "preview" && preview && (
@@ -543,15 +518,9 @@ function ImportSourceCard({ config }: { config: SourceConfig }) {
onCancel={handleClose}
/>
)}
{step === "importing" && (
<ImportingStep source={config.label} progress={progress} />
)}
{step === "importing" && <ImportingStep source={config.label} progress={progress} />}
{step === "done" && result && (
<DoneStep
source={config.label}
result={result}
onClose={handleClose}
/>
<DoneStep source={config.label} result={result} onClose={handleClose} />
)}
</DialogContent>
</Dialog>
@@ -590,35 +559,32 @@ function ChooseStep({
<div className="space-y-3 py-2">
{oauthError && (
<div className="rounded-lg bg-destructive/10 p-3">
<div className="bg-destructive/10 rounded-lg p-3">
<p className="text-destructive text-sm">{oauthError}</p>
</div>
)}
<button
type="button"
className="flex w-full items-center gap-3 rounded-lg border border-border/50 p-4 text-left transition-colors hover:bg-muted/50"
className="border-border/50 hover:bg-muted/50 flex w-full items-center gap-3 rounded-lg border p-4 text-left transition-colors"
onClick={onConnect}
>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<IconLink aria-hidden className="size-5 text-primary" />
<div className="bg-primary/10 flex h-10 w-10 shrink-0 items-center justify-center rounded-lg">
<IconLink aria-hidden className="text-primary size-5" />
</div>
<div>
<p className="font-medium text-sm">
<p className="text-sm font-medium">
<Trans>Connect with {config.label}</Trans>
</p>
<p className="text-muted-foreground text-xs">
<Trans>
Authorize Sofa to read your {config.label} library. No password
shared.
</Trans>
<Trans>Authorize Sofa to read your {config.label} library. No password shared.</Trans>
</p>
</div>
</button>
<button
type="button"
className="flex w-full items-center gap-3 rounded-lg border border-border/50 p-4 text-left transition-colors hover:bg-muted/50"
className="border-border/50 hover:bg-muted/50 flex w-full items-center gap-3 rounded-lg border p-4 text-left transition-colors"
onClick={() => {
onCancel();
// Small delay so the dialog closes before file picker opens
@@ -626,11 +592,11 @@ function ChooseStep({
}}
disabled={isParsing}
>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<IconCloudUpload aria-hidden className="size-5 text-primary" />
<div className="bg-primary/10 flex h-10 w-10 shrink-0 items-center justify-center rounded-lg">
<IconCloudUpload aria-hidden className="text-primary size-5" />
</div>
<div>
<p className="font-medium text-sm">
<p className="text-sm font-medium">
<Trans>Upload export file</Trans>
</p>
<p className="text-muted-foreground text-xs">
@@ -665,9 +631,7 @@ function DeviceCodeStep({
<Trans>Connect to {source}</Trans>
</DialogTitle>
<DialogDescription>
<Trans>
Enter the code below on {source}'s website to authorize Sofa.
</Trans>
<Trans>Enter the code below on {source}'s website to authorize Sofa.</Trans>
</DialogDescription>
</DialogHeader>
@@ -677,7 +641,7 @@ function DeviceCodeStep({
<p className="text-muted-foreground text-sm">
<Trans>Your code:</Trans>
</p>
<p className="rounded-lg bg-muted px-6 py-3 font-bold font-mono text-2xl tracking-widest">
<p className="bg-muted rounded-lg px-6 py-3 font-mono text-2xl font-bold tracking-widest">
{deviceCode.user_code}
</p>
</div>
@@ -687,14 +651,14 @@ function DeviceCodeStep({
href={deviceCode.verification_url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 font-medium text-primary text-sm underline-offset-4 hover:underline"
className="text-primary inline-flex items-center gap-1.5 text-sm font-medium underline-offset-4 hover:underline"
>
<IconLink aria-hidden className="size-4" />
<Trans>Open {source} to enter code</Trans>
</a>
</div>
<div className="flex items-center justify-center gap-2 text-muted-foreground text-xs">
<div className="text-muted-foreground flex items-center justify-center gap-2 text-xs">
<Spinner className="size-3" />
<Trans>Waiting for authorization...</Trans>
</div>
@@ -728,9 +692,7 @@ function FetchingStep({ source }: { source: string }) {
<div className="flex flex-col items-center gap-3 py-8">
<Spinner className="size-8" />
<p className="text-muted-foreground text-sm">
<Trans>
Retrieving your watch history, watchlist, and ratings...
</Trans>
<Trans>Retrieving your watch history, watchlist, and ratings...</Trans>
</p>
</div>
</>
@@ -783,18 +745,18 @@ function PreviewStep({
</div>
{preview.diagnostics && preview.diagnostics.unresolved > 0 && (
<div className="rounded-lg bg-muted/50 p-3">
<div className="bg-muted/50 rounded-lg p-3">
<p className="text-muted-foreground text-xs">
<Trans>
{preview.diagnostics.unresolved} items have no external IDs and
will be resolved by title search, which may be less accurate.
{preview.diagnostics.unresolved} items have no external IDs and will be resolved by
title search, which may be less accurate.
</Trans>
</p>
</div>
)}
<div className="space-y-2">
<p className="font-medium text-sm">
<p className="text-sm font-medium">
<Trans>Import options</Trans>
</p>
<OptionCheckbox
@@ -819,12 +781,11 @@ function PreviewStep({
{warnings.length > 0 && (
<div className="rounded-lg bg-yellow-500/10 p-3">
<p className="mb-1 font-medium text-xs text-yellow-600">
<p className="mb-1 text-xs font-medium text-yellow-600">
<Trans>Warnings</Trans>
</p>
<ul className="space-y-0.5 text-xs text-yellow-600/80">
{warnings.map((w, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: static display list
<li key={i}>{w}</li>
))}
</ul>
@@ -852,9 +813,7 @@ function ImportingStep({
progress: { current: number; total: number; message: string } | null;
}) {
const pct =
progress && progress.total > 0
? Math.round((progress.current / progress.total) * 100)
: null;
progress && progress.total > 0 ? Math.round((progress.current / progress.total) * 100) : null;
return (
<>
@@ -864,8 +823,7 @@ function ImportingStep({
</DialogTitle>
<DialogDescription>
<Trans>
This may take a few minutes for large libraries. Please don't close
this tab.
This may take a few minutes for large libraries. Please don't close this tab.
</Trans>
</DialogDescription>
</DialogHeader>
@@ -874,10 +832,10 @@ function ImportingStep({
<div className="flex flex-col items-center gap-1 text-center">
{progress ? (
<>
<p className="font-medium text-sm">
<p className="text-sm font-medium">
{progress.current} / {progress.total}
</p>
<p className="max-w-[300px] truncate text-muted-foreground text-xs">
<p className="text-muted-foreground max-w-[300px] truncate text-xs">
{progress.message}
</p>
</>
@@ -924,13 +882,12 @@ function DoneStep({
</div>
{result.errors.length > 0 && (
<div className="max-h-40 overflow-y-auto rounded-lg bg-destructive/10 p-3">
<p className="mb-1 font-medium text-destructive text-xs">
<div className="bg-destructive/10 max-h-40 overflow-y-auto rounded-lg p-3">
<p className="text-destructive mb-1 text-xs font-medium">
<Trans>Errors ({result.errors.length})</Trans>
</p>
<ul className="space-y-0.5 text-destructive/80 text-xs">
<ul className="text-destructive/80 space-y-0.5 text-xs">
{result.errors.slice(0, 50).map((e, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: static display list
<li key={i}>{e}</li>
))}
{result.errors.length > 50 && (
@@ -944,12 +901,11 @@ function DoneStep({
{result.warnings.length > 0 && (
<div className="max-h-32 overflow-y-auto rounded-lg bg-yellow-500/10 p-3">
<p className="mb-1 font-medium text-xs text-yellow-600">
<p className="mb-1 text-xs font-medium text-yellow-600">
<Trans>Warnings ({result.warnings.length})</Trans>
</p>
<ul className="space-y-0.5 text-xs text-yellow-600/80">
{result.warnings.slice(0, 20).map((w, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: static display list
<li key={i}>{w}</li>
))}
</ul>
@@ -970,9 +926,9 @@ function DoneStep({
function StatBadge({ label, count }: { label: string; count: number }) {
return (
<div className="rounded-lg bg-muted/50 p-2.5 text-center">
<p className="font-semibold text-lg leading-none">{count}</p>
<p className="mt-1 text-muted-foreground text-xs">{label}</p>
<div className="bg-muted/50 rounded-lg p-2.5 text-center">
<p className="text-lg leading-none font-semibold">{count}</p>
<p className="text-muted-foreground mt-1 text-xs">{label}</p>
</div>
);
}
@@ -1,7 +1,5 @@
import { msg } from "@lingui/core/macro";
import { Trans, useLingui } from "@lingui/react/macro";
import { i18n } from "@sofa/i18n";
import { formatRelativeTime } from "@sofa/i18n/format";
import {
IconBook2,
IconCheck,
@@ -16,18 +14,10 @@ import { AnimatePresence, motion } from "motion/react";
import type { ComponentType, ReactNode } from "react";
import { useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardTitle,
} from "@/components/ui/card";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { Card, CardContent, CardDescription, CardTitle } from "@/components/ui/card";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import {
InputGroup,
InputGroupAddon,
@@ -35,12 +25,10 @@ import {
InputGroupInput,
} from "@/components/ui/input-group";
import { Label } from "@/components/ui/label";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { orpc } from "@/lib/orpc/client";
import { i18n } from "@sofa/i18n";
import { formatRelativeTime } from "@sofa/i18n/format";
// ─── Types ──────────────────────────────────────────────────────────
@@ -92,12 +80,7 @@ export function IntegrationCard({
}) {
const { t } = useLingui();
const { provider, label } = config;
const providerInput = provider as
| "plex"
| "jellyfin"
| "emby"
| "sonarr"
| "radarr";
const providerInput = provider as "plex" | "jellyfin" | "emby" | "sonarr" | "radarr";
const connectMutation = useMutation(
orpc.integrations.create.mutationOptions({
@@ -131,9 +114,7 @@ export function IntegrationCard({
orpc.integrations.regenerateToken.mutationOptions({
onSuccess: (result) => {
setConnections((prev) =>
prev.map((c) =>
c.provider === provider ? { ...c, token: result.token } : c,
),
prev.map((c) => (c.provider === provider ? { ...c, token: result.token } : c)),
);
toast.success(t`${label} URL regenerated`);
},
@@ -148,9 +129,7 @@ export function IntegrationCard({
const connecting = connectMutation.isPending;
const url =
connection && typeof window !== "undefined"
? config.buildUrl(connection.token)
: null;
connection && typeof window !== "undefined" ? config.buildUrl(connection.token) : null;
async function handleCopy() {
if (!url) return;
@@ -165,43 +144,35 @@ export function IntegrationCard({
<CardContent className={cardOpen ? "pb-4" : ""}>
<CollapsibleTrigger className="flex w-full cursor-pointer items-center justify-between gap-4">
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<Icon className="size-4 text-primary" />
<div className="bg-primary/10 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg">
<Icon className="text-primary size-4" />
</div>
<div className="text-left">
<CardTitle>{config.label}</CardTitle>
<CardDescription>
{connection
? config.connectedStatus(connection.lastEventAt)
: t`Not configured`}
{connection ? config.connectedStatus(connection.lastEventAt) : t`Not configured`}
</CardDescription>
</div>
</div>
<IconChevronDown
aria-hidden={true}
className={`size-4 text-muted-foreground transition-transform duration-200 ${cardOpen ? "rotate-180" : ""}`}
className={`text-muted-foreground size-4 transition-transform duration-200 ${cardOpen ? "rotate-180" : ""}`}
/>
</CollapsibleTrigger>
</CardContent>
<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-border/30 border-t pt-4">
<CardContent className="border-border/30 space-y-3 border-t pt-4">
{config.alert}
{!connection ? (
<Button
onClick={() =>
connectMutation.mutate({ provider: providerInput })
}
onClick={() => connectMutation.mutate({ provider: providerInput })}
disabled={connecting}
size="lg"
className="w-full"
>
{connecting ? (
<Trans>Connecting...</Trans>
) : (
<Trans>Connect {config.label}</Trans>
)}
{connecting ? <Trans>Connecting...</Trans> : <Trans>Connect {config.label}</Trans>}
</Button>
) : (
<AnimatePresence>
@@ -215,7 +186,7 @@ export function IntegrationCard({
<div>
<Label
htmlFor={`${config.provider}-url`}
className="mb-1 text-muted-foreground"
className="text-muted-foreground mb-1"
>
{config.urlLabel}
</Label>
@@ -224,23 +195,14 @@ export function IntegrationCard({
id={`${config.provider}-url`}
readOnly
value={url}
className="font-mono text-[10px] text-muted-foreground"
className="text-muted-foreground font-mono text-[10px]"
/>
<InputGroupAddon align="inline-end">
<Tooltip>
<TooltipTrigger
render={
<InputGroupButton
size="icon-xs"
onClick={handleCopy}
/>
}
render={<InputGroupButton size="icon-xs" onClick={handleCopy} />}
>
{copied ? (
<IconCheck className="text-green-400" />
) : (
<IconCopy />
)}
{copied ? <IconCheck className="text-green-400" /> : <IconCopy />}
</TooltipTrigger>
<TooltipContent>
<Trans>Copy URL</Trans>
@@ -266,9 +228,7 @@ export function IntegrationCard({
<Button
variant="destructive"
size="sm"
onClick={() =>
deleteMutation.mutate({ provider: providerInput })
}
onClick={() => deleteMutation.mutate({ provider: providerInput })}
>
<IconTrash />
<Trans>Disconnect</Trans>
@@ -280,7 +240,7 @@ export function IntegrationCard({
)}
<Collapsible open={setupOpen} onOpenChange={setSetupOpen}>
<CollapsibleTrigger className="flex w-full items-center gap-1.5 rounded-md py-1 text-muted-foreground text-xs transition-colors hover:text-foreground">
<CollapsibleTrigger className="text-muted-foreground hover:text-foreground flex w-full items-center gap-1.5 rounded-md py-1 text-xs transition-colors">
<IconChevronDown
aria-hidden={true}
className={`size-3 transition-transform ${setupOpen ? "rotate-0" : "-rotate-90"}`}
@@ -288,10 +248,8 @@ export function IntegrationCard({
<Trans>Setup instructions</Trans>
</CollapsibleTrigger>
<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">
<div className="mt-2 rounded-lg border border-border/50 bg-muted/30 p-3 text-muted-foreground text-xs leading-relaxed">
<ol className="list-inside list-decimal space-y-1.5">
{config.setupSteps}
</ol>
<div className="border-border/50 bg-muted/30 text-muted-foreground mt-2 rounded-lg border p-3 text-xs leading-relaxed">
<ol className="list-inside list-decimal space-y-1.5">{config.setupSteps}</ol>
{config.docsUrl && (
<p className="mt-2 -ml-0.5">
<IconBook2
@@ -303,7 +261,7 @@ export function IntegrationCard({
href={config.docsUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-0.5 font-medium text-foreground underline-offset-2 hover:underline"
className="text-foreground inline-flex items-center gap-0.5 font-medium underline-offset-2 hover:underline"
>
<Trans>Open docs</Trans>{" "}
<IconExternalLink
@@ -1,13 +1,9 @@
import { Trans } from "@lingui/react/macro";
import { IconExternalLink, IconInfoCircle } from "@tabler/icons-react";
import { Alert, AlertDescription } from "@/components/ui/alert";
import {
EmbyIcon,
JellyfinIcon,
PlexIcon,
RadarrIcon,
SonarrIcon,
} from "./icons";
import { EmbyIcon, JellyfinIcon, PlexIcon, RadarrIcon, SonarrIcon } from "./icons";
import type { IntegrationConfig } from "./integration-card";
import { listStatus, webhookStatus } from "./integration-card";
@@ -18,11 +14,9 @@ function origin() {
/** Reusable alert banner for integrations that require a subscription. */
function RequirementAlert({ children }: { children: React.ReactNode }) {
return (
<Alert className="gap-0 border-primary/20 bg-primary/5 [&>svg]:text-primary">
<Alert className="border-primary/20 bg-primary/5 [&>svg]:text-primary gap-0">
<IconInfoCircle aria-hidden={true} className="inline-block size-3.5" />
<AlertDescription className="text-foreground/80">
{children}
</AlertDescription>
<AlertDescription className="text-foreground/80">{children}</AlertDescription>
</Alert>
);
}
@@ -44,7 +38,7 @@ export const INTEGRATION_CONFIGS: IntegrationConfig[] = [
href="https://www.plex.tv/plex-pass/"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-0.5 font-medium text-foreground underline-offset-2 hover:underline"
className="text-foreground inline-flex items-center gap-0.5 font-medium underline-offset-2 hover:underline"
>
Plex Pass
<IconExternalLink
@@ -65,7 +59,7 @@ export const INTEGRATION_CONFIGS: IntegrationConfig[] = [
href="https://app.plex.tv/desktop/#!/settings/webhooks"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-0.5 font-medium text-foreground underline-offset-2 hover:underline"
className="text-foreground inline-flex items-center gap-0.5 font-medium underline-offset-2 hover:underline"
>
Settings &gt; Webhooks
<IconExternalLink
@@ -77,15 +71,13 @@ export const INTEGRATION_CONFIGS: IntegrationConfig[] = [
</li>
<li>
<Trans>
Click{" "}
<span className="font-medium text-foreground">Add Webhook</span> and
paste the URL above
Click <span className="text-foreground font-medium">Add Webhook</span> and paste the URL
above
</Trans>
</li>
<li>
<Trans>
Sofa will automatically log movies and episodes when you finish
watching them
Sofa will automatically log movies and episodes when you finish watching them
</Trans>
</li>
</>
@@ -108,7 +100,7 @@ export const INTEGRATION_CONFIGS: IntegrationConfig[] = [
href="https://github.com/jellyfin/jellyfin-plugin-webhook/tree/master"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-0.5 font-medium text-foreground underline-offset-2 hover:underline"
className="text-foreground inline-flex items-center gap-0.5 font-medium underline-offset-2 hover:underline"
>
Webhook plugin
<IconExternalLink
@@ -122,31 +114,24 @@ export const INTEGRATION_CONFIGS: IntegrationConfig[] = [
<li>
<Trans>
Go to{" "}
<span className="font-medium text-foreground">
Dashboard &gt; Plugins &gt; Webhook
</span>
<span className="text-foreground font-medium">Dashboard &gt; Plugins &gt; Webhook</span>
</Trans>
</li>
<li>
<Trans>
Add a{" "}
<span className="font-medium text-foreground">
Generic Destination
</span>{" "}
and paste the URL above
Add a <span className="text-foreground font-medium">Generic Destination</span> and paste
the URL above
</Trans>
</li>
<li>
<Trans>
Enable the{" "}
<span className="font-medium text-foreground">Playback Stop</span>{" "}
Enable the <span className="text-foreground font-medium">Playback Stop</span>{" "}
notification type
</Trans>
</li>
<li>
<Trans>
Sofa will automatically log movies and episodes when you finish
watching them
Sofa will automatically log movies and episodes when you finish watching them
</Trans>
</li>
</>
@@ -163,16 +148,13 @@ export const INTEGRATION_CONFIGS: IntegrationConfig[] = [
alert: (
<RequirementAlert>
<Trans>
Requires{" "}
<span className="font-medium text-foreground">
Emby Server 4.7.9+
</span>{" "}
and an active{" "}
Requires <span className="text-foreground font-medium">Emby Server 4.7.9+</span> and an
active{" "}
<a
href="https://emby.media/premiere.html"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-0.5 font-medium text-foreground underline-offset-2 hover:underline"
className="text-foreground inline-flex items-center gap-0.5 font-medium underline-offset-2 hover:underline"
>
Emby Premiere
<IconExternalLink
@@ -189,9 +171,7 @@ export const INTEGRATION_CONFIGS: IntegrationConfig[] = [
<li>
<Trans>
Open Emby, go to{" "}
<span className="font-medium text-foreground">
Settings &gt; Webhooks
</span>
<span className="text-foreground font-medium">Settings &gt; Webhooks</span>
</Trans>
</li>
<li>
@@ -199,15 +179,12 @@ export const INTEGRATION_CONFIGS: IntegrationConfig[] = [
</li>
<li>
<Trans>
Enable the{" "}
<span className="font-medium text-foreground">Playback</span> event
category
Enable the <span className="text-foreground font-medium">Playback</span> event category
</Trans>
</li>
<li>
<Trans>
Sofa will automatically log movies and episodes when you finish
watching them
Sofa will automatically log movies and episodes when you finish watching them
</Trans>
</li>
</>
@@ -228,16 +205,13 @@ export const INTEGRATION_CONFIGS: IntegrationConfig[] = [
<li>
<Trans>
Open Sonarr, go to{" "}
<span className="font-medium text-foreground">
Settings &gt; Import Lists
</span>
<span className="text-foreground font-medium">Settings &gt; Import Lists</span>
</Trans>
</li>
<li>
<Trans>
Click <span className="font-medium text-foreground">+</span> and
select{" "}
<span className="font-medium text-foreground">Custom Lists</span>
Click <span className="text-foreground font-medium">+</span> and select{" "}
<span className="text-foreground font-medium">Custom Lists</span>
</Trans>
</li>
<li>
@@ -248,8 +222,8 @@ export const INTEGRATION_CONFIGS: IntegrationConfig[] = [
</li>
<li>
<Trans>
Titles on your Sofa watchlist will be automatically added for
download when Sonarr polls this list (every 6 hours by default)
Titles on your Sofa watchlist will be automatically added for download when Sonarr polls
this list (every 6 hours by default)
</Trans>
</li>
</>
@@ -268,16 +242,13 @@ export const INTEGRATION_CONFIGS: IntegrationConfig[] = [
<li>
<Trans>
Open Radarr, go to{" "}
<span className="font-medium text-foreground">
Settings &gt; Import Lists
</span>
<span className="text-foreground font-medium">Settings &gt; Import Lists</span>
</Trans>
</li>
<li>
<Trans>
Click <span className="font-medium text-foreground">+</span> and
select{" "}
<span className="font-medium text-foreground">Custom Lists</span>
Click <span className="text-foreground font-medium">+</span> and select{" "}
<span className="text-foreground font-medium">Custom Lists</span>
</Trans>
</li>
<li>
@@ -288,8 +259,8 @@ export const INTEGRATION_CONFIGS: IntegrationConfig[] = [
</li>
<li>
<Trans>
Titles on your Sofa watchlist will be automatically added for
download when Radarr polls this list (every 12 hours by default)
Titles on your Sofa watchlist will be automatically added for download when Radarr polls
this list (every 12 hours by default)
</Trans>
</li>
</>
@@ -2,27 +2,22 @@ import { Trans } from "@lingui/react/macro";
import { IconWebhook } from "@tabler/icons-react";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { Skeleton } from "@/components/ui/skeleton";
import { orpc } from "@/lib/orpc/client";
import {
IntegrationCard,
type IntegrationConnection,
} from "./integration-card";
import { IntegrationCard, type IntegrationConnection } from "./integration-card";
import { INTEGRATION_CONFIGS } from "./integration-configs";
export function IntegrationsSection() {
const { data, isPending } = useQuery(orpc.integrations.list.queryOptions());
const [localConnections, setLocalConnections] = useState<
IntegrationConnection[] | null
>(null);
const [localConnections, setLocalConnections] = useState<IntegrationConnection[] | null>(null);
// Use local state if user has modified connections, else use query data
const connections = localConnections ?? data?.integrations ?? [];
function handleSetConnections(
updater:
| IntegrationConnection[]
| ((prev: IntegrationConnection[]) => IntegrationConnection[]),
updater: IntegrationConnection[] | ((prev: IntegrationConnection[]) => IntegrationConnection[]),
) {
setLocalConnections((prev) => {
const current = prev ?? data?.integrations ?? [];
@@ -33,11 +28,8 @@ export function IntegrationsSection() {
return (
<div>
<div className="mb-3 flex items-center gap-2">
<IconWebhook
aria-hidden={true}
className="size-4 text-muted-foreground"
/>
<h2 className="font-medium text-muted-foreground text-xs uppercase tracking-wider">
<IconWebhook aria-hidden={true} className="text-muted-foreground size-4" />
<h2 className="text-muted-foreground text-xs font-medium tracking-wider uppercase">
<Trans>Integrations</Trans>
</h2>
</div>
@@ -54,9 +46,8 @@ export function IntegrationsSection() {
key={config.provider}
config={config}
connection={
connections.find(
(c: IntegrationConnection) => c.provider === config.provider,
) ?? null
connections.find((c: IntegrationConnection) => c.provider === config.provider) ??
null
}
setConnections={handleSetConnections}
/>
@@ -1,20 +1,12 @@
import { Trans, useLingui } from "@lingui/react/macro";
import { activateLocale, type SupportedLocale } from "@sofa/i18n";
import { LOCALE_INFO } from "@sofa/i18n/locales";
import { IconCheck, IconChevronDown, IconLanguage } from "@tabler/icons-react";
import { useState } from "react";
import {
Card,
CardContent,
CardDescription,
CardTitle,
} from "@/components/ui/card";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { Card, CardContent, CardDescription, CardTitle } from "@/components/ui/card";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { setPersistedLocale } from "@/lib/i18n";
import { activateLocale, type SupportedLocale } from "@sofa/i18n";
import { LOCALE_INFO } from "@sofa/i18n/locales";
export function LanguageSection() {
const { t, i18n } = useLingui();
@@ -34,21 +26,19 @@ export function LanguageSection() {
<CardContent className={open ? "pb-4" : ""}>
<CollapsibleTrigger className="flex w-full cursor-pointer items-center justify-between gap-4">
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<IconLanguage className="size-4 text-primary" />
<div className="bg-primary/10 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg">
<IconLanguage className="text-primary size-4" />
</div>
<div className="text-left">
<CardTitle>
<Trans>Language</Trans>
</CardTitle>
<CardDescription>
{currentInfo?.nativeName ?? "English"}
</CardDescription>
<CardDescription>{currentInfo?.nativeName ?? "English"}</CardDescription>
</div>
</div>
<IconChevronDown
aria-hidden={true}
className={`size-4 text-muted-foreground transition-transform duration-200 ${open ? "rotate-180" : ""}`}
className={`text-muted-foreground size-4 transition-transform duration-200 ${open ? "rotate-180" : ""}`}
/>
</CollapsibleTrigger>
</CardContent>
@@ -70,15 +60,11 @@ export function LanguageSection() {
aria-pressed={currentLocale === info.code}
>
<div className="min-w-0">
<div className="truncate font-medium">
{info.nativeName}
</div>
<div className="truncate text-muted-foreground text-xs">
{info.name}
</div>
<div className="truncate font-medium">{info.nativeName}</div>
<div className="text-muted-foreground truncate text-xs">{info.name}</div>
</div>
{currentLocale === info.code && (
<IconCheck className="ml-auto size-4 shrink-0 text-primary" />
<IconCheck className="text-primary ml-auto size-4 shrink-0" />
)}
</button>
))}
@@ -3,6 +3,7 @@ import { IconDoorEnter } from "@tabler/icons-react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useOptimistic, useState, useTransition } from "react";
import { toast } from "sonner";
import { CardContent, CardDescription, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { Switch } from "@/components/ui/switch";
@@ -10,18 +11,12 @@ import { orpc } from "@/lib/orpc/client";
export function RegistrationSection() {
const { t } = useLingui();
const { data, isPending: isLoading } = useQuery(
orpc.admin.registration.queryOptions(),
);
const [registrationOpen, setRegistrationOpen] = useState<boolean | null>(
null,
);
const { data, isPending: isLoading } = useQuery(orpc.admin.registration.queryOptions());
const [registrationOpen, setRegistrationOpen] = useState<boolean | null>(null);
const currentOpen = registrationOpen ?? data?.open ?? false;
const [optimisticOpen, setOptimisticOpen] = useOptimistic(currentOpen);
const [isPending, startTransition] = useTransition();
const toggleMutation = useMutation(
orpc.admin.toggleRegistration.mutationOptions(),
);
const toggleMutation = useMutation(orpc.admin.toggleRegistration.mutationOptions());
if (isLoading) {
return (
@@ -37,9 +32,7 @@ export function RegistrationSection() {
try {
await toggleMutation.mutateAsync({ open: checked });
setRegistrationOpen(checked);
toast.success(
checked ? t`Registration opened` : t`Registration closed`,
);
toast.success(checked ? t`Registration opened` : t`Registration closed`);
} catch {
toast.error(t`Failed to update registration setting`);
}
@@ -50,8 +43,8 @@ export function RegistrationSection() {
<CardContent>
<div className="flex items-center justify-between gap-4">
<div className="flex items-start gap-3">
<div className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<IconDoorEnter aria-hidden={true} className="size-4 text-primary" />
<div className="bg-primary/10 mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg">
<IconDoorEnter aria-hidden={true} className="text-primary size-4" />
</div>
<div>
<CardTitle>
@@ -12,13 +12,7 @@ const sectionVariants = {
},
};
export function SettingsShell({
children,
footer,
}: {
children: ReactNode;
footer?: ReactNode;
}) {
export function SettingsShell({ children, footer }: { children: ReactNode; footer?: ReactNode }) {
return (
<motion.div
className="mx-auto max-w-2xl space-y-8"
@@ -31,12 +25,12 @@ export function SettingsShell({
>
<motion.div variants={sectionVariants}>
<div className="flex items-center gap-2">
<IconSettings aria-hidden={true} className="size-5 text-primary" />
<h1 className="text-balance font-display text-3xl tracking-tight">
<IconSettings aria-hidden={true} className="text-primary size-5" />
<h1 className="font-display text-3xl tracking-tight text-balance">
<Trans>Settings</Trans>
</h1>
</div>
<p className="mt-1 text-muted-foreground text-sm">
<p className="text-muted-foreground mt-1 text-sm">
<Trans>Manage your account and preferences</Trans>
</p>
</motion.div>
@@ -1,5 +1,4 @@
import { Trans, useLingui } from "@lingui/react/macro";
import type { CronJobName, SystemHealthData } from "@sofa/api/schemas";
import {
IconActivity,
IconAlertTriangle,
@@ -11,14 +10,10 @@ import {
} from "@tabler/icons-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { StatusDot } from "@/components/status-dot";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardTitle,
} from "@/components/ui/card";
import { Card, CardContent, CardDescription, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { Spinner } from "@/components/ui/spinner";
import {
@@ -29,13 +24,10 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useTimeAgo } from "@/hooks/use-time-ago";
import { orpc } from "@/lib/orpc/client";
import type { CronJobName, SystemHealthData } from "@sofa/api/schemas";
/** Convert a cron pattern to a short human-readable string */
function cronToHuman(pattern: string): string {
@@ -74,8 +66,7 @@ function cronToHuman(pattern: string): string {
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024)
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
}
@@ -89,7 +80,7 @@ export function SkeletonCards() {
return (
<div className="space-y-3">
{["status", "jobs", "storage"].map((s) => (
<Card key={s} className="border-l-2 border-l-primary/30">
<Card key={s} className="border-l-primary/30 border-l-2">
<CardContent>
<div className="flex items-start gap-3">
<Skeleton className="mt-0.5 h-8 w-8 rounded-lg" />
@@ -120,12 +111,9 @@ function LiveTimeAgo({
/** Hydrates system health state and renders the 3 cards */
export function SystemHealthCards() {
const queryClient = useQueryClient();
const { data, isPending, isFetching } = useQuery(
orpc.admin.systemHealth.queryOptions(),
);
const { data, isPending, isFetching } = useQuery(orpc.admin.systemHealth.queryOptions());
const isRefreshing = isFetching;
const refresh = () =>
queryClient.invalidateQueries({ queryKey: orpc.admin.systemHealth.key() });
const refresh = () => queryClient.invalidateQueries({ queryKey: orpc.admin.systemHealth.key() });
if (isPending || !data) return <SkeletonCards />;
@@ -139,11 +127,7 @@ export function SystemHealthCards() {
isRefreshing={isRefreshing}
onRefresh={refresh}
/>
<BackgroundJobsCard
jobs={data.jobs}
isRefreshing={isRefreshing}
onRefresh={refresh}
/>
<BackgroundJobsCard jobs={data.jobs} isRefreshing={isRefreshing} onRefresh={refresh} />
<StorageCard
imageCache={data.imageCache}
backups={data.backups}
@@ -197,15 +181,12 @@ function SystemStatusCard({
onRefresh: () => void;
}) {
return (
<Card className="border-l-2 border-l-primary/30">
<Card className="border-l-primary/30 border-l-2">
<CardContent>
<div className="flex items-center justify-between gap-4">
<div className="flex items-start gap-3">
<div className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<IconActivity
aria-hidden={true}
className="size-4 text-primary"
/>
<div className="bg-primary/10 mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg">
<IconActivity aria-hidden={true} className="text-primary size-4" />
</div>
<div>
<CardTitle>
@@ -225,13 +206,12 @@ function SystemStatusCard({
{/* Database */}
<CardContent className="border-border/30 border-t pt-4">
<div className="flex items-center gap-2">
<span className="font-medium text-[11px] text-muted-foreground/70 uppercase tracking-wider">
<span className="text-muted-foreground/70 text-[11px] font-medium tracking-wider uppercase">
<Trans>Database</Trans>
</span>
<span className="font-mono text-[11px] text-muted-foreground">
<span className="text-muted-foreground font-mono text-[11px]">
{formatBytes(database.dbSizeBytes)}
{database.walSizeBytes > 0 &&
` + ${formatBytes(database.walSizeBytes)} WAL`}
{database.walSizeBytes > 0 && ` + ${formatBytes(database.walSizeBytes)} WAL`}
</span>
</div>
</CardContent>
@@ -239,7 +219,7 @@ function SystemStatusCard({
{/* TMDB */}
<CardContent className="border-border/30 border-t pt-4">
<div className="flex items-center gap-2">
<span className="font-medium text-[11px] text-muted-foreground/70 uppercase tracking-wider">
<span className="text-muted-foreground/70 text-[11px] font-medium tracking-wider uppercase">
TMDB API
</span>
{!tmdb.tokenConfigured ? (
@@ -255,7 +235,7 @@ function SystemStatusCard({
<span className="text-muted-foreground text-xs">
<Trans>Connected</Trans>
</span>
<span className="font-mono text-[11px] text-muted-foreground/80">
<span className="text-muted-foreground/80 font-mono text-[11px]">
{tmdb.responseTimeMs}ms
</span>
</>
@@ -273,9 +253,7 @@ function SystemStatusCard({
<Trans>Unreachable</Trans>
</span>
{tmdb.error && (
<span className="text-[11px] text-muted-foreground/50">
{tmdb.error}
</span>
<span className="text-muted-foreground/50 text-[11px]">{tmdb.error}</span>
)}
</>
)}
@@ -285,15 +263,12 @@ function SystemStatusCard({
{/* Environment */}
<CardContent className="border-border/30 border-t pt-4">
<div className="space-y-2">
<span className="inline-flex items-center gap-1.5 font-medium text-[11px] text-muted-foreground/70 uppercase tracking-wider">
<span className="text-muted-foreground/70 inline-flex items-center gap-1.5 text-[11px] font-medium tracking-wider uppercase">
<Trans>Environment</Trans>
{environment.dataDirWritable ? (
<IconCheck aria-hidden={true} className="size-3 text-green-500" />
) : (
<IconAlertTriangle
aria-hidden={true}
className="size-3 text-destructive"
/>
<IconAlertTriangle aria-hidden={true} className="text-destructive size-3" />
)}
</span>
<div className="space-y-1">
@@ -305,9 +280,7 @@ function SystemStatusCard({
className="flex items-baseline gap-[1px] font-mono text-[11px] leading-relaxed"
>
<span className="text-muted-foreground/60">{env.name}=</span>
<span className="break-all text-muted-foreground">
{env.value}
</span>
<span className="text-muted-foreground break-all">{env.value}</span>
</div>
))}
</div>
@@ -344,9 +317,7 @@ function BackgroundJobsCard({
setTimeout(onRefresh, 1500);
},
onError: (err) => {
toast.error(
err instanceof Error ? err.message : t`Failed to trigger job`,
);
toast.error(err instanceof Error ? err.message : t`Failed to trigger job`);
},
}),
);
@@ -362,20 +333,15 @@ function BackgroundJobsCard({
return new Date(a.nextRunAt).getTime() - new Date(b.nextRunAt).getTime();
});
const activeJobs = jobs.filter((j) => !j.disabled);
const healthyCount = activeJobs.filter(
(j) => j.lastStatus === "success",
).length;
const healthyCount = activeJobs.filter((j) => j.lastStatus === "success").length;
return (
<Card className="border-l-2 border-l-primary/30">
<Card className="border-l-primary/30 border-l-2">
<CardContent>
<div className="flex items-center justify-between gap-4">
<div className="flex items-start gap-3">
<div className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<IconCalendarCheck
aria-hidden={true}
className="size-4 text-primary"
/>
<div className="bg-primary/10 mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg">
<IconCalendarCheck aria-hidden={true} className="text-primary size-4" />
</div>
<div>
<CardTitle>
@@ -395,19 +361,19 @@ function BackgroundJobsCard({
<Table>
<TableHeader>
<TableRow className="border-b-border/30 hover:bg-transparent">
<TableHead className="h-8 pl-5 font-medium text-[10px] text-muted-foreground uppercase tracking-wider">
<TableHead className="text-muted-foreground h-8 pl-5 text-[10px] font-medium tracking-wider uppercase">
<Trans>Job</Trans>
</TableHead>
<TableHead className="h-8 font-medium text-[10px] text-muted-foreground uppercase tracking-wider">
<TableHead className="text-muted-foreground h-8 text-[10px] font-medium tracking-wider uppercase">
<Trans>Schedule</Trans>
</TableHead>
<TableHead className="h-8 font-medium text-[10px] text-muted-foreground uppercase tracking-wider">
<TableHead className="text-muted-foreground h-8 text-[10px] font-medium tracking-wider uppercase">
<Trans>Last run</Trans>
</TableHead>
<TableHead className="h-8 font-medium text-[10px] text-muted-foreground uppercase tracking-wider">
<TableHead className="text-muted-foreground h-8 text-[10px] font-medium tracking-wider uppercase">
<Trans>Next run</Trans>
</TableHead>
<TableHead className="h-8 pr-5 text-right font-medium text-[10px] text-muted-foreground uppercase tracking-wider">
<TableHead className="text-muted-foreground h-8 pr-5 text-right text-[10px] font-medium tracking-wider uppercase">
<span className="sr-only">
<Trans>Actions</Trans>
</span>
@@ -420,10 +386,7 @@ function BackgroundJobsCard({
const isRunning = job.isCurrentlyRunning || isTriggering;
return (
<TableRow
key={job.jobName}
className="border-b-border/20 hover:bg-muted/30"
>
<TableRow key={job.jobName} className="border-b-border/20 hover:bg-muted/30">
{/* Job name + status */}
<TableCell className="pl-5">
<div className="flex items-center gap-2">
@@ -436,10 +399,7 @@ function BackgroundJobsCard({
) : job.lastStatus === "success" ? (
<StatusDot status="ok" label={t`Last run succeeded`} />
) : (
<StatusDot
status="error"
label={job.lastError ?? t`Last run failed`}
/>
<StatusDot status="error" label={job.lastError ?? t`Last run failed`} />
)}
<span className="text-muted-foreground text-xs">
{JOB_LABELS[job.jobName] ?? job.jobName}
@@ -461,9 +421,7 @@ function BackgroundJobsCard({
</TooltipContent>
</Tooltip>
) : (
<span className="text-muted-foreground/50 text-xs">
</span>
<span className="text-muted-foreground/50 text-xs"></span>
)}
</TableCell>
@@ -479,20 +437,17 @@ function BackgroundJobsCard({
>
<LiveTimeAgo date={job.lastRunAt} />
</span>
{job.lastDurationMs !== null &&
job.lastDurationMs > 0 && (
<span className="font-mono text-[10px] text-muted-foreground/50">
{formatDuration(job.lastDurationMs)}
</span>
)}
{job.lastDurationMs !== null && job.lastDurationMs > 0 && (
<span className="text-muted-foreground/50 font-mono text-[10px]">
{formatDuration(job.lastDurationMs)}
</span>
)}
</div>
</TooltipTrigger>
<TooltipContent>
{new Date(job.lastRunAt).toLocaleString()}
{job.lastError && (
<div className="mt-1 text-destructive">
{job.lastError}
</div>
<div className="text-destructive mt-1">{job.lastError}</div>
)}
</TooltipContent>
</Tooltip>
@@ -515,14 +470,10 @@ function BackgroundJobsCard({
<LiveTimeAgo date={job.nextRunAt} />
</span>
</TooltipTrigger>
<TooltipContent>
{new Date(job.nextRunAt).toLocaleString()}
</TooltipContent>
<TooltipContent>{new Date(job.nextRunAt).toLocaleString()}</TooltipContent>
</Tooltip>
) : (
<span className="text-muted-foreground/50 text-xs">
</span>
<span className="text-muted-foreground/50 text-xs"></span>
)}
</TableCell>
@@ -550,7 +501,7 @@ function BackgroundJobsCard({
) : (
<IconPlayerPlay
aria-hidden={true}
className="size-3 text-muted-foreground/70"
className="text-muted-foreground/70 size-3"
/>
)}
</TooltipTrigger>
@@ -580,15 +531,12 @@ function StorageCard({
}) {
const { t } = useLingui();
return (
<Card className="border-l-2 border-l-primary/30">
<Card className="border-l-primary/30 border-l-2">
<CardContent>
<div className="flex items-center justify-between gap-4">
<div className="flex items-start gap-3">
<div className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<IconDatabase
aria-hidden={true}
className="size-4 text-primary"
/>
<div className="bg-primary/10 mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg">
<IconDatabase aria-hidden={true} className="text-primary size-4" />
</div>
<div>
<CardTitle>
@@ -606,28 +554,28 @@ function StorageCard({
{/* Image cache */}
<CardContent className="border-border/30 border-t pt-4">
<div className="flex items-center justify-between">
<span className="font-medium text-[11px] text-muted-foreground/70 uppercase tracking-wider">
<span className="text-muted-foreground/70 text-[11px] font-medium tracking-wider uppercase">
<Trans>Image cache</Trans>
</span>
{imageCache.enabled ? (
<span className="font-mono text-[11px] text-muted-foreground/50">
<span className="text-muted-foreground/50 font-mono text-[11px]">
{formatBytes(imageCache.totalSizeBytes)}
</span>
) : null}
</div>
{imageCache.enabled ? (
<>
<p className="mt-1 text-muted-foreground text-xs">
<p className="text-muted-foreground mt-1 text-xs">
{t`${imageCache.imageCount.toLocaleString()} cached images`}
</p>
<p className="mt-0.5 text-[10px] text-muted-foreground/50 leading-relaxed">
<p className="text-muted-foreground/50 mt-0.5 text-[10px] leading-relaxed">
{Object.entries(imageCache.categories)
.map(([name, cat]) => `${name} ${cat.count}`)
.join(" · ")}
</p>
</>
) : (
<p className="mt-1 flex items-center gap-1.5 text-muted-foreground/50 text-xs">
<p className="text-muted-foreground/50 mt-1 flex items-center gap-1.5 text-xs">
<StatusDot status="inactive" />
<Trans>Disabled</Trans>
</p>
@@ -637,27 +585,24 @@ function StorageCard({
{/* Backup summary */}
<CardContent className="border-border/30 border-t pt-4">
<div className="flex items-center justify-between">
<span className="font-medium text-[11px] text-muted-foreground/70 uppercase tracking-wider">
<span className="text-muted-foreground/70 text-[11px] font-medium tracking-wider uppercase">
<Trans>Backups</Trans>
</span>
{backups.backupCount > 0 && (
<span className="font-mono text-[11px] text-muted-foreground/50">
<span className="text-muted-foreground/50 font-mono text-[11px]">
{formatBytes(backups.totalSizeBytes)}
</span>
)}
</div>
{backups.backupCount > 0 ? (
<p
className="mt-1 text-muted-foreground text-xs"
suppressHydrationWarning
>
<p className="text-muted-foreground mt-1 text-xs" suppressHydrationWarning>
<Trans>
{backups.backupCount} backups · last{" "}
<LiveTimeAgo date={backups.lastBackupAt} fallback={t`unknown`} />
</Trans>
</p>
) : (
<p className="mt-1 flex items-center gap-1.5 text-muted-foreground/50 text-xs">
<p className="text-muted-foreground/50 mt-1 flex items-center gap-1.5 text-xs">
<StatusDot status="inactive" />
<Trans>No backups yet</Trans>
</p>
@@ -3,6 +3,7 @@ import { IconWorldUpload } from "@tabler/icons-react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useOptimistic, useState, useTransition } from "react";
import { toast } from "sonner";
import { CardContent, CardDescription, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { Switch } from "@/components/ui/switch";
@@ -10,17 +11,12 @@ import { orpc } from "@/lib/orpc/client";
export function UpdateCheckSection() {
const { t } = useLingui();
const { data, isPending: isLoading } = useQuery(
orpc.admin.updateCheck.queryOptions(),
);
const { data, isPending: isLoading } = useQuery(orpc.admin.updateCheck.queryOptions());
const [localEnabled, setLocalEnabled] = useState<boolean | null>(null);
const currentEnabled = localEnabled ?? data?.enabled ?? true;
const [optimisticEnabled, setOptimisticEnabled] =
useOptimistic(currentEnabled);
const [optimisticEnabled, setOptimisticEnabled] = useOptimistic(currentEnabled);
const [isPending, startTransition] = useTransition();
const toggleMutation = useMutation(
orpc.admin.toggleUpdateCheck.mutationOptions(),
);
const toggleMutation = useMutation(orpc.admin.toggleUpdateCheck.mutationOptions());
if (isLoading) {
return (
@@ -36,9 +32,7 @@ export function UpdateCheckSection() {
try {
await toggleMutation.mutateAsync({ enabled: checked });
setLocalEnabled(checked);
toast.success(
checked ? t`Update checks enabled` : t`Update checks disabled`,
);
toast.success(checked ? t`Update checks enabled` : t`Update checks disabled`);
} catch {
toast.error(t`Failed to update setting`);
}
@@ -49,11 +43,8 @@ export function UpdateCheckSection() {
<CardContent>
<div className="flex items-center justify-between gap-4">
<div className="flex items-start gap-3">
<div className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<IconWorldUpload
aria-hidden={true}
className="size-4 text-primary"
/>
<div className="bg-primary/10 mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg">
<IconWorldUpload aria-hidden={true} className="text-primary size-4" />
</div>
<div>
<CardTitle>
@@ -1,6 +1,7 @@
import { Trans } from "@lingui/react/macro";
import { IconCheck, IconCopy } from "@tabler/icons-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
export function CopyButton({ code }: { code: string }) {
@@ -17,7 +18,7 @@ export function CopyButton({ code }: { code: string }) {
variant="ghost"
size="sm"
onClick={handleCopy}
className="text-[11px] text-muted-foreground"
className="text-muted-foreground text-[11px]"
>
{copied ? (
<>
@@ -1,6 +1,7 @@
import { Trans } from "@lingui/react/macro";
import { IconRefresh } from "@tabler/icons-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
@@ -15,20 +16,12 @@ export function RefreshButton() {
return (
<Button
size="lg"
className="h-9 rounded-lg px-4 text-sm hover:shadow-md hover:shadow-primary/20"
className="hover:shadow-primary/20 h-9 rounded-lg px-4 text-sm hover:shadow-md"
onClick={handleRefresh}
disabled={isRefreshing}
>
{isRefreshing ? (
<Spinner />
) : (
<IconRefresh aria-hidden={true} className="size-3.5" />
)}
{isRefreshing ? (
<Trans>Checking</Trans>
) : (
<Trans>Check configuration</Trans>
)}
{isRefreshing ? <Spinner /> : <IconRefresh aria-hidden={true} className="size-3.5" />}
{isRefreshing ? <Trans>Checking</Trans> : <Trans>Check configuration</Trans>}
</Button>
);
}
+5 -21
View File
@@ -1,9 +1,6 @@
import { motion, useReducedMotion } from "motion/react";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
const colors = {
@@ -44,19 +41,12 @@ export function StatusDot({
const dotEl = pulse ? (
<motion.span
className={cn(
"inline-block h-2 w-2 shrink-0 rounded-full",
bg,
className,
)}
className={cn("inline-block h-2 w-2 shrink-0 rounded-full", bg, className)}
animate={
prefersReducedMotion
? {}
: {
boxShadow: [
shadow,
pulseColors[status as keyof typeof pulseColors],
],
boxShadow: [shadow, pulseColors[status as keyof typeof pulseColors]],
}
}
transition={{
@@ -67,13 +57,7 @@ export function StatusDot({
}}
/>
) : (
<span
className={cn(
"inline-block h-2 w-2 shrink-0 rounded-full",
bg,
className,
)}
/>
<span className={cn("inline-block h-2 w-2 shrink-0 rounded-full", bg, className)} />
);
return (
+18 -43
View File
@@ -13,19 +13,16 @@ import { useMutation } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
import { type MotionStyle, type MotionValue, motion } from "motion/react";
import { useEffect, useState } from "react";
import { Skeleton } from "@/components/ui/skeleton";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useTiltEffect } from "@/hooks/use-tilt-effect";
import { orpc } from "@/lib/orpc/client";
import { thumbHashToUrl } from "@/lib/thumbhash";
export function TitleCardSkeleton() {
return (
<div className="overflow-hidden rounded-xl bg-card ring-1 ring-white/[0.06]">
<div className="bg-card overflow-hidden rounded-xl ring-1 ring-white/[0.06]">
<Skeleton className="aspect-[2/3] w-full rounded-none" />
<div className="px-3 pt-2.5 pb-3">
<Skeleton className="h-4 w-3/4" />
@@ -80,18 +77,10 @@ function useStatusConfig() {
} as const;
}
function QuickAddButton({
id,
userStatus,
}: {
id: string;
userStatus?: TitleStatus | null;
}) {
function QuickAddButton({ id, userStatus }: { id: string; userStatus?: TitleStatus | null }) {
const { t } = useLingui();
const statusConfig = useStatusConfig();
const [addedStatus, setAddedStatus] = useState<TitleStatus | null>(
userStatus ?? null,
);
const [addedStatus, setAddedStatus] = useState<TitleStatus | null>(userStatus ?? null);
// Sync local state when prop changes (e.g. after navigation or SWR revalidation)
useEffect(() => {
@@ -139,9 +128,7 @@ function QuickAddButton({
render={<button type="button" />}
>
{!quickAddMutation.isPending && <IconPlus className="size-4" />}
{quickAddMutation.isPending && (
<IconLoader className="size-4 animate-spin" />
)}
{quickAddMutation.isPending && <IconLoader className="size-4 animate-spin" />}
</TooltipTrigger>
<TooltipContent side="bottom">{t`Add to Watchlist`}</TooltipContent>
</Tooltip>
@@ -158,13 +145,11 @@ function ProgressBar({ watched, total }: { watched: number; total: number }) {
render={<div />}
>
<div
className="h-full bg-status-watching transition-[width] duration-500 ease-out"
className="bg-status-watching h-full transition-[width] duration-500 ease-out"
style={{ width: `${pct}%` }}
/>
</TooltipTrigger>
<TooltipContent side="top">
{t`${watched}/${total} episodes`}
</TooltipContent>
<TooltipContent side="top">{t`${watched}/${total} episodes`}</TooltipContent>
</Tooltip>
);
}
@@ -185,16 +170,14 @@ function CardInner({
const TypeIcon = type === "movie" ? IconMovie : IconDeviceTv;
const placeholderUrl = thumbHashToUrl(posterThumbHash);
const ringClass = userStatus
? "ring-primary/25 shadow-sm shadow-primary/5"
: "ring-white/[0.06]";
const ringClass = userStatus ? "ring-primary/25 shadow-sm shadow-primary/5" : "ring-white/[0.06]";
return (
<div
className={`relative overflow-hidden rounded-xl bg-card ring-1 transition-[box-shadow,ring-color] duration-200 ease-out hover:shadow-lg hover:shadow-primary/5 hover:ring-primary/25 ${ringClass}`}
className={`bg-card hover:shadow-primary/5 hover:ring-primary/25 relative overflow-hidden rounded-xl ring-1 transition-[box-shadow,ring-color] duration-200 ease-out hover:shadow-lg ${ringClass}`}
>
<div
className="aspect-[2/3] overflow-hidden bg-card"
className="bg-card aspect-[2/3] overflow-hidden"
style={
placeholderUrl
? {
@@ -217,14 +200,14 @@ function CardInner({
/>
</motion.div>
) : (
<div className="relative flex h-full items-center justify-center overflow-hidden bg-gradient-to-br from-card via-secondary to-muted">
<div className="from-card via-secondary to-muted relative flex h-full items-center justify-center overflow-hidden bg-gradient-to-br">
<div
className="pointer-events-none absolute inset-0 opacity-[0.06]"
style={{
backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E")`,
}}
/>
<div className="absolute inset-0 bg-gradient-to-t from-primary/10 via-transparent to-transparent" />
<div className="from-primary/10 absolute inset-0 bg-gradient-to-t via-transparent to-transparent" />
<div className="relative px-3 text-center">
<p className="font-display text-foreground/70 text-sm leading-snug tracking-tight">
{title}
@@ -264,18 +247,13 @@ function CardInner({
<TooltipContent>{statusConfig[userStatus].label}</TooltipContent>
</Tooltip>
)}
<p className="line-clamp-1 font-medium text-sm leading-snug">
{title}
</p>
<p className="line-clamp-1 text-sm leading-snug font-medium">{title}</p>
</div>
<div className="mt-1.5 flex items-center gap-2 text-muted-foreground text-xs">
<TypeIcon
aria-hidden={true}
className="size-3.5 shrink-0 text-primary/60"
/>
<div className="text-muted-foreground mt-1.5 flex items-center gap-2 text-xs">
<TypeIcon aria-hidden={true} className="text-primary/60 size-3.5 shrink-0" />
{year && <span>{year}</span>}
{voteAverage != null && voteAverage > 0 && (
<span className="ml-auto flex items-center gap-0.5 text-primary/80">
<span className="text-primary/80 ml-auto flex items-center gap-0.5">
<IconStarFilled aria-hidden={true} className="size-[11px]" />
{voteAverage.toFixed(1)}
</span>
@@ -284,10 +262,7 @@ function CardInner({
</div>
{episodeProgress && episodeProgress.watched > 0 && (
<ProgressBar
watched={episodeProgress.watched}
total={episodeProgress.total}
/>
<ProgressBar watched={episodeProgress.watched} total={episodeProgress.total} />
)}
</div>
);
@@ -1,10 +1,10 @@
import { Trans, useLingui } from "@lingui/react/macro";
import type { CastMember } from "@sofa/api/schemas";
import { IconUser, IconUsers } from "@tabler/icons-react";
import { Link } from "@tanstack/react-router";
import { ScrollArea } from "@/components/ui/scroll-area";
import { thumbHashToUrl } from "@/lib/thumbhash";
import type { CastMember } from "@sofa/api/schemas";
interface CastCarouselProps {
actors: CastMember[];
@@ -16,7 +16,7 @@ export function CastCarousel({ actors, titleType }: CastCarouselProps) {
return (
<section className="space-y-4">
<div className="flex items-center gap-2">
<IconUsers aria-hidden={true} className="size-5 text-primary" />
<IconUsers aria-hidden={true} className="text-primary size-5" />
<h2 className="font-display text-xl tracking-tight">
<Trans>Cast</Trans>
</h2>
@@ -37,7 +37,7 @@ export function CastCarousel({ actors, titleType }: CastCarouselProps) {
className="group flex flex-col items-center gap-2"
>
<div
className="size-20 overflow-hidden rounded-full ring-1 ring-white/10 transition-all group-hover:ring-primary/25 sm:size-24"
className="group-hover:ring-primary/25 size-20 overflow-hidden rounded-full ring-1 ring-white/10 transition-all sm:size-24"
style={
member.profileThumbHash
? {
@@ -58,25 +58,23 @@ export function CastCarousel({ actors, titleType }: CastCarouselProps) {
className="h-full w-full object-cover motion-safe:transition-transform motion-safe:group-hover:scale-105"
/>
) : (
<div className="flex h-full w-full items-center justify-center bg-gradient-to-br from-muted to-muted/50">
<div className="from-muted to-muted/50 flex h-full w-full items-center justify-center bg-gradient-to-br">
<IconUser
aria-hidden={true}
className="size-8 text-muted-foreground/50"
className="text-muted-foreground/50 size-8"
/>
</div>
)}
</div>
<div className="w-full text-center">
<p className="truncate font-medium text-xs">
{member.name}
</p>
<p className="truncate text-xs font-medium">{member.name}</p>
{member.character && (
<p className="truncate text-[10px] text-muted-foreground">
<p className="text-muted-foreground truncate text-[10px]">
{member.character}
</p>
)}
{titleType === "tv" && member.episodeCount && (
<p className="text-[10px] text-muted-foreground/70">
<p className="text-muted-foreground/70 text-[10px]">
{t`${member.episodeCount} ep${member.episodeCount !== 1 ? "s" : ""}`}
</p>
)}
@@ -1,8 +1,4 @@
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
export function GenreCollapse({ genres }: { genres: string[] }) {
if (genres.length === 0) return null;
@@ -18,17 +14,14 @@ export function GenreCollapse({ genres }: { genres: string[] }) {
openOnHover
delay={0}
closeDelay={300}
className="cursor-default text-muted-foreground/70 transition-colors hover:text-muted-foreground"
className="text-muted-foreground/70 hover:text-muted-foreground cursor-default transition-colors"
aria-label={`${remaining.length} more genre${remaining.length > 1 ? "s" : ""}`}
>
+{remaining.length}
</PopoverTrigger>
<PopoverContent className="flex w-auto min-w-28 max-w-48 flex-col gap-0 p-1">
<PopoverContent className="flex w-auto max-w-48 min-w-28 flex-col gap-0 p-1">
{remaining.map((genre) => (
<span
key={genre}
className="px-2 py-1 text-[13px] text-popover-foreground"
>
<span key={genre} className="text-popover-foreground px-2 py-1 text-[13px]">
{genre}
</span>
))}
@@ -37,9 +37,7 @@ export function StarRating({ value, onChange }: StarRatingProps) {
className="p-0.5"
whileHover={{ scale: 1.15 }}
whileTap={{ scale: 0.9 }}
animate={
filled && star === value ? { scale: [1, 1.25, 1] } : { scale: 1 }
}
animate={filled && star === value ? { scale: [1, 1.25, 1] } : { scale: 1 }}
transition={
filled && star === value
? { type: "tween", duration: 0.3, ease: "easeInOut" }
@@ -47,9 +45,9 @@ export function StarRating({ value, onChange }: StarRatingProps) {
}
>
{filled ? (
<IconStarFilled className="size-4.5 text-primary" />
<IconStarFilled className="text-primary size-4.5" />
) : (
<IconStar className="size-4.5 text-muted-foreground/30" />
<IconStar className="text-muted-foreground/30 size-4.5" />
)}
</motion.button>
);
@@ -1,10 +1,5 @@
import { useLingui } from "@lingui/react/macro";
import {
IconCheck,
IconPlayerPlayFilled,
IconPlus,
IconX,
} from "@tabler/icons-react";
import { IconCheck, IconPlayerPlayFilled, IconPlus, IconX } from "@tabler/icons-react";
import { AnimatePresence, motion } from "motion/react";
interface StatusButtonProps {
@@ -35,8 +30,7 @@ export function StatusButton({ currentStatus, onChange }: StatusButtonProps) {
},
} as const;
const config =
statusConfig[currentStatus as keyof typeof statusConfig] ?? null;
const config = statusConfig[currentStatus as keyof typeof statusConfig] ?? null;
return (
<AnimatePresence mode="wait" initial={false}>
@@ -49,7 +43,7 @@ export function StatusButton({ currentStatus, onChange }: StatusButtonProps) {
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
transition={{ duration: 0.15 }}
className="inline-flex h-9 items-center gap-2 rounded-lg bg-primary/10 px-4 font-medium text-primary text-sm ring-1 ring-primary/20 transition-all hover:bg-primary/15 hover:ring-primary/30 active:scale-[0.97]"
className="bg-primary/10 text-primary ring-primary/20 hover:bg-primary/15 hover:ring-primary/30 inline-flex h-9 items-center gap-2 rounded-lg px-4 text-sm font-medium ring-1 transition-all active:scale-[0.97]"
>
<IconPlus aria-hidden={true} className="size-3.5" strokeWidth={2.5} />
{t`Watchlist`}
@@ -64,7 +58,7 @@ export function StatusButton({ currentStatus, onChange }: StatusButtonProps) {
exit={{ opacity: 0, y: -4 }}
transition={{ duration: 0.15 }}
title={t`Remove from library`}
className={`group inline-flex h-9 items-center gap-2 rounded-lg px-4 font-medium text-sm ring-1 transition-all active:scale-[0.97] ${config.class} ${config.bgClass} ${config.borderClass} hover:!bg-destructive/10 hover:!text-destructive hover:!ring-destructive/30`}
className={`group inline-flex h-9 items-center gap-2 rounded-lg px-4 text-sm font-medium ring-1 transition-all active:scale-[0.97] ${config.class} ${config.bgClass} ${config.borderClass} hover:!bg-destructive/10 hover:!text-destructive hover:!ring-destructive/30`}
>
<span className="grid [&>svg]:col-start-1 [&>svg]:row-start-1">
<config.icon
@@ -73,13 +67,11 @@ export function StatusButton({ currentStatus, onChange }: StatusButtonProps) {
/>
<IconX
aria-hidden={true}
className="size-3.5 text-destructive opacity-0 transition-opacity group-hover:opacity-100"
className="text-destructive size-3.5 opacity-0 transition-opacity group-hover:opacity-100"
/>
</span>
<span className="grid [&>span]:col-start-1 [&>span]:row-start-1">
<span className="transition-opacity group-hover:opacity-0">
{config.label}
</span>
<span className="transition-opacity group-hover:opacity-0">{config.label}</span>
<span className="opacity-0 transition-opacity group-hover:opacity-100">
{t`Remove`}
</span>
@@ -1,7 +1,9 @@
import { Trans } from "@lingui/react/macro";
import { IconCheck } from "@tabler/icons-react";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { StarRating } from "./star-rating";
import { StatusButton } from "./status-button";
import { useTitleContext, useTitleUserInfo } from "./title-context";
@@ -10,29 +12,22 @@ import { useTitleActions } from "./use-title-actions";
export function TitleActions() {
const { titleType } = useTitleContext();
const { userStatus, userRating } = useTitleUserInfo();
const { handleStatusChange, handleRating, handleWatchMovie } =
useTitleActions();
const { handleStatusChange, handleRating, handleWatchMovie } = useTitleActions();
return (
<div className="flex flex-wrap items-center gap-3">
<StatusButton
currentStatus={userStatus ?? null}
onChange={handleStatusChange}
/>
<StatusButton currentStatus={userStatus ?? null} onChange={handleStatusChange} />
{titleType === "movie" && (
<Button
onClick={handleWatchMovie}
size="lg"
className="h-9 rounded-lg px-4 text-sm hover:shadow-md hover:shadow-primary/20 active:scale-[0.97]"
className="hover:shadow-primary/20 h-9 rounded-lg px-4 text-sm hover:shadow-md active:scale-[0.97]"
>
<IconCheck aria-hidden={true} className="size-3.5" />
<Trans>Mark Watched</Trans>
</Button>
)}
<Separator
orientation="vertical"
className="mx-0.5 my-auto h-6 bg-border/50"
/>
<Separator orientation="vertical" className="bg-border/50 mx-0.5 my-auto h-6" />
<StarRating value={userRating ?? 0} onChange={handleRating} />
</div>
);
@@ -1,16 +1,8 @@
import { Trans, useLingui } from "@lingui/react/macro";
import type { AvailabilityOffer } from "@sofa/api/schemas";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import type { AvailabilityOffer } from "@sofa/api/schemas";
const MAX_VISIBLE = 4;
@@ -31,13 +23,10 @@ function ProviderBadge({
<TooltipTrigger
{...(watchUrl
? {
render: (
// biome-ignore lint/a11y/useAnchorContent: content is provided conditionally below
<a href={watchUrl} target="_blank" rel="noopener noreferrer" />
),
render: <a href={watchUrl} target="_blank" rel="noopener noreferrer" />,
}
: {})}
className={`flex h-10 w-10 items-center justify-center overflow-hidden rounded-lg border border-border/30 bg-card motion-safe:transition-transform motion-safe:hover:scale-105${watchUrl ? "" : "cursor-default"}`}
className={`border-border/30 bg-card flex h-10 w-10 items-center justify-center overflow-hidden rounded-lg border motion-safe:transition-transform motion-safe:hover:scale-105${watchUrl ? "" : "cursor-default"}`}
>
{logoPath ? (
<img
@@ -50,12 +39,10 @@ function ProviderBadge({
className="h-full w-full object-cover"
/>
) : (
<span className="font-medium text-[8px] text-muted-foreground">
{name.slice(0, 2)}
</span>
<span className="text-muted-foreground text-[8px] font-medium">{name.slice(0, 2)}</span>
)}
</TooltipTrigger>
<TooltipContent className="bg-popover px-2 py-1 font-medium text-[10px] text-popover-foreground shadow-md [&>:last-child]:hidden">
<TooltipContent className="bg-popover text-popover-foreground px-2 py-1 text-[10px] font-medium shadow-md [&>:last-child]:hidden">
{watchUrl ? t`Watch on ${name}` : name}
</TooltipContent>
</Tooltip>
@@ -64,7 +51,7 @@ function ProviderBadge({
function OverflowProviderIcon({ offer }: { offer: AvailabilityOffer }) {
return (
<div className="flex h-7 w-7 shrink-0 items-center justify-center overflow-hidden rounded-md border border-border/20 bg-card">
<div className="border-border/20 bg-card flex h-7 w-7 shrink-0 items-center justify-center overflow-hidden rounded-md border">
{offer.logoPath ? (
<img
src={offer.logoPath}
@@ -76,7 +63,7 @@ function OverflowProviderIcon({ offer }: { offer: AvailabilityOffer }) {
className="h-7 w-7 object-cover"
/>
) : (
<span className="font-medium text-[7px] text-muted-foreground">
<span className="text-muted-foreground text-[7px] font-medium">
{offer.providerName.slice(0, 2)}
</span>
)}
@@ -91,11 +78,11 @@ function OverflowBadge({ offers }: { offers: AvailabilityOffer[] }) {
openOnHover
delay={0}
closeDelay={300}
className="flex h-10 w-10 cursor-default items-center justify-center rounded-lg border border-border/30 bg-card font-semibold text-muted-foreground text-xs motion-safe:transition-transform motion-safe:hover:scale-105"
className="border-border/30 bg-card text-muted-foreground flex h-10 w-10 cursor-default items-center justify-center rounded-lg border text-xs font-semibold motion-safe:transition-transform motion-safe:hover:scale-105"
>
+{offers.length}
</PopoverTrigger>
<PopoverContent className="flex w-auto max-w-64 flex-col gap-0 divide-y divide-border/30 p-0.5">
<PopoverContent className="divide-border/30 flex w-auto max-w-64 flex-col gap-0 divide-y p-0.5">
{offers.map((offer) =>
offer.watchUrl ? (
<a
@@ -103,22 +90,15 @@ function OverflowBadge({ offers }: { offers: AvailabilityOffer[] }) {
href={offer.watchUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2.5 px-2 py-1.5 hover:bg-muted/50"
className="hover:bg-muted/50 flex items-center gap-2.5 px-2 py-1.5"
>
<OverflowProviderIcon offer={offer} />
<span className="truncate text-popover-foreground text-xs">
{offer.providerName}
</span>
<span className="text-popover-foreground truncate text-xs">{offer.providerName}</span>
</a>
) : (
<div
key={offer.providerId}
className="flex items-center gap-2.5 px-2 py-1.5"
>
<div key={offer.providerId} className="flex items-center gap-2.5 px-2 py-1.5">
<OverflowProviderIcon offer={offer} />
<span className="truncate text-popover-foreground text-xs">
{offer.providerName}
</span>
<span className="text-popover-foreground truncate text-xs">{offer.providerName}</span>
</div>
),
)}
@@ -127,11 +107,7 @@ function OverflowBadge({ offers }: { offers: AvailabilityOffer[] }) {
);
}
export function TitleAvailability({
availability,
}: {
availability: AvailabilityOffer[];
}) {
export function TitleAvailability({ availability }: { availability: AvailabilityOffer[] }) {
const { t } = useLingui();
const offerLabels: Record<string, string> = {
flatrate: t`Stream`,
@@ -150,7 +126,7 @@ export function TitleAvailability({
return (
<div className="space-y-2 pt-1">
<h2 className="font-semibold text-muted-foreground text-xs uppercase tracking-wider">
<h2 className="text-muted-foreground text-xs font-semibold tracking-wider uppercase">
<Trans>Where to Watch</Trans>
</h2>
<div className="flex flex-wrap gap-4">
@@ -160,7 +136,7 @@ export function TitleAvailability({
return (
<div key={type} className="space-y-1.5">
<span className="font-medium text-[10px] text-muted-foreground/60 uppercase tracking-wider">
<span className="text-muted-foreground/60 text-[10px] font-medium tracking-wider uppercase">
{offerLabels[type] ?? type}
</span>
<div className="flex gap-1.5">
@@ -1,4 +1,5 @@
import type { CastMember } from "@sofa/api/schemas";
import { CastCarousel } from "./cast-carousel";
interface TitleCastProps {
@@ -1,8 +1,9 @@
import type { Season } from "@sofa/api/schemas";
import { useQuery } from "@tanstack/react-query";
import { createContext, use } from "react";
import { useSession } from "@/lib/auth/client";
import { orpc } from "@/lib/orpc/client";
import type { Season } from "@sofa/api/schemas";
interface TitleContextValue {
titleId: string;
@@ -18,8 +19,7 @@ export const TitleContext = createContext<TitleContextValue | null>(null);
export function useTitleContext() {
const ctx = use(TitleContext);
if (!ctx)
throw new Error("useTitleContext must be used within TitleProvider");
if (!ctx) throw new Error("useTitleContext must be used within TitleProvider");
return ctx;
}
+16 -25
View File
@@ -1,5 +1,4 @@
import { Trans } from "@lingui/react/macro";
import type { ColorPalette, ResolvedTitle } from "@sofa/api/schemas";
import {
IconCalendarEvent,
IconCircleCheck,
@@ -15,6 +14,8 @@ import type { ReactNode } from "react";
import { ExpandableText } from "@/components/expandable-text";
import { TmdbLogo } from "@/components/tmdb-logo";
import { thumbHashToUrl } from "@/lib/thumbhash";
import type { ColorPalette, ResolvedTitle } from "@sofa/api/schemas";
import { GenreCollapse } from "./genre-collapse";
import { TrailerDialog } from "./trailer-dialog";
@@ -55,10 +56,10 @@ export function TitleHero({
decoding="async"
className="absolute inset-0 h-full w-full object-cover"
/>
<div className="absolute inset-0 bg-gradient-to-t from-background via-background/70 to-background/30" />
<div className="absolute inset-0 bg-gradient-to-r from-background/90 via-background/40 to-transparent" />
<div className="absolute inset-0 bg-gradient-to-b from-background/50 via-transparent to-transparent" />
<div className="absolute inset-0 bg-background/15" />
<div className="from-background via-background/70 to-background/30 absolute inset-0 bg-gradient-to-t" />
<div className="from-background/90 via-background/40 absolute inset-0 bg-gradient-to-r to-transparent" />
<div className="from-background/50 absolute inset-0 bg-gradient-to-b via-transparent to-transparent" />
<div className="bg-background/15 absolute inset-0" />
{palette?.darkMuted && (
<div
className="absolute inset-0 opacity-40 mix-blend-multiply"
@@ -99,7 +100,7 @@ export function TitleHero({
{title.posterPath && (
<div className="shrink-0 self-center md:self-start">
<div
className="overflow-hidden rounded-xl shadow-2xl ring-1 ring-foreground/5 transition-shadow duration-500 md:rounded-2xl"
className="ring-foreground/5 overflow-hidden rounded-xl shadow-2xl ring-1 transition-shadow duration-500 md:rounded-2xl"
style={{
boxShadow: palette?.darkVibrant
? `0 25px 60px -12px ${palette.darkVibrant}50, 0 12px 28px -8px rgba(0,0,0,0.5)`
@@ -127,28 +128,22 @@ export function TitleHero({
<div className="flex-1 space-y-5">
<div className="space-y-1.5">
<h1 className="text-balance font-display text-2xl tracking-tight md:text-5xl">
<h1 className="font-display text-2xl tracking-tight text-balance md:text-5xl">
{title.title}
</h1>
{/* Desktop: single row with dot separators */}
<div className="flex flex-wrap items-center gap-x-3.5 gap-y-2 text-muted-foreground text-sm md:gap-x-5">
<div className="inline-flex cursor-default items-center justify-center gap-1.5 rounded bg-primary/10 px-1 py-1 font-medium text-primary text-xs md:px-1.5">
<div className="text-muted-foreground flex flex-wrap items-center gap-x-3.5 gap-y-2 text-sm md:gap-x-5">
<div className="bg-primary/10 text-primary inline-flex cursor-default items-center justify-center gap-1.5 rounded px-1 py-1 text-xs font-medium md:px-1.5">
{title.type === "movie" ? (
<>
<IconMovie
aria-hidden
className="size-3.5 translate-y-[-0.5px]"
/>
<IconMovie aria-hidden className="size-3.5 translate-y-[-0.5px]" />
<span className="hidden md:inline">
<Trans>Movie</Trans>
</span>
</>
) : (
<>
<IconDeviceTv
aria-hidden
className="size-3.5 translate-y-[-0.5px]"
/>
<IconDeviceTv aria-hidden className="size-3.5 translate-y-[-0.5px]" />
<span className="hidden md:inline">
<Trans>TV</Trans>
</span>
@@ -156,25 +151,21 @@ export function TitleHero({
)}
</div>
{title.contentRating && (
<div className="inline-flex border border-muted-foreground/50 px-1.5 font-medium text-[13px]">
<div className="border-muted-foreground/50 inline-flex border px-1.5 text-[13px] font-medium">
{title.contentRating}
</div>
)}
{year && <span>{year}</span>}
{title.genres.length > 0 && (
<GenreCollapse genres={title.genres} />
)}
{title.genres.length > 0 && <GenreCollapse genres={title.genres} />}
{title.voteAverage != null && title.voteAverage > 0 && (
<span className="inline-flex items-center gap-1 text-primary">
<span className="text-primary inline-flex items-center gap-1">
<IconStarFilled className="size-3.5 translate-y-[-0.5px]" />
{title.voteAverage.toFixed(1)}
</span>
)}
{title.status &&
!(title.type === "movie" && title.status === "Released") &&
!(
title.type === "tv" && title.status === "Returning Series"
) && (
!(title.type === "tv" && title.status === "Returning Series") && (
<span className="inline-flex items-center gap-1">
<StatusIcon status={title.status} />
{title.status}
@@ -1,8 +1,10 @@
import type { Hotkey } from "@tanstack/react-hotkeys";
import { useHotkey } from "@tanstack/react-hotkeys";
import { useAtomValue } from "jotai";
import { useProgress } from "@/components/navigation-progress";
import { commandPaletteOpenAtom } from "@/lib/atoms/command-palette";
import { useTitleContext, useTitleUserInfo } from "./title-context";
import { useTitleActions } from "./use-title-actions";
@@ -10,8 +12,7 @@ export function TitleKeyboardShortcuts() {
const progress = useProgress();
const { titleType } = useTitleContext();
const { userStatus } = useTitleUserInfo();
const { handleStatusChange, handleRating, handleWatchMovie } =
useTitleActions();
const { handleStatusChange, handleRating, handleWatchMovie } = useTitleActions();
const commandPaletteOpen = useAtomValue(commandPaletteOpenAtom);
const enabled = !commandPaletteOpen;
@@ -37,7 +38,6 @@ export function TitleKeyboardShortcuts() {
);
for (const n of [1, 2, 3, 4, 5]) {
// biome-ignore lint/correctness/useHookAtTopLevel: loop is stable (always 5 iterations)
useHotkey(String(n) as Hotkey, () => handleRating(n), { enabled });
}
@@ -1,5 +1,7 @@
import type { Season } from "@sofa/api/schemas";
import { useState } from "react";
import type { Season } from "@sofa/api/schemas";
import { TitleContext } from "./title-context";
export function TitleProvider({
@@ -1,6 +1,7 @@
import { Trans } from "@lingui/react/macro";
import { IconThumbUp } from "@tabler/icons-react";
import { useQuery } from "@tanstack/react-query";
import { TitleCard, TitleCardSkeleton } from "@/components/title-card";
import { Skeleton } from "@/components/ui/skeleton";
import { orpc } from "@/lib/orpc/client";
@@ -35,7 +36,7 @@ export function TitleRecommendations({ titleId }: { titleId: string }) {
return (
<div className="space-y-4">
<div className="flex items-center gap-2">
<IconThumbUp aria-hidden={true} className="size-5 text-primary" />
<IconThumbUp aria-hidden={true} className="text-primary size-5" />
<h2 className="font-display text-xl tracking-tight">
<Trans>Recommended</Trans>
</h2>
@@ -1,6 +1,4 @@
import { Trans } from "@lingui/react/macro";
import type { Season } from "@sofa/api/schemas";
import { formatShortDate } from "@sofa/i18n/format";
import {
IconCheck,
IconChecks,
@@ -9,8 +7,8 @@ import {
IconDeviceTvOld,
} from "@tabler/icons-react";
import { AnimatePresence, motion } from "motion/react";
import { useEffect, useMemo, useState } from "react";
import {
AlertDialog,
AlertDialogAction,
@@ -25,6 +23,9 @@ import {
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { Skeleton } from "@/components/ui/skeleton";
import type { Season } from "@sofa/api/schemas";
import { formatShortDate } from "@sofa/i18n/format";
import { useTitleContext, useTitleUserInfo } from "./title-context";
import { useTitleActions } from "./use-title-actions";
@@ -37,10 +38,7 @@ export function SeasonsSkeleton() {
</div>
<div className="space-y-2">
{["s1", "s2", "s3"].map((id) => (
<div
key={id}
className="overflow-hidden rounded-xl border border-border/50 bg-card/50"
>
<div key={id} className="border-border/50 bg-card/50 overflow-hidden rounded-xl border">
<div className="flex items-center justify-between p-4">
<Skeleton className="h-4 w-24" />
<div className="flex items-center gap-3">
@@ -72,12 +70,8 @@ export function TitleSeasons({
}, [streamedSeasons, setSeasons]);
const watchedSet = useMemo(() => new Set(episodeWatches), [episodeWatches]);
const {
handleWatchEpisode,
handleMarkSeason,
handleUnmarkSeason,
handleMarkAllWatched,
} = useTitleActions();
const { handleWatchEpisode, handleMarkSeason, handleUnmarkSeason, handleMarkAllWatched } =
useTitleActions();
const seasonProgress = useMemo(() => {
const map = new Map<string, number>();
for (const season of seasons) {
@@ -97,7 +91,7 @@ export function TitleSeasons({
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<IconDeviceTvOld aria-hidden={true} className="size-5 text-primary" />
<IconDeviceTvOld aria-hidden={true} className="text-primary size-5" />
<h2 className="font-display text-2xl tracking-tight">
<Trans>Episodes</Trans>
</h2>
@@ -109,7 +103,7 @@ export function TitleSeasons({
<Button
variant="ghost"
size="xs"
className="text-muted-foreground uppercase tracking-wider"
className="text-muted-foreground tracking-wider uppercase"
>
<IconChecks aria-hidden={true} className="size-3.5" />
<Trans>Mark All Watched</Trans>
@@ -123,8 +117,8 @@ export function TitleSeasons({
</AlertDialogTitle>
<AlertDialogDescription>
<Trans>
This will mark every episode of this show as watched. You
can undo this later by unmarking individual seasons.
This will mark every episode of this show as watched. You can undo this later by
unmarking individual seasons.
</Trans>
</AlertDialogDescription>
</AlertDialogHeader>
@@ -150,28 +144,24 @@ export function TitleSeasons({
const isOpen = openSeason === season.seasonNumber;
const watchedCount = seasonProgress.get(season.id) ?? 0;
const totalCount = season.episodes.length;
const progressPercent =
totalCount > 0 ? (watchedCount / totalCount) * 100 : 0;
const progressPercent = totalCount > 0 ? (watchedCount / totalCount) * 100 : 0;
return (
<div
key={season.id}
className="overflow-hidden rounded-xl border border-border/50 bg-card/50"
className="border-border/50 bg-card/50 overflow-hidden rounded-xl border"
>
{/* biome-ignore lint/a11y/useSemanticElements: contains nested buttons */}
<div
role="button"
tabIndex={0}
onClick={() =>
setOpenSeason(isOpen ? null : season.seasonNumber)
}
onClick={() => setOpenSeason(isOpen ? null : season.seasonNumber)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setOpenSeason(isOpen ? null : season.seasonNumber);
}
}}
className="group/season flex w-full cursor-pointer items-center justify-between p-4 text-left transition-colors hover:bg-accent/50"
className="group/season hover:bg-accent/50 flex w-full cursor-pointer items-center justify-between p-4 text-left transition-colors"
>
<div className="flex items-center gap-3">
<span className="font-medium">
@@ -192,12 +182,9 @@ export function TitleSeasons({
e.stopPropagation();
handleMarkSeason(season);
}}
className="text-primary uppercase tracking-wider hover:bg-primary/10 hover:text-primary sm:hidden sm:w-24 sm:group-hover/season:block"
className="text-primary hover:bg-primary/10 hover:text-primary tracking-wider uppercase sm:hidden sm:w-24 sm:group-hover/season:block"
>
<IconChecks
aria-hidden={true}
className="size-3.5 sm:hidden"
/>
<IconChecks aria-hidden={true} className="size-3.5 sm:hidden" />
<span className="hidden sm:inline">
<Trans>Watch all</Trans>
</span>
@@ -211,32 +198,23 @@ export function TitleSeasons({
e.stopPropagation();
handleUnmarkSeason(season);
}}
className="text-muted-foreground uppercase tracking-wider hover:bg-destructive/10 hover:text-destructive sm:hidden sm:w-24 sm:group-hover/season:block"
className="text-muted-foreground hover:bg-destructive/10 hover:text-destructive tracking-wider uppercase sm:hidden sm:w-24 sm:group-hover/season:block"
>
<IconChecks
aria-hidden={true}
className="size-3.5 sm:hidden"
/>
<IconChecks aria-hidden={true} className="size-3.5 sm:hidden" />
<span className="hidden sm:inline">
<Trans>Unwatch all</Trans>
</span>
</Button>
)}
{totalCount > 0 && (
<span className="font-mono text-muted-foreground text-xs tabular-nums">
<span className="text-muted-foreground font-mono text-xs tabular-nums">
{watchedCount}/{totalCount}
</span>
)}
{isOpen ? (
<IconChevronUp
aria-hidden={true}
className="size-4 text-muted-foreground"
/>
<IconChevronUp aria-hidden={true} className="text-muted-foreground size-4" />
) : (
<IconChevronDown
aria-hidden={true}
className="size-4 text-muted-foreground"
/>
<IconChevronDown aria-hidden={true} className="text-muted-foreground size-4" />
)}
</div>
</div>
@@ -252,7 +230,7 @@ export function TitleSeasons({
stiffness: 300,
damping: 30,
}}
className="overflow-hidden border-border/50 border-t"
className="border-border/50 overflow-hidden border-t"
>
{season.episodes.map((ep) => {
const isWatched = watchedSet.has(ep.id);
@@ -264,7 +242,7 @@ export function TitleSeasons({
>
{/* Mobile: still banner above episode info */}
{stillPath && (
<div className="relative aspect-video w-full overflow-hidden bg-muted sm:hidden">
<div className="bg-muted relative aspect-video w-full overflow-hidden sm:hidden">
<img
src={stillPath}
alt={ep.name ?? ""}
@@ -315,7 +293,7 @@ export function TitleSeasons({
</button>
{/* Desktop: inline thumbnail */}
{stillPath && (
<div className="hidden h-14 w-24 shrink-0 overflow-hidden rounded-md bg-muted sm:block">
<div className="bg-muted hidden h-14 w-24 shrink-0 overflow-hidden rounded-md sm:block">
<img
src={stillPath}
alt={ep.name ?? ""}
@@ -331,7 +309,7 @@ export function TitleSeasons({
<p className="text-sm">
{/* Episode number shown inline on desktop, or mobile without still */}
<span
className={`font-mono text-muted-foreground text-xs ${stillPath ? "hidden sm:inline" : ""}`}
className={`text-muted-foreground font-mono text-xs ${stillPath ? "hidden sm:inline" : ""}`}
>
E{String(ep.episodeNumber).padStart(2, "0")}
</span>
@@ -343,12 +321,10 @@ export function TitleSeasons({
<p className="text-muted-foreground text-xs">
{ep.airDate ? formatShortDate(ep.airDate) : ""}
{ep.airDate && ep.runtimeMinutes ? " · " : ""}
{ep.runtimeMinutes
? `${ep.runtimeMinutes}m`
: ""}
{ep.runtimeMinutes ? `${ep.runtimeMinutes}m` : ""}
</p>
{ep.overview && (
<p className="mt-1 line-clamp-2 text-muted-foreground/70 text-xs leading-relaxed">
<p className="text-muted-foreground/70 mt-1 line-clamp-2 text-xs leading-relaxed">
{ep.overview}
</p>
)}
@@ -1,6 +1,7 @@
import { useLingui } from "@lingui/react/macro";
import { IconPlayerPlayFilled } from "@tabler/icons-react";
import { lazy, Suspense, useState } from "react";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
const YoutubeVideo = lazy(() => import("youtube-video-element/react"));
@@ -31,7 +32,7 @@ export function TrailerDialog({
<button
type="button"
onClick={() => setOpen(true)}
className="inline-flex h-5 items-center gap-1 rounded border border-border/50 px-2 text-muted-foreground text-xs transition-colors hover:border-border hover:text-foreground"
className="border-border/50 text-muted-foreground hover:border-border hover:text-foreground inline-flex h-5 items-center gap-1 rounded border px-2 text-xs transition-colors"
>
<IconPlayerPlayFilled aria-hidden={true} className="h-2.5 w-2.5" />
{t`Trailer`}
@@ -1,10 +1,12 @@
import { plural } from "@lingui/core/macro";
import { useLingui } from "@lingui/react/macro";
import type { Season } from "@sofa/api/schemas";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useCallback } from "react";
import { toast } from "sonner";
import { orpc } from "@/lib/orpc/client";
import type { Season } from "@sofa/api/schemas";
import { useTitleContext } from "./title-context";
type UserInfo = {
@@ -38,26 +40,14 @@ export function useTitleActions() {
[queryClient, userInfoKey],
);
const batchWatchMutation = useMutation(
orpc.episodes.batchWatch.mutationOptions(),
);
const updateStatusMutation = useMutation(
orpc.titles.updateStatus.mutationOptions(),
);
const updateRatingMutation = useMutation(
orpc.titles.updateRating.mutationOptions(),
);
const watchMovieMutation = useMutation(
orpc.titles.watchMovie.mutationOptions(),
);
const unwatchEpMutation = useMutation(
orpc.episodes.unwatch.mutationOptions(),
);
const batchWatchMutation = useMutation(orpc.episodes.batchWatch.mutationOptions());
const updateStatusMutation = useMutation(orpc.titles.updateStatus.mutationOptions());
const updateRatingMutation = useMutation(orpc.titles.updateRating.mutationOptions());
const watchMovieMutation = useMutation(orpc.titles.watchMovie.mutationOptions());
const unwatchEpMutation = useMutation(orpc.episodes.unwatch.mutationOptions());
const watchEpMutation = useMutation(orpc.episodes.watch.mutationOptions());
const watchSeasonMutation = useMutation(orpc.seasons.watch.mutationOptions());
const unwatchSeasonMutation = useMutation(
orpc.seasons.unwatch.mutationOptions(),
);
const unwatchSeasonMutation = useMutation(orpc.seasons.unwatch.mutationOptions());
const watchAllMutation = useMutation(orpc.titles.watchAll.mutationOptions());
const catchUp = useCallback(
@@ -98,10 +88,7 @@ export function useTitleActions() {
const prevStatus = getUserInfo().status;
setUserInfo((old) => ({
...old,
status:
status === "watchlist"
? "in_progress"
: (status as UserInfo["status"]),
status: status === "watchlist" ? "in_progress" : (status as UserInfo["status"]),
}));
try {
await updateStatusMutation.mutateAsync({
@@ -152,12 +139,7 @@ export function useTitleActions() {
}, [getUserInfo, setUserInfo, titleId, titleName, watchMovieMutation, t]);
const handleWatchEpisode = useCallback(
async (
episodeId: string,
seasonNum: number,
epNum: number,
isWatched: boolean,
) => {
async (episodeId: string, seasonNum: number, epNum: number, isWatched: boolean) => {
setWatchingEp(episodeId);
if (isWatched) {
@@ -191,10 +173,7 @@ export function useTitleActions() {
setUserInfo((old) => ({
...old,
episodeWatches: newWatches,
status:
old.status === null || old.status === "watchlist"
? "in_progress"
: old.status,
status: old.status === null || old.status === "watchlist" ? "in_progress" : old.status,
}));
try {
@@ -331,9 +310,7 @@ export function useTitleActions() {
try {
await unwatchSeasonMutation.mutateAsync({ id: season.id });
toast.success(
t`Unwatched all of ${season.name ?? t`Season ${season.seasonNumber}`}`,
);
toast.success(t`Unwatched all of ${season.name ?? t`Season ${season.seasonNumber}`}`);
} catch {
setUserInfo((old) => ({
...old,
+8 -18
View File
@@ -1,15 +1,13 @@
import { Accordion as AccordionPrimitive } from "@base-ui/react/accordion";
import { IconChevronDown, IconChevronUp } from "@tabler/icons-react";
import { cn } from "@/lib/utils";
function Accordion({ className, ...props }: AccordionPrimitive.Root.Props) {
return (
<AccordionPrimitive.Root
data-slot="accordion"
className={cn(
"flex w-full flex-col overflow-hidden rounded-md border",
className,
)}
className={cn("flex w-full flex-col overflow-hidden rounded-md border", className)}
{...props}
/>
);
@@ -19,23 +17,19 @@ function AccordionItem({ className, ...props }: AccordionPrimitive.Item.Props) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn("not-last:border-b data-open:bg-muted/50", className)}
className={cn("data-open:bg-muted/50 not-last:border-b", className)}
{...props}
/>
);
}
function AccordionTrigger({
className,
children,
...props
}: AccordionPrimitive.Trigger.Props) {
function AccordionTrigger({ className, children, ...props }: AccordionPrimitive.Trigger.Props) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"group/accordion-trigger relative flex flex-1 items-start justify-between gap-6 border border-transparent p-2 text-left font-medium text-xs/relaxed outline-none transition-all hover:underline aria-disabled:pointer-events-none aria-disabled:opacity-50 **:data-[slot=accordion-trigger-icon]:ml-auto **:data-[slot=accordion-trigger-icon]:size-4 **:data-[slot=accordion-trigger-icon]:text-muted-foreground",
"group/accordion-trigger **:data-[slot=accordion-trigger-icon]:text-muted-foreground relative flex flex-1 items-start justify-between gap-6 border border-transparent p-2 text-left text-xs/relaxed font-medium transition-all outline-none hover:underline aria-disabled:pointer-events-none aria-disabled:opacity-50 **:data-[slot=accordion-trigger-icon]:ml-auto **:data-[slot=accordion-trigger-icon]:size-4",
className,
)}
{...props}
@@ -54,20 +48,16 @@ function AccordionTrigger({
);
}
function AccordionContent({
className,
children,
...props
}: AccordionPrimitive.Panel.Props) {
function AccordionContent({ className, children, ...props }: AccordionPrimitive.Panel.Props) {
return (
<AccordionPrimitive.Panel
data-slot="accordion-content"
className="overflow-hidden px-2 text-xs/relaxed data-closed:animate-accordion-up data-open:animate-accordion-down"
className="data-closed:animate-accordion-up data-open:animate-accordion-down overflow-hidden px-2 text-xs/relaxed"
{...props}
>
<div
className={cn(
"h-(--accordion-panel-height) pt-0 pb-4 data-ending-style:h-0 data-starting-style:h-0 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
"[&_a]:hover:text-foreground h-(--accordion-panel-height) pt-0 pb-4 data-ending-style:h-0 data-starting-style:h-0 [&_a]:underline [&_a]:underline-offset-3 [&_p:not(:last-child)]:mb-4",
className,
)}
>
+14 -38
View File
@@ -1,5 +1,6 @@
import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog";
import type * as React from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
@@ -8,26 +9,19 @@ function AlertDialog({ ...props }: AlertDialogPrimitive.Root.Props) {
}
function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
);
return <AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />;
}
function AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
);
return <AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />;
}
function AlertDialogOverlay({
className,
...props
}: AlertDialogPrimitive.Backdrop.Props) {
function AlertDialogOverlay({ className, ...props }: AlertDialogPrimitive.Backdrop.Props) {
return (
<AlertDialogPrimitive.Backdrop
data-slot="alert-dialog-overlay"
className={cn(
"data-closed:fade-out-0 data-open:fade-in-0 fixed inset-0 isolate z-50 bg-black/80 duration-100 data-closed:animate-out data-open:animate-in supports-backdrop-filter:backdrop-blur-xs",
"data-closed:fade-out-0 data-open:fade-in-0 data-closed:animate-out data-open:animate-in fixed inset-0 isolate z-50 bg-black/80 duration-100 supports-backdrop-filter:backdrop-blur-xs",
className,
)}
{...props}
@@ -49,7 +43,7 @@ function AlertDialogContent({
data-slot="alert-dialog-content"
data-size={size}
className={cn(
"data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-3 rounded-xl bg-background p-4 outline-none ring-1 ring-foreground/10 duration-100 data-[size=default]:max-w-xs data-[size=sm]:max-w-64 data-closed:animate-out data-open:animate-in data-[size=default]:sm:max-w-sm",
"data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 group/alert-dialog-content bg-background ring-foreground/10 data-closed:animate-out data-open:animate-in fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-3 rounded-xl p-4 ring-1 duration-100 outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-64 data-[size=default]:sm:max-w-sm",
className,
)}
{...props}
@@ -58,10 +52,7 @@ function AlertDialogContent({
);
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
function AlertDialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
@@ -74,10 +65,7 @@ function AlertDialogHeader({
);
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
function AlertDialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
@@ -90,15 +78,12 @@ function AlertDialogFooter({
);
}
function AlertDialogMedia({
className,
...props
}: React.ComponentProps<"div">) {
function AlertDialogMedia({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-media"
className={cn(
"mb-2 inline-flex size-8 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-4",
"bg-muted mb-2 inline-flex size-8 items-center justify-center rounded-md sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
@@ -114,7 +99,7 @@ function AlertDialogTitle({
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn(
"font-medium text-sm sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
"text-sm font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
className,
)}
{...props}
@@ -130,7 +115,7 @@ function AlertDialogDescription({
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn(
"text-balance text-muted-foreground text-xs/relaxed md:text-pretty *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
"text-muted-foreground *:[a]:hover:text-foreground text-xs/relaxed text-balance md:text-pretty *:[a]:underline *:[a]:underline-offset-3",
className,
)}
{...props}
@@ -138,17 +123,8 @@ function AlertDialogDescription({
);
}
function AlertDialogAction({
className,
...props
}: React.ComponentProps<typeof Button>) {
return (
<Button
data-slot="alert-dialog-action"
className={cn(className)}
{...props}
/>
);
function AlertDialogAction({ className, ...props }: React.ComponentProps<typeof Button>) {
return <Button data-slot="alert-dialog-action" className={cn(className)} {...props} />;
}
function AlertDialogCancel({
+4 -7
View File
@@ -4,7 +4,7 @@ import type * as React from "react";
import { cn } from "@/lib/utils";
const alertVariants = cva(
"group/alert relative grid w-full gap-0.5 rounded-lg border px-2 py-1.5 text-left text-xs/relaxed has-data-[slot=alert-action]:relative has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-1.5 has-data-[slot=alert-action]:pr-18 *:[svg:not([class*='size-'])]:size-3.5 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current",
"group/alert relative grid w-full gap-0.5 rounded-lg border px-2 py-1.5 text-left text-xs/relaxed has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-1.5 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-3.5",
{
variants: {
variant: {
@@ -39,7 +39,7 @@ function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
<div
data-slot="alert-title"
className={cn(
"font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground",
"[&_a]:hover:text-foreground font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3",
className,
)}
{...props}
@@ -47,15 +47,12 @@ function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
);
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
function AlertDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-balance text-muted-foreground text-xs/relaxed md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
"text-muted-foreground [&_a]:hover:text-foreground text-xs/relaxed text-balance md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_p:not(:last-child)]:mb-4",
className,
)}
{...props}
+9 -25
View File
@@ -15,7 +15,7 @@ function Avatar({
data-slot="avatar"
data-size={size}
className={cn(
"group/avatar relative flex size-8 shrink-0 select-none rounded-full after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
"group/avatar after:border-border relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
className,
)}
{...props}
@@ -27,24 +27,18 @@ function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn(
"aspect-square size-full rounded-full object-cover",
className,
)}
className={cn("aspect-square size-full rounded-full object-cover", className)}
{...props}
/>
);
}
function AvatarFallback({
className,
...props
}: AvatarPrimitive.Fallback.Props) {
function AvatarFallback({ className, ...props }: AvatarPrimitive.Fallback.Props) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"flex size-full items-center justify-center rounded-full bg-muted text-muted-foreground text-sm group-data-[size=sm]/avatar:text-xs",
"bg-muted text-muted-foreground flex size-full items-center justify-center rounded-full text-sm group-data-[size=sm]/avatar:text-xs",
className,
)}
{...props}
@@ -57,7 +51,7 @@ function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
<span
data-slot="avatar-badge"
className={cn(
"absolute right-0 bottom-0 z-10 inline-flex select-none items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background",
"bg-primary text-primary-foreground ring-background absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-blend-color ring-2 select-none",
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
@@ -73,7 +67,7 @@ function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
<div
data-slot="avatar-group"
className={cn(
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
"group/avatar-group *:data-[slot=avatar]:ring-background flex -space-x-2 *:data-[slot=avatar]:ring-2",
className,
)}
{...props}
@@ -81,15 +75,12 @@ function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
);
}
function AvatarGroupCount({
className,
...props
}: React.ComponentProps<"div">) {
function AvatarGroupCount({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group-count"
className={cn(
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground text-xs/relaxed ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
"bg-muted text-muted-foreground ring-background relative flex size-8 shrink-0 items-center justify-center rounded-full text-xs/relaxed ring-2 group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
className,
)}
{...props}
@@ -97,11 +88,4 @@ function AvatarGroupCount({
);
}
export {
Avatar,
AvatarBadge,
AvatarFallback,
AvatarGroup,
AvatarGroupCount,
AvatarImage,
};
export { Avatar, AvatarBadge, AvatarFallback, AvatarGroup, AvatarGroupCount, AvatarImage };
+3 -5
View File
@@ -5,19 +5,17 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden whitespace-nowrap rounded-full border border-transparent px-2 py-0.5 font-medium text-[0.625rem] transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-2.5!",
"group/badge focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-[0.625rem] font-medium whitespace-nowrap transition-all focus-visible:ring-[3px] has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&>svg]:pointer-events-none [&>svg]:size-2.5!",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary:
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
secondary: "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive:
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
outline:
"border-border bg-input/20 text-foreground dark:bg-input/30 [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost:
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
ghost: "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
+9 -29
View File
@@ -2,16 +2,12 @@ import { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from "@base-ui/react/use-render";
import { IconChevronRight, IconDots } from "@tabler/icons-react";
import type * as React from "react";
import { cn } from "@/lib/utils";
function Breadcrumb({ className, ...props }: React.ComponentProps<"nav">) {
return (
<nav
aria-label="breadcrumb"
data-slot="breadcrumb"
className={cn(className)}
{...props}
/>
<nav aria-label="breadcrumb" data-slot="breadcrumb" className={cn(className)} {...props} />
);
}
@@ -20,7 +16,7 @@ function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
<ol
data-slot="breadcrumb-list"
className={cn(
"wrap-break-word flex flex-wrap items-center gap-1.5 text-muted-foreground text-xs/relaxed",
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-xs/relaxed wrap-break-word",
className,
)}
{...props}
@@ -38,16 +34,12 @@ function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
);
}
function BreadcrumbLink({
className,
render,
...props
}: useRender.ComponentProps<"a">) {
function BreadcrumbLink({ className, render, ...props }: useRender.ComponentProps<"a">) {
return useRender({
defaultTagName: "a",
props: mergeProps<"a">(
{
className: cn("transition-colors hover:text-foreground", className),
className: cn("hover:text-foreground transition-colors", className),
},
props,
),
@@ -60,24 +52,18 @@ function BreadcrumbLink({
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
return (
// biome-ignore lint/a11y/useFocusableInteractive: shadcn generated
// biome-ignore lint/a11y/useSemanticElements: shadcn generated
<span
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
className={cn("font-normal text-foreground", className)}
className={cn("text-foreground font-normal", className)}
{...props}
/>
);
}
function BreadcrumbSeparator({
children,
className,
...props
}: React.ComponentProps<"li">) {
function BreadcrumbSeparator({ children, className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-separator"
@@ -91,19 +77,13 @@ function BreadcrumbSeparator({
);
}
function BreadcrumbEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
function BreadcrumbEllipsis({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
className={cn(
"flex size-4 items-center justify-center [&>svg]:size-3.5",
className,
)}
className={cn("flex size-4 items-center justify-center [&>svg]:size-3.5", className)}
{...props}
>
<IconDots />
+5 -14
View File
@@ -1,6 +1,7 @@
import { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from "@base-ui/react/use-render";
import { cva, type VariantProps } from "class-variance-authority";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";
@@ -27,7 +28,6 @@ function ButtonGroup({
...props
}: React.ComponentProps<"div"> & VariantProps<typeof buttonGroupVariants>) {
return (
// biome-ignore lint/a11y/useSemanticElements: shadcn generated
<div
role="group"
data-slot="button-group"
@@ -38,17 +38,13 @@ function ButtonGroup({
);
}
function ButtonGroupText({
className,
render,
...props
}: useRender.ComponentProps<"div">) {
function ButtonGroupText({ className, render, ...props }: useRender.ComponentProps<"div">) {
return useRender({
defaultTagName: "div",
props: mergeProps<"div">(
{
className: cn(
"flex items-center gap-2 rounded-md border bg-muted px-2.5 font-medium text-xs/relaxed [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none",
"bg-muted flex items-center gap-2 rounded-md border px-2.5 text-xs/relaxed font-medium [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className,
),
},
@@ -71,7 +67,7 @@ function ButtonGroupSeparator({
data-slot="button-group-separator"
orientation={orientation}
className={cn(
"relative self-stretch bg-input data-horizontal:mx-px data-vertical:my-px data-vertical:h-auto data-horizontal:w-auto",
"bg-input relative self-stretch data-horizontal:mx-px data-horizontal:w-auto data-vertical:my-px data-vertical:h-auto",
className,
)}
{...props}
@@ -79,9 +75,4 @@ function ButtonGroupSeparator({
);
}
export {
ButtonGroup,
ButtonGroupSeparator,
ButtonGroupText,
buttonGroupVariants,
};
export { ButtonGroup, ButtonGroupSeparator, ButtonGroupText, buttonGroupVariants };
+1 -1
View File
@@ -4,7 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"group/button inline-flex shrink-0 select-none items-center justify-center whitespace-nowrap rounded-md border border-transparent bg-clip-padding font-medium text-xs/relaxed outline-none transition-colors focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/30 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-2 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
"group/button focus-visible:border-ring focus-visible:ring-ring/30 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 inline-flex shrink-0 items-center justify-center rounded-md border border-transparent bg-clip-padding text-xs/relaxed font-medium whitespace-nowrap transition-colors outline-none select-none focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-invalid:ring-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
+5 -22
View File
@@ -12,7 +12,7 @@ function Card({
data-slot="card"
data-size={size}
className={cn(
"group/card flex flex-col gap-4 overflow-hidden rounded-lg bg-card py-4 text-card-foreground text-xs/relaxed ring-1 ring-foreground/10 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 *:[img:first-child]:rounded-t-lg *:[img:last-child]:rounded-b-lg",
"group/card bg-card text-card-foreground ring-foreground/10 flex flex-col gap-4 overflow-hidden rounded-lg py-4 text-xs/relaxed ring-1 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 *:[img:first-child]:rounded-t-lg *:[img:last-child]:rounded-b-lg",
className,
)}
{...props}
@@ -25,7 +25,7 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
<div
data-slot="card-header"
className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-lg px-4 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] group-data-[size=sm]/card:px-3 [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-lg px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
className,
)}
{...props}
@@ -34,13 +34,7 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("font-medium text-sm", className)}
{...props}
/>
);
return <div data-slot="card-title" className={cn("text-sm font-medium", className)} {...props} />;
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
@@ -57,10 +51,7 @@ function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className,
)}
className={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)}
{...props}
/>
);
@@ -89,12 +80,4 @@ function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
);
}
export {
Card,
CardAction,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
};
export { Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle };
+16 -48
View File
@@ -40,9 +40,7 @@ function ChartContainer({
...props
}: React.ComponentProps<"div"> & {
config: ChartConfig;
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"];
children: React.ComponentProps<typeof RechartsPrimitive.ResponsiveContainer>["children"];
}) {
const uniqueId = React.useId();
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
@@ -53,24 +51,20 @@ function ChartContainer({
data-slot="chart"
data-chart={chartId}
className={cn(
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-hidden [&_.recharts-surface]:outline-hidden",
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
className,
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
<RechartsPrimitive.ResponsiveContainer>{children}</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
);
}
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme || config.color,
);
const colorConfig = Object.entries(config).filter(([, entry]) => entry.theme || entry.color);
if (!colorConfig.length) {
return null;
@@ -78,7 +72,6 @@ const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
return (
<style
// biome-ignore lint/security/noDangerouslySetInnerHtml: shadcn generated chart theming
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
@@ -86,9 +79,7 @@ const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color;
const color = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || itemConfig.color;
return color ? ` --color-${key}: ${color};` : null;
})
.join("\n")}
@@ -145,9 +136,7 @@ function ChartTooltipContent({
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
<div className={cn("font-medium", labelClassName)}>{labelFormatter(value, payload)}</div>
);
}
@@ -156,15 +145,7 @@ function ChartTooltipContent({
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
]);
}, [label, labelFormatter, payload, hideLabel, labelClassName, config, labelKey]);
if (!active || !payload?.length) {
return null;
@@ -175,7 +156,7 @@ function ChartTooltipContent({
return (
<div
className={cn(
"grid min-w-32 items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs/relaxed shadow-xl",
"border-border/50 bg-background grid min-w-32 items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs/relaxed shadow-xl",
className,
)}
>
@@ -192,7 +173,7 @@ function ChartTooltipContent({
<div
key={key}
className={cn(
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
indicator === "dot" && "items-center",
)}
>
@@ -237,7 +218,7 @@ function ChartTooltipContent({
</span>
</div>
{item.value && (
<span className="font-medium font-mono text-foreground tabular-nums">
<span className="text-foreground font-mono font-medium tabular-nums">
{item.value.toLocaleString()}
</span>
)}
@@ -290,7 +271,7 @@ function ChartLegendContent({
<div
key={item.value}
className={cn(
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground",
"[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3",
)}
>
{itemConfig?.icon && !hideIcon ? (
@@ -311,42 +292,29 @@ function ChartLegendContent({
);
}
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string,
) {
function getPayloadConfigFromPayload(config: ChartConfig, payload: unknown, key: string) {
if (typeof payload !== "object" || payload === null) {
return undefined;
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
"payload" in payload && typeof payload.payload === "object" && payload.payload !== null
? payload.payload
: undefined;
let configLabelKey: string = key;
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
if (key in payload && typeof payload[key as keyof typeof payload] === "string") {
configLabelKey = payload[key as keyof typeof payload] as string;
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string;
configLabelKey = payloadPayload[key as keyof typeof payloadPayload] as string;
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config];
return configLabelKey in config ? config[configLabelKey] : config[key as keyof typeof config];
}
export {
+2 -1
View File
@@ -1,5 +1,6 @@
import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox";
import { IconCheck } from "@tabler/icons-react";
import { cn } from "@/lib/utils";
function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
@@ -7,7 +8,7 @@ function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input outline-none transition-shadow after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/30 disabled:cursor-not-allowed disabled:opacity-50 group-has-disabled/field:opacity-50 aria-invalid:border-destructive aria-invalid:ring-2 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:bg-input/30 dark:data-checked:bg-primary dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
"peer border-input focus-visible:border-ring focus-visible:ring-ring/30 aria-invalid:border-destructive aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:bg-input/30 dark:data-checked:bg-primary dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border transition-shadow outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:ring-2",
className,
)}
{...props}
+2 -6
View File
@@ -5,15 +5,11 @@ function Collapsible({ ...props }: CollapsiblePrimitive.Root.Props) {
}
function CollapsibleTrigger({ ...props }: CollapsiblePrimitive.Trigger.Props) {
return (
<CollapsiblePrimitive.Trigger data-slot="collapsible-trigger" {...props} />
);
return <CollapsiblePrimitive.Trigger data-slot="collapsible-trigger" {...props} />;
}
function CollapsibleContent({ ...props }: CollapsiblePrimitive.Panel.Props) {
return (
<CollapsiblePrimitive.Panel data-slot="collapsible-content" {...props} />
);
return <CollapsiblePrimitive.Panel data-slot="collapsible-content" {...props} />;
}
export { Collapsible, CollapsibleContent, CollapsibleTrigger };
+18 -44
View File
@@ -1,6 +1,7 @@
import { Combobox as ComboboxPrimitive } from "@base-ui/react";
import { IconCheck, IconChevronDown, IconX } from "@tabler/icons-react";
import * as React from "react";
import { Button } from "@/components/ui/button";
import {
InputGroup,
@@ -16,11 +17,7 @@ function ComboboxValue({ ...props }: ComboboxPrimitive.Value.Props) {
return <ComboboxPrimitive.Value data-slot="combobox-value" {...props} />;
}
function ComboboxTrigger({
className,
children,
...props
}: ComboboxPrimitive.Trigger.Props) {
function ComboboxTrigger({ className, children, ...props }: ComboboxPrimitive.Trigger.Props) {
return (
<ComboboxPrimitive.Trigger
data-slot="combobox-trigger"
@@ -28,7 +25,7 @@ function ComboboxTrigger({
{...props}
>
{children}
<IconChevronDown className="pointer-events-none size-3.5 text-muted-foreground" />
<IconChevronDown className="text-muted-foreground pointer-events-none size-3.5" />
</ComboboxPrimitive.Trigger>
);
}
@@ -59,10 +56,7 @@ function ComboboxInput({
}) {
return (
<InputGroup className={cn("w-auto", className)}>
<ComboboxPrimitive.Input
render={<InputGroupInput disabled={disabled} />}
{...props}
/>
<ComboboxPrimitive.Input render={<InputGroupInput disabled={disabled} />} {...props} />
<InputGroupAddon align="inline-end">
{showTrigger && (
<InputGroupButton
@@ -108,7 +102,7 @@ function ComboboxContent({
data-slot="combobox-content"
data-chips={!!anchor}
className={cn(
"data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 group/combobox-content relative max-h-(--available-height) w-(--anchor-width) min-w-[calc(var(--anchor-width)+--spacing(7))] max-w-(--available-width) origin-(--transform-origin) overflow-hidden rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[chips=true]:min-w-(--anchor-width) data-closed:animate-out data-open:animate-in *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-7 *:data-[slot=input-group]:border-none *:data-[slot=input-group]:bg-input/20 *:data-[slot=input-group]:shadow-none dark:bg-popover",
"data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 group/combobox-content bg-popover text-popover-foreground ring-foreground/10 data-closed:animate-out data-open:animate-in *:data-[slot=input-group]:bg-input/20 dark:bg-popover relative max-h-(--available-height) w-(--anchor-width) max-w-(--available-width) min-w-[calc(var(--anchor-width)+--spacing(7))] origin-(--transform-origin) overflow-hidden rounded-lg shadow-md ring-1 duration-100 data-[chips=true]:min-w-(--anchor-width) *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-7 *:data-[slot=input-group]:border-none *:data-[slot=input-group]:shadow-none",
className,
)}
{...props}
@@ -131,16 +125,12 @@ function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) {
);
}
function ComboboxItem({
className,
children,
...props
}: ComboboxPrimitive.Item.Props) {
function ComboboxItem({ className, children, ...props }: ComboboxPrimitive.Item.Props) {
return (
<ComboboxPrimitive.Item
data-slot="combobox-item"
className={cn(
"relative flex min-h-7 w-full cursor-default select-none items-center gap-2 rounded-md px-2 py-1 text-xs/relaxed outline-hidden data-disabled:pointer-events-none data-highlighted:bg-accent data-highlighted:text-accent-foreground data-disabled:opacity-50 not-data-[variant=destructive]:data-highlighted:**:text-accent-foreground [&_svg:not([class*='size-'])]:size-3.5 [&_svg]:pointer-events-none [&_svg]:shrink-0",
"data-highlighted:bg-accent data-highlighted:text-accent-foreground not-data-[variant=destructive]:data-highlighted:**:text-accent-foreground relative flex min-h-7 w-full cursor-default items-center gap-2 rounded-md px-2 py-1 text-xs/relaxed outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
className,
)}
{...props}
@@ -159,31 +149,22 @@ function ComboboxItem({
function ComboboxGroup({ className, ...props }: ComboboxPrimitive.Group.Props) {
return (
<ComboboxPrimitive.Group
data-slot="combobox-group"
className={cn(className)}
{...props}
/>
<ComboboxPrimitive.Group data-slot="combobox-group" className={cn(className)} {...props} />
);
}
function ComboboxLabel({
className,
...props
}: ComboboxPrimitive.GroupLabel.Props) {
function ComboboxLabel({ className, ...props }: ComboboxPrimitive.GroupLabel.Props) {
return (
<ComboboxPrimitive.GroupLabel
data-slot="combobox-label"
className={cn("px-2 py-1.5 text-muted-foreground text-xs", className)}
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
);
}
function ComboboxCollection({ ...props }: ComboboxPrimitive.Collection.Props) {
return (
<ComboboxPrimitive.Collection data-slot="combobox-collection" {...props} />
);
return <ComboboxPrimitive.Collection data-slot="combobox-collection" {...props} />;
}
function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) {
@@ -191,7 +172,7 @@ function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) {
<ComboboxPrimitive.Empty
data-slot="combobox-empty"
className={cn(
"hidden w-full justify-center py-2 text-center text-muted-foreground text-xs/relaxed group-data-empty/combobox-content:flex",
"text-muted-foreground hidden w-full justify-center py-2 text-center text-xs/relaxed group-data-empty/combobox-content:flex",
className,
)}
{...props}
@@ -199,14 +180,11 @@ function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) {
);
}
function ComboboxSeparator({
className,
...props
}: ComboboxPrimitive.Separator.Props) {
function ComboboxSeparator({ className, ...props }: ComboboxPrimitive.Separator.Props) {
return (
<ComboboxPrimitive.Separator
data-slot="combobox-separator"
className={cn("-mx-1 my-1 h-px bg-border/50", className)}
className={cn("bg-border/50 -mx-1 my-1 h-px", className)}
{...props}
/>
);
@@ -215,13 +193,12 @@ function ComboboxSeparator({
function ComboboxChips({
className,
...props
}: React.ComponentPropsWithRef<typeof ComboboxPrimitive.Chips> &
ComboboxPrimitive.Chips.Props) {
}: React.ComponentPropsWithRef<typeof ComboboxPrimitive.Chips> & ComboboxPrimitive.Chips.Props) {
return (
<ComboboxPrimitive.Chips
data-slot="combobox-chips"
className={cn(
"flex min-h-7 flex-wrap items-center gap-1 rounded-md border border-input bg-input/20 bg-clip-padding px-2 py-0.5 text-xs/relaxed transition-colors focus-within:border-ring focus-within:ring-2 focus-within:ring-ring/30 has-aria-invalid:border-destructive has-data-[slot=combobox-chip]:px-1 has-aria-invalid:ring-2 has-aria-invalid:ring-destructive/20 dark:bg-input/30 dark:has-aria-invalid:border-destructive/50 dark:has-aria-invalid:ring-destructive/40",
"border-input bg-input/20 focus-within:border-ring focus-within:ring-ring/30 has-aria-invalid:border-destructive has-aria-invalid:ring-destructive/20 dark:bg-input/30 dark:has-aria-invalid:border-destructive/50 dark:has-aria-invalid:ring-destructive/40 flex min-h-7 flex-wrap items-center gap-1 rounded-md border bg-clip-padding px-2 py-0.5 text-xs/relaxed transition-colors focus-within:ring-2 has-aria-invalid:ring-2 has-data-[slot=combobox-chip]:px-1",
className,
)}
{...props}
@@ -241,7 +218,7 @@ function ComboboxChip({
<ComboboxPrimitive.Chip
data-slot="combobox-chip"
className={cn(
"flex h-[calc(--spacing(4.75))] w-fit items-center justify-center gap-1 whitespace-nowrap rounded-[calc(var(--radius-sm)-2px)] bg-muted-foreground/10 px-1.5 font-medium text-foreground text-xs/relaxed has-disabled:pointer-events-none has-disabled:cursor-not-allowed has-data-[slot=combobox-chip-remove]:pr-0 has-disabled:opacity-50",
"bg-muted-foreground/10 text-foreground flex h-[calc(--spacing(4.75))] w-fit items-center justify-center gap-1 rounded-[calc(var(--radius-sm)-2px)] px-1.5 text-xs/relaxed font-medium whitespace-nowrap has-disabled:pointer-events-none has-disabled:cursor-not-allowed has-disabled:opacity-50 has-data-[slot=combobox-chip-remove]:pr-0",
className,
)}
{...props}
@@ -260,10 +237,7 @@ function ComboboxChip({
);
}
function ComboboxChipsInput({
className,
...props
}: ComboboxPrimitive.Input.Props) {
function ComboboxChipsInput({ className, ...props }: ComboboxPrimitive.Input.Props) {
return (
<ComboboxPrimitive.Input
data-slot="combobox-chip-input"
+12 -23
View File
@@ -1,6 +1,7 @@
import { IconCheck, IconSearch } from "@tabler/icons-react";
import { Command as CommandPrimitive } from "cmdk";
import type * as React from "react";
import {
Dialog,
DialogContent,
@@ -11,15 +12,12 @@ import {
import { InputGroup, InputGroupAddon } from "@/components/ui/input-group";
import { cn } from "@/lib/utils";
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
function Command({ className, ...props }: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"flex size-full flex-col overflow-hidden rounded-xl bg-popover p-1 text-popover-foreground",
"bg-popover text-popover-foreground flex size-full flex-col overflow-hidden rounded-xl p-1",
className,
)}
{...props}
@@ -48,10 +46,7 @@ function CommandDialog({
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn(
"top-1/3 translate-y-0 overflow-hidden rounded-xl! p-0",
className,
)}
className={cn("top-1/3 translate-y-0 overflow-hidden rounded-xl! p-0", className)}
showCloseButton={showCloseButton}
>
{children}
@@ -66,7 +61,7 @@ function CommandInput({
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div data-slot="command-input-wrapper" className="p-1 pb-0">
<InputGroup className="h-8! bg-input/20 dark:bg-input/30">
<InputGroup className="bg-input/20 dark:bg-input/30 h-8!">
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
@@ -83,15 +78,12 @@ function CommandInput({
);
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
function CommandList({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"no-scrollbar max-h-72 scroll-py-1 overflow-y-auto overflow-x-hidden outline-none",
"no-scrollbar max-h-72 scroll-py-1 overflow-x-hidden overflow-y-auto outline-none",
className,
)}
{...props}
@@ -120,7 +112,7 @@ function CommandGroup({
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"overflow-hidden p-1 text-foreground **:[[cmdk-group-heading]]:px-2.5 **:[[cmdk-group-heading]]:py-1.5 **:[[cmdk-group-heading]]:font-medium **:[[cmdk-group-heading]]:text-[13px] **:[[cmdk-group-heading]]:text-muted-foreground",
"text-foreground **:[[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 **:[[cmdk-group-heading]]:px-2.5 **:[[cmdk-group-heading]]:py-1.5 **:[[cmdk-group-heading]]:text-[13px] **:[[cmdk-group-heading]]:font-medium",
className,
)}
{...props}
@@ -135,7 +127,7 @@ function CommandSeparator({
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("-mx-1 my-1 h-px bg-border/50", className)}
className={cn("bg-border/50 -mx-1 my-1 h-px", className)}
{...props}
/>
);
@@ -150,7 +142,7 @@ function CommandItem({
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"group/command-item relative flex min-h-7 cursor-default select-none items-center gap-2 in-data-[slot=dialog-content]:rounded-md rounded-md px-2.5 py-1.5 text-[13px] leading-relaxed outline-hidden data-[disabled=true]:pointer-events-none data-selected:bg-muted data-selected:text-foreground data-[disabled=true]:opacity-50 [&_svg:not([class*='size-'])]:size-3.5 [&_svg]:pointer-events-none [&_svg]:shrink-0 data-selected:*:[svg]:text-foreground",
"group/command-item data-selected:bg-muted data-selected:text-foreground data-selected:*:[svg]:text-foreground relative flex min-h-7 cursor-default items-center gap-2 rounded-md px-2.5 py-1.5 text-[13px] leading-relaxed outline-hidden select-none in-data-[slot=dialog-content]:rounded-md data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
className,
)}
{...props}
@@ -161,15 +153,12 @@ function CommandItem({
);
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
function CommandShortcut({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"ml-auto text-[0.625rem] text-muted-foreground tracking-widest group-data-selected/command-item:text-foreground",
"text-muted-foreground group-data-selected/command-item:text-foreground ml-auto text-[0.625rem] tracking-widest",
className,
)}
{...props}
+19 -48
View File
@@ -1,6 +1,7 @@
import { ContextMenu as ContextMenuPrimitive } from "@base-ui/react/context-menu";
import { IconCheck, IconChevronRight } from "@tabler/icons-react";
import type * as React from "react";
import { cn } from "@/lib/utils";
function ContextMenu({ ...props }: ContextMenuPrimitive.Root.Props) {
@@ -8,15 +9,10 @@ function ContextMenu({ ...props }: ContextMenuPrimitive.Root.Props) {
}
function ContextMenuPortal({ ...props }: ContextMenuPrimitive.Portal.Props) {
return (
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
);
return <ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />;
}
function ContextMenuTrigger({
className,
...props
}: ContextMenuPrimitive.Trigger.Props) {
function ContextMenuTrigger({ className, ...props }: ContextMenuPrimitive.Trigger.Props) {
return (
<ContextMenuPrimitive.Trigger
data-slot="context-menu-trigger"
@@ -34,10 +30,7 @@ function ContextMenuContent({
sideOffset = 0,
...props
}: ContextMenuPrimitive.Popup.Props &
Pick<
ContextMenuPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
Pick<ContextMenuPrimitive.Positioner.Props, "align" | "alignOffset" | "side" | "sideOffset">) {
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Positioner
@@ -50,7 +43,7 @@ function ContextMenuContent({
<ContextMenuPrimitive.Popup
data-slot="context-menu-content"
className={cn(
"data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 z-50 max-h-(--available-height) min-w-32 origin-(--transform-origin) overflow-y-auto overflow-x-hidden rounded-lg bg-popover p-1 text-popover-foreground shadow-md outline-none ring-1 ring-foreground/10 duration-100 data-closed:animate-out data-open:animate-in",
"data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 bg-popover text-popover-foreground ring-foreground/10 data-closed:animate-out data-open:animate-in z-50 max-h-(--available-height) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg p-1 shadow-md ring-1 duration-100 outline-none",
className,
)}
{...props}
@@ -61,9 +54,7 @@ function ContextMenuContent({
}
function ContextMenuGroup({ ...props }: ContextMenuPrimitive.Group.Props) {
return (
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
);
return <ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />;
}
function ContextMenuLabel({
@@ -77,10 +68,7 @@ function ContextMenuLabel({
<ContextMenuPrimitive.GroupLabel
data-slot="context-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-muted-foreground text-xs data-inset:pl-7.5",
className,
)}
className={cn("text-muted-foreground px-2 py-1.5 text-xs data-inset:pl-7.5", className)}
{...props}
/>
);
@@ -101,7 +89,7 @@ function ContextMenuItem({
data-inset={inset}
data-variant={variant}
className={cn(
"group/context-menu-item relative flex min-h-7 cursor-default select-none items-center gap-2 rounded-md px-2 py-1 text-xs/relaxed outline-hidden focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-inset:pl-7.5 data-[variant=destructive]:text-destructive data-disabled:opacity-50 data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg:not([class*='size-'])]:size-3.5 [&_svg]:pointer-events-none [&_svg]:shrink-0 data-[variant=destructive]:*:[svg]:text-destructive",
"group/context-menu-item focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:*:[svg]:text-destructive relative flex min-h-7 cursor-default items-center gap-2 rounded-md px-2 py-1 text-xs/relaxed outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-inset:pl-7.5 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
className,
)}
{...props}
@@ -110,9 +98,7 @@ function ContextMenuItem({
}
function ContextMenuSub({ ...props }: ContextMenuPrimitive.SubmenuRoot.Props) {
return (
<ContextMenuPrimitive.SubmenuRoot data-slot="context-menu-sub" {...props} />
);
return <ContextMenuPrimitive.SubmenuRoot data-slot="context-menu-sub" {...props} />;
}
function ContextMenuSubTrigger({
@@ -128,7 +114,7 @@ function ContextMenuSubTrigger({
data-slot="context-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex min-h-7 cursor-default select-none items-center gap-2 rounded-md px-2 py-1 text-xs outline-hidden focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-open:bg-accent data-inset:pl-7.5 data-open:text-accent-foreground [&_svg:not([class*='size-'])]:size-3.5 [&_svg]:pointer-events-none [&_svg]:shrink-0",
"focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground flex min-h-7 cursor-default items-center gap-2 rounded-md px-2 py-1 text-xs outline-hidden select-none data-inset:pl-7.5 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
className,
)}
{...props}
@@ -139,9 +125,7 @@ function ContextMenuSubTrigger({
);
}
function ContextMenuSubContent({
...props
}: React.ComponentProps<typeof ContextMenuContent>) {
function ContextMenuSubContent({ ...props }: React.ComponentProps<typeof ContextMenuContent>) {
return (
<ContextMenuContent
data-slot="context-menu-sub-content"
@@ -166,7 +150,7 @@ function ContextMenuCheckboxItem({
data-slot="context-menu-checkbox-item"
data-inset={inset}
className={cn(
"relative flex min-h-7 cursor-default select-none items-center gap-2 rounded-md py-1.5 pr-8 pl-2 text-xs outline-hidden focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-disabled:pointer-events-none data-inset:pl-7.5 data-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-3.5 [&_svg]:pointer-events-none [&_svg]:shrink-0",
"focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground relative flex min-h-7 cursor-default items-center gap-2 rounded-md py-1.5 pr-8 pl-2 text-xs outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-inset:pl-7.5 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
className,
)}
checked={checked}
@@ -182,15 +166,8 @@ function ContextMenuCheckboxItem({
);
}
function ContextMenuRadioGroup({
...props
}: ContextMenuPrimitive.RadioGroup.Props) {
return (
<ContextMenuPrimitive.RadioGroup
data-slot="context-menu-radio-group"
{...props}
/>
);
function ContextMenuRadioGroup({ ...props }: ContextMenuPrimitive.RadioGroup.Props) {
return <ContextMenuPrimitive.RadioGroup data-slot="context-menu-radio-group" {...props} />;
}
function ContextMenuRadioItem({
@@ -206,7 +183,7 @@ function ContextMenuRadioItem({
data-slot="context-menu-radio-item"
data-inset={inset}
className={cn(
"relative flex min-h-7 cursor-default select-none items-center gap-2 rounded-md py-1.5 pr-8 pl-2 text-xs outline-hidden focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-disabled:pointer-events-none data-inset:pl-7.5 data-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-3.5 [&_svg]:pointer-events-none [&_svg]:shrink-0",
"focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground relative flex min-h-7 cursor-default items-center gap-2 rounded-md py-1.5 pr-8 pl-2 text-xs outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-inset:pl-7.5 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
className,
)}
{...props}
@@ -221,28 +198,22 @@ function ContextMenuRadioItem({
);
}
function ContextMenuSeparator({
className,
...props
}: ContextMenuPrimitive.Separator.Props) {
function ContextMenuSeparator({ className, ...props }: ContextMenuPrimitive.Separator.Props) {
return (
<ContextMenuPrimitive.Separator
data-slot="context-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border/50", className)}
className={cn("bg-border/50 -mx-1 my-1 h-px", className)}
{...props}
/>
);
}
function ContextMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
function ContextMenuShortcut({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="context-menu-shortcut"
className={cn(
"ml-auto text-[0.625rem] text-muted-foreground tracking-widest group-focus/context-menu-item:text-accent-foreground",
"text-muted-foreground group-focus/context-menu-item:text-accent-foreground ml-auto text-[0.625rem] tracking-widest",
className,
)}
{...props}
+11 -31
View File
@@ -1,6 +1,7 @@
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog";
import { IconX } from "@tabler/icons-react";
import type * as React from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
@@ -20,15 +21,12 @@ function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function DialogOverlay({
className,
...props
}: DialogPrimitive.Backdrop.Props) {
function DialogOverlay({ className, ...props }: DialogPrimitive.Backdrop.Props) {
return (
<DialogPrimitive.Backdrop
data-slot="dialog-overlay"
className={cn(
"data-closed:fade-out-0 data-open:fade-in-0 fixed inset-0 isolate z-50 bg-black/80 duration-100 data-closed:animate-out data-open:animate-in supports-backdrop-filter:backdrop-blur-xs",
"data-closed:fade-out-0 data-open:fade-in-0 data-closed:animate-out data-open:animate-in fixed inset-0 isolate z-50 bg-black/80 duration-100 supports-backdrop-filter:backdrop-blur-xs",
className,
)}
{...props}
@@ -50,7 +48,7 @@ function DialogContent({
<DialogPrimitive.Popup
data-slot="dialog-content"
className={cn(
"data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-background p-4 text-xs/relaxed outline-none ring-1 ring-foreground/10 duration-100 data-closed:animate-out data-open:animate-in sm:max-w-sm",
"data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 bg-background ring-foreground/10 data-closed:animate-out data-open:animate-in fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl p-4 text-xs/relaxed ring-1 duration-100 outline-none sm:max-w-sm",
className,
)}
{...props}
@@ -59,13 +57,7 @@ function DialogContent({
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
render={
<Button
variant="ghost"
className="absolute top-2 right-2"
size="icon-sm"
/>
}
render={<Button variant="ghost" className="absolute top-2 right-2" size="icon-sm" />}
>
<IconX />
<span className="sr-only">Close</span>
@@ -78,11 +70,7 @@ function DialogContent({
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-1", className)}
{...props}
/>
<div data-slot="dialog-header" className={cn("flex flex-col gap-1", className)} {...props} />
);
}
@@ -97,17 +85,12 @@ function DialogFooter({
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className,
)}
className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close render={<Button variant="outline" />}>
Close
</DialogPrimitive.Close>
<DialogPrimitive.Close render={<Button variant="outline" />}>Close</DialogPrimitive.Close>
)}
</div>
);
@@ -117,21 +100,18 @@ function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("font-medium text-sm", className)}
className={cn("text-sm font-medium", className)}
{...props}
/>
);
}
function DialogDescription({
className,
...props
}: DialogPrimitive.Description.Props) {
function DialogDescription({ className, ...props }: DialogPrimitive.Description.Props) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"text-muted-foreground text-xs/relaxed *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
"text-muted-foreground *:[a]:hover:text-foreground text-xs/relaxed *:[a]:underline *:[a]:underline-offset-3",
className,
)}
{...props}
+1 -4
View File
@@ -1,4 +1 @@
export {
DirectionProvider,
useDirection,
} from "@base-ui/react/direction-provider";
export { DirectionProvider, useDirection } from "@base-ui/react/direction-provider";
+14 -30
View File
@@ -1,6 +1,7 @@
import { Menu as MenuPrimitive } from "@base-ui/react/menu";
import { IconCheck, IconChevronRight } from "@tabler/icons-react";
import type * as React from "react";
import { cn } from "@/lib/utils";
function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
@@ -23,10 +24,7 @@ function DropdownMenuContent({
className,
...props
}: MenuPrimitive.Popup.Props &
Pick<
MenuPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
Pick<MenuPrimitive.Positioner.Props, "align" | "alignOffset" | "side" | "sideOffset">) {
return (
<MenuPrimitive.Portal>
<MenuPrimitive.Positioner
@@ -39,7 +37,7 @@ function DropdownMenuContent({
<MenuPrimitive.Popup
data-slot="dropdown-menu-content"
className={cn(
"data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-y-auto overflow-x-hidden rounded-lg bg-popover p-1 text-popover-foreground shadow-md outline-none ring-1 ring-foreground/10 duration-100 data-closed:animate-out data-open:animate-in data-closed:overflow-hidden",
"data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 bg-popover text-popover-foreground ring-foreground/10 data-closed:animate-out data-open:animate-in z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg p-1 shadow-md ring-1 duration-100 outline-none data-closed:overflow-hidden",
className,
)}
{...props}
@@ -64,10 +62,7 @@ function DropdownMenuLabel({
<MenuPrimitive.GroupLabel
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-muted-foreground text-xs data-inset:pl-7.5",
className,
)}
className={cn("text-muted-foreground px-2 py-1.5 text-xs data-inset:pl-7.5", className)}
{...props}
/>
);
@@ -88,7 +83,7 @@ function DropdownMenuItem({
data-inset={inset}
data-variant={variant}
className={cn(
"group/dropdown-menu-item relative flex min-h-7 cursor-default select-none items-center gap-2 rounded-md px-2 py-1 text-xs/relaxed outline-hidden focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-inset:pl-7.5 data-[variant=destructive]:text-destructive data-disabled:opacity-50 data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg:not([class*='size-'])]:size-3.5 [&_svg]:pointer-events-none [&_svg]:shrink-0 data-[variant=destructive]:*:[svg]:text-destructive",
"group/dropdown-menu-item focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:*:[svg]:text-destructive relative flex min-h-7 cursor-default items-center gap-2 rounded-md px-2 py-1 text-xs/relaxed outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-inset:pl-7.5 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
className,
)}
{...props}
@@ -113,7 +108,7 @@ function DropdownMenuSubTrigger({
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex min-h-7 cursor-default select-none items-center gap-2 rounded-md px-2 py-1 text-xs outline-hidden focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-open:bg-accent data-popup-open:bg-accent data-inset:pl-7.5 data-open:text-accent-foreground data-popup-open:text-accent-foreground [&_svg:not([class*='size-'])]:size-3.5 [&_svg]:pointer-events-none [&_svg]:shrink-0",
"focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-open:bg-accent data-popup-open:bg-accent data-open:text-accent-foreground data-popup-open:text-accent-foreground flex min-h-7 cursor-default items-center gap-2 rounded-md px-2 py-1 text-xs outline-hidden select-none data-inset:pl-7.5 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
className,
)}
{...props}
@@ -136,7 +131,7 @@ function DropdownMenuSubContent({
<DropdownMenuContent
data-slot="dropdown-menu-sub-content"
className={cn(
"data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 w-auto min-w-32 rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-closed:animate-out data-open:animate-in",
"data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 bg-popover text-popover-foreground ring-foreground/10 data-closed:animate-out data-open:animate-in w-auto min-w-32 rounded-lg p-1 shadow-md ring-1 duration-100",
className,
)}
align={align}
@@ -162,7 +157,7 @@ function DropdownMenuCheckboxItem({
data-slot="dropdown-menu-checkbox-item"
data-inset={inset}
className={cn(
"relative flex min-h-7 cursor-default select-none items-center gap-2 rounded-md py-1.5 pr-8 pl-2 text-xs outline-hidden focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-disabled:pointer-events-none data-inset:pl-7.5 data-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-3.5 [&_svg]:pointer-events-none [&_svg]:shrink-0",
"focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground relative flex min-h-7 cursor-default items-center gap-2 rounded-md py-1.5 pr-8 pl-2 text-xs outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-inset:pl-7.5 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
className,
)}
checked={checked}
@@ -182,12 +177,7 @@ function DropdownMenuCheckboxItem({
}
function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
return (
<MenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
);
return <MenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props} />;
}
function DropdownMenuRadioItem({
@@ -203,7 +193,7 @@ function DropdownMenuRadioItem({
data-slot="dropdown-menu-radio-item"
data-inset={inset}
className={cn(
"relative flex min-h-7 cursor-default select-none items-center gap-2 rounded-md py-1.5 pr-8 pl-2 text-xs outline-hidden focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-disabled:pointer-events-none data-inset:pl-7.5 data-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-3.5 [&_svg]:pointer-events-none [&_svg]:shrink-0",
"focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground relative flex min-h-7 cursor-default items-center gap-2 rounded-md py-1.5 pr-8 pl-2 text-xs outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-inset:pl-7.5 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
className,
)}
{...props}
@@ -221,28 +211,22 @@ function DropdownMenuRadioItem({
);
}
function DropdownMenuSeparator({
className,
...props
}: MenuPrimitive.Separator.Props) {
function DropdownMenuSeparator({ className, ...props }: MenuPrimitive.Separator.Props) {
return (
<MenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border/50", className)}
className={cn("bg-border/50 -mx-1 my-1 h-px", className)}
{...props}
/>
);
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"ml-auto text-[0.625rem] text-muted-foreground tracking-widest group-focus/dropdown-menu-item:text-accent-foreground",
"text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground ml-auto text-[0.625rem] tracking-widest",
className,
)}
{...props}
+6 -13
View File
@@ -7,7 +7,7 @@ function Empty({ className, ...props }: React.ComponentProps<"div">) {
<div
data-slot="empty"
className={cn(
"flex w-full min-w-0 flex-1 flex-col items-center justify-center gap-4 text-balance rounded-xl border-dashed p-6 text-center",
"flex w-full min-w-0 flex-1 flex-col items-center justify-center gap-4 rounded-xl border-dashed p-6 text-center text-balance",
className,
)}
{...props}
@@ -31,7 +31,7 @@ const emptyMediaVariants = cva(
variants: {
variant: {
default: "bg-transparent",
icon: "flex size-8 shrink-0 items-center justify-center rounded-md bg-muted text-foreground [&_svg:not([class*='size-'])]:size-4",
icon: "bg-muted text-foreground flex size-8 shrink-0 items-center justify-center rounded-md [&_svg:not([class*='size-'])]:size-4",
},
},
defaultVariants: {
@@ -59,7 +59,7 @@ function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-title"
className={cn("font-medium text-sm tracking-tight", className)}
className={cn("text-sm font-medium tracking-tight", className)}
{...props}
/>
);
@@ -70,7 +70,7 @@ function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
<div
data-slot="empty-description"
className={cn(
"text-muted-foreground text-xs/relaxed [&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
"text-muted-foreground [&>a:hover]:text-primary text-xs/relaxed [&>a]:underline [&>a]:underline-offset-4",
className,
)}
{...props}
@@ -83,7 +83,7 @@ function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
<div
data-slot="empty-content"
className={cn(
"flex w-full min-w-0 max-w-sm flex-col items-center gap-2 text-balance text-xs/relaxed",
"flex w-full max-w-sm min-w-0 flex-col items-center gap-2 text-xs/relaxed text-balance",
className,
)}
{...props}
@@ -91,11 +91,4 @@ function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
);
}
export {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
};
export { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle };
+23 -45
View File
@@ -1,5 +1,6 @@
import { cva, type VariantProps } from "class-variance-authority";
import { useMemo } from "react";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";
@@ -48,23 +49,20 @@ function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
);
}
const fieldVariants = cva(
"group/field flex w-full gap-2 data-[invalid=true]:text-destructive",
{
variants: {
orientation: {
vertical: "flex-col *:w-full [&>.sr-only]:w-auto",
horizontal:
"flex-row items-center has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
responsive:
"@md/field-group:flex-row flex-col @md/field-group:items-center *:w-full @md/field-group:*:w-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:data-[slot=field-label]:flex-auto [&>.sr-only]:w-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
},
},
defaultVariants: {
orientation: "vertical",
const fieldVariants = cva("group/field data-[invalid=true]:text-destructive flex w-full gap-2", {
variants: {
orientation: {
vertical: "flex-col *:w-full [&>.sr-only]:w-auto",
horizontal:
"flex-row items-center has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
responsive:
"flex-col *:w-full @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:data-[slot=field-label]:flex-auto [&>.sr-only]:w-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
},
},
);
defaultVariants: {
orientation: "vertical",
},
});
function Field({
className,
@@ -72,7 +70,6 @@ function Field({
...props
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
return (
// biome-ignore lint/a11y/useSemanticElements: shadcn generated
<div
role="group"
data-slot="field"
@@ -87,24 +84,18 @@ function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-content"
className={cn(
"group/field-content flex flex-1 flex-col gap-0.5 leading-snug",
className,
)}
className={cn("group/field-content flex flex-1 flex-col gap-0.5 leading-snug", className)}
{...props}
/>
);
}
function FieldLabel({
className,
...props
}: React.ComponentProps<typeof Label>) {
function FieldLabel({ className, ...props }: React.ComponentProps<typeof Label>) {
return (
<Label
data-slot="field-label"
className={cn(
"group/field-label peer/field-label flex w-fit gap-2 leading-snug has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border has-data-checked:bg-primary/5 *:data-[slot=field]:p-2 group-data-[disabled=true]/field:opacity-50 dark:has-data-checked:bg-primary/10",
"group/field-label peer/field-label has-data-checked:bg-primary/5 dark:has-data-checked:bg-primary/10 flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50 has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border *:data-[slot=field]:p-2",
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col",
className,
)}
@@ -118,7 +109,7 @@ function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
<div
data-slot="field-label"
className={cn(
"flex w-fit items-center gap-2 font-medium text-xs/relaxed leading-snug group-data-[disabled=true]/field:opacity-50",
"flex w-fit items-center gap-2 text-xs/relaxed leading-snug font-medium group-data-[disabled=true]/field:opacity-50",
className,
)}
{...props}
@@ -131,8 +122,8 @@ function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
<p
data-slot="field-description"
className={cn(
"text-left font-normal text-muted-foreground text-xs/relaxed leading-normal group-has-data-horizontal/field:text-balance [[data-variant=legend]+&]:-mt-1.5",
"nth-last-2:-mt-1 last:mt-0",
"text-muted-foreground text-left text-xs/relaxed leading-normal font-normal group-has-data-horizontal/field:text-balance [[data-variant=legend]+&]:-mt-1.5",
"last:mt-0 nth-last-2:-mt-1",
"[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
className,
)}
@@ -161,7 +152,7 @@ function FieldSeparator({
<Separator className="absolute inset-0 top-1/2" />
{children && (
<span
className="relative mx-auto block w-fit bg-background px-2 text-muted-foreground"
className="bg-background text-muted-foreground relative mx-auto block w-fit px-2"
data-slot="field-separator-content"
>
{children}
@@ -188,28 +179,15 @@ function FieldError({
return null;
}
const uniqueErrors = [
...new Map(errors.map((error) => [error?.message, error])).values(),
];
const uniqueErrors = [...new Map(errors.map((error) => [error?.message, error])).values()];
// biome-ignore lint/suspicious/noDoubleEquals: shadcn generated
if (uniqueErrors?.length == 1) {
return uniqueErrors[0]?.message;
}
return (
<ul className="ml-4 flex list-disc flex-col gap-1">
{uniqueErrors.map(
(error, index) =>
error?.message && (
<li
// biome-ignore lint/suspicious/noArrayIndexKey: shadcn generated
key={index}
>
{error.message}
</li>
),
)}
{uniqueErrors.map((error, index) => error?.message && <li key={index}>{error.message}</li>)}
</ul>
);
}, [children, errors]);
@@ -222,7 +200,7 @@ function FieldError({
<div
role="alert"
data-slot="field-error"
className={cn("font-normal text-destructive text-xs/relaxed", className)}
className={cn("text-destructive text-xs/relaxed font-normal", className)}
{...props}
>
{content}
+3 -8
View File
@@ -7,9 +7,7 @@ function HoverCard({ ...props }: PreviewCardPrimitive.Root.Props) {
}
function HoverCardTrigger({ ...props }: PreviewCardPrimitive.Trigger.Props) {
return (
<PreviewCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
);
return <PreviewCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />;
}
function HoverCardContent({
@@ -20,10 +18,7 @@ function HoverCardContent({
alignOffset = 4,
...props
}: PreviewCardPrimitive.Popup.Props &
Pick<
PreviewCardPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
Pick<PreviewCardPrimitive.Positioner.Props, "align" | "alignOffset" | "side" | "sideOffset">) {
return (
<PreviewCardPrimitive.Portal data-slot="hover-card-portal">
<PreviewCardPrimitive.Positioner
@@ -36,7 +31,7 @@ function HoverCardContent({
<PreviewCardPrimitive.Popup
data-slot="hover-card-content"
className={cn(
"data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 z-50 w-72 origin-(--transform-origin) rounded-lg bg-popover p-2.5 text-popover-foreground text-xs/relaxed shadow-md outline-hidden ring-1 ring-foreground/10 duration-100 data-closed:animate-out data-open:animate-in",
"data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 bg-popover text-popover-foreground ring-foreground/10 data-closed:animate-out data-open:animate-in z-50 w-72 origin-(--transform-origin) rounded-lg p-2.5 text-xs/relaxed shadow-md ring-1 outline-hidden duration-100",
className,
)}
{...props}
+8 -18
View File
@@ -1,5 +1,6 @@
import { cva, type VariantProps } from "class-variance-authority";
import type * as React from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
@@ -7,12 +8,11 @@ import { cn } from "@/lib/utils";
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
// biome-ignore lint/a11y/useSemanticElements: shadcn generated
<div
data-slot="input-group"
role="group"
className={cn(
"group/input-group relative flex h-7 w-full min-w-0 items-center rounded-md border border-input bg-input/20 outline-none transition-colors in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-start]]:h-auto has-[>textarea]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:flex-col has-[textarea]:rounded-md has-data-[align=block-end]:rounded-md has-data-[align=block-start]:rounded-md has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot=input-group-control]:focus-visible]:ring-2 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/30 has-[[data-slot][aria-invalid=true]]:ring-2 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 dark:bg-input/30 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-start]]:[&>input]:pl-1.5",
"group/input-group border-input bg-input/20 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot=input-group-control]:focus-visible]:ring-ring/30 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 dark:bg-input/30 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 relative flex h-7 w-full min-w-0 items-center rounded-md border transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-data-[align=block-end]:rounded-md has-data-[align=block-start]:rounded-md has-[[data-slot=input-group-control]:focus-visible]:ring-2 has-[[data-slot][aria-invalid=true]]:ring-2 has-[textarea]:rounded-md has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5",
className,
)}
{...props}
@@ -21,14 +21,12 @@ function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
}
const inputGroupAddonVariants = cva(
"flex h-auto cursor-text select-none items-center justify-center gap-1 py-2 font-medium text-muted-foreground text-xs/relaxed **:data-[slot=kbd]:rounded-[calc(var(--radius-sm)-2px)] **:data-[slot=kbd]:bg-muted-foreground/10 **:data-[slot=kbd]:px-1 **:data-[slot=kbd]:text-[0.625rem] group-data-[disabled=true]/input-group:opacity-50 [&>svg:not([class*='size-'])]:size-3.5",
"text-muted-foreground **:data-[slot=kbd]:bg-muted-foreground/10 flex h-auto cursor-text items-center justify-center gap-1 py-2 text-xs/relaxed font-medium select-none group-data-[disabled=true]/input-group:opacity-50 **:data-[slot=kbd]:rounded-[calc(var(--radius-sm)-2px)] **:data-[slot=kbd]:px-1 **:data-[slot=kbd]:text-[0.625rem] [&>svg:not([class*='size-'])]:size-3.5",
{
variants: {
align: {
"inline-start":
"order-first pl-2 has-[>button]:ml-[-0.275rem] has-[>kbd]:ml-[-0.275rem]",
"inline-end":
"order-last pr-2 has-[>button]:mr-[-0.275rem] has-[>kbd]:mr-[-0.275rem]",
"inline-start": "order-first pl-2 has-[>button]:ml-[-0.275rem] has-[>kbd]:ml-[-0.275rem]",
"inline-end": "order-last pr-2 has-[>button]:mr-[-0.275rem] has-[>kbd]:mr-[-0.275rem]",
"block-start":
"order-first w-full justify-start px-2 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2",
"block-end":
@@ -47,8 +45,6 @@ function InputGroupAddon({
...props
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
return (
// biome-ignore lint/a11y/useKeyWithClickEvents: shadcn generated
// biome-ignore lint/a11y/useSemanticElements: shadcn generated
<div
role="group"
data-slot="input-group-addon"
@@ -107,7 +103,7 @@ function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
className={cn(
"flex items-center gap-2 text-muted-foreground text-xs/relaxed [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none",
"text-muted-foreground flex items-center gap-2 text-xs/relaxed [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
@@ -115,10 +111,7 @@ function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
);
}
function InputGroupInput({
className,
...props
}: React.ComponentProps<"input">) {
function InputGroupInput({ className, ...props }: React.ComponentProps<"input">) {
return (
<Input
data-slot="input-group-control"
@@ -131,10 +124,7 @@ function InputGroupInput({
);
}
function InputGroupTextarea({
className,
...props
}: React.ComponentProps<"textarea">) {
function InputGroupTextarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<Textarea
data-slot="input-group-control"
+1 -1
View File
@@ -9,7 +9,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
type={type}
data-slot="input"
className={cn(
"h-7 w-full min-w-0 rounded-md border border-input bg-input/20 px-2 py-0.5 text-sm outline-none transition-colors file:inline-flex file:h-6 file:border-0 file:bg-transparent file:font-medium file:text-foreground file:text-xs/relaxed placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/30 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-2 aria-invalid:ring-destructive/20 md:text-xs/relaxed dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
"border-input bg-input/20 file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/30 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 h-7 w-full min-w-0 rounded-md border px-2 py-0.5 text-sm transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-xs/relaxed file:font-medium focus-visible:ring-2 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:ring-2 md:text-xs/relaxed",
className,
)}
{...props}
+10 -23
View File
@@ -2,12 +2,12 @@ import { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from "@base-ui/react/use-render";
import { cva, type VariantProps } from "class-variance-authority";
import type * as React from "react";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";
function ItemGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
// biome-ignore lint/a11y/useSemanticElements: shadcn generated
<div
role="list"
data-slot="item-group"
@@ -20,10 +20,7 @@ function ItemGroup({ className, ...props }: React.ComponentProps<"div">) {
);
}
function ItemSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
function ItemSeparator({ className, ...props }: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="item-separator"
@@ -35,18 +32,18 @@ function ItemSeparator({
}
const itemVariants = cva(
"group/item flex w-full flex-wrap items-center rounded-md border text-xs/relaxed outline-none transition-colors duration-100 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 [a]:transition-colors [a]:hover:bg-muted",
"group/item focus-visible:border-ring focus-visible:ring-ring/50 [a]:hover:bg-muted flex w-full flex-wrap items-center rounded-md border text-xs/relaxed transition-colors duration-100 outline-none focus-visible:ring-[3px] [a]:transition-colors",
{
variants: {
variant: {
default: "border-transparent",
outline: "border-border",
muted: "border-transparent bg-muted/50",
muted: "bg-muted/50 border-transparent",
},
size: {
default: "gap-2.5 px-3 py-2.5",
sm: "gap-2.5 px-3 py-2.5",
xs: "gap-2.5 in-data-[slot=dropdown-menu-content]:p-0 px-2.5 py-2",
xs: "gap-2.5 px-2.5 py-2 in-data-[slot=dropdown-menu-content]:p-0",
},
},
defaultVariants: {
@@ -130,7 +127,7 @@ function ItemTitle({ className, ...props }: React.ComponentProps<"div">) {
<div
data-slot="item-title"
className={cn(
"line-clamp-1 flex w-fit items-center gap-2 font-medium text-xs/relaxed leading-snug underline-offset-4",
"line-clamp-1 flex w-fit items-center gap-2 text-xs/relaxed leading-snug font-medium underline-offset-4",
className,
)}
{...props}
@@ -143,7 +140,7 @@ function ItemDescription({ className, ...props }: React.ComponentProps<"p">) {
<p
data-slot="item-description"
className={cn(
"line-clamp-2 text-left font-normal text-muted-foreground text-xs/relaxed [&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
"text-muted-foreground [&>a:hover]:text-primary line-clamp-2 text-left text-xs/relaxed font-normal [&>a]:underline [&>a]:underline-offset-4",
className,
)}
{...props}
@@ -153,11 +150,7 @@ function ItemDescription({ className, ...props }: React.ComponentProps<"p">) {
function ItemActions({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-actions"
className={cn("flex items-center gap-2", className)}
{...props}
/>
<div data-slot="item-actions" className={cn("flex items-center gap-2", className)} {...props} />
);
}
@@ -165,10 +158,7 @@ function ItemHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-header"
className={cn(
"flex basis-full items-center justify-between gap-2",
className,
)}
className={cn("flex basis-full items-center justify-between gap-2", className)}
{...props}
/>
);
@@ -178,10 +168,7 @@ function ItemFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-footer"
className={cn(
"flex basis-full items-center justify-between gap-2",
className,
)}
className={cn("flex basis-full items-center justify-between gap-2", className)}
{...props}
/>
);
+1 -1
View File
@@ -5,7 +5,7 @@ function Kbd({ className, ...props }: React.ComponentProps<"kbd">) {
<kbd
data-slot="kbd"
className={cn(
"pointer-events-none inline-flex h-5 w-fit min-w-5 select-none items-center justify-center gap-1 rounded-xs bg-muted in-data-[slot=tooltip-content]:bg-background/20 px-1 font-medium font-sans in-data-[slot=tooltip-content]:text-background text-[0.625rem] text-muted-foreground dark:in-data-[slot=tooltip-content]:bg-background/10 [&_svg:not([class*='size-'])]:size-3",
"bg-muted in-data-[slot=tooltip-content]:bg-background/20 in-data-[slot=tooltip-content]:text-background text-muted-foreground dark:in-data-[slot=tooltip-content]:bg-background/10 pointer-events-none inline-flex h-5 w-fit min-w-5 items-center justify-center gap-1 rounded-xs px-1 font-sans text-[0.625rem] font-medium select-none [&_svg:not([class*='size-'])]:size-3",
className,
)}
{...props}
+1 -2
View File
@@ -4,11 +4,10 @@ import { cn } from "@/lib/utils";
function Label({ className, ...props }: React.ComponentProps<"label">) {
return (
// biome-ignore lint/a11y/noLabelWithoutControl: shadcn generated
<label
data-slot="label"
className={cn(
"flex select-none items-center gap-2 font-medium text-xs/relaxed leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-50 group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50",
"flex items-center gap-2 text-xs/relaxed leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className,
)}
{...props}
+17 -33
View File
@@ -2,6 +2,7 @@ import { Menu as MenuPrimitive } from "@base-ui/react/menu";
import { Menubar as MenubarPrimitive } from "@base-ui/react/menubar";
import { IconCheck } from "@tabler/icons-react";
import type * as React from "react";
import {
DropdownMenu,
DropdownMenuContent,
@@ -23,10 +24,7 @@ function Menubar({ className, ...props }: MenubarPrimitive.Props) {
return (
<MenubarPrimitive
data-slot="menubar"
className={cn(
"flex h-9 items-center rounded-lg border bg-background p-1",
className,
)}
className={cn("bg-background flex h-9 items-center rounded-lg border p-1", className)}
{...props}
/>
);
@@ -36,27 +34,20 @@ function MenubarMenu({ ...props }: React.ComponentProps<typeof DropdownMenu>) {
return <DropdownMenu data-slot="menubar-menu" {...props} />;
}
function MenubarGroup({
...props
}: React.ComponentProps<typeof DropdownMenuGroup>) {
function MenubarGroup({ ...props }: React.ComponentProps<typeof DropdownMenuGroup>) {
return <DropdownMenuGroup data-slot="menubar-group" {...props} />;
}
function MenubarPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPortal>) {
function MenubarPortal({ ...props }: React.ComponentProps<typeof DropdownMenuPortal>) {
return <DropdownMenuPortal data-slot="menubar-portal" {...props} />;
}
function MenubarTrigger({
className,
...props
}: React.ComponentProps<typeof DropdownMenuTrigger>) {
function MenubarTrigger({ className, ...props }: React.ComponentProps<typeof DropdownMenuTrigger>) {
return (
<DropdownMenuTrigger
data-slot="menubar-trigger"
className={cn(
"flex select-none items-center rounded-[calc(var(--radius-md)-2px)] px-2 py-[calc(--spacing(0.85))] font-medium text-xs/relaxed outline-hidden hover:bg-muted aria-expanded:bg-muted",
"hover:bg-muted aria-expanded:bg-muted flex items-center rounded-[calc(var(--radius-md)-2px)] px-2 py-[calc(--spacing(0.85))] text-xs/relaxed font-medium outline-hidden select-none",
className,
)}
{...props}
@@ -78,7 +69,7 @@ function MenubarContent({
alignOffset={alignOffset}
sideOffset={sideOffset}
className={cn(
"data-open:fade-in-0 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 min-w-32 rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-open:animate-in",
"data-open:fade-in-0 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 bg-popover text-popover-foreground ring-foreground/10 data-open:animate-in min-w-32 rounded-lg p-1 shadow-md ring-1 duration-100",
className,
)}
{...props}
@@ -98,7 +89,7 @@ function MenubarItem({
data-inset={inset}
data-variant={variant}
className={cn(
"group/menubar-item min-h-7 gap-2 rounded-md px-2 py-1 text-xs/relaxed focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7.5 data-[variant=destructive]:text-destructive data-disabled:opacity-50 data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg:not([class*='size-'])]:size-3.5 data-[variant=destructive]:*:[svg]:text-destructive!",
"group/menubar-item focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:*:[svg]:text-destructive! min-h-7 gap-2 rounded-md px-2 py-1 text-xs/relaxed data-disabled:opacity-50 data-inset:pl-7.5 [&_svg:not([class*='size-'])]:size-3.5",
className,
)}
{...props}
@@ -120,7 +111,7 @@ function MenubarCheckboxItem({
data-slot="menubar-checkbox-item"
data-inset={inset}
className={cn(
"relative flex min-h-7 cursor-default select-none items-center gap-2 rounded-md py-1.5 pr-2 pl-7.5 text-xs outline-hidden focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-disabled:pointer-events-none data-inset:pl-7.5 data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
"focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground relative flex min-h-7 cursor-default items-center gap-2 rounded-md py-1.5 pr-2 pl-7.5 text-xs outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-inset:pl-7.5 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className,
)}
checked={checked}
@@ -136,9 +127,7 @@ function MenubarCheckboxItem({
);
}
function MenubarRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuRadioGroup>) {
function MenubarRadioGroup({ ...props }: React.ComponentProps<typeof DropdownMenuRadioGroup>) {
return <DropdownMenuRadioGroup data-slot="menubar-radio-group" {...props} />;
}
@@ -155,7 +144,7 @@ function MenubarRadioItem({
data-slot="menubar-radio-item"
data-inset={inset}
className={cn(
"relative flex min-h-7 cursor-default select-none items-center gap-2 rounded-md py-1.5 pr-2 pl-7.5 text-xs outline-hidden focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-disabled:pointer-events-none data-inset:pl-7.5 data-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-3.5 [&_svg]:pointer-events-none [&_svg]:shrink-0",
"focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground relative flex min-h-7 cursor-default items-center gap-2 rounded-md py-1.5 pr-2 pl-7.5 text-xs outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-inset:pl-7.5 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
className,
)}
{...props}
@@ -181,10 +170,7 @@ function MenubarLabel({
<DropdownMenuLabel
data-slot="menubar-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-muted-foreground text-xs data-inset:pl-7.5",
className,
)}
className={cn("text-muted-foreground px-2 py-1.5 text-xs data-inset:pl-7.5", className)}
{...props}
/>
);
@@ -197,7 +183,7 @@ function MenubarSeparator({
return (
<DropdownMenuSeparator
data-slot="menubar-separator"
className={cn("-mx-1 my-1 h-px bg-border/50", className)}
className={cn("bg-border/50 -mx-1 my-1 h-px", className)}
{...props}
/>
);
@@ -211,7 +197,7 @@ function MenubarShortcut({
<DropdownMenuShortcut
data-slot="menubar-shortcut"
className={cn(
"ml-auto text-[0.625rem] text-muted-foreground tracking-widest group-focus/menubar-item:text-accent-foreground",
"text-muted-foreground group-focus/menubar-item:text-accent-foreground ml-auto text-[0.625rem] tracking-widest",
className,
)}
{...props}
@@ -219,9 +205,7 @@ function MenubarShortcut({
);
}
function MenubarSub({
...props
}: React.ComponentProps<typeof DropdownMenuSub>) {
function MenubarSub({ ...props }: React.ComponentProps<typeof DropdownMenuSub>) {
return <DropdownMenuSub data-slot="menubar-sub" {...props} />;
}
@@ -237,7 +221,7 @@ function MenubarSubTrigger({
data-slot="menubar-sub-trigger"
data-inset={inset}
className={cn(
"min-h-7 gap-2 rounded-md px-2 py-1 text-xs focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-open:bg-accent data-inset:pl-7.5 data-open:text-accent-foreground [&_svg:not([class*='size-'])]:size-3.5",
"focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground min-h-7 gap-2 rounded-md px-2 py-1 text-xs data-inset:pl-7.5 [&_svg:not([class*='size-'])]:size-3.5",
className,
)}
{...props}
@@ -253,7 +237,7 @@ function MenubarSubContent({
<DropdownMenuSubContent
data-slot="menubar-sub-content"
className={cn(
"data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 min-w-32 rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-closed:animate-out data-open:animate-in",
"data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 bg-popover text-popover-foreground ring-foreground/10 data-closed:animate-out data-open:animate-in min-w-32 rounded-lg p-1 shadow-md ring-1 duration-100",
className,
)}
{...props}
+11 -20
View File
@@ -1,6 +1,7 @@
import { NavigationMenu as NavigationMenuPrimitive } from "@base-ui/react/navigation-menu";
import { IconChevronDown } from "@tabler/icons-react";
import { cva } from "class-variance-authority";
import { cn } from "@/lib/utils";
function NavigationMenu({
@@ -8,8 +9,7 @@ function NavigationMenu({
className,
children,
...props
}: NavigationMenuPrimitive.Root.Props &
Pick<NavigationMenuPrimitive.Positioner.Props, "align">) {
}: NavigationMenuPrimitive.Root.Props & Pick<NavigationMenuPrimitive.Positioner.Props, "align">) {
return (
<NavigationMenuPrimitive.Root
data-slot="navigation-menu"
@@ -32,10 +32,7 @@ function NavigationMenuList({
return (
<NavigationMenuPrimitive.List
data-slot="navigation-menu-list"
className={cn(
"group flex flex-1 list-none items-center justify-center gap-0",
className,
)}
className={cn("group flex flex-1 list-none items-center justify-center gap-0", className)}
{...props}
/>
);
@@ -55,7 +52,7 @@ function NavigationMenuItem({
}
const navigationMenuTriggerStyle = cva(
"group/navigation-menu-trigger inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-2.5 py-1.5 font-medium text-xs/relaxed outline-none transition-all hover:bg-muted focus:bg-muted focus-visible:outline-1 focus-visible:ring-2 focus-visible:ring-ring/30 disabled:pointer-events-none disabled:opacity-50 data-open:bg-muted/50 data-popup-open:bg-muted/50 data-open:focus:bg-muted data-open:hover:bg-muted data-popup-open:hover:bg-muted",
"group/navigation-menu-trigger bg-background hover:bg-muted focus:bg-muted focus-visible:ring-ring/30 data-open:bg-muted/50 data-popup-open:bg-muted/50 data-open:focus:bg-muted data-open:hover:bg-muted data-popup-open:hover:bg-muted inline-flex h-9 w-max items-center justify-center rounded-md px-2.5 py-1.5 text-xs/relaxed font-medium transition-all outline-none focus-visible:ring-2 focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50",
);
function NavigationMenuTrigger({
@@ -78,15 +75,12 @@ function NavigationMenuTrigger({
);
}
function NavigationMenuContent({
className,
...props
}: NavigationMenuPrimitive.Content.Props) {
function NavigationMenuContent({ className, ...props }: NavigationMenuPrimitive.Content.Props) {
return (
<NavigationMenuPrimitive.Content
data-slot="navigation-menu-content"
className={cn(
"data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 group-data-[viewport=false]/navigation-menu:data-closed:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-open:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-open:fade-in-0 group-data-[viewport=false]/navigation-menu:data-closed:fade-out-0 h-full w-auto p-1.5 transition-[opacity,transform,translate] duration-[0.35s] ease-[cubic-bezier(0.22,1,0.36,1)] data-ending-style:data-activation-direction=left:translate-x-[50%] data-ending-style:data-activation-direction=right:translate-x-[-50%] data-starting-style:data-activation-direction=left:translate-x-[-50%] data-starting-style:data-activation-direction=right:translate-x-[50%] data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-ending-style:opacity-0 data-starting-style:opacity-0 **:data-[slot=navigation-menu-link]:focus:outline-none **:data-[slot=navigation-menu-link]:focus:ring-0 group-data-[viewport=false]/navigation-menu:rounded-xl group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:shadow-md group-data-[viewport=false]/navigation-menu:ring-1 group-data-[viewport=false]/navigation-menu:ring-foreground/10 group-data-[viewport=false]/navigation-menu:duration-300 group-data-[viewport=false]/navigation-menu:data-closed:animate-out group-data-[viewport=false]/navigation-menu:data-open:animate-in",
"data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 group-data-[viewport=false]/navigation-menu:data-closed:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-open:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-open:fade-in-0 group-data-[viewport=false]/navigation-menu:data-closed:fade-out-0 data-ending-style:data-activation-direction=left:translate-x-[50%] data-ending-style:data-activation-direction=right:translate-x-[-50%] data-starting-style:data-activation-direction=left:translate-x-[-50%] data-starting-style:data-activation-direction=right:translate-x-[50%] data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:ring-foreground/10 group-data-[viewport=false]/navigation-menu:data-closed:animate-out group-data-[viewport=false]/navigation-menu:data-open:animate-in h-full w-auto p-1.5 transition-[opacity,transform,translate] duration-[0.35s] ease-[cubic-bezier(0.22,1,0.36,1)] group-data-[viewport=false]/navigation-menu:rounded-xl group-data-[viewport=false]/navigation-menu:shadow-md group-data-[viewport=false]/navigation-menu:ring-1 group-data-[viewport=false]/navigation-menu:duration-300 data-ending-style:opacity-0 data-starting-style:opacity-0 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none",
className,
)}
{...props}
@@ -115,7 +109,7 @@ function NavigationMenuPositioner({
)}
{...props}
>
<NavigationMenuPrimitive.Popup className="data-[ending-style]:easing-[ease] relative h-(--popup-height) w-(--popup-width) xs:w-(--popup-width) origin-(--transform-origin) rounded-xl bg-popover text-popover-foreground shadow outline-none ring-1 ring-foreground/10 transition-[opacity,transform,width,height,scale,translate] duration-[0.35s] ease-[cubic-bezier(0.22,1,0.36,1)] data-ending-style:scale-90 data-starting-style:scale-90 data-ending-style:opacity-0 data-starting-style:opacity-0 data-ending-style:duration-150">
<NavigationMenuPrimitive.Popup className="data-[ending-style]:easing-[ease] xs:w-(--popup-width) bg-popover text-popover-foreground ring-foreground/10 relative h-(--popup-height) w-(--popup-width) origin-(--transform-origin) rounded-xl shadow ring-1 transition-[opacity,transform,width,height,scale,translate] duration-[0.35s] ease-[cubic-bezier(0.22,1,0.36,1)] outline-none data-ending-style:scale-90 data-ending-style:opacity-0 data-ending-style:duration-150 data-starting-style:scale-90 data-starting-style:opacity-0">
<NavigationMenuPrimitive.Viewport className="relative size-full overflow-hidden" />
</NavigationMenuPrimitive.Popup>
</NavigationMenuPrimitive.Positioner>
@@ -123,15 +117,12 @@ function NavigationMenuPositioner({
);
}
function NavigationMenuLink({
className,
...props
}: NavigationMenuPrimitive.Link.Props) {
function NavigationMenuLink({ className, ...props }: NavigationMenuPrimitive.Link.Props) {
return (
<NavigationMenuPrimitive.Link
data-slot="navigation-menu-link"
className={cn(
"flex items-center gap-1.5 rounded-lg p-2 text-xs/relaxed outline-none transition-all hover:bg-muted focus:bg-muted focus-visible:outline-1 focus-visible:ring-2 focus-visible:ring-ring/30 data-[active=true]:bg-muted/50 data-[active=true]:focus:bg-muted data-[active=true]:hover:bg-muted [&_svg:not([class*='size-'])]:size-4",
"hover:bg-muted focus:bg-muted focus-visible:ring-ring/30 data-[active=true]:bg-muted/50 data-[active=true]:focus:bg-muted data-[active=true]:hover:bg-muted flex items-center gap-1.5 rounded-lg p-2 text-xs/relaxed transition-all outline-none focus-visible:ring-2 focus-visible:outline-1 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
@@ -147,12 +138,12 @@ function NavigationMenuIndicator({
<NavigationMenuPrimitive.Icon
data-slot="navigation-menu-indicator"
className={cn(
"data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-1 flex h-1.5 items-end justify-center overflow-hidden data-[state=hidden]:animate-out data-[state=visible]:animate-in",
"data-[state=hidden]:fade-out data-[state=visible]:fade-in data-[state=hidden]:animate-out data-[state=visible]:animate-in top-full z-1 flex h-1.5 items-end justify-center overflow-hidden",
className,
)}
{...props}
>
<div className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-border shadow-md" />
<div className="bg-border relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm shadow-md" />
</NavigationMenuPrimitive.Icon>
);
}
+5 -19
View File
@@ -1,9 +1,6 @@
import {
IconChevronLeft,
IconChevronRight,
IconDots,
} from "@tabler/icons-react";
import { IconChevronLeft, IconChevronRight, IconDots } from "@tabler/icons-react";
import type * as React from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
@@ -18,10 +15,7 @@ function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
);
}
function PaginationContent({
className,
...props
}: React.ComponentProps<"ul">) {
function PaginationContent({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="pagination-content"
@@ -40,12 +34,7 @@ type PaginationLinkProps = {
} & Pick<React.ComponentProps<typeof Button>, "size"> &
React.ComponentProps<"a">;
function PaginationLink({
className,
isActive,
size = "icon",
...props
}: PaginationLinkProps) {
function PaginationLink({ className, isActive, size = "icon", ...props }: PaginationLinkProps) {
return (
<Button
variant={isActive ? "outline" : "ghost"}
@@ -100,10 +89,7 @@ function PaginationNext({
);
}
function PaginationEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
function PaginationEllipsis({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
aria-hidden
+5 -18
View File
@@ -19,10 +19,7 @@ function PopoverContent({
sideOffset = 4,
...props
}: PopoverPrimitive.Popup.Props &
Pick<
PopoverPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
Pick<PopoverPrimitive.Positioner.Props, "align" | "alignOffset" | "side" | "sideOffset">) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Positioner
@@ -35,7 +32,7 @@ function PopoverContent({
<PopoverPrimitive.Popup
data-slot="popover-content"
className={cn(
"data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 z-50 flex w-72 origin-(--transform-origin) flex-col gap-4 rounded-lg bg-popover p-2.5 text-popover-foreground text-xs shadow-md outline-hidden ring-1 ring-foreground/10 duration-100 data-closed:animate-out data-open:animate-in",
"data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 bg-popover text-popover-foreground ring-foreground/10 data-closed:animate-out data-open:animate-in z-50 flex w-72 origin-(--transform-origin) flex-col gap-4 rounded-lg p-2.5 text-xs shadow-md ring-1 outline-hidden duration-100",
className,
)}
{...props}
@@ -59,16 +56,13 @@ function PopoverTitle({ className, ...props }: PopoverPrimitive.Title.Props) {
return (
<PopoverPrimitive.Title
data-slot="popover-title"
className={cn("font-medium text-sm", className)}
className={cn("text-sm font-medium", className)}
{...props}
/>
);
}
function PopoverDescription({
className,
...props
}: PopoverPrimitive.Description.Props) {
function PopoverDescription({ className, ...props }: PopoverPrimitive.Description.Props) {
return (
<PopoverPrimitive.Description
data-slot="popover-description"
@@ -78,11 +72,4 @@ function PopoverDescription({
);
}
export {
Popover,
PopoverContent,
PopoverDescription,
PopoverHeader,
PopoverTitle,
PopoverTrigger,
};
export { Popover, PopoverContent, PopoverDescription, PopoverHeader, PopoverTitle, PopoverTrigger };
+7 -24
View File
@@ -2,12 +2,7 @@ import { Progress as ProgressPrimitive } from "@base-ui/react/progress";
import { cn } from "@/lib/utils";
function Progress({
className,
children,
value,
...props
}: ProgressPrimitive.Root.Props) {
function Progress({ className, children, value, ...props }: ProgressPrimitive.Root.Props) {
return (
<ProgressPrimitive.Root
value={value}
@@ -27,7 +22,7 @@ function ProgressTrack({ className, ...props }: ProgressPrimitive.Track.Props) {
return (
<ProgressPrimitive.Track
className={cn(
"relative flex h-1 w-full items-center overflow-x-hidden rounded-md bg-muted",
"bg-muted relative flex h-1 w-full items-center overflow-x-hidden rounded-md",
className,
)}
data-slot="progress-track"
@@ -36,14 +31,11 @@ function ProgressTrack({ className, ...props }: ProgressPrimitive.Track.Props) {
);
}
function ProgressIndicator({
className,
...props
}: ProgressPrimitive.Indicator.Props) {
function ProgressIndicator({ className, ...props }: ProgressPrimitive.Indicator.Props) {
return (
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className={cn("h-full bg-primary transition-all", className)}
className={cn("bg-primary h-full transition-all", className)}
{...props}
/>
);
@@ -52,7 +44,7 @@ function ProgressIndicator({
function ProgressLabel({ className, ...props }: ProgressPrimitive.Label.Props) {
return (
<ProgressPrimitive.Label
className={cn("font-medium text-xs/relaxed", className)}
className={cn("text-xs/relaxed font-medium", className)}
data-slot="progress-label"
{...props}
/>
@@ -62,20 +54,11 @@ function ProgressLabel({ className, ...props }: ProgressPrimitive.Label.Props) {
function ProgressValue({ className, ...props }: ProgressPrimitive.Value.Props) {
return (
<ProgressPrimitive.Value
className={cn(
"ml-auto text-muted-foreground text-xs/relaxed tabular-nums",
className,
)}
className={cn("text-muted-foreground ml-auto text-xs/relaxed tabular-nums", className)}
data-slot="progress-value"
{...props}
/>
);
}
export {
Progress,
ProgressIndicator,
ProgressLabel,
ProgressTrack,
ProgressValue,
};
export { Progress, ProgressIndicator, ProgressLabel, ProgressTrack, ProgressValue };
+2 -2
View File
@@ -18,7 +18,7 @@ function RadioGroupItem({ className, ...props }: RadioPrimitive.Root.Props) {
<RadioPrimitive.Root
data-slot="radio-group-item"
className={cn(
"group/radio-group-item peer relative flex aspect-square size-4 shrink-0 rounded-full border border-input outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:bg-input/30 dark:data-checked:bg-primary dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
"group/radio-group-item peer border-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:bg-input/30 dark:data-checked:bg-primary dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 relative flex aspect-square size-4 shrink-0 rounded-full border outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:ring-3 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:ring-3",
className,
)}
{...props}
@@ -27,7 +27,7 @@ function RadioGroupItem({ className, ...props }: RadioPrimitive.Root.Props) {
data-slot="radio-group-indicator"
className="flex size-4 items-center justify-center"
>
<span className="absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary-foreground" />
<span className="bg-primary-foreground absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2 rounded-full" />
</RadioPrimitive.Indicator>
</RadioPrimitive.Root>
);
+6 -9
View File
@@ -1,4 +1,5 @@
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area";
import { cn } from "@/lib/utils";
function ScrollArea({
@@ -32,17 +33,13 @@ function ScrollArea({
data-slot="scroll-area-viewport"
ref={scrollRef}
className={cn(
"no-scrollbar flex-1 rounded-[inherit] outline-none focus-visible:outline-1 focus-visible:ring-[3px] focus-visible:ring-ring/50 data-has-overflow-x:overscroll-x-contain",
"no-scrollbar focus-visible:ring-ring/50 flex-1 rounded-[inherit] outline-none focus-visible:ring-[3px] focus-visible:outline-1 data-has-overflow-x:overscroll-x-contain",
scrollFade &&
"mask-t-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-y-start)))] mask-b-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-y-end)))] mask-l-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-x-start)))] mask-r-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-x-end)))] [--fade-size:1.5rem]",
scrollbarGutter &&
"data-has-overflow-y:pr-2.5 data-has-overflow-x:pb-2.5",
"mask-t-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-y-start)))] mask-r-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-x-end)))] mask-b-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-y-end)))] mask-l-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-x-start)))] [--fade-size:1.5rem]",
scrollbarGutter && "data-has-overflow-x:pb-2.5 data-has-overflow-y:pr-2.5",
)}
>
<ScrollAreaPrimitive.Content
data-slot="scroll-area-content"
ref={contentRef}
>
<ScrollAreaPrimitive.Content data-slot="scroll-area-content" ref={contentRef}>
{children}
</ScrollAreaPrimitive.Content>
</ScrollAreaPrimitive.Viewport>
@@ -67,7 +64,7 @@ function ScrollBar({
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
"m-1 flex opacity-0 transition-opacity delay-300 data-[orientation=horizontal]:h-1 data-[orientation=vertical]:w-1 data-[orientation=horizontal]:flex-col data-hovering:opacity-100 data-scrolling:opacity-100 data-hovering:delay-0 data-scrolling:delay-0 data-hovering:duration-100 data-scrolling:duration-100",
"m-1 flex opacity-0 transition-opacity delay-300 data-hovering:opacity-100 data-hovering:delay-0 data-hovering:duration-100 data-scrolling:opacity-100 data-scrolling:delay-0 data-scrolling:duration-100 data-[orientation=horizontal]:h-1 data-[orientation=horizontal]:flex-col data-[orientation=vertical]:w-1",
className,
)}
{...props}
+13 -32
View File
@@ -1,11 +1,7 @@
import { Select as SelectPrimitive } from "@base-ui/react/select";
import {
IconCheck,
IconChevronDown,
IconChevronUp,
IconSelector,
} from "@tabler/icons-react";
import { IconCheck, IconChevronDown, IconChevronUp, IconSelector } from "@tabler/icons-react";
import type * as React from "react";
import { cn } from "@/lib/utils";
const Select = SelectPrimitive.Root;
@@ -43,16 +39,14 @@ function SelectTrigger({
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-1.5 whitespace-nowrap rounded-md border border-input bg-input/20 px-2 py-1.5 text-xs/relaxed outline-none transition-colors focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/30 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-2 aria-invalid:ring-destructive/20 data-[size=default]:h-7 data-[size=sm]:h-6 data-placeholder:text-muted-foreground *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 dark:hover:bg-input/50 [&_svg:not([class*='size-'])]:size-3.5 [&_svg]:pointer-events-none [&_svg]:shrink-0",
"border-input bg-input/20 focus-visible:border-ring focus-visible:ring-ring/30 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-1.5 rounded-md border px-2 py-1.5 text-xs/relaxed whitespace-nowrap transition-colors outline-none focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:ring-2 data-[size=default]:h-7 data-[size=sm]:h-6 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon
render={
<IconSelector className="pointer-events-none size-3.5 text-muted-foreground" />
}
render={<IconSelector className="text-muted-foreground pointer-events-none size-3.5" />}
/>
</SelectPrimitive.Trigger>
);
@@ -86,7 +80,7 @@ function SelectContent({
data-slot="select-content"
data-align-trigger={alignItemWithTrigger}
className={cn(
"data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-y-auto overflow-x-hidden rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-closed:animate-out data-open:animate-in",
"data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 bg-popover text-popover-foreground ring-foreground/10 data-closed:animate-out data-open:animate-in relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg shadow-md ring-1 duration-100 data-[align-trigger=true]:animate-none",
className,
)}
{...props}
@@ -100,29 +94,22 @@ function SelectContent({
);
}
function SelectLabel({
className,
...props
}: SelectPrimitive.GroupLabel.Props) {
function SelectLabel({ className, ...props }: SelectPrimitive.GroupLabel.Props) {
return (
<SelectPrimitive.GroupLabel
data-slot="select-label"
className={cn("px-2 py-1.5 text-muted-foreground text-xs", className)}
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
);
}
function SelectItem({
className,
children,
...props
}: SelectPrimitive.Item.Props) {
function SelectItem({ className, children, ...props }: SelectPrimitive.Item.Props) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex min-h-7 w-full cursor-default select-none items-center gap-2 rounded-md px-2 py-1 text-xs/relaxed outline-hidden focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-3.5 [&_svg]:pointer-events-none [&_svg]:shrink-0 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
"focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground relative flex min-h-7 w-full cursor-default items-center gap-2 rounded-md px-2 py-1 text-xs/relaxed outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className,
)}
{...props}
@@ -141,17 +128,11 @@ function SelectItem({
);
}
function SelectSeparator({
className,
...props
}: SelectPrimitive.Separator.Props) {
function SelectSeparator({ className, ...props }: SelectPrimitive.Separator.Props) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn(
"pointer-events-none -mx-1 my-1 h-px bg-border/50",
className,
)}
className={cn("bg-border/50 pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
);
@@ -165,7 +146,7 @@ function SelectScrollUpButton({
<SelectPrimitive.ScrollUpArrow
data-slot="select-scroll-up-button"
className={cn(
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-3.5",
"bg-popover top-0 z-10 flex w-full cursor-default items-center justify-center py-1 [&_svg:not([class*='size-'])]:size-3.5",
className,
)}
{...props}
@@ -183,7 +164,7 @@ function SelectScrollDownButton({
<SelectPrimitive.ScrollDownArrow
data-slot="select-scroll-down-button"
className={cn(
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-3.5",
"bg-popover bottom-0 z-10 flex w-full cursor-default items-center justify-center py-1 [&_svg:not([class*='size-'])]:size-3.5",
className,
)}
{...props}
+2 -6
View File
@@ -2,17 +2,13 @@ import { Separator as SeparatorPrimitive } from "@base-ui/react/separator";
import { cn } from "@/lib/utils";
function Separator({
className,
orientation = "horizontal",
...props
}: SeparatorPrimitive.Props) {
function Separator({ className, orientation = "horizontal", ...props }: SeparatorPrimitive.Props) {
return (
<SeparatorPrimitive
data-slot="separator"
orientation={orientation}
className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
"bg-border shrink-0 data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className,
)}
{...props}
+6 -14
View File
@@ -1,6 +1,7 @@
import { Dialog as SheetPrimitive } from "@base-ui/react/dialog";
import { IconX } from "@tabler/icons-react";
import type * as React from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
@@ -25,7 +26,7 @@ function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
<SheetPrimitive.Backdrop
data-slot="sheet-overlay"
className={cn(
"data-closed:fade-out-0 data-open:fade-in-0 fixed inset-0 z-50 bg-black/80 duration-100 data-closed:animate-out data-open:animate-in data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs",
"data-closed:fade-out-0 data-open:fade-in-0 data-closed:animate-out data-open:animate-in fixed inset-0 z-50 bg-black/80 duration-100 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs",
className,
)}
{...props}
@@ -50,7 +51,7 @@ function SheetContent({
data-slot="sheet-content"
data-side={side}
className={cn(
"data-[side=right]:data-closed:slide-out-to-right-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=top]:data-closed:slide-out-to-top-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:fade-out-0 data-open:fade-in-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=bottom]:data-open:slide-in-from-bottom-10 fixed z-50 flex flex-col bg-background bg-clip-padding text-xs/relaxed shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=top]:inset-x-0 data-[side=left]:inset-y-0 data-[side=right]:inset-y-0 data-[side=top]:top-0 data-[side=right]:right-0 data-[side=bottom]:bottom-0 data-[side=left]:left-0 data-[side=bottom]:h-auto data-[side=left]:h-full data-[side=right]:h-full data-[side=top]:h-auto data-[side=left]:w-3/4 data-[side=right]:w-3/4 data-closed:animate-out data-open:animate-in data-[side=bottom]:border-t data-[side=left]:border-r data-[side=top]:border-b data-[side=right]:border-l data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm",
"data-[side=right]:data-closed:slide-out-to-right-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=top]:data-closed:slide-out-to-top-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:fade-out-0 data-open:fade-in-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=bottom]:data-open:slide-in-from-bottom-10 bg-background data-closed:animate-out data-open:animate-in fixed z-50 flex flex-col bg-clip-padding text-xs/relaxed shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm",
className,
)}
{...props}
@@ -59,13 +60,7 @@ function SheetContent({
{showCloseButton && (
<SheetPrimitive.Close
data-slot="sheet-close"
render={
<Button
variant="ghost"
className="absolute top-4 right-4"
size="icon-sm"
/>
}
render={<Button variant="ghost" className="absolute top-4 right-4" size="icon-sm" />}
>
<IconX />
<span className="sr-only">Close</span>
@@ -100,16 +95,13 @@ function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("font-medium text-foreground text-sm", className)}
className={cn("text-foreground text-sm font-medium", className)}
{...props}
/>
);
}
function SheetDescription({
className,
...props
}: SheetPrimitive.Description.Props) {
function SheetDescription({ className, ...props }: SheetPrimitive.Description.Props) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
+32 -71
View File
@@ -3,6 +3,7 @@ import { useRender } from "@base-ui/react/use-render";
import { IconLayoutSidebar } from "@tabler/icons-react";
import { cva, type VariantProps } from "class-variance-authority";
import * as React from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
@@ -14,11 +15,7 @@ import {
SheetTitle,
} from "@/components/ui/sheet";
import { Skeleton } from "@/components/ui/skeleton";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useIsMobile } from "@/hooks/use-mobile";
import { cn } from "@/lib/utils";
@@ -80,25 +77,20 @@ function SidebarProvider({
}
// This sets the cookie to keep the sidebar state.
// biome-ignore lint/suspicious/noDocumentCookie: shadcn generated
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
},
[setOpenProp, open],
);
// Helper to toggle the sidebar.
// biome-ignore lint/correctness/useExhaustiveDependencies: shadcn generated
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);
return isMobile ? setOpenMobile((prev) => !prev) : setOpen((prev) => !prev);
}, [isMobile, setOpen, setOpenMobile]);
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
toggleSidebar();
}
@@ -112,7 +104,6 @@ function SidebarProvider({
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed";
// biome-ignore lint/correctness/useExhaustiveDependencies: shadcn generated
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
@@ -138,7 +129,7 @@ function SidebarProvider({
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar",
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
className,
)}
{...props}
@@ -169,7 +160,7 @@ function Sidebar({
<div
data-slot="sidebar"
className={cn(
"flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground",
"bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
className,
)}
{...props}
@@ -187,7 +178,7 @@ function Sidebar({
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className="w-(--sidebar-width) bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
@@ -207,7 +198,7 @@ function Sidebar({
return (
<div
className="group peer hidden text-sidebar-foreground md:block"
className="group peer text-sidebar-foreground hidden md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
@@ -230,7 +221,7 @@ function Sidebar({
data-slot="sidebar-container"
data-side={side}
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear data-[side=right]:right-0 data-[side=left]:left-0 data-[side=right]:group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)] data-[side=left]:group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)] md:flex",
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear data-[side=left]:left-0 data-[side=left]:group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)] data-[side=right]:right-0 data-[side=right]:group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)] md:flex",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
@@ -242,7 +233,7 @@ function Sidebar({
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
className="flex size-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:shadow-sm group-data-[variant=floating]:ring-1 group-data-[variant=floating]:ring-sidebar-border"
className="bg-sidebar group-data-[variant=floating]:ring-sidebar-border flex size-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:shadow-sm group-data-[variant=floating]:ring-1"
>
{children}
</div>
@@ -251,11 +242,7 @@ function Sidebar({
);
}
function SidebarTrigger({
className,
onClick,
...props
}: React.ComponentProps<typeof Button>) {
function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar();
return (
@@ -289,10 +276,10 @@ function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2",
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"group-data-[collapsible=offcanvas]:translate-x-0 hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:after:left-full",
"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className,
@@ -307,7 +294,7 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
<main
data-slot="sidebar-inset"
className={cn(
"relative flex w-full flex-1 flex-col bg-background md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2 md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm",
"bg-background relative flex w-full flex-1 flex-col md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className,
)}
{...props}
@@ -315,18 +302,12 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
);
}
function SidebarInput({
className,
...props
}: React.ComponentProps<typeof Input>) {
function SidebarInput({ className, ...props }: React.ComponentProps<typeof Input>) {
return (
<Input
data-slot="sidebar-input"
data-sidebar="input"
className={cn(
"h-8 w-full border-input bg-muted/20 dark:bg-muted/30",
className,
)}
className={cn("border-input bg-muted/20 dark:bg-muted/30 h-8 w-full", className)}
{...props}
/>
);
@@ -354,15 +335,12 @@ function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
);
}
function SidebarSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
function SidebarSeparator({ className, ...props }: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="sidebar-separator"
data-sidebar="separator"
className={cn("mx-2 w-auto bg-sidebar-border", className)}
className={cn("bg-sidebar-border mx-2 w-auto", className)}
{...props}
/>
);
@@ -387,10 +365,7 @@ function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
<div
data-slot="sidebar-group"
data-sidebar="group"
className={cn(
"relative flex w-full min-w-0 flex-col px-2 py-1",
className,
)}
className={cn("relative flex w-full min-w-0 flex-col px-2 py-1", className)}
{...props}
/>
);
@@ -406,7 +381,7 @@ function SidebarGroupLabel({
props: mergeProps<"div">(
{
className: cn(
"flex h-8 shrink-0 items-center rounded-md px-2 text-sidebar-foreground/70 text-xs outline-hidden ring-sidebar-ring transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 [&>svg]:size-4 [&>svg]:shrink-0",
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs outline-hidden transition-[margin,opacity] duration-200 ease-linear group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
className,
),
},
@@ -430,7 +405,7 @@ function SidebarGroupAction({
props: mergeProps<"button">(
{
className: cn(
"absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-hidden ring-sidebar-ring transition-transform after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 group-data-[collapsible=icon]:hidden md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
className,
),
},
@@ -444,10 +419,7 @@ function SidebarGroupAction({
});
}
function SidebarGroupContent({
className,
...props
}: React.ComponentProps<"div">) {
function SidebarGroupContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group-content"
@@ -481,13 +453,13 @@ function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
}
const sidebarMenuButtonVariants = cva(
"peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden rounded-[calc(var(--radius-sm)+2px)] p-2 text-left text-xs outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-active:bg-sidebar-accent data-active:font-medium data-active:text-sidebar-accent-foreground data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&_svg]:size-4 [&_svg]:shrink-0",
"peer/menu-button group/menu-button ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground flex w-full items-center gap-2 overflow-hidden rounded-[calc(var(--radius-sm)+2px)] p-2 text-left text-xs outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-active:font-medium [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
"bg-background hover:bg-sidebar-accent hover:text-sidebar-accent-foreground shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-xs",
@@ -570,9 +542,9 @@ function SidebarMenuAction({
props: mergeProps<"button">(
{
className: cn(
"absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-[calc(var(--radius-sm)-2px)] p-0 text-sidebar-foreground outline-hidden ring-sidebar-ring transition-transform after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground group-data-[collapsible=icon]:hidden peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-[calc(var(--radius-sm)-2px)] p-0 outline-hidden transition-transform group-data-[collapsible=icon]:hidden peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 after:absolute after:-inset-2 focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
showOnHover &&
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 aria-expanded:opacity-100 peer-data-active/menu-button:text-sidebar-accent-foreground md:opacity-0",
"peer-data-active/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 aria-expanded:opacity-100 md:opacity-0",
className,
),
},
@@ -586,16 +558,13 @@ function SidebarMenuAction({
});
}
function SidebarMenuBadge({
className,
...props
}: React.ComponentProps<"div">) {
function SidebarMenuBadge({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
className={cn(
"pointer-events-none absolute right-1 flex h-5 min-w-5 select-none items-center justify-center rounded-[calc(var(--radius-sm)-2px)] px-1 font-medium text-sidebar-foreground text-xs tabular-nums peer-hover/menu-button:text-sidebar-accent-foreground group-data-[collapsible=icon]:hidden peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 peer-data-active/menu-button:text-sidebar-accent-foreground",
"text-sidebar-foreground peer-hover/menu-button:text-sidebar-accent-foreground peer-data-active/menu-button:text-sidebar-accent-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-[calc(var(--radius-sm)-2px)] px-1 text-xs font-medium tabular-nums select-none group-data-[collapsible=icon]:hidden peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1",
className,
)}
{...props}
@@ -622,12 +591,7 @@ function SidebarMenuSkeleton({
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
{showIcon && <Skeleton className="size-4 rounded-md" data-sidebar="menu-skeleton-icon" />}
<Skeleton
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
@@ -647,7 +611,7 @@ function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
className={cn(
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-sidebar-border border-l px-2.5 py-0.5 group-data-[collapsible=icon]:hidden",
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5 group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
@@ -655,10 +619,7 @@ function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
);
}
function SidebarMenuSubItem({
className,
...props
}: React.ComponentProps<"li">) {
function SidebarMenuSubItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-sub-item"
@@ -685,7 +646,7 @@ function SidebarMenuSubButton({
props: mergeProps<"a">(
{
className: cn(
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-hidden ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-active:bg-sidebar-accent data-[size=md]:text-xs data-[size=sm]:text-xs data-active:text-sidebar-accent-foreground group-data-[collapsible=icon]:hidden [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden group-data-[collapsible=icon]:hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[size=md]:text-xs data-[size=sm]:text-xs [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
className,
),
},
+1 -1
View File
@@ -4,7 +4,7 @@ function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("animate-pulse rounded-md bg-muted", className)}
className={cn("bg-muted animate-pulse rounded-md", className)}
{...props}
/>
);
+6 -12
View File
@@ -12,18 +12,13 @@ function Slider({
...props
}: SliderPrimitive.Root.Props) {
const _values = React.useMemo(
() =>
Array.isArray(value)
? value
: Array.isArray(defaultValue)
? defaultValue
: [min, max],
() => (Array.isArray(value) ? value : Array.isArray(defaultValue) ? defaultValue : [min, max]),
[value, defaultValue, min, max],
);
return (
<SliderPrimitive.Root
className={cn("data-vertical:h-full data-horizontal:w-full", className)}
className={cn("data-horizontal:w-full data-vertical:h-full", className)}
data-slot="slider"
defaultValue={defaultValue}
value={value}
@@ -32,22 +27,21 @@ function Slider({
thumbAlignment="edge"
{...props}
>
<SliderPrimitive.Control className="relative flex w-full touch-none select-none items-center data-vertical:h-full data-vertical:min-h-40 data-vertical:w-auto data-vertical:flex-col data-disabled:opacity-50">
<SliderPrimitive.Control className="relative flex w-full touch-none items-center select-none data-disabled:opacity-50 data-vertical:h-full data-vertical:min-h-40 data-vertical:w-auto data-vertical:flex-col">
<SliderPrimitive.Track
data-slot="slider-track"
className="relative grow select-none overflow-hidden rounded-md bg-muted data-horizontal:h-3 data-vertical:h-full data-horizontal:w-full data-vertical:w-3"
className="bg-muted relative grow overflow-hidden rounded-md select-none data-horizontal:h-3 data-horizontal:w-full data-vertical:h-full data-vertical:w-3"
>
<SliderPrimitive.Indicator
data-slot="slider-range"
className="select-none bg-primary data-horizontal:h-full data-vertical:w-full"
className="bg-primary select-none data-horizontal:h-full data-vertical:w-full"
/>
</SliderPrimitive.Track>
{Array.from({ length: _values.length }, (_, index) => (
<SliderPrimitive.Thumb
data-slot="slider-thumb"
// biome-ignore lint/suspicious/noArrayIndexKey: shadcn generated
key={index}
className="block size-4 shrink-0 select-none rounded-md border border-primary bg-white shadow-sm ring-ring/30 transition-colors hover:ring-4 focus-visible:outline-hidden focus-visible:ring-4 disabled:pointer-events-none disabled:opacity-50"
className="border-primary ring-ring/30 block size-4 shrink-0 rounded-md border bg-white shadow-sm transition-colors select-none hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
/>
))}
</SliderPrimitive.Control>
+1
View File
@@ -1,4 +1,5 @@
import { IconLoader } from "@tabler/icons-react";
import { cn } from "@/lib/utils";
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
+2 -2
View File
@@ -14,14 +14,14 @@ function Switch({
data-slot="switch"
data-size={size}
className={cn(
"peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent outline-none transition-all after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/30 aria-invalid:border-destructive aria-invalid:ring-2 aria-invalid:ring-destructive/20 data-[size=default]:h-[18px] data-[size=sm]:h-[14px] data-[size=default]:w-[30px] data-[size=sm]:w-[24px] data-disabled:cursor-not-allowed data-checked:bg-primary data-unchecked:bg-input data-disabled:opacity-50 dark:data-unchecked:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
"peer group/switch focus-visible:border-ring focus-visible:ring-ring/30 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-checked:bg-primary data-unchecked:bg-input dark:data-unchecked:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:ring-2 aria-invalid:ring-2 data-disabled:cursor-not-allowed data-disabled:opacity-50 data-[size=default]:h-[18px] data-[size=default]:w-[30px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px]",
className,
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className="pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-3.5 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-1px)] group-data-[size=default]/switch:data-unchecked:translate-x-px group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-1px)] group-data-[size=sm]/switch:data-unchecked:translate-x-px dark:data-checked:bg-primary-foreground dark:data-unchecked:bg-foreground"
className="bg-background dark:data-checked:bg-primary-foreground dark:data-unchecked:bg-foreground pointer-events-none block rounded-full ring-0 transition-transform group-data-[size=default]/switch:size-3.5 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-1px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-1px)] group-data-[size=default]/switch:data-unchecked:translate-x-px group-data-[size=sm]/switch:data-unchecked:translate-x-px"
/>
</SwitchPrimitive.Root>
);
+9 -36
View File
@@ -4,10 +4,7 @@ import { cn } from "@/lib/utils";
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<div data-slot="table-container" className="relative w-full overflow-x-auto">
<table
data-slot="table"
className={cn("w-full caption-bottom text-xs", className)}
@@ -18,13 +15,7 @@ function Table({ className, ...props }: React.ComponentProps<"table">) {
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
);
return <thead data-slot="table-header" className={cn("[&_tr]:border-b", className)} {...props} />;
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
@@ -41,10 +32,7 @@ function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className,
)}
className={cn("bg-muted/50 border-t font-medium [&>tr]:last:border-b-0", className)}
{...props}
/>
);
@@ -55,7 +43,7 @@ function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
<tr
data-slot="table-row"
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
className,
)}
{...props}
@@ -68,7 +56,7 @@ function TableHead({ className, ...props }: React.ComponentProps<"th">) {
<th
data-slot="table-head"
className={cn(
"h-10 whitespace-nowrap px-2 text-left align-middle font-medium text-foreground [&:has([role=checkbox])]:pr-0",
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0",
className,
)}
{...props}
@@ -80,35 +68,20 @@ function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"whitespace-nowrap p-2 align-middle [&:has([role=checkbox])]:pr-0",
className,
)}
className={cn("p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0", className)}
{...props}
/>
);
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
function TableCaption({ className, ...props }: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("mt-4 text-muted-foreground text-xs", className)}
className={cn("text-muted-foreground mt-4 text-xs", className)}
{...props}
/>
);
}
export {
Table,
TableBody,
TableCaption,
TableCell,
TableFooter,
TableHead,
TableHeader,
TableRow,
};
export { Table, TableBody, TableCaption, TableCell, TableFooter, TableHead, TableHeader, TableRow };
+5 -12
View File
@@ -3,26 +3,19 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
function Tabs({
className,
orientation = "horizontal",
...props
}: TabsPrimitive.Root.Props) {
function Tabs({ className, orientation = "horizontal", ...props }: TabsPrimitive.Root.Props) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
className={cn(
"group/tabs flex gap-2 data-horizontal:flex-col",
className,
)}
className={cn("group/tabs flex gap-2 data-horizontal:flex-col", className)}
{...props}
/>
);
}
const tabsListVariants = cva(
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground data-[variant=line]:rounded-none group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col",
"group/tabs-list text-muted-foreground inline-flex w-fit items-center justify-center rounded-lg p-[3px] group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
{
variants: {
variant: {
@@ -56,10 +49,10 @@ function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
<TabsPrimitive.Tab
data-slot="tabs-trigger"
className={cn(
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 whitespace-nowrap rounded-md border border-transparent px-1.5 py-0.5 font-medium text-foreground/60 text-xs transition-colors hover:text-foreground focus-visible:border-ring focus-visible:outline-1 focus-visible:outline-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start group-data-vertical/tabs:py-[calc(--spacing(1.25))] dark:text-muted-foreground dark:hover:text-foreground [&_svg:not([class*='size-'])]:size-3.5 [&_svg]:pointer-events-none [&_svg]:shrink-0",
"text-foreground/60 hover:text-foreground focus-visible:border-ring focus-visible:outline-ring focus-visible:ring-ring/50 dark:text-muted-foreground dark:hover:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-xs font-medium whitespace-nowrap transition-colors group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start group-data-vertical/tabs:py-[calc(--spacing(1.25))] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
"after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
className,
)}
{...props}
+1 -1
View File
@@ -7,7 +7,7 @@ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
<textarea
data-slot="textarea"
className={cn(
"field-sizing-content flex min-h-16 w-full resize-none rounded-md border border-input bg-input/20 px-2 py-2 text-sm outline-none transition-colors placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/30 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-2 aria-invalid:ring-destructive/20 md:text-xs/relaxed dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
"border-input bg-input/20 placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/30 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 flex field-sizing-content min-h-16 w-full resize-none rounded-md border px-2 py-2 text-sm transition-colors outline-none focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:ring-2 md:text-xs/relaxed",
className,
)}
{...props}

Some files were not shown because too many files have changed in this diff Show More