mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 02:45:39 -04:00
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:
@@ -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'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'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>
|
||||
);
|
||||
|
||||
@@ -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's what's happening with your library</Trans>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 > 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 > Plugins > Webhook
|
||||
</span>
|
||||
<span className="text-foreground font-medium">Dashboard > Plugins > 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 > Webhooks
|
||||
</span>
|
||||
<span className="text-foreground font-medium">Settings > 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 > Import Lists
|
||||
</span>
|
||||
<span className="text-foreground font-medium">Settings > 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 > Import Lists
|
||||
</span>
|
||||
<span className="text-foreground font-medium">Settings > 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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 +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}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 +1 @@
|
||||
export {
|
||||
DirectionProvider,
|
||||
useDirection,
|
||||
} from "@base-ui/react/direction-provider";
|
||||
export { DirectionProvider, useDirection } from "@base-ui/react/direction-provider";
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
},
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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,4 +1,5 @@
|
||||
import { IconLoader } from "@tabler/icons-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user